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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions packages/sdk/src/core/agent-loop.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()
})
})

// =========================================================================
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/src/core/agents/agent-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ describe('Debounce Callbacks', () => {
oldestWaitingMs,
totalPending: messageCount,
pendingToolResults,
hasPendingInput: messageCount > 0,
})

describe('defaultDebounceCallback', () => {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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: '' },
Expand Down
6 changes: 5 additions & 1 deletion packages/sdk/src/core/agents/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -469,6 +471,7 @@ export class Agent {
oldestWaitingMs,
totalPending: unconsumed.length,
pendingToolResults,
hasPendingInput,
})

// Re-check after async callback — schedule could be cancelled during await
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion packages/sdk/src/core/agents/debounce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

/**
Expand Down Expand Up @@ -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'
}

Expand Down
6 changes: 5 additions & 1 deletion packages/sdk/src/core/tools/context.ts
Original file line number Diff line number Diff line change
@@ -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
}
2 changes: 2 additions & 0 deletions packages/sdk/src/core/tools/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'),
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading