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:
parent
92193d49e0
commit
f9a986f735
13 changed files with 108 additions and 49 deletions
8
go.mod
8
go.mod
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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=
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,14 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextBuilder struct {
|
type ContextBuilder struct {
|
||||||
workspace string
|
workspace string
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
memoryStore memory.Memory // 3-tier MemGPT memory (may be nil)
|
memoryStore memory.Memory // 3-tier MemGPT memory (may be nil)
|
||||||
delegate memory.MemoryDelegate // Direct delegate for document loading (may be nil)
|
delegate memory.MemoryDelegate // Direct delegate for document loading (may be nil)
|
||||||
tools *tools.ToolRegistry // Direct reference to tool registry
|
tools *tools.ToolRegistry // Direct reference to tool registry
|
||||||
observationBlock string // Pre-rendered observation block for prompt injection
|
observationBlock string // Pre-rendered observation block for prompt injection
|
||||||
knowledgeBlock string // Pre-rendered knowledge block from Focus completions
|
knowledgeBlock string // Pre-rendered knowledge block from Focus completions
|
||||||
dagBlock string // Pre-rendered DAG compressed history
|
dagBlock string // Pre-rendered DAG compressed history
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
|
|
@ -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 != "" {
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,10 @@ type AgentLoop struct {
|
||||||
state *state.Manager
|
state *state.Manager
|
||||||
contextBuilder *ContextBuilder
|
contextBuilder *ContextBuilder
|
||||||
tools *tools.ToolRegistry
|
tools *tools.ToolRegistry
|
||||||
memoryStore *memstore.MemoryStore // 3-tier MemGPT memory (nil if init failed)
|
memoryStore *memstore.MemoryStore // 3-tier MemGPT memory (nil if init failed)
|
||||||
memDelegate memory.MemoryDelegate // DB delegate (nil if memory disabled)
|
memDelegate memory.MemoryDelegate // DB delegate (nil if memory disabled)
|
||||||
obsManager *observation.Manager // Observational memory (nil if memory disabled)
|
obsManager *observation.Manager // Observational memory (nil if memory disabled)
|
||||||
activeSessionKey atomic.Value // Current session key for tool access
|
activeSessionKey atomic.Value // Current session key for tool access
|
||||||
running atomic.Bool
|
running atomic.Bool
|
||||||
summarizing sync.Map // Tracks which sessions are currently being summarized
|
summarizing sync.Map // Tracks which sessions are currently being summarized
|
||||||
summarizeFailures sync.Map // Tracks consecutive summarization failures per session (string -> int)
|
summarizeFailures sync.Map // Tracks consecutive summarization failures per session (string -> int)
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue