Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ With a strategy that supports them, as this one does, the manager also offers:
- `setToolDefinitions` and `setSystemPrompt` give the summarizer's calls the agent's own tools and system prompt. A chunk whose messages hold tool calls or results waits to be compressed until tool definitions are set.
- `getRenderStats` reports how much of the last compile was raw and how much was summaries, with counts for levels 1 to 3. It's `null` until a live compile has run since the strategy loaded its branch, which opening the store, `setStrategy()` and a branch switch each do; a dry compile or preview leaves it as it was. Each compile's result carries its own as `renderStats`, so a dry compile (`dryRun`) shows the composition of the request it built without sending it.

For a model that binds each signed thinking block to the request it was minted in, pass `{ thinkingBinding: { binds: true, dropBlock, participant }, identity }` as compile's third argument, with `participant` the name the model's own replies are stored under and `identity` digests of the request's system text, tools, model and the rest of its head. The compile then leaves out the thinking such a model can no longer take, against a per-branch record of the last request the branch sent, and says what it newly left out in `thinkingStrip`. Once the branch has a record, a reply keeps its thinking at the compile after it's stored only if the host has noted it with `noteRequestSent(rows, identity, compileId)`, naming the `provenance.compileId` of the compile its stream was built from: as it sends each later round of a stream, the ids of the stored messages the request carried after the compiled ones, every round's so far, in order, and once the stream has ended, those and its last reply's, with `{ unstored: true }` for a row it can't vouch was carried or returned as stored. A host that doesn't call it loses each reply's thinking at the compile after it's stored, and is never refused for it. While one of a branch's requests is still streaming, compile for that branch with `binds` only with `dryRun`: a committing compile with `binds` makes that stream's later notes vouch for nothing (see `SelectOptions.thinkingBinding` in `src/types/strategy.ts`).

Every option is documented on `AutobiographicalConfig` in `src/types/strategy.ts`.

