Files
goclaw/internal/agent/media_tool_routing.go
viettranx 1ac08155b0 feat(trace): reliable stop/abort with ctx-aware streams and 2-phase router
Makes the Stop button on the traces page actually stop running traces.
Seven-phase implementation across provider HTTP, agent router, trace
persistence, WS events, tool exec, i18n, and integration tests.

- Provider HTTP+SSE ctx-aware: close socket on cancel via CtxBody wrapper
- Router 2-phase abort: CAS state machine, 3s grace, force-mark fallback
- Trace retry: 3 inline retries + 10-max retry queue, stale recovery 10min
- trace.status WS event: real-time UI updates (invalidates query on receive)
- Tool exec: process-group kill (SIGTERM→3s→SIGKILL), Rod page ctx watch
- i18n: 6 abort toast variants in en/vi/zh
- Integration: 9 scenarios, -race clean

Fixes tenant-ctx loss in forceMarkTraceAborted and retry worker broadcast
(caught by code-reviewer: C1/C2). Stale threshold intentionally 10min
because start_time-based; last_span_at migration is a follow-up.
2026-04-14 18:28:31 +07:00

103 lines
3.0 KiB
Go

package agent
import (
"context"
"encoding/json"
"log/slog"
"path/filepath"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/providers"
"github.com/nextlevelbuilder/goclaw/internal/tools"
)
// hasReadImageProvider checks if the read_image builtin tool has a dedicated provider configured.
// When true, images should NOT be attached inline to the main LLM — instead the agent
// uses the read_image tool which routes to the configured vision provider.
// Supports both legacy flat format {"provider":"X"} and chain format {"providers":[...]}.
func (l *Loop) hasReadImageProvider() bool {
if l.builtinToolSettings == nil {
return false
}
raw, ok := l.builtinToolSettings["read_image"]
if !ok || len(raw) == 0 {
return false
}
// Try chain format first: {"providers":[{"provider":"X",...}]}
var chain struct {
Providers []struct {
Provider string `json:"provider"`
Enabled *bool `json:"enabled,omitempty"`
} `json:"providers"`
}
if json.Unmarshal(raw, &chain) == nil && len(chain.Providers) > 0 {
for _, p := range chain.Providers {
if p.Provider != "" && (p.Enabled == nil || *p.Enabled) {
return true
}
}
}
// Fallback: legacy flat format {"provider":"X"}
var legacy struct {
Provider string `json:"provider"`
}
if json.Unmarshal(raw, &legacy) == nil && legacy.Provider != "" {
return true
}
return false
}
// loadHistoricalImagesForTool collects image MediaRefs from historical messages
// and loads them into context for the read_image tool. Merges with any images already
// in context (from current turn). Limited to last maxMediaReloadMessages messages with image refs.
func (l *Loop) loadHistoricalImagesForTool(ctx context.Context, currentRefs []providers.MediaRef, messages []providers.Message) context.Context {
// Start with current-turn images already in context.
existing := tools.MediaImagesFromCtx(ctx)
// Collect image paths from historical messages (scan backwards, limit count).
var histPaths []bus.MediaFile
count := 0
for i := len(messages) - 1; i >= 0 && count < maxMediaReloadMessages; i-- {
if len(messages[i].MediaRefs) == 0 {
continue
}
hasImage := false
for _, ref := range messages[i].MediaRefs {
if ref.Kind != "image" {
continue
}
hasImage = true
p := ref.Path
if p == "" && l.mediaStore != nil {
p, _ = l.mediaStore.LoadPath(ref.ID)
}
if p == "" {
continue
}
histPaths = append(histPaths, bus.MediaFile{Path: p, MimeType: ref.MimeType, Filename: filepath.Base(p)})
}
if hasImage {
count++
}
}
if len(histPaths) == 0 {
return ctx
}
histImages := loadImages(histPaths)
if len(histImages) == 0 {
return ctx
}
// Merge: existing (current turn) + historical.
merged := make([]providers.ImageContent, 0, len(existing)+len(histImages))
merged = append(merged, existing...)
merged = append(merged, histImages...)
slog.Debug("vision: loaded historical images for read_image tool", "current", len(existing), "historical", len(histImages))
return tools.WithMediaImages(ctx, merged)
}