From 6aa093d14ed116722639f55b5f4cefe73f798726 Mon Sep 17 00:00:00 2001 From: Jay/Fienna Liang Date: Sat, 12 Sep 2026 21:32:33 +0800 Subject: [PATCH] feat: add Runtime 9 heartbeat memory contract --- docs/guides/heartbeat.md | 38 ++++++ .../programmatic/conversation-engine.md | 18 +++ docs/releases/runtime-v9.0.0.md | 52 +++++++++ packages/runtime/package.json | 2 +- .../control-plane-heartbeat-mutations.test.ts | 1 + .../integration/core/agent-loop.test.ts | 110 ++++++++++++++++++ .../core/heartbeat-concurrency.test.ts | 1 + .../core/heartbeat-execution-context.test.ts | 6 + .../core/heartbeat-run-requests.test.ts | 1 + .../core/heartbeat-scheduler.test.ts | 1 + .../heartbeat-targeted-cancellation.test.ts | 1 + .../core/heartbeat-targeted-execution.test.ts | 1 + .../unit/core/heartbeat-lucid.test.ts | 1 + .../unit/core/heartbeat-memory-result.test.ts | 61 ++++++++++ .../unit/core/heartbeat-run-service.test.ts | 1 + .../unit/core/heartbeat-views.test.ts | 1 + .../unit/core/memory-run-result.test.ts | 59 ++++++++++ src/core/chat/engine/turn-result.ts | 15 ++- src/core/chat/engine/turns/service.ts | 11 +- src/core/custom-agents/schemas.ts | 3 +- src/core/heartbeat/README.md | 16 ++- src/core/heartbeat/agent/runner-agent.ts | 2 + src/core/heartbeat/agent/types.ts | 3 + src/core/heartbeat/scheduler/runner.ts | 1 + src/core/heartbeat/scheduler/types.ts | 4 + .../tasks/heartbeat-task-store-conformance.ts | 2 +- src/core/heartbeat/tasks/schemas.ts | 2 + src/core/memory/README.md | 46 ++++++++ src/core/memory/index.ts | 4 + src/core/memory/run-result.ts | 29 +++++ src/core/memory/tool-mode.ts | 10 ++ src/core/runtime/loop/service.ts | 1 + src/core/runtime/loop/types.ts | 3 + src/core/runtime/tools/profiles/types.ts | 4 +- src/core/runtime/tools/service.ts | 4 +- src/core/runtime/tools/types.ts | 3 +- src/core/tools/toolkit.ts | 3 +- src/core/tools/toolkits/knowledge/toolkit.ts | 9 +- src/index.ts | 5 + 39 files changed, 510 insertions(+), 25 deletions(-) create mode 100644 docs/releases/runtime-v9.0.0.md create mode 100644 src/__tests__/unit/core/heartbeat-memory-result.test.ts create mode 100644 src/__tests__/unit/core/memory-run-result.test.ts create mode 100644 src/core/memory/run-result.ts create mode 100644 src/core/memory/tool-mode.ts diff --git a/docs/guides/heartbeat.md b/docs/guides/heartbeat.md index 251f5977..bf811ed2 100644 --- a/docs/guides/heartbeat.md +++ b/docs/guides/heartbeat.md @@ -670,6 +670,7 @@ checkpoint, execution record, and framework events: ```ts import { HeartbeatSchedulerService, + memoryToolkit, type HeartbeatTaskHandler, } from '@heddleagent/runtime/advanced'; @@ -683,6 +684,8 @@ const handler: HeartbeatTaskHandler = async (context) => { task: `${context.task.task}\n\nClaimed work: ${claim.instruction}`, systemContext: `Operate only on claim ${claim.id}.`, tools: domainTools, + toolkits: [memoryToolkit], + memoryMode: 'read-only', includeDefaultTools: false, }); @@ -711,6 +714,41 @@ The host does not receive credential records or token fields and must not retain the execution context. Set `preferApiKey: true` in `runtime` only when an environment API key should take precedence over stored OpenAI OAuth state. +With `includeDefaultTools: false`, the explicit `memoryToolkit` plus +`memoryMode: 'read-only'` exposes exactly `list_memory_notes`, +`read_memory_note`, and `search_memory_notes` in addition to the host tools. +It does not expose candidate recording, the memory checkpoint decision tool, +or direct note editing. Memory mode selects Heddle memory capabilities only; +the host remains responsible for authenticating product identity and +authorizing every product-owned tool. + +Every current `AgentHeartbeatResult` includes a settled mutation receipt: + +```ts +const result = await context.runAgent({ + includeDefaultTools: false, + toolkits: [memoryToolkit], + memoryMode: 'read-and-record', +}); + +if (result.memory.changed) { + await durableMemory.checkpointAfterSuccessfulRun(); +} +``` + +`memory.changed` is true only when the trace proves that a Heddle-owned memory +mutation completed. Read-only activity, an explicit checkpoint skip, and a +failed write report false. Historical persisted heartbeat results without the +field decode as `{ changed: false }`. A hosted adapter must restore the +authenticated working copy before `runAgent()`, wait for successful settlement, +then checkpoint on `true`; Heddle does not choose the storage key, authenticate +the scope, or retry the host checkpoint. + +`HeartbeatAgentExecutionTransport` intentionally does not serialize toolkits or +filesystem paths. When the nested agent runs in another process, that execution +host must compose the same capability mode from its own signed allowlist and +resolved memory working copy. + When the handler itself completes admitted host-owned work without invoking the Heddle agent loop, return `context.complete()` instead: diff --git a/docs/guides/programmatic/conversation-engine.md b/docs/guides/programmatic/conversation-engine.md index 7847d1a3..3015de4b 100644 --- a/docs/guides/programmatic/conversation-engine.md +++ b/docs/guides/programmatic/conversation-engine.md @@ -74,6 +74,16 @@ const engine = createConversationEngine({ }) ``` +The public `MemoryToolMode` values are: + +| Mode | Heddle memory tools | +| --- | --- | +| `none` | none | +| `read-only` | list, read, and search notes | +| `read-and-record` | read-only tools plus candidate recording and the explicit memory checkpoint decision tool | +| `maintainer` | read-only tools plus direct note editing | +| `legacy-full` | legacy direct-edit compatibility | + This is separate from `memoryMaintenanceMode`. `toolProfile.memoryMode` controls the memory tools visible to the model, while `memoryMaintenanceMode` controls post-turn memory maintenance scheduling. If a @@ -86,6 +96,14 @@ maintenance to reach a stable boundary before reporting memory changes to checkpointing hosts. `inline` includes maintenance events in the primary persisted turn result; `none` leaves recorded candidates pending. +Every settled turn result includes `memory.changed`. It is true after Heddle +records a memory candidate or successfully edits a memory note and false after +read-only activity, a skipped checkpoint, or a failed write. A host with a +durable external memory store can use this receipt to checkpoint after the turn +promise settles. It still owns authenticated scope selection, restore-before- +run ordering, durable checkpoint retries, and retention. The receipt does not +cover arbitrary product tools or direct filesystem writes. + For host event adapters, import `HeddleEventType` instead of duplicating event name strings: diff --git a/docs/releases/runtime-v9.0.0.md b/docs/releases/runtime-v9.0.0.md new file mode 100644 index 00000000..dcdb47af --- /dev/null +++ b/docs/releases/runtime-v9.0.0.md @@ -0,0 +1,52 @@ +# `@heddleagent/runtime` 9.0.0 + +This release candidate adds a portable read-only memory composition for agent +and heartbeat runs, plus an explicit mutation receipt for host checkpoint +policy. + +## What changed + +- Export `MemoryToolMode` with a new `read-only` value. +- Export `memoryToolkit` for explicit composition with + `includeDefaultTools: false`. Read-only mode creates exactly the memory list, + read, and search tools. +- Add `RunAgentLoopOptions.memoryMode` and carry the mode through direct + heartbeat and execution-context agent invocation. +- Require `AgentHeartbeatResult.memory.changed` on current results. Conversation + turns and heartbeat runs now share the same trace projector for Heddle-owned + memory mutations. +- Decode historical persisted heartbeat results that do not contain the new + receipt as `{ changed: false }`. + +## Host lifecycle boundary + +The receipt reports whether a settled Heddle memory tool changed the portable +memory working copy. Candidate recording and successful direct note editing +report `true`; memory reads, explicit checkpoint skips, and failed writes report +`false`. It does not detect product-tool, shell, or arbitrary filesystem writes. + +A hosted adapter still owns authenticated memory scope selection, restore before +agent invocation, signed capability allowlisting, checkpoint after successful +settlement, durable retry/conflict handling, and retention. Heddle does not +select product identities or storage keys and does not serialize tool functions +or filesystem paths through the heartbeat execution transport. + +## Upgrade notes + +- Code that constructs an `AgentHeartbeatResult` must now include + `memory: { changed: boolean }`. +- Persisted heartbeat records require no migration; the schema supplies the + conservative `false` default when the field is absent. +- `read-and-record` remains the default for the ordinary default tool bundle. + Select `read-only` explicitly for inspection-only runs. + +## Verification + +The candidate is covered by exact read-only toolkit composition, heartbeat +changed/unchanged receipts, historical schema decoding, conversation receipt +regressions, typechecking, lint, the full unit/integration baseline, and Runtime +package build/pack/consumer-import checks. + +This file and the package version describe a reviewable release candidate only. +Merge, tag, GitHub release creation, npm publication, deployment, and observed +adopter behavior remain separate operator-controlled states. diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 03a78685..89d2268d 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@heddleagent/runtime", - "version": "8.1.0", + "version": "9.0.0", "description": "Embeddable TypeScript and Node.js agent runtime and SDK for Heddle-powered products", "author": "Jay / Fienna Liang ", "license": "MIT", diff --git a/src/__tests__/integration/control-plane/control-plane-heartbeat-mutations.test.ts b/src/__tests__/integration/control-plane/control-plane-heartbeat-mutations.test.ts index cf3a6457..aca104a7 100644 --- a/src/__tests__/integration/control-plane/control-plane-heartbeat-mutations.test.ts +++ b/src/__tests__/integration/control-plane/control-plane-heartbeat-mutations.test.ts @@ -421,6 +421,7 @@ function createHeartbeatResult( return { decision: 'continue', summary, + memory: { changed: false }, checkpoint: { version: 1, runId, diff --git a/src/__tests__/integration/core/agent-loop.test.ts b/src/__tests__/integration/core/agent-loop.test.ts index 6060ec57..af185e4e 100644 --- a/src/__tests__/integration/core/agent-loop.test.ts +++ b/src/__tests__/integration/core/agent-loop.test.ts @@ -10,6 +10,7 @@ import { ProviderCredentialRepository } from '@/core/auth/index.js'; import { LlmAdapterService } from '@/core/llm/index.js'; import type { ChatMessage, LlmAdapter, LlmResponse } from '../../../core/llm/types.js'; import type { AgentHeartbeatEvent, AgentLoopEvent, ToolDefinition } from '../../../advanced.js'; +import { memoryToolkit } from '../../../index.js'; import { createLogger } from '../../../core/utils/logger.js'; import { HeartbeatDecisionPolicy, @@ -897,6 +898,12 @@ describe('RuntimeToolService.createDefaultAgentTools', () => { memoryDir, memoryMode: 'none', }).map((tool) => tool.name); + const readOnly = RuntimeToolService.createDefaultAgentTools({ + model: 'gpt-test', + workspaceRoot, + memoryDir, + memoryMode: 'read-only', + }).map((tool) => tool.name); const maintainer = RuntimeToolService.createDefaultAgentTools({ model: 'gpt-test', workspaceRoot, @@ -912,6 +919,14 @@ describe('RuntimeToolService.createDefaultAgentTools', () => { expect(none).not.toContain('list_memory_notes'); expect(none).not.toContain('record_knowledge'); + expect(readOnly).toEqual(expect.arrayContaining([ + 'list_memory_notes', + 'read_memory_note', + 'search_memory_notes', + ])); + expect(readOnly).not.toContain('memory_checkpoint'); + expect(readOnly).not.toContain('record_knowledge'); + expect(readOnly).not.toContain('edit_memory_note'); expect(maintainer).toEqual(expect.arrayContaining([ 'list_memory_notes', 'read_memory_note', @@ -962,6 +977,100 @@ describe('ToolBundleComposer', () => { }); describe('HeartbeatRunnerAgent.run', () => { + it('composes exactly the read-only memory toolkit for an isolated heartbeat run', async () => { + const root = await mkdtemp(join(tmpdir(), 'heddle-heartbeat-read-only-memory-')); + let modelVisibleTools: string[] = []; + const fakeLlm: LlmAdapter = { + info: { + provider: 'openai', + model: 'gpt-test', + capabilities: { + toolCalls: true, + systemMessages: true, + reasoningSummaries: false, + parallelToolCalls: true, + }, + }, + async chat(_messages, tools): Promise { + modelVisibleTools = tools.map((tool) => tool.name); + return { + content: 'Read-only inspection is complete.\n\nHEARTBEAT_DECISION: continue', + }; + }, + }; + + const result = await HeartbeatRunnerAgent.run({ + task: 'Inspect durable memory without changing it.', + llm: fakeLlm, + apiKey: 'test-api-key', + apiKeyProvider: 'explicit', + preferApiKey: true, + toolkits: [memoryToolkit], + includeDefaultTools: false, + memoryMode: 'read-only', + memoryDir: join(root, 'memory'), + workspaceRoot: root, + maxSteps: 1, + logger: silentLogger, + }); + + expect(modelVisibleTools).toEqual([ + 'list_memory_notes', + 'read_memory_note', + 'search_memory_notes', + ]); + expect(result.memory).toEqual({ changed: false }); + }); + + it('reports a settled memory change after a heartbeat records knowledge', async () => { + const root = await mkdtemp(join(tmpdir(), 'heddle-heartbeat-memory-change-')); + let modelCalls = 0; + const fakeLlm: LlmAdapter = { + info: { + provider: 'openai', + model: 'gpt-test', + capabilities: { + toolCalls: true, + systemMessages: true, + reasoningSummaries: false, + parallelToolCalls: true, + }, + }, + async chat(): Promise { + modelCalls += 1; + if (modelCalls === 1) { + return { + toolCalls: [{ + id: 'record-1', + tool: 'record_knowledge', + input: { summary: 'Use the focused heartbeat verification command for this repository.' }, + }], + }; + } + return { + content: 'The durable observation was recorded.\n\nHEARTBEAT_DECISION: continue', + }; + }, + }; + + const result = await HeartbeatRunnerAgent.run({ + task: 'Capture one durable heartbeat observation.', + llm: fakeLlm, + apiKey: 'test-api-key', + apiKeyProvider: 'explicit', + preferApiKey: true, + toolkits: [memoryToolkit], + includeDefaultTools: false, + memoryMode: 'read-and-record', + memoryDir: join(root, 'memory'), + workspaceRoot: root, + maxSteps: 2, + logger: silentLogger, + }); + + expect(result.memory).toEqual({ changed: true }); + }); + it('runs an autonomous runner cycle and returns a checkpoint with the parsed decision', async () => { const seenMessages: ChatMessage[][] = []; const fakeLlm: LlmAdapter = { @@ -993,6 +1102,7 @@ describe('HeartbeatRunnerAgent.run', () => { }); expect(result.decision).toBe('continue'); + expect(result.memory).toEqual({ changed: false }); expect(result.checkpoint.version).toBe(1); expect(result.state.goal).toContain('# Heartbeat Run'); expect(seenMessages[0][0]).toMatchObject({ diff --git a/src/__tests__/integration/core/heartbeat-concurrency.test.ts b/src/__tests__/integration/core/heartbeat-concurrency.test.ts index 0deb2009..aff5ad9a 100644 --- a/src/__tests__/integration/core/heartbeat-concurrency.test.ts +++ b/src/__tests__/integration/core/heartbeat-concurrency.test.ts @@ -294,6 +294,7 @@ function createHeartbeatResult(taskId: string): AgentHeartbeatResult { return { decision: 'continue', summary, + memory: { changed: false }, state, checkpoint: AgentLoopCheckpointService.createCheckpoint(state, { createdAt: NOW.toISOString(), diff --git a/src/__tests__/integration/core/heartbeat-execution-context.test.ts b/src/__tests__/integration/core/heartbeat-execution-context.test.ts index 2fd2ddd9..6c400aa9 100644 --- a/src/__tests__/integration/core/heartbeat-execution-context.test.ts +++ b/src/__tests__/integration/core/heartbeat-execution-context.test.ts @@ -8,6 +8,7 @@ import { DEFAULT_HEARTBEAT_HANDLER_RETRY_MS, HeartbeatRunnerAgent, HeartbeatSchedulerService, + memoryToolkit, type AgentHeartbeatResult, type HeartbeatExecutionContext, type HeartbeatAgentExecutionTransport, @@ -211,6 +212,8 @@ describe('heartbeat execution context', () => { task: 'Process claimed work item domain-42.', systemContext: 'Only operate on domain-42.', tools: [domainTool], + toolkits: [memoryToolkit], + memoryMode: 'read-only', maxSteps: 3, }); }, @@ -223,6 +226,8 @@ describe('heartbeat execution context', () => { task: 'Process claimed work item domain-42.', systemContext: 'Only operate on domain-42.', tools: [domainTool], + toolkits: [memoryToolkit], + memoryMode: 'read-only', maxSteps: 3, checkpoint: undefined, abortSignal: executionContext?.signal, @@ -822,6 +827,7 @@ function createHeartbeatResult( return { decision, summary, + memory: { changed: false }, state, checkpoint: AgentLoopCheckpointService.createCheckpoint(state, { createdAt: state.finishedAt, diff --git a/src/__tests__/integration/core/heartbeat-run-requests.test.ts b/src/__tests__/integration/core/heartbeat-run-requests.test.ts index dbef1464..e297b322 100644 --- a/src/__tests__/integration/core/heartbeat-run-requests.test.ts +++ b/src/__tests__/integration/core/heartbeat-run-requests.test.ts @@ -325,6 +325,7 @@ function createHeartbeatResult(runId: string): AgentHeartbeatResult { return { decision: 'continue', summary, + memory: { changed: false }, state, checkpoint: AgentLoopCheckpointService.createCheckpoint(state, { createdAt: NOW.toISOString(), diff --git a/src/__tests__/integration/core/heartbeat-scheduler.test.ts b/src/__tests__/integration/core/heartbeat-scheduler.test.ts index 0d3e8c20..36d16161 100644 --- a/src/__tests__/integration/core/heartbeat-scheduler.test.ts +++ b/src/__tests__/integration/core/heartbeat-scheduler.test.ts @@ -805,6 +805,7 @@ function createHeartbeatResult(decision: AgentHeartbeatResult['decision']): Agen return { decision, summary, + memory: { changed: false }, state, checkpoint: AgentLoopCheckpointService.createCheckpoint(state, { createdAt: '2026-04-13T00:00:01.000Z', diff --git a/src/__tests__/integration/core/heartbeat-targeted-cancellation.test.ts b/src/__tests__/integration/core/heartbeat-targeted-cancellation.test.ts index ac2f83d1..7c9fd22b 100644 --- a/src/__tests__/integration/core/heartbeat-targeted-cancellation.test.ts +++ b/src/__tests__/integration/core/heartbeat-targeted-cancellation.test.ts @@ -413,6 +413,7 @@ function createHeartbeatResult(runId: string): AgentHeartbeatResult { return { decision: 'continue', summary, + memory: { changed: false }, state, checkpoint: AgentLoopCheckpointService.createCheckpoint(state, { createdAt: NOW.toISOString(), diff --git a/src/__tests__/integration/core/heartbeat-targeted-execution.test.ts b/src/__tests__/integration/core/heartbeat-targeted-execution.test.ts index 978d67f4..27aec245 100644 --- a/src/__tests__/integration/core/heartbeat-targeted-execution.test.ts +++ b/src/__tests__/integration/core/heartbeat-targeted-execution.test.ts @@ -326,6 +326,7 @@ function createAgentResult(runId: string): AgentHeartbeatResult { return { decision: 'continue', summary: state.summary, + memory: { changed: false }, state, checkpoint: { version: 1, runId, createdAt: state.finishedAt, state }, }; diff --git a/src/__tests__/unit/core/heartbeat-lucid.test.ts b/src/__tests__/unit/core/heartbeat-lucid.test.ts index 8c65baab..68b2ca8a 100644 --- a/src/__tests__/unit/core/heartbeat-lucid.test.ts +++ b/src/__tests__/unit/core/heartbeat-lucid.test.ts @@ -301,6 +301,7 @@ function createHeartbeatResult(): AgentHeartbeatResult { return { decision: 'continue', summary: 'Repository check complete.', + memory: { changed: false }, checkpoint: { version: 1, runId: 'run_1', diff --git a/src/__tests__/unit/core/heartbeat-memory-result.test.ts b/src/__tests__/unit/core/heartbeat-memory-result.test.ts new file mode 100644 index 00000000..608d9054 --- /dev/null +++ b/src/__tests__/unit/core/heartbeat-memory-result.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import { + AgentHeartbeatResultSchema, + HeartbeatTaskRunRecordSchema, + HeartbeatTaskSchema, +} from '@/core/heartbeat/tasks/schemas.js'; + +const legacyResult = { + decision: 'continue', + summary: 'Historical result without a memory receipt.', + checkpoint: { + version: 1, + runId: 'run-1', + createdAt: '2026-09-12T00:00:00.000Z', + state: { + runId: 'run-1', + status: 'finished', + transcript: [], + trace: [], + }, + }, + state: { + runId: 'run-1', + finishedAt: '2026-09-12T00:00:00.000Z', + outcome: 'done', + }, +}; + +describe('AgentHeartbeatResultSchema memory receipt', () => { + it('decodes a historical result without a receipt as unchanged', () => { + expect(AgentHeartbeatResultSchema.parse(legacyResult).memory).toEqual({ changed: false }); + }); + + it('preserves an explicit changed receipt', () => { + expect(AgentHeartbeatResultSchema.parse({ + ...legacyResult, + memory: { changed: true }, + }).memory).toEqual({ changed: true }); + }); + + it('applies the compatibility default inside historical task and run records', () => { + const task = { + id: 'legacy-task', + task: 'Continue historical work.', + enabled: true, + schedule: { intervalMs: 60_000 }, + state: { + status: 'waiting', + resumable: true, + result: legacyResult, + }, + }; + + expect(HeartbeatTaskSchema.parse(task).state?.result?.memory).toEqual({ changed: false }); + expect(HeartbeatTaskRunRecordSchema.parse({ + task: { ...task, state: undefined }, + result: legacyResult, + loadedCheckpoint: false, + }).result?.memory).toEqual({ changed: false }); + }); +}); diff --git a/src/__tests__/unit/core/heartbeat-run-service.test.ts b/src/__tests__/unit/core/heartbeat-run-service.test.ts index ea51b1e2..411ecc65 100644 --- a/src/__tests__/unit/core/heartbeat-run-service.test.ts +++ b/src/__tests__/unit/core/heartbeat-run-service.test.ts @@ -294,6 +294,7 @@ function result(): AgentHeartbeatResult { return { decision: 'complete', summary: state.summary, + memory: { changed: false }, state, checkpoint: { version: 1, diff --git a/src/__tests__/unit/core/heartbeat-views.test.ts b/src/__tests__/unit/core/heartbeat-views.test.ts index 52060d44..6f3733b7 100644 --- a/src/__tests__/unit/core/heartbeat-views.test.ts +++ b/src/__tests__/unit/core/heartbeat-views.test.ts @@ -261,6 +261,7 @@ function createHeartbeatResult(): AgentHeartbeatResult { return { decision: 'continue', summary: 'Repository check complete.', + memory: { changed: false }, checkpoint: { version: 1, runId: 'run_1', diff --git a/src/__tests__/unit/core/memory-run-result.test.ts b/src/__tests__/unit/core/memory-run-result.test.ts new file mode 100644 index 00000000..ebd97c62 --- /dev/null +++ b/src/__tests__/unit/core/memory-run-result.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { projectMemoryRunResult } from '@/core/memory/run-result.js'; +import type { TraceEvent } from '@/core/types.js'; + +const timestamp = '2026-09-12T00:00:00.000Z'; + +describe('projectMemoryRunResult', () => { + it('reports a recorded candidate as a memory change', () => { + const trace: TraceEvent[] = [{ + type: 'memory.candidate_recorded', + candidateId: 'candidate-1', + path: '_maintenance/candidates.jsonl', + step: 1, + timestamp, + }]; + + expect(projectMemoryRunResult(trace)).toEqual({ changed: true }); + }); + + it('reports a successful direct memory edit as a memory change', () => { + const trace: TraceEvent[] = [{ + type: 'tool.completed', + call: { id: 'call-1', tool: 'edit_memory_note', input: {} }, + result: { ok: true, output: { path: 'projects/heddle.md' } }, + step: 1, + timestamp, + }]; + + expect(projectMemoryRunResult(trace)).toEqual({ changed: true }); + }); + + it('does not report failed edits, checkpoint skips, or unrelated tools as changes', () => { + const trace: TraceEvent[] = [ + { + type: 'tool.completed', + call: { id: 'call-1', tool: 'edit_memory_note', input: {} }, + result: { ok: false, error: 'write failed' }, + step: 1, + timestamp, + }, + { + type: 'memory.checkpoint_skipped', + rationale: 'Nothing durable changed.', + step: 2, + timestamp, + }, + { + type: 'tool.completed', + call: { id: 'call-2', tool: 'read_memory_note', input: {} }, + result: { ok: true, output: 'read only' }, + step: 3, + timestamp, + }, + ]; + + expect(projectMemoryRunResult(trace)).toEqual({ changed: false }); + expect(projectMemoryRunResult([])).toEqual({ changed: false }); + }); +}); diff --git a/src/core/chat/engine/turn-result.ts b/src/core/chat/engine/turn-result.ts index 4e021509..4abdda66 100644 --- a/src/core/chat/engine/turn-result.ts +++ b/src/core/chat/engine/turn-result.ts @@ -1,4 +1,5 @@ import type { RuntimeArtifact } from '@/core/artifacts/index.js'; +import type { MemoryRunResult } from '@/core/memory/run-result.js'; import type { RunFailure, ToolCall, ToolResult } from '@/core/types.js'; import type { ChatSession } from '@/core/chat/types.js'; import type { SettledDelegationRootScopeSnapshot } from '@/core/delegation/index.js'; @@ -21,12 +22,10 @@ export type ConversationTurnResultSummary = { toolResults: ConversationTurnToolResult[]; /** In-memory child-run evidence for this turn. Omitted when delegation was off. */ delegation?: SettledDelegationRootScopeSnapshot; - memory: { - /** - * Whether this turn changed Heddle's portable memory working copy. - * The turn result resolves only after configured memory maintenance reaches - * a stable boundary, so checkpointing callers may act on this value. - */ - changed: boolean; - }; + /** + * Whether this turn changed Heddle's portable memory working copy. + * The turn result resolves only after configured memory maintenance reaches + * a stable boundary, so checkpointing callers may act on this value. + */ + memory: MemoryRunResult; }; diff --git a/src/core/chat/engine/turns/service.ts b/src/core/chat/engine/turns/service.ts index a2d02fe6..3b0c29de 100644 --- a/src/core/chat/engine/turns/service.ts +++ b/src/core/chat/engine/turns/service.ts @@ -3,6 +3,7 @@ import { AutonomyPermissionModeService, ToolApprovalProfileService } from '@/cor import { ArtifactService } from '@/core/artifacts/index.js'; import type { ArtifactRepository } from '@/core/artifacts/index.js'; import { HeddleEventType } from '@/core/event-types.js'; +import { projectMemoryRunResult } from '@/core/memory/run-result.js'; import { ProjectConfigService } from '@/core/project-config/index.js'; import { DelegationService, @@ -263,13 +264,9 @@ export class EngineConversationTurnService implements ConversationTurnService { }), toolResults: EngineConversationTurnService.summarizeToolResults(resultForPersistence.trace), ...(delegation ? { delegation } : {}), - memory: { - // Background maintenance runs after the primary turn is persisted, - // but the result does not resolve until that working copy is stable. - changed: resultForPersistence.trace.some( - ({ type }) => type === HeddleEventType.memoryCandidateRecorded, - ), - }, + // Background maintenance runs after the primary turn is persisted, + // but the result does not resolve until that working copy is stable. + memory: projectMemoryRunResult(resultForPersistence.trace), }; } finally { await delegationScope?.cancelAndWait(); diff --git a/src/core/custom-agents/schemas.ts b/src/core/custom-agents/schemas.ts index 99cddb7f..ff4494be 100644 --- a/src/core/custom-agents/schemas.ts +++ b/src/core/custom-agents/schemas.ts @@ -1,12 +1,13 @@ import { z } from 'zod'; import { REASONING_EFFORTS } from '@/core/llm/types.js'; +import { MEMORY_TOOL_MODES } from '@/core/memory/tool-mode.js'; export const CustomAgentModeAliasSchema = z.enum(['ask', 'code', 'review']); export const CustomAgentSourceKindSchema = z.enum(['project', 'user', 'built-in']); export const CustomAgentToolPresetSchema = z.enum(['default', 'inspect', 'none', 'custom']); export const CustomAgentApprovalPresetSchema = z.enum(['interactive', 'read_only', 'auto', 'custom']); export const CustomAgentReasoningEffortSchema = z.enum(REASONING_EFFORTS); -export const CustomAgentMemoryModeSchema = z.enum(['none', 'read-and-record', 'maintainer', 'legacy-full']); +export const CustomAgentMemoryModeSchema = z.enum(MEMORY_TOOL_MODES); export const ToolCapabilitySchema = z.enum([ 'workspace.read', 'workspace.write', diff --git a/src/core/heartbeat/README.md b/src/core/heartbeat/README.md index 1f34133f..3467a11c 100644 --- a/src/core/heartbeat/README.md +++ b/src/core/heartbeat/README.md @@ -11,7 +11,9 @@ operator-facing heartbeat views. - `agent/`: `HeartbeatRunnerAgent` owns one autonomous runner-agent cycle on top of `AgentLoopRuntimeService.run`, with prompt and decision policy classes kept - beside it. + beside it. Every current result includes `memory.changed`, projected from the + settled Heddle memory-tool trace so a host can make an explicit checkpoint + decision. - `runs/`: `HeartbeatRunService` owns the process-local lifecycle for one explicitly requested heartbeat cycle: run identity, cancellation, ordered activity, awaited host result projection, and exactly one terminal result, @@ -203,6 +205,18 @@ operator-facing heartbeat views. approval callbacks, filesystem paths, loggers, and model adapters. The execution process resolves those locally; the scheduler validates its returned result before committing the new checkpoint or successful state. +- `AgentHeartbeatResult.memory.changed` is required on current results. The + persisted-result schema supplies `{ changed: false }` when decoding older + records that predate the receipt. A read-only run therefore remains + checkpoint-free, while a successful Heddle candidate record or direct memory + edit produces `true`. The host must restore authenticated memory before the + invocation and checkpoint it only after the successful result settles. +- `context.runAgent({ includeDefaultTools: false, toolkits: [memoryToolkit], + memoryMode: 'read-only' })` is the portable local composition for heartbeat + memory reads. Toolkits and filesystem paths are deliberately not serialized + through `HeartbeatAgentExecutionTransport`; a remote execution process must + compose its own signed, authenticated capability set rather than trusting a + coordinator-selected function or path. - Local interface adapters should use `FileHeartbeatTaskService` methods or the control-plane heartbeat API. Remote operator surfaces should depend on `HeartbeatTaskAdministrationService` and keep backend transaction mechanics diff --git a/src/core/heartbeat/agent/runner-agent.ts b/src/core/heartbeat/agent/runner-agent.ts index d5065b19..073cfa2c 100644 --- a/src/core/heartbeat/agent/runner-agent.ts +++ b/src/core/heartbeat/agent/runner-agent.ts @@ -8,6 +8,7 @@ import { resolve } from 'node:path'; import dayjs from 'dayjs'; import { MemoryCatalogService } from '@/core/memory/catalog.js'; +import { projectMemoryRunResult } from '@/core/memory/run-result.js'; import { AgentLoopCheckpointService, AgentLoopRuntimeService } from '@/core/runtime/loop/index.js'; import type { RunAgentLoopOptions } from '@/core/runtime/loop/index.js'; import { HeartbeatDecisionPolicy } from './decision.js'; @@ -57,6 +58,7 @@ export class HeartbeatRunnerAgent { return { decision, summary: result.summary, + memory: projectMemoryRunResult(result.trace), checkpoint, state: result.state, }; diff --git a/src/core/heartbeat/agent/types.ts b/src/core/heartbeat/agent/types.ts index 4a5676ea..48daf54f 100644 --- a/src/core/heartbeat/agent/types.ts +++ b/src/core/heartbeat/agent/types.ts @@ -1,4 +1,5 @@ import type { AgentLoopCheckpoint, AgentLoopEvent, AgentLoopState, RunAgentLoopOptions } from '@/core/runtime/loop/index.js'; +import type { MemoryRunResult } from '@/core/memory/run-result.js'; import type { StopReason } from '@/core/types.js'; export type HeartbeatDecision = 'continue' | 'pause' | 'complete' | 'escalate'; @@ -43,6 +44,8 @@ export type RunAgentHeartbeatOptions = Omit; + +const DIRECT_MEMORY_MUTATION_TOOLS = new Set(['edit_memory_note']); + +/** + * Projects settled Heddle memory-tool activity into a checkpoint decision. + * Arbitrary host tools and filesystem writes are intentionally outside this + * portable receipt. + */ +export function projectMemoryRunResult(trace: readonly TraceEvent[]): MemoryRunResult { + return { + changed: trace.some((event) => ( + event.type === HeddleEventType.memoryCandidateRecorded + || ( + event.type === HeddleEventType.toolCompleted + && event.result.ok + && DIRECT_MEMORY_MUTATION_TOOLS.has(event.call.tool) + ) + )), + }; +} diff --git a/src/core/memory/tool-mode.ts b/src/core/memory/tool-mode.ts new file mode 100644 index 00000000..3ae46c6d --- /dev/null +++ b/src/core/memory/tool-mode.ts @@ -0,0 +1,10 @@ +export const MEMORY_TOOL_MODES = [ + 'none', + 'read-only', + 'read-and-record', + 'maintainer', + 'legacy-full', +] as const; + +/** Controls which Heddle-managed memory tools are available to one run. */ +export type MemoryToolMode = typeof MEMORY_TOOL_MODES[number]; diff --git a/src/core/runtime/loop/service.ts b/src/core/runtime/loop/service.ts index a756d76b..8b18a981 100644 --- a/src/core/runtime/loop/service.ts +++ b/src/core/runtime/loop/service.ts @@ -281,6 +281,7 @@ export class AgentLoopRuntimeService { stateDir: options.stateDir, stateRoot: this.resolveStateRoot(runtime.workspaceRoot, options.stateDir), memoryDir: options.memoryDir, + memoryMode: options.memoryMode, searchIgnoreDirs: options.searchIgnoreDirs, includePlanTool: options.includePlanTool, includeDefaultTools: options.includeDefaultTools, diff --git a/src/core/runtime/loop/types.ts b/src/core/runtime/loop/types.ts index 74508b47..69882e7a 100644 --- a/src/core/runtime/loop/types.ts +++ b/src/core/runtime/loop/types.ts @@ -19,6 +19,7 @@ import type { ChatMessage, LlmAdapter, LlmProvider, LlmUsage, ReasoningEffort } import type { RunFailure, RunResult, StopReason, ToolCall, ToolDefinition, TraceEvent } from '@/core/types.js'; import type { RuntimeProviderCredential } from '@/core/runtime/credentials/index.js'; import type { AgentModelContextRecovery } from '@/core/agent/index.js'; +import type { MemoryToolMode } from '@/core/memory/tool-mode.js'; import type { ToolToolkit } from '@/core/tools/index.js'; export type AgentLoopStatus = 'finished'; @@ -106,6 +107,8 @@ export type RunAgentLoopOptions = { workspaceRoot?: string; stateDir?: string; memoryDir?: string; + /** Selects the Heddle memory capabilities exposed by composed memory toolkits. */ + memoryMode?: MemoryToolMode; searchIgnoreDirs?: string[]; systemContext?: string; history?: ChatMessage[]; diff --git a/src/core/runtime/tools/profiles/types.ts b/src/core/runtime/tools/profiles/types.ts index 3d42bb1f..08e5a30d 100644 --- a/src/core/runtime/tools/profiles/types.ts +++ b/src/core/runtime/tools/profiles/types.ts @@ -1,3 +1,5 @@ +import type { MemoryToolMode } from '@/core/memory/tool-mode.js'; + export type ToolCapability = | 'agent.delegate' | 'workspace.read' @@ -20,5 +22,5 @@ export type RuntimeToolSelectionProfile = { excludeTools?: string[]; allowedCapabilities?: ToolCapability[]; deniedCapabilities?: ToolCapability[]; - memoryMode?: 'none' | 'read-and-record' | 'maintainer' | 'legacy-full'; + memoryMode?: MemoryToolMode; }; diff --git a/src/core/runtime/tools/service.ts b/src/core/runtime/tools/service.ts index 576f5a81..ec79e8d9 100644 --- a/src/core/runtime/tools/service.ts +++ b/src/core/runtime/tools/service.ts @@ -7,7 +7,7 @@ import { codingAwarenessToolkit } from '@/core/tools/toolkits/coding-awareness/t import { codingFilesToolkit } from '@/core/tools/toolkits/coding-files/toolkit.js'; import { externalContextToolkit } from '@/core/tools/toolkits/external-context/toolkit.js'; import { internalToolkit } from '@/core/tools/toolkits/internal/toolkit.js'; -import { knowledgeToolkit } from '@/core/tools/toolkits/knowledge/toolkit.js'; +import { memoryToolkit } from '@/core/tools/toolkits/knowledge/toolkit.js'; import { mcpToolkit } from '@/core/tools/toolkits/mcp/toolkit.js'; import { shellProcessToolkit } from '@/core/tools/toolkits/shell-process/toolkit.js'; import { ToolBundleComposer, type ToolToolkit } from '@/core/tools/index.js'; @@ -91,7 +91,7 @@ export class RuntimeToolService { codingAwarenessToolkit, codingFilesToolkit, externalContextToolkit, - knowledgeToolkit, + memoryToolkit, mcpToolkit, ...browserToolkits, ...(args.artifactsEnabled ? [artifactsToolkit] : []), diff --git a/src/core/runtime/tools/types.ts b/src/core/runtime/tools/types.ts index f66b2055..5fe4a4e1 100644 --- a/src/core/runtime/tools/types.ts +++ b/src/core/runtime/tools/types.ts @@ -1,4 +1,5 @@ import type { ArtifactRepository } from '@/core/artifacts/index.js'; +import type { MemoryToolMode } from '@/core/memory/tool-mode.js'; import type { ProviderCredentialSource, ResolvedProviderCredential, @@ -21,7 +22,7 @@ export type DefaultAgentToolsOptions = { artifactsEnabled?: boolean; sessionId?: string; memoryDir?: string; - memoryMode?: 'none' | 'read-and-record' | 'maintainer' | 'legacy-full'; + memoryMode?: MemoryToolMode; tools?: ToolDefinition[]; toolkits?: ToolToolkit[]; hiddenMcpServerIds?: string[]; diff --git a/src/core/tools/toolkit.ts b/src/core/tools/toolkit.ts index ee1c819a..9b18381b 100644 --- a/src/core/tools/toolkit.ts +++ b/src/core/tools/toolkit.ts @@ -1,4 +1,5 @@ import type { ToolDefinition } from '../types.js'; +import type { MemoryToolMode } from '../memory/tool-mode.js'; export type ToolToolkitContext = { workspaceRoot: string; @@ -13,7 +14,7 @@ export type ToolToolkitContext = { providerCredentialSource?: import('../runtime/credentials/index.js').ProviderCredentialSource; credentialStorePath?: string; memoryDir: string; - memoryMode: 'none' | 'read-and-record' | 'maintainer' | 'legacy-full'; + memoryMode: MemoryToolMode; searchIgnoreDirs?: string[]; hiddenMcpServerIds?: string[]; }; diff --git a/src/core/tools/toolkits/knowledge/toolkit.ts b/src/core/tools/toolkits/knowledge/toolkit.ts index b6a35935..a0c1a886 100644 --- a/src/core/tools/toolkits/knowledge/toolkit.ts +++ b/src/core/tools/toolkits/knowledge/toolkit.ts @@ -8,7 +8,7 @@ import { createMemoryCheckpointTool } from './memory-checkpoint.js'; import { createRecordKnowledgeTool } from './record-knowledge.js'; import type { ToolToolkit } from '../../toolkit.js'; -export const knowledgeToolkit: ToolToolkit = { +export const memoryToolkit: ToolToolkit = { id: 'knowledge', createTools(context) { if (context.memoryMode === 'none') { @@ -21,6 +21,10 @@ export const knowledgeToolkit: ToolToolkit = { createSearchMemoryNotesTool({ memoryRoot: context.memoryDir }), ]; + if (context.memoryMode === 'read-only') { + return readableKnowledgeTools; + } + if (context.memoryMode === 'read-and-record') { return [ ...readableKnowledgeTools, @@ -37,3 +41,6 @@ export const knowledgeToolkit: ToolToolkit = { throw new Error(`Unsupported memory mode: ${exhaustive}`); }, }; + +// Preserve the internal knowledge-domain name for existing imports. +export const knowledgeToolkit = memoryToolkit; diff --git a/src/index.ts b/src/index.ts index 69cc8660..8772b5e2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -134,6 +134,11 @@ export type { } from './core/chat/engine/host-extension.js'; export { RuntimeToolService } from './core/runtime/tools/index.js'; export type { DefaultAgentToolsOptions } from './core/runtime/tools/index.js'; +export { memoryToolkit } from './core/tools/toolkits/knowledge/toolkit.js'; +export { MemoryRunResultSchema, projectMemoryRunResult } from './core/memory/run-result.js'; +export type { MemoryRunResult } from './core/memory/run-result.js'; +export { MEMORY_TOOL_MODES } from './core/memory/tool-mode.js'; +export type { MemoryToolMode } from './core/memory/tool-mode.js'; export { ToolBundleComposer, ToolExecutionService,