Skip to content

Agent runs: listener, limits, cancellation, token usage, refusal handling and current models - #12

Merged
soukicz merged 15 commits into
soukicz:masterfrom
bobsoukup:feature/agent-run
Oct 20, 2026
Merged

soukicz merged 15 commits into
soukicz:masterfrom
bobsoukup:feature/agent-run

Conversation

@bobsoukup

Copy link
Copy Markdown

Makes LLMAgentClient usable as the engine of long-running, observable agent turns (background jobs with progress, limits and cost logging) and fixes several bugs found on the way. Existing callers stay source-compatible: every new parameter is optional and appended.

Fixes

  • Tool errors are sent to the model as is_error (the encoder read the flag from the wrapping message, so errors arrived as successes).
  • A tool that throws (or a rejected promise) becomes an error result instead of aborting the whole run.
  • Valid API responses no longer throw: refusal and pause_turn stop reasons, redacted_thinking and unknown content blocks (kept opaque and re-sent unchanged).
  • temperature is optional (null omits it) and never sent to models that reject sampling parameters (Claude 5 family, Opus 4.7/4.8).
  • Effort is always sent when set; new XHIGH and MAX levels.
  • Cache pricing: 5-minute vs 1-hour cache writes and cache reads priced separately; Sonnet 5.5 cache read corrected to $0.10/MTok (live pricing page, 2026-10-20).

New

  • AgentRunOptions (listener, max iterations, max cost, deadline, cancel callback) as an optional 4th argument of run()/runAsync(); AgentListener events (model step with step usage, tool start/finish); AgentRunInterruptedException with a conversation that is valid to persist and replay (open tool calls closed with error results, refused turn dropped).
  • TokenUsage per step and cumulative (LLMResponse::getUsage(), getStepUsage()).
  • Thinking display option (summarized / updates / omitted) with per-request beta headers, sent only to models that support updates.
  • Server-side context editing (ContextManagement / ClearToolUses).
  • System-prompt caching and system messages between turns (native where supported, otherwise appended to the previous user message).
  • Models: Claude Sonnet 5.5 and Haiku 5.5 capabilities merged with the upstream classes, GPT-6.1 Sol, Gemini 4 Argon (prices from the providers' pages, sources in the commit body).
  • PDF documents in user messages and tool results (tests).
  • Docs: docs/guides/agent-runs.md and updates to the configuration, reasoning, caching, multimodal and tools guides.

Tests

vendor/bin/phpunit --testsuite default: 431 tests OK; PHPStan level 5 clean. Every change has tests with a scripted client — no live API calls.

Used by the Simplia e-shop's new agent framework (newsletter AI).

🤖 Generated with Claude Code

Bob Soukup and others added 15 commits October 20, 2026 17:43
…n a failing tool

A result built with LLMMessageContents::fromErrorString() reached Claude as a
success: the encoder read the error flag of the wrapping user message, which the
agent loop always creates without one. And a tool that threw (or whose promise
was rejected) aborted the whole run, so one failing lookup killed a two-minute
agent turn. The model can only recover from an error it is told about.

Encode is_error from each tool result's own content, and turn exceptions,
rejected promises and wrongly typed results of ToolDefinition::handle() into
error results carrying the exception class and message.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The agent framework logs tokens and cost per model call and per turn, but a
response only exposed cumulative uncached input/output: cache writes and reads
were folded into the price and lost, so "input tokens" hid most of the real
prompt, and 1-hour cache writes (2x base input) were billed at the 5-minute rate.

Add a TokenUsage value object (uncached input, output, 5m/1h cache writes, cache
reads, reasoning), accumulate it on the request like the cost, and expose
getUsage() (run), getStepUsage() (this call) and getTotalCostUsd() on the
response. Anthropic models get explicit cache prices and a rate card per prompt
size; OpenAI and Gemini report their cached and reasoning tokens per step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Valid API responses threw in the decoder: Opus 5.5 / Sonnet 5.5 safety
classifiers answer with stop_reason "refusal" (HTTP 200), server tools pause
with "pause_turn", and redacted_thinking or server-tool blocks raised
"Unsupported message type" - in a background agent job that is a crashed turn.
Thinking and server blocks must also go back unchanged, or the next request is
rejected.

Add StopReason::REFUSAL (with getStopDetails()) and PAUSE_TURN, map
model_context_window_exceeded to LENGTH, decode redacted_thinking into
LLMMessageRedactedReasoning and any other block into LLMMessageOpaque that is
re-sent as received (empty inputs restored as JSON objects), in both the plain
and the streaming path; both types survive the conversation JSON round trip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent turns run as background jobs with a progress UI: the job must show each
model step and tool call, stop a looping model before it burns the budget or
outlives the cron route, honour a "stop" button, and still store a transcript
that the next turn can replay. A refusal must end the run instead of being
returned as if it were an answer, and a paused server-tool turn must just go on.

Add an optional AgentRunOptions argument to run()/runAsync(): an AgentListener
(model response with step usage, tool start, tool finish with error flag and
duration), maxIterations, maxCostUsd, deadline and a cancel callback. Hitting
one throws AgentRunInterruptedException with the reason, usage, cost and a
conversation whose open tool uses are closed with error results; refusals end
the run the same way without the refused turn; pause_turn is resumed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…that reject it

Claude Opus 4.7+, Sonnet 5 and Fable reject sampling parameters with a 400, yet
the encoder always sent temperature (0.0 by default), so callers had to guess a
value the model happens to accept (the eshop passes 1.0 as a workaround).

Allow temperature: null to omit it for every provider, and let Anthropic
models declare supportsSamplingParameters(); the encoder drops temperature for
those whatever the caller passed. The 0.0 default stays for older models.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…effort

Claude's effort scale is low/medium/high/xhigh/max, but the library had no
xhigh and only reached max through EXTRA_HIGH. NONE simply omitted thinking,
which on Opus 5.5 and the other adaptive-by-default models still runs thinking
at the default effort (medium on Opus 5.5) - the opposite of what was asked.

Add ReasoningEffort::XHIGH and MAX (EXTRA_HIGH keeps mapping to max), let
models declare hasAdaptiveThinkingByDefault(), and send the lowest effort for
NONE there; OpenAI maps the top levels to xhigh, Gemini to high.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Opus 5.5 and Sonnet 5.5 return their between-tool progress notes only with
thinking.display "updates" (beta thinking-display-updates-2026-08-18); without
it a long agent turn looks silent for minutes. Beta headers could only be set
for the whole client, so a request-level option had no way to switch its beta.

Add LLMRequest's thinkingDisplay (summarized / updates / omitted), encode it on
the thinking object (or alone on models that think by default), and let the
encoder report the betas a request needs; the client merges them with its own
for messages, streaming and batches.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Agent conversations grow with every screenshot and tool result. Pruning old
tool results client-side edits earlier turns, which on Opus 5.5 / Sonnet 5.5
invalidates the replayed thinking blocks (a 400 for accounts created after
2026-08-31) and the prompt cache; server-side context editing never does.

Add LLMRequest's contextManagement with ClearToolUses (trigger, keep, clear
inputs, excluded tools, minimum cleared) encoded as context_management.edits
with the context-management beta header.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…tween turns

