Files
goclaw/internal/tools/subagent_exec.go
T
viettranx ef720ee13a feat(tracing): two-phase spans — show running state before completion
Spans are now emitted BEFORE execution starts with status "running" and
input visible, then updated when the step completes. This lets users see
what the system is doing during long-running LLM calls and tool executions.

- Add EmitSpanUpdate() to collector with separate update buffer
- Flush ordering: batch INSERT new spans first, then process updates
- Split LLM/tool/agent spans into start/end pairs (agent loop + subagent)
- Emit tool span end inside goroutines for parallel calls (no orphans)
- EmitSpanUpdate is a channel send — works after ctx cancellation
2026-03-10 13:52:24 +07:00

268 lines
8.6 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"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"
)
// runTask executes the subagent in a goroutine.
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, callback AsyncCallback) {
iterations := sm.executeTask(ctx, task)
// 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,
Label: task.Label,
Status: task.Status,
Result: task.Result,
Media: task.Media,
Runtime: elapsed,
Iterations: iterations,
}
meta := AnnounceMetadata{
OriginChannel: task.OriginChannel,
OriginChatID: task.OriginChatID,
OriginPeerKind: task.OriginPeerKind,
OriginLocalKey: task.OriginLocalKey,
OriginUserID: task.OriginUserID,
OriginSessionKey: task.OriginSessionKey,
ParentAgent: task.ParentID,
OriginTraceID: task.OriginTraceID.String(),
OriginRootSpanID: task.OriginRootSpanID.String(),
}
if sm.announceQueue != nil {
// Use batched announce queue (matching TS debounce pattern)
sessionKey := fmt.Sprintf("announce:%s:%s", task.ParentID, task.OriginChatID)
sm.announceQueue.Enqueue(sessionKey, item, meta)
} else {
// Direct publish (no batching)
remainingActive := sm.CountRunningForParent(task.ParentID)
announceContent := FormatBatchedAnnounce([]AnnounceQueueItem{item}, remainingActive)
announceMeta := map[string]string{
"origin_channel": task.OriginChannel,
"origin_peer_kind": task.OriginPeerKind,
"parent_agent": task.ParentID,
"subagent_id": task.ID,
"subagent_label": task.Label,
"origin_trace_id": task.OriginTraceID.String(),
"origin_root_span_id": task.OriginRootSpanID.String(),
}
if task.OriginLocalKey != "" {
announceMeta["origin_local_key"] = task.OriginLocalKey
}
if task.OriginSessionKey != "" {
announceMeta["origin_session_key"] = task.OriginSessionKey
}
sm.msgBus.PublishInbound(bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("subagent:%s", task.ID),
ChatID: task.OriginChatID,
Content: announceContent,
UserID: task.OriginUserID,
Metadata: announceMeta,
Media: task.Media,
})
}
}
// 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)
}
}
// 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()
// Use a detached context for tracing so spans are emitted even if parent ctx is cancelled.
// We copy tracing values but remove the cancellation chain.
traceCtx := context.Background()
if collector := tracing.CollectorFromContext(ctx); collector != nil {
traceCtx = tracing.WithCollector(traceCtx, collector)
traceCtx = tracing.WithTraceID(traceCtx, tracing.TraceIDFromContext(ctx))
// Keep original parent_span_id (parent agent's root span) for the subagent root span.
traceCtx = tracing.WithParentSpanID(traceCtx, tracing.ParentSpanIDFromContext(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)
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)
// Schedule auto-archive
if sm.config.ArchiveAfterMinutes > 0 {
go sm.scheduleArchive(task.ID, time.Duration(sm.config.ArchiveAfterMinutes)*time.Minute)
}
}()
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()
sm.applyDenyList(toolsReg, task.Depth)
// Determine model (cascading priority matching TS sessions-spawn-tool.ts):
// 1. Per-task model override (highest)
// 2. SubagentConfig.Model (global subagent override)
// 3. SubagentManager default model (inherited from parent)
model = sm.model
if sm.config.Model != "" {
model = sm.config.Model
}
if task.Model != "" {
model = task.Model
}
// Emit running subagent root span (after model resolution so span has correct model).
sm.emitSubagentSpanStart(traceCtx, subRootSpanID, taskStart, task, model)
// Build subagent system prompt (matching TS buildSubagentSystemPrompt pattern).
systemPrompt := sm.buildSubagentSystemPrompt(task)
messages := []providers.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: 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, messages)
resp, err := sm.provider.Chat(ctx, chatReq)
sm.emitLLMSpanEnd(subTraceCtx, llmSpanID, llmStart, resp, err)
if err != nil {
sm.mu.Lock()
task.Status = TaskStatusFailed
task.Result = 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(ctx, tc.Name, tc.Arguments)
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})
}
}
messages = append(messages, providers.Message{
Role: "tool",
Content: result.ForLLM,
ToolCallID: tc.ID,
})
}
}
sm.mu.Lock()
if finalContent == "" {
finalContent = "Task completed but no final response was generated."
}
task.Status = TaskStatusCompleted
task.Result = finalContent
task.Media = mediaFiles
sm.mu.Unlock()
slog.Info("subagent completed", "id", task.ID, "iterations", iteration)
return iteration
}