Models that don't support native function calling emit tool calls as
plain text. Instead of treating this as ToolUnsupportedError, parse the
text and execute the tool calls normally.
Handles both direct JSON and shorthand (key=value) formats with relaxed
JSON support (unquoted keys).
- Add configurable retryBaseDelay and streamDropBaseDelay for tests
- Fix hook kill: don't wait for done channel after Kill() since child
processes may keep stdout/stderr pipes open
- Update tests to use short delays
Tests now run in ~0.4s instead of ~59s.
Identical read-safe tool calls in the same batch now execute only
once. Duplicates get the result copied with the correct ToolCallID.
Fixes a race where concurrent goroutines both missed the cache
before either could store, causing double execution.
On user interrupt (stop/CTRL+C), the session history now rolls back
to the pre-turn snapshot instead of keeping partial/cancelled tool
results in the model's context. This prevents raw errors like
'context canceled' from poisoning subsequent turns.
Tool results interrupted by cancellation now report 'tool execution
interrupted by user' instead of the raw Go error chain.
Track consecutive rounds where every tool call returns an error.
At 5, inject a nudge telling the model to try a different approach.
At 10, hard-abort the loop.
Also fix a data race in collectEvents (test helper) that caused
flaky failures in TestResultCache_IdenticalParallelReads.
When a model emits tool invocations as plain assistant text instead of
using the function-calling API (e.g. "execute_code: steps=[...]"), the
loop now returns ToolUnsupportedError. This lets freeloader blacklist the
model and cycle to one that properly supports function calling.
Detection: after a turn with stopReason="stop" and no API-level tool
calls, scan assistant text for any registered tool name followed by ':'
or '('. Only fires when tools are configured.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- HookResult.Handled: true only when all commands exit 0; loop checks
Handled (not Ran) so exit-1 from freeloader falls through to normal retry
- runHookCmd: non-zero non-2 exits now return Ran:false with warning
instead of Ran:true, so allHandled tracking is accurate
- loop: check r.Handled instead of r.Ran for turnError intercept
- tests: updated to return Handled:true from mock hook functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- backend: ToolUnsupportedError type, isToolUnsupported detector (400/422)
- backend: classifyError maps error types to hook payload strings
- agent: HookTurnError constant; TurnError func on agentConfig set at init
- loop: fire TurnError hook on first backend error; if ran, skip retries
- store: MemoryStore — in-memory KV Store implementation
- store: Session.prevPrompt field; prompt.prev read-only session file
- hooks: inject payload map as OLLIE_* env vars in runHookCmd
- agent: agentSpawn payload includes model field
- agent: limitretry state emitted on RateLimitError backoff
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Truncation (loop.go, core.go):
- Add ToolResultMaxBytes to agentConfig; default 8192 bytes
- In execOne, after PostTool/PopInject, truncate results that exceed the
limit and append a continuation hint with byte offset
- Error results are never truncated; resultCache now stores the
post-truncation form so cache hits are consistent
Stale read pruning (loop_prune.go):
- pruneStaleReads() is called each iteration before building the
history slice sent to the backend
- Parses execute_code step args to track file_read call-IDs and the
paths touched by file_write/file_edit steps
- When a write is seen, all accumulated read results for that path
(not just the most recent) are removed from the history view
- Inline code steps are left unanalysed (conservative: never prune
what cannot be determined safe to prune)
Also note: compaction (3) was already a full LLM summarisation pass
(CONTEXT CHECKPOINT COMPACTION prompt in session.go); no change needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Maintain a sync.Map (resultCache) across the lifetime of a run() call.
Before executing a read-safe tool (ClassifyTool returns true), check the
cache by key name+"\x00"+args. On hit, emit the cached result and skip
Exec entirely. On miss with a successful Exec, store the result. Error
results are not cached so transient failures are retried.
Tests: HitSkipsExec, DifferentArgsMiss, SerialToolNotCached,
ErrorNotCached (multi-turn sequential).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- loop_parallel_test.go: four tests covering concurrent execution (barrier
pattern), serial tool breaking a batch, cancellation filling all results,
and nil ClassifyTool falling back to serial
- loop.go: emit "tool" events for all cancelled tool calls (early-ctx-err
path in execOne, top-level ctx check, and post-batch fill) so every
dispatched tool call has a visible result regardless of cancellation timing
- anthropic_stream_test.go: five tests covering tool schema cache_control
placement (last tool only, single-tool edge case) and tool result caching
(large gets cache, small does not, last of multiple large results wins)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Tools annotated with "# ollie:parallel read" now run concurrently when
the LLM issues multiple such calls in a single round, without needing
an execute_code pipeline.
- tools.ParallelClassifier interface; execute.Server implements it via
IsParallelRead, which reads the tool script and checks the annotation
- BuildAgentEnv wires IsParallelRead → AgentEnv.classifyTool →
agentConfig.ClassifyTool (nil when execute server absent)
- loop.go: consecutive parallel-safe calls are fanned out with a
WaitGroup; results are collected in submission order; cancellation
fills remaining calls with interrupted results; serial tools and
batches of one skip the goroutine overhead
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Anthropic system prompt sent as a cacheable content block
(cache_control: ephemeral) so repeated turns reuse the cached prefix
- Parse cache_read_input_tokens and cache_creation_input_tokens from
Anthropic message_start; parse cached_tokens from OpenAI
prompt_tokens_details
- Normalise Usage so CachedInputTokens is always excluded from
InputTokens regardless of provider
- computeCostUSD now takes backend.Usage and applies correct cache
discounts: 10% read / 125% creation for Anthropic, 50% read for OpenAI
- Session tracks TotalCachedInputTokens / TotalCacheCreationTokens;
/usage shows cached token count when non-zero
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Usage events now carry a 4th field (costUSD) so OpenRouter's reported
cost flows through unchanged. For backends that only report token counts
(Anthropic, direct OpenAI, Ollama), cost is computed from a prefix-keyed
pricing table. The higher-fidelity source always wins.
Per turn:
- turnInputTokens/turnOutputTokens/turnCostUSD reset before each turn
- recordTurnCost() stores LastTurnCostUSD, adds to SessionCostUSD
- "costLast=$X.XXXX" emitted as an info event at turn end
Session file "cost" returns costLast/costSession key=value pairs.
Agent interface gains Cost() string.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously only HTTP 429 triggered a retry. Network failures, 5xx
responses, and mid-stream drops all hard-failed the turn.
- Add TransientError for 5xx and network-level failures; streamRequest
wraps client.Do errors and >=500 responses in it
- Merge pre-stream and mid-stream retry into one loop (maxTransientRetries=3)
covering rate limits, transient errors, and pure stream drops
- On stream drop with ctx cancelled: record partial state and return
(existing pause behaviour preserved)
- On stream drop without cancellation: close any open <think> block,
emit retry countdown, restart ChatStream from the same history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SaveSession callback to agentConfig, called after every state.update()
in the run loop. On restart, the session JSON contains all completed tool
rounds; the model continues naturally from current history.
SIGTERM is already handled in olliesrv via InterruptAll + Shutdown, which
cancels all session contexts and lets the loop exit cleanly after the
current state.update(). Worst-case (SIGKILL) loses one tool round, not
the whole turn.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The function only appends to a slice and can never fail. Removing the
dead error return eliminates the nolint:errcheck suppression on the
interrupted-stream path in loop.go.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Snapshot session state before each turn. On any error from run(),
restore to the pre-turn snapshot so the session is clean for the next
attempt. Remove recordInterruption — those messages were appended to
state that gets rolled back anyway, making them dead code.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
preTool fires before each tool execution; exit 2 blocks the call and
returns the hook's stderr as the tool error result. postTool fires after;
exit 0 appends context to the result, exit 2 replaces it entirely.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extract runCompact() to handle the full compact cycle (pre-hook, compact,
spawn-context re-injection, post-hook) consistently across all three call
sites. Wire AutoCompact into agentConfig so loop.go triggers it after each
tool round, not just before turns.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rename loopConfig→agentConfig, agentCore→agent, loopcfg→cfg, systemPrompt→preamble
for clarity: the struct represents agent runtime config, the field is its config,
and preamble accurately describes the compiled system+agent prompt sent as system role
- Separate preamble from agent context: preamble now contains only SYSTEM_PROMPT.md;
agent prompt and agentSpawn hook output are injected into session history at
context refresh points instead of being bolted onto the system role
- Replace fireAgentSpawn (mutated preamble once, guarded by agentSpawnFired flag)
with spawnContext (assembles agent prompt + hook output, returns string for caller
to inject); remove agentSpawnFired entirely — session==nil is the sufficient guard
- Three refresh points inject spawn context into session history:
session start, post-clear (session=nil triggers same path), post-compaction
(auto and manual)
- Move all preTurn tool primes (tools-file, tools-reasoning, tools-memory,
tools-subagent, tools-elevate) to agentSpawn in all agent configs so they are
injected at session refresh points alongside bd prime, not repeated every turn
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Hook timeout configurable via hookTimeout var (unlocks <-timeout branch)
- newBackend injection into agentCore (unlocks /backend switch in tests)
- Remove dead code: WatchSignals, Prompt(), Goal(), isComplete/markComplete,
contextStatsString, extractMCPText, DefaultAgentsDir, DefaultSessionsDir,
Dispatcher() on AgentEnv
- Signal handling moved to process entry points; core has no OS signal knowledge
- Tests: extractMCPResult, mcpToolsToBackend, RestoreSession, SetEnv,
BuildAgentEnv, firstSentence, hook timeout, backend injection
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Submit routes to Inject when a turn is already running, keeping the
routing decision in core rather than the frontend or 9p layer
- Inject no longer cancels the current action; the pending prompt is
stored and consumed at the next tool result boundary (success or
failure), matching goq interrupt semantics
- If an inject is already pending, fall back to the FIFO so nothing is
lost; /irw (interrupt-rewrite) explicitly overwrites the pending inject
- PopInject is now called after every tool result, not just the last one
- Injected prompts are echoed to the chat via the running turn's output
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remove MaxSteps limit and associated stalled event emission.
The agent will now run indefinitely until completion or interruption,
eliminating the annoying 'agent stalled' messages.
- Inject(prompt) cancels the running tool and injects the message into
the conversation as <system-user-interruption>
- Queue/PopQueue FIFO for deferred prompt execution
- Submit() drains FIFO after each turn
- IsRunning() for frontends to choose Submit vs Inject
- TUI: typing during a turn calls Inject; /q queues
- 9P: prompt interrupts if busy; prompt.fifo is push/pop FIFO
Instead of discarding history on interruption, record what happened:
- Partial assistant content and tool calls are preserved
- Skipped tool calls get synthetic cancelled results
- Phase-specific notes (stream/tools/request) tell the model what occurred
- Side effects from already-executed tools are no longer hidden from history
Move internal/backend and internal/config to pkg/ so external consumers
can import them. Remove cmd/ollie and internal/tui — they now live in
the separate ollie-tui repo which imports ollie as a library.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each backend now owns its active model via Model()/SetModel(). ChatStream
no longer takes a model string — callers use SetModel to configure it.
AgentCoreConfig.ModelName sets the backend model at construction time via
SetModel, then the field can be dropped from the live agentCore struct.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>