- Added support for tool loop processing when tool call responses are present and sandbox mode is disabled, improving the assistant's ability to handle complex tool interactions. - Implemented fallback delegation to a loop fallback mechanism in case of tool loop execution failure, enhancing robustness in error scenarios. - Updated the assistant message structure to include reasoning content, providing better context for generated responses. - Enhanced error logging in tool call execution to include detailed content, improving diagnostics for tool call failures. - Updated system configuration to include a new loop fallback agent, expanding the assistant's capabilities.
295 lines
9.1 KiB
Go
295 lines
9.1 KiB
Go
package assistant
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
jsoniter "github.com/json-iterator/go"
|
|
"github.com/yaoapp/yao/agent/context"
|
|
"github.com/yaoapp/yao/agent/output/message"
|
|
"github.com/yaoapp/yao/trace/types"
|
|
)
|
|
|
|
// ToolLoopParams holds all parameters needed by executeToolLoop.
|
|
type ToolLoopParams struct {
|
|
CompletionMessages []context.Message
|
|
CompletionOptions *context.CompletionOptions
|
|
CompletionResponse *context.CompletionResponse
|
|
ToolCallResponses []context.ToolCallResponse
|
|
FullMessages []context.Message
|
|
AgentNode types.Node
|
|
StreamHandler message.StreamFunc
|
|
CreateResponse *context.HookCreateResponse
|
|
Opts *context.Options
|
|
}
|
|
|
|
// executeToolLoop feeds tool results back to the LLM in a loop until
|
|
// the LLM produces a final text response (no more tool_calls) or
|
|
// the maximum number of turns is reached.
|
|
//
|
|
// Returns the final Response, the last CompletionResponse (for tracing),
|
|
// accumulated ToolCallResponses, and any error.
|
|
func (ast *Assistant) executeToolLoop(
|
|
ctx *context.Context,
|
|
params *ToolLoopParams,
|
|
) (*context.Response, *context.CompletionResponse, []context.ToolCallResponse, error) {
|
|
|
|
maxTurns := ast.getMaxToolLoopTurns()
|
|
currentMessages := params.CompletionMessages
|
|
currentCompletion := params.CompletionResponse
|
|
allToolResponses := make([]context.ToolCallResponse, 0, len(params.ToolCallResponses))
|
|
allToolResponses = append(allToolResponses, params.ToolCallResponses...)
|
|
|
|
for turn := 0; turn < maxTurns; turn++ {
|
|
ctx.Logger.Debug("Tool loop turn %d/%d", turn+1, maxTurns)
|
|
|
|
// Build messages: previous messages + assistant(tool_calls) + tool results
|
|
loopMessages := buildToolLoopMessages(currentMessages, currentCompletion, allToolResponses[len(allToolResponses)-len(params.ToolCallResponses):])
|
|
|
|
// Step tracking: LLM call
|
|
ast.BeginStep(ctx, context.StepTypeLLM, map[string]interface{}{
|
|
"messages": loopMessages,
|
|
"loop_turn": turn + 1,
|
|
})
|
|
|
|
// Call LLM with tool results included
|
|
newCompletion, err := ast.executeLLMStream(ctx, loopMessages, params.CompletionOptions, params.AgentNode, params.StreamHandler, params.Opts)
|
|
if err != nil {
|
|
return nil, nil, nil, fmt.Errorf("tool loop LLM call failed (turn %d): %w", turn+1, err)
|
|
}
|
|
|
|
ast.CompleteStep(ctx, map[string]interface{}{
|
|
"content": newCompletion.Content,
|
|
"tool_calls": newCompletion.ToolCalls,
|
|
})
|
|
|
|
// No tool_calls → LLM gave final text response
|
|
if newCompletion.ToolCalls == nil || len(newCompletion.ToolCalls) == 0 {
|
|
finalResponse := ast.buildStandardResponse(&NextProcessContext{
|
|
Context: ctx,
|
|
CompletionResponse: newCompletion,
|
|
FullMessages: params.FullMessages,
|
|
ToolCallResponses: allToolResponses,
|
|
StreamHandler: params.StreamHandler,
|
|
CreateResponse: params.CreateResponse,
|
|
})
|
|
return finalResponse, newCompletion, allToolResponses, nil
|
|
}
|
|
|
|
// Has tool_calls → execute them
|
|
ast.BeginStep(ctx, context.StepTypeTool, map[string]interface{}{
|
|
"tool_calls": newCompletion.ToolCalls,
|
|
"loop_turn": turn + 1,
|
|
})
|
|
|
|
toolResults, _ := ast.executeToolCalls(ctx, newCompletion.ToolCalls, 0)
|
|
|
|
// Convert ToolCallResult → ToolCallResponse
|
|
toolCallArgsMap := make(map[string]interface{})
|
|
for _, tc := range newCompletion.ToolCalls {
|
|
toolCallArgsMap[tc.ID] = tc.Function.Arguments
|
|
}
|
|
|
|
turnResponses := make([]context.ToolCallResponse, len(toolResults))
|
|
for i, result := range toolResults {
|
|
parsedContent, _ := result.ParsedContent()
|
|
turnResponses[i] = context.ToolCallResponse{
|
|
ToolCallID: result.ToolCallID,
|
|
Server: result.Server(),
|
|
Tool: result.Tool(),
|
|
Arguments: toolCallArgsMap[result.ToolCallID],
|
|
Result: parsedContent,
|
|
Error: "",
|
|
}
|
|
if result.Error != nil {
|
|
turnResponses[i].Error = result.Error.Error()
|
|
}
|
|
}
|
|
|
|
ast.CompleteStep(ctx, map[string]interface{}{
|
|
"results": turnResponses,
|
|
"loop_turn": turn + 1,
|
|
})
|
|
|
|
// Accumulate and prepare next iteration
|
|
allToolResponses = append(allToolResponses, turnResponses...)
|
|
currentMessages = loopMessages
|
|
currentCompletion = newCompletion
|
|
params.ToolCallResponses = turnResponses
|
|
}
|
|
|
|
return nil, nil, allToolResponses, fmt.Errorf("tool loop reached max turns (%d)", maxTurns)
|
|
}
|
|
|
|
// buildToolLoopMessages constructs the message sequence for the next LLM call:
|
|
// previous messages + assistant message (with tool_calls) + tool result messages.
|
|
// Unlike buildToolRetryMessages, this does NOT append a retry system prompt.
|
|
func buildToolLoopMessages(
|
|
previousMessages []context.Message,
|
|
completion *context.CompletionResponse,
|
|
toolResponses []context.ToolCallResponse,
|
|
) []context.Message {
|
|
messages := make([]context.Message, 0, len(previousMessages)+len(toolResponses)+2)
|
|
messages = append(messages, previousMessages...)
|
|
|
|
// Assistant message with tool_calls
|
|
messages = append(messages, context.Message{
|
|
Role: context.RoleAssistant,
|
|
Content: completion.Content,
|
|
ReasoningContent: completion.ReasoningContent,
|
|
ToolCalls: completion.ToolCalls,
|
|
})
|
|
|
|
// One tool-role message per tool call result
|
|
for _, tr := range toolResponses {
|
|
var content string
|
|
if tr.Error != "" {
|
|
content = fmt.Sprintf("Error: %s", tr.Error)
|
|
} else if tr.Result != nil {
|
|
raw, _ := jsoniter.MarshalToString(tr.Result)
|
|
content = raw
|
|
}
|
|
toolCallID := tr.ToolCallID
|
|
messages = append(messages, context.Message{
|
|
Role: context.RoleTool,
|
|
Content: content,
|
|
ToolCallID: &toolCallID,
|
|
})
|
|
}
|
|
|
|
return messages
|
|
}
|
|
|
|
// isToolLoopDisabled checks mcp.options.tool_loop.
|
|
// Default is enabled (returns false). Only disabled when explicitly set to false.
|
|
func (ast *Assistant) isToolLoopDisabled() bool {
|
|
if ast.MCP == nil || ast.MCP.Options == nil {
|
|
return false
|
|
}
|
|
if v, ok := ast.MCP.Options["tool_loop"]; ok {
|
|
if enabled, ok := v.(bool); ok {
|
|
return !enabled
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// getMaxToolLoopTurns reads mcp.options.max_turn. Default is 5.
|
|
func (ast *Assistant) getMaxToolLoopTurns() int {
|
|
const defaultMaxTurns = 5
|
|
if ast.MCP == nil || ast.MCP.Options == nil {
|
|
return defaultMaxTurns
|
|
}
|
|
if v, ok := ast.MCP.Options["max_turn"]; ok {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
if n > 0 {
|
|
return int(n)
|
|
}
|
|
case int:
|
|
if n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
}
|
|
return defaultMaxTurns
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fallback: __yao.loop_fallback delegation (used when tool loop fails/maxes out)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// buildLoopFallbackDelegate constructs a DelegateConfig for __yao.loop_fallback.
|
|
// It packages conversation context and tool results into a Markdown user message.
|
|
func (ast *Assistant) buildLoopFallbackDelegate(
|
|
ctx *context.Context,
|
|
fullMessages []context.Message,
|
|
completion *context.CompletionResponse,
|
|
toolResults []context.ToolCallResponse,
|
|
) *context.DelegateConfig {
|
|
|
|
content := buildLoopFallbackMarkdown(fullMessages, toolResults)
|
|
return &context.DelegateConfig{
|
|
AgentID: "__yao.loop_fallback",
|
|
Messages: []context.Message{
|
|
{Role: context.RoleUser, Content: content},
|
|
},
|
|
}
|
|
}
|
|
|
|
// buildLoopFallbackMarkdown formats context into a Markdown string for the fallback agent.
|
|
func buildLoopFallbackMarkdown(
|
|
fullMessages []context.Message,
|
|
toolResults []context.ToolCallResponse,
|
|
) string {
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString("## Assistant Context\n\n")
|
|
for _, msg := range fullMessages {
|
|
if msg.Role == context.RoleSystem {
|
|
if text := messageText(msg); text != "" {
|
|
sb.WriteString(text)
|
|
sb.WriteString("\n\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
sb.WriteString("## Conversation\n\n")
|
|
for _, msg := range fullMessages {
|
|
text := messageText(msg)
|
|
switch msg.Role {
|
|
case context.RoleUser:
|
|
if text != "" {
|
|
sb.WriteString(fmt.Sprintf("**User**: %s\n\n", text))
|
|
}
|
|
case context.RoleAssistant:
|
|
if text != "" {
|
|
sb.WriteString(fmt.Sprintf("**Assistant**: %s\n\n", text))
|
|
}
|
|
}
|
|
}
|
|
|
|
sb.WriteString("## Tool Results\n\n")
|
|
for _, tr := range toolResults {
|
|
toolName := tr.Tool
|
|
if tr.Server != "" {
|
|
toolName = tr.Server + "." + tr.Tool
|
|
}
|
|
sb.WriteString(fmt.Sprintf("### %s\n", toolName))
|
|
if tr.Error != "" {
|
|
sb.WriteString(fmt.Sprintf("Error: %s\n\n", tr.Error))
|
|
} else {
|
|
raw, _ := jsoniter.MarshalToString(tr.Result)
|
|
sb.WriteString(fmt.Sprintf("```json\n%s\n```\n\n", raw))
|
|
}
|
|
}
|
|
|
|
sb.WriteString("---\nPlease answer the user's question based on the above context and tool results.\n")
|
|
sb.WriteString("Respond in the same language as the user.\n")
|
|
return sb.String()
|
|
}
|
|
|
|
// messageText extracts text content from a message's Content field.
|
|
// Content can be a string or an array of content parts (multimodal).
|
|
func messageText(msg context.Message) string {
|
|
if msg.Content == nil {
|
|
return ""
|
|
}
|
|
if str, ok := msg.Content.(string); ok {
|
|
return str
|
|
}
|
|
if parts, ok := msg.Content.([]interface{}); ok {
|
|
var texts []string
|
|
for _, part := range parts {
|
|
if partMap, ok := part.(map[string]interface{}); ok {
|
|
if partMap["type"] == "text" {
|
|
if text, ok := partMap["text"].(string); ok {
|
|
texts = append(texts, text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return strings.Join(texts, "\n")
|
|
}
|
|
return fmt.Sprintf("%v", msg.Content)
|
|
}
|