Skip to content

Mid conversation system messages - #9548

Open
mitsuhiko wants to merge 11 commits into
mainfrom
mid-conversation-system-messages
Open

mitsuhiko wants to merge 11 commits into
mainfrom
mid-conversation-system-messages

Conversation

@mitsuhiko

@mitsuhiko mitsuhiko commented Sep 13, 2026

Copy link
Copy Markdown
Member

This PR makes system prompt text and tool changes part of the transcript rather than silently rewriting its starting conditions. This lets Pi record when instructions changed or tools became available, restore that state after resuming or navigating branches, and preserve cached prompt prefixes where the upstream supports it.

  • Pi records the initial system prompt and complete tool definitions in the transcript's first system message. Later changes are appended as system messages before the next model response, including changes made by tools during a run.
  • Tool activation is no longer tied to a particular tool result.
  • Unchanged state produces no extra message. Every change is a section diff: an update repeats only changed prompt sections, marks removed sections with null, and lists toolsAdded/toolsRemoved (a changed tool definition is a removal plus an addition). A forced or custom prompt is the same mechanism: it patches preamble and removes the other sections. There is no separate "complete prompt" checkpoint outside compaction; replaying the system messages in order always yields the current prompt and tools.
  • Models without mid-conversation system message support get collapseSystemMessages(): the replayed prompt and current tools become the leading system message and later system messages are dropped. That is the old behavior, one cache miss per change.

The default system prompt now has a fixed introductory paragraph followed by named XML-style sections: <tools>, <rules>, <docs>, optional <addendum>, <project_context> and <skills>, and <cwd>. Extensions can edit the structured prompt inputs, contribute named sections, and change the tool loadout; the same diff mechanism applies.

Upstream behavior

Whether a model receives updates in place is a catalog compat flag (supportsMidConvoSystemMessages) set only for verified models. Everything else collapses.

  • OpenAI Responses (openai, openai-codex, and the OpenCode Zen/Go and GitHub Copilot proxies): flagged for GPT-5.4, 5.4-mini, 5.4-pro, 5.5, 5.6-*, and GPT-6 Astra where each endpoint offers them. Instruction updates are in-place developer messages. Tool additions use additional_tools where supported, or completed client-side tool-search entries (Codex GPT-5.5); removals fall back to the complete current request-level tool list. Codex keeps the initial prompt in its separate instructions field. Azure Responses has the transport support but no flagged model.
  • Chat Completions: flagged for Kimi K3 (Moonshot, Fireworks, OpenCode), DeepSeek V4 Pro, openai/* on OpenRouter, and Copilot Kimi K3. Instruction updates are in-place system/developer messages. Kimi-style tool-bearing system messages (supportsMidConvoToolAdditions) are enabled for Kimi K3 on Moonshot, Fireworks, and OpenCode; Copilot silently drops them, so it gets text only. Otherwise tools are sent as the current request-level list. Mistral has the transport support but no flagged model.
  • Anthropic: flagged for Opus 4.8, Opus 5, and Fable 5/5.1 on the anthropic provider. Later system messages are system-role messages, and tool changes are native tool_addition/tool_removal blocks under the mid-conversation-tool-changes-2026-07-01 beta. Every declared tool is still sent at request level, so native transitions do not guarantee an unchanged tool-schema prefix. Later system messages are emitted directly before the next assistant message so tool_result stays adjacent to tool_use. OpenCode and Copilot Claude endpoints forward the system messages but reject the tool blocks, so they are flagged for text only.
  • Google/Gemini, Vertex, Bedrock Converse, and all unflagged models: collapse into the leading system prompt and send the current tool list.

The old tool-result-based deferred-loading mechanism is removed, including Anthropic/Fireworks deferred tool references. Dynamic activation works through the transcript transitions or the collapse fallback. Cache preservation depends on the endpoint and transition; it is not universal.

Verification

Live-tested with four scenarios (section update, tool addition, tool removal, tool addition directly after a tool result with no user turn) against every flagged transport: Anthropic Opus 5 / Opus 4.8 / Fable 5.1, OpenAI GPT-5.4 / 5.6 Terra / 6 Astra, Codex GPT-5.5 (tool search) and 5.6 Terra (additional_tools), Moonshot Kimi K3, and the OpenCode, Copilot, DeepSeek, and OpenRouter entries above with their generated flags. Proxies that reject a native form (Anthropic tool blocks on OpenCode/Copilot, Kimi tools on Copilot) were measured and flagged accordingly.

Caching: on api.openai.com an in-place developer update keeps the full cached prefix (measured 3245 read on a 3.3k prompt). The ChatGPT Codex backend uses the legacy 2048-token-interval caching and reports a full miss on the request that introduces a developer item; the collapse strategy would miss there too.

Sessions and compaction

Session JSONL stores system messages with sections and tool additions/removals; there is no separate prompt state entry. The session version remains 3. Sessions created before this change have no leading system message; the first request declares the current prompt and tools as a later system message, which replays the same way, rather than rewriting history.

Compaction entries gain a systemMessage checkpoint holding the replayed prompt sections and tool declarations at the boundary. Rebuilt context starts with that checkpoint, then the summary and the retained non-system entries; pre-compaction system updates are folded into the checkpoint rather than replayed. Later updates remain chronological. Context estimates include system-message text and tool changes.

Also included

  • fix(ai): OpenRouter rejects configuration_update effort messages on anthropic/claude-opus-5 (every request 400'd on main) while accepting them on Fable 5.1; the model is now gated there.

@badlogic

Copy link
Copy Markdown
Collaborator

getTranscriptCapabilities() currently treats every openai-completions model as supporting native mid-conversation system messages. That also opts arbitrary OpenAI-compatible endpoints in when compat is missing.

This should be capability-gated and default to false. Please add a supportsMidConvoSystemMessages compat flag, enable it only for providers/models where we know this works, and lower later system messages to user messages for everything else.

This comment is AI-generated by /wr.

@badlogic

Copy link
Copy Markdown
Collaborator

I tested the transcript behavior against the live APIs. There are a few concrete packages/ai changes needed before this matches the intended design.

Anthropic

The generated flags currently cover:

  • claude-fable-5
  • claude-fable-5-1
  • claude-opus-4-8
  • claude-opus-5

I tested all four. Each accepted and followed a mid-conversation system message, tool_addition, and tool_removal. There is no separate documented Anthropic addition-only model set: additions and removals are the same beta capability on the same models.

Anthropic's required message order is important. user -> system -> user is rejected with:

role 'system' must precede an 'assistant' message or end the array

The canonical order must be:

user* -> system* -> assistant

and for tool loops:

assistant(tool_use*) -> user(tool_result*) -> system* -> assistant

So the buffering in convertMessages() that moves system directives to the next assistant boundary is correct. For example, user -> system -> user -> assistant must become user -> user -> system -> assistant.

transformMessages() currently does more than reshuffle at a system boundary inside a multi-tool batch: it synthesizes missing tool results. A system message should be transparent to pending tool-call accounting; do not produce No result provided merely because one appeared between tool results. The Anthropic converter should continue buffering that system message to the next assistant boundary. Providers such as OpenAI that accept it between tool outputs can keep its exact position.

Deferred tools and caching

Late Anthropic tools must be declared at request level with defer_loading: true and activated with tool_addition. Live behavior:

  • late tool deferred, before addition: not callable
  • late tool deferred, after addition: callable
  • late tool not deferred: callable before the transcript addition
  • removed request-level tool: no longer callable
  • all tools deferred: request rejected with At least one tool must have defer_loading=false

Anthropic also inserts hidden system scaffolding when any deferred tool exists. We need a stable deferred dummy from the first request whenever there are initial active tools, otherwise introducing the first deferred tool later invalidates most of the cached prefix.

I tested the intended shape with cache breakpoints on the last initial active tool, top-level system prompt, and conversation content:

initial active tools       // non-deferred
stable dummy tool          // defer_loading: true, never activated
later declared tools       // defer_loading: true, appended after dummy

With the dummy present from request one:

request 1: cache_write 16,556
request 2: cache_read  16,556
           cache_write  2,251

Without it, adding the first deferred tool on request two:

request 1: cache_write 16,463
request 2: cache_read   5,172
           cache_write 13,632

The dummy itself cannot carry cache_control; Anthropic rejects cache_control combined with defer_loading. Keep the tool cache breakpoint on the last initial active tool.

Change the Anthropic path as follows:

  1. Initial active tools remain non-deferred.
  2. If there is at least one initial active tool, inject one stable deferred dummy on every request and never activate it.
  3. Put later declarations after the dummy with defer_loading: true.
  4. Keep cache_control on the last initial active tool, not the last declared/deferred tool.
  5. Emit tool_addition / tool_removal blocks at the canonical assistant boundary.
  6. If the initial active set is empty, omit the dummy. A later addition cannot use the all-deferred native shape, so fall back to the current non-deferred request-level tool set and suppress inline tool-change blocks.
  7. A same-name definition replacement also needs that fallback because Anthropic's inline blocks only reference names; they do not carry replacement schemas.

The current code declares every tool through getDeclaredTools() but marks none of the late definitions deferred, so additions currently have the wrong semantics and do not get the intended cache behavior.

Docs: https://platform.claude.com/docs/en/build-with-claude/mid-conversation-system-messages

OpenAI Responses and Codex

I tested every model currently marked for these mechanisms.

Direct OpenAI, both additional_tools and completed client tool-search replay:

  • gpt-5.4
  • gpt-5.4-mini
  • gpt-5.4-pro
  • gpt-5.5
  • gpt-5.6-luna
  • gpt-5.6-sol
  • gpt-5.6-terra
  • gpt-6-astra

All eight accepted the transcript-anchored mechanism and called the loaded tool.

Codex:

  • gpt-5.5: completed client tool-search replay worked
  • gpt-5.6-luna, gpt-5.6-sol, gpt-5.6-terra, gpt-6-astra: both additional_tools and tool-search replay worked

The important behavior is that additional_tools is additive, not a snapshot:

  • I added [tool_a, tool_b], then [tool_b, tool_c], then requested tool_a. GPT still called tool_a; omission from the later item did not remove it.
  • I redeclared same_tool with a new required argument. GPT continued using the original schema. Redeclaration is not a schema replacement.
  • A developer message between two function outputs was accepted at its exact transcript position.

Change all three Responses request builders (openai-responses, Azure, Codex) as follows:

  1. For additions-only history, keep initial tools at request level and emit only newly added definitions at each additional_tools item. Do not emit cumulative snapshots or repeat already loaded names.
  2. Keep the existing tool-search behavior that emits only newly added tools.
  3. If any toolsRemoved occurs, disable all inline tool-transition items for that request and send getCurrentTools() at request level.
  4. Do the same for a repeated tool name / definition replacement.
  5. Keep system/developer text at its transcript position. Codex keeps only the leading system text in instructions; later text remains inline.

The current additionalToolsIncludeInitial snapshot path cannot remove tools and the removal test currently asserts behavior the API does not provide. Update that test to expect request-level fallback and no additional_tools items. Also update anchors OpenAI additions at their system message: its second item should contain only last_tool, not late_tool again.

Docs: https://developers.openai.com/api/docs/guides/tools-tool-search#add-tools-at-a-specific-point-in-the-input

Kimi Chat Completions

System text worked on all tested Moonshot models:

  • kimi-k2.6
  • kimi-k2.7-code
  • kimi-k2.7-code-highspeed
  • kimi-k3

Dynamic system-message tools worked on:

  • Moonshot kimi-k3
  • Fireworks accounts/fireworks/models/kimi-k3
  • Fireworks accounts/fireworks/routers/kimi-k3-fast

They failed with tokenization failed on Moonshot K2.6 and both K2.7 variants. The official documentation explicitly says dynamic tool loading is currently K3-only.

Kimi tool messages are additions-only and must be retained in subsequent requests. A tool-bearing system message cannot also carry content, so keep the current split of a pi SystemMessage containing both text and tool additions into two adjacent system messages:

{ role: "system", tools: [new complete definitions] }
{ role: "system", content: "instruction text" }

Change the Kimi path as follows:

  1. Restrict supportsMidConvoToolAdditions generation to K3. The current id.includes("kimi") check incorrectly marks K2.6 and K2.7.
  2. Keep the existing filtering that emits only newly added complete definitions; never redeclare a previously loaded name.
  3. Any removal or same-name replacement falls back to the current request-level tool set and suppresses all tool-bearing system messages. Keep system text inline.
  4. Keep the existing no-content tool-message wire shape.

Docs: https://platform.kimi.ai/docs/guide/use-dynamic-tool-loading

Capability reporting

getTranscriptCapabilities() currently treats every openai-completions model as supporting native mid-conversation system messages. In the generated catalog that is 684 models across unrelated compatible providers. Kimi working does not establish that blanket capability.

Add an explicit supportsMidConvoSystemMessages compat flag for OpenAI Chat-compatible models, default it to false, and mark only verified provider/model families. The deliberate trade-off is that unmarked endpoints use collapseSystemMessages() and lose inline system updates rather than being optimistically sent a wire shape we have not verified. Keep this independent from supportsMidConvoToolAdditions: K2.6/K2.7 support inline system text but not inline tools. mistral-conversations is also hard-coded as native, but that is a separate capability claim and should remain unchanged unless we verify it separately.

Required pi-ai tests

Please add/update focused payload tests for:

  • Anthropic canonical user/user/system/assistant and tool-result batching
  • no synthetic tool result when a system message is inside a tool-result batch
  • initial active tools + stable deferred dummy + late deferred definitions
  • no dummy/all-deferred shape when initial tools are empty, with request-level fallback
  • Anthropic removal and same-name replacement fallback
  • OpenAI additions emitting only new definitions
  • OpenAI removal/replacement producing current request-level tools and no inline transition items
  • tool-search removal fallback
  • Kimi K3 additions, K2.6/K2.7 capability disabled, and removal/replacement fallback
  • getTranscriptCapabilities() default false for an unmarked openai-completions model and true for explicitly marked models

Unsupported providers should continue deriving current request-level tools by replaying transcript additions/removals. The target design calls for user-role <system> lowering; the PR currently emits <system_reminder>, so change renderSystemMessageAsUserText() if <system> remains the agreed wire format.

This comment is AI-generated by /wr.

@mitsuhiko
mitsuhiko force-pushed the mid-conversation-system-messages branch from 7f45441 to 9a9591e Compare September 14, 2026 17:29
@mitsuhiko
mitsuhiko marked this pull request as ready for review September 14, 2026 21:29
@mitsuhiko

mitsuhiko commented Sep 14, 2026

Copy link
Copy Markdown
Member Author

Example session transcript showing the system messages. Captured from a real run of pi -p from this branch against openai-codex/gpt-5.6-terra with a small extension: turn 1 runs with read and bash; before turn 2 the extension sets an addendum section, activates its own get_time tool, and drops bash.

Entries are one JSON object per line in the file; pretty-printed here. The only edits: long prompt section text is clipped (), tool-call ids are shortened, and assistant usage/thinking blocks are omitted. Tool declarations (toolsAdded) are verbatim: parameters is the JSON Schema object, and built-in tools also carry constrainedSampling.

Points of interest:

  • The first system message declares every prompt section and the complete tool definitions.
  • The second system message is the diff: only the tools, rules, and addendum sections are repeated, get_time is in toolsAdded, and bash is in toolsRemoved. The unchanged preamble, docs, and cwd sections are not resent.
  • The model calls get_time on the very next request and follows the addendum.
{
  "type": "session",
  "id": "loadout-demo",
  "version": 3,
  "cwd": "/Users/mitsuhiko/Development/pi-mono"
}

{
  "type": "model_change",
  "id": "ce3c555f",
  "parentId": null,
  "provider": "openai-codex",
  "modelId": "gpt-5.6-terra"
}

{
  "type": "thinking_level_change",
  "id": "13f2de78",
  "parentId": "ce3c555f",
  "thinkingLevel": "low"
}

{
  "type": "message",
  "id": "4074d10e",
  "parentId": "13f2de78",
  "message": {
    "role": "system",
    "sections": {
      "preamble": "You are an expert coding assistant operating inside pi, a coding agent …",
      "tools": "<tools>\n- read: Read file contents\n- bash: Execute bash commands (ls, grep, find, etc.)\n",
      "rules": "<rules>\n- Use bash for file operations like ls, rg, find …",
      "docs": "<docs>\nPi documentation (read only when the user asks about pi itself, its SDK, extensions …",
      "cwd": "<cwd>\n/Users/mitsuhiko/Development/pi-mono\n</cwd>"
    },
    "toolsAdded": [
      {
        "name": "read",
        "description": "Read the contents of a file. Supports text files and images (jpg, png, gif, webp, bmp). Images are sent as attachments. For text files, output is truncated to 2000 lines or 50KB (whichever is hit first). Use offset/limit for large files. When you need the full file, continue with offset until complete.",
        "parameters": {
          "type": "object",
          "required": [
            "path"
          ],
          "properties": {
            "path": {
              "type": "string",
              "description": "Path to the file to read (relative or absolute)"
            },
            "offset": {
              "type": "number",
              "description": "Line number to start reading from (1-indexed)"
            },
            "limit": {
              "type": "number",
              "description": "Maximum number of lines to read"
            }
          }
        },
        "constrainedSampling": {
          "type": "json_schema",
          "strict": "prefer"
        }
      },
      {
        "name": "bash",
        "description": "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.",
        "parameters": {
          "type": "object",
          "required": [
            "command"
          ],
          "properties": {
            "command": {
              "type": "string",
              "description": "Shell command to execute"
            },
            "timeout": {
              "type": "number",
              "description": "Timeout in seconds (optional, no default timeout)"
            }
          }
        },
        "constrainedSampling": {
          "type": "json_schema",
          "strict": "prefer"
        }
      }
    ],
    "timestamp": 1789421512079
  }
}

{
  "type": "message",
  "id": "34d47490",
  "parentId": "4074d10e",
  "message": {
    "role": "user",
    "content": [
      {
        "type": "text",
        "text": "What time is it in Vienna? Use a tool."
      }
    ],
    "timestamp": 1789421512079
  }
}

{
  "type": "message",
  "id": "1dd944de",
  "parentId": "34d47490",
  "message": {
    "role": "assistant",
    "content": [
      {
        "type": "toolCall",
        "id": "call_AxIloA…|fc_03c8d2…",
        "name": "bash",
        "arguments": {
          "command": "TZ=Europe/Vienna date '+%A, %B %-d, %Y — %H:%M:%S %Z'",
          "timeout": 10
        }
      }
    ],
    "model": "openai-codex/gpt-5.6-terra",
    "timestamp": 1789421512094
  }
}

{
  "type": "message",
  "id": "4ba47352",
  "parentId": "1dd944de",
  "message": {
    "role": "toolResult",
    "toolCallId": "call_AxIloA…|fc_03c8d2…",
    "toolName": "bash",
    "content": [
      {
        "type": "text",
        "text": "Monday, September 14, 2026 — 23:31:59 CEST"
      }
    ],
    "timestamp": 1789421519006
  }
}

{
  "type": "message",
  "id": "40c3a48c",
  "parentId": "4ba47352",
  "message": {
    "role": "assistant",
    "content": [
      {
        "type": "text",
        "text": "It’s 23:31 CEST in Vienna (Monday, September 14, 2026)."
      }
    ],
    "model": "openai-codex/gpt-5.6-terra",
    "timestamp": 1789421519008
  }
}

{
  "type": "message",
  "id": "4fef1fa4",
  "parentId": "40c3a48c",
  "message": {
    "role": "system",
    "sections": {
      "tools": "<tools>\n- read: Read file contents\n- get_time: Look up the current local time for a city\n",
      "rules": "<rules>\n- Use read to examine files instead of cat or sed.\n- Be concise in your responses …",
      "addendum": "<addendum>\nTalk like a pirate.\n</addendum>"
    },
    "toolsAdded": [
      {
        "name": "get_time",
        "description": "Get the current local time for a city.",
        "parameters": {
          "type": "object",
          "required": [
            "city"
          ],
          "properties": {
            "city": {
              "type": "string",
              "description": "City name"
            }
          }
        }
      }
    ],
    "toolsRemoved": [
      {
        "name": "bash"
      }
    ],
    "timestamp": 1789421525596
  }
}

{
  "type": "message",
  "id": "010bab5d",
  "parentId": "4fef1fa4",
  "message": {
    "role": "user",
    "content": [
      {
        "type": "text",
        "text": "And now?"
      }
    ],
    "timestamp": 1789421525596
  }
}

{
  "type": "message",
  "id": "44ebe458",
  "parentId": "010bab5d",
  "message": {
    "role": "assistant",
    "content": [
      {
        "type": "toolCall",
        "id": "call_A0U3os…|fc_03c8d2…",
        "name": "get_time",
        "arguments": {
          "city": "Vienna"
        }
      }
    ],
    "model": "openai-codex/gpt-5.6-terra",
    "timestamp": 1789421525613
  }
}

{
  "type": "message",
  "id": "63aa5558",
  "parentId": "44ebe458",
  "message": {
    "role": "toolResult",
    "toolCallId": "call_A0U3os…|fc_03c8d2…",
    "toolName": "get_time",
    "content": [
      {
        "type": "text",
        "text": "The time in Vienna is 22:41."
      }
    ],
    "timestamp": 1789421527568
  }
}

{
  "type": "message",
  "id": "2c811842",
  "parentId": "63aa5558",
  "message": {
    "role": "assistant",
    "content": [
      {
        "type": "text",
        "text": "Arrr, it’s 22:41 in Vienna."
      }
    ],
    "model": "openai-codex/gpt-5.6-terra",
    "timestamp": 1789421527571
  }
}

This comment is AI-generated.

@mitsuhiko

Copy link
Copy Markdown
Member Author

AI generated (analysis by Claude, reviewed by the author)

Custom providers that hand-roll the payload lose the prompt and tools

The agent loop now streams { messages } only (packages/agent/src/agent-loop.ts:362); the system prompt and tool definitions travel inside role: "system" messages. Built-in providers are fine because they all go through normalizeContext(), which accepts both the old top-level systemPrompt/tools shorthand and the transcript form, and custom providers that delegate to a built-in stream function (checked: pi-llamacpp, pi-ds4, pi-radius, and the bundled custom-provider-gitlab-duo example) inherit that. Custom providers that build the wire request themselves from context.systemPrompt and context.tools break silently: Context still declares both fields as optional so nothing fails to type-check, but at runtime they send no instructions and no tool definitions, and their message converters meet an unknown system role (the bundled custom-provider-anthropic example drops it; other implementations may throw). The dispatch point is provider-composer.ts:470, where extension.streamSimple(model, context, ...) receives the raw transcript context.

Options

  1. Legacy shape at the extension boundary, opt-in for the transcript. In provider-composer.ts, before calling an extension's streamSimple, convert with collapseSystemMessages(normalizeContext(ctx)) and hand over { systemPrompt, tools, messages } with the system messages removed. Add a ProviderConfig flag (e.g. transcriptSystemMessages: true) for providers that want the raw transcript. Existing extensions keep the old semantics unchanged (one cache miss per prompt change). Cost: a delegating wrapper around a flagged model (e.g. pi-ds4 over openai-codex) loses in-place updates until it sets the flag, since the built-in provider downstream only sees the collapsed shape.

  2. No shim; migrate the example and document the break. Update custom-provider-anthropic to read getInitialSystemMessage()/getCurrentTools() (or collapse first), show the transcript-aware pattern in docs/custom-provider.md, and add Breaking Changes entries to the pi-ai and pi-coding-agent changelogs. The helpers are already exported from @earendil-works/pi-ai. Cost: third-party hand-rolled providers fail at runtime until updated, and the failure is quiet.

  3. Carry both in Context: top-level systemPrompt/tools derived from the transcript plus the system messages in messages. Not recommended: a legacy provider would send the top-level prompt and also push system messages through its converter, so it double-instructs or crashes, and normalizeContext() would need a precedence rule between the two sources.

@holny

holny commented Sep 15, 2026

Copy link
Copy Markdown

Had a close read of normalize-context and the compaction interaction — the leading/patch model is a nice fit for the skills hot-swap case. Two things I couldn't fully resolve from the diff:

  1. When a mid-conversation patch (say a sections swap) falls inside the region that gets summarized, does the compaction entry replay fold it back into the reconstructed leading message? system-message-replay.test.ts didn't show a case for a patch landing in the summarized region — might be worth one if the folding happens at replay time.

  2. For adapters without native mid-conversation system support (the mistral/kimi paths render later system messages as role:"system" regardless) — have you confirmed those APIs tolerate a system message after the first user turn, or does the collapse path always run first for them? If the collapse always runs, fine; if a raw system role can reach them, that looks like a 400 waiting to happen.

Otherwise the AgentState.systemPrompt removal reads clean, and the sdk.md migration coverage looks solid.

@badlogic

Copy link
Copy Markdown
Collaborator

There are still three blocking issues in packages/ai.

Before changing this implementation, the referenced Anthropic documentation MUST be read and understood in full, especially its placement, deferred-tool, and cache-prefix rules. Targeted live API probes must also be done for each payload shape below; unit tests that only assert locally generated JSON are not sufficient to establish provider compatibility.

1. System messages create fake tool results

transformMessages() treats a system message like a new user turn and closes pending tool calls.

assistant: tool_call(read, id=1)
system: update tools/instructions
toolResult: actual result for id=1

becomes:

assistant: tool_call(read, id=1)
toolResult: "No result provided" for id=1
system: update
toolResult: actual result for id=1

Providers may reject the duplicate result or consume the synthetic failure. Anthropic explicitly requires the system message to come after the tool_result, not between tool_use and its result:

A system message must not close pending tool accounting. The Anthropic adapter already moves it to a valid boundary after the real result.

2. Anthropic late-tool declarations are invalid

The adapter sends every declared tool as active at the top level, then emits tool_addition for later tools. For an initial read followed by a later write, the payload effectively says:

top-level active tools: read, write
later system block: add write

Anthropic specifies that every top-level tool is active immediately unless it has defer_loading: true. A later tool_addition must reference a previously declared deferred tool:

The implementation also needs safe fallback for:

  • no initially active tools;
  • tool removals;
  • replacing an existing tool's schema.

Those cases cannot safely use additive native transitions. Send the final current tool inventory at request level and omit inline changes.

3. Anthropic cache scaffolding is introduced too late

Even after late tools are marked deferred, request one may contain no deferred tools. Adding the first deferred tool on request two changes Anthropic's hidden prompt structure and invalidates much of the cached prefix.

Anthropic documents that tools come first in the cached request prefix, cache hits require an exact matching prefix, changing tool definitions invalidates the cache, and deferred loading exists to preserve that cache:

A stable deferred dummy therefore needs to be present from request one whenever there is at least one active initial tool. This specific dummy strategy follows from the live cache probe rather than being prescribed directly by the documentation.

The dummy must not have cache_control. It also cannot be used when there are no active tools because Anthropic rejects all-deferred inventories; that case needs the fallback described above.

The probes need to cover at minimum:

  1. tool_use -> system -> real tool_result, confirming no synthetic duplicate;
  2. initial active tool followed by a deferred late addition;
  3. empty initial inventory followed by an addition;
  4. removal and same-name schema replacement fallback;
  5. two cached requests with a late addition, checking cache_creation_input_tokens and cache_read_input_tokens rather than only request success.

This comment is AI-generated by /wr

Normalize top-level prompts and tools into transcript state while preserving the existing shorthand. Lower later system messages per provider and persist them in coding-agent sessions.
Remove addedToolNames propagation and provider-specific hidden tool loading. Dynamic tool changes now send the complete current tool list and system prompt on the next request.
…ranscript

Make the transcript the single source of truth for the system prompt and tools.
SystemMessage gains opaque, ordered sections that later messages patch by name;
replaying every system message yields the current prompt and tools. Providers
that accept system messages mid-conversation (gated per model via
supportsMidConvoSystemMessages) send them in place; all others fold them into
the leading system message instead of lowering them to user reminders.

The agent loop declares state.tools changes to the model before each request,
and AgentState.systemPrompt becomes a read-only replay of the transcript. The
coding agent emits section patches only and drops its parallel prompt state:
TranscriptCapabilities, ModelContextState, system_prompt session entries, and
the old-session migration checkpoint. Old sessions get a full patch on their
first request.

Also fixes setActiveTools() inside before_agent_start being undone by the stale
options copy, keeps compaction from summarizing system entries, and uses the
previous summary for split-turn compactions.
…prompt options

The agent loop now treats tool fields on a pending system message as intent and
rewrites them to the delta between the committed transcript and the executable
tool set, so a pending addition of a non-executable tool no longer survives
replay next to its own removal.

The coding agent stores the refreshed prompt options after a mid-run tool
change, so session.systemPrompt and ctx.getSystemPrompt() match what the
provider receives for the rest of the run.
OpenRouter rejects every request carrying a configuration_update system message for anthropic/claude-opus-5 while accepting it for Fable 5.1. Gate the model instead of the provider.
Flag OpenCode, OpenCode Go, GitHub Copilot, DeepSeek V4 Pro, and OpenRouter's OpenAI models after live verification of each transport:

- OpenCode/Copilot Responses: in-place developer messages and additional_tools pass through; tool search is unverified there.
- OpenCode/Copilot Claude: system text passes through; tool_addition/tool_removal blocks are rejected, so only supportsMidConvoSystemMessages is set.
- Kimi K3: OpenCode forwards the tool-bearing system message; Copilot drops it, so Copilot gets text only.
- DeepSeek V4 Pro and openai/* on OpenRouter accept system text in place.

Accept dotted Opus 4.8 ids in the Anthropic predicate for Copilot, and document why the Anthropic converter defers later system messages until the next assistant message.
…eam entry points

Public entry points (Models, compat stream functions, ModelRuntime) keep
accepting Context and call normalizeContext once. Providers, API modules,
StreamFn, streamProxy, faux, and extension custom providers receive a
TranscriptContext; the prompt and tools live in the transcript's system
messages.

Merge normalize-context.ts and transcript-state.ts into transcript.ts. The
replay helpers take any message list, so the agent package no longer needs
getTranscriptSystemMessage wrappers. Add createInitialSystemMessage and
declarationsEqual as the single places that build the leading system
message and compare tool declarations.

Drop createUserTurnAppender, the resolveTranscriptTools closure, the
faux prompt serialization special cases, and the renderSystemPromptSections
wrapper; buildSystemPrompt renders through getSystemMessageText so the
session prompt and the replayed transcript share one rendering rule.
…ool flow

Anthropic native tool changes now keep the initial tools active, send every later
declaration with defer_loading behind a stable deferred placeholder, and keep removed
tools declared, so tool additions and removals no longer invalidate the prompt cache
(measured: full miss before, full read after). The current tool list is sent instead
when there is no initial tool or a tool was redefined under the same name.

transformMessages no longer closes pending tool calls on a system message; it is held
until the results are in, so it cannot produce a duplicate synthetic tool result.
@mitsuhiko
mitsuhiko force-pushed the mid-conversation-system-messages branch from e478b30 to 9b8f143 Compare September 15, 2026 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants