Files
Justin Truong d13cc0804a fix(providers): carry tool name on tool results for Gemini
Gemini drops tool_call_id and pairs functionCall/functionResponse by
function name, so its OpenAI-compat shim requires a non-empty
FunctionResponse.name. The name was only recoverable through a reverse
id->name lookup over assistant tool_calls still present in the request
window, which fails after pruning, truncation or tool_call collapse and
produces HTTP 400 "Name cannot be empty" on any follow-up iteration.

Add Message.ToolName, set it at every tool-result creation site and
preserve it through context pruning, then prefer it when serializing.
Fall back to the existing index for history persisted before the field,
so old sessions keep working without a reset.

When neither source resolves a name, drop the unlabelled tool result
instead of emitting an empty one: an empty name is a guaranteed 400 and
a synthetic name would match no prior functionCall. Gated behind the
existing Gemini detection so other OpenAI-compat hosts, which pair by
tool_call_id, are unaffected.

Field is JSON-optional, so session history needs no migration and older
binaries ignore it on rollback.
2026-08-03 22:50:06 +07:00

219 lines
8.0 KiB
Go

package agent
import (
"context"
"log/slog"
"path/filepath"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tools"
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
)
// toolResultAction describes what the caller should do after processing a tool result.
type toolResultAction int
const (
toolResultContinue toolResultAction = iota // proceed normally
toolResultWarning // injected warning message, continue
toolResultBreak // critical loop detected, break iteration
)
// processToolResult handles post-execution bookkeeping for a single tool result:
// loop detection, event emission, media collection, deliverables, and message building.
// Used by both single-tool and parallel-tool paths to eliminate duplication.
//
// Returns the tool message, an optional warning message to inject, and an action signal.
// The caller must append toolMsg and warningMsg to messages/pendingMsgs, and break if action == toolResultBreak.
func (l *Loop) processToolResult(
ctx context.Context,
rs *runState,
req *RunRequest,
emitRun func(AgentEvent),
tc providers.ToolCall,
registryName string,
result *tools.Result,
hadBootstrap bool,
) (toolMsg providers.Message, warningMsgs []providers.Message, action toolResultAction) {
// Agent Link outputs are not valid outbound media until the runtime has
// validated and atomically published the delegation manifest. Apply this
// before logs, events, loop detection, or message construction so no
// pre-publication MEDIA marker escapes through observability surfaces.
tools.ApplyDelegationArtifactResultPolicy(ctx, result)
// Record for loop detection.
argsHash := rs.loopDetector.record(registryName, tc.Arguments)
rs.loopDetector.recordResult(argsHash, result.ForLLM)
rs.loopDetector.recordMutation(registryName, tc.Arguments)
if result.Async {
rs.asyncToolCalls = append(rs.asyncToolCalls, tc.Name)
}
if result.IsError {
errMsg := result.ForLLM
if len(errMsg) > 200 {
errMsg = errMsg[:200] + "..."
}
slog.Warn("tool error", "agent", l.id, "tool", tc.Name, "error", errMsg)
}
// Count successful spawn calls for orphan detection (post-execution).
if registryName == "spawn" && !result.IsError {
if tid, _ := tc.Arguments["team_task_id"].(string); tid != "" {
rs.teamTaskSpawns++
}
}
if hadBootstrap && bootstrapToolAllowlist[registryName] {
rs.bootstrapWriteDetected = true
}
// Emit tool result event.
toolResultPayload := map[string]any{
"name": tc.Name,
"id": tc.ID,
"is_error": result.IsError,
"arguments": tc.Arguments,
"result": truncateStr(result.ForLLM, 1000),
}
if result.IsError && result.ForLLM != "" {
toolResultPayload["content"] = result.ForLLM
}
emitRun(AgentEvent{
Type: protocol.AgentEventToolResult,
AgentID: l.id,
RunID: req.RunID,
Payload: toolResultPayload,
})
l.scanWebToolResult(tc.Name, result)
// Collect MEDIA: paths from tool results.
// Prefer result.Media (explicit) over ForLLM MEDIA: prefix (legacy) to avoid duplicates.
mediaRoots := l.mediaEgressRoots(ctx)
if len(result.Media) > 0 {
// Egress containment: a tool that sets result.Media[].Path to a path
// outside every allowed scope (e.g. /etc/passwd from a prompt-injected
// path) must not reach a channel's file-upload sink. Confine here at the
// source so every channel is covered. The allowed roots mirror what the
// producing tools (create_*, send_file, delegate) may legitimately write
// to — agent workspace, team workspace, and tenant-allowed paths — so a
// cross-workspace file (e.g. a teammate-produced file in the shared team
// workspace, or a synchronous delegatee's output) is not wrongly dropped.
for i, mf := range result.Media {
cleaned, ok := confineToAnyRoot(mf.Path, mediaRoots)
if !ok {
slog.Warn("security.media_path_rejected",
"agent", l.id, "tool", tc.Name, "path", mf.Path,
"reason", "outside agent workspace")
continue
}
ct := mf.MimeType
if ct == "" {
ct = mimeFromExt(filepath.Ext(cleaned))
}
mr := MediaResult{Path: cleaned, ContentType: ct, Caption: mf.Caption}
if result.MediaPrompts != nil {
mr.Prompt = result.MediaPrompts[i]
}
rs.mediaResults = append(rs.mediaResults, mr)
// The file is now queued for the run's automatic outbound delivery.
// Mark it so a later message(MEDIA:same_path) self-send cannot publish
// the same attachment before the final response is dispatched.
if dm := tools.DeliveredMediaFromCtx(ctx); dm != nil {
dm.Mark(cleaned)
}
}
} else if mr := parseMediaResult(result.ForLLM); mr != nil {
// Security (egress boundary): a tool's MEDIA:<path> output is taken
// verbatim, so confine it to the agent workspace before it can reach an
// outbound channel's file-upload sink (e.g. Bitrix imbot.v2.File.upload,
// Telegram sendDocument). A malicious or buggy tool emitting
// MEDIA:/etc/passwd is dropped here — fixing every channel at the source
// rather than per-channel. Mirrors extractMediaFromContent containment.
if cleaned, ok := confineToAnyRoot(mr.Path, mediaRoots); ok {
mr.Path = cleaned
rs.mediaResults = append(rs.mediaResults, *mr)
if dm := tools.DeliveredMediaFromCtx(ctx); dm != nil {
dm.Mark(cleaned)
}
} else {
slog.Warn("security.media_path_rejected",
"agent", l.id, "tool", tc.Name, "path", mr.Path,
"reason", "outside agent workspace")
}
}
// Auto-attach workspace media to task (covers create_image/audio/video).
if teamWs := tools.ToolTeamWorkspaceFromCtx(ctx); teamWs != "" {
for _, mf := range result.Media {
tools.AutoAttachWorkspaceFile(ctx, l.teamStore, teamWs, mf.Path)
}
}
if result.Deliverable != "" {
rs.deliverables = append(rs.deliverables, result.Deliverable)
}
toolMsg = providers.Message{
Role: "tool",
Content: result.ForLLM,
ToolCallID: tc.ID,
ToolName: tc.Name,
IsError: result.IsError,
}
action = toolResultContinue
// Check for tool call loop after recording result.
if level, msg := rs.loopDetector.detect(registryName, argsHash); level != "" {
if level == "critical" {
slog.Warn("tool loop critical", "agent", l.id, "tool", registryName, "message", msg)
rs.finalContent = "I was unable to complete this task — I got stuck repeatedly calling " + registryName + " without making progress. Please try rephrasing your request."
rs.loopKilled = true
return toolMsg, nil, toolResultBreak
}
slog.Warn("tool loop warning", "agent", l.id, "tool", registryName, "message", msg)
warningMsgs = append(warningMsgs, providers.Message{Role: "user", Content: msg})
action = toolResultWarning
}
// Check for same tool returning identical results with different args.
if rh := hashResult(result.ForLLM); rh != "" {
if level, msg := rs.loopDetector.detectSameResult(registryName, rh); level != "" {
if level == "critical" {
slog.Warn("tool loop critical: same result",
"tool", registryName, "agent", l.id, "run", req.RunID)
rs.finalContent = msg
rs.loopKilled = true
return toolMsg, nil, toolResultBreak
}
warningMsgs = append(warningMsgs, providers.Message{Role: "user", Content: msg})
action = toolResultWarning
}
}
return toolMsg, warningMsgs, action
}
// checkReadOnlyStreak detects when the agent is stuck in a read-only loop.
// Returns warning messages to inject and whether the loop should break.
func (l *Loop) checkReadOnlyStreak(rs *runState, req *RunRequest) (warningMsg *providers.Message, shouldBreak bool) {
level, msg := rs.loopDetector.detectReadOnlyStreak()
if level == "" {
return nil, false
}
if level == "critical" {
slog.Warn("tool loop critical: read-only streak",
"streak", rs.loopDetector.readOnlyStreak,
"unique", rs.loopDetector.readOnlyUnique,
"agent", l.id, "run", req.RunID)
rs.finalContent = msg
rs.loopKilled = true
return nil, true
}
slog.Warn("tool loop warning: read-only streak",
"streak", rs.loopDetector.readOnlyStreak, "agent", l.id, "run", req.RunID)
warnMsg := providers.Message{Role: "user", Content: msg}
return &warnMsg, false
}