Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ $response = $agentClient->run($client, new LLMRequest(

**→ [Tools Documentation](docs/guides/tools.md)**

**→ [Agent Runs: progress, limits and cancellation](docs/guides/agent-runs.md)**

### ✅ Feedback Loops

Build self-correcting agents that validate and improve their outputs:
Expand Down Expand Up @@ -271,7 +273,7 @@ use Soukicz\Llm\Client\OpenAI\Model\GPT54;
$request = new LLMRequest(
model: new GPT54(GPT54::VERSION_2026_03_05),
conversation: $conversation,
reasoningConfig: ReasoningEffort::HIGH // NONE, MINIMAL, LOW, MEDIUM, HIGH, or EXTRA_HIGH
reasoningConfig: ReasoningEffort::HIGH // NONE, MINIMAL, LOW, MEDIUM, HIGH, XHIGH, EXTRA_HIGH or MAX
);

// Or use token-based budget control (Anthropic only)
Expand Down
72 changes: 72 additions & 0 deletions docs/guides/agent-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Agent Runs: Progress, Limits and Cancellation

`LLMAgentClient::run()` loops while the model calls tools. Pass `AgentRunOptions` as the fourth argument to watch
the run and to stop it safely. Without options a run still differs from older versions in four ways: a refusal throws `AgentRunInterruptedException` (reason `refusal`) instead of returning the refused text, a `pause_turn` stop continues the run, a tool that throws, rejects or returns the wrong type becomes an error result for the model instead of aborting the run, and error results are sent to Claude with `is_error`.

```php
<?php
use Soukicz\Llm\Agent\AgentListener;
use Soukicz\Llm\Agent\AgentRunInterruptedException;
use Soukicz\Llm\Agent\AgentRunOptions;
use Soukicz\Llm\Client\LLMAgentClient;
use Soukicz\Llm\LLMResponse;
use Soukicz\Llm\Message\LLMMessageContents;

$listener = new class implements AgentListener {
public function onModelResponse(LLMResponse $stepResponse): void {
// one API call: $stepResponse->getStepUsage(), $stepResponse->getTotalCostUsd() (cumulative)
}

public function onToolCallStarted(string $toolName, array $input): void {
echo "running $toolName\n";
}

public function onToolCallFinished(string $toolName, array $input, LLMMessageContents $result, bool $isError, int $durationMs): void {
echo "$toolName " . ($isError ? 'failed' : 'done') . " in {$durationMs} ms\n";
}
};

try {
$response = (new LLMAgentClient())->run($client, $request, null, new AgentRunOptions(
listener: $listener,
maxIterations: 40, // model calls
maxCostUsd: 5.0, // cumulative, cache reads and writes included
deadline: microtime(true) + 480, // unix time
shouldCancel: fn(): bool => $stopRequested(),
));
} catch (AgentRunInterruptedException $e) {
$e->getReason(); // 'iterations' | 'cost' | 'deadline' | 'cancelled' | 'refusal'
$e->getConversation(); // everything so far, safe to store and continue later
$e->getUsage(); // TokenUsage of the run so far
$e->getCostUsd();
}
```

## When the limits are checked

| Option | Checked | Effect |
|---|---|---|
| `maxIterations` | before each model call | N calls run, the next one throws `iterations` |
| `maxCostUsd` | after each model response | throws `cost`; the tools of that response do not run |
| `deadline` | before each model call and each tool call | throws `deadline` |
| `shouldCancel` | before each model call and each tool call (before the deadline) | throws `cancelled` |

When a run stops while tool calls are open, the stored conversation gets one closing user message. That message holds the results that finished, plus `ERROR: not run, the agent run was interrupted (<reason>)` for the rest. Every `tool_use` therefore has a `tool_result`, and the conversation replays as a valid request.

## Tool errors

A tool may fail in three ways:
- it throws;
- its promise is rejected;
- it returns something that is not `LLMMessageContents`.

Each one becomes an error result such as `ERROR: RuntimeException: timeout`, and the run continues. Results built with `LLMMessageContents::fromErrorString()` are sent to Claude with `is_error: true`.

## Refusals and paused turns

- `StopReason::REFUSAL`: a safety classifier declined (HTTP 200), with details in `LLMResponse::getStopDetails()`. In an agent run this throws `AgentRunInterruptedException` with reason `refusal`. The refused turn is left out of `getConversation()`. A plain `sendRequestAsync()` call returns the stop reason instead.
- `StopReason::PAUSE_TURN`: a server-side tool loop paused. The agent loop re-sends the conversation unchanged and goes on. This counts as an iteration.

## Usage and cost

`LLMResponse::getUsage()` returns a `Soukicz\Llm\Usage\TokenUsage` for the whole run, and `getStepUsage()` for one API call. Both carry `input` (uncached), `output`, `cacheWrite5m`, `cacheWrite1h`, `cacheRead` and `reasoning`. `getTotalCostUsd()` includes cache writes at their TTL price and cache reads. OpenAI fills `cacheRead` and `reasoning` from the provider's own counters; Gemini fills `reasoning` only, so its `cacheRead` stays 0.
15 changes: 15 additions & 0 deletions docs/guides/caching.md
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,21 @@ Behavior details:
- OpenAI and Gemini cache prompts automatically, so the option is a no-op there.
- Prompts shorter than the provider's minimum (e.g. 1024 tokens for Claude Sonnet) are silently not cached.

### System prompt

Create the system message with `cached: true` to put a cache breakpoint right after it. With a `ConversationCacheConfig` TTL, the same TTL is used there too, because longer TTLs must come first:

```php
<?php
LLMMessage::createFromSystemString($stableInstructions, cached: true);
```

System messages added later in the conversation (for example per-turn state) do not edit the cached prefix. Models that support it (Opus 4.8+, Sonnet 5.5, Haiku 5.5, Fable) get them as mid-conversation `role: system` messages. On other models their text is appended to the neighbouring user message. Supported models fall back to the same appending when the position does not allow a native system message (it must follow a user message and be last or be followed by an assistant message).

### Cost of cache writes

`LLMResponse::getUsage()` separates 5-minute writes, 1-hour writes and reads. 1-hour writes are billed at 2× the base input price, and 5-minute writes at 1.25×.

## See Also

- [Configuration Guide](configuration.md) - Client configuration
Expand Down
34 changes: 32 additions & 2 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,18 @@ Configure your AI agent requests with various parameters to control behavior, ou

```php
<?php
use Soukicz\Llm\Config\ClearToolUses;
use Soukicz\Llm\Config\ContextManagement;
use Soukicz\Llm\Config\ReasoningEffort;
use Soukicz\Llm\Config\StructuredOutputConfig;
use Soukicz\Llm\Config\ThinkingDisplay;
use Soukicz\Llm\LLMRequest;
use Soukicz\Llm\Stream\CallableStreamListener;

$request = new LLMRequest(
model: $model, // Required: Model instance
conversation: $conversation, // Required: LLMConversation
temperature: 0.7, // Optional: 0.0 to 1.0 (default 0.0)
temperature: 0.7, // Optional: float or null (default 0.0); null = provider default
maxTokens: 4096, // Optional: Maximum response tokens (default 4096)
tools: $tools, // Optional: Array of tool definitions
stopSequences: ['###', 'END'], // Optional: Stop generation strings
Expand All @@ -23,6 +26,10 @@ $request = new LLMRequest(
streamListener: new CallableStreamListener( // Optional: Real-time progress updates
fn($event) => print($event->delta)
),
thinkingDisplay: ThinkingDisplay::UPDATES, // Optional (Anthropic): what thinking blocks contain
contextManagement: new ContextManagement( // Optional (Anthropic): server-side context editing
clearToolUses: new ClearToolUses(triggerInputTokens: 60000, keepToolUses: 3),
),
);
```

Expand Down Expand Up @@ -70,11 +77,15 @@ $conversation = new LLMConversation([

### temperature

Controls randomness in responses (0.0 to 1.0):
Controls randomness in responses (0.0 to 1.0), or `null` to send no temperature at all:

- **0.0** - Deterministic, focused responses (the library default)
- **0.5** - Balanced
- **1.0** - Creative, varied responses
- **null** - Omit the parameter and use the provider default

Claude Opus 4.7 and newer, Sonnet 5 and newer, Haiku 5.5 and Fable reject sampling parameters. The Anthropic
encoder never sends `temperature` to them (`AnthropicModel::supportsSamplingParameters()`), whatever you pass.

```php
<?php
Expand Down Expand Up @@ -248,6 +259,25 @@ $request = new LLMRequest(

See [Reasoning Models Guide](reasoning.md) for more details.

### thinkingDisplay

Anthropic only. `ThinkingDisplay::SUMMARIZED` returns a readable summary of the reasoning, and `OMITTED` returns empty thinking text (the default on current models). `UPDATES` returns the model's short progress notes between tool calls as thinking text; it needs the `thinking-display-updates-2026-08-18` beta, which the client adds by itself. Only Fable 5 / 5.1, Opus 5.5 and Sonnet 5.5 accept it; on other models `UPDATES` is dropped and the default display applies. Display controls visibility only: thinking is billed the same.

### contextManagement

Anthropic only. Clears old tool results on the server once the prompt grows, without editing your stored conversation, which keeps replayed thinking blocks valid. The client adds the `context-management-2025-06-27` beta.

```php
<?php
new ContextManagement(clearToolUses: new ClearToolUses(
triggerInputTokens: 60000, // start clearing above this prompt size (API default 100,000)
keepToolUses: 3, // keep the newest pairs (API default 3)
clearToolInputs: false, // also clear tool_use inputs
excludeTools: ['str_replace_based_edit_tool'],
clearAtLeastInputTokens: 5000, // skip clearings that free less
));
```

## Client Configuration

### Cache Configuration
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/multimodal.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ OpenAI requires images to be encoded as base64 data URIs. The library handles th
### Anthropic PDF Support
Anthropic's Claude models have excellent PDF parsing capabilities and can handle complex document layouts, tables, and multi-column text.

PDFs can also be returned from a tool: put an `LLMMessagePdf` into the tool's `LLMMessageContents`, and it is sent as a `document` block inside the `tool_result`.

### File Size Limits
Be aware of file size limits:
- Images are typically limited to 5-20MB depending on provider
Expand Down
10 changes: 8 additions & 2 deletions docs/guides/reasoning.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,26 @@ $request = new LLMRequest(
- `ReasoningEffort::LOW` - Fast, less thorough reasoning
- `ReasoningEffort::MEDIUM` - Balanced reasoning
- `ReasoningEffort::HIGH` - Thorough, slower reasoning
- `ReasoningEffort::EXTRA_HIGH` - Maximum reasoning effort
- `ReasoningEffort::XHIGH` - Between high and max (good default for agentic work on current Claude models)
- `ReasoningEffort::EXTRA_HIGH` - Kept for compatibility (Anthropic `max`, OpenAI `xhigh`)
- `ReasoningEffort::MAX` - Maximum reasoning effort

There is no default level — omitting `reasoningConfig` leaves the decision to the provider.

**How effort maps to each provider:**

| Effort | Anthropic (adaptive thinking + effort) | OpenAI (`reasoning_effort`) | Gemini 3.x (`thinkingLevel`) | Gemini 2.x (`thinkingBudget`) |
|---|---|---|---|---|
| `NONE` | thinking disabled | `none` | `thinkingBudget: 0` | `0` |
| `NONE` | models that think by default (Opus 5+, Sonnet 5+, Haiku 5.5, Fable): `effort: low`, no `thinking`; others: thinking off | `none` | `thinkingBudget: 0` | `0` |
| `MINIMAL` | `low` | `minimal` | `minimal` | `512` |
| `LOW` | `low` | `low` | `low` | `1024` |
| `MEDIUM` | `medium` | `medium` | `medium` | `8192` |
| `HIGH` | `high` | `high` | `high` | `24576` |
| `XHIGH` | `xhigh` | `xhigh` | `high` | `24576` |
| `EXTRA_HIGH` | `max` | `xhigh` | `high` | `24576` |
| `MAX` | `max` | `xhigh` | `high` | `24576` |

When an effort is set, the Anthropic encoder sends it, except `NONE` on models that do not think by default, where thinking is simply left off. A request-level `thinkingDisplay` makes the encoder send `thinking: adaptive` with that display on models that think by default, even for `NONE`; on other models it applies only when thinking is on. Claude Opus 5.5 defaults to `medium` when it is omitted.

Gemini 2.x models do not accept `thinkingLevel` — the library automatically translates the effort level to a `thinkingBudget` token budget for them.

Expand Down
4 changes: 4 additions & 0 deletions docs/guides/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ The `inputSchema` follows JSON Schema specification. Common patterns:
- ✅ **Google Gemini** - Full function calling support
- ⚠️ **OpenAI-compatible** - Depends on the underlying model

## Errors, progress and limits

A tool that throws, rejects its promise or returns the wrong type becomes an error result for the model, and the run goes on. Results built with `LLMMessageContents::fromErrorString()` are flagged as errors (`is_error`) when sent to Claude; OpenAI and Gemini have no such flag, so the error text is sent as an ordinary result. For progress events, iteration, cost and time limits, and cancellation, see [Agent Runs](agent-runs.md).

## See Also

- [Feedback Loops](feedback-loops.md) - Validate tool outputs
Expand Down
2 changes: 1 addition & 1 deletion docs/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ $model = new GPTo3(GPTo3::VERSION_2025_04_16);

### Reasoning Configuration

Use the `reasoningConfig` parameter of `LLMRequest`. The `ReasoningEffort` enum (`NONE`, `MINIMAL`, `LOW`, `MEDIUM`, `HIGH`, `EXTRA_HIGH`) works on OpenAI, Anthropic, and Gemini:
Use the `reasoningConfig` parameter of `LLMRequest`. The `ReasoningEffort` enum (`NONE`, `MINIMAL`, `LOW`, `MEDIUM`, `HIGH`, `XHIGH`, `EXTRA_HIGH`, `MAX`) works on OpenAI, Anthropic, and Gemini:

```php
<?php
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ nav:
- Guides:
- Configuration: guides/configuration.md
- Tools: guides/tools.md
- Agent Runs: guides/agent-runs.md
- Caching: guides/caching.md
- Batch Processing: guides/batch-processing.md
- Multimodal: guides/multimodal.md
Expand Down
21 changes: 21 additions & 0 deletions src/Agent/AgentListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Soukicz\Llm\Agent;

use Soukicz\Llm\LLMResponse;
use Soukicz\Llm\Message\LLMMessageContents;

/**
* Progress events of LLMAgentClient::run(). Calls happen synchronously inside the run; an exception thrown
* here ends the run.
*/
interface AgentListener {
/**
* One model API call finished; $stepResponse->getStepUsage() is that call's usage
*/
public function onModelResponse(LLMResponse $stepResponse): void;

public function onToolCallStarted(string $toolName, array $input): void;

public function onToolCallFinished(string $toolName, array $input, LLMMessageContents $result, bool $isError, int $durationMs): void;
}
Loading
Loading