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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,18 @@ All notable changes to this project are documented here. The format is based on

### Added

- `text_before_tool_calls` capability (`ProviderCapabilities::$textBeforeToolCalls`, true by
default, false for Claude Sonnet 5.5, Opus 5.5 and Fable 5): these models return a note longer
than a sentence or two written before a tool call as a progress update inside a thinking
block, never as text, so a reply written before the chips was never shown. For such a model
the chips tool takes the reply first, in `reply` (`ChipComponent::REPLY_FIELD`), the prompt
gets a "# Replies with chips" section, and the reply is shown as text while it is written
(`PartialJson::stringSoFar()`); it counts as the turn's text for the `chips_alone` hold, whose
note for these models is `ExecutorWording::$chipsAloneReplyText`, and it is kept in the
message's text and in what the memory reads. Other models see no change.
- `ToolSpec::$streamsInput`: the Anthropic adapter asks for the tool's arguments as they are
written (`eager_input_streaming`) instead of buffered. The chips tool sets it when it carries
the reply.
- The guide index goes inside the fence, sanitized, since its titles and "when" lines are content:
a guide written in an admin or by another system cannot reach the prompt as an instruction.
- `PromptSection::Guidance`: a section of the cached prompt, right before the house rules, where
Expand Down Expand Up @@ -89,6 +101,12 @@ All notable changes to this project are documented here. The format is based on
extraction is raised by `AgentLoop::extractMemory()` instead of being swallowed in
`MemoryRuntime`, so a transport can retry it.

### Fixed

- `prices` and `capabilities` keep a model's name as written: Symfony turned the dashes of
`claude-sonnet-5-5` into underscores, so a price or a capability configured for a model with
dashes was never applied.

## [0.2.0] - 2026-10-03