## Branches
Expand Down
14 changes: 14 additions & 0 deletions changelog.d/155-thinking-binding.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
- `compile()` takes `thinkingBinding` and `identity` (`SelectOptions`) for a model that binds each signed thinking block to the request it was minted in (#155). On an account that enforces the binding, such a model refuses a request carrying a block whose request has changed since, with a 400 ("Invalid `signature` in `thinking` block. The block is bound to a different conversation…"), and with the provider's drop control on it sets the block aside instead. With `thinkingBinding.binds`, a compile leaves such blocks out.
- **What it takes:** `thinkingBinding: { binds, dropBlock, participant }`, with `participant` the name the model's own replies are stored under, and `identity: { system, tools, model, request }`, digests of the request's head. With `binds`, `participant` and `identity` are required, and a compile without them, or with a digest that isn't a string, is refused before anything is selected.
- **The record:** the manager keeps one per branch, in its namespace, of what the branch last sent: each message its last committing compile with `binds` sent (injections included, cache breakpoints aside), the rows a host noted after them, the stored replies the compile sent without their thinking, the compile's head digests, and the branch's sequence where the record began. It survives a restart, and a branch made from another starts from the parent's as of its branch point.
- **What a compile keeps:** from the first message that differs from the record, or from the first message when an `identity` digest differs, every message goes without its thinking. Past the record's end nothing keeps its thinking: only the host knows which stored reply answered which request. A reply sent without its thinking stays without it while it renders. Another participant's message, and one with no stored body, such as a summary's recall answer or an injection, never keeps thinking.
- **Replies from before the record:** without `dropBlock`, a reply stored at or before the sequence where the branch's record began never keeps its thinking. So a branch's first compile without the drop control leaves out every reply's thinking; with the drop control it leaves them for the provider to judge. Either way it starts the record.
- **A dry compile** reports what it would leave out and writes no record.
- **Without `binds`,** a compile is as before: it neither reads nor writes the branch's record. A record an earlier binding compile left is compared at the next binding compile like any other, so only a prefix that matches what that compile sent and its stream noted keeps its thinking, and nothing past the record's end does.
- **A record the store can't read or write** is logged and never fails a compile or a note. One it can't read is read as none, so a committing compile with `binds` writes its own in its place; when it can't write a compile's record, the record before it, if any, stands.
- **While a request is streaming,** compile for its branch with `binds` only with `dryRun`: a committing compile with `binds` makes that stream's later notes vouch for nothing, so its replies go without their thinking.
- **A host that passes `thinkingBinding` and notes nothing** loses each reply's thinking at the compile after it's stored, once the branch has a record, and is never refused for it.
- `ContextManager.noteRequestSent(rows, identity, compileId)` (new): a host calls it, with the `provenance.compileId` of the compile its stream was built from, as it sends each later round of that stream, with every row the stream's request carried after the compiled messages so far, and once the stream has ended, with those rows and its last reply. Each row, in order, is the id of a stored message, or `{ unstored: true }` for one the host can't vouch was carried or returned as stored. The record then holds them, so the next compile keeps their thinking while nothing up to them differs. Each stored row is digested as `renderAsNewest` renders it and as the compile sends it, a bundled tool cycle as each of its parts, and an id the store no longer holds vouches for nothing, as the marker does. A request-only row the host puts back in the same place at every later request needs no row. Call it on the branch the stream's compile was on. A note sent with another head than the record's leaves the record vouching for nothing until a compile writes it again; a compile under the new head reports the head change, and one under the record's own head reports `'messages'`. It returns false, writing nothing, when `compileId` isn't the compile that wrote the branch's record, as after another committing compile with `binds` (a compile without it leaves the record, so a later note still writes), and on a branch with no record or one it can't read, or when the store can't write it.
- `CompileResult.thinkingStrip` says what a binding compile newly leaves out of the participant's replies (`newly`, the stored bodies, against the compile that wrote the branch's record, sent or not, so a reply that compile didn't send can be reported again) and why (`cause`: `'head'` with the `changed` digests, `'first'`, or `'messages'`). `CompileResult.thinkingRecordedAfter` gives, on every binding compile, the branch's sequence when its record began. A reply at or below it was stored before the record, so the provider dropping its thinking isn't a strip that missed. A body sent without its thinking reads as incomplete in provenance (`missing: ['content']`), and the rendered layout prices what was sent: thinking only where a stored reply kept it. New exports: `ThinkingBinding`, `RequestIdentity`, `ThinkingStrip`.
- `ContextStrategy.noteThinkingLeftOut(blocks)`: after a committing compile, the manager hands the strategy the stored thinking it left out. `AutobiographicalStrategy` takes their price off its calibration sample's estimate, so the sample compares the provider's count with what was sent.
- On a binding compile, `AutobiographicalStrategy` renders and prices a summary's carriers as under `carrierPolicy: 'live-strip'`, whatever the configured policy: a carrier was minted in its summarizer's request, so such a model never takes it in the live window. A select prices them by its own option, so `previewContext` takes `thinkingBinding` too, and a preview without it prices them as configured. Mint and merge requests are unchanged.
14 changes: 14 additions & 0 deletions src/canonical-json.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* JSON with each object's keys in one order whatever order they were set in
* (sorted, though integer-like keys come first, ascending, as in any object),
* and each array's items in their own order, so two values that differ only
* in the order their keys were set give the same text. An object the store
* keeps comes back with its keys sorted, while one built in code keeps the
* order it was built in.
*/
export function canonicalJson(value: unknown): string {
return JSON.stringify(value, (_key, inner: unknown) => {
if (inner === null || typeof inner !== 'object' || Array.isArray(inner)) return inner;
return Object.fromEntries(Object.keys(inner).sort().map((key) => [key, (inner as Record<string, unknown>)[key]]));
});
}
Loading
Loading