diff --git a/packages/services/conversation-service/src/infrastructure/claude/claude-agent.service.ts b/packages/services/conversation-service/src/infrastructure/claude/claude-agent.service.ts index cf46ea2..798cfd5 100644 --- a/packages/services/conversation-service/src/infrastructure/claude/claude-agent.service.ts +++ b/packages/services/conversation-service/src/infrastructure/claude/claude-agent.service.ts @@ -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,80 +97,148 @@ export class ClaudeAgentService implements OnModuleInit { content: message, }); - try { - // Create streaming message - const stream = await this.client.messages.stream({ - model: 'claude-sonnet-4-20250514', - max_tokens: 4096, - system: systemPrompt, - messages, - tools: tools as Anthropic.Tool[], - }); + // Tool loop - continue until we get a final response (no tool use) + const maxIterations = 10; // Safety limit + let iterations = 0; - let currentToolUse: { - id: string; - name: string; - input: Record; - } | null = null; + while (iterations < maxIterations) { + iterations++; - 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, - input: {}, - }; - } - } else if (event.type === 'content_block_delta') { - if (event.delta.type === 'text_delta') { - 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, + try { + // Create streaming message + const stream = await this.client.messages.stream({ + model: 'claude-sonnet-4-20250514', + max_tokens: 4096, + system: systemPrompt, + messages, + tools: tools as Anthropic.Tool[], + }); + + let currentToolUse: { + id: string; + name: string; + inputJson: string; + input: Record; + } | null = null; + + // Collect all tool uses and text blocks in this response + const toolUses: Array<{ id: string; name: string; input: Record }> = []; + 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: {}, }; - } 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, + }); + } + } + + // 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' }; } /**