### Added
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,11 @@ odiseo_ai_conversational_agent:
claude-sonnet-6: { forced_tool_choice: false }
```

Some models return a longer note written before a tool call hidden inside a thinking block
(`text_before_tool_calls: false`; Claude Sonnet 5.5, Opus 5.5 and Fable 5 by default). Since a
turn without a card ends with the chips, those models write the reply in the chips tool's
`reply` field, and it is shown as text while it streams.

`doctrine:migrations:diff` then produces the schema: the mappings ship here, the migration
belongs to the application that runs them. Without `doctrine/orm` (or with `orm.enabled: false`)
the stores are in memory and nothing outlives the process.
Expand Down
4 changes: 2 additions & 2 deletions src/Agent/AgentLoop.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,9 @@ public function streamTurn(array &$messages, SessionContext $session, TurnState
if (!$can->promptCaching) {
$system = array_map(static fn (SystemBlock $block): SystemBlock => new SystemBlock($block->text), $system);
}
$tools = $this->toolSurface->tools();
$tools = $this->toolSurface->toolsFor($can);

$scope = new TurnScope();
$scope = new TurnScope(replyInChips: !$can->textBeforeToolCalls);
$toolContext = new ToolContext(
session: $session,
state: $state,
Expand Down
35 changes: 34 additions & 1 deletion src/Agent/StreamedRound.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Odiseo\AiConversationalAgentBundle\Execution\ToolExecutor;
use Odiseo\AiConversationalAgentBundle\Execution\ToolSurface;
use Odiseo\AiConversationalAgentBundle\Fencing\Sanitizer;
use Odiseo\AiConversationalAgentBundle\Presentation\ChipComponent;
use Odiseo\AiConversationalAgentBundle\Streaming\AgentEvent;
use Odiseo\AiConversationalAgentBundle\Streaming\PartialJson;
use Odiseo\AiConversationalAgentBundle\Streaming\ToolOutcome;
Expand All @@ -17,7 +18,8 @@
* One model round while it streams: each tool call's arguments buffered as they arrive, and
* what the host gets to see before the round is over.
*
* Three things come out of the buffer, in this order, on every chunk:
* Three things come out of the buffer, in this order, on every chunk (after the reply, when
* the chips call carries it, as text while it is written):
* - the call's status line, once it is complete (a `progress` event);
* - a `ui_partial` frame when the call is a presentation component that renders partially
* and something visible changed since the last frame;
Expand All @@ -36,6 +38,9 @@ final class StreamedRound
/** @var array<string, ToolOutcome> */
private array $settled = [];

/** @var array<string, string> the reply already shown, by call */
private array $replies = [];

public function __construct(
private readonly ToolExecutor $executor,
private readonly ToolContext $context,
Expand All @@ -60,6 +65,10 @@ public function chunk(string $id, string $tool, string $partialJson): \Generator
}
$call['buffer'] .= $partialJson;

if ($this->context->scope->replyInChips && ChipComponent::TOOL === $call['tool']) {
yield from $this->reply($id, PartialJson::stringSoFar($call['buffer'], ChipComponent::REPLY_FIELD) ?? '');
}

if (!$call['statusShown']) {
$decoded = PartialJson::decode($call['buffer']);
$status = Sanitizer::label($decoded[ToolSurface::STATUS_FIELD] ?? '', ToolSurface::STATUS_MAX_CHARS);
Expand Down Expand Up @@ -98,6 +107,10 @@ public function chunk(string $id, string $tool, string $partialJson): \Generator
*/
public function run(string $id, string $tool, array $input): \Generator
{
if ($this->context->scope->replyInChips && ChipComponent::TOOL === $tool) {
yield from $this->reply($id, \is_string($input[ChipComponent::REPLY_FIELD] ?? null) ? $input[ChipComponent::REPLY_FIELD] : '');
}

yield $this->executor->toolCallEvent($tool, $id, $input);

$outcome = $this->executor->execute($tool, $input, $this->context);
Expand All @@ -117,6 +130,26 @@ public function run(string $id, string $tool, array $input): \Generator
);
}

/**
* The part of the reply not shown yet, as text. It counts as the turn's text, so the chips
* that carry it are not held for a reply already written.
*
* @return \Generator<int, AgentEvent>
*/
private function reply(string $id, string $reply): \Generator
{
$shown = $this->replies[$id] ?? '';
if (\strlen($reply) <= \strlen($shown) || !str_starts_with($reply, $shown)) {
return;
}
$this->replies[$id] = $reply;
if ('' !== trim($reply)) {
$this->context->scope->markText();
}

yield AgentEvent::textDelta(substr($reply, \strlen($shown)));
}

public function outcome(string $id): ?ToolOutcome
{
return $this->settled[$id] ?? null;
Expand Down
3 changes: 3 additions & 0 deletions src/Agent/Transcript.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Odiseo\AiConversationalAgentBundle\Agent;

use Odiseo\AiConversationalAgentBundle\Presentation\ChipComponent;
use Odiseo\AiConversationalAgentBundle\Streaming\ToolOutcome;
use Odiseo\AiConversationalAgentBundle\Support\Scalar;

Expand Down Expand Up @@ -161,6 +162,8 @@ public static function text(array $messages): string
foreach (\is_array($message['content'] ?? null) ? $message['content'] : [] as $block) {
if (\is_array($block) && 'text' === ($block['type'] ?? null)) {
$lines[] = $role.': '.Scalar::string($block['text'] ?? null);
} elseif ('' !== $reply = ChipComponent::replyIn($block)) {
$lines[] = $role.': '.$reply;
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion src/Bridge/Doctrine/Model/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Odiseo\AiConversationalAgentBundle\Bridge\Doctrine\Model;

use Odiseo\AiConversationalAgentBundle\Presentation\ChipComponent;
use Odiseo\AiConversationalAgentBundle\Support\Scalar;

/**
Expand Down Expand Up @@ -92,7 +93,8 @@ public function getCreatedAt(): \DateTimeImmutable
}

/**
* The text blocks of an API message joined, or null when it carries none (a tool call, a tool result).
* The text blocks of an API message joined, with a reply carried in the chips call, or null
* when it carries none (a tool call, a tool result).
*
* @param array<string, mixed> $payload
*/
Expand All @@ -107,6 +109,8 @@ public static function textOf(array $payload): ?string
foreach (\is_array($content) ? $content : [] as $block) {
if (\is_array($block) && 'text' === ($block['type'] ?? null) && '' !== trim(Scalar::string($block['text'] ?? null))) {
$parts[] = trim(Scalar::string($block['text']));
} elseif ('' !== $reply = ChipComponent::replyIn($block)) {
$parts[] = $reply;
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/Bridge/Symfony/OdiseoAiConversationalAgentBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ public function configure(DefinitionConfigurator $definition): void
->arrayNode('prices')
->info('USD per million tokens, for a model the bundle has no price for or whose price changed. Every configured model needs one.')
->useAttributeAsKey('model')
->normalizeKeys(false)
->arrayPrototype()->children()
->floatNode('input')->isRequired()->end()
->floatNode('output')->isRequired()->end()
Expand All @@ -143,6 +144,7 @@ public function configure(DefinitionConfigurator $definition): void
->arrayNode('capabilities')
->info('What a model can do, for one its adapter does not know yet: forced_tool_choice, thinking, prompt_caching, tool_input_deltas, temperature, server_tools, parallel_tool_calls, text_beside_tool_calls, editable_history.')
->useAttributeAsKey('model')
->normalizeKeys(false)
->arrayPrototype()->useAttributeAsKey('name')->booleanPrototype()->end()->end()
->end()
->arrayNode('budgets')->addDefaultsIfNotSet()->children()
Expand Down
2 changes: 2 additions & 0 deletions src/Capability/ToolSpec.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public function __construct(
public string $description,
public array $inputSchema,
public bool $wantsStatusLine = true,
/** Its arguments are sent as the model writes them, unchecked, for what is shown while it streams. */
public bool $streamsInput = false,
) {
}
}
2 changes: 2 additions & 0 deletions src/Execution/ExecutorWording.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public function __construct(
/** Formatted with {name} and {problems}. */
public string $invalidInputText = '{name} was not run: {problems}. Correct the arguments and call it again.',
public string $chipsAloneText = 'The suggestions were not shown: this turn has no reply yet. Write the reply first, then send the suggestions with it.',
/** The same, for a model whose reply goes in the chips call. */
public string $chipsAloneReplyText = 'The suggestions were not shown: this turn has no reply yet, and text written before a call is not shown. Call it again with the whole reply in `reply`.',
) {
}
}
2 changes: 1 addition & 1 deletion src/Execution/ToolExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ private function present(PresentationComponent $component, array $input, ToolCon

// Chips alone would close the turn with nothing answered.
if ($isChips && !$context->scope->textWritten() && 0 === $context->scope->componentsPresented()) {
return ToolOutcome::held('chips_alone', $this->wording->chipsAloneText);
return ToolOutcome::held('chips_alone', $context->scope->replyInChips ? $this->wording->chipsAloneReplyText : $this->wording->chipsAloneText);
}

$chips = [];
Expand Down
46 changes: 44 additions & 2 deletions src/Execution/ToolSurface.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use Odiseo\AiConversationalAgentBundle\Presentation\ChipComponent;
use Odiseo\AiConversationalAgentBundle\Presentation\ChipMode;
use Odiseo\AiConversationalAgentBundle\Presentation\PresentationComponent;
use Odiseo\AiConversationalAgentBundle\Provider\ProviderCapabilities;
use Odiseo\AiConversationalAgentBundle\Support\Scalar;

/**
Expand All @@ -25,6 +26,9 @@ final class ToolSurface
/** @var list<ToolSpec>|null */
private ?array $tools = null;

/** @var list<ToolSpec>|null */
private ?array $withReply = null;

public function __construct(
private readonly CapabilityRegistry $capabilities,
private readonly ExecutorWording $wording = new ExecutorWording(),
Expand All @@ -51,6 +55,44 @@ public function tools(): array
);
}

/**
* The list for one model. A model that hides text written before a tool call gets the
* reply as the chips tool's first argument, so the turn's answer is written where it shows.
*
* @return list<ToolSpec>
*/
public function toolsFor(ProviderCapabilities $can): array
{
if ($can->textBeforeToolCalls) {
return $this->tools();
}

return $this->withReply ??= array_map(
fn (ToolSpec $tool): ToolSpec => ChipComponent::TOOL === $tool->name ? $this->withReply($tool) : $tool,
$this->tools(),
);
}

private function withReply(ToolSpec $tool): ToolSpec
{
$schema = $tool->inputSchema;
$schema['properties'] = [
ChipComponent::REPLY_FIELD => [
'type' => 'string',
'description' => 'The turn\'s whole reply when the turn has no component, shown before the chips. Text written outside this call is not shown.',
],
...Scalar::keyed($schema['properties'] ?? null),
];

return new ToolSpec(
$tool->name,
$tool->description.' On a turn with no component, write the reply in `'.ChipComponent::REPLY_FIELD.'`: text written before this call is not shown.',
$schema,
$tool->wantsStatusLine,
streamsInput: true,
);
}

/**
* A component that takes the turn's chips gets the field last, so the model writes them
* once the component itself is written. The component's chip note follows the tool's
Expand Down Expand Up @@ -81,7 +123,7 @@ private function withChips(ToolSpec $tool, PresentationComponent $component): To

$description = '' === $component->chipNote ? $tool->description : $tool->description.' '.$component->chipNote;

return new ToolSpec($tool->name, $description, $schema, $tool->wantsStatusLine);
return new ToolSpec($tool->name, $description, $schema, $tool->wantsStatusLine, $tool->streamsInput);
}

/**
Expand All @@ -104,6 +146,6 @@ private function withStatus(ToolSpec $tool): ToolSpec
...Scalar::keyed($schema['properties'] ?? null),
];

return new ToolSpec($tool->name, $tool->description, $schema, $tool->wantsStatusLine);
return new ToolSpec($tool->name, $tool->description, $schema, $tool->wantsStatusLine, $tool->streamsInput);
}
}
6 changes: 6 additions & 0 deletions src/Execution/TurnScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ final class TurnScope

private bool $textWritten = false;

public function __construct(
/** The chips call carries the turn's reply (ChipComponent::REPLY_FIELD). */
public readonly bool $replyInChips = false,
) {
}

public function componentsPresented(): int
{
return $this->componentsPresented;
Expand Down
14 changes: 14 additions & 0 deletions src/Presentation/ChipComponent.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,18 @@ final class ChipComponent

/** The argument a ChipMode::Field component takes its chips in. */
public const FIELD = 'suggestions';

/** The chips tool's argument for the turn's reply, on a model that hides text written before a call. */
public const REPLY_FIELD = 'reply';

/** The reply a block carries in the chips tool, or '' when it is not such a call. */
public static function replyIn(mixed $block): string
{
if (!\is_array($block) || 'tool_use' !== ($block['type'] ?? null) || self::TOOL !== ($block['name'] ?? null)) {
return '';
}
$reply = \is_array($block['input'] ?? null) ? ($block['input'][self::REPLY_FIELD] ?? null) : null;

return \is_string($reply) ? trim($reply) : '';
}
}
18 changes: 13 additions & 5 deletions src/Prompt/StaticPromptBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,24 @@ public function build(): string

/**
* The prompt for one model: a model that writes text or calls tools, never both, is told
* it may do both, so the sentence a rule asks for is not dropped.
* it may do both, so the sentence a rule asks for is not dropped; one that hides text
* written before a call is told where the reply goes instead.
*/
public function buildFor(ProviderCapabilities $can): string
{
if ($can->textBesideToolCalls) {
return $this->build();
$text = $this->build();
if (!$can->textBesideToolCalls) {
$text .= "\n\n# Text and tool calls\n\n"
.'- One response can carry text and tool calls together. Write the sentence a rule above asks for (an answer, a gap, a clarifying question) as text in the same response as its tool calls, before them, not in a response of its own. A turn that ends with nothing the person can read, or with only chips, is wrong.';
}
// Such a model hides a longer note written before a call, so a turn with no component
// carries its reply inside the chips call.
if (!$can->textBeforeToolCalls) {
$text .= "\n\n# Replies with chips\n\n"
.\sprintf('- Text you write before a tool call is not shown to the person. On a turn with no component, write the whole reply in the `%s` field of %s instead of as text; it is shown before the chips, so no text goes before or after the call.', ChipComponent::REPLY_FIELD, ChipComponent::TOOL);
}

return $this->build()."\n\n# Text and tool calls\n\n"
.'- One response can carry text and tool calls together. Write the sentence a rule above asks for (an answer, a gap, a clarifying question) as text in the same response as its tool calls, before them, not in a response of its own. A turn that ends with nothing the person can read, or with only chips, is wrong.';
return $text;
}

/**
Expand Down
5 changes: 5 additions & 0 deletions src/Provider/Anthropic/AnthropicProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public function capabilities(string $model): ProviderCapabilities
toolInputDeltas: true,
// The Claude 5 models reject a temperature.
temperature: 1 !== preg_match('/^claude-(fable|opus|sonnet|haiku)-5/', $model),
// These return a note longer than a sentence or two written before a tool call as a
// progress update inside a thinking block, never as text.
textBeforeToolCalls: 1 !== preg_match('/^claude-(fable-5|mythos-5|opus-5-5|sonnet-5-5)/', $model),
// These check that the history before a replayed thinking block was not edited.
editableHistory: 1 !== preg_match('/^claude-(fable-5-1|opus-5-5|sonnet-5-5|haiku-5-5)/', $model),
);
Expand Down Expand Up @@ -136,6 +139,8 @@ private function tools(array $tools, bool $cacheTools): array
'name' => $tool->name,
'description' => $tool->description,
'input_schema' => self::schema($tool->inputSchema),
// Without it the API buffers the arguments and they arrive in one piece.
...($tool->streamsInput ? ['eager_input_streaming' => true] : []),
],
$tools,
);
Expand Down
3 changes: 3 additions & 0 deletions src/Provider/ProviderCapabilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
'tool_input_deltas' => 'toolInputDeltas',
'temperature' => 'temperature',
'text_beside_tool_calls' => 'textBesideToolCalls',
'text_before_tool_calls' => 'textBeforeToolCalls',
'editable_history' => 'editableHistory',
];

Expand All @@ -41,6 +42,8 @@ public function __construct(
public bool $temperature = true,
/** Writes text in the same response as its tool calls without being told it may. */
public bool $textBesideToolCalls = true,
/** Returns as text what it writes before a tool call; without it a longer note there comes back hidden. */
public bool $textBeforeToolCalls = true,
/** Accepts earlier turns edited under its thinking blocks; without it the history is not compacted. */
public bool $editableHistory = true,
) {
Expand Down
Loading
Loading