feat(agent): implement tool loop processing and enhance error handling

- 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.
This commit is contained in:
Max 2026-05-02 21:42:55 +08:00
parent 7da06a4ae1
commit fb01a1c141
10 changed files with 867 additions and 420 deletions

View file

@ -571,11 +571,47 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
} else {
// No Next hook: use standard response
} else if len(toolCallResponses) > 0 && !ast.HasSandbox() && !ast.isToolLoopDisabled() {
// No Next hook + has tool results + not sandbox → tool loop
ctx.Logger.Debug("Entering tool loop for tool result processing")
loopResponse, loopCompletion, loopTools, err := ast.executeToolLoop(ctx, &ToolLoopParams{
CompletionMessages: completionMessages,
CompletionOptions: completionOptions,
CompletionResponse: completionResponse,
ToolCallResponses: toolCallResponses,
FullMessages: fullMessages,
AgentNode: agentNode,
StreamHandler: streamHandler,
CreateResponse: createResponse,
Opts: opts,
})
if err != nil {
// Fallback to __yao.loop_fallback delegation
ctx.Logger.Warn("Tool loop failed: %v, falling back to loop_fallback", err)
fallbackDelegate := ast.buildLoopFallbackDelegate(ctx, fullMessages, completionResponse, toolCallResponses)
delegateResponse, delegateErr := ast.handleDelegation(ctx, fallbackDelegate, streamHandler)
if delegateErr != nil {
ctx.Logger.Warn("loop_fallback also failed: %v, using standard response", delegateErr)
finalResponse = ast.buildStandardResponse(&NextProcessContext{
Context: ctx,
CompletionResponse: completionResponse,
FullMessages: fullMessages,
ToolCallResponses: toolCallResponses,
StreamHandler: streamHandler,
CreateResponse: createResponse,
})
} else {
finalResponse = delegateResponse
}
} else {
completionResponse = loopCompletion
toolCallResponses = loopTools
finalResponse = loopResponse
}
} else {
// No tool calls, sandbox mode, or loop disabled: standard response
finalResponse = ast.buildStandardResponse(&NextProcessContext{
Context: ctx,
NextResponse: nil,
CompletionResponse: completionResponse,
FullMessages: fullMessages,
ToolCallResponses: toolCallResponses,
@ -803,6 +839,7 @@ func (ast *Assistant) buildToolRetryMessages(
assistantMsg := context.Message{
Role: context.RoleAssistant,
Content: completionResponse.Content,
ReasoningContent: completionResponse.ReasoningContent,
ToolCalls: completionResponse.ToolCalls,
}
retryMessages = append(retryMessages, assistantMsg)

View file

@ -29,6 +29,7 @@ var systemAgents = []string{
"entity",
"vision",
"fetch",
"loop_fallback",
}
// SystemConfig holds the system agents connector configuration
@ -49,6 +50,7 @@ type SystemConfig struct {
RobotPrompt string // Connector for __yao.robot_prompt agent
NeedSearch string // Connector for __yao.needsearch agent
Entity string // Connector for __yao.entity agent
LoopFallback string // Connector for __yao.loop_fallback agent
}
// systemConfig holds the system agents configuration (global variable like others in load.go)
@ -237,6 +239,8 @@ func resolveSystemConnector(agentID string) string {
return systemConfig.Vision
case "__yao.audio":
return systemConfig.Audio
case "__yao.loop_fallback":
return systemConfig.LoopFallback
}
return ""
}

295
agent/assistant/loop.go Normal file
View file

@ -0,0 +1,295 @@
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)
}

View file

@ -375,12 +375,6 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
return []ToolCallResult{result}, true
}
// Check if result is an error
if callResult.IsError {
result.Error = fmt.Errorf("MCP tool error")
result.IsRetryableError = false // MCP internal error is not retryable
}
// Serialize the Content field only ([]ToolContent)
contentBytes, err := jsoniter.Marshal(callResult.Content)
if err != nil {
@ -396,6 +390,19 @@ func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall
}
result.Content = string(contentBytes)
// Check if result is an error — include actual content so LLM can see the details
if callResult.IsError {
result.Error = fmt.Errorf("tool call error: %s", result.Content)
result.IsRetryableError = isRetryableToolError(result.Error)
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolCall.Function.Name, result.Content, result.IsRetryableError)
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
return []ToolCallResult{result}, true
}
ctx.Logger.ToolComplete(toolCall.Function.Name, true)
if toolNode != nil {
@ -809,19 +816,12 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
toolNode.Fail(err)
}
} else {
// Check if result is an error
if mcpResult.IsError {
result.Error = fmt.Errorf("MCP tool error")
result.IsRetryableError = false // MCP internal error is not retryable
hasErrors = true
}
// Serialize the Content field only ([]ToolContent)
contentBytes, err := jsoniter.Marshal(mcpResult.Content)
if err != nil {
result.Error = err
result.Content = fmt.Sprintf("Failed to serialize result: %v", err)
result.IsRetryableError = false // Serialization error is not retryable
result.IsRetryableError = false
hasErrors = true
ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil {
@ -829,7 +829,19 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
}
} else {
result.Content = string(contentBytes)
ctx.Logger.ToolComplete(tc.Function.Name, !mcpResult.IsError)
// Check if result is an error — include actual content so LLM can see the details
if mcpResult.IsError {
result.Error = fmt.Errorf("tool call error: %s", result.Content)
result.IsRetryableError = isRetryableToolError(result.Error)
hasErrors = true
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolName, result.Content, result.IsRetryableError)
ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
} else {
ctx.Logger.ToolComplete(tc.Function.Name, true)
if toolNode != nil {
toolNode.Complete(map[string]any{
"result": mcpResult.Content,
@ -837,6 +849,7 @@ func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Conte
}
}
}
}
results = append(results, result)
}

