Skip to content

Commit 4d762d0

Browse files
committed
fix(ai): declare append-only history for session-cached backends
Trailing volatile placement only pays off for backends that cache by request prefix. Meridian's proxy matches the incoming message array against the history it already holds, and its lineage boundary tolerates a last-message append only for new tool_result blocks, so a payload-only volatile text block classified as modified-history and forced a full replay every turn. Model.appendOnlyHistory now declares that constraint. When set, the API registry routes volatileContext into the system prompt and adds no payload-only message block, so the request history stays byte-identical to the persisted conversation. Default placement is unchanged. The flag is settable from registerProvider model entries, models.json model definitions, and modelOverrides. refs #26
1 parent 227c80e commit 4d762d0

12 files changed

Lines changed: 207 additions & 7 deletions

File tree

.pylon/features.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ decisions:
331331
- https://github.com/PrimeIntellect-ai/prime-agent/commit/f81acc6679ab214f1c40821588d9e5e97c47bdb1
332332
- https://github.com/PrimeIntellect-ai/prime-agent/tree/c382f09856d4a8c8d2b765179657047d58691f25
333333
fork_change: stable-prompt-cache-prefix-v1
334-
upstream_support: Prime through c382f09856d4 reduced the system-prompt date to a day stamp for cache stability, but it still serializes mutable continual-harness state and that date into the cached system prompt, has no provider-neutral volatile-content channel, and does not pin active tool order behind the `cache_control` marker on the last tool definition.
334+
upstream_support: Prime through c382f09856d4 reduced the system-prompt date to a day stamp for cache stability, but it still serializes mutable continual-harness state and that date into the cached system prompt, has no provider-neutral volatile-content channel, no per-model placement contract for session-cached proxy backends, and does not pin active tool order behind the `cache_control` marker on the last tool definition.
335335
revisit_when:
336-
- Prime upstream provides an equivalent provider-neutral channel that keeps mutable state and clock-derived content out of the cached tools/system/history prefix.
337-
- Pylon can drop the fork behavior without regressing measured cache-hit rates through a Claude-Max proxy.
336+
- Prime upstream provides an equivalent provider-neutral channel that keeps mutable state and clock-derived content out of the cached tools/system/history prefix, including a declared constraint for append-only-history backends.
337+
- Pylon can drop the fork behavior without regressing measured cache-hit rates through a Claude-Max proxy or Meridian lineage continuation rates.

.pylon/upstream-review.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,8 @@ This ledger records Prime upstream evidence and the decision taken for each over
157157
- Design: `Context.volatileContext` carries continual-harness state and the current date. The Anthropic provider appends it as the final content block after the `cache_control` breakpoint on the last history block, so the cached tools -> system -> history prefix is unchanged by a harness write or a date flip. The OpenAI-completions provider appends it after its Anthropic-style markers and after the automatically cached prefix of providers without markers. The API registry folds the content into the end of the message list for every other provider, so no provider can silently drop it. `AgentSession` pins each tool to the slot it first occupied, so a registry refresh or an extension reload serializes an unchanged tool set to identical bytes.
158158
- Behavior preservation: the harness-state section and the `Current date` line are byte-identical to what the system prompt previously inlined, including the empty-state rendering and the custom-prompt-only scope of the date. Only their position changed. The subagent guidance still precedes the harness-state roster in the assembled request.
159159
- Compatibility classification: **backward-compatible and additive**. `Context.volatileContext`, `AgentState.volatileContext`, `AgentLoopConfig.getVolatileContext`, and `ApiProvider.handlesVolatileContext` are all optional; a caller that sets none behaves exactly as before. No daemon command, event, protocol version, or schema revision changes.
160-
- Validation: 10 new `packages/ai` payload tests assert a byte-identical cached prefix across volatile changes for both the Anthropic and OpenAI-completions payload builders, correct marker placement, the trailing-user-message case, blank-input handling, and the registry fallback. 4 new `packages/coding-agent/test/suite/regressions/26-stable-cache-prefix.test.ts` cases assert an unchanged prefix across a mid-session harness memory write, an unchanged prefix across a mocked two-day clock advance, identical tool bytes across repeated registry refreshes, and first-activation tool-order pinning across reordering and removal. `test/system-prompt.test.ts` (25), the compaction, compact-skill, refine, runtime, queue, and prompt suites (357), `packages/agent` (60), and 13 focused `packages/ai` provider files (93 passed, 9 gated skips) are green. `npm run check` passes. `test/extensions-runner.test.ts` fails 21 cases identically on unmodified `origin/pylon`, so it is pre-existing and unrelated.
160+
- Validation: 15 `packages/ai` payload tests assert a byte-identical cached prefix across volatile changes for both the Anthropic and OpenAI-completions payload builders, correct marker placement, the trailing-user-message case, blank-input handling, and the registry fallback. 5 `packages/coding-agent/test/suite/regressions/26-stable-cache-prefix.test.ts` cases assert an unchanged prefix across a mid-session harness memory write, an unchanged prefix across a mocked two-day clock advance, identical tool bytes across repeated registry refreshes, first-activation tool-order pinning across reordering and removal, and — for an `appendOnlyHistory` model registered through `registerProvider` — an append-only history with the volatile content in the system prompt. `test/suite` (79 files, 960), `test/system-prompt.test.ts` (25), `test/model-registry.test.ts`, `test/agent-session-recursion.test.ts` (112), `packages/agent` (60), and 13 focused `packages/ai` provider files (93 passed, 9 gated skips) are green. `npm run check` passes. `test/extensions-runner.test.ts` fails 21 cases identically on unmodified `origin/pylon`, so it is pre-existing and unrelated.
161+
- Review correction — two placements, declared per model. The first candidate always used trailing placement, which regressed the Meridian proxy path from "cold cache when harness state or the date changes" to a full fresh replay every turn. Meridian's lineage matcher (`meridian/src/proxy/session/lineage.ts`) resumes an SDK session only when the incoming history matches the stored history; its boundary branch at `cached.messageCount - 1` tolerates a last-message block append only when every stored block hash is preserved and every appended block is a new `tool_result` (`hasOnlyNewToolResults`, deliberately narrow per Meridian #689/#692). A payload-only volatile text block fails that on the turn it appears, and on the next turn the stored boundary message holds more blocks than the incoming replay because the block was never persisted, so `incomingBlockHashes.length > storedBlocks.length` fails too. Classification falls through to `diverged: modified-history` and forces a full-history replay on the exact deployment #22 targets.
162+
- The fix is a declared per-model constraint, not a workaround. `Model.appendOnlyHistory` marks a backend that caches by session rather than by request prefix and therefore requires the request message array to stay byte-identical to the persisted history. When set, the API registry routes `volatileContext` into the system prompt and adds no payload-only message block; the backend's own session cache absorbs the system-prompt change. Default stays trailing placement, which is correct for direct prefix-cached APIs. The flag is settable from extension `registerProvider` model entries and from `models.json` model definitions and `modelOverrides`, and it is documented for extension authors in `packages/coding-agent/docs/extensions.md`.
161163
- Deferred: the optional debug-level prefix-drift check from issue #26 is not implemented. The regression tests assert the invariant directly, and a cross-turn byte comparison would need new mutable state inside otherwise pure prompt assembly.
162164
- Revisit when Prime upstream offers an equivalent provider-neutral channel that keeps mutable state and clock-derived content out of the cached prefix, and Pylon can drop the fork behavior without regressing measured cache-hit rates through a Claude-Max proxy.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
- Added `Context.volatileContext` for content that must stay out of every cached prompt prefix; Anthropic and OpenAI-completions place it after their cache breakpoints and other providers receive it appended to the message list ([#26](https://github.com/pylon-code/prime-agent/issues/26)).
2+
- Added the `Model.appendOnlyHistory` flag for session-cached backends, which routes volatile content into the system prompt so the request message array stays byte-identical to the persisted history ([#26](https://github.com/pylon-code/prime-agent/issues/26)).

packages/ai/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,16 @@ The model still sees it, but never inside a cached region:
997997
- Anthropic and OpenAI-completions append it after their `cache_control` breakpoints, so the cached tools, system prompt, and conversation history are unaffected when it changes.
998998
- Every other provider receives it appended to the end of `Context.messages`, which is after the automatically cached prefix. Set `handlesVolatileContext: true` when registering a custom provider that positions the content itself.
999999

1000+
### Session-cached backends
1001+
1002+
Trailing placement assumes the backend caches by request prefix. A proxy in front of an agent SDK does not: it matches the incoming message array against the history it already holds and resumes that session, so a payload-only trailing block reads as a modified history and forces a full replay.
1003+
1004+
Set `appendOnlyHistory: true` on those models. The volatile content then goes into the system prompt and the message array stays byte-identical to the caller's history:
1005+
1006+
```typescript
1007+
const model = { ...getModel('anthropic', 'claude-opus-4-6'), baseUrl: 'https://my-sdk-proxy.example.com', appendOnlyHistory: true };
1008+
```
1009+
10001010
## Context Serialization
10011011

10021012
The `Context` object can be easily serialized and deserialized using standard JSON methods, making it simple to persist conversations, implement chat history, or transfer contexts between services:

packages/ai/src/api-registry.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type {
77
StreamFunction,
88
StreamOptions,
99
} from "./types.js";
10-
import { foldVolatileContext } from "./utils/volatile-context.js";
10+
import { foldVolatileContext, foldVolatileContextIntoSystemPrompt } from "./utils/volatile-context.js";
1111

1212
export type ApiStreamFunction = (
1313
model: Model<Api>,
@@ -46,6 +46,20 @@ type RegisteredApiProvider = {
4646

4747
const apiProviderRegistry = new Map<string, RegisteredApiProvider>();
4848

49+
/**
50+
* Decide where `Context.volatileContext` goes before the provider sees it.
51+
*
52+
* Prefix-cached backends want it after the last cache breakpoint, which the
53+
* provider places. Session-cached `appendOnlyHistory` backends need the message
54+
* array untouched, so it goes into the system prompt instead.
55+
*/
56+
function resolveVolatilePlacement(model: Model<Api>, context: Context, handlesVolatileContext: boolean): Context {
57+
if (model.appendOnlyHistory) {
58+
return foldVolatileContextIntoSystemPrompt(context);
59+
}
60+
return handlesVolatileContext ? context : foldVolatileContext(context);
61+
}
62+
4963
function wrapStream<TApi extends Api, TOptions extends StreamOptions>(
5064
api: TApi,
5165
stream: StreamFunction<TApi, TOptions>,
@@ -55,7 +69,7 @@ function wrapStream<TApi extends Api, TOptions extends StreamOptions>(
5569
if (model.api !== api) {
5670
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
5771
}
58-
const resolved = handlesVolatileContext ? context : foldVolatileContext(context);
72+
const resolved = resolveVolatilePlacement(model, context, handlesVolatileContext);
5973
return stream(model as Model<TApi>, resolved, options as TOptions);
6074
};
6175
}
@@ -69,7 +83,7 @@ function wrapStreamSimple<TApi extends Api>(
6983
if (model.api !== api) {
7084
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
7185
}
72-
const resolved = handlesVolatileContext ? context : foldVolatileContext(context);
86+
const resolved = resolveVolatilePlacement(model, context, handlesVolatileContext);
7387
return streamSimple(model as Model<TApi>, resolved, options);
7488
};
7589
}

packages/ai/src/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,17 @@ export interface Model<TApi extends Api> {
462462
maxTokens: number;
463463
/** Flagship model surfaced above non-featured models of the same provider in pickers. */
464464
featured?: boolean;
465+
/**
466+
* Set when the backend keeps its own session cache keyed on the request message
467+
* array, so that array must stay byte-identical to the conversation history the
468+
* client persisted — a proxy in front of an agent SDK, for example.
469+
*
470+
* `Context.volatileContext` then goes into the system prompt instead of the end
471+
* of the message list, because a payload-only message block breaks the backend's
472+
* lineage matching and forces a full-history replay on every request. The
473+
* backend's own session cache absorbs the system-prompt change instead.
474+
*/
475+
appendOnlyHistory?: boolean;
465476
headers?: Record<string, string>;
466477
/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */
467478
compat?: TApi extends "openai-completions"

packages/ai/src/utils/volatile-context.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,21 @@ export function foldVolatileContext(context: Context): Context {
2727
const messages: Message[] = [...context.messages, trailing];
2828
return { ...context, messages, volatileContext: undefined };
2929
}
30+
31+
/**
32+
* Move volatile content to the end of the system prompt, leaving the message
33+
* list byte-identical to the caller's history.
34+
*
35+
* Used for `Model.appendOnlyHistory` backends. They cache by session rather than
36+
* by prefix, so a system-prompt change costs one re-send while a payload-only
37+
* message block would invalidate their whole history lineage.
38+
*/
39+
export function foldVolatileContextIntoSystemPrompt(context: Context): Context {
40+
const text = resolveVolatileContext(context);
41+
if (!text) {
42+
return context.volatileContext === undefined ? context : { ...context, volatileContext: undefined };
43+
}
44+
45+
const systemPrompt = context.systemPrompt ? `${context.systemPrompt}\n\n${text}` : text;
46+
return { ...context, systemPrompt, volatileContext: undefined };
47+
}

packages/ai/test/volatile-context-cache-prefix.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,35 @@ describe("Anthropic volatile context placement", () => {
263263
const payload = await captureAnthropicPayload(" \n ");
264264
expect(flattenParts(payload.messages[payload.messages.length - 1])).toHaveLength(1);
265265
});
266+
267+
it("sends volatile context in the system prompt for an appendOnlyHistory model", async () => {
268+
const model: Model<"anthropic-messages"> = {
269+
...getModel("anthropic", "claude-sonnet-4-5"),
270+
baseUrl: "http://127.0.0.1:9",
271+
appendOnlyHistory: true,
272+
};
273+
const source = baseContext("# Continual Harness State\n\nmemory: 1");
274+
let captured: CapturedPayload | undefined;
275+
// Routed through the registry, which owns the placement decision.
276+
await streamSimple(model, source, {
277+
apiKey: "fake-key",
278+
maxRetries: 0,
279+
onPayload: (payload) => {
280+
captured = payload as CapturedPayload;
281+
return payload;
282+
},
283+
}).result();
284+
285+
if (!captured) throw new Error("Expected the Anthropic payload to be captured");
286+
expect(JSON.stringify(captured.system)).toContain("Continual Harness State");
287+
288+
// No payload-only block: the message array still matches the caller's history.
289+
const parts = flattenParts(captured.messages[captured.messages.length - 1]);
290+
expect(parts).toHaveLength(1);
291+
expect(parts[0]?.text).toBe("second");
292+
expect(parts[0]?.cache_control).toEqual({ type: "ephemeral" });
293+
expect(captured.messages).toHaveLength(source.messages.length);
294+
});
266295
});
267296

268297
describe("OpenAI-completions volatile context placement", () => {
@@ -356,6 +385,36 @@ describe("volatile context handling at the provider registry", () => {
356385
expect(contexts[0].messages).toHaveLength(baseContext().messages.length);
357386
});
358387

388+
it("routes volatile context into the system prompt for appendOnlyHistory models", () => {
389+
for (const handlesVolatileContext of [true, false]) {
390+
const api = handlesVolatileContext ? "native-api" : "fallback-api";
391+
const { contexts, model } = recordingProvider(api, handlesVolatileContext);
392+
const source = baseContext("# Continual Harness State\n\nmemory: 1");
393+
394+
streamSimple({ ...model, appendOnlyHistory: true }, source);
395+
396+
expect(contexts).toHaveLength(1);
397+
const received = contexts[0];
398+
expect(received.volatileContext).toBeUndefined();
399+
// A session-cached backend matches on the message array, so it must be
400+
// byte-identical to the caller's persisted history.
401+
expect(JSON.stringify(received.messages)).toBe(JSON.stringify(source.messages));
402+
expect(received.systemPrompt).toBe(
403+
"You are a general purpose agent.\n\n# Continual Harness State\n\nmemory: 1",
404+
);
405+
unregisterApiProviders(api);
406+
}
407+
});
408+
409+
it("uses the volatile content as the system prompt when an appendOnlyHistory model has none", () => {
410+
const { contexts, model } = recordingProvider("native-api", true);
411+
412+
streamSimple({ ...model, appendOnlyHistory: true }, { messages: [], volatileContext: "memory: 1" });
413+
414+
expect(contexts[0].systemPrompt).toBe("memory: 1");
415+
expect(contexts[0].messages).toHaveLength(0);
416+
});
417+
359418
it("keeps volatile content out of the faux provider's simulated cache prefix", async () => {
360419
const faux = registerFauxProvider();
361420
try {
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
- Fixed prompt-cache churn by sending continual harness state and the current date outside the cached prompt prefix, so a harness refinement or a midnight date flip no longer re-caches a long-running session ([#26](https://github.com/pylon-code/prime-agent/issues/26)).
22
- Pinned each tool to the slot it first occupied so a tool-registry refresh serializes an unchanged tool set to identical bytes ([#26](https://github.com/pylon-code/prime-agent/issues/26)).
3+
- Added the `appendOnlyHistory` model flag so proxy-backed providers registered by extensions keep an append-only request history and receive volatile prompt content in the system prompt ([#26](https://github.com/pylon-code/prime-agent/issues/26)).

packages/coding-agent/docs/extensions.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1648,6 +1648,34 @@ pi.registerProvider("corporate-ai", {
16481648
- `oauth` - OAuth provider config for `/login` support. When provided, the provider appears in the login menu.
16491649
- `streamSimple` - Custom streaming implementation for non-standard APIs.
16501650

1651+
**Model definitions: `appendOnlyHistory`**
1652+
1653+
Set `appendOnlyHistory: true` on a model when the backend keeps its own session cache keyed on the request message array, rather than caching by request prefix. Proxies in front of an agent SDK work this way: they match the incoming history against the history they already hold and resume that session, so they only tolerate an append.
1654+
1655+
The flag controls where Prime Agent puts volatile prompt content — continual harness state and the current date. By default that content is appended after the message list so a change cannot invalidate a prefix cache. A session-cached backend reads the same append as a modified history and replays the whole conversation instead, so the flag routes the content into the system prompt and leaves the message array byte-identical to the persisted conversation.
1656+
1657+
```typescript
1658+
pi.registerProvider("my-sdk-proxy", {
1659+
baseUrl: "https://proxy.example.com",
1660+
apiKey: "PROXY_API_KEY",
1661+
api: "anthropic-messages",
1662+
models: [
1663+
{
1664+
id: "claude-opus-4-6",
1665+
name: "Claude Opus 4.6 (via SDK proxy)",
1666+
reasoning: true,
1667+
input: ["text", "image"],
1668+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
1669+
contextWindow: 200000,
1670+
maxTokens: 32000,
1671+
appendOnlyHistory: true
1672+
}
1673+
]
1674+
});
1675+
```
1676+
1677+
Leave it unset for direct provider APIs, including Anthropic and OpenAI-compatible endpoints that cache by prefix. `models.json` accepts the same field on a model definition or a `modelOverrides` entry.
1678+
16511679
See [custom-provider.md](custom-provider.md) for advanced topics: custom streaming APIs, OAuth details, model definition reference.
16521680

16531681
### pi.unregisterProvider(name)

0 commit comments

Comments
 (0)