Skip to content

plan: agent working memory — session-owned notes block (A2, source #550) #938

Description

@btipling

Plan header

Field Value
Status HANDOFF-READY
Date 2026-09-04
Type single
Parent #548 (agent session architecture)
Source issue #550agent memory: durable working notes / findings across turns (identity, not one-shot)
Branch plan/working-memory-notes
Layers Vercel backend, DOM host, harness (Wasm, display-only)
Reusability impact config-only — the notes block is a session-carrier on the existing reserved-meta envelope seam (provider-neutral; clone-and-run works with no new env)
Production mutate? no — no migrate / backfill / seed / env cutover / schema change
Cloud ops path N/A — no Production mutate
Living docs docs/session-model.md, docs/feature-divide.md, docs/harness-limits.md, docs/agent-stream.md, AGENTS.md

Review notes (2026-09-04)

# Severity Axis Finding Fix applied
1 Major (would be Blocker if left un-fixed: unsafe baseline) Parent adherence The plan states A1 (#936) is "shipped/IMPLEMENTED", but PR #937 is OPEN — not merged (gh pr view 937{state:OPEN, mergedAt:null}); lib/agent/modelMessages.ts is absent from origin/main. A2 composes on the A1 seam (meta.modelMessagesPointer + parity tests) and touches the same mod-1/2 files (RESERVED_META_KEYS, WORKER_META_KEYS) that A1's PR will also change. Parent #548's A2 lock says "Ship A1 first so there is evidence to remember." Explicit precondition added to DoD + Risks: A1 must be merged to main before this plan is implemented (not merely "shipped"); the A2 branch must be cut from post-A1 main, and the A2 parity tests must run against the merged projection. Added a "Depends on" clause in Non-goals. Fully resolved in-body — no residual blocker.
2 Major Correctness The honesty bar in source #550 is agent-authored persistence. Folding the notes verbatim into the system prompt as "established fact" and "the session's identity" is exactly the surrogate-identity / self-injection vector #550 warns about ("we never silently invent a persona from truncated history"). The agent can write persistent instructions into its own system prompt with "Treat it as established fact." System-fold block reframed: notes are agent-authored working memory, "verify before relying", no "fact/identity" claims, and an explicit "never write secrets" instruction. Added a locked edge-case row "Model hijacks the fold" strengthened: the block is folded verbatim but framed as unverified agent-authored text, matching #550's authorship bar.
3 Major Performance / Cap governance The Caps table's wire accounting ("~0.7% of the wire ceiling") counts only bytes against the Function body. The real cost is inference tokens every model round: 32 KiB ≈ 8–16k tokens folded into every system prompt for the rest of the session — exactly the "novel is not memory" (#550) and cache-stable (#558) concern. The plan punts budget policy to #551/#552 (cross-phase) but still ships the fold. Caps table byte-accounting rewritten to count the per-round inference cost as the primary bound: ~8–16k tokens/round on every turn once non-empty, bounded by the 32 KiB cap, and explicitly "fold omits when empty; a non-empty block is a standing per-round token cost the agent chose". Residual-risk row added: budget policy remains #551/#552 (cross-phase), but the cap bounds the cost today.
4 Major Correctness The plan's own text says the note "lands at the NEXT turn" (overlayWorkerMeta's envelope read is per-model-round, and durable reads the envelope pre-start). So a note written mid-turn does not reach the model until the next model round or next turn. The edge-case table knows this but Goal 1's success signal ("a later turn's system prompt contains the exact note text") and the DoD don't state the v1 fold delay honestly. Goal 1 success signal + DoD now state: the fold is not hot in v1 — a note written mid-turn is folded on a later model round/turn (the plan already says "worst case a note written mid-turn is folded on the next model round/turn — acceptable and honest"); testing row 9 asserts envelope-level + resolveInStepPreambles/assemble-level equality, not a same-turn assertion.
5 Minor Correctness The tool named working_notes_* on /api/agent reads/writes the route userId/sessionId's envelope via the sessionStoreSeam — the same bound meta_sandbox_switch uses. This is correct, but the plan's "always-on … both /api/agent and durable /api/turns" needs the durable frame: the durable world assembles via assembleDurableToolWorld/buildToolWorld — where the notes tools join is one line but must be before the durable FS merge (like meta_*). Implementation order step 6 now says "assemble createWorkingNotesTools in buildToolWorld before the FS/tool-registry merge (mirroring meta_*), so both /api/agent and assembleDurableToolWorld inherit it."
6 Minor Testing Testing rows 6 (durable in-step preamble reads meta.workingNotes when persona/skills stores absent — the widened guard) and 7 (/api/agent vs durable parity) are the highest-risk seams and are not in the locked DoD minimum (1,2,3,4,5,8,9,11,12). DoD minimum expanded to include rows 6 and 7 (the guard-widening + parity are the two places a divergence or absent-fold silently breaks the feature).
7 Minor Correctness The host-side SessionSnapshot needs a symmetric carrier: folded by cloudMetaFor (absent = clear per the reserved-meta replace contract — the risk table already names the race), restored by parseCloudSessionSnapshot and overlayEnvelopeMeta (poison → unset, never 400). Without the overlay row, a reload can drop an unsynced local note. Layer-placement row for the host mirror now names all three sites: cloudMetaFor / parseCloudSessionSnapshot / overlayEnvelopeMeta (+ SessionSnapshot.workingNotes); DoD gained a checkbox for the tri-site fold.
8 Nit Correctness "the tool must not echo any serverSecrets/redaction list" — the notes tools take {userId, sessionId, sessionStoreSeam, deps} and never touch serverSecrets; there is no code path that could echo one. Nit dropped from the tool spec (no false surface). The "never write secrets" instruction is now in the system-block text (finding 2) and the working_notes_update description.

Reviewed-by: plan-review v1.5 (mode=fix). Verdict: HANDOFF-READY — the A1-merge sequencing defect (Major, would-be Blocker) is resolved in-body as a hard implementation precondition; the surrogate-identity and per-round-inference Majors are fixed in-body above. All required axes ≥ 4 (see chat report for scores).

Summary

Ship the A2 working-memory slice of parent #548. The agent gets three
always-available in-process AI-SDK tools — working_notes_get,
working_notes_update, working_notes_clear — that read/write a
session-owned notes block persisted on the session envelope as the reserved
meta.workingNotes (a bounded text scalar; carrier = the existing Redis
envelope, never a Function body, never a new table). The notes block is
folded into every model turn's system prompt (durable /api/turns and
legacy /api/agent) as a ### Working notes (across turns) block between the
persona and the skills catalog, so "what did we conclude last turn?" is answered
from session state, not from hoping the last 400 assistant paragraphs
survive the fold. New session / Clear / New wipes the block (new mind);
refresh / device-switch keeps it (session-owned, exactly like cwd /
selectedModel). No dual chat, no second agent window, no secrets.

