mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 14:13:21 +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.
453 lines
15 KiB
Go
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,
|
|
})
|
|
}
|