refactor: eliminate TurnCtx, make loop functions Agent methods
- Delete TurnCtx struct (was progressively populated with optional nil fields) - Convert run(), streamResponse(), execToolCalls(), execBatch(), execOne(), trackErrors(), retryCountdown() to Agent methods - Extract autoCompact() and popInject() as Agent methods - turn.go now installs a turn-scoped output handler wrapper instead of building TurnCtx closures - context.Context is now passed as plain context.Context, not smuggled with unrelated state Add history compaction tests: - TestBuildCompactedHistory_OrphanedToolWalkback: verifies walk-back logic prevents orphaned tool messages at hot zone boundary - TestBuildCompactedHistory_NoOrphanAtBoundary: verifies tool messages always have preceding assistant with tool_calls
This commit is contained in:
parent
fe24736b19
commit
23f3a6092b
|
|
@ -0,0 +1,126 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"ollie/cmd/olliesrv/internal/backend"
|
||||
)
|
||||
|
||||
func TestBuildCompactedHistory_OrphanedToolWalkback(t *testing.T) {
|
||||
// Scenario: hotStart would land on a tool message. The walk-back logic
|
||||
// should include the preceding assistant message with tool_calls to avoid
|
||||
// violating the API constraint that tool messages must follow the assistant
|
||||
// message containing the corresponding tool_calls.
|
||||
|
||||
// Build a history where the naive hotStart (total - hotTailSize) lands on a tool message.
|
||||
// hotTailSize = 6, so we need enough messages that slicing at -6 hits a tool message.
|
||||
|
||||
// We want total - hotTailSize to land on a tool message.
|
||||
// hotTailSize = 6, so with 12 messages, hotStart = 6.
|
||||
// If messages[6] is a tool message, the walk-back should kick in.
|
||||
|
||||
messages := []backend.Message{
|
||||
{Role: "user", Content: "old message 1"},
|
||||
{Role: "assistant", Content: "old response 1"},
|
||||
{Role: "user", Content: "old message 2"},
|
||||
{Role: "assistant", Content: "old response 2"},
|
||||
{Role: "user", Content: "request tool use"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []backend.ToolCall{{ID: "tc1", Name: "shell"}}}, // index 5
|
||||
{Role: "tool", ToolCallID: "tc1", Content: "command output"}, // index 6 - naive hotStart lands here!
|
||||
{Role: "user", Content: "next question"},
|
||||
{Role: "assistant", Content: "answer"},
|
||||
{Role: "user", Content: "another question"},
|
||||
{Role: "assistant", Content: "another answer"},
|
||||
{Role: "user", Content: "final question"},
|
||||
}
|
||||
|
||||
// Total = 12, hotTailSize = 6, so naive hotStart = 6
|
||||
// messages[6] is a tool message → should walk back to 5.
|
||||
|
||||
result := buildCompactedHistory("test summary", messages)
|
||||
|
||||
// Result structure:
|
||||
// [0] cold zone (compaction summary)
|
||||
// [1] warm zone (if any)
|
||||
// [2+] hot zone
|
||||
|
||||
// The hot zone should start with the assistant message at index 5, not the tool at 6.
|
||||
// Find the first non-summary message.
|
||||
|
||||
var hotStart int
|
||||
for i, m := range result {
|
||||
if m.Role == "assistant" && len(m.ToolCalls) > 0 {
|
||||
hotStart = i
|
||||
break
|
||||
}
|
||||
if m.Role == "tool" {
|
||||
t.Errorf("hot zone starts with orphaned tool message at index %d", i)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the assistant with tool_calls is included before the tool message
|
||||
if hotStart == 0 {
|
||||
// The assistant message was not found, which is fine if the walk-back
|
||||
// moved the boundary correctly. Let's just verify no orphaned tool.
|
||||
for i, m := range result {
|
||||
if m.Role == "tool" {
|
||||
// Check that the previous message is assistant with matching tool_calls
|
||||
if i == 0 {
|
||||
t.Error("tool message at start of result with no preceding assistant")
|
||||
return
|
||||
}
|
||||
prev := result[i-1]
|
||||
if prev.Role != "assistant" {
|
||||
t.Errorf("tool message at %d not preceded by assistant (got %s)", i, prev.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCompactedHistory_NoOrphanAtBoundary(t *testing.T) {
|
||||
// Simpler test: verify that any tool message in the result has
|
||||
// a preceding assistant message with tool_calls.
|
||||
|
||||
messages := []backend.Message{
|
||||
{Role: "user", Content: "m1"},
|
||||
{Role: "assistant", Content: "r1"},
|
||||
{Role: "user", Content: "m2"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []backend.ToolCall{{ID: "t1", Name: "shell"}, {ID: "t2", Name: "file_read"}}},
|
||||
{Role: "tool", ToolCallID: "t1", Content: "output1"},
|
||||
{Role: "tool", ToolCallID: "t2", Content: "output2"},
|
||||
{Role: "user", Content: "m3"},
|
||||
{Role: "assistant", Content: "r3"},
|
||||
{Role: "user", Content: "m4"},
|
||||
{Role: "assistant", Content: "r4"},
|
||||
{Role: "user", Content: "m5"},
|
||||
{Role: "assistant", Content: "r5"},
|
||||
}
|
||||
|
||||
result := buildCompactedHistory("summary", messages)
|
||||
|
||||
// Scan for tool messages and verify they have proper context
|
||||
for i, m := range result {
|
||||
if m.Role == "tool" {
|
||||
if i == 0 {
|
||||
t.Fatal("tool message at index 0 with no preceding message")
|
||||
}
|
||||
// Walk backward to find assistant with tool_calls
|
||||
found := false
|
||||
for j := i - 1; j >= 0; j-- {
|
||||
if result[j].Role == "assistant" && len(result[j].ToolCalls) > 0 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if result[j].Role == "user" {
|
||||
// Hit a user message without finding assistant+tool_calls
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("tool message at index %d has no preceding assistant with tool_calls", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -104,38 +104,23 @@ func toolMemoryTier(rt *Runtime, name string) MemoryTier {
|
|||
return MemoryHot
|
||||
}
|
||||
|
||||
// TurnCtx holds per-turn closures and state that vary between turns within
|
||||
// the same agent. Everything else comes from *Runtime. Embeds context.Context
|
||||
// so the turn's cancellation is carried with the turn state rather than as a
|
||||
// separate parameter.
|
||||
type TurnCtx struct {
|
||||
context.Context
|
||||
Output EventHandler
|
||||
PopInject func() string
|
||||
AutoCompact func(ctx context.Context)
|
||||
Save func()
|
||||
IncrToolCallCount func() int64
|
||||
ResultCache *sync.Map
|
||||
ToolServer *toolsrv.Conn
|
||||
}
|
||||
|
||||
// errorState tracks consecutive and repeated tool errors across turns.
|
||||
type errorState struct {
|
||||
consecutive int
|
||||
lastSig string
|
||||
repeatCount int
|
||||
consecutive int
|
||||
lastSig string
|
||||
repeatCount int
|
||||
}
|
||||
|
||||
func run(rt *Runtime, ctx TurnCtx, h *History) error {
|
||||
// run is the main agent loop. It streams responses from the LLM, executes tool
|
||||
// calls, and updates history until the model stops or context is cancelled.
|
||||
func (ag *Agent) run(ctx context.Context) error {
|
||||
var step int
|
||||
var errs errorState
|
||||
resultCache := ctx.ResultCache
|
||||
if resultCache == nil {
|
||||
resultCache = &sync.Map{}
|
||||
}
|
||||
rt := ag.runtime
|
||||
h := ag.history
|
||||
|
||||
for {
|
||||
emit(ctx, Event{Role: "state", Content: "thinking"})
|
||||
ag.emit(Event{Role: "state", Content: "thinking"})
|
||||
if budget := contextBudget(ctx, rt.Backend); budget > 0 && h.estimateTokens() > budget {
|
||||
h.stripCold(ctx, rt.Backend)
|
||||
}
|
||||
|
|
@ -146,7 +131,7 @@ func run(rt *Runtime, ctx TurnCtx, h *History) error {
|
|||
}
|
||||
|
||||
responseID := paths.NewUUID()
|
||||
content, reasoning, toolCalls, stopReason, err := streamResponse(rt, ctx, history, responseID, step)
|
||||
content, reasoning, toolCalls, stopReason, err := ag.streamResponse(ctx, history, responseID, step)
|
||||
if err != nil {
|
||||
// On context cancellation mid-stream, persist partial state.
|
||||
if ctx.Err() != nil && (content != "" || len(toolCalls) > 0) {
|
||||
|
|
@ -159,7 +144,7 @@ func run(rt *Runtime, ctx TurnCtx, h *History) error {
|
|||
})
|
||||
}
|
||||
h.update(msg, partialResults)
|
||||
ctx.Save()
|
||||
ag.save()
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -178,10 +163,10 @@ func run(rt *Runtime, ctx TurnCtx, h *History) error {
|
|||
}
|
||||
|
||||
msg := backend.Message{ID: responseID, Role: "assistant", Content: content, Reasoning: reasoning, ToolCalls: toolCalls}
|
||||
results, interrupted := execToolCalls(rt, ctx, toolCalls, resultCache)
|
||||
results, interrupted := ag.execToolCalls(ctx, toolCalls)
|
||||
|
||||
h.update(msg, results)
|
||||
ctx.Save()
|
||||
ag.save()
|
||||
|
||||
if interrupted {
|
||||
return ctx.Err()
|
||||
|
|
@ -190,7 +175,7 @@ func run(rt *Runtime, ctx TurnCtx, h *History) error {
|
|||
break
|
||||
}
|
||||
|
||||
if abort := trackErrors(h, ctx, results, &errs, step); abort != nil {
|
||||
if abort := ag.trackErrors(ctx, results, &errs, step); abort != nil {
|
||||
return abort
|
||||
}
|
||||
|
||||
|
|
@ -213,7 +198,7 @@ func run(rt *Runtime, ctx TurnCtx, h *History) error {
|
|||
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)
|
||||
} else if step >= rt.MaxSteps-1 {
|
||||
emit(ctx, Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)})
|
||||
ag.emit(Event{Role: "maxsteps", Content: fmt.Sprintf("%d", step+1)})
|
||||
h.update(backend.Message{
|
||||
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, rt.MaxSteps),
|
||||
|
|
@ -222,9 +207,7 @@ func run(rt *Runtime, ctx TurnCtx, h *History) error {
|
|||
}
|
||||
}
|
||||
|
||||
if ctx.AutoCompact != nil {
|
||||
ctx.AutoCompact(ctx)
|
||||
}
|
||||
ag.autoCompact(ctx)
|
||||
|
||||
step++
|
||||
}
|
||||
|
|
@ -232,12 +215,30 @@ func run(rt *Runtime, ctx TurnCtx, h *History) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// autoCompact triggers context compaction if above threshold.
|
||||
func (ag *Agent) autoCompact(ctx context.Context) {
|
||||
if ctx.Err() != nil || ag.history == nil {
|
||||
return
|
||||
}
|
||||
limit := ag.autoCompactLimit(ctx)
|
||||
if limit <= 0 || ag.history.estimateTokens() < limit {
|
||||
return
|
||||
}
|
||||
ag.emit(Event{Role: "info", Content: "auto-compacting context...\n"})
|
||||
ag.SetState("compacting")
|
||||
if _, err := ag.runCompact(ctx, "auto"); err != nil {
|
||||
panic(fmt.Sprintf("mid-turn auto-compact: %v", err))
|
||||
}
|
||||
ag.SetState("thinking")
|
||||
}
|
||||
|
||||
// ── streamResponse ──────────────────────────────────────────────────────────
|
||||
|
||||
// streamResponse calls ChatStream with retry on rate limits, transient errors,
|
||||
// and mid-stream drops. Returns the assembled content, reasoning, tool calls,
|
||||
// and stop reason.
|
||||
func streamResponse(rt *Runtime, ctx TurnCtx, history []backend.Message, responseID string, step int) (content string, reasoning string, toolCalls []backend.ToolCall, stopReason string, err error) {
|
||||
func (ag *Agent) streamResponse(ctx context.Context, history []backend.Message, responseID string, step int) (content string, reasoning string, toolCalls []backend.ToolCall, stopReason string, err error) {
|
||||
rt := ag.runtime
|
||||
var contentBuf strings.Builder
|
||||
var reasoningBuf strings.Builder
|
||||
|
||||
|
|
@ -257,9 +258,9 @@ func streamResponse(rt *Runtime, ctx TurnCtx, history []backend.Message, respons
|
|||
}
|
||||
var rlErr *backend.RateLimitError
|
||||
if errors.As(streamErr, &rlErr) {
|
||||
emit(ctx, Event{Role: "limitretry"})
|
||||
ag.emit(Event{Role: "limitretry"})
|
||||
}
|
||||
if err := retryCountdown(ctx, wait); err != nil {
|
||||
if err := ag.retryCountdown(ctx, wait); err != nil {
|
||||
return "", "", nil, "", fmt.Errorf("step %d: %w", step, err)
|
||||
}
|
||||
continue
|
||||
|
|
@ -270,30 +271,30 @@ func streamResponse(rt *Runtime, ctx TurnCtx, history []backend.Message, respons
|
|||
for ev := range ch {
|
||||
if ev.Reasoning != "" {
|
||||
if !hadReasoning {
|
||||
emit(ctx, Event{Role: "reasoning", Content: "<think>\n"})
|
||||
ag.emit(Event{Role: "reasoning", Content: "<think>\n"})
|
||||
hadReasoning = true
|
||||
}
|
||||
reasoningBuf.WriteString(ev.Reasoning)
|
||||
emit(ctx, Event{Role: "reasoning", Content: ev.Reasoning})
|
||||
ag.emit(Event{Role: "reasoning", Content: ev.Reasoning})
|
||||
}
|
||||
if ev.Content != "" {
|
||||
if hadReasoning {
|
||||
emit(ctx, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
ag.emit(Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
hadReasoning = false
|
||||
}
|
||||
contentBuf.WriteString(ev.Content)
|
||||
emit(ctx, Event{Role: "assistant", Content: ev.Content, ResponseID: responseID})
|
||||
ag.emit(Event{Role: "assistant", Content: ev.Content, ResponseID: responseID})
|
||||
}
|
||||
if ev.Done {
|
||||
toolCalls = append(toolCalls, ev.ToolCalls...)
|
||||
if hadReasoning {
|
||||
emit(ctx, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
ag.emit(Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
hadReasoning = false
|
||||
}
|
||||
stopReason = ev.StopReason
|
||||
done = true
|
||||
if ev.Usage.InputTokens > 0 || ev.Usage.OutputTokens > 0 {
|
||||
emit(ctx, Event{
|
||||
ag.emit(Event{
|
||||
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),
|
||||
})
|
||||
|
|
@ -305,7 +306,7 @@ func streamResponse(rt *Runtime, ctx TurnCtx, history []backend.Message, respons
|
|||
inChars += len(tc.Name) + len(tc.Arguments)
|
||||
}
|
||||
}
|
||||
emit(ctx, Event{
|
||||
ag.emit(Event{
|
||||
Role: "usage",
|
||||
Content: fmt.Sprintf("%d %d 1 0", inChars/4, contentBuf.Len()/4),
|
||||
})
|
||||
|
|
@ -320,7 +321,7 @@ func streamResponse(rt *Runtime, ctx TurnCtx, history []backend.Message, respons
|
|||
|
||||
if ctx.Err() != nil {
|
||||
if hadReasoning {
|
||||
emit(ctx, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
ag.emit(Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
}
|
||||
// Return partial state for caller to persist.
|
||||
return contentBuf.String(), reasoningBuf.String(), toolCalls, "", ctx.Err()
|
||||
|
|
@ -331,10 +332,10 @@ func streamResponse(rt *Runtime, ctx TurnCtx, history []backend.Message, respons
|
|||
return "", "", nil, "", fmt.Errorf("step %d: stream dropped (no more retries)", step)
|
||||
}
|
||||
if hadReasoning {
|
||||
emit(ctx, Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
ag.emit(Event{Role: "reasoning", Content: "\n</think>\n"})
|
||||
}
|
||||
wait := streamDropBaseDelay << attempt
|
||||
if err := retryCountdown(ctx, wait); err != nil {
|
||||
if err := ag.retryCountdown(ctx, wait); err != nil {
|
||||
return "", "", nil, "", fmt.Errorf("step %d: %w", step, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -348,7 +349,8 @@ func streamResponse(rt *Runtime, ctx TurnCtx, history []backend.Message, respons
|
|||
// into parallel batches. Conflict detection uses file paths from tool
|
||||
// arguments: writes on the same path serialize, writes on different paths
|
||||
// parallelize, shell/unknown is a full serialization barrier.
|
||||
func execToolCalls(rt *Runtime, ctx TurnCtx, toolCalls []backend.ToolCall, resultCache *sync.Map) ([]toolResult, bool) {
|
||||
func (ag *Agent) execToolCalls(ctx context.Context, toolCalls []backend.ToolCall) ([]toolResult, bool) {
|
||||
rt := ag.runtime
|
||||
results := make([]toolResult, 0, len(toolCalls))
|
||||
|
||||
cancelledResult := func(call backend.ToolCall) toolResult {
|
||||
|
|
@ -363,7 +365,7 @@ func execToolCalls(rt *Runtime, ctx TurnCtx, toolCalls []backend.ToolCall, resul
|
|||
fillCancelled := func(calls []backend.ToolCall) {
|
||||
for _, c := range calls {
|
||||
cr := cancelledResult(c)
|
||||
emit(ctx, Event{Role: "tool", Name: c.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, c.Name)})
|
||||
ag.emit(Event{Role: "tool", Name: c.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, c.Name)})
|
||||
results = append(results, cr)
|
||||
}
|
||||
}
|
||||
|
|
@ -394,14 +396,14 @@ func execToolCalls(rt *Runtime, ctx TurnCtx, toolCalls []backend.ToolCall, resul
|
|||
}
|
||||
|
||||
if len(batch) == 1 {
|
||||
tr, wasInt := execOne(rt, ctx, batch[0], resultCache)
|
||||
tr, wasInt := ag.execOne(ctx, batch[0])
|
||||
results = append(results, tr)
|
||||
if wasInt {
|
||||
fillCancelled(toolCalls[j:])
|
||||
return results, true
|
||||
}
|
||||
} else {
|
||||
batchResults, wasInt := execBatch(rt, ctx, batch, resultCache)
|
||||
batchResults, wasInt := ag.execBatch(ctx, batch)
|
||||
results = append(results, batchResults...)
|
||||
if wasInt {
|
||||
fillCancelled(toolCalls[j:])
|
||||
|
|
@ -475,7 +477,8 @@ func hasGlobal(set []string) bool {
|
|||
}
|
||||
|
||||
// execBatch fans out a batch of parallel-safe calls, deduplicating identical ones.
|
||||
func execBatch(rt *Runtime, ctx TurnCtx, batch []backend.ToolCall, resultCache *sync.Map) ([]toolResult, bool) {
|
||||
func (ag *Agent) execBatch(ctx context.Context, batch []backend.ToolCall) ([]toolResult, bool) {
|
||||
rt := ag.runtime
|
||||
type inflightResult struct {
|
||||
tr toolResult
|
||||
wasInt bool
|
||||
|
|
@ -494,7 +497,7 @@ func execBatch(rt *Runtime, ctx TurnCtx, batch []backend.ToolCall, resultCache *
|
|||
wg.Add(1)
|
||||
go func(k int, call backend.ToolCall) {
|
||||
defer wg.Done()
|
||||
uniqueResults[k].tr, uniqueResults[k].wasInt = execOne(rt, ctx, call, resultCache)
|
||||
uniqueResults[k].tr, uniqueResults[k].wasInt = ag.execOne(ctx, call)
|
||||
}(k, call)
|
||||
}
|
||||
wg.Wait()
|
||||
|
|
@ -515,24 +518,27 @@ func execBatch(rt *Runtime, ctx TurnCtx, batch []backend.ToolCall, resultCache *
|
|||
Content: uniqueResults[first].tr.Content,
|
||||
IsError: uniqueResults[first].tr.IsError,
|
||||
}
|
||||
emit(ctx, Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
|
||||
emit(ctx, Event{Role: "tool", Name: call.Name, Content: batchResults[k].Content, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
ag.emit(Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
|
||||
ag.emit(Event{Role: "tool", Name: call.Name, Content: batchResults[k].Content, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
}
|
||||
}
|
||||
return batchResults, interrupted
|
||||
}
|
||||
|
||||
// execOne runs a single tool call and reports whether the context was cancelled.
|
||||
func execOne(rt *Runtime, ctx TurnCtx, call backend.ToolCall, resultCache *sync.Map) (toolResult, bool) {
|
||||
func (ag *Agent) execOne(ctx context.Context, call backend.ToolCall) (toolResult, bool) {
|
||||
rt := ag.runtime
|
||||
resultCache := &ag.resultCache
|
||||
|
||||
if call.Name == "" {
|
||||
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: "error: empty tool name", IsError: true}, false
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
cr := toolResult{ToolCallID: call.ID, Name: call.Name, Content: `{"status":"cancelled","error":"interrupted"}`, IsError: true}
|
||||
emit(ctx, Event{Role: "tool", Name: call.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
ag.emit(Event{Role: "tool", Name: call.Name, Content: cr.Content, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
return cr, true
|
||||
}
|
||||
emit(ctx, Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
|
||||
ag.emit(Event{Role: "call", Name: call.Name, Content: string(call.Arguments)})
|
||||
|
||||
readSafe := rt.ToolMeta[call.Name].Scope == "read"
|
||||
if readSafe {
|
||||
|
|
@ -540,7 +546,7 @@ func execOne(rt *Runtime, ctx TurnCtx, call backend.ToolCall, resultCache *sync.
|
|||
if v, ok := resultCache.Load(key); ok {
|
||||
entry := v.(cachedResult)
|
||||
if cacheValid(entry, call.Arguments) {
|
||||
emit(ctx, Event{Role: "tool", Name: call.Name, Content: entry.Result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
ag.emit(Event{Role: "tool", Name: call.Name, Content: entry.Result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: entry.Result}, false
|
||||
}
|
||||
resultCache.Delete(key)
|
||||
|
|
@ -551,16 +557,18 @@ func execOne(rt *Runtime, ctx TurnCtx, call backend.ToolCall, resultCache *sync.
|
|||
var resultBlocks []backend.ContentBlock
|
||||
var isErr bool
|
||||
|
||||
toolServer := rt.ToolServer
|
||||
|
||||
// Check for background flag — if present, execute via proc/new.bg
|
||||
if isBackground(call.Arguments) {
|
||||
strippedArgs := stripBackgroundFlag(call.Arguments)
|
||||
if ctx.ToolServer == nil {
|
||||
if toolServer == nil {
|
||||
result = "error: no tool server available for background execution"
|
||||
isErr = true
|
||||
} else {
|
||||
// Extract command description for display
|
||||
cmdDesc := extractCmdDesc(call.Name, call.Arguments)
|
||||
pid, err := ctx.ToolServer.CallToolBackground(call.Name, strippedArgs)
|
||||
pid, err := toolServer.CallToolBackground(call.Name, strippedArgs)
|
||||
if err != nil {
|
||||
result = fmt.Sprintf("error: background exec: %v", err)
|
||||
isErr = true
|
||||
|
|
@ -568,7 +576,7 @@ func execOne(rt *Runtime, ctx TurnCtx, call backend.ToolCall, resultCache *sync.
|
|||
result = fmt.Sprintf("<system-proc-background>\nid=%d cmd=%q\n</system-proc-background>", pid, cmdDesc)
|
||||
}
|
||||
}
|
||||
emit(ctx, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
ag.emit(Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, IsError: isErr}, false
|
||||
}
|
||||
|
||||
|
|
@ -584,16 +592,16 @@ func execOne(rt *Runtime, ctx TurnCtx, call backend.ToolCall, resultCache *sync.
|
|||
} else if args.Name == "" {
|
||||
result = "error: name is required"
|
||||
isErr = true
|
||||
} else if rt.ToolServer == nil {
|
||||
} else if toolServer == nil {
|
||||
result = "error: no tool server"
|
||||
isErr = true
|
||||
} else if err := rt.ToolServer.LoadTool(args.Name); err != nil {
|
||||
} else if err := toolServer.LoadTool(args.Name); err != nil {
|
||||
result = "error: " + err.Error()
|
||||
isErr = true
|
||||
} else {
|
||||
result = "loaded: " + args.Name
|
||||
}
|
||||
emit(ctx, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
ag.emit(Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, IsError: isErr}, false
|
||||
}
|
||||
|
||||
|
|
@ -603,12 +611,10 @@ func execOne(rt *Runtime, ctx TurnCtx, call backend.ToolCall, resultCache *sync.
|
|||
isErr = true
|
||||
if ctx.Err() != nil {
|
||||
result = "error: tool execution interrupted by user"
|
||||
if ctx.PopInject != nil {
|
||||
if injected := ctx.PopInject(); injected != "" {
|
||||
result += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
||||
}
|
||||
if injected := ag.popInject(); injected != "" {
|
||||
result += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
||||
}
|
||||
emit(ctx, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
ag.emit(Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, IsError: true}, true
|
||||
}
|
||||
var rlErr *toolsrv.RateLimitedError
|
||||
|
|
@ -628,10 +634,8 @@ func execOne(rt *Runtime, ctx TurnCtx, call backend.ToolCall, resultCache *sync.
|
|||
|
||||
// Append suffix (user-interruptions, truncation).
|
||||
var suffix string
|
||||
if ctx.PopInject != nil {
|
||||
if injected := ctx.PopInject(); injected != "" {
|
||||
suffix += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
||||
}
|
||||
if injected := ag.popInject(); injected != "" {
|
||||
suffix += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
||||
}
|
||||
result += suffix
|
||||
|
||||
|
|
@ -653,7 +657,7 @@ func execOne(rt *Runtime, ctx TurnCtx, call backend.ToolCall, resultCache *sync.
|
|||
})
|
||||
}
|
||||
|
||||
emit(ctx, Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
ag.emit(Event{Role: "tool", Name: call.Name, Content: result, OutputFormat: toolOutputFormat(rt, call.Name)})
|
||||
|
||||
tier := MemoryHot
|
||||
if !isErr {
|
||||
|
|
@ -662,11 +666,21 @@ func execOne(rt *Runtime, ctx TurnCtx, call backend.ToolCall, resultCache *sync.
|
|||
return toolResult{ToolCallID: call.ID, Name: call.Name, Content: result, ContentBlocks: resultBlocks, IsError: isErr, Tier: tier}, false
|
||||
}
|
||||
|
||||
// popInject returns and clears any pending inject text.
|
||||
func (ag *Agent) popInject() string {
|
||||
if p := ag.pendingInject.Swap(nil); p != nil {
|
||||
return *p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ── trackErrors ─────────────────────────────────────────────────────────────
|
||||
|
||||
// trackErrors updates consecutive/repeated error tracking and injects nudge
|
||||
// messages when thresholds are hit. Returns non-nil to abort the loop.
|
||||
func trackErrors(h *History, ctx TurnCtx, results []toolResult, es *errorState, step int) error {
|
||||
func (ag *Agent) trackErrors(ctx context.Context, results []toolResult, es *errorState, step int) error {
|
||||
h := ag.history
|
||||
|
||||
allErrors := len(results) > 0
|
||||
allRateLimited := allErrors
|
||||
for _, r := range results {
|
||||
|
|
@ -689,7 +703,7 @@ func trackErrors(h *History, ctx TurnCtx, results []toolResult, es *errorState,
|
|||
}
|
||||
|
||||
if es.consecutive >= consecutiveErrorHardLimit {
|
||||
emit(ctx, Event{Role: "error", Content: fmt.Sprintf("%d consecutive tool errors — aborting", es.consecutive)})
|
||||
ag.emit(Event{Role: "error", Content: fmt.Sprintf("%d consecutive tool errors — aborting", es.consecutive)})
|
||||
return fmt.Errorf("step %d: %d consecutive tool errors", step, es.consecutive)
|
||||
}
|
||||
if es.consecutive == consecutiveErrorSoftLimit {
|
||||
|
|
@ -731,12 +745,6 @@ func trackErrors(h *History, ctx TurnCtx, results []toolResult, es *errorState,
|
|||
|
||||
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func emit(ctx TurnCtx, msg Event) {
|
||||
if ctx.Output != nil {
|
||||
ctx.Output(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// contextBudget returns the token threshold (50% of context length) above which
|
||||
// cold material should be proactively stripped. Returns 0 if unknown.
|
||||
func contextBudget(ctx context.Context, b backend.Backend) int {
|
||||
|
|
@ -799,7 +807,7 @@ func transientWait(err error, attempt int) (time.Duration, bool) {
|
|||
return 0, false
|
||||
}
|
||||
|
||||
func retryCountdown(ctx TurnCtx, wait time.Duration) error {
|
||||
func (ag *Agent) retryCountdown(ctx context.Context, wait time.Duration) error {
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
remaining := time.Until(deadline)
|
||||
|
|
@ -810,7 +818,7 @@ func retryCountdown(ctx TurnCtx, wait time.Duration) error {
|
|||
h := totalSecs / 3600
|
||||
m := (totalSecs % 3600) / 60
|
||||
s := totalSecs % 60
|
||||
emit(ctx, Event{Role: "retry", Content: fmt.Sprintf("%02d:%02d:%02d", h, m, s)})
|
||||
ag.emit(Event{Role: "retry", Content: fmt.Sprintf("%02d:%02d:%02d", h, m, s)})
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
|
|
|
|||
|
|
@ -117,12 +117,11 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
ag.runtime.Preamble.Set(SectionTools, renderTools(ti))
|
||||
}
|
||||
|
||||
turnCtx := TurnCtx{
|
||||
ToolServer: ag.runtime.ToolServer,
|
||||
}
|
||||
|
||||
// Install turn-scoped output handler to intercept events for reply
|
||||
// accumulation, state updates, and usage tracking.
|
||||
var replyBuf strings.Builder
|
||||
turnCtx.Output = func(ev Event) {
|
||||
origOutput := ag.output
|
||||
ag.output = func(ev Event) {
|
||||
switch ev.Role {
|
||||
case "assistant":
|
||||
replyBuf.WriteString(ev.Content)
|
||||
|
|
@ -151,34 +150,11 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
}, est != 0)
|
||||
ag.notifyChange()
|
||||
}
|
||||
ag.emit(ev)
|
||||
}
|
||||
turnCtx.PopInject = func() string {
|
||||
if p := ag.pendingInject.Swap(nil); p != nil {
|
||||
return *p
|
||||
if origOutput != nil {
|
||||
origOutput(ev)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
turnCtx.IncrToolCallCount = func() int64 {
|
||||
return ag.toolCallCount.Add(1)
|
||||
}
|
||||
turnCtx.Save = ag.save
|
||||
turnCtx.ResultCache = &ag.resultCache
|
||||
turnCtx.AutoCompact = func(ctx context.Context) {
|
||||
if ctx.Err() != nil || ag.history == nil {
|
||||
return
|
||||
}
|
||||
limit := ag.autoCompactLimit(ctx)
|
||||
if limit <= 0 || ag.history.estimateTokens() < limit {
|
||||
return
|
||||
}
|
||||
ag.emit(Event{Role: "info", Content: "auto-compacting context...\n"})
|
||||
ag.SetState("compacting")
|
||||
if _, err := ag.runCompact(ctx, "auto"); err != nil {
|
||||
panic(fmt.Sprintf("mid-turn auto-compact: %v", err))
|
||||
}
|
||||
ag.SetState("thinking")
|
||||
}
|
||||
defer func() { ag.output = origOutput }()
|
||||
|
||||
// Warn once when context usage crosses 60%; compact at 75%.
|
||||
if ag.history != nil {
|
||||
|
|
@ -211,8 +187,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
err error
|
||||
)
|
||||
for {
|
||||
turnCtx.Context = actCtx
|
||||
err = run(ag.runtime, turnCtx, ag.history)
|
||||
err = ag.run(actCtx)
|
||||
actCancel(nil)
|
||||
ag.currentAction.CompareAndSwap(handle, nil)
|
||||
|
||||
|
|
@ -358,4 +333,3 @@ const defaultContextLength = 128000
|
|||
func infoEvent(text string) Event {
|
||||
return Event{Role: "info", Content: text + "\n"}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue