From d74a864204b82999609a199d121afb4df9862da5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20=7B=7BEbaAaZ=7D=7D?= Date: Wed, 24 Jun 2026 01:45:39 -0400 Subject: [PATCH 1/2] feat(cap): add context continuity packet metadata --- .../__tests__/service/clawdbot-e2e.test.js | 31 +++ .../unit/services/agentEventService.test.js | 19 ++ .../contextContinuityPacketService.test.ts | 145 +++++++++++++ backend/services/agentEventService.ts | 24 +- .../contextContinuityPacketService.ts | 205 ++++++++++++++++++ docs/agents/AGENT_RUNTIME.md | 15 ++ docs/api/openapi.yaml | 80 +++++++ docs/architecture/CAP.md | 39 ++++ docs/design/context-continuity-packet.md | 114 ++++++++++ docs/openapi/cap-kernel.yaml | 79 +++++++ packages/types/src/events.ts | 35 +++ 11 files changed, 785 insertions(+), 1 deletion(-) create mode 100644 backend/__tests__/unit/services/contextContinuityPacketService.test.ts create mode 100644 backend/services/contextContinuityPacketService.ts create mode 100644 docs/design/context-continuity-packet.md diff --git a/backend/__tests__/service/clawdbot-e2e.test.js b/backend/__tests__/service/clawdbot-e2e.test.js index c1de87d7f..a808f2f85 100644 --- a/backend/__tests__/service/clawdbot-e2e.test.js +++ b/backend/__tests__/service/clawdbot-e2e.test.js @@ -25,6 +25,7 @@ const Summary = require('../../models/Summary'); const { AgentRegistry, AgentInstallation } = require('../../models/AgentRegistry'); const AgentEvent = require('../../models/AgentEvent'); const AgentProfile = require('../../models/AgentProfile'); +const AgentMemory = require('../../models/AgentMemory'); // Routes const registryRoutes = require('../../routes/registry'); @@ -33,6 +34,7 @@ const agentsRuntimeRoutes = require('../../routes/agentsRuntime'); // Services const AgentEventService = require('../../services/agentEventService'); const AgentIdentityService = require('../../services/agentIdentityService'); +const { appendSystemExchange } = require('../../services/agentMemoryService'); // Utils const { hash } = require('../../utils/secret'); @@ -159,6 +161,7 @@ describe('Clawdbot E2E Integration Tests', () => { await AgentInstallation.deleteMany({}); await AgentEvent.deleteMany({}); await AgentProfile.deleteMany({}); + await AgentMemory.deleteMany({}); await Message.deleteMany({}); await Summary.deleteMany({}); // Reset agent runtime tokens so each beforeEach gets a fresh token @@ -522,6 +525,16 @@ describe('Clawdbot E2E Integration Tests', () => { }); test('should poll events via runtime API', async () => { + await appendSystemExchange({ + agentName: 'clawdbot-bridge', + instanceId: 'default', + kind: 'task-completed', + surfacePodId: testPod._id.toString(), + surfaceLabel: 'team:Test Chat Pod', + peers: [], + takeaway: 'completed prior kernel task', + }); + // Enqueue multiple events await AgentEventService.enqueue({ agentName: 'clawdbot-bridge', @@ -549,6 +562,24 @@ describe('Clawdbot E2E Integration Tests', () => { // 'delivered' (claimed, awaiting ack), not 'pending'. The ack route // transitions them to 'acked'. expect(res.body.events[0].status).toBe('delivered'); + expect(res.body.events[0].continuity).toEqual(expect.objectContaining({ + schema: 'commonly.ccp.v1', + owner: expect.objectContaining({ + agentName: 'clawdbot-bridge', + instanceId: 'default', + podId: testPod._id.toString(), + }), + freshness: expect.objectContaining({ + memoryRevision: 1, + memoryRevisionAtDelivery: 1, + lastSeenRevision: 0, + status: 'valid', + }), + refs: expect.objectContaining({ + memorySections: ['system_exchanges'], + }), + })); + expect(res.body.events[0].payload.memoryDigest).toHaveLength(1); }); test('should acknowledge events after processing', async () => { diff --git a/backend/__tests__/unit/services/agentEventService.test.js b/backend/__tests__/unit/services/agentEventService.test.js index 41a6211a0..3115e4bb4 100644 --- a/backend/__tests__/unit/services/agentEventService.test.js +++ b/backend/__tests__/unit/services/agentEventService.test.js @@ -262,6 +262,25 @@ describe('AgentEventService', () => { cyclesDigest: expect.any(Array), longTermDigest: 'durable', })); + expect(events[0].continuity).toEqual(expect.objectContaining({ + schema: 'commonly.ccp.v1', + contextId: 'cap-event:evt-1', + owner: { + agentName: 'openclaw', + instanceId: 'liz', + podId: 'pod-1', + }, + freshness: { + memoryRevision: 7, + memoryRevisionAtDelivery: 7, + lastSeenRevision: 5, + status: 'valid', + }, + refs: expect.objectContaining({ + messageId: '1800', + memorySections: ['system_exchanges', 'cycles', 'long_term'], + }), + })); }); test('list returns [] when no candidates are pending (no envelope read)', async () => { diff --git a/backend/__tests__/unit/services/contextContinuityPacketService.test.ts b/backend/__tests__/unit/services/contextContinuityPacketService.test.ts new file mode 100644 index 000000000..1c2faccf2 --- /dev/null +++ b/backend/__tests__/unit/services/contextContinuityPacketService.test.ts @@ -0,0 +1,145 @@ +// @ts-nocheck + +const { + CONTEXT_CONTINUITY_PACKET_SCHEMA, + buildContextContinuityPacket, + memorySectionsFromDigestBundle, +} = require('../../../services/contextContinuityPacketService'); + +describe('contextContinuityPacketService', () => { + it('builds the stable commonly.ccp.v1 envelope', () => { + const packet = buildContextContinuityPacket({ + event: { + _id: 'evt-1', + type: 'chat.mention', + agentName: 'OpenClaw', + instanceId: 'liz', + podId: 'pod-1', + createdAt: new Date('2026-06-24T00:00:00Z'), + deliveredAt: new Date('2026-06-24T00:00:03Z'), + payload: { trigger: 'mention', messageId: 42 }, + }, + memoryRevision: 7, + memoryRevisionAtDelivery: 7, + lastSeenRevision: 5, + memorySections: ['system_exchanges'], + }); + + expect(packet).toEqual({ + schema: CONTEXT_CONTINUITY_PACKET_SCHEMA, + contextId: 'cap-event:evt-1', + owner: { + agentName: 'openclaw', + instanceId: 'liz', + podId: 'pod-1', + }, + provenance: { + source: 'cap.event', + eventId: 'evt-1', + eventType: 'chat.mention', + trigger: 'mention', + createdAt: '2026-06-24T00:00:00.000Z', + deliveredAt: '2026-06-24T00:00:03.000Z', + }, + freshness: { + memoryRevision: 7, + memoryRevisionAtDelivery: 7, + lastSeenRevision: 5, + status: 'valid', + }, + refs: { + messageId: '42', + memorySections: ['system_exchanges'], + }, + }); + }); + + it('extracts safe payload refs without copying content or memory bodies', () => { + const packet = buildContextContinuityPacket({ + event: { + _id: 'evt-2', + type: 'agent.ask', + agentName: 'codex', + instanceId: 'default', + podId: 'pod-2', + payload: { + content: 'do not copy me', + longTermDigest: 'do not copy memory', + memoryDigest: [{ takeaway: 'do not copy digest entry' }], + requestId: 'ask-1', + taskId: 'task-1', + threadId: 'thread-1', + replyToMessageId: 'msg-parent', + summaryId: 'sum-1', + integrationId: 'int-1', + }, + }, + }); + + expect(packet.refs).toEqual({ + replyToMessageId: 'msg-parent', + threadId: 'thread-1', + taskId: 'task-1', + requestId: 'ask-1', + summaryId: 'sum-1', + integrationId: 'int-1', + }); + expect(JSON.stringify(packet)).not.toContain('do not copy me'); + expect(JSON.stringify(packet)).not.toContain('do not copy memory'); + expect(JSON.stringify(packet)).not.toContain('do not copy digest entry'); + }); + + it('marks freshness stale when the delivery snapshot is behind current memory', () => { + const packet = buildContextContinuityPacket({ + event: { + _id: 'evt-3', + agentName: 'openclaw', + instanceId: 'pixel', + podId: 'pod-3', + payload: {}, + }, + memoryRevision: 9, + memoryRevisionAtDelivery: 7, + lastSeenRevision: 6, + }); + + expect(packet.freshness).toEqual({ + memoryRevision: 9, + memoryRevisionAtDelivery: 7, + lastSeenRevision: 6, + status: 'stale', + }); + }); + + it('omits freshness when no memory snapshot is supplied', () => { + const packet = buildContextContinuityPacket({ + event: { + _id: 'evt-4', + type: 'heartbeat', + agentName: 'openclaw', + instanceId: 'default', + podId: 'pod-4', + payload: {}, + }, + }); + + expect(packet.owner).toEqual({ + agentName: 'openclaw', + instanceId: 'default', + podId: 'pod-4', + }); + expect(packet.freshness).toBeUndefined(); + expect(packet.refs).toBeUndefined(); + }); + + it('deduplicates memory section refs from digest bundle presence', () => { + const sections = memorySectionsFromDigestBundle({ + memoryDigest: [{ takeaway: 'x' }], + cyclesDigest: [{ content: 'cycle' }], + longTermDigest: 'memory', + recentDailyDigest: [{ date: '2026-06-24', content: 'daily' }], + }); + + expect(sections).toEqual(['system_exchanges', 'cycles', 'long_term', 'daily']); + }); +}); diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index a2c218e61..8ce50fe7f 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -23,6 +23,10 @@ const AgentMemory = require('../models/AgentMemory'); const { buildMemoryDigestBundle, } = require('./agentMemoryService'); +const { + buildContextContinuityPacket, + memorySectionsFromDigestBundle, +} = require('./contextContinuityPacketService'); interface EventDoc { _id?: unknown; @@ -36,6 +40,7 @@ interface EventDoc { attempts?: number; delivery?: DeliveryMeta; memoryRevisionAtDelivery?: number | null; + continuity?: unknown; } interface InstallationDoc { @@ -148,6 +153,8 @@ const deliverEventViaWebhook = async (installation: InstallationDoc, event: Even const { webhookUrl, webhookSecret } = runtimeConfig as { webhookUrl?: string; webhookSecret?: string }; if (!webhookUrl) return; + const continuity = buildContextContinuityPacket({ event }); + const payload = JSON.stringify({ _id: event._id, type: event.type, @@ -155,6 +162,7 @@ const deliverEventViaWebhook = async (installation: InstallationDoc, event: Even agentName: event.agentName, instanceId: event.instanceId, createdAt: event.createdAt, + continuity, payload: event.payload, }); @@ -820,6 +828,8 @@ class AgentEventService { : {}), }) as EventDoc; + const continuity = buildContextContinuityPacket({ event }); + if (routedToNative && nativeInstallation) { try { // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports @@ -830,6 +840,7 @@ class AgentEventService { Promise.resolve(runAgent(nativeInstallation, { type, eventId: eventIdStr, + continuity, payload: event.payload, })).catch((err: Error) => { console.error('[native-runtime] runAgent failed:', err?.message || err); @@ -861,6 +872,7 @@ class AgentEventService { instanceId: event.instanceId, podId: event.podId, type: event.type, + continuity, payload: event.payload, createdAt: event.createdAt, }); @@ -953,6 +965,7 @@ class AgentEventService { const currentRevision = memoryDoc?.revision ?? 0; const lastSeenRevision = memoryDoc?.lastSeenRevision ?? 0; const digestBundle = buildMemoryDigestBundle(memoryDoc || {}, lastSeenRevision); + const hasMemorySnapshot = Boolean(memoryDoc) || currentRevision > 0 || lastSeenRevision > 0; const claimed: EventDoc[] = []; for (const candidate of candidates) { @@ -989,7 +1002,16 @@ class AgentEventService { if (messageId !== undefined && messageId !== null && typeof messageId !== 'string') { enrichedPayload.messageId = String(messageId); } - return { ...event, payload: enrichedPayload }; + const continuity = buildContextContinuityPacket({ + event: { ...event, payload: enrichedPayload }, + memoryRevision: hasMemorySnapshot ? currentRevision : undefined, + lastSeenRevision: hasMemorySnapshot ? lastSeenRevision : undefined, + memoryRevisionAtDelivery: hasMemorySnapshot + ? (event.memoryRevisionAtDelivery ?? currentRevision) + : undefined, + memorySections: memorySectionsFromDigestBundle(digestBundle), + }); + return { ...event, continuity, payload: enrichedPayload }; }); } diff --git a/backend/services/contextContinuityPacketService.ts b/backend/services/contextContinuityPacketService.ts new file mode 100644 index 000000000..2f2e5c6fb --- /dev/null +++ b/backend/services/contextContinuityPacketService.ts @@ -0,0 +1,205 @@ +export const CONTEXT_CONTINUITY_PACKET_SCHEMA = 'commonly.ccp.v1'; + +export type ContinuityFreshnessStatus = 'valid' | 'stale' | 'unknown'; + +export interface ContextContinuityPacketV1 { + schema: typeof CONTEXT_CONTINUITY_PACKET_SCHEMA; + contextId: string; + owner: { + agentName: string; + instanceId: string; + podId?: string; + }; + provenance: { + source: 'cap.event'; + eventId?: string; + eventType?: string; + trigger?: string; + createdAt?: string; + deliveredAt?: string; + }; + freshness?: { + memoryRevision?: number; + memoryRevisionAtDelivery?: number; + lastSeenRevision?: number; + status: ContinuityFreshnessStatus; + }; + refs?: { + messageId?: string; + replyToMessageId?: string; + threadId?: string; + taskId?: string; + requestId?: string; + summaryId?: string; + integrationId?: string; + memorySections?: string[]; + }; +} + +interface EventLike { + _id?: unknown; + type?: string; + podId?: unknown; + agentName?: string; + instanceId?: string; + createdAt?: Date | string; + deliveredAt?: Date | string; + payload?: Record; + memoryRevisionAtDelivery?: number | null; +} + +export interface BuildContextContinuityPacketArgs { + event: EventLike; + memoryRevision?: number; + lastSeenRevision?: number; + memoryRevisionAtDelivery?: number | null; + memorySections?: string[]; +} + +const toStringValue = (value: unknown): string | undefined => { + if (value === undefined || value === null) return undefined; + const s = typeof value === 'string' + ? value + : (value as { toString?: () => string })?.toString?.(); + const trimmed = String(s || '').trim(); + return trimmed || undefined; +}; + +const toIsoString = (value: unknown): string | undefined => { + if (!value) return undefined; + if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString(); + if (typeof value === 'string') { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString(); + } + return undefined; +}; + +const toNumber = (value: unknown): number | undefined => { + if (typeof value !== 'number' || !Number.isFinite(value)) return undefined; + return value; +}; + +const pickPayloadRef = ( + payload: Record | undefined, + keys: string[], +): string | undefined => { + if (!payload) return undefined; + for (const key of keys) { + const value = toStringValue(payload[key]); + if (value) return value; + } + return undefined; +}; + +export const memorySectionsFromDigestBundle = ( + digestBundle: Record = {}, +): string[] => { + const sections: string[] = []; + if (Array.isArray(digestBundle.memoryDigest) && digestBundle.memoryDigest.length > 0) { + sections.push('system_exchanges'); + } + if (Array.isArray(digestBundle.cyclesDigest) && digestBundle.cyclesDigest.length > 0) { + sections.push('cycles'); + } + if (typeof digestBundle.longTermDigest === 'string' && digestBundle.longTermDigest.trim()) { + sections.push('long_term'); + } + if (Array.isArray(digestBundle.recentDailyDigest) && digestBundle.recentDailyDigest.length > 0) { + sections.push('daily'); + } + return sections; +}; + +const buildFreshnessStatus = ( + memoryRevision: number | undefined, + memoryRevisionAtDelivery: number | undefined, +): ContinuityFreshnessStatus => { + if (memoryRevision === undefined || memoryRevisionAtDelivery === undefined) return 'unknown'; + return memoryRevisionAtDelivery >= memoryRevision ? 'valid' : 'stale'; +}; + +export function buildContextContinuityPacket({ + event, + memoryRevision, + lastSeenRevision, + memoryRevisionAtDelivery, + memorySections = [], +}: BuildContextContinuityPacketArgs): ContextContinuityPacketV1 { + const eventId = toStringValue(event?._id); + const agentName = toStringValue(event?.agentName)?.toLowerCase() || 'unknown'; + const instanceId = toStringValue(event?.instanceId) || 'default'; + const podId = toStringValue(event?.podId); + const payload = event?.payload && typeof event.payload === 'object' ? event.payload : {}; + const capturedRevision = toNumber(memoryRevisionAtDelivery ?? event?.memoryRevisionAtDelivery); + const currentRevision = toNumber(memoryRevision); + const seenRevision = toNumber(lastSeenRevision); + + const refs: ContextContinuityPacketV1['refs'] = {}; + const messageId = pickPayloadRef(payload, ['messageId', 'message_id']); + const replyToMessageId = pickPayloadRef(payload, ['replyToMessageId', 'replyToId', 'reply_to_message_id']); + const threadId = pickPayloadRef(payload, ['threadId', 'thread_id']); + const taskId = pickPayloadRef(payload, ['taskId', 'task_id']); + const requestId = pickPayloadRef(payload, ['requestId', 'request_id']); + const summaryId = pickPayloadRef(payload, ['summaryId', 'summary_id']); + const integrationId = pickPayloadRef(payload, ['integrationId', 'integration_id']); + + if (messageId) refs.messageId = messageId; + if (replyToMessageId) refs.replyToMessageId = replyToMessageId; + if (threadId) refs.threadId = threadId; + if (taskId) refs.taskId = taskId; + if (requestId) refs.requestId = requestId; + if (summaryId) refs.summaryId = summaryId; + if (integrationId) refs.integrationId = integrationId; + const uniqueMemorySections = Array.from(new Set((memorySections || []).filter(Boolean))); + if (uniqueMemorySections.length > 0) refs.memorySections = uniqueMemorySections; + + const packet: ContextContinuityPacketV1 = { + schema: CONTEXT_CONTINUITY_PACKET_SCHEMA, + contextId: eventId + ? `cap-event:${eventId}` + : `cap-event:${agentName}:${instanceId}:${podId || 'no-pod'}`, + owner: { + agentName, + instanceId, + ...(podId ? { podId } : {}), + }, + provenance: { + source: 'cap.event', + ...(eventId ? { eventId } : {}), + ...(event?.type ? { eventType: event.type } : {}), + ...(typeof payload.trigger === 'string' && payload.trigger.trim() ? { trigger: payload.trigger.trim() } : {}), + ...(toIsoString(event?.createdAt) ? { createdAt: toIsoString(event?.createdAt) } : {}), + ...(toIsoString(event?.deliveredAt) ? { deliveredAt: toIsoString(event?.deliveredAt) } : {}), + }, + }; + + if ( + currentRevision !== undefined + || capturedRevision !== undefined + || seenRevision !== undefined + ) { + packet.freshness = { + ...(currentRevision !== undefined ? { memoryRevision: currentRevision } : {}), + ...(capturedRevision !== undefined ? { memoryRevisionAtDelivery: capturedRevision } : {}), + ...(seenRevision !== undefined ? { lastSeenRevision: seenRevision } : {}), + status: buildFreshnessStatus(currentRevision, capturedRevision), + }; + } + + if (Object.keys(refs).length > 0) { + packet.refs = refs; + } + + return packet; +} + +export default { + CONTEXT_CONTINUITY_PACKET_SCHEMA, + buildContextContinuityPacket, + memorySectionsFromDigestBundle, +}; + +// CJS compat: let require() consume the named helpers from TS-transpiled output. +// eslint-disable-next-line @typescript-eslint/no-require-imports +module.exports = exports; diff --git a/docs/agents/AGENT_RUNTIME.md b/docs/agents/AGENT_RUNTIME.md index 2f756d251..40789a497 100644 --- a/docs/agents/AGENT_RUNTIME.md +++ b/docs/agents/AGENT_RUNTIME.md @@ -526,6 +526,21 @@ Each event returned by `GET /api/agents/runtime/events` carries up to four memor Each sub-field is independently emit-gated (absent when its source section is empty). These are **structured metadata** — runtimes that want to surface them in an analytics dashboard, inspector panel, or as an opt-in system block in the model prompt may read them. The platform does NOT inject them into the prompt by default (see §Memory-changed cue and ADR-012 §11 for why prefix injection was rejected). +### Context Continuity Packet (CCP) + +Events may also include top-level `continuity` metadata using schema `commonly.ccp.v1`. + +This packet points at the existing kernel-owned continuity state for the event: + +- agent identity: `owner.agentName`, `owner.instanceId`, `owner.podId` +- event provenance: `provenance.eventId`, `provenance.eventType`, `provenance.trigger` +- memory freshness: `freshness.memoryRevision`, `freshness.memoryRevisionAtDelivery`, `freshness.lastSeenRevision` +- compact refs: message, reply, thread, task, ask, summary, integration, and memory section ids + +CCP is not a memory body and is not a prompt block. It is metadata for routing, logging, deduplication, and deciding whether to pull memory through the existing memory tools or CAP memory endpoints. + +See `docs/design/context-continuity-packet.md` for the full draft convention. + ### Memory-changed cue (ADR-012 §11) When an agent receives a `chat.mention` or `thread.mention` event AND its `revision > lastSeenRevision`, the backend prepends a single line to `payload.content`: diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index df3fd2f96..e1981180d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -961,6 +961,8 @@ components: type: string podId: type: string + continuity: + $ref: '#/components/schemas/ContextContinuityPacketV1' payload: type: object additionalProperties: true @@ -969,6 +971,84 @@ components: format: date-time additionalProperties: true + ContextContinuityPacketV1: + type: object + required: [schema, contextId, owner, provenance] + properties: + schema: + type: string + const: commonly.ccp.v1 + contextId: + type: string + owner: + type: object + required: [agentName, instanceId] + properties: + agentName: + type: string + instanceId: + type: string + podId: + type: string + additionalProperties: false + provenance: + type: object + required: [source] + properties: + source: + type: string + const: cap.event + eventId: + type: string + eventType: + type: string + trigger: + type: string + createdAt: + type: string + format: date-time + deliveredAt: + type: string + format: date-time + additionalProperties: false + freshness: + type: object + required: [status] + properties: + memoryRevision: + type: integer + memoryRevisionAtDelivery: + type: integer + lastSeenRevision: + type: integer + status: + type: string + enum: [valid, stale, unknown] + additionalProperties: false + refs: + type: object + properties: + messageId: + type: string + replyToMessageId: + type: string + threadId: + type: string + taskId: + type: string + requestId: + type: string + summaryId: + type: string + integrationId: + type: string + memorySections: + type: array + items: + type: string + additionalProperties: false + additionalProperties: false + DeliveryResult: type: object properties: diff --git a/docs/architecture/CAP.md b/docs/architecture/CAP.md index 0c6713e01..20c81d968 100644 --- a/docs/architecture/CAP.md +++ b/docs/architecture/CAP.md @@ -95,6 +95,7 @@ interface CAPEvent { agentName: string instanceId: string createdAt: string + continuity?: ContextContinuityPacketV1 payload: { content?: string // message content that triggered the event userId?: string // who triggered it @@ -105,6 +106,44 @@ interface CAPEvent { } ``` +### Context Continuity Packet (CCP) + +`continuity` is optional top-level event metadata. It is a kernel-owned pointer to the event boundary, agent identity, memory freshness checkpoint, and compact refs that matter for a runtime turn. + +CCP does not add a fifth CAP verb and does not replace AgentMemory. It is derived from existing kernel state and can be ignored by older drivers. + +```typescript +interface ContextContinuityPacketV1 { + schema: "commonly.ccp.v1" + contextId: string + owner: { agentName: string; instanceId: string; podId?: string } + provenance: { + source: "cap.event" + eventId?: string + eventType?: string + trigger?: string + createdAt?: string + deliveredAt?: string + } + freshness?: { + memoryRevision?: number + memoryRevisionAtDelivery?: number + lastSeenRevision?: number + status: "valid" | "stale" | "unknown" + } + refs?: { + messageId?: string + replyToMessageId?: string + threadId?: string + taskId?: string + requestId?: string + summaryId?: string + integrationId?: string + memorySections?: string[] + } +} +``` + --- ## Runtime Types diff --git a/docs/design/context-continuity-packet.md b/docs/design/context-continuity-packet.md new file mode 100644 index 000000000..59d749a52 --- /dev/null +++ b/docs/design/context-continuity-packet.md @@ -0,0 +1,114 @@ +# Context Continuity Packet (CCP) + +Status: draft implementation proposal + +## Summary + +Context Continuity Packet (CCP) is optional metadata attached to CAP event delivery. It gives a runtime a small, stable pointer to the kernel-owned continuity state behind an event without adding a new memory store, prompt prefix, or CAP verb. + +Commonly already owns the durable continuity primitives: + +- identity: `(agentName, instanceId)` +- event delivery: `AgentEvent` +- durable memory: `AgentMemory` +- freshness checkpoints: `memoryRevision`, `memoryRevisionAtDelivery`, `lastSeenRevision` + +CCP names the boundary packet that ties those existing primitives together. + +## Event Shape + +Events returned by `GET /api/agents/runtime/events`, pushed over the agent websocket, delivered to native runtimes, or sent to webhook runtimes MAY include: + +```ts +interface ContextContinuityPacketV1 { + schema: 'commonly.ccp.v1'; + contextId: string; + owner: { + agentName: string; + instanceId: string; + podId?: string; + }; + provenance: { + source: 'cap.event'; + eventId?: string; + eventType?: string; + trigger?: string; + createdAt?: string; + deliveredAt?: string; + }; + freshness?: { + memoryRevision?: number; + memoryRevisionAtDelivery?: number; + lastSeenRevision?: number; + status: 'valid' | 'stale' | 'unknown'; + }; + refs?: { + messageId?: string; + replyToMessageId?: string; + threadId?: string; + taskId?: string; + requestId?: string; + summaryId?: string; + integrationId?: string; + memorySections?: string[]; + }; +} +``` + +The packet is attached as a top-level event field: + +```json +{ + "_id": "event-id", + "type": "chat.mention", + "podId": "pod-id", + "continuity": { + "schema": "commonly.ccp.v1", + "contextId": "cap-event:event-id", + "owner": { + "agentName": "openclaw", + "instanceId": "liz", + "podId": "pod-id" + }, + "provenance": { + "source": "cap.event", + "eventId": "event-id", + "eventType": "chat.mention" + } + }, + "payload": { + "content": "..." + } +} +``` + +## Semantics + +- `contextId` identifies the event-boundary continuity packet. In v1 it is derived from the CAP event id. +- `owner` identifies the agent and pod scope that owns the continuity state. +- `provenance` identifies the kernel event that caused this runtime turn. +- `freshness` reflects the memory revision snapshot used at delivery time. +- `refs` carries compact ids that a runtime can use for threading, tasks, asks, messages, or memory section awareness. + +`refs.memorySections` names only the memory sections represented by already-emitted digest metadata. It never contains the memory body itself. + +## Non-Goals + +- No new CAP verb. +- No new AgentMemory section. +- No database migration. +- No default prompt injection. +- No replacement for `memoryDigest`, `cyclesDigest`, `longTermDigest`, or `recentDailyDigest`. +- No runtime-specific adapter glue. +- No requirement that drivers persist CCP. + +## Driver Guidance + +Drivers should treat `event.continuity` as a metadata envelope for routing, logging, deduplication, or optional memory pull decisions. + +Drivers should not blindly inject the full packet into model context. If a model needs memory, the runtime should use the existing memory tools or CAP memory endpoints to pull only what is relevant. + +## Naming + +This implementation uses `Context Continuity Packet` / `CCP` because the name is already useful in adjacent discussions. Maintainers can rename the convention without changing the underlying kernel pattern: top-level event metadata that points to existing Commonly continuity state. + diff --git a/docs/openapi/cap-kernel.yaml b/docs/openapi/cap-kernel.yaml index e8fd4d679..76cae6928 100644 --- a/docs/openapi/cap-kernel.yaml +++ b/docs/openapi/cap-kernel.yaml @@ -249,10 +249,89 @@ components: createdAt: type: string format: date-time + continuity: + $ref: '#/components/schemas/ContextContinuityPacketV1' payload: type: object additionalProperties: true required: [id, type] + ContextContinuityPacketV1: + type: object + additionalProperties: false + properties: + schema: + type: string + const: commonly.ccp.v1 + contextId: + type: string + owner: + type: object + additionalProperties: false + properties: + agentName: + type: string + instanceId: + type: string + podId: + type: string + required: [agentName, instanceId] + provenance: + type: object + additionalProperties: false + properties: + source: + type: string + const: cap.event + eventId: + type: string + eventType: + type: string + trigger: + type: string + createdAt: + type: string + format: date-time + deliveredAt: + type: string + format: date-time + required: [source] + freshness: + type: object + additionalProperties: false + properties: + memoryRevision: + type: integer + memoryRevisionAtDelivery: + type: integer + lastSeenRevision: + type: integer + status: + type: string + enum: [valid, stale, unknown] + required: [status] + refs: + type: object + additionalProperties: false + properties: + messageId: + type: string + replyToMessageId: + type: string + threadId: + type: string + taskId: + type: string + requestId: + type: string + summaryId: + type: string + integrationId: + type: string + memorySections: + type: array + items: + type: string + required: [schema, contextId, owner, provenance] RuntimeMemoryResponse: type: object properties: diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index fc6e0319d..9b8cd6108 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -14,11 +14,46 @@ export interface IAgentEvent { agentId: string; eventType: EventType; podId?: string; + continuity?: ContextContinuityPacketV1; payload: Record; processed: boolean; createdAt?: string | Date; } +export interface ContextContinuityPacketV1 { + schema: 'commonly.ccp.v1'; + contextId: string; + owner: { + agentName: string; + instanceId: string; + podId?: string; + }; + provenance: { + source: 'cap.event'; + eventId?: string; + eventType?: string; + trigger?: string; + createdAt?: string; + deliveredAt?: string; + }; + freshness?: { + memoryRevision?: number; + memoryRevisionAtDelivery?: number; + lastSeenRevision?: number; + status: 'valid' | 'stale' | 'unknown'; + }; + refs?: { + messageId?: string; + replyToMessageId?: string; + threadId?: string; + taskId?: string; + requestId?: string; + summaryId?: string; + integrationId?: string; + memorySections?: string[]; + }; +} + /** CAP — the four interfaces any agent must implement to join a Commonly instance */ export interface ICAPContext { podId: string; From 0e3612f3f4b778fba26a3ba646e459ceb6a342e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-S=C3=A9bastien=20=7B=7BEbaAaZ=7D=7D?= Date: Wed, 24 Jun 2026 04:53:47 -0400 Subject: [PATCH 2/2] fix(cap): gate context continuity packet emission --- .../__tests__/service/clawdbot-e2e.test.js | 31 -------- .../unit/services/agentEventService.test.js | 69 ++++++++++++++++- .../contextContinuityPacketService.test.ts | 44 ++++++++++- backend/services/agentEventService.ts | 76 ++++++++++++++----- .../contextContinuityPacketService.ts | 55 +++----------- docs/agents/AGENT_RUNTIME.md | 2 + docs/api/openapi.yaml | 1 + docs/architecture/CAP.md | 2 +- docs/design/context-continuity-packet.md | 8 +- docs/openapi/cap-kernel.yaml | 1 + 10 files changed, 189 insertions(+), 100 deletions(-) diff --git a/backend/__tests__/service/clawdbot-e2e.test.js b/backend/__tests__/service/clawdbot-e2e.test.js index a808f2f85..c1de87d7f 100644 --- a/backend/__tests__/service/clawdbot-e2e.test.js +++ b/backend/__tests__/service/clawdbot-e2e.test.js @@ -25,7 +25,6 @@ const Summary = require('../../models/Summary'); const { AgentRegistry, AgentInstallation } = require('../../models/AgentRegistry'); const AgentEvent = require('../../models/AgentEvent'); const AgentProfile = require('../../models/AgentProfile'); -const AgentMemory = require('../../models/AgentMemory'); // Routes const registryRoutes = require('../../routes/registry'); @@ -34,7 +33,6 @@ const agentsRuntimeRoutes = require('../../routes/agentsRuntime'); // Services const AgentEventService = require('../../services/agentEventService'); const AgentIdentityService = require('../../services/agentIdentityService'); -const { appendSystemExchange } = require('../../services/agentMemoryService'); // Utils const { hash } = require('../../utils/secret'); @@ -161,7 +159,6 @@ describe('Clawdbot E2E Integration Tests', () => { await AgentInstallation.deleteMany({}); await AgentEvent.deleteMany({}); await AgentProfile.deleteMany({}); - await AgentMemory.deleteMany({}); await Message.deleteMany({}); await Summary.deleteMany({}); // Reset agent runtime tokens so each beforeEach gets a fresh token @@ -525,16 +522,6 @@ describe('Clawdbot E2E Integration Tests', () => { }); test('should poll events via runtime API', async () => { - await appendSystemExchange({ - agentName: 'clawdbot-bridge', - instanceId: 'default', - kind: 'task-completed', - surfacePodId: testPod._id.toString(), - surfaceLabel: 'team:Test Chat Pod', - peers: [], - takeaway: 'completed prior kernel task', - }); - // Enqueue multiple events await AgentEventService.enqueue({ agentName: 'clawdbot-bridge', @@ -562,24 +549,6 @@ describe('Clawdbot E2E Integration Tests', () => { // 'delivered' (claimed, awaiting ack), not 'pending'. The ack route // transitions them to 'acked'. expect(res.body.events[0].status).toBe('delivered'); - expect(res.body.events[0].continuity).toEqual(expect.objectContaining({ - schema: 'commonly.ccp.v1', - owner: expect.objectContaining({ - agentName: 'clawdbot-bridge', - instanceId: 'default', - podId: testPod._id.toString(), - }), - freshness: expect.objectContaining({ - memoryRevision: 1, - memoryRevisionAtDelivery: 1, - lastSeenRevision: 0, - status: 'valid', - }), - refs: expect.objectContaining({ - memorySections: ['system_exchanges'], - }), - })); - expect(res.body.events[0].payload.memoryDigest).toHaveLength(1); }); test('should acknowledge events after processing', async () => { diff --git a/backend/__tests__/unit/services/agentEventService.test.js b/backend/__tests__/unit/services/agentEventService.test.js index 3115e4bb4..4b3c146d1 100644 --- a/backend/__tests__/unit/services/agentEventService.test.js +++ b/backend/__tests__/unit/services/agentEventService.test.js @@ -50,6 +50,7 @@ describe('AgentEventService', () => { beforeEach(() => { jest.clearAllMocks(); delete process.env.AGENT_CONTEXT_OVERFLOW_RETRY_LIMIT; + delete process.env.COMMONLY_CCP_ENABLED; }); test('acknowledge auto-recovers OpenClaw context overflow and re-enqueues once', async () => { @@ -213,6 +214,11 @@ describe('AgentEventService', () => { cyclesDigest: [{ ts: new Date(), content: 'c' }], longTermDigest: 'durable', }); + AgentInstallation.findOne.mockReturnValue({ + select: () => ({ + lean: jest.fn().mockResolvedValue(null), + }), + }); AgentEvent.findOneAndUpdate.mockReturnValue({ lean: jest.fn().mockResolvedValue({ @@ -262,6 +268,62 @@ describe('AgentEventService', () => { cyclesDigest: expect.any(Array), longTermDigest: 'durable', })); + expect(events[0].continuity).toBeUndefined(); + }); + + test('list includes continuity when CCP emission is enabled', async () => { + const AgentMemory = require('../../../models/AgentMemory'); + const { buildMemoryDigestBundle } = require('../../../services/agentMemoryService'); + process.env.COMMONLY_CCP_ENABLED = '1'; + + AgentEvent.find.mockReturnValue({ + sort: () => ({ + limit: () => ({ + select: () => ({ + lean: jest.fn().mockResolvedValue([{ _id: 'evt-1' }]), + }), + }), + }), + }); + + AgentMemory.findOne.mockReturnValue({ + select: () => ({ + lean: jest.fn().mockResolvedValue({ + revision: 7, + lastSeenRevision: 5, + sections: { /* shape-only */ }, + }), + }), + }); + + buildMemoryDigestBundle.mockReturnValue({ + memoryRevision: 7, + memoryDigest: [{ takeaway: 'a' }, { takeaway: 'b' }], + cyclesDigest: [{ ts: new Date(), content: 'c' }], + longTermDigest: 'durable', + }); + + AgentEvent.findOneAndUpdate.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ + _id: 'evt-1', + agentName: 'openclaw', + instanceId: 'liz', + podId: 'pod-1', + type: 'chat.mention', + status: 'delivered', + memoryRevisionAtDelivery: 7, + payload: { messageId: 1800, content: 'hi' }, + }), + }); + + const events = await AgentEventService.list({ + agentName: 'openclaw', + instanceId: 'liz', + podId: 'pod-1', + limit: 5, + }); + + expect(events).toHaveLength(1); expect(events[0].continuity).toEqual(expect.objectContaining({ schema: 'commonly.ccp.v1', contextId: 'cap-event:evt-1', @@ -274,7 +336,7 @@ describe('AgentEventService', () => { memoryRevision: 7, memoryRevisionAtDelivery: 7, lastSeenRevision: 5, - status: 'valid', + status: 'stale', }, refs: expect.objectContaining({ messageId: '1800', @@ -328,6 +390,11 @@ describe('AgentEventService', () => { }), }); buildMemoryDigestBundle.mockReturnValue({}); + AgentInstallation.findOne.mockReturnValue({ + select: () => ({ + lean: jest.fn().mockResolvedValue(null), + }), + }); AgentEvent.findOneAndUpdate .mockReturnValueOnce({ diff --git a/backend/__tests__/unit/services/contextContinuityPacketService.test.ts b/backend/__tests__/unit/services/contextContinuityPacketService.test.ts index 1c2faccf2..5e0cc898d 100644 --- a/backend/__tests__/unit/services/contextContinuityPacketService.test.ts +++ b/backend/__tests__/unit/services/contextContinuityPacketService.test.ts @@ -45,7 +45,7 @@ describe('contextContinuityPacketService', () => { memoryRevision: 7, memoryRevisionAtDelivery: 7, lastSeenRevision: 5, - status: 'valid', + status: 'stale', }, refs: { messageId: '42', @@ -111,6 +111,48 @@ describe('contextContinuityPacketService', () => { }); }); + it('marks freshness valid when the agent has seen the current memory revision', () => { + const packet = buildContextContinuityPacket({ + event: { + _id: 'evt-valid', + agentName: 'openclaw', + instanceId: 'pixel', + podId: 'pod-valid', + payload: {}, + }, + memoryRevision: 9, + memoryRevisionAtDelivery: 9, + lastSeenRevision: 9, + }); + + expect(packet.freshness).toEqual({ + memoryRevision: 9, + memoryRevisionAtDelivery: 9, + lastSeenRevision: 9, + status: 'valid', + }); + }); + + it('marks freshness unknown when lastSeenRevision is missing', () => { + const packet = buildContextContinuityPacket({ + event: { + _id: 'evt-unknown', + agentName: 'openclaw', + instanceId: 'pixel', + podId: 'pod-unknown', + payload: {}, + }, + memoryRevision: 9, + memoryRevisionAtDelivery: 9, + }); + + expect(packet.freshness).toEqual({ + memoryRevision: 9, + memoryRevisionAtDelivery: 9, + status: 'unknown', + }); + }); + it('omits freshness when no memory snapshot is supplied', () => { const packet = buildContextContinuityPacket({ event: { diff --git a/backend/services/agentEventService.ts b/backend/services/agentEventService.ts index 8ce50fe7f..87ab6e9e1 100644 --- a/backend/services/agentEventService.ts +++ b/backend/services/agentEventService.ts @@ -148,12 +148,33 @@ const normalizeConfig = (config: unknown): Record => { return config as Record; }; +const normalizeRuntimeConfig = (config: unknown): Record => ( + normalizeConfig(normalizeConfig(config).runtime) +); + +const isTruthyFlag = (value: unknown): boolean => ( + ['1', 'true', 'yes', 'on'].includes(String(value || '').trim().toLowerCase()) +); + +const isContinuityConfigEnabled = (config: unknown): boolean => { + if (isTruthyFlag(process.env.COMMONLY_CCP_ENABLED)) return true; + const continuity = normalizeRuntimeConfig(config).continuity; + if (continuity === true) return true; + if (!continuity || typeof continuity !== 'object') return false; + return normalizeConfig(continuity).enabled === true; +}; + const deliverEventViaWebhook = async (installation: InstallationDoc, event: EventDoc): Promise => { - const runtimeConfig = (normalizeConfig(installation.config)?.runtime || {}) as Record; + const runtimeConfig = normalizeRuntimeConfig(installation.config); const { webhookUrl, webhookSecret } = runtimeConfig as { webhookUrl?: string; webhookSecret?: string }; if (!webhookUrl) return; - const continuity = buildContextContinuityPacket({ event }); + const continuity = isContinuityConfigEnabled(installation.config) + ? buildContextContinuityPacket({ + event, + memoryRevisionAtDelivery: event.memoryRevisionAtDelivery, + }) + : undefined; const payload = JSON.stringify({ _id: event._id, @@ -162,7 +183,7 @@ const deliverEventViaWebhook = async (installation: InstallationDoc, event: Even agentName: event.agentName, instanceId: event.instanceId, createdAt: event.createdAt, - continuity, + ...(continuity ? { continuity } : {}), payload: event.payload, }); @@ -374,7 +395,7 @@ class AgentEventService { } static async resolveGatewayFromInstallation(installation: InstallationDoc): Promise { - const runtimeConfig = (normalizeConfig(installation?.config)?.runtime || {}) as Record; + const runtimeConfig = normalizeRuntimeConfig(installation?.config); const gatewayId = runtimeConfig?.gatewayId; if (!gatewayId) return null; const gateway = await Gateway.findById(gatewayId).lean() as GatewayDoc | null; @@ -449,7 +470,7 @@ class AgentEventService { if (typeConfig?.runtime !== 'moltbot') return; const agentName = String(installation.agentName || '').toLowerCase(); const instanceId = String(installation.instanceId || 'default'); - const runtimeConfig = (normalizeConfig(installation?.config)?.runtime || {}) as Record; + const runtimeConfig = normalizeRuntimeConfig(installation?.config); const gatewayId = runtimeConfig?.gatewayId ? String(runtimeConfig.gatewayId) : ''; const key = `${agentName}:${instanceId}:${gatewayId}`; if (!byInstance.has(key)) { @@ -791,6 +812,7 @@ class AgentEventService { // loop in-process instead. Non-native installations continue to land in // the pending queue exactly as before. let routedToNative = false; + let routingInstallation: InstallationDoc | null = null; let nativeInstallation: InstallationDoc | null = null; try { const installationDoc = await AgentInstallation.findOne({ @@ -800,7 +822,8 @@ class AgentEventService { status: 'active', }).lean() as InstallationDoc | null; if (installationDoc) { - const installationRuntimeCfg = (normalizeConfig(installationDoc.config)?.runtime || {}) as Record; + routingInstallation = installationDoc; + const installationRuntimeCfg = normalizeRuntimeConfig(installationDoc.config); const installationRuntimeType = String(installationRuntimeCfg.runtimeType || '').toLowerCase(); if (installationRuntimeType === 'native') { routedToNative = true; @@ -828,7 +851,8 @@ class AgentEventService { : {}), }) as EventDoc; - const continuity = buildContextContinuityPacket({ event }); + const continuityEnabled = isContinuityConfigEnabled(routingInstallation?.config); + const continuity = continuityEnabled ? buildContextContinuityPacket({ event }) : undefined; if (routedToNative && nativeInstallation) { try { @@ -840,7 +864,7 @@ class AgentEventService { Promise.resolve(runAgent(nativeInstallation, { type, eventId: eventIdStr, - continuity, + ...(continuity ? { continuity } : {}), payload: event.payload, })).catch((err: Error) => { console.error('[native-runtime] runAgent failed:', err?.message || err); @@ -872,7 +896,7 @@ class AgentEventService { instanceId: event.instanceId, podId: event.podId, type: event.type, - continuity, + ...(continuity ? { continuity } : {}), payload: event.payload, createdAt: event.createdAt, }); @@ -892,7 +916,7 @@ class AgentEventService { status: 'active', }).lean().then((installations: InstallationDoc[]) => { for (const inst of installations) { - const runtimeConfig = (normalizeConfig(inst.config)?.runtime || {}) as Record; + const runtimeConfig = normalizeRuntimeConfig(inst.config); if (runtimeConfig.webhookUrl) { deliverEventViaWebhook(inst, event); } @@ -966,6 +990,16 @@ class AgentEventService { const lastSeenRevision = memoryDoc?.lastSeenRevision ?? 0; const digestBundle = buildMemoryDigestBundle(memoryDoc || {}, lastSeenRevision); const hasMemorySnapshot = Boolean(memoryDoc) || currentRevision > 0 || lastSeenRevision > 0; + const continuityEnabled = isTruthyFlag(process.env.COMMONLY_CCP_ENABLED) + || Boolean(await AgentInstallation.findOne({ + agentName: safeAgentName, + instanceId: safeInstanceId, + status: 'active', + ...(podIds && Array.isArray(podIds) && podIds.length > 0 + ? { podId: { $in: podIds } } + : (podId ? { podId } : {})), + 'config.runtime.continuity.enabled': true, + }).select({ _id: 1 }).lean()); const claimed: EventDoc[] = []; for (const candidate of candidates) { @@ -1002,16 +1036,18 @@ class AgentEventService { if (messageId !== undefined && messageId !== null && typeof messageId !== 'string') { enrichedPayload.messageId = String(messageId); } - const continuity = buildContextContinuityPacket({ - event: { ...event, payload: enrichedPayload }, - memoryRevision: hasMemorySnapshot ? currentRevision : undefined, - lastSeenRevision: hasMemorySnapshot ? lastSeenRevision : undefined, - memoryRevisionAtDelivery: hasMemorySnapshot - ? (event.memoryRevisionAtDelivery ?? currentRevision) - : undefined, - memorySections: memorySectionsFromDigestBundle(digestBundle), - }); - return { ...event, continuity, payload: enrichedPayload }; + const continuity = continuityEnabled + ? buildContextContinuityPacket({ + event: { ...event, payload: enrichedPayload }, + memoryRevision: hasMemorySnapshot ? currentRevision : undefined, + lastSeenRevision: hasMemorySnapshot ? lastSeenRevision : undefined, + memoryRevisionAtDelivery: hasMemorySnapshot + ? (event.memoryRevisionAtDelivery ?? currentRevision) + : undefined, + memorySections: memorySectionsFromDigestBundle(digestBundle), + }) + : undefined; + return { ...event, ...(continuity ? { continuity } : {}), payload: enrichedPayload }; }); } diff --git a/backend/services/contextContinuityPacketService.ts b/backend/services/contextContinuityPacketService.ts index 2f2e5c6fb..89caaa0a8 100644 --- a/backend/services/contextContinuityPacketService.ts +++ b/backend/services/contextContinuityPacketService.ts @@ -1,40 +1,6 @@ export const CONTEXT_CONTINUITY_PACKET_SCHEMA = 'commonly.ccp.v1'; -export type ContinuityFreshnessStatus = 'valid' | 'stale' | 'unknown'; - -export interface ContextContinuityPacketV1 { - schema: typeof CONTEXT_CONTINUITY_PACKET_SCHEMA; - contextId: string; - owner: { - agentName: string; - instanceId: string; - podId?: string; - }; - provenance: { - source: 'cap.event'; - eventId?: string; - eventType?: string; - trigger?: string; - createdAt?: string; - deliveredAt?: string; - }; - freshness?: { - memoryRevision?: number; - memoryRevisionAtDelivery?: number; - lastSeenRevision?: number; - status: ContinuityFreshnessStatus; - }; - refs?: { - messageId?: string; - replyToMessageId?: string; - threadId?: string; - taskId?: string; - requestId?: string; - summaryId?: string; - integrationId?: string; - memorySections?: string[]; - }; -} +type ContinuityFreshnessStatus = 'valid' | 'stale' | 'unknown'; interface EventLike { _id?: unknown; @@ -113,10 +79,10 @@ export const memorySectionsFromDigestBundle = ( const buildFreshnessStatus = ( memoryRevision: number | undefined, - memoryRevisionAtDelivery: number | undefined, + lastSeenRevision: number | undefined, ): ContinuityFreshnessStatus => { - if (memoryRevision === undefined || memoryRevisionAtDelivery === undefined) return 'unknown'; - return memoryRevisionAtDelivery >= memoryRevision ? 'valid' : 'stale'; + if (memoryRevision === undefined || lastSeenRevision === undefined) return 'unknown'; + return lastSeenRevision < memoryRevision ? 'stale' : 'valid'; }; export function buildContextContinuityPacket({ @@ -125,7 +91,7 @@ export function buildContextContinuityPacket({ lastSeenRevision, memoryRevisionAtDelivery, memorySections = [], -}: BuildContextContinuityPacketArgs): ContextContinuityPacketV1 { +}: BuildContextContinuityPacketArgs) { const eventId = toStringValue(event?._id); const agentName = toStringValue(event?.agentName)?.toLowerCase() || 'unknown'; const instanceId = toStringValue(event?.instanceId) || 'default'; @@ -135,7 +101,7 @@ export function buildContextContinuityPacket({ const currentRevision = toNumber(memoryRevision); const seenRevision = toNumber(lastSeenRevision); - const refs: ContextContinuityPacketV1['refs'] = {}; + const refs: Record = {}; const messageId = pickPayloadRef(payload, ['messageId', 'message_id']); const replyToMessageId = pickPayloadRef(payload, ['replyToMessageId', 'replyToId', 'reply_to_message_id']); const threadId = pickPayloadRef(payload, ['threadId', 'thread_id']); @@ -154,7 +120,10 @@ export function buildContextContinuityPacket({ const uniqueMemorySections = Array.from(new Set((memorySections || []).filter(Boolean))); if (uniqueMemorySections.length > 0) refs.memorySections = uniqueMemorySections; - const packet: ContextContinuityPacketV1 = { + const packet: Record & { + freshness?: Record; + refs?: Record; + } = { schema: CONTEXT_CONTINUITY_PACKET_SCHEMA, contextId: eventId ? `cap-event:${eventId}` @@ -183,7 +152,7 @@ export function buildContextContinuityPacket({ ...(currentRevision !== undefined ? { memoryRevision: currentRevision } : {}), ...(capturedRevision !== undefined ? { memoryRevisionAtDelivery: capturedRevision } : {}), ...(seenRevision !== undefined ? { lastSeenRevision: seenRevision } : {}), - status: buildFreshnessStatus(currentRevision, capturedRevision), + status: buildFreshnessStatus(currentRevision, seenRevision), }; } @@ -202,4 +171,4 @@ export default { // CJS compat: let require() consume the named helpers from TS-transpiled output. // eslint-disable-next-line @typescript-eslint/no-require-imports -module.exports = exports; +module.exports = exports["default"]; Object.assign(module.exports, exports); diff --git a/docs/agents/AGENT_RUNTIME.md b/docs/agents/AGENT_RUNTIME.md index 40789a497..0347e3b3d 100644 --- a/docs/agents/AGENT_RUNTIME.md +++ b/docs/agents/AGENT_RUNTIME.md @@ -530,6 +530,8 @@ Each sub-field is independently emit-gated (absent when its source section is em Events may also include top-level `continuity` metadata using schema `commonly.ccp.v1`. +Computed `continuity` attachment is opt-in and default-off. Enable it globally with `COMMONLY_CCP_ENABLED=1`, or for a specific install with `config.runtime.continuity.enabled: true`. + This packet points at the existing kernel-owned continuity state for the event: - agent identity: `owner.agentName`, `owner.instanceId`, `owner.podId` diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e1981180d..586d26dc2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -962,6 +962,7 @@ components: podId: type: string continuity: + description: Optional CCP metadata. Computed attachment is opt-in and default-off. $ref: '#/components/schemas/ContextContinuityPacketV1' payload: type: object diff --git a/docs/architecture/CAP.md b/docs/architecture/CAP.md index 20c81d968..a957d4df3 100644 --- a/docs/architecture/CAP.md +++ b/docs/architecture/CAP.md @@ -110,7 +110,7 @@ interface CAPEvent { `continuity` is optional top-level event metadata. It is a kernel-owned pointer to the event boundary, agent identity, memory freshness checkpoint, and compact refs that matter for a runtime turn. -CCP does not add a fifth CAP verb and does not replace AgentMemory. It is derived from existing kernel state and can be ignored by older drivers. +CCP does not add a fifth CAP verb and does not replace AgentMemory. Computed event attachment is opt-in and default-off via `COMMONLY_CCP_ENABLED=1` or `config.runtime.continuity.enabled: true`, so older drivers receive unchanged events unless they opt in. ```typescript interface ContextContinuityPacketV1 { diff --git a/docs/design/context-continuity-packet.md b/docs/design/context-continuity-packet.md index 59d749a52..ff0f0fec7 100644 --- a/docs/design/context-continuity-packet.md +++ b/docs/design/context-continuity-packet.md @@ -4,7 +4,7 @@ Status: draft implementation proposal ## Summary -Context Continuity Packet (CCP) is optional metadata attached to CAP event delivery. It gives a runtime a small, stable pointer to the kernel-owned continuity state behind an event without adding a new memory store, prompt prefix, or CAP verb. +Context Continuity Packet (CCP) is an optional CAP event metadata convention. It gives a runtime a small, stable pointer to the kernel-owned continuity state behind an event without adding a new memory store, prompt prefix, or CAP verb. Commonly already owns the durable continuity primitives: @@ -82,12 +82,14 @@ The packet is attached as a top-level event field: } ``` +Computed event attachment is default-off. Operators can enable it globally with `COMMONLY_CCP_ENABLED=1`, or per install with `config.runtime.continuity.enabled: true`. The schema remains useful as a convention even when no runtime has opted into receiving computed packets. + ## Semantics - `contextId` identifies the event-boundary continuity packet. In v1 it is derived from the CAP event id. - `owner` identifies the agent and pod scope that owns the continuity state. - `provenance` identifies the kernel event that caused this runtime turn. -- `freshness` reflects the memory revision snapshot used at delivery time. +- `freshness.status` is `stale` when `lastSeenRevision < memoryRevision`, `valid` when the agent has seen the current memory revision, and `unknown` when either value is unavailable. - `refs` carries compact ids that a runtime can use for threading, tasks, asks, messages, or memory section awareness. `refs.memorySections` names only the memory sections represented by already-emitted digest metadata. It never contains the memory body itself. @@ -98,6 +100,7 @@ The packet is attached as a top-level event field: - No new AgentMemory section. - No database migration. - No default prompt injection. +- No default computed event attachment. - No replacement for `memoryDigest`, `cyclesDigest`, `longTermDigest`, or `recentDailyDigest`. - No runtime-specific adapter glue. - No requirement that drivers persist CCP. @@ -111,4 +114,3 @@ Drivers should not blindly inject the full packet into model context. If a model ## Naming This implementation uses `Context Continuity Packet` / `CCP` because the name is already useful in adjacent discussions. Maintainers can rename the convention without changing the underlying kernel pattern: top-level event metadata that points to existing Commonly continuity state. - diff --git a/docs/openapi/cap-kernel.yaml b/docs/openapi/cap-kernel.yaml index 76cae6928..ae2fbdc9f 100644 --- a/docs/openapi/cap-kernel.yaml +++ b/docs/openapi/cap-kernel.yaml @@ -250,6 +250,7 @@ components: type: string format: date-time continuity: + description: Optional CCP metadata. Computed attachment is opt-in and default-off. $ref: '#/components/schemas/ContextContinuityPacketV1' payload: type: object