agent: simplify configuration/instantiation layers
- Kill agentConfig struct. run() takes (*Runtime, TurnCtx, state) directly. TurnCtx holds only 7 per-turn closures; stable config reads come from Runtime. - Lazy tool refresh: toolsNeedRefresh() checks ToolRegistryRevision() on the tool server. Skips ListTools IPC when registry hasn't changed. Remote Conn falls back to always-refetch (same as before). - AgentConfig.GenParams() method replaces the 16-line manual field copy in BuildRuntime. - Remove CfgBackend/CfgModel from Runtime. Callers read cfg.Backend/cfg.Model directly since they already have the AgentConfig in scope.
This commit is contained in:
parent
8f7d21fc38
commit
32ff54f708
|
|
@ -22,8 +22,7 @@ import (
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
history *History
|
history *History
|
||||||
runtime *Runtime
|
runtime *Runtime
|
||||||
cfg agentConfig // per-turn config built from runtime
|
name string // display name (defaults to id)
|
||||||
name string // display name (defaults to id)
|
|
||||||
profile string // config profile name (e.g. "default")
|
profile string // config profile name (e.g. "default")
|
||||||
agentsDir string
|
agentsDir string
|
||||||
systemPrompt string // system prompt for /agent reloads
|
systemPrompt string // system prompt for /agent reloads
|
||||||
|
|
@ -493,6 +492,25 @@ func (ag *Agent) wireToolsChanged() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toolsNeedRefresh reports whether the tool registry has changed since the
|
||||||
|
// last time Tools was populated. If the tool server doesn't support revision
|
||||||
|
// tracking (e.g. remote Conn), this always returns true (safe fallback).
|
||||||
|
func (ag *Agent) toolsNeedRefresh() bool {
|
||||||
|
type revisionProvider interface {
|
||||||
|
ToolRegistryRevision() uint64
|
||||||
|
}
|
||||||
|
srv, ok := ag.runtime.ToolServer.(revisionProvider)
|
||||||
|
if !ok {
|
||||||
|
return true // no revision support — always refetch
|
||||||
|
}
|
||||||
|
rev := srv.ToolRegistryRevision()
|
||||||
|
if rev != ag.runtime.toolRegistryRevision {
|
||||||
|
ag.runtime.toolRegistryRevision = rev
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// SaveFull persists the full session state (history + metadata) to the given path.
|
// SaveFull persists the full session state (history + metadata) to the given path.
|
||||||
func (ag *Agent) SaveFull(path, sessionID, sessionName, cwd, remote string) error {
|
func (ag *Agent) SaveFull(path, sessionID, sessionName, cwd, remote string) error {
|
||||||
// Switching profiles intentionally clears the conversation history, but
|
// Switching profiles intentionally clears the conversation history, but
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package agent
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
|
"ollie/backend"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Prompt holds the agent prompt. It unmarshals from either a JSON string
|
// Prompt holds the agent prompt. It unmarshals from either a JSON string
|
||||||
|
|
@ -86,3 +88,25 @@ func Load(r io.Reader) (*AgentConfig, error) {
|
||||||
func (c *AgentConfig) ToolsEnabled() bool {
|
func (c *AgentConfig) ToolsEnabled() bool {
|
||||||
return c.Tools == nil || *c.Tools
|
return c.Tools == nil || *c.Tools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenParams returns the generation parameters derived from this config.
|
||||||
|
func (c *AgentConfig) GenParams() backend.GenerationParams {
|
||||||
|
return backend.GenerationParams{
|
||||||
|
MaxTokens: c.MaxTokens,
|
||||||
|
MaxCompletionTokens: c.MaxCompletionTokens,
|
||||||
|
Temperature: c.Temperature,
|
||||||
|
TopP: c.TopP,
|
||||||
|
TopK: c.TopK,
|
||||||
|
MinP: c.MinP,
|
||||||
|
TopA: c.TopA,
|
||||||
|
FrequencyPenalty: c.FrequencyPenalty,
|
||||||
|
PresencePenalty: c.PresencePenalty,
|
||||||
|
RepetitionPenalty: c.RepetitionPenalty,
|
||||||
|
ThinkingBudget: c.Reasoning,
|
||||||
|
ReasoningEffort: c.ReasoningEffort,
|
||||||
|
IncludeReasoning: c.IncludeReasoning,
|
||||||
|
ResponseFormat: c.ResponseFormat,
|
||||||
|
Stop: c.Stop,
|
||||||
|
Verbosity: c.Verbosity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -43,24 +43,7 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
|
||||||
} else {
|
} else {
|
||||||
agentPrompt = resolved
|
agentPrompt = resolved
|
||||||
}
|
}
|
||||||
genParams = backend.GenerationParams{
|
genParams = cfg.GenParams()
|
||||||
MaxTokens: cfg.MaxTokens,
|
|
||||||
MaxCompletionTokens: cfg.MaxCompletionTokens,
|
|
||||||
Temperature: cfg.Temperature,
|
|
||||||
TopP: cfg.TopP,
|
|
||||||
TopK: cfg.TopK,
|
|
||||||
MinP: cfg.MinP,
|
|
||||||
TopA: cfg.TopA,
|
|
||||||
FrequencyPenalty: cfg.FrequencyPenalty,
|
|
||||||
PresencePenalty: cfg.PresencePenalty,
|
|
||||||
RepetitionPenalty: cfg.RepetitionPenalty,
|
|
||||||
ThinkingBudget: cfg.Reasoning,
|
|
||||||
ReasoningEffort: cfg.ReasoningEffort,
|
|
||||||
IncludeReasoning: cfg.IncludeReasoning,
|
|
||||||
ResponseFormat: cfg.ResponseFormat,
|
|
||||||
Stop: cfg.Stop,
|
|
||||||
Verbosity: cfg.Verbosity,
|
|
||||||
}
|
|
||||||
maxSteps = cfg.MaxSteps
|
maxSteps = cfg.MaxSteps
|
||||||
if len(cfg.AllowTools) > 0 {
|
if len(cfg.AllowTools) > 0 {
|
||||||
if rs, ok := srv.(interface{ SetAllowTools([]string) }); ok {
|
if rs, ok := srv.(interface{ SetAllowTools([]string) }); ok {
|
||||||
|
|
@ -131,10 +114,8 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var backendName, modelName, compactionModel string
|
var compactionModel string
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
backendName = cfg.Backend
|
|
||||||
modelName = cfg.Model
|
|
||||||
compactionModel = cfg.CompactionModel
|
compactionModel = cfg.CompactionModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,8 +132,6 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
|
||||||
Preamble: preamble,
|
Preamble: preamble,
|
||||||
GenParams: genParams,
|
GenParams: genParams,
|
||||||
MaxSteps: maxSteps,
|
MaxSteps: maxSteps,
|
||||||
CfgBackend: backendName,
|
|
||||||
CfgModel: modelName,
|
|
||||||
CompactionModel: compactionModel,
|
CompactionModel: compactionModel,
|
||||||
Messages: messages,
|
Messages: messages,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -183,20 +183,20 @@ func (ag *Agent) cmdAgent(_ context.Context, args []string) {
|
||||||
env := []string{"OLLIE_SESSION_ID=" + ag.sessionID, "OLLIE_UNAME=" + ag.id}
|
env := []string{"OLLIE_SESSION_ID=" + ag.sessionID, "OLLIE_UNAME=" + ag.id}
|
||||||
env = append(env, ag.promptEnvExtra...)
|
env = append(env, ag.promptEnvExtra...)
|
||||||
rt := BuildRuntime(cfg, disp, ag.cwd, env, ag.systemPrompt, ag.envBlock)
|
rt := BuildRuntime(cfg, disp, ag.cwd, env, ag.systemPrompt, ag.envBlock)
|
||||||
if rt.CfgBackend != "" {
|
if cfg.Backend != "" {
|
||||||
newBe, err := ag.newBackend(rt.CfgBackend)
|
newBe, err := ag.newBackend(cfg.Backend)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ag.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", rt.CfgBackend, err)))
|
ag.emit(infoEvent(fmt.Sprintf("error: backend %q: %v", cfg.Backend, err)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if rt.CfgModel != "" {
|
if cfg.Model != "" {
|
||||||
newBe.SetModel(rt.CfgModel)
|
newBe.SetModel(cfg.Model)
|
||||||
}
|
}
|
||||||
rt.Backend = newBe
|
rt.Backend = newBe
|
||||||
} else {
|
} else {
|
||||||
rt.Backend = ag.runtime.Backend
|
rt.Backend = ag.runtime.Backend
|
||||||
if rt.CfgModel != "" {
|
if cfg.Model != "" {
|
||||||
rt.Backend.SetModel(rt.CfgModel)
|
rt.Backend.SetModel(cfg.Model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ag.runtime = rt
|
ag.runtime = rt
|
||||||
|
|
|
||||||
219
agent/loop.go
219
agent/loop.go
|
|
@ -98,43 +98,26 @@ func fileStat(path string) (int64, int64) {
|
||||||
return info.ModTime().UnixNano(), info.Size()
|
return info.ModTime().UnixNano(), info.Size()
|
||||||
}
|
}
|
||||||
|
|
||||||
func toolOutputFormat(cfg agentConfig, name string) string {
|
func toolOutputFormat(rt *Runtime, name string) string {
|
||||||
if cfg.OutputFormat == nil {
|
if rt.OutputFormat == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return cfg.OutputFormat(name)
|
return rt.OutputFormat(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
type agentConfig struct {
|
// TurnCtx holds per-turn closures and state that vary between turns within
|
||||||
Backend backend.Backend
|
// the same agent. Everything else comes from *Runtime.
|
||||||
Tools []backend.Tool
|
type TurnCtx struct {
|
||||||
Exec toolExecutor
|
Output EventHandler
|
||||||
ClassifyTool func(name string) bool // nil=treat all as serial; true=parallel-read-safe
|
PopInject func() string // returns and clears pending inject, or ""
|
||||||
OutputFormat func(name string) string // empty means plaintext fence
|
AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
|
||||||
ClassifyTier func(name string, args json.RawMessage) ResultTier // nil=TierHot for all; tools self-classify retention
|
Save func() // called after each state.update(); persists mid-turn progress
|
||||||
ResetsCounter func(name string) bool // nil=false for all; true=reset step counter
|
ReadPlanStep func() string // next unchecked step from plan file, or ""
|
||||||
Output EventHandler
|
|
||||||
preamble string // compiled system+agent prompt sent as the system role
|
|
||||||
GenerationParams backend.GenerationParams
|
|
||||||
PopInject func() string // returns and clears pending inject, or ""
|
|
||||||
AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
|
|
||||||
Save func() // called after each state.update(); persists mid-turn progress
|
|
||||||
// MaxSteps is the maximum number of tool-call rounds per turn.
|
|
||||||
// When reached, a soft nudge is injected and the loop exits cleanly.
|
|
||||||
// 0 means unlimited.
|
|
||||||
MaxSteps int
|
|
||||||
// ReadPlanStep returns the next unchecked step from the plan file, or "".
|
|
||||||
// Used as the primary source for TaskState.PlanStep; falls back to
|
|
||||||
// heuristic extraction from assistant text if nil.
|
|
||||||
ReadPlanStep func() string
|
|
||||||
// IncrToolCallCount increments and returns the session-wide tool call counter.
|
|
||||||
IncrToolCallCount func() int64
|
IncrToolCallCount func() int64
|
||||||
// ResultCache persists read-safe tool results across turns.
|
ResultCache *sync.Map
|
||||||
// Cleared on write operations (file_write, file_edit).
|
|
||||||
ResultCache *sync.Map
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func run(ctx context.Context, cfg agentConfig, state state) error {
|
func run(ctx context.Context, rt *Runtime, tc TurnCtx, state state) error {
|
||||||
var step int
|
var step int
|
||||||
var consecutiveErrors int // rounds where every tool call returned an error
|
var consecutiveErrors int // rounds where every tool call returned an error
|
||||||
var roundsWithoutPlan int // consecutive tool rounds without a PLAN: block
|
var roundsWithoutPlan int // consecutive tool rounds without a PLAN: block
|
||||||
|
|
@ -145,22 +128,22 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
// resultCache stores outputs of read-safe tool calls keyed by name+args.
|
// resultCache stores outputs of read-safe tool calls keyed by name+args.
|
||||||
// Only tools classified as parallel-safe (immutable reads) are cached.
|
// Only tools classified as parallel-safe (immutable reads) are cached.
|
||||||
// sync.Map is required because execOne may run in concurrent goroutines.
|
// sync.Map is required because execOne may run in concurrent goroutines.
|
||||||
// Uses the cross-turn cache from agentConfig if available.
|
// Uses the cross-turn cache from TurnCtx if available.
|
||||||
resultCache := cfg.ResultCache
|
resultCache := tc.ResultCache
|
||||||
if resultCache == nil {
|
if resultCache == nil {
|
||||||
resultCache = &sync.Map{}
|
resultCache = &sync.Map{}
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
emit(cfg, Event{Role: "state", Content: "thinking"})
|
emit(tc, Event{Role: "state", Content: "thinking"})
|
||||||
// Proactive context gate: strip cold material before calling the backend.
|
// Proactive context gate: strip cold material before calling the backend.
|
||||||
if budget := contextBudget(ctx, cfg.Backend); budget > 0 && state.estimateTokens() > budget {
|
if budget := contextBudget(ctx, rt.Backend); budget > 0 && state.estimateTokens() > budget {
|
||||||
state.stripCold(ctx, cfg.Backend)
|
state.stripCold(ctx, rt.Backend)
|
||||||
}
|
}
|
||||||
|
|
||||||
history := state.history()
|
history := state.history()
|
||||||
if cfg.preamble != "" {
|
if rt.Preamble != "" {
|
||||||
history = append([]backend.Message{{Role: "system", Content: cfg.preamble}}, history...)
|
history = append([]backend.Message{{Role: "system", Content: rt.Preamble}}, history...)
|
||||||
}
|
}
|
||||||
if ts := state.taskState(); ts != nil {
|
if ts := state.taskState(); ts != nil {
|
||||||
if msg := ts.render(); msg != "" && len(history) > 0 {
|
if msg := ts.render(); msg != "" && len(history) > 0 {
|
||||||
|
|
@ -183,7 +166,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
reasoning.Reset()
|
reasoning.Reset()
|
||||||
toolCalls = nil
|
toolCalls = nil
|
||||||
|
|
||||||
ch, err := cfg.Backend.ChatStream(ctx, history, cfg.Tools, cfg.GenerationParams)
|
ch, err := rt.Backend.ChatStream(ctx, history, rt.Tools, rt.GenParams)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
|
|
@ -194,9 +177,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
}
|
}
|
||||||
var rlErr *backend.RateLimitError
|
var rlErr *backend.RateLimitError
|
||||||
if errors.As(err, &rlErr) {
|
if errors.As(err, &rlErr) {
|
||||||
emit(cfg, Event{Role: "limitretry"})
|
emit(tc, Event{Role: "limitretry"})
|
||||||
}
|
}
|
||||||
if err := retryCountdown(ctx, cfg, wait); err != nil {
|
if err := retryCountdown(ctx, tc, wait); err != nil {
|
||||||
return fmt.Errorf("step %d: %w", step, err)
|
return fmt.Errorf("step %d: %w", step, err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
@ -207,30 +190,30 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
for ev := range ch {
|
for ev := range ch {
|
||||||
if ev.Reasoning != "" {
|
if ev.Reasoning != "" {
|
||||||
if !hadReasoning {
|
if !hadReasoning {
|
||||||
emit(cfg, Event{Role: "reasoning", Content: "<think>\n"})
|
emit(tc, Event{Role: "reasoning", Content: "<think>\n"})
|
||||||
hadReasoning = true
|
hadReasoning = true
|
||||||
}
|
}
|
||||||
reasoning.WriteString(ev.Reasoning)
|
reasoning.WriteString(ev.Reasoning)
|
||||||
emit(cfg, Event{Role: "reasoning", Content: ev.Reasoning})
|
emit(tc, Event{Role: "reasoning", Content: ev.Reasoning})
|
||||||
}
|
}
|
||||||
if ev.Content != "" {
|
if ev.Content != "" {
|
||||||
if hadReasoning {
|
if hadReasoning {
|
||||||
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"})
|
emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||||
hadReasoning = false
|
hadReasoning = false
|
||||||
}
|
}
|
||||||
content.WriteString(ev.Content)
|
content.WriteString(ev.Content)
|
||||||
emit(cfg, Event{Role: "assistant", Content: ev.Content, ResponseID: responseID})
|
emit(tc, Event{Role: "assistant", Content: ev.Content, ResponseID: responseID})
|
||||||
}
|
}
|
||||||
toolCalls = append(toolCalls, ev.ToolCalls...)
|
toolCalls = append(toolCalls, ev.ToolCalls...)
|
||||||
if ev.Done {
|
if ev.Done {
|
||||||
if hadReasoning {
|
if hadReasoning {
|
||||||
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"})
|
emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||||
hadReasoning = false
|
hadReasoning = false
|
||||||
}
|
}
|
||||||
stopReason = ev.StopReason
|
stopReason = ev.StopReason
|
||||||
done = true
|
done = true
|
||||||
if ev.Usage.InputTokens > 0 || ev.Usage.OutputTokens > 0 {
|
if ev.Usage.InputTokens > 0 || ev.Usage.OutputTokens > 0 {
|
||||||
emit(cfg, Event{
|
emit(tc, Event{
|
||||||
Role: "usage",
|
Role: "usage",
|
||||||
Content: fmt.Sprintf("%d %d 0 %g %d %d", ev.Usage.InputTokens, ev.Usage.OutputTokens, ev.Usage.CostUSD, ev.Usage.CachedInputTokens, ev.Usage.CacheCreationTokens),
|
Content: fmt.Sprintf("%d %d 0 %g %d %d", ev.Usage.InputTokens, ev.Usage.OutputTokens, ev.Usage.CostUSD, ev.Usage.CachedInputTokens, ev.Usage.CacheCreationTokens),
|
||||||
})
|
})
|
||||||
|
|
@ -243,7 +226,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
inChars += len(tc.Name) + len(tc.Arguments)
|
inChars += len(tc.Name) + len(tc.Arguments)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
emit(cfg, Event{
|
emit(tc, Event{
|
||||||
Role: "usage",
|
Role: "usage",
|
||||||
Content: fmt.Sprintf("%d %d 1 0", inChars/4, content.Len()/4),
|
Content: fmt.Sprintf("%d %d 1 0", inChars/4, content.Len()/4),
|
||||||
})
|
})
|
||||||
|
|
@ -259,7 +242,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
// User pause: record partial state and return.
|
// User pause: record partial state and return.
|
||||||
if hadReasoning {
|
if hadReasoning {
|
||||||
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"})
|
emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||||
}
|
}
|
||||||
msg := backend.Message{ID: responseID, Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls}
|
msg := backend.Message{ID: responseID, Role: "assistant", Content: content.String(), Reasoning: reasoning.String(), ToolCalls: toolCalls}
|
||||||
var results []toolResult
|
var results []toolResult
|
||||||
|
|
@ -272,7 +255,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
state.update(msg, results)
|
state.update(msg, results)
|
||||||
cfg.Save()
|
tc.Save()
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -281,10 +264,10 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
return fmt.Errorf("step %d: stream dropped (no more retries)", step)
|
return fmt.Errorf("step %d: stream dropped (no more retries)", step)
|
||||||
}
|
}
|
||||||
if hadReasoning {
|
if hadReasoning {
|
||||||
emit(cfg, Event{Role: "reasoning", Content: "\n</think>\n"})
|
emit(tc, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||||
}
|
}
|
||||||
wait := streamDropBaseDelay << attempt
|
wait := streamDropBaseDelay << attempt
|
||||||
if err := retryCountdown(ctx, cfg, wait); err != nil {
|
if err := retryCountdown(ctx, tc, wait); err != nil {
|
||||||
return fmt.Errorf("step %d: %w", step, err)
|
return fmt.Errorf("step %d: %w", step, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -299,8 +282,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
// Parse text-based tool calls: models that don't support the function
|
// Parse text-based tool calls: models that don't support the function
|
||||||
// calling API emit tool invocations as plain text (e.g.
|
// calling API emit tool invocations as plain text (e.g.
|
||||||
// "file_read: args=[...]" (text-based tool call syntax)). Parse these into proper ToolCall structs.
|
// "file_read: args=[...]" (text-based tool call syntax)). Parse these into proper ToolCall structs.
|
||||||
if len(toolCalls) == 0 && len(cfg.Tools) > 0 {
|
if len(toolCalls) == 0 && len(rt.Tools) > 0 {
|
||||||
if parsed := parseTextToolCalls(content.String(), cfg.Tools); len(parsed) > 0 {
|
if parsed := parseTextToolCalls(content.String(), rt.Tools); len(parsed) > 0 {
|
||||||
toolCalls = parsed
|
toolCalls = parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -310,10 +293,10 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
results := make([]toolResult, 0, len(toolCalls))
|
results := make([]toolResult, 0, len(toolCalls))
|
||||||
interrupted := false
|
interrupted := false
|
||||||
|
|
||||||
cancelledResult := func(tc backend.ToolCall) toolResult {
|
cancelledResult := func(call backend.ToolCall) toolResult {
|
||||||
return toolResult{
|
return toolResult{
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: call.ID,
|
||||||
Name: tc.Name,
|
Name: call.Name,
|
||||||
Content: `{"status":"cancelled","error":"interrupted"}`,
|
Content: `{"status":"cancelled","error":"interrupted"}`,
|
||||||
IsError: true,
|
IsError: true,
|
||||||
}
|
}
|
||||||
|
|
@ -321,24 +304,24 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
|
|
||||||
// execOne runs a single tool call end-to-end and reports whether the
|
// execOne runs a single tool call end-to-end and reports whether the
|
||||||
// context was cancelled during execution.
|
// context was cancelled during execution.
|
||||||
execOne := func(tc backend.ToolCall) (toolResult, bool) {
|
execOne := func(call backend.ToolCall) (toolResult, bool) {
|
||||||
if tc.Name == "" {
|
if call.Name == "" {
|
||||||
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: "error: empty tool name", IsError: true}, false
|
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: "error: empty tool name", IsError: true}, false
|
||||||
}
|
}
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
cr := cancelledResult(tc)
|
cr := cancelledResult(call)
|
||||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
emit(tc, Event{Role: "tool", Name: call.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||||
return cr, true
|
return cr, true
|
||||||
}
|
}
|
||||||
emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
|
emit(tc, Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
|
||||||
readSafe := cfg.ClassifyTool != nil && cfg.ClassifyTool(tc.Name)
|
readSafe := rt.ClassifyTool != nil && rt.ClassifyTool(call.Name)
|
||||||
if readSafe {
|
if readSafe {
|
||||||
key := tc.Name + "\x00" + string(tc.Arguments)
|
key := call.Name + "\x00" + string(call.Arguments)
|
||||||
if v, ok := resultCache.Load(key); ok {
|
if v, ok := resultCache.Load(key); ok {
|
||||||
entry := v.(cachedResult)
|
entry := v.(cachedResult)
|
||||||
if cacheValid(entry, tc.Arguments) {
|
if cacheValid(entry, call.Arguments) {
|
||||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: entry.Result, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
emit(tc, Event{Role: "tool", Name: call.Name, Content: entry.Result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||||
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: entry.Result}, false
|
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: entry.Result}, false
|
||||||
}
|
}
|
||||||
// Stale — remove and re-execute.
|
// Stale — remove and re-execute.
|
||||||
resultCache.Delete(key)
|
resultCache.Delete(key)
|
||||||
|
|
@ -348,14 +331,14 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
var resultBlocks []backend.ContentBlock
|
var resultBlocks []backend.ContentBlock
|
||||||
var isErr bool
|
var isErr bool
|
||||||
streamed := false
|
streamed := false
|
||||||
if cfg.Exec != nil {
|
if rt.Exec != nil {
|
||||||
streamBytes := 0
|
streamBytes := 0
|
||||||
streamCtx := toolsrv.WithOutputStream(ctx, func(data string) {
|
streamCtx := toolsrv.WithOutputStream(ctx, func(data string) {
|
||||||
if streamBytes >= defaultToolResultMaxBytes {
|
if streamBytes >= defaultToolResultMaxBytes {
|
||||||
return // already at ceiling, drop further chunks
|
return // already at ceiling, drop further chunks
|
||||||
}
|
}
|
||||||
if !streamed {
|
if !streamed {
|
||||||
emit(cfg, Event{Role: "tool", Name: tc.Name, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
emit(tc, Event{Role: "tool", Name: call.Name, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||||
streamed = true
|
streamed = true
|
||||||
}
|
}
|
||||||
streamBytes += len(data)
|
streamBytes += len(data)
|
||||||
|
|
@ -364,20 +347,20 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
excess := streamBytes - defaultToolResultMaxBytes
|
excess := streamBytes - defaultToolResultMaxBytes
|
||||||
data = data[:len(data)-excess]
|
data = data[:len(data)-excess]
|
||||||
}
|
}
|
||||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: data, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
emit(tc, Event{Role: "tool", Name: call.Name, Content: data, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||||
})
|
})
|
||||||
out, blocks, err := cfg.Exec(streamCtx, tc.Name, tc.Arguments)
|
out, blocks, err := rt.Exec(streamCtx, call.Name, call.Arguments)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
isErr = true
|
isErr = true
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
result = "error: tool execution interrupted by user"
|
result = "error: tool execution interrupted by user"
|
||||||
if cfg.PopInject != nil {
|
if tc.PopInject != nil {
|
||||||
if injected := cfg.PopInject(); injected != "" {
|
if injected := tc.PopInject(); injected != "" {
|
||||||
result += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
result += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
emit(tc, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||||
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, IsError: true}, true
|
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, IsError: true}, true
|
||||||
}
|
}
|
||||||
var rlErr *toolsrv.RateLimitedError
|
var rlErr *toolsrv.RateLimitedError
|
||||||
if errors.As(err, &rlErr) {
|
if errors.As(err, &rlErr) {
|
||||||
|
|
@ -396,8 +379,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
// Accumulate suffix text (user-interruptions, truncation hints)
|
// Accumulate suffix text (user-interruptions, truncation hints)
|
||||||
// that must be emitted after streaming completes.
|
// that must be emitted after streaming completes.
|
||||||
var suffix string
|
var suffix string
|
||||||
if cfg.PopInject != nil {
|
if tc.PopInject != nil {
|
||||||
if injected := cfg.PopInject(); injected != "" {
|
if injected := tc.PopInject(); injected != "" {
|
||||||
suffix += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
suffix += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -407,48 +390,48 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
orig := len(result)
|
orig := len(result)
|
||||||
result = strings.ToValidUTF8(result[:defaultToolResultMaxBytes], "")
|
result = strings.ToValidUTF8(result[:defaultToolResultMaxBytes], "")
|
||||||
suffix = fmt.Sprintf("\n\n[HARD LIMIT: %s output truncated — %d of %d bytes shown. This is a safety ceiling, not a semantic boundary.]",
|
suffix = fmt.Sprintf("\n\n[HARD LIMIT: %s output truncated — %d of %d bytes shown. This is a safety ceiling, not a semantic boundary.]",
|
||||||
tc.Name, defaultToolResultMaxBytes, orig)
|
call.Name, defaultToolResultMaxBytes, orig)
|
||||||
result += suffix
|
result += suffix
|
||||||
}
|
}
|
||||||
if readSafe && !isErr {
|
if readSafe && !isErr {
|
||||||
path := extractFilePath(tc.Arguments)
|
path := extractFilePath(call.Arguments)
|
||||||
mtime, size := fileStat(path)
|
mtime, size := fileStat(path)
|
||||||
resultCache.Store(tc.Name+"\x00"+string(tc.Arguments), cachedResult{
|
resultCache.Store(call.Name+"\x00"+string(call.Arguments), cachedResult{
|
||||||
Result: result,
|
Result: result,
|
||||||
ModTime: mtime,
|
ModTime: mtime,
|
||||||
Size: size,
|
Size: size,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if !streamed {
|
if !streamed {
|
||||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: result, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
emit(tc, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||||
} else {
|
} else {
|
||||||
// Emit suffixes that were appended after streaming completed
|
// Emit suffixes that were appended after streaming completed
|
||||||
// so they appear at the end of the chat output.
|
// so they appear at the end of the chat output.
|
||||||
if suffix != "" {
|
if suffix != "" {
|
||||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: suffix, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
emit(tc, Event{Role: "tool", Name: call.Name, Content: suffix, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||||
}
|
}
|
||||||
// Signal the log handler to close the source fence and emit
|
// Signal the log handler to close the source fence and emit
|
||||||
// [[[end]]]. Without this, streamingRole stays set and the
|
// [[[end]]]. Without this, streamingRole stays set and the
|
||||||
// fence is only closed on the next role transition — or never,
|
// fence is only closed on the next role transition — or never,
|
||||||
// if the agent turn ends or is interrupted.
|
// if the agent turn ends or is interrupted.
|
||||||
emit(cfg, Event{Role: "tool", Name: tc.Name, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
emit(tc, Event{Role: "tool", Name: call.Name, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||||
}
|
}
|
||||||
tier := TierHot
|
tier := TierHot
|
||||||
if !isErr && cfg.ClassifyTier != nil {
|
if !isErr && rt.ClassifyTier != nil {
|
||||||
tier = cfg.ClassifyTier(tc.Name, tc.Arguments)
|
tier = rt.ClassifyTier(call.Name, call.Arguments)
|
||||||
}
|
}
|
||||||
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: result, ContentBlocks: resultBlocks, IsError: isErr, Tier: tier}, false
|
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, ContentBlocks: resultBlocks, IsError: isErr, Tier: tier}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
isParallelSafe := func(name string) bool {
|
isParallelSafe := func(name string) bool {
|
||||||
return name != "" && cfg.ClassifyTool != nil && cfg.ClassifyTool(name)
|
return name != "" && rt.ClassifyTool != nil && rt.ClassifyTool(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < len(toolCalls) && !interrupted; {
|
for i := 0; i < len(toolCalls) && !interrupted; {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
for _, remaining := range toolCalls[i:] {
|
for _, remaining := range toolCalls[i:] {
|
||||||
cr := cancelledResult(remaining)
|
cr := cancelledResult(remaining)
|
||||||
emit(cfg, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, remaining.Name)})
|
emit(tc, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, remaining.Name)})
|
||||||
results = append(results, cr)
|
results = append(results, cr)
|
||||||
}
|
}
|
||||||
interrupted = true
|
interrupted = true
|
||||||
|
|
@ -467,7 +450,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
fillCancelled := func(from int) {
|
fillCancelled := func(from int) {
|
||||||
for _, remaining := range toolCalls[from:] {
|
for _, remaining := range toolCalls[from:] {
|
||||||
cr := cancelledResult(remaining)
|
cr := cancelledResult(remaining)
|
||||||
emit(cfg, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(cfg, remaining.Name)})
|
emit(tc, Event{Role: "tool", Name: remaining.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, remaining.Name)})
|
||||||
results = append(results, cr)
|
results = append(results, cr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -491,8 +474,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
batchInt := make([]bool, len(batch))
|
batchInt := make([]bool, len(batch))
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
uniqueResults := make([]inflightResult, len(batch))
|
uniqueResults := make([]inflightResult, len(batch))
|
||||||
for k, tc := range batch {
|
for k, call := range batch {
|
||||||
key := tc.Name + "\x00" + string(tc.Arguments)
|
key := call.Name + "\x00" + string(call.Arguments)
|
||||||
if first, dup := inflight[key]; dup {
|
if first, dup := inflight[key]; dup {
|
||||||
// Will copy result from first occurrence after wg.Wait.
|
// Will copy result from first occurrence after wg.Wait.
|
||||||
batchResults[k] = toolResult{} // placeholder
|
batchResults[k] = toolResult{} // placeholder
|
||||||
|
|
@ -501,29 +484,29 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
}
|
}
|
||||||
inflight[key] = k
|
inflight[key] = k
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(k int, tc backend.ToolCall) {
|
go func(k int, call backend.ToolCall) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
uniqueResults[k].tr, uniqueResults[k].wasInt = execOne(tc)
|
uniqueResults[k].tr, uniqueResults[k].wasInt = execOne(call)
|
||||||
}(k, tc)
|
}(k, call)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
// Fill results: unique calls get their own result, duplicates copy from first.
|
// Fill results: unique calls get their own result, duplicates copy from first.
|
||||||
for k, tc := range batch {
|
for k, call := range batch {
|
||||||
key := tc.Name + "\x00" + string(tc.Arguments)
|
key := call.Name + "\x00" + string(call.Arguments)
|
||||||
first := inflight[key]
|
first := inflight[key]
|
||||||
if k == first {
|
if k == first {
|
||||||
batchResults[k] = uniqueResults[k].tr
|
batchResults[k] = uniqueResults[k].tr
|
||||||
batchInt[k] = uniqueResults[k].wasInt
|
batchInt[k] = uniqueResults[k].wasInt
|
||||||
} else {
|
} else {
|
||||||
batchResults[k] = toolResult{
|
batchResults[k] = toolResult{
|
||||||
ToolCallID: tc.ID,
|
ToolCallID: call.ID,
|
||||||
Name: tc.Name,
|
Name: call.Name,
|
||||||
Content: uniqueResults[first].tr.Content,
|
Content: uniqueResults[first].tr.Content,
|
||||||
IsError: uniqueResults[first].tr.IsError,
|
IsError: uniqueResults[first].tr.IsError,
|
||||||
}
|
}
|
||||||
batchInt[k] = uniqueResults[first].wasInt
|
batchInt[k] = uniqueResults[first].wasInt
|
||||||
emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
|
emit(tc, Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
|
||||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: batchResults[k].Content, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
emit(tc, Event{Role: "tool", Name: call.Name, Content: batchResults[k].Content, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Add all batch results — model needs a result for every tool call.
|
// Add all batch results — model needs a result for every tool call.
|
||||||
|
|
@ -540,11 +523,11 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
state.update(msg, results)
|
state.update(msg, results)
|
||||||
cfg.Save()
|
tc.Save()
|
||||||
|
|
||||||
// Auto-update TaskState from what actually happened this round.
|
// Auto-update TaskState from what actually happened this round.
|
||||||
if ts := state.taskState(); ts != nil {
|
if ts := state.taskState(); ts != nil {
|
||||||
inferTaskStateUpdate(ts, msg, results, cfg.ReadPlanStep)
|
inferTaskStateUpdate(ts, msg, results, tc.ReadPlanStep)
|
||||||
state.updateTaskState(*ts)
|
state.updateTaskState(*ts)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -580,7 +563,7 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
consecutiveErrors = 0
|
consecutiveErrors = 0
|
||||||
}
|
}
|
||||||
if consecutiveErrors >= consecutiveErrorHardLimit {
|
if consecutiveErrors >= consecutiveErrorHardLimit {
|
||||||
emit(cfg, Event{Role: "error", Content: fmt.Sprintf("%d consecutive tool errors — aborting", consecutiveErrors)})
|
emit(tc, Event{Role: "error", Content: fmt.Sprintf("%d consecutive tool errors — aborting", consecutiveErrors)})
|
||||||
return fmt.Errorf("step %d: %d consecutive tool errors", step, consecutiveErrors)
|
return fmt.Errorf("step %d: %d consecutive tool errors", step, consecutiveErrors)
|
||||||
}
|
}
|
||||||
if consecutiveErrors == consecutiveErrorSoftLimit {
|
if consecutiveErrors == consecutiveErrorSoftLimit {
|
||||||
|
|
@ -658,9 +641,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
// This allows the agent to continue working without hitting the soft
|
// This allows the agent to continue working without hitting the soft
|
||||||
// step-budget guardrail, as long as it's making active progress (writing
|
// step-budget guardrail, as long as it's making active progress (writing
|
||||||
// files, renaming symbols, etc.) rather than looping on research.
|
// files, renaming symbols, etc.) rather than looping on research.
|
||||||
if cfg.ResetsCounter != nil {
|
if rt.ResetsCounter != nil {
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
if !r.IsError && cfg.ResetsCounter(r.Name) {
|
if !r.IsError && rt.ResetsCounter(r.Name) {
|
||||||
step = 0
|
step = 0
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -670,18 +653,18 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
// Step budget warnings: nudge the agent at 50% and when exhausted.
|
// Step budget warnings: nudge the agent at 50% and when exhausted.
|
||||||
// These only fire for research-heavy loops — action tools (file_write,
|
// These only fire for research-heavy loops — action tools (file_write,
|
||||||
// file_edit, shell) reset the counter, so active progress never triggers.
|
// file_edit, shell) reset the counter, so active progress never triggers.
|
||||||
if cfg.MaxSteps > 0 {
|
if rt.MaxSteps > 0 {
|
||||||
halfBudget := cfg.MaxSteps / 2
|
halfBudget := rt.MaxSteps / 2
|
||||||
if step == halfBudget {
|
if step == halfBudget {
|
||||||
state.update(backend.Message{
|
state.update(backend.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: fmt.Sprintf("<system-step-budget-warning>\nYou have used %d/%d research steps without taking action. Consider making progress — write code, edit files, or run commands. Action tools reset this counter.\n</system-step-budget-warning>", step, cfg.MaxSteps),
|
Content: fmt.Sprintf("<system-step-budget-warning>\nYou have used %d/%d research steps without taking action. Consider making progress — write code, edit files, or run commands. Action tools reset this counter.\n</system-step-budget-warning>", step, rt.MaxSteps),
|
||||||
}, nil)
|
}, nil)
|
||||||
} else if step >= cfg.MaxSteps-1 {
|
} else if step >= rt.MaxSteps-1 {
|
||||||
emit(cfg, Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)})
|
emit(tc, Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)})
|
||||||
state.update(backend.Message{
|
state.update(backend.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
Content: fmt.Sprintf("<system-step-budget-stop>\nStep budget exhausted (%d/%d steps used). Stop calling tools. Summarize what you have done and what remains, then stop.\n</system-step-budget-stop>", step+1, cfg.MaxSteps),
|
Content: fmt.Sprintf("<system-step-budget-stop>\nStep budget exhausted (%d/%d steps used). Stop calling tools. Summarize what you have done and what remains, then stop.\n</system-step-budget-stop>", step+1, rt.MaxSteps),
|
||||||
}, nil)
|
}, nil)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
@ -700,8 +683,8 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.AutoCompact != nil {
|
if tc.AutoCompact != nil {
|
||||||
cfg.AutoCompact(ctx)
|
tc.AutoCompact(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
step++
|
step++
|
||||||
|
|
@ -710,9 +693,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func emit(cfg agentConfig, msg Event) {
|
func emit(tc TurnCtx, msg Event) {
|
||||||
if cfg.Output != nil {
|
if tc.Output != nil {
|
||||||
cfg.Output(msg)
|
tc.Output(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1070,7 +1053,7 @@ func transientWait(err error, attempt int) (time.Duration, bool) {
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func retryCountdown(ctx context.Context, cfg agentConfig, wait time.Duration) error {
|
func retryCountdown(ctx context.Context, tc TurnCtx, wait time.Duration) error {
|
||||||
deadline := time.Now().Add(wait)
|
deadline := time.Now().Add(wait)
|
||||||
for {
|
for {
|
||||||
remaining := time.Until(deadline)
|
remaining := time.Until(deadline)
|
||||||
|
|
@ -1081,7 +1064,7 @@ func retryCountdown(ctx context.Context, cfg agentConfig, wait time.Duration) er
|
||||||
h := totalSecs / 3600
|
h := totalSecs / 3600
|
||||||
m := (totalSecs % 3600) / 60
|
m := (totalSecs % 3600) / 60
|
||||||
s := totalSecs % 60
|
s := totalSecs % 60
|
||||||
emit(cfg, Event{Role: "retry", Content: fmt.Sprintf("%02d:%02d:%02d", h, m, s)})
|
emit(tc, Event{Role: "retry", Content: fmt.Sprintf("%02d:%02d:%02d", h, m, s)})
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,10 @@ type Runtime struct {
|
||||||
ResetsCounter func(string) bool // true if tool resets step counter
|
ResetsCounter func(string) bool // true if tool resets step counter
|
||||||
GenParams backend.GenerationParams
|
GenParams backend.GenerationParams
|
||||||
MaxSteps int
|
MaxSteps int
|
||||||
// CfgBackend and CfgModel are the backend/model overrides from the agent
|
// toolRegistryRevision tracks the last-seen tool registry revision. When the
|
||||||
// config JSON. Empty means no override. Applied by the caller after
|
// registry revision advances (after tool_load/unload), the next turn
|
||||||
// BuildRuntime returns.
|
// will detect the mismatch and refetch.
|
||||||
CfgBackend string
|
toolRegistryRevision uint64
|
||||||
CfgModel string
|
|
||||||
// CompactionModel overrides the model used for context compaction.
|
// CompactionModel overrides the model used for context compaction.
|
||||||
// Resolved from: agent config > OLLIE_COMPACTION_MODEL env > per-backend default.
|
// Resolved from: agent config > OLLIE_COMPACTION_MODEL env > per-backend default.
|
||||||
CompactionModel string
|
CompactionModel string
|
||||||
|
|
|
||||||
|
|
@ -100,46 +100,35 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
|
|
||||||
ag.auditLog.Debug("turn: start input=%s session=%s", auditTruncate(input), ag.sessionID)
|
ag.auditLog.Debug("turn: start input=%s session=%s", auditTruncate(input), ag.sessionID)
|
||||||
|
|
||||||
// Build per-turn agentConfig from the current runtime.
|
// Refresh tool definitions only when the registry has changed (tool_load/unload).
|
||||||
// Refresh tool definitions from the tool server so dynamically loaded
|
// This avoids ListTools IPC + allocation on every turn in the common case.
|
||||||
// tools (via tool_load) are available on the next turn without a rebuild.
|
if ag.toolsNeedRefresh() {
|
||||||
ti, listErr := ag.runtime.ToolServer.ListTools()
|
ti, listErr := ag.runtime.ToolServer.ListTools()
|
||||||
if listErr != nil {
|
if listErr != nil {
|
||||||
ag.log.Error("tool server unavailable: %v", listErr)
|
ag.log.Error("tool server unavailable: %v", listErr)
|
||||||
ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)})
|
ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)})
|
||||||
ag.SetState("idle")
|
ag.SetState("idle")
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
tools := toolInfosToBackend(ti)
|
ag.runtime.Tools = toolInfosToBackend(ti)
|
||||||
|
outputFormats := make(map[string]string, len(ti))
|
||||||
// Build output format lookup from the fresh tool list. This ensures
|
for _, info := range ti {
|
||||||
// dynamically loaded tools (via tool_load) have their outputFormat
|
outputFormats[info.Name] = info.OutputFormat
|
||||||
// available immediately, not just after a full runtime rebuild.
|
}
|
||||||
outputFormats := make(map[string]string, len(ti))
|
ag.runtime.OutputFormat = func(name string) string {
|
||||||
for _, info := range ti {
|
return outputFormats[name]
|
||||||
outputFormats[info.Name] = info.OutputFormat
|
}
|
||||||
}
|
|
||||||
outputFormatFn := func(name string) string {
|
|
||||||
return outputFormats[name]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ag.cfg = agentConfig{
|
turnCtx := TurnCtx{
|
||||||
Backend: ag.runtime.Backend,
|
Output: ag.output,
|
||||||
preamble: ag.runtime.Preamble,
|
ReadPlanStep: ag.readPlanStep,
|
||||||
Output: ag.output,
|
Save: ag.save,
|
||||||
Tools: tools,
|
ResultCache: &ag.resultCache,
|
||||||
Exec: ag.runtime.Exec,
|
|
||||||
ClassifyTool: ag.runtime.ClassifyTool,
|
|
||||||
OutputFormat: outputFormatFn,
|
|
||||||
ClassifyTier: ag.runtime.ClassifyTier,
|
|
||||||
ResetsCounter: ag.runtime.ResetsCounter,
|
|
||||||
GenerationParams: ag.runtime.GenParams,
|
|
||||||
MaxSteps: ag.runtime.MaxSteps,
|
|
||||||
ReadPlanStep: ag.readPlanStep,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var replyBuf strings.Builder
|
var replyBuf strings.Builder
|
||||||
ag.cfg.Output = func(ev Event) {
|
turnCtx.Output = func(ev Event) {
|
||||||
switch ev.Role {
|
switch ev.Role {
|
||||||
case "assistant":
|
case "assistant":
|
||||||
replyBuf.WriteString(ev.Content)
|
replyBuf.WriteString(ev.Content)
|
||||||
|
|
@ -176,18 +165,18 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
}
|
}
|
||||||
ag.emit(ev)
|
ag.emit(ev)
|
||||||
}
|
}
|
||||||
ag.cfg.PopInject = func() string {
|
turnCtx.PopInject = func() string {
|
||||||
if p := ag.pendingInject.Swap(nil); p != nil {
|
if p := ag.pendingInject.Swap(nil); p != nil {
|
||||||
return *p
|
return *p
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
ag.cfg.IncrToolCallCount = func() int64 {
|
turnCtx.IncrToolCallCount = func() int64 {
|
||||||
return ag.toolCallCount.Add(1)
|
return ag.toolCallCount.Add(1)
|
||||||
}
|
}
|
||||||
ag.cfg.Save = ag.save
|
turnCtx.Save = ag.save
|
||||||
ag.cfg.ResultCache = &ag.resultCache
|
turnCtx.ResultCache = &ag.resultCache
|
||||||
ag.cfg.AutoCompact = func(ctx context.Context) {
|
turnCtx.AutoCompact = func(ctx context.Context) {
|
||||||
if ctx.Err() != nil || ag.history == nil {
|
if ctx.Err() != nil || ag.history == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -214,7 +203,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
}
|
}
|
||||||
ag.SetState("thinking")
|
ag.SetState("thinking")
|
||||||
} else if warnLimit := ag.autoWarnLimit(ctx); warnLimit > 0 && tokens >= warnLimit && !ag.warnedContext {
|
} else if warnLimit := ag.autoWarnLimit(ctx); warnLimit > 0 && tokens >= warnLimit && !ag.warnedContext {
|
||||||
ctxLen := ag.cfg.Backend.ContextLength(ctx)
|
ctxLen := ag.runtime.Backend.ContextLength(ctx)
|
||||||
if ctxLen <= 0 {
|
if ctxLen <= 0 {
|
||||||
ctxLen = defaultContextLength
|
ctxLen = defaultContextLength
|
||||||
}
|
}
|
||||||
|
|
@ -252,7 +241,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
err error
|
err error
|
||||||
)
|
)
|
||||||
for {
|
for {
|
||||||
err = run(actCtx, ag.cfg, ag.history)
|
err = run(actCtx, ag.runtime, turnCtx, ag.history)
|
||||||
actCancel(nil)
|
actCancel(nil)
|
||||||
ag.currentAction.CompareAndSwap(handle, nil)
|
ag.currentAction.CompareAndSwap(handle, nil)
|
||||||
|
|
||||||
|
|
@ -307,8 +296,8 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
if ag.history != nil {
|
if ag.history != nil {
|
||||||
ag.history.recordTurnCost(ag.cfg.Backend.Model())
|
ag.history.recordTurnCost(ag.runtime.Backend.Model())
|
||||||
appendUsageLog(ag.sessionID, ag.cfg.Backend.Name(), ag.cfg.Backend.Model(), ag.history)
|
appendUsageLog(ag.sessionID, ag.runtime.Backend.Name(), ag.runtime.Backend.Model(), ag.history)
|
||||||
if ag.history.LastTurnCostUSD > 0 {
|
if ag.history.LastTurnCostUSD > 0 {
|
||||||
ag.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", ag.history.LastTurnCostUSD)})
|
ag.emit(Event{Role: "info", Content: fmt.Sprintf("costLast=$%.4f\n", ag.history.LastTurnCostUSD)})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -398,7 +398,7 @@ func CreateAgent(sessName string, args []string) (*agent.Agent, error) {
|
||||||
// Create backend - use override if provided, otherwise use config or env default
|
// Create backend - use override if provided, otherwise use config or env default
|
||||||
beName := backendName
|
beName := backendName
|
||||||
if beName == "" {
|
if beName == "" {
|
||||||
beName = rt.CfgBackend
|
beName = cfg.Backend
|
||||||
}
|
}
|
||||||
be, err := backend.NewWithName(beName)
|
be, err := backend.NewWithName(beName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -409,8 +409,8 @@ func CreateAgent(sessName string, args []string) (*agent.Agent, error) {
|
||||||
// Apply model override if provided
|
// Apply model override if provided
|
||||||
if modelName != "" {
|
if modelName != "" {
|
||||||
rt.Backend.SetModel(modelName)
|
rt.Backend.SetModel(modelName)
|
||||||
} else if rt.CfgModel != "" {
|
} else if cfg.Model != "" {
|
||||||
rt.Backend.SetModel(rt.CfgModel)
|
rt.Backend.SetModel(cfg.Model)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create agent
|
// Create agent
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,15 @@ func (e *Server) SetOnToolsChanged(fn func(string)) {
|
||||||
e.OnToolsChanged = fn
|
e.OnToolsChanged = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToolRegistryRevision returns the current registry revision for this server's session.
|
||||||
|
// Returns 0 if no registry is configured.
|
||||||
|
func (e *Server) ToolRegistryRevision() uint64 {
|
||||||
|
if e.toolRegistry == nil || e.sessionID == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return e.toolRegistry.Revision(e.sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
// ExecuteInSandbox runs a command in the sandbox. Exported for builtin tools.
|
// ExecuteInSandbox runs a command in the sandbox. Exported for builtin tools.
|
||||||
func (e *Server) ExecuteInSandbox(ctx context.Context, cmd, language string, timeout int, sandbox string, trusted bool, stdin string, detach bool) (string, error) {
|
func (e *Server) ExecuteInSandbox(ctx context.Context, cmd, language string, timeout int, sandbox string, trusted bool, stdin string, detach bool) (string, error) {
|
||||||
return e.executeWithStdin(ctx, cmd, language, timeout, sandbox, trusted, stdin, detach)
|
return e.executeWithStdin(ctx, cmd, language, timeout, sandbox, trusted, stdin, detach)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue