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:
parent
141b45bec2
commit
72e67fa5d9
|
|
@ -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,80 +97,148 @@ export class ClaudeAgentService implements OnModuleInit {
|
||||||
content: message,
|
content: message,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
// Tool loop - continue until we get a final response (no tool use)
|
||||||
// Create streaming message
|
const maxIterations = 10; // Safety limit
|
||||||
const stream = await this.client.messages.stream({
|
let iterations = 0;
|
||||||
model: 'claude-sonnet-4-20250514',
|
|
||||||
max_tokens: 4096,
|
|
||||||
system: systemPrompt,
|
|
||||||
messages,
|
|
||||||
tools: tools as Anthropic.Tool[],
|
|
||||||
});
|
|
||||||
|
|
||||||
let currentToolUse: {
|
while (iterations < maxIterations) {
|
||||||
id: string;
|
iterations++;
|
||||||
name: string;
|
|
||||||
input: Record<string, unknown>;
|
|
||||||
} | null = null;
|
|
||||||
|
|
||||||
for await (const event of stream) {
|
try {
|
||||||
if (event.type === 'content_block_start') {
|
// Create streaming message
|
||||||
if (event.content_block.type === 'tool_use') {
|
const stream = await this.client.messages.stream({
|
||||||
currentToolUse = {
|
model: 'claude-sonnet-4-20250514',
|
||||||
id: event.content_block.id,
|
max_tokens: 4096,
|
||||||
name: event.content_block.name,
|
system: systemPrompt,
|
||||||
input: {},
|
messages,
|
||||||
};
|
tools: tools as Anthropic.Tool[],
|
||||||
}
|
});
|
||||||
} else if (event.type === 'content_block_delta') {
|
|
||||||
if (event.delta.type === 'text_delta') {
|
let currentToolUse: {
|
||||||
yield {
|
id: string;
|
||||||
type: 'text',
|
name: string;
|
||||||
content: event.delta.text,
|
inputJson: string;
|
||||||
};
|
input: Record<string, unknown>;
|
||||||
} else if (event.delta.type === 'input_json_delta' && currentToolUse) {
|
} | null = null;
|
||||||
// Accumulate tool input
|
|
||||||
try {
|
// Collect all tool uses and text blocks in this response
|
||||||
const partialInput = JSON.parse(event.delta.partial_json || '{}');
|
const toolUses: Array<{ id: string; name: string; input: Record<string, unknown> }> = [];
|
||||||
currentToolUse.input = {
|
const assistantContent: Anthropic.ContentBlockParam[] = [];
|
||||||
...currentToolUse.input,
|
let hasText = false;
|
||||||
...partialInput,
|
|
||||||
|
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: {},
|
||||||
};
|
};
|
||||||
} catch {
|
}
|
||||||
// Ignore parse errors for partial JSON
|
} 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) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
currentToolUse = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (event.type === 'content_block_stop') {
|
|
||||||
if (currentToolUse) {
|
|
||||||
yield {
|
|
||||||
type: 'tool_use',
|
|
||||||
toolName: currentToolUse.name,
|
|
||||||
toolInput: currentToolUse.input,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Execute the tool
|
|
||||||
const toolResult = await this.immigrationToolsService.executeTool(
|
|
||||||
currentToolUse.name,
|
|
||||||
currentToolUse.input,
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
|
|
||||||
yield {
|
|
||||||
type: 'tool_result',
|
|
||||||
toolName: currentToolUse.name,
|
|
||||||
toolResult,
|
|
||||||
};
|
|
||||||
|
|
||||||
currentToolUse = null;
|
|
||||||
}
|
|
||||||
} else if (event.type === 'message_stop') {
|
|
||||||
yield { type: 'end' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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: toolUse.name,
|
||||||
|
toolResult: result,
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error('Claude API error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.error('Tool loop exceeded maximum iterations');
|
||||||
|
yield { type: 'end' };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue