Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/guides/heartbeat.md
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ checkpoint, execution record, and framework events:
```ts
import {
HeartbeatSchedulerService,
memoryToolkit,
type HeartbeatTaskHandler,
} from '@heddleagent/runtime/advanced';

Expand All @@ -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,
});

Expand Down Expand Up @@ -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:

Expand Down
18 changes: 18 additions & 0 deletions docs/guides/programmatic/conversation-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:

Expand Down
52 changes: 52 additions & 0 deletions docs/releases/runtime-v9.0.0.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/runtime/package.json
Original file line number Diff line number Diff line change
@@ -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 <roackb2@gmail.com>",
"license": "MIT",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,7 @@ function createHeartbeatResult(
return {
decision: 'continue',
summary,
memory: { changed: false },
checkpoint: {
version: 1,
runId,
Expand Down
110 changes: 110 additions & 0 deletions src/__tests__/integration/core/agent-loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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',
Expand Down Expand Up @@ -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<LlmResponse> {
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<LlmResponse> {
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 = {
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ function createHeartbeatResult(taskId: string): AgentHeartbeatResult {
return {
decision: 'continue',
summary,
memory: { changed: false },
state,
checkpoint: AgentLoopCheckpointService.createCheckpoint(state, {
createdAt: NOW.toISOString(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
DEFAULT_HEARTBEAT_HANDLER_RETRY_MS,
HeartbeatRunnerAgent,
HeartbeatSchedulerService,
memoryToolkit,
type AgentHeartbeatResult,
type HeartbeatExecutionContext,
type HeartbeatAgentExecutionTransport,
Expand Down Expand Up @@ -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,
});
},
Expand All @@ -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,
Expand Down Expand Up @@ -822,6 +827,7 @@ function createHeartbeatResult(
return {
decision,
summary,
memory: { changed: false },
state,
checkpoint: AgentLoopCheckpointService.createCheckpoint(state, {
createdAt: state.finishedAt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ function createHeartbeatResult(runId: string): AgentHeartbeatResult {
return {
decision: 'continue',
summary,
memory: { changed: false },
state,
checkpoint: AgentLoopCheckpointService.createCheckpoint(state, {
createdAt: NOW.toISOString(),
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/integration/core/heartbeat-scheduler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ function createHeartbeatResult(runId: string): AgentHeartbeatResult {
return {
decision: 'continue',
summary,
memory: { changed: false },
state,
checkpoint: AgentLoopCheckpointService.createCheckpoint(state, {
createdAt: NOW.toISOString(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
};
Expand Down
1 change: 1 addition & 0 deletions src/__tests__/unit/core/heartbeat-lucid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ function createHeartbeatResult(): AgentHeartbeatResult {
return {
decision: 'continue',
summary: 'Repository check complete.',
memory: { changed: false },
checkpoint: {
version: 1,
runId: 'run_1',
Expand Down
Loading
Loading