diff --git a/packages/sdk/src/core/agent-loop.integration.test.ts b/packages/sdk/src/core/agent-loop.integration.test.ts index 4a50dd55..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' @@ -209,6 +210,82 @@ 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() + }) + + 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 3f267fd3..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 @@ -1099,6 +1102,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/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' } 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'