Skip to content
Merged
2 changes: 1 addition & 1 deletion docs/dispatch_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ Admission is checked *before* a delivery rather than after, so the admitted acti

Both limits count each delivery once. An `activity_dispatched` size is the whole `get_activity` response, so the techniques and resources it bundled eagerly are already inside it and their own observability events are not added again; what counts on top is only what the worker went back for lazily. Counting the bundled entries twice inflated one activity of the main workflow by 48% and a run of three by 70%, which made a nominal 280,000-character budget bind at 164,540.

`get_activity` reports where a context stands in `_meta.batch` (`activities`, `max_activities`, `delivered_chars`, `budget_chars`, `may_continue`), so the ordinary end of a batch is the worker stopping. Asking past the bound is refused with the payload undelivered and a `batch_refused` history event naming the limit — recorded once per scope, activity and limit, so the tally counts how often a limit bound rather than how often a worker retried. That tally is what the starting settings are revised from.
`get_activity` reports where a context stands (`activities`, `max_activities`, `delivered_chars`, `budget_chars`, `may_continue`) in two places: a `batch:` block leading the response text, and `_meta.batch`. The text is the surface a worker is certain to read — the same reason `artifact_prefix` rides there — and the standing is what makes the ordinary end of a batch the worker stopping. The counts describe where the context stands once this delivery lands, so the activity being delivered is already counted among the ones it has taken. Asking past the bound is refused with the payload undelivered and a `batch_refused` history event naming the limit — recorded once per scope, activity and limit, so the tally counts how often a limit bound rather than how often a worker retried. That tally is what the starting settings are revised from.

`may_continue` is answered as of that delivery, and the worker then fetches techniques and resources lazily while it runs the activity, drawing down the same budget. So a batch reported as having room can still be refused at the next boundary — the delivered and budget counts on the same response are what a reader compares to see how close it was. The refusal is an expected outcome rather than an error, and the orchestrator handles it by releasing the identity and dispatching a replacement — which must carry a **new** `agent_id`, since the bound is keyed on the identity and a fresh context under a used one would receive markers for content it does not hold.

Expand Down
18 changes: 14 additions & 4 deletions docs/resource_resolution_model.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ The server resolves the reference:

An optional `#section` anchor (a GitHub-style heading slug) narrows the result to that section and its body — used to fetch just the template a technique references without the whole file. The content is loaded from `workflows/{workflow}/resources/{slug}.md` and returned alongside the resource `id` and `version`.

Under `context_mode: "persistent"`, a byte-identical refetch of the same exact `resource_id` (including any `#section`) returns a short `{ delivery: "unchanged", content_hash }` marker instead of the body — the same reference-delivery contract as `get_technique` (see [Reference Delivery](#11-reference-delivery)). Bare and sectioned ids are independent ledger keys. Pass `full: true` to force the full body when the calling context no longer holds the earlier delivery. Fresh/default sessions always receive the full resource body. Each call still appends a `resource_fetched` history event (observability only), including when the answer is an unchanged marker.
A byte-identical refetch of the same exact `resource_id` (including any `#section`) to a context that already holds it returns a short `{ delivery: "unchanged", content_hash }` marker instead of the body — the same reference-delivery contract as `get_technique`, on the grounds set out in [Reference Delivery](#11-reference-delivery). Bare and sectioned ids are independent ledger keys. Pass `full: true` to force the full body when the calling context no longer holds the earlier delivery. Each call still appends a `resource_fetched` history event (observability only), including when the answer is an unchanged marker.

### Benefits

Expand All @@ -150,9 +150,17 @@ Under `context_mode: "persistent"`, a byte-identical refetch of the same exact `

## 11. Reference Delivery

By default the server sends every payload in full, every time. A freshly spawned worker starts with an empty context, so that repetition is what gives it the content at all.
The server sends a payload in full to a context that does not hold it. A freshly spawned worker starts with an empty context, so that first delivery is what gives it the content at all.

An agent that already holds a payload can ask for **reference delivery** instead. The server replaces that payload with a short marker — `{ delivery: "unchanged", content_hash }` — and the agent reuses what it has.
Where a context already holds the bytes, the server sends a short marker instead — `{ delivery: "unchanged", content_hash }` — and the agent reuses what it has. Three things establish that it holds them, and each governs a different call:

| Ground | What collapses |
|---|---|
| **Reference delivery** — `context_mode: "persistent"`, or `bundle: "reference"` on one call | anything this scope's ledger records |
| **The same response** — an earlier `step_techniques` entry of this `get_activity` carries the block in full | the shared contract and rules blocks a response repeats, on a full delivery too |
| **A named context asking again** — this `agent_id`'s ledger records the payload | a repeat `get_technique` or `get_resource`, whatever mode the call declares |

The second and third need no opt-in, because in both cases the bytes demonstrably reached the asking context: in one they are above the marker in the same payload, in the other the ledger says this identity received them. The third requires a caller that names its context — with `agent_id` omitted the scope falls back to the session's own identity, which sibling workers share, so nothing collapses there.

### What counts as "already holds"

Expand Down Expand Up @@ -189,14 +197,16 @@ The orchestrator mints an `agent_id` per dispatch and reuses it verbatim for as
- **`get_resource`** — a byte-identical refetch of the same `resource_id` returns `delivery: unchanged` and a `content_hash` instead of the body. The key is the caller's exact `resource_id`, anchor included, so `pr-description` and `pr-description#templates` occupy independent slots.
- **`get_workflow`** — under `context_mode: "persistent"` the orchestrator ops bundle (everything above the `---` separator) is keyed under `workflow_bundle:<hash>`. On a resume where the agent already holds it, the whole bundle collapses to a single marker, while the workflow summary below the separator stays full.

`get_technique` and `get_resource` collapse under either `bundle: "reference"` or a session-wide `context_mode: "persistent"`. Fresh and default sessions always receive full bodies.
`get_technique` and `get_resource` collapse a repeat to any caller that named its context, and under `bundle: "reference"` or a session-wide `context_mode: "persistent"` besides. A caller that named no context receives full bodies however often it asks, and `full: true` overrides every ground.

### Blocks inside a technique

Collapsing can go finer than a whole technique. Techniques sharing a workflow contract share blocks: the contract-inherited `inherited_inputs` and `inherited_outputs`, and the merged `rules`. Each is hashed on its own, under `technique:<block>:<hash>`.

So when a technique is new to the context but one of its shared blocks already arrived with a sibling technique, that block becomes a marker in place while the technique-specific core arrives in full. This happens both on the `get_technique` full-delivery path and inside each eagerly inlined `get_activity` `step_techniques` entry.

Inside one `get_activity` response this holds on a full delivery too, and it is where most of the repetition is: every composed technique of a delivery carries the contract and rules blocks it inherits from its container, so a response bundling five of them would carry five copies. The first copy ships in full and the rest are markers, whose bytes are above them in the same payload — readable by a context holding no prior delivery at all. Measured over the batch benchmark's three-activity run, a fresh worker per activity receives 213,476 characters where five copies apiece cost 225,617. Where any block collapses this way the response carries a `bundle_note` pointing the reader at the earliest entry showing it.

Hashing the content is what keeps this from going stale: a block annotated with binding-seam provenance hashes differently, so it correctly arrives in full.

### Forcing full delivery
Expand Down
43 changes: 39 additions & 4 deletions src/tools/resource-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,39 @@ function withSessionStoreErrors<T extends Record<string, unknown>, R>(
};
}

/**
* Whether a fetch is a context asking again for content it already holds (#404 W9).
*
* A ledger entry says a scope received a payload in full, so a second ask for the same bytes is
* answered with a marker whatever delivery mode the call declares — the content is in the asking
* context already, and re-sending it buys nothing.
*
* Three conditions make that sound.
*
* `full: true` always overrides, which is the escape hatch for a context that summarized the content
* away.
*
* The caller must NAME its context, because with `agent_id` omitted the scope falls back to the
* session's own identity and a marker could reach a context that never received the bytes.
*
* And the name must not BE the session's own identity, which is the one name known to be shared by
* construction: `dispatch_child` defaults it to `"worker"`, so two sibling workers can each pass it
* without either having received what the other did. That scope keeps its earlier behaviour — it
* collapses only where the caller asked for reference delivery, which is a claim about one context
* rather than an inference from a name. A solo walk, which legitimately owns that identity, declares
* `context_mode: "persistent"` and so collapses on that ground instead.
*
* A distinct name shared by two contexts anyway defeats this, as it defeats reference delivery today;
* minting one identity per dispatch is what the corpus requires, and `full: true` recovers.
*/
function isRepeatToNamedContext(
agentId: string | undefined,
full: boolean | undefined,
sessionAgentId: string,
): boolean {
return full !== true && agentId !== undefined && agentId !== sessionAgentId;
}

export function registerResourceTools(server: McpServer, config: ServerConfig): void {
const traceOpts = config.traceStore ? { traceStore: config.traceStore } : undefined;
// Process-level engineering root (may be install multi-root). Per-session
Expand Down Expand Up @@ -602,7 +635,7 @@ export function registerResourceTools(server: McpServer, config: ServerConfig):
server.tool(
'get_technique',
'Load one fully composed technique (step-bound when `step_id` is set; otherwise the activity\'s or workflow\'s first). ' +
'Under `context_mode: "persistent"` or `bundle: "reference"`, a byte-identical refetch to the SAME `agent_id` scope may return an unchanged-reference; pass `full: true` when earlier content was summarized away. ' +
'A byte-identical refetch to a named `agent_id` scope returns an unchanged-reference — that context already holds the bytes — as does any refetch under `context_mode: "persistent"` or `bundle: "reference"`; pass `full: true` when earlier content was summarized away. ' +
'A fresh worker context must not ask for reference delivery — it holds no prior delivery to reference.',
{
...sessionIndexParam,
Expand Down Expand Up @@ -762,7 +795,8 @@ export function registerResourceTools(server: McpServer, config: ServerConfig):
&& (bundle ?? (state.contextMode === 'persistent' ? 'reference' : 'full')) === 'reference';
const ledgerKey = `technique:${techniqueId}`;
const hash = contentHash(text);
if (referenceMode && deliveredHash(state, ledgerKey, scope) === hash) {
if ((referenceMode || isRepeatToNamedContext(agent_id, full, state.agentId))
&& deliveredHash(state, ledgerKey, scope) === hash) {
const next = advanceSession(state, (draft) => {
draft.currentTechnique = techniqueId as string;
recordFirstArrival(draft);
Expand Down Expand Up @@ -825,7 +859,7 @@ export function registerResourceTools(server: McpServer, config: ServerConfig):
server.tool(
'get_resource',
'Load a resource by id (optional `#section`). Bare slug = session workflow; `workflow/slug` = cross-workflow. ' +
'Under `context_mode: "persistent"` or `bundle: "reference"`, a byte-identical refetch to the SAME `agent_id` scope may return an unchanged-reference; pass `full: true` when content was summarized away. ' +
'A byte-identical refetch to a named `agent_id` scope returns an unchanged-reference — that context already holds the bytes — as does any refetch under `context_mode: "persistent"` or `bundle: "reference"`; pass `full: true` when content was summarized away. ' +
'A freshly spawned worker must not ask for reference delivery — it holds no prior delivery to reference.',
{
...sessionIndexParam,
Expand Down Expand Up @@ -899,7 +933,8 @@ export function registerResourceTools(server: McpServer, config: ServerConfig):
const hash = contentHash(fullText);
const referenceMode = full !== true
&& (bundle ?? (state.contextMode === 'persistent' ? 'reference' : 'full')) === 'reference';
if (referenceMode && deliveredHash(state, ledgerKey, scope) === hash) {
if ((referenceMode || isRepeatToNamedContext(agent_id, full, state.agentId))
&& deliveredHash(state, ledgerKey, scope) === hash) {
const next = advanceSession(state, (draft) => {
recordFirstArrival(draft);
recordFetch(draft, 'unchanged', fullText.length);
Expand Down
Loading
Loading