From 9d01aa5aa7cbf66ec311f3a93f9ae3deffd966b0 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Thu, 8 Oct 2026 01:35:50 -0300 Subject: [PATCH 01/10] fix(core): preserve ordered durable tool decisions --- .../core/src/controller-decision-internal.ts | 24 ++- packages/core/src/controller-helpers.ts | 27 ++- packages/core/src/controller-tool-call.ts | 5 +- packages/core/src/controller.ts | 24 ++- .../controller-decision-internal.test.ts | 170 +++++++++++++++++- 5 files changed, 211 insertions(+), 39 deletions(-) diff --git a/packages/core/src/controller-decision-internal.ts b/packages/core/src/controller-decision-internal.ts index b179b6b73..34925be61 100644 --- a/packages/core/src/controller-decision-internal.ts +++ b/packages/core/src/controller-decision-internal.ts @@ -5,7 +5,7 @@ import type { AgentEventContext, Message, ToolCall, ToolDefinition, ToolDecision interface DecisionContext { store: ToolDecisionStore | undefined load: (snapshot: Message[]) => Promise - prepare: (messages: Message[], reconciled: Map) => Promise<{ generation: number; correlation: AgentEventContext }> + prepare: (messages: Message[], reconciled: Map) => Promise<{ generation: number; correlation: AgentEventContext } | undefined> tool: (name: string) => ToolDefinition | undefined runTool: (tool: ToolDefinition | undefined, call: ToolCall, onPartial: (result: string) => void, generation: number, correlation: AgentEventContext) => Promise patch: (assistantId: string, toolCallId: string, patch: Partial) => void @@ -30,7 +30,10 @@ export async function decide( const pending = await store.get(id) if (!pending) throw new ToolError({ code: ErrorCodes.AK_ACTION_NOT_FOUND, message: 'Tool decision not found' }) if (pending.status !== 'pending') { - if (pending.decision === decision && pending.outcome && ['complete', 'failed', 'denied'].includes(pending.status)) return pending.outcome + if (pending.decision === decision) { + const replay = pending.outcome ?? pending.messages.flatMap(message => message.toolCalls ?? []).find(call => call.id === id) + if (replay) return replay + } throw new ToolError({ code: ErrorCodes.AK_ACTION_ALREADY_DECIDED, message: 'Tool decision already claimed or decided' }) } const messages = await context.load(pending.messages) @@ -56,19 +59,23 @@ export async function decide( if (!record) { const existing = await store.get(id) if (!existing) throw new ToolError({ code: ErrorCodes.AK_ACTION_NOT_FOUND, message: 'Tool decision not found' }) - if (existing.decision === decision && existing.outcome && ['complete', 'failed', 'denied'].includes(existing.status)) return existing.outcome + if (existing.decision === decision) { + const replay = existing.outcome ?? existing.messages.flatMap(message => message.toolCalls ?? []).find(call => call.id === id) + if (replay) return replay + } throw new ToolError({ code: ErrorCodes.AK_ACTION_ALREADY_DECIDED, message: 'Tool decision already claimed or decided' }) } if (record.toolCallId !== id || record.status !== 'claimed' || record.decision !== decision || !Array.isArray(record.messages)) { throw new ConfigError({ code: ErrorCodes.AK_CONFIG_INVALID, message: 'Decision store returned an invalid claim' }) } - const { generation, correlation } = await context.prepare(messages, reconciled) + const prepared = await context.prepare(messages, reconciled) const current = context.messages().find(m => m.id === message.id)?.toolCalls?.find(c => c.id === id) - if (current?.status !== 'requires_confirmation') { + if (!prepared || current?.status !== 'requires_confirmation') { const error = new ToolError({ code: ErrorCodes.AK_ACTION_ALREADY_DECIDED, message: 'Tool decision is no longer pending' }) await store.settle({ ...record, status: 'failed', outcome: { ...call, status: 'error', error: error.message }, messages: context.messages() }) throw error } + const { generation, correlation } = prepared call = current let outcome: ToolCall if (decision === 'deny') { @@ -94,6 +101,11 @@ export async function decide( messages: context.messages(), }) await context.persist(correlation) - if (context.isCurrent(generation)) await context.resume(message.id, generation, correlation) + const latest = context.messages() + const last = latest[latest.length - 1] + if (context.isCurrent(generation) && last?.id === message.id && last?.status !== 'streaming' + && !latest.some(message => message.toolCalls?.some(call => call.status !== 'complete' && call.status !== 'error'))) { + await context.resume(message.id, generation, correlation) + } return outcome } diff --git a/packages/core/src/controller-helpers.ts b/packages/core/src/controller-helpers.ts index 7e557efb0..c5d126076 100644 --- a/packages/core/src/controller-helpers.ts +++ b/packages/core/src/controller-helpers.ts @@ -82,20 +82,16 @@ export function buildToolContinuation( calls: ToolCall[], buildMsg: (init: { role: Message['role']; content: string; toolCallId?: string; status?: Message['status'] }) => Message, ): { messages: Message[]; nextAssistantId: string } { - const results = calls.map(call => - buildMsg({ - role: 'tool', - content: call.result ?? call.error ?? '', - toolCallId: call.id, - }), - ) const nextA = buildMsg({ role: 'assistant', content: '', status: 'streaming' }) return { messages: [ - ...messages.map(message => - message.id === assistantId ? { ...message, status: 'complete' as const } : message - ), - ...results, + ...messages.flatMap(message => { + const settled = message.id === assistantId ? calls : message.toolCalls ?? [] + if (!settled.length || settled.some(call => call.status !== 'complete' && call.status !== 'error')) return [message] + const results = settled.filter(call => !messages.some(result => result.role === 'tool' && result.toolCallId === call.id)) + .map(call => buildMsg({ role: 'tool', content: call.result ?? call.error ?? '', toolCallId: call.id })) + return [{ ...message, status: 'complete' as const }, ...results] + }), nextA, ], nextAssistantId: nextA.id, @@ -227,13 +223,14 @@ export function createControllerToolLoop({ getConfig, getState, getCorrelation, for (let remaining = getConfig().maxToolIterations ?? 5; remaining > 0; remaining--) { const assistant = getState().messages.find(message => message.id === id) const calls = assistant?.toolCalls ?? [] - const waits = calls.some(call => call.status !== 'complete' && call.status !== 'error') + const waits = getState().messages.some(message => message.toolCalls?.some(call => call.status !== 'complete' && call.status !== 'error')) // Nothing to feed back, or something still awaiting confirmation — // stop here; the caller drives the next step. - if (!calls.length || waits) { - if (waits) await persistPending() - await finalize(id, !waits) + if (!calls.length || waits || getState().messages[getState().messages.length - 1]?.id !== id) { + const pending = calls.some(call => call.status !== 'complete' && call.status !== 'error') + await finalize(id, !pending) + if (pending) await persistPending() return } diff --git a/packages/core/src/controller-tool-call.ts b/packages/core/src/controller-tool-call.ts index 256f02ea9..4096fb49d 100644 --- a/packages/core/src/controller-tool-call.ts +++ b/packages/core/src/controller-tool-call.ts @@ -28,7 +28,7 @@ interface ToolCallContext { patchCall: PatchCall runTool: RunTool correlation: AgentEventContext - registerToolCall: (id: string) => void + registerToolCall: (id: string) => void | Promise } export async function handleControllerToolCall({ @@ -76,9 +76,8 @@ export async function handleControllerToolCall({ await auth(authorize, toolCall, { messages, tool, phase: 'propose' }) if (!isCurrentGeneration()) return - if (tool?.requiresConfirmation) registerToolCall(toolCall.id) - setMessage(assistantId, current => ({ ...current, toolCalls: [...(current.toolCalls ?? []), toolCall] })) + if (tool?.requiresConfirmation) await registerToolCall(toolCall.id) await onToolCall?.(toolCall, { messages, tool }) if (!isCurrentGeneration()) return diff --git a/packages/core/src/controller.ts b/packages/core/src/controller.ts index 19737507c..143d369a1 100644 --- a/packages/core/src/controller.ts +++ b/packages/core/src/controller.ts @@ -1,5 +1,6 @@ import { buildMessage as message, consumeStream, createEventEmitter, generateId, createToolLifecycle } from './primitives' import { buildToolMap, activateSkills, executeSafeTool as execute } from './agent-loop' +import { ConfigError, ErrorCodes } from './errors' import { partsToText } from './types/content' import { createControllerPersistence } from './controller-persistence' import { handleControllerToolCall } from './controller-tool-call' @@ -49,6 +50,7 @@ import type { let lifecycle = createToolLifecycle(toolMap) let skillTools: ToolDefinition[] = [] const approvalGenerations = new Map() + let pendingRegistration = Promise.resolve() let hydrated = false let active = false const authorize: NonNullable = async (call, context) => { @@ -206,7 +208,11 @@ import type { patchCall, runTool, correlation, - registerToolCall: id => approvalGenerations.set(id, g), + registerToolCall: id => { + approvalGenerations.set(id, g) + pendingRegistration = persistPending() + return pendingRegistration + }, }) }, onToolResult(content) { @@ -432,15 +438,20 @@ import type { }) }, async decide(tid, decision, reason) { + const { decide } = await import('./controller-decision-internal.js') + await pendingRegistration let loadedLive = false let loadedGeneration = gen - return import('./controller-decision-internal.js').then(m => m.decide(tid, decision, reason, { + return decide(tid, decision, reason, { store: config.decisionStore, messages: () => state.messages, load: async snapshot => { await activate() loadedGeneration = gen loadedLive = state.messages.length > 0 const messages = loadedLive ? state.messages : await persistence.load() + if (!messages.length && snapshot.some(message => message.status === 'streaming')) { + throw new ConfigError({ code: ErrorCodes.AK_CONFIG_INVALID, message: 'Resuming a streamed tool decision requires current ChatMemory' }) + } return (messages.length ? messages : snapshot).map(message => ({ ...message, toolCalls: message.toolCalls?.map(call => ({ ...call })) })) }, tool: name => toolMap.get(name), runTool, patch: patchCall, @@ -448,16 +459,17 @@ import type { persist: correlation => persist(state.messages, correlation, true), resume, prepare: async (messages, reconciled) => { const useLive = loadedLive || state.messages.length > 0 || gen !== loadedGeneration - controller.stop() + const currentMessages = useLive ? state.messages : messages + if (!currentMessages.some(message => message.toolCalls?.some(call => call.id === tid && call.status === 'requires_confirmation'))) return undefined set(current => ({ ...current, messages: (useLive ? current.messages : messages).map(message => { - return { ...message, status: message.status === 'streaming' ? 'complete' as const : message.status, toolCalls: message.toolCalls?.map(call => { + return { ...message, toolCalls: message.toolCalls?.map(call => { const outcome = reconciled.get(message.id)?.find(loaded => loaded.id === call.id) return call.id !== tid && call.status === 'requires_confirmation' && outcome ? outcome : call }) } }), error: null })) - return { generation: gen, correlation: beginRun() } + return { generation: gen, correlation: activeCorrelation } }, - })) + }) }, async approve(tid) { if (config.decisionStore) { diff --git a/packages/core/tests/controller-decision-internal.test.ts b/packages/core/tests/controller-decision-internal.test.ts index 09024f552..cbec76c0f 100644 --- a/packages/core/tests/controller-decision-internal.test.ts +++ b/packages/core/tests/controller-decision-internal.test.ts @@ -59,6 +59,143 @@ describe('durable controller decisions', () => { if (memory) expect(await memory.load()).toEqual(messages) }) + it('does not resume an older call while a later confirmation waits', async () => { + const { chat, requests } = fixture() + await chat.proposeToolCall(proposal) + await chat.send('later') + await chat.proposeToolCall({ ...proposal, id: 'later-call' }) + const before = requests.length + await chat.decide(proposal.id, 'approve') + expect(requests).toHaveLength(before) + expect(chat.getState().messages.at(-1)?.toolCalls?.[0].status).toBe('requires_confirmation') + await chat.decide('later-call', 'deny') + expect(requests).toHaveLength(before + 1) + const messages = requests.at(-1)!.messages + for (let index = 0; index < messages.length; index++) { + const calls = messages[index].toolCalls ?? [] + expect(messages.slice(index + 1, index + 1 + calls.length).map(message => message.toolCallId)).toEqual(calls.map(call => call.id)) + } + }) + + it.each(['approve', 'deny'] as const)('registers streamed calls before %s and accepts identical double clicks', async action => { + const { config, execute, requests } = fixture() + let release = () => {} + const gate = new Promise(resolve => { release = resolve }) + let arrived = false + const chat = createChatController({ ...config, adapter: { + createSource(request) { + if (request.messages.some(message => message.role === 'tool')) return config.adapter.createSource(request) + return { + async *stream() { + yield { type: 'tool_call' as const, toolCall: { ...proposal, args: '{}' } } + arrived = true + await gate + yield { type: 'tool_call' as const, toolCall: { ...proposal, id: 'call-2', args: '{}' } } + yield { type: 'done' as const } + }, + abort: vi.fn(release), + } + }, + }, onToolCall: async call => { await Promise.all([chat[action](call.id), chat[action](call.id)]) } }) + const sending = chat.send('write twice') + try { + await vi.waitUntil(() => arrived) + expect(requests).toHaveLength(0) + expect((await config.decisionStore!.get(proposal.id))?.decision).toBe(action) + } finally { release() } + await sending + expect(requests).toHaveLength(1) + expect(requests[0].messages.filter(message => message.role === 'tool').map(message => message.toolCallId)).toEqual([proposal.id, 'call-2']) + expect(execute).toHaveBeenCalledTimes(action === 'approve' ? 2 : 0) + }) + + it('does not abort a live stream when the claimed target is no longer pending', async () => { + const { chat, config, execute } = fixture() + await chat.proposeToolCall(proposal) + let releaseClaim = () => {} + let claimed = false + const claimGate = new Promise(resolve => { releaseClaim = resolve }) + const claim = config.decisionStore!.claim.bind(config.decisionStore) + config.decisionStore!.claim = async (...args) => { const record = await claim(...args); claimed = true; await claimGate; return record } + const deciding = chat.decide(proposal.id, 'approve') + const rejected = expect(deciding).rejects.toMatchObject({ code: 'AK_ACTION_ALREADY_DECIDED' }) + await vi.waitUntil(() => claimed) + let releaseStream = () => {} + const gate = new Promise(resolve => { releaseStream = resolve }) + const abort = vi.fn(releaseStream) + chat.updateConfig({ adapter: { createSource: () => ({ + async *stream() { yield { type: 'text' as const, content: 'early' }; await gate; yield { type: 'text' as const, content: ' late' }; yield { type: 'done' as const } }, + abort, + }) } }) + const sending = chat.send('later') + await vi.waitUntil(() => chat.getState().messages.at(-1)?.content === 'early') + chat.setMessages(chat.getState().messages.slice(1)) + releaseClaim() + await rejected + expect(abort).not.toHaveBeenCalled() + expect(chat.getState().status).toBe('streaming') + releaseStream() + await sending + expect(chat.getState().messages.at(-1)?.content).toBe('early late') + expect(execute).not.toHaveBeenCalled() + }) + + it('orders mixed decisions made in reverse order and avoids duplicate results', async () => { + const { config, requests, execute } = fixture() + const chat = createChatController({ ...config, adapter: { + createSource(request) { + if (request.messages.some(message => message.role === 'tool')) return config.adapter.createSource(request) + return createMockAdapter([ + { type: 'tool_call', toolCall: { ...proposal, args: '{}' } }, + { type: 'tool_call', toolCall: { ...proposal, id: 'call-2', args: '{}' } }, { type: 'done' }, + ]).createSource(request) + }, + } }) + await chat.send('write twice') + await chat.decide('call-2', 'approve') + expect(requests).toHaveLength(0) + await chat.decide(proposal.id, 'deny', 'cancelled') + expect(requests).toHaveLength(1) + const messages = requests[0].messages + const index = messages.findIndex(message => message.toolCalls?.length === 2) + expect(messages.slice(index + 1, index + 3).map(message => [message.toolCallId, message.content])).toEqual([ + [proposal.id, 'Permission denied: cancelled'], ['call-2', 'stored'], + ]) + await chat.send('next') + const tools = requests.at(-1)!.messages.filter(message => message.role === 'tool') + expect(tools.map(message => message.toolCallId)).toEqual([proposal.id, 'call-2']) + expect(execute).toHaveBeenCalledTimes(1) + }) + + it('allows subscriber approval while arrival persistence is still saving', async () => { + const memory = createInMemoryMemory() + const save = memory.save.bind(memory) + let release = () => {} + const gate = new Promise(resolve => { release = resolve }) + memory.save = async messages => { await gate; await save(messages) } + const { config, requests, execute } = fixture({ memory }) + const chat = createChatController({ ...config, adapter: { + createSource(request) { + return request.messages.some(message => message.role === 'tool') + ? config.adapter.createSource(request) + : createMockAdapter([{ type: 'tool_call', toolCall: { ...proposal, args: '{}' } }, { type: 'done' }]).createSource(request) + }, + } }) + let approving: Promise | undefined + const unsubscribe = chat.subscribe(() => { + if (!approving && chat.getState().messages.some(message => message.toolCalls?.some(call => call.id === proposal.id))) { + approving = chat.approve(proposal.id) + } + }) + const sending = chat.send('write') + await vi.waitUntil(() => approving !== undefined) + release() + await Promise.all([sending, approving]) + unsubscribe() + expect(execute).toHaveBeenCalledTimes(1) + expect(requests).toHaveLength(1) + }) + it('detects a non-atomic read/await/write claim', async () => { const port = store(new Map(), false) await port.putPending({ toolCallId: 'id', messages: [], status: 'pending' }) @@ -108,11 +245,13 @@ describe('durable controller decisions', () => { await vi.waitUntil(() => started) const before = copy(chat.getState().messages) await chat.decide(proposal.id, 'approve') + expect(chat.getState().status).toBe('streaming') + release() await sending const messages = chat.getState().messages expect(messages.slice(0, before.length).map(message => [message.id, message.content])).toEqual(before.map(message => [message.id, message.content])) expect(messages[0].toolCalls?.[0]).toMatchObject({ status: 'complete', result: 'stored' }) - expect(messages.at(-1)?.content).toBe('Finished') + expect(messages.at(-1)?.content).toBe('partial response') expect(await memory.load()).toEqual(messages) expect(execute).toHaveBeenCalledTimes(1) }) @@ -219,7 +358,8 @@ describe('durable controller decisions', () => { } const deciding = chat.decide(proposal.id, 'approve') await vi.waitUntil(() => held) - await expect(chat[action](proposal.id)).rejects.toMatchObject({ code: 'AK_ACTION_ALREADY_DECIDED' }) + if (action === 'approve') await expect(chat[action](proposal.id)).resolves.toBeUndefined() + else await expect(chat[action](proposal.id)).rejects.toMatchObject({ code: 'AK_ACTION_ALREADY_DECIDED' }) expect(execute).not.toHaveBeenCalled() release() await deciding @@ -287,14 +427,13 @@ describe('durable controller decisions', () => { expect(chat.getState().messages.flatMap(message => message.toolCalls ?? []).find(call => call.id === 'sibling')).toMatchObject({ status: 'complete', result: 'external' }) }) - it('admits one of 100 parallel decisions across independent controllers, repeated 100 times', async () => { + it('executes once for 100 identical parallel decisions across independent controllers, repeated 100 times', async () => { for (let round = 0; round < 100; round++) { const { config, chat, execute } = fixture() const id = `parallel-${round}` await chat.proposeToolCall({ ...proposal, id }) const results = await Promise.allSettled(Array.from({ length: 100 }, () => createChatController(config).decide(id, 'approve'))) - expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1) - for (const result of results) if (result.status === 'rejected') expect(result.reason.code).toBe('AK_ACTION_ALREADY_DECIDED') + expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(100) expect(execute).toHaveBeenCalledTimes(1) } }) @@ -328,7 +467,7 @@ describe('durable controller decisions', () => { const { config, chat, execute } = fixture() await chat.proposeToolCall(proposal) await config.decisionStore!.claim(proposal.id, 'approve') - await expect(createChatController(config).decide(proposal.id, 'approve')).rejects.toMatchObject({ code: 'AK_ACTION_ALREADY_DECIDED' }) + await expect(createChatController(config).decide(proposal.id, 'approve')).resolves.toMatchObject({ id: proposal.id }) expect(execute).not.toHaveBeenCalled() }) @@ -351,7 +490,7 @@ describe('durable controller decisions', () => { }) it('persists model-produced pending calls and resumes them in a new controller', async () => { - const { config, execute } = fixture() + const { config, execute } = fixture({ memory: createInMemoryMemory() }) const chat = createChatController({ ...config, adapter: createMockAdapter([ { type: 'tool_call', toolCall: { ...proposal, args: JSON.stringify(proposal.args) } }, { type: 'done' }, ]) }) @@ -362,7 +501,7 @@ describe('durable controller decisions', () => { }) it('resumes a multi-call assistant only after all persisted confirmations settle', async () => { - const { config, requests, execute } = fixture() + const { config, requests, execute } = fixture({ memory: createInMemoryMemory() }) const chat = createChatController({ ...config, adapter: createMockAdapter([ { type: 'tool_call', toolCall: { ...proposal, args: '{}' } }, { type: 'tool_call', toolCall: { ...proposal, id: 'call-2', args: '{}' } }, { type: 'done' }, @@ -376,6 +515,19 @@ describe('durable controller decisions', () => { expect(requests[0].messages.filter(m => m.role === 'tool')).toHaveLength(2) }) + it('rejects a streamed snapshot after restart without memory before claiming', async () => { + const { config, execute, requests } = fixture() + const chat = createChatController({ ...config, adapter: createMockAdapter([ + { type: 'tool_call', toolCall: { ...proposal, args: '{}' } }, + { type: 'tool_call', toolCall: { ...proposal, id: 'call-2', args: '{}' } }, { type: 'done' }, + ]) }) + await chat.send('write twice') + await expect(createChatController(config).decide(proposal.id, 'approve')).rejects.toMatchObject({ code: 'AK_CONFIG_INVALID' }) + expect((await config.decisionStore!.get(proposal.id))?.status).toBe('pending') + expect(execute).not.toHaveBeenCalled() + expect(requests).toHaveLength(0) + }) + it('fails closed on an invalid external claim', async () => { const decisionStore = store() decisionStore.claim = async () => ({ toolCallId: 'wrong', status: 'claimed', decision: 'approve', messages: [] }) @@ -398,7 +550,7 @@ describe('durable controller decisions', () => { const { chat, config, execute, requests } = fixture({ decisionStore }) await chat.proposeToolCall(proposal) await expect(chat.decide(proposal.id, 'approve')).rejects.toThrow('terminal write failed') - await expect(createChatController(config).decide(proposal.id, 'approve')).rejects.toMatchObject({ code: 'AK_ACTION_ALREADY_DECIDED' }) + await expect(createChatController(config).decide(proposal.id, 'approve')).resolves.toMatchObject({ id: proposal.id }) expect(execute).toHaveBeenCalledTimes(1) expect(requests).toHaveLength(0) }) From b7c2ce0bf6466a52c498006c344d1118b6904610 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Thu, 8 Oct 2026 01:36:55 -0300 Subject: [PATCH 02/10] docs(core): clarify streamed decision recovery --- .changeset/fair-tools-resume.md | 7 + .doc-bridge/capabilities.json | 2 +- .doc-bridge/index.json | 66 +++++---- .../content/docs/for-agents/core.mdx | 3 +- .../content/docs/reference/packages/core.mdx | 6 +- .../adrs/0042-durable-tool-decisions.md | 2 +- packages/core/README.md | 6 +- readme-standard-v1.json | 130 +++++++++--------- 8 files changed, 122 insertions(+), 100 deletions(-) create mode 100644 .changeset/fair-tools-resume.md diff --git a/.changeset/fair-tools-resume.md b/.changeset/fair-tools-resume.md new file mode 100644 index 000000000..96919f30a --- /dev/null +++ b/.changeset/fair-tools-resume.md @@ -0,0 +1,7 @@ +--- +"@agentskit/core": patch +--- + +Register streamed confirmations on arrival, treat identical in-flight decisions as no-ops, and preserve active streams when a decision is rejected. Resume only the latest completed assistant turn after every confirmation is decided, with results immediately following their originating assistant in call order. + +Restarting streamed decisions requires current ChatMemory because immutable arrival snapshots may omit subsequent calls. Without current conversation memory, an incomplete streamed snapshot raises AK_CONFIG_INVALID before claiming or executing. diff --git a/.doc-bridge/capabilities.json b/.doc-bridge/capabilities.json index f266cae74..6e1195a8d 100644 --- a/.doc-bridge/capabilities.json +++ b/.doc-bridge/capabilities.json @@ -7,7 +7,7 @@ "name": "AgentsKit", "root": "." }, - "contentHash": "0dbf922bfae3b8b64f1654a926fa23e3678c44c08a9510aade00d8e237918fa8", + "contentHash": "35c79ad55d43f71d59185f27bd8ffb9aecbab76aebf983fa1fedd568de3a7bc6", "artifacts": { "index": ".doc-bridge/index.json", "llmsTxt": ".doc-bridge/llms.txt" diff --git a/.doc-bridge/index.json b/.doc-bridge/index.json index 18a70927e..de4be87e9 100644 --- a/.doc-bridge/index.json +++ b/.doc-bridge/index.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "contentHash": "0dbf922bfae3b8b64f1654a926fa23e3678c44c08a9510aade00d8e237918fa8", + "contentHash": "35c79ad55d43f71d59185f27bd8ffb9aecbab76aebf983fa1fedd568de3a7bc6", "contentHashAlgo": "sha256-normalized-v1", - "generatedAt": "2026-10-08T03:22:15.493Z", + "generatedAt": "2026-10-08T04:35:55.883Z", "project": { "name": "AgentsKit", "root": "." @@ -3723,7 +3723,7 @@ "document", "human" ], - "contentHash": "a45c2e7c4960a50d190312d9eabc737f8973aa7d2b1720270a630578d0880423" + "contentHash": "5143366b0bed4e6cccd66a3a37209d9501462252c465da104236cf7e799615e8" }, { "id": "document:apps/docs-next/content/docs/reference/packages/cross-platform.mdx", @@ -6050,7 +6050,7 @@ "document", "human" ], - "contentHash": "6e1205db68cf23787d54c5b5e30edc45263ebebb31bf12381caf66080304a13a" + "contentHash": "542f2d33984ae4c31a163e5749abdfc65dd8c9153b89d63db1184f29d5a711c3" }, { "id": "document:docs/architecture/adrs/0043-partition-bound-rag.md", @@ -6998,7 +6998,7 @@ "document", "project" ], - "contentHash": "41c9985b7138e1f2880ca675c7dbecdcf8a154c2b8d836af116d65d98e14ba22" + "contentHash": "1f09c88450c32e1aaea4e0211bad1d2a12763d713b54728af44541a5b785f51d" }, { "id": "document:packages/cross-platform/CONVENTIONS.md", @@ -18852,7 +18852,7 @@ "ts", "src" ], - "contentHash": "69d0754cd15289a22b914a9f518de47c2dd0987a21dada4c5575b0aa2fed8d6b" + "contentHash": "0e23242a7928abc906eeab61ab7501f08ccb6570ed10d9ce0aeb35165c6cab7a" }, { "id": "module:packages/core/src/controller-helpers.ts", @@ -18876,7 +18876,7 @@ "ts", "src" ], - "contentHash": "41bd7d1aa578b0ebc774eb7038ede6ef6d74662e0f627690f4e4fb27143dd90d" + "contentHash": "527da671e9992b6c8ab4a806839c6f65ea6c9bb7325665593355970f38c3cb1a" }, { "id": "module:packages/core/src/controller-persistence.ts", @@ -18907,7 +18907,7 @@ "ts", "src" ], - "contentHash": "fa49e4c60cfaa2f824d943fca30c4d98f8bdb490a3accd7f5234ffc0e1478335" + "contentHash": "7cc1474de6d6faea4c5b17bd28e883ad0785c1e525988168f0cba60c04a2026c" }, { "id": "module:packages/core/src/controller.ts", @@ -18922,7 +18922,7 @@ "ts", "src" ], - "contentHash": "bfaa4fdc1be8b3536dcc1df4d1b5a8d4259f88b8ef7a3a15efa342e30cdaa6b8" + "contentHash": "c3fe03dc98fe18abf54e2a30962d5e98d042ad2f481df538f0cc54665d0483e8" }, { "id": "module:packages/core/src/errors.ts", @@ -20238,7 +20238,7 @@ "test", "tests" ], - "contentHash": "5682144bdbb19e39ccde70a760bf83f57899833a8e91a811d8da41d33cb51991" + "contentHash": "9edddfa706c90c09c5cff680d71bd133ed128ba89df76afe2e032cf2b206eb59" }, { "id": "module:packages/core/tests/controller-edit-regenerate.test.ts", @@ -38197,7 +38197,7 @@ "changes": {} }, "inputs": { - "hash": "54915d85f7a308fa5ec1af4cf890ad9bed63ec04e569a06772a865a7b0676bca", + "hash": "1dc585b9db385e5e23ed4091773700efc6faefe0bafad23abd15a6823db6bdc6", "fileCount": 2539, "projectionVersion": 2 }, @@ -38220,9 +38220,9 @@ "projection": { "type": "retrieval-index", "schemaVersion": 1, - "contentHash": "997a1701967a593a3431a61ddb7c105abf17552fcf32c48a76697493fd9c31a4", + "contentHash": "5a0ef1c33e60a7cb486a6853d2a543963a57547a7f56deb7f78bd3d5f2e286e7", "contentHashAlgo": "sha256-normalized-v1", - "snapshotHash": "15107afcf7657b21ca4445c206e72b993c7d0e05d80fd3ab83476518d31ce853", + "snapshotHash": "06cd3f95ab47c5344f735ee9e75e424d38e065ce836d5f01dd76e684a90c0424", "overlayHash": "f99666ff9119084dd25c2974ae64eb6935ec2aa785ff5ad478fb9d541f7a5e94", "configurationHash": "9662cc07ea4ab203ff2ddf457d2db48b5375a7ab4a1f8fbc79dfebcaa8f9a00c", "lexiconVersion": 1, @@ -38254,7 +38254,7 @@ ], "averageFieldLength": { "aliases": 3.0144346431435447, - "body": 61.41539695268645, + "body": 61.388933440256615, "headings": 4.019647153167602, "path": 7.613472333600641, "summary": 4.5509222133119485, @@ -50999,7 +50999,7 @@ "path": "apps/docs-next/content/docs/for-agents/core.mdx", "symbols": "", "summary": "Stable TypeScript contracts (Adapter, Tool, Memory, Retriever, Skill, Runtime) + `createChatController` + primitives. Target: <10 KB gzipped, zero runtime deps.", - "body": "Purpose Stable TypeScript contracts (Adapter, Tool, Memory, Retriever, Skill, Runtime) + createChatController + primitives. Target: <10 KB gzipped, zero runtime deps. Install Primary exports (main entry) - createChatController(config) — state machine behind every UI - proposeToolCall(proposal) — controller method that places a trusted, validated action into the same confirmation lifecycle; it never executes directly. The standalone helper is available from @agentskit/core/tool-proposal . Returns { getState, subscribe, send, stop, retry, edit, regenerate, setInput, clear, decide, approve, deny, updateConfig } . - defineTool(config) — typed tool factory with JSON-Schema input inference. - controller.send(string ContentPart[]) preserves multimodal parts and text projection. - controller.decide(id, 'approve' 'deny', reason?) uses ChatConfig.decisionStore to validate the current conversation before claiming, prioritizing live controller state over memory and preserving unsaved later turns and using the pending snapshot only as a fallback ( ToolDecisionStore , ToolDecisionRecord ). The store provides insert-if-absent putPending , linearizable get , atomic pending-to-claimed claim , and matching-claim terminal settle . Scope it to an application-authorized conversation. Same-decision terminal replay returns the recorded tool outcome; unknown IDs raise AK ACTION NOT FOUND , competing or in-flight decisions raise AK ACTION ALREADY DECIDED . Failed and claimed records are never automatically retried. Serialize different decisions per conversation to protect transcript writes. - approve / deny are deprecated. With a decision store they delegate to decide , including atomic claims and typed errors; without a store they preserve generation-local behavior. - ChatConfig.authorizeToolCall — trusted host authorization before tool proposal and again before execution. Callback failures default to deny. - createInMemoryMemory / createLocalStorageMemory — default ChatMemory implementations. - createStaticRetriever / formatRetrievedDocuments — RAG-less retrieval using token substring overlap; use @agentskit/rag for semantic production retrieval. - executeToolCall , consumeStream , createEventEmitter , parseToolArgs , safeParseArgs , isRecord , createId , generateId , buildMessage — low-level helpers. parseToolArgs returns { args, valid } so adapters can fail closed; safeParseArgs remains the compatibility value-only parser. IDs use crypto.randomUUID() and retain the caller-provided prefix. - isRecord(value) accepts non-null objects other than arrays, including Date , Map , and class instances; use a schema validator when plain JSON objects are required. - AgentsKitError , AdapterError , ToolError , MemoryError , ConfigError , RuntimeError , SandboxError , SkillError , ErrorCodes — error taxonomy. - Token budget: compileBudget , approximateCounter . - Progressive tool args: createProgressiveArgParser , executeToolProgressively . - createVirtualizedMemory — hot-window + cold-retriever wrapper (also re-exported from @agentskit/memory ). - Untrusted-input fencing lives in the @agentskit/core/security entry: fenceUntrustedContent(content, opts?) + UNTRUSTED CONTENT DIRECTIVE wrap attacker-influenced text so the model treats it as data, not instructions (the mitigation companion to createInjectionDetector ). - Multi-modal content parts: textPart , imagePart , audioPart , videoPart , filePart , partsToText , normalizeContent , filterParts . - Agent-loop internals (advanced): buildToolMap , activateSkills , executeSafeTool , createToolLifecycle . - Memory serialization: serializeMessages , deserializeMessages ; validate untrusted records with validateMemoryRecord from @agentskit/core/memory-validation . Subpath exports (zero main-bundle weight) Subpath Purpose Recipe --- --- --- @agentskit/core/agent-schema Declarative agent YAML/JSON + validator @agentskit/core/prompt-experiments A/B prompts with feature flags @agentskit/core/auto-summarize Auto-summar", + "body": "Purpose Stable TypeScript contracts (Adapter, Tool, Memory, Retriever, Skill, Runtime) + createChatController + primitives. Target: <10 KB gzipped, zero runtime deps. Install Primary exports (main entry) - createChatController(config) — state machine behind every UI - proposeToolCall(proposal) — controller method that places a trusted, validated action into the same confirmation lifecycle; it never executes directly. The standalone helper is available from @agentskit/core/tool-proposal . Returns { getState, subscribe, send, stop, retry, edit, regenerate, setInput, clear, decide, approve, deny, updateConfig } . - defineTool(config) — typed tool factory with JSON-Schema input inference. - controller.send(string ContentPart[]) preserves multimodal parts and text projection. - controller.decide(id, 'approve' 'deny', reason?) uses ChatConfig.decisionStore to validate the current conversation before claiming, prioritizing live controller state over memory and preserving unsaved later turns and using the pending snapshot only as a fallback ( ToolDecisionStore , ToolDecisionRecord ). The store provides insert-if-absent putPending , linearizable get , atomic pending-to-claimed claim , and matching-claim terminal settle . Scope it to an application-authorized conversation. Same-decision terminal replay returns the recorded tool outcome; unknown IDs raise AK ACTION NOT FOUND , conflicting decisions raise AK ACTION ALREADY DECIDED . Failed and claimed records are never automatically retried. Serialize different decisions per conversation to protect transcript writes. - Identical decisions are idempotent even while claimed: they return the recorded outcome, or the original call while execution is in flight, without invoking the tool or model again. Streamed confirmations are registered before onToolCall . After restart, streamed decisions require current ChatMemory : arrival snapshots are immutable and may omit later calls; without a current conversation, AK CONFIG INVALID rejects an incomplete streaming snapshot before claiming. A live controller does not require memory. - approve / deny are deprecated. With a decision store they delegate to decide , including atomic claims and typed errors; without a store they preserve generation-local behavior. - ChatConfig.authorizeToolCall — trusted host authorization before tool proposal and again before execution. Callback failures default to deny. - createInMemoryMemory / createLocalStorageMemory — default ChatMemory implementations. - createStaticRetriever / formatRetrievedDocuments — RAG-less retrieval using token substring overlap; use @agentskit/rag for semantic production retrieval. - executeToolCall , consumeStream , createEventEmitter , parseToolArgs , safeParseArgs , isRecord , createId , generateId , buildMessage — low-level helpers. parseToolArgs returns { args, valid } so adapters can fail closed; safeParseArgs remains the compatibility value-only parser. IDs use crypto.randomUUID() and retain the caller-provided prefix. - isRecord(value) accepts non-null objects other than arrays, including Date , Map , and class instances; use a schema validator when plain JSON objects are required. - AgentsKitError , AdapterError , ToolError , MemoryError , ConfigError , RuntimeError , SandboxError , SkillError , ErrorCodes — error taxonomy. - Token budget: compileBudget , approximateCounter . - Progressive tool args: createProgressiveArgParser , executeToolProgressively . - createVirtualizedMemory — hot-window + cold-retriever wrapper (also re-exported from @agentskit/memory ). - Untrusted-input fencing lives in the @agentskit/core/security entry: fenceUntrustedContent(content, opts?) + UNTRUSTED CONTENT DIRECTIVE wrap attacker-influenced text so the model treats it as data, not instructions (the mitigation companion to createInjectionDetector ). - Multi-modal content parts: textPart , imagePart , audioPart , videoPart , filePart , partsToText , normalizeContent , filterParts . - Agent-loop inter", "aliases": "core document agent" }, "graph": { @@ -51146,7 +51146,7 @@ } ] }, - "contentHash": "573989e1de71fdaeefe6492d54e3ca8d59283fc01028008ed784b8184fce7e65", + "contentHash": "e7e2adc0cc12756855e4052c6a6253c126eaae4ee4587c5c0ad658b05915c8e2", "provenance": "observed", "confidence": "observed" }, @@ -59886,7 +59886,7 @@ "path": "apps/docs-next/content/docs/reference/packages/core.mdx", "symbols": "", "summary": "import { ContributeCallout } from '@/components/contribute/contribute-callout'", - "body": "import { ContributeCallout } from '@/components/contribute/contribute-callout' The shared contract layer for AgentsKit: TypeScript types, the headless chat controller, stream helpers, and building blocks used by @agentskit/react , @agentskit/ink , @agentskit/runtime , and adapters. No third-party runtime dependencies — keep this package small and stable. When to use - You implement a custom adapter , tool, memory, or UI on top of official types. - You need createChatController without React (advanced integrations). - You want to understand messages, tool calls, and stream chunks across the ecosystem. You usually do not import core directly in a typical React app except for types — prefer and . Install Most feature packages already depend on core ; you add it explicitly when authoring libraries or sharing types. Public exports (overview) Chat controller and config Export Role -------- ------ createChatController Headless state machine: send, stream, tools, memory, skills, retriever Types: ChatConfig , ChatController , ChatState , ChatReturn Configuration and controller shape The controller merges system prompts, runs retrieval, dispatches tool calls, saves via ChatMemory after successful turns, skips persistence for failed or aborted turns, surfaces background memory and skill activation failures through onError , and exposes subscribe/update patterns consumed by UI packages. Durable decisions and multimodal input controller.send accepts string ContentPart[] . User messages retain their parts and a partsToText projection in content ; provider support remains adapter-specific. ChatReturn.send has the same widened signature. Implementers of ChatController must supply the required decide member and accept parts in send . Configure ChatConfig.decisionStore with a conversation-scoped ToolDecisionStore to use await controller.decide(toolCallId, 'approve') or await controller.decide(toolCallId, 'deny', reason) . Pending message snapshots survive controller replacement and process restart through that store. Configured chat memory also receives pending snapshots before proposal completion. ToolDecisionRecord stores toolCallId , messages , status ( pending , claimed , complete , failed , denied ), and optional decision , reason , and outcome . Implement putPending as insert-if-absent, get as a linearizable read, claim as an atomic pending-to-claimed transition returning the snapshot only to its winner, and settle as a durable terminal write for the matching claim. A database implementation can use UPDATE ... WHERE status = 'pending' RETURNING ... . The application must scope access to the authorized conversation. Before claiming, the controller loads and validates the current conversation, prioritizing live state (including unsaved turns), then configured chat memory, then the stored snapshot when neither exists. It preserves later messages and patches the target call, revalidates and reauthorizes the tool, persists its outcome, and resumes the model. Same-decision terminal replay returns the stored tool outcome without another tool or model invocation. Unknown IDs raise AK ACTION NOT FOUND ; in-flight or conflicting decisions raise AK ACTION ALREADY DECIDED . Tool failures become terminal failed records and reach the model as tool errors. A new attempt needs a new proposal ID. For a stuck claimed record, inspect get(id) and domain execution evidence, stop the original worker, and terminally settle the matching claim with a proven outcome or explicit indeterminate failure. Never reset or delete the claim. Reconcile the target call in current chat memory and continue with a new user turn; a retry requires a separately authorized new proposal ID. See . A crash after claim leaves an indeterminate claimed record requiring application reconciliation; core never automatically retries side effects. Serialize different decisions in one conversation to avoid lost transcript writes. Legacy approve / deny are deprecated: with a decision store they", + "body": "import { ContributeCallout } from '@/components/contribute/contribute-callout' The shared contract layer for AgentsKit: TypeScript types, the headless chat controller, stream helpers, and building blocks used by @agentskit/react , @agentskit/ink , @agentskit/runtime , and adapters. No third-party runtime dependencies — keep this package small and stable. When to use - You implement a custom adapter , tool, memory, or UI on top of official types. - You need createChatController without React (advanced integrations). - You want to understand messages, tool calls, and stream chunks across the ecosystem. You usually do not import core directly in a typical React app except for types — prefer and . Install Most feature packages already depend on core ; you add it explicitly when authoring libraries or sharing types. Public exports (overview) Chat controller and config Export Role -------- ------ createChatController Headless state machine: send, stream, tools, memory, skills, retriever Types: ChatConfig , ChatController , ChatState , ChatReturn Configuration and controller shape The controller merges system prompts, runs retrieval, dispatches tool calls, saves via ChatMemory after successful turns, skips persistence for failed or aborted turns, surfaces background memory and skill activation failures through onError , and exposes subscribe/update patterns consumed by UI packages. Durable decisions and multimodal input controller.send accepts string ContentPart[] . User messages retain their parts and a partsToText projection in content ; provider support remains adapter-specific. ChatReturn.send has the same widened signature. Implementers of ChatController must supply the required decide member and accept parts in send . Configure ChatConfig.decisionStore with a conversation-scoped ToolDecisionStore to use await controller.decide(toolCallId, 'approve') or await controller.decide(toolCallId, 'deny', reason) . Pending message snapshots survive controller replacement and process restart through that store. Configured chat memory also receives pending snapshots before proposal completion. ToolDecisionRecord stores toolCallId , messages , status ( pending , claimed , complete , failed , denied ), and optional decision , reason , and outcome . Implement putPending as insert-if-absent, get as a linearizable read, claim as an atomic pending-to-claimed transition returning the snapshot only to its winner, and settle as a durable terminal write for the matching claim. A database implementation can use UPDATE ... WHERE status = 'pending' RETURNING ... . The application must scope access to the authorized conversation. Before claiming, the controller loads and validates the current conversation, prioritizing live state (including unsaved turns), then configured chat memory, then the stored snapshot when neither exists. It preserves later messages and patches the target call, revalidates and reauthorizes the tool, persists its outcome, and resumes the model only for the latest completed assistant message when all confirmations are decided. Tool results follow their originating assistant message in call order. Same-decision terminal replay returns the stored tool outcome without another tool or model invocation. Unknown IDs raise AK ACTION NOT FOUND ; conflicting decisions raise AK ACTION ALREADY DECIDED . Tool failures become terminal failed records and reach the model as tool errors. A new attempt needs a new proposal ID. For a stuck claimed record, inspect get(id) and domain execution evidence, stop the original worker, and terminally settle the matching claim with a proven outcome or explicit indeterminate failure. Never reset or delete the claim. Reconcile the target call in current chat memory and continue with a new user turn; a retry requires a separately authorized new proposal ID. See . A crash after claim leaves an indeterminate claimed record requiring application reconciliation; core never automatically retries side effects. Serial", "aliases": "document human" }, "graph": { @@ -60028,7 +60028,7 @@ } ] }, - "contentHash": "a45c2e7c4960a50d190312d9eabc737f8973aa7d2b1720270a630578d0880423", + "contentHash": "5143366b0bed4e6cccd66a3a37209d9501462252c465da104236cf7e799615e8", "provenance": "observed", "confidence": "observed" }, @@ -71939,7 +71939,7 @@ "path": "docs/architecture/adrs/0042-durable-tool-decisions.md", "symbols": "", "summary": "Status: Proposed (implementation authorized; evidence review remains external)", - "body": "ADR 0042 — Durable tool decisions in core 1.x - Status : Proposed (implementation authorized; evidence review remains external) - Date : 2026-10-06 Context Legacy controller approvals use generation-local identity and cannot resume a confirmation in another controller. Distributed callers need an atomic storage boundary rather than a process-local mutex. Decision Add ChatController.decide(toolCallId, decision, reason?) and ChatConfig.decisionStore . Deprecate approve and deny ; with a decision store they delegate to decide , sharing atomic claims and typed errors, and without a store they retain generation-local behavior. Existing callers remain compatible, but structural implementations of ChatController must implement the required decide member. Add ContentPart[] to controller and shared ChatReturn send , retaining string input and its empty-input behavior. The conversation-scoped store creates immutable pending records containing a serializable message snapshot, atomically transitions pending to claimed with its decision, and stores a terminal tool outcome. Claim returns the authoritative snapshot only to its winner. A database implementation can use UPDATE ... WHERE status = 'pending' RETURNING ... ; creation must be insert-if-absent. Reads and writes must be durable and linearizable per tool call. Store access is an application-authorized conversation boundary, not an authorization token supplied by an untrusted client. Before claiming, the controller loads and validates the current conversation: live state takes precedence over configured chat memory, and the pending snapshot is used only when no current conversation is available. Loading and sibling reconciliation operate on cloned messages so existing subscriber snapshots remain immutable. After claim, it stops the stream and merges only reconciled terminal sibling outcomes by message and call ID into the latest live state, preserving chunks, new confirmations and turns received during storage awaits. It rechecks that the target remains pending before execution; a removed or changed target terminally settles the claim as failed without executing or resuming the model. It patches the target call while preserving later turns, revalidates and reauthorizes execution through the existing tool pipeline, saves the terminal outcome before model continuation, and never automatically reclaims a claimed or failed record. Same-decision terminal replay returns the recorded tool outcome without invoking tool or model. Unknown IDs and competing decisions raise typed errors; an in-flight duplicate raises a conflict. Pending snapshots are persisted before proposal completion when the store is configured. Consequences Core adds no database implementation or runtime dependency. HTTP schema, identity mapping, stream transport, and provider-specific persistence are integration responsibilities. A crash after claim can leave an indeterminate claimed record; exactly-once admission does not promise transactional domain writes or automatic crash recovery. Applications must reconcile such records without automatically rerunning side effects. Concurrent different calls in one conversation require application-level conversation serialization to avoid lost transcript updates. Store-backed legacy approvals and denials use the same claim boundary as durable decisions. Implementation is lazy-loaded to preserve the root import budget. Tests use independently specified synthetic stores and snapshots, without private consumer details. Operational recovery of a claimed decision The operator reads store.get(toolCallId) in the authorized conversation scope and verifies status , decision , arguments in messages , and any domain transaction or tool audit evidence. Stop the original worker and serialize conversation writes before reconciliation; elapsed time alone does not prove that execution never occurred. Never release a claim by deleting it or resetting it to pending . If the domain evidence proves compl", + "body": "ADR 0042 — Durable tool decisions in core 1.x - Status : Proposed (implementation authorized; evidence review remains external) - Date : 2026-10-06 Context Legacy controller approvals use generation-local identity and cannot resume a confirmation in another controller. Distributed callers need an atomic storage boundary rather than a process-local mutex. Decision Add ChatController.decide(toolCallId, decision, reason?) and ChatConfig.decisionStore . Deprecate approve and deny ; with a decision store they delegate to decide , sharing atomic claims and typed errors, and without a store they retain generation-local behavior. Existing callers remain compatible, but structural implementations of ChatController must implement the required decide member. Add ContentPart[] to controller and shared ChatReturn send , retaining string input and its empty-input behavior. The conversation-scoped store creates immutable pending records containing a serializable message snapshot, atomically transitions pending to claimed with its decision, and stores a terminal tool outcome. Claim returns the authoritative snapshot only to its winner. A database implementation can use UPDATE ... WHERE status = 'pending' RETURNING ... ; creation must be insert-if-absent. Reads and writes must be durable and linearizable per tool call. Store access is an application-authorized conversation boundary, not an authorization token supplied by an untrusted client. Before claiming, the controller loads and validates the current conversation: live state takes precedence over configured chat memory, and the pending snapshot is used only when no current conversation is available. Loading and sibling reconciliation operate on cloned messages so existing subscriber snapshots remain immutable. After claim, it rechecks the live target before merging only reconciled terminal sibling outcomes by message and call ID into the latest live state, preserving chunks, new confirmations and turns received during storage awaits. It rechecks that the target remains pending before execution; a removed or changed target terminally settles the claim as failed without executing or resuming the model. A rejected decision leaves any active stream untouched. It patches the target call while preserving later turns, revalidates and reauthorizes execution through the existing tool pipeline, saves the terminal outcome before model continuation (only from the latest completed assistant when all calls are decided, with tool results inserted immediately after their originating message in call order), and never automatically reclaims a claimed or failed record. Same-decision terminal replay returns the recorded tool outcome without invoking tool or model. Unknown IDs and competing decisions raise typed errors; an identical in-flight duplicate returns the original call without execution. Pending snapshots are persisted on streamed call arrival before callbacks, and before proposal completion when the store is configured. Their immutable arrival snapshots may omit later streamed calls, so reconstruction after restart requires current ChatMemory; an incomplete streaming fallback snapshot fails before claim with AK CONFIG INVALID. Consequences Core adds no database implementation or runtime dependency. HTTP schema, identity mapping, stream transport, and provider-specific persistence are integration responsibilities. A crash after claim can leave an indeterminate claimed record; exactly-once admission does not promise transactional domain writes or automatic crash recovery. Applications must reconcile such records without automatically rerunning side effects. Concurrent different calls in one conversation require application-level conversation serialization to avoid lost transcript updates. Store-backed legacy approvals and denials use the same claim boundary as durable decisions. Implementation is lazy-loaded to preserve the root import budget. Tests use independently specified synthetic stores and snap", "aliases": "document human" }, "graph": { @@ -71972,7 +71972,7 @@ } ] }, - "contentHash": "6e1205db68cf23787d54c5b5e30edc45263ebebb31bf12381caf66080304a13a", + "contentHash": "542f2d33984ae4c31a163e5749abdfc65dd8c9153b89d63db1184f29d5a711c3", "provenance": "observed", "confidence": "observed" }, @@ -77874,7 +77874,7 @@ "path": "packages/core/README.md", "symbols": "", "summary": "Profile: major-package", - "body": "@agentskit/core Profile: Date: Thu, 8 Oct 2026 01:38:40 -0300 Subject: [PATCH 03/10] fix(core): persist results for earlier decided turns --- packages/core/src/controller-helpers.ts | 24 +++++++++++++------ packages/core/src/controller.ts | 9 ++++++- .../controller-decision-internal.test.ts | 11 +++++---- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/packages/core/src/controller-helpers.ts b/packages/core/src/controller-helpers.ts index c5d126076..3112b1e49 100644 --- a/packages/core/src/controller-helpers.ts +++ b/packages/core/src/controller-helpers.ts @@ -75,6 +75,22 @@ export function sameToolLifecycle( return true } +export function insertToolResults( + messages: Message[], + assistantId: string, + calls: ToolCall[], + buildMsg: (init: { role: Message['role']; content: string; toolCallId?: string; status?: Message['status'] }) => Message, +): Message[] { + return messages.flatMap(message => { + if (message.id !== assistantId && message.status === 'streaming') return [message] + const settled = message.id === assistantId ? calls : message.toolCalls ?? [] + if (!settled.length || settled.some(call => call.status !== 'complete' && call.status !== 'error')) return [message] + const results = settled.filter(call => !messages.some(result => result.role === 'tool' && result.toolCallId === call.id)) + .map(call => buildMsg({ role: 'tool', content: call.result ?? call.error ?? '', toolCallId: call.id })) + return [{ ...message, status: 'complete' as const }, ...results] + }) +} + /** Build tool-result messages + a fresh streaming assistant for multi-turn tool loops. */ export function buildToolContinuation( messages: Message[], @@ -85,13 +101,7 @@ export function buildToolContinuation( const nextA = buildMsg({ role: 'assistant', content: '', status: 'streaming' }) return { messages: [ - ...messages.flatMap(message => { - const settled = message.id === assistantId ? calls : message.toolCalls ?? [] - if (!settled.length || settled.some(call => call.status !== 'complete' && call.status !== 'error')) return [message] - const results = settled.filter(call => !messages.some(result => result.role === 'tool' && result.toolCallId === call.id)) - .map(call => buildMsg({ role: 'tool', content: call.result ?? call.error ?? '', toolCallId: call.id })) - return [{ ...message, status: 'complete' as const }, ...results] - }), + ...insertToolResults(messages, assistantId, calls, buildMsg), nextA, ], nextAssistantId: nextA.id, diff --git a/packages/core/src/controller.ts b/packages/core/src/controller.ts index 143d369a1..4855174ef 100644 --- a/packages/core/src/controller.ts +++ b/packages/core/src/controller.ts @@ -8,6 +8,7 @@ import { accumulateUsage, buildAdapterRequest, createControllerToolLoop, + insertToolResults, mapMessageById, mapToolCallById, normalizeLlmUsage, @@ -455,7 +456,13 @@ import type { return (messages.length ? messages : snapshot).map(message => ({ ...message, toolCalls: message.toolCalls?.map(call => ({ ...call })) })) }, tool: name => toolMap.get(name), runTool, patch: patchCall, - finish: id => { approvalGenerations.delete(id) }, isCurrent: generation => generation === gen, + finish: id => { + approvalGenerations.delete(id) + const assistant = state.messages.find(message => message.toolCalls?.some(call => call.id === id)) + if (assistant && assistant.status !== 'streaming' && state.messages[state.messages.length - 1]?.id !== assistant.id) { + set(current => ({ ...current, messages: insertToolResults(current.messages, assistant.id, assistant.toolCalls ?? [], message) })) + } + }, isCurrent: generation => generation === gen, persist: correlation => persist(state.messages, correlation, true), resume, prepare: async (messages, reconciled) => { const useLive = loadedLive || state.messages.length > 0 || gen !== loadedGeneration diff --git a/packages/core/tests/controller-decision-internal.test.ts b/packages/core/tests/controller-decision-internal.test.ts index cbec76c0f..2df2e938f 100644 --- a/packages/core/tests/controller-decision-internal.test.ts +++ b/packages/core/tests/controller-decision-internal.test.ts @@ -53,7 +53,7 @@ describe('durable controller decisions', () => { const current = restarted ? createChatController(config) : chat await current.decide(proposal.id, 'approve') const messages = current.getState().messages - expect(messages.slice(0, before.length).map(m => [m.id, m.content])).toEqual(before.map(m => [m.id, m.content])) + expect(messages.filter(message => message.role !== 'tool').slice(0, before.length).map(m => [m.id, m.content])).toEqual(before.map(m => [m.id, m.content])) expect(messages[0].toolCalls?.[0].status).toBe('complete') expect(messages.at(-1)?.content).toBe('Finished') if (memory) expect(await memory.load()).toEqual(messages) @@ -67,6 +67,7 @@ describe('durable controller decisions', () => { const before = requests.length await chat.decide(proposal.id, 'approve') expect(requests).toHaveLength(before) + expect(chat.getState().messages[1]).toMatchObject({ role: 'tool', toolCallId: proposal.id, content: 'stored' }) expect(chat.getState().messages.at(-1)?.toolCalls?.[0].status).toBe('requires_confirmation') await chat.decide('later-call', 'deny') expect(requests).toHaveLength(before + 1) @@ -249,7 +250,7 @@ describe('durable controller decisions', () => { release() await sending const messages = chat.getState().messages - expect(messages.slice(0, before.length).map(message => [message.id, message.content])).toEqual(before.map(message => [message.id, message.content])) + expect(messages.filter(message => message.role !== 'tool').slice(0, before.length).map(message => [message.id, message.content])).toEqual(before.map(message => [message.id, message.content])) expect(messages[0].toolCalls?.[0]).toMatchObject({ status: 'complete', result: 'stored' }) expect(messages.at(-1)?.content).toBe('partial response') expect(await memory.load()).toEqual(messages) @@ -310,11 +311,11 @@ describe('durable controller decisions', () => { releaseStore() await deciding const messages = chat.getState().messages - expect(messages.slice(0, before.length).map(message => [message.id, message.content])).toEqual(before.map(message => [message.id, message.content])) + expect(messages.filter(message => message.role !== 'tool').slice(0, before.length).map(message => [message.id, message.content])).toEqual(before.map(message => [message.id, message.content])) expect(messages.flatMap(message => message.toolCalls ?? []).find(call => call.id === 'new-call')).toMatchObject({ status: 'requires_confirmation' }) expect(messages[0].toolCalls?.[0]).toMatchObject({ status: 'complete', result: 'stored' }) expect(await memory.load()).toEqual(messages) - expect((await port.get(proposal.id))?.messages).toEqual(copy(messages.slice(0, before.length))) + expect((await port.get(proposal.id))?.messages).toEqual(copy(messages)) expect(execute).toHaveBeenCalledTimes(1) }) @@ -378,7 +379,7 @@ describe('durable controller decisions', () => { const before = copy(chat.getState().messages) memory.save = save await chat.decide(proposal.id, 'approve') - expect(chat.getState().messages.slice(0, before.length).map(message => message.content)).toEqual(before.map(message => message.content)) + expect(chat.getState().messages.filter(message => message.role !== 'tool').slice(0, before.length).map(message => message.content)).toEqual(before.map(message => message.content)) expect(await memory.load()).toEqual(chat.getState().messages) }) From 2449d12018fa4e5fc92212a108f09e8721c45c59 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Thu, 8 Oct 2026 01:41:35 -0300 Subject: [PATCH 04/10] chore(docs): refresh durable decision index --- .doc-bridge/capabilities.json | 2 +- .doc-bridge/index.json | 28 +++++++++++++++------------- 2 files changed, 16 insertions(+), 14 deletions(-) diff --git a/.doc-bridge/capabilities.json b/.doc-bridge/capabilities.json index 6e1195a8d..eaf6325a6 100644 --- a/.doc-bridge/capabilities.json +++ b/.doc-bridge/capabilities.json @@ -7,7 +7,7 @@ "name": "AgentsKit", "root": "." }, - "contentHash": "35c79ad55d43f71d59185f27bd8ffb9aecbab76aebf983fa1fedd568de3a7bc6", + "contentHash": "4d20d05999bea35a5d11cf2aa147b8ebe1ea1ce5c664d10f3698d4ca1d4ca7b0", "artifacts": { "index": ".doc-bridge/index.json", "llmsTxt": ".doc-bridge/llms.txt" diff --git a/.doc-bridge/index.json b/.doc-bridge/index.json index de4be87e9..8e7944952 100644 --- a/.doc-bridge/index.json +++ b/.doc-bridge/index.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "contentHash": "35c79ad55d43f71d59185f27bd8ffb9aecbab76aebf983fa1fedd568de3a7bc6", + "contentHash": "4d20d05999bea35a5d11cf2aa147b8ebe1ea1ce5c664d10f3698d4ca1d4ca7b0", "contentHashAlgo": "sha256-normalized-v1", - "generatedAt": "2026-10-08T04:35:55.883Z", + "generatedAt": "2026-10-08T04:39:06.967Z", "project": { "name": "AgentsKit", "root": "." @@ -18865,6 +18865,7 @@ "buildRetrievalMessage", "buildToolContinuation", "createControllerToolLoop", + "insertToolResults", "mapMessageById", "mapToolCallById", "mergeSystemMessages", @@ -18876,7 +18877,7 @@ "ts", "src" ], - "contentHash": "527da671e9992b6c8ab4a806839c6f65ea6c9bb7325665593355970f38c3cb1a" + "contentHash": "fe6d83e365dcafb27df5032e66c6f941733340199b1e549be2ce89d7f909f5ba" }, { "id": "module:packages/core/src/controller-persistence.ts", @@ -18922,7 +18923,7 @@ "ts", "src" ], - "contentHash": "c3fe03dc98fe18abf54e2a30962d5e98d042ad2f481df538f0cc54665d0483e8" + "contentHash": "93155e5aaca6a1221aaf636abcc6ee911a853b0a36de52937cc76dc66c7bfd1f" }, { "id": "module:packages/core/src/errors.ts", @@ -20238,7 +20239,7 @@ "test", "tests" ], - "contentHash": "9edddfa706c90c09c5cff680d71bd133ed128ba89df76afe2e032cf2b206eb59" + "contentHash": "5239d92d01a9c878b709cc324be7d49b80b045f31b8612159636c5df0ac95a31" }, { "id": "module:packages/core/tests/controller-edit-regenerate.test.ts", @@ -38197,7 +38198,7 @@ "changes": {} }, "inputs": { - "hash": "1dc585b9db385e5e23ed4091773700efc6faefe0bafad23abd15a6823db6bdc6", + "hash": "9ca5dafceb06a9a9cea822c12d443a01471d8024b22cca81d6e2cfc2ac4dc247", "fileCount": 2539, "projectionVersion": 2 }, @@ -38220,9 +38221,9 @@ "projection": { "type": "retrieval-index", "schemaVersion": 1, - "contentHash": "5a0ef1c33e60a7cb486a6853d2a543963a57547a7f56deb7f78bd3d5f2e286e7", + "contentHash": "29e5ca3230eede1b826fb2db14502ad755ba8e86a0039417dcf2e43771472180", "contentHashAlgo": "sha256-normalized-v1", - "snapshotHash": "06cd3f95ab47c5344f735ee9e75e424d38e065ce836d5f01dd76e684a90c0424", + "snapshotHash": "2f9607ad8ac91cde78cd9ad2424944a262efef9c6ffc44ee85ce9761035ea5aa", "overlayHash": "f99666ff9119084dd25c2974ae64eb6935ec2aa785ff5ad478fb9d541f7a5e94", "configurationHash": "9662cc07ea4ab203ff2ddf457d2db48b5375a7ab4a1f8fbc79dfebcaa8f9a00c", "lexiconVersion": 1, @@ -38258,7 +38259,7 @@ "headings": 4.019647153167602, "path": 7.613472333600641, "summary": 4.5509222133119485, - "symbols": 7.454691259021652, + "symbols": 7.456295108259823, "title": 3.354851643945469 } }, @@ -122216,6 +122217,7 @@ "buildRetrievalMessage", "buildToolContinuation", "createControllerToolLoop", + "insertToolResults", "mapMessageById", "mapToolCallById", "mergeSystemMessages", @@ -122231,7 +122233,7 @@ "title": "controller-helpers.ts", "headings": "", "path": "packages/core/src/controller-helpers.ts", - "symbols": "accumulateUsage buildAdapterRequest buildRetrievalMessage buildToolContinuation createControllerToolLoop mapMessageById mapToolCallById mergeSystemMessages normalizeLlmUsage sameToolLifecycle", + "symbols": "accumulateUsage buildAdapterRequest buildRetrievalMessage buildToolContinuation createControllerToolLoop insertToolResults mapMessageById mapToolCallById mergeSystemMessages normalizeLlmUsage sameToolLifecycle", "summary": "", "body": "", "aliases": "module ts src" @@ -122277,7 +122279,7 @@ } ] }, - "contentHash": "527da671e9992b6c8ab4a806839c6f65ea6c9bb7325665593355970f38c3cb1a", + "contentHash": "fe6d83e365dcafb27df5032e66c6f941733340199b1e549be2ce89d7f909f5ba", "provenance": "observed", "confidence": "observed" }, @@ -122603,7 +122605,7 @@ } ] }, - "contentHash": "c3fe03dc98fe18abf54e2a30962d5e98d042ad2f481df538f0cc54665d0483e8", + "contentHash": "93155e5aaca6a1221aaf636abcc6ee911a853b0a36de52937cc76dc66c7bfd1f", "provenance": "observed", "confidence": "observed" }, @@ -128834,7 +128836,7 @@ } ] }, - "contentHash": "9edddfa706c90c09c5cff680d71bd133ed128ba89df76afe2e032cf2b206eb59", + "contentHash": "5239d92d01a9c878b709cc324be7d49b80b045f31b8612159636c5df0ac95a31", "provenance": "observed", "confidence": "observed" }, From e22f5cb55f125525c4dc963eb7707bf1232347af Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Thu, 8 Oct 2026 01:53:14 -0300 Subject: [PATCH 05/10] fix(core): keep tool continuation within import budget --- .../core/src/controller-decision-internal.ts | 27 ++++++++++++++--- packages/core/src/controller-helpers.ts | 7 +++-- packages/core/src/controller.ts | 29 +++++-------------- 3 files changed, 35 insertions(+), 28 deletions(-) diff --git a/packages/core/src/controller-decision-internal.ts b/packages/core/src/controller-decision-internal.ts index 34925be61..833520df3 100644 --- a/packages/core/src/controller-decision-internal.ts +++ b/packages/core/src/controller-decision-internal.ts @@ -1,17 +1,21 @@ +export { buildToolContinuation } from './controller-helpers' +import { insertToolResults } from './controller-helpers' +import { buildMessage } from './primitives' import { ErrorCodes, ConfigError, ToolError } from './errors' import type { ToolExecResult } from './agent-loop' import type { AgentEventContext, Message, ToolCall, ToolDefinition, ToolDecisionStore, ToolDecisionRecord } from './types' interface DecisionContext { store: ToolDecisionStore | undefined - load: (snapshot: Message[]) => Promise - prepare: (messages: Message[], reconciled: Map) => Promise<{ generation: number; correlation: AgentEventContext } | undefined> + load: (snapshot: Message[]) => Promise<{ messages: Message[]; fallback: boolean }> + prepare: (messages: Message[], reconcile: (current: Message[]) => Message[] | undefined) => Promise<{ generation: number; correlation: AgentEventContext } | undefined> tool: (name: string) => ToolDefinition | undefined runTool: (tool: ToolDefinition | undefined, call: ToolCall, onPartial: (result: string) => void, generation: number, correlation: AgentEventContext) => Promise patch: (assistantId: string, toolCallId: string, patch: Partial) => void finish: (toolCallId: string) => void isCurrent: (generation: number) => boolean resume: (assistantId: string, generation: number, correlation: AgentEventContext) => Promise + setMessages: (messages: Message[]) => void messages: () => Message[] persist: (correlation: AgentEventContext) => Promise } @@ -36,7 +40,11 @@ export async function decide( } throw new ToolError({ code: ErrorCodes.AK_ACTION_ALREADY_DECIDED, message: 'Tool decision already claimed or decided' }) } - const messages = await context.load(pending.messages) + const loaded = await context.load(pending.messages) + if (loaded.fallback && pending.messages.some(message => message.status === 'streaming')) { + throw new ConfigError({ code: ErrorCodes.AK_CONFIG_INVALID, message: 'Resuming a streamed tool decision requires current ChatMemory' }) + } + const messages = loaded.messages.map(message => ({ ...message, toolCalls: message.toolCalls?.map(call => ({ ...call })) })) const reconciled = new Map() for (const message of messages) { if (!message.toolCalls) continue @@ -68,7 +76,13 @@ export async function decide( if (record.toolCallId !== id || record.status !== 'claimed' || record.decision !== decision || !Array.isArray(record.messages)) { throw new ConfigError({ code: ErrorCodes.AK_CONFIG_INVALID, message: 'Decision store returned an invalid claim' }) } - const prepared = await context.prepare(messages, reconciled) + const prepared = await context.prepare(messages, latest => { + if (!latest.some(message => message.toolCalls?.some(call => call.id === id && call.status === 'requires_confirmation'))) return undefined + return latest.map(message => ({ ...message, toolCalls: message.toolCalls?.map(call => { + const outcome = reconciled.get(message.id)?.find(loaded => loaded.id === call.id) + return call.id !== id && call.status === 'requires_confirmation' && outcome ? outcome : call + }) })) + }) const current = context.messages().find(m => m.id === message.id)?.toolCalls?.find(c => c.id === id) if (!prepared || current?.status !== 'requires_confirmation') { const error = new ToolError({ code: ErrorCodes.AK_ACTION_ALREADY_DECIDED, message: 'Tool decision is no longer pending' }) @@ -92,6 +106,11 @@ export async function decide( } } context.patch(message.id, id, outcome) + const transcript = context.messages() + const assistant = transcript.find(current => current.id === message.id) + if (assistant && assistant.status !== 'streaming' && transcript[transcript.length - 1]?.id !== assistant.id) { + context.setMessages(insertToolResults(transcript, assistant.id, assistant.toolCalls ?? [], buildMessage)) + } context.finish(id) const status = outcome.status === 'complete' ? 'complete' : 'failed' await store.settle({ diff --git a/packages/core/src/controller-helpers.ts b/packages/core/src/controller-helpers.ts index 3112b1e49..f27a7510b 100644 --- a/packages/core/src/controller-helpers.ts +++ b/packages/core/src/controller-helpers.ts @@ -201,9 +201,11 @@ export function createControllerToolLoop({ getConfig, getState, getCorrelation, if (done && shouldPersist) await persist(getState().messages, getCorrelation()) } - const continueTools = (aid: string, calls: ToolCall[]): string => { + const continueTools = async (aid: string, calls: ToolCall[]): Promise => { + const { buildToolContinuation } = await import('./controller-decision-internal.js') let nextId = '' set(current => { + if (current.messages[current.messages.length - 1]?.id !== aid) return current const { messages: next, nextAssistantId } = buildToolContinuation( current.messages, aid, @@ -244,7 +246,8 @@ export function createControllerToolLoop({ getConfig, getState, getCorrelation, return } - id = continueTools(id, calls) + id = await continueTools(id, calls) + if (!id) return const ok = await run(id, '', g, correlation) if (!ok) return } diff --git a/packages/core/src/controller.ts b/packages/core/src/controller.ts index 4855174ef..91e09c802 100644 --- a/packages/core/src/controller.ts +++ b/packages/core/src/controller.ts @@ -1,6 +1,5 @@ import { buildMessage as message, consumeStream, createEventEmitter, generateId, createToolLifecycle } from './primitives' import { buildToolMap, activateSkills, executeSafeTool as execute } from './agent-loop' -import { ConfigError, ErrorCodes } from './errors' import { partsToText } from './types/content' import { createControllerPersistence } from './controller-persistence' import { handleControllerToolCall } from './controller-tool-call' @@ -8,7 +7,6 @@ import { accumulateUsage, buildAdapterRequest, createControllerToolLoop, - insertToolResults, mapMessageById, mapToolCallById, normalizeLlmUsage, @@ -450,30 +448,17 @@ import type { loadedGeneration = gen loadedLive = state.messages.length > 0 const messages = loadedLive ? state.messages : await persistence.load() - if (!messages.length && snapshot.some(message => message.status === 'streaming')) { - throw new ConfigError({ code: ErrorCodes.AK_CONFIG_INVALID, message: 'Resuming a streamed tool decision requires current ChatMemory' }) - } - return (messages.length ? messages : snapshot).map(message => ({ ...message, toolCalls: message.toolCalls?.map(call => ({ ...call })) })) + return { fallback: !messages.length, messages: messages.length ? messages : snapshot } }, tool: name => toolMap.get(name), runTool, patch: patchCall, - finish: id => { - approvalGenerations.delete(id) - const assistant = state.messages.find(message => message.toolCalls?.some(call => call.id === id)) - if (assistant && assistant.status !== 'streaming' && state.messages[state.messages.length - 1]?.id !== assistant.id) { - set(current => ({ ...current, messages: insertToolResults(current.messages, assistant.id, assistant.toolCalls ?? [], message) })) - } - }, isCurrent: generation => generation === gen, + finish: id => { approvalGenerations.delete(id) }, isCurrent: generation => generation === gen, + setMessages: controller.setMessages, persist: correlation => persist(state.messages, correlation, true), resume, - prepare: async (messages, reconciled) => { + prepare: async (messages, reconcile) => { const useLive = loadedLive || state.messages.length > 0 || gen !== loadedGeneration - const currentMessages = useLive ? state.messages : messages - if (!currentMessages.some(message => message.toolCalls?.some(call => call.id === tid && call.status === 'requires_confirmation'))) return undefined - set(current => ({ ...current, messages: (useLive ? current.messages : messages).map(message => { - return { ...message, toolCalls: message.toolCalls?.map(call => { - const outcome = reconciled.get(message.id)?.find(loaded => loaded.id === call.id) - return call.id !== tid && call.status === 'requires_confirmation' && outcome ? outcome : call - }) } - }), error: null })) + const next = reconcile(useLive ? state.messages : messages) + if (!next) return undefined + set(current => ({ ...current, messages: next, error: null })) return { generation: gen, correlation: activeCorrelation } }, }) From 15d4045fdb5519a196f63fec4e0b2db531784273 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Thu, 8 Oct 2026 01:54:33 -0300 Subject: [PATCH 06/10] chore(docs): index lazy tool continuation --- .doc-bridge/capabilities.json | 2 +- .doc-bridge/index.json | 78 ++++++++++++++++++++++++----------- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/.doc-bridge/capabilities.json b/.doc-bridge/capabilities.json index eaf6325a6..20ba4aa9e 100644 --- a/.doc-bridge/capabilities.json +++ b/.doc-bridge/capabilities.json @@ -7,7 +7,7 @@ "name": "AgentsKit", "root": "." }, - "contentHash": "4d20d05999bea35a5d11cf2aa147b8ebe1ea1ce5c664d10f3698d4ca1d4ca7b0", + "contentHash": "c7224b0d340778761dea69db0831352dd9f1740f478fadac604e85300d831350", "artifacts": { "index": ".doc-bridge/index.json", "llmsTxt": ".doc-bridge/llms.txt" diff --git a/.doc-bridge/index.json b/.doc-bridge/index.json index 8e7944952..01344edcf 100644 --- a/.doc-bridge/index.json +++ b/.doc-bridge/index.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "contentHash": "4d20d05999bea35a5d11cf2aa147b8ebe1ea1ce5c664d10f3698d4ca1d4ca7b0", + "contentHash": "c7224b0d340778761dea69db0831352dd9f1740f478fadac604e85300d831350", "contentHashAlgo": "sha256-normalized-v1", - "generatedAt": "2026-10-08T04:39:06.967Z", + "generatedAt": "2026-10-08T04:53:28.336Z", "project": { "name": "AgentsKit", "root": "." @@ -18845,6 +18845,7 @@ "title": "controller-decision-internal.ts", "path": "packages/core/src/controller-decision-internal.ts", "symbols": [ + "buildToolContinuation", "decide" ], "tags": [ @@ -18852,7 +18853,7 @@ "ts", "src" ], - "contentHash": "0e23242a7928abc906eeab61ab7501f08ccb6570ed10d9ce0aeb35165c6cab7a" + "contentHash": "2505516631cdb1290c9610e560d4a032913442ba8ca65c802c5c81f1bad55afa" }, { "id": "module:packages/core/src/controller-helpers.ts", @@ -18877,7 +18878,7 @@ "ts", "src" ], - "contentHash": "fe6d83e365dcafb27df5032e66c6f941733340199b1e549be2ce89d7f909f5ba" + "contentHash": "cff73c65129da037d8fc2c7e33370d629c133aed78e4a4d9332a6ad829d7ddac" }, { "id": "module:packages/core/src/controller-persistence.ts", @@ -18923,7 +18924,7 @@ "ts", "src" ], - "contentHash": "93155e5aaca6a1221aaf636abcc6ee911a853b0a36de52937cc76dc66c7bfd1f" + "contentHash": "3b5441f090877efd87124f3edff62509096d4d02440044938b76aeb411415b36" }, { "id": "module:packages/core/src/errors.ts", @@ -38198,7 +38199,7 @@ "changes": {} }, "inputs": { - "hash": "9ca5dafceb06a9a9cea822c12d443a01471d8024b22cca81d6e2cfc2ac4dc247", + "hash": "e2f7cff4f56018b664a08b89d958ba16f3245d37600b3d8a6ba5db86456b93d0", "fileCount": 2539, "projectionVersion": 2 }, @@ -38221,9 +38222,9 @@ "projection": { "type": "retrieval-index", "schemaVersion": 1, - "contentHash": "29e5ca3230eede1b826fb2db14502ad755ba8e86a0039417dcf2e43771472180", + "contentHash": "ad63354c0be595f4a9c14f3a624188c7d7678ae8d11500783253849eecf6f6f2", "contentHashAlgo": "sha256-normalized-v1", - "snapshotHash": "2f9607ad8ac91cde78cd9ad2424944a262efef9c6ffc44ee85ce9761035ea5aa", + "snapshotHash": "4faeb5b6f80fed35a146cdb2bb2cb9526493fdbfdc8b5dd3aaf955ab65fd9db6", "overlayHash": "f99666ff9119084dd25c2974ae64eb6935ec2aa785ff5ad478fb9d541f7a5e94", "configurationHash": "9662cc07ea4ab203ff2ddf457d2db48b5375a7ab4a1f8fbc79dfebcaa8f9a00c", "lexiconVersion": 1, @@ -38259,7 +38260,7 @@ "headings": 4.019647153167602, "path": 7.613472333600641, "summary": 4.5509222133119485, - "symbols": 7.456295108259823, + "symbols": 7.457898957497995, "title": 3.354851643945469 } }, @@ -122123,6 +122124,7 @@ "title": "controller-decision-internal.ts", "aliases": [], "symbols": [ + "buildToolContinuation", "decide" ], "tags": [ @@ -122134,7 +122136,7 @@ "title": "controller-decision-internal.ts", "headings": "", "path": "packages/core/src/controller-decision-internal.ts", - "symbols": "decide", + "symbols": "buildToolContinuation decide", "summary": "", "body": "", "aliases": "module ts src" @@ -122152,6 +122154,11 @@ ], "packageId": "package:@agentskit/core", "inbound": [ + { + "kind": "imports", + "id": "module:packages/core/src/controller-helpers.ts", + "confidence": "observed" + }, { "kind": "imports", "id": "module:packages/core/src/controller.ts", @@ -122189,19 +122196,34 @@ "id": "module:packages/core/src/agent-loop.ts", "confidence": "observed" }, + { + "kind": "imports", + "id": "module:packages/core/src/controller-helpers.ts", + "confidence": "observed" + }, { "kind": "imports", "id": "module:packages/core/src/errors.ts", "confidence": "observed" }, + { + "kind": "imports", + "id": "module:packages/core/src/primitives.ts", + "confidence": "observed" + }, { "kind": "imports", "id": "module:packages/core/src/types.ts", "confidence": "observed" + }, + { + "kind": "re-exports", + "id": "module:packages/core/src/controller-helpers.ts", + "confidence": "observed" } ] }, - "contentHash": "0e23242a7928abc906eeab61ab7501f08ccb6570ed10d9ce0aeb35165c6cab7a", + "contentHash": "2505516631cdb1290c9610e560d4a032913442ba8ca65c802c5c81f1bad55afa", "provenance": "observed", "confidence": "observed" }, @@ -122245,6 +122267,11 @@ "mentionedBy": [], "packageId": "package:@agentskit/core", "inbound": [ + { + "kind": "imports", + "id": "module:packages/core/src/controller-decision-internal.ts", + "confidence": "observed" + }, { "kind": "imports", "id": "module:packages/core/src/controller.ts", @@ -122254,9 +122281,19 @@ "kind": "imports", "id": "module:packages/core/tests/controller-helpers.test.ts", "confidence": "observed" + }, + { + "kind": "re-exports", + "id": "module:packages/core/src/controller-decision-internal.ts", + "confidence": "observed" } ], "outbound": [ + { + "kind": "imports", + "id": "module:packages/core/src/controller-decision-internal.ts", + "confidence": "observed" + }, { "kind": "imports", "id": "module:packages/core/src/primitives.ts", @@ -122279,7 +122316,7 @@ } ] }, - "contentHash": "fe6d83e365dcafb27df5032e66c6f941733340199b1e549be2ce89d7f909f5ba", + "contentHash": "cff73c65129da037d8fc2c7e33370d629c133aed78e4a4d9332a6ad829d7ddac", "provenance": "observed", "confidence": "observed" }, @@ -122578,11 +122615,6 @@ "id": "module:packages/core/src/controller-tool-call.ts", "confidence": "observed" }, - { - "kind": "imports", - "id": "module:packages/core/src/errors.ts", - "confidence": "observed" - }, { "kind": "imports", "id": "module:packages/core/src/primitives.ts", @@ -122605,7 +122637,7 @@ } ] }, - "contentHash": "93155e5aaca6a1221aaf636abcc6ee911a853b0a36de52937cc76dc66c7bfd1f", + "contentHash": "3b5441f090877efd87124f3edff62509096d4d02440044938b76aeb411415b36", "provenance": "observed", "confidence": "observed" }, @@ -122697,11 +122729,6 @@ "id": "module:packages/core/src/controller-tool-call.ts", "confidence": "observed" }, - { - "kind": "imports", - "id": "module:packages/core/src/controller.ts", - "confidence": "observed" - }, { "kind": "imports", "id": "module:packages/core/src/hitl.ts", @@ -123959,6 +123986,11 @@ "id": "module:packages/core/src/agent-schema.ts", "confidence": "observed" }, + { + "kind": "imports", + "id": "module:packages/core/src/controller-decision-internal.ts", + "confidence": "observed" + }, { "kind": "imports", "id": "module:packages/core/src/controller-helpers.ts", From 835808296f1418e9c19b685fbe874e59b2c36710 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Thu, 8 Oct 2026 02:19:50 -0300 Subject: [PATCH 07/10] fix(core): recover tool decisions across later turns and restart --- .changeset/controller-review-recovery.md | 5 + .../core/src/controller-decision-internal.ts | 2 +- packages/core/src/controller-helpers.ts | 2 +- packages/core/src/controller.ts | 9 +- .../controller-decision-internal.test.ts | 106 +++++++++++++++++- packages/core/tests/controller-restart.mjs | 26 ++++- 6 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 .changeset/controller-review-recovery.md diff --git a/.changeset/controller-review-recovery.md b/.changeset/controller-review-recovery.md new file mode 100644 index 000000000..41eb11301 --- /dev/null +++ b/.changeset/controller-review-recovery.md @@ -0,0 +1,5 @@ +--- +"@agentskit/core": patch +--- + +Keep older pending or superseded tool calls from blocking later controller turns, recover decisions after a pending-registration save failure, and resume interrupted streaming messages loaded from memory. Use a fresh run ID for durable decision executions outside a live stream. diff --git a/packages/core/src/controller-decision-internal.ts b/packages/core/src/controller-decision-internal.ts index 833520df3..519bc9d4a 100644 --- a/packages/core/src/controller-decision-internal.ts +++ b/packages/core/src/controller-decision-internal.ts @@ -123,7 +123,7 @@ export async function decide( const latest = context.messages() const last = latest[latest.length - 1] if (context.isCurrent(generation) && last?.id === message.id && last?.status !== 'streaming' - && !latest.some(message => message.toolCalls?.some(call => call.status !== 'complete' && call.status !== 'error'))) { + && !latest.slice(latest.findIndex(message => message.id === last.id)).some(message => message.toolCalls?.some(call => call.status !== 'complete' && call.status !== 'error'))) { await context.resume(message.id, generation, correlation) } return outcome diff --git a/packages/core/src/controller-helpers.ts b/packages/core/src/controller-helpers.ts index f27a7510b..05e7bf5c3 100644 --- a/packages/core/src/controller-helpers.ts +++ b/packages/core/src/controller-helpers.ts @@ -235,7 +235,7 @@ export function createControllerToolLoop({ getConfig, getState, getCorrelation, for (let remaining = getConfig().maxToolIterations ?? 5; remaining > 0; remaining--) { const assistant = getState().messages.find(message => message.id === id) const calls = assistant?.toolCalls ?? [] - const waits = getState().messages.some(message => message.toolCalls?.some(call => call.status !== 'complete' && call.status !== 'error')) + const waits = getState().messages.slice(getState().messages.findIndex(message => message.id === id)).some(message => message.toolCalls?.some(call => call.status !== 'complete' && call.status !== 'error')) // Nothing to feed back, or something still awaiting confirmation — // stop here; the caller drives the next step. diff --git a/packages/core/src/controller.ts b/packages/core/src/controller.ts index 91e09c802..1bb3e0113 100644 --- a/packages/core/src/controller.ts +++ b/packages/core/src/controller.ts @@ -438,7 +438,7 @@ import type { }, async decide(tid, decision, reason) { const { decide } = await import('./controller-decision-internal.js') - await pendingRegistration + await pendingRegistration.catch(() => {}) let loadedLive = false let loadedGeneration = gen return decide(tid, decision, reason, { @@ -456,10 +456,13 @@ import type { persist: correlation => persist(state.messages, correlation, true), resume, prepare: async (messages, reconcile) => { const useLive = loadedLive || state.messages.length > 0 || gen !== loadedGeneration - const next = reconcile(useLive ? state.messages : messages) + const current = useLive ? state.messages : messages + const next = reconcile(state.status === 'streaming' ? current : current.map(message => + message.status === 'streaming' ? { ...message, status: 'complete' as const } : message, + )) if (!next) return undefined set(current => ({ ...current, messages: next, error: null })) - return { generation: gen, correlation: activeCorrelation } + return { generation: gen, correlation: state.status === 'streaming' ? activeCorrelation : beginRun() } }, }) }, diff --git a/packages/core/tests/controller-decision-internal.test.ts b/packages/core/tests/controller-decision-internal.test.ts index 2df2e938f..04690edf5 100644 --- a/packages/core/tests/controller-decision-internal.test.ts +++ b/packages/core/tests/controller-decision-internal.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest' import { createChatController, createInMemoryMemory, imagePart, filePart, textPart } from '../src/index' -import type { AdapterFactory, AdapterRequest, ToolDecisionRecord, ToolDecisionStore, ChatConfig } from '../src/index' +import type { AdapterFactory, AdapterRequest, ToolDecisionRecord, ToolDecisionStore, ChatConfig, StreamChunk } from '../src/index' import { createMockAdapter } from './helpers' const copy = (value: T): T => JSON.parse(JSON.stringify(value)) as T @@ -44,6 +44,110 @@ function fixture(overrides: Partial = {}) { const proposal = { id: 'call-1', name: 'write', args: { value: 7 } } describe('durable controller decisions', () => { + it('continues an auto-run turn despite an older undecided confirmation', async () => { + const requests: AdapterRequest[] = [] + const weather = vi.fn(() => 'sunny') + const { config } = fixture({ tools: [ + { name: 'write', requiresConfirmation: true, execute: () => 'stored' }, + { name: 'weather', execute: weather }, + ] }) + const chat = createChatController({ ...config, adapter: { + createSource(request) { + requests.push(request) + let chunks: StreamChunk[] = [{ type: 'text', content: 'Sunny today' }] + if (requests.length === 1) chunks = [{ type: 'tool_call', toolCall: { ...proposal, args: '{}' } }] + if (requests.length === 2) chunks = [{ type: 'tool_call', toolCall: { id: 'weather', name: 'weather', args: '{}' } }] + return createMockAdapter([...chunks, { type: 'done' }]).createSource(request) + }, + } }) + await chat.send('write') + await chat.send('weather?') + expect(requests).toHaveLength(3) + expect(weather).toHaveBeenCalledTimes(1) + expect(chat.getState().messages.flatMap(message => message.toolCalls ?? []).find(call => call.id === proposal.id)?.status).toBe('requires_confirmation') + expect(requests[2].messages.find(message => message.toolCallId === 'weather')?.content).toBe('sunny') + expect(chat.getState().messages.at(-1)?.content).toBe('Sunny today') + }) + + it('resumes the latest decision despite a running call from a superseded generation', async () => { + let release = () => {} + const gate = new Promise(resolve => { release = resolve }) + const { config, requests } = fixture({ tools: [ + { name: 'write', requiresConfirmation: true, execute: () => 'stored' }, + { name: 'slow', execute: async () => { await gate; return 'old result' } }, + ] }) + const chat = createChatController({ ...config, adapter: createMockAdapter([ + { type: 'tool_call', toolCall: { id: 'old-running', name: 'slow', args: '{}' } }, { type: 'done' }, + ]) }) + const sending = chat.send('slow') + await vi.waitUntil(() => chat.getState().messages.at(-1)?.toolCalls?.[0].status === 'running') + chat.updateConfig({ adapter: config.adapter }) + try { + await chat.send('later') + await chat.proposeToolCall(proposal) + await chat.decide(proposal.id, 'approve') + expect(requests).toHaveLength(2) + expect(requests[1].messages.find(message => message.toolCallId === proposal.id)?.content).toBe('stored') + expect(requests[1].correlation?.runId).not.toBe(requests[0].correlation?.runId) + expect(chat.getState().messages.at(-1)?.content).toBe('Finished') + } finally { release(); await sending } + }) + + it.each(['decide', 'approve', 'deny'] as const)('allows %s after a single pending-registration save failure', async action => { + const memory = createInMemoryMemory() + const onError = vi.fn() + const { chat, config, execute } = fixture({ memory, onError }) + await chat.proposeToolCall(proposal) + const save = memory.save.bind(memory) + memory.save = vi.fn().mockRejectedValueOnce(new Error('offline once')).mockImplementation(save) + chat.updateConfig({ adapter: createMockAdapter([ + { type: 'tool_call', toolCall: { ...proposal, id: 'failed-arrival', args: '{}' } }, { type: 'done' }, + ]) }) + await chat.send('another write') + expect(chat.getState().error).toMatchObject({ code: 'AK_MEMORY_SAVE_FAILED' }) + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ code: 'AK_MEMORY_SAVE_FAILED' })) + expect(await config.decisionStore!.get('failed-arrival')).toBeUndefined() + if (action === 'decide') await chat.decide(proposal.id, 'approve') + else await chat[action](proposal.id) + expect(execute).toHaveBeenCalledTimes(action === 'deny' ? 0 : 1) + expect(await config.decisionStore!.get(proposal.id)).toMatchObject({ status: action === 'deny' ? 'denied' : 'complete' }) + expect(await memory.load()).toEqual(chat.getState().messages) + }) + + it('completes a persisted streaming assistant after restart and resumes in call order', async () => { + const memory = createInMemoryMemory() + const { config, requests, execute } = fixture({ memory }) + const messages = [{ id: 'user', role: 'user' as const, content: 'write twice', createdAt: 1 }, { + id: 'interrupted', role: 'assistant' as const, content: 'partial', createdAt: 2, status: 'streaming' as const, + toolCalls: [ + { ...proposal, status: 'requires_confirmation' as const }, + { ...proposal, id: 'call-2', status: 'requires_confirmation' as const }, + ], + }] + await memory.save(messages) + for (const call of messages[1].toolCalls!) await config.decisionStore!.putPending({ toolCallId: call.id, status: 'pending', messages }) + const restarted = createChatController(config) + await restarted.decide('call-2', 'approve') + expect(requests).toHaveLength(0) + await restarted.decide(proposal.id, 'approve') + expect(execute).toHaveBeenCalledTimes(2) + expect(requests).toHaveLength(1) + expect(requests[0].messages.map(message => message.role)).toEqual(['user', 'assistant', 'tool', 'tool', 'assistant']) + expect(requests[0].messages.filter(message => message.role === 'tool').map(message => [message.toolCallId, message.content])).toEqual([[proposal.id, 'stored'], ['call-2', 'stored']]) + expect(restarted.getState().messages.at(-1)?.content).toBe('Finished') + expect(await memory.load()).toEqual(restarted.getState().messages) + }) + + it('replays a denial with a different reason without replacing the recorded reason', async () => { + const { chat, config, execute, requests } = fixture() + await chat.proposeToolCall(proposal) + const first = await chat.decide(proposal.id, 'deny', 'original') + expect(await chat.decide(proposal.id, 'deny', 'different')).toEqual(first) + expect(await config.decisionStore!.get(proposal.id)).toMatchObject({ reason: 'original' }) + expect(execute).not.toHaveBeenCalled() + expect(requests).toHaveLength(1) + }) + it.each([[false, true], [true, true], [false, false]])('preserves later turns when restarted=%s, memory=%s', async (restarted, persisted) => { const memory = persisted ? createInMemoryMemory() : undefined const { chat, config } = fixture({ memory }) diff --git a/packages/core/tests/controller-restart.mjs b/packages/core/tests/controller-restart.mjs index f25ac4c35..0052d8f27 100644 --- a/packages/core/tests/controller-restart.mjs +++ b/packages/core/tests/controller-restart.mjs @@ -11,8 +11,8 @@ if (!mode) { const directory = mkdtempSync(join(tmpdir(), 'core-restart-')) try { for (const format of ['esm', 'cjs']) { - for (const mode of ['propose', 'decide']) { - const child = await runCommand(process.execPath, [fileURLToPath(import.meta.url), mode, format, join(directory, `${format}.json`)]) + for (const mode of ['propose', 'decide', 'stream-propose', 'stream-decide']) { + const child = await runCommand(process.execPath, [fileURLToPath(import.meta.url), mode, format, join(directory, `${format}-${mode.startsWith('stream-') ? 'stream' : 'proposal'}.json`)]) assert.equal(child.code, 0, child.stderr) process.stdout.write(child.stdout) } @@ -46,16 +46,36 @@ if (!mode) { let executions = 0, requests = 0 const chat = createChatController({ decisionStore, + ...(mode.startsWith('stream-') ? { + memory: { + async load() { return existsSync(`${recordPath}.memory`) ? JSON.parse(readFileSync(`${recordPath}.memory`, 'utf8')) : [] }, + async save(messages) { writeFileSync(`${recordPath}.memory`, JSON.stringify(messages)) }, + }, + } : {}), + onToolCall() { + if (mode === 'stream-propose') { + assert.equal(read().messages.at(-1).status, 'streaming') + console.log(JSON.stringify({ mode, format, status: read().status })) + process.exit(0) + } + }, tools: [{ name: 'write', requiresConfirmation: true, execute() { executions++; return 'stored' } }], adapter: { createSource(request) { + if (mode === 'stream-propose') return { + abort() {}, + async *stream() { yield { type: 'tool_call', toolCall: { id: 'restart', name: 'write', args: '{}' } }; yield { type: 'done' } }, + } requests++ assert.equal(request.messages.find(m => m.role === 'tool')?.content, 'stored') return { abort() {}, async *stream() { yield { type: 'text', content: 'Finished' }; yield { type: 'done' } } } }, }, }) - if (mode === 'propose') { + if (mode === 'stream-propose') { + await chat.send('write') + assert.fail('Expected process termination during streaming') + } else if (mode === 'propose') { await chat.proposeToolCall({ id: 'restart', name: 'write', args: {} }) assert.equal(read().status, 'pending') assert.equal(executions, 0) From 703be9dde57b29b55d9827fb9c869418fd227e3f Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Thu, 8 Oct 2026 02:26:01 -0300 Subject: [PATCH 08/10] fix(core): flatten restart recovery conditionals --- packages/core/src/controller.ts | 7 ++++--- packages/core/tests/controller-restart.mjs | 11 +++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/core/src/controller.ts b/packages/core/src/controller.ts index 1bb3e0113..9db2d6bf3 100644 --- a/packages/core/src/controller.ts +++ b/packages/core/src/controller.ts @@ -456,10 +456,11 @@ import type { persist: correlation => persist(state.messages, correlation, true), resume, prepare: async (messages, reconcile) => { const useLive = loadedLive || state.messages.length > 0 || gen !== loadedGeneration - const current = useLive ? state.messages : messages - const next = reconcile(state.status === 'streaming' ? current : current.map(message => + let current = useLive ? state.messages : messages + if (state.status !== 'streaming') current = current.map(message => message.status === 'streaming' ? { ...message, status: 'complete' as const } : message, - )) + ) + const next = reconcile(current) if (!next) return undefined set(current => ({ ...current, messages: next, error: null })) return { generation: gen, correlation: state.status === 'streaming' ? activeCorrelation : beginRun() } diff --git a/packages/core/tests/controller-restart.mjs b/packages/core/tests/controller-restart.mjs index 0052d8f27..5aad4916a 100644 --- a/packages/core/tests/controller-restart.mjs +++ b/packages/core/tests/controller-restart.mjs @@ -44,14 +44,13 @@ if (!mode) { }, } let executions = 0, requests = 0 + const memory = { + async load() { return existsSync(`${recordPath}.memory`) ? JSON.parse(readFileSync(`${recordPath}.memory`, 'utf8')) : [] }, + async save(messages) { writeFileSync(`${recordPath}.memory`, JSON.stringify(messages)) }, + } const chat = createChatController({ decisionStore, - ...(mode.startsWith('stream-') ? { - memory: { - async load() { return existsSync(`${recordPath}.memory`) ? JSON.parse(readFileSync(`${recordPath}.memory`, 'utf8')) : [] }, - async save(messages) { writeFileSync(`${recordPath}.memory`, JSON.stringify(messages)) }, - }, - } : {}), + ...(mode.startsWith('stream-') ? { memory } : {}), onToolCall() { if (mode === 'stream-propose') { assert.equal(read().messages.at(-1).status, 'streaming') From b65f699c046b2034e93d004813a29dd3deb172ce Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Thu, 8 Oct 2026 02:35:51 -0300 Subject: [PATCH 09/10] fix(core): trim target confirmation checks within size budget --- packages/core/src/controller-decision-internal.ts | 2 +- packages/core/src/controller-helpers.ts | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/core/src/controller-decision-internal.ts b/packages/core/src/controller-decision-internal.ts index 519bc9d4a..faec72a1c 100644 --- a/packages/core/src/controller-decision-internal.ts +++ b/packages/core/src/controller-decision-internal.ts @@ -123,7 +123,7 @@ export async function decide( const latest = context.messages() const last = latest[latest.length - 1] if (context.isCurrent(generation) && last?.id === message.id && last?.status !== 'streaming' - && !latest.slice(latest.findIndex(message => message.id === last.id)).some(message => message.toolCalls?.some(call => call.status !== 'complete' && call.status !== 'error'))) { + && !last.toolCalls?.some(call => call.status !== 'complete' && call.status !== 'error')) { await context.resume(message.id, generation, correlation) } return outcome diff --git a/packages/core/src/controller-helpers.ts b/packages/core/src/controller-helpers.ts index 05e7bf5c3..1f87e54b7 100644 --- a/packages/core/src/controller-helpers.ts +++ b/packages/core/src/controller-helpers.ts @@ -235,14 +235,13 @@ export function createControllerToolLoop({ getConfig, getState, getCorrelation, for (let remaining = getConfig().maxToolIterations ?? 5; remaining > 0; remaining--) { const assistant = getState().messages.find(message => message.id === id) const calls = assistant?.toolCalls ?? [] - const waits = getState().messages.slice(getState().messages.findIndex(message => message.id === id)).some(message => message.toolCalls?.some(call => call.status !== 'complete' && call.status !== 'error')) + const waits = calls.some(call => call.status !== 'complete' && call.status !== 'error') // Nothing to feed back, or something still awaiting confirmation — // stop here; the caller drives the next step. if (!calls.length || waits || getState().messages[getState().messages.length - 1]?.id !== id) { - const pending = calls.some(call => call.status !== 'complete' && call.status !== 'error') - await finalize(id, !pending) - if (pending) await persistPending() + await finalize(id, !waits) + if (waits) await persistPending() return } From ba12da2502a06a47be8a5a2de4bca0ee57d429d8 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Thu, 8 Oct 2026 02:37:16 -0300 Subject: [PATCH 10/10] docs(core): clarify decision replay and target confirmation scope --- .doc-bridge/capabilities.json | 2 +- .doc-bridge/index.json | 50 +++++++++---------- .../content/docs/for-agents/core.mdx | 2 +- .../content/docs/reference/packages/core.mdx | 4 +- apps/docs-next/lib/api-symbol-routes.json | 3 ++ .../deterministic-knowledge.generated.json | 6 +-- .../lib/deterministic-site.generated.json | 4 +- ...02cc7503b1fb59614d4ac7e73392b7aec1f7.json} | 6 +-- packages/core/README.md | 4 +- readme-standard-v1.json | 2 +- 10 files changed, 43 insertions(+), 40 deletions(-) rename apps/docs-next/public/deterministic-knowledge/{0f0c04bee4e18b603df0f08ceab4eaffc6e88165420be8b2ea20da60973365d6.json => 131edf7d35dd2646cd567cb15eda02cc7503b1fb59614d4ac7e73392b7aec1f7.json} (99%) diff --git a/.doc-bridge/capabilities.json b/.doc-bridge/capabilities.json index e10b57540..45547a6e6 100644 --- a/.doc-bridge/capabilities.json +++ b/.doc-bridge/capabilities.json @@ -7,7 +7,7 @@ "name": "AgentsKit", "root": "." }, - "contentHash": "2c5250fef5308390d417fa16f257bfba278fb45b09a965c2580261aaa1cb3ab8", + "contentHash": "8efbc15b9798e169144aed1ad9efab22b1418a9053a014ee8806baea29b7f4e0", "artifacts": { "index": ".doc-bridge/index.json", "llmsTxt": ".doc-bridge/llms.txt" diff --git a/.doc-bridge/index.json b/.doc-bridge/index.json index 90d9e25d5..f1028b304 100644 --- a/.doc-bridge/index.json +++ b/.doc-bridge/index.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "contentHash": "2c5250fef5308390d417fa16f257bfba278fb45b09a965c2580261aaa1cb3ab8", + "contentHash": "8efbc15b9798e169144aed1ad9efab22b1418a9053a014ee8806baea29b7f4e0", "contentHashAlgo": "sha256-normalized-v1", - "generatedAt": "2026-10-08T05:16:15.924Z", + "generatedAt": "2026-10-08T05:36:46.849Z", "project": { "name": "AgentsKit", "root": "." @@ -3723,7 +3723,7 @@ "document", "human" ], - "contentHash": "5143366b0bed4e6cccd66a3a37209d9501462252c465da104236cf7e799615e8" + "contentHash": "c0805db69400cae3a7a0abc8f1dbacfc9bae0078798f4a2157df89c22c5bdf32" }, { "id": "document:apps/docs-next/content/docs/reference/packages/cross-platform.mdx", @@ -7022,7 +7022,7 @@ "document", "project" ], - "contentHash": "1f09c88450c32e1aaea4e0211bad1d2a12763d713b54728af44541a5b785f51d" + "contentHash": "5f3bb79e91401ee25cb9dcda785dea22b99d3605c5f62c528fc3623f8f3965b4" }, { "id": "document:packages/cross-platform/CONVENTIONS.md", @@ -18877,7 +18877,7 @@ "ts", "src" ], - "contentHash": "2505516631cdb1290c9610e560d4a032913442ba8ca65c802c5c81f1bad55afa" + "contentHash": "79928bb33e5ed6a967353e3914694dbc928d90c8ed1f86c75500c373427a9518" }, { "id": "module:packages/core/src/controller-helpers.ts", @@ -18902,7 +18902,7 @@ "ts", "src" ], - "contentHash": "cff73c65129da037d8fc2c7e33370d629c133aed78e4a4d9332a6ad829d7ddac" + "contentHash": "b8e745c83c267b54438be20d3243e7a2c463017e7a62926a994b1a695a354d48" }, { "id": "module:packages/core/src/controller-persistence.ts", @@ -18948,7 +18948,7 @@ "ts", "src" ], - "contentHash": "3b5441f090877efd87124f3edff62509096d4d02440044938b76aeb411415b36" + "contentHash": "c64ffe19da240db2d517cc5782e37e87a8defe6364fff4ae0c4c340d81a1ac60" }, { "id": "module:packages/core/src/errors.ts", @@ -20264,7 +20264,7 @@ "test", "tests" ], - "contentHash": "5239d92d01a9c878b709cc324be7d49b80b045f31b8612159636c5df0ac95a31" + "contentHash": "fab8069b6983f1d8da3218ab536ad9f7ecdd6c87f93d398357819d70218da4b0" }, { "id": "module:packages/core/tests/controller-edit-regenerate.test.ts", @@ -20315,7 +20315,7 @@ "mjs", "tests" ], - "contentHash": "376489948ab4d4b2d5941f97d5110ff3b72949467f8f3f41423984f0b750443a" + "contentHash": "7bc64ff6c8b16285878dad1847c17d258396f5509dc32feee41e240ccc473b2b" }, { "id": "module:packages/core/tests/controller-tool-call.test.ts", @@ -38296,7 +38296,7 @@ "changes": {} }, "inputs": { - "hash": "05fae1ee189bfa99666b0c06c320f1349af9fbc7e64e9a65c9bb222df659d658", + "hash": "743a3f6ba78828b3446b96d6e1bd1d91559448c65fdcaf092a9c27daec5bb91a", "fileCount": 2546, "projectionVersion": 2 }, @@ -38319,9 +38319,9 @@ "projection": { "type": "retrieval-index", "schemaVersion": 1, - "contentHash": "8fac938565594f5786708edf8783c94236f18a8e9323178032a9c9b786ab8a78", + "contentHash": "7eaaccf8e662f3f69d83cd6b10d747f392e82339268008f475c09b99a80d4f67", "contentHashAlgo": "sha256-normalized-v1", - "snapshotHash": "adbfaef634fa27797a4ae6f547e964ae9c35696783e53379bf92644ddafa1506", + "snapshotHash": "e2f485f514c517eb77ebaade41b6c6342a13e6b36c6a6b0d91a8cd677a4f3b9c", "overlayHash": "f99666ff9119084dd25c2974ae64eb6935ec2aa785ff5ad478fb9d541f7a5e94", "configurationHash": "9662cc07ea4ab203ff2ddf457d2db48b5375a7ab4a1f8fbc79dfebcaa8f9a00c", "lexiconVersion": 1, @@ -38353,7 +38353,7 @@ ], "averageFieldLength": { "aliases": 3.0139944022391045, - "body": 61.63374650139944, + "body": 61.61895241903239, "headings": 4.034386245501799, "path": 7.611755297880848, "summary": 4.562574970011995, @@ -51098,7 +51098,7 @@ "path": "apps/docs-next/content/docs/for-agents/core.mdx", "symbols": "", "summary": "Stable TypeScript contracts (Adapter, Tool, Memory, Retriever, Skill, Runtime) + `createChatController` + primitives. Target: <10 KB gzipped, zero runtime deps.", - "body": "Purpose Stable TypeScript contracts (Adapter, Tool, Memory, Retriever, Skill, Runtime) + createChatController + primitives. Target: <10 KB gzipped, zero runtime deps. Install Primary exports (main entry) - createChatController(config) — state machine behind every UI - proposeToolCall(proposal) — controller method that places a trusted, validated action into the same confirmation lifecycle; it never executes directly. The standalone helper is available from @agentskit/core/tool-proposal . Returns { getState, subscribe, send, stop, retry, edit, regenerate, setInput, clear, decide, approve, deny, updateConfig } . - defineTool(config) — typed tool factory with JSON-Schema input inference. - controller.send(string ContentPart[]) preserves multimodal parts and text projection. - controller.decide(id, 'approve' 'deny', reason?) uses ChatConfig.decisionStore to validate the current conversation before claiming, prioritizing live controller state over memory and preserving unsaved later turns and using the pending snapshot only as a fallback ( ToolDecisionStore , ToolDecisionRecord ). The store provides insert-if-absent putPending , linearizable get , atomic pending-to-claimed claim , and matching-claim terminal settle . Scope it to an application-authorized conversation. Same-decision terminal replay returns the recorded tool outcome; unknown IDs raise AK ACTION NOT FOUND , conflicting decisions raise AK ACTION ALREADY DECIDED . Failed and claimed records are never automatically retried. Serialize different decisions per conversation to protect transcript writes. - Identical decisions are idempotent even while claimed: they return the recorded outcome, or the original call while execution is in flight, without invoking the tool or model again. Streamed confirmations are registered before onToolCall . After restart, streamed decisions require current ChatMemory : arrival snapshots are immutable and may omit later calls; without a current conversation, AK CONFIG INVALID rejects an incomplete streaming snapshot before claiming. A live controller does not require memory. - approve / deny are deprecated. With a decision store they delegate to decide , including atomic claims and typed errors; without a store they preserve generation-local behavior. - ChatConfig.authorizeToolCall — trusted host authorization before tool proposal and again before execution. Callback failures default to deny. - createInMemoryMemory / createLocalStorageMemory — default ChatMemory implementations. - createStaticRetriever / formatRetrievedDocuments — RAG-less retrieval using token substring overlap; use @agentskit/rag for semantic production retrieval. - executeToolCall , consumeStream , createEventEmitter , parseToolArgs , safeParseArgs , isRecord , createId , generateId , buildMessage — low-level helpers. parseToolArgs returns { args, valid } so adapters can fail closed; safeParseArgs remains the compatibility value-only parser. IDs use crypto.randomUUID() and retain the caller-provided prefix. - isRecord(value) accepts non-null objects other than arrays, including Date , Map , and class instances; use a schema validator when plain JSON objects are required. - AgentsKitError , AdapterError , ToolError , MemoryError , ConfigError , RuntimeError , SandboxError , SkillError , ErrorCodes — error taxonomy. - Token budget: compileBudget , approximateCounter . - Progressive tool args: createProgressiveArgParser , executeToolProgressively . - createVirtualizedMemory — hot-window + cold-retriever wrapper (also re-exported from @agentskit/memory ). - Untrusted-input fencing lives in the @agentskit/core/security entry: fenceUntrustedContent(content, opts?) + UNTRUSTED CONTENT DIRECTIVE wrap attacker-influenced text so the model treats it as data, not instructions (the mitigation companion to createInjectionDetector ). - Multi-modal content parts: textPart , imagePart , audioPart , videoPart , filePart , partsToText , normalizeContent , filterParts . - Agent-loop inter", + "body": "Purpose Stable TypeScript contracts (Adapter, Tool, Memory, Retriever, Skill, Runtime) + createChatController + primitives. Target: <10 KB gzipped, zero runtime deps. Install Primary exports (main entry) - createChatController(config) — state machine behind every UI - proposeToolCall(proposal) — controller method that places a trusted, validated action into the same confirmation lifecycle; it never executes directly. The standalone helper is available from @agentskit/core/tool-proposal . Returns { getState, subscribe, send, stop, retry, edit, regenerate, setInput, clear, decide, approve, deny, updateConfig } . - defineTool(config) — typed tool factory with JSON-Schema input inference. - controller.send(string ContentPart[]) preserves multimodal parts and text projection. - controller.decide(id, 'approve' 'deny', reason?) uses ChatConfig.decisionStore to validate the current conversation before claiming, prioritizing live controller state over memory and preserving unsaved later turns and using the pending snapshot only as a fallback ( ToolDecisionStore , ToolDecisionRecord ). The store provides insert-if-absent putPending , linearizable get , atomic pending-to-claimed claim , and matching-claim terminal settle . Scope it to an application-authorized conversation. Idempotency compares only the decision, retaining the original denial reason. Same-decision terminal replay returns the recorded tool outcome; unknown IDs raise AK ACTION NOT FOUND , conflicting decisions or targets that are no longer pending raise AK ACTION ALREADY DECIDED . Failed and claimed records are never automatically retried. Serialize different decisions per conversation to protect transcript writes. - Identical decisions are idempotent even while claimed: they return the recorded outcome, or the original call while execution is in flight, without invoking the tool or model again. Streamed confirmations are registered before onToolCall . After restart, streamed decisions require current ChatMemory : arrival snapshots are immutable and may omit later calls; without a current conversation, AK CONFIG INVALID rejects an incomplete streaming snapshot before claiming. A live controller does not require memory. - approve / deny are deprecated. With a decision store they delegate to decide , including atomic claims and typed errors; without a store they preserve generation-local behavior. - ChatConfig.authorizeToolCall — trusted host authorization before tool proposal and again before execution. Callback failures default to deny. - createInMemoryMemory / createLocalStorageMemory — default ChatMemory implementations. - createStaticRetriever / formatRetrievedDocuments — RAG-less retrieval using token substring overlap; use @agentskit/rag for semantic production retrieval. - executeToolCall , consumeStream , createEventEmitter , parseToolArgs , safeParseArgs , isRecord , createId , generateId , buildMessage — low-level helpers. parseToolArgs returns { args, valid } so adapters can fail closed; safeParseArgs remains the compatibility value-only parser. IDs use crypto.randomUUID() and retain the caller-provided prefix. - isRecord(value) accepts non-null objects other than arrays, including Date , Map , and class instances; use a schema validator when plain JSON objects are required. - AgentsKitError , AdapterError , ToolError , MemoryError , ConfigError , RuntimeError , SandboxError , SkillError , ErrorCodes — error taxonomy. - Token budget: compileBudget , approximateCounter . - Progressive tool args: createProgressiveArgParser , executeToolProgressively . - createVirtualizedMemory — hot-window + cold-retriever wrapper (also re-exported from @agentskit/memory ). - Untrusted-input fencing lives in the @agentskit/core/security entry: fenceUntrustedContent(content, opts?) + UNTRUSTED CONTENT DIRECTIVE wrap attacker-influenced text so the model treats it as data, not instructions (the mitigation companion to createInjectionDetector ). - Multi-modal content parts: textPar", "aliases": "core document agent" }, "graph": { @@ -51245,7 +51245,7 @@ } ] }, - "contentHash": "e7e2adc0cc12756855e4052c6a6253c126eaae4ee4587c5c0ad658b05915c8e2", + "contentHash": "5a6c5ed5dcee2a4368afb53deb724ff0f189fe8008b742912f46fd3d409799a9", "provenance": "observed", "confidence": "observed" }, @@ -59985,7 +59985,7 @@ "path": "apps/docs-next/content/docs/reference/packages/core.mdx", "symbols": "", "summary": "import { ContributeCallout } from '@/components/contribute/contribute-callout'", - "body": "import { ContributeCallout } from '@/components/contribute/contribute-callout' The shared contract layer for AgentsKit: TypeScript types, the headless chat controller, stream helpers, and building blocks used by @agentskit/react , @agentskit/ink , @agentskit/runtime , and adapters. No third-party runtime dependencies — keep this package small and stable. When to use - You implement a custom adapter , tool, memory, or UI on top of official types. - You need createChatController without React (advanced integrations). - You want to understand messages, tool calls, and stream chunks across the ecosystem. You usually do not import core directly in a typical React app except for types — prefer and . Install Most feature packages already depend on core ; you add it explicitly when authoring libraries or sharing types. Public exports (overview) Chat controller and config Export Role -------- ------ createChatController Headless state machine: send, stream, tools, memory, skills, retriever Types: ChatConfig , ChatController , ChatState , ChatReturn Configuration and controller shape The controller merges system prompts, runs retrieval, dispatches tool calls, saves via ChatMemory after successful turns, skips persistence for failed or aborted turns, surfaces background memory and skill activation failures through onError , and exposes subscribe/update patterns consumed by UI packages. Durable decisions and multimodal input controller.send accepts string ContentPart[] . User messages retain their parts and a partsToText projection in content ; provider support remains adapter-specific. ChatReturn.send has the same widened signature. Implementers of ChatController must supply the required decide member and accept parts in send . Configure ChatConfig.decisionStore with a conversation-scoped ToolDecisionStore to use await controller.decide(toolCallId, 'approve') or await controller.decide(toolCallId, 'deny', reason) . Pending message snapshots survive controller replacement and process restart through that store. Configured chat memory also receives pending snapshots before proposal completion. ToolDecisionRecord stores toolCallId , messages , status ( pending , claimed , complete , failed , denied ), and optional decision , reason , and outcome . Implement putPending as insert-if-absent, get as a linearizable read, claim as an atomic pending-to-claimed transition returning the snapshot only to its winner, and settle as a durable terminal write for the matching claim. A database implementation can use UPDATE ... WHERE status = 'pending' RETURNING ... . The application must scope access to the authorized conversation. Before claiming, the controller loads and validates the current conversation, prioritizing live state (including unsaved turns), then configured chat memory, then the stored snapshot when neither exists. It preserves later messages and patches the target call, revalidates and reauthorizes the tool, persists its outcome, and resumes the model only for the latest completed assistant message when all confirmations are decided. Tool results follow their originating assistant message in call order. Same-decision terminal replay returns the stored tool outcome without another tool or model invocation. Unknown IDs raise AK ACTION NOT FOUND ; conflicting decisions raise AK ACTION ALREADY DECIDED . Tool failures become terminal failed records and reach the model as tool errors. A new attempt needs a new proposal ID. For a stuck claimed record, inspect get(id) and domain execution evidence, stop the original worker, and terminally settle the matching claim with a proven outcome or explicit indeterminate failure. Never reset or delete the claim. Reconcile the target call in current chat memory and continue with a new user turn; a retry requires a separately authorized new proposal ID. See . A crash after claim leaves an indeterminate claimed record requiring application reconciliation; core never automatically retries side effects. Serial", + "body": "import { ContributeCallout } from '@/components/contribute/contribute-callout' The shared contract layer for AgentsKit: TypeScript types, the headless chat controller, stream helpers, and building blocks used by @agentskit/react , @agentskit/ink , @agentskit/runtime , and adapters. No third-party runtime dependencies — keep this package small and stable. When to use - You implement a custom adapter , tool, memory, or UI on top of official types. - You need createChatController without React (advanced integrations). - You want to understand messages, tool calls, and stream chunks across the ecosystem. You usually do not import core directly in a typical React app except for types — prefer and . Install Most feature packages already depend on core ; you add it explicitly when authoring libraries or sharing types. Public exports (overview) Chat controller and config Export Role -------- ------ createChatController Headless state machine: send, stream, tools, memory, skills, retriever Types: ChatConfig , ChatController , ChatState , ChatReturn Configuration and controller shape The controller merges system prompts, runs retrieval, dispatches tool calls, saves via ChatMemory after successful turns, skips persistence for failed or aborted turns, surfaces background memory and skill activation failures through onError , and exposes subscribe/update patterns consumed by UI packages. Durable decisions and multimodal input controller.send accepts string ContentPart[] . User messages retain their parts and a partsToText projection in content ; provider support remains adapter-specific. ChatReturn.send has the same widened signature. Implementers of ChatController must supply the required decide member and accept parts in send . Configure ChatConfig.decisionStore with a conversation-scoped ToolDecisionStore to use await controller.decide(toolCallId, 'approve') or await controller.decide(toolCallId, 'deny', reason) . Pending message snapshots survive controller replacement and process restart through that store. Configured chat memory also receives pending snapshots before proposal completion. ToolDecisionRecord stores toolCallId , messages , status ( pending , claimed , complete , failed , denied ), and optional decision , reason , and outcome . Implement putPending as insert-if-absent, get as a linearizable read, claim as an atomic pending-to-claimed transition returning the snapshot only to its winner, and settle as a durable terminal write for the matching claim. A database implementation can use UPDATE ... WHERE status = 'pending' RETURNING ... . The application must scope access to the authorized conversation. Before claiming, the controller loads and validates the current conversation, prioritizing live state (including unsaved turns), then configured chat memory, then the stored snapshot when neither exists. It preserves later messages and patches the target call, revalidates and reauthorizes the tool, persists its outcome, and resumes the model only for the latest completed assistant message when all confirmations in that target assistant message are decided. Tool results follow their originating assistant message in call order. Idempotency compares the decision only; a repeated denial with a different reason retains the original reason and outcome. Same-decision terminal replay returns the stored tool outcome without another tool or model invocation. Unknown IDs raise AK ACTION NOT FOUND ; conflicting decisions or targets that are no longer pending raise AK ACTION ALREADY DECIDED . Tool failures become terminal failed records and reach the model as tool errors. A new attempt needs a new proposal ID. For a stuck claimed record, inspect get(id) and domain execution evidence, stop the original worker, and terminally settle the matching claim with a proven outcome or explicit indeterminate failure. Never reset or delete the claim. Reconcile the target call in current chat memory and continue with a new user turn; a retry requires a s", "aliases": "document human" }, "graph": { @@ -60127,7 +60127,7 @@ } ] }, - "contentHash": "5143366b0bed4e6cccd66a3a37209d9501462252c465da104236cf7e799615e8", + "contentHash": "c0805db69400cae3a7a0abc8f1dbacfc9bae0078798f4a2157df89c22c5bdf32", "provenance": "observed", "confidence": "observed" }, @@ -78081,7 +78081,7 @@ "path": "packages/core/README.md", "symbols": "", "summary": "Profile: major-package", - "body": "@agentskit/core Profile: