fix: accumulate tool call arguments even when toolCallId is missing (#413)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user