Source #550 locked the shape: the notes must be agent-authored (the agent
chooses to persist findings; we never silently invent a persona from truncated
history), bounded (the #550 "novel is not memory" constraint), and
compaction-proof (the compact engine of A4 #552 keeps them). This plan
deliberately does not implement the durable-spec sidecar (#552's Orrery
"compact into the slot") — that is a different phase; it ships the seam +
tools + fold so A4 has a home to compact into.

Depends on: A1 (#936) merged to main before implementation. A2 composes on
the A1 meta.modelMessagesPointer seam and parity tests, and A1's PR is
currently OPEN / not merged (2026-09-04). The A2 branch must be cut from
post-A1 main; the parity tests must run against the merged projection.

Goals

# Goal Success signal
1 The agent can persist its own findings/decisions/identity across turns within a session Mid-session "what did we conclude last turn?" is answerable from meta.workingNotes (the model sees the block and answers from it), verified by an integration test where a turn writes a note via working_notes_update and a later turn's system prompt (or in-step preamble) contains the exact note text. The fold is not hot in v1: a note written mid-turn is folded on a later model round/turn — the honest delay, not a same-turn assertion
2 The notes block is agent-authored, never silently auto-extracted from CoT/history No code path writes workingNotes except the working_notes_update tool; thinking rows stay ephemeral; no CoT dump into Redis/Blob (source #550 constraint)
3 Session ownership matches cwd/selectedModel: refresh/device-switch keeps, New/Clear wipes meta.workingNotes rides the envelope; Clear/New mints a fresh session → no notes; reload/adopt restores the block and it reaches the next turn's system
4 Bounded and Function-safe Notes cap WORKING_NOTES_MAX_BYTES = 32 KiB, whole-meta budget stays 1 MiB; the block rides the system prompt (a Function body part like persona/skills), never a giant server fetch; Caps table (below) accounts it as a per-round inference cost, not just envelope bytes
5 No new infra / no ops No migration, no schema change, no new env, no new workflow. Reuses RESERVED_META_KEYS + overlayWorkerMeta + the envelope seam exactly like persona/skills/meta-sandbox tools

Non-goals / out of scope

Architectural decisions

Decision Options considered Choice Why
Carrier for the notes block A) reserved meta.workingNotes on the small Redis envelope (scalar, ≤32 KiB) / B) Blob sidecar workingNotesPointer (row/byte-capped object like modelMessagesPointer) / C) localStorage only / D) new Postgres table A — reserved meta.workingNotes Source #550 ("rides the #408 object / envelope meta or a dedicated small field") + the Orrery lock: the block must be byte-stable and cheap so #552 can compact into it and #558's block 1/2 split caches it. A 32 KiB scalar in the envelope is fetched with every turn already (resolveInStepPreambles reads the envelope; POST /api/turns reads it pre-start) — no new read, no Function body blow-up, no schema/migration. B adds a Blob read+write per turn for no benefit at 32 KiB. C is not durable across devices (source #550: "not local-only"). D is a schema/migration for 32 KiB of text. Only A composes with the wall-clock / LWW / worker-overlay invariants already built for the envelope.
Fold point into the model A) system prompt block (like persona/skills) / B) first user message / C) separate system typed message A — system prompt block Source #550: "a small session-owned notes block the fold can always include"; #548 peer map: CustomMessageEntry/CustomEntry inject extension state into LLM context. The persona/skills preambles already resolve in-step via resolveInStepPreamblesresolveSystem; notes are the same shape and same parity requirement (/api/agent + durable must resolve the same string). A typed system message (C) would fragment the A7 #558 two-block cache story and is harder to keep identical across both routes. B is wrong — the fold must be always-present, not user-typed.
Tool family A) new working_notes_* in-process tools (like find_skill/meta_*) / B) reuse MCP / C) prompt-only instruction with no tools A — three in-process first-party tools Always-on, no remote transport, bound to route userId/sessionId (confused-deputy like meta_sandbox_switch), DI-gate clean, and pick-able by the model via tool descriptions. Provides the honesty bar: the agent chooses to persist. B drags MCP lifecycle for a session-local scalar. C leaves the model no wire to write notes on → source #550's goal fails.
Who owns the write Host PUT cloudMetaFor mirror (a SessionSnapshot.workingNotes carrier) + worker overlay (overlayWorkerMeta copy-forward) for mid-turn writes Split: host mirrors, worker owns Exactly the persona/sandbox pattern. The model-facing truth must not LWW-clobber host PUTs: overlayWorkerMeta's copy-forward patch (worker-owned keys) can own workingNotes if we decide the durable in-step fold reads the envelope directly (Option: add workingNotes to WORKER_META_KEYS) — otherwise it is a host-only key written from the turn result like selectedModel. Locked below: host carrier + host PUT, plus an in-step envelope-fresh read for the durable fold (the fold already reads the envelope; no new write path needed for v1 — the note lands at the NEXT turn/round).
Sanitization Length + charset vs length only Length only + fail-closed drop-to-unset on poison Notes are freeform user content (findings, paths, decisions) — a charset would be wrong. sanitizeWorkingNotes(value): string → trim(), utf8ByteLength ≤ WORKING_NOTES_MAX_BYTES, else undefined; non-string / over-cap → poison → drop to unset at read, rejected with an error to the model at tool-write (update returns ERROR working_notes_update: notes exceed the 32 KiB cap — never a silent truncate). Empty string → unset (clear). Matches the drop-to-unset decision class of selectedModel/usage.
Durability on error/abort A) persist immediately at tool-execute (worker owns, like change_dir) / B) persist only at turn end (host folds from result) A — best-effort persist at tool-execute via a worker-owned mirror A cancelled / wall-clocked / errored turn is exactly when a finding must survive ("a confirmed successful change_dir still lands … when the turn later cancels / times out / hard-errors"). working_notes_update executes inside the durable toolExecuteStep (in-process, has getWorkflowMetadata().workflowRunId), so it can call overlayWorkerMeta({ patch: { workingNotes } }) best-effort (fail-soft on store error — the tool still returns success text but does not claim persisted state). Host mirrors the value on SessionSnapshot.workingNotes so a later host PUT keeps it (absent = clear is a risk to manage — see edge cases).

Layer placement

Concern Layer Path(s) Rationale
Notes carrier + sanitizer + caps Vercel backend (shared client-safe seam) lib/sessionCloudCaps.ts (WORKING_NOTES_MAX_BYTES, sanitizeWorkingNotes) single source for host trim + server validator + worker overlay; lib/sessions/sessionStore.ts reserved key
Reserved meta key + envelope validation Vercel backend lib/sessions/sessionStore.ts RESERVED_META_KEYS += workingNotes schema-typed reserved contract (parent #411); scalar string
Worker-owned copy-forward PATCH Vercel backend lib/agent/workerMetaOverlay.ts (WORKER_META_KEYS += workingNotes, sanitizeWorkerKeyValue case) mid-turn tool writes must never clobber host keys; LWW guarded (plan #934-style)
Tools Vercel backend (in-process AI SDK) new lib/agent/workingNotesTools.ts (createWorkingNotesTools({userId, sessionId, sessionStoreSeam, deps})working_notes_get / working_notes_update / working_notes_clear) always-on first-party family, mirrors metaSandboxTools incl. the envelope seam; no secrets returned to model
Tool-world assembly Vercel backend lib/agent/buildToolWorld.ts (createWorkingNotesTools after createMetaSandboxTools, before the FS/tool-registry merge) shared seam for /api/agent + durable; assembleDurableToolWorld inherits via buildToolWorld — the notes tools must be assembled before the durable FS merge (mirroring meta_*)
System-fold Vercel backend lib/agent/agentSystem.ts (notesPreamble param + ### Working notes (across turns) block), lib/workflows/modelGenerateStep.ts (resolveInStepPreambles reads envelope meta.workingNotes), lib/agent/runAgent.ts (notesPreamble params + fold) byte-identical parity between /api/agent and durable (same resolver, like persona/skills)
Session carrier mirror (host) DOM host lib/sessionStore.ts (workingNotes?: string on SessionSnapshot), lib/sessionRepository.ts (cloudMetaFor folds meta.workingNotes; parseCloudSessionSnapshot and overlayEnvelopeMeta restore), optional app/harness/HarnessHost.tsx (New/Clear already mints a fresh session — nothing to wipe) refresh/device-switch restore; New/Clear wipes by minting a fresh session (no deletion code needed); absent = clear per the reserved-meta replace contract
Canvas display Harness (Wasm) — display-only, deferred in this plan native/harness/src/* (future) source #550 allows a compact "notes" affordance later; v1 is host/system-fold only. No bridge/protocol change in this plan.

Current baseline (live code)

Claim Path / symbol Notes
Reserved meta keys are schema-typed scalars with a full-replace write contract lib/sessions/sessionStore.ts RESERVED_META_KEYS (17 keys today incl. modelMessagesPointer), HarnessSessionMeta (scalars only) attachedSkills is a JSON-string scalar; workingNotes will be a plain string scalar. A1 (#936) must be merged first — its PR #937 is OPEN
Mid-turn worker envelope PATCH is copy-forward + LWW, host keys never clobbered lib/agent/workerMetaOverlay.ts WORKER_META_KEYS (10 keys), overlayWorkerMeta, sanitizeWorkerKeyValue workingNotes joins this list when worker-owned
Client-safe shared caps live in one seam lib/sessionCloudCaps.ts (HARNESS_SESSION_MAX_META_BYTES = 1 MiB, PERSONA_SNAPSHOT_MAX_BYTES = 512 KiB, STATUS_SLOT_MAX_BYTES = 96, MODEL_MSG_* from A1, …) add WORKING_NOTES_MAX_BYTES + sanitizeWorkingNotes here
In-step preambles already read the session envelope lib/workflows/modelGenerateStep.ts resolveInStepPreambles (imports resolvePersonaPreamble / resolveSkillPreamble when stores exist; envelope store + sessionKeyFor) requires args.services.userPersonas/userSkills today — the fold must run even when those stores are absent, so extend the guard, don't reuse it as-is
Durable turn reads the envelope pre-start app/api/turns/route.ts (envelope read → logicalCwd/activeSandboxId bind → modelMessagesPointer seed → userMessage) notes can ride the same envelope read; the fold is in-step via the same seam as persona
/api/agent and durable share resolveSystem lib/agent/agentSystem.ts (personaPreamble + skillsPreamble blocks), lib/workflows/modelGenerateStep.ts line ~508, lib/agent/runAgent.ts lines ~129/136 notes block must be byte-identical across routes (same resolver, same order)
Always-on tool families assembled in one world lib/agent/buildToolWorld.ts (createSkillTools + createMetaPersonaSkillTools + createMetaSandboxTools, merged with MCP + builtin-HTTP) createWorkingNotesTools joins here; durable uses the same helper via assembleDurableToolWorld
Tool-execute step runs in-process with LWW-aware envelope writes lib/workflows/toolExecuteStep.ts (assemble + execute), lib/agent/metaSandboxTools.ts retryPersistActiveSandbox (read→copy-forward→upsert, bounded retry) working_notes_update mirrors this envelope-write pattern
Host folds session carriers into meta on PUT lib/sessionRepository.ts cloudMetaFor (+ parseCloudSessionSnapshot + overlayEnvelopeMeta), lib/sessionStore.ts SessionSnapshot add workingNotes mirror so a host PUT after a note write doesn't clear it (absent = clear contract)
Session-owned carriers restored on reload/adopt docs/session-model.md meta row (personaId, selectedModel, reasoningEffort, turnRunId/Status/Cursor, usage) workingNotes joins this table
Wall-clock + step caps on durable turns lib/sessionCloudCaps.ts TURN_WALL_CLOCK_MAX_MS, MAX_WORKFLOW_STEPS notes tools are trivial in-step writes — no cap interplay
Durable SSE + terminal persist lib/agent/turnPersistSeam.ts, lib/workflows/turnLoop.ts terminal persist does not read/overwrite meta.workingNotes — the worker overlay is the only note writer

Design

Carrier

  • New reserved key: meta.workingNotes — a plain UTF-8 string scalar
    (freeform), byte-capped WORKING_NOTES_MAX_BYTES = 32 KiB.
    Whole-meta budget stays 1 MiB (HARNESS_SESSION_MAX_META_BYTES); 32 KiB +
    the existing persona snapshot (512 KiB) + skills list fits with room. Envelope
    is Redis, fetched on every turn already; not a Function body carrier in
    its own right (cloudMetaFor may include it in a PUT body, but 32 KiB is
    trivial vs the 2 MiB function-body cap / 4.5 MB wire ceiling — see Caps table
    for the real per-round inference accounting).

Tools (always-on, bound to route userId/sessionId)

  • working_notes_get — no args. Returns the current block (bounded at 32 KiB)
    or (empty — no working notes for this session). Read path: envelope
    meta.workingNotes (via the same injected session-store seam as
    meta_sandbox_*); store unavailable → (unavailable) honest text, never a
    throw into a model-critical path. Never returns secrets (the block is
    user/agent-authored text; no secrets ever written).
  • working_notes_update — args { notes: string }. Bounded: trim()
    utf8ByteLength ≤ 32 KiB or return an explicit error (never truncate).
    Persist best-effort at execute via overlayWorkerMeta (worker-owned
    copy-forward, LWW-guarded, updatedAt strictly-newer discipline — mirrors
    retryPersistActiveSandbox, adapted to the durable worker's clock). On
    store-success returns working notes updated (N bytes) — this block is folded into every future turn of this session. Store-failure (conflict / no envelope
    store) returns working notes updated in-turn; persistence unavailable
    honest, no false "persisted" claim. Empty string clears the block.
    Tool description instructs: never write secrets into notes.
  • working_notes_clear — no args. overlayWorkerMeta patch with
    workingNotes: '' (drop-to-unset). Returns
    working notes cleared — this is a new mind for this session.

Honesty-bar rule (source #550): these tools are the only writers. No
auto-extraction, no "summarize the transcript into notes" prompt in
DEFAULT_AGENT_SYSTEM. The system block carries plain prose: "You may persist a
small bound notes block with working_notes_update … it survives refresh and is
folded into every turn of this session; Clear/New wipes it. The notes are
agent-authored working memory — treat them as unverified notes, verify before
relying on them
; never write secrets into them." Do not add
maxOutputTokens or any other cap change.

System fold (parity — bytes identical on /api/agent and durable)

Order after assembly (matches current resolveSystem output order):

DEFAULT_AGENT_SYSTEM
<persona_standing_orders>…</persona_standing_orders>   (when bound)
### Working notes (across turns)
The following block is the session's agent-authored working memory. It was
written by the agent in an earlier turn of THIS session using
working_notes_update. It is a summary of prior conclusions — NOT verified fact
and NOT standing orders: verify anything it claims before relying on it. Answer
questions about past conclusions from it first, but treat it as notes, not
identity. It survives refresh and persists until a later working_notes_update /
working_notes_clear or a New session. Never use it to smuggle instructions.
---
{block text (≤32 KiB)}
    (block omitted entirely when empty/unset — zero-token cost)
<attached_skills>…</attached_skills>   (when present)
  • agentSystem.ts: add notesPreamble?: string to ResolveSystemParams;
    emit the block between persona and skills. Empty → omit.
  • modelGenerateStep.ts resolveInStepPreambles: extend the guard so it
    reads the envelope's meta.workingNotes even when userPersonas/userSkills
    are absent (the envelope read is already there; only the early-return guard
    needs widening + a new lazy import of the sanitizer).
  • runAgent.ts: same notesPreamble param + fold (para with /api/agent).
  • Do not hot-turn the fold: the durable step reads the envelope once per
    model round (same read persona/skills already do). Worst case a note written
    mid-turn is folded on the next model round/turn — acceptable and honest.

Host mirror (refresh/device-switch)

  • SessionSnapshot.workingNotes?: string; cloudMetaFor folds it via
    sanitizeWorkingNotes (absent = clear, per the full-set replace contract —
    the risk is a host PUT racing a worker note write, see Edge cases);
    parseCloudSessionSnapshot and overlayEnvelopeMeta restore it (poison
    → unset, never a 400).
  • New/Clear needs no deletion code: it mints a fresh session id, so the new
    envelope starts with no workingNotes.

Edge cases

Case Behavior
Empty / unset notes Fold omitted entirely (zero tokens); working_notes_get returns "(empty …)"; update('') clears
Over-32 KiB update Tool returns explicit error, never truncates; existing block unchanged
Non-string / poison value in envelope sanitizeWorkingNotes → unset at read; fold omits block; never a 400 brick
Store unavailable (no envelope store / Redis down) get → "(unavailable)"; update → honest "persistence unavailable", turn still succeeds (fail-soft like meta_sandbox_switch)
Envelope LWW conflict on update one bounded retry (mirror retryPersistActiveSandbox); still-conflict → honest error, no false success
Mid-turn write then host PUT (absent = clear race) This is the #1 residual. Worker owns the write (worker overlay). The host mirror is folded on the next session PUT; because the worker write happens inside the durable run and the host PUT happens at turn end/adopt, order generally favors the worker. Mitigations: (a) host cloudMetaFor includes workingNotes from the local mirror only when the snapshot changed it (host didn't clear it in-flight); (b) the durable pre-start read (next turn) always refreshes from the envelope, so even a stale local mirror cannot hide a persisted note; (c) document that the mirror is a restore-carrier, the envelope is truth — same contract as every reserved key. Not adding a worker→result event for v1 (see Open questions → test-driven).
Turn cancels / times out / wall-clocked after update persisted Note survives — worker overlay wrote it already (same commitment as change_dir); next turn's pre-start envelope read sees it.
Clear/New mid-session Fresh session id → no notes. Old session's notes remain on the old envelope (invisible; deleted with the session) — same as cwd/selectedModel.
/api/agent legacy path Same tools + fold via buildToolWorld + runAgent.notesPreamble — parity is locked (same resolveSystem).
Model hijacks the fold The block is folded verbatim from the envelope (server-side, sanitized); the wrapper explicitly frames it as unverified agent-authored notes, not standing orders (fixes the surrogate-identity / self-injection vector — Review notes #2). A model cannot inject into the system prompt by writing text that looks like XML — it is a plain fenced block, and the persona/skills wrappers are already plain text.
Mobile / ~390px / no canvas notes UI v1 has no canvas notes UI — nothing to break. The block rides the system prompt only.
Wasm load failure No bridge/protocol change → no new load-failure surface.

Performance notes

  • 32 KiB notes = ~8–16k tokens worst case per model round while non-empty
    the real cost is the standing per-round inference tax, bounded by the cap and
    omitted entirely when empty (zero tokens). It sits after the persona (standing
    orders) and before skills (catalog) — a stable position that inference: cache-stable two-block system prompt (don’t bust the KV prefix) #558's block 1/2
    split can cache (byte-stable when unchanged — the Orrery lock).
  • One extra envelope read per model round in the durable step (already happens
    for persona/skills; widening the guard adds the notes read to the SAME read).
  • Tool writes are one small Redis write; no workflow-step cost beyond the
    existing tool-execute step.

Cloud ops path

N/A — no Production mutate. No migration, no backfill, no seed, no env,
no schema change, no new workflow. The only "ops" is a deploy (Git link), and
the existing db-migrate/build-harness/smoke surfaces are untouched.

Living docs plan

Surface Change Notes
docs/session-model.md add workingNotes to the meta reserved list (row in the payload table: scalar string ≤ WORKING_NOTES_MAX_BYTES = 32 KiB, session-owned, restore on adopt, Clear/New mints fresh) timeless; no phase/issue theater
docs/feature-divide.md note the notes block in the system-fold paragraph (persona → notes → skills order; host/system-fold only; canvas affordance deferred; no dual chat)
docs/harness-limits.md add WORKING_NOTES_MAX_BYTES = 32 KiB to the caps table (Cloud record caps row / new row)
docs/agent-stream.md one line: durable turns fold meta.workingNotes into the system via the in-step preamble resolver
AGENTS.md infra/consumer row: reserved meta.workingNotes seam + working_notes_* tools (owned by lib/agent/workingNotesTools.ts + agentSystem.ts fold)
README.md no change (no visitor-facing behavior)
SECURITY.md no change — the block is non-secret user/agent-authored text (like persona/skills bodies); the envelope already holds no secrets; explicitly never write secrets to notes (tool description + system block say so)
.env.example no change — no new env

Implementation order

  1. lib/sessionCloudCaps.ts: add WORKING_NOTES_MAX_BYTES = 32 KiB +
    sanitizeWorkingNotes + unit tests.
  2. lib/sessions/sessionStore.ts: add workingNotes to RESERVED_META_KEYS;
    validator/type updates (scalar string); store tests.
  3. lib/agent/workerMetaOverlay.ts: add workingNotes to WORKER_META_KEYS +
    the sanitizeWorkerKeyValue case; overlay tests.
  4. New lib/agent/workingNotesTools.ts: createWorkingNotesTools +
    working_notes_get/update/clear (envelope seam, bounded, honest errors) +
    unit tests (inject a fake envelope store; assert no secrets, no I/O
    construction, no truncation).
  5. lib/agent/agentSystem.ts: notesPreamble param + fold block (between
    persona and skills, framed as unverified agent-authored working memory —
    Review notes 2.2 Install and register GitHub Actions self-hosted runner #2) + agentSystem.test.ts.
  6. lib/workflows/modelGenerateStep.ts resolveInStepPreambles: widen the
    guard + read meta.workingNotes; lib/agent/runAgent.ts: threads
    notesPreamble + fold; lib/agent/buildToolWorld.ts: assemble
    createWorkingNotesTools before the FS/tool-registry merge (mirroring
    meta_*); assembleDurableToolWorld consumers inherit via the same helper.
    Durable + /api/agent parity tests (same resolveSystem output for same
    inputs).
  7. Host mirror: lib/sessionStore.ts SessionSnapshot.workingNotes,
    lib/sessionRepository.ts cloudMetaFor + parseCloudSessionSnapshot +
    overlayEnvelopeMeta (+ tests: absent = clear, poison → unset, restore).
  8. Turn-seam integration test (int project): durable turn A writes a note →
    the next durable turn's system prompt (or in-step preamble) contains the
    exact note text; Clear mints fresh → block gone. Where a model round cannot
    be driven in CI, assert at the resolveInStepPreambles/assemble level and
    at the envelope after a simulated update tool-execute.
  9. Docs: docs/session-model.md, docs/feature-divide.md,
    docs/harness-limits.md, docs/agent-stream.md, AGENTS.md.
  10. Build gates in agent workspace / CI: npm test (includes test:di-gate +
    vitest run --project default --project tenancy), npm run typecheck,
    npm run build. No Wasm change → no build-harness need.

Testing

# Case Layer Type Command / method
1 sanitizeWorkingNotes bounds / trims / poison lib unit vitest run lib/sessionCloudCaps.test.ts (or new test file)
2 RESERVED_META_KEYS accepts workingNotes; non-scalar rejected lib unit lib/sessions/sessionStore.test.ts
3 Worker overlay copy-forward patches workingNotes, never clears siblings/host keys; LWW conflict lib unit lib/agent/workerMetaOverlay.test.ts
4 working_notes_get/update/clear happy path + over-cap reject + store-down honesty + no truncation lib unit new lib/agent/workingNotesTools.test.ts (fake envelope store; DI-gate clean)
5 resolveSystem folds notes between persona and skills; empty omits; order locked lib unit lib/agent/agentSystem.test.ts
6 Durable in-step preamble reads meta.workingNotes when persona/skills stores absent (widened guard) workflows unit lib/workflows/modelGenerateStep.test.ts / turnLoop.test.ts seamed
7 /api/agent vs durable parity: same inputs → same system string int unit parity test at resolveSystem level (already the byte-identical contract)
8 Host cloudMetaFor folds workingNotes (absent = clear); parseCloudSessionSnapshot/overlayEnvelopeMeta restore; poison → unset lib unit lib/sessionRepository.test.ts
9 Turn A writes note → turn B fold contains exact text (envelope-level, no live model) int integration npm run test:int (no live Gateway call; assert preamble + envelope)
10 New/Clear mints fresh session → no workingNotes DOM/lib unit lib/sessionRepository.test.ts (fresh mint path)
11 Existing npm test suite stays green (di-gate passes — no I/O construction in tool body) repo gate npm test (agent workspace or CI)
12 npm run typecheck + npm run build repo gate agent workspace or CI

Minimum locked (DoD): 1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12.
(6 and 7 are the highest-risk seams — the widened guard and the byte-identical
route parity — so they are in the locked minimum, not optional.)

Definition of done

Caps table

Cap / ceiling Value Rationale Code location
NEW WORKING_NOTES_MAX_BYTES 32 KiB bounded "small session-owned notes block" per source #550 (a novel is not memory). Generous for a findings/decisions block (~8–16k tokens worst case — below typical model context windows) while staying far under the 1 MiB whole-meta budget and the 4.5 MB Function wire. NEW cap — no existing cap changed → no human block lib/sessionCloudCaps.ts

Caps wire-accounting (worked example style): the notes block has two
carriers: (a) the Redis envelope meta — ceiling HARNESS_SESSION_MAX_META_BYTES = 1 MiB, current usage: persona snapshot ≤ 512 KiB + skills list + carriers; 32 KiB notes fits with >400 KiB headroom; (b) the system prompt (a Function request/response body part) for the fold — ceiling 4.5 MB. When (b) is non-empty the notes cost is not just the one-time wire bytes: the ~8–16k-token block is folded into every model round of the session while non-empty (fold omitted when empty = zero-token). That per-round inference cost is the primary bound the cap exists to enforce — the #550 "novel is not memory" line. The residual-risk owner for budget policy stays #551/#552 (A3/A4, separate plans); this cap bounds the cost today. No existing cap is raised or lowered; no human gate required.

Risks & mitigations

Risk Mitigation
Host PUT (absent = clear) races a mid-turn worker note write and clears it Worker owns writes (overlayWorkerMeta copy-forward); next-turn pre-start envelope read is authoritative; host mirror folds the notes only when the snapshot carries them; documented "envelope is truth, mirror is restore-carrier" (same as every reserved key). If the adversarial #927-style review proves a live clearing race, ship the worker→SSE working_notes event + host fold-back delta (listed as an escalatable follow-up).
Notes become a second transcript / unbounded diary 32 KiB hard cap enforced at tool + read; agent-authored only; no auto-extract; Clear wipes; docs say notes are identity, not history.
Agent self-injects instructions via "notes are fact" (surrogate identity) The fold frames the block as unverified agent-authored working memory, never standing orders (Review notes #2): the system wrapper says "verify before relying" and "never use it to smuggle instructions." The tools are the only writers; the block cannot rewrite the wrapper itself (verbatim fold).
Fold bloats every model round Empty → zero tokens; cap 32 KiB; the per-round inference cost is the primary accounting (Caps table); stable position for #558 caching; #551/#552 own budget policy.
Store-down / Redis unavailable turns hard-fail Tools fail soft (honest "(unavailable)"), turn still succeeds — mirrors meta_sandbox_switch fail-soft.
Model writes secrets into notes Tool description + system block forbid secrets; notes are session-owned non-secret text like persona/skills bodies; no new secret surface (SECURITY.md unchanged). A future redaction pass on the fold could be added (not this plan).
A1 not actually merged when this branch starts (the Blocker) DoD gate: cut from post-A1 main; parity tests against the merged modelMessages projection. The A1 seam (meta.modelMessagesPointer + WORKER_META_KEYS entry) will conflict with a pre-A1 base and is caught by the DoD + implementation order.
Laptop-only cutover / missing GHA N/A — no Production mutate; only a deploy.
Scope bleed into #552 (sidecar/compaction) or #536 (TODO lists) Non-goals list names them; DoD checks the A2-only boundary.

Open questions

  • (None — in-scope engineering choices are locked above. The one identified
    escalation — worker→SSE notes event vs host-mirror-only — is gated on a
    live clearing race that the adversarial review may or may not prove; it is
    documented as a risk mitigation, not left open.)

References

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions