From d91830ac7dfc3a8dbc532dae63e1dd2087beaee9 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Sun, 13 Sep 2026 11:39:07 +0200 Subject: [PATCH 1/2] fix(sdk): tell a tool which call it is executing `Agent.executeToolCall` captures `agentState` before it emits `tool_started`, and the reducer clears `executingToolCall` when a call completes. A tool that read `context.agentState.executingToolCall?.toolCallId` therefore always got `undefined`. Any tool that keys state on its own call id (for example, to point a later result back at an earlier one in the conversation) silently never did so. `ToolContext` now carries `toolCallId` explicitly, set from the call that is actually executed. Also export the `DebounceCallback`, `DebounceContext` and `DebounceDecision` types, so a preset can type its own `debounceCallback` without reaching into `src/`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012cebkT9kA2XbNtbdLM3HFt --- .../src/core/agent-loop.integration.test.ts | 46 +++++++++++++++++++ packages/sdk/src/core/agents/agent.ts | 1 + packages/sdk/src/core/tools/context.ts | 6 ++- packages/sdk/src/core/tools/executor.test.ts | 2 + packages/sdk/src/index.ts | 1 + 5 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/core/agent-loop.integration.test.ts b/packages/sdk/src/core/agent-loop.integration.test.ts index 4a50dd55..70cd2337 100644 --- a/packages/sdk/src/core/agent-loop.integration.test.ts +++ b/packages/sdk/src/core/agent-loop.integration.test.ts @@ -209,6 +209,52 @@ describe('agent processing loop', () => { await harness.shutdown() }) + + const createCallIdPreset = (seen: string[]): Preset => ({ + id: 'test', + name: 'Test Preset', + orchestrator: { + system: 'You are a test agent.', + model: ModelId('mock'), + tools: [ + createTool({ + name: 'record_call_id', + description: 'Records the call id it runs under', + input: z.unknown(), + execute: async (_input, context) => { + seen.push(context.toolCallId) + return { ok: true, value: 'recorded' } + }, + }), + ], + agents: [], + debounceMs: 0, + }, + agents: [], + }) + + it('tool context carries the id of the call being executed', async () => { + const seen: string[] = [] + const harness = new TestHarness({ + presets: [createCallIdPreset(seen)], + llmProvider: MockLLMProvider.withSequence([ + { + toolCalls: [ + { id: ToolCallId('tc1'), name: 'record_call_id', input: {} }, + { id: ToolCallId('tc2'), name: 'record_call_id', input: {} }, + ], + }, + { content: 'Done', toolCalls: [] }, + ]), + }) + + const session = await harness.createSession('test') + await session.sendAndWaitForIdle('Record') + + expect(seen).toEqual(['tc1', 'tc2']) + + await harness.shutdown() + }) }) // ========================================================================= diff --git a/packages/sdk/src/core/agents/agent.ts b/packages/sdk/src/core/agents/agent.ts index 3f267fd3..09ffb76d 100644 --- a/packages/sdk/src/core/agents/agent.ts +++ b/packages/sdk/src/core/agents/agent.ts @@ -1099,6 +1099,7 @@ export class Agent { const context: ToolContext = { ...this.buildAgentContext(agentState), logger: this.logger.child({ toolName: toolCall.name }), + toolCallId: effectiveToolCall.id, } const result = await this.toolExecutor.execute(tool, effectiveToolCall.input, context) diff --git a/packages/sdk/src/core/tools/context.ts b/packages/sdk/src/core/tools/context.ts index 6604174c..a069e8b8 100644 --- a/packages/sdk/src/core/tools/context.ts +++ b/packages/sdk/src/core/tools/context.ts @@ -1,3 +1,7 @@ import type { AgentContext } from '../agents/context.js' +import type { ToolCallId } from './schema.js' -export type ToolContext = AgentContext +export type ToolContext = AgentContext & { + /** The call being executed. `agentState` is captured before it starts, so it cannot tell you this. */ + toolCallId: ToolCallId +} diff --git a/packages/sdk/src/core/tools/executor.test.ts b/packages/sdk/src/core/tools/executor.test.ts index dcc0ca80..e63ff314 100644 --- a/packages/sdk/src/core/tools/executor.test.ts +++ b/packages/sdk/src/core/tools/executor.test.ts @@ -14,6 +14,7 @@ import { SessionFileStore } from '../file-store/file-store.js' import type { ToolContext } from './context.js' import { createTool } from './definition.js' import { ToolExecutor } from './executor.js' +import { ToolCallId } from './schema.js' // ============================================================================ // Test Helpers @@ -60,6 +61,7 @@ const createTestContext = (): ToolContext => { input: undefined, parentId: null, runAuxiliaryInference: async () => Err({ type: 'invalid_request', message: 'not implemented in test' }), + toolCallId: ToolCallId('test-call'), } } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index b1685519..39cb55ac 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -39,6 +39,7 @@ export type { AgentTransportConfig, IAgentTransport, PluginNotification } from ' // Runtime export { Agent } from '~/core/agents/agent.js' export type { AgentConfig } from '~/core/agents/agent.js' +export type { DebounceCallback, DebounceContext, DebounceDecision } from '~/core/agents/debounce.js' export { SessionManager } from '~/core/sessions/session-manager.js' export type { SessionManagerOptions } from '~/core/sessions/session-manager.js' export { SessionRuntimeDetachedError, SessionStore } from '~/core/sessions/session-store.js' From 2350be9460d8eaff93045b2faf22ec6f43ff0f3e Mon Sep 17 00:00:00 2001 From: David Matejka Date: Sun, 13 Sep 2026 11:46:00 +0200 Subject: [PATCH 2/2] fix(sdk): let a debounce callback see input that is not in the mailbox `DebounceContext.messages` and `totalPending` read the mailbox alone. A user chat message is delivered through the user-chat plugin's dequeue, so a callback deciding to wait for a child agent's report could not see that the user had written, and held the message until its own timeout ran out. The context now carries `hasPendingInput`, the same check the agent already makes before calling the callback, and `waitForResponseDebounceCallback` processes immediately when it is set. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012cebkT9kA2XbNtbdLM3HFt --- .../src/core/agent-loop.integration.test.ts | 31 +++++++++++++++++++ .../sdk/src/core/agents/agent-config.test.ts | 7 +++++ packages/sdk/src/core/agents/agent.ts | 5 ++- packages/sdk/src/core/agents/debounce.ts | 4 ++- 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/sdk/src/core/agent-loop.integration.test.ts b/packages/sdk/src/core/agent-loop.integration.test.ts index 70cd2337..eb83a897 100644 --- a/packages/sdk/src/core/agent-loop.integration.test.ts +++ b/packages/sdk/src/core/agent-loop.integration.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'bun:test' import z from 'zod/v4' +import type { DebounceContext } from '~/core/agents/debounce.js' import { agentEvents } from '~/core/agents/state.js' import { MockLLMProvider } from '~/core/llm/mock.js' import { ModelId } from '~/core/llm/schema.js' @@ -255,6 +256,36 @@ describe('agent processing loop', () => { await harness.shutdown() }) + + it('debounce callback sees a user message that is not in the mailbox', async () => { + const contexts: DebounceContext[] = [] + const preset: Preset = { + id: 'test', + name: 'Test Preset', + orchestrator: { + system: 'You are a test agent.', + model: ModelId('mock'), + tools: [], + agents: [], + debounceCallback: (context) => { + contexts.push(context) + return 'process_now' + }, + }, + agents: [], + } + const harness = new TestHarness({ + presets: [preset], + llmProvider: MockLLMProvider.withFixedResponse({ content: 'Hello back!', toolCalls: [] }), + }) + + const session = await harness.createSession('test') + await session.sendAndWaitForIdle('Hello') + + expect(contexts.some((c) => c.hasPendingInput && c.totalPending === 0)).toBe(true) + + await harness.shutdown() + }) }) // ========================================================================= diff --git a/packages/sdk/src/core/agents/agent-config.test.ts b/packages/sdk/src/core/agents/agent-config.test.ts index 8117f2cd..ea28e4f0 100644 --- a/packages/sdk/src/core/agents/agent-config.test.ts +++ b/packages/sdk/src/core/agents/agent-config.test.ts @@ -40,6 +40,7 @@ describe('Debounce Callbacks', () => { oldestWaitingMs, totalPending: messageCount, pendingToolResults, + hasPendingInput: messageCount > 0, }) describe('defaultDebounceCallback', () => { @@ -140,6 +141,11 @@ describe('Debounce Callbacks', () => { expect(waitForResponseDebounceCallback(context)).toBe('wait') }) + test("returns 'process_now' for input outside the mailbox, such as a user chat message", () => { + const context = { ...createContext(0, 0, [createToolResult('send_message', 1000)]), hasPendingInput: true } + expect(waitForResponseDebounceCallback(context)).toBe('process_now') + }) + test("returns 'wait' for start_* agent tool", () => { const context = createContext(0, 0, [ createToolResult('start_researcher', 1000), @@ -225,6 +231,7 @@ describe('Debounce Callbacks', () => { messages: [], oldestWaitingMs: 0, totalPending: 0, + hasPendingInput: false, pendingToolResults: [ { toolCallId: ToolCallId('tc-1'), toolName: 'send_message', timestamp: now - 59000, isError: false, content: '' }, { toolCallId: ToolCallId('tc-2'), toolName: 'send_message', timestamp: now - 30000, isError: false, content: '' }, diff --git a/packages/sdk/src/core/agents/agent.ts b/packages/sdk/src/core/agents/agent.ts index 09ffb76d..334afb8b 100644 --- a/packages/sdk/src/core/agents/agent.ts +++ b/packages/sdk/src/core/agents/agent.ts @@ -453,8 +453,10 @@ export class Agent { const unconsumed = getUnconsumedMessages(sessionState, this.id) const pendingToolResults = currentState.pendingToolResults + const hasPendingInput = this.hasPluginPendingMessages() + // If no messages, no pending tool results, and no plugin pending, nothing to do - if (unconsumed.length === 0 && pendingToolResults.length === 0 && !this.hasPluginPendingMessages()) { + if (unconsumed.length === 0 && pendingToolResults.length === 0 && !hasPendingInput) { this.cancelSchedule() return } @@ -469,6 +471,7 @@ export class Agent { oldestWaitingMs, totalPending: unconsumed.length, pendingToolResults, + hasPendingInput, }) // Re-check after async callback — schedule could be cancelled during await diff --git a/packages/sdk/src/core/agents/debounce.ts b/packages/sdk/src/core/agents/debounce.ts index 5e42794f..24f7d04e 100644 --- a/packages/sdk/src/core/agents/debounce.ts +++ b/packages/sdk/src/core/agents/debounce.ts @@ -13,6 +13,8 @@ export interface DebounceContext { totalPending: number /** Pending tool results awaiting LLM processing */ pendingToolResults: PendingToolResult[] + /** Whether any dequeue source holds input for this agent. `messages` is the mailbox alone, so a user chat message shows up only here. */ + hasPendingInput: boolean } /** @@ -76,7 +78,7 @@ export const batchingDebounceCallback: DebounceCallback = (context) => { */ export const waitForResponseDebounceCallback: DebounceCallback = (context) => { // If we have new messages, process immediately - if (context.totalPending > 0) { + if (context.totalPending > 0 || context.hasPendingInput) { return 'process_now' }