fix(conversation): implement proper tool loop for Claude API

- Fix streaming JSON parsing for tool inputs by accumulating partial JSON
  and parsing only on content_block_stop
- Implement proper tool loop to continue conversation after tool execution
- Send tool results back to Claude to get final response
- Add safety limit of 10 iterations for tool loops

This fixes the issue where AI responses were truncated after using tools
like search_knowledge.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-09 21:29:18 -08:00
parent 141b45bec2
commit 72e67fa5d9
1 changed files with 135 additions and 67 deletions

View File

@ -69,7 +69,7 @@ export class ClaudeAgentService implements OnModuleInit {
} }
/** /**
* Send a message and get streaming response * Send a message and get streaming response with tool loop support
*/ */
async *sendMessage( async *sendMessage(
message: string, message: string,
@ -97,6 +97,13 @@ export class ClaudeAgentService implements OnModuleInit {
content: message, content: message,
}); });
// Tool loop - continue until we get a final response (no tool use)
const maxIterations = 10; // Safety limit
let iterations = 0;
while (iterations < maxIterations) {
iterations++;
try { try {
// Create streaming message // Create streaming message
const stream = await this.client.messages.stream({ const stream = await this.client.messages.stream({
@ -110,69 +117,130 @@ export class ClaudeAgentService implements OnModuleInit {
let currentToolUse: { let currentToolUse: {
id: string; id: string;
name: string; name: string;
inputJson: string;
input: Record<string, unknown>; input: Record<string, unknown>;
} | null = null; } | null = null;
// Collect all tool uses and text blocks in this response
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = [];
const assistantContent: Anthropic.ContentBlockParam[] = [];
let hasText = false;
for await (const event of stream) { for await (const event of stream) {
if (event.type === 'content_block_start') { if (event.type === 'content_block_start') {
if (event.content_block.type === 'tool_use') { if (event.content_block.type === 'tool_use') {
currentToolUse = { currentToolUse = {
id: event.content_block.id, id: event.content_block.id,
name: event.content_block.name, name: event.content_block.name,
inputJson: '',
input: {}, input: {},
}; };
} }
} else if (event.type === 'content_block_delta') { } else if (event.type === 'content_block_delta') {
if (event.delta.type === 'text_delta') { if (event.delta.type === 'text_delta') {
hasText = true;
yield { yield {
type: 'text', type: 'text',
content: event.delta.text, content: event.delta.text,
}; };
} else if (event.delta.type === 'input_json_delta' && currentToolUse) { } else if (event.delta.type === 'input_json_delta' && currentToolUse) {
// Accumulate tool input currentToolUse.inputJson += event.delta.partial_json || '';
try {
const partialInput = JSON.parse(event.delta.partial_json || '{}');
currentToolUse.input = {
...currentToolUse.input,
...partialInput,
};
} catch {
// Ignore parse errors for partial JSON
}
} }
} else if (event.type === 'content_block_stop') { } else if (event.type === 'content_block_stop') {
if (currentToolUse) { if (currentToolUse) {
// Parse the complete accumulated JSON
try {
currentToolUse.input = JSON.parse(currentToolUse.inputJson || '{}');
} catch (e) {
console.error('Failed to parse tool input JSON:', currentToolUse.inputJson, e);
currentToolUse.input = {};
}
toolUses.push({
id: currentToolUse.id,
name: currentToolUse.name,
input: currentToolUse.input,
});
yield { yield {
type: 'tool_use', type: 'tool_use',
toolName: currentToolUse.name, toolName: currentToolUse.name,
toolInput: currentToolUse.input, toolInput: currentToolUse.input,
}; };
// Execute the tool currentToolUse = null;
const toolResult = await this.immigrationToolsService.executeTool( }
currentToolUse.name, }
currentToolUse.input, }
// If no tool uses, we're done
if (toolUses.length === 0) {
yield { type: 'end' };
return;
}
// Build assistant message content with tool uses
// First get the final message to extract text content
const finalMessage = await stream.finalMessage();
for (const block of finalMessage.content) {
if (block.type === 'text') {
assistantContent.push({ type: 'text', text: block.text });
} else if (block.type === 'tool_use') {
assistantContent.push({
type: 'tool_use',
id: block.id,
name: block.name,
input: block.input as Record<string, unknown>,
});
}
}
// Add assistant message with tool uses
messages.push({
role: 'assistant',
content: assistantContent,
});
// Execute all tools and collect results
const toolResults: Anthropic.ToolResultBlockParam[] = [];
for (const toolUse of toolUses) {
const result = await this.immigrationToolsService.executeTool(
toolUse.name,
toolUse.input,
context, context,
); );
yield { yield {
type: 'tool_result', type: 'tool_result',
toolName: currentToolUse.name, toolName: toolUse.name,
toolResult, toolResult: result,
}; };
currentToolUse = null; toolResults.push({
} type: 'tool_result',
} else if (event.type === 'message_stop') { tool_use_id: toolUse.id,
yield { type: 'end' }; content: JSON.stringify(result),
} });
} }
// Add user message with tool results
messages.push({
role: 'user',
content: toolResults,
});
// Continue the loop to get Claude's response after tool execution
} catch (error) { } catch (error) {
console.error('Claude API error:', error); console.error('Claude API error:', error);
throw error; throw error;
} }
} }
console.error('Tool loop exceeded maximum iterations');
yield { type: 'end' };
}
/** /**
* Non-streaming message for simple queries * Non-streaming message for simple queries
*/ */