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

453 lines
15 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"path/filepath"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/nextlevelbuilder/goclaw/internal/tracing"
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
)
// announceTask publishes terminal state after child-run execution capacity has
// been released. Slow bus consumers and callbacks must never hold admission.
func (sm *SubagentManager) announceTask(
ctx context.Context,
task *SubagentTask,
callback AsyncCallback,
iterations int,
terminalPersisted bool,
) {
// Announce result to parent via bus (matching TS subagent-announce.ts pattern).
// The announce goes through the parent agent's session so the agent can
// reformulate the result for the user.
if sm.msgBus != nil && task.OriginChannel != "" {
elapsed := time.Since(time.UnixMilli(task.CreatedAt))
item := AnnounceQueueItem{
SubagentID: task.ID,
CompletionID: task.dbID,
DurablyPersisted: terminalPersisted,
ParentTaskID: task.ParentTaskID,
Depth: task.Depth,
Label: task.Label,
Status: task.Status,
Result: task.Result,
Media: task.Media,
Runtime: elapsed,
Iterations: iterations,
InputTokens: task.TotalInputTokens,
OutputTokens: task.TotalOutputTokens,
}
meta := AnnounceMetadata{
OriginChannel: task.OriginChannel,
OriginChatID: task.OriginChatID,
OriginPeerKind: task.OriginPeerKind,
OriginLocalKey: task.OriginLocalKey,
OriginUserID: task.OriginUserID,
OriginSenderID: task.OriginSenderID,
OriginRole: task.OriginRole,
OriginSessionKey: task.OriginSessionKey,
OriginTenantID: task.OriginTenantID,
RootAgentID: task.RootAgentID,
ParentAgent: task.RootAgentKey,
OriginTraceID: task.OriginTraceID.String(),
OriginRootSpanID: task.OriginRootSpanID.String(),
}
if sm.announceQueue != nil {
// Use batched announce queue (matching TS debounce pattern)
sessionKey := subagentAnnounceBatchKey(task)
sm.announceQueue.Enqueue(sessionKey, item, meta)
} else {
// Direct publish (no batching)
roster := sm.RosterForParent(TaskScope{
TenantID: task.OriginTenantID, RootAgentID: task.RootAgentID, RootAgentKey: task.RootAgentKey,
})
announceContent := FormatBatchedAnnounce([]AnnounceQueueItem{item}, roster)
announceMeta := map[string]string{
MetaOriginChannel: task.OriginChannel,
MetaOriginPeerKind: task.OriginPeerKind,
MetaParentAgent: task.RootAgentKey,
MetaSubagentRootAgentID: task.RootAgentID.String(),
"subagent_id": task.ID,
MetaSubagentLabel: task.Label,
MetaSubagentStatus: task.Status,
MetaSubagentResult: task.Result,
MetaSubagentRuntime: fmt.Sprintf("%d", elapsed.Milliseconds()),
MetaSubagentIterations: fmt.Sprintf("%d", iterations),
MetaSubagentInputToks: fmt.Sprintf("%d", task.TotalInputTokens),
MetaSubagentOutputToks: fmt.Sprintf("%d", task.TotalOutputTokens),
MetaSubagentParentTask: task.ParentTaskID,
MetaSubagentDepth: fmt.Sprintf("%d", task.Depth),
MetaOriginTraceID: task.OriginTraceID.String(),
MetaOriginRootSpanID: task.OriginRootSpanID.String(),
}
if task.OriginLocalKey != "" {
announceMeta[MetaOriginLocalKey] = task.OriginLocalKey
}
if task.OriginSessionKey != "" {
announceMeta[MetaOriginSessionKey] = task.OriginSessionKey
}
if task.OriginSenderID != "" {
announceMeta[MetaOriginSenderID] = task.OriginSenderID
}
if task.OriginRole != "" {
announceMeta[MetaOriginRole] = task.OriginRole
}
if task.OriginUserID != "" {
announceMeta[MetaOriginUserID] = task.OriginUserID
}
delivered := PublishAsyncCompletion(ctx, sm.msgBus, bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("subagent:%s", task.ID),
ChatID: task.OriginChatID,
Content: announceContent,
UserID: task.OriginUserID,
TenantID: task.OriginTenantID,
Metadata: announceMeta,
Media: task.Media,
})
if terminalPersisted {
sm.UpdateAnnouncementStatus(ctx, task.RootAgentID, task.dbID, delivered)
} else {
slog.Error("subagent.announce_without_durable_terminal",
"task_id", task.ID,
"completion_id", task.dbID,
"root_agent_id", task.RootAgentID,
"delivered", delivered,
)
}
if !delivered {
slog.Warn("subagent.announce_deferred_to_ledger",
"task_id", task.ID,
"completion_id", task.dbID,
"root_agent_id", task.RootAgentID,
"reason", "inbound_bus_full",
)
}
}
}
// Call completion callback
if callback != nil {
result := NewResult(fmt.Sprintf("Subagent '%s' completed in %d iterations.\n\nResult:\n%s",
task.Label, iterations, task.Result))
callback(ctx, result)
}
}
func subagentAnnounceBatchKey(task *SubagentTask) string {
return strings.Join([]string{
"announce",
task.OriginTenantID.String(),
task.RootAgentID.String(),
task.RootAgentKey,
task.OriginSessionKey,
task.OriginChannel,
task.OriginChatID,
task.OriginPeerKind,
task.OriginLocalKey,
task.OriginUserID,
task.OriginSenderID,
task.OriginRole,
}, "\x00")
}
// executeTask runs the LLM tool loop for a subagent. Returns iteration count.
func (sm *SubagentManager) executeTask(ctx context.Context, task *SubagentTask) int {
// Tracing: generate a root span ID for this subagent execution.
// LLM/tool spans will nest under this root span via parent_span_id.
// The root span itself links to the parent agent's root span (from ctx).
subRootSpanID := store.GenNewID()
taskStart := time.Now().UTC()
// Detach cancellation while preserving the delegation redactor and the
// original trace parent. Rebuilding from Background would silently drop the
// artifact confidentiality boundary on nested synchronous work.
traceCtx := context.WithoutCancel(ctx)
// subCtx overrides parent_span_id so child spans nest under subRootSpanID.
// traceCtx retains the original parent_span_id for the root subagent span.
subTraceCtx := tracing.WithParentSpanID(traceCtx, subRootSpanID)
toolCtx := tracing.WithParentSpanID(ctx, subRootSpanID)
var model string
var finalContent string
iteration := 0
defer func() {
sm.mu.Lock()
task.CompletedAt = time.Now().UnixMilli()
sm.mu.Unlock()
// Finalize root subagent span on exit (uses traceCtx which is never cancelled).
sm.emitSubagentSpanEnd(traceCtx, subRootSpanID, taskStart, task, finalContent)
slog.Debug("subagent tracing: root span finalized",
"id", task.ID, "span_id", subRootSpanID,
"trace_id", tracing.TraceIDFromContext(traceCtx),
"status", task.Status, "iterations", iteration)
}()
if ctx.Err() != nil {
sm.mu.Lock()
task.Status = TaskStatusCancelled
task.Result = "cancelled before execution"
sm.mu.Unlock()
return 0
}
// Build tools for subagent (no spawn/subagent tools to prevent recursion)
toolsReg := sm.createTools()
toolsReg.Register(NewSpawnTool(sm, task.RootAgentKey, task.Depth))
sm.applyDenyList(toolsReg, task.Depth, task.spawnConfig)
// Determine model (cascading priority):
// 1. Per-task model override (highest — LLM specified model in spawn call)
// 2. SubagentConfig.Model (agent-level subagent override)
// 3. Parent agent's model (inherit from the agent that spawned us)
// 4. SubagentManager default model (system-wide fallback)
model = sm.model
if parentModel := ParentModelFromCtx(ctx); parentModel != "" {
model = parentModel
}
if task.spawnConfig.Model != "" {
model = task.spawnConfig.Model
}
if task.Model != "" {
model = task.Model
}
// Determine provider (cascading priority):
// 1. Parent agent's provider (inherit so model/provider combo stays valid)
// 2. SubagentManager default provider (system-wide fallback)
activeProvider := sm.provider
if sm.providerReg != nil {
if parentProviderName := ParentProviderFromCtx(ctx); parentProviderName != "" {
if p, err := sm.providerReg.Get(ctx, parentProviderName); err == nil {
activeProvider = p
}
}
}
// Emit running subagent root span (after model resolution so span has correct model).
sm.emitSubagentSpanStart(traceCtx, subRootSpanID, taskStart, task, model, activeProvider.Name())
// Build subagent system prompt (matching TS buildSubagentSystemPrompt pattern).
workspace := ToolWorkspaceFromCtx(ctx)
promptWorkspace := workspace
if IsDelegationArtifactRun(ctx) {
promptWorkspace = "outputs/"
}
systemPrompt := tracing.RedactText(ctx, sm.buildSubagentSystemPrompt(task, task.spawnConfig, promptWorkspace))
messages := []providers.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: tracing.RedactText(ctx, task.Task)},
}
// Run LLM iteration loop (similar to agent loop but simplified)
var mediaFiles []bus.MediaFile
maxIterations := 20
for iteration < maxIterations {
iteration++
if ctx.Err() != nil {
sm.mu.Lock()
task.Status = TaskStatusCancelled
task.Result = "cancelled during execution"
sm.mu.Unlock()
return iteration
}
chatReq := providers.ChatRequest{
Messages: messages,
Tools: toolsReg.ProviderDefs(),
Model: model,
Options: map[string]any{
"max_tokens": 4096,
"temperature": 0.5,
},
}
llmStart := time.Now().UTC()
llmSpanID := sm.emitLLMSpanStart(subTraceCtx, llmStart, iteration, model, activeProvider.Name(), messages)
maxRetries := task.spawnConfig.MaxRetries
if maxRetries <= 0 {
maxRetries = 2
}
var resp *providers.ChatResponse
var err error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
backoff := time.Duration(attempt) * 2 * time.Second
select {
case <-ctx.Done():
case <-time.After(backoff):
}
if ctx.Err() != nil {
break
}
slog.Info("subagent LLM retry", "id", task.ID, "iteration", iteration, "attempt", attempt+1)
}
resp, err = sm.chatSubagentWithUsageCap(ctx, task, activeProvider, model, chatReq, iteration, attempt+1)
if err == nil {
break
}
}
sm.emitLLMSpanEnd(subTraceCtx, llmSpanID, llmStart, resp, err)
// Accumulate token usage for cost tracking.
if resp != nil && resp.Usage != nil {
sm.mu.Lock()
task.TotalInputTokens += int64(resp.Usage.PromptTokens)
task.TotalOutputTokens += int64(resp.Usage.CompletionTokens)
sm.mu.Unlock()
}
if err != nil {
sm.mu.Lock()
task.Status = TaskStatusFailed
task.Result = tracing.RedactText(ctx, fmt.Sprintf("LLM error at iteration %d: %v", iteration, err))
sm.mu.Unlock()
slog.Warn("subagent LLM error", "id", task.ID, "iteration", iteration, "error", err)
return iteration
}
// No tool calls → done
if len(resp.ToolCalls) == 0 {
finalContent = resp.Content
break
}
// Build assistant message
assistantMsg := providers.Message{
Role: "assistant",
Content: resp.Content,
ToolCalls: resp.ToolCalls,
}
messages = append(messages, assistantMsg)
// Execute tools
for _, tc := range resp.ToolCalls {
slog.Debug("subagent tool call", "id", task.ID, "tool", tc.Name)
argsJSON, _ := json.Marshal(tc.Arguments)
toolStart := time.Now().UTC()
toolSpanID := sm.emitToolSpanStart(subTraceCtx, toolStart, tc.Name, tc.ID, string(argsJSON))
result := toolsReg.Execute(toolCtx, tc.Name, tc.Arguments)
result.ForLLM = tracing.RedactText(ctx, result.ForLLM)
sm.emitToolSpanEnd(subTraceCtx, toolSpanID, toolStart, result.ForLLM, result.IsError)
// Capture media file paths from tool results (e.g. image generation).
if len(result.Media) > 0 {
mediaFiles = append(mediaFiles, result.Media...)
} else if strings.HasPrefix(strings.TrimSpace(result.ForLLM), "MEDIA:") {
// Fallback: parse MEDIA: prefix from ForLLM (same as agent loop's parseMediaResult)
p := strings.TrimSpace(strings.TrimSpace(result.ForLLM)[6:])
if nl := strings.IndexByte(p, '\n'); nl >= 0 {
p = strings.TrimSpace(p[:nl])
}
if p != "" {
mediaFiles = append(mediaFiles, bus.MediaFile{Path: p, Filename: filepath.Base(p)})
}
}
messages = append(messages, providers.Message{
Role: "tool",
Content: result.ForLLM,
ToolCallID: tc.ID,
ToolName: tc.Name,
})
}
}
sm.mu.Lock()
if task.Status != TaskStatusCancelled {
if finalContent == "" {
finalContent = "Task completed but no final response was generated."
}
finalContent = tracing.RedactText(ctx, finalContent)
task.Status = TaskStatusCompleted
task.Result = finalContent
task.Media = mediaFiles
}
sm.mu.Unlock()
slog.Info("subagent completed", "id", task.ID, "iterations", iteration)
return iteration
}
func (sm *SubagentManager) chatSubagentWithUsageCap(ctx context.Context, task *SubagentTask, activeProvider providers.Provider, model string, chatReq providers.ChatRequest, iteration, attempt int) (*providers.ChatResponse, error) {
budget := usagecaps.AgentBudget{
ContextWindow: task.OriginContextWindow,
MaxTokens: task.OriginMaxTokens,
}
if budget.ContextWindow <= 0 {
budget.ContextWindow = sm.contextWindow
}
if budget.MaxTokens <= 0 {
budget.MaxTokens = sm.maxTokens
}
chatReq = clampToolRequestMaxTokens(chatReq, budget.MaxTokens)
if fallbackProvider, ok := activeProvider.(*providers.ModelFallbackProvider); ok {
before := func(callCtx context.Context, entry providers.FallbackCandidate, actualReq providers.ChatRequest) (providers.FallbackAfterCall, error) {
// Guard this fallback request with the calling agent budget.
if guardErr := usagecaps.GuardContextWindow(clampToolRequestMaxTokens(actualReq, budget.MaxTokens), entry.ProviderName, actualReq.Model, "subagent:"+task.ID, budget); guardErr != nil {
return nil, guardErr
}
reservation, err := sm.reserveSubagentUsage(callCtx, task, entry.ProviderName, actualReq.Model, actualReq, fmt.Sprintf("%d:%d:%s:%s", iteration, attempt, entry.ProviderName, actualReq.Model))
if err != nil {
return nil, err
}
return func(resp *providers.ChatResponse, callErr error, _ providers.FallbackCallInfo) {
if reservation != nil {
reservation.Reconcile(callCtx, resp, callErr)
}
}, nil
}
return fallbackProvider.ChatWithHook(ctx, chatReq, before)
}
// Guard the non-fallback request with the calling agent budget.
if guardErr := usagecaps.GuardContextWindow(chatReq, activeProvider.Name(), model, "subagent:"+task.ID, budget); guardErr != nil {
return nil, guardErr
}
reservation, err := sm.reserveSubagentUsage(ctx, task, activeProvider.Name(), model, chatReq, fmt.Sprintf("%d:%d", iteration, attempt))
if err != nil {
return nil, err
}
resp, err := activeProvider.Chat(ctx, chatReq)
if reservation != nil {
reservation.Reconcile(ctx, resp, err)
}
return resp, err
}
func (sm *SubagentManager) reserveSubagentUsage(ctx context.Context, task *SubagentTask, providerName, model string, chatReq providers.ChatRequest, suffix string) (*usagecaps.Reservation, error) {
if sm.usageCaps == nil {
return nil, nil
}
return sm.usageCaps.Preflight(ctx, usagecaps.Request{
TenantID: task.OriginTenantID,
AgentID: task.OriginAgentID,
ProviderName: providerName,
ModelID: model,
ReservationKey: fmt.Sprintf("%s:%s", task.ID, suffix),
Messages: chatReq.Messages,
MaxOutputTokens: 4096,
})
}