Agent turns repeat a large, stable system prompt and need to add per-turn
operator notes (the editor's current state) without editing earlier turns:
editing the history breaks the prompt cache and, on Opus 5.5 / Sonnet 5.5, the
replayed thinking blocks. The encoder sent the system prompt uncached and
rejected any second system message.

A system message created with cached: true now gets a cache breakpoint (with
the conversation cache TTL). Later system messages go out as mid-conversation
role "system" messages where the model and position allow it, and are
otherwise appended to the neighbouring user message, never between a tool use
and its result.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…mini 4 Argon

The agent framework runs on Claude Sonnet 5.5, which the library did not know;
Haiku 5.5 is the cheap sibling for sub-tasks. Cost logging must match the
provider's rate cards, including Sonnet 5.5's 0.05x cache reads and Haiku 5.5's
higher prices for prompts over 100,000 tokens.

Add both Claude classes with prices from the Anthropic pricing page, their
capability flags, and Haiku's prompt-length rate card; gpt-6.1-sol and
gemini-4-argon are added from their pricing pages. A capability matrix test
covers every current Claude class.

Sources, all fetched 2026-10-20 and matching the plan values:
- https://platform.claude.com/docs/en/about-claude/pricing.md (Sonnet 5.5 and
  Haiku 5.5 rate cards, Haiku long-prompt rule counting cache tokens)
- https://platform.claude.com/docs/en/models/overview (IDs claude-sonnet-5-5,
  claude-haiku-5-5)
- https://developers.openai.com/api/docs/pricing (gpt-6.1-sol 2.00 / 0.10 /
  10.00 for inputs up to 272K tokens)
- https://ai.google.dev/gemini-api/docs/pricing (gemini-4-argon 2.00 / 10.00
  until 2026-11-30, 4.00 / 20.00 from 2026-12-01)
- https://ai.google.dev/gemini-api/docs/models/gemini-4-argon lists thinking as
  "low, medium, high" without naming the API field, so thinking level is kept
  (thinking level unverified by field name).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The brand agent reads uploaded brand manuals through a tool, so a PDF must be
encoded as an Anthropic document block inside tool_result content, survive the
transcript JSON round trip and travel through the agent loop. Nothing tested
that path, and a stored PDF without the cached key failed to load.

Cover the three paths with tests and default the missing cached flag to false.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Callers of the library learn its options from these guides; without them the
listener, run limits, nullable temperature, thinking display, context editing,
system-prompt caching and the new effort levels would only be discoverable by
reading the encoder.

Add an "Agent Runs" guide and update the configuration, reasoning, caching,
multimodal and tools guides and the Readme.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The final review found guides promising behaviour the code lacks: Gemini
never fills cacheRead, only Claude receives is_error, and the 'behaves as
before' line hid the refusal, pause_turn and tool-error changes that apply
even without options. Wrong docs make callers rely on things that are not
there, so each statement now matches the encoder it describes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The API answers a 400 to display 'updates' (and its beta) on models that
do not support it, but the encoder sent it to any Claude model, so one
request option could break a run when the model was swapped. A model
capability, in the pattern of supportsSamplingParameters(), now decides;
other models fall back to the default display and no beta is added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Each block type had its own round-trip test, but nothing covered the
composition a stored cancelled run depends on: thinking, redacted
thinking, opaque server blocks and an unrun tool call must come back
from JSON and encode into a request where every tool_use has a
tool_result. The final review verified it only with a scratch probe.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@soukicz
soukicz merged commit 108505a into soukicz:master Oct 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants