Files
goclaw/internal/tools/media_provider_chain.go
T
1a42dc93a6 feat(teams): team system v2 with bug fixes, workspace scope, versioning, and prompt optimization (#183)
* feat(workspace): add team shared workspace for file collaboration

- Add workspace_write and workspace_read tools for agents to share files across team members
- Create team_workspaces DB table with migration 000017 (file metadata, pinning, tags)
- Implement PostgreSQL store layer for workspace CRUD operations
- Add RPC handlers for workspace list/read/delete from web UI
- Build React workspace tab with file listing, content preview, and delete
- Propagate workspace channel/chatID scope through delegation chain
- Auto-allow workspace tools in agent tool policy when agent belongs to a team
- Inject team workspace guidance into system prompt for team agents
- Add /reset command handler for clearing session history
- Harden MCP bridge context middleware to reject headers when no gateway token
- Add i18n strings for workspace UI in en/vi/zh locales

* feat(teams): add comprehensive task management with followup reminders and recovery

- Add task followup/reminder system with auto-set on lead agent reply and auto-clear when user responds on channel
- Add task recovery ticker to re-dispatch stale/pending tasks periodically
- Add task scopes, filtering by status/channel/chatID, and task events
- Add WS RPC handlers for task CRUD, assignments, comments, events, and bulk operations (teams_tasks.go)
- Add task detail dialog, settings UI for followup config, and scope filtering in web dashboard
- Add migrations 000018 (team_tasks_v2) and 000019 (task_followup)
- Extend team_tasks_tool with await_reply, clear_followup actions
- Auto-complete/fail team tasks when delegate agent finishes
- Add workspace file listing and team tool manager enhancements

* docs(teams): add team system architecture and playbook ideas documentation

- Add TEAM_SYSTEM.md with full architecture design covering task management, shared workspace, and delegation engine subsystems
- Add TEAM_PLAYBOOK_IDEAS.md outlining future team coordination layers (playbook, member capabilities, auto-learned patterns)
- Document data models, status flows, tool actions, followup reminder system, task ticker, execution locking, and workspace scope model

* fix(teams): resolve 6 critical bugs in team task system

- Fix unblock SQL: check array_length after array_remove (not before)
- Enforce single-team leadership in team creation
- Add requireLead() for approve/reject tool actions
- Validate cross-team dependency references in blocked_by
- Add team_id to handoff route for multi-team isolation
- Set blocked_by DEFAULT '{}' to prevent NULL array issues

* refactor(workspace): use stable userID as scope key instead of connection UUID

Workspace scope changed from (team_id, channel, chat_id) to (team_id, userID).
Fixes workspace fragmentation across WS tab refreshes and reconnections.

* feat(teams): add V1/V2 versioning with feature gating and optimized prompts

- IsTeamV2() helper gates advanced features (locking, followup, review, audit)
- V2 tool actions rejected for V1 teams with clear error message
- Ticker, gateway consumer, delegation hooks respect version flag
- TEAM.md renders v1/v2 sections conditionally
- Tool descriptions and params optimized (~38% token reduction)
- UI: version toggle in settings, V2 Beta badge, conditional rendering
- i18n: version modal keys for en/vi/zh

* fix(migration): use VARCHAR(255) for user ID columns and add metadata JSONB

- assignee_user_id, user_id, actor_id: TEXT → VARCHAR(255)
- Add metadata JSONB to team_task_comments and team_task_attachments

---------

Co-authored-by: Nam Nguyen Ngoc <namnn.0911@gmail.com>
2026-03-13 22:41:32 +07:00

350 lines
10 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"maps"
"strconv"
"strings"
"time"
"github.com/nextlevelbuilder/goclaw/internal/providers"
)
// MediaProviderEntry represents a single provider in an ordered fallback chain.
type MediaProviderEntry struct {
ProviderID string `json:"provider_id,omitempty"` // UUID for tracing (optional)
Provider string `json:"provider"` // name for registry.Get()
Model string `json:"model"`
Enabled bool `json:"enabled"`
Timeout int `json:"timeout"` // seconds, default 120
MaxRetries int `json:"max_retries"` // default 2
Params map[string]any `json:"params,omitempty"` // provider-specific config
}
// mediaProviderChain is the settings JSON structure for media tools.
// Only supports chain format: {"providers":[...]}.
// Legacy flat format is auto-migrated at startup (see cmd/gateway_builtin_tools.go).
type mediaProviderChain struct {
Providers []MediaProviderEntry `json:"providers,omitempty"`
}
// applyDefaults fills in zero-value fields with sensible defaults.
func (e *MediaProviderEntry) applyDefaults() {
if e.Timeout <= 0 {
e.Timeout = 120
}
if e.MaxRetries <= 0 {
e.MaxRetries = 2
}
}
// ResolveMediaProviderChain parses builtin_tools.settings for a media tool and
// returns an ordered list of enabled provider entries. Falls back to hardcoded
// defaults when no user-configured chain exists.
//
// Resolution priority:
// 1. Per-agent config override (provider/model from context) — wrapped as single entry
// 2. builtin_tools.settings (new chain format or legacy flat format)
// 3. Hardcoded default chain for the tool
func ResolveMediaProviderChain(
ctx context.Context,
toolName string,
perAgentProvider, perAgentModel string,
defaultPriority []string,
defaultModels map[string]string,
registry *providers.Registry,
) []MediaProviderEntry {
// 1. Per-agent override takes highest priority
if perAgentProvider != "" {
model := perAgentModel
if model == "" {
model = defaultModels[perAgentProvider]
}
entry := MediaProviderEntry{
Provider: perAgentProvider,
Model: model,
Enabled: true,
}
entry.applyDefaults()
return []MediaProviderEntry{entry}
}
// 2. Parse from builtin_tools.settings
if settings := BuiltinToolSettingsFromCtx(ctx); settings != nil {
if raw, ok := settings[toolName]; ok && len(raw) > 0 {
chain := parseChainSettings(raw, defaultModels)
if len(chain) > 0 {
return chain
}
}
}
// 3. Hardcoded default chain — use first available provider
return buildDefaultChain(defaultPriority, defaultModels, registry)
}
// parseChainSettings parses the settings JSON into a chain, handling both new
// and legacy formats. Returns nil if parsing fails or result is empty.
func parseChainSettings(raw []byte, defaultModels map[string]string) []MediaProviderEntry {
var chain mediaProviderChain
if err := json.Unmarshal(raw, &chain); err != nil {
slog.Warn("media_chain: failed to parse settings", "error", err)
return nil
}
var result []MediaProviderEntry
for _, e := range chain.Providers {
if !e.Enabled {
continue
}
if e.Provider == "" {
continue
}
if e.Model == "" {
e.Model = defaultModels[e.Provider]
}
e.applyDefaults()
result = append(result, e)
}
return result
}
// buildDefaultChain creates a chain from the hardcoded priority list,
// including only providers that are currently registered.
func buildDefaultChain(
priority []string,
defaultModels map[string]string,
registry *providers.Registry,
) []MediaProviderEntry {
var chain []MediaProviderEntry
for _, name := range priority {
if _, err := registry.Get(name); err == nil {
entry := MediaProviderEntry{
Provider: name,
Model: defaultModels[name],
Enabled: true,
}
entry.applyDefaults()
chain = append(chain, entry)
}
}
return chain
}
// ChainCallFn is the function signature for provider-specific API calls.
// Receives the credential provider, provider name, model, and params.
type ChainCallFn func(ctx context.Context, cp credentialProvider, providerName, model string, params map[string]any) ([]byte, *providers.Usage, error)
// ChainResult holds the result of ExecuteWithChain.
type ChainResult struct {
Data []byte
Usage *providers.Usage
Provider string
Model string
}
// ExecuteWithChain tries each provider in the chain sequentially.
// For each provider, it retries up to MaxRetries times (with the configured timeout).
// Returns the first successful result or the last error encountered.
func ExecuteWithChain(
ctx context.Context,
chain []MediaProviderEntry,
registry *providers.Registry,
fn ChainCallFn,
) (*ChainResult, error) {
if len(chain) == 0 {
return nil, fmt.Errorf("no providers configured")
}
var lastErr error
for _, entry := range chain {
p, err := registry.Get(entry.Provider)
if err != nil {
slog.Warn("media_chain: provider not found, skipping",
"provider", entry.Provider, "error", err)
lastErr = fmt.Errorf("provider %q not available", entry.Provider)
continue
}
// credentialProvider is optional — providers that don't expose static
// credentials (e.g. OAuth-based CodexProvider) pass nil and each
// callProvider falls back to using the provider's Chat() API.
cp, _ := p.(credentialProvider)
// Inject resolved provider type into params so callProvider can route correctly.
// Clone params to avoid mutating the original entry config.
resolvedType := ResolveProviderType(p)
callParams := make(map[string]any, len(entry.Params)+1)
maps.Copy(callParams, entry.Params)
callParams["_provider_type"] = resolvedType
// Retry loop for this provider
for attempt := 1; attempt <= entry.MaxRetries; attempt++ {
timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(entry.Timeout)*time.Second)
data, usage, callErr := fn(timeoutCtx, cp, entry.Provider, entry.Model, callParams)
cancel()
if callErr == nil {
return &ChainResult{
Data: data,
Usage: usage,
Provider: entry.Provider,
Model: entry.Model,
}, nil
}
lastErr = callErr
// Don't retry on context cancellation (parent ctx cancelled)
if ctx.Err() != nil {
return nil, fmt.Errorf("context cancelled: %w", lastErr)
}
if attempt < entry.MaxRetries {
slog.Warn("media_chain: attempt failed, retrying",
"provider", entry.Provider, "model", entry.Model,
"attempt", attempt, "max_retries", entry.MaxRetries,
"error", truncateError(callErr))
}
}
slog.Warn("media_chain: provider exhausted retries, moving to next",
"provider", entry.Provider, "model", entry.Model,
"max_retries", entry.MaxRetries, "error", truncateError(lastErr))
}
return nil, fmt.Errorf("all providers failed: %w", lastErr)
}
// maxMediaDownloadBytes is the maximum size for media file downloads (200 MB).
const maxMediaDownloadBytes = 200 * 1024 * 1024
// limitedReadAll reads up to maxMediaDownloadBytes from r, returning an error if the limit is exceeded.
func limitedReadAll(r io.Reader, maxBytes int64) ([]byte, error) {
lr := io.LimitReader(r, maxBytes+1)
data, err := io.ReadAll(lr)
if err != nil {
return nil, err
}
if int64(len(data)) > maxBytes {
return nil, fmt.Errorf("response exceeds %d bytes limit", maxBytes)
}
return data, nil
}
// truncateError returns a short string representation of an error for logging.
func truncateError(err error) string {
if err == nil {
return ""
}
s := err.Error()
if len(s) > 200 {
return s[:200] + "..."
}
return s
}
// GetParamString extracts a string param from the params map, returning fallback if not found.
func GetParamString(params map[string]any, key, fallback string) string {
if params == nil {
return fallback
}
if v, ok := params[key].(string); ok && v != "" {
return v
}
return fallback
}
// GetParamBool extracts a bool param from the params map, returning fallback if not found.
func GetParamBool(params map[string]any, key string, fallback bool) bool {
if params == nil {
return fallback
}
if v, ok := params[key].(bool); ok {
return v
}
return fallback
}
// GetParamInt extracts an int param from the params map, returning fallback if not found.
func GetParamInt(params map[string]any, key string, fallback int) int {
if params == nil {
return fallback
}
switch v := params[key].(type) {
case float64:
return int(v)
case int:
return v
case string:
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
// typedProvider is optionally implemented by providers that carry their DB provider_type.
type typedProvider interface {
ProviderType() string
}
// dbTypeToMediaType maps DB provider_type values to the media routing type
// used by callProvider switch statements.
var dbTypeToMediaType = map[string]string{
"gemini_native": "gemini",
"openai_compat": "openai_compat",
"openrouter": "openrouter",
"minimax_native": "minimax",
"dashscope": "dashscope",
"bailian": "dashscope",
"anthropic_native": "anthropic",
"suno": "suno",
}
// ResolveProviderType returns the media routing type for a provider.
// It first checks the provider's DB type (via typedProvider interface),
// then falls back to name-based heuristics for config-registered providers.
func ResolveProviderType(p providers.Provider) string {
// Prefer the actual DB provider_type when available
if tp, ok := p.(typedProvider); ok {
if pt := tp.ProviderType(); pt != "" {
if mt, found := dbTypeToMediaType[pt]; found {
return mt
}
}
}
// Fallback: infer from provider name (for config-registered providers)
return providerTypeFromName(p.Name())
}
// providerTypeFromName infers provider type from naming patterns.
// Used as fallback when the provider doesn't carry its DB type.
func providerTypeFromName(name string) string {
switch {
case name == "gemini" || strings.HasPrefix(name, "gemini"):
return "gemini"
case name == "openrouter":
return "openrouter"
case name == "minimax" || strings.HasPrefix(name, "minimax"):
return "minimax"
case name == "alibaba" || name == "dashscope" || name == "bailian":
return "dashscope"
case name == "openai":
return "openai"
case name == "anthropic":
return "anthropic"
case name == "suno" || strings.HasPrefix(name, "suno"):
return "suno"
case name == "yescale":
return "openai"
default:
return "openai_compat"
}
}