View file

@ -566,6 +566,7 @@ type Message struct {
ToolCallID *string `json:"tool_call_id,omitempty"` // Required for tool messages: tool call that this message is responding to
// Assistant message specific fields
ReasoningContent string `json:"reasoning_content,omitempty"` // Optional for assistant: reasoning/thinking content (DeepSeek, OpenAI o-series)
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // Optional for assistant: tool calls generated by the model
Refusal *string `json:"refusal,omitempty"` // Optional for assistant: refusal message (null when not refusing)
}

View file

@ -1084,6 +1084,10 @@ func (p *Provider) buildRequestBody(messages []context.Message, options *context
apiMsg["tool_calls"] = msg.ToolCalls
}
if msg.ReasoningContent != "" {
apiMsg["reasoning_content"] = msg.ReasoningContent
}
if msg.Refusal != nil {
apiMsg["refusal"] = *msg.Refusal
}

File diff suppressed because it is too large Load diff

View file

@ -18,17 +18,14 @@ var allowedPrefixes = []string{
"stores.",
"flows.",
"scripts.",
"services.",
"tasks.",
"schedules.",
"widgets.",
"utils.",
"http.",
}
// Explicitly blocked prefixes for safety.
var blockedPrefixes = []string{
"yao.sys.",
"yao.env.",
"utils.",
"tools.",
}

View file

@ -0,0 +1,8 @@
{
"name": "Loop Fallback",
"description": "Synthesize tool call results into natural language when tool loop fails or reaches max turns",
"type": "worker",
"connector": "use::light",
"uses": { "search": "disabled" },
"options": {}
}

View file

@ -0,0 +1,40 @@
- role: system
content: |
You are a response synthesizer for an AI assistant.
You are the FALLBACK — you are called only when the primary tool loop has been exhausted.
## Your Input
You will receive a single message containing three sections in Markdown format:
### "## Assistant Context"
This is the system prompt / personality of the original assistant that the user is talking to.
You MUST adopt this context as your own — respond as if you ARE that assistant.
Follow its tone, domain expertise, language preferences, and constraints.
### "## Conversation"
This shows the recent conversation between the user and the assistant.
The last user message is the question you need to answer.
### "## Tool Results"
These are the results from tools that were executed to help answer the user's question.
Each result is labeled with the tool name and contains JSON data or an error message.
## Your Task
1. Read the Assistant Context — adopt that persona
2. Understand what the user asked in the Conversation
3. Use the Tool Results data to formulate your answer
4. Respond naturally as the original assistant would
## Rules
- Respond in the SAME LANGUAGE as the user's message
- Be concise and directly answer what was asked
- Do NOT mention tool names, server IDs, JSON structures, or any technical internals
- Do NOT say "based on the tool results" or "according to the data" — just answer naturally
- If results contain structured data (lists, tables), format them readably
- If the Assistant Context has specific formatting/style rules, follow them
- You have NO access to any tools — do NOT suggest calling tools, retrying, or trying alternative methods
- Work ONLY with the Tool Results provided — do NOT fabricate or guess data
- If all tool results are errors or empty, honestly tell the user the request could not be completed and suggest rephrasing