anthropics / anthropics/anthropic-sdk-csharp
AsIChatClient: consecutive same-role messages aren't merged, so identical histories serialize differently across turns (undocumented prompt-cache reliance)
- Vorherrschende Sprache
- C#
- Sterne
- 322
- Forks
- 105
- Ø Merge
- 1 T. 6 Std.
- Gemergte PRs (30 T.)
- 9
Beschreibung
### Summary
The `IChatClient` bridge (`AnthropicClientExtensions.CreateMessageParams`) maps one `ChatMessage` to one `MessageParam` and merges only consecutive **system** messages (`NormalizeConsecutiveSystemMessages`, `src/Anthropic/AnthropicClientExtensions.cs:889`, called at `:930`). Consecutive **user** or **assistant** `ChatMessage`s are sent as separate `messages[]` entries.
This means the *same* assistant turn can serialize to **different `messages` bytes** depending on how the history was assembled — which, for apps that persist and re-hydrate conversation history, silently depends on the Anthropic API combining consecutive same-role messages *before* it computes the prompt-cache key. That combining is documented for request validity ("Consecutive `user` or `assistant` turns … will be combined into a single turn"), but its interaction with prompt-cache key computation is **not documented anywhere**, and the API's message-ordering rules have changed before (the 2024-10-08 relaxation from strict-alternation-reject to combine). Depending on undocumented behavior for cache economics is fragile.
### How consecutive same-role messages arise
With server-persisted history (very common for multi-turn agents), a turn produced in-run by `FunctionInvokingChatClient` and the *same* turn rebuilt from storage differ in message grouping:
| Turn | In-run (FICC) | Rebuilt from history |
|---|---|---|
| Parallel tool results | 1 `Tool`/user message with N `FunctionResultContent` | N separate `Tool`/user messages |
| Reasoning + text + tool call | 1 assistant message `[reasoning, text, tool_use]` | reasoning as its own assistant message, then `[text, tool_use]` — 2 consecutive assistant messages |
Same content blocks, same order after concatenation — only the message boundaries differ. The bridge forwards both shapes verbatim.
### Why it's currently "fine," and why that's fragile
We measured (live, against the real API, Sept 2026, SDK 12.42/12.45): priming a cached prefix in the grouped shape and re-sending in the split shape reads back the **full** cached prefix — so the API combines same-role messages before keying, and the split is currently cache-harmless. But:
- **Undocumented**: no Anthropic doc/changelog states that the cache key is computed post-combining. We can only confirm it empirically.
- **Has changed before**: the API used to *reject* consecutive same-role messages (400 `roles must alternate…`) and relaxed to combining on 2024-10-08. Message-ordering semantics are not a stable contract.
### What other SDKs do
Every peer merges consecutive same-role messages client-side rather than trusting the wire:
- Vercel `@ai-sdk/anthropic` — `groupIntoBlocks()`
- LangChain — `_merge_messages` / `merge_message_runs`
The C# SDK is the outlier (it merges only system messages).
### Ask
Either:
1. **Merge consecutive same-role user/assistant messages in `CreateMessageParams`** (concatenating their content blocks in order), mirroring the existing `NormalizeConsecutiveSystemMessages` and the rest of the ecosystem; or
2. **Document** that the prompt-cache key is computed after consecutive same-role combining, so `IChatClient` consumers can rely on it deliberately rather than by observation.
Either removes consumers' need to hand-roll a merge purely for cache stability.
### Environment
- `Anthropic` 12.45.0 (behavior unchanged since 12.42.0); `Microsoft.Extensions.AI` `IChatClient` path.
- Production use: an M.E.AI `IChatClient` over the Anthropic client, with conversation history persisted server-side and re-hydrated per run.
Beitragsleitfaden
Bewertung
Dieses Issue wurde noch nicht bewertet.