diff --git a/backend/__tests__/unit/services/agentEventService.test.js b/backend/__tests__/unit/services/agentEventService.test.js index 41a6211a0..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,81 @@ 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', + owner: { + agentName: 'openclaw', + instanceId: 'liz', + podId: 'pod-1', + }, + freshness: { + memoryRevision: 7, + memoryRevisionAtDelivery: 7, + lastSeenRevision: 5, + status: 'stale', + }, + refs: expect.objectContaining({ + messageId: '1800', + memorySections: ['system_exchanges', 'cycles', 'long_term'], + }), + })); }); test('list returns [] when no candidates are pending (no envelope read)', async () => { @@ -309,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 new file mode 100644 index 000000000..5e0cc898d --- /dev/null +++ b/backend/__tests__/unit/services/contextContinuityPacketService.test.ts @@ -0,0 +1,187 @@ +// @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: 'stale', + }, + 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('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: { + _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..87ab6e9e1 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 { @@ -143,11 +148,34 @@ 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 = isContinuityConfigEnabled(installation.config) + ? buildContextContinuityPacket({ + event, + memoryRevisionAtDelivery: event.memoryRevisionAtDelivery, + }) + : undefined; + const payload = JSON.stringify({ _id: event._id, type: event.type, @@ -155,6 +183,7 @@ const deliverEventViaWebhook = async (installation: InstallationDoc, event: Even agentName: event.agentName, instanceId: event.instanceId, createdAt: event.createdAt, + ...(continuity ? { continuity } : {}), payload: event.payload, }); @@ -366,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; @@ -441,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)) { @@ -783,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({ @@ -792,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; @@ -820,6 +851,9 @@ class AgentEventService { : {}), }) as EventDoc; + const continuityEnabled = isContinuityConfigEnabled(routingInstallation?.config); + const continuity = continuityEnabled ? buildContextContinuityPacket({ event }) : undefined; + if (routedToNative && nativeInstallation) { try { // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports @@ -830,6 +864,7 @@ class AgentEventService { Promise.resolve(runAgent(nativeInstallation, { type, eventId: eventIdStr, + ...(continuity ? { continuity } : {}), payload: event.payload, })).catch((err: Error) => { console.error('[native-runtime] runAgent failed:', err?.message || err); @@ -861,6 +896,7 @@ class AgentEventService { instanceId: event.instanceId, podId: event.podId, type: event.type, + ...(continuity ? { continuity } : {}), payload: event.payload, createdAt: event.createdAt, }); @@ -880,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); } @@ -953,6 +989,17 @@ 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 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) { @@ -989,7 +1036,18 @@ class AgentEventService { if (messageId !== undefined && messageId !== null && typeof messageId !== 'string') { enrichedPayload.messageId = String(messageId); } - return { ...event, 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 new file mode 100644 index 000000000..89caaa0a8 --- /dev/null +++ b/backend/services/contextContinuityPacketService.ts @@ -0,0 +1,174 @@ +export const CONTEXT_CONTINUITY_PACKET_SCHEMA = 'commonly.ccp.v1'; + +type ContinuityFreshnessStatus = 'valid' | 'stale' | 'unknown'; + +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, + lastSeenRevision: number | undefined, +): ContinuityFreshnessStatus => { + if (memoryRevision === undefined || lastSeenRevision === undefined) return 'unknown'; + return lastSeenRevision < memoryRevision ? 'stale' : 'valid'; +}; + +export function buildContextContinuityPacket({ + event, + memoryRevision, + lastSeenRevision, + memoryRevisionAtDelivery, + memorySections = [], +}: BuildContextContinuityPacketArgs) { + 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: 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']); + 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: Record & { + freshness?: Record; + refs?: Record; + } = { + 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, seenRevision), + }; + } + + 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["default"]; Object.assign(module.exports, exports); diff --git a/docs/agents/AGENT_RUNTIME.md b/docs/agents/AGENT_RUNTIME.md index 2f756d251..0347e3b3d 100644 --- a/docs/agents/AGENT_RUNTIME.md +++ b/docs/agents/AGENT_RUNTIME.md @@ -526,6 +526,23 @@ 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`. + +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` +- 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..586d26dc2 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -961,6 +961,9 @@ components: type: string podId: type: string + continuity: + description: Optional CCP metadata. Computed attachment is opt-in and default-off. + $ref: '#/components/schemas/ContextContinuityPacketV1' payload: type: object additionalProperties: true @@ -969,6 +972,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..a957d4df3 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. 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 { + 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..ff0f0fec7 --- /dev/null +++ b/docs/design/context-continuity-packet.md @@ -0,0 +1,116 @@ +# Context Continuity Packet (CCP) + +Status: draft implementation proposal + +## Summary + +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: + +- 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": "..." + } +} +``` + +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.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. + +## Non-Goals + +- No new CAP verb. +- 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. + +## 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..ae2fbdc9f 100644 --- a/docs/openapi/cap-kernel.yaml +++ b/docs/openapi/cap-kernel.yaml @@ -249,10 +249,90 @@ components: createdAt: 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 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;