feat(agent): integrate ReAct FSM into agent workflow

- Added a composite observer for transition events in the agent's Generate method.
- Implemented state transitions for various stages of the agent's processing, including preparation, execution, and error handling.
- Enhanced the processStepStream method to correctly track step indices and tool results.
- Updated the agent's context handling to improve observability and debugging.
This commit is contained in:
ZanzyTHEbar 2026-02-19 00:31:54 +00:00
parent 92193d49e0
commit f9a986f735
13 changed files with 108 additions and 49 deletions

8
go.mod
View file

@ -6,6 +6,8 @@ replace charm.land/fantasy v0.8.1 => ./internal/fantasy
require ( require (
charm.land/fantasy v0.8.1 charm.land/fantasy v0.8.1
github.com/ZanzyTHEbar/assert-lib v1.3.1
github.com/ZanzyTHEbar/errbuilder-go v1.5.1
github.com/adhocore/gronx v1.19.6 github.com/adhocore/gronx v1.19.6
github.com/bwmarrin/discordgo v0.29.0 github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
@ -18,10 +20,12 @@ require (
github.com/openai/openai-go/v2 v2.7.1 github.com/openai/openai-go/v2 v2.7.1
github.com/pkoukk/tiktoken-go v0.1.6 github.com/pkoukk/tiktoken-go v0.1.6
github.com/pressly/goose/v3 v3.26.0 github.com/pressly/goose/v3 v3.26.0
github.com/rs/zerolog v1.34.0
github.com/slack-go/slack v0.17.3 github.com/slack-go/slack v0.17.3
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1 github.com/tencent-connect/botgo v0.2.1
github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.35.0 golang.org/x/oauth2 v0.35.0
) )
@ -32,8 +36,6 @@ require (
) )
require ( require (
github.com/ZanzyTHEbar/assert-lib v1.3.1 // indirect
github.com/ZanzyTHEbar/errbuilder-go v1.5.1 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
@ -61,7 +63,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/qmuntal/stateless v1.8.0 // indirect github.com/qmuntal/stateless v1.8.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/match v1.2.0 // indirect
@ -73,7 +74,6 @@ require (
github.com/valyala/fastjson v1.6.7 // indirect github.com/valyala/fastjson v1.6.7 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.24.0 // indirect golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect

View file

@ -12,8 +12,7 @@ PicoClaw's `go.mod` contains a `replace` directive:
replace charm.land/fantasy v0.8.1 => ./internal/fantasy replace charm.land/fantasy v0.8.1 => ./internal/fantasy
``` ```
This redirects all `charm.land/fantasy` imports to this local copy. No import paths This redirects all `charm.land/fantasy` imports to this local copy. No import paths need to change in either PicoClaw code or the fantasy source itself.
need to change in either PicoClaw code or the fantasy source itself.
## Automated Sync System ## Automated Sync System
@ -69,14 +68,11 @@ The sync script will:
- Update `.vendor-version`, `go.mod` replace directive, and this file - Update `.vendor-version`, `go.mod` replace directive, and this file
- Run `go build` and `go test` for validation - Run `go build` and `go test` for validation
If any patch fails to apply, the script aborts with a clear error message showing If any patch fails to apply, the script aborts with a clear error message showing which patch conflicted. You'll need to resolve the conflict manually, then re-save the patch with `make fantasy-patch`.
which patch conflicted. You'll need to resolve the conflict manually, then re-save
the patch with `make fantasy-patch`.
## Patch Management ## Patch Management
Local modifications to the vendored SDK are tracked as numbered `.patch` files in the Local modifications to the vendored SDK are tracked as numbered `.patch` files in the `patches/` directory:
`patches/` directory:
``` ```
internal/fantasy/patches/ internal/fantasy/patches/
@ -119,7 +115,3 @@ If a patch fails during sync:
| `patches/` | Directory of local modification patches | | `patches/` | Directory of local modification patches |
| `patches/.gitkeep` | Ensures the directory is tracked in git | | `patches/.gitkeep` | Ensures the directory is tracked in git |
| `VENDORING.md` | This documentation file | | `VENDORING.md` | This documentation file |
## Original License
Fantasy is licensed under the MIT License. See `LICENSE` in this directory.

View file

@ -377,6 +377,20 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
var responseMessages []Message var responseMessages []Message
var steps []StepResult var steps []StepResult
// Build a composite observer that fans out to all registered transition observers.
var fsmObserver ReActTransitionObserver
if len(a.settings.transitionObservers) > 0 {
obs := a.settings.transitionObservers
fsmObserver = ReActTransitionObserverFunc(func(ctx context.Context, t ReActTransition) {
for _, o := range obs {
o.OnReActTransition(ctx, t)
}
})
}
stepIdx := 0
fsm := newReActFSM(fsmObserver, &stepIdx)
fsm.Fire(ctx, ReActTriggerStart)
for { for {
stepInputMessages := append(initialPrompt, responseMessages...) stepInputMessages := append(initialPrompt, responseMessages...)
stepModel := a.settings.model stepModel := a.settings.model
@ -432,6 +446,8 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
} }
} }
fsm.Fire(ctx, ReActTriggerPrepared)
preparedTools := a.prepareTools(stepTools, stepActiveTools, disableAllTools) preparedTools := a.prepareTools(stepTools, stepActiveTools, disableAllTools)
retryOptions := DefaultRetryOptions() retryOptions := DefaultRetryOptions()
@ -456,8 +472,10 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
}) })
}) })
if err != nil { if err != nil {
fsm.Fire(ctx, ReActTriggerErrored)
return nil, err return nil, err
} }
fsm.Fire(ctx, ReActTriggerLLMResponded)
var stepToolCalls []ToolCallContent var stepToolCalls []ToolCallContent
for _, content := range result.Content { for _, content := range result.Content {
@ -472,6 +490,7 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
stepToolCalls = append(stepToolCalls, validatedToolCall) stepToolCalls = append(stepToolCalls, validatedToolCall)
} }
} }
fsm.Fire(ctx, ReActTriggerToolsValidated)
var toolResults []ToolResultContent var toolResults []ToolResultContent
if a.settings.toolRuntime != nil { if a.settings.toolRuntime != nil {
@ -479,6 +498,7 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
} else { } else {
toolResults, err = a.executeTools(ctx, stepTools, stepToolCalls, nil) toolResults, err = a.executeTools(ctx, stepTools, stepToolCalls, nil)
} }
fsm.Fire(ctx, ReActTriggerToolsExecuted)
// Build step content with validated tool calls and tool results // Build step content with validated tool calls and tool results
stepContent := []Content{} stepContent := []Content{}
@ -503,6 +523,7 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
} }
currentStepMessages := toResponseMessages(stepContent) currentStepMessages := toResponseMessages(stepContent)
responseMessages = append(responseMessages, currentStepMessages...) responseMessages = append(responseMessages, currentStepMessages...)
fsm.Fire(ctx, ReActTriggerMessagesAppended)
stepResult := StepResult{ stepResult := StepResult{
Response: Response{ Response: Response{
@ -515,6 +536,7 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
Messages: currentStepMessages, Messages: currentStepMessages,
} }
steps = append(steps, stepResult) steps = append(steps, stepResult)
stepIdx = len(steps) - 1
for _, obs := range a.settings.stepObservers { for _, obs := range a.settings.stepObservers {
obs.OnReActStep(ctx, len(steps)-1, stepResult) obs.OnReActStep(ctx, len(steps)-1, stepResult)
@ -522,9 +544,15 @@ func (a *agent) Generate(ctx context.Context, opts AgentCall) (*AgentResult, err
shouldStop := isStopConditionMet(opts.StopWhen, steps) shouldStop := isStopConditionMet(opts.StopWhen, steps)
if shouldStop || err != nil || len(stepToolCalls) == 0 || result.FinishReason != FinishReasonToolCalls { if shouldStop {
fsm.Fire(ctx, ReActTriggerStopConditionMet)
break break
} }
if err != nil || len(stepToolCalls) == 0 || result.FinishReason != FinishReasonToolCalls {
fsm.Fire(ctx, ReActTriggerFinished)
break
}
fsm.Fire(ctx, ReActTriggerContinue)
} }
totalUsage := Usage{} totalUsage := Usage{}
@ -1148,7 +1176,7 @@ func WithToolResultObserver(o ReActToolResultObserver) AgentOption {
} }
// processStepStream processes a single step's stream and returns the step result. // processStepStream processes a single step's stream and returns the step result.
func (a *agent) processStepStream(ctx context.Context, stream StreamResponse, opts AgentStreamCall, _ []StepResult, stepTools []AgentTool) (stepExecutionResult, error) { func (a *agent) processStepStream(ctx context.Context, stream StreamResponse, opts AgentStreamCall, steps []StepResult, stepTools []AgentTool) (stepExecutionResult, error) {
var stepContent []Content var stepContent []Content
var stepToolCalls []ToolCallContent var stepToolCalls []ToolCallContent
var stepUsage Usage var stepUsage Usage
@ -1439,7 +1467,7 @@ func (a *agent) processStepStream(ctx context.Context, stream StreamResponse, op
for _, tr := range toolResults { for _, tr := range toolResults {
stepContent = append(stepContent, tr) stepContent = append(stepContent, tr)
for _, obs := range a.settings.toolResultObservers { for _, obs := range a.settings.toolResultObservers {
obs.OnReActToolResult(ctx, 0, tr) obs.OnReActToolResult(ctx, len(steps), tr)
} }
} }

View file

@ -16,6 +16,7 @@ require (
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/kaptinlin/jsonschema v0.6.10 github.com/kaptinlin/jsonschema v0.6.10
github.com/openai/openai-go/v2 v2.7.1 github.com/openai/openai-go/v2 v2.7.1
github.com/qmuntal/stateless v1.8.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
golang.org/x/oauth2 v0.35.0 golang.org/x/oauth2 v0.35.0
google.golang.org/genai v1.45.0 google.golang.org/genai v1.45.0

View file

@ -118,6 +118,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgm
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qmuntal/stateless v1.8.0 h1:9+Eg/7bWLKxUxs/vysNYAelFAh85kTyueC3ee6v8im8=
github.com/qmuntal/stateless v1.8.0/go.mod h1:KWa8KVzIBD/ZS0EdzL5oU79sGq7fKwH9WEFijTC5AWw=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"sync" "sync"
"sync/atomic"
) )
// ParallelToolRuntime executes tool calls concurrently when tools opt-in via // ParallelToolRuntime executes tool calls concurrently when tools opt-in via
@ -64,11 +65,11 @@ func (r ParallelToolRuntime) Execute(ctx context.Context, tools []AgentTool, too
} }
sem := make(chan struct{}, maxConc) sem := make(chan struct{}, maxConc)
inFlight := 0 var inFlight atomic.Int64
barrierWaits := 0 barrierWaits := 0
i := 0 i := 0
emit := func() { emit := func() {
metrics(ToolRuntimeMetrics{Queued: len(toolCalls) - i, InFlightParallel: inFlight, BarrierWaits: barrierWaits}) metrics(ToolRuntimeMetrics{Queued: len(toolCalls) - i, InFlightParallel: int(inFlight.Load()), BarrierWaits: barrierWaits})
} }
for i < len(toolCalls) { for i < len(toolCalls) {
if !isParallelSafe(toolCalls[i]) { if !isParallelSafe(toolCalls[i]) {
@ -110,11 +111,11 @@ func (r ParallelToolRuntime) Execute(ctx context.Context, tools []AgentTool, too
defer wg.Done() defer wg.Done()
logEvent(ToolRuntimeLogEvent{Event: "dispatch", ToolCallID: tc.ToolCallID, ToolName: tc.ToolName}) logEvent(ToolRuntimeLogEvent{Event: "dispatch", ToolCallID: tc.ToolCallID, ToolName: tc.ToolName})
sem <- struct{}{} sem <- struct{}{}
inFlight++ inFlight.Add(1)
emit() emit()
defer func() { defer func() {
<-sem <-sem
inFlight-- inFlight.Add(-1)
emit() emit()
}() }()

View file

@ -151,15 +151,18 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
parts = append(parts, bootstrapContent) parts = append(parts, bootstrapContent)
} }
// Skills - show summary, AI can read full content with read_file tool // Skills - show summary index and inline full definitions for direct use
skillsSummary := cb.skillsLoader.BuildSkillsSummary() skillsSummary := cb.skillsLoader.BuildSkillsSummary()
if skillsSummary != "" { if skillsSummary != "" {
parts = append(parts, fmt.Sprintf(`# Skills parts = append(parts, fmt.Sprintf(`# Skills
The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. The following skills extend your capabilities. Full definitions are included below.
%s`, skillsSummary)) %s`, skillsSummary))
} }
if skillsDefs := cb.loadSkills(); skillsDefs != "" {
parts = append(parts, skillsDefs)
}
// Observation block (stable prefix for prompt cache alignment) // Observation block (stable prefix for prompt cache alignment)
if cb.observationBlock != "" { if cb.observationBlock != "" {

View file

@ -64,6 +64,7 @@ type processOptions struct {
SessionKey string // Session identifier for history/context SessionKey string // Session identifier for history/context
Channel string // Target channel for tool execution Channel string // Target channel for tool execution
ChatID string // Target chat ID for tool execution ChatID string // Target chat ID for tool execution
SenderID string // Originating sender identifier (for logging/audit)
UserMessage string // User message content (may include prefix) UserMessage string // User message content (may include prefix)
DefaultResponse string // Response when LLM returns empty DefaultResponse string // Response when LLM returns empty
EnableSummary bool // Whether to trigger summarization EnableSummary bool // Whether to trigger summarization
@ -413,6 +414,7 @@ func (al *AgentLoop) ProcessDirectStreaming(ctx context.Context, content, sessio
SessionKey: msg.SessionKey, SessionKey: msg.SessionKey,
Channel: msg.Channel, Channel: msg.Channel,
ChatID: msg.ChatID, ChatID: msg.ChatID,
SenderID: msg.SenderID,
UserMessage: msg.Content, UserMessage: msg.Content,
DefaultResponse: "I've completed processing but have no response to give.", DefaultResponse: "I've completed processing but have no response to give.",
EnableSummary: true, EnableSummary: true,
@ -474,7 +476,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}) })
} }
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { func (al *AgentLoop) processSystemMessage(_ context.Context, msg bus.InboundMessage) (string, error) {
// Verify this is a system message // Verify this is a system message
if msg.Channel != "system" { if msg.Channel != "system" {
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel) return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel)
@ -546,6 +548,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
} }
// 1. Update tool contexts // 1. Update tool contexts
logger.DebugCF("agent", "runAgentLoop: starting",
map[string]interface{}{
"session_key": opts.SessionKey,
"channel": opts.Channel,
"sender_id": opts.SenderID,
})
al.updateToolContexts(opts.Channel, opts.ChatID) al.updateToolContexts(opts.Channel, opts.ChatID)
// 2. Load observation block for system prompt injection // 2. Load observation block for system prompt injection
@ -598,6 +606,10 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
} }
// 5. Convert history to Fantasy message format // 5. Convert history to Fantasy message format
logger.DebugCF("agent", "runAgentLoop: history messages",
map[string]interface{}{
"history": formatMessagesForLog(historyMsgs),
})
fantasyHistory := picofantasy.MessagesToFantasy(historyMsgs) fantasyHistory := picofantasy.MessagesToFantasy(historyMsgs)
// 6. Build adapted tools from PicoClaw registry (with optional offloading) // 6. Build adapted tools from PicoClaw registry (with optional offloading)
@ -912,10 +924,19 @@ func (al *AgentLoop) updateToolContexts(channel, chatID string) {
} }
// maybeSummarize triggers summarization if the session history exceeds thresholds. // maybeSummarize triggers summarization if the session history exceeds thresholds.
// At the critical threshold (≥95% of context window) it synchronously force-compresses
// the history before the normal async summarization path runs.
func (al *AgentLoop) maybeSummarize(sessionKey, channel, chatID string) { func (al *AgentLoop) maybeSummarize(sessionKey, channel, chatID string) {
newHistory := al.sessions.GetHistory(sessionKey) newHistory := al.sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory) tokenEstimate := al.estimateTokens(newHistory)
threshold := al.contextWindow * 75 / 100 threshold := al.contextWindow * 75 / 100
criticalThreshold := al.contextWindow * 95 / 100
// Emergency path: drop oldest messages immediately when near context limit.
if tokenEstimate > criticalThreshold {
al.forceCompression(sessionKey)
return
}
if len(newHistory) > 20 || tokenEstimate > threshold { if len(newHistory) > 20 || tokenEstimate > threshold {
if _, loading := al.summarizing.LoadOrStore(sessionKey, true); !loading { if _, loading := al.summarizing.LoadOrStore(sessionKey, true); !loading {
@ -1249,7 +1270,7 @@ func (al *AgentLoop) estimateTokens(msgs []messages.Message) int {
return totalChars * 2 / 5 return totalChars * 2 / 5
} }
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { func (al *AgentLoop) handleCommand(_ context.Context, msg bus.InboundMessage) (string, bool) {
content := strings.TrimSpace(msg.Content) content := strings.TrimSpace(msg.Content)
if !strings.HasPrefix(content, "/") { if !strings.HasPrefix(content, "/") {
return "", false return "", false

View file

@ -35,7 +35,9 @@ func (m *mockLanguageModel) Generate(_ context.Context, call fantasy.Call) (*fan
func (m *mockLanguageModel) Stream(_ context.Context, call fantasy.Call) (fantasy.StreamResponse, error) { func (m *mockLanguageModel) Stream(_ context.Context, call fantasy.Call) (fantasy.StreamResponse, error) {
return func(yield func(fantasy.StreamPart) bool) { return func(yield func(fantasy.StreamPart) bool) {
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, Delta: m.response}) if !yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeTextDelta, Delta: m.response}) {
return
}
yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop}) yield(fantasy.StreamPart{Type: fantasy.StreamPartTypeFinish, FinishReason: fantasy.FinishReasonStop})
}, nil }, nil
} }

View file

@ -278,6 +278,9 @@ func loadView(ctx context.Context, kv KVDelegate, row sqlc.AgentToolResult, star
if sl < 1 { if sl < 1 {
sl = 1 sl = 1
} }
if sl > len(lines) {
sl = len(lines)
}
if el > len(lines) { if el > len(lines) {
el = len(lines) el = len(lines)
} }

View file

@ -20,6 +20,7 @@ import (
// CreateProvider builds a Fantasy provider from PicoClaw config. // CreateProvider builds a Fantasy provider from PicoClaw config.
// It mirrors the provider selection logic from the legacy providers.CreateProvider. // It mirrors the provider selection logic from the legacy providers.CreateProvider.
// FIXME: we should use provider condigs and a handler, not hardcoded cases
func CreateProvider(cfg *config.Config) (fantasy.Provider, error) { func CreateProvider(cfg *config.Config) (fantasy.Provider, error) {
model := cfg.Agents.Defaults.Model model := cfg.Agents.Defaults.Model
providerName := strings.ToLower(cfg.Agents.Defaults.Provider) providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
@ -67,14 +68,16 @@ func CreateProvider(cfg *config.Config) (fantasy.Provider, error) {
// ModelID returns the effective model ID to pass to Fantasy's LanguageModel. // ModelID returns the effective model ID to pass to Fantasy's LanguageModel.
// It strips provider prefixes that the old system used for routing. // It strips provider prefixes that the old system used for routing.
// FIXME: we should use provider condigs and a handler, not hardcoded cases
func ModelID(cfg *config.Config) string { func ModelID(cfg *config.Config) string {
model := cfg.Agents.Defaults.Model model := cfg.Agents.Defaults.Model
// Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5) // Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5)
if idx := strings.Index(model, "/"); idx != -1 { if before, after, ok := strings.Cut(model, "/"); ok {
prefix := model[:idx] prefix := before
// FIXME: hardcoded provider prefixes are a hack
if prefix == "moonshot" || prefix == "nvidia" { if prefix == "moonshot" || prefix == "nvidia" {
return model[idx+1:] return after
} }
} }
@ -82,6 +85,7 @@ func ModelID(cfg *config.Config) string {
} }
// resolveProvider determines the API key, base URL, and proxy for a given config. // resolveProvider determines the API key, base URL, and proxy for a given config.
// FIXME: we should use provider condigs and a handler, not hardcoded cases
func resolveProvider(cfg *config.Config, providerName, model, lowerModel string) (apiKey, apiBase, proxy string) { func resolveProvider(cfg *config.Config, providerName, model, lowerModel string) (apiKey, apiBase, proxy string) {
// First, try explicitly configured provider // First, try explicitly configured provider
if providerName != "" { if providerName != "" {
@ -166,6 +170,7 @@ func resolveProvider(cfg *config.Config, providerName, model, lowerModel string)
} }
// resolveProviderTimeout extracts the timeout from the matched provider config. // resolveProviderTimeout extracts the timeout from the matched provider config.
// FIXME: we should use provider condigs and a handler, not hardcoded cases
func resolveProviderTimeout(cfg *config.Config, providerName string) time.Duration { func resolveProviderTimeout(cfg *config.Config, providerName string) time.Duration {
var timeoutSec int var timeoutSec int

View file

@ -1,6 +1,7 @@
package tools package tools
import ( import (
"context"
"sort" "sort"
"testing" "testing"
) )
@ -100,7 +101,7 @@ func TestProgressiveDisclosure_AllToolsStillDispatchable(t *testing.T) {
// tool_call should still dispatch to it // tool_call should still dispatch to it
tc, _ := r.Get("tool_call") tc, _ := r.Get("tool_call")
result := tc.Execute(nil, map[string]interface{}{ result := tc.Execute(context.TODO(), map[string]interface{}{
"tool_name": "read_file", "tool_name": "read_file",
"arguments": map[string]interface{}{}, "arguments": map[string]interface{}{},
}) })
@ -118,7 +119,7 @@ func TestProgressiveDisclosure_SearchFindsHiddenTools(t *testing.T) {
// Even though read_file is hidden from Fantasy, tool_search should find it // Even though read_file is hidden from Fantasy, tool_search should find it
ts, _ := r.Get("tool_search") ts, _ := r.Get("tool_search")
result := ts.Execute(nil, map[string]interface{}{"query": "read"}) result := ts.Execute(context.TODO(), map[string]interface{}{"query": "read"})
if result.IsError { if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM) t.Fatalf("unexpected error: %s", result.ForLLM)

View file

@ -191,25 +191,25 @@ func formatSearchResults(source, query string, results []memory.SearchResult) st
} }
var sb strings.Builder var sb strings.Builder
sb.WriteString(fmt.Sprintf("Found %d results for '%s':\n\n", len(results), query)) fmt.Fprintf(&sb, "Found %d results for '%s':\n\n", len(results), query)
for i, r := range results { for i, r := range results {
sb.WriteString(fmt.Sprintf("%d. [%s] (score: %.2f) id=%s\n", i+1, r.Source, r.Score, r.ID)) fmt.Fprintf(&sb, "%d. [%s] (score: %.2f) id=%s\n", i+1, r.Source, r.Score, r.ID)
preview := r.Content preview := r.Content
if len(preview) > 200 { if len(preview) > 200 {
preview = preview[:200] + "..." preview = preview[:200] + "..."
} }
sb.WriteString(fmt.Sprintf(" %s\n", preview)) fmt.Fprintf(&sb, " %s\n", preview)
if len(r.Metadata) > 0 { if len(r.Metadata) > 0 {
var meta []string var meta []string
for k, v := range r.Metadata { for k, v := range r.Metadata {
meta = append(meta, fmt.Sprintf("%s=%s", k, v)) meta = append(meta, fmt.Sprintf("%s=%s", k, v))
} }
sb.WriteString(fmt.Sprintf(" meta: %s\n", strings.Join(meta, ", "))) fmt.Fprintf(&sb, " meta: %s\n", strings.Join(meta, ", "))
} }
sb.WriteByte('\n') fmt.Fprintf(&sb, "\n")
} }
return sb.String() return sb.String()