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(
message: string,
@ -97,6 +97,13 @@ export class ClaudeAgentService implements OnModuleInit {
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 {
// Create streaming message
const stream = await this.client.messages.stream({
@ -110,69 +117,130 @@ export class ClaudeAgentService implements OnModuleInit {
let currentToolUse: {
id: string;
name: string;
inputJson: string;
input: Record<string, unknown>;
} | 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) {
if (event.type === 'content_block_start') {
if (event.content_block.type === 'tool_use') {
currentToolUse = {
id: event.content_block.id,
name: event.content_block.name,
inputJson: '',
input: {},
};
}
} else if (event.type === 'content_block_delta') {
if (event.delta.type === 'text_delta') {
hasText = true;
yield {
type: 'text',
content: event.delta.text,
};
} else if (event.delta.type === 'input_json_delta' && currentToolUse) {
// Accumulate tool input
try {
const partialInput = JSON.parse(event.delta.partial_json || '{}');
currentToolUse.input = {
...currentToolUse.input,
...partialInput,
};
} catch {
// Ignore parse errors for partial JSON
}
currentToolUse.inputJson += event.delta.partial_json || '';
}
} else if (event.type === 'content_block_stop') {
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 {
type: 'tool_use',
toolName: currentToolUse.name,
toolInput: currentToolUse.input,
};
// Execute the tool
const toolResult = await this.immigrationToolsService.executeTool(
currentToolUse.name,
currentToolUse.input,
currentToolUse = null;
}
}
}
// 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,
);
yield {
type: 'tool_result',
toolName: currentToolUse.name,
toolResult,
toolName: toolUse.name,
toolResult: result,
};
currentToolUse = null;
}
} else if (event.type === 'message_stop') {
yield { type: 'end' };
}
toolResults.push({
type: 'tool_result',
tool_use_id: toolUse.id,
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) {
console.error('Claude API error:', error);
throw error;
}
}
console.error('Tool loop exceeded maximum iterations');
yield { type: 'end' };
}
/**
* Non-streaming message for simple queries
*/