fix: accumulate tool call arguments even when toolCallId is missing (#413)

This commit is contained in:
Cameron
2026-02-26 15:03:47 -08:00
committed by GitHub
parent d11777a1a2
commit 723ba2670f
2 changed files with 55 additions and 2 deletions

View File

@@ -1289,15 +1289,31 @@ export class LettaBot implements AgentSession {
yield { ...pending.msg, toolInput };
}
pendingToolCalls.clear();
lastPendingToolCallId = null;
}
let anonToolCallCounter = 0;
let lastPendingToolCallId: string | null = null;
async function* dedupedStream(): AsyncGenerator<StreamMsg> {
for await (const raw of session.stream()) {
const msg = raw as StreamMsg;
if (msg.type === 'tool_call') {
const id = msg.toolCallId;
if (!id) { yield msg; continue; }
let id = msg.toolCallId;
if (!id) {
// Tool calls without IDs (e.g., from models that don't emit
// tool_call_id on subsequent argument chunks) still need to be
// accumulated. Assign a synthetic ID so they enter the buffer.
// If tool name matches the most recent pending call, treat this as
// a continuation even when the first chunk had a real toolCallId.
const currentPending = lastPendingToolCallId ? pendingToolCalls.get(lastPendingToolCallId) : null;
if (lastPendingToolCallId && currentPending && (currentPending.msg.toolName || 'unknown') === (msg.toolName || 'unknown')) {
id = lastPendingToolCallId;
} else {
id = `__anon_${++anonToolCallCounter}__`;
}
}
const incoming = (msg as StreamMsg & { rawArguments?: string }).rawArguments || '';
const existing = pendingToolCalls.get(id);
@@ -1306,6 +1322,7 @@ export class LettaBot implements AgentSession {
} else {
pendingToolCalls.set(id, { msg, accumulatedArgs: incoming });
}
lastPendingToolCallId = id;
continue; // buffer, don't yield yet
}

View File

@@ -122,6 +122,42 @@ describe('SDK session contract', () => {
expect(mockSession.stream).toHaveBeenCalledTimes(2);
});
it('accumulates tool_call arguments when continuation chunks omit toolCallId', async () => {
const mockSession = {
initialize: vi.fn(async () => undefined),
send: vi.fn(async (_message: unknown) => undefined),
stream: vi.fn(() =>
(async function* () {
yield { type: 'tool_call', toolCallId: 'tc-1', toolName: 'Bash', rawArguments: '{"command":"ec' };
yield { type: 'tool_call', toolName: 'Bash', rawArguments: 'ho hi"}' };
yield { type: 'assistant', content: 'done' };
yield { type: 'result', success: true };
})()
),
close: vi.fn(() => undefined),
agentId: 'agent-contract-test',
conversationId: 'conversation-contract-test',
};
vi.mocked(createSession).mockReturnValue(mockSession as never);
vi.mocked(resumeSession).mockReturnValue(mockSession as never);
const bot = new LettaBot({
workingDir: join(dataDir, 'working'),
allowedTools: [],
});
const chunks: Array<Record<string, unknown>> = [];
for await (const msg of bot.streamToAgent('test')) {
chunks.push(msg as Record<string, unknown>);
}
const toolCalls = chunks.filter((m) => m.type === 'tool_call');
expect(toolCalls).toHaveLength(1);
expect(toolCalls[0].toolCallId).toBe('tc-1');
expect(toolCalls[0].toolInput).toEqual({ command: 'echo hi' });
});
it('closes session if initialize times out before first send', async () => {
process.env.LETTA_SESSION_TIMEOUT_MS = '5';