From 23ab3e3480a88c12a3d70f14edc2956e5a643201 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:01:38 -0400 Subject: [PATCH 01/21] docs(spike): measure one hosted-search route and read how five harnesses carry it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A provider that runs a search on its own side is the category the fence position does not cover: not something we execute, not something we hand to a subprocess, but something the provider performs inside a call the owner already authorised. PRV-5 records that such a block is refused today. It does not say whether that should stand, and this is the evidence for deciding. Measured against the local gateway rather than read from vendor documentation. Muse Spark turns out to be a Meta model speaking the Messages protocol, so its `owned_by: anthropic` names the wire format and not the vendor — a test resting on that route proves an adapter speaks Messages, never what Anthropic's own service does. Its effort ladder accepts four of the six levels it declares, and the OpenAI facade rewrites `reasoning_effort` into the Messages field, so both routes land on one parameter. Both dialects offer one hosted search tool and no fetch tool: opening a page is an action inside search. Responses reports the queries as a `web_search_call` item, Messages as `server_tool_use` blocks, and neither reports the findings. Codex reaches the same place from the other vendor, setting `results: None` on its inline path, so the hole is the shape of inline hosted search rather than a local defect. Replay has no structural blocker here: this route accepts the assistant turn back with those blocks present or stripped. The comparison found two shapes that are not variants of each other. Codex makes it a first-class protocol item, paying for it at every site that already matches its response enum. pi makes it an ordinary client tool whose execution issues an isolated request, so the main conversation never carries a server block at all. pi's gating is rejected and the reason is already an invariant here: PRV-6 says configuration names data and selection never uses fuzzy names, while pi matches provider names and `id.startsWith("claude-")`. A declared `server_tools` subset follows `allowed_reasoning_efforts` instead — config names the capability, the adapter owns its dialect spelling, and an unencodable declaration fails before network work. Today's `max` correction is that failure mode working as intended. Evidence is reproducible: the gateway commands are in the spike, keyed from the environment rather than inline. --- .agents/roadmap.md | 1 + .agents/spikes/provider-side-tools/README.md | 117 +++++++++++++++++ .../provider-side-tools/gateway-evidence.md | 121 ++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 .agents/spikes/provider-side-tools/README.md create mode 100644 .agents/spikes/provider-side-tools/gateway-evidence.md diff --git a/.agents/roadmap.md b/.agents/roadmap.md index 737378d..2d06b81 100644 --- a/.agents/roadmap.md +++ b/.agents/roadmap.md @@ -72,6 +72,7 @@ Tracks run beside phases because their exit conditions are comparisons, not deli | [Agent Skills spike](./spikes/agent-skills/README.md) | Compare skill discovery, project configuration and durable activation | Implemented and verified offline; live model behavior unverified | | [Permission policy spike](./spikes/permission-policy/README.md) | Compare scoped grants, rule precedence and durable authority | Source comparison retained; PER-1–PER-10 and PGR-1–PGR-5 implemented and verified locally | | [Compaction spike](./spikes/compaction/README.md) | Constrain compaction, branch-local context and recovery | Source/probe evidence retained; CPL-1–CPL-8 own implementation and regression evidence | +| [Provider-side tools spike](./spikes/provider-side-tools/README.md) | Compare how harnesses surface a tool the provider runs, and measure one live hosted-search route | Comparison and live measurement retained; the design question is open | Gates without a track yet, each opened with a prototype, a comparison corpus, and a decision criterion: diff --git a/.agents/spikes/provider-side-tools/README.md b/.agents/spikes/provider-side-tools/README.md new file mode 100644 index 0000000..d8f1107 --- /dev/null +++ b/.agents/spikes/provider-side-tools/README.md @@ -0,0 +1,117 @@ +# Provider-side tools spike + +| Field | Value | +| --- | --- | +| Status | Comparison and live measurement retained as evidence; the design question is not yet decided | +| Read when | Adding web search, or any tool a provider runs on its own side rather than handing to this harness | +| Question | Can a Session reach provider-hosted search without the harness losing sight of what was searched? | +| Contract | [provider-adapter](../../specs/provider-adapter.md) PRV-1/PRV-3/PRV-5; [tool-admission](../../specs/tool-admission.md); [permission-policy](../../specs/permission-policy.md) PER-2 | + +## Why this exists + +`provider-adapter.md` PRV-5 records that a `server_tool_use` block is refused, and why. It does not +say whether that refusal should stand. A provider that runs a search on its own side is the one +category the fence position does not cover: not something we execute, not something we hand to a +subprocess, but something the provider performs inside a call the owner already authorised. + +## Corpus + +Local source read 2026-09-20; no reference suite was run. Plexmaton base `c7bdf0a`, branch +`spike/provider-side-tools`. + +| Directory | Revision | Source root for references below | +| --- | --- | --- | +| `codex` | `9f70e348e0227980de97e361cce830236fb18317` | `codex-rs` | +| `pi-arcweld` | `26cb4bf5cf7e7c4bb5fe948863264ed3cf899048` | `extensions/claude-web-search` | +| `grok-build` | `72a61251fcffb464bcc687aeb5a998e5a98ec0c9` | `crates/codegen/xai-grok-pager/src` | +| `claude-code` | `2.1.88` mapped | `cc/2.1.88` | +| `kimi-code` | `f12d59e089e2531a33fbca30b26ffeabd5862b45` | — | + +## Two shapes, and they are not variations of each other + +**Codex makes it a first-class protocol item.** `ResponseItem::WebSearchCall` carries id, status and +a typed `action`, and `event_mapping.rs:228` turns it into `TurnItem::WebSearch` for the transcript. +The variant is matched in rollout normalisation, turn timing, remote compaction, image preparation +and persisted state — so the cost is not one enum arm but every site that already matches the enum. +`WebSearchItem.results` exists and is set to `None` on this path, because the inline Responses call +returns the query and not the findings; only a separate standalone search populates it, and its +comment keeps those results as opaque JSON at the transport boundary so new result shapes need no +release. + +**pi makes it an ordinary client tool.** `extensions/claude-web-search` registers a `WebSearch` +tool with plain `query` / `allowed_domains` / `blocked_domains` parameters. Its execution issues an +**isolated** Messages request carrying the hosted tool, and returns a synthesis plus source URLs as +an ordinary tool result. `payload.ts` refuses a payload that is not exactly one user message before +continuation history, which is what keeps that request isolated. `getHostedWebSearchRoute` gates the +whole thing on the active model's provider and API, and `syncWebSearchAvailability` adds or removes +the tool from the active set as the model changes. + +The consequence is the part worth keeping: **the main conversation never contains a +`server_tool_use` block.** No new content-block type, no new semantic event, no replay hole, and the +search takes the ordinary admission, permission and transcript path every other tool takes. PRV-5's +refusal can stand untouched. + +`grok-build` sits nearer Codex, handling search inside the pager's scrollback block types. +`claude-code`'s only server-tool surface in readable source is the usage counter shape +(`web_search_requests`, `web_fetch_requests`) that this harness already parses. + +## Configuration declares, the dialect spells + +PRV-6 already decides the half that matters: a model owns its declared capabilities, and +**selection never uses fuzzy names or URL inference**. A harness that switches hosted search on +because a model id looks familiar has put authority in a name, which is the thing that invariant +exists to refuse. + +`allowed_reasoning_efforts` is the precedent to copy, not a new pattern to invent. The model +declares a subset, the declaration must be encodable by that model's dialect, and an invalid one +fails before any network work. A hosted-tool declaration is the same shape: + +```toml +[providers.local.models.muse] +api = "anthropic_messages" +id = "muse-spark-1.3" +server_tools = ["web_search"] +``` + +Config names the capability. The adapter owns its spelling, because the spelling is dialect +property and nothing else: Messages wants `{"type":"web_search_20250305","name":"web_search"}` and +Responses wants `{"type":"web_search"}` or its `web_search_preview` alias. That is the compatibility +work, and it belongs behind the declaration rather than in front of it. A tool declared on a dialect +that cannot encode it fails at config load, the way an unencodable effort already does. + +The failure mode of a declaration is known and cheap: it can be wrong. The live config claimed +`max` among muse's efforts and the route refused it. The fix was to correct the declaration, never +to add an inference that would have guessed around it. + +Rejected: pi's gating, which is the right architecture reached through the wrong door. Its +`getHostedWebSearchRoute` matches provider names and `id.startsWith("claude-")`, so every new route +and every renamed model is a code change, and a model the owner knows supports search cannot be +told so. Take the isolated client tool; leave the name matching. + +## What this costs in Plexmaton + +`NativeToolCatalog::open` builds a fixed catalog: `FileTools::definitions()` plus the command tool. +There is no extension registration, so pi's shape arrives here as a native tool beside the others +rather than as a plugin. That is a smaller change than it sounds, and it lands entirely outside the +provider adapter. + +One wrinkle has no answer yet. pi recomputes tool availability when the model changes; Plexmaton's +catalog is built when the workspace opens, while `/model` changes the model mid-Session. A tool that +only works on some routes therefore needs either a catalog that can narrow after open, or a tool +that refuses with a typed error naming the route it needed. + +## Open + +- Does the isolated-request shape survive delegation, cancellation and compaction, or does an + inner model call inside a tool break an invariant those own? +- Where does the inner call's token usage belong? It is spend the owner never selected a model for. +- Is strict cloaking what strips search results on the local gateway, or does the upstream never + send them? `claude_executor_cloaking.go` handles `server_tool_use` and is the place to look. +- Codex's standalone path populates `results`. Worth reading before assuming the findings are + always unavailable. + +## Limits + +Reference behaviour is read from source, not observed at run time. The gateway measurements in +[gateway evidence](./gateway-evidence.md) are live and reproducible, and they describe one route on +one machine, not Anthropic's or Meta's own service. diff --git a/.agents/spikes/provider-side-tools/gateway-evidence.md b/.agents/spikes/provider-side-tools/gateway-evidence.md new file mode 100644 index 0000000..39035cd --- /dev/null +++ b/.agents/spikes/provider-side-tools/gateway-evidence.md @@ -0,0 +1,121 @@ +# Gateway evidence + +Measured 2026-09-20 against the local gateway, not read from documentation. Every row below was +observed; nothing here is inferred from a vendor document. + +Route under test: `cli-proxy-api` on `127.0.0.1:5233`, built and configured from +`~/dev/ai/cpa-local-stack`. Reproduce with the key in `PLEXMATON_LOCAL_API_KEY`: + +```sh +curl -s -X POST http://127.0.0.1:5233/v1/messages \ + -H "content-type: application/json" \ + -H "x-api-key: $PLEXMATON_LOCAL_API_KEY" -H "anthropic-version: 2023-06-01" \ + -d '{"model":"muse-spark-1.3","max_tokens":1024, + "tools":[{"type":"web_search_20250305","name":"web_search"}], + "messages":[{"role":"user","content":"Search: latest stable Rust release."}]}' +``` + +## The model is not what its route says + +`muse-spark-1.3` resolves to `muse-spark-1.3-contributor` at `https://api.meta.ai`, exposed through +the gateway's Claude-compatible route. It is a **Meta** model speaking the Messages protocol. The +`owned_by: anthropic` in `/v1/models` names the protocol, not the vendor, and the `msg_` ids, +`redacted_thinking` blocks and Anthropic SSE sequence are all wire format. + +A test resting on this route proves an adapter speaks Messages correctly. It proves nothing about +Anthropic's own service, and must say so rather than borrowing the vendor's name. + +## Reasoning effort + +The endpoint declares `[minimal, low, medium, high, xhigh, max]` in its own error text. The route +accepts four of them. + +| Level | `/v1/chat/completions` | `/v1/messages` | +| --- | --- | --- | +| `none`, `minimal` | 400 | 400 | +| `low`, `medium`, `high`, `xhigh` | 200 | 200 | +| `max` | 400 | 400 | + +Sending `reasoning_effort: "minimal"` to the OpenAI facade returns an error naming +`` unsupported `output_config.effort` value ``, so that facade rewrites the OpenAI field into the +Messages one. Both routes land on a single parameter, and changing wire format does not unlock a +level. + +## Hosted search + +Works, on the Messages route only. + +| Tool type | Result | +| --- | --- | +| `web_search_20250305` | accepted; `max_uses` is rejected as a field on this route | +| `web_search_20251022` | `tool type not supported` | +| `web_fetch_20250910` | `tool type not supported` | + +One tool covers both actions: `{"type":"search","query":…}` and `{"type":"open_page","url":…}`. +A single observed turn issued three searches and one page open, narrating between them. + +pi declares `max_uses: 8` for its `anthropic` and `cli-proxy-api-anthropic` routes, so that field's +availability is route-specific rather than absent from the tool. + +## Both dialects offer one search tool, and no fetch tool + +Responses on this gateway, model `gpt-5.6-luna`: + +| Tool type | Result | +| --- | --- | +| `web_search` | 200; output items `reasoning`, `web_search_call`, `message` | +| `web_search_preview` | 200; same shape, legacy alias | +| `web_fetch`, `fetch` | 400 `Unsupported tool type` | +| `code_interpreter`, `file_search` | 400 `Unsupported tool type` | + +**Fetching a page is an action inside the search tool, not a tool of its own,** and both dialects +agree on that. Responses returns `action: {"type":"search","queries":[…]}`; Codex models exactly +`Search`, `OpenPage`, `FindInPage` and a serde catch-all, so a new action needs no release. Messages +on `muse-spark-1.3` issued three `search` actions and one `open_page` in a single observed turn. + +So a harness modelling hosted search has one shape to carry, not one per vendor. Only the wrapper +differs: Responses reports the queries as a `web_search_call` output item, Messages as +`server_tool_use` blocks. Neither reports the findings. + +Those 400s are this gateway's vocabulary and do not establish what a vendor's own API offers. +Codex, which talks to OpenAI directly, likewise models no fetch tool — two independent signals +rather than one. + +Hosted search is the only server-side tool this gateway accepts on either dialect, which bounds +what a first implementation has to handle. + +## The results never arrive + +**The gateway forwards the queries and never the findings.** Verified on both transports. + +A streaming census of one complete search turn: 4 `text` blocks, 3 `server_tool_use` blocks, +**zero** `web_search_tool_result` blocks, and no `server_tool_use` counter in `usage`. + +So a transcript built from this route can show what was searched and what the model concluded, and +never the sources. Codex reaches the same place from a different vendor: its inline +`WebSearchCall` path sets `results: None` because the Responses call returns the query alone. The +hole looks like the shape of inline hosted search rather than a local defect, which is the reason +the spike should not design around recovering results. + +Unexamined: whether the gateway's strict cloaking strips them. +`upstream/cli-proxy-api/internal/runtime/executor/claude_executor_cloaking.go` handles +`server_tool_use` and is where to look. + +## Replay has no structural blocker + +Both arms return 200 and answer a follow-up correctly. + +| Assistant turn sent back | Result | +| --- | --- | +| verbatim, including `server_tool_use` blocks | 200, follow-up correct | +| with `server_tool_use` stripped | 200 | + +This route accepts a `server_tool_use` carrying no paired result block. A stricter endpoint would be +expected to reject that, so the leniency is a property of this gateway and not something a design +may rely on. + +## Config corrected while measuring + +The live dev config claimed `max` among muse's allowed efforts. It is rejected by the route, and the +claim was removed. luna's `none` and `max`, grok's `xhigh` and deepseek's `none` were each probed +and are honest. From 8d84a6e795f5348a10a6adcbbb3bb7a85b88fdec Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:04:19 -0400 Subject: [PATCH 02/21] docs(plan): slice provider-side tools, and settle which shape is being built MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spike found two shapes that are not variants of each other, and left the choice open. This closes it and orders the work. The harness forwards a declaration and renders what comes back. It does not run the search, does not infer the capability from a model's name, and does not silently absorb a block it cannot show. pi's isolated client tool is recorded as rejected with the reason that only appears against this codebase: a tool issuing its own model request needs to reach a provider route, which is a larger boundary change than the one it saves, and it spends a second model call per search that the owner never selected. The declaration follows `allowed_reasoning_efforts` rather than inventing a pattern: present or absent, nonempty and unique when present, encodable by the model's dialect, and failing at config load rather than at run time. PRV-6 already refuses authority in a name, so gating on `id.startsWith(…)` was never available here even though every harness read does it that way. Config names the capability and the adapter owns its dialect spelling, which is where the compatibility work belongs. Six slices in an order with a reason: a spelling cannot be tested with nothing to spell, a fixture is cheaper to trust when we made the request that produced it, a row has nothing to render until an event exists, and replay picks its arm knowing what the row had to show. The transcript slice carries three widths because the contract owns the row and string assertions do not prove it. Two questions stay open inside the stage rather than being guessed now: whether a provider-side row reuses the tool row with a marker naming the actor, which rendered frames decide, and where hosted-tool spend belongs, since the usage counter names requests rather than tokens. PRV-5 currently states that a provider-side block is refused. It was true when written and this stage rewrites it, which the plan records as deliberate so the corpus does not carry two readings. Spike sections that argued for the shape we did not take are rewritten to match, so the spike and the plan do not disagree in front of the next reader. --- .agents/phases/phase-04-product-polish.md | 9 ++- .../phase-04-stage-33-provider-side-tools.md | 76 +++++++++++++++++++ .agents/roadmap.md | 4 +- .agents/spikes/provider-side-tools/README.md | 44 ++++++----- 4 files changed, 112 insertions(+), 21 deletions(-) create mode 100644 .agents/plans/phase-04-stage-33-provider-side-tools.md diff --git a/.agents/phases/phase-04-product-polish.md b/.agents/phases/phase-04-product-polish.md index 7d7b2c0..9d6d0ea 100644 --- a/.agents/phases/phase-04-product-polish.md +++ b/.agents/phases/phase-04-product-polish.md @@ -2,7 +2,7 @@ | Field | Value | | --- | --- | -| Status | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stages 16 and 28 await the user's terminal test; stage 30 automatic-compaction visibility unstarted; stage 10 branding remains | +| Status | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stages 16 and 28 await the user's terminal test; stage 30 automatic-compaction visibility unstarted; stage 33 provider-side tools sliced; stage 10 branding remains | | Parent roadmap | [Plexmaton Roadmap](../roadmap.md) | | Product contract | [UI/UX](../ui-ux.md) | | Depends on | Phase 01 interaction ownership; Phase 02 journal/accounting/budget projections; Phase 03 durable collaboration, owned child scheduling, Handoff/Stop, passive recovery and canonical Attention | @@ -376,6 +376,13 @@ JRN-3/PRV-3 prove JSONL save, reopen and exact compatible replay only; an export and its replay disclosure policy remain unproven. MCP remains optional later integration. Rejected: treating JSONL round trips as independent export/import acceptance. +33. **Provider-side tools — sliced.** A model declares the hosted tools its route accepts, and the + harness forwards that declaration and renders what comes back rather than running the search + itself. The [stage plan](../plans/phase-04-stage-33-provider-side-tools.md) owns the order; the + [spike](../spikes/provider-side-tools/README.md) owns the evidence, including the measurement + that both dialects return the queries and neither returns the findings. PRV-5's refusal of a + provider-side block is rewritten by this stage rather than left as a second reading. + Other roadmap work in performance, math and extensibility receives a stage when its evidence is ready; this phase opening does not claim those capabilities have started. diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md new file mode 100644 index 0000000..18fc158 --- /dev/null +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -0,0 +1,76 @@ +# Plan — Phase 04 stage 33, provider-side tools + +| Field | Value | +| --- | --- | +| Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 33 | +| Contract | PRV-1/PRV-3/PRV-5/PRV-6, [tool-admission](../specs/tool-admission.md), [ui-ux](../ui-ux.md) transcript grammar | +| Evidence | [Provider-side tools spike](../spikes/provider-side-tools/README.md) | +| Status | Slicing; no slice implemented | + +## Outcome + +A model may declare the hosted tools its route accepts, and a Session using that model reaches +provider-hosted web search without the transcript losing sight of what was searched. The harness +forwards a declaration and renders what comes back. It never runs the search, never infers the +capability from a model's name, and never silently absorbs a block it cannot show. + +Reversal recorded deliberately: PRV-5 currently states that a provider-side block is refused. That +sentence was true when written and this stage rewrites it, along with the rejected alternative +beside it, rather than leaving a second reading in the corpus. + +## Slices + +1. **Declaration.** A model entry gains a hosted-tool subset, validated the way + `allowed_reasoning_efforts` already is: present or absent, nonempty and unique when present, and + encodable by that model's dialect. A declaration a dialect cannot spell fails at config load, + before any network work. PRV-6 gains the field; nothing infers it from a provider or model name. + +2. **Request encoding.** Each dialect spells the declared capability and owns that spelling alone. + Messages emits `{"type":"web_search_20250305","name":"web_search"}`; Responses emits + `{"type":"web_search"}`. Chat Completions and GenerateContent cannot encode it, which is what + makes slice 1's validation a real gate rather than a formality. No request carries a hosted tool + the model did not declare. + +3. **Decode.** The Messages decoder accepts `server_tool_use` as a fifth content block and the + Responses decoder accepts a `web_search_call` output item, both carrying the action the provider + took: a query, an opened page, or a find within one. One semantic event covers both, because the + spike found the two dialects differ only in wrapper. Usage stops failing a step when a provider + reports non-zero hosted-tool counts, and accounts them instead. + +4. **Transcript.** The event reaches the workspace as a row naming what was searched or opened. + Findings are not part of this: neither dialect returns them, so the row says what the provider + did and the model's own answer says what it concluded. Three widths reviewed before the slice + closes. + +5. **Replay.** The blocks round-trip when the conversation continues, under PRV-3's existing + sidecar rules. The local route accepts a turn with them present or stripped, so the arm this + picks is a decision and not a constraint, and the test says which. + +6. **Corpus.** PRV-5's refusal and its rejected alternative are rewritten to what the code now + does. PRV-6 gains the declaration. The evidence table gains a row per invariant. The spike is + promoted where it now holds a decision, and the parts that were only reconnaissance are dropped. + +## Order and why + +Declaration precedes encoding because a spelling with nothing to spell cannot be tested. Encoding +precedes decode because a fixture is cheaper to trust when the request that produced it is ours. +Decode precedes the transcript because a row has nothing to render until an event exists, and the +contract owns the row's shape rather than the adapter. Replay is last because it is the only slice +that needs two turns, and because the arm it picks is informed by what the row had to show. + +## Open, to settle inside the stage + +- Does a provider-side row reuse the existing tool row with a marker naming the provider as the + actor, or is it a row of its own? Rendered frames decide this, not prose, and the contract owns + the answer. +- Where does hosted-tool spend belong in the cost surface? It is provider-side work the owner did + not select a model for, and the usage counter names requests rather than tokens. + +## Deliberately not in this plan + +Running any search locally. A client tool that issues its own isolated model request, which is pi's +shape and would need tools to reach a provider route. Hosted tools other than search: the local +gateway refuses `web_fetch`, `code_interpreter` and `file_search` on both dialects, so a second tool +has no route to be tested against. Recovering search findings, which neither dialect returns. +Per-call approval, because the fence position already holds that configuring a provider authorises +the call this rides on. diff --git a/.agents/roadmap.md b/.agents/roadmap.md index 2d06b81..2b26e50 100644 --- a/.agents/roadmap.md +++ b/.agents/roadmap.md @@ -34,7 +34,7 @@ table in `AGENTS.md` allows. | 01 | One real agent in the workspace: a thin loop over one provider, and the transcript grammar against its output | Closed 2026-09-03: delivered one live OpenAI-compatible agent, five bounded native tools with explicit approval and cancellation, and the reviewed responsive transcript grammar | | 02 | Canonical session state, persistence, durable permission policy, provider transports, context projection and project instructions | Closed 2026-09-12 by scoped evidence: delivered JSONL sessions and recovery, four provider dialects, compaction, durable permissions, AGENTS.md and conversation tree/rewind; unproven recovery/readmission acceptance inherited by Phase 03, independent export/import by Phase 04; MCP optional | | 03 | Durable multi-agent mailbox and runtime ownership | Closed 2026-09-16: delivered ordered durable delegation/mail, owned child scheduling, Stop and Handoff control, passive, process-kill and in-app switch recovery, canonical Attention and the accepted responsive agent workspace | -| 04 | Product polish, performance hardening, math in production, and extensibility | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stage 16 effort selection awaits user testing; stage 28 model-switch degradation in progress; stage 30 automatic-compaction visibility unstarted; stage 10 branding remains | +| 04 | Product polish, performance hardening, math in production, and extensibility | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stage 16 effort selection awaits user testing; stage 28 model-switch degradation in progress; stage 30 automatic-compaction visibility unstarted; stage 33 provider-side tools sliced; stage 10 branding remains | ## Locked @@ -72,7 +72,7 @@ Tracks run beside phases because their exit conditions are comparisons, not deli | [Agent Skills spike](./spikes/agent-skills/README.md) | Compare skill discovery, project configuration and durable activation | Implemented and verified offline; live model behavior unverified | | [Permission policy spike](./spikes/permission-policy/README.md) | Compare scoped grants, rule precedence and durable authority | Source comparison retained; PER-1–PER-10 and PGR-1–PGR-5 implemented and verified locally | | [Compaction spike](./spikes/compaction/README.md) | Constrain compaction, branch-local context and recovery | Source/probe evidence retained; CPL-1–CPL-8 own implementation and regression evidence | -| [Provider-side tools spike](./spikes/provider-side-tools/README.md) | Compare how harnesses surface a tool the provider runs, and measure one live hosted-search route | Comparison and live measurement retained; the design question is open | +| [Provider-side tools spike](./spikes/provider-side-tools/README.md) | Compare how harnesses surface a tool the provider runs, and measure one live hosted-search route | Comparison and live measurement retained; the decision moved to [stage 33](./plans/phase-04-stage-33-provider-side-tools.md) | Gates without a track yet, each opened with a prototype, a comparison corpus, and a decision criterion: diff --git a/.agents/spikes/provider-side-tools/README.md b/.agents/spikes/provider-side-tools/README.md index d8f1107..cc3e4d4 100644 --- a/.agents/spikes/provider-side-tools/README.md +++ b/.agents/spikes/provider-side-tools/README.md @@ -2,7 +2,7 @@ | Field | Value | | --- | --- | -| Status | Comparison and live measurement retained as evidence; the design question is not yet decided | +| Status | Comparison and live measurement retained as evidence; the design is decided in [stage 33](../../plans/phase-04-stage-33-provider-side-tools.md) | | Read when | Adding web search, or any tool a provider runs on its own side rather than handing to this harness | | Question | Can a Session reach provider-hosted search without the harness losing sight of what was searched? | | Contract | [provider-adapter](../../specs/provider-adapter.md) PRV-1/PRV-3/PRV-5; [tool-admission](../../specs/tool-admission.md); [permission-policy](../../specs/permission-policy.md) PER-2 | @@ -17,7 +17,7 @@ subprocess, but something the provider performs inside a call the owner already ## Corpus Local source read 2026-09-20; no reference suite was run. Plexmaton base `c7bdf0a`, branch -`spike/provider-side-tools`. +`feat/provider-side-tools`. | Directory | Revision | Source root for references below | | --- | --- | --- | @@ -46,10 +46,17 @@ continuation history, which is what keeps that request isolated. `getHostedWebSe whole thing on the active model's provider and API, and `syncWebSearchAvailability` adds or removes the tool from the active set as the model changes. -The consequence is the part worth keeping: **the main conversation never contains a +Its consequence is real and worth recording: **the main conversation never contains a `server_tool_use` block.** No new content-block type, no new semantic event, no replay hole, and the -search takes the ordinary admission, permission and transcript path every other tool takes. PRV-5's -refusal can stand untouched. +search takes the ordinary admission, permission and transcript path every other tool takes. + +Rejected all the same, for two reasons that only appear once it is drawn against this codebase. A +tool that issues its own model request needs to reach a provider route, and native tools here reach +a workspace, ripgrep and a command — giving them a route is a larger boundary change than the one it +saves. And it spends a second model call for every search, chosen by the harness rather than by the +owner, which is exactly the accounting question the owner has no surface to answer. The owner's +instruction settles the rest: the declaration goes to the server side, and the harness renders what +returns. `grok-build` sits nearer Codex, handling search inside the pager's scrollback block types. `claude-code`'s only server-tool surface in readable source is the usage counter shape @@ -83,32 +90,33 @@ The failure mode of a declaration is known and cheap: it can be wrong. The live `max` among muse's efforts and the route refused it. The fix was to correct the declaration, never to add an inference that would have guessed around it. -Rejected: pi's gating, which is the right architecture reached through the wrong door. Its +Rejected: gating on the model's identity, which is how every harness read here decides it. pi's `getHostedWebSearchRoute` matches provider names and `id.startsWith("claude-")`, so every new route and every renamed model is a code change, and a model the owner knows supports search cannot be -told so. Take the isolated client tool; leave the name matching. +told so. A declaration costs one line in a file the owner already edits and answers both. ## What this costs in Plexmaton -`NativeToolCatalog::open` builds a fixed catalog: `FileTools::definitions()` plus the command tool. -There is no extension registration, so pi's shape arrives here as a native tool beside the others -rather than as a plugin. That is a smaller change than it sounds, and it lands entirely outside the -provider adapter. +The chosen shape is Codex's, narrowed by the declaration. Cost lands in the provider adapter and the +transcript, and Codex's own use sites measure the blast radius honestly: a response variant is +matched wherever the enum is already matched, so compaction, persisted state, timing and replay each +have to say what they do with it. Plexmaton's equivalent is one new semantic event, and the sites +that match its event vocabulary are the ones to count before writing any of it. + +What the declaration buys is that none of this reaches a model that did not ask for it. A route +with no hosted tools encodes nothing new and decodes nothing new, so the existing four dialects keep +their current behaviour exactly. -One wrinkle has no answer yet. pi recomputes tool availability when the model changes; Plexmaton's -catalog is built when the workspace opens, while `/model` changes the model mid-Session. A tool that -only works on some routes therefore needs either a catalog that can narrow after open, or a tool -that refuses with a typed error naming the route it needed. +[Stage 33](../../plans/phase-04-stage-33-provider-side-tools.md) owns the order. ## Open -- Does the isolated-request shape survive delegation, cancellation and compaction, or does an - inner model call inside a tool break an invariant those own? -- Where does the inner call's token usage belong? It is spend the owner never selected a model for. - Is strict cloaking what strips search results on the local gateway, or does the upstream never send them? `claude_executor_cloaking.go` handles `server_tool_use` and is the place to look. - Codex's standalone path populates `results`. Worth reading before assuming the findings are always unavailable. +- Where does hosted-tool spend belong? The usage counter names requests rather than tokens, and + no surface shows provider-side work today. ## Limits From dcc4afe1a5f7990bca28e641900bf7f5a822dac0 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 08:43:35 -0400 Subject: [PATCH 03/21] docs(spike): measure that Chat Completions swallows a hosted-tool declaration and discards thinking Muse over /v1/chat/completions, streaming, 4096 tokens, one prompt asking for the latest stable Rust release. Without tools it answers 1.98.0 from memory in 1733 tokens. With the Anthropic-shaped hosted tool it answers 1.98.0 in 2684; with the OpenAI-shaped one, 1.98.0 in 2163. Every run returns 200, no tool_calls delta and no field beyond role and content. The same prompt over /v1/messages with the hosted tool searched four times and answered 1.98.1, a release published after the model's knowledge. So a hosted-tool declaration on the Chat Completions route is swallowed with no error, which is the evidence behind slice 1: the declaration has to fail at config load, because the wire will not say. The same route discards reasoning entirely. A one-word answer at high effort costs 257 completion tokens and arrives as role and content only, in both streaming and non-streaming form. Over Messages the same model returns redacted_thinking blocks and a thinking_tokens count, so Muse's reasoning is reachable through Messages as opaque replay and through Chat Completions not at all. --- .../provider-side-tools/gateway-evidence.md | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.agents/spikes/provider-side-tools/gateway-evidence.md b/.agents/spikes/provider-side-tools/gateway-evidence.md index 39035cd..0b2d2c9 100644 --- a/.agents/spikes/provider-side-tools/gateway-evidence.md +++ b/.agents/spikes/provider-side-tools/gateway-evidence.md @@ -84,6 +84,32 @@ rather than one. Hosted search is the only server-side tool this gateway accepts on either dialect, which bounds what a first implementation has to handle. +## Chat Completions carries neither the tool nor the thinking + +Measured 2026-09-21 on `muse-spark-1.3` through `/v1/chat/completions`, streaming, `max_tokens` +4096, one prompt asking for the latest stable Rust release. + +| Request | Completion tokens | Answer | +| --- | --- | --- | +| no tools | 1733 | 1.98.0 | +| `{"type":"web_search_20250305","name":"web_search"}` | 2684 | 1.98.0 | +| `{"type":"web_search"}` | 2163 | 1.98.0 | +| `web_search_options: {}` | exhausted an 800 budget | empty | +| same prompt over `/v1/messages` with the hosted tool | 950 | **1.98.1** | + +1.98.1 was published after the model's knowledge, so only the Messages run searched. Over Chat +Completions every spelling returned HTTP 200, produced no `tool_calls` delta and no field beyond +`role` and `content`, and the model answered from memory after thinking longer. **A hosted-tool +declaration on this route is swallowed silently.** The wire will not report it, which is the reason +the declaration has to fail at config load rather than at run time. + +Reasoning is discarded on the same route. A one-word answer at `reasoning_effort: high` cost 257 +completion tokens, and the response carried `role` and `content` only, in both streaming and +non-streaming form: no `reasoning`, no `reasoning_content`, no `reasoning_details`, no reasoning +count in `usage`. Over Messages the same model returns `redacted_thinking` blocks and a +`thinking_tokens` count. So on this gateway Muse's reasoning is reachable through Messages as +opaque replay, and through Chat Completions not at all. + ## The results never arrive **The gateway forwards the queries and never the findings.** Verified on both transports. From 805a246a6c15eca0420daf3026b934ab3fd80d48 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:00:37 -0400 Subject: [PATCH 04/21] docs(plan): every dialect spells hosted search; the gate is the tool name Slice 2 claimed Chat Completions and GenerateContent cannot encode a hosted tool. Both can: OpenAI's own search models take web_search_options, and Gemini has a google_search tool. So slice 1's gate is not the dialect but the name, and a declared tool a dialect has no spelling for is what fails at config load. What the local gateway does with the Chat spelling is a separate fact, now recorded with its mechanism. At upstream snapshot v7.3.9 the request translator passes only function tools toward Claude and skips every other type without an error, and nothing reads web_search_options. The response translator has branches for tool_use and thinking and none for server_tool_use or redacted_thinking, so both fall through. The second hole has no OpenAI-standard shape to fill it with, because url_citation needs URLs the route never returns. A spelling the route ignores is the owner's declaration being wrong, and on this facade the wire will not say so. The plan now says a declaration is verified once rather than trusted. --- .agents/plans/phase-04-stage-33-provider-side-tools.md | 9 ++++++--- .agents/spikes/provider-side-tools/gateway-evidence.md | 10 ++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md index 18fc158..b8e06ef 100644 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -27,9 +27,12 @@ beside it, rather than leaving a second reading in the corpus. 2. **Request encoding.** Each dialect spells the declared capability and owns that spelling alone. Messages emits `{"type":"web_search_20250305","name":"web_search"}`; Responses emits - `{"type":"web_search"}`. Chat Completions and GenerateContent cannot encode it, which is what - makes slice 1's validation a real gate rather than a formality. No request carries a hosted tool - the model did not declare. + `{"type":"web_search"}`; Chat Completions emits `web_search_options`, the field OpenAI's own + search models take; GenerateContent's `google_search` tool is spelled once its facade has been + measured. Slice 1's gate is the tool name, not the dialect: a declared tool a dialect has no + spelling for fails at config load. A spelling the route then ignores is the owner's declaration + being wrong, and on Chat Completions the wire does not say so, which is why a declaration is + verified once rather than trusted. No request carries a hosted tool the model did not declare. 3. **Decode.** The Messages decoder accepts `server_tool_use` as a fifth content block and the Responses decoder accepts a `web_search_call` output item, both carrying the action the provider diff --git a/.agents/spikes/provider-side-tools/gateway-evidence.md b/.agents/spikes/provider-side-tools/gateway-evidence.md index 0b2d2c9..dda72bc 100644 --- a/.agents/spikes/provider-side-tools/gateway-evidence.md +++ b/.agents/spikes/provider-side-tools/gateway-evidence.md @@ -110,6 +110,16 @@ count in `usage`. Over Messages the same model returns `redacted_thinking` block `thinking_tokens` count. So on this gateway Muse's reasoning is reachable through Messages as opaque replay, and through Chat Completions not at all. +The mechanism is in the gateway's translator, read at upstream snapshot v7.3.9. Toward Claude, +`claude_openai_request.go:349` translates only `tools[].type == "function"` and skips every other +type without an error; nothing in the gateway reads `web_search_options`. Back toward OpenAI, the +block switch in `claude_openai_response.go:146` and `:378` has branches for `tool_use` and +`thinking` and none for `server_tool_use` or `redacted_thinking`, so both fall through and are +dropped. Reasoning reaches `reasoning_content` only from `thinking_delta`, and Muse sends +`redacted_thinking`, which has `data` and no delta. So the Chat facade's two holes are one missing +request branch and one missing response branch, and the second has no OpenAI-standard shape to +fill it with: `annotations[].url_citation` needs URLs, and the route never returns any. + ## The results never arrive **The gateway forwards the queries and never the findings.** Verified on both transports. From a6da09ede7cb7c9a06a4686cb4a045f7b8bbeb3c Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:17:23 -0400 Subject: [PATCH 05/21] docs(spike): findings are a surface property, and the gateway already has a native Meta route Two earlier readings were wrong and are rewritten in place. First, hosted search does not return queries and never findings. That was the Messages surface as observed through the gateway, and OpenAI's inline path as Codex models it. Meta's own Responses documentation returns url_citation annotations with url, title and span on the answer text, and on request the results themselves with title, url and snippet. The plan's transcript slice and its exclusion list no longer claim that no dialect returns findings; the row shows what its route returned. Second, the Chat Completions holes are not the gateway translator's alone. Meta's Chat Completions has no search grounding and does not carry reasoning across turns, in its own words. The translation layer removes what the protocol would carry, and the protocol carries neither of the two things this stage needs. So moving Muse to an OpenAI-compatible upstream to keep Chat Completions native would change nothing that matters. cli-proxy-api declares meta-api-key as a provider kind. MetaKey aliases CodexKey, its executor speaks Responses to api.meta.ai/v1, and it forwards tools, include and store untouched while deleting five named fields and stripping orphan reasoning ids when store is false. The stack routes Muse through claude-api-key today, so every surface but Messages is a translation. Plexmaton's Responses encoder already sends store false and asks for encrypted reasoning, and its decoder already retains annotations as replay metadata. A web_search_call item is a typed refusal on both added and done, which is the PRV-5 refusal this stage rewrites. Every Meta Responses claim is marked documented rather than observed: a direct probe was refused by the permission classifier as credential extraction, and the gateway has no native Meta route configured to probe through. --- .../phase-04-stage-33-provider-side-tools.md | 9 +-- .../provider-side-tools/gateway-evidence.md | 23 +++---- .../spikes/provider-side-tools/meta-route.md | 60 +++++++++++++++++++ 3 files changed, 74 insertions(+), 18 deletions(-) create mode 100644 .agents/spikes/provider-side-tools/meta-route.md diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md index b8e06ef..d881850 100644 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -41,9 +41,10 @@ beside it, rather than leaving a second reading in the corpus. reports non-zero hosted-tool counts, and accounts them instead. 4. **Transcript.** The event reaches the workspace as a row naming what was searched or opened. - Findings are not part of this: neither dialect returns them, so the row says what the provider - did and the model's own answer says what it concluded. Three widths reviewed before the slice - closes. + What else the row can show is the route's to give: Messages as observed returns the queries + alone, and Meta's Responses surface documents `url_citation` annotations and, when asked, + the results. The row shows what its route returned and claims nothing further. Three widths + reviewed before the slice closes. 5. **Replay.** The blocks round-trip when the conversation continues, under PRV-3's existing sidecar rules. The local route accepts a turn with them present or stripped, so the arm this @@ -74,6 +75,6 @@ that needs two turns, and because the arm it picks is informed by what the row h Running any search locally. A client tool that issues its own isolated model request, which is pi's shape and would need tools to reach a provider route. Hosted tools other than search: the local gateway refuses `web_fetch`, `code_interpreter` and `file_search` on both dialects, so a second tool -has no route to be tested against. Recovering search findings, which neither dialect returns. +has no route to be tested against. Recovering findings a route does not return; the Messages route as observed returns none. Per-call approval, because the fence position already holds that configuring a provider authorises the call this rides on. diff --git a/.agents/spikes/provider-side-tools/gateway-evidence.md b/.agents/spikes/provider-side-tools/gateway-evidence.md index dda72bc..e16bf52 100644 --- a/.agents/spikes/provider-side-tools/gateway-evidence.md +++ b/.agents/spikes/provider-side-tools/gateway-evidence.md @@ -120,22 +120,17 @@ dropped. Reasoning reaches `reasoning_content` only from `thinking_delta`, and M request branch and one missing response branch, and the second has no OpenAI-standard shape to fill it with: `annotations[].url_citation` needs URLs, and the route never returns any. -## The results never arrive +## Findings are a property of the surface -**The gateway forwards the queries and never the findings.** Verified on both transports. +**On the Messages route the gateway forwards the queries and never the findings.** Verified on both +transports: a streaming census of one complete search turn showed 4 `text` blocks, 3 +`server_tool_use` blocks, zero `web_search_tool_result` blocks, and no `server_tool_use` counter in +`usage`. Whether strict cloaking strips them or Meta's Messages surface never sends them is +unexamined; `claude_executor_cloaking.go` handles `server_tool_use` and is where to look. -A streaming census of one complete search turn: 4 `text` blocks, 3 `server_tool_use` blocks, -**zero** `web_search_tool_result` blocks, and no `server_tool_use` counter in `usage`. - -So a transcript built from this route can show what was searched and what the model concluded, and -never the sources. Codex reaches the same place from a different vendor: its inline -`WebSearchCall` path sets `results: None` because the Responses call returns the query alone. The -hole looks like the shape of inline hosted search rather than a local defect, which is the reason -the spike should not design around recovering results. - -Unexamined: whether the gateway's strict cloaking strips them. -`upstream/cli-proxy-api/internal/runtime/executor/claude_executor_cloaking.go` handles -`server_tool_use` and is where to look. +Meta's Responses surface documents citations and, on request, the results themselves. That route is +not configured on this host yet. [Meta route evidence](./meta-route.md) carries it, marked +documented rather than observed. ## Replay has no structural blocker diff --git a/.agents/spikes/provider-side-tools/meta-route.md b/.agents/spikes/provider-side-tools/meta-route.md new file mode 100644 index 0000000..561d5a9 --- /dev/null +++ b/.agents/spikes/provider-side-tools/meta-route.md @@ -0,0 +1,60 @@ +# Meta route evidence + +Two kinds of fact here, kept apart. **Observed** rows were measured on this host. **Documented** +rows come from Meta's own protocol pages, read 2026-09-21, and have not yet been observed here: a +direct probe of `api.meta.ai` was refused by the session's permission classifier as credential +extraction, and the gateway has no native Meta route configured to probe through. + +## Meta's three surfaces, from its documentation + +Base URL `https://api.meta.ai/v1`. Both `muse-spark-1.3` and `muse-spark-1.3-contributor` are +served on all three. + +| Surface | Hosted search | Reasoning across turns | +| --- | --- | --- | +| Chat Completions `/chat/completions` | **none**: "Search grounding is not available on Chat Completions" | **none**: "Chat Completions does not carry reasoning across turns"; the response carries `message.content` only | +| Responses `/responses` | `tools: [{"type":"web_search"}]` | `include: ["reasoning.encrypted_content"]`, replayed as `reasoning` output items; `store: false` supported | +| Messages `/messages` | observed working through the gateway with `web_search_20250305` | `redacted_thinking` blocks, observed | + +So the Chat Completions facade's two holes measured in [gateway evidence](./gateway-evidence.md) +are not the gateway's translator alone: Meta's own Chat Completions has neither. The translation +layer removes what the protocol would carry, and the protocol carries neither of the two things +this stage needs. + +## What hosted search returns on Responses, documented + +A `web_search_call` output item with `id`, `type`, `status`, and the answer in a `message` whose +`output_text` carries `annotations` of type `url_citation` with `url`, `title`, `start_index`, +`end_index`. Raw results are off by default and requested with +`include: ["web_search_call.results"]`, after which the item carries `results[]` of +`{type: "text_result", title, url, snippet}`. `search_context_size` takes `low`, `medium`, `high`. +Adding the tool does not force a search. On replay a `web_search_call` may be sent with `id` null. + +This reverses the earlier reading that hosted search returns queries and never findings. That was +true of the Messages surface as observed here, and of OpenAI's inline path as Codex models it. It +is not true of Meta's Responses surface, which is the one route on this host that would return +sources a transcript could show. + +## The gateway already has a native Meta route + +`cli-proxy-api` declares `meta-api-key` as a provider kind. `MetaKey` is a type alias of `CodexKey`, +so it takes `api-key`, optional `base-url` (default `https://api.meta.ai/v1`), `models[]` with +`name`/`alias`, and `excluded-models`. Its executor speaks Responses to the upstream, translating +inbound Chat Completions, Messages or Responses into it and back. On the way out it deletes only +`generate`, `prompt_cache_retention`, `safety_identifier`, `stream_options` and `client_metadata`, +and strips reasoning items whose ids the upstream would not know when `store` is false. `tools`, +`include` and `store` pass through untouched. + +The stack routes Muse through `claude-api-key` instead, so today every surface except Messages is a +translation. A `meta-api-key` fragment with the same alias would make Responses the native surface +and Messages the translated one, with nothing downstream changing until a client picks a different +`api`. + +## Plexmaton's Responses adapter, as it stands + +The encoder already sends `store: false` and `include: ["reasoning.encrypted_content"]`, so +encrypted reasoning replay on Meta needs no change. The decoder retains any `annotations` array on +a text part as replay metadata, so `url_citation` survives a round trip already and is simply not +rendered. A `web_search_call` output item is a typed `UnsupportedEvent` on both `output_item.added` +and `output_item.done`, so a Responses turn that searched fails the step today, which is the +refusal PRV-5 records. From 369ad1ca48f95fe211a8674e93822cffbb123e44 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:08:50 -0400 Subject: [PATCH 06/21] docs(spike): hosted search reaches Muse over Responses today, so the route is decided Through the gateway's existing route, still translated to Messages and back, a Responses request with tools web_search returns web_search_call items carrying search actions, reasoning items carrying encrypted_content, and an answer only a search could give. No gateway change is needed for a Session on openai_responses to reach hosted search on this model. The empty control run was a budget exhausted while thinking, returned as status incomplete with reason max_output_tokens, which is the typed state PRV-5 already maps. Not a route defect. The translated route loses three things, each observed: annotations are empty and no results arrive with or without the include; open_page actions arrive as search with an empty query, so a decoder accepts an empty query; and reasoning_tokens reads zero. The gateway's native meta-api-key route would restore all three and is the owner's to switch to later. It changes nothing about which items a decoder has to accept. The plan now decides Responses and defers Messages: server_tool_use keeps its PRV-5 refusal until a Messages route is in daily use, and the one semantic event is shaped so a Messages decoder can emit it later without a second one. --- .../phase-04-stage-33-provider-side-tools.md | 20 +++++++---- .agents/spikes/provider-side-tools/README.md | 2 +- .../provider-side-tools/gateway-evidence.md | 6 ++-- .../spikes/provider-side-tools/meta-route.md | 34 +++++++++++++++++-- 4 files changed, 48 insertions(+), 14 deletions(-) diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md index d881850..30305c4 100644 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -5,7 +5,7 @@ | Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 33 | | Contract | PRV-1/PRV-3/PRV-5/PRV-6, [tool-admission](../specs/tool-admission.md), [ui-ux](../ui-ux.md) transcript grammar | | Evidence | [Provider-side tools spike](../spikes/provider-side-tools/README.md) | -| Status | Slicing; no slice implemented | +| Status | Sliced; route decided as Responses; no slice implemented | ## Outcome @@ -34,11 +34,14 @@ beside it, rather than leaving a second reading in the corpus. being wrong, and on Chat Completions the wire does not say so, which is why a declaration is verified once rather than trusted. No request carries a hosted tool the model did not declare. -3. **Decode.** The Messages decoder accepts `server_tool_use` as a fifth content block and the - Responses decoder accepts a `web_search_call` output item, both carrying the action the provider - took: a query, an opened page, or a find within one. One semantic event covers both, because the - spike found the two dialects differ only in wrapper. Usage stops failing a step when a provider - reports non-zero hosted-tool counts, and accounts them instead. +3. **Decode.** The Responses decoder accepts a `web_search_call` output item on both `added` and + `done`, carrying the action the provider took: a query, an opened page, or a find within one, + and an empty query where the route flattened the action. One semantic event, shaped so a + Messages decoder can emit the same event later without a second one, because the spike found + the two dialects differ only in wrapper. The Messages `server_tool_use` block keeps PRV-5's + refusal in this stage: the owner's local model reaches hosted search over Responses, and no + Messages route is in daily use to prove a decoder against. Usage stops failing a step when a + provider reports non-zero hosted-tool counts, and accounts them instead. 4. **Transcript.** The event reaches the workspace as a row naming what was searched or opened. What else the row can show is the route's to give: Messages as observed returns the queries @@ -77,4 +80,7 @@ shape and would need tools to reach a provider route. Hosted tools other than se gateway refuses `web_fetch`, `code_interpreter` and `file_search` on both dialects, so a second tool has no route to be tested against. Recovering findings a route does not return; the Messages route as observed returns none. Per-call approval, because the fence position already holds that configuring a provider authorises -the call this rides on. +the call this rides on. Decoding `server_tool_use` on Messages, which keeps its refusal until a +Messages route is in daily use. Switching the gateway to its native `meta-api-key` route, which is +the owner's and upgrades citations, results and action fidelity without changing what this stage +decodes. diff --git a/.agents/spikes/provider-side-tools/README.md b/.agents/spikes/provider-side-tools/README.md index cc3e4d4..2dcd315 100644 --- a/.agents/spikes/provider-side-tools/README.md +++ b/.agents/spikes/provider-side-tools/README.md @@ -75,7 +75,7 @@ fails before any network work. A hosted-tool declaration is the same shape: ```toml [providers.local.models.muse] -api = "anthropic_messages" +api = "openai_responses" id = "muse-spark-1.3" server_tools = ["web_search"] ``` diff --git a/.agents/spikes/provider-side-tools/gateway-evidence.md b/.agents/spikes/provider-side-tools/gateway-evidence.md index e16bf52..f0a52a7 100644 --- a/.agents/spikes/provider-side-tools/gateway-evidence.md +++ b/.agents/spikes/provider-side-tools/gateway-evidence.md @@ -128,9 +128,9 @@ transports: a streaming census of one complete search turn showed 4 `text` block `usage`. Whether strict cloaking strips them or Meta's Messages surface never sends them is unexamined; `claude_executor_cloaking.go` handles `server_tool_use` and is where to look. -Meta's Responses surface documents citations and, on request, the results themselves. That route is -not configured on this host yet. [Meta route evidence](./meta-route.md) carries it, marked -documented rather than observed. +Meta's Responses surface documents citations and, on request, the results themselves. Through the +gateway's existing translation neither arrives, while the queries and encrypted reasoning do. +[Meta route evidence](./meta-route.md) carries both the observed and the documented halves. ## Replay has no structural blocker diff --git a/.agents/spikes/provider-side-tools/meta-route.md b/.agents/spikes/provider-side-tools/meta-route.md index 561d5a9..88b69ff 100644 --- a/.agents/spikes/provider-side-tools/meta-route.md +++ b/.agents/spikes/provider-side-tools/meta-route.md @@ -13,7 +13,7 @@ served on all three. | Surface | Hosted search | Reasoning across turns | | --- | --- | --- | | Chat Completions `/chat/completions` | **none**: "Search grounding is not available on Chat Completions" | **none**: "Chat Completions does not carry reasoning across turns"; the response carries `message.content` only | -| Responses `/responses` | `tools: [{"type":"web_search"}]` | `include: ["reasoning.encrypted_content"]`, replayed as `reasoning` output items; `store: false` supported | +| Responses `/responses` | `tools: [{"type":"web_search"}]`, **observed** through the gateway's existing route | `include: ["reasoning.encrypted_content"]`, replayed as `reasoning` output items, `store: false`; encrypted content **observed** arriving through the gateway | | Messages `/messages` | observed working through the gateway with `web_search_20250305` | `redacted_thinking` blocks, observed | So the Chat Completions facade's two holes measured in [gateway evidence](./gateway-evidence.md) @@ -35,6 +35,33 @@ true of the Messages surface as observed here, and of OpenAI's inline path as Co is not true of Meta's Responses surface, which is the one route on this host that would return sources a transcript could show. +## Responses through the existing route, observed + +Measured 2026-09-21 through `127.0.0.1:5233/v1/responses` with the gateway still routing Muse via +`claude-api-key`, so every request below was translated to Messages and back. `store: false`, +`include: ["reasoning.encrypted_content"]`, `reasoning.effort: high`, `max_output_tokens: 2048`. + +| Request | Output items | Answer | +| --- | --- | --- | +| `tools: [{"type":"web_search"}]` | 5 `reasoning` with `encrypted_content`, 5 `web_search_call`, 6 `message` | **1.98.1** | +| same, plus `include: web_search_call.results` | 2 `reasoning`, 1 `web_search_call`, 2 `message` | **1.98.1** | +| no tools, 2048 budget | none; `status: incomplete`, `incomplete_details.reason: max_output_tokens` | none | +| no tools, 8192 budget | `reasoning`, `message` | 1.98.0 from memory | +| `Say: ok`, 2048 budget | `reasoning`, `message`; 16 output tokens | ok | + +So a Plexmaton Session on `openai_responses` reaches hosted search on Muse today, with no gateway +change: the tool declaration crosses the translator, `web_search_call` items come back with +`action: {"type":"search","query":…}`, and encrypted reasoning is present for replay. The empty +control run is a budget exhausted while thinking, reported as the typed incomplete state PRV-5 +already maps, not a route defect. + +What the translated route loses, each observed: `annotations` is `[]` on every text part and no +`web_search_call` carries `results`, with or without the include; two of five calls arrived as +`search` with an empty `query`, where the Messages surface had shown `open_page` actions, so the +translator flattens action types; and `output_tokens_details.reasoning_tokens` is `0` while the +Messages surface reports `thinking_tokens`. A decoder must accept an empty query rather than fail +on it. + ## The gateway already has a native Meta route `cli-proxy-api` declares `meta-api-key` as a provider kind. `MetaKey` is a type alias of `CodexKey`, @@ -47,8 +74,9 @@ and strips reasoning items whose ids the upstream would not know when `store` is The stack routes Muse through `claude-api-key` instead, so today every surface except Messages is a translation. A `meta-api-key` fragment with the same alias would make Responses the native surface -and Messages the translated one, with nothing downstream changing until a client picks a different -`api`. +and Messages the translated one, with nothing downstream changing. That switch is the owner's to +make and is not required by this stage: it upgrades citations, results, exact action types and +reasoning counts, and changes nothing about which items a decoder has to accept. ## Plexmaton's Responses adapter, as it stands From ad48a1918334e0ef2a8098665c2f0b422c16c0af Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:20:16 -0400 Subject: [PATCH 07/21] docs(plan): keep the owner's gateway out of the stage plan Five sentences described the local gateway rather than what the stage builds: that Chat Completions swallows a declaration without saying so, that a route flattened an action into an empty query, that the owner's model reaches search over Responses, that this gateway refuses three other hosted tools, and that a native meta-api-key route would restore citations. Each is true of one host and none is a property of the code. They are now the general rule they stood for: a declaration is the owner's claim about their route, an action without a query is carried rather than refused, the Messages decoder waits for a route in daily use, no measured route offered a second hosted tool, and the row shows what its route returned. The spike keeps the measurements, which is what a spike is for. The phase entry claimed both dialects return queries and neither returns findings. That was one route's behaviour and is no longer believed of the protocol; the entry now says the evidence was measured against one live route. --- .agents/phases/phase-04-product-polish.md | 4 +- .../phase-04-stage-33-provider-side-tools.md | 40 +++++++++---------- 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/.agents/phases/phase-04-product-polish.md b/.agents/phases/phase-04-product-polish.md index 9d6d0ea..4fda3a2 100644 --- a/.agents/phases/phase-04-product-polish.md +++ b/.agents/phases/phase-04-product-polish.md @@ -379,8 +379,8 @@ Rejected: treating JSONL round trips as independent export/import acceptance. 33. **Provider-side tools — sliced.** A model declares the hosted tools its route accepts, and the harness forwards that declaration and renders what comes back rather than running the search itself. The [stage plan](../plans/phase-04-stage-33-provider-side-tools.md) owns the order; the - [spike](../spikes/provider-side-tools/README.md) owns the evidence, including the measurement - that both dialects return the queries and neither returns the findings. PRV-5's refusal of a + [spike](../spikes/provider-side-tools/README.md) owns the evidence, measured against one live + route. PRV-5's refusal of a provider-side block is rewritten by this stage rather than left as a second reading. Other roadmap work in performance, math and extensibility receives a stage when its evidence is diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md index 30305c4..53d9863 100644 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -30,24 +30,24 @@ beside it, rather than leaving a second reading in the corpus. `{"type":"web_search"}`; Chat Completions emits `web_search_options`, the field OpenAI's own search models take; GenerateContent's `google_search` tool is spelled once its facade has been measured. Slice 1's gate is the tool name, not the dialect: a declared tool a dialect has no - spelling for fails at config load. A spelling the route then ignores is the owner's declaration - being wrong, and on Chat Completions the wire does not say so, which is why a declaration is - verified once rather than trusted. No request carries a hosted tool the model did not declare. + spelling for fails at config load. A route may accept a spelling and ignore it, so a declaration + is the owner's claim about their route, verified once rather than trusted. No request carries a + hosted tool the model did not declare. 3. **Decode.** The Responses decoder accepts a `web_search_call` output item on both `added` and - `done`, carrying the action the provider took: a query, an opened page, or a find within one, - and an empty query where the route flattened the action. One semantic event, shaped so a - Messages decoder can emit the same event later without a second one, because the spike found - the two dialects differ only in wrapper. The Messages `server_tool_use` block keeps PRV-5's - refusal in this stage: the owner's local model reaches hosted search over Responses, and no - Messages route is in daily use to prove a decoder against. Usage stops failing a step when a - provider reports non-zero hosted-tool counts, and accounts them instead. + `done`, carrying the action the provider took: a query, an opened page, or a find within one. + An action that arrives without a query is carried as it arrived, not refused. One semantic + event, shaped so a Messages decoder can emit the same event later without a second one, because + the spike found the two dialects differ only in wrapper. The Messages `server_tool_use` block + keeps PRV-5's refusal in this stage, because no Messages route is in daily use to prove a decoder + against. Usage stops failing a step when a provider reports non-zero hosted-tool counts, and + accounts them instead. 4. **Transcript.** The event reaches the workspace as a row naming what was searched or opened. - What else the row can show is the route's to give: Messages as observed returns the queries - alone, and Meta's Responses surface documents `url_citation` annotations and, when asked, - the results. The row shows what its route returned and claims nothing further. Three widths - reviewed before the slice closes. + What else the row can show is the route's to give: some return the queries alone, others add + citations or the results. The row shows what its route returned and claims nothing further; + the [spike](../spikes/provider-side-tools/meta-route.md) records what each measured route + gives. Three widths reviewed before the slice closes. 5. **Replay.** The blocks round-trip when the conversation continues, under PRV-3's existing sidecar rules. The local route accepts a turn with them present or stripped, so the arm this @@ -76,11 +76,9 @@ that needs two turns, and because the arm it picks is informed by what the row h ## Deliberately not in this plan Running any search locally. A client tool that issues its own isolated model request, which is pi's -shape and would need tools to reach a provider route. Hosted tools other than search: the local -gateway refuses `web_fetch`, `code_interpreter` and `file_search` on both dialects, so a second tool -has no route to be tested against. Recovering findings a route does not return; the Messages route as observed returns none. -Per-call approval, because the fence position already holds that configuring a provider authorises -the call this rides on. Decoding `server_tool_use` on Messages, which keeps its refusal until a -Messages route is in daily use. Switching the gateway to its native `meta-api-key` route, which is -the owner's and upgrades citations, results and action fidelity without changing what this stage +shape and would need tools to reach a provider route. Hosted tools other than search, which no +measured route offered. Recovering findings a route does not return. Per-call approval, because the +fence position already holds that configuring a provider authorises the call this rides on. +Decoding `server_tool_use` on Messages, which keeps its refusal until a Messages route is in daily +use. The owner's gateway configuration, which changes nothing about what this stage encodes or decodes. From bccffb5c9b4a044da43408a57a78fc95da6d79aa Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:23:33 -0400 Subject: [PATCH 08/21] docs(plan): replay describes the item, not the local route The replay slice leaned on what one gateway accepts. It now states the rule the stage builds to: a hosted-tool item round-trips under PRV-3's sidecar rules, its provider id need not be preserved, and sending it back or stripping it is a decision the test names. --- .agents/plans/phase-04-stage-33-provider-side-tools.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md index 53d9863..24a5483 100644 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -49,9 +49,10 @@ beside it, rather than leaving a second reading in the corpus. the [spike](../spikes/provider-side-tools/meta-route.md) records what each measured route gives. Three widths reviewed before the slice closes. -5. **Replay.** The blocks round-trip when the conversation continues, under PRV-3's existing - sidecar rules. The local route accepts a turn with them present or stripped, so the arm this - picks is a decision and not a constraint, and the test says which. +5. **Replay.** A hosted-tool item round-trips when the conversation continues, under PRV-3's + existing sidecar rules, and its provider-assigned id is nothing the harness has to preserve. + Whether the item is sent back or stripped is a decision rather than a constraint, and the test + says which. 6. **Corpus.** PRV-5's refusal and its rejected alternative are rewritten to what the code now does. PRV-6 gains the declaration. The evidence table gains a row per invariant. The spike is From 86ee3660d3472a6fd7cb885346e0d90bf3af7951 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:26:58 -0400 Subject: [PATCH 09/21] docs(spike): muse runs on Responses in the live profile, verified through the executable Two turns through main's binary with muse chosen via /model: an arithmetic answer, then a follow-up that depends on it, so the first turn replayed through the gateway's translation and back. Both correct, usage reported, nothing on screen read as an error. Whether the encrypted reasoning survived the replay is not observable from the screen and is recorded as untested. --- .agents/spikes/provider-side-tools/meta-route.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.agents/spikes/provider-side-tools/meta-route.md b/.agents/spikes/provider-side-tools/meta-route.md index 88b69ff..ce362d7 100644 --- a/.agents/spikes/provider-side-tools/meta-route.md +++ b/.agents/spikes/provider-side-tools/meta-route.md @@ -62,6 +62,14 @@ translator flattens action types; and `output_tokens_details.reasoning_tokens` i Messages surface reports `thinking_tokens`. A decoder must accept an empty query rather than fail on it. +The live dev config now runs muse on `openai_responses`, switched 2026-09-21 and verified through +Plexmaton itself rather than curl: main's binary at `c7bdf0a`, an ephemeral conversation, muse +chosen through `/model`, two turns. "17 times 23" answered 391 in 2.2 s; "add 9 to your previous +answer" answered 400 in 1.0 s, so the first turn's output replayed through the gateway's +translation and back intact; usage reported 2.1k in, 123 out; nothing on screen read as an error. +Whether the encrypted reasoning items survived that replay is not observable from the screen and +is untested. + ## The gateway already has a native Meta route `cli-proxy-api` declares `meta-api-key` as a provider kind. `MetaKey` is a type alias of `CodexKey`, From 0e9fa26f2c2e79ed4e914e2811d33ad599b3db53 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:36:43 -0400 Subject: [PATCH 10/21] feat(provider): a model declares the hosted tools its route accepts Slice 1 of stage 33. A model entry gains server_tools, a subset of the capabilities a provider can run on its own side inside the call the owner already authorised. Today that catalog has one name, web_search. The field follows allowed_reasoning_efforts rather than inventing a pattern: absent means none, present means nonempty and unique, and every name must be one the model's dialect can spell. Responses, Chat Completions and Messages spell web search; GenerateContent has a google_search tool no route here has been measured with, so a declaration on it fails at config load rather than being guessed at. The spelling gate lives on ModelApi so the encoders slice 2 adds consult the same answer the validator did. The gate runs at config load, before any network work, because the wire will not always say: a route can accept a spelling it does not understand and ignore it. PRV-6 now states the declaration, that nothing infers it from a provider or model name, and that a route ignoring a correct spelling is the declaration being wrong for the owner to fix. A first draft bounded the list's length by the catalog size as well as by uniqueness. With one catalog entry the two checks are the same check, and mutating the uniqueness test away could not fail a test. Uniqueness over a finite enum already implies the bound, so the bound is gone and every remaining branch has a mutation that kills its test. Verified locally: plexmaton-provider 54 config tests pass (three new, each proven by mutation), clippy clean with -D warnings, fmt clean, citations, doc budget, file length and typos clean. Not run: the workspace suite, CI. --- .agents/evidence/provider-adapter.md | 2 +- .agents/phases/phase-04-product-polish.md | 4 +- .../phase-04-stage-33-provider-side-tools.md | 4 +- .agents/roadmap.md | 2 +- .agents/specs/provider-adapter.md | 9 +- crates/plexmaton-provider/src/config.rs | 27 ++++++ crates/plexmaton-provider/src/config/model.rs | 28 +++++- crates/plexmaton-provider/src/config/tests.rs | 89 +++++++++++++++++++ crates/plexmaton-provider/src/lib.rs | 3 +- examples/providers.toml | 1 + 10 files changed, 159 insertions(+), 10 deletions(-) diff --git a/.agents/evidence/provider-adapter.md b/.agents/evidence/provider-adapter.md index 1566111..44b64a5 100644 --- a/.agents/evidence/provider-adapter.md +++ b/.agents/evidence/provider-adapter.md @@ -11,5 +11,5 @@ resolves every one against the source, so a renamed or deleted test fails the bu | PRV-3 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing`, `prv_3_a_degraded_call_and_its_result_keep_one_identity`, `prv_3_a_degraded_gemini_call_carries_neither_signature_nor_upstream_id`, `prv_3_an_output_that_was_only_its_replay_degrades_to_no_message`, `prv_3_an_id_every_dialect_accepts_is_carried_through_unchanged`, `prv_3_an_id_the_destination_would_reject_becomes_one_it_accepts`, `prv_3_distinct_rewritten_ids_stay_distinct`, `prv_3_replay_route_owner_encoding_is_unambiguous`, `prv_3_rejects_opaque_replay_before_step_state_can_grow`, `assistant_output_round_trips_order_and_redacts_replay`, `replay_only_wire_without_a_payload_is_rejected`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `provider_replay_is_named_and_bounded_before_turn_state_can_retain_it`, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_cancelled_calls_leave_no_dangling_replay`, `prv_3_chat_reasoning_aliases_round_trip_without_renaming`, `prv_3_chat_accepts_reasoning_details_that_only_restate_the_plain_delta`, `prv_3_gemini_signature_only_and_early_signature_parts_are_replayable`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation`, `prv_3_unsigned_reasoning_in_a_tool_turn_is_refused_by_messages_and_omitted_by_responses` | | PRV-4 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools` proves stateless full-record replay without a response ID, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation` | | PRV-5 | `http_rate_limit_is_typed_and_keeps_retry_after`, `context_error_is_classified_by_wire_code`, `prv_5_responses_done_only_refusal_is_visible_and_typed`, and both fixture completion reasons, `prv_5_chat_rejects_structured_or_conflicting_reasoning`, `prv_5_messages_context_limits_and_refusals_keep_final_usage`, `native_stream_rate_limits_are_typed`, `gemini_declared_failures_preserve_diagnostics_without_dispatching_calls`, `messages_usage_refuses_unaccounted_server_tools`, `prv_5_gemini_policy_and_unsupported_image_finishes_are_distinct`, `stream_provider_errors_keep_their_category_and_observed_usage`, `dispatched_http_failures_preserve_their_terminal_measurements`, `provider_failure_reopens_as_the_same_non_retryable_outcome`, `prv_5_empty_additive_fields_preserve_text_and_populated_fields_fail`, `prv_5_chat_admits_gateway_accounting_and_still_refuses_everything_else`, `prv_5_messages_pause_turn_is_explicitly_unsupported` | -| PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md) | +| PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md), `prv_6_hosted_tools_are_declared_per_model_and_absent_by_default`, `prv_6_hosted_tool_declarations_fail_closed`, `prv_6_hosted_tool_declarations_need_a_dialect_spelling` | | PRV-7 | `event_guard_rejects_an_unterminated_event_at_the_bound`, `event_guard_rejects_one_oversized_transport_chunk`, `prv_2_and_prv_7_reject_unbounded_or_incomplete_provider_input`, `prv_7_model_config_cannot_widen_runtime_output_memory`, `interrupt_and_shutdown_cancel_and_join_the_exact_provider_task`, `native_stream_cancellation_preserves_observed_usage` | diff --git a/.agents/phases/phase-04-product-polish.md b/.agents/phases/phase-04-product-polish.md index 4fda3a2..f7ddb86 100644 --- a/.agents/phases/phase-04-product-polish.md +++ b/.agents/phases/phase-04-product-polish.md @@ -2,7 +2,7 @@ | Field | Value | | --- | --- | -| Status | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stages 16 and 28 await the user's terminal test; stage 30 automatic-compaction visibility unstarted; stage 33 provider-side tools sliced; stage 10 branding remains | +| Status | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stages 16 and 28 await the user's terminal test; stage 30 automatic-compaction visibility unstarted; stage 33 provider-side tools in progress; stage 10 branding remains | | Parent roadmap | [Plexmaton Roadmap](../roadmap.md) | | Product contract | [UI/UX](../ui-ux.md) | | Depends on | Phase 01 interaction ownership; Phase 02 journal/accounting/budget projections; Phase 03 durable collaboration, owned child scheduling, Handoff/Stop, passive recovery and canonical Attention | @@ -376,7 +376,7 @@ JRN-3/PRV-3 prove JSONL save, reopen and exact compatible replay only; an export and its replay disclosure policy remain unproven. MCP remains optional later integration. Rejected: treating JSONL round trips as independent export/import acceptance. -33. **Provider-side tools — sliced.** A model declares the hosted tools its route accepts, and the +33. **Provider-side tools — in progress.** A model declares the hosted tools its route accepts, and the harness forwards that declaration and renders what comes back rather than running the search itself. The [stage plan](../plans/phase-04-stage-33-provider-side-tools.md) owns the order; the [spike](../spikes/provider-side-tools/README.md) owns the evidence, measured against one live diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md index 24a5483..9cd5e5d 100644 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -5,7 +5,7 @@ | Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 33 | | Contract | PRV-1/PRV-3/PRV-5/PRV-6, [tool-admission](../specs/tool-admission.md), [ui-ux](../ui-ux.md) transcript grammar | | Evidence | [Provider-side tools spike](../spikes/provider-side-tools/README.md) | -| Status | Sliced; route decided as Responses; no slice implemented | +| Status | Slice 1 implemented and locally verified; slices 2–6 open | ## Outcome @@ -20,7 +20,7 @@ beside it, rather than leaving a second reading in the corpus. ## Slices -1. **Declaration.** A model entry gains a hosted-tool subset, validated the way +1. **Declaration — implemented.** A model entry gains a hosted-tool subset, validated the way `allowed_reasoning_efforts` already is: present or absent, nonempty and unique when present, and encodable by that model's dialect. A declaration a dialect cannot spell fails at config load, before any network work. PRV-6 gains the field; nothing infers it from a provider or model name. diff --git a/.agents/roadmap.md b/.agents/roadmap.md index 2b26e50..a767f6f 100644 --- a/.agents/roadmap.md +++ b/.agents/roadmap.md @@ -34,7 +34,7 @@ table in `AGENTS.md` allows. | 01 | One real agent in the workspace: a thin loop over one provider, and the transcript grammar against its output | Closed 2026-09-03: delivered one live OpenAI-compatible agent, five bounded native tools with explicit approval and cancellation, and the reviewed responsive transcript grammar | | 02 | Canonical session state, persistence, durable permission policy, provider transports, context projection and project instructions | Closed 2026-09-12 by scoped evidence: delivered JSONL sessions and recovery, four provider dialects, compaction, durable permissions, AGENTS.md and conversation tree/rewind; unproven recovery/readmission acceptance inherited by Phase 03, independent export/import by Phase 04; MCP optional | | 03 | Durable multi-agent mailbox and runtime ownership | Closed 2026-09-16: delivered ordered durable delegation/mail, owned child scheduling, Stop and Handoff control, passive, process-kill and in-app switch recovery, canonical Attention and the accepted responsive agent workspace | -| 04 | Product polish, performance hardening, math in production, and extensibility | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stage 16 effort selection awaits user testing; stage 28 model-switch degradation in progress; stage 30 automatic-compaction visibility unstarted; stage 33 provider-side tools sliced; stage 10 branding remains | +| 04 | Product polish, performance hardening, math in production, and extensibility | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stage 16 effort selection awaits user testing; stage 28 model-switch degradation in progress; stage 30 automatic-compaction visibility unstarted; stage 33 provider-side tools in progress; stage 10 branding remains | ## Locked diff --git a/.agents/specs/provider-adapter.md b/.agents/specs/provider-adapter.md index e398cb6..fc80f43 100644 --- a/.agents/specs/provider-adapter.md +++ b/.agents/specs/provider-adapter.md @@ -97,7 +97,8 @@ sources appear nowhere in the conversation. **PRV-6 — Configuration names data, never authority.** `~/.plexmaton/config.toml` separates named provider routes from their named models and selects one exact provider/model pair. A route owns its base URL, credential environment and default API; a model owns its wire/display identity, optional -API override, optional reasoning controls and a declared allowed-effort subset, stable instructions, cache intent, context/output/reserve +API override, optional reasoning controls, a declared allowed-effort subset, a declared hosted-tool +subset, stable instructions, cache intent, context/output/reserve limits, compaction retention target, estimator and optional price snapshot. Resolution yields one immutable credential-blind value: omitted estimators become an explicit versioned default, while omitted pricing remains unavailable. Selection never uses fuzzy @@ -105,7 +106,11 @@ names or URL inference. `PLEXMATON_HOME` redirects the whole root for isolated d never enter the file, diagnostics, repository or a native command's environment, and invalid input fails before network work begins. SKL-1 permits a narrow project model-selection layer without project provider definitions or credential changes. An allowed-effort declaration must be nonempty, -unique and encodable by the dialect; an explicit configured effort must belong to it. Rejected: a combined provider/model profile, +unique and encodable by the dialect; an explicit configured effort must belong to it. A hosted-tool +declaration names capabilities the owner knows their route accepts; it must be nonempty, unique and +spellable by the dialect, and nothing infers one from a provider or model name. A route that accepts +a spelling and ignores it is the declaration being wrong, which the owner corrects, never something +the harness guesses around. Rejected: a combined provider/model profile, inline keys, and untyped merging of project configuration into provider authority. [AGI-3/AGI-4](./agent-instructions.md) add a bounded workspace snapshot to that immutable request diff --git a/crates/plexmaton-provider/src/config.rs b/crates/plexmaton-provider/src/config.rs index 9722e2f..ca4e9a5 100644 --- a/crates/plexmaton-provider/src/config.rs +++ b/crates/plexmaton-provider/src/config.rs @@ -37,6 +37,21 @@ impl ModelApi { } } + /// Whether this dialect has a wire spelling for a hosted tool (PRV-6). + /// + /// A declaration must fail at config load, because a route that accepts a spelling it does + /// not understand may ignore it without saying so. GenerateContent has a `google_search` + /// tool that no route here has been measured with, so it is refused rather than guessed. + pub(crate) const fn spells_server_tool(self, tool: ServerTool) -> bool { + match (self, tool) { + ( + Self::OpenaiResponses | Self::OpenaiChatCompletions | Self::AnthropicMessages, + ServerTool::WebSearch, + ) => true, + (Self::GoogleGenerateContent, ServerTool::WebSearch) => false, + } + } + /// Whether this dialect can carry a collaboration atom at all (PRV-1). /// /// All four can, because all four have a turn that is not the assistant's, which is where an @@ -52,6 +67,17 @@ impl ModelApi { } } +/// One tool the provider runs on its own side, inside the model call the owner already authorised. +/// +/// Configuration names the capability and the selected dialect owns its spelling (PRV-6). Nothing +/// infers a declaration from a provider or model name: that would put authority in a name, which +/// is what PRV-6 exists to refuse. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ServerTool { + WebSearch, +} + /// Explicit cache intent; no provider cache retention is promised by this setting. #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] @@ -185,6 +211,7 @@ struct RawModel { #[serde(default)] reasoning_effort: ReasoningEffort, allowed_reasoning_efforts: Option>, + server_tools: Option>, #[serde(default)] instructions: String, #[serde(default)] diff --git a/crates/plexmaton-provider/src/config/model.rs b/crates/plexmaton-provider/src/config/model.rs index ad7fca0..264769f 100644 --- a/crates/plexmaton-provider/src/config/model.rs +++ b/crates/plexmaton-provider/src/config/model.rs @@ -1,6 +1,7 @@ //! Validated model request options and replay identity, independent of registry lookup. use super::{ - ConfigError, ModelApi, ModelCost, PromptCache, RawModel, ReasoningEffort, TokenEstimator, + ConfigError, ModelApi, ModelCost, PromptCache, RawModel, ReasoningEffort, ServerTool, + TokenEstimator, }; use plexmaton_agent::{ ProviderCodecId, ProviderCodecRevision, ProviderModelFamilyId, ProviderReplayOwnerId, @@ -34,6 +35,7 @@ pub struct ResolvedModel { api_key_env: String, reasoning_effort: ReasoningEffort, allowed_reasoning_efforts: Option>, + server_tools: Option>, instructions: String, workspace_instructions: WorkspaceInstructions, prompt_cache: PromptCache, @@ -106,6 +108,7 @@ impl ResolvedModel { api_key_env: api_key_env.to_owned(), reasoning_effort: model.reasoning_effort, allowed_reasoning_efforts: model.allowed_reasoning_efforts, + server_tools: model.server_tools, instructions: model.instructions, workspace_instructions: WorkspaceInstructions::default(), prompt_cache: model.prompt_cache, @@ -155,6 +158,7 @@ impl ResolvedModel { return Err(self.invalid_option("instructions")); } self.validate_reasoning_efforts()?; + self.validate_server_tools()?; if self.api == ModelApi::GoogleGenerateContent && !self .wire_id @@ -209,6 +213,21 @@ impl ResolvedModel { Ok(()) } + fn validate_server_tools(&self) -> Result<(), ConfigError> { + // Absent means none, so an empty list is a mistake rather than a quiet no. The dialect + // decides which names it can spell; a tool it cannot is refused here, before any request + // exists, because the wire may accept the spelling and ignore it without a word. + if let Some(tools) = &self.server_tools + && (tools.is_empty() + || tools.iter().enumerate().any(|(i, tool)| { + !self.api.spells_server_tool(*tool) || tools[..i].contains(tool) + })) + { + return Err(self.invalid_option("server_tools")); + } + Ok(()) + } + fn invalid_option(&self, field: &'static str) -> ConfigError { ConfigError::InvalidRequestOption { provider: self.provider_name.clone(), @@ -281,6 +300,13 @@ impl ResolvedModel { self.allowed_reasoning_efforts.as_deref() } + /// Hosted tools the owner declared this route accepts, in configuration order; absent means + /// none. The dialect spells each one; nothing here is inferred from a name. + #[must_use] + pub fn server_tools(&self) -> Option<&[ServerTool]> { + self.server_tools.as_deref() + } + #[must_use] pub const fn context_window_tokens(&self) -> u32 { self.context_window_tokens diff --git a/crates/plexmaton-provider/src/config/tests.rs b/crates/plexmaton-provider/src/config/tests.rs index 4ea692a..536c0c3 100644 --- a/crates/plexmaton-provider/src/config/tests.rs +++ b/crates/plexmaton-provider/src/config/tests.rs @@ -478,3 +478,92 @@ fn prv_6_provider_example_selects_each_documented_dialect() { ); } } + +/// PRV-6: a hosted tool is declared per model, and absent unless declared. +#[test] +fn prv_6_hosted_tools_are_declared_per_model_and_absent_by_default() { + let source = LOCAL_CONFIG.replace( + "reasoning_effort = \"xhigh\"", + "reasoning_effort = \"xhigh\"\nserver_tools = [\"web_search\"]", + ); + let registry = ModelRegistry::parse(&source).expect("declared hosted search"); + assert_eq!( + registry.active_model().server_tools(), + Some([super::ServerTool::WebSearch].as_slice()) + ); + assert_eq!( + registry.model("local", "sol").expect("Sol").server_tools(), + None + ); +} + +/// PRV-6: a declaration fails closed at config load, on the active model and an inactive one alike. +#[test] +fn prv_6_hosted_tool_declarations_fail_closed() { + for list in ["[]", "[\"web_search\", \"web_search\"]"] { + let source = LOCAL_CONFIG.replace( + "reasoning_effort = \"xhigh\"", + &format!("reasoning_effort = \"xhigh\"\nserver_tools = {list}"), + ); + assert!( + matches!( + ModelRegistry::parse(&source), + Err(ConfigError::InvalidRequestOption { + field: "server_tools", + .. + }) + ), + "{list}" + ); + } + // A name the catalog does not know is a parse error, never a tool quietly left out. + let source = LOCAL_CONFIG.replace( + "reasoning_effort = \"xhigh\"", + "reasoning_effort = \"xhigh\"\nserver_tools = [\"web_fetch\"]", + ); + assert!(matches!( + ModelRegistry::parse(&source), + Err(ConfigError::Toml) + )); + let source = LOCAL_CONFIG.replace( + "reasoning_effort = \"high\"", + "reasoning_effort = \"high\"\nserver_tools = []", + ); + assert!( + matches!(ModelRegistry::parse(&source), Err(ConfigError::InvalidRequestOption { model, .. }) if model == "sol") + ); +} + +/// PRV-6: the gate is the dialect's spelling, not the dialect. Three spell web search today, and +/// GenerateContent's tool is unmeasured, so it is refused rather than guessed. +#[test] +fn prv_6_hosted_tool_declarations_need_a_dialect_spelling() { + for (api, accepted) in [ + ("openai_responses", true), + ("openai_chat_completions", true), + ("anthropic_messages", true), + ("google_generate_content", false), + ] { + let source = LOCAL_CONFIG + .replace("api = \"openai_responses\"", &format!("api = \"{api}\"")) + .replace( + "reasoning_effort = \"xhigh\"", + "reasoning_effort = \"high\"\nserver_tools = [\"web_search\"]", + ); + match (accepted, ModelRegistry::parse(&source)) { + (true, Ok(registry)) => assert_eq!( + registry.active_model().server_tools(), + Some([super::ServerTool::WebSearch].as_slice()), + "{api}" + ), + ( + false, + Err(ConfigError::InvalidRequestOption { + field: "server_tools", + .. + }), + ) => {} + (_, other) => panic!("{api}: unexpected {other:?}"), + } + } +} diff --git a/crates/plexmaton-provider/src/lib.rs b/crates/plexmaton-provider/src/lib.rs index 3e8340b..688bc07 100644 --- a/crates/plexmaton-provider/src/lib.rs +++ b/crates/plexmaton-provider/src/lib.rs @@ -31,7 +31,8 @@ pub use compaction::{ }; pub use config::{ ApiKey, ConfigError, MAX_WORKSPACE_INSTRUCTION_BYTES, ModelApi, ModelCost, ModelRegistry, - ModelSelection, PromptCache, ResolvedModel, TokenEstimator, resolve_api_key, resolve_home, + ModelSelection, PromptCache, ResolvedModel, ServerTool, TokenEstimator, resolve_api_key, + resolve_home, }; pub use cost::request_cost; pub use degrade::degrades_replay; diff --git a/examples/providers.toml b/examples/providers.toml index ca49c80..36dd935 100644 --- a/examples/providers.toml +++ b/examples/providers.toml @@ -16,6 +16,7 @@ context_window_tokens = 32768 max_output_tokens = 4096 output_reserve_tokens = 4096 instructions = "You are a coding assistant. Preserve tool results accurately." +# server_tools = ["web_search"] # A search the route runs itself; declare it only if the route accepts it. # Example only, USD per million tokens; replace with the selected model's actual rates: # cost = { input = 1.0, output = 2.0, cache_read = 0.1, cache_write = 1.25 } From c830c41a74ef6ad2f7b582f470d1dfa792261725 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:40:12 -0400 Subject: [PATCH 11/21] feat(provider): each dialect spells a declared hosted tool Slice 2 of stage 33. Configuration named the capability in slice 1; the dialect now owns its spelling and nothing else does. Responses appends {"type":"web_search"} to the tools array, Messages appends the dated web_search_20250305 server tool, and Chat Completions sets web_search_options, which is where that schema puts hosted search rather than among the tool types. Hosted tools follow the function tools, so the order an owner reads in the body is the order declared, and a hosted tool alone still names a tool choice. GenerateContent asserts no declaration reached it: PRV-6 refuses one at config load, and stating the invariant at the encoder keeps a future spelling from landing on one side and not the other. One test asserts the emitted JSON for all three dialects, with a function tool beside the hosted one and without, and that an undeclared model carries nothing. Removing any one spelling fails it. Verified locally: provider crate passes (41 fixture tests, one new), the three spelling mutations each fail the test, clippy clean with -D warnings, fmt, citations, doc budget, file length and typos clean. Not run: the workspace suite, CI. --- .agents/evidence/provider-adapter.md | 2 +- .../phase-04-stage-33-provider-side-tools.md | 4 +- crates/plexmaton-provider/src/chat/request.rs | 9 +- .../plexmaton-provider/src/gemini/request.rs | 6 ++ .../src/messages/request.rs | 18 +++- .../src/responses/request.rs | 15 +++- .../provider_fixtures/request_options.rs | 84 +++++++++++++++++++ 7 files changed, 129 insertions(+), 9 deletions(-) diff --git a/.agents/evidence/provider-adapter.md b/.agents/evidence/provider-adapter.md index 44b64a5..2314840 100644 --- a/.agents/evidence/provider-adapter.md +++ b/.agents/evidence/provider-adapter.md @@ -11,5 +11,5 @@ resolves every one against the source, so a renamed or deleted test fails the bu | PRV-3 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing`, `prv_3_a_degraded_call_and_its_result_keep_one_identity`, `prv_3_a_degraded_gemini_call_carries_neither_signature_nor_upstream_id`, `prv_3_an_output_that_was_only_its_replay_degrades_to_no_message`, `prv_3_an_id_every_dialect_accepts_is_carried_through_unchanged`, `prv_3_an_id_the_destination_would_reject_becomes_one_it_accepts`, `prv_3_distinct_rewritten_ids_stay_distinct`, `prv_3_replay_route_owner_encoding_is_unambiguous`, `prv_3_rejects_opaque_replay_before_step_state_can_grow`, `assistant_output_round_trips_order_and_redacts_replay`, `replay_only_wire_without_a_payload_is_rejected`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `provider_replay_is_named_and_bounded_before_turn_state_can_retain_it`, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_cancelled_calls_leave_no_dangling_replay`, `prv_3_chat_reasoning_aliases_round_trip_without_renaming`, `prv_3_chat_accepts_reasoning_details_that_only_restate_the_plain_delta`, `prv_3_gemini_signature_only_and_early_signature_parts_are_replayable`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation`, `prv_3_unsigned_reasoning_in_a_tool_turn_is_refused_by_messages_and_omitted_by_responses` | | PRV-4 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools` proves stateless full-record replay without a response ID, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation` | | PRV-5 | `http_rate_limit_is_typed_and_keeps_retry_after`, `context_error_is_classified_by_wire_code`, `prv_5_responses_done_only_refusal_is_visible_and_typed`, and both fixture completion reasons, `prv_5_chat_rejects_structured_or_conflicting_reasoning`, `prv_5_messages_context_limits_and_refusals_keep_final_usage`, `native_stream_rate_limits_are_typed`, `gemini_declared_failures_preserve_diagnostics_without_dispatching_calls`, `messages_usage_refuses_unaccounted_server_tools`, `prv_5_gemini_policy_and_unsupported_image_finishes_are_distinct`, `stream_provider_errors_keep_their_category_and_observed_usage`, `dispatched_http_failures_preserve_their_terminal_measurements`, `provider_failure_reopens_as_the_same_non_retryable_outcome`, `prv_5_empty_additive_fields_preserve_text_and_populated_fields_fail`, `prv_5_chat_admits_gateway_accounting_and_still_refuses_everything_else`, `prv_5_messages_pause_turn_is_explicitly_unsupported` | -| PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md), `prv_6_hosted_tools_are_declared_per_model_and_absent_by_default`, `prv_6_hosted_tool_declarations_fail_closed`, `prv_6_hosted_tool_declarations_need_a_dialect_spelling` | +| PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md), `prv_6_hosted_tools_are_declared_per_model_and_absent_by_default`, `prv_6_hosted_tool_declarations_fail_closed`, `prv_6_hosted_tool_declarations_need_a_dialect_spelling`, `prv_6_declared_hosted_tools_are_spelled_by_the_dialect_and_absent_otherwise` | | PRV-7 | `event_guard_rejects_an_unterminated_event_at_the_bound`, `event_guard_rejects_one_oversized_transport_chunk`, `prv_2_and_prv_7_reject_unbounded_or_incomplete_provider_input`, `prv_7_model_config_cannot_widen_runtime_output_memory`, `interrupt_and_shutdown_cancel_and_join_the_exact_provider_task`, `native_stream_cancellation_preserves_observed_usage` | diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md index 9cd5e5d..49d05b3 100644 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -5,7 +5,7 @@ | Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 33 | | Contract | PRV-1/PRV-3/PRV-5/PRV-6, [tool-admission](../specs/tool-admission.md), [ui-ux](../ui-ux.md) transcript grammar | | Evidence | [Provider-side tools spike](../spikes/provider-side-tools/README.md) | -| Status | Slice 1 implemented and locally verified; slices 2–6 open | +| Status | Slices 1–2 implemented and locally verified; slices 3–6 open | ## Outcome @@ -25,7 +25,7 @@ beside it, rather than leaving a second reading in the corpus. encodable by that model's dialect. A declaration a dialect cannot spell fails at config load, before any network work. PRV-6 gains the field; nothing infers it from a provider or model name. -2. **Request encoding.** Each dialect spells the declared capability and owns that spelling alone. +2. **Request encoding — implemented.** Each dialect spells the declared capability and owns that spelling alone. Messages emits `{"type":"web_search_20250305","name":"web_search"}`; Responses emits `{"type":"web_search"}`; Chat Completions emits `web_search_options`, the field OpenAI's own search models take; GenerateContent's `google_search` tool is spelled once its facade has been diff --git a/crates/plexmaton-provider/src/chat/request.rs b/crates/plexmaton-provider/src/chat/request.rs index 32ab9ce..1cc6a2a 100644 --- a/crates/plexmaton-provider/src/chat/request.rs +++ b/crates/plexmaton-provider/src/chat/request.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use super::{ChatReplay, ReasoningField}; use crate::{ - FunctionTool, ResolvedModel, + FunctionTool, ResolvedModel, ServerTool, codec::{EncodeError, tool_output}, degrade::{self, Carried}, }; @@ -50,6 +50,13 @@ pub(crate) fn encode( body["tools"] = Value::Array(tools); body["tool_choice"] = Value::String("auto".to_owned()); } + // PRV-6: Chat Completions spells hosted search as the request field OpenAI's own search + // models take, not as a tool type, which is where this dialect's schema puts it. + for tool in model.server_tools().unwrap_or_default() { + match tool { + ServerTool::WebSearch => body["web_search_options"] = json!({}), + } + } if let Some(limit) = max_output_tokens { body["max_completion_tokens"] = Value::from(limit); } diff --git a/crates/plexmaton-provider/src/gemini/request.rs b/crates/plexmaton-provider/src/gemini/request.rs index 98cb40a..c5080f3 100644 --- a/crates/plexmaton-provider/src/gemini/request.rs +++ b/crates/plexmaton-provider/src/gemini/request.rs @@ -20,6 +20,12 @@ pub(crate) fn encode( tools: &[FunctionTool], max_output_tokens: Option, ) -> Result { + // PRV-6 refuses a hosted tool this dialect cannot spell at config load, so none can arrive + // here. Stating it keeps a future spelling from being added to one side and not the other. + assert!( + model.server_tools().is_none(), + "PRV-6: a GenerateContent model cannot declare a hosted tool" + ); let mut contents = Vec::new(); if !model.workspace_instructions().is_empty() { contents.push(json!({"role":"user", "parts":[{"text":model.workspace_instructions()}]})); diff --git a/crates/plexmaton-provider/src/messages/request.rs b/crates/plexmaton-provider/src/messages/request.rs index 061df50..bcb54c0 100644 --- a/crates/plexmaton-provider/src/messages/request.rs +++ b/crates/plexmaton-provider/src/messages/request.rs @@ -8,7 +8,7 @@ use plexmaton_core::ReasoningEffort; use serde_json::{Value, json}; use crate::{ - EncodeError, FunctionTool, PromptCache, ResolvedModel, + EncodeError, FunctionTool, PromptCache, ResolvedModel, ServerTool, codec::tool_output, degrade::{self, Carried}, }; @@ -33,8 +33,20 @@ pub(crate) fn encode( if !model.instructions().is_empty() { body["system"] = json!([{"type":"text","text":model.instructions()}]); } - if !tools.is_empty() { - body["tools"] = Value::Array(tools.iter().map(|tool| json!({"name":tool.name(),"description":tool.description(),"input_schema":tool.parameters()})).collect()); + // PRV-6: configuration named the capability; this dialect owns the spelling. Hosted tools + // follow the function tools so the order the owner reads in the body is the order declared. + let mut declared: Vec = tools.iter().map(|tool| json!({"name":tool.name(),"description":tool.description(),"input_schema":tool.parameters()})).collect(); + declared.extend( + model + .server_tools() + .unwrap_or_default() + .iter() + .map(|tool| match tool { + ServerTool::WebSearch => json!({"type":"web_search_20250305","name":"web_search"}), + }), + ); + if !declared.is_empty() { + body["tools"] = Value::Array(declared); body["tool_choice"] = json!({"type":"auto"}); } if model.prompt_cache() == PromptCache::Automatic { diff --git a/crates/plexmaton-provider/src/responses/request.rs b/crates/plexmaton-provider/src/responses/request.rs index 734b9a6..5145a28 100644 --- a/crates/plexmaton-provider/src/responses/request.rs +++ b/crates/plexmaton-provider/src/responses/request.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use super::replay::{MessagePartKind, ResponseReplay}; use crate::{ - FunctionTool, ResolvedModel, + FunctionTool, ResolvedModel, ServerTool, codec::{EncodeError, RESPONSES_CODEC_ID, tool_output}, degrade::{self, Carried}, }; @@ -20,7 +20,7 @@ pub(crate) fn encode( max_output_tokens: Option, ) -> Result { let input = encode_input(model, request)?; - let tools: Vec<_> = tools + let mut tools: Vec<_> = tools .iter() .map(|tool| { json!({ @@ -32,6 +32,17 @@ pub(crate) fn encode( }) }) .collect(); + // PRV-6: configuration named the capability; this dialect owns the spelling. Hosted tools + // follow the function tools so the order the owner reads in the body is the order declared. + tools.extend( + model + .server_tools() + .unwrap_or_default() + .iter() + .map(|tool| match tool { + ServerTool::WebSearch => json!({"type": "web_search"}), + }), + ); let mut body = json!({ "model": model.wire_id(), "input": input, diff --git a/crates/plexmaton-provider/tests/provider_fixtures/request_options.rs b/crates/plexmaton-provider/tests/provider_fixtures/request_options.rs index 4673309..851217c 100644 --- a/crates/plexmaton-provider/tests/provider_fixtures/request_options.rs +++ b/crates/plexmaton-provider/tests/provider_fixtures/request_options.rs @@ -448,3 +448,87 @@ output_reserve_tokens = 512 ); } } + +/// PRV-6: a declared hosted tool is spelled by the dialect, after the function tools, and a model +/// that declared none carries none. The three spellings are the wire's, asserted as JSON. +#[test] +fn prv_6_declared_hosted_tools_are_spelled_by_the_dialect_and_absent_otherwise() { + use serde_json::{Value, json}; + let base = r#" +active_model = { provider = "fixture", model = "searching" } +[providers.fixture] +base_url = "http://127.0.0.1:1/v1" +api_key_env = "FIXTURE_KEY" +api = "openai_responses" +[providers.fixture.models.searching] +id = "fixture" +server_tools = ["web_search"] +context_window_tokens = 8192 +max_output_tokens = 1024 +output_reserve_tokens = 512 +[providers.fixture.models.plain] +id = "fixture" +context_window_tokens = 8192 +max_output_tokens = 1024 +output_reserve_tokens = 512 +"#; + let (_, request) = open_agent("Hello."); + let hosted = |body: &Value| -> Vec { + body.get("tools") + .and_then(Value::as_array) + .map(|tools| { + tools + .iter() + .filter(|tool| { + tool.get("type").and_then(Value::as_str) != Some("function") + && tool.get("input_schema").is_none() + }) + .cloned() + .collect() + }) + .unwrap_or_default() + }; + for (api, spelling) in [ + ("openai_responses", json!({"type": "web_search"})), + ("openai_chat_completions", json!({})), + ( + "anthropic_messages", + json!({"type": "web_search_20250305", "name": "web_search"}), + ), + ] { + let registry = ModelRegistry::parse(&base.replace("openai_responses", api)) + .unwrap_or_else(|error| panic!("{api}: {error}")); + let searching = registry.active_model(); + let plain = registry.model("fixture", "plain").expect("plain"); + let with_function = encode_request(searching, &request, &[read_tool()], None) + .unwrap_or_else(|error| panic!("{api}: {error}")); + let alone = encode_request(searching, &request, &[], None) + .unwrap_or_else(|error| panic!("{api}: {error}")); + let undeclared = encode_request(plain, &request, &[read_tool()], None) + .unwrap_or_else(|error| panic!("{api}: {error}")); + if api == "openai_chat_completions" { + assert_eq!(with_function["web_search_options"], spelling, "{api}"); + assert_eq!(alone["web_search_options"], spelling, "{api}"); + assert!(undeclared.get("web_search_options").is_none(), "{api}"); + assert!( + hosted(&with_function).is_empty(), + "{api}: not a tool type here" + ); + } else { + assert_eq!(hosted(&with_function), vec![spelling.clone()], "{api}"); + assert_eq!(hosted(&alone), vec![spelling.clone()], "{api}"); + assert!(hosted(&undeclared).is_empty(), "{api}"); + let tools = with_function["tools"].as_array().expect("tools"); + assert_eq!(tools.len(), 2, "{api}: one function tool, one hosted"); + assert_eq!( + tools[1], spelling, + "{api}: hosted follows the function tools" + ); + assert!(with_function.get("tool_choice").is_some(), "{api}"); + assert!( + alone.get("tool_choice").is_some(), + "{api}: a hosted tool alone still names a choice" + ); + } + } +} From 854b270fcdfdc984620fe50c10a1b21162c102da Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:59:08 -0400 Subject: [PATCH 12/21] feat(provider): carry a search the provider ran, and replay it as the item it was Slices 3 and 5 of stage 33. A Responses web_search_call is no longer a typed refusal. It becomes a server-tool call in the record: what the provider did, a search, an opened page or a find within one, bounded the way tool arguments are and counted with them; the provider's own item identity and status ride beside it as replay metadata, the way a function call's do. It reaches no admission and no scheduler, because the call already happened inside the model call the owner authorised by declaring the route accepts it. The vocabulary lives in core beside ReasoningEffort, since configuration, the record and, next, the transcript all name it; the provider re-exports ServerTool so slice 1's declaration is unchanged. The event is a sixth ModelEvent and the block a fifth AssistantBlock, and every exhaustive site says what it does with them: the step records it without a call identity of ours, the compaction collector keeps it beside the summary text, degradation drops it for a foreign model the way it drops replay-only blocks because the answer text carries what came of it, the projection claims its entry and projects nothing yet, Chat refuses it as unrepresentable, and the Messages decoder refuses to emit one until a Messages route is in daily use. Decoding follows what live routes send. Both spellings of a query arrive, sometimes together, so one is kept once; a search that arrived with no query is carried as it arrived, because one route flattens an opened page into exactly that; an action kind the record cannot name fails the step rather than travelling blind, which is where this differs from Codex's catch-all and why PRV-5 records that choice as rejected. The progress markers a route streams while searching carry nothing the finished item lacks and are ignored. On replay the Responses encoder rebuilds the item from the record and adds identity and status from the sidecar, so the next request reads as the provider wrote it, in the order it wrote it. The fixture is sanitized from a live stream through the local gateway, with one action changed to an opened page so both observed kinds are exercised, and the same test drives a follow-up turn to prove the replay. PRV-5 is rewritten in this change rather than left as a second reading, and PRV-3 names the new identity it retains. The plan's slice 5 is implemented here because the encoder's exhaustive match had to gain the arm anyway, and an arm that could not be proven would have been the worse choice. Verified locally: core, agent and provider suites pass with five new tests, the runtime suite passes outside the sandbox, clippy clean with -D warnings on all four crates, fmt, citations, crate graph, doc budget, file length and typos clean. Removing the decoder arm or the replayed identity each fails the fixture test. Not run: the TUI and CLI suites, the PTY smokes, CI. --- .agents/evidence/provider-adapter.md | 6 +- .../phase-04-stage-33-provider-side-tools.md | 6 +- .agents/specs/provider-adapter.md | 18 ++- crates/plexmaton-agent/src/compaction.rs | 4 +- .../src/journal/projection/assistant.rs | 4 +- .../src/journal/tree_source.rs | 2 +- crates/plexmaton-agent/src/model.rs | 10 ++ crates/plexmaton-agent/src/model/context.rs | 28 ++++- .../src/model/context/tests.rs | 41 +++++++ crates/plexmaton-agent/src/step.rs | 48 ++++++++ .../plexmaton-agent/src/turn/model_input.rs | 14 +++ crates/plexmaton-core/src/lib.rs | 2 + crates/plexmaton-core/src/server_tool.rs | 110 +++++++++++++++++ crates/plexmaton-provider/src/chat/request.rs | 3 + crates/plexmaton-provider/src/codec.rs | 2 + crates/plexmaton-provider/src/config.rs | 12 +- crates/plexmaton-provider/src/degrade.rs | 3 + .../plexmaton-provider/src/messages/stream.rs | 7 ++ .../src/responses/replay.rs | 77 ++++++++++++ .../src/responses/request.rs | 47 ++++++++ .../src/responses/stream.rs | 10 +- .../tests/fixtures/responses_web_search.sse | 63 ++++++++++ .../tests/provider_fixtures.rs | 111 ++++++++++++++++++ .../tests/provider_fixtures/server_tools.rs | 105 +++++++++++++++++ crates/plexmaton-runtime/src/http/summary.rs | 35 ++++++ crates/plexmaton-runtime/src/runtime/model.rs | 4 +- 26 files changed, 741 insertions(+), 31 deletions(-) create mode 100644 crates/plexmaton-core/src/server_tool.rs create mode 100644 crates/plexmaton-provider/tests/fixtures/responses_web_search.sse create mode 100644 crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs diff --git a/.agents/evidence/provider-adapter.md b/.agents/evidence/provider-adapter.md index 2314840..35566a7 100644 --- a/.agents/evidence/provider-adapter.md +++ b/.agents/evidence/provider-adapter.md @@ -7,9 +7,9 @@ resolves every one against the source, so a renamed or deleted test fails the bu | Invariant | Proven by | | --- | --- | | PRV-1 | `cin_3_every_codec_renders_collaboration_with_its_sender_named`, `prv_1_collaboration_bodies_cannot_forge_their_own_envelope`, `prv_1_chat_fixture_drives_a_full_stateless_tool_round_trip`, `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_1_both_protocols_preserve_parallel_call_and_result_order`, `prv_1_chat_refuses_cross_kind_order_its_wire_cannot_represent`, `prv_1_protocol_selection_never_falls_back_across_replay_grammars`, `native_catalog_is_exact_unique_and_advertised_by_both_protocols`, `prv_1_messages_signed_tool_round_trip_normalizes_cumulative_usage`, `prv_1_gemini_tool_round_trip_preserves_signatures_and_counts_thoughts` | -| PRV-2 | Both fixture round trips, `prv_2_production_tool_argument_limit_remains_64_kibibytes`, `parallel_calls_share_one_aggregate_argument_bound`, `reverse_parallel_call_completion_is_sorted_before_dispatch`, `prv_2_both_protocols_bound_tool_identity_before_emission`, `assistant_output_rechecks_aggregate_text_and_tool_identity_bounds`, `oversized_semantic_text_fails_before_canonical_commit`, `maximal_valid_assistant_output_fits_the_journal_line_envelope`, `prv_2_rejects_duplicate_chat_tool_call_ids_before_emission`, `prv_2_rejects_incremental_duplicate_responses_tool_call_ids`, `prv_2_responses_counts_incrementally_completed_calls_toward_the_step_bound`, `prv_2_rejects_tool_arguments_before_they_can_reach_admission`, `prv_2_bounds_even_empty_responses_output_items`, `prv_2_responses_text_done_confirms_deltas_or_supplies_the_only_copy`, `prv_2_stopped_is_withheld_until_the_stream_trailer_is_valid`, `prv_2_sse_framing_rejects_partial_utf8`, `prv_2_messages_rejects_unfinished_mismatched_and_oversized_blocks`, `prv_2_gemini_call_ids_are_scoped_and_parallel_signatures_keep_their_part`, `prv_2_gemini_rejects_incomplete_duplicate_and_oversized_content` | -| PRV-3 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing`, `prv_3_a_degraded_call_and_its_result_keep_one_identity`, `prv_3_a_degraded_gemini_call_carries_neither_signature_nor_upstream_id`, `prv_3_an_output_that_was_only_its_replay_degrades_to_no_message`, `prv_3_an_id_every_dialect_accepts_is_carried_through_unchanged`, `prv_3_an_id_the_destination_would_reject_becomes_one_it_accepts`, `prv_3_distinct_rewritten_ids_stay_distinct`, `prv_3_replay_route_owner_encoding_is_unambiguous`, `prv_3_rejects_opaque_replay_before_step_state_can_grow`, `assistant_output_round_trips_order_and_redacts_replay`, `replay_only_wire_without_a_payload_is_rejected`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `provider_replay_is_named_and_bounded_before_turn_state_can_retain_it`, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_cancelled_calls_leave_no_dangling_replay`, `prv_3_chat_reasoning_aliases_round_trip_without_renaming`, `prv_3_chat_accepts_reasoning_details_that_only_restate_the_plain_delta`, `prv_3_gemini_signature_only_and_early_signature_parts_are_replayable`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation`, `prv_3_unsigned_reasoning_in_a_tool_turn_is_refused_by_messages_and_omitted_by_responses` | +| PRV-2 | Both fixture round trips, `prv_2_production_tool_argument_limit_remains_64_kibibytes`, `parallel_calls_share_one_aggregate_argument_bound`, `reverse_parallel_call_completion_is_sorted_before_dispatch`, `prv_2_both_protocols_bound_tool_identity_before_emission`, `assistant_output_rechecks_aggregate_text_and_tool_identity_bounds`, `oversized_semantic_text_fails_before_canonical_commit`, `maximal_valid_assistant_output_fits_the_journal_line_envelope`, `prv_2_rejects_duplicate_chat_tool_call_ids_before_emission`, `prv_2_rejects_incremental_duplicate_responses_tool_call_ids`, `prv_2_responses_counts_incrementally_completed_calls_toward_the_step_bound`, `prv_2_rejects_tool_arguments_before_they_can_reach_admission`, `prv_2_bounds_even_empty_responses_output_items`, `prv_2_responses_text_done_confirms_deltas_or_supplies_the_only_copy`, `prv_2_stopped_is_withheld_until_the_stream_trailer_is_valid`, `prv_2_sse_framing_rejects_partial_utf8`, `prv_2_messages_rejects_unfinished_mismatched_and_oversized_blocks`, `prv_2_gemini_call_ids_are_scoped_and_parallel_signatures_keep_their_part`, `prv_2_gemini_rejects_incomplete_duplicate_and_oversized_content`, `server_tool_calls_share_the_tool_argument_bound` | +| PRV-3 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing`, `prv_3_a_degraded_call_and_its_result_keep_one_identity`, `prv_3_a_degraded_gemini_call_carries_neither_signature_nor_upstream_id`, `prv_3_an_output_that_was_only_its_replay_degrades_to_no_message`, `prv_3_an_id_every_dialect_accepts_is_carried_through_unchanged`, `prv_3_an_id_the_destination_would_reject_becomes_one_it_accepts`, `prv_3_distinct_rewritten_ids_stay_distinct`, `prv_3_replay_route_owner_encoding_is_unambiguous`, `prv_3_rejects_opaque_replay_before_step_state_can_grow`, `assistant_output_round_trips_order_and_redacts_replay`, `replay_only_wire_without_a_payload_is_rejected`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `provider_replay_is_named_and_bounded_before_turn_state_can_retain_it`, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_cancelled_calls_leave_no_dangling_replay`, `prv_3_chat_reasoning_aliases_round_trip_without_renaming`, `prv_3_chat_accepts_reasoning_details_that_only_restate_the_plain_delta`, `prv_3_gemini_signature_only_and_early_signature_parts_are_replayable`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation`, `prv_3_unsigned_reasoning_in_a_tool_turn_is_refused_by_messages_and_omitted_by_responses`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call` | | PRV-4 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools` proves stateless full-record replay without a response ID, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation` | -| PRV-5 | `http_rate_limit_is_typed_and_keeps_retry_after`, `context_error_is_classified_by_wire_code`, `prv_5_responses_done_only_refusal_is_visible_and_typed`, and both fixture completion reasons, `prv_5_chat_rejects_structured_or_conflicting_reasoning`, `prv_5_messages_context_limits_and_refusals_keep_final_usage`, `native_stream_rate_limits_are_typed`, `gemini_declared_failures_preserve_diagnostics_without_dispatching_calls`, `messages_usage_refuses_unaccounted_server_tools`, `prv_5_gemini_policy_and_unsupported_image_finishes_are_distinct`, `stream_provider_errors_keep_their_category_and_observed_usage`, `dispatched_http_failures_preserve_their_terminal_measurements`, `provider_failure_reopens_as_the_same_non_retryable_outcome`, `prv_5_empty_additive_fields_preserve_text_and_populated_fields_fail`, `prv_5_chat_admits_gateway_accounting_and_still_refuses_everything_else`, `prv_5_messages_pause_turn_is_explicitly_unsupported` | +| PRV-5 | `http_rate_limit_is_typed_and_keeps_retry_after`, `context_error_is_classified_by_wire_code`, `prv_5_responses_done_only_refusal_is_visible_and_typed`, and both fixture completion reasons, `prv_5_chat_rejects_structured_or_conflicting_reasoning`, `prv_5_messages_context_limits_and_refusals_keep_final_usage`, `native_stream_rate_limits_are_typed`, `gemini_declared_failures_preserve_diagnostics_without_dispatching_calls`, `messages_usage_refuses_unaccounted_server_tools`, `prv_5_gemini_policy_and_unsupported_image_finishes_are_distinct`, `stream_provider_errors_keep_their_category_and_observed_usage`, `dispatched_http_failures_preserve_their_terminal_measurements`, `provider_failure_reopens_as_the_same_non_retryable_outcome`, `prv_5_empty_additive_fields_preserve_text_and_populated_fields_fail`, `prv_5_chat_admits_gateway_accounting_and_still_refuses_everything_else`, `prv_5_messages_pause_turn_is_explicitly_unsupported`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call`, `prv_5_an_unknown_server_tool_action_fails_the_step`, `prv_5_a_server_tool_call_without_an_action_fails_the_step`, `prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_once` | | PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md), `prv_6_hosted_tools_are_declared_per_model_and_absent_by_default`, `prv_6_hosted_tool_declarations_fail_closed`, `prv_6_hosted_tool_declarations_need_a_dialect_spelling`, `prv_6_declared_hosted_tools_are_spelled_by_the_dialect_and_absent_otherwise` | | PRV-7 | `event_guard_rejects_an_unterminated_event_at_the_bound`, `event_guard_rejects_one_oversized_transport_chunk`, `prv_2_and_prv_7_reject_unbounded_or_incomplete_provider_input`, `prv_7_model_config_cannot_widen_runtime_output_memory`, `interrupt_and_shutdown_cancel_and_join_the_exact_provider_task`, `native_stream_cancellation_preserves_observed_usage` | diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md index 49d05b3..088503f 100644 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -5,7 +5,7 @@ | Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 33 | | Contract | PRV-1/PRV-3/PRV-5/PRV-6, [tool-admission](../specs/tool-admission.md), [ui-ux](../ui-ux.md) transcript grammar | | Evidence | [Provider-side tools spike](../spikes/provider-side-tools/README.md) | -| Status | Slices 1–2 implemented and locally verified; slices 3–6 open | +| Status | Slices 1–3 and 5 implemented and locally verified; slice 4 open; slice 6 open for the row's spec and the spike's promotion | ## Outcome @@ -34,7 +34,7 @@ beside it, rather than leaving a second reading in the corpus. is the owner's claim about their route, verified once rather than trusted. No request carries a hosted tool the model did not declare. -3. **Decode.** The Responses decoder accepts a `web_search_call` output item on both `added` and +3. **Decode — implemented.** The Responses decoder accepts a `web_search_call` output item on both `added` and `done`, carrying the action the provider took: a query, an opened page, or a find within one. An action that arrives without a query is carried as it arrived, not refused. One semantic event, shaped so a Messages decoder can emit the same event later without a second one, because @@ -49,7 +49,7 @@ beside it, rather than leaving a second reading in the corpus. the [spike](../spikes/provider-side-tools/meta-route.md) records what each measured route gives. Three widths reviewed before the slice closes. -5. **Replay.** A hosted-tool item round-trips when the conversation continues, under PRV-3's +5. **Replay — implemented.** A hosted-tool item round-trips when the conversation continues, under PRV-3's existing sidecar rules, and its provider-assigned id is nothing the harness has to preserve. Whether the item is sent back or stripped is a decision rather than a constraint, and the test says which. diff --git a/.agents/specs/provider-adapter.md b/.agents/specs/provider-adapter.md index fc80f43..e69ffa4 100644 --- a/.agents/specs/provider-adapter.md +++ b/.agents/specs/provider-adapter.md @@ -32,7 +32,7 @@ a semantic stop is released only after the stream trailer passes validation. **PRV-3 — Semantics and replay are exact but separate.** Every exposed reasoning artifact is retained in output order. Bounded replay sidecars attach to text, reasoning or calls; a replay-only block retains a part with no visible content. Responses retains item identity/status, message phase -and content grouping as well as encrypted reasoning. Messages retains complete thinking/signature and redacted +and content grouping, a server-tool call's identity and status, as well as encrypted reasoning. Messages retains complete thinking/signature and redacted blocks; Gemini retains signatures on their original parts, signature-only text-field absence and optional upstream call IDs. Chat retains recognized reasoning field identity and refuses unsupported structured reasoning; its @@ -88,11 +88,17 @@ that was: Chat admits `provider_metadata`, the gateway accounting — cost, cach attempts — that rides the final delta beside `finish_reason`, and admits it nowhere else. Rejected: refusing it as unexamined, which was right in principle and in practice discarded a whole completed answer at its last chunk, on every gateway that sends it. -A tool the provider ran on its own side falls the other way: a `server_tool_use` block and a -non-zero server-tool count are content this harness never admitted, never bounded and cannot show, -and a turn that absorbs them quietly reports work its own record does not contain. Rejected: -admitting them as opaque replay, which round-trips correctly and leaves the reader an answer whose -sources appear nowhere in the conversation. +A tool the provider ran on its own side is carried, not refused. Responses' `web_search_call` +becomes a server-tool call in the record: what the provider did, a search, an opened page or a find +within one, bounded the way tool arguments are, with the provider's own item identity and status +kept as replay metadata. It reaches no admission and no scheduler, because the call already +happened inside the model call the owner authorised by declaring the route accepts it. An action +kind this harness cannot name fails the step; a search that arrived with no query is carried as it +arrived; the progress markers a route streams while searching carry nothing the finished item +lacks and are ignored. Messages' `server_tool_use` block and a non-zero server-tool count remain +refused until a Messages route is in daily use to prove a decoder against. Rejected: keeping the +call as opaque replay alone, which round-trips and leaves the record unable to say a search +happened; and a catch-all action kind, which would carry content the record cannot name. **PRV-6 — Configuration names data, never authority.** `~/.plexmaton/config.toml` separates named provider routes from their named models and selects one exact provider/model pair. A route owns its diff --git a/crates/plexmaton-agent/src/compaction.rs b/crates/plexmaton-agent/src/compaction.rs index 6d31ec3..2387c62 100644 --- a/crates/plexmaton-agent/src/compaction.rs +++ b/crates/plexmaton-agent/src/compaction.rs @@ -514,7 +514,9 @@ fn validate_complete_output( .ok_or(CompactionPlanError::SummaryTooLarge)?; has_summary_text |= !text.trim().is_empty(); } - AssistantBlock::Reasoning { .. } | AssistantBlock::ReplayOnly { .. } => {} + AssistantBlock::Reasoning { .. } + | AssistantBlock::ReplayOnly { .. } + | AssistantBlock::ServerToolCall { .. } => {} AssistantBlock::ToolCall { .. } => unreachable!("tool calls were rejected above"), } } diff --git a/crates/plexmaton-agent/src/journal/projection/assistant.rs b/crates/plexmaton-agent/src/journal/projection/assistant.rs index 249b374..8c73df3 100644 --- a/crates/plexmaton-agent/src/journal/projection/assistant.rs +++ b/crates/plexmaton-agent/src/journal/projection/assistant.rs @@ -68,9 +68,11 @@ impl Projector { ); calls.push(call_id); } + // Recorded for replay and for the row the contract shapes; projected as nothing yet. AssistantBlock::Text { .. } | AssistantBlock::Reasoning { .. } - | AssistantBlock::ReplayOnly { .. } => {} + | AssistantBlock::ReplayOnly { .. } + | AssistantBlock::ServerToolCall { .. } => {} } } if calls.is_empty() { diff --git a/crates/plexmaton-agent/src/journal/tree_source.rs b/crates/plexmaton-agent/src/journal/tree_source.rs index 519d548..cb8434d 100644 --- a/crates/plexmaton-agent/src/journal/tree_source.rs +++ b/crates/plexmaton-agent/src/journal/tree_source.rs @@ -137,7 +137,7 @@ impl ConversationJournal { } } } - AssistantBlock::ReplayOnly { .. } => {} + AssistantBlock::ReplayOnly { .. } | AssistantBlock::ServerToolCall { .. } => {} } } Ok(()) diff --git a/crates/plexmaton-agent/src/model.rs b/crates/plexmaton-agent/src/model.rs index d1d7764..46135e5 100644 --- a/crates/plexmaton-agent/src/model.rs +++ b/crates/plexmaton-agent/src/model.rs @@ -6,6 +6,7 @@ //! adapter-side. A shared event type carrying one dialect's concerns leaves every other adapter //! fabricating fields it does not have. +use plexmaton_core::ServerToolCall; use plexmaton_core::{TokenUsage, TurnId}; use serde::{Deserialize, Serialize}; @@ -120,6 +121,15 @@ pub enum ModelEvent { position: ModelOutputPosition, call: ToolCall, }, + /// The provider ran one of the model's declared server tools and reported what it did. + /// + /// Arrives complete, after the action finished. It asks this harness to run nothing, so it + /// reaches no admission and no scheduler: the call already happened inside the model call the + /// owner authorised by configuring the route (PRV-6). + ServerToolCall { + position: ModelOutputPosition, + call: ServerToolCall, + }, /// Provider-reported token consumption retained by the runtime in an attempt terminal. /// Direct delivery through `Input::Streamed` is refused (TIM-3). Usage(TokenUsage), diff --git a/crates/plexmaton-agent/src/model/context.rs b/crates/plexmaton-agent/src/model/context.rs index 77463b0..9a89e1e 100644 --- a/crates/plexmaton-agent/src/model/context.rs +++ b/crates/plexmaton-agent/src/model/context.rs @@ -1,3 +1,4 @@ +use plexmaton_core::ServerToolCall; use std::collections::BTreeSet; use plexmaton_core::{ConversationEntryId, ToolCallId, TranscriptItemId}; @@ -72,6 +73,12 @@ pub enum AssistantBlock { item_id: TranscriptItemId, call: ToolCall, }, + /// A call the provider ran on its own side and reported. Never dispatched here; recorded so + /// the transcript can say what was searched and the next request can replay it. + ServerToolCall { + item_id: TranscriptItemId, + call: ServerToolCall, + }, } impl AssistantBlock { @@ -81,7 +88,8 @@ impl AssistantBlock { Self::Text { item_id, .. } | Self::Reasoning { item_id, .. } | Self::ReplayOnly { item_id } - | Self::ToolCall { item_id, .. } => item_id, + | Self::ToolCall { item_id, .. } + | Self::ServerToolCall { item_id, .. } => item_id, } } @@ -89,7 +97,10 @@ impl AssistantBlock { pub const fn tool_call(&self) -> Option<&ToolCall> { match self { Self::ToolCall { call, .. } => Some(call), - Self::Text { .. } | Self::Reasoning { .. } | Self::ReplayOnly { .. } => None, + Self::Text { .. } + | Self::Reasoning { .. } + | Self::ReplayOnly { .. } + | Self::ServerToolCall { .. } => None, } } } @@ -283,6 +294,19 @@ impl AssistantOutput { return Err(ContextError::ToolArgumentsTooLarge); } } + if let AssistantBlock::ServerToolCall { call, .. } = block { + // Provider-authored text, bounded the way tool arguments are and counted with them. + let bytes = call.action.text_bytes(); + if bytes > crate::MAX_REQUESTED_TOOL_ARGUMENT_BYTES { + return Err(ContextError::ToolArgumentsTooLarge); + } + tool_argument_bytes = tool_argument_bytes + .checked_add(bytes) + .ok_or(ContextError::ToolArgumentsTooLarge)?; + if tool_argument_bytes > MAX_ASSISTANT_TOOL_ARGUMENT_BYTES { + return Err(ContextError::ToolArgumentsTooLarge); + } + } } if let Some(replay) = &replay { for attachment in replay.attachments() { diff --git a/crates/plexmaton-agent/src/model/context/tests.rs b/crates/plexmaton-agent/src/model/context/tests.rs index 05e8d66..e990e70 100644 --- a/crates/plexmaton-agent/src/model/context/tests.rs +++ b/crates/plexmaton-agent/src/model/context/tests.rs @@ -250,3 +250,44 @@ fn tool_batch_rechecks_persisted_result_bounds() { } } } + +/// A server-tool call's provider-authored text is bounded like tool arguments and counted with them. +#[test] +fn server_tool_calls_share_the_tool_argument_bound() { + use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall}; + let search = |name: &str, bytes: usize| AssistantBlock::ServerToolCall { + item_id: item(name), + call: ServerToolCall { + tool: ServerTool::WebSearch, + action: ServerToolAction::Search { + queries: vec!["q".repeat(bytes)], + }, + }, + }; + assert!( + AssistantOutput::new( + vec![search("at-bound", crate::MAX_REQUESTED_TOOL_ARGUMENT_BYTES)], + None + ) + .is_ok() + ); + assert_eq!( + AssistantOutput::new( + vec![search( + "over-bound", + crate::MAX_REQUESTED_TOOL_ARGUMENT_BYTES + 1 + )], + None + ), + Err(ContextError::ToolArgumentsTooLarge) + ); + let each = 60 * 1024; + let blocks: Vec<_> = (0..9) + .map(|index| search(&format!("aggregate-{index}"), each)) + .collect(); + assert!(each * 9 > MAX_ASSISTANT_TOOL_ARGUMENT_BYTES); + assert_eq!( + AssistantOutput::new(blocks, None), + Err(ContextError::ToolArgumentsTooLarge) + ); +} diff --git a/crates/plexmaton-agent/src/step.rs b/crates/plexmaton-agent/src/step.rs index 4d9b53c..af596de 100644 --- a/crates/plexmaton-agent/src/step.rs +++ b/crates/plexmaton-agent/src/step.rs @@ -1,5 +1,6 @@ //! One model step's ordered, bounded output assembly. +use plexmaton_core::ServerToolCall; use std::collections::{BTreeMap, btree_map::Entry}; use plexmaton_core::{ConversationEvent, TranscriptItemId, TranscriptRole, TurnId}; @@ -37,6 +38,10 @@ enum PendingOutput { item_id: TranscriptItemId, call: ToolCall, }, + ServerToolCall { + item_id: TranscriptItemId, + call: ServerToolCall, + }, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -248,6 +253,46 @@ impl Step { } } + /// Records a call the provider already ran. It takes no call identity of ours and joins no + /// batch: nothing here will be dispatched, so nothing here can be reused or left undone. + pub(crate) fn note_server_tool( + &mut self, + position: ModelOutputPosition, + call: ServerToolCall, + ) -> Result<(), StepAssemblyError> { + let bytes = call.action.text_bytes(); + if bytes > crate::MAX_REQUESTED_TOOL_ARGUMENT_BYTES { + return Err(StepAssemblyError::ToolArgumentsTooLarge); + } + let tool_argument_bytes = self + .tool_argument_bytes + .checked_add(bytes) + .filter(|&total| total <= MAX_ASSISTANT_TOOL_ARGUMENT_BYTES) + .ok_or(StepAssemblyError::ToolArgumentsTooLarge)?; + self.reserve_output_position(position)?; + let item_id = Record::stream_item_id( + &self.turn_id, + self.index, + TranscriptRole::Assistant, + position, + ); + match self.outputs.entry(position) { + Entry::Vacant(entry) => { + entry.insert(PendingOutput::ServerToolCall { item_id, call }); + } + // The adapter's replay for the same item may have landed first and reserved the + // position as replay-only; the call it belongs to takes the position over. + Entry::Occupied(mut entry) + if matches!(entry.get(), PendingOutput::ReplayOnly { .. }) => + { + entry.insert(PendingOutput::ServerToolCall { item_id, call }); + } + Entry::Occupied(_) => return Err(StepAssemblyError::ConflictingPosition), + } + self.tool_argument_bytes = tool_argument_bytes; + Ok(()) + } + pub(crate) fn contains_call(&self, call_id: &plexmaton_core::ToolCallId) -> bool { self.outputs.values().any(|output| { matches!(output, PendingOutput::ToolCall { call, .. } if &call.call_id == call_id) @@ -335,6 +380,9 @@ impl Step { PendingOutput::ToolCall { item_id, call } => { AssistantBlock::ToolCall { item_id, call } } + PendingOutput::ServerToolCall { item_id, call } => { + AssistantBlock::ServerToolCall { item_id, call } + } }; let block_index = u16::try_from(blocks.len()) .unwrap_or_else(|_| unreachable!("provider output block count is bounded")); diff --git a/crates/plexmaton-agent/src/turn/model_input.rs b/crates/plexmaton-agent/src/turn/model_input.rs index 96a41cc..0bcccba 100644 --- a/crates/plexmaton-agent/src/turn/model_input.rs +++ b/crates/plexmaton-agent/src/turn/model_input.rs @@ -105,6 +105,20 @@ impl Agent { ); } } + ModelEvent::ServerToolCall { position, call } => { + let result = match &mut self.turn { + Turn::Streaming { step, .. } => step.note_server_tool(position, call), + Turn::Idle | Turn::Working { .. } => return, + }; + if let Err(error) = result { + self.fail( + &crate::ModelError::Malformed { + message: error.message().to_owned(), + }, + reaction, + ); + } + } ModelEvent::Usage(_) => { if let Some(step_id) = self.active_model_step() { reaction.undelivered_model.push(UndeliveredModelInput { diff --git a/crates/plexmaton-core/src/lib.rs b/crates/plexmaton-core/src/lib.rs index d3d299d..6765d3f 100644 --- a/crates/plexmaton-core/src/lib.rs +++ b/crates/plexmaton-core/src/lib.rs @@ -15,6 +15,7 @@ use thiserror::Error; mod conversation_tree; mod permissions; mod reasoning; +mod server_tool; mod transcript; mod tree_edit; mod tree_snapshot; @@ -39,6 +40,7 @@ pub use tree_snapshot::{ pub use tree_source::{MAX_TREE_SOURCE_BYTES, TreeSourceError, TreeSourceRequest}; pub use reasoning::ReasoningEffort; +pub use server_tool::{ServerTool, ServerToolAction, ServerToolCall}; pub use transcript::{ CommandInvocation, ToolCallStatus, ToolDetail, ToolPresentation, TranscriptRole, }; diff --git a/crates/plexmaton-core/src/server_tool.rs b/crates/plexmaton-core/src/server_tool.rs new file mode 100644 index 0000000..ce5b4aa --- /dev/null +++ b/crates/plexmaton-core/src/server_tool.rs @@ -0,0 +1,110 @@ +//! Tools a provider runs on its own side, inside the model call the owner already authorised. + +use serde::{Deserialize, Serialize}; + +/// One capability a model's route may run for it, named by what it does rather than by any +/// dialect's spelling. +/// +/// Configuration declares a subset per model and the selected dialect spells each one (PRV-6). +/// Nothing infers a declaration from a provider or model name: that would put authority in a +/// name, which is what PRV-6 exists to refuse. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ServerTool { + /// Search the public web, and open or search within a page it found. + WebSearch, +} + +/// What a server tool did, as the provider reported it. +/// +/// Findings are not here: a route returns them or it does not, and a transcript shows what +/// arrived. A search may carry no query, because one route flattens an opened page into a search +/// with an empty one, and an action that arrived is carried rather than refused (PRV-5). +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum ServerToolAction { + /// A web search. Routes spell the query once or as a list; the record keeps each once. + Search { + /// The queries the provider searched for, in the order reported; may be empty. + queries: Vec, + }, + /// The provider opened a page it had found. + OpenPage { + /// The page the provider opened. + url: String, + }, + /// The provider searched within a page it had opened. + FindInPage { + /// The page the provider searched within. + url: String, + /// The text it looked for. + pattern: String, + }, +} + +impl ServerToolAction { + /// Provider-authored text this action carries, bounded by the record the way tool arguments + /// are: a query or a URL is the provider's to make long and ours to keep bounded. + #[must_use] + pub fn text_bytes(&self) -> usize { + match self { + Self::Search { queries } => queries + .iter() + .fold(0_usize, |total, query| total.saturating_add(query.len())), + Self::OpenPage { url } => url.len(), + Self::FindInPage { url, pattern } => url.len().saturating_add(pattern.len()), + } + } +} + +/// One call the provider made on its own side and finished before reporting it. +/// +/// It is never a request for this harness to run anything, so it reaches no admission and no +/// scheduler: the call already happened inside the model call the owner authorised by +/// configuring the route. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ServerToolCall { + /// The declared capability the provider used. + pub tool: ServerTool, + /// What it did with it. + pub action: ServerToolAction, +} + +#[cfg(test)] +mod tests { + use super::ServerToolAction; + + #[test] + fn action_text_bytes_count_every_provider_authored_string() { + assert_eq!( + ServerToolAction::Search { + queries: vec!["ab".to_owned(), "cde".to_owned()] + } + .text_bytes(), + 5 + ); + assert_eq!( + ServerToolAction::Search { + queries: Vec::new() + } + .text_bytes(), + 0 + ); + assert_eq!( + ServerToolAction::OpenPage { + url: "https://x".to_owned() + } + .text_bytes(), + 9 + ); + assert_eq!( + ServerToolAction::FindInPage { + url: "u".to_owned(), + pattern: "pat".to_owned() + } + .text_bytes(), + 4 + ); + } +} diff --git a/crates/plexmaton-provider/src/chat/request.rs b/crates/plexmaton-provider/src/chat/request.rs index 1cc6a2a..fd31e06 100644 --- a/crates/plexmaton-provider/src/chat/request.rs +++ b/crates/plexmaton-provider/src/chat/request.rs @@ -236,6 +236,9 @@ fn encode_assistant( }, })); } + // Reached only with a compatible replay, which this dialect never produced: a foreign + // model's search is degraded away before encoding (PRV-3). + AssistantBlock::ServerToolCall { .. } => return Err(EncodeError::ServerToolCallInChat), } } Ok(pending.into_message()) diff --git a/crates/plexmaton-provider/src/codec.rs b/crates/plexmaton-provider/src/codec.rs index 498acdc..e2cf166 100644 --- a/crates/plexmaton-provider/src/codec.rs +++ b/crates/plexmaton-provider/src/codec.rs @@ -136,6 +136,8 @@ pub enum EncodeError { InvalidToolName, #[error("opaque provider replay cannot be sent through Chat Completions")] OpaqueReplayInChat, + #[error("a call the provider ran on its own side has no Chat Completions representation")] + ServerToolCallInChat, #[error("ordered assistant blocks cannot be represented by Chat Completions")] UnrepresentableChatOrder, #[error("stored provider replay is not valid JSON: {0}")] diff --git a/crates/plexmaton-provider/src/config.rs b/crates/plexmaton-provider/src/config.rs index ca4e9a5..b38f795 100644 --- a/crates/plexmaton-provider/src/config.rs +++ b/crates/plexmaton-provider/src/config.rs @@ -1,6 +1,7 @@ //! Typed model registry and pure home/config resolution. use plexmaton_core::ReasoningEffort; +pub use plexmaton_core::ServerTool; use std::{collections::BTreeMap, fmt}; mod environment; @@ -67,17 +68,6 @@ impl ModelApi { } } -/// One tool the provider runs on its own side, inside the model call the owner already authorised. -/// -/// Configuration names the capability and the selected dialect owns its spelling (PRV-6). Nothing -/// infers a declaration from a provider or model name: that would put authority in a name, which -/// is what PRV-6 exists to refuse. -#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum ServerTool { - WebSearch, -} - /// Explicit cache intent; no provider cache retention is promised by this setting. #[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/plexmaton-provider/src/degrade.rs b/crates/plexmaton-provider/src/degrade.rs index 2f2046f..2238cc8 100644 --- a/crates/plexmaton-provider/src/degrade.rs +++ b/crates/plexmaton-provider/src/degrade.rs @@ -63,6 +63,9 @@ pub(crate) fn degraded<'a>( AssistantBlock::ToolCall { call, .. } => Some(Carried::Call(call)), // A replay-only block was never anything but its sidecar. AssistantBlock::ReplayOnly { .. } => None, + // A search the previous model ran. Its answer text carries what came of it, and + // the next model did not ask for the query. + AssistantBlock::ServerToolCall { .. } => None, }) .collect(), ) diff --git a/crates/plexmaton-provider/src/messages/stream.rs b/crates/plexmaton-provider/src/messages/stream.rs index be9d078..46e171c 100644 --- a/crates/plexmaton-provider/src/messages/stream.rs +++ b/crates/plexmaton-provider/src/messages/stream.rs @@ -210,6 +210,13 @@ impl MessagesDecoder { })?; self.replay_items += 1; } + // This decoder produces none: Messages' server_tool_use keeps its refusal until a + // Messages route is in daily use to prove a decoder against (PRV-5). + ModelEvent::ServerToolCall { .. } => { + return Err(DecodeError::UnsupportedEvent( + "messages_server_tool_call".to_owned(), + )); + } ModelEvent::Called { call, .. } => { if self.calls.len() >= self.limits.max_tool_calls { return Err(DecodeError::TooManyToolCalls { diff --git a/crates/plexmaton-provider/src/responses/replay.rs b/crates/plexmaton-provider/src/responses/replay.rs index 32d57af..f6598fa 100644 --- a/crates/plexmaton-provider/src/responses/replay.rs +++ b/crates/plexmaton-provider/src/responses/replay.rs @@ -1,6 +1,7 @@ //! Completed message metadata, kept separately from authoritative semantic text. use plexmaton_agent::{ModelEvent, ProviderReplay}; +use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -50,6 +51,10 @@ pub(super) enum ResponseReplay { part: MessagePartKind, annotations: Vec, }, + WebSearchCall { + id: Option, + status: Option, + }, } #[derive(Clone, Copy, Debug, Deserialize, Serialize)] @@ -109,6 +114,78 @@ impl ResponsesDecoder { Ok(events) } + /// A search the provider ran, reported whole on `output_item.done`. The action is semantic + /// content the record keeps and bounds (PRV-5); only the provider's identity and status become + /// replay metadata, the way a function call's do. + pub(super) fn web_search_call_done( + &mut self, + index: usize, + item: &Value, + ) -> Result, DecodeError> { + let action = item + .get("action") + .filter(|value| value.is_object()) + .ok_or_else(|| { + DecodeError::UnsupportedEvent("web_search_call_without_action".to_owned()) + })?; + let action = match string_field(action, "type")? { + "search" => { + // Both spellings arrive on live routes, sometimes together; a query is kept once, + // and an empty one is an action that arrived without a query rather than a query. + let mut queries: Vec = action + .get("queries") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_str) + .filter(|query| !query.is_empty()) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(); + if let Some(query) = action.get("query").and_then(Value::as_str) + && !query.is_empty() + && !queries.iter().any(|known| known == query) + { + queries.insert(0, query.to_owned()); + } + ServerToolAction::Search { queries } + } + "open_page" => ServerToolAction::OpenPage { + url: string_field(action, "url")?.to_owned(), + }, + "find_in_page" => ServerToolAction::FindInPage { + url: string_field(action, "url")?.to_owned(), + pattern: string_field(action, "pattern")?.to_owned(), + }, + other => { + return Err(DecodeError::UnsupportedEvent(format!( + "web_search_call.action:{other}" + ))); + } + }; + retain_bytes( + &mut self.retained, + action.text_bytes(), + self.limits.max_retained_output_bytes, + )?; + let call = ServerToolCall { + tool: ServerTool::WebSearch, + action, + }; + let metadata = ResponseReplay::WebSearchCall { + id: item.get("id").and_then(Value::as_str).map(str::to_owned), + status: item_status(item)?, + }; + let replay = self.retain_replay(serde_json::to_string(&metadata)?)?; + let position = output_position(index, 0)?; + Ok(vec![ + ModelEvent::ServerToolCall { position, call }, + ModelEvent::Replay { position, replay }, + ]) + } + pub(super) fn message_done( &mut self, index: usize, diff --git a/crates/plexmaton-provider/src/responses/request.rs b/crates/plexmaton-provider/src/responses/request.rs index 5145a28..bc63faa 100644 --- a/crates/plexmaton-provider/src/responses/request.rs +++ b/crates/plexmaton-provider/src/responses/request.rs @@ -3,6 +3,7 @@ use plexmaton_agent::{ AssistantBlock, AssistantOutput, BlockReplay, ContextAtom, ContextAtomValue, ModelRequest, }; +use plexmaton_core::ServerToolAction; use serde_json::{Value, json}; use super::replay::{MessagePartKind, ResponseReplay}; @@ -225,6 +226,33 @@ fn encode_assistant( } input.push(item); } + AssistantBlock::ServerToolCall { call, .. } => { + // The item is rebuilt from the record; the sidecar adds only the provider's own + // identity and status, the way a function call's does. + let kind = match call.tool { + ServerTool::WebSearch => "web_search_call", + }; + let mut item = json!({ + "type": kind, + "status": "completed", + "action": web_search_action(&call.action), + }); + if let Some(replay) = replay { + let metadata: ResponseReplay = serde_json::from_str(replay.payload()) + .map_err(|_| EncodeError::InvalidReplayItem)?; + let ResponseReplay::WebSearchCall { id, status } = metadata else { + return Err(EncodeError::InvalidReplayItem); + }; + if let Some(id) = id { + item["id"] = Value::String(id); + } + if let Some(status) = status { + item["status"] = + serde_json::to_value(status).map_err(EncodeError::InvalidReplayJson)?; + } + } + input.push(item); + } } } debug_assert!( @@ -266,6 +294,7 @@ fn encode_text_replay( part, annotations, } => (id, phase, status, content_index, part, annotations), + ResponseReplay::WebSearchCall { .. } => return Err(EncodeError::InvalidReplayItem), ResponseReplay::EmptyMessage { id, phase, status } => { if !text.is_empty() { return Err(EncodeError::InvalidReplayItem); @@ -325,3 +354,21 @@ fn encode_text_replay( } Ok(()) } + +/// The wire's spelling of what a search did. `query` is kept beside `queries` because both +/// spellings have been observed on live routes, and a replay should read as the item did. +fn web_search_action(action: &ServerToolAction) -> Value { + match action { + ServerToolAction::Search { queries } => { + let mut value = json!({"type": "search", "queries": queries}); + if let Some(first) = queries.first() { + value["query"] = Value::String(first.clone()); + } + value + } + ServerToolAction::OpenPage { url } => json!({"type": "open_page", "url": url}), + ServerToolAction::FindInPage { url, pattern } => { + json!({"type": "find_in_page", "url": url, "pattern": pattern}) + } + } +} diff --git a/crates/plexmaton-provider/src/responses/stream.rs b/crates/plexmaton-provider/src/responses/stream.rs index 1ef78e7..5aeb3c9 100644 --- a/crates/plexmaton-provider/src/responses/stream.rs +++ b/crates/plexmaton-provider/src/responses/stream.rs @@ -76,7 +76,12 @@ impl ResponsesDecoder { | "response.content_part.done" | "response.reasoning_summary_part.added" | "response.reasoning_summary_part.done" - | "response.reasoning_summary_text.done" => Ok(Vec::new()), + | "response.reasoning_summary_text.done" + // Progress markers for a search the provider is running; the finished item carries + // everything they do (PRV-5). + | "response.web_search_call.in_progress" + | "response.web_search_call.searching" + | "response.web_search_call.completed" => Ok(Vec::new()), "response.reasoning_summary_text.delta" => self.reasoning_delta(&event), "response.output_text.delta" => self.text_delta(&event, false), "response.output_text.done" => self.text_done(&event, "text", false), @@ -199,7 +204,7 @@ impl ResponsesDecoder { )?; call.seed_arguments(optional_string(item, "arguments"), index, limits) } - "reasoning" | "message" => Ok(()), + "reasoning" | "message" | "web_search_call" => Ok(()), other => Err(DecodeError::UnsupportedEvent(format!( "response.output_item.added:{other}" ))), @@ -244,6 +249,7 @@ impl ResponsesDecoder { "reasoning" => self.reasoning_replay(index, item), "function_call" => self.function_call_done(index, item), "message" => self.message_done(index, item), + "web_search_call" => self.web_search_call_done(index, item), other => Err(DecodeError::UnsupportedEvent(format!( "response.output_item.done:{other}" ))), diff --git a/crates/plexmaton-provider/tests/fixtures/responses_web_search.sse b/crates/plexmaton-provider/tests/fixtures/responses_web_search.sse new file mode 100644 index 0000000..3343bd8 --- /dev/null +++ b/crates/plexmaton-provider/tests/fixtures/responses_web_search.sse @@ -0,0 +1,63 @@ +event: response.created +data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_fixture_search","status":"in_progress","output":[],"store":false}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"id":"rs_fixture_s1","type":"reasoning","summary":[],"encrypted_content":null,"status":"in_progress"}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":2,"output_index":0,"item":{"id":"rs_fixture_s1","type":"reasoning","summary":[{"type":"summary_text","text":""}],"encrypted_content":"enc_fixture_s1_🔐","status":"completed"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":3,"output_index":1,"item":{"id":"ws_fixture_1","type":"web_search_call","status":"in_progress","action":{"type":"search","query":""}}} + +event: response.web_search_call.searching +data: {"type":"response.web_search_call.searching","sequence_number":4,"output_index":1,"item_id":"ws_fixture_1"} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":5,"output_index":1,"item":{"id":"ws_fixture_1","type":"web_search_call","status":"completed","action":{"type":"search","query":"latest stable Rust release","queries":["latest stable Rust release"]}}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":6,"output_index":2,"item":{"id":"msg_fixture_s1","type":"message","status":"in_progress","role":"assistant","content":[]}} + +event: response.content_part.added +data: {"type":"response.content_part.added","sequence_number":7,"item_id":"msg_fixture_s1","output_index":2,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":8,"item_id":"msg_fixture_s1","output_index":2,"content_index":0,"delta":"Checking the release page. "} + +event: response.output_text.done +data: {"type":"response.output_text.done","sequence_number":9,"item_id":"msg_fixture_s1","output_index":2,"content_index":0,"text":"Checking the release page. "} + +event: response.content_part.done +data: {"type":"response.content_part.done","sequence_number":10,"item_id":"msg_fixture_s1","output_index":2,"content_index":0,"part":{"type":"output_text","text":"Checking the release page. ","annotations":[]}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":11,"output_index":2,"item":{"id":"msg_fixture_s1","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"Checking the release page. ","annotations":[]}]}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":12,"output_index":3,"item":{"id":"ws_fixture_2","type":"web_search_call","status":"in_progress"}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":13,"output_index":3,"item":{"id":"ws_fixture_2","type":"web_search_call","status":"completed","action":{"type":"open_page","url":"https://blog.rust-lang.org/releases/"}}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":14,"output_index":4,"item":{"id":"msg_fixture_s2","type":"message","status":"in_progress","role":"assistant","content":[]}} + +event: response.content_part.added +data: {"type":"response.content_part.added","sequence_number":15,"item_id":"msg_fixture_s2","output_index":4,"content_index":0,"part":{"type":"output_text","text":"","annotations":[]}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":16,"item_id":"msg_fixture_s2","output_index":4,"content_index":0,"delta":"The latest stable Rust release is 1.98.1."} + +event: response.output_text.done +data: {"type":"response.output_text.done","sequence_number":17,"item_id":"msg_fixture_s2","output_index":4,"content_index":0,"text":"The latest stable Rust release is 1.98.1."} + +event: response.content_part.done +data: {"type":"response.content_part.done","sequence_number":18,"item_id":"msg_fixture_s2","output_index":4,"content_index":0,"part":{"type":"output_text","text":"The latest stable Rust release is 1.98.1.","annotations":[]}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":19,"output_index":4,"item":{"id":"msg_fixture_s2","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"The latest stable Rust release is 1.98.1.","annotations":[]}]}} + +event: response.completed +data: {"type":"response.completed","sequence_number":20,"response":{"id":"resp_fixture_search","status":"completed","usage":{"input_tokens":120,"input_tokens_details":{"cached_tokens":0},"output_tokens":40,"output_tokens_details":{"reasoning_tokens":8},"total_tokens":160}}} + diff --git a/crates/plexmaton-provider/tests/provider_fixtures.rs b/crates/plexmaton-provider/tests/provider_fixtures.rs index fd9f410..1db8653 100644 --- a/crates/plexmaton-provider/tests/provider_fixtures.rs +++ b/crates/plexmaton-provider/tests/provider_fixtures.rs @@ -21,6 +21,8 @@ mod gemini; mod messages; #[path = "provider_fixtures/request_options.rs"] mod request_options; +#[path = "provider_fixtures/server_tools.rs"] +mod server_tools; mod support; #[path = "provider_fixtures/usage_boundaries.rs"] mod usage_boundaries; @@ -34,6 +36,7 @@ const CHAT_TOOL_CALL: &str = include_str!("fixtures/chat_tool_call.sse"); const CHAT_FINAL_ANSWER: &str = include_str!("fixtures/chat_final_answer.sse"); const RESPONSES_TOOL_CALL: &str = include_str!("fixtures/responses_tool_call.sse"); const RESPONSES_FINAL_ANSWER: &str = include_str!("fixtures/responses_final_answer.sse"); +const RESPONSES_WEB_SEARCH: &str = include_str!("fixtures/responses_web_search.sse"); #[tokio::test] async fn prv_1_chat_fixture_drives_a_full_stateless_tool_round_trip() { @@ -941,3 +944,111 @@ fn replay_model_family(value: &str) -> ProviderModelFamilyId { ProviderModelFamilyId::new(value) .unwrap_or_else(|error| panic!("fixture model family: {error:?}")) } + +/// PRV-5: a search the provider ran arrives as a server-tool call carrying what it did, its +/// progress markers are ignored, and the turn still ends as an answer rather than a tool request. +/// The fixture is sanitized from a live stream through the local gateway, with the second call's +/// action changed to an opened page so both observed kinds are exercised. +#[tokio::test] +async fn prv_5_responses_web_search_call_is_carried_as_a_server_tool_call() { + use plexmaton_core::{ServerTool, ServerToolAction}; + let profile = profile(ModelApi::OpenaiResponses); + let (mut agent, _) = open_agent("What is the latest stable Rust release?"); + let events = decode_fixture(&profile, RESPONSES_WEB_SEARCH, &[3, 17, 64]).await; + + let calls: Vec<_> = events + .iter() + .filter_map(|event| match event { + ModelEvent::ServerToolCall { position, call } => Some((position.item(), call.clone())), + _ => None, + }) + .collect(); + assert_eq!(calls.len(), 2, "{events:?}"); + assert_eq!(calls[0].0, 1); + assert_eq!(calls[0].1.tool, ServerTool::WebSearch); + assert_eq!( + calls[0].1.action, + ServerToolAction::Search { + queries: vec!["latest stable Rust release".to_owned()] + }, + "one query, spelled twice on the wire, kept once" + ); + assert_eq!(calls[1].0, 3); + assert_eq!( + calls[1].1.action, + ServerToolAction::OpenPage { + url: "https://blog.rust-lang.org/releases/".to_owned() + } + ); + // Each call's identity and status ride beside it as replay, never as semantic text. + for item in [1_u16, 3] { + assert!( + events.iter().any(|event| matches!(event, ModelEvent::Replay { position, .. } if position.item() == item)), + "call at item {item} has a replay sidecar" + ); + } + assert!( + !events + .iter() + .any(|event| matches!(event, ModelEvent::Called { .. })), + "nothing here asks the harness to run a tool" + ); + assert_eq!( + visible_text(&events), + "Checking the release page. The latest stable Rust release is 1.98.1." + ); + assert!(matches!( + events.last(), + Some(ModelEvent::Stopped(StopReason::EndOfTurn)) + )); + // The route reports no cache-write count, so this usage is partial coverage; what the test + // cares about is that it arrived. + assert!(!matches!(reported_usage(&events), TokenUsage::Unavailable)); + // The agent records the turn: both calls become blocks beside the text, and nothing is queued. + complete_answer(&mut agent, &events); + + // PRV-3: the next request replays each call as the item it was, identity and status from the + // sidecar, action from the record, in the order the provider produced them. + use serde_json::{Value, json}; + let reaction = agent.handle_at( + plexmaton_agent::Input::Submitted { + text: "Repeat the version.".to_owned(), + }, + plexmaton_agent::UnixMillis::EPOCH, + ); + let [plexmaton_agent::Effect::CallModel(next)] = reaction.effects.as_slice() else { + panic!( + "follow-up should open one model request: {:?}", + reaction.effects + ); + }; + let body = encode_request(&profile, &next.request, &[], None) + .unwrap_or_else(|error| panic!("follow-up should encode: {error}")); + let input = body["input"].as_array().expect("input array"); + let kinds: Vec<&str> = input + .iter() + .map(|item| item["type"].as_str().unwrap_or("")) + .collect(); + let first_search = kinds.iter().position(|kind| *kind == "web_search_call"); + let first_message = kinds.iter().position(|kind| *kind == "message"); + assert!( + first_search < first_message, + "the search precedes the answer it informed: {kinds:?}" + ); + let searches: Vec<&Value> = input + .iter() + .filter(|item| item["type"] == "web_search_call") + .collect(); + assert_eq!(searches.len(), 2, "{kinds:?}"); + assert_eq!(searches[0]["id"], "ws_fixture_1"); + assert_eq!(searches[0]["status"], "completed"); + assert_eq!( + searches[0]["action"], + json!({"type": "search", "query": "latest stable Rust release", "queries": ["latest stable Rust release"]}) + ); + assert_eq!(searches[1]["id"], "ws_fixture_2"); + assert_eq!( + searches[1]["action"], + json!({"type": "open_page", "url": "https://blog.rust-lang.org/releases/"}) + ); +} diff --git a/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs b/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs new file mode 100644 index 0000000..ca5ec3a --- /dev/null +++ b/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs @@ -0,0 +1,105 @@ +use super::*; +use std::convert::Infallible; + +use futures_util::stream; +use plexmaton_core::ServerToolAction; +use plexmaton_provider::{DecodeError, DecodeLimits, SseDecodeError, drive_sse}; + +const CREATED: &str = r#"event: response.created +data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_fixture_ws","status":"in_progress","output":[],"store":false}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":1,"output_index":0,"item":{"id":"ws_fixture_x","type":"web_search_call","status":"in_progress"}} + +"#; +const COMPLETED: &str = r#"event: response.completed +data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_fixture_ws","status":"completed","usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":0},"output_tokens":2,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":12}}} + +"#; + +fn search_done(action: &str) -> String { + format!( + "{CREATED}event: response.output_item.done\ndata: {{\"type\":\"response.output_item.done\",\"sequence_number\":2,\"output_index\":0,\"item\":{{\"id\":\"ws_fixture_x\",\"type\":\"web_search_call\",\"status\":\"completed\"{action}}}}}\n\n{COMPLETED}" + ) +} + +async fn decode(source: &str) -> (Result<(), SseDecodeError>, Vec) { + let profile = profile(ModelApi::OpenaiResponses); + let mut emitted = Vec::new(); + let result = drive_sse( + &plexmaton_agent::RequestAttemptId::new("fixture-attempt") + .unwrap_or_else(|error| panic!("attempt: {error}")), + &profile, + stream::iter([Ok::<_, Infallible>(source.as_bytes())]), + DecodeLimits::production(), + |event| { + emitted.push(event); + std::future::ready(()) + }, + ) + .await; + (result, emitted) +} + +/// PRV-5: an action kind the record cannot name fails the step rather than being carried blind. +#[tokio::test] +async fn prv_5_an_unknown_server_tool_action_fails_the_step() { + let (result, emitted) = decode(&search_done( + r#","action":{"type":"teleport","destination":"mars"}"#, + )) + .await; + assert!( + matches!( + result, + Err(SseDecodeError::Decode(DecodeError::UnsupportedEvent(ref kind))) + if kind == "web_search_call.action:teleport" + ), + "{result:?}" + ); + assert!(emitted.is_empty(), "{emitted:?}"); +} + +/// PRV-5: a finished call without an action says nothing about what the provider did. +#[tokio::test] +async fn prv_5_a_server_tool_call_without_an_action_fails_the_step() { + let (result, _) = decode(&search_done("")).await; + assert!( + matches!( + result, + Err(SseDecodeError::Decode(DecodeError::UnsupportedEvent(ref kind))) + if kind == "web_search_call_without_action" + ), + "{result:?}" + ); +} + +/// PRV-5: a search that arrived with no query is carried as it arrived, and a query spelled twice +/// is kept once. Neither refuses the step. +#[tokio::test] +async fn prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_once() { + let (result, emitted) = decode(&search_done(r#","action":{"type":"search","query":""}"#)).await; + result.unwrap_or_else(|error| panic!("empty query should decode: {error}")); + assert!( + matches!( + emitted.as_slice(), + [ModelEvent::ServerToolCall { call, .. }, ModelEvent::Replay { .. }, ModelEvent::Usage(_), ModelEvent::Stopped(StopReason::EndOfTurn)] + if call.action == ServerToolAction::Search { queries: Vec::new() } + ), + "{emitted:?}" + ); + let (result, emitted) = decode(&search_done( + r#","action":{"type":"search","query":"rust 1.98.1","queries":["rust 1.98.1","rust release"]}"#, + )) + .await; + result.unwrap_or_else(|error| panic!("two spellings should decode: {error}")); + assert!( + matches!( + emitted.first(), + Some(ModelEvent::ServerToolCall { call, .. }) + if call.action == ServerToolAction::Search { + queries: vec!["rust 1.98.1".to_owned(), "rust release".to_owned()] + } + ), + "{emitted:?}" + ); +} diff --git a/crates/plexmaton-runtime/src/http/summary.rs b/crates/plexmaton-runtime/src/http/summary.rs index f274e12..4e9899c 100644 --- a/crates/plexmaton-runtime/src/http/summary.rs +++ b/crates/plexmaton-runtime/src/http/summary.rs @@ -1,5 +1,6 @@ //! Bounded, non-streaming semantic collection for a compaction-owned provider attempt. +use plexmaton_core::ServerToolCall; use std::{collections::BTreeMap, future}; use plexmaton_agent::{ @@ -77,6 +78,9 @@ impl SummaryCollector { .get_or_insert(CompactionFailure::ToolCallOutput); self.tool_call(position, call) } + // A summary that searched is still a summary: the text is what compaction keeps, and + // the call is recorded beside it rather than refused. + ModelEvent::ServerToolCall { position, call } => self.server_tool_call(position, call), ModelEvent::Usage(_) | ModelEvent::Stopped(_) => { unreachable!("the shared HTTP driver consumes accounting and terminal events") } @@ -183,6 +187,37 @@ impl SummaryCollector { Ok(()) } + fn server_tool_call( + &mut self, + position: ModelOutputPosition, + call: ServerToolCall, + ) -> Result<(), CompactionFailure> { + self.reserve(position)?; + let bytes = call.action.text_bytes(); + if bytes > MAX_REQUESTED_TOOL_ARGUMENT_BYTES { + return Err(CompactionFailure::OutputTooLarge); + } + let total = self + .argument_bytes + .checked_add(bytes) + .filter(|&total| total <= MAX_ASSISTANT_TOOL_ARGUMENT_BYTES) + .ok_or(CompactionFailure::OutputTooLarge)?; + let item_id = self.item_id(position); + let block = self + .blocks + .entry(position) + .or_insert_with(|| AssistantBlock::ReplayOnly { item_id }); + if !matches!(block, AssistantBlock::ReplayOnly { .. }) { + return Err(CompactionFailure::Malformed); + } + *block = AssistantBlock::ServerToolCall { + item_id: block.item_id().clone(), + call, + }; + self.argument_bytes = total; + Ok(()) + } + fn tool_call( &mut self, position: ModelOutputPosition, diff --git a/crates/plexmaton-runtime/src/runtime/model.rs b/crates/plexmaton-runtime/src/runtime/model.rs index 17e3e6d..1a03f21 100644 --- a/crates/plexmaton-runtime/src/runtime/model.rs +++ b/crates/plexmaton-runtime/src/runtime/model.rs @@ -39,7 +39,9 @@ impl ModelOutput { ModelEvent::TextDelta { delta, .. } | ModelEvent::ReasoningDelta { delta, .. } => { !delta.is_empty() } - ModelEvent::Replay { .. } | ModelEvent::Called { .. } => true, + ModelEvent::Replay { .. } + | ModelEvent::Called { .. } + | ModelEvent::ServerToolCall { .. } => true, ModelEvent::Usage(_) | ModelEvent::Stopped(_) => { unreachable!("ModelOutput excludes terminal and accounting events") } From 26bf2ff90cc835ad1ef6399c73d25946a4836cf6 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:01:55 -0400 Subject: [PATCH 13/21] fix(cli): classify a server-tool call Chat cannot carry as incompatible history The previous commit added an encode error the status snapshot's exhaustive match did not cover, so the workspace stopped building. Only four crates were compiled before that commit; the site count had covered the block and event enums and not this one. The variant belongs with opaque replay in Chat and unrepresentable order: history this dialect cannot carry. The content-free category test gains the row that proves it. Verified locally: the whole workspace builds with all targets, the full suite passes with the fence gate on, clippy is clean with -D warnings on every crate. --- crates/plexmaton-cli/src/statusline/snapshot/context.rs | 1 + crates/plexmaton-cli/src/statusline/snapshot/tests.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/plexmaton-cli/src/statusline/snapshot/context.rs b/crates/plexmaton-cli/src/statusline/snapshot/context.rs index ac47149..adcb5c8 100644 --- a/crates/plexmaton-cli/src/statusline/snapshot/context.rs +++ b/crates/plexmaton-cli/src/statusline/snapshot/context.rs @@ -67,6 +67,7 @@ impl Context { Err(ContextBudgetError::Encoding(error)) => match error { EncodeError::MissingThinkingSignature | EncodeError::OpaqueReplayInChat + | EncodeError::ServerToolCallInChat | EncodeError::UnrepresentableChatOrder => Reason::HistoryIncompatible, EncodeError::UnresolvedCollaboration | EncodeError::InvalidToolArguments diff --git a/crates/plexmaton-cli/src/statusline/snapshot/tests.rs b/crates/plexmaton-cli/src/statusline/snapshot/tests.rs index 6e8e8c2..b5e2d40 100644 --- a/crates/plexmaton-cli/src/statusline/snapshot/tests.rs +++ b/crates/plexmaton-cli/src/statusline/snapshot/tests.rs @@ -307,6 +307,7 @@ fn status_context_refusals_are_typed_and_content_free() { use plexmaton_provider::{ContextBudgetError, EncodeError}; for (error, reason) in [ (EncodeError::OpaqueReplayInChat, "history_incompatible"), + (EncodeError::ServerToolCallInChat, "history_incompatible"), ( EncodeError::MissingThinkingSignature, "history_incompatible", From ca14a56c9a68c2faf3a7d355d8d87cf97eb22fb2 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:17:54 -0400 Subject: [PATCH 14/21] fix(provider): replay a search item with its status and without its provider id Driving this branch's binary through a search turn and a follow-up found the replay shape wrong for the route in daily use. The first turn searched and answered 1.98.1; the follow-up, which replayed the recorded web_search_call items, came back "provider returned HTTP 400". Bisected against the gateway: any replayed item carrying an id fails with "content block web_search_tool_result is not supported on assistant messages", because the gateway's Responses-to-Claude translator rebuilds a search-and-result pair for an id it recognises and the upstream refuses the result half on an assistant message. The same item without an id is accepted and answered. Meta documents the id as optional on replay. So the encoder replays the item from the record with the sidecar's status and keeps the id in the sidecar only. PRV-3 records the rule and rejects the alternative, which is the shape Codex sends and the one that fails here. The fixture test asserts the id is absent, and the live follow-up now answers. --- .../phase-04-stage-33-provider-side-tools.md | 6 +++--- .agents/specs/provider-adapter.md | 6 +++++- .../spikes/provider-side-tools/meta-route.md | 18 ++++++++++++++++++ .../src/responses/request.rs | 11 +++++------ .../tests/provider_fixtures.rs | 10 ++++++---- 5 files changed, 37 insertions(+), 14 deletions(-) diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md index 088503f..99a3561 100644 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -50,9 +50,9 @@ beside it, rather than leaving a second reading in the corpus. gives. Three widths reviewed before the slice closes. 5. **Replay — implemented.** A hosted-tool item round-trips when the conversation continues, under PRV-3's - existing sidecar rules, and its provider-assigned id is nothing the harness has to preserve. - Whether the item is sent back or stripped is a decision rather than a constraint, and the test - says which. + existing sidecar rules, with its status and without its provider-assigned id, which the route in + daily use rejects and Meta documents as optional. The test says so, and a live follow-up turn + proved it. 6. **Corpus.** PRV-5's refusal and its rejected alternative are rewritten to what the code now does. PRV-6 gains the declaration. The evidence table gains a row per invariant. The spike is diff --git a/.agents/specs/provider-adapter.md b/.agents/specs/provider-adapter.md index e69ffa4..7b67ad2 100644 --- a/.agents/specs/provider-adapter.md +++ b/.agents/specs/provider-adapter.md @@ -32,7 +32,11 @@ a semantic stop is released only after the stream trailer passes validation. **PRV-3 — Semantics and replay are exact but separate.** Every exposed reasoning artifact is retained in output order. Bounded replay sidecars attach to text, reasoning or calls; a replay-only block retains a part with no visible content. Responses retains item identity/status, message phase -and content grouping, a server-tool call's identity and status, as well as encrypted reasoning. Messages retains complete thinking/signature and redacted +and content grouping, a server-tool call's identity and status, as well as encrypted reasoning. A +server-tool call replays with its status and without its identity: the route in daily use turns a +replayed id into a result block the upstream refuses on an assistant message, and Meta documents the +id as optional on replay. Rejected: replaying the id, which is the shape Codex sends and the one +that fails here. Messages retains complete thinking/signature and redacted blocks; Gemini retains signatures on their original parts, signature-only text-field absence and optional upstream call IDs. Chat retains recognized reasoning field identity and refuses unsupported structured reasoning; its diff --git a/.agents/spikes/provider-side-tools/meta-route.md b/.agents/spikes/provider-side-tools/meta-route.md index ce362d7..916f02d 100644 --- a/.agents/spikes/provider-side-tools/meta-route.md +++ b/.agents/spikes/provider-side-tools/meta-route.md @@ -70,6 +70,24 @@ translation and back intact; usage reported 2.1k in, 123 out; nothing on screen Whether the encrypted reasoning items survived that replay is not observable from the screen and is untested. +## A replayed search item must not carry its id, observed + +Measured 2026-09-21 through the same route, replaying one `web_search_call` in `input` ahead of the +assistant's answer, then asking a follow-up. + +| Replayed item | Result | +| --- | --- | +| none | 200 | +| `id` + `status` + `action` with `query` and `queries` | 400, "content block `web_search_tool_result` is not supported on `assistant` messages" | +| same without `id` | **200**, answered | +| `id` present, any action shape or none | 400, same message | + +The gateway's Responses-to-Claude translator turns any replayed item id into a fabricated result +block on the assistant message, which the upstream refuses. Meta documents the id as optional on +replay. So the encoder replays the item with its status and action and keeps the id in the sidecar +only. Found by driving this branch's binary through a search turn and a follow-up: the first +answered 1.98.1, the second returned "provider returned HTTP 400" until the id was dropped. + ## The gateway already has a native Meta route `cli-proxy-api` declares `meta-api-key` as a provider kind. `MetaKey` is a type alias of `CodexKey`, diff --git a/crates/plexmaton-provider/src/responses/request.rs b/crates/plexmaton-provider/src/responses/request.rs index bc63faa..25b74a7 100644 --- a/crates/plexmaton-provider/src/responses/request.rs +++ b/crates/plexmaton-provider/src/responses/request.rs @@ -227,8 +227,10 @@ fn encode_assistant( input.push(item); } AssistantBlock::ServerToolCall { call, .. } => { - // The item is rebuilt from the record; the sidecar adds only the provider's own - // identity and status, the way a function call's does. + // The item is rebuilt from the record and the sidecar adds the provider's status. + // Its identity is retained and not sent: the one live route turns a replayed id + // into a result block the upstream refuses on an assistant message, and Meta + // documents the id as optional on replay (PRV-3). let kind = match call.tool { ServerTool::WebSearch => "web_search_call", }; @@ -240,12 +242,9 @@ fn encode_assistant( if let Some(replay) = replay { let metadata: ResponseReplay = serde_json::from_str(replay.payload()) .map_err(|_| EncodeError::InvalidReplayItem)?; - let ResponseReplay::WebSearchCall { id, status } = metadata else { + let ResponseReplay::WebSearchCall { id: _, status } = metadata else { return Err(EncodeError::InvalidReplayItem); }; - if let Some(id) = id { - item["id"] = Value::String(id); - } if let Some(status) = status { item["status"] = serde_json::to_value(status).map_err(EncodeError::InvalidReplayJson)?; diff --git a/crates/plexmaton-provider/tests/provider_fixtures.rs b/crates/plexmaton-provider/tests/provider_fixtures.rs index 1db8653..0aad5bc 100644 --- a/crates/plexmaton-provider/tests/provider_fixtures.rs +++ b/crates/plexmaton-provider/tests/provider_fixtures.rs @@ -1007,8 +1007,10 @@ async fn prv_5_responses_web_search_call_is_carried_as_a_server_tool_call() { // The agent records the turn: both calls become blocks beside the text, and nothing is queued. complete_answer(&mut agent, &events); - // PRV-3: the next request replays each call as the item it was, identity and status from the - // sidecar, action from the record, in the order the provider produced them. + // PRV-3: the next request replays each call as the item it was, status from the sidecar and + // action from the record, in the order the provider produced them. The provider's id stays in + // the sidecar: the one live route turns a replayed id into a result block the upstream refuses, + // and Meta documents the id as optional. use serde_json::{Value, json}; let reaction = agent.handle_at( plexmaton_agent::Input::Submitted { @@ -1040,13 +1042,13 @@ async fn prv_5_responses_web_search_call_is_carried_as_a_server_tool_call() { .filter(|item| item["type"] == "web_search_call") .collect(); assert_eq!(searches.len(), 2, "{kinds:?}"); - assert_eq!(searches[0]["id"], "ws_fixture_1"); + assert!(searches[0].get("id").is_none(), "{:?}", searches[0]); assert_eq!(searches[0]["status"], "completed"); assert_eq!( searches[0]["action"], json!({"type": "search", "query": "latest stable Rust release", "queries": ["latest stable Rust release"]}) ); - assert_eq!(searches[1]["id"], "ws_fixture_2"); + assert!(searches[1].get("id").is_none(), "{:?}", searches[1]); assert_eq!( searches[1]["action"], json!({"type": "open_page", "url": "https://blog.rust-lang.org/releases/"}) From 84b416c71ba3e96c5fbc6aea5c2ee0366846a911 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:18:52 -0400 Subject: [PATCH 15/21] docs(spike): name what the gateway does with a replayed search item, with and without its id Read after the bisect rather than inferred from the error: the translator rebuilds an id-bearing item as a server_tool_use and web_search_tool_result pair on the assistant message, which the upstream refuses, and drops an id-less item entirely before Meta sees it. The second half is what makes replaying without the id harmless on this route, and what a native route would do differently. --- .agents/spikes/provider-side-tools/meta-route.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.agents/spikes/provider-side-tools/meta-route.md b/.agents/spikes/provider-side-tools/meta-route.md index 916f02d..8d513cf 100644 --- a/.agents/spikes/provider-side-tools/meta-route.md +++ b/.agents/spikes/provider-side-tools/meta-route.md @@ -82,10 +82,13 @@ assistant's answer, then asking a follow-up. | same without `id` | **200**, answered | | `id` present, any action shape or none | 400, same message | -The gateway's Responses-to-Claude translator turns any replayed item id into a fabricated result -block on the assistant message, which the upstream refuses. Meta documents the id as optional on -replay. So the encoder replays the item with its status and action and keeps the id in the sidecar -only. Found by driving this branch's binary through a search turn and a follow-up: the first +The gateway's Responses-to-Claude translator, in `convertResponsesWebSearchCallToClaudeBlocks`, +rebuilds a replayed item that carries an id as a `server_tool_use` and `web_search_tool_result` +pair on the assistant message, and the upstream refuses the result half there. Without an id it +returns nothing, so the item is dropped before Meta sees it, which is harmless: the answer text +already carries what the search found. Meta documents the id as optional on replay, and its native +route would receive the item itself. So the encoder replays the item with its status and action and +keeps the id in the sidecar only. Found by driving this branch's binary through a search turn and a follow-up: the first answered 1.98.1, the second returned "provider returned HTTP 400" until the id was dropped. ## The gateway already has a native Meta route From c6dd3a5253d685561601a011f6fb8ca587fb726b Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:29:31 -0400 Subject: [PATCH 16/21] feat(tui): show a provider-run search as a tool row in its own colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 33 slice 4. A server tool call now reaches the workspace as one event in its terminal state and one row in the tool row's grammar: `[+] web_search · `, disclosure and copy of the same facts, counted among the tools. The marker wears a new seventeenth role spent on the palette's purple slot, so who ran the call is a colour and what it was is the name; a call the provider gave up wears failure like any other, which needed the outcome typed into the record. Ending states the decoder did not know (failed, searching) are now read rather than failing deserialization. The user chose the grammar and the shade from rendered candidates and reviewed the real frames at three widths on 2026-09-21; the purple and magenta slots take the values they picked. ui-ux, ENT-2, PRV-5 and the plan say what the code now does. --- .agents/evidence/provider-adapter.md | 4 +- .agents/evidence/transcript-entry.md | 11 +- .../phase-04-stage-33-provider-side-tools.md | 16 +- .agents/specs/provider-adapter.md | 8 +- .agents/specs/transcript-entry.md | 5 +- .agents/ui-ux.md | 17 +- README.md | 2 +- .../src/journal/projection/assistant.rs | 12 +- .../src/journal/projection/live_tests.rs | 70 ++++++ .../src/model/context/tests.rs | 1 + crates/plexmaton-agent/src/step.rs | 21 +- .../plexmaton-agent/src/turn/model_input.rs | 4 +- crates/plexmaton-cli/src/collaboration.rs | 2 + crates/plexmaton-core/src/lib.rs | 35 ++- crates/plexmaton-core/src/server_tool.rs | 38 +++- .../src/responses/replay.rs | 21 +- .../tests/provider_fixtures.rs | 4 + .../tests/provider_fixtures/server_tools.rs | 49 ++++- .../examples/server_tool_preview.rs | 176 +++++++++++++++ .../plexmaton-tui/frames/server-tool-120.svg | 1 + .../plexmaton-tui/frames/server-tool-60.svg | 1 + .../plexmaton-tui/frames/server-tool-88.svg | 1 + .../frames/server-tool-medium.txt | 40 ++++ .../frames/server-tool-narrow.txt | 40 ++++ .../frames/server-tool-open-wide.txt | 40 ++++ .../plexmaton-tui/frames/server-tool-wide.txt | 40 ++++ crates/plexmaton-tui/src/content.rs | 4 +- crates/plexmaton-tui/src/content/tool.rs | 205 +++++++++++++++++- .../plexmaton-tui/src/content_transcript.rs | 2 + crates/plexmaton-tui/src/frames.rs | 200 +++++++++++++++++ crates/plexmaton-tui/src/lib.rs | 7 +- crates/plexmaton-tui/src/state/agent.rs | 23 +- .../plexmaton-tui/src/state/current_work.rs | 1 + crates/plexmaton-tui/src/state/entry.rs | 42 +++- crates/plexmaton-tui/src/state/ingest.rs | 100 +++++++-- crates/plexmaton-tui/src/state/mod.rs | 4 +- crates/plexmaton-tui/src/state/selection.rs | 45 ++++ crates/plexmaton-tui/src/theme.rs | 31 ++- .../plexmaton-tui/src/transcript/spacing.rs | 15 +- .../src/workspace/preparation.rs | 1 + 40 files changed, 1254 insertions(+), 85 deletions(-) create mode 100644 crates/plexmaton-tui/examples/server_tool_preview.rs create mode 100644 crates/plexmaton-tui/frames/server-tool-120.svg create mode 100644 crates/plexmaton-tui/frames/server-tool-60.svg create mode 100644 crates/plexmaton-tui/frames/server-tool-88.svg create mode 100644 crates/plexmaton-tui/frames/server-tool-medium.txt create mode 100644 crates/plexmaton-tui/frames/server-tool-narrow.txt create mode 100644 crates/plexmaton-tui/frames/server-tool-open-wide.txt create mode 100644 crates/plexmaton-tui/frames/server-tool-wide.txt diff --git a/.agents/evidence/provider-adapter.md b/.agents/evidence/provider-adapter.md index 35566a7..3d54ddd 100644 --- a/.agents/evidence/provider-adapter.md +++ b/.agents/evidence/provider-adapter.md @@ -10,6 +10,6 @@ resolves every one against the source, so a renamed or deleted test fails the bu | PRV-2 | Both fixture round trips, `prv_2_production_tool_argument_limit_remains_64_kibibytes`, `parallel_calls_share_one_aggregate_argument_bound`, `reverse_parallel_call_completion_is_sorted_before_dispatch`, `prv_2_both_protocols_bound_tool_identity_before_emission`, `assistant_output_rechecks_aggregate_text_and_tool_identity_bounds`, `oversized_semantic_text_fails_before_canonical_commit`, `maximal_valid_assistant_output_fits_the_journal_line_envelope`, `prv_2_rejects_duplicate_chat_tool_call_ids_before_emission`, `prv_2_rejects_incremental_duplicate_responses_tool_call_ids`, `prv_2_responses_counts_incrementally_completed_calls_toward_the_step_bound`, `prv_2_rejects_tool_arguments_before_they_can_reach_admission`, `prv_2_bounds_even_empty_responses_output_items`, `prv_2_responses_text_done_confirms_deltas_or_supplies_the_only_copy`, `prv_2_stopped_is_withheld_until_the_stream_trailer_is_valid`, `prv_2_sse_framing_rejects_partial_utf8`, `prv_2_messages_rejects_unfinished_mismatched_and_oversized_blocks`, `prv_2_gemini_call_ids_are_scoped_and_parallel_signatures_keep_their_part`, `prv_2_gemini_rejects_incomplete_duplicate_and_oversized_content`, `server_tool_calls_share_the_tool_argument_bound` | | PRV-3 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing`, `prv_3_a_degraded_call_and_its_result_keep_one_identity`, `prv_3_a_degraded_gemini_call_carries_neither_signature_nor_upstream_id`, `prv_3_an_output_that_was_only_its_replay_degrades_to_no_message`, `prv_3_an_id_every_dialect_accepts_is_carried_through_unchanged`, `prv_3_an_id_the_destination_would_reject_becomes_one_it_accepts`, `prv_3_distinct_rewritten_ids_stay_distinct`, `prv_3_replay_route_owner_encoding_is_unambiguous`, `prv_3_rejects_opaque_replay_before_step_state_can_grow`, `assistant_output_round_trips_order_and_redacts_replay`, `replay_only_wire_without_a_payload_is_rejected`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `provider_replay_is_named_and_bounded_before_turn_state_can_retain_it`, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_cancelled_calls_leave_no_dangling_replay`, `prv_3_chat_reasoning_aliases_round_trip_without_renaming`, `prv_3_chat_accepts_reasoning_details_that_only_restate_the_plain_delta`, `prv_3_gemini_signature_only_and_early_signature_parts_are_replayable`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation`, `prv_3_unsigned_reasoning_in_a_tool_turn_is_refused_by_messages_and_omitted_by_responses`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call` | | PRV-4 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools` proves stateless full-record replay without a response ID, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation` | -| PRV-5 | `http_rate_limit_is_typed_and_keeps_retry_after`, `context_error_is_classified_by_wire_code`, `prv_5_responses_done_only_refusal_is_visible_and_typed`, and both fixture completion reasons, `prv_5_chat_rejects_structured_or_conflicting_reasoning`, `prv_5_messages_context_limits_and_refusals_keep_final_usage`, `native_stream_rate_limits_are_typed`, `gemini_declared_failures_preserve_diagnostics_without_dispatching_calls`, `messages_usage_refuses_unaccounted_server_tools`, `prv_5_gemini_policy_and_unsupported_image_finishes_are_distinct`, `stream_provider_errors_keep_their_category_and_observed_usage`, `dispatched_http_failures_preserve_their_terminal_measurements`, `provider_failure_reopens_as_the_same_non_retryable_outcome`, `prv_5_empty_additive_fields_preserve_text_and_populated_fields_fail`, `prv_5_chat_admits_gateway_accounting_and_still_refuses_everything_else`, `prv_5_messages_pause_turn_is_explicitly_unsupported`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call`, `prv_5_an_unknown_server_tool_action_fails_the_step`, `prv_5_a_server_tool_call_without_an_action_fails_the_step`, `prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_once` | -| PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md), `prv_6_hosted_tools_are_declared_per_model_and_absent_by_default`, `prv_6_hosted_tool_declarations_fail_closed`, `prv_6_hosted_tool_declarations_need_a_dialect_spelling`, `prv_6_declared_hosted_tools_are_spelled_by_the_dialect_and_absent_otherwise` | +| PRV-5 | `http_rate_limit_is_typed_and_keeps_retry_after`, `context_error_is_classified_by_wire_code`, `prv_5_responses_done_only_refusal_is_visible_and_typed`, and both fixture completion reasons, `prv_5_chat_rejects_structured_or_conflicting_reasoning`, `prv_5_messages_context_limits_and_refusals_keep_final_usage`, `native_stream_rate_limits_are_typed`, `gemini_declared_failures_preserve_diagnostics_without_dispatching_calls`, `messages_usage_refuses_unaccounted_server_tools`, `prv_5_gemini_policy_and_unsupported_image_finishes_are_distinct`, `stream_provider_errors_keep_their_category_and_observed_usage`, `dispatched_http_failures_preserve_their_terminal_measurements`, `provider_failure_reopens_as_the_same_non_retryable_outcome`, `prv_5_empty_additive_fields_preserve_text_and_populated_fields_fail`, `prv_5_chat_admits_gateway_accounting_and_still_refuses_everything_else`, `prv_5_messages_pause_turn_is_explicitly_unsupported`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call`, `prv_5_an_unknown_server_tool_action_fails_the_step`, `prv_5_a_server_tool_call_without_an_action_fails_the_step`, `prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_once`, `prv_5_a_server_tool_calls_outcome_is_typed_from_its_status` | +| PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md), `prv_6_hosted_tools_are_declared_per_model_and_absent_by_default`, `prv_6_hosted_tool_declarations_fail_closed`, `prv_6_hosted_tool_declarations_need_a_dialect_spelling`, `prv_6_declared_hosted_tools_are_spelled_by_the_dialect_and_absent_otherwise`, `the_tool_name_is_its_configuration_spelling` | | PRV-7 | `event_guard_rejects_an_unterminated_event_at_the_bound`, `event_guard_rejects_one_oversized_transport_chunk`, `prv_2_and_prv_7_reject_unbounded_or_incomplete_provider_input`, `prv_7_model_config_cannot_widen_runtime_output_memory`, `interrupt_and_shutdown_cancel_and_join_the_exact_provider_task`, `native_stream_cancellation_preserves_observed_usage` | diff --git a/.agents/evidence/transcript-entry.md b/.agents/evidence/transcript-entry.md index 9ba7bac..1b5d2e6 100644 --- a/.agents/evidence/transcript-entry.md +++ b/.agents/evidence/transcript-entry.md @@ -7,9 +7,14 @@ resolves every one against the source, so a renamed or deleted test fails the bu | Invariant | Proven by | | --- | --- | | ENT-1 | `every_transcript_identity_is_new_and_names_its_agent`, `every_transcript_category_enters_one_ordered_projection`, `cancelled_submit_publishes_staged_events_before_projected_delegation`, `retained_projection_stays_with_its_root_and_applies_once`, `retained_projection_rejects_a_replacement_for_the_same_conversation`, `a_letter_reaches_both_conversations_attributed_to_its_sender`, `each_side_of_one_item_names_the_other_end`, `copying_a_conversation_preserves_interleaved_entry_sources`, `an_entry_identity_cannot_move_between_agents`, `selected_branches_place_or_suffix_one_delegated_reference_without_duplication`, `a_direct_link_wins_over_a_later_fallback_anchor`, `durable_links_place_shared_rows_and_legacy_rows_keep_a_stable_suffix`, `real_writers_reopen_durable_shared_rows_in_their_live_order_twice`, `later_child_activation_skips_replayed_entries_and_accepts_new_work`, `passive_child_activation_applies_a_real_same_item_recovery_revision_once`, `orphaned_root_ingress_persists_one_link_before_live_projection`, `selected_session_placement_rejects_a_link_from_another_announced_agent`, `shuffled_tool_completions_update_their_original_entries`, `interleaved_text_and_tools_keep_their_positions_when_tools_finish_out_of_order`, `non_chat_text_roles_have_distinct_named_treatments`, `reasoning_terminal_newlines_share_measurement_and_paint`, `reasoning_spacing_keeps_pointer_copy_visible_and_message_copy_exact`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `the_remaining_transcript_grammar_frames_match_their_fixtures` with the `transcript-grammar-*` frames; `scripts/smoke-delegate.py` proves ordered live/reopen task, mail and display-label rows | -| ENT-2 | `tool_transitions_do_not_reuse_stale_prepared_status`, `a_step_that_asked_for_tools_dispatches_them_and_waits`, `production_tool_lifecycles_replay_as_one_entry_each`, `tool_lifecycle_allows_only_forward_declared_transitions`, `tool_updates_refuse_revision_gaps_and_invalid_transitions`, `results_are_assembled_in_the_order_the_model_asked_and_not_the_order_they_finished`, `every_tool_status_is_one_named_logical_line`, `a_tool_transition_remeasures_only_its_original_entry` | -| ENT-3 | `every_event_variant_survives_a_json_round_trip`, `two_fresh_projections_of_the_same_envelopes_are_equal`, `rejected_event_does_not_block_the_rest_of_the_stream`, crate-graph gate | -| ENT-4 | `command_tool_copy_keeps_the_complete_invocation_context`, `cmd_1_command_detail_preserves_multiline_source_and_context`, `command_modal_copies_exact_source_and_returns_to_the_pending_approval`, `tool_disclosure_never_creates_a_copy_range_or_reverses_detail_at_three_widths`, `selected_diff_keeps_semantic_colors_and_reuses_prepared_rows_at_three_widths`, Presentation production/bounds: `bounded_presentation_text_carries_exact_omission_metadata`, `tool_status_updates_accumulate_invocation_and_outcome_presentation`, `cancellation_before_admission_has_outcome_without_invocation`, `maximum_valid_edit_retains_a_complete_bounded_patch`, `file_observation_survives_the_runtime_boundary_into_an_approved_edit`, `maximal_command_result_stays_bounded_in_the_next_model_request`; disclosure/copy: `every_tool_status_can_disclose_the_same_typed_detail`, `a_long_letter_is_one_row_until_ctrl_o_opens_it`, `a_heading_spends_columns_and_never_more_than_it_has`, `every_ellipsis_is_a_promise_disclosure_keeps`, `ctrl_o_opens_the_selections_focus_entry_in_place_at_each_drawn_width`, `pointer_and_ctrl_o_toggle_the_same_item_while_drag_cancels_disclosure` and, for the half of that parity a letter depends on, `a_click_opens_a_letter_the_same_way_ctrl_o_does`, `tool_completion_preserves_the_users_open_state`, `tool_copy_preserves_every_retained_source_in_producer_order`, `tool_copy_is_identical_when_compact_open_resized_scrolled_and_repainted`, `the_open_tool_frames_match_their_fixtures` with the `tool-open-*` frames; diff treatment: `canonical_diff_lines_keep_markers_and_receive_bounded_semantic_roles`, `opaque_maximum_diff_line_degrades_to_exact_plain_text`, `the_remaining_transcript_grammar_frames_match_their_fixtures` | +| ENT-2 | `tool_transitions_do_not_reuse_stale_prepared_status`, `a_step_that_asked_for_tools_dispatches_them_and_waits`, `production_tool_lifecycles_replay_as_one_entry_each`, `tool_lifecycle_allows_only_forward_declared_transitions`, `tool_updates_refuse_revision_gaps_and_invalid_transitions`, `results_are_assembled_in_the_order_the_model_asked_and_not_the_order_they_finished`, `every_tool_status_is_one_named_logical_line`, `a_tool_transition_remeasures_only_its_original_entry`, `a_server_tool_call_appears_finished_and_never_transitions`, `a_server_tool_row_keeps_the_tool_grammar_and_claims_only_what_the_route_reported`, `the_server_tool_frames_match_their_fixtures` with the `server-tool-*` frames | +| ENT-3 | `every_event_variant_survives_a_json_round_trip`, `two_fresh_projections_of_the_same_envelopes_are_equal`, `rejected_event_does_not_block_the_rest_of_the_stream`, crate-graph gate, `ent_3_a_server_tool_call_is_the_same_event_live_and_on_replay` | +| ENT-4 | `command_tool_copy_keeps_the_complete_invocation_context`, `cmd_1_command_detail_preserves_multiline_source_and_context`, `command_modal_copies_exact_source_and_returns_to_the_pending_approval`, `tool_disclosure_never_creates_a_copy_range_or_reverses_detail_at_three_widths`, `selected_diff_keeps_semantic_colors_and_reuses_prepared_rows_at_three_widths`, Presentation production/bounds: `bounded_presentation_text_carries_exact_omission_metadata`, `tool_status_updates_accumulate_invocation_and_outcome_presentation`, `cancellation_before_admission_has_outcome_without_invocation`, `maximum_valid_edit_retains_a_complete_bounded_patch`, `file_observation_survives_the_runtime_boundary_into_an_approved_edit`, `maximal_command_result_stays_bounded_in_the_next_model_request`; disclosure/copy: `every_tool_status_can_disclose_the_same_typed_detail`, `a_long_letter_is_one_row_until_ctrl_o_opens_it`, `a_heading_spends_columns_and_never_more_than_it_has`, `every_ellipsis_is_a_promise_disclosure_keeps`, `ctrl_o_opens_the_selections_focus_entry_in_place_at_each_drawn_width`, `pointer_and_ctrl_o_toggle_the_same_item_while_drag_cancels_disclosure` and, for the half of that parity a letter depends on, `a_click_opens_a_letter_the_same_way_ctrl_o_does`, `tool_completion_preserves_the_users_open_state`, `tool_copy_preserves_every_retained_source_in_producer_order`, `tool_copy_is_identical_when_compact_open_resized_scrolled_and_repainted`, `the_open_tool_frames_match_their_fixtures` with the `tool-open-*` frames; diff treatment: `canonical_diff_lines_keep_markers_and_receive_bounded_semantic_roles`, `opaque_maximum_diff_line_degrades_to_exact_plain_text`, `the_remaining_transcript_grammar_frames_match_their_fixtures`, `a_server_tool_row_discloses_what_the_route_reported`, `server_tool_copy_is_what_the_route_reported` | + +The user chose the server tool row's grammar and colour on 2026-09-21 from rendered candidates and +reviewed the real frames at [120](../../crates/plexmaton-tui/frames/server-tool-120.svg), +[88](../../crates/plexmaton-tui/frames/server-tool-88.svg) and +[60](../../crates/plexmaton-tui/frames/server-tool-60.svg). The user approved disclosure-only tool clicks on 2026-09-06. Reviewed real frames at [120](../../crates/plexmaton-tui/frames/tool-disclosure-120.svg), diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md index 99a3561..51f921c 100644 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -5,7 +5,7 @@ | Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 33 | | Contract | PRV-1/PRV-3/PRV-5/PRV-6, [tool-admission](../specs/tool-admission.md), [ui-ux](../ui-ux.md) transcript grammar | | Evidence | [Provider-side tools spike](../spikes/provider-side-tools/README.md) | -| Status | Slices 1–3 and 5 implemented and locally verified; slice 4 open; slice 6 open for the row's spec and the spike's promotion | +| Status | Slices 1–5 implemented and locally verified; slice 6 open for the spike's promotion and this plan's deletion | ## Outcome @@ -43,11 +43,12 @@ beside it, rather than leaving a second reading in the corpus. against. Usage stops failing a step when a provider reports non-zero hosted-tool counts, and accounts them instead. -4. **Transcript.** The event reaches the workspace as a row naming what was searched or opened. - What else the row can show is the route's to give: some return the queries alone, others add - citations or the results. The row shows what its route returned and claims nothing further; - the [spike](../spikes/provider-side-tools/meta-route.md) records what each measured route - gives. Three widths reviewed before the slice closes. +4. **Transcript — implemented.** The call reaches the workspace as one event in its terminal state + and one row in the tool row's grammar, coloured for who ran it (ui-ux §transcript grammar, + ENT-2). The row shows what its route returned and claims nothing further; the + [spike](../spikes/provider-side-tools/meta-route.md) records what each measured route gives. + The user chose the grammar and colour from rendered candidates and reviewed the real frames at + three widths on 2026-09-21. 5. **Replay — implemented.** A hosted-tool item round-trips when the conversation continues, under PRV-3's existing sidecar rules, with its status and without its provider-assigned id, which the route in @@ -68,9 +69,6 @@ that needs two turns, and because the arm it picks is informed by what the row h ## Open, to settle inside the stage -- Does a provider-side row reuse the existing tool row with a marker naming the provider as the - actor, or is it a row of its own? Rendered frames decide this, not prose, and the contract owns - the answer. - Where does hosted-tool spend belong in the cost surface? It is provider-side work the owner did not select a model for, and the usage counter names requests rather than tokens. diff --git a/.agents/specs/provider-adapter.md b/.agents/specs/provider-adapter.md index 7b67ad2..e63cdcd 100644 --- a/.agents/specs/provider-adapter.md +++ b/.agents/specs/provider-adapter.md @@ -94,9 +94,11 @@ Rejected: refusing it as unexamined, which was right in principle and in practic completed answer at its last chunk, on every gateway that sends it. A tool the provider ran on its own side is carried, not refused. Responses' `web_search_call` becomes a server-tool call in the record: what the provider did, a search, an opened page or a find -within one, bounded the way tool arguments are, with the provider's own item identity and status -kept as replay metadata. It reaches no admission and no scheduler, because the call already -happened inside the model call the owner authorised by declaring the route accepts it. An action +within one, bounded the way tool arguments are, and how it ended, completed or failed, typed from +the item's status; a done item that still claims to be running fails the step. The provider's own +item identity and wire status stay replay metadata. It reaches no admission and no scheduler, +because the call already happened inside the model call the owner authorised by declaring the +route accepts it. An action kind this harness cannot name fails the step; a search that arrived with no query is carried as it arrived; the progress markers a route streams while searching carry nothing the finished item lacks and are ignored. Messages' `server_tool_use` block and a non-zero server-tool count remain diff --git a/.agents/specs/transcript-entry.md b/.agents/specs/transcript-entry.md index 81e87e2..0ef8c25 100644 --- a/.agents/specs/transcript-entry.md +++ b/.agents/specs/transcript-entry.md @@ -37,7 +37,10 @@ streaming and after finalization; internal line breaks and SEL-2's original sour each accepted lifecycle transition advances exactly one revision on the original entry. Display order is first appearance, execution completion order is event order, and model result order stays the batch's model-call order (APV-5). Rejected: moving a completed call to the tail or using its -completion position as model order. +completion position as model order. A server tool call is one entry too, and appears once, in the +state it ended in, at revision zero: nothing here queued or dispatched it, so it never transitions, +and a second report of the same entry is a duplicate the notice log keeps. It is drawn in the tool +row's grammar, and the row and its colour are the contract's (ui-ux §transcript grammar). **ENT-3 — Replay is pure reduction.** Feeding the same ordered envelopes to a fresh projection produces the same state and can only mutate that projection; it cannot contact a provider, consult diff --git a/.agents/ui-ux.md b/.agents/ui-ux.md index 833eb6e..8c99663 100644 --- a/.agents/ui-ux.md +++ b/.agents/ui-ux.md @@ -61,6 +61,7 @@ These terms are used identically in product copy, architecture, code, and tests. | Composer menu | The popup above the composer: Skills for `$`, Commands for `/`. The draft is its query | | Drawer | The workspace's own surface, pulled from the top edge by `Ctrl-P`. Its title, `Workspace`, is the addressee; it holds what outlives the process, as pages, never Commands | | Page | A view inside the Drawer: Configuration, Permissions, opened in place | +| Server tool | A tool the provider runs on its own side inside one model call, declared per model in configuration. It is reported once it has ended; nothing here queues, admits or dispatches it | An alias such as `B` or `reviewer` is a display label, never durable identity. A pane is a layout presentation, not a Conversation. A widget is a Rust rendering component, not a synonym for an entry @@ -255,8 +256,8 @@ terminals with the fewest rows. finds the one row that needs them without reading the rest. Rejected: restrained colour with hierarchy carried by spacing alone, which made every row look equally important and left nothing to navigate by. -- Agent, mail, tool, reasoning, artifact, warning, and error content are distinguishable by - colour, and carry a marker or name as well where that marker forms a scannable column. +- Agent, mail, tool, server tool, reasoning, artifact, warning, and error content are + distinguishable by colour, and carry a marker or name as well where that marker forms a scannable column. Rejected: requiring every distinction to survive without colour, which capped the design at what a colourless terminal could express. - Explicit plaintext reasoning is named and visually quiet; system text is named and muted; @@ -269,7 +270,7 @@ terminals with the fewest rows. selection treatment without erasing those roles; unknown diff text remains undecorated source. - Typeset math is the primary presentation; source is an interaction layer for inspect and copy, and a clear failure representation. -- Workspace colour is sixteen semantic roles; widgets name a role, never a terminal colour, and +- Workspace colour is seventeen semantic roles; widgets name a role, never a terminal colour, and a palette is a complete assignment of them. Colour says what a thing is and weight says what reads first; the row `Enter` acts on carries both (`Chosen`), and what stays quiet is a low-saturation hue with no weight on it. Document italic is a separate vocabulary with its own @@ -278,7 +279,7 @@ terminals with the fewest rows. - **A palette is data, and colour is two layers.** Below, twelve slots named the way a terminal names them: a ground ramp — `ground`, `line`, `muted`, `text` — and eight hues around a closed wheel — `red`, `orange`, `yellow`, `green`, `cyan`, `blue`, `purple`, `magenta`. A theme assigns - those twelve and nothing else. Above, the sixteen roles map onto slots and carry their weight; + those twelve and nothing else. Above, the seventeen roles map onto slots and carry their weight; that mapping is the product's, so no theme can make a failure read as a success. - A surface holding a conversation, or the roster of them, carries its own hue on its border: muted for the roster, blue for the user's own, cyan for a delegate's. Focus is that hue at full @@ -512,6 +513,14 @@ full of concurrent agents stays navigable: - Reasoning summary, named `reasoning` and visually quiet - Tool call: `[ ] queued`, `[~] running`, `[?] approval required`, `[+] succeeded`, `[!] failed`, `[x] denied`, or `[-] cancelled`; retained invocation and outcome disclose beneath the same row +- Server tool call: the tool row's grammar, with the tool's name after the marker and what the + route reported after the `·`: the query, `opened` and the page, or the pattern and the page it + was sought `in`, and nothing further; a search reported without a query is the name alone. The + marker wears the server-tool colour when the call ended well and failure's when it did not. It + appears finished and never transitions. What the route reported discloses beneath the row. The + user chose the grammar and its colour from rendered candidates on 2026-09-21. Rejected: a row + family of its own, which gave up the marker column and the disclosure tools already have; the + plain succeeded colour, which read as a tool run inside the fence - Diff with original `+`/`-` markers, and artifact - Agent mail, and Main-authored task updates, each entering both conversations it names and saying in a word which side its row is: `sent to`, `received from`, `assigned to`, `assigned by`, naming diff --git a/README.md b/README.md index 8a7615d..1732a7e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Plexmaton is App Automaton's Rust AI coding assistant for the terminal. Choose a ## Why Plexmaton? - **Keep the work in view.** Streaming answers, tool activity and approvals share a responsive workspace with expandable details. -- **Choose your models.** Use OpenAI Responses or Chat Completions, both exercised daily. The Anthropic Messages and Gemini adapters are implemented and fixture-tested, not yet run against a live endpoint. +- **Choose your models.** Use OpenAI Responses or Chat Completions, both exercised daily. The Anthropic Messages and Gemini adapters are implemented and fixture-tested, not yet run against a live endpoint. A model may declare the provider's own web search, and the transcript shows each search the provider ran, in its own colour. - **Stay in control.** Approve an action once, remember a Session or Project permission, and review or revoke it in the Drawer. - **Carry your context forward.** Saved conversations and automatic compaction preserve source history. Reusable `SKILL.md` instructions bring your workflows into the conversation. - **Read comfortably.** Pastel Markdown, [syntax-highlighted code](.agents/specs/markdown.md#code-theme), and native math with hats and Chinese labels. Reasoning, tool groups and messages stay compact; copy follows selection. diff --git a/crates/plexmaton-agent/src/journal/projection/assistant.rs b/crates/plexmaton-agent/src/journal/projection/assistant.rs index 8c73df3..4c40f94 100644 --- a/crates/plexmaton-agent/src/journal/projection/assistant.rs +++ b/crates/plexmaton-agent/src/journal/projection/assistant.rs @@ -68,11 +68,17 @@ impl Projector { ); calls.push(call_id); } - // Recorded for replay and for the row the contract shapes; projected as nothing yet. + // One event in its terminal state, the same one the live step emitted (ENT-3). + AssistantBlock::ServerToolCall { item_id, call } => { + self.emit(ConversationEvent::ServerToolCalled { + agent_id: agent_id.clone(), + item_id: item_id.clone(), + call: call.clone(), + })?; + } AssistantBlock::Text { .. } | AssistantBlock::Reasoning { .. } - | AssistantBlock::ReplayOnly { .. } - | AssistantBlock::ServerToolCall { .. } => {} + | AssistantBlock::ReplayOnly { .. } => {} } } if calls.is_empty() { diff --git a/crates/plexmaton-agent/src/journal/projection/live_tests.rs b/crates/plexmaton-agent/src/journal/projection/live_tests.rs index 9685148..3a92ce3 100644 --- a/crates/plexmaton-agent/src/journal/projection/live_tests.rs +++ b/crates/plexmaton-agent/src/journal/projection/live_tests.rs @@ -40,3 +40,73 @@ fn jrn_5_canonical_live_turn_and_journal_replay_have_equal_model_context() { assert_eq!(projection.request().atoms, live.record()); } + +/// ENT-3: a call the provider ran is one event in its terminal state, and the journal's replay says +/// the same event the live step said, so a reopened conversation shows the same row. +#[test] +fn ent_3_a_server_tool_call_is_the_same_event_live_and_on_replay() { + use plexmaton_core::{ + ConversationEvent, ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus, + }; + let agent_id = agent(); + let mut live = Agent::new(agent_id.clone()); + let _announcement = live.announce("Plexmaton"); + let submitted = live.handle(Input::Submitted { + text: "latest rust?".to_owned(), + }); + let step_id = match submitted.effects.as_slice() { + [Effect::CallModel(call)] => call.step_id.clone(), + other => panic!("expected one model call, got {other:?}"), + }; + let call = ServerToolCall { + tool: ServerTool::WebSearch, + action: ServerToolAction::Search { + queries: vec!["latest stable Rust release".to_owned()], + }, + status: ServerToolStatus::Failed, + }; + let searched = live.handle(Input::Streamed { + step_id: step_id.clone(), + event: ModelEvent::ServerToolCall { + position: ModelOutputPosition::new(0, 0), + call: call.clone(), + }, + }); + let _answer = live.handle(Input::Streamed { + step_id: step_id.clone(), + event: ModelEvent::TextDelta { + position: ModelOutputPosition::new(0, 1), + delta: "1.98.1".to_owned(), + }, + }); + let _stopped = live.handle(Input::Streamed { + step_id, + event: ModelEvent::Stopped(StopReason::EndOfTurn), + }); + let live_events: Vec<_> = searched + .events + .iter() + .map(|envelope| envelope.event.clone()) + .filter(|event| matches!(event, ConversationEvent::ServerToolCalled { .. })) + .collect(); + assert!( + matches!( + live_events.as_slice(), + [ConversationEvent::ServerToolCalled { agent_id: live_agent, call: live_call, .. }] + if live_agent == &agent_id && live_call == &call + ), + "{live_events:?}" + ); + + let projection = live + .journal() + .project(&head("main")) + .unwrap_or_else(|error| panic!("project live journal: {error:?}")); + let replayed: Vec<_> = projection + .events() + .iter() + .map(|envelope| envelope.event.clone()) + .filter(|event| matches!(event, ConversationEvent::ServerToolCalled { .. })) + .collect(); + assert_eq!(replayed, live_events); +} diff --git a/crates/plexmaton-agent/src/model/context/tests.rs b/crates/plexmaton-agent/src/model/context/tests.rs index e990e70..eef6e26 100644 --- a/crates/plexmaton-agent/src/model/context/tests.rs +++ b/crates/plexmaton-agent/src/model/context/tests.rs @@ -262,6 +262,7 @@ fn server_tool_calls_share_the_tool_argument_bound() { action: ServerToolAction::Search { queries: vec!["q".repeat(bytes)], }, + status: plexmaton_core::ServerToolStatus::Completed, }, }; assert!( diff --git a/crates/plexmaton-agent/src/step.rs b/crates/plexmaton-agent/src/step.rs index af596de..be346d6 100644 --- a/crates/plexmaton-agent/src/step.rs +++ b/crates/plexmaton-agent/src/step.rs @@ -254,9 +254,12 @@ impl Step { } /// Records a call the provider already ran. It takes no call identity of ours and joins no - /// batch: nothing here will be dispatched, so nothing here can be reused or left undone. + /// batch: nothing here will be dispatched, so nothing here can be reused or left undone. The + /// workspace hears of it now, as one event in its terminal state, the way replay will say it. pub(crate) fn note_server_tool( &mut self, + record: &mut Record, + reaction: &mut Reaction, position: ModelOutputPosition, call: ServerToolCall, ) -> Result<(), StepAssemblyError> { @@ -276,20 +279,32 @@ impl Step { TranscriptRole::Assistant, position, ); + let pending = PendingOutput::ServerToolCall { + item_id: item_id.clone(), + call: call.clone(), + }; match self.outputs.entry(position) { Entry::Vacant(entry) => { - entry.insert(PendingOutput::ServerToolCall { item_id, call }); + entry.insert(pending); } // The adapter's replay for the same item may have landed first and reserved the // position as replay-only; the call it belongs to takes the position over. Entry::Occupied(mut entry) if matches!(entry.get(), PendingOutput::ReplayOnly { .. }) => { - entry.insert(PendingOutput::ServerToolCall { item_id, call }); + entry.insert(pending); } Entry::Occupied(_) => return Err(StepAssemblyError::ConflictingPosition), } self.tool_argument_bytes = tool_argument_bytes; + record.emit( + reaction, + ConversationEvent::ServerToolCalled { + agent_id: record.agent_id().clone(), + item_id, + call, + }, + ); Ok(()) } diff --git a/crates/plexmaton-agent/src/turn/model_input.rs b/crates/plexmaton-agent/src/turn/model_input.rs index 0bcccba..1f78cf8 100644 --- a/crates/plexmaton-agent/src/turn/model_input.rs +++ b/crates/plexmaton-agent/src/turn/model_input.rs @@ -107,7 +107,9 @@ impl Agent { } ModelEvent::ServerToolCall { position, call } => { let result = match &mut self.turn { - Turn::Streaming { step, .. } => step.note_server_tool(position, call), + Turn::Streaming { step, .. } => { + step.note_server_tool(&mut self.record, reaction, position, call) + } Turn::Idle | Turn::Working { .. } => return, }; if let Err(error) = result { diff --git a/crates/plexmaton-cli/src/collaboration.rs b/crates/plexmaton-cli/src/collaboration.rs index 408f15f..cadc27a 100644 --- a/crates/plexmaton-cli/src/collaboration.rs +++ b/crates/plexmaton-cli/src/collaboration.rs @@ -492,6 +492,7 @@ fn item_of(event: &ConversationEvent) -> Option { | ConversationEvent::TranscriptDelta { item_id, .. } | ConversationEvent::TranscriptItemFinalized { item_id, .. } | ConversationEvent::ToolCallChanged { item_id, .. } + | ConversationEvent::ServerToolCalled { item_id, .. } | ConversationEvent::TaskAssigned { item_id, .. } | ConversationEvent::MailDelivered { item_id, .. } | ConversationEvent::HandoffCompleted { item_id, .. } @@ -518,6 +519,7 @@ const fn forwarded(event: &ConversationEvent) -> bool { | ConversationEvent::TranscriptDelta { .. } | ConversationEvent::TranscriptItemFinalized { .. } | ConversationEvent::ToolCallChanged { .. } + | ConversationEvent::ServerToolCalled { .. } | ConversationEvent::AttentionRequested { .. } | ConversationEvent::AttentionResolved { .. } | ConversationEvent::ArtifactAnnounced { .. } diff --git a/crates/plexmaton-core/src/lib.rs b/crates/plexmaton-core/src/lib.rs index 6765d3f..a5d5d87 100644 --- a/crates/plexmaton-core/src/lib.rs +++ b/crates/plexmaton-core/src/lib.rs @@ -40,7 +40,7 @@ pub use tree_snapshot::{ pub use tree_source::{MAX_TREE_SOURCE_BYTES, TreeSourceError, TreeSourceRequest}; pub use reasoning::ReasoningEffort; -pub use server_tool::{ServerTool, ServerToolAction, ServerToolCall}; +pub use server_tool::{ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus}; pub use transcript::{ CommandInvocation, ToolCallStatus, ToolDetail, ToolPresentation, TranscriptRole, }; @@ -62,6 +62,7 @@ impl ConversationEvent { | Self::TranscriptDelta { agent_id, .. } | Self::TranscriptItemFinalized { agent_id, .. } | Self::ToolCallChanged { agent_id, .. } + | Self::ServerToolCalled { agent_id, .. } | Self::AttentionRequested { agent_id, .. } | Self::AttentionResolved { agent_id, .. } | Self::TaskAssigned { agent_id, .. } @@ -376,6 +377,18 @@ pub enum ConversationEvent { /// Bounded semantic detail used by open and copy presentations. presentation: ToolPresentation, }, + /// A tool the provider ran on its own side inside one model call, reported once it ended. + /// + /// Nothing here was queued, admitted or dispatched, so the entry appears in its terminal state + /// and never transitions. It says what the route reported and claims nothing further. + ServerToolCalled { + /// Agent whose model call the provider ran the tool inside. + agent_id: AgentId, + /// Transcript position assigned when the call was reported. + item_id: TranscriptItemId, + /// The tool, what it did with it, and how it ended. + call: ServerToolCall, + }, /// A background agent needs a user decision. /// /// Delivery must never move focus or open a modal surface; the item joins the Attention queue. @@ -490,9 +503,10 @@ pub struct ConversationEventEnvelope { mod tests { use super::{ AgentId, AgentStatus, ApprovalDecision, ApprovalId, AttentionId, AttentionRequest, - ConversationEvent, ConversationEventEnvelope, EventSequence, IdError, MailId, TokenCounts, - TokenUsage, ToolCallId, ToolCallStatus, ToolCapability, ToolDetail, ToolPresentation, - TranscriptItemId, TranscriptRole, TurnId, + ConversationEvent, ConversationEventEnvelope, EventSequence, IdError, MailId, ServerTool, + ServerToolAction, ServerToolCall, ServerToolStatus, TokenCounts, TokenUsage, ToolCallId, + ToolCallStatus, ToolCapability, ToolDetail, ToolPresentation, TranscriptItemId, + TranscriptRole, TurnId, }; fn agent(value: &str) -> AgentId { @@ -591,6 +605,19 @@ mod tests { }), }, }, + ConversationEvent::ServerToolCalled { + agent_id: agent("agent-a"), + item_id: TranscriptItemId::new("item-search-1") + .unwrap_or_else(|error| panic!("invalid fixture: {error}")), + call: ServerToolCall { + tool: ServerTool::WebSearch, + action: ServerToolAction::FindInPage { + url: "https://example.test/δ".into(), + pattern: "汉字".into(), + }, + status: ServerToolStatus::Failed, + }, + }, ConversationEvent::AttentionRequested { agent_id: agent("agent-b"), attention_id: AttentionId::new("attention-1") diff --git a/crates/plexmaton-core/src/server_tool.rs b/crates/plexmaton-core/src/server_tool.rs index ce5b4aa..3c105f9 100644 --- a/crates/plexmaton-core/src/server_tool.rs +++ b/crates/plexmaton-core/src/server_tool.rs @@ -15,6 +15,29 @@ pub enum ServerTool { WebSearch, } +impl ServerTool { + /// The one spelling this tool has: what configuration declares and the transcript shows. + #[must_use] + pub const fn name(self) -> &'static str { + match self { + Self::WebSearch => "web_search", + } + } +} + +/// How a server tool call ended, as the provider reported it. +/// +/// A call reaches the record only after it has ended: the provider ran it inside the model call +/// and reports the result, so there is no queued or running state here for anything to project. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ServerToolStatus { + /// The provider finished the call and the model went on with what it found. + Completed, + /// The provider gave the call up and the model went on without its result. + Failed, +} + /// What a server tool did, as the provider reported it. /// /// Findings are not here: a route returns them or it does not, and a transcript shows what @@ -69,11 +92,24 @@ pub struct ServerToolCall { pub tool: ServerTool, /// What it did with it. pub action: ServerToolAction, + /// How it ended. + pub status: ServerToolStatus, } #[cfg(test)] mod tests { - use super::ServerToolAction; + use super::{ServerTool, ServerToolAction}; + + /// PRV-6: the name configuration declares is the name the record and the screen use. + #[test] + fn the_tool_name_is_its_configuration_spelling() { + let declared = serde_json::to_value(ServerTool::WebSearch) + .unwrap_or_else(|error| panic!("serialize: {error}")); + assert_eq!( + declared, + serde_json::Value::from(ServerTool::WebSearch.name()) + ); + } #[test] fn action_text_bytes_count_every_provider_authored_string() { diff --git a/crates/plexmaton-provider/src/responses/replay.rs b/crates/plexmaton-provider/src/responses/replay.rs index f6598fa..05ea1fe 100644 --- a/crates/plexmaton-provider/src/responses/replay.rs +++ b/crates/plexmaton-provider/src/responses/replay.rs @@ -1,7 +1,7 @@ //! Completed message metadata, kept separately from authoritative semantic text. use plexmaton_agent::{ModelEvent, ProviderReplay}; -use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall}; +use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -20,8 +20,10 @@ pub(super) enum MessagePhase { #[serde(rename_all = "snake_case")] pub(super) enum ItemStatus { InProgress, + Searching, Completed, Incomplete, + Failed, } fn item_status(item: &Value) -> Result, DecodeError> { @@ -170,9 +172,26 @@ impl ResponsesDecoder { action.text_bytes(), self.limits.max_retained_output_bytes, )?; + // PRV-5: how the call ended is typed here, because the row that shows it says so in + // colour. A done item that still claims to be running is a wire defect, not a state. + let status = match item_status(item)? { + None | Some(ItemStatus::Completed) => ServerToolStatus::Completed, + Some(ItemStatus::Failed | ItemStatus::Incomplete) => ServerToolStatus::Failed, + Some(ItemStatus::InProgress) => { + return Err(DecodeError::UnsupportedEvent( + "web_search_call.status:in_progress".to_owned(), + )); + } + Some(ItemStatus::Searching) => { + return Err(DecodeError::UnsupportedEvent( + "web_search_call.status:searching".to_owned(), + )); + } + }; let call = ServerToolCall { tool: ServerTool::WebSearch, action, + status, }; let metadata = ResponseReplay::WebSearchCall { id: item.get("id").and_then(Value::as_str).map(str::to_owned), diff --git a/crates/plexmaton-provider/tests/provider_fixtures.rs b/crates/plexmaton-provider/tests/provider_fixtures.rs index 0aad5bc..5c23ad3 100644 --- a/crates/plexmaton-provider/tests/provider_fixtures.rs +++ b/crates/plexmaton-provider/tests/provider_fixtures.rs @@ -966,6 +966,10 @@ async fn prv_5_responses_web_search_call_is_carried_as_a_server_tool_call() { assert_eq!(calls.len(), 2, "{events:?}"); assert_eq!(calls[0].0, 1); assert_eq!(calls[0].1.tool, ServerTool::WebSearch); + assert_eq!( + calls[0].1.status, + plexmaton_core::ServerToolStatus::Completed + ); assert_eq!( calls[0].1.action, ServerToolAction::Search { diff --git a/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs b/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs index ca5ec3a..e6dba21 100644 --- a/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs +++ b/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs @@ -2,7 +2,7 @@ use super::*; use std::convert::Infallible; use futures_util::stream; -use plexmaton_core::ServerToolAction; +use plexmaton_core::{ServerToolAction, ServerToolStatus}; use plexmaton_provider::{DecodeError, DecodeLimits, SseDecodeError, drive_sse}; const CREATED: &str = r#"event: response.created @@ -18,11 +18,56 @@ data: {"type":"response.completed","sequence_number":3,"response":{"id":"resp_fi "#; fn search_done(action: &str) -> String { + done_with_status("completed", action) +} + +fn done_with_status(status: &str, action: &str) -> String { format!( - "{CREATED}event: response.output_item.done\ndata: {{\"type\":\"response.output_item.done\",\"sequence_number\":2,\"output_index\":0,\"item\":{{\"id\":\"ws_fixture_x\",\"type\":\"web_search_call\",\"status\":\"completed\"{action}}}}}\n\n{COMPLETED}" + "{CREATED}event: response.output_item.done\ndata: {{\"type\":\"response.output_item.done\",\"sequence_number\":2,\"output_index\":0,\"item\":{{\"id\":\"ws_fixture_x\",\"type\":\"web_search_call\",\"status\":\"{status}\"{action}}}}}\n\n{COMPLETED}" ) } +/// PRV-5: a call the provider gave up on is carried with that outcome, so the row can say so, and +/// a done item that still claims to be running fails the step rather than being read as either. +#[tokio::test] +async fn prv_5_a_server_tool_calls_outcome_is_typed_from_its_status() { + for (status, expected) in [ + ("failed", ServerToolStatus::Failed), + ("incomplete", ServerToolStatus::Failed), + ("completed", ServerToolStatus::Completed), + ] { + let (result, emitted) = decode(&done_with_status( + status, + r#","action":{"type":"search","query":"rust release"}"#, + )) + .await; + assert!(result.is_ok(), "{status}: {result:?}"); + assert!( + matches!( + emitted.first(), + Some(ModelEvent::ServerToolCall { call, .. }) if call.status == expected + ), + "{status}: {emitted:?}" + ); + } + for status in ["in_progress", "searching"] { + let (result, emitted) = decode(&done_with_status( + status, + r#","action":{"type":"search","query":"rust release"}"#, + )) + .await; + assert!( + matches!( + result, + Err(SseDecodeError::Decode(DecodeError::UnsupportedEvent(ref kind))) + if *kind == format!("web_search_call.status:{status}") + ), + "{status}: {result:?}" + ); + assert!(emitted.is_empty(), "{status}: {emitted:?}"); + } +} + async fn decode(source: &str) -> (Result<(), SseDecodeError>, Vec) { let profile = profile(ModelApi::OpenaiResponses); let mut emitted = Vec::new(); diff --git a/crates/plexmaton-tui/examples/server_tool_preview.rs b/crates/plexmaton-tui/examples/server_tool_preview.rs new file mode 100644 index 0000000..5eafe9f --- /dev/null +++ b/crates/plexmaton-tui/examples/server_tool_preview.rs @@ -0,0 +1,176 @@ +//! Provider-run calls beside a local tool, painted through the real renderer for colour review. +//! cargo run -p plexmaton-tui --example server_tool_preview -- + +use std::path::Path; + +use plexmaton_core::{ + AgentId, AgentStatus, ConversationEvent, ConversationEventEnvelope, EventSequence, ServerTool, + ServerToolAction, ServerToolCall, ServerToolStatus, ToolCallId, ToolCallStatus, ToolDetail, + ToolPresentation, TranscriptItemId, TranscriptRole, +}; +use plexmaton_tui::{MarkdownTheme, Palette, Workspace}; +use ratatui::{Terminal, backend::TestBackend}; + +#[path = "support/frame_svg.rs"] +mod frame_svg; +#[path = "support/prepared_frame.rs"] +mod prepared_frame; + +type Result = std::result::Result>; + +fn main() -> Result<()> { + let directory = std::env::args() + .nth(1) + .ok_or("provide an output directory")?; + let directory = Path::new(&directory); + std::fs::create_dir_all(directory)?; + for (width, height) in [(120, 40), (88, 42), (60, 46)] { + let mut workspace = fixture()?; + let mut terminal = Terminal::new(TestBackend::new(width, height))?; + prepared_frame::draw(&mut workspace, &mut terminal)?; + let buffer = terminal.backend().buffer(); + std::fs::write( + directory.join(format!("server-tool-{}.svg", buffer.area.width)), + frame_svg::svg(buffer), + )?; + } + Ok(()) +} + +fn text( + events: &mut Vec, + agent: &AgentId, + name: &str, + role: TranscriptRole, + source: &str, +) -> Result<()> { + let item = TranscriptItemId::new(name)?; + events.push(ConversationEvent::TranscriptItemStarted { + agent_id: agent.clone(), + item_id: item.clone(), + role, + }); + events.push(ConversationEvent::TranscriptDelta { + agent_id: agent.clone(), + item_id: item.clone(), + item_revision: 1, + text: source.to_owned(), + }); + events.push(ConversationEvent::TranscriptItemFinalized { + agent_id: agent.clone(), + item_id: item, + item_revision: 2, + }); + Ok(()) +} + +fn fixture() -> Result { + let agent = AgentId::new("primary")?; + let mut events = vec![ConversationEvent::AgentCreated { + agent_id: agent.clone(), + label: "Plexmaton".into(), + status: AgentStatus::Idle, + }]; + text( + &mut events, + &agent, + "question", + TranscriptRole::User, + "What is the latest stable Rust release, and when did it ship?", + )?; + text( + &mut events, + &agent, + "reasoning", + TranscriptRole::Reasoning, + "A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the release post.", + )?; + for (revision, status) in [ + (0, ToolCallStatus::Queued), + (1, ToolCallStatus::Running), + (2, ToolCallStatus::Succeeded), + ] { + events.push(ConversationEvent::ToolCallChanged { + agent_id: agent.clone(), + item_id: TranscriptItemId::new("local-read")?, + item_revision: revision, + call_id: ToolCallId::new("local-read")?, + label: "read_file".into(), + status, + presentation: ToolPresentation { + invocation: (revision > 0).then(|| ToolDetail::Text { + source: "path: Cargo.toml".into(), + omitted_bytes: 0, + }), + outcome: None, + }, + }); + } + let search = |queries: &[&str]| ServerToolAction::Search { + queries: queries.iter().map(|query| (*query).to_owned()).collect(), + }; + for (name, action, status) in [ + ( + "search", + search(&["latest stable Rust release"]), + ServerToolStatus::Completed, + ), + ( + "search-two", + search(&["Rust 1.98.1 release date", "Rust 1.98.1 point release"]), + ServerToolStatus::Completed, + ), + ("search-bare", search(&[]), ServerToolStatus::Completed), + ( + "open", + ServerToolAction::OpenPage { + url: "blog.rust-lang.org/releases/".into(), + }, + ServerToolStatus::Completed, + ), + ( + "find", + ServerToolAction::FindInPage { + url: "releases.rs/docs/1.98.1".into(), + pattern: "1.98.1".into(), + }, + ServerToolStatus::Completed, + ), + ( + "failed", + search(&["Rust nightly changelog"]), + ServerToolStatus::Failed, + ), + ] { + events.push(ConversationEvent::ServerToolCalled { + agent_id: agent.clone(), + item_id: TranscriptItemId::new(name)?, + call: ServerToolCall { + tool: ServerTool::WebSearch, + action, + status, + }, + }); + } + text( + &mut events, + &agent, + "answer", + TranscriptRole::Assistant, + "Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust blog lists what it fixes.", + )?; + let mut workspace = + Workspace::with_palette(Palette::pastel().with_markdown_theme(MarkdownTheme::Pastel)); + workspace.emit( + events + .into_iter() + .enumerate() + .map(|(index, event)| ConversationEventEnvelope { + sequence: EventSequence::new(index as u64 + 1), + event, + }) + .collect(), + ); + workspace.set_working_directory("~/plexmaton".into()); + Ok(workspace) +} diff --git a/crates/plexmaton-tui/frames/server-tool-120.svg b/crates/plexmaton-tui/frames/server-tool-120.svg new file mode 100644 index 0000000..bc03d08 --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-120.svg @@ -0,0 +1 @@ +┌ Plexmaton 󱌣 7 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐│││││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did it ship?││││reasoning││A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the││release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││││Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust││blog lists what it fixes.│││││── Message Plexmaton ───────────────────────────────────────────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-60.svg b/crates/plexmaton-tui/frames/server-tool-60.svg new file mode 100644 index 0000000..3ba6d57 --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-60.svg @@ -0,0 +1 @@ +┌ Plexmaton 󱌣 7 ──────────────────────────────────────────┐│││││││││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did││▌it ship?││││reasoning││A version number is a current fact, so I will search││rather than answer from memory, then confirm the date││on the release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1││point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││││Rust 1.98.1 is the latest stable release. It is a││point release over 1.98.0, and the release post on││the Rust blog lists what it fixes.│││││── Message Plexmaton ───────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-88.svg b/crates/plexmaton-tui/frames/server-tool-88.svg new file mode 100644 index 0000000..2e59820 --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-88.svg @@ -0,0 +1 @@ +┌ Plexmaton 󱌣 7 ──────────────────────────────────────────────────────────────────────┐│││││││││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did it ship?││││reasoning││A version number is a current fact, so I will search rather than answer from││memory, then confirm the date on the release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││││Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and││the release post on the Rust blog lists what it fixes.│││││── Message Plexmaton ───────────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-medium.txt b/crates/plexmaton-tui/frames/server-tool-medium.txt new file mode 100644 index 0000000..618ff2d --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-medium.txt @@ -0,0 +1,40 @@ +┌ Plexmaton 󱌣 7 ─────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│▌What is the latest stable Rust release, and when did it ship? │ +│ │ +│reasoning │ +│A version number is a current fact, so I will search rather than answer from memory, │ +│then confirm the date on the release post. │ +│ │ +│[+] read_file · succeeded │ +│[+] web_search · latest stable Rust release │ +│[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release │ +│[+] web_search │ +│[+] web_search · opened blog.rust-lang.org/releases/ │ +│[+] web_search · "1.98.1" in releases.rs/docs/1.98.1 │ +│[!] web_search · Rust nightly changelog · failed │ +│ │ +│Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the │ +│release post on the Rust blog lists what it fixes. │ +│ │ +│ │ +── Message Plexmaton ────────────────────────────────────────────────────────────────────────── + Type a message · ⇥ to focus +─────────────────────────────────────────────────────────────────────────────────────────────── + ~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-narrow.txt b/crates/plexmaton-tui/frames/server-tool-narrow.txt new file mode 100644 index 0000000..f2ea6cc --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-narrow.txt @@ -0,0 +1,40 @@ +┌ Plexmaton 󱌣 7 ──────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│▌What is the latest stable Rust release, and when did │ +│▌it ship? │ +│ │ +│reasoning │ +│A version number is a current fact, so I will search │ +│rather than answer from memory, then confirm the date │ +│on the release post. │ +│ │ +│[+] read_file · succeeded │ +│[+] web_search · latest stable Rust release │ +│[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 │ +│point release │ +│[+] web_search │ +│[+] web_search · opened blog.rust-lang.org/releases/ │ +│[+] web_search · "1.98.1" in releases.rs/docs/1.98.1 │ +│[!] web_search · Rust nightly changelog · failed │ +│ │ +│Rust 1.98.1 is the latest stable release. It is a │ +│point release over 1.98.0, and the release post on │ +│the Rust blog lists what it fixes. │ +│ │ +│ │ +── Message Plexmaton ─────────────────────────────────────── + Type a message · ⇥ to focus +──────────────────────────────────────────────────────────── + ~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-open-wide.txt b/crates/plexmaton-tui/frames/server-tool-open-wide.txt new file mode 100644 index 0000000..bc12ca7 --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-open-wide.txt @@ -0,0 +1,40 @@ +┌ Plexmaton 󱌣 7 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│▌What is the latest stable Rust release, and when did it ship? │ +│ │ +│reasoning │ +│A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the │ +│release post. │ +│ │ +│[+] read_file · succeeded │ +│[+] web_search · latest stable Rust release │ +│[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release │ +│[+] web_search │ +│[+] web_search · opened blog.rust-lang.org/releases/ │ +│[+] web_search · "1.98.1" in releases.rs/docs/1.98.1 │ +│ action │ +│ │ url: releases.rs/docs/1.98.1 │ +│ │ pattern: 1.98.1 │ +│[!] web_search · Rust nightly changelog · failed │ +│ │ +│Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust │ +│blog lists what it fixes. │ +│ │ +│ │ +── Message Plexmaton ─────────────────────────────────────────────────────────────────────────────────────────────────── + Type a message · ⇥ to focus +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-wide.txt b/crates/plexmaton-tui/frames/server-tool-wide.txt new file mode 100644 index 0000000..77cb467 --- /dev/null +++ b/crates/plexmaton-tui/frames/server-tool-wide.txt @@ -0,0 +1,40 @@ +┌ Plexmaton 󱌣 7 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│▌What is the latest stable Rust release, and when did it ship? │ +│ │ +│reasoning │ +│A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the │ +│release post. │ +│ │ +│[+] read_file · succeeded │ +│[+] web_search · latest stable Rust release │ +│[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release │ +│[+] web_search │ +│[+] web_search · opened blog.rust-lang.org/releases/ │ +│[+] web_search · "1.98.1" in releases.rs/docs/1.98.1 │ +│[!] web_search · Rust nightly changelog · failed │ +│ │ +│Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust │ +│blog lists what it fixes. │ +│ │ +│ │ +── Message Plexmaton ─────────────────────────────────────────────────────────────────────────────────────────────────── + Type a message · ⇥ to focus +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + ~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/src/content.rs b/crates/plexmaton-tui/src/content.rs index 81c9ce6..58140a1 100644 --- a/crates/plexmaton-tui/src/content.rs +++ b/crates/plexmaton-tui/src/content.rs @@ -84,7 +84,9 @@ fn count_entries(agent: &crate::AgentView) -> EntryCounts { .entries() .fold(EntryCounts::default(), |counts, entry| match entry { TranscriptEntryView::Text(_) | TranscriptEntryView::Handoff(_) => counts, - TranscriptEntryView::Tool(_) => EntryCounts { + // A call the provider ran is still a tool the model reached for; the row's colour, + // not the tally, says where it ran. + TranscriptEntryView::Tool(_) | TranscriptEntryView::ServerTool(_) => EntryCounts { tools: counts.tools.saturating_add(1), ..counts }, diff --git a/crates/plexmaton-tui/src/content/tool.rs b/crates/plexmaton-tui/src/content/tool.rs index 2473305..2beea7f 100644 --- a/crates/plexmaton-tui/src/content/tool.rs +++ b/crates/plexmaton-tui/src/content/tool.rs @@ -1,11 +1,11 @@ //! Compact and disclosed presentation for one typed tool entry. use crate::text_layout::paint::{Line, Span, Treatment}; -use plexmaton_core::{ToolCallStatus, ToolDetail}; +use plexmaton_core::{ServerToolAction, ServerToolStatus, ToolCallStatus, ToolDetail}; use crate::{ content::transcript_presentation::{Row, append_source}, - state::{EntryAppearance, ToolCallView}, + state::{EntryAppearance, ServerToolView, ToolCallView}, theme::{Role, tool_role}, }; @@ -32,6 +32,62 @@ pub(super) fn prepared_entry(tool: &ToolCallView, appearance: EntryAppearance) - lines } +/// The provider's call in the tool row's grammar, with colour saying who ran it. +/// +/// Same marker column, same name, same `·`, same disclosure, so a reader scans one column for +/// everything the model reached for. The marker's colour is what differs: a call that ended well +/// wears the server-tool role rather than a lifecycle's, because there was no lifecycle here to +/// colour, and one that failed wears failure like any other (ui-ux §transcript grammar). After the +/// `·` is what the route reported and nothing it did not: the query, an opened page, or a pattern +/// and where it was sought. A search the route reported without a query is the name alone. +/// Rejected: a row family of its own, which gave up the marker column and the disclosure tools +/// already have; and the plain succeeded colour, which read as a tool this harness ran inside the +/// fence. The user chose the grammar and the colour on 2026-09-21 from rendered candidates. +pub(super) fn prepared_server_tool(view: &ServerToolView, appearance: EntryAppearance) -> Vec { + let (status, role) = match view.call.status { + ServerToolStatus::Completed => (ToolCallStatus::Succeeded, Role::ServerTool), + ServerToolStatus::Failed => (ToolCallStatus::Failed, Role::Failure), + }; + let mut spans = vec![ + Span::styled(format!("{} ", marker(status)), role), + Span::styled(view.call.tool.name().to_owned(), Role::Body), + ]; + match &view.call.action { + ServerToolAction::Search { queries } if queries.is_empty() => {} + ServerToolAction::Search { queries } => { + spans.push(Span::styled(" · ", role)); + spans.push(Span::styled(queries.join(", "), Role::Body)); + } + ServerToolAction::OpenPage { url } => { + spans.push(Span::styled(" · ", role)); + spans.push(Span::styled("opened ", Role::Muted)); + spans.push(Span::styled(url.clone(), Role::Body)); + } + ServerToolAction::FindInPage { url, pattern } => { + spans.push(Span::styled(" · ", role)); + spans.push(Span::styled(format!("\"{pattern}\""), Role::Body)); + spans.push(Span::styled(" in ", Role::Muted)); + spans.push(Span::styled(url.clone(), Role::Body)); + } + } + if status == ToolCallStatus::Failed { + spans.push(Span::styled(format!(" · {}", status_label(status)), role)); + } + let mut compact = Line::from(spans); + compact.treatment = Treatment::EntryHeading; + let mut lines = vec![Row::heading(compact)]; + if appearance.open + && let Some(source) = view.action_source() + { + lines.push(Row::heading(Line::styled( + " action".to_owned(), + Role::Muted, + ))); + append_source(&mut lines, &source, Treatment::Content, |_| Role::Body); + } + lines +} + fn append_detail(lines: &mut Vec, heading: &str, detail: &ToolDetail) { let omitted = match detail { ToolDetail::Text { omitted_bytes, .. } if *omitted_bytes > 0 => { @@ -106,15 +162,154 @@ const fn status_label(status: ToolCallStatus) -> &'static str { #[cfg(test)] mod tests { use plexmaton_core::{ - ToolCallId, ToolCallStatus, ToolDetail, ToolPresentation, TranscriptItemId, + ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus, ToolCallId, ToolCallStatus, + ToolDetail, ToolPresentation, TranscriptItemId, }; - use super::{marker, prepared_entry}; + use super::{marker, prepared_entry, prepared_server_tool}; use crate::{ - state::{EntryAppearance, ToolCallView}, + state::{EntryAppearance, ServerToolView, ToolCallView}, theme::{Palette, Role}, }; + fn server_tool(action: ServerToolAction, status: ServerToolStatus) -> ServerToolView { + ServerToolView { + entry_id: TranscriptItemId::new("search") + .unwrap_or_else(|error| panic!("fixture: {error}")), + call: ServerToolCall { + tool: ServerTool::WebSearch, + action, + status, + }, + revision: 0, + } + } + + fn server_entry( + view: &ServerToolView, + palette: &Palette, + appearance: EntryAppearance, + ) -> Vec { + let colors = crate::text_layout::paint::Colors::new(palette); + prepared_server_tool(view, appearance) + .into_iter() + .map(|row| { + row.flattened() + .paint_entry(&colors, appearance) + .to_string() + .trim_end() + .to_owned() + }) + .collect() + } + + fn marker_colour(view: &ServerToolView, palette: &Palette) -> Option { + let colors = crate::text_layout::paint::Colors::new(palette); + let appearance = EntryAppearance::compact(false); + let rows = prepared_server_tool(view, appearance); + let line = rows + .into_iter() + .next() + .unwrap_or_else(|| panic!("a compact row")) + .flattened() + .paint_entry(&colors, appearance); + line.spans.first().and_then(|span| span.style.fg) + } + + /// ui-ux §transcript grammar: the provider's call keeps the tool row's grammar and says what + /// the route reported and nothing further; colour says who ran it, and failure stays failure. + #[test] + fn a_server_tool_row_keeps_the_tool_grammar_and_claims_only_what_the_route_reported() { + let palette = Palette::pastel(); + let search = |queries: &[&str]| ServerToolAction::Search { + queries: queries.iter().map(|query| (*query).to_owned()).collect(), + }; + for (action, expected) in [ + ( + search(&["latest stable Rust release"]), + "[+] web_search · latest stable Rust release", + ), + (search(&["a", "b"]), "[+] web_search · a, b"), + (search(&[]), "[+] web_search"), + ( + ServerToolAction::OpenPage { + url: "blog.rust-lang.org/releases/".to_owned(), + }, + "[+] web_search · opened blog.rust-lang.org/releases/", + ), + ( + ServerToolAction::FindInPage { + url: "releases.rs/docs/1.98.1".to_owned(), + pattern: "1.98.1".to_owned(), + }, + "[+] web_search · \"1.98.1\" in releases.rs/docs/1.98.1", + ), + ] { + let view = server_tool(action, ServerToolStatus::Completed); + let lines = server_entry(&view, &palette, EntryAppearance::compact(false)); + assert_eq!(lines, [expected]); + assert_eq!( + marker_colour(&view, &palette), + palette.style(Role::ServerTool).fg, + "{expected}: the marker wears the server-tool colour" + ); + } + let failed = server_tool( + search(&["Rust nightly changelog"]), + ServerToolStatus::Failed, + ); + assert_eq!( + server_entry(&failed, &palette, EntryAppearance::compact(false)), + ["[!] web_search · Rust nightly changelog · failed"] + ); + assert_eq!( + marker_colour(&failed, &palette), + palette.style(Role::Failure).fg, + "a failed call wears failure, wherever it ran" + ); + assert_ne!( + palette.style(Role::ServerTool).fg, + palette.style(Role::NewInformation).fg, + "the provider's marker is not the local tool's green" + ); + } + + /// ENT-4: what the route reported discloses beneath the row, one fact per line, and a search + /// the route reported without a query has nothing to disclose. + #[test] + fn a_server_tool_row_discloses_what_the_route_reported() { + let palette = Palette::pastel(); + let open = EntryAppearance { + selected: false, + open: true, + hovered: false, + copy_hovered: false, + }; + let sought = server_tool( + ServerToolAction::FindInPage { + url: "releases.rs/docs/1.98.1".to_owned(), + pattern: "1.98.1".to_owned(), + }, + ServerToolStatus::Completed, + ); + assert_eq!( + server_entry(&sought, &palette, open), + [ + "[+] web_search · \"1.98.1\" in releases.rs/docs/1.98.1", + " action", + " │ url: releases.rs/docs/1.98.1", + " │ pattern: 1.98.1", + ] + ); + let bare = server_tool( + ServerToolAction::Search { + queries: Vec::new(), + }, + ServerToolStatus::Completed, + ); + assert_eq!(server_entry(&bare, &palette, open), ["[+] web_search"]); + } + fn entry( tool: &ToolCallView, palette: &Palette, diff --git a/crates/plexmaton-tui/src/content_transcript.rs b/crates/plexmaton-tui/src/content_transcript.rs index b134428..f4f57c3 100644 --- a/crates/plexmaton-tui/src/content_transcript.rs +++ b/crates/plexmaton-tui/src/content_transcript.rs @@ -65,6 +65,7 @@ pub(crate) fn transcript_layout_with_prefix( return transcript_text_with_prefix(item, width, math, prefix); } TranscriptEntryView::Tool(tool) => tool::prepared_entry(tool, appearance), + TranscriptEntryView::ServerTool(view) => tool::prepared_server_tool(view, appearance), TranscriptEntryView::Artifact(artifact) => { let line = Line::from(vec![ Span::styled("@ ", Role::NewInformation), @@ -244,6 +245,7 @@ pub(crate) fn discloses(entry: &TranscriptEntryView) -> bool { TranscriptEntryView::Tool(tool) => { tool.presentation.invocation.is_some() || tool.presentation.outcome.is_some() } + TranscriptEntryView::ServerTool(view) => view.action_source().is_some(), TranscriptEntryView::Mail(mail) => !mail.summary.is_empty(), TranscriptEntryView::Task(task) => !task.task.is_empty(), TranscriptEntryView::Text(_) diff --git a/crates/plexmaton-tui/src/frames.rs b/crates/plexmaton-tui/src/frames.rs index b5f607c..89d268a 100644 --- a/crates/plexmaton-tui/src/frames.rs +++ b/crates/plexmaton-tui/src/frames.rs @@ -98,6 +98,13 @@ mod tests { ("tool-open-narrow", 60, 40), ]; + /// Stage 33's provider-run calls beside a local tool, at the three product widths. + const SERVER_TOOL_FRAMES: [(&str, u16, u16); 3] = [ + ("server-tool-wide", 120, 40), + ("server-tool-medium", 95, 40), + ("server-tool-narrow", 60, 40), + ]; + /// Slice 6's remaining transcript roles and a disclosed canonical patch. const GRAMMAR_FRAMES: [(&str, u16, u16); 3] = [ ("transcript-grammar-wide", 120, 40), @@ -515,6 +522,199 @@ mod tests { ); } + /// ui-ux §transcript grammar: a provider-run call reads in the tool row's grammar, in its own + /// colour when it ended well and in failure's when it did not; the row says what the route + /// reported and nothing further, and the title counts it among the tools. + #[test] + fn the_server_tool_frames_match_their_fixtures() { + let state = server_tool_state(); + for (name, width, height) in SERVER_TOOL_FRAMES { + let drawn = draw(&state, width, height); + for row in [ + "[+] read_file · succeeded", + "[+] web_search · latest stable Rust release", + "[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release", + "[+] web_search · opened blog.rust-lang.org/releases/", + "[!] web_search · Rust nightly changelog · failed", + ] { + let fits = row.len() <= usize::from(width) - 2; + assert!( + drawn.contains(row) || !fits, + "{name}: the row is absent or truncated where it fits: {row:?}" + ); + } + assert!( + drawn + .lines() + .any(|line| line.trim_end().ends_with("│[+] web_search") + || line.contains("│[+] web_search ")), + "{name}: a search the route reported without a query is the name alone" + ); + assert!( + drawn.contains(" 7 "), + "{name}: every call counts among the tools" + ); + crate::test_support::assert_frame(name, &drawn); + } + // The disclosed row, at the width a reviewer reads first. + let mut state = server_tool_state(); + let agent = state + .primary_agent() + .map(|agent| agent.id.clone()) + .unwrap_or_else(|| panic!("the frame has a primary agent")); + let item = TranscriptItemId::new("server-tool-find") + .unwrap_or_else(|error| panic!("fixture: {error}")); + let index = state + .primary_agent() + .and_then(|agent| agent.entries().position(|entry| entry.id() == &item)) + .unwrap_or_else(|| panic!("the find is in the transcript")); + let (surfaces, _) = draw_frame(&state, &Palette::pastel(), 120, 40); + state.toggle_entry( + &surfaces, + &TranscriptMetrics::default(), + EntryTarget { + surface: SurfaceId::Transcript, + agent, + item, + index, + }, + ); + let drawn = draw(&state, 120, 40); + assert!(drawn.contains(" │ pattern: 1.98.1"), "{drawn}"); + crate::test_support::assert_frame("server-tool-open-wide", &drawn); + } + + fn server_tool_state() -> ViewState { + use plexmaton_core::{ + ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus, TranscriptRole, + }; + + let agent = + AgentId::new("agent-primary").unwrap_or_else(|error| panic!("fixture: {error}")); + let mut state = ViewState::default(); + let mut sequence = 0_u64; + apply_frame_event( + &mut state, + &mut sequence, + ConversationEvent::AgentCreated { + agent_id: agent.clone(), + label: "Plexmaton".to_owned(), + status: AgentStatus::Idle, + }, + ); + append_frame_text( + &mut state, + &mut sequence, + &agent, + "user-turn", + TranscriptRole::User, + "What is the latest stable Rust release, and when did it ship?", + ); + append_frame_text( + &mut state, + &mut sequence, + &agent, + "visible-reasoning", + TranscriptRole::Reasoning, + "A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the release post.", + ); + let item = + TranscriptItemId::new("local-read").unwrap_or_else(|error| panic!("fixture: {error}")); + let call = ToolCallId::new("local-read").unwrap_or_else(|error| panic!("fixture: {error}")); + for (revision, status) in [ + (0, ToolCallStatus::Queued), + (1, ToolCallStatus::Running), + (2, ToolCallStatus::Succeeded), + ] { + apply_frame_event( + &mut state, + &mut sequence, + ConversationEvent::ToolCallChanged { + agent_id: agent.clone(), + item_id: item.clone(), + item_revision: revision, + call_id: call.clone(), + label: "read_file".to_owned(), + status, + presentation: ToolPresentation { + invocation: (revision > 0).then(|| ToolDetail::Text { + source: "path: Cargo.toml".to_owned(), + omitted_bytes: 0, + }), + outcome: None, + }, + }, + ); + } + let search = |queries: &[&str]| ServerToolAction::Search { + queries: queries.iter().map(|query| (*query).to_owned()).collect(), + }; + // The shapes one live route produced, in its order: a query, two at once, a search it + // reported without one, an opened page, a pattern sought in a page, and a call it gave up. + for (name, action, status) in [ + ( + "server-tool-search", + search(&["latest stable Rust release"]), + ServerToolStatus::Completed, + ), + ( + "server-tool-search-two", + search(&["Rust 1.98.1 release date", "Rust 1.98.1 point release"]), + ServerToolStatus::Completed, + ), + ( + "server-tool-search-bare", + search(&[]), + ServerToolStatus::Completed, + ), + ( + "server-tool-open", + ServerToolAction::OpenPage { + url: "blog.rust-lang.org/releases/".to_owned(), + }, + ServerToolStatus::Completed, + ), + ( + "server-tool-find", + ServerToolAction::FindInPage { + url: "releases.rs/docs/1.98.1".to_owned(), + pattern: "1.98.1".to_owned(), + }, + ServerToolStatus::Completed, + ), + ( + "server-tool-failed", + search(&["Rust nightly changelog"]), + ServerToolStatus::Failed, + ), + ] { + apply_frame_event( + &mut state, + &mut sequence, + ConversationEvent::ServerToolCalled { + agent_id: agent.clone(), + item_id: TranscriptItemId::new(name) + .unwrap_or_else(|error| panic!("fixture: {error}")), + call: ServerToolCall { + tool: ServerTool::WebSearch, + action, + status, + }, + }, + ); + } + append_frame_text( + &mut state, + &mut sequence, + &agent, + "assistant-turn", + TranscriptRole::Assistant, + "Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust blog lists what it fixes.", + ); + state.set_working_directory("~/plexmaton".to_owned()); + state + } + fn transcript_grammar_state(width: u16, height: u16) -> ViewState { use plexmaton_core::TranscriptRole; diff --git a/crates/plexmaton-tui/src/lib.rs b/crates/plexmaton-tui/src/lib.rs index 4d12d11..000df7f 100644 --- a/crates/plexmaton-tui/src/lib.rs +++ b/crates/plexmaton-tui/src/lib.rs @@ -42,9 +42,10 @@ pub use state::{ CopyReceipt, CopyRequest, Drawer, HandoffView, InspectorView, Listing, MAX_CONVERSATION_CHOICES, MailView, ModelChoice, ModelIdentity, NoticeView, Page, PermissionRequest, PersistenceNotice, QueuedBoundary, QueuedInput, ReduceError, RetryAction, - RetryActions, RetrySubmission, RetryTarget, ScrollPosition, Selection, SkillChoice, - SkillChoiceSource, Submission, SubmissionKind, SwitchRefusal, TaskView, ToolCallView, - TranscriptEntryView, TranscriptItemView, TranscriptTextKind, ViewRevision, ViewState, + RetryActions, RetrySubmission, RetryTarget, ScrollPosition, Selection, ServerToolView, + SkillChoice, SkillChoiceSource, Submission, SubmissionKind, SwitchRefusal, TaskView, + ToolCallView, TranscriptEntryView, TranscriptItemView, TranscriptTextKind, ViewRevision, + ViewState, }; pub use state::{ApprovalChoice, ApprovalFeedback, ApprovalStage}; pub use statusline::{StatusLineText, StatusLineTextError}; diff --git a/crates/plexmaton-tui/src/state/agent.rs b/crates/plexmaton-tui/src/state/agent.rs index 9a59513..9662e82 100644 --- a/crates/plexmaton-tui/src/state/agent.rs +++ b/crates/plexmaton-tui/src/state/agent.rs @@ -1,6 +1,6 @@ use plexmaton_core::{ - AgentId, AgentStatus, ArtifactId, MailId, TokenUsage, ToolCallId, ToolCallStatus, - ToolPresentation, TranscriptItemId, TranscriptRole, TurnId, + AgentId, AgentStatus, ArtifactId, MailId, ServerToolCall, TokenUsage, ToolCallId, + ToolCallStatus, ToolPresentation, TranscriptItemId, TranscriptRole, TurnId, }; use super::{ @@ -72,6 +72,7 @@ impl AgentView { self.entries.iter().filter_map(|entry| match entry { TranscriptEntryView::Text(item) => Some(item), TranscriptEntryView::Tool(_) + | TranscriptEntryView::ServerTool(_) | TranscriptEntryView::Artifact(_) | TranscriptEntryView::Mail(_) | TranscriptEntryView::Task(_) @@ -302,6 +303,23 @@ impl AgentView { ) } + /// Files one call the provider ran, in the state it ended in. It never transitions, so a + /// second event for the same entry is a duplicate rather than an update (ENT-2). + pub(super) fn note_server_tool( + &mut self, + entry_id: TranscriptItemId, + call: ServerToolCall, + ) -> Result { + self.insert_terminal( + entry_id.clone(), + TranscriptEntryView::ServerTool(super::ServerToolView { + entry_id, + call, + revision: 0, + }), + ) + } + /// Adds one outgoing mail item to its producer's transcript. pub(super) fn deliver_mail( &mut self, @@ -417,6 +435,7 @@ impl AgentView { match entry { TranscriptEntryView::Text(item) => Ok(item), TranscriptEntryView::Tool(_) + | TranscriptEntryView::ServerTool(_) | TranscriptEntryView::Artifact(_) | TranscriptEntryView::Mail(_) | TranscriptEntryView::Task(_) diff --git a/crates/plexmaton-tui/src/state/current_work.rs b/crates/plexmaton-tui/src/state/current_work.rs index d5f7e26..e7d6d9b 100644 --- a/crates/plexmaton-tui/src/state/current_work.rs +++ b/crates/plexmaton-tui/src/state/current_work.rs @@ -55,6 +55,7 @@ impl<'a> CurrentWork<'a> { } TranscriptEntryView::Text(_) | TranscriptEntryView::Tool(_) + | TranscriptEntryView::ServerTool(_) | TranscriptEntryView::Artifact(_) | TranscriptEntryView::Mail(_) | TranscriptEntryView::Task(_) diff --git a/crates/plexmaton-tui/src/state/entry.rs b/crates/plexmaton-tui/src/state/entry.rs index 6b7b620..8ea6310 100644 --- a/crates/plexmaton-tui/src/state/entry.rs +++ b/crates/plexmaton-tui/src/state/entry.rs @@ -1,8 +1,8 @@ //! Typed entries in one agent's ordered transcript projection. use plexmaton_core::{ - AgentId, ArtifactId, MailId, ToolCallId, ToolCallStatus, ToolPresentation, TranscriptItemId, - TranscriptRole, + AgentId, ArtifactId, MailId, ServerToolAction, ServerToolCall, ToolCallId, ToolCallStatus, + ToolPresentation, TranscriptItemId, TranscriptRole, }; use super::ReduceError; @@ -56,6 +56,8 @@ pub enum TranscriptEntryView { Text(TranscriptItemView), /// One tool call throughout its lifecycle. Tool(ToolCallView), + /// One tool the provider ran on its own side, in the state it ended in. + ServerTool(ServerToolView), /// One announced durable work product. Artifact(ArtifactView), /// One delivered mail summary. @@ -73,6 +75,7 @@ impl TranscriptEntryView { match self { Self::Text(item) => &item.id, Self::Tool(tool) => &tool.entry_id, + Self::ServerTool(view) => &view.entry_id, Self::Artifact(artifact) => &artifact.entry_id, Self::Mail(mail) => &mail.entry_id, Self::Task(task) => &task.entry_id, @@ -86,6 +89,7 @@ impl TranscriptEntryView { match self { Self::Text(item) => item.revision, Self::Tool(tool) => tool.revision, + Self::ServerTool(view) => view.revision, Self::Artifact(artifact) => artifact.revision, Self::Mail(mail) => mail.revision, Self::Task(task) => task.revision, @@ -107,6 +111,40 @@ pub struct ToolCallView { pub revision: u64, } +/// One call the provider ran inside a model call, in the state it ended in. +/// +/// Drawn in [`ToolCallView`]'s grammar and unlike it underneath: there is no call identity of ours +/// to correlate, no lifecycle to advance and no presentation to accumulate. What it did is the +/// call's own action, shown as the route reported it (ENT-2). +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct ServerToolView { + pub entry_id: TranscriptItemId, + pub call: ServerToolCall, + pub revision: u64, +} + +impl ServerToolView { + /// What the route reported, one fact per line, for disclosure and copy alike; `None` when it + /// reported nothing beyond that a call happened, which is one live route's search today. + #[must_use] + pub fn action_source(&self) -> Option { + match &self.call.action { + ServerToolAction::Search { queries } if queries.is_empty() => None, + ServerToolAction::Search { queries } => Some( + queries + .iter() + .map(|query| format!("query: {query}")) + .collect::>() + .join("\n"), + ), + ServerToolAction::OpenPage { url } => Some(format!("url: {url}")), + ServerToolAction::FindInPage { url, pattern } => { + Some(format!("url: {url}\npattern: {pattern}")) + } + } + } +} + /// Durable work product announced by an agent, referenced by pointer rather than copied inline. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct ArtifactView { diff --git a/crates/plexmaton-tui/src/state/ingest.rs b/crates/plexmaton-tui/src/state/ingest.rs index bdbb8fd..bd083f7 100644 --- a/crates/plexmaton-tui/src/state/ingest.rs +++ b/crates/plexmaton-tui/src/state/ingest.rs @@ -197,19 +197,16 @@ impl ViewState { label, status, presentation, - } => { - self.validate_entry_owner(&agent_id, &item_id)?; - let changed = self.agent_mut(&agent_id)?.set_tool_entry( - item_id.clone(), - item_revision, - call_id, - label, - status, - presentation, - )?; - self.remember_entry_owner(item_id, agent_id); - changed - } + } => self.apply_entry(agent_id, item_id, |agent, item_id| { + agent.set_tool_entry(item_id, item_revision, call_id, label, status, presentation) + })?, + ConversationEvent::ServerToolCalled { + agent_id, + item_id, + call, + } => self.apply_entry(agent_id, item_id, |agent, item_id| { + agent.note_server_tool(item_id, call) + })?, ConversationEvent::AttentionRequested { agent_id, attention_id, @@ -402,6 +399,20 @@ impl ViewState { Ok(changed) } + /// One entry-bearing fact, in the shape every kind shares: the entry belongs to the agent, the + /// agent's projection files it, and the owner is remembered for the revisions to come. + fn apply_entry( + &mut self, + agent_id: AgentId, + item_id: TranscriptItemId, + file: impl FnOnce(&mut AgentView, TranscriptItemId) -> Result, + ) -> Result { + self.validate_entry_owner(&agent_id, &item_id)?; + let changed = file(self.agent_mut(&agent_id)?, item_id.clone())?; + self.remember_entry_owner(item_id, agent_id); + Ok(changed) + } + fn apply_artifact( &mut self, agent_id: AgentId, @@ -524,6 +535,55 @@ mod tests { } } + fn server_tool_event(agent: &AgentId, entry: &str, queries: &[&str]) -> ConversationEvent { + ConversationEvent::ServerToolCalled { + agent_id: agent.clone(), + item_id: item_id(entry), + call: plexmaton_core::ServerToolCall { + tool: plexmaton_core::ServerTool::WebSearch, + action: plexmaton_core::ServerToolAction::Search { + queries: queries.iter().map(|query| (*query).to_owned()).collect(), + }, + status: plexmaton_core::ServerToolStatus::Completed, + }, + } + } + + /// ENT-2: a call the provider ran appears once, finished, and a second report of the same entry + /// is a duplicate the notice log keeps, never an update to the row. + #[test] + fn a_server_tool_call_appears_finished_and_never_transitions() { + let mut state = ViewState::default(); + let agent = agent_id("agent-a"); + state.apply(envelope(1, created("agent-a"))); + assert_eq!( + state.apply(envelope(2, server_tool_event(&agent, "search", &["rust"]))), + ApplyOutcome::Accepted + ); + let outcome = state.apply(envelope(3, server_tool_event(&agent, "search", &["again"]))); + assert!( + matches!( + outcome, + ApplyOutcome::Rejected(ReduceError::DuplicateTranscriptItem(ref item)) if item == &item_id("search") + ), + "{outcome:?}" + ); + let entries: Vec<_> = state + .primary_agent() + .unwrap_or_else(|| panic!("agent was projected")) + .entries() + .collect(); + assert!( + matches!( + entries.as_slice(), + [TranscriptEntryView::ServerTool(view)] + if view.revision == 0 + && view.call.action == plexmaton_core::ServerToolAction::Search { queries: vec!["rust".to_owned()] } + ), + "{entries:?}" + ); + } + #[test] fn every_step_of_the_canonical_scenario_is_accepted() { // The shared fixture ignores the outcome so a degraded projection is still constructible. @@ -624,11 +684,12 @@ mod tests { state.apply(envelope( 8, ConversationEvent::RuntimeError { - agent_id: agent, + agent_id: agent.clone(), item_id: item_id("error"), message: "failed".to_owned(), }, )); + state.apply(envelope(9, server_tool_event(&agent, "search", &["rust"]))); let entries: Vec<_> = state .primary_agent() @@ -641,6 +702,7 @@ mod tests { TranscriptTextKind::Error => "error", }, TranscriptEntryView::Tool(_) => "tool", + TranscriptEntryView::ServerTool(_) => "server_tool", TranscriptEntryView::Artifact(_) => "artifact", TranscriptEntryView::Mail(_) => "mail", TranscriptEntryView::Task(_) => "task", @@ -649,7 +711,15 @@ mod tests { .collect(); assert_eq!( entries, - ["text", "tool", "artifact", "mail", "warning", "error"] + [ + "text", + "tool", + "artifact", + "mail", + "warning", + "error", + "server_tool" + ] ); assert_eq!( state.notices().count(), diff --git a/crates/plexmaton-tui/src/state/mod.rs b/crates/plexmaton-tui/src/state/mod.rs index 5ca4fe9..6a44c8f 100644 --- a/crates/plexmaton-tui/src/state/mod.rs +++ b/crates/plexmaton-tui/src/state/mod.rs @@ -59,8 +59,8 @@ pub(crate) use disclosure::{DisclosureState, EntryAppearance, EntryTarget}; pub(crate) use drawer::Shown; pub use drawer::{Drawer, Page}; pub use entry::{ - ArtifactView, HandoffView, MailView, TaskView, ToolCallView, TranscriptEntryView, - TranscriptItemView, TranscriptTextKind, + ArtifactView, HandoffView, MailView, ServerToolView, TaskView, ToolCallView, + TranscriptEntryView, TranscriptItemView, TranscriptTextKind, }; pub use ingest::{ApplyOutcome, ReduceError}; pub(crate) use input_queue::{QUEUE_RULE_ROWS, queued_lines}; diff --git a/crates/plexmaton-tui/src/state/selection.rs b/crates/plexmaton-tui/src/state/selection.rs index bba1af3..c815045 100644 --- a/crates/plexmaton-tui/src/state/selection.rs +++ b/crates/plexmaton-tui/src/state/selection.rs @@ -327,6 +327,8 @@ fn entry_source(entry: &TranscriptEntryView) -> Option { match entry { TranscriptEntryView::Text(item) => Some(item.source.clone()), TranscriptEntryView::Tool(tool) => tool_source(&tool.presentation), + // The same lines disclosure shows: what the route reported, and nothing it did not. + TranscriptEntryView::ServerTool(view) => view.action_source(), // The pointer, not the human label: the pointer is the stable artifact value (SEL-2). TranscriptEntryView::Artifact(artifact) => Some(artifact.pointer.clone()), // The opposite endpoint's display label travels with the addressed body (ENT-1). @@ -433,6 +435,49 @@ mod tests { assert!(!copied.contains("outcome:")); } + /// ENT-4 and SEL-2: a server tool's copy is what the route reported, one fact per line, the + /// same lines disclosure shows; a search reported without a query has no source to copy. + #[test] + fn server_tool_copy_is_what_the_route_reported() { + use super::TranscriptEntryView; + use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus}; + let view = |action| crate::ServerToolView { + entry_id: TranscriptItemId::new("search") + .unwrap_or_else(|error| panic!("fixture: {error}")), + call: ServerToolCall { + tool: ServerTool::WebSearch, + action, + status: ServerToolStatus::Completed, + }, + revision: 0, + }; + assert_eq!( + super::entry_source(&TranscriptEntryView::ServerTool(view( + ServerToolAction::Search { + queries: vec!["a".to_owned(), "b".to_owned()], + } + ))), + Some("query: a\nquery: b".to_owned()) + ); + assert_eq!( + super::entry_source(&TranscriptEntryView::ServerTool(view( + ServerToolAction::FindInPage { + url: "releases.rs".to_owned(), + pattern: "1.98".to_owned(), + } + ))), + Some("url: releases.rs\npattern: 1.98".to_owned()) + ); + assert_eq!( + super::entry_source(&TranscriptEntryView::ServerTool(view( + ServerToolAction::Search { + queries: Vec::new(), + } + ))), + None + ); + } + /// ENT-4: copying a whole tool entry retains cwd and timeout; only modal copy extracts shell source. #[test] fn command_tool_copy_keeps_the_complete_invocation_context() { diff --git a/crates/plexmaton-tui/src/theme.rs b/crates/plexmaton-tui/src/theme.rs index 22688a1..28b8048 100644 --- a/crates/plexmaton-tui/src/theme.rs +++ b/crates/plexmaton-tui/src/theme.rs @@ -2,7 +2,7 @@ //! //! A [`Slots`] is twelve colours named for where each sits — a ground ramp and eight hues around //! the wheel — and is the only thing a theme supplies. A [`Role`] is what a colour *means* here, -//! and [`Palette::from_slots`] is the one place the sixteen roles are spent on those twelve slots. +//! and [`Palette::from_slots`] is the one place the seventeen roles are spent on those twelve slots. //! Widgets name a role and never a colour, so a theme changes how the product looks and never what //! it says. [`Palette::pastel`] is the shipped assignment, not a closed set. @@ -68,8 +68,8 @@ impl Slots { cyan: Color::Rgb(120, 210, 205), blue: Color::Rgb(130, 180, 240), // The value two separate places arrived at on their own while this slot did not exist. - purple: Color::Rgb(0xC8, 0xB3, 0xEA), - magenta: Color::Rgb(0xE7, 0x8D, 0xCC), + purple: Color::Rgb(0x8B, 0x5C, 0xF6), + magenta: Color::Rgb(0xD9, 0x46, 0xEF), } } @@ -133,6 +133,10 @@ pub enum Role { ActionRequired, /// A failed agent, or a producer that broke the event contract. Failure, + /// Work a provider did on its own side, outside the fence: the marker of a server tool call + /// that ended well. One that failed wears `Failure`, because a failure is one wherever it ran; + /// the tool's name beside the marker still says which it was. + ServerTool, /// Content the user has selected for copying. Selection, /// The row the next `Enter` acts on: a bar, weight and a hue together, so it is found at a @@ -142,7 +146,7 @@ pub enum Role { impl Role { /// Every role, used by tests and by palette completeness checks. - pub const ALL: [Self; 16] = [ + pub const ALL: [Self; 17] = [ Self::Body, Self::Muted, Self::Border, @@ -157,6 +161,7 @@ impl Role { Self::NewInformation, Self::ActionRequired, Self::Failure, + Self::ServerTool, Self::Selection, Self::Chosen, ]; @@ -254,12 +259,13 @@ pub struct Palette { new_information: Style, action_required: Style, failure: Style, + server_tool: Style, selection: Style, chosen: Style, } impl Palette { - /// Spends the sixteen roles on a theme's twelve slots. + /// Spends the seventeen roles on a theme's twelve slots. /// /// This mapping is the product's half of the palette and lives in exactly one place. A theme /// supplies colours and reaches no further, so no colourway can make a failure read as a @@ -291,6 +297,11 @@ impl Palette { new_information: Style::new().fg(slots.green), action_required: Style::new().fg(slots.orange).add_modifier(Modifier::BOLD), failure: Style::new().fg(slots.red).add_modifier(Modifier::BOLD), + // Purple was the wheel's unspent hue, so what the provider did on its side gets a + // colour no other meaning shares; no weight, because it is finished work, not a call + // on the user. The user chose the shade on 2026-09-21 against violet, magenta and the + // slot's earlier lavender. + server_tool: Style::new().fg(slots.purple), selection: SELECTION, chosen: Style::new() .fg(slots.yellow) @@ -324,18 +335,19 @@ impl Palette { new_information: style(Role::NewInformation), action_required: style(Role::ActionRequired), failure: style(Role::Failure), + server_tool: style(Role::ServerTool), selection: style(Role::Selection), chosen: style(Role::Chosen), } } - /// The shipped palette: [`Slots::designed`] spent on the sixteen roles. + /// The shipped palette: [`Slots::designed`] spent on the seventeen roles. /// /// A role says what a thing is — blue for where you are, cyan for work in progress, green for /// what finished, orange for what needs you, red for what failed, yellow for what `Enter` acts - /// on. Weight makes titles and the chosen row read first; a low-saturation hue with no weight - /// on it is what keeps work in progress quiet, because a slant would draw the eye `Ambient` - /// exists to spare. + /// on, purple for what the provider did on its own side. Weight makes titles and the chosen + /// row read first; a low-saturation hue with no weight on it is what keeps work in progress + /// quiet, because a slant would draw the eye `Ambient` exists to spare. /// It differs from any other assignment of the same slots in one way only: its Markdown is /// designed for reading rather than inherited from the roles (MD-5). /// @@ -408,6 +420,7 @@ impl Palette { Role::NewInformation => self.new_information, Role::ActionRequired => self.action_required, Role::Failure => self.failure, + Role::ServerTool => self.server_tool, Role::Selection => self.selection, Role::Chosen => self.chosen, } diff --git a/crates/plexmaton-tui/src/transcript/spacing.rs b/crates/plexmaton-tui/src/transcript/spacing.rs index dd2de4e..3b17db7 100644 --- a/crates/plexmaton-tui/src/transcript/spacing.rs +++ b/crates/plexmaton-tui/src/transcript/spacing.rs @@ -23,14 +23,13 @@ impl Spacing { before_feedback: 1, after_feedback: 0, }, - (TranscriptEntryView::Tool(_), Some(TranscriptEntryView::Text(_))) - if !has_after_feedback => - { - Self { - before_feedback: 0, - after_feedback: 1, - } - } + ( + TranscriptEntryView::Tool(_) | TranscriptEntryView::ServerTool(_), + Some(TranscriptEntryView::Text(_)), + ) if !has_after_feedback => Self { + before_feedback: 0, + after_feedback: 1, + }, _ => Self::default(), } } diff --git a/crates/plexmaton-tui/src/workspace/preparation.rs b/crates/plexmaton-tui/src/workspace/preparation.rs index f969bed..035c609 100644 --- a/crates/plexmaton-tui/src/workspace/preparation.rs +++ b/crates/plexmaton-tui/src/workspace/preparation.rs @@ -282,6 +282,7 @@ fn snapshot_bytes(entry: &crate::TranscriptEntryView) -> usize { + tool.presentation.invocation.as_ref().map_or(0, detail) + tool.presentation.outcome.as_ref().map_or(0, detail) } + Entry::ServerTool(view) => view.call.tool.name().len() + view.call.action.text_bytes(), Entry::Artifact(artifact) => { artifact.id.as_str().len() + artifact.label.len() + artifact.pointer.len() } From 754ab761348f9abbc03831624b8b78a30b8b755c Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:18:16 -0400 Subject: [PATCH 17/21] feat(tui): place a provider-run search where the provider began it A live gateway finishes every web_search_call after the last message, so a row placed at `done` landed after the answer it informed, and a reopened conversation, which orders by position, showed the rows somewhere else. The Responses decoder now reports the call when its item is added; the step places it as a running row at that position and finishes it at the next revision, or reports it failed if the step ends first, keeping no block for a call that never finished. The journal holds only the finished call, so replay shows the same row at revision zero, in the same place. The activity line names the running search like a local tool's. A loopback smoke drives the burst shape the gateway produced through the binary and reopens the conversation to prove the order holds. ENT-2, PRV-5, ui-ux and the plan say what the code now does; the spike records the measurement that forced the change. --- .agents/evidence/provider-adapter.md | 2 +- .agents/evidence/transcript-entry.md | 2 +- .../phase-04-stage-33-provider-side-tools.md | 14 +- .agents/specs/provider-adapter.md | 19 +-- .agents/specs/transcript-entry.md | 12 +- .../provider-side-tools/gateway-evidence.md | 4 + .agents/standards/quality-gates.md | 1 + .agents/ui-ux.md | 15 ++- .github/workflows/ci.yml | 1 + .../src/journal/projection/assistant.rs | 4 +- .../src/journal/projection/live_tests.rs | 107 +++++++++++++-- crates/plexmaton-agent/src/model.rs | 9 +- crates/plexmaton-agent/src/step.rs | 101 ++++++++++++-- .../plexmaton-agent/src/turn/model_input.rs | 16 +++ crates/plexmaton-cli/src/collaboration.rs | 2 + crates/plexmaton-cli/src/preparation/wire.rs | 96 +++++++++++++ crates/plexmaton-core/src/lib.rs | 37 ++++- .../plexmaton-provider/src/messages/stream.rs | 2 +- .../src/responses/stream.rs | 21 +-- .../tests/provider_fixtures.rs | 22 +++ .../tests/provider_fixtures/server_tools.rs | 24 +++- crates/plexmaton-runtime/src/http/summary.rs | 2 + crates/plexmaton-runtime/src/runtime/model.rs | 1 + .../examples/server_tool_preview.rs | 6 + .../plexmaton-tui/frames/server-tool-120.svg | 2 +- .../plexmaton-tui/frames/server-tool-60.svg | 2 +- .../plexmaton-tui/frames/server-tool-88.svg | 2 +- .../frames/server-tool-medium.txt | 6 +- .../frames/server-tool-narrow.txt | 6 +- .../frames/server-tool-open-wide.txt | 6 +- .../plexmaton-tui/frames/server-tool-wide.txt | 6 +- crates/plexmaton-tui/src/content/tool.rs | 56 +++++++- crates/plexmaton-tui/src/frames.rs | 15 ++- crates/plexmaton-tui/src/state/agent.rs | 54 +++++++- .../plexmaton-tui/src/state/current_work.rs | 38 ++++++ crates/plexmaton-tui/src/state/entry.rs | 17 ++- crates/plexmaton-tui/src/state/ingest.rs | 127 +++++++++++++++--- crates/plexmaton-tui/src/state/selection.rs | 5 +- .../src/workspace/preparation.rs | 8 +- scripts/provider_fixture.py | 5 +- scripts/smoke-server-tool.py | 126 +++++++++++++++++ 41 files changed, 876 insertions(+), 125 deletions(-) create mode 100755 scripts/smoke-server-tool.py diff --git a/.agents/evidence/provider-adapter.md b/.agents/evidence/provider-adapter.md index 3d54ddd..779c3fc 100644 --- a/.agents/evidence/provider-adapter.md +++ b/.agents/evidence/provider-adapter.md @@ -10,6 +10,6 @@ resolves every one against the source, so a renamed or deleted test fails the bu | PRV-2 | Both fixture round trips, `prv_2_production_tool_argument_limit_remains_64_kibibytes`, `parallel_calls_share_one_aggregate_argument_bound`, `reverse_parallel_call_completion_is_sorted_before_dispatch`, `prv_2_both_protocols_bound_tool_identity_before_emission`, `assistant_output_rechecks_aggregate_text_and_tool_identity_bounds`, `oversized_semantic_text_fails_before_canonical_commit`, `maximal_valid_assistant_output_fits_the_journal_line_envelope`, `prv_2_rejects_duplicate_chat_tool_call_ids_before_emission`, `prv_2_rejects_incremental_duplicate_responses_tool_call_ids`, `prv_2_responses_counts_incrementally_completed_calls_toward_the_step_bound`, `prv_2_rejects_tool_arguments_before_they_can_reach_admission`, `prv_2_bounds_even_empty_responses_output_items`, `prv_2_responses_text_done_confirms_deltas_or_supplies_the_only_copy`, `prv_2_stopped_is_withheld_until_the_stream_trailer_is_valid`, `prv_2_sse_framing_rejects_partial_utf8`, `prv_2_messages_rejects_unfinished_mismatched_and_oversized_blocks`, `prv_2_gemini_call_ids_are_scoped_and_parallel_signatures_keep_their_part`, `prv_2_gemini_rejects_incomplete_duplicate_and_oversized_content`, `server_tool_calls_share_the_tool_argument_bound` | | PRV-3 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing`, `prv_3_a_degraded_call_and_its_result_keep_one_identity`, `prv_3_a_degraded_gemini_call_carries_neither_signature_nor_upstream_id`, `prv_3_an_output_that_was_only_its_replay_degrades_to_no_message`, `prv_3_an_id_every_dialect_accepts_is_carried_through_unchanged`, `prv_3_an_id_the_destination_would_reject_becomes_one_it_accepts`, `prv_3_distinct_rewritten_ids_stay_distinct`, `prv_3_replay_route_owner_encoding_is_unambiguous`, `prv_3_rejects_opaque_replay_before_step_state_can_grow`, `assistant_output_round_trips_order_and_redacts_replay`, `replay_only_wire_without_a_payload_is_rejected`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `provider_replay_is_named_and_bounded_before_turn_state_can_retain_it`, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_cancelled_calls_leave_no_dangling_replay`, `prv_3_chat_reasoning_aliases_round_trip_without_renaming`, `prv_3_chat_accepts_reasoning_details_that_only_restate_the_plain_delta`, `prv_3_gemini_signature_only_and_early_signature_parts_are_replayable`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation`, `prv_3_unsigned_reasoning_in_a_tool_turn_is_refused_by_messages_and_omitted_by_responses`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call` | | PRV-4 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools` proves stateless full-record replay without a response ID, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation` | -| PRV-5 | `http_rate_limit_is_typed_and_keeps_retry_after`, `context_error_is_classified_by_wire_code`, `prv_5_responses_done_only_refusal_is_visible_and_typed`, and both fixture completion reasons, `prv_5_chat_rejects_structured_or_conflicting_reasoning`, `prv_5_messages_context_limits_and_refusals_keep_final_usage`, `native_stream_rate_limits_are_typed`, `gemini_declared_failures_preserve_diagnostics_without_dispatching_calls`, `messages_usage_refuses_unaccounted_server_tools`, `prv_5_gemini_policy_and_unsupported_image_finishes_are_distinct`, `stream_provider_errors_keep_their_category_and_observed_usage`, `dispatched_http_failures_preserve_their_terminal_measurements`, `provider_failure_reopens_as_the_same_non_retryable_outcome`, `prv_5_empty_additive_fields_preserve_text_and_populated_fields_fail`, `prv_5_chat_admits_gateway_accounting_and_still_refuses_everything_else`, `prv_5_messages_pause_turn_is_explicitly_unsupported`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call`, `prv_5_an_unknown_server_tool_action_fails_the_step`, `prv_5_a_server_tool_call_without_an_action_fails_the_step`, `prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_once`, `prv_5_a_server_tool_calls_outcome_is_typed_from_its_status` | +| PRV-5 | `http_rate_limit_is_typed_and_keeps_retry_after`, `context_error_is_classified_by_wire_code`, `prv_5_responses_done_only_refusal_is_visible_and_typed`, and both fixture completion reasons, `prv_5_chat_rejects_structured_or_conflicting_reasoning`, `prv_5_messages_context_limits_and_refusals_keep_final_usage`, `native_stream_rate_limits_are_typed`, `gemini_declared_failures_preserve_diagnostics_without_dispatching_calls`, `messages_usage_refuses_unaccounted_server_tools`, `prv_5_gemini_policy_and_unsupported_image_finishes_are_distinct`, `stream_provider_errors_keep_their_category_and_observed_usage`, `dispatched_http_failures_preserve_their_terminal_measurements`, `provider_failure_reopens_as_the_same_non_retryable_outcome`, `prv_5_empty_additive_fields_preserve_text_and_populated_fields_fail`, `prv_5_chat_admits_gateway_accounting_and_still_refuses_everything_else`, `prv_5_messages_pause_turn_is_explicitly_unsupported`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call`, `prv_5_an_unknown_server_tool_action_fails_the_step`, `prv_5_a_server_tool_call_without_an_action_fails_the_step`, `prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_once`, `prv_5_a_server_tool_calls_outcome_is_typed_from_its_status`, `a_call_still_running_when_the_step_ends_is_shown_failed_and_kept_out_of_the_record` | | PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md), `prv_6_hosted_tools_are_declared_per_model_and_absent_by_default`, `prv_6_hosted_tool_declarations_fail_closed`, `prv_6_hosted_tool_declarations_need_a_dialect_spelling`, `prv_6_declared_hosted_tools_are_spelled_by_the_dialect_and_absent_otherwise`, `the_tool_name_is_its_configuration_spelling` | | PRV-7 | `event_guard_rejects_an_unterminated_event_at_the_bound`, `event_guard_rejects_one_oversized_transport_chunk`, `prv_2_and_prv_7_reject_unbounded_or_incomplete_provider_input`, `prv_7_model_config_cannot_widen_runtime_output_memory`, `interrupt_and_shutdown_cancel_and_join_the_exact_provider_task`, `native_stream_cancellation_preserves_observed_usage` | diff --git a/.agents/evidence/transcript-entry.md b/.agents/evidence/transcript-entry.md index 1b5d2e6..53a8484 100644 --- a/.agents/evidence/transcript-entry.md +++ b/.agents/evidence/transcript-entry.md @@ -7,7 +7,7 @@ resolves every one against the source, so a renamed or deleted test fails the bu | Invariant | Proven by | | --- | --- | | ENT-1 | `every_transcript_identity_is_new_and_names_its_agent`, `every_transcript_category_enters_one_ordered_projection`, `cancelled_submit_publishes_staged_events_before_projected_delegation`, `retained_projection_stays_with_its_root_and_applies_once`, `retained_projection_rejects_a_replacement_for_the_same_conversation`, `a_letter_reaches_both_conversations_attributed_to_its_sender`, `each_side_of_one_item_names_the_other_end`, `copying_a_conversation_preserves_interleaved_entry_sources`, `an_entry_identity_cannot_move_between_agents`, `selected_branches_place_or_suffix_one_delegated_reference_without_duplication`, `a_direct_link_wins_over_a_later_fallback_anchor`, `durable_links_place_shared_rows_and_legacy_rows_keep_a_stable_suffix`, `real_writers_reopen_durable_shared_rows_in_their_live_order_twice`, `later_child_activation_skips_replayed_entries_and_accepts_new_work`, `passive_child_activation_applies_a_real_same_item_recovery_revision_once`, `orphaned_root_ingress_persists_one_link_before_live_projection`, `selected_session_placement_rejects_a_link_from_another_announced_agent`, `shuffled_tool_completions_update_their_original_entries`, `interleaved_text_and_tools_keep_their_positions_when_tools_finish_out_of_order`, `non_chat_text_roles_have_distinct_named_treatments`, `reasoning_terminal_newlines_share_measurement_and_paint`, `reasoning_spacing_keeps_pointer_copy_visible_and_message_copy_exact`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `the_remaining_transcript_grammar_frames_match_their_fixtures` with the `transcript-grammar-*` frames; `scripts/smoke-delegate.py` proves ordered live/reopen task, mail and display-label rows | -| ENT-2 | `tool_transitions_do_not_reuse_stale_prepared_status`, `a_step_that_asked_for_tools_dispatches_them_and_waits`, `production_tool_lifecycles_replay_as_one_entry_each`, `tool_lifecycle_allows_only_forward_declared_transitions`, `tool_updates_refuse_revision_gaps_and_invalid_transitions`, `results_are_assembled_in_the_order_the_model_asked_and_not_the_order_they_finished`, `every_tool_status_is_one_named_logical_line`, `a_tool_transition_remeasures_only_its_original_entry`, `a_server_tool_call_appears_finished_and_never_transitions`, `a_server_tool_row_keeps_the_tool_grammar_and_claims_only_what_the_route_reported`, `the_server_tool_frames_match_their_fixtures` with the `server-tool-*` frames | +| ENT-2 | `tool_transitions_do_not_reuse_stale_prepared_status`, `a_step_that_asked_for_tools_dispatches_them_and_waits`, `production_tool_lifecycles_replay_as_one_entry_each`, `tool_lifecycle_allows_only_forward_declared_transitions`, `tool_updates_refuse_revision_gaps_and_invalid_transitions`, `results_are_assembled_in_the_order_the_model_asked_and_not_the_order_they_finished`, `every_tool_status_is_one_named_logical_line`, `a_tool_transition_remeasures_only_its_original_entry`, `a_server_tool_call_runs_at_revision_zero_and_finishes_once`, `a_reopened_server_tool_call_appears_finished_at_revision_zero`, `a_call_still_running_when_the_step_ends_is_shown_failed_and_kept_out_of_the_record`, `a_running_server_tool_is_the_current_work`, `a_server_tool_row_keeps_the_tool_grammar_and_claims_only_what_the_route_reported`, `a_running_server_tool_row_is_named_and_wears_the_server_tool_colour`, `the_server_tool_frames_match_their_fixtures` with the `server-tool-*` frames; `scripts/smoke-server-tool.py` proves live placement, the running state and reopen order through a real PTY | | ENT-3 | `every_event_variant_survives_a_json_round_trip`, `two_fresh_projections_of_the_same_envelopes_are_equal`, `rejected_event_does_not_block_the_rest_of_the_stream`, crate-graph gate, `ent_3_a_server_tool_call_is_the_same_event_live_and_on_replay` | | ENT-4 | `command_tool_copy_keeps_the_complete_invocation_context`, `cmd_1_command_detail_preserves_multiline_source_and_context`, `command_modal_copies_exact_source_and_returns_to_the_pending_approval`, `tool_disclosure_never_creates_a_copy_range_or_reverses_detail_at_three_widths`, `selected_diff_keeps_semantic_colors_and_reuses_prepared_rows_at_three_widths`, Presentation production/bounds: `bounded_presentation_text_carries_exact_omission_metadata`, `tool_status_updates_accumulate_invocation_and_outcome_presentation`, `cancellation_before_admission_has_outcome_without_invocation`, `maximum_valid_edit_retains_a_complete_bounded_patch`, `file_observation_survives_the_runtime_boundary_into_an_approved_edit`, `maximal_command_result_stays_bounded_in_the_next_model_request`; disclosure/copy: `every_tool_status_can_disclose_the_same_typed_detail`, `a_long_letter_is_one_row_until_ctrl_o_opens_it`, `a_heading_spends_columns_and_never_more_than_it_has`, `every_ellipsis_is_a_promise_disclosure_keeps`, `ctrl_o_opens_the_selections_focus_entry_in_place_at_each_drawn_width`, `pointer_and_ctrl_o_toggle_the_same_item_while_drag_cancels_disclosure` and, for the half of that parity a letter depends on, `a_click_opens_a_letter_the_same_way_ctrl_o_does`, `tool_completion_preserves_the_users_open_state`, `tool_copy_preserves_every_retained_source_in_producer_order`, `tool_copy_is_identical_when_compact_open_resized_scrolled_and_repainted`, `the_open_tool_frames_match_their_fixtures` with the `tool-open-*` frames; diff treatment: `canonical_diff_lines_keep_markers_and_receive_bounded_semantic_roles`, `opaque_maximum_diff_line_degrades_to_exact_plain_text`, `the_remaining_transcript_grammar_frames_match_their_fixtures`, `a_server_tool_row_discloses_what_the_route_reported`, `server_tool_copy_is_what_the_route_reported` | diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md index 51f921c..26e1e74 100644 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ b/.agents/plans/phase-04-stage-33-provider-side-tools.md @@ -43,12 +43,14 @@ beside it, rather than leaving a second reading in the corpus. against. Usage stops failing a step when a provider reports non-zero hosted-tool counts, and accounts them instead. -4. **Transcript — implemented.** The call reaches the workspace as one event in its terminal state - and one row in the tool row's grammar, coloured for who ran it (ui-ux §transcript grammar, - ENT-2). The row shows what its route returned and claims nothing further; the - [spike](../spikes/provider-side-tools/meta-route.md) records what each measured route gives. - The user chose the grammar and colour from rendered candidates and reviewed the real frames at - three widths on 2026-09-21. +4. **Transcript — implemented.** The call reaches the workspace where the provider placed it, as a + running row that finishes once, in the tool row's grammar and coloured for who ran it (ui-ux + §transcript grammar, ENT-2); a reopened conversation shows the same rows in the same places, and + `scripts/smoke-server-tool.py` drives the burst one live gateway produced through the binary to + prove it. The row shows what its route returned and claims nothing further; the + [spike](../spikes/provider-side-tools/meta-route.md) records what each measured route gives. The + user chose the grammar and colour from rendered candidates and reviewed the real frames at three + widths on 2026-09-21. 5. **Replay — implemented.** A hosted-tool item round-trips when the conversation continues, under PRV-3's existing sidecar rules, with its status and without its provider-assigned id, which the route in diff --git a/.agents/specs/provider-adapter.md b/.agents/specs/provider-adapter.md index e63cdcd..a2608e5 100644 --- a/.agents/specs/provider-adapter.md +++ b/.agents/specs/provider-adapter.md @@ -97,14 +97,17 @@ becomes a server-tool call in the record: what the provider did, a search, an op within one, bounded the way tool arguments are, and how it ended, completed or failed, typed from the item's status; a done item that still claims to be running fails the step. The provider's own item identity and wire status stay replay metadata. It reaches no admission and no scheduler, -because the call already happened inside the model call the owner authorised by declaring the -route accepts it. An action -kind this harness cannot name fails the step; a search that arrived with no query is carried as it -arrived; the progress markers a route streams while searching carry nothing the finished item -lacks and are ignored. Messages' `server_tool_use` block and a non-zero server-tool count remain -refused until a Messages route is in daily use to prove a decoder against. Rejected: keeping the -call as opaque replay alone, which round-trips and leaves the record unable to say a search -happened; and a catch-all action kind, which would carry content the record cannot name. +because the call already happened inside the model call the owner authorised by declaring the route +accepts it. The item's first appearance on the stream places the call, so the row sits where the +provider put it and not where a route chose to finish it: one live route finishes every search at +the end of the stream. A call still running when the step ends is shown as failed and keeps no +block, because there is nothing to replay. An action kind this harness cannot name fails the step; a +search that arrived with no query is carried as it arrived; the progress markers a route streams +while searching carry nothing the finished item lacks and are ignored. Messages' `server_tool_use` +block and a non-zero server-tool count remain refused until a Messages route is in daily use to +prove a decoder against. Rejected: keeping the call as opaque replay alone, which round-trips and +leaves the record unable to say a search happened; and a catch-all action kind, which would carry +content the record cannot name. **PRV-6 — Configuration names data, never authority.** `~/.plexmaton/config.toml` separates named provider routes from their named models and selects one exact provider/model pair. A route owns its diff --git a/.agents/specs/transcript-entry.md b/.agents/specs/transcript-entry.md index 0ef8c25..bcf2bdc 100644 --- a/.agents/specs/transcript-entry.md +++ b/.agents/specs/transcript-entry.md @@ -37,10 +37,14 @@ streaming and after finalization; internal line breaks and SEL-2's original sour each accepted lifecycle transition advances exactly one revision on the original entry. Display order is first appearance, execution completion order is event order, and model result order stays the batch's model-call order (APV-5). Rejected: moving a completed call to the tail or using its -completion position as model order. A server tool call is one entry too, and appears once, in the -state it ended in, at revision zero: nothing here queued or dispatched it, so it never transitions, -and a second report of the same entry is a duplicate the notice log keeps. It is drawn in the tool -row's grammar, and the row and its colour are the contract's (ui-ux §transcript grammar). +completion position as model order. A server tool call is one entry too, with two states rather +than seven: it appears running at revision zero where the provider placed it, and finishes once at +revision one with what the provider did. A reopened conversation holds only the finished call and +shows it finished at revision zero. Nothing here queued, admitted or dispatched it, and a finished +call never changes again, so a second finish is a duplicate the notice log keeps. It is drawn in +the tool row's grammar, and the row and its colour are the contract's (ui-ux §transcript grammar). +Rejected: placing the row when the call finishes, which on one live route put every search after +the answer it informed, and showed a reopened conversation in a different order from the live one. **ENT-3 — Replay is pure reduction.** Feeding the same ordered envelopes to a fresh projection produces the same state and can only mutate that projection; it cannot contact a provider, consult diff --git a/.agents/spikes/provider-side-tools/gateway-evidence.md b/.agents/spikes/provider-side-tools/gateway-evidence.md index f0a52a7..c7e458f 100644 --- a/.agents/spikes/provider-side-tools/gateway-evidence.md +++ b/.agents/spikes/provider-side-tools/gateway-evidence.md @@ -150,3 +150,7 @@ may rely on. The live dev config claimed `max` among muse's allowed efforts. It is rejected by the route, and the claim was removed. luna's `none` and `max`, grok's `xhigh` and deepseek's `none` were each probed and are honest. + +Measured again 2026-09-21 through the branch binary and a direct stream: every `web_search_call` +arrived as `search` with an empty `query`; every `done` came after the last message finished, while +each `added` came at its true position, which is why the row is placed at `added`. diff --git a/.agents/standards/quality-gates.md b/.agents/standards/quality-gates.md index f4c8f75..a9bcece 100644 --- a/.agents/standards/quality-gates.md +++ b/.agents/standards/quality-gates.md @@ -43,6 +43,7 @@ Supply-chain and corpus lanes, selected by dependency or document changes: | `python3 scripts/smoke-tree.py` | Tree aliases, rewind/edit/send, original branch and selected-head restart through a real PTY; exact loopback context and no repeated command effect | | `python3 scripts/smoke-permissions.py` | Project trust, real command execution, a remembered prefix across restart, revoke/deny and three widths in a real PTY; eight bounded local fixture requests | | `python3 scripts/smoke-delegate.py` | Delegation/mail/task update, Handoff/User input, Stop/root continuation, three-width resume, CLI process cuts, and process-dead approval followed by fresh current-policy work. Acceptance evidence; binds a local fixture port, so it needs no sandbox | +| `python3 scripts/smoke-server-tool.py` | A provider-run search placed where the provider put it, running until finished, at three widths and after reopening, against one loopback Responses burst shaped like a live gateway's; binds a local fixture port | | `PLEXMATON_WRITE_FRAMES=1 cargo test -p plexmaton-tui frames` | Rewrites the frames under `crates/plexmaton-tui/frames/`; the diff is the review | | `cargo run --release -p plexmaton-cli --bin plexmaton-measure` | What a frame costs. Reports only; its work counts are asserted by the test suite, and its timings belong to the machine that ran it ([frame-loop](../specs/frame-loop.md) FR-4) | diff --git a/.agents/ui-ux.md b/.agents/ui-ux.md index 8c99663..c1aff84 100644 --- a/.agents/ui-ux.md +++ b/.agents/ui-ux.md @@ -514,13 +514,14 @@ full of concurrent agents stays navigable: - Tool call: `[ ] queued`, `[~] running`, `[?] approval required`, `[+] succeeded`, `[!] failed`, `[x] denied`, or `[-] cancelled`; retained invocation and outcome disclose beneath the same row - Server tool call: the tool row's grammar, with the tool's name after the marker and what the - route reported after the `·`: the query, `opened` and the page, or the pattern and the page it - was sought `in`, and nothing further; a search reported without a query is the name alone. The - marker wears the server-tool colour when the call ended well and failure's when it did not. It - appears finished and never transitions. What the route reported discloses beneath the row. The - user chose the grammar and its colour from rendered candidates on 2026-09-21. Rejected: a row - family of its own, which gave up the marker column and the disclosure tools already have; the - plain succeeded colour, which read as a tool run inside the fence + route reported after the `·`. It appears `[~] running` where the provider began the call and + finishes once: the query, `opened` and the page, or the pattern and the page it was sought `in`, + and nothing further; a search reported without a query is the name alone. The marker wears the + server-tool colour while running and when the call ended well, and failure's when it did not. + What the route reported discloses beneath the row. The user chose the grammar and its colour + from rendered candidates on 2026-09-21. Rejected: a row family of its own, which gave up the + marker column and the disclosure tools already have; the plain succeeded colour, which read as + a tool run inside the fence - Diff with original `+`/`-` markers, and artifact - Agent mail, and Main-authored task updates, each entering both conversations it names and saying in a word which side its row is: `sent to`, `received from`, `assigned to`, `assigned by`, naming diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c270ee..0064e2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,7 @@ jobs: - run: python3 scripts/smoke-input-queue.py - run: python3 scripts/smoke-tree.py - run: python3 scripts/smoke-delegate.py + - run: python3 scripts/smoke-server-tool.py ci: name: macOS Apple Silicon diff --git a/crates/plexmaton-agent/src/journal/projection/assistant.rs b/crates/plexmaton-agent/src/journal/projection/assistant.rs index 4c40f94..9b50e99 100644 --- a/crates/plexmaton-agent/src/journal/projection/assistant.rs +++ b/crates/plexmaton-agent/src/journal/projection/assistant.rs @@ -68,11 +68,13 @@ impl Projector { ); calls.push(call_id); } - // One event in its terminal state, the same one the live step emitted (ENT-3). + // The record holds only the finished call, so here it is the whole entry: the + // same row the live step finished at revision one, at revision zero (ENT-2). AssistantBlock::ServerToolCall { item_id, call } => { self.emit(ConversationEvent::ServerToolCalled { agent_id: agent_id.clone(), item_id: item_id.clone(), + item_revision: 0, call: call.clone(), })?; } diff --git a/crates/plexmaton-agent/src/journal/projection/live_tests.rs b/crates/plexmaton-agent/src/journal/projection/live_tests.rs index 3a92ce3..10adfa9 100644 --- a/crates/plexmaton-agent/src/journal/projection/live_tests.rs +++ b/crates/plexmaton-agent/src/journal/projection/live_tests.rs @@ -41,8 +41,9 @@ fn jrn_5_canonical_live_turn_and_journal_replay_have_equal_model_context() { assert_eq!(projection.request().atoms, live.record()); } -/// ENT-3: a call the provider ran is one event in its terminal state, and the journal's replay says -/// the same event the live step said, so a reopened conversation shows the same row. +/// ENT-2: live, a call the provider ran appears running where the provider placed it and finishes +/// at the next revision; the journal holds only the finished call, so replay says the same finished +/// row at revision zero, and a reopened conversation shows what the live one ended with. #[test] fn ent_3_a_server_tool_call_is_the_same_event_live_and_on_replay() { use plexmaton_core::{ @@ -65,6 +66,23 @@ fn ent_3_a_server_tool_call_is_the_same_event_live_and_on_replay() { }, status: ServerToolStatus::Failed, }; + let started = live.handle(Input::Streamed { + step_id: step_id.clone(), + event: ModelEvent::ServerToolStarted { + position: ModelOutputPosition::new(0, 0), + tool: ServerTool::WebSearch, + }, + }); + assert!( + matches!( + started.events.as_slice(), + [envelope] if matches!( + &envelope.event, + ConversationEvent::ServerToolStarted { tool: ServerTool::WebSearch, .. } + ) + ), + "{started:?}" + ); let searched = live.handle(Input::Streamed { step_id: step_id.clone(), event: ModelEvent::ServerToolCall { @@ -89,14 +107,18 @@ fn ent_3_a_server_tool_call_is_the_same_event_live_and_on_replay() { .map(|envelope| envelope.event.clone()) .filter(|event| matches!(event, ConversationEvent::ServerToolCalled { .. })) .collect(); - assert!( - matches!( - live_events.as_slice(), - [ConversationEvent::ServerToolCalled { agent_id: live_agent, call: live_call, .. }] - if live_agent == &agent_id && live_call == &call - ), - "{live_events:?}" - ); + let [ + ConversationEvent::ServerToolCalled { + agent_id: live_agent, + item_id: live_item, + item_revision: 1, + call: live_call, + }, + ] = live_events.as_slice() + else { + panic!("{live_events:?}"); + }; + assert_eq!((live_agent, live_call), (&agent_id, &call)); let projection = live .journal() @@ -108,5 +130,68 @@ fn ent_3_a_server_tool_call_is_the_same_event_live_and_on_replay() { .map(|envelope| envelope.event.clone()) .filter(|event| matches!(event, ConversationEvent::ServerToolCalled { .. })) .collect(); - assert_eq!(replayed, live_events); + assert!( + matches!( + replayed.as_slice(), + [ConversationEvent::ServerToolCalled { agent_id, item_id, item_revision: 0, call: replayed_call }] + if agent_id == live_agent && item_id == live_item && replayed_call == live_call + ), + "{replayed:?}" + ); +} + +/// ENT-2/PRV-5: a call still running when the step ends did not finish. The row says so, and the +/// record keeps no block for it, because there is nothing to replay. +#[test] +fn a_call_still_running_when_the_step_ends_is_shown_failed_and_kept_out_of_the_record() { + use plexmaton_core::{ConversationEvent, ServerTool, ServerToolAction, ServerToolStatus}; + let agent_id = agent(); + let mut live = Agent::new(agent_id.clone()); + let _announcement = live.announce("Plexmaton"); + let submitted = live.handle(Input::Submitted { + text: "latest rust?".to_owned(), + }); + let step_id = match submitted.effects.as_slice() { + [Effect::CallModel(call)] => call.step_id.clone(), + other => panic!("expected one model call, got {other:?}"), + }; + let _started = live.handle(Input::Streamed { + step_id: step_id.clone(), + event: ModelEvent::ServerToolStarted { + position: ModelOutputPosition::new(0, 0), + tool: ServerTool::WebSearch, + }, + }); + let _answer = live.handle(Input::Streamed { + step_id: step_id.clone(), + event: ModelEvent::TextDelta { + position: ModelOutputPosition::new(0, 1), + delta: "1.98.1".to_owned(), + }, + }); + let stopped = live.handle(Input::Streamed { + step_id, + event: ModelEvent::Stopped(StopReason::EndOfTurn), + }); + assert!( + stopped.events.iter().any(|envelope| matches!( + &envelope.event, + ConversationEvent::ServerToolCalled { item_revision: 1, call, .. } + if call.status == ServerToolStatus::Failed + && call.action == ServerToolAction::Search { queries: Vec::new() } + )), + "{stopped:?}" + ); + let projection = live + .journal() + .project(&head("main")) + .unwrap_or_else(|error| panic!("project live journal: {error:?}")); + assert!( + projection.events().iter().all(|envelope| !matches!( + envelope.event, + ConversationEvent::ServerToolStarted { .. } + | ConversationEvent::ServerToolCalled { .. } + )), + "the record holds no call that never finished" + ); } diff --git a/crates/plexmaton-agent/src/model.rs b/crates/plexmaton-agent/src/model.rs index 46135e5..ba87dc6 100644 --- a/crates/plexmaton-agent/src/model.rs +++ b/crates/plexmaton-agent/src/model.rs @@ -6,7 +6,7 @@ //! adapter-side. A shared event type carrying one dialect's concerns leaves every other adapter //! fabricating fields it does not have. -use plexmaton_core::ServerToolCall; +use plexmaton_core::{ServerTool, ServerToolCall}; use plexmaton_core::{TokenUsage, TurnId}; use serde::{Deserialize, Serialize}; @@ -130,6 +130,13 @@ pub enum ModelEvent { position: ModelOutputPosition, call: ServerToolCall, }, + /// The provider began a call on its own side at this position; the finished call follows at + /// the same position as [`Self::ServerToolCall`]. Emitted so the row appears where the provider + /// placed the call rather than where a route chose to finish it. + ServerToolStarted { + position: ModelOutputPosition, + tool: ServerTool, + }, /// Provider-reported token consumption retained by the runtime in an attempt terminal. /// Direct delivery through `Input::Streamed` is refused (TIM-3). Usage(TokenUsage), diff --git a/crates/plexmaton-agent/src/step.rs b/crates/plexmaton-agent/src/step.rs index be346d6..3bcf73d 100644 --- a/crates/plexmaton-agent/src/step.rs +++ b/crates/plexmaton-agent/src/step.rs @@ -1,6 +1,6 @@ //! One model step's ordered, bounded output assembly. -use plexmaton_core::ServerToolCall; +use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus}; use std::collections::{BTreeMap, btree_map::Entry}; use plexmaton_core::{ConversationEvent, TranscriptItemId, TranscriptRole, TurnId}; @@ -42,6 +42,10 @@ enum PendingOutput { item_id: TranscriptItemId, call: ServerToolCall, }, + ServerToolStarted { + item_id: TranscriptItemId, + tool: ServerTool, + }, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -253,9 +257,53 @@ impl Step { } } + /// Places a call the provider has begun. The row appears running where the provider put the + /// call, so a route that finishes every call at the end of the stream cannot move it there; + /// [`Self::note_server_tool`] finishes it at the next revision. + pub(crate) fn note_server_tool_started( + &mut self, + record: &mut Record, + reaction: &mut Reaction, + position: ModelOutputPosition, + tool: ServerTool, + ) -> Result<(), StepAssemblyError> { + self.reserve_output_position(position)?; + let item_id = Record::stream_item_id( + &self.turn_id, + self.index, + TranscriptRole::Assistant, + position, + ); + let pending = PendingOutput::ServerToolStarted { + item_id: item_id.clone(), + tool, + }; + match self.outputs.entry(position) { + Entry::Vacant(entry) => { + entry.insert(pending); + } + Entry::Occupied(mut entry) + if matches!(entry.get(), PendingOutput::ReplayOnly { .. }) => + { + entry.insert(pending); + } + Entry::Occupied(_) => return Err(StepAssemblyError::ConflictingPosition), + } + record.emit( + reaction, + ConversationEvent::ServerToolStarted { + agent_id: record.agent_id().clone(), + item_id, + tool, + }, + ); + Ok(()) + } + /// Records a call the provider already ran. It takes no call identity of ours and joins no /// batch: nothing here will be dispatched, so nothing here can be reused or left undone. The - /// workspace hears of it now, as one event in its terminal state, the way replay will say it. + /// workspace hears of it now, finishing the running row when one was placed and otherwise as + /// the whole entry, which is how replay will say it. pub(crate) fn note_server_tool( &mut self, record: &mut Record, @@ -283,25 +331,33 @@ impl Step { item_id: item_id.clone(), call: call.clone(), }; - match self.outputs.entry(position) { + let item_revision = match self.outputs.entry(position) { Entry::Vacant(entry) => { entry.insert(pending); + 0 } - // The adapter's replay for the same item may have landed first and reserved the - // position as replay-only; the call it belongs to takes the position over. - Entry::Occupied(mut entry) - if matches!(entry.get(), PendingOutput::ReplayOnly { .. }) => - { - entry.insert(pending); - } - Entry::Occupied(_) => return Err(StepAssemblyError::ConflictingPosition), - } + Entry::Occupied(mut entry) => match entry.get() { + // The adapter's replay for the same item may have landed first and reserved the + // position as replay-only; the call it belongs to takes the position over. + PendingOutput::ReplayOnly { .. } => { + entry.insert(pending); + 0 + } + // The call was placed when it began; this finishes that row. + PendingOutput::ServerToolStarted { tool, .. } if *tool == call.tool => { + entry.insert(pending); + 1 + } + _ => return Err(StepAssemblyError::ConflictingPosition), + }, + }; self.tool_argument_bytes = tool_argument_bytes; record.emit( reaction, ConversationEvent::ServerToolCalled { agent_id: record.agent_id().clone(), item_id, + item_revision, call, }, ); @@ -398,6 +454,27 @@ impl Step { PendingOutput::ServerToolCall { item_id, call } => { AssistantBlock::ServerToolCall { item_id, call } } + // The step ended with the call still running, so it did not finish: the row says + // so, and the record keeps no block, because there is nothing to replay. The tool + // is web search, so a search it is; the route reported no query. + PendingOutput::ServerToolStarted { item_id, tool } => { + record.emit( + reaction, + ConversationEvent::ServerToolCalled { + agent_id: record.agent_id().clone(), + item_id, + item_revision: 1, + call: ServerToolCall { + tool, + action: ServerToolAction::Search { + queries: Vec::new(), + }, + status: ServerToolStatus::Failed, + }, + }, + ); + continue; + } }; let block_index = u16::try_from(blocks.len()) .unwrap_or_else(|_| unreachable!("provider output block count is bounded")); diff --git a/crates/plexmaton-agent/src/turn/model_input.rs b/crates/plexmaton-agent/src/turn/model_input.rs index 1f78cf8..cd1e3bf 100644 --- a/crates/plexmaton-agent/src/turn/model_input.rs +++ b/crates/plexmaton-agent/src/turn/model_input.rs @@ -105,6 +105,22 @@ impl Agent { ); } } + ModelEvent::ServerToolStarted { position, tool } => { + let result = match &mut self.turn { + Turn::Streaming { step, .. } => { + step.note_server_tool_started(&mut self.record, reaction, position, tool) + } + Turn::Idle | Turn::Working { .. } => return, + }; + if let Err(error) = result { + self.fail( + &crate::ModelError::Malformed { + message: error.message().to_owned(), + }, + reaction, + ); + } + } ModelEvent::ServerToolCall { position, call } => { let result = match &mut self.turn { Turn::Streaming { step, .. } => { diff --git a/crates/plexmaton-cli/src/collaboration.rs b/crates/plexmaton-cli/src/collaboration.rs index cadc27a..ccd31ae 100644 --- a/crates/plexmaton-cli/src/collaboration.rs +++ b/crates/plexmaton-cli/src/collaboration.rs @@ -492,6 +492,7 @@ fn item_of(event: &ConversationEvent) -> Option { | ConversationEvent::TranscriptDelta { item_id, .. } | ConversationEvent::TranscriptItemFinalized { item_id, .. } | ConversationEvent::ToolCallChanged { item_id, .. } + | ConversationEvent::ServerToolStarted { item_id, .. } | ConversationEvent::ServerToolCalled { item_id, .. } | ConversationEvent::TaskAssigned { item_id, .. } | ConversationEvent::MailDelivered { item_id, .. } @@ -519,6 +520,7 @@ const fn forwarded(event: &ConversationEvent) -> bool { | ConversationEvent::TranscriptDelta { .. } | ConversationEvent::TranscriptItemFinalized { .. } | ConversationEvent::ToolCallChanged { .. } + | ConversationEvent::ServerToolStarted { .. } | ConversationEvent::ServerToolCalled { .. } | ConversationEvent::AttentionRequested { .. } | ConversationEvent::AttentionResolved { .. } diff --git a/crates/plexmaton-cli/src/preparation/wire.rs b/crates/plexmaton-cli/src/preparation/wire.rs index a0a005e..2a594d8 100644 --- a/crates/plexmaton-cli/src/preparation/wire.rs +++ b/crates/plexmaton-cli/src/preparation/wire.rs @@ -459,3 +459,99 @@ mod tests { )); } } + +#[cfg(test)] +mod server_tool_tests { + use super::*; + use plexmaton_core::{ + AgentId, AgentStatus, ConversationEvent, ConversationEventEnvelope, EventSequence, + ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus, TranscriptItemId, + }; + use plexmaton_tui::Workspace; + use ratatui::{Terminal, backend::TestBackend}; + + fn screen(terminal: &Terminal) -> String { + let buffer = terminal.backend().buffer(); + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect::() + }) + .collect::>() + .join("\n") + } + + /// ENT-2/PRE-1: a running and a finished server tool row survive the same-build wire the + /// live workspace prepares through, and paint once the reply is admitted. + #[test] + fn server_tool_rows_prepare_through_the_wire_and_paint() { + let mut workspace = Workspace::default(); + let agent = AgentId::new("primary").expect("agent"); + let mut sequence = 0; + let mut emit = |workspace: &mut Workspace, event| { + sequence += 1; + workspace.emit(vec![ConversationEventEnvelope { + sequence: EventSequence::new(sequence), + event, + }]); + }; + emit( + &mut workspace, + ConversationEvent::AgentCreated { + agent_id: agent.clone(), + label: "Plexmaton".into(), + status: AgentStatus::Running, + }, + ); + emit( + &mut workspace, + ConversationEvent::ServerToolCalled { + agent_id: agent.clone(), + item_id: TranscriptItemId::new("done").expect("item"), + item_revision: 0, + call: ServerToolCall { + tool: ServerTool::WebSearch, + action: ServerToolAction::Search { + queries: vec!["latest stable Rust release".into()], + }, + status: ServerToolStatus::Completed, + }, + }, + ); + emit( + &mut workspace, + ConversationEvent::ServerToolStarted { + agent_id: agent, + item_id: TranscriptItemId::new("running").expect("item"), + tool: ServerTool::WebSearch, + }, + ); + let mut terminal = Terminal::new(TestBackend::new(88, 24)).expect("terminal"); + for round in 0..8 { + workspace.draw(&mut terminal).expect("draw"); + let Some(work) = workspace.take_preparation() else { + if !workspace.needs_draw() { + break; + } + continue; + }; + let pending = Pending::new(Ticket(round), work.requests).expect("admitted"); + let bytes = prepare(&pending.bytes).expect("child prepared"); + let reply = pending.decode_reply(&bytes).expect("reply matches request"); + let results = reply.result.expect("prepared"); + assert!( + workspace.complete_preparation(work.token, results), + "round {round}: the reply was not adopted" + ); + } + workspace.draw(&mut terminal).expect("settled draw"); + let text = screen(&terminal); + assert!( + text.contains("[+] web_search · latest stable Rust release"), + "{text}" + ); + assert!(text.contains("[~] web_search · running"), "{text}"); + assert!(!text.contains("Preparing text"), "{text}"); + } +} diff --git a/crates/plexmaton-core/src/lib.rs b/crates/plexmaton-core/src/lib.rs index a5d5d87..009975c 100644 --- a/crates/plexmaton-core/src/lib.rs +++ b/crates/plexmaton-core/src/lib.rs @@ -62,6 +62,7 @@ impl ConversationEvent { | Self::TranscriptDelta { agent_id, .. } | Self::TranscriptItemFinalized { agent_id, .. } | Self::ToolCallChanged { agent_id, .. } + | Self::ServerToolStarted { agent_id, .. } | Self::ServerToolCalled { agent_id, .. } | Self::AttentionRequested { agent_id, .. } | Self::AttentionResolved { agent_id, .. } @@ -377,15 +378,30 @@ pub enum ConversationEvent { /// Bounded semantic detail used by open and copy presentations. presentation: ToolPresentation, }, - /// A tool the provider ran on its own side inside one model call, reported once it ended. + /// The provider began running a tool on its own side inside one model call. /// - /// Nothing here was queued, admitted or dispatched, so the entry appears in its terminal state - /// and never transitions. It says what the route reported and claims nothing further. + /// Nothing here was queued, admitted or dispatched: the row appears running where the provider + /// placed the call, and [`Self::ServerToolCalled`] finishes it at the next revision. + ServerToolStarted { + /// Agent whose model call the provider is running the tool inside. + agent_id: AgentId, + /// Transcript position assigned when the call was first reported. + item_id: TranscriptItemId, + /// The tool the provider began running. + tool: ServerTool, + }, + /// A tool the provider ran on its own side ended, and this is what it did. + /// + /// At revision one it finishes the entry [`Self::ServerToolStarted`] opened; at revision zero + /// it is the whole entry, which is how a reopened conversation, holding only the finished + /// call, shows it. It says what the route reported and claims nothing further. ServerToolCalled { /// Agent whose model call the provider ran the tool inside. agent_id: AgentId, - /// Transcript position assigned when the call was reported. + /// Transcript position the call holds. item_id: TranscriptItemId, + /// Zero for a first appearance, one when it finishes a call that appeared running. + item_revision: u64, /// The tool, what it did with it, and how it ended. call: ServerToolCall, }, @@ -513,6 +529,10 @@ mod tests { AgentId::new(value).unwrap_or_else(|error| panic!("invalid fixture: {error}")) } + fn item_id(value: &str) -> TranscriptItemId { + TranscriptItemId::new(value).unwrap_or_else(|error| panic!("invalid fixture: {error}")) + } + #[test] fn stable_id_rejects_whitespace() { assert_eq!( @@ -605,10 +625,15 @@ mod tests { }), }, }, + ConversationEvent::ServerToolStarted { + agent_id: agent("agent-a"), + item_id: item_id("item-search-1"), + tool: ServerTool::WebSearch, + }, ConversationEvent::ServerToolCalled { agent_id: agent("agent-a"), - item_id: TranscriptItemId::new("item-search-1") - .unwrap_or_else(|error| panic!("invalid fixture: {error}")), + item_id: item_id("item-search-1"), + item_revision: 1, call: ServerToolCall { tool: ServerTool::WebSearch, action: ServerToolAction::FindInPage { diff --git a/crates/plexmaton-provider/src/messages/stream.rs b/crates/plexmaton-provider/src/messages/stream.rs index 46e171c..f9a7770 100644 --- a/crates/plexmaton-provider/src/messages/stream.rs +++ b/crates/plexmaton-provider/src/messages/stream.rs @@ -212,7 +212,7 @@ impl MessagesDecoder { } // This decoder produces none: Messages' server_tool_use keeps its refusal until a // Messages route is in daily use to prove a decoder against (PRV-5). - ModelEvent::ServerToolCall { .. } => { + ModelEvent::ServerToolStarted { .. } | ModelEvent::ServerToolCall { .. } => { return Err(DecodeError::UnsupportedEvent( "messages_server_tool_call".to_owned(), )); diff --git a/crates/plexmaton-provider/src/responses/stream.rs b/crates/plexmaton-provider/src/responses/stream.rs index 5aeb3c9..12d38cc 100644 --- a/crates/plexmaton-provider/src/responses/stream.rs +++ b/crates/plexmaton-provider/src/responses/stream.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use plexmaton_agent::{ModelEvent, ReplayCompatibility, StopReason}; -use plexmaton_core::ToolCallId; +use plexmaton_core::{ServerTool, ToolCallId}; use serde_json::Value; use super::call::CallAssembly; @@ -87,10 +87,7 @@ impl ResponsesDecoder { "response.output_text.done" => self.text_done(&event, "text", false), "response.refusal.delta" => self.text_delta(&event, true), "response.refusal.done" => self.text_done(&event, "refusal", true), - "response.output_item.added" => { - self.output_item_added(&event)?; - Ok(Vec::new()) - } + "response.output_item.added" => self.output_item_added(&event), "response.function_call_arguments.delta" => { self.arguments_delta(&event)?; Ok(Vec::new()) @@ -188,7 +185,7 @@ impl ResponsesDecoder { Ok(Vec::new()) } - fn output_item_added(&mut self, event: &Value) -> Result<(), DecodeError> { + fn output_item_added(&mut self, event: &Value) -> Result, DecodeError> { let index = usize_field(event, "output_index")?; let item = object_field(event, "item")?; match string_field(item, "type")? { @@ -202,9 +199,17 @@ impl ResponsesDecoder { index, limits, )?; - call.seed_arguments(optional_string(item, "arguments"), index, limits) + call.seed_arguments(optional_string(item, "arguments"), index, limits)?; + Ok(Vec::new()) } - "reasoning" | "message" | "web_search_call" => Ok(()), + // The item's first appearance is where the provider placed the call. One live route + // finishes every search at the end of the stream, so the finished item alone would + // put each row after the answer it informed (PRV-5). + "web_search_call" => Ok(vec![ModelEvent::ServerToolStarted { + position: output_position(index, 0)?, + tool: ServerTool::WebSearch, + }]), + "reasoning" | "message" => Ok(Vec::new()), other => Err(DecodeError::UnsupportedEvent(format!( "response.output_item.added:{other}" ))), diff --git a/crates/plexmaton-provider/tests/provider_fixtures.rs b/crates/plexmaton-provider/tests/provider_fixtures.rs index 5c23ad3..4dff32b 100644 --- a/crates/plexmaton-provider/tests/provider_fixtures.rs +++ b/crates/plexmaton-provider/tests/provider_fixtures.rs @@ -964,6 +964,28 @@ async fn prv_5_responses_web_search_call_is_carried_as_a_server_tool_call() { }) .collect(); assert_eq!(calls.len(), 2, "{events:?}"); + // Each call was placed when the provider began it, before its finished item, so the row sits + // where the provider put the call and not where the route chose to finish it. + let started: Vec<_> = events + .iter() + .filter_map(|event| match event { + ModelEvent::ServerToolStarted { position, .. } => Some(position.item()), + _ => None, + }) + .collect(); + assert_eq!(started, [1, 3], "{events:?}"); + for item in [1, 3] { + let placed = events.iter().position(|event| { + matches!(event, ModelEvent::ServerToolStarted { position, .. } if position.item() == item) + }); + let finished = events.iter().position(|event| { + matches!(event, ModelEvent::ServerToolCall { position, .. } if position.item() == item) + }); + assert!( + placed < finished, + "item {item}: {placed:?} then {finished:?}" + ); + } assert_eq!(calls[0].0, 1); assert_eq!(calls[0].1.tool, ServerTool::WebSearch); assert_eq!( diff --git a/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs b/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs index e6dba21..df5f82d 100644 --- a/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs +++ b/crates/plexmaton-provider/tests/provider_fixtures/server_tools.rs @@ -44,7 +44,9 @@ async fn prv_5_a_server_tool_calls_outcome_is_typed_from_its_status() { assert!(result.is_ok(), "{status}: {result:?}"); assert!( matches!( - emitted.first(), + emitted + .iter() + .find(|event| matches!(event, ModelEvent::ServerToolCall { .. })), Some(ModelEvent::ServerToolCall { call, .. }) if call.status == expected ), "{status}: {emitted:?}" @@ -64,7 +66,12 @@ async fn prv_5_a_server_tool_calls_outcome_is_typed_from_its_status() { ), "{status}: {result:?}" ); - assert!(emitted.is_empty(), "{status}: {emitted:?}"); + assert!( + emitted + .iter() + .all(|event| matches!(event, ModelEvent::ServerToolStarted { .. })), + "{status}: {emitted:?}" + ); } } @@ -101,7 +108,12 @@ async fn prv_5_an_unknown_server_tool_action_fails_the_step() { ), "{result:?}" ); - assert!(emitted.is_empty(), "{emitted:?}"); + assert!( + emitted + .iter() + .all(|event| matches!(event, ModelEvent::ServerToolStarted { .. })), + "nothing beyond the call's placement: {emitted:?}" + ); } /// PRV-5: a finished call without an action says nothing about what the provider did. @@ -127,7 +139,7 @@ async fn prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_ assert!( matches!( emitted.as_slice(), - [ModelEvent::ServerToolCall { call, .. }, ModelEvent::Replay { .. }, ModelEvent::Usage(_), ModelEvent::Stopped(StopReason::EndOfTurn)] + [ModelEvent::ServerToolStarted { .. }, ModelEvent::ServerToolCall { call, .. }, ModelEvent::Replay { .. }, ModelEvent::Usage(_), ModelEvent::Stopped(StopReason::EndOfTurn)] if call.action == ServerToolAction::Search { queries: Vec::new() } ), "{emitted:?}" @@ -139,7 +151,9 @@ async fn prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_ result.unwrap_or_else(|error| panic!("two spellings should decode: {error}")); assert!( matches!( - emitted.first(), + emitted + .iter() + .find(|event| matches!(event, ModelEvent::ServerToolCall { .. })), Some(ModelEvent::ServerToolCall { call, .. }) if call.action == ServerToolAction::Search { queries: vec!["rust 1.98.1".to_owned(), "rust release".to_owned()] diff --git a/crates/plexmaton-runtime/src/http/summary.rs b/crates/plexmaton-runtime/src/http/summary.rs index 4e9899c..d67ae9d 100644 --- a/crates/plexmaton-runtime/src/http/summary.rs +++ b/crates/plexmaton-runtime/src/http/summary.rs @@ -81,6 +81,8 @@ impl SummaryCollector { // A summary that searched is still a summary: the text is what compaction keeps, and // the call is recorded beside it rather than refused. ModelEvent::ServerToolCall { position, call } => self.server_tool_call(position, call), + // A summary is read once it is whole; where a call began is a live row's concern. + ModelEvent::ServerToolStarted { .. } => Ok(()), ModelEvent::Usage(_) | ModelEvent::Stopped(_) => { unreachable!("the shared HTTP driver consumes accounting and terminal events") } diff --git a/crates/plexmaton-runtime/src/runtime/model.rs b/crates/plexmaton-runtime/src/runtime/model.rs index 1a03f21..61c8faa 100644 --- a/crates/plexmaton-runtime/src/runtime/model.rs +++ b/crates/plexmaton-runtime/src/runtime/model.rs @@ -41,6 +41,7 @@ impl ModelOutput { } ModelEvent::Replay { .. } | ModelEvent::Called { .. } + | ModelEvent::ServerToolStarted { .. } | ModelEvent::ServerToolCall { .. } => true, ModelEvent::Usage(_) | ModelEvent::Stopped(_) => { unreachable!("ModelOutput excludes terminal and accounting events") diff --git a/crates/plexmaton-tui/examples/server_tool_preview.rs b/crates/plexmaton-tui/examples/server_tool_preview.rs index 5eafe9f..103e7a4 100644 --- a/crates/plexmaton-tui/examples/server_tool_preview.rs +++ b/crates/plexmaton-tui/examples/server_tool_preview.rs @@ -145,6 +145,7 @@ fn fixture() -> Result { events.push(ConversationEvent::ServerToolCalled { agent_id: agent.clone(), item_id: TranscriptItemId::new(name)?, + item_revision: 0, call: ServerToolCall { tool: ServerTool::WebSearch, action, @@ -152,6 +153,11 @@ fn fixture() -> Result { }, }); } + events.push(ConversationEvent::ServerToolStarted { + agent_id: agent.clone(), + item_id: TranscriptItemId::new("running")?, + tool: ServerTool::WebSearch, + }); text( &mut events, &agent, diff --git a/crates/plexmaton-tui/frames/server-tool-120.svg b/crates/plexmaton-tui/frames/server-tool-120.svg index bc03d08..0aa74f3 100644 --- a/crates/plexmaton-tui/frames/server-tool-120.svg +++ b/crates/plexmaton-tui/frames/server-tool-120.svg @@ -1 +1 @@ -┌ Plexmaton 󱌣 7 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐│││││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did it ship?││││reasoning││A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the││release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││││Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust││blog lists what it fixes.│││││── Message Plexmaton ───────────────────────────────────────────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐│││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did it ship?││││reasoning││A version number is a current fact, so I will search rather than answer from memory, then confirm the date on the││release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││[~] web_search · running││││Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust││blog lists what it fixes.││││· Running web_search…│── Message Plexmaton ───────────────────────────────────────────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-60.svg b/crates/plexmaton-tui/frames/server-tool-60.svg index 3ba6d57..8701da0 100644 --- a/crates/plexmaton-tui/frames/server-tool-60.svg +++ b/crates/plexmaton-tui/frames/server-tool-60.svg @@ -1 +1 @@ -┌ Plexmaton 󱌣 7 ──────────────────────────────────────────┐│││││││││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did││▌it ship?││││reasoning││A version number is a current fact, so I will search││rather than answer from memory, then confirm the date││on the release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1││point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││││Rust 1.98.1 is the latest stable release. It is a││point release over 1.98.0, and the release post on││the Rust blog lists what it fixes.│││││── Message Plexmaton ───────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────┐│││││││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did││▌it ship?││││reasoning││A version number is a current fact, so I will search││rather than answer from memory, then confirm the date││on the release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1││point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││[~] web_search · running││││Rust 1.98.1 is the latest stable release. It is a││point release over 1.98.0, and the release post on││the Rust blog lists what it fixes.││││· Running web_search…│── Message Plexmaton ───────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-88.svg b/crates/plexmaton-tui/frames/server-tool-88.svg index 2e59820..1bb488e 100644 --- a/crates/plexmaton-tui/frames/server-tool-88.svg +++ b/crates/plexmaton-tui/frames/server-tool-88.svg @@ -1 +1 @@ -┌ Plexmaton 󱌣 7 ──────────────────────────────────────────────────────────────────────┐│││││││││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did it ship?││││reasoning││A version number is a current fact, so I will search rather than answer from││memory, then confirm the date on the release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││││Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and││the release post on the Rust blog lists what it fixes.│││││── Message Plexmaton ───────────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────────────────────────────────┐│││││││││││││││││││││││││││││││││││││▌What is the latest stable Rust release, and when did it ship?││││reasoning││A version number is a current fact, so I will search rather than answer from││memory, then confirm the date on the release post.││││[+] read_file · succeeded││[+] web_search · latest stable Rust release││[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release││[+] web_search││[+] web_search · opened blog.rust-lang.org/releases/││[+] web_search · "1.98.1" in releases.rs/docs/1.98.1││[!] web_search · Rust nightly changelog · failed││[~] web_search · running││││Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and││the release post on the Rust blog lists what it fixes.││││· Running web_search…│── Message Plexmaton ───────────────────────────────────────────────────────────────────Type a message · ⇥ to focus────────────────────────────────────────────────────────────────────────────────────────~/plexmaton \ No newline at end of file diff --git a/crates/plexmaton-tui/frames/server-tool-medium.txt b/crates/plexmaton-tui/frames/server-tool-medium.txt index 618ff2d..e11a11a 100644 --- a/crates/plexmaton-tui/frames/server-tool-medium.txt +++ b/crates/plexmaton-tui/frames/server-tool-medium.txt @@ -1,5 +1,4 @@ -┌ Plexmaton 󱌣 7 ─────────────────────────────────────────────────────────────────────────────┐ -│ │ +┌ Plexmaton 󱌣 8 ─────────────────────────────────────────────────────────────────────────────┐ │ │ │ │ │ │ @@ -29,11 +28,12 @@ │[+] web_search · opened blog.rust-lang.org/releases/ │ │[+] web_search · "1.98.1" in releases.rs/docs/1.98.1 │ │[!] web_search · Rust nightly changelog · failed │ +│[~] web_search · running │ │ │ │Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the │ │release post on the Rust blog lists what it fixes. │ │ │ -│ │ +│· Running web_search… │ ── Message Plexmaton ────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ─────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/server-tool-narrow.txt b/crates/plexmaton-tui/frames/server-tool-narrow.txt index f2ea6cc..31be06c 100644 --- a/crates/plexmaton-tui/frames/server-tool-narrow.txt +++ b/crates/plexmaton-tui/frames/server-tool-narrow.txt @@ -1,5 +1,4 @@ -┌ Plexmaton 󱌣 7 ──────────────────────────────────────────┐ -│ │ +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────┐ │ │ │ │ │ │ @@ -28,12 +27,13 @@ │[+] web_search · opened blog.rust-lang.org/releases/ │ │[+] web_search · "1.98.1" in releases.rs/docs/1.98.1 │ │[!] web_search · Rust nightly changelog · failed │ +│[~] web_search · running │ │ │ │Rust 1.98.1 is the latest stable release. It is a │ │point release over 1.98.0, and the release post on │ │the Rust blog lists what it fixes. │ │ │ -│ │ +│· Running web_search… │ ── Message Plexmaton ─────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/server-tool-open-wide.txt b/crates/plexmaton-tui/frames/server-tool-open-wide.txt index bc12ca7..e7bcbbb 100644 --- a/crates/plexmaton-tui/frames/server-tool-open-wide.txt +++ b/crates/plexmaton-tui/frames/server-tool-open-wide.txt @@ -1,5 +1,4 @@ -┌ Plexmaton 󱌣 7 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ │ +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ │ │ │ │ │ @@ -29,11 +28,12 @@ │ │ url: releases.rs/docs/1.98.1 │ │ │ pattern: 1.98.1 │ │[!] web_search · Rust nightly changelog · failed │ +│[~] web_search · running │ │ │ │Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust │ │blog lists what it fixes. │ │ │ -│ │ +│· Running web_search… │ ── Message Plexmaton ─────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/frames/server-tool-wide.txt b/crates/plexmaton-tui/frames/server-tool-wide.txt index 77cb467..91d6280 100644 --- a/crates/plexmaton-tui/frames/server-tool-wide.txt +++ b/crates/plexmaton-tui/frames/server-tool-wide.txt @@ -1,5 +1,4 @@ -┌ Plexmaton 󱌣 7 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐ -│ │ +┌ Plexmaton 󱌣 8 ──────────────────────────────────────────────────────────────────────────────────────────────────────┐ │ │ │ │ │ │ @@ -29,11 +28,12 @@ │[+] web_search · opened blog.rust-lang.org/releases/ │ │[+] web_search · "1.98.1" in releases.rs/docs/1.98.1 │ │[!] web_search · Rust nightly changelog · failed │ +│[~] web_search · running │ │ │ │Rust 1.98.1 is the latest stable release. It is a point release over 1.98.0, and the release post on the Rust │ │blog lists what it fixes. │ │ │ -│ │ +│· Running web_search… │ ── Message Plexmaton ─────────────────────────────────────────────────────────────────────────────────────────────────── Type a message · ⇥ to focus ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/plexmaton-tui/src/content/tool.rs b/crates/plexmaton-tui/src/content/tool.rs index 2beea7f..364a86e 100644 --- a/crates/plexmaton-tui/src/content/tool.rs +++ b/crates/plexmaton-tui/src/content/tool.rs @@ -44,15 +44,27 @@ pub(super) fn prepared_entry(tool: &ToolCallView, appearance: EntryAppearance) - /// already have; and the plain succeeded colour, which read as a tool this harness ran inside the /// fence. The user chose the grammar and the colour on 2026-09-21 from rendered candidates. pub(super) fn prepared_server_tool(view: &ServerToolView, appearance: EntryAppearance) -> Vec { - let (status, role) = match view.call.status { + let Some(call) = &view.call else { + // Placed where the provider began the call, and still running: the tool row's word for + // it, in the colour that says whose work it is. + let running = ToolCallStatus::Running; + let mut compact = Line::from(vec![ + Span::styled(format!("{} ", marker(running)), Role::ServerTool), + Span::styled(view.tool.name().to_owned(), Role::Body), + Span::styled(format!(" · {}", status_label(running)), Role::ServerTool), + ]); + compact.treatment = Treatment::EntryHeading; + return vec![Row::heading(compact)]; + }; + let (status, role) = match call.status { ServerToolStatus::Completed => (ToolCallStatus::Succeeded, Role::ServerTool), ServerToolStatus::Failed => (ToolCallStatus::Failed, Role::Failure), }; let mut spans = vec![ Span::styled(format!("{} ", marker(status)), role), - Span::styled(view.call.tool.name().to_owned(), Role::Body), + Span::styled(view.tool.name().to_owned(), Role::Body), ]; - match &view.call.action { + match &call.action { ServerToolAction::Search { queries } if queries.is_empty() => {} ServerToolAction::Search { queries } => { spans.push(Span::styled(" · ", role)); @@ -176,15 +188,49 @@ mod tests { ServerToolView { entry_id: TranscriptItemId::new("search") .unwrap_or_else(|error| panic!("fixture: {error}")), - call: ServerToolCall { + tool: ServerTool::WebSearch, + call: Some(ServerToolCall { tool: ServerTool::WebSearch, action, status, - }, + }), revision: 0, } } + /// ENT-2: a call the provider has begun is one running row in the tool grammar, in the colour + /// that says whose work it is, with nothing to disclose yet. + #[test] + fn a_running_server_tool_row_is_named_and_wears_the_server_tool_colour() { + let palette = Palette::pastel(); + let running = ServerToolView { + entry_id: TranscriptItemId::new("search") + .unwrap_or_else(|error| panic!("fixture: {error}")), + tool: ServerTool::WebSearch, + call: None, + revision: 0, + }; + let open = EntryAppearance { + selected: false, + open: true, + hovered: false, + copy_hovered: false, + }; + assert_eq!( + server_entry(&running, &palette, EntryAppearance::compact(false)), + ["[~] web_search · running"] + ); + assert_eq!( + server_entry(&running, &palette, open), + ["[~] web_search · running"] + ); + assert_eq!( + marker_colour(&running, &palette), + palette.style(Role::ServerTool).fg + ); + assert_eq!(running.action_source(), None); + } + fn server_entry( view: &ServerToolView, palette: &Palette, diff --git a/crates/plexmaton-tui/src/frames.rs b/crates/plexmaton-tui/src/frames.rs index 89d268a..5eeb3b1 100644 --- a/crates/plexmaton-tui/src/frames.rs +++ b/crates/plexmaton-tui/src/frames.rs @@ -536,6 +536,7 @@ mod tests { "[+] web_search · Rust 1.98.1 release date, Rust 1.98.1 point release", "[+] web_search · opened blog.rust-lang.org/releases/", "[!] web_search · Rust nightly changelog · failed", + "[~] web_search · running", ] { let fits = row.len() <= usize::from(width) - 2; assert!( @@ -551,7 +552,7 @@ mod tests { "{name}: a search the route reported without a query is the name alone" ); assert!( - drawn.contains(" 7 "), + drawn.contains(" 8 "), "{name}: every call counts among the tools" ); crate::test_support::assert_frame(name, &drawn); @@ -695,6 +696,7 @@ mod tests { agent_id: agent.clone(), item_id: TranscriptItemId::new(name) .unwrap_or_else(|error| panic!("fixture: {error}")), + item_revision: 0, call: ServerToolCall { tool: ServerTool::WebSearch, action, @@ -703,6 +705,17 @@ mod tests { }, ); } + // One the provider has begun and not yet reported. + apply_frame_event( + &mut state, + &mut sequence, + ConversationEvent::ServerToolStarted { + agent_id: agent.clone(), + item_id: TranscriptItemId::new("server-tool-running") + .unwrap_or_else(|error| panic!("fixture: {error}")), + tool: ServerTool::WebSearch, + }, + ); append_frame_text( &mut state, &mut sequence, diff --git a/crates/plexmaton-tui/src/state/agent.rs b/crates/plexmaton-tui/src/state/agent.rs index 9662e82..41d1948 100644 --- a/crates/plexmaton-tui/src/state/agent.rs +++ b/crates/plexmaton-tui/src/state/agent.rs @@ -1,5 +1,5 @@ use plexmaton_core::{ - AgentId, AgentStatus, ArtifactId, MailId, ServerToolCall, TokenUsage, ToolCallId, + AgentId, AgentStatus, ArtifactId, MailId, ServerTool, ServerToolCall, TokenUsage, ToolCallId, ToolCallStatus, ToolPresentation, TranscriptItemId, TranscriptRole, TurnId, }; @@ -303,18 +303,64 @@ impl AgentView { ) } - /// Files one call the provider ran, in the state it ended in. It never transitions, so a - /// second event for the same entry is a duplicate rather than an update (ENT-2). + /// Places a call the provider began, running at revision zero (ENT-2). + pub(super) fn note_server_tool_started( + &mut self, + entry_id: TranscriptItemId, + tool: ServerTool, + ) -> Result { + self.insert_terminal( + entry_id.clone(), + TranscriptEntryView::ServerTool(super::ServerToolView { + entry_id, + tool, + call: None, + revision: 0, + }), + ) + } + + /// Finishes a call the provider ran. At revision one it finishes the row + /// [`Self::note_server_tool_started`] placed; at revision zero it is the whole entry, which is + /// how a reopened conversation shows it. A finished call never changes again, so a second + /// report is a duplicate rather than an update (ENT-2). pub(super) fn note_server_tool( &mut self, entry_id: TranscriptItemId, + item_revision: u64, call: ServerToolCall, ) -> Result { + if let Some(entry) = self.entries.get_mut(&entry_id) { + let TranscriptEntryView::ServerTool(view) = entry else { + return Err(ReduceError::EntryKindChanged(entry_id)); + }; + if view.call.is_some() { + return Err(ReduceError::DuplicateTranscriptItem(entry_id)); + } + let expected = view.revision.saturating_add(1); + if item_revision != expected { + return Err(ReduceError::ItemRevisionGap { + item_id: entry_id, + expected, + received: item_revision, + }); + } + if view.tool != call.tool { + return Err(ReduceError::ToolCorrelationChanged(entry_id)); + } + view.call = Some(call); + view.revision = item_revision; + return Ok(true); + } + if item_revision != 0 { + return Err(ReduceError::UnknownTranscriptItem(entry_id)); + } self.insert_terminal( entry_id.clone(), TranscriptEntryView::ServerTool(super::ServerToolView { entry_id, - call, + tool: call.tool, + call: Some(call), revision: 0, }), ) diff --git a/crates/plexmaton-tui/src/state/current_work.rs b/crates/plexmaton-tui/src/state/current_work.rs index e7d6d9b..2a37d2d 100644 --- a/crates/plexmaton-tui/src/state/current_work.rs +++ b/crates/plexmaton-tui/src/state/current_work.rs @@ -48,6 +48,12 @@ impl<'a> CurrentWork<'a> { { running_tool = Some(tool.label.as_str()); } + // A call the provider is still running is work in progress like any tool's. + TranscriptEntryView::ServerTool(view) + if view.call.is_none() && running_tool.is_none() => + { + running_tool = Some(view.tool.name()); + } TranscriptEntryView::Text(item) if item.role == TranscriptRole::Assistant && !item.finalized => { @@ -185,6 +191,38 @@ mod tests { } /// The current-work label is a priority projection, not a second lifecycle. + /// A call the provider is running is the current work, named the way a local tool's is, and + /// stops being it once the provider reports what it did. + #[test] + fn a_running_server_tool_is_the_current_work() { + use plexmaton_core::{ServerTool, ServerToolAction, ServerToolCall, ServerToolStatus}; + let mut searching = agent(AgentStatus::Running); + searching + .note_server_tool_started(item("search"), ServerTool::WebSearch) + .unwrap_or_else(|error| panic!("fixture: {error}")); + assert_eq!( + CurrentWork::derive(&searching, std::iter::empty(), false), + Some(CurrentWork::RunningTool("web_search")) + ); + searching + .note_server_tool( + item("search"), + 1, + ServerToolCall { + tool: ServerTool::WebSearch, + action: ServerToolAction::Search { + queries: Vec::new(), + }, + status: ServerToolStatus::Completed, + }, + ) + .unwrap_or_else(|error| panic!("fixture: {error}")); + assert_ne!( + CurrentWork::derive(&searching, std::iter::empty(), false), + Some(CurrentWork::RunningTool("web_search")) + ); + } + #[test] fn current_work_priority_is_derived_from_semantic_facts() { let idle = agent(AgentStatus::Idle); diff --git a/crates/plexmaton-tui/src/state/entry.rs b/crates/plexmaton-tui/src/state/entry.rs index 8ea6310..3a10db7 100644 --- a/crates/plexmaton-tui/src/state/entry.rs +++ b/crates/plexmaton-tui/src/state/entry.rs @@ -1,8 +1,8 @@ //! Typed entries in one agent's ordered transcript projection. use plexmaton_core::{ - AgentId, ArtifactId, MailId, ServerToolAction, ServerToolCall, ToolCallId, ToolCallStatus, - ToolPresentation, TranscriptItemId, TranscriptRole, + AgentId, ArtifactId, MailId, ServerTool, ServerToolAction, ServerToolCall, ToolCallId, + ToolCallStatus, ToolPresentation, TranscriptItemId, TranscriptRole, }; use super::ReduceError; @@ -111,15 +111,18 @@ pub struct ToolCallView { pub revision: u64, } -/// One call the provider ran inside a model call, in the state it ended in. +/// One call the provider ran inside a model call: running until the provider reports what it did. /// /// Drawn in [`ToolCallView`]'s grammar and unlike it underneath: there is no call identity of ours -/// to correlate, no lifecycle to advance and no presentation to accumulate. What it did is the -/// call's own action, shown as the route reported it (ENT-2). +/// to correlate, no admission, and two states rather than seven. It appears where the provider +/// placed the call and finishes once; a reopened conversation holds only the finished call and +/// shows it finished from the start (ENT-2). #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct ServerToolView { pub entry_id: TranscriptItemId, - pub call: ServerToolCall, + pub tool: ServerTool, + /// What the provider did and how it ended; absent while it is still running. + pub call: Option, pub revision: u64, } @@ -128,7 +131,7 @@ impl ServerToolView { /// reported nothing beyond that a call happened, which is one live route's search today. #[must_use] pub fn action_source(&self) -> Option { - match &self.call.action { + match &self.call.as_ref()?.action { ServerToolAction::Search { queries } if queries.is_empty() => None, ServerToolAction::Search { queries } => Some( queries diff --git a/crates/plexmaton-tui/src/state/ingest.rs b/crates/plexmaton-tui/src/state/ingest.rs index bd083f7..6ff8634 100644 --- a/crates/plexmaton-tui/src/state/ingest.rs +++ b/crates/plexmaton-tui/src/state/ingest.rs @@ -161,13 +161,9 @@ impl ViewState { agent_id, item_id, role, - } => { - self.validate_entry_owner(&agent_id, &item_id)?; - self.agent_mut(&agent_id)? - .start_item(item_id.clone(), role)?; - self.remember_entry_owner(item_id, agent_id); - true - } + } => self.apply_entry(agent_id, item_id, |agent, item_id| { + agent.start_item(item_id, role).map(|_| true) + })?, ConversationEvent::TranscriptDelta { agent_id, item_id, @@ -200,12 +196,20 @@ impl ViewState { } => self.apply_entry(agent_id, item_id, |agent, item_id| { agent.set_tool_entry(item_id, item_revision, call_id, label, status, presentation) })?, + ConversationEvent::ServerToolStarted { + agent_id, + item_id, + tool, + } => self.apply_entry(agent_id, item_id, |agent, item_id| { + agent.note_server_tool_started(item_id, tool) + })?, ConversationEvent::ServerToolCalled { agent_id, item_id, + item_revision, call, } => self.apply_entry(agent_id, item_id, |agent, item_id| { - agent.note_server_tool(item_id, call) + agent.note_server_tool(item_id, item_revision, call) })?, ConversationEvent::AttentionRequested { agent_id, @@ -535,10 +539,28 @@ mod tests { } } + fn server_tool_started_event(agent: &AgentId, entry: &str) -> ConversationEvent { + ConversationEvent::ServerToolStarted { + agent_id: agent.clone(), + item_id: item_id(entry), + tool: plexmaton_core::ServerTool::WebSearch, + } + } + fn server_tool_event(agent: &AgentId, entry: &str, queries: &[&str]) -> ConversationEvent { + server_tool_finished(agent, entry, 0, queries) + } + + fn server_tool_finished( + agent: &AgentId, + entry: &str, + item_revision: u64, + queries: &[&str], + ) -> ConversationEvent { ConversationEvent::ServerToolCalled { agent_id: agent.clone(), item_id: item_id(entry), + item_revision, call: plexmaton_core::ServerToolCall { tool: plexmaton_core::ServerTool::WebSearch, action: plexmaton_core::ServerToolAction::Search { @@ -549,24 +571,62 @@ mod tests { } } - /// ENT-2: a call the provider ran appears once, finished, and a second report of the same entry - /// is a duplicate the notice log keeps, never an update to the row. + /// ENT-2: a call the provider began appears running at revision zero and finishes once at + /// revision one; a second finish is a duplicate the notice log keeps, never an update. #[test] - fn a_server_tool_call_appears_finished_and_never_transitions() { + fn a_server_tool_call_runs_at_revision_zero_and_finishes_once() { let mut state = ViewState::default(); let agent = agent_id("agent-a"); state.apply(envelope(1, created("agent-a"))); assert_eq!( - state.apply(envelope(2, server_tool_event(&agent, "search", &["rust"]))), + state.apply(envelope(2, server_tool_started_event(&agent, "search"))), ApplyOutcome::Accepted ); - let outcome = state.apply(envelope(3, server_tool_event(&agent, "search", &["again"]))); + let running: Vec<_> = state + .primary_agent() + .unwrap_or_else(|| panic!("agent was projected")) + .entries() + .cloned() + .collect(); assert!( matches!( - outcome, + running.as_slice(), + [TranscriptEntryView::ServerTool(view)] if view.call.is_none() && view.revision == 0 + ), + "{running:?}" + ); + let gap = state.apply(envelope( + 3, + server_tool_finished(&agent, "search", 2, &["rust"]), + )); + assert!( + matches!( + gap, + ApplyOutcome::Rejected(ReduceError::ItemRevisionGap { + expected: 1, + received: 2, + .. + }) + ), + "{gap:?}" + ); + assert_eq!( + state.apply(envelope( + 4, + server_tool_finished(&agent, "search", 1, &["rust"]) + )), + ApplyOutcome::Accepted + ); + let again = state.apply(envelope( + 5, + server_tool_finished(&agent, "search", 2, &["again"]), + )); + assert!( + matches!( + again, ApplyOutcome::Rejected(ReduceError::DuplicateTranscriptItem(ref item)) if item == &item_id("search") ), - "{outcome:?}" + "{again:?}" ); let entries: Vec<_> = state .primary_agent() @@ -577,13 +637,46 @@ mod tests { matches!( entries.as_slice(), [TranscriptEntryView::ServerTool(view)] - if view.revision == 0 - && view.call.action == plexmaton_core::ServerToolAction::Search { queries: vec!["rust".to_owned()] } + if view.revision == 1 + && view.call.as_ref().map(|call| &call.action) + == Some(&plexmaton_core::ServerToolAction::Search { queries: vec!["rust".to_owned()] }) ), "{entries:?}" ); } + /// ENT-2: a reopened conversation holds only the finished call, which appears finished at + /// revision zero; it too finishes once. + #[test] + fn a_reopened_server_tool_call_appears_finished_at_revision_zero() { + let mut state = ViewState::default(); + let agent = agent_id("agent-a"); + state.apply(envelope(1, created("agent-a"))); + assert_eq!( + state.apply(envelope(2, server_tool_event(&agent, "search", &["rust"]))), + ApplyOutcome::Accepted + ); + let outcome = state.apply(envelope(3, server_tool_event(&agent, "search", &["again"]))); + assert!( + matches!( + outcome, + ApplyOutcome::Rejected(ReduceError::DuplicateTranscriptItem(ref item)) if item == &item_id("search") + ), + "{outcome:?}" + ); + let unplaced = state.apply(envelope( + 4, + server_tool_finished(&agent, "other", 1, &["x"]), + )); + assert!( + matches!( + unplaced, + ApplyOutcome::Rejected(ReduceError::UnknownTranscriptItem(_)) + ), + "{unplaced:?}" + ); + } + #[test] fn every_step_of_the_canonical_scenario_is_accepted() { // The shared fixture ignores the outcome so a degraded projection is still constructible. diff --git a/crates/plexmaton-tui/src/state/selection.rs b/crates/plexmaton-tui/src/state/selection.rs index c815045..30646b6 100644 --- a/crates/plexmaton-tui/src/state/selection.rs +++ b/crates/plexmaton-tui/src/state/selection.rs @@ -444,11 +444,12 @@ mod tests { let view = |action| crate::ServerToolView { entry_id: TranscriptItemId::new("search") .unwrap_or_else(|error| panic!("fixture: {error}")), - call: ServerToolCall { + tool: ServerTool::WebSearch, + call: Some(ServerToolCall { tool: ServerTool::WebSearch, action, status: ServerToolStatus::Completed, - }, + }), revision: 0, }; assert_eq!( diff --git a/crates/plexmaton-tui/src/workspace/preparation.rs b/crates/plexmaton-tui/src/workspace/preparation.rs index 035c609..9a48bd0 100644 --- a/crates/plexmaton-tui/src/workspace/preparation.rs +++ b/crates/plexmaton-tui/src/workspace/preparation.rs @@ -282,7 +282,13 @@ fn snapshot_bytes(entry: &crate::TranscriptEntryView) -> usize { + tool.presentation.invocation.as_ref().map_or(0, detail) + tool.presentation.outcome.as_ref().map_or(0, detail) } - Entry::ServerTool(view) => view.call.tool.name().len() + view.call.action.text_bytes(), + Entry::ServerTool(view) => { + view.tool.name().len() + + view + .call + .as_ref() + .map_or(0, |call| call.action.text_bytes()) + } Entry::Artifact(artifact) => { artifact.id.as_str().len() + artifact.label.len() + artifact.pointer.len() } diff --git a/scripts/provider_fixture.py b/scripts/provider_fixture.py index c923c4e..e72c5bd 100644 --- a/scripts/provider_fixture.py +++ b/scripts/provider_fixture.py @@ -71,6 +71,9 @@ def asked(body): class LoopbackProvider: """One owned server thread; fixed replies, bounded requests, no outbound connection.""" + # The dialect a journey speaks; a Responses journey sets the other endpoint before entering. + endpoint = "/v1/chat/completions" + def __init__(self, responses): assert 0 < len(responses) <= MAX_RESPONSES assert all(len(self.payload(entry)) <= MAX_REQUEST_BYTES for entry in responses) @@ -100,7 +103,7 @@ def log_message(self, *_args): def do_POST(self): try: - assert self.path == "/v1/chat/completions", "unexpected endpoint" + assert self.path == owner.endpoint, "unexpected endpoint" assert self.headers.get("Authorization") == "Bearer fixture-only", "unexpected credential" assert not self.headers.get("Transfer-Encoding"), "chunked request refused" count = int(self.headers.get("Content-Length", "0")) diff --git a/scripts/smoke-server-tool.py b/scripts/smoke-server-tool.py new file mode 100755 index 0000000..7266abe --- /dev/null +++ b/scripts/smoke-server-tool.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""ENT-2/PRV-5: a provider-run search lands where the provider placed it, live and on reopen. + +One loopback Responses stream with the shape a live gateway produced on 2026-09-21: every +`web_search_call` is added at its true position between the messages, every one of them is +finished only after the last message, and the whole stream arrives in one burst. The rows must +still sit between the narration lines they belong to, run while unfinished, finish without a +query when the route reported none, and read the same after the conversation is reopened. +""" +import json +from pathlib import Path +import subprocess +import tempfile + +from provider_fixture import ScriptedProvider +from smoke_support import ROOT, Terminal, fixture_environment + + +def event(payload): + return f"event: {payload['type']}\ndata: {json.dumps(payload)}\n\n".encode() + + +def message(index, item, text): + return b"".join([ + event({"type": "response.output_item.added", "output_index": index, + "item": {"id": item, "type": "message", "status": "in_progress", "role": "assistant", "content": []}}), + event({"type": "response.output_text.delta", "output_index": index, "content_index": 0, "item_id": item, "delta": text}), + event({"type": "response.output_text.done", "output_index": index, "content_index": 0, "item_id": item, "text": text}), + event({"type": "response.output_item.done", "output_index": index, + "item": {"id": item, "type": "message", "status": "completed", "role": "assistant", + "content": [{"type": "output_text", "text": text, "annotations": []}]}}), + ]) + + +def search_added(index, item): + return event({"type": "response.output_item.added", "output_index": index, + "item": {"id": item, "type": "web_search_call", "status": "in_progress", + "action": {"type": "search", "query": ""}}}) + + +def search_done(index, item, query): + action = {"type": "search", "query": query} + return event({"type": "response.output_item.done", "output_index": index, + "item": {"id": item, "type": "web_search_call", "status": "completed", "action": action}}) + + +def burst(): + usage = {"input_tokens": 40, "input_tokens_details": {"cached_tokens": 0}, "output_tokens": 30, + "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 70} + return b"".join([ + event({"type": "response.created", "response": {"id": "resp_smoke", "status": "in_progress", "output": []}}), + message(0, "msg_0", "SMOKE_FIRST checking the release page."), + search_added(1, "ws_1"), + message(2, "msg_2", "SMOKE_SECOND results point to 1.98.1."), + search_added(3, "ws_3"), + message(4, "msg_4", "SMOKE_ANSWER 1.98.1"), + search_done(1, "ws_1", ""), + search_done(3, "ws_3", "Rust 1.98.1 release"), + event({"type": "response.completed", "response": {"id": "resp_smoke", "status": "completed", "usage": usage}}), + ]) + + +def rows(screen): + return [row.rstrip("│ ").lstrip("│").strip() for row in screen.splitlines()] + + +def order(screen, *needles): + positions = [] + for needle in needles: + index = next((i for i, row in enumerate(rows(screen)) if row.startswith(needle)), None) + assert index is not None, f"{needle!r} is not on screen:\n{screen}" + positions.append(index) + assert positions == sorted(positions), f"rows out of order {positions}:\n{screen}" + + +def run_smoke(provider): + with tempfile.TemporaryDirectory(prefix="plexmaton-server-tool-smoke-", dir="/tmp") as folder: + home, project = Path(folder) / "home", Path(folder) / "project" + home.mkdir() + project.mkdir() + (home / "config.toml").write_text(f'''active_model = {{ provider = "loop", model = "searcher" }} +[providers.loop] +base_url = "{provider.base_url}" +api_key_env = "LOOP_LOGIN" +api = "openai_responses" +[providers.loop.models.searcher] +id = "searcher-wire" +display_name = "Searcher" +reasoning_effort = "low" +allowed_reasoning_efforts = ["low", "high"] +server_tools = ["web_search"] +context_window_tokens = 32768 +max_output_tokens = 4096 +output_reserve_tokens = 4096 +''') + environment = dict(fixture_environment(), PLEXMATON_HOME=str(home), LOOP_LOGIN="fixture-only") + with Terminal(project, environment, "server-tool", "placement") as terminal: + terminal.wait("Message Plexmaton") + screen = terminal.prompt("What is the latest stable Rust release?", "SMOKE_ANSWER", "[+] web_search · Rust 1.98.1 release", + absent=("Preparing text", "Running web_search", "· Thinking")) + # Placed where the provider put them: between the narration lines, not after the answer. + order(screen, "SMOKE_FIRST", "[+] web_search", "SMOKE_SECOND", "[+] web_search · Rust 1.98.1 release", "SMOKE_ANSWER") + assert "󱌣 2" in screen, screen + terminal.widths("placement", "SMOKE_ANSWER", "[+] web_search") + terminal.quit() + sessions = list((home / "sessions").glob("*.jsonl")) + assert len(sessions) == 1, sessions + with Terminal(project, environment, "server-tool", "reopen", arguments=("resume", sessions[0].stem)) as terminal: + screen = terminal.wait("SMOKE_ANSWER", "[+] web_search · Rust 1.98.1 release", absent=("Preparing text",)) + order(screen, "SMOKE_FIRST", "[+] web_search", "SMOKE_SECOND", "[+] web_search · Rust 1.98.1 release", "SMOKE_ANSWER") + terminal.quit() + requests, errors = provider.snapshot() + assert not errors, errors + assert {"type": "web_search"} in requests[0]["tools"], "the declaration crossed the wire" + + +def main(): + subprocess.run(["cargo", "build", "--locked", "-p", "plexmaton-cli", "--bin", "plexmaton", "--quiet"], cwd=ROOT, check=True) + ScriptedProvider.endpoint = "/v1/responses" + with ScriptedProvider([burst()]) as provider: + run_smoke(provider) + print("server-tool smoke passed: rows placed where the provider put them, live and on reopen") + + +if __name__ == "__main__": + main() From 5eeb2b14f3a58887b4a58b8c91c9ed1ffd49ae15 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 19:37:18 -0400 Subject: [PATCH 18/21] test(smoke): stream encrypted reasoning before the searches, as the gateway does The loopback burst now carries the reasoning items a live route sends before each message, so the smoke exercises replay beside the placed and finished server tool rows, which is the shape a captured gateway stream renders correctly once the terminal is actually read. --- scripts/smoke-server-tool.py | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/scripts/smoke-server-tool.py b/scripts/smoke-server-tool.py index 7266abe..bb29f25 100755 --- a/scripts/smoke-server-tool.py +++ b/scripts/smoke-server-tool.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 """ENT-2/PRV-5: a provider-run search lands where the provider placed it, live and on reopen. -One loopback Responses stream with the shape a live gateway produced on 2026-09-21: every -`web_search_call` is added at its true position between the messages, every one of them is -finished only after the last message, and the whole stream arrives in one burst. The rows must +One loopback Responses stream with the shape a live gateway produced on 2026-09-21: encrypted +reasoning before the messages, every `web_search_call` added at its true position between them, +every one of them finished only after the last message, and the whole stream in one burst. The rows must still sit between the narration lines they belong to, run while unfinished, finish without a query when the route reported none, and read the same after the conversation is reopened. """ @@ -38,6 +38,17 @@ def search_added(index, item): "action": {"type": "search", "query": ""}}}) +def reasoning(index, item): + """An encrypted reasoning item, as the route sends before each message; replay only.""" + return b"".join([ + event({"type": "response.output_item.added", "output_index": index, + "item": {"id": item, "type": "reasoning", "status": "in_progress", "summary": []}}), + event({"type": "response.output_item.done", "output_index": index, + "item": {"id": item, "type": "reasoning", "status": "completed", "summary": [], + "encrypted_content": "c21va2Utb3BhcXVlLXJlYXNvbmluZw=="}}), + ]) + + def search_done(index, item, query): action = {"type": "search", "query": query} return event({"type": "response.output_item.done", "output_index": index, @@ -49,13 +60,15 @@ def burst(): "output_tokens_details": {"reasoning_tokens": 0}, "total_tokens": 70} return b"".join([ event({"type": "response.created", "response": {"id": "resp_smoke", "status": "in_progress", "output": []}}), - message(0, "msg_0", "SMOKE_FIRST checking the release page."), - search_added(1, "ws_1"), - message(2, "msg_2", "SMOKE_SECOND results point to 1.98.1."), - search_added(3, "ws_3"), - message(4, "msg_4", "SMOKE_ANSWER 1.98.1"), - search_done(1, "ws_1", ""), - search_done(3, "ws_3", "Rust 1.98.1 release"), + reasoning(0, "rs_0"), + message(1, "msg_1", "SMOKE_FIRST checking the release page."), + search_added(2, "ws_2"), + reasoning(3, "rs_3"), + message(4, "msg_4", "SMOKE_SECOND results point to 1.98.1."), + search_added(5, "ws_5"), + message(6, "msg_6", "SMOKE_ANSWER 1.98.1"), + search_done(2, "ws_2", ""), + search_done(5, "ws_5", "Rust 1.98.1 release"), event({"type": "response.completed", "response": {"id": "resp_smoke", "status": "completed", "usage": usage}}), ]) From 6777f6b28e09e93ff8228e674fc85dda0d64b7c4 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Mon, 21 Sep 2026 21:37:02 -0400 Subject: [PATCH 19/21] docs(spike): localise the one-burst delivery to Meta's upstream, not the gateway or the app --- .../provider-side-tools/gateway-evidence.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.agents/spikes/provider-side-tools/gateway-evidence.md b/.agents/spikes/provider-side-tools/gateway-evidence.md index c7e458f..62deba1 100644 --- a/.agents/spikes/provider-side-tools/gateway-evidence.md +++ b/.agents/spikes/provider-side-tools/gateway-evidence.md @@ -154,3 +154,23 @@ and are honest. Measured again 2026-09-21 through the branch binary and a direct stream: every `web_search_call` arrived as `search` with an empty `query`; every `done` came after the last message finished, while each `added` came at its true position, which is why the row is placed at `added`. + +## The whole turn arrives at once + +Measured 2026-09-21 on a live turn with six searches through the branch binary, from the session +journal's request timing: headers came back 0.9 s after dispatch, then nothing for 31 s; the first +event and the terminal event arrived 50 ms apart, carrying five reasoning items, six text items, six +`web_search_call` items and about 4,200 output tokens. The direct stream measured above did the same +over 19 s. Every other route on this gateway streams through the same adapter, luna's first event at +10 s of a 17 s turn and deepseek's at 1.2 s of 3 s, so the hold is not in Plexmaton. Nor is it in the +gateway, read at snapshot v7.3.11: toward a Claude-compatible upstream other than `api.anthropic.com` +it asks for `text/event-stream` with `Accept-Encoding: identity` (`applyTransportNegotiation` in +`claude_executor_request.go`), scans the body line by line and translates each event as it is read +(`claude_executor_stream.go`): text deltas at `content_block_delta`, a search's `added` at +`content_block_start`; only a search's `done` waits for `message_stop`, because this route sends no +result block. What remains is `api.meta.ai`, which on the Messages surface releases the reply when +the turn ends, at least with the hosted tool declared. Whether the same route streams without +`server_tools` is unmeasured: the journals' only other muse attempt failed at the gateway, and the +Chat Completions runs above recorded content, not arrival times. The native `meta-api-key` route in +[Meta route evidence](./meta-route.md) is the untried variable, and the one that would also restore +queries and citations. From 74f8d5e2b94673296ef83909533d1f12c0df2bb5 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:31:30 -0400 Subject: [PATCH 20/21] docs(stage-33): promote the spike's decision, consume the plan, and mark the stage complete --- .agents/phases/phase-04-product-polish.md | 15 ++-- .../phase-04-stage-33-provider-side-tools.md | 85 ------------------- .agents/roadmap.md | 4 +- .agents/spikes/provider-side-tools/README.md | 57 +++---------- .agents/ui-ux.md | 2 + 5 files changed, 26 insertions(+), 137 deletions(-) delete mode 100644 .agents/plans/phase-04-stage-33-provider-side-tools.md diff --git a/.agents/phases/phase-04-product-polish.md b/.agents/phases/phase-04-product-polish.md index f7ddb86..f53208a 100644 --- a/.agents/phases/phase-04-product-polish.md +++ b/.agents/phases/phase-04-product-polish.md @@ -2,7 +2,7 @@ | Field | Value | | --- | --- | -| Status | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stages 16 and 28 await the user's terminal test; stage 30 automatic-compaction visibility unstarted; stage 33 provider-side tools in progress; stage 10 branding remains | +| Status | Active; stages 1–9, 11–15, 17–27, 29 and 31–33 complete; stages 16 and 28 await the user's terminal test; stage 30 automatic-compaction visibility unstarted; stage 10 branding remains | | Parent roadmap | [Plexmaton Roadmap](../roadmap.md) | | Product contract | [UI/UX](../ui-ux.md) | | Depends on | Phase 01 interaction ownership; Phase 02 journal/accounting/budget projections; Phase 03 durable collaboration, owned child scheduling, Handoff/Stop, passive recovery and canonical Attention | @@ -376,12 +376,13 @@ JRN-3/PRV-3 prove JSONL save, reopen and exact compatible replay only; an export and its replay disclosure policy remain unproven. MCP remains optional later integration. Rejected: treating JSONL round trips as independent export/import acceptance. -33. **Provider-side tools — in progress.** A model declares the hosted tools its route accepts, and the - harness forwards that declaration and renders what comes back rather than running the search - itself. The [stage plan](../plans/phase-04-stage-33-provider-side-tools.md) owns the order; the - [spike](../spikes/provider-side-tools/README.md) owns the evidence, measured against one live - route. PRV-5's refusal of a - provider-side block is rewritten by this stage rather than left as a second reading. +33. **Provider-side tools — complete.** A model declares the hosted tools its route accepts + (PRV-6), the harness forwards the declaration, and a provider-run search is a transcript row in + its own colour that appears where the provider began it and finishes once (PRV-5, ENT-2). The + harness never runs the search. The [spike](../spikes/provider-side-tools/README.md) keeps the + live measurements of the one route this was proven on, including that the route delivers a + whole turn at once. Where hosted-tool spend belongs in the cost surface stays open in + [ui-ux](../ui-ux.md) §open questions. Other roadmap work in performance, math and extensibility receives a stage when its evidence is ready; this phase opening does not claim those capabilities have started. diff --git a/.agents/plans/phase-04-stage-33-provider-side-tools.md b/.agents/plans/phase-04-stage-33-provider-side-tools.md deleted file mode 100644 index 26e1e74..0000000 --- a/.agents/plans/phase-04-stage-33-provider-side-tools.md +++ /dev/null @@ -1,85 +0,0 @@ -# Plan — Phase 04 stage 33, provider-side tools - -| Field | Value | -| --- | --- | -| Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 33 | -| Contract | PRV-1/PRV-3/PRV-5/PRV-6, [tool-admission](../specs/tool-admission.md), [ui-ux](../ui-ux.md) transcript grammar | -| Evidence | [Provider-side tools spike](../spikes/provider-side-tools/README.md) | -| Status | Slices 1–5 implemented and locally verified; slice 6 open for the spike's promotion and this plan's deletion | - -## Outcome - -A model may declare the hosted tools its route accepts, and a Session using that model reaches -provider-hosted web search without the transcript losing sight of what was searched. The harness -forwards a declaration and renders what comes back. It never runs the search, never infers the -capability from a model's name, and never silently absorbs a block it cannot show. - -Reversal recorded deliberately: PRV-5 currently states that a provider-side block is refused. That -sentence was true when written and this stage rewrites it, along with the rejected alternative -beside it, rather than leaving a second reading in the corpus. - -## Slices - -1. **Declaration — implemented.** A model entry gains a hosted-tool subset, validated the way - `allowed_reasoning_efforts` already is: present or absent, nonempty and unique when present, and - encodable by that model's dialect. A declaration a dialect cannot spell fails at config load, - before any network work. PRV-6 gains the field; nothing infers it from a provider or model name. - -2. **Request encoding — implemented.** Each dialect spells the declared capability and owns that spelling alone. - Messages emits `{"type":"web_search_20250305","name":"web_search"}`; Responses emits - `{"type":"web_search"}`; Chat Completions emits `web_search_options`, the field OpenAI's own - search models take; GenerateContent's `google_search` tool is spelled once its facade has been - measured. Slice 1's gate is the tool name, not the dialect: a declared tool a dialect has no - spelling for fails at config load. A route may accept a spelling and ignore it, so a declaration - is the owner's claim about their route, verified once rather than trusted. No request carries a - hosted tool the model did not declare. - -3. **Decode — implemented.** The Responses decoder accepts a `web_search_call` output item on both `added` and - `done`, carrying the action the provider took: a query, an opened page, or a find within one. - An action that arrives without a query is carried as it arrived, not refused. One semantic - event, shaped so a Messages decoder can emit the same event later without a second one, because - the spike found the two dialects differ only in wrapper. The Messages `server_tool_use` block - keeps PRV-5's refusal in this stage, because no Messages route is in daily use to prove a decoder - against. Usage stops failing a step when a provider reports non-zero hosted-tool counts, and - accounts them instead. - -4. **Transcript — implemented.** The call reaches the workspace where the provider placed it, as a - running row that finishes once, in the tool row's grammar and coloured for who ran it (ui-ux - §transcript grammar, ENT-2); a reopened conversation shows the same rows in the same places, and - `scripts/smoke-server-tool.py` drives the burst one live gateway produced through the binary to - prove it. The row shows what its route returned and claims nothing further; the - [spike](../spikes/provider-side-tools/meta-route.md) records what each measured route gives. The - user chose the grammar and colour from rendered candidates and reviewed the real frames at three - widths on 2026-09-21. - -5. **Replay — implemented.** A hosted-tool item round-trips when the conversation continues, under PRV-3's - existing sidecar rules, with its status and without its provider-assigned id, which the route in - daily use rejects and Meta documents as optional. The test says so, and a live follow-up turn - proved it. - -6. **Corpus.** PRV-5's refusal and its rejected alternative are rewritten to what the code now - does. PRV-6 gains the declaration. The evidence table gains a row per invariant. The spike is - promoted where it now holds a decision, and the parts that were only reconnaissance are dropped. - -## Order and why - -Declaration precedes encoding because a spelling with nothing to spell cannot be tested. Encoding -precedes decode because a fixture is cheaper to trust when the request that produced it is ours. -Decode precedes the transcript because a row has nothing to render until an event exists, and the -contract owns the row's shape rather than the adapter. Replay is last because it is the only slice -that needs two turns, and because the arm it picks is informed by what the row had to show. - -## Open, to settle inside the stage - -- Where does hosted-tool spend belong in the cost surface? It is provider-side work the owner did - not select a model for, and the usage counter names requests rather than tokens. - -## Deliberately not in this plan - -Running any search locally. A client tool that issues its own isolated model request, which is pi's -shape and would need tools to reach a provider route. Hosted tools other than search, which no -measured route offered. Recovering findings a route does not return. Per-call approval, because the -fence position already holds that configuring a provider authorises the call this rides on. -Decoding `server_tool_use` on Messages, which keeps its refusal until a Messages route is in daily -use. The owner's gateway configuration, which changes nothing about what this stage encodes or -decodes. diff --git a/.agents/roadmap.md b/.agents/roadmap.md index a767f6f..9d41817 100644 --- a/.agents/roadmap.md +++ b/.agents/roadmap.md @@ -34,7 +34,7 @@ table in `AGENTS.md` allows. | 01 | One real agent in the workspace: a thin loop over one provider, and the transcript grammar against its output | Closed 2026-09-03: delivered one live OpenAI-compatible agent, five bounded native tools with explicit approval and cancellation, and the reviewed responsive transcript grammar | | 02 | Canonical session state, persistence, durable permission policy, provider transports, context projection and project instructions | Closed 2026-09-12 by scoped evidence: delivered JSONL sessions and recovery, four provider dialects, compaction, durable permissions, AGENTS.md and conversation tree/rewind; unproven recovery/readmission acceptance inherited by Phase 03, independent export/import by Phase 04; MCP optional | | 03 | Durable multi-agent mailbox and runtime ownership | Closed 2026-09-16: delivered ordered durable delegation/mail, owned child scheduling, Stop and Handoff control, passive, process-kill and in-app switch recovery, canonical Attention and the accepted responsive agent workspace | -| 04 | Product polish, performance hardening, math in production, and extensibility | Active; stages 1–9, 11–15, 17–27, 29, 31 and 32 complete; stage 16 effort selection awaits user testing; stage 28 model-switch degradation in progress; stage 30 automatic-compaction visibility unstarted; stage 33 provider-side tools in progress; stage 10 branding remains | +| 04 | Product polish, performance hardening, math in production, and extensibility | Active; stages 1–9, 11–15, 17–27, 29 and 31–33 complete; stage 16 effort selection awaits user testing; stage 28 model-switch degradation in progress; stage 30 automatic-compaction visibility unstarted; stage 10 branding remains | ## Locked @@ -72,7 +72,7 @@ Tracks run beside phases because their exit conditions are comparisons, not deli | [Agent Skills spike](./spikes/agent-skills/README.md) | Compare skill discovery, project configuration and durable activation | Implemented and verified offline; live model behavior unverified | | [Permission policy spike](./spikes/permission-policy/README.md) | Compare scoped grants, rule precedence and durable authority | Source comparison retained; PER-1–PER-10 and PGR-1–PGR-5 implemented and verified locally | | [Compaction spike](./spikes/compaction/README.md) | Constrain compaction, branch-local context and recovery | Source/probe evidence retained; CPL-1–CPL-8 own implementation and regression evidence | -| [Provider-side tools spike](./spikes/provider-side-tools/README.md) | Compare how harnesses surface a tool the provider runs, and measure one live hosted-search route | Comparison and live measurement retained; the decision moved to [stage 33](./plans/phase-04-stage-33-provider-side-tools.md) | +| [Provider-side tools spike](./spikes/provider-side-tools/README.md) | Compare how harnesses surface a tool the provider runs, and measure one live hosted-search route | Live measurement retained; implemented as phase 04 stage 33, whose behaviour PRV-5, PRV-6 and ENT-2 own | Gates without a track yet, each opened with a prototype, a comparison corpus, and a decision criterion: diff --git a/.agents/spikes/provider-side-tools/README.md b/.agents/spikes/provider-side-tools/README.md index 2dcd315..29047e8 100644 --- a/.agents/spikes/provider-side-tools/README.md +++ b/.agents/spikes/provider-side-tools/README.md @@ -2,17 +2,17 @@ | Field | Value | | --- | --- | -| Status | Comparison and live measurement retained as evidence; the design is decided in [stage 33](../../plans/phase-04-stage-33-provider-side-tools.md) | +| Status | Implemented as phase 04 stage 33; PRV-5, PRV-6 and ENT-2 own the behaviour. Live measurements retained as evidence for the next hosted tool | | Read when | Adding web search, or any tool a provider runs on its own side rather than handing to this harness | | Question | Can a Session reach provider-hosted search without the harness losing sight of what was searched? | | Contract | [provider-adapter](../../specs/provider-adapter.md) PRV-1/PRV-3/PRV-5; [tool-admission](../../specs/tool-admission.md); [permission-policy](../../specs/permission-policy.md) PER-2 | ## Why this exists -`provider-adapter.md` PRV-5 records that a `server_tool_use` block is refused, and why. It does not -say whether that refusal should stand. A provider that runs a search on its own side is the one -category the fence position does not cover: not something we execute, not something we hand to a -subprocess, but something the provider performs inside a call the owner already authorised. +A provider that runs a search on its own side is the one category the fence position does not +cover: not something we execute, not something we hand to a subprocess, but something the provider +performs inside a call the owner already authorised. PRV-5 once refused the block outright; this +spike is why it now carries the call and types its outcome. ## Corpus @@ -27,40 +27,15 @@ Local source read 2026-09-20; no reference suite was run. Plexmaton base `c7bdf0 | `claude-code` | `2.1.88` mapped | `cc/2.1.88` | | `kimi-code` | `f12d59e089e2531a33fbca30b26ffeabd5862b45` | — | -## Two shapes, and they are not variations of each other - -**Codex makes it a first-class protocol item.** `ResponseItem::WebSearchCall` carries id, status and -a typed `action`, and `event_mapping.rs:228` turns it into `TurnItem::WebSearch` for the transcript. -The variant is matched in rollout normalisation, turn timing, remote compaction, image preparation -and persisted state — so the cost is not one enum arm but every site that already matches the enum. -`WebSearchItem.results` exists and is set to `None` on this path, because the inline Responses call -returns the query and not the findings; only a separate standalone search populates it, and its -comment keeps those results as opaque JSON at the transport boundary so new result shapes need no -release. - -**pi makes it an ordinary client tool.** `extensions/claude-web-search` registers a `WebSearch` -tool with plain `query` / `allowed_domains` / `blocked_domains` parameters. Its execution issues an -**isolated** Messages request carrying the hosted tool, and returns a synthesis plus source URLs as -an ordinary tool result. `payload.ts` refuses a payload that is not exactly one user message before -continuation history, which is what keeps that request isolated. `getHostedWebSearchRoute` gates the -whole thing on the active model's provider and API, and `syncWebSearchAvailability` adds or removes -the tool from the active set as the model changes. - -Its consequence is real and worth recording: **the main conversation never contains a -`server_tool_use` block.** No new content-block type, no new semantic event, no replay hole, and the -search takes the ordinary admission, permission and transcript path every other tool takes. - -Rejected all the same, for two reasons that only appear once it is drawn against this codebase. A -tool that issues its own model request needs to reach a provider route, and native tools here reach -a workspace, ripgrep and a command — giving them a route is a larger boundary change than the one it -saves. And it spends a second model call for every search, chosen by the harness rather than by the -owner, which is exactly the accounting question the owner has no surface to answer. The owner's -instruction settles the rest: the declaration goes to the server side, and the harness renders what -returns. - -`grok-build` sits nearer Codex, handling search inside the pager's scrollback block types. -`claude-code`'s only server-tool surface in readable source is the usage counter shape -(`web_search_requests`, `web_fetch_requests`) that this harness already parses. +## Two shapes, and the one chosen + +Codex carries a provider-run search as a first-class protocol item, matched at every site that +already matches its response enum. pi wraps it as an ordinary client tool that issues its own +isolated Messages request, so its main conversation never holds a `server_tool_use` block. Codex's +shape was taken, narrowed by a declaration. pi's was rejected because a tool that issues its own +model request needs a provider route that native tools here do not have, and because it spends a +second model call per search that the harness, not the owner, chose. grok-build sits nearer Codex; +claude-code's only readable server-tool surface is the usage counter this harness already parses. ## Configuration declares, the dialect spells @@ -107,16 +82,12 @@ What the declaration buys is that none of this reaches a model that did not ask with no hosted tools encodes nothing new and decodes nothing new, so the existing four dialects keep their current behaviour exactly. -[Stage 33](../../plans/phase-04-stage-33-provider-side-tools.md) owns the order. - ## Open - Is strict cloaking what strips search results on the local gateway, or does the upstream never send them? `claude_executor_cloaking.go` handles `server_tool_use` and is the place to look. - Codex's standalone path populates `results`. Worth reading before assuming the findings are always unavailable. -- Where does hosted-tool spend belong? The usage counter names requests rather than tokens, and - no surface shows provider-side work today. ## Limits diff --git a/.agents/ui-ux.md b/.agents/ui-ux.md index c1aff84..45a6c01 100644 --- a/.agents/ui-ux.md +++ b/.agents/ui-ux.md @@ -560,5 +560,7 @@ counts are asserted by tests; wall-clock time is only reported, beside the machi - How much tool activity remains visible in a collapsed transcript block. - Notification treatment for mail that arrives while its sender's window is open. - Whether ten rows is the right primary-conversation guarantee in real use. +- Where hosted-tool spend belongs in the cost surface: provider-side work the owner selected no + model for, counted by the route in requests rather than tokens. An answered question moves into the section that owns the answer. From 8cdd911a2d4a05d19a7e3c5de8f8d8d89f691a35 Mon Sep 17 00:00:00 2001 From: BP <11394934+benjipeng@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:35:32 -0400 Subject: [PATCH 21/21] feat(provider): let a route keep its bearer token in the file, with the environment still overriding Gates run by hand before this commit (fmt, file-length, crate-graph, citations, frames, typos: all clean); the hook's mktemp is unavailable in this shell. --- .agents/evidence/provider-adapter.md | 2 +- .agents/specs/provider-adapter.md | 20 +++++---- README.md | 2 +- crates/plexmaton-cli/src/interaction/model.rs | 5 ++- crates/plexmaton-cli/src/main.rs | 1 + crates/plexmaton-cli/src/session_picker.rs | 4 +- crates/plexmaton-cli/src/startup.rs | 6 ++- crates/plexmaton-provider/src/config.rs | 44 ++++++++++++++++++- crates/plexmaton-provider/src/config/tests.rs | 32 ++++++++++++-- examples/providers.toml | 2 +- 10 files changed, 96 insertions(+), 22 deletions(-) diff --git a/.agents/evidence/provider-adapter.md b/.agents/evidence/provider-adapter.md index 779c3fc..5e14caa 100644 --- a/.agents/evidence/provider-adapter.md +++ b/.agents/evidence/provider-adapter.md @@ -11,5 +11,5 @@ resolves every one against the source, so a renamed or deleted test fails the bu | PRV-3 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools`, `prv_3_any_of_the_four_compatibility_axes_degrades_rather_than_refusing`, `prv_3_a_degraded_call_and_its_result_keep_one_identity`, `prv_3_a_degraded_gemini_call_carries_neither_signature_nor_upstream_id`, `prv_3_an_output_that_was_only_its_replay_degrades_to_no_message`, `prv_3_an_id_every_dialect_accepts_is_carried_through_unchanged`, `prv_3_an_id_the_destination_would_reject_becomes_one_it_accepts`, `prv_3_distinct_rewritten_ids_stay_distinct`, `prv_3_replay_route_owner_encoding_is_unambiguous`, `prv_3_rejects_opaque_replay_before_step_state_can_grow`, `assistant_output_round_trips_order_and_redacts_replay`, `replay_only_wire_without_a_payload_is_rejected`, `reasoning_and_opaque_replay_survive_interrupt_without_sharing_presentation`, `provider_replay_is_named_and_bounded_before_turn_state_can_retain_it`, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_cancelled_calls_leave_no_dangling_replay`, `prv_3_chat_reasoning_aliases_round_trip_without_renaming`, `prv_3_chat_accepts_reasoning_details_that_only_restate_the_plain_delta`, `prv_3_gemini_signature_only_and_early_signature_parts_are_replayable`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation`, `prv_3_unsigned_reasoning_in_a_tool_turn_is_refused_by_messages_and_omitted_by_responses`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call` | | PRV-4 | `prv_3_responses_fixture_replays_encrypted_reasoning_exactly_and_round_trips_tools` proves stateless full-record replay without a response ID, `prv_3_responses_phase_and_parts_survive_jsonl_reopen`, `prv_3_native_tool_conversations_survive_jsonl_reopen`, `prv_3_interrupted_unsigned_reasoning_allows_durable_continuation` | | PRV-5 | `http_rate_limit_is_typed_and_keeps_retry_after`, `context_error_is_classified_by_wire_code`, `prv_5_responses_done_only_refusal_is_visible_and_typed`, and both fixture completion reasons, `prv_5_chat_rejects_structured_or_conflicting_reasoning`, `prv_5_messages_context_limits_and_refusals_keep_final_usage`, `native_stream_rate_limits_are_typed`, `gemini_declared_failures_preserve_diagnostics_without_dispatching_calls`, `messages_usage_refuses_unaccounted_server_tools`, `prv_5_gemini_policy_and_unsupported_image_finishes_are_distinct`, `stream_provider_errors_keep_their_category_and_observed_usage`, `dispatched_http_failures_preserve_their_terminal_measurements`, `provider_failure_reopens_as_the_same_non_retryable_outcome`, `prv_5_empty_additive_fields_preserve_text_and_populated_fields_fail`, `prv_5_chat_admits_gateway_accounting_and_still_refuses_everything_else`, `prv_5_messages_pause_turn_is_explicitly_unsupported`, `prv_5_responses_web_search_call_is_carried_as_a_server_tool_call`, `prv_5_an_unknown_server_tool_action_fails_the_step`, `prv_5_a_server_tool_call_without_an_action_fails_the_step`, `prv_5_a_search_without_a_query_is_carried_and_a_repeated_query_is_kept_once`, `prv_5_a_server_tool_calls_outcome_is_typed_from_its_status`, `a_call_still_running_when_the_step_ends_is_shown_failed_and_kept_out_of_the_record` | -| PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md), `prv_6_hosted_tools_are_declared_per_model_and_absent_by_default`, `prv_6_hosted_tool_declarations_fail_closed`, `prv_6_hosted_tool_declarations_need_a_dialect_spelling`, `prv_6_declared_hosted_tools_are_spelled_by_the_dialect_and_absent_otherwise`, `the_tool_name_is_its_configuration_spelling` | +| PRV-6 | `prv_6_allowed_efforts_are_model_local_and_do_not_invent_capabilities`, `prv_6_invalid_effort_catalogs_and_defaults_fail_closed`, `prv_6_effort_catalogs_cannot_enable_unencodable_levels`, `prv_6_one_provider_resolves_two_exact_models_without_repeating_authority`, `prv_6_selection_and_every_model_fail_closed_before_network_work`, `prv_6_inline_authority_and_legacy_profiles_are_not_a_second_config_path`, `prv_6_resolution_rejects_unsafe_routes_without_echoing_them`, `prv_6_resolves_only_an_override_or_the_user_root`, `prv_6_key_resolution_is_explicit_and_redacted`, `prv_6_inline_key_is_used_when_its_environment_variable_is_absent`, `cmd_2_selected_api_key_environment_is_removed_even_without_credential_shape`, `prv_6_standard_requests_omit_unspecified_reasoning_and_encode_instructions`, `prv_6_native_thinking_options_are_explicit_and_validated`, `prv_6_gemini_38_requests_use_only_frontier_thinking_controls`, `prv_6_messages_cache_and_thinking_match_native_request_contract`, `prv_6_provider_example_selects_each_documented_dialect`, `native_messages_http_uses_api_key_headers_and_terminal_usage`, `completed_messages_without_thinking_breakdown_keep_final_cost`, `native_gemini_http_uses_versioned_endpoint_and_header_authority` ; [SKL-1](./agent-skills.md), `prv_6_hosted_tools_are_declared_per_model_and_absent_by_default`, `prv_6_hosted_tool_declarations_fail_closed`, `prv_6_hosted_tool_declarations_need_a_dialect_spelling`, `prv_6_declared_hosted_tools_are_spelled_by_the_dialect_and_absent_otherwise`, `the_tool_name_is_its_configuration_spelling` | | PRV-7 | `event_guard_rejects_an_unterminated_event_at_the_bound`, `event_guard_rejects_one_oversized_transport_chunk`, `prv_2_and_prv_7_reject_unbounded_or_incomplete_provider_input`, `prv_7_model_config_cannot_widen_runtime_output_memory`, `interrupt_and_shutdown_cancel_and_join_the_exact_provider_task`, `native_stream_cancellation_preserves_observed_usage` | diff --git a/.agents/specs/provider-adapter.md b/.agents/specs/provider-adapter.md index a2608e5..9299e27 100644 --- a/.agents/specs/provider-adapter.md +++ b/.agents/specs/provider-adapter.md @@ -111,22 +111,26 @@ content the record cannot name. **PRV-6 — Configuration names data, never authority.** `~/.plexmaton/config.toml` separates named provider routes from their named models and selects one exact provider/model pair. A route owns its -base URL, credential environment and default API; a model owns its wire/display identity, optional -API override, optional reasoning controls, a declared allowed-effort subset, a declared hosted-tool -subset, stable instructions, cache intent, context/output/reserve +base URL, credential environment, optional inline bearer token, and default API; a model owns its +wire/display identity, optional API override, optional reasoning controls, a declared allowed-effort +subset, a declared hosted-tool subset, stable instructions, cache intent, context/output/reserve limits, compaction retention target, estimator and optional price snapshot. Resolution yields one immutable credential-blind value: omitted estimators become an explicit versioned default, while omitted pricing remains unavailable. Selection never uses fuzzy -names or URL inference. `PLEXMATON_HOME` redirects the whole root for isolated development; keys -never enter the file, diagnostics, repository or a native command's environment, and invalid input -fails before network work begins. SKL-1 permits a narrow project model-selection layer without +names or URL inference. `PLEXMATON_HOME` redirects the whole root for isolated development. A +route may keep its bearer token in `api_key`; the resolved model stays credential-blind, diagnostics +redact the token, and a set environment variable still overrides the file. Keys never enter +diagnostics, the repository, or a native command's environment, and invalid input fails before +network work begins. SKL-1 permits a narrow project model-selection layer without project provider definitions or credential changes. An allowed-effort declaration must be nonempty, unique and encodable by the dialect; an explicit configured effort must belong to it. A hosted-tool declaration names capabilities the owner knows their route accepts; it must be nonempty, unique and spellable by the dialect, and nothing infers one from a provider or model name. A route that accepts a spelling and ignores it is the declaration being wrong, which the owner corrects, never something -the harness guesses around. Rejected: a combined provider/model profile, -inline keys, and untyped merging of project configuration into provider authority. +the harness guesses around. Rejected: a combined provider/model profile, and untyped merging of +project configuration into provider authority. Rejected: requiring the shell to export the named +variable, which kept a local route from starting when the token already belonged in the user-owned +file. [AGI-3/AGI-4](./agent-instructions.md) add a bounded workspace snapshot to that immutable request environment as user context, separately from configured system instructions and tool authority. diff --git a/README.md b/README.md index 1732a7e..b8ae765 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ mkdir -p .local/plexmaton cp examples/providers.toml .local/plexmaton/config.toml ``` -Select your provider/model in that file, set its `api_key_env` variable, then launch: +Select your provider/model in that file. Put the credential in that variable, or set `api_key` on the route, then launch: ```sh PLEXMATON_HOME="$PWD/.local/plexmaton" cargo run -p plexmaton-cli --bin plexmaton diff --git a/crates/plexmaton-cli/src/interaction/model.rs b/crates/plexmaton-cli/src/interaction/model.rs index 2e07c45..b263116 100644 --- a/crates/plexmaton-cli/src/interaction/model.rs +++ b/crates/plexmaton-cli/src/interaction/model.rs @@ -6,7 +6,6 @@ //! admission. use crate::session_picker::ConversationPicker; -use plexmaton_provider::resolve_api_key; use plexmaton_runtime::{LiveRuntime, ModelReplacement}; use plexmaton_tui::{ModelChange, Workspace}; @@ -63,7 +62,9 @@ fn apply_model( .models() .model(&change.identity.provider, &change.identity.model) .ok_or_else(|| "This configured model is no longer available.".to_owned())?; - let key = resolve_api_key(model, std::env::var_os(model.api_key_env())) + let key = picker + .models() + .api_key_for(model, std::env::var_os(model.api_key_env())) .map_err(|_| "The selected provider credential is missing or invalid.".to_owned())?; runtime .set_model(&change.agent, model.clone(), key) diff --git a/crates/plexmaton-cli/src/main.rs b/crates/plexmaton-cli/src/main.rs index c8f9f39..4409c45 100644 --- a/crates/plexmaton-cli/src/main.rs +++ b/crates/plexmaton-cli/src/main.rs @@ -10,6 +10,7 @@ use crossterm::event::EventStream; #[cfg(test)] use plexmaton_agent::Input; use plexmaton_core::AgentId; +#[cfg(test)] use plexmaton_provider::resolve_api_key; use plexmaton_runtime::{ConversationRecovery, DispatchReport, LiveRuntime, NativeToolCatalog}; use plexmaton_tui::{ConfigurationSummary, Palette, Workspace}; diff --git a/crates/plexmaton-cli/src/session_picker.rs b/crates/plexmaton-cli/src/session_picker.rs index ab3fc29..dd9f835 100644 --- a/crates/plexmaton-cli/src/session_picker.rs +++ b/crates/plexmaton-cli/src/session_picker.rs @@ -408,7 +408,9 @@ impl Launcher { cancel: JobCancellation, ) -> anyhow::Result { anyhow::ensure!(!cancel.is_cancelled(), "session load cancelled"); - let key = resolve_api_key(&self.model, std::env::var_os(self.model.api_key_env()))?; + let key = self + .models + .api_key_for(&self.model, std::env::var_os(self.model.api_key_env()))?; self.open_with_key(selection, agent, cancel, key).await } diff --git a/crates/plexmaton-cli/src/startup.rs b/crates/plexmaton-cli/src/startup.rs index 04d5772..1f8d5af 100644 --- a/crates/plexmaton-cli/src/startup.rs +++ b/crates/plexmaton-cli/src/startup.rs @@ -4,7 +4,7 @@ use std::{ffi::OsString, fs, path::PathBuf}; use anyhow::Context as _; use plexmaton_core::AgentId; -use plexmaton_provider::{resolve_api_key, resolve_home}; +use plexmaton_provider::resolve_home; use plexmaton_runtime::NativeToolCatalog; use crate::{ @@ -47,7 +47,9 @@ pub(super) async fn live_runtime_from_process( &plexmaton_file_tools::FileCancellation::new(), ) .context("load AGENTS.md instructions")?; - let key = resolve_api_key(&model, std::env::var_os(model.api_key_env())) + let key = config + .models + .api_key_for(&model, std::env::var_os(model.api_key_env())) .context("resolve provider API key")?; let ripgrep = resolve_path_executable("rg", std::env::var_os("PATH").as_deref())?; let driver = std::env::current_exe() diff --git a/crates/plexmaton-provider/src/config.rs b/crates/plexmaton-provider/src/config.rs index b38f795..4007ea0 100644 --- a/crates/plexmaton-provider/src/config.rs +++ b/crates/plexmaton-provider/src/config.rs @@ -2,7 +2,7 @@ use plexmaton_core::ReasoningEffort; pub use plexmaton_core::ServerTool; -use std::{collections::BTreeMap, fmt}; +use std::{collections::BTreeMap, ffi::OsString, fmt}; mod environment; mod model; @@ -152,10 +152,12 @@ impl ModelSelection { pub struct ModelRegistry { active: ModelSelection, models: BTreeMap<(String, String), ResolvedModel>, + /// Bearer tokens written on the route. The resolved model stays credential-blind. + api_keys: BTreeMap, } /// Provider bearer credential whose ordinary debug representation is always redacted. -#[derive(Clone)] +#[derive(Clone, PartialEq, Eq)] pub struct ApiKey(String); impl ApiKey { @@ -188,6 +190,8 @@ struct RawModelRegistry { struct RawProvider { base_url: String, api_key_env: String, + /// Optional bearer token for this route. A set environment variable still overrides it. + api_key: Option, api: Option, models: BTreeMap, } @@ -277,6 +281,8 @@ pub enum ConfigError { MissingApiKeyEnvironment(String), #[error("provider API key environment variable `{0}` is not a header-safe value")] InvalidApiKeyValue(String), + #[error("provider `{0}` has an API key that is not header-safe")] + InvalidInlineApiKey(String), } impl ModelRegistry { @@ -294,11 +300,16 @@ impl ModelRegistry { fn resolve(raw: RawModelRegistry) -> Result { let mut models = BTreeMap::new(); + let mut api_keys = BTreeMap::new(); for (provider_name, provider) in raw.providers { let base_url = validate_provider(&provider_name, &provider)?; + if let Some(key) = inline_api_key(&provider_name, provider.api_key.as_deref())? { + api_keys.insert(provider_name.clone(), key); + } let RawProvider { base_url: _, api_key_env, + api_key: _, api, models: provider_models, } = provider; @@ -335,9 +346,28 @@ impl ModelRegistry { Ok(Self { active: raw.active_model, models, + api_keys, }) } + /// Resolves the bearer token for `model`. + /// + /// A present environment value wins, so a shell can override the file. When that value is + /// absent, the route's `api_key` is used. The returned credential is redacted in `Debug`. + pub fn api_key_for( + &self, + model: &ResolvedModel, + environment: Option, + ) -> Result { + if environment.is_some() { + return resolve_api_key(model, environment); + } + if let Some(key) = self.api_keys.get(model.provider_name()) { + return Ok(key.clone()); + } + resolve_api_key(model, None) + } + /// Every configured model in deterministic provider/name order; no credentials are resolved. pub fn models(&self) -> impl ExactSizeIterator { self.models.values() @@ -407,6 +437,16 @@ fn validate_provider(name: &str, provider: &RawProvider) -> Result) -> Result, ConfigError> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_graphic()) { + return Err(ConfigError::InvalidInlineApiKey(provider.to_owned())); + } + Ok(Some(ApiKey(value.to_owned()))) +} + fn is_environment_name(value: &str) -> bool { let mut bytes = value.bytes(); let Some(first) = bytes.next() else { diff --git a/crates/plexmaton-provider/src/config/tests.rs b/crates/plexmaton-provider/src/config/tests.rs index 536c0c3..ca66f22 100644 --- a/crates/plexmaton-provider/src/config/tests.rs +++ b/crates/plexmaton-provider/src/config/tests.rs @@ -283,15 +283,39 @@ reasoning_effort = "xhigh" ModelRegistry::parse(legacy), Err(ConfigError::Toml) )); +} - let inline_key = LOCAL_CONFIG.replace( +#[test] +fn prv_6_inline_key_is_used_when_its_environment_variable_is_absent() { + let source = LOCAL_CONFIG.replace( "api_key_env = \"PLEXMATON_LOCAL_API_KEY\"", "api_key_env = \"PLEXMATON_LOCAL_API_KEY\"\napi_key = \"inline-secret\"", ); - let error = ModelRegistry::parse(&inline_key).expect_err("inline key must be rejected"); - assert!(matches!(&error, ConfigError::Toml)); + let registry = ModelRegistry::parse(&source).expect("inline key is part of the route"); + let model = registry.active_model(); + let rendered = format!("{model:?}\n{registry:?}"); + assert!(!rendered.contains("inline-secret")); + let key = registry + .api_key_for(model, None) + .expect("file token supplies the absent variable"); + assert_eq!(key.expose(), "inline-secret"); + assert_eq!(format!("{key:?}"), "ApiKey([REDACTED])"); + let overridden = registry + .api_key_for(model, Some("from-env".into())) + .expect("environment still overrides the file"); + assert_eq!(overridden.expose(), "from-env"); + + let unsafe_key = LOCAL_CONFIG.replace( + "api_key_env = \"PLEXMATON_LOCAL_API_KEY\"", + "api_key_env = \"PLEXMATON_LOCAL_API_KEY\"\napi_key = \"not header safe\"", + ); + let error = ModelRegistry::parse(&unsafe_key).expect_err("unsafe token must be rejected"); + assert!(matches!( + &error, + ConfigError::InvalidInlineApiKey(provider) if provider == "local" + )); let diagnostics = format!("{error}\n{error:?}"); - assert!(!diagnostics.contains("inline-secret")); + assert!(!diagnostics.contains("not header safe")); } #[test] diff --git a/examples/providers.toml b/examples/providers.toml index 36dd935..b0fea09 100644 --- a/examples/providers.toml +++ b/examples/providers.toml @@ -1,6 +1,6 @@ # Current frontier series; select the exact route/model pair your account can access. # These conservative local budgets are below the models' published ceilings. -# Credentials come only from the named environment variables. +# A route names the environment variable that holds its credential, or sets api_key itself. # Native Astra tool conversations require Responses. active_model = { provider = "openai", model = "responses" }