mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 16:12:55 +00:00
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.
219 lines
8.0 KiB
Go
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
|
|
}
|