From b0991fe97a04fd799cf8ba67bd5896706e485e57 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Sun, 30 Aug 2026 09:15:31 +1000 Subject: [PATCH 01/69] =?UTF-8?q?feat(sdk):=20multimodal=20ingress=20?= =?UTF-8?q?=E2=80=94=20prefillMultimodal,=20multimodal=20delta=20builder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK counterpart of lloyal.node's embedding rail (feat/mtmd there). Composition mirrors the text path exactly; the one difference is where tokenization lives — mtmd owns it, so the multimodal delta stops at the string stage. - ContextOptions: mmprojPath (fail-loud load), imageMinTokens / imageMaxTokens (per-image budget — the video-frames lever). - SessionContext: supportsVision()/supportsAudio() probes; _storePrefillMultimodal → per-branch MultimodalPrefillResult {tokensDecoded, positionAdvance} (JS can't know multimodal token counts; traces + pressure math need them). - deltas: MEDIA_MARKER ('<__media__>' — the one place the constant lives) + buildUserDeltaMultimodal(ctx, content, images, opts) → {sep, prompt, bitmaps}. Emits structured media_marker content parts (the chat layer's native part type — never spliced into a string); same formatChatSync options as buildUserDelta. - Branch.prefillMultimodal(prompt, bitmaps, sepTokens?) — cohort-of-1 through the store path, like prefill(). - Session.prefillUserMultimodal — trunk turn with images; observer tokenCount from the native counts. Images land as a shared prefix: spine and agents forked from the trunk attend them with zero re-encode. - README: multimodal section. --- packages/sdk/README.md | 24 +++++++++++- packages/sdk/src/Branch.ts | 31 ++++++++++++++- packages/sdk/src/Session.ts | 26 ++++++++++++- packages/sdk/src/deltas.ts | 71 ++++++++++++++++++++++++++++++++++ packages/sdk/src/index.ts | 5 ++- packages/sdk/src/types.ts | 76 +++++++++++++++++++++++++++++++++++++ 6 files changed, 227 insertions(+), 6 deletions(-) diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 0f389d8f..44add49d 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -136,6 +136,26 @@ session.trunk; // the live branch `commitTurn` is the recommended high-level helper. Future queries fork from `session.trunk` and read prior conversation through KV attention — no prompt-history injection. +## Multimodal (Vision) + +With a context created with `mmprojPath`, images prefill into any branch's KV beside text. One `<__media__>` marker per image; the native layer tokenizes the prompt and decodes text on the token rail, image rows on the embedding rail. + +```typescript +import { buildUserDeltaMultimodal } from '@lloyal-labs/sdk'; + +const ctx = await createContext({ modelPath, mmprojPath, nSeqMax: 8 }); +ctx.supportsVision(); // true + +// Trunk turn with an image — one call +await session.prefillUserMultimodal('What is in this image?', [imageBytes]); + +// Or branch-level, via the delta builder +const { sep, prompt, bitmaps } = buildUserDeltaMultimodal(ctx, 'Describe:', [imageBytes]); +const { tokensDecoded } = await branch.prefillMultimodal(prompt, bitmaps, sep); +``` + +The image lands as an ordinary shared prefix: fork afterwards and every child attends it with zero re-encode. Several markers with several images in one prefill also works — video frames, each preceded by a timestamp, are just that. + ## Rerank Backend-agnostic reranker. The caller provides a `SessionContext` — how it was created (local, remote, quantized) is not the SDK's concern. @@ -153,8 +173,8 @@ const scores = await reranker.rank(query, documents); // Classes export { Branch, BranchStore, Session, Rerank }; -// Delta builders (for tool result injection) -export { buildUserDelta, buildToolResultDelta }; +// Delta builders (for tool result injection + multimodal turns) +export { buildUserDelta, buildUserDeltaMultimodal, buildToolResultDelta, MEDIA_MARKER }; // Types export type { SessionContext, SamplingParams, Produced, ContextOptions, ... }; diff --git a/packages/sdk/src/Branch.ts b/packages/sdk/src/Branch.ts index d0c2d521..bd0d532d 100644 --- a/packages/sdk/src/Branch.ts +++ b/packages/sdk/src/Branch.ts @@ -1,4 +1,4 @@ -import type { SessionContext, SamplingParams, Produced, GrammarTrigger } from './types'; +import type { SessionContext, SamplingParams, Produced, GrammarTrigger, MultimodalPrefillResult } from './types'; import { GrammarTriggerType } from './types'; /** @@ -206,6 +206,35 @@ export class Branch { await this._ctx._storePrefill([this._handle], [tokens]); } + /** + * Prefill a templated prompt with images into this branch's KV + * + * The multimodal counterpart of {@link prefill}. The prompt carries one + * `<__media__>` marker per image (build it with + * `buildUserDeltaMultimodal`); the native layer tokenizes it, decodes + * text on the token rail and image rows on the embedding rail, in order. + * Requires a context created with `mmprojPath`. + * + * The image lands as an ordinary shared prefix: fork afterwards and every + * child attends it with zero re-encode. + * + * @param prompt - Templated prompt containing the media markers + * @param bitmaps - Encoded image bytes (jpg/png/bmp/gif), one per marker + * @param sepTokens - Optional leading token run (e.g. a turn separator) + * @returns Counts — see `MultimodalPrefillResult` (JS can't know + * multimodal token counts; the native walk reports them) + */ + async prefillMultimodal( + prompt: string, + bitmaps: Uint8Array[], + sepTokens: number[] = [], + ): Promise { + this._ensureNotDisposed(); + const [result] = await this._ctx._storePrefillMultimodal( + [this._handle], [sepTokens], [prompt], [bitmaps]); + return result; + } + /** * Sample next token from branch's logits snapshot * diff --git a/packages/sdk/src/Session.ts b/packages/sdk/src/Session.ts index 902b6575..9e4176c6 100644 --- a/packages/sdk/src/Session.ts +++ b/packages/sdk/src/Session.ts @@ -1,7 +1,7 @@ import { Branch } from './Branch'; import type { BranchStore } from './BranchStore'; import type { SessionContext } from './types'; -import { buildUserDelta, buildAssistantDelta, buildToolResultDelta, buildTurnDelta } from './deltas'; +import { buildUserDelta, buildUserDeltaMultimodal, buildAssistantDelta, buildToolResultDelta, buildTurnDelta } from './deltas'; /** * Observer invoked after each trunk conversation prefill lands. @@ -107,6 +107,30 @@ export class Session { this._onPrefill?.({ role: 'user', content, tokenCount: tokens.length, branchHandle: this._trunk!.handle }); } + /** + * Prefill a user turn with images into trunk + * + * The multimodal counterpart of {@link prefillUser}: same composition, + * with one media marker per image in the user content. The images land + * as a shared prefix on the trunk — a spine forked from it (and every + * agent forked from the spine) attends them with zero re-encode. + * + * Requires a context created with `mmprojPath`. + * + * @param content - User message text + * @param images - Encoded image bytes (jpg/png/bmp/gif) + * @param opts - Optional tools JSON string + */ + async prefillUserMultimodal( + content: string, + images: Uint8Array[], + opts: { tools?: string } = {}, + ): Promise { + const { sep, prompt, bitmaps } = buildUserDeltaMultimodal(this._ctx, content, images, opts); + const { tokensDecoded } = await this._trunk!.prefillMultimodal(prompt, bitmaps, sep); + this._onPrefill?.({ role: 'user', content, tokenCount: tokensDecoded, branchHandle: this._trunk!.handle }); + } + /** * Prefill an assistant turn into trunk * diff --git a/packages/sdk/src/deltas.ts b/packages/sdk/src/deltas.ts index 6a55e56c..f8eed178 100644 --- a/packages/sdk/src/deltas.ts +++ b/packages/sdk/src/deltas.ts @@ -1,5 +1,36 @@ import type { SessionContext } from './types'; +/** + * The media marker — one per image in a prompt + * + * mtmd's literal placeholder: the native tokenizer splits the templated + * prompt on this marker and replaces each occurrence with that image's + * encoded rows. Injected as a `media_marker` content part (the chat + * layer's native part type — never spliced into a content string). + * + * @category Agents + */ +export const MEDIA_MARKER = '<__media__>'; + +/** + * A multimodal turn delta — the string-stage counterpart of a token delta + * + * Token deltas end at `number[]` because JS owns tokenization on the text + * path. On the multimodal path mtmd owns tokenization, so the delta stops + * at the string stage: sep tokens + the templated prompt (markers embedded) + * + the image bytes, ready for {@link Branch.prefillMultimodal}. + * + * @category Agents + */ +export interface MultimodalDelta { + /** Turn separator tokens (decoded ahead of the prompt) */ + sep: number[]; + /** Templated prompt containing one {@link MEDIA_MARKER} per image */ + prompt: string; + /** Encoded image bytes (jpg/png/bmp/gif), one per marker, in order */ + bitmaps: Uint8Array[]; +} + /** * Options common to all delta builders. * @@ -63,6 +94,46 @@ export function buildUserDelta( return [...sep, ...delta]; } +/** + * Build a multimodal delta for a user turn with images + * + * The multimodal counterpart of {@link buildUserDelta}: same composition, + * same options, but the user content carries one `media_marker` part per + * image and the delta stops at the string stage (mtmd owns tokenization — + * tokenizing the prompt here would double-tokenize). + * + * @param ctx - Active session context (created with `mmprojPath`) + * @param content - User message text + * @param images - Encoded image bytes, one marker emitted per image + * @param opts - Same as {@link buildUserDelta} + * @returns Delta ready for {@link Branch.prefillMultimodal} + * + * @category Agents + */ +export function buildUserDeltaMultimodal( + ctx: SessionContext, + content: string, + images: Uint8Array[], + opts: { tools?: string; system?: string } & DeltaOpts = {} +): MultimodalDelta { + const sep = ctx.getTurnSeparator(); + const fmtOpts: Record = {}; + if (opts.tools) fmtOpts.tools = opts.tools; + if (opts.enableThinking !== undefined) fmtOpts.enableThinking = opts.enableThinking; + const userContent = [ + { type: 'text', text: content }, + ...images.map(() => ({ type: 'media_marker', text: MEDIA_MARKER })), + ]; + const { prompt } = ctx.formatChatSync( + JSON.stringify([ + { role: 'system', content: opts.system ?? '' }, + { role: 'user', content: userContent }, + ]), + fmtOpts + ); + return { sep, prompt, bitmaps: images }; +} + /** * Build a token delta for an assistant turn * diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 426f6da0..dba91303 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -4,8 +4,8 @@ export { BranchStore } from './BranchStore'; export { Session } from './Session'; export { Rerank, RerankCalibrationError, RerankInternalError, RETRIEVAL_INSTRUCTION } from './Rerank'; export type { RerankOpts, RerankTruncation, RerankInstruction } from './Rerank'; -export { buildUserDelta, buildAssistantDelta, buildToolResultDelta, buildTurnDelta } from './deltas'; -export type { DeltaOpts } from './deltas'; +export { buildUserDelta, buildUserDeltaMultimodal, buildAssistantDelta, buildToolResultDelta, buildTurnDelta, MEDIA_MARKER } from './deltas'; +export type { DeltaOpts, MultimodalDelta } from './deltas'; // ── Enums + constants ──────────────────────────────────────── export { PoolingType, CHAT_FORMAT_CONTENT_ONLY, CHAT_FORMAT_GENERIC, ReasoningFormat, GrammarTriggerType } from './types'; @@ -30,6 +30,7 @@ export type { AdvancedSamplingParams, SamplingParams, SessionContext, + MultimodalPrefillResult, Produced, RerankOptions, RerankResult, diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index b83efc78..24349a98 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -241,6 +241,54 @@ export interface ContextOptions { * Default: 'f16' */ typeV?: KvCacheType; + + /** + * Path to the model's multimodal projector (mmproj .gguf) + * + * Enables image input: the projector encodes images into embedding rows + * that prefill into a branch's KV beside text (see + * {@link Branch.prefillMultimodal}). Any llama.cpp-supported VL model + * works — the projector decides the position mode at runtime. + * + * Fail-loud: a configured mmproj that cannot load throws at + * `createContext` — never a silent fall back to text-only. + */ + mmprojPath?: string; + + /** + * Minimum tokens per image (multimodal) + * + * Per-image token budget floor for models with dynamic resolution. + * Default: model metadata. Only relevant with {@link mmprojPath}. + */ + imageMinTokens?: number; + + /** + * Maximum tokens per image (multimodal) + * + * Per-image token budget cap — the lever for fitting many images (or + * video frames) into a context. Default: model metadata. Only relevant + * with {@link mmprojPath}. + */ + imageMaxTokens?: number; +} + +/** + * Per-branch result of a multimodal prefill + * + * The native layer owns multimodal tokenization (the projector's token + * counts aren't knowable from JS), so the prefill reports what it decoded: + * `tokensDecoded` is KV cells added; `positionAdvance` is how far the + * branch position moved. Under M-RoPE the two differ for images (cells + * grow by rows, position by max(nx, ny)). + * + * @category Branching + */ +export interface MultimodalPrefillResult { + /** KV cells added (sep + text + image rows) */ + tokensDecoded: number; + /** Branch position advance (< tokensDecoded under M-RoPE with images) */ + positionAdvance: number; } /** @@ -890,6 +938,23 @@ export interface SessionContext { */ kvCacheLoad(sequenceId: number, state: Buffer): Promise; + /** + * True when the loaded mmproj has a vision encoder + * + * `false` when no {@link ContextOptions.mmprojPath} was configured. + * Gate image features on this rather than on configuration. + */ + supportsVision(): boolean; + + /** + * True when the loaded mmproj has an audio encoder + * + * `false` when no mmproj was configured, and for vision-only projectors. + * Audio input has no API surface yet — a prefill that routes audio bytes + * throws rather than silently skipping. + */ + supportsAudio(): boolean; + /** * Clear all KV cache (fresh start) * @@ -1465,6 +1530,17 @@ export interface SessionContext { /** @internal */ _storePrefill(handles: number[], tokenArrays: number[][]): Promise; + /** @internal — multimodal prefill: per-branch sep tokens + templated + * prompt (with `<__media__>` markers) + image bytes. The native worker + * walks TEXT/IMAGE chunks in order (token rail / embedding rail) and + * returns per-branch counts. See {@link Branch.prefillMultimodal}. */ + _storePrefillMultimodal( + handles: number[], + sepTokens: number[][], + prompts: string[], + bitmaps: Uint8Array[][], + ): Promise; + /** @internal — additively merge experts' logits_snapshot into dst's: * dst[t] += alpha * sum(experts[i][t]). Pure CPU op, no GPU dispatch. */ _storeMergeLogits(dstHandle: number, srcHandles: number[], alpha: number): void; From 07a74627bbf730422d4e9535610137fc1923168e Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Sun, 30 Aug 2026 09:53:40 +1000 Subject: [PATCH 02/69] chore: ignore provisioned model weights at the repo root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rig's resolveModel fetches into /models//.gguf — the repo root when tests or examples run here — and only the harness-cli template path was ignored, so a 610 MB reranker GGUF sat untracked and stageable. Mirrors lloyal.node's existing `models/` rule. --- .gitignore | 5 ++++ packages/agents/src/spine.ts | 51 +++++++++++++++++++++++++++++++----- packages/rig/src/models.ts | 37 ++++++++++++++++++++++++-- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 4d364cbb..da5977d0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ packages/harness-cli/templates/*/dist-web/ packages/harness-cli/templates/*/traces/ packages/harness-cli/templates/*/*.log packages/harness-cli/templates/*/models/**/*.gguf + +# Provisioned model weights (too large for git). rig's resolveModel fetches +# into /models//.gguf — at the repo root when tests +# or examples run here. Mirrors lloyal.node's rule. +models/ diff --git a/packages/agents/src/spine.ts b/packages/agents/src/spine.ts index 5c4ec392..e1b87350 100644 --- a/packages/agents/src/spine.ts +++ b/packages/agents/src/spine.ts @@ -1,6 +1,6 @@ import { call } from "effection"; import type { Operation } from "effection"; -import { Branch } from "@lloyal-labs/sdk"; +import { Branch, MEDIA_MARKER } from "@lloyal-labs/sdk"; import type { SessionContext } from "@lloyal-labs/sdk"; import { Ctx, Trace, TraceParent, SpineFmt } from "./context"; import { traceScope } from "./trace-scope"; @@ -75,6 +75,19 @@ export interface SpineOptions { * @default true */ enableThinking?: boolean; + /** + * Images prefilled into the spine's chat-format header at setup — the + * shared reference the whole pool attends. One media marker is emitted + * into the system content per image; the header decodes ONCE (text on + * the token rail, image rows on the embedding rail) and every agent + * forking from the spine inherits the images via fork prefix-share — + * encoded exactly once, zero re-encode per agent. + * + * Requires a context created with `mmprojPath`. Like `tools`, only + * applied when `systemPrompt` is also set (shared mode); ignored + * otherwise. + */ + bitmaps?: Uint8Array[]; } /** @@ -172,7 +185,18 @@ export function* withSpine( let spineFmt: FormatConfig | null = null; if (opts.systemPrompt !== undefined) { const enableThinking = opts.enableThinking ?? true; - const messages = JSON.stringify([{ role: "system", content: opts.systemPrompt }]); + const bitmaps = opts.bitmaps ?? []; + // With bitmaps, the system content carries one media_marker part per + // image (the chat layer's native part type); the marker survives the + // template verbatim and the native walk replaces it with the image's + // encoded rows. + const systemContent = bitmaps.length > 0 + ? [ + { type: "text", text: opts.systemPrompt }, + ...bitmaps.map(() => ({ type: "media_marker", text: MEDIA_MARKER })), + ] + : opts.systemPrompt; + const messages = JSON.stringify([{ role: "system", content: systemContent }]); const fmtOpts: Record = { enableThinking, // Header ends at <|im_end|>; agents append <|im_start|>user…assistant @@ -184,7 +208,21 @@ export function* withSpine( fmtOpts.tools = createToolkit(opts.tools).toolsJson; } const formatted = ctx.formatChatSync(messages, fmtOpts); - const headerTokens = ctx.tokenizeSync(formatted.prompt, false); + // Header token count: JS-tokenized on the text path; on the multimodal + // path mtmd owns tokenization, so the count comes from the native + // prefill's return (below) and the trace events emit AFTER the prefill. + let headerTokenCount = 0; + if (bitmaps.length > 0) { + const counts = yield* call(() => + spine.prefillMultimodal(formatted.prompt, bitmaps)); + headerTokenCount = counts.tokensDecoded; + } else { + const headerTokens = ctx.tokenizeSync(formatted.prompt, false); + headerTokenCount = headerTokens.length; + if (headerTokens.length > 0) { + yield* call(() => spine.prefill(headerTokens)); + } + } // Spine-seed emission for trace replay (`extractSpineSeed`). Captures // the rendered chat prompt verbatim so a later `reconstructBranch` // can rebuild this exact KV state in a fresh context. The token-count @@ -196,22 +234,21 @@ export function* withSpine( ts: performance.now(), type: "prompt:format", promptText: formatted.prompt, - tokenCount: headerTokens.length, + tokenCount: headerTokenCount, messages, tools: opts.tools && opts.tools.length > 0 ? createToolkit(opts.tools).toolsJson : undefined, role: "spine", }); - if (headerTokens.length > 0) { - yield* call(() => spine.prefill(headerTokens)); + if (headerTokenCount > 0) { tw.write({ traceId: tw.nextId(), parentTraceId: scope.traceId, ts: performance.now(), type: "branch:prefill", branchHandle: spine.handle, - tokenCount: headerTokens.length, + tokenCount: headerTokenCount, role: "spineHeader", }); } diff --git a/packages/rig/src/models.ts b/packages/rig/src/models.ts index 46e9d5cd..eae4bc63 100644 --- a/packages/rig/src/models.ts +++ b/packages/rig/src/models.ts @@ -22,8 +22,10 @@ import { Readable, Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; /** The model roles a harness provisions. `llm` always; `reranker` when an ability - * requires it; `embedding` reserved for the first consumer. */ -export type ModelRole = 'llm' | 'reranker' | 'embedding'; + * requires it; `mmproj` rides its llm entry (vision — resolved in the boot + * beside the llm, never a Service); `embedding` reserved for the first + * consumer. */ +export type ModelRole = 'llm' | 'reranker' | 'embedding' | 'mmproj'; /** * A curated default model. `sha256` is the platform trust root — every catalog @@ -44,6 +46,12 @@ export interface ModelCatalogEntry { sizeBytes: number; /** Suggested `context` (nCtx) when the harness doesn't set one. */ recommendedContext?: number; + /** LLM entries only: the id of this model's multimodal projector (role + * `mmproj`). One vision tower serves every quant of the same model. The + * boot resolves the llm, then its linked mmproj, and passes `mmprojPath` + * into `createContext` — vision rides the llm choice, never a separate + * pick. */ + mmproj?: string; } const USER_AGENT = '@lloyal-labs/rig model-fetch'; @@ -80,6 +88,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [ sha256: '00fe7986ff5f6b463e62455821146049db6f9313603938a70800d1fb69ef11a4', sizeBytes: 2_600_000_000, recommendedContext: 32768, + mmproj: 'qwen3.5-4b-mmproj', }, { id: 'qwen3.8-27b-q4', @@ -94,6 +103,7 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [ sha256: '322e194ff79741c7baa497c240f677f54b201b0efab44ca8e50f122b39123482', sizeBytes: 16_464_440_224, recommendedContext: 32768, + mmproj: 'qwen3.8-27b-mmproj', }, { id: 'qwen3.8-27b-iq1', @@ -105,6 +115,29 @@ export const MODEL_CATALOG: readonly ModelCatalogEntry[] = [ sha256: '3895b6eaa91e705c06ad1938d16c22e86f073c6a67df86260a1da79be3d1f887', sizeBytes: 6_192_222_208, recommendedContext: 32768, + mmproj: 'qwen3.8-27b-mmproj', + }, + { + id: 'qwen3.5-4b-mmproj', + role: 'mmproj', + label: 'Qwen3.5 4B vision projector · F16', + // Upstream only: models.lloyal.ai does not carry the mmprojs yet — add + // the mirror URL when seeded, never a fallback that cannot serve. + urls: [ + 'https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/mmproj-F16.gguf', + ], + sha256: 'cd88edcf8d031894960bb0c9c5b9b7e1fea6ebee02b9f7ce925a00d12891f864', + sizeBytes: 672_423_616, + }, + { + id: 'qwen3.8-27b-mmproj', + role: 'mmproj', + label: 'Qwen3.8 27B vision projector · F16', + urls: [ + 'https://huggingface.co/unsloth/Qwen3.8-27B-GGUF/resolve/main/mmproj-F16.gguf', + ], + sha256: 'cbb841a9ee0636b2ec172f5bb8df2ea8dfeb01e90fe7c6126581d662a0b4e43e', + sizeBytes: 927_607_488, }, { id: 'qwen3-reranker-0.6b-q8', From abfa6837df0e7c0651eea61e9e1b28463f9d7c91 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Tue, 1 Sep 2026 08:31:40 +1000 Subject: [PATCH 03/69] feat(media)!: content addressing gets its own package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An image entering a run had no durable identity. A trace records the media marker, never the pixels, so a media-bearing run could not be replayed or inspected — and admission could not account for what an image cost, because image cost is non-additive (measured: 1 and 2 images both cost 580 cells on Qwen3.5, 3 and 4 both 1142). The substrate is an OCI Image Layout, not an approximation of one: `oras` and `crane` read a store directory with none of our code in the path, which keeps distribution a later adapter rather than a rewrite. An attachment references a MANIFEST, never a blob — the seam that makes video (one manifest, N frame representations) and live capture additive rather than a second pipeline. BREAKING: `@lloyal-labs/agents` no longer exports the content vocabulary. `Attachment`, `AttachmentStore`, `ContentIngress`, `Descriptor`, `sniffMediaType`, `materialize` and 12 others now live in `@lloyal-labs/media`. Agents NAMES attachments; it no longer defines them. `FileAttachmentStore` moves to `@lloyal-labs/media/node`; `createProjectMediaStore` stays in rig, because WHERE a project keeps content is harness policy while the layout is format. `packages/media` is a dependency ROOT — `.` is pure and browser-safe (zero internal deps), `./node` holds what needs a runtime (sharp, the filesystem). `verify:packed` proves that structurally, walking the packed entry's own requires rather than trusting that an import succeeded. Defects fixed, each with a test confirmed red first: - a media failure tore down the whole pool, not the one agent - `GET /v1/content` sent Content-Length with an empty body - pass-through rescued 1 of 6 projector formats - the published package could not normalize (sharp was a devDependency) - a media spine left no replayable seed, and `Branch.prefillMultimodal` never implemented the throw its own docs promised — four callers carried on against a POISONED branch - the tool-result trace was lossy, and `branch:prefill` claimed KV had moved before the dispatch that moves it Decomplection, one concept per change: - one home for content · one meaning for `Attachment` (branded, so a representation can no longer pass as a root — the confusion that shipped as the replay marker-guard bug) · one failure convention (writes throw, lookups return nothing; 26 non-null assertions deleted) · one media-type authority (the bytes decide; the HTTP route stops trusting a client's Content-Type) · one unit (`cells`, not `tokens` — four fields carried the wrong one) · one rail model (a discriminated union; the stall-break was telling the policy a media result costs ZERO) · one discard set (a poisoned agent was force-recovered and got two terminal events) · one admission failure event · one commit sequence for manifests Normalization safety, which PR-3 requires: a decompression-bomb ceiling, a decode timeout, process-wide bounded concurrency with a bounded wait, header dimensions for formats sharp cannot read, and an admission policy that forces derivation on non-identity EXIF orientation even under the pixel ceiling — `stb_image` contains no orientation handling, so a phone photo small enough to pass through reached the model sideways. The composer now uploads over HTTP and sends root descriptors; bytes never touch the socket, whose replay history is sized for tiny frames. Verification added: a test-only tsc project (test files were in no program, so a double could drift from the interface it claimed), `verify:packed`, and `verify:oci` — seven checks driving `oras` against a layout the real ingress wrote and our reader against a layout `oras` wrote, in CI. --- .github/workflows/ci.yml | 28 +- package-lock.json | 619 ++++++++++++++++++ package.json | 8 +- .../abilities/corpus/test/ability.test.ts | 10 +- packages/abilities/corpus/test/bm25.test.ts | 3 + packages/agents/README.md | 11 + packages/agents/package.json | 3 +- packages/agents/src/Agent.ts | 6 +- packages/agents/src/AgentPolicy.ts | 13 +- packages/agents/src/Tool.ts | 86 +++ packages/agents/src/agent-pool.ts | 436 +++++++++--- packages/agents/src/context.ts | 35 + packages/agents/src/index.ts | 12 +- packages/agents/src/init.ts | 26 +- packages/agents/src/prepare-content.ts | 70 ++ packages/agents/src/replay.ts | 105 ++- packages/agents/src/spine.ts | 129 ++-- packages/agents/src/trace-scope.ts | 50 +- packages/agents/src/trace-types.ts | 68 +- packages/agents/src/trace-writer.ts | 2 +- packages/agents/src/use-agent.ts | 6 +- packages/agents/test/Agent.test.ts | 15 +- packages/agents/test/AgentPolicy.test.ts | 13 +- packages/agents/test/agent-pool.test.ts | 228 ++++++- packages/agents/test/attachments.test.ts | 426 ++++++++++++ packages/agents/test/authGuard.test.ts | 21 +- packages/agents/test/helpers/format-config.ts | 29 + packages/agents/test/helpers/media.ts | 39 ++ packages/agents/test/helpers/memory-store.ts | 57 ++ packages/agents/test/helpers/raw-ingress.ts | 26 + packages/agents/test/invariants/harness.ts | 68 +- packages/agents/test/invariants/predicates.ts | 74 ++- ...-cancel-no-sweep-recovery.scenario.test.ts | 2 +- .../decision-matrix.scenario.test.ts | 4 +- ...rred-media-cost-is-honest.scenario.test.ts | 57 ++ ...ded-agent-not-resurrected.scenario.test.ts | 79 +++ ...-ingress-failure-isolated.scenario.test.ts | 56 ++ ...fill-failure-claims-no-kv.scenario.test.ts | 96 +++ .../no-projector-says-so.scenario.test.ts | 65 ++ packages/agents/test/spawn-agents.test.ts | 11 +- packages/agents/test/spine-multimodal.test.ts | 161 +++++ packages/agents/test/tool-media.test.ts | 52 ++ packages/agents/test/trace-scope-halt.test.ts | 57 ++ packages/agents/tsconfig.json | 11 +- packages/dev-tools/src/index.ts | 7 + packages/dev-tools/src/react.tsx | 56 +- packages/media/LICENSE | 107 +++ packages/media/LICENSE-FAQ.md | 257 ++++++++ packages/media/README.md | 248 +++++++ packages/media/package.json | 59 ++ packages/media/src/attachment.ts | 267 ++++++++ packages/media/src/file-store.ts | 176 +++++ packages/media/src/image.ts | 589 +++++++++++++++++ packages/media/src/index.ts | 30 + packages/media/src/ingress.ts | 128 ++++ packages/media/src/media-type.ts | 76 +++ packages/media/src/node.ts | 15 + packages/media/src/store.ts | 124 ++++ packages/media/test/commit-manifest.test.ts | 112 ++++ packages/media/test/file-store.test.ts | 182 +++++ packages/media/test/normalize.test.ts | 356 ++++++++++ packages/media/test/store.test.ts | 38 ++ packages/media/tsconfig.json | 11 + packages/rig/package.json | 3 +- packages/rig/src/content-routes.ts | 334 ++++++++++ packages/rig/src/media-store.ts | 48 ++ packages/rig/src/models.ts | 68 ++ packages/rig/src/node.ts | 20 +- packages/rig/src/runner.ts | 26 +- packages/rig/src/tools/delegate.ts | 5 +- packages/rig/src/trace-sink.ts | 68 ++ packages/rig/test/content-routes.test.ts | 256 ++++++++ packages/rig/test/define-ability.test.ts | 2 +- packages/rig/test/keyless-search.test.ts | 5 +- packages/rig/test/models.test.ts | 2 +- packages/rig/test/plan-routing-key.test.ts | 4 +- packages/rig/test/provision.test.ts | 2 +- packages/rig/test/registry.test.ts | 4 +- packages/rig/test/reranker-options.test.ts | 15 +- packages/rig/test/runner-substrate.test.ts | 2 +- packages/rig/test/spine-render.test.ts | 2 - .../rig/test/verification-properties.test.ts | 2 - packages/rig/tsconfig.json | 27 +- packages/sdk/src/Branch.ts | 9 +- packages/sdk/src/BranchStore.ts | 41 +- packages/sdk/src/Session.ts | 64 +- packages/sdk/src/deltas.ts | 116 +++- packages/sdk/src/index.ts | 4 +- packages/sdk/src/types.ts | 21 + packages/sdk/test/MockSessionContext.ts | 103 ++- packages/sdk/test/branch-double-free.test.ts | 62 ++ packages/sdk/test/deltas-multimodal.test.ts | 186 ++++++ packages/sdk/test/rerank-instruction.test.ts | 8 +- scripts/verify-oci-conformance.sh | 173 +++++ scripts/verify-packed-install.sh | 99 +++ tsconfig.test.json | 62 ++ 96 files changed, 7754 insertions(+), 300 deletions(-) create mode 100644 packages/agents/src/prepare-content.ts create mode 100644 packages/agents/test/attachments.test.ts create mode 100644 packages/agents/test/helpers/format-config.ts create mode 100644 packages/agents/test/helpers/media.ts create mode 100644 packages/agents/test/helpers/memory-store.ts create mode 100644 packages/agents/test/helpers/raw-ingress.ts create mode 100644 packages/agents/test/invariants/scenarios/deferred-media-cost-is-honest.scenario.test.ts create mode 100644 packages/agents/test/invariants/scenarios/discarded-agent-not-resurrected.scenario.test.ts create mode 100644 packages/agents/test/invariants/scenarios/media-ingress-failure-isolated.scenario.test.ts create mode 100644 packages/agents/test/invariants/scenarios/media-prefill-failure-claims-no-kv.scenario.test.ts create mode 100644 packages/agents/test/invariants/scenarios/no-projector-says-so.scenario.test.ts create mode 100644 packages/agents/test/spine-multimodal.test.ts create mode 100644 packages/agents/test/tool-media.test.ts create mode 100644 packages/agents/test/trace-scope-halt.test.ts create mode 100644 packages/media/LICENSE create mode 100644 packages/media/LICENSE-FAQ.md create mode 100644 packages/media/README.md create mode 100644 packages/media/package.json create mode 100644 packages/media/src/attachment.ts create mode 100644 packages/media/src/file-store.ts create mode 100644 packages/media/src/image.ts create mode 100644 packages/media/src/index.ts create mode 100644 packages/media/src/ingress.ts create mode 100644 packages/media/src/media-type.ts create mode 100644 packages/media/src/node.ts create mode 100644 packages/media/src/store.ts create mode 100644 packages/media/test/commit-manifest.test.ts create mode 100644 packages/media/test/file-store.test.ts create mode 100644 packages/media/test/normalize.test.ts create mode 100644 packages/media/test/store.test.ts create mode 100644 packages/media/tsconfig.json create mode 100644 packages/rig/src/content-routes.ts create mode 100644 packages/rig/src/media-store.ts create mode 100644 packages/rig/src/trace-sink.ts create mode 100644 packages/rig/test/content-routes.test.ts create mode 100644 packages/sdk/test/branch-double-free.test.ts create mode 100644 packages/sdk/test/deltas-multimodal.test.ts create mode 100755 scripts/verify-oci-conformance.sh create mode 100755 scripts/verify-packed-install.sh create mode 100644 tsconfig.test.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e6fd601..606e8efc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,7 +51,33 @@ jobs: node-version: 24 - run: npm install - - run: npx tsc -b packages/sdk packages/agents packages/rig packages/channel-verify + - run: npm run typecheck + + # The format claim is that a store directory is a VALID OCI Image Layout, not + # an approximation of one — which is what keeps distribution a later, + # replaceable adapter. Only a tool with none of our code in it can check that, + # so this drives `oras` against a layout the real ingress wrote, and our + # reader against a layout `oras` wrote. + oci-conformance: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + + - name: Install oras + run: | + VERSION=1.3.0 + curl -fsSL -o oras.tar.gz \ + "https://github.com/oras-project/oras/releases/download/v${VERSION}/oras_${VERSION}_linux_amd64.tar.gz" + tar -xzf oras.tar.gz oras + sudo mv oras /usr/local/bin/ + oras version + + - run: npm install + - run: npm run verify:oci gpu-tests: name: GPU Integration Tests diff --git a/package-lock.json b/package-lock.json index 0345d1a6..4f208602 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "packages/relay", "packages/host", "packages/dev-tools", + "packages/media", "packages/abilities/*" ], "devDependencies": { @@ -45,6 +46,17 @@ "node": ">=18" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -501,6 +513,533 @@ "@shikijs/vscode-textmate": "^10.0.2" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz", + "integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz", + "integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz", + "integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz", + "integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz", + "integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz", + "integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz", + "integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz", + "integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz", + "integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz", + "integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz", + "integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz", + "integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz", + "integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz", + "integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz", + "integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz", + "integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz", + "integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz", + "integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.3" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz", + "integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz", + "integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz", + "integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.3" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz", + "integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz", + "integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz", + "integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz", + "integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz", + "integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", @@ -733,6 +1272,10 @@ "win32" ] }, + "node_modules/@lloyal-labs/media": { + "resolved": "packages/media", + "link": true + }, "node_modules/@lloyal-labs/relay": { "resolved": "packages/relay", "link": true @@ -2681,6 +3224,56 @@ "node": ">=10" } }, + "node_modules/sharp": { + "version": "0.35.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz", + "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.4", + "@img/sharp-darwin-x64": "0.35.4", + "@img/sharp-freebsd-wasm32": "0.35.4", + "@img/sharp-libvips-darwin-arm64": "1.3.3", + "@img/sharp-libvips-darwin-x64": "1.3.3", + "@img/sharp-libvips-linux-arm": "1.3.3", + "@img/sharp-libvips-linux-arm64": "1.3.3", + "@img/sharp-libvips-linux-ppc64": "1.3.3", + "@img/sharp-libvips-linux-riscv64": "1.3.3", + "@img/sharp-libvips-linux-s390x": "1.3.3", + "@img/sharp-libvips-linux-x64": "1.3.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.3", + "@img/sharp-libvips-linuxmusl-x64": "1.3.3", + "@img/sharp-linux-arm": "0.35.4", + "@img/sharp-linux-arm64": "0.35.4", + "@img/sharp-linux-ppc64": "0.35.4", + "@img/sharp-linux-riscv64": "0.35.4", + "@img/sharp-linux-s390x": "0.35.4", + "@img/sharp-linux-x64": "0.35.4", + "@img/sharp-linuxmusl-arm64": "0.35.4", + "@img/sharp-linuxmusl-x64": "0.35.4", + "@img/sharp-webcontainers-wasm32": "0.35.4", + "@img/sharp-win32-arm64": "0.35.4", + "@img/sharp-win32-ia32": "0.35.4", + "@img/sharp-win32-x64": "0.35.4" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2852,6 +3445,14 @@ "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.23.12", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", @@ -3290,6 +3891,7 @@ "version": "5.5.1", "license": "SEE LICENSE IN LICENSE", "dependencies": { + "@lloyal-labs/media": "^0.1.0", "@lloyal-labs/sdk": "^3.0.0", "effection": "^4.0.2", "eta": "^4.5.1" @@ -3339,6 +3941,22 @@ "@lloyal-labs/sdk": "^3.0.3" } }, + "packages/media": { + "name": "@lloyal-labs/media", + "version": "0.1.0", + "license": "SEE LICENSE IN LICENSE", + "devDependencies": { + "sharp": "^0.35.4" + }, + "peerDependencies": { + "sharp": "^0.35.4" + }, + "peerDependenciesMeta": { + "sharp": { + "optional": true + } + } + }, "packages/relay": { "name": "@lloyal-labs/relay", "version": "0.1.0", @@ -3354,6 +3972,7 @@ "dependencies": { "@lloyal-labs/channel-verify": "^0.3.1", "@lloyal-labs/lloyal-agents": "^5.0.0", + "@lloyal-labs/media": "^0.1.0", "@lloyal-labs/sdk": "^3.1.0", "@mozilla/readability": "^0.6.0", "effection": "^4.0.2", diff --git a/package.json b/package.json index 82904ecd..22896f16 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,14 @@ "packages/relay", "packages/host", "packages/dev-tools", + "packages/media", "packages/abilities/*" ], "scripts": { "build": "npm run build --workspaces", - "clean": "rm -rf packages/*/dist", + "typecheck": "tsc -b packages/media packages/sdk packages/agents packages/rig packages/channel-verify && tsc -p tsconfig.test.json", + "verify:packed": "bash scripts/verify-packed-install.sh", + "clean": "rm -rf packages/*/dist packages/abilities/*/dist packages/*/tsconfig.tsbuildinfo packages/abilities/*/tsconfig.tsbuildinfo", "docs": "npx typedoc", "test": "npm run build && npx tsx test/sdk.ts && npx tsx test/agents.ts", "test:unit": "vitest run", @@ -22,7 +25,8 @@ "smoke:visual": "npx tsx examples/shared/tui-ink/__visual-smoke.tsx", "examples:compare": "npx tsx examples/compare/main.ts", "examples:react": "npx tsx examples/react-agent/main.ts", - "examples:reflect": "npx tsx examples/reflection/main.ts" + "examples:reflect": "npx tsx examples/reflection/main.ts", + "verify:oci": "bash scripts/verify-oci-conformance.sh" }, "devDependencies": { "@types/node": "^25.3.0", diff --git a/packages/abilities/corpus/test/ability.test.ts b/packages/abilities/corpus/test/ability.test.ts index 176acfec..f4a8cd18 100644 --- a/packages/abilities/corpus/test/ability.test.ts +++ b/packages/abilities/corpus/test/ability.test.ts @@ -106,6 +106,10 @@ function mkScoringReranker(expectedScores: Map): Reranker { }, scoreBatch: async (_q, texts) => texts.map(() => 0), tokenizeChunks: async () => {}, + // The double must carry the WHOLE contract: `tokenize` returns tokens from + // the reranker's own vocabulary (BM25's first stage needs them). Omitting + // it compiled only because no tsc project covered this file. + tokenize: async (_text: string) => [], dispose: () => {}, }; } @@ -127,7 +131,7 @@ describe('SearchTool envelope (TICK-001)', () => { const result = (await run(function* () { yield* Trace.set(new NullTraceWriter()); - return yield* tool.execute({ query: 'q' }) as Generator; + return yield* tool.execute({ query: 'q' }); })) as { hits: ScoredChunk[]; thresholdScore: number; totalScored: number; topRejected: ScoredChunk[] }; expect(result.hits).toEqual([]); @@ -153,7 +157,7 @@ describe('SearchTool envelope (TICK-001)', () => { const result = (await run(function* () { yield* Trace.set(new NullTraceWriter()); - return yield* tool.execute({ query: 'q' }) as Generator; + return yield* tool.execute({ query: 'q' }); })) as { hits: ScoredChunk[]; thresholdScore: number; totalScored: number; topRejected: ScoredChunk[] }; expect(result.hits.map((h) => h.heading)).toEqual(['high', 'medium']); @@ -173,7 +177,7 @@ describe('SearchTool envelope (TICK-001)', () => { const result = (await run(function* () { yield* Trace.set(new NullTraceWriter()); - return yield* tool.execute({ query: 'q' }) as Generator; + return yield* tool.execute({ query: 'q' }); })) as { hits: ScoredChunk[]; thresholdScore: number }; expect(result.hits.map((h) => h.heading)).toEqual(['high']); diff --git a/packages/abilities/corpus/test/bm25.test.ts b/packages/abilities/corpus/test/bm25.test.ts index 138a295e..b5068d98 100644 --- a/packages/abilities/corpus/test/bm25.test.ts +++ b/packages/abilities/corpus/test/bm25.test.ts @@ -21,6 +21,9 @@ const T = { glucose: 10, is: 100, // common stopword-like the: 101, + a: 102, + city: 103, + in: 104, }; describe('BM25Index', () => { diff --git a/packages/agents/README.md b/packages/agents/README.md index 8e69b242..03ab3a28 100644 --- a/packages/agents/README.md +++ b/packages/agents/README.md @@ -92,6 +92,17 @@ yield* withSpine( `withSpine` creates the spine branch, passes it to the body, and guarantees cleanup via `try/finally` — the spine cannot leak out of the block. Effection enforces the lifetime. +Images share the same way. Pass `bitmaps` (with a context created with `mmprojPath`) and the spine header decodes them once — one media marker per image — into the shared prefix: + +```typescript +yield* withSpine( + { systemPrompt: PLAYBOOKS, tools, bitmaps: [screenshot] }, + function* (spine) { /* every agent forked from spine attends the image */ }, +); +``` + +A KV cell doesn't know whether it came from a token or an image patch, so the frontier is modality-agnostic: N agents attend one image, encoded exactly once, with zero re-encode per agent. + ## Orchestrators `agentPool` accepts an orchestrator that determines how agents are spawned and sequenced: diff --git a/packages/agents/package.json b/packages/agents/package.json index d0adfc00..f03618a3 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -33,7 +33,8 @@ "dependencies": { "@lloyal-labs/sdk": "^3.0.0", "effection": "^4.0.2", - "eta": "^4.5.1" + "eta": "^4.5.1", + "@lloyal-labs/media": "^0.1.0" }, "files": [ "dist/", diff --git a/packages/agents/src/Agent.ts b/packages/agents/src/Agent.ts index 18db0515..1e54bf88 100644 --- a/packages/agents/src/Agent.ts +++ b/packages/agents/src/Agent.ts @@ -71,8 +71,10 @@ export interface ToolHistoryEntry { name: string; /** Summarized arguments (e.g. query string, URL) */ args: string; - /** Number of tokens prefilled for this tool's result */ - resultTokenCount: number; + /** KV CELLS this tool's result cost — not tokens. Equal on the token rail; + * on the embedding rail a returned image costs cells that no token count + * describes, and this is the number admission actually spent. */ + resultCells: number; /** Context available percent after this result settled */ contextAfterPercent: number; /** Timestamp (performance.now) when result was recorded */ diff --git a/packages/agents/src/AgentPolicy.ts b/packages/agents/src/AgentPolicy.ts index 18039e3d..25ff20f0 100644 --- a/packages/agents/src/AgentPolicy.ts +++ b/packages/agents/src/AgentPolicy.ts @@ -229,10 +229,19 @@ export interface AgentPolicy { * error payload (carries the budget in its message). Return * `{type: 'idle', reason: 'pressure_settle_reject'}` to drop the agent. * If the hook is absent, the pool falls back to `settle_stall_break`. + * + * OPTIONAL, and that is the contract the pool actually implements: + * `agent-pool.ts:2184` calls it as `policy.onSettleReject?.(…)`. Declaring it + * required contradicted both that call site and the sentence above, and made + * every policy double that legitimately omits it fail to typecheck. */ - onSettleReject( + onSettleReject?( agent: Agent, - resultTokens: number, + /** KV CELLS the pending result will cost — not tokens. A media result's + * cost is measured, never a token length; this used to be handed + * `prefillTokens.length`, which for that rail is `[]` and reported a + * confident zero. */ + resultCells: number, pressure: ContextPressure, config: PolicyConfig, ): SettleAction; diff --git a/packages/agents/src/Tool.ts b/packages/agents/src/Tool.ts index 3eb1cab2..83b8309d 100644 --- a/packages/agents/src/Tool.ts +++ b/packages/agents/src/Tool.ts @@ -173,3 +173,89 @@ export class ToolRetryError extends Error { super(message); } } + +/* + * ── The framework channel on a tool result ────────────────────────────── + * + * Three underscore-prefixed keys, named here rather than spelled inline, so + * the convention has one home and a constant never drifts from a literal. + * + * The underscore marks AUTHORSHIP — the framework wrote this, not the tool — + * and NOT invisibility. An earlier version of this comment said these are + * "not something the model ever reads", which is false for two of the three + * and mattered: it is the sentence that would talk a reader out of wording + * `_imageError` carefully. They differ by DIRECTION: + * + * | key | direction | does the model read it? | + * |---|---|---| + * | `_images` | OUT of the result, before serializing | **no** — that is the point | + * | `_contextAvailablePercent` | INTO the result | yes | + * | `_imageError` | INTO the result | yes — it exists to be read | + * + * `_contextAvailablePercent` is an AMBIENT METER for the model: reaching it is + * the point, and it carries how much KV was free when the tool ran. Its + * absence from any prompt is deliberate, not an oversight — the number travels + * with every tool result and is meant to be read as one. + */ + +/** The key a tool returns image bytes under. + * + * Taken OUT before serializing, because these bytes must reach the cache down + * the embedding rail and must never reach it as JSON. A 180 KB image + * stringifies to ~700k characters of digits, which is not a degraded prefill + * but a destroyed one. */ +export const TOOL_MEDIA_KEY = '_images'; + +/** Split a tool result into the images it carried and the result WITHOUT them. + * + * Pure: it used to delete the key in place, which made the order of this call + * and the trace write decide what the trace said — the bytes must reach + * neither the model's JSON nor the trace, and an in-place delete leaves that + * as a property of call order rather than of the code. The caller names both + * halves and hands each to exactly one consumer. + * + * `result` is returned unchanged when there is no media, so a text-only tool + * copies nothing. Entries that are not `Uint8Array` are ignored — one marker + * is emitted per SURVIVING entry, so the prompt and the bitmap list stay in + * step whatever a tool hands over. + * + * @category Agents + */ +export function takeToolMedia( + result: unknown, +): { media: Uint8Array[]; result: unknown } { + if (!result || typeof result !== 'object' || Array.isArray(result)) { + return { media: [], result }; + } + const { [TOOL_MEDIA_KEY]: raw, ...rest } = result as Record; + if (!Array.isArray(raw)) return { media: [], result }; + return { + media: raw.filter((b): b is Uint8Array => b instanceof Uint8Array), + result: rest, + }; +} + +/** + * The key the framework INJECTS onto a tool result, carrying how much KV was + * free when the tool ran. + * + * @category Agents + */ +export const TOOL_CONTEXT_KEY = '_contextAvailablePercent'; + +/** + * The key the framework INJECTS when a tool returned images this model cannot + * see, carrying the reason. + * + * Written for the MODEL, which is what separates it from the other two: an + * agent handed no picture and no explanation reasons confidently about + * something it was never shown, and nothing downstream can tell that is what + * happened. Same shape as the rate-limit `exhausted` path — an honest failure + * in the result text rather than a silent drop. + * + * A constant for the reason the other two are: it was the one member of this + * namespace still spelled as a bare literal at its write site. + * + * @category Agents + */ +export const TOOL_IMAGE_ERROR_KEY = '_imageError'; diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index 9aeb0657..2e493a9d 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -3,10 +3,13 @@ import type { Operation, Subscription, Task, Signal } from 'effection'; import type { Branch } from '@lloyal-labs/sdk'; import { CHAT_FORMAT_CONTENT_ONLY, CHAT_FORMAT_GENERIC, GrammarTriggerType, type ParsedToolCall, type SessionContext } from '@lloyal-labs/sdk'; import type { BranchStore } from '@lloyal-labs/sdk'; -import { Ctx, Store, Trace, TraceParent, CallingAgent, SpineFmt, GrantStoreCtx, WindDown, CancelAgent, Pause } from './context'; +import { Ctx, Store, Trace, TraceParent, CallingAgent, SpineFmt, GrantStoreCtx, WindDown, CancelAgent, Pause, Attachments, Ingress } from './context'; +import { prepareBatch } from './prepare-content'; import type { FormatConfig } from './Agent'; -import { buildToolResultDelta, buildTurnDelta, buildUserDelta } from '@lloyal-labs/sdk'; -import { traceScope } from './trace-scope'; +import { buildToolResultDelta, buildToolResultDeltaMultimodal, buildTurnDelta, buildUserDelta, deltaCells } from '@lloyal-labs/sdk'; +import type { MultimodalDelta } from '@lloyal-labs/sdk'; +import type { Attachment } from '@lloyal-labs/media'; +import { useTraceScope } from './trace-scope'; /** Brands a tee-wrapping TraceWriter so a nested pool never wraps it again * (see the teeOn comment at the tee construction). */ @@ -18,7 +21,7 @@ import type { AgentPolicy, IdleReason, ToolRetryAction } from './AgentPolicy'; import { Agent } from './Agent'; import { DefaultAgentPolicy, RECOVERY_PREFILL_OVERHEAD, BATCH_BUFFER } from './AgentPolicy'; import type { PolicyConfig } from './AgentPolicy'; -import { Tool, ToolRetryError } from './Tool'; +import { Tool, ToolRetryError, takeToolMedia, TOOL_CONTEXT_KEY, TOOL_IMAGE_ERROR_KEY } from './Tool'; import type { PressureThresholds, AgentTaskSpec, @@ -39,15 +42,40 @@ import type { /** Minimal event sender interface — accepts any Channel close type */ type EventSender = { send(value: AgentEvent): Operation }; -interface SettledTool { +type SettledTool = { agentId: number; - prefillTokens: number[]; toolName: string; callId: string; args: string; probe?: string; +} & ( + /** The token rail: the result tokenized here and prefills as tokens. */ + | { rail: 'token'; prefillTokens: number[]; media?: never } + /** The embedding rail. `llama_batch` is token-XOR-embd, so this cannot join + * a token batch — a separate call, not a separate strategy. The delta stops + * at the string stage because mtmd tokenizes downstream, which is why the + * cost had to be MEASURED. */ + | { + rail: 'media'; + prefillTokens?: never; + media: { delta: MultimodalDelta; cells: number; attachments: readonly Attachment[] }; + } +); + +/** + * What admission spends on this item — the ONE place that answers it. + * + * It used to be re-derived wherever it was needed, and one site forgot: the + * stall-break passed `prefillTokens.length` to `policy.onSettleReject`, which + * for a media item is `[]` and therefore **0**. Not "unknown" — a confident + * zero, from which the policy decided whether an agent was worth keeping. A + * union plus one accessor is what makes that site impossible to write. + */ +function settledCells(item: SettledTool): number { + return item.rail === 'media' ? item.media.cells : item.prefillTokens.length; } + /** * A fan-out tool's completion, pushed by its off-fiber child onto * `completedTools` and processed on the loop fiber in DRAIN. Carries @@ -156,7 +184,13 @@ export class ContextPressure { */ static readonly ASSUMED_N_BATCH = 512; - /** Total KV cache capacity (max positions). 0 when no context limit. */ + /** Total KV cache capacity, in CELLS. 0 when no context limit. + * + * Not positions — the two diverge on the embedding rail. Under M-RoPE an + * image occupies far more cells than it advances position (measured on + * Qwen3.5: 564 cells for 32 positions, ~18x), so budgeting from a branch's + * position would under-count an image by that factor. Every number on this + * class is cells, and `cellsUsed` is what the cache actually reports. */ readonly nCtx: number; /** KV cells currently in use (monotonic within a pool run). */ readonly cellsUsed: number; @@ -428,7 +462,7 @@ function* recoverInline( tw.write({ traceId: tw.nextId(), parentTraceId, ts: performance.now(), type: 'branch:prefill', branchHandle: agent.id, - tokenCount: tokens.length, role: 'recovery', + cells: tokens.length, role: 'recovery', }); // Single-agent produce/commit loop @@ -536,7 +570,7 @@ function* handleNudge( const prefillTokens = buildToolResultDelta(ctx, JSON.stringify(nudgeResult), callId, { enableThinking: a.fmt.enableThinking }); const probe = tools?.get(tc?.name || '')?.probe(nudgeResult) ?? undefined; a.resetTurn(); - return { agentId: a.id, prefillTokens, toolName: tc?.name || '', callId, args: tc?.arguments || '', probe }; + return { rail: 'token', agentId: a.id, prefillTokens, toolName: tc?.name || '', callId, args: tc?.arguments || '', probe }; } function* handleReturn( @@ -634,7 +668,7 @@ function* handleRecover( // call, but blank toolName/callId would emit a blank `tool:settle_order` entry and a // blank ToolHistoryEntry; label them so the trace + history are self-describing // (callId is unique per agent → keeps any callId-keyed replay oracle deterministic). - return { agentId: a.id, prefillTokens, toolName: 'recovery', callId: `recovery:${a.id}`, args: '' }; + return { rail: 'token', agentId: a.id, prefillTokens, toolName: 'recovery', callId: `recovery:${a.id}`, args: '' }; } /** @@ -810,6 +844,11 @@ export function useAgentPool(opts: AgentPoolOptions): Operation | null = null; try { pauseSignal = (yield* Pause.get()) ?? null; } catch { /* no pause provided */ } - const poolScope = traceScope(tw, poolParentTraceId, 'pool', { maxTurns, terminalToolName }); + const poolScopeId = yield* useTraceScope(tw, poolParentTraceId, 'pool', { maxTurns, terminalToolName }); // Whether the pool's tool registry contains tools besides the terminal tool. // When false, agents are allowed to call the terminal tool as their first @@ -1014,18 +1053,52 @@ export function useAgentPool(opts: AgentPoolOptions): Operation(); + // Agents that have received a TERMINAL `agent:failed` and are fully + // DISCARDED. Downstream phases must never resurrect one: the termination + // sweep must not force-recover it (its branch may still be alive — + // `safePrune` is a documented no-op on a branch with live children), and + // DRAIN must not emit tool events for a completion that lands afterwards. + // + // TWO paths write it, and only one used to. A user cancel + // (`drainCancels`) and a poisoned media prefill (SETTLE) do the identical + // three things at the point of discard — terminal `agent:failed`, + // `safePrune`, `transition('idle')` — but only the cancel was remembered + // past the tick, so a poisoned agent still satisfied every condition the + // sweep tests and was recovered on a branch the runtime had just called + // unresumable. The observable symptom was TWO terminal events for one + // agent: `media_prefill_failed`, then `recovery_skipped`. + // + // NOT the same set as SETTLE's local `poisoned`, which answers a different + // question — "did this agent's prefill land in THIS tick?" — and is used + // to skip re-activation. One fact needs one name; two facts keep two. + const discardedIds = new Set(); // Pool-level branch cleanup — ensures orphan-branch cleanup even when // spawns are lazy and the orchestrator's spawn scope exits early. + // + // `safePrune`, not `pruneSync` and not `pruneSubtreeSync`. + // + // `pruneSync()` (the original) throws on a branch with live children — + // inside an `ensure()`, where a throw unwinds teardown and can mask + // whatever the run was already failing on. That is the real defect here. + // + // `pruneSubtreeSync()` (what briefly replaced it) is not a memory bug — + // the kernel is generation-checked, so freeing a stale handle is inert — + // but it is still the wrong tool: it frees OTHER AGENTS' branches as a + // side effect while leaving their `Branch` objects reading + // `disposed === false`, so every later reader of those objects is working + // from a flag that lies. It is right in `spine.ts` / `use-agent.ts`, where + // the branch owns its subtree and no sibling object aliases a descendant. + // + // `safePrune` does neither: it asks the CONTEXT whether children are live + // (disposed-filtered, so a freed child stops counting) and lets each + // branch set its own flag. REVERSED, so children are reached before their + // parents — agents are spawned parent-first, so a parent can become + // prunable in the same pass. Whatever this cannot free is freed when the + // context itself goes. yield* ensure(() => { - for (const a of agents) { - if (!a.branch.disposed) a.branch.pruneSync(); + for (let i = agents.length - 1; i >= 0; i--) { + safePrune(agents[i], tw, poolScopeId); } }); @@ -1062,7 +1135,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation { const p = new ContextPressure(ctx, pressureOpts); @@ -1102,7 +1175,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation { + tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), + type: 'branch:prefill', branchHandle: a.id, + cells, role: 'toolResult', ...(refs ? { attachments: refs } : {}) }); + }; const settledAgents: Agent[] = []; - const settledOrder: { agentId: number; callId: string; tokenCount: number }[] = []; + const settledOrder: { agentId: number; callId: string; cells: number }[] = []; const itemProbes = new Map(); const deferred: SettledTool[] = []; @@ -1228,7 +1320,10 @@ export function useAgentPool(opts: AgentPoolOptions): Operation budget) { // Defer — siblings may finish and free KV, letting this result @@ -1240,44 +1335,94 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0) { - yield* call(() => store.prefill(prefillPairs)); + if (tokenItems.length > 0) { + yield* call(() => store.prefill( + tokenItems.map(t => [t.agent.branch, t.tokens] as [Branch, number[]]))); counters.warmPrefillCalls++; - counters.warmPrefillBranches += prefillPairs.length; + counters.warmPrefillBranches += tokenItems.length; + for (const t of tokenItems) writePrefilled(t.agent, t.cells); + } + // The third dispatch. Media cannot share the token batch, so it goes as + // one cohort call in the same position and style as the two around it — + // how many dispatches (and vision-tower encodes) that costs stays the + // native worker's business, so making it cheaper later touches no JS. + // + // Per-item failures, not a rejected promise: one agent's corrupt image + // must not cost its siblings their prefills, and a failed entry's branch + // is POISONED (decode_segments is not atomic, and partial-range KV ops + // are meaningless on recurrent layers) — so it is pruned, never resumed. + const poisoned = new Set(); + if (mediaItems.length > 0) { + const results = yield* call(() => + store.prefillMultimodal(mediaItems.map(m => [m.agent.branch, m.delta] as [Branch, MultimodalDelta]))); + counters.warmPrefillCalls++; + counters.warmPrefillBranches += mediaItems.length; + for (let i = 0; i < mediaItems.length; i++) { + const m = mediaItems[i]; + const err = results[i]?.error; + if (!err) { + writePrefilled(m.agent, m.cells, m.attachments); + continue; + } + const a = m.agent; + poisoned.add(a.id); // skip re-activation THIS tick + discardedIds.add(a.id); // and never resurrect it in any later one + tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), + type: 'pool:settleFailed', agentId: a.id, reason: 'media_prefill_failed', + detail: err.slice(0, 200) }); + yield* poolChannel.send({ type: 'agent:failed', agentId: a.id, reason: 'media_prefill_failed' }); + safePrune(a, tw, poolScopeId); + a.transition('idle'); + } + } + + // Re-activation runs over everything admitted this tick, on either rail. + // Guarding it on `tokenItems` would strand a tick whose items were ALL + // media: those agents would sit in awaiting_tool with their results + // already in KV, and nothing would ever wake them. + if (settledAgents.length > 0) { // Fan-out determinism: record the canonical scatter order so the replay // settle-order oracle can reproduce this exact interleaving. On the // serial path this equals dispatch order; the event is emitted uniformly. - tw.write({ traceId: tw.nextId(), parentTraceId: poolScope.traceId, ts: performance.now(), + tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), type: 'tool:settle_order', batch: settledOrder }); // Probe prefill from DISPATCH or nudge-replacement. const probePairs: [Branch, number[]][] = []; for (const a of settledAgents) { + if (poisoned.has(a.id)) continue; const probe = itemProbes.get(a.id); if (probe) { const probeTokens = ctx.tokenizeSync(probe, false); probePairs.push([a.branch, probeTokens]); - tw.write({ traceId: tw.nextId(), parentTraceId: poolScope.traceId, ts: performance.now(), + tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), type: 'branch:prefill', branchHandle: a.id, - tokenCount: probeTokens.length, role: 'probe', probeText: probe }); + cells: probeTokens.length, role: 'probe', probeText: probe }); } } if (probePairs.length > 0) { @@ -1290,6 +1435,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation { + const a = c.agent; + const detail = err instanceof Error ? err.message : String(err); + tw.write({ + traceId: tw.nextId(), parentTraceId: c.dispatchTraceId, ts: performance.now(), + type: 'pool:settleFailed', agentId: a.id, reason: 'tool_result_failed', + detail: detail.slice(0, 200), + }); + yield* poolChannel.send({ type: 'agent:failed', agentId: a.id, reason: 'tool_result_failed' }); + discardedIds.add(a.id); + safePrune(a, tw, poolScopeId); + a.transition('idle'); + } + function* processCompletion(c: ToolCompletion): Operation { const { agent, tc, callId, dispatchTraceId } = c; // Discarded by a user cancel while this tool was in flight: drop the completion // silently — no tool:result / agent:tool_result, no result set. The agent already // got its terminal agent:failed(user_cancel); a late tool event would contradict it. - if (cancelledIds.has(agent.id)) return null; + if (discardedIds.has(agent.id)) return null; if (c.kind === 'error') { agent.transition('idle'); @@ -1504,7 +1686,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation)._contextAvailablePercent = contextAvailablePercent; + (result as Record)[TOOL_CONTEXT_KEY] = contextAvailablePercent; const resultObj = result as Record; if (Array.isArray(resultObj.results)) { agent.addNestedResults((resultObj.results as unknown[]).filter((f): f is string => typeof f === 'string')); @@ -1522,15 +1704,61 @@ export function useAgentPool(opts: AgentPoolOptions): Operation typeof f === 'string')); } } - const resultStr = JSON.stringify(result); + // Images come OUT before serializing — see TOOL_MEDIA_KEY. A model with + // no projector cannot be handed them, and dropping them silently would + // leave the agent reasoning about a picture it was never shown, so say + // so in the result text instead: an honest failure the model can read, + // the same shape the rate-limit path uses above. + const { media, result: told } = takeToolMedia(result); + if (media.length > 0 && !ctx.supportsVision()) { + (told as Record)[TOOL_IMAGE_ERROR_KEY] = + `${tc.name} returned ${media.length} image(s), but this model cannot see images. ` + + `Work from the text, or use a different source.`; + } + const resultStr = JSON.stringify(told); yield* poolChannel.send({ type: 'agent:tool_result', agentId: agent.id, tool: tc.name, result: resultStr, contextAvailablePercent }); - const prefillTokens = buildToolResultDelta(ctx, resultStr, callId, { enableThinking: agent.fmt.enableThinking }); - const probe = tool?.probe(result) ?? undefined; + + // Two rails, one seam. The token rail tokenizes here; the embedding rail + // stops at the string stage because mtmd tokenizes downstream, and its + // cost has to be MEASURED (image cost is non-linear — a per-image + // estimate over-commits) before SETTLE can spend it against headroom. + // Measured on the loop fiber, never inside a fan-out `execute()`. + let prefillTokens: number[] = []; + let mediaItem: { delta: MultimodalDelta; cells: number; attachments: readonly Attachment[] } | undefined; + if (media.length > 0 && ctx.supportsVision()) { + // THE BARRIER for this ingress: the whole batch is normalized and + // committed before a marker exists, before admission, before any KV + // moves. `delta` then carries the ADMITTED representations, so the + // cells measured here are the cells replay will rebuild. + // + // A failure is NOT a tool retry: the tool already ran and may have had + // an external side effect, so re-running it is not a neutral act. The + // agent fails through the existing recovery path instead, and its + // branch is pruned — never silently dropped, and never repeated. + const prepared = yield* prepareBatch(ingress, attachments, media); + const delta = buildToolResultDeltaMultimodal( + ctx, resultStr, callId, prepared.bitmaps as Uint8Array[], + { enableThinking: agent.fmt.enableThinking }); + mediaItem = { + delta, + cells: yield* call(() => deltaCells(ctx, delta)), + attachments: prepared.attachments, + }; + } else { + prefillTokens = buildToolResultDelta(ctx, resultStr, callId, { enableThinking: agent.fmt.enableThinking }); + } + // `told` throughout, never `result`: the probe reads what the model was + // told, and the trace records it. Image bytes reach the cache down the + // embedding rail and belong in neither. + const probe = tool?.probe(told) ?? undefined; tw.write({ traceId: tw.nextId(), parentTraceId: dispatchTraceId, ts: performance.now(), type: 'tool:result', agentId: agent.id, tool: tc.name, - result, prefillTokenCount: prefillTokens.length, + result: told, prefillTokenCount: mediaItem?.cells ?? prefillTokens.length, durationMs: performance.now() - c.toolT0 }); - return { agentId: agent.id, prefillTokens, toolName: tc.name, callId, args: tc.arguments, probe }; + const common = { agentId: agent.id, toolName: tc.name, callId, args: tc.arguments, probe }; + return mediaItem + ? { rail: 'media', ...common, media: mediaItem } + : { rail: 'token', ...common, prefillTokens }; } /** DISPATCH: run inline tools on the loop fiber, spawn fan-out tools off it. @@ -1561,7 +1789,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation= a.recoveryBudget) { - yield* completeExtraction(a, poolChannel, tw, poolScope.traceId, ctx, pressureOpts, terminalToolName); + yield* completeExtraction(a, poolChannel, tw, poolScopeId, ctx, pressureOpts, terminalToolName); continue; } @@ -1896,13 +2131,13 @@ export function useAgentPool(opts: AgentPoolOptions): Operation= voluntaryReportCap) { a.exitReason = 'report_cap'; - tw.write({ traceId: tw.nextId(), parentTraceId: poolScope.traceId, ts: performance.now(), + tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), type: 'pool:agentDrop', agentId: a.id, reason: 'report_cap' }); - traceAgentDone(tw, poolScope.traceId, a.id); + traceAgentDone(tw, poolScopeId, a.id); yield* poolChannel.send({ type: 'agent:done', agentId: a.id }); - yield* finishRecovery(a, a.rawOutput, a.turnTokens, poolChannel, tw, poolScope.traceId, ctx, terminalToolName); + yield* finishRecovery(a, a.rawOutput, a.turnTokens, poolChannel, tw, poolScopeId, ctx, terminalToolName); a.transition('idle'); - safePrune(a, tw, poolScope.traceId); + safePrune(a, tw, poolScopeId); continue; } @@ -1912,13 +2147,13 @@ export function useAgentPool(opts: AgentPoolOptions): Operation x.status === 'active' || x.status === 'awaiting_tool').length, pressure: { @@ -2042,7 +2277,12 @@ export function useAgentPool(opts: AgentPoolOptions): Operation ({ agentId: a.id, tokenCount: a.tokenCount, @@ -2252,7 +2494,6 @@ export function useAgentPool(opts: AgentPoolOptions): Operation s + a.tokenCount, 0), steps, durationMs: performance.now() - poolT0, }); - poolScope.close(); const result: AgentPoolResult = { agents: agents.map(a => ({ @@ -2279,7 +2520,6 @@ export function useAgentPool(opts: AgentPoolOptions): Operation ({ agentId: a.id, parentAgentId: a.parentId, branch: a.branch, agent: a, diff --git a/packages/agents/src/context.ts b/packages/agents/src/context.ts index 44bc7d63..42a28b22 100644 --- a/packages/agents/src/context.ts +++ b/packages/agents/src/context.ts @@ -4,6 +4,8 @@ import type { BranchStore, Branch } from '@lloyal-labs/sdk'; import type { Channel, Signal } from 'effection'; import type { AgentEvent } from './types'; import type { TraceWriter } from './trace-writer'; +import type { AttachmentStore, ContentIngress } from '@lloyal-labs/media'; +import { NullAttachmentStore, NoContentIngress } from '@lloyal-labs/media'; import type { TraceId } from './trace-types'; import type { Agent, FormatConfig } from './Agent'; import type { Reranker } from './chunk'; @@ -52,6 +54,39 @@ export const Events = createContext>('lloyal.events'); */ export const Trace = createContext('lloyal.trace'); +/** + * Effection context holding the store for images that entered the KV cache + * + * Set by {@link initAgents}. Defaults to {@link NullAttachmentStore}, so a run + * nobody is recording hashes nothing and touches no disk. + * + * A trace records the media marker, not the pixels. This is where the pixels + * go, so a media-bearing run stays replayable and inspectable: the trace + * carries a digest per image on `branch:prefill`, and this resolves it back to + * bytes. One store per run, whichever ingress an image arrived through. + * + * @category Agents + */ +export const Attachments = createContext( + 'lloyal.attachments', + new NullAttachmentStore(), +); + +/** + * Effection context holding the service that admits raw media. + * + * Defaults to {@link NoContentIngress}: inert for a text-only run, and a loud + * failure the first time media arrives without one installed. Normalizing + * needs a native dependency this package must not import, so the harness + * supplies it — the same shape as {@link Attachments}. + * + * @category Agents + */ +export const Ingress = createContext( + 'lloyal.ingress', + new NoContentIngress(), +); + /** * Effection context carrying the current trace scope ID * diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 665bdc5a..1ac931ef 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -10,6 +10,7 @@ export { GrantStoreCtx, WindDown, CancelAgent, Pause, + Attachments, Ingress, } from './context'; export { Tool, ToolRetryError } from './Tool'; export { Agent } from './Agent'; @@ -31,7 +32,16 @@ export { createToolkit } from './toolkit'; export { initAgents } from './init'; export { withSpine } from './spine'; export { NullTraceWriter, JsonlTraceWriter } from './trace-writer'; -export { traceScope } from './trace-scope'; +// The content vocabulary is NOT re-exported. It lives in `@lloyal-labs/media` +// and eighteen symbols of it used to surface here, in the package whose job is +// orchestration — `agents` NAMES attachments, it does not define them. What it +// does own is the barrier that drives the two ports across a batch. +export { prepareBatch } from './prepare-content'; +// The one member of the framework-channel namespace a TOOL writes. The other +// two are framework→model and no tool author ever sets them, so they stay +// internal rather than growing the surface to describe a convention. +export { TOOL_MEDIA_KEY } from './Tool'; +export { useTraceScope } from './trace-scope'; export { admitChunks } from './admission'; export type { AdmitOpts, AdmitResult, AdmitSelect, AdmittedPassage } from './admission'; export { composePrompt, renderPrompt, renderTemplate } from './prompt'; diff --git a/packages/agents/src/init.ts b/packages/agents/src/init.ts index 5e779bf0..252762b2 100644 --- a/packages/agents/src/init.ts +++ b/packages/agents/src/init.ts @@ -3,10 +3,12 @@ import type { Operation, Channel } from 'effection'; import { BranchStore } from '@lloyal-labs/sdk'; import { Session } from '@lloyal-labs/sdk'; import type { SessionContext } from '@lloyal-labs/sdk'; -import { Ctx, Store, Events, Trace } from './context'; +import { Ctx, Store, Events, Trace, Attachments } from './context'; import type { AgentEvent } from './types'; import type { TraceWriter } from './trace-writer'; import { NullTraceWriter } from './trace-writer'; +import type { Attachment, AttachmentStore } from '@lloyal-labs/media'; +import { NullAttachmentStore } from '@lloyal-labs/media'; /** * Handle returned by {@link initAgents} containing all agent resources @@ -62,10 +64,11 @@ export interface AgentHandle { */ export function* initAgents( ctx: SessionContext, - opts?: { traceWriter?: TraceWriter }, + opts?: { traceWriter?: TraceWriter; attachmentStore?: AttachmentStore }, ): Operation> { const store = new BranchStore(ctx); const tw = opts?.traceWriter ?? new NullTraceWriter(); + const attachments = opts?.attachmentStore ?? new NullAttachmentStore(); // Make the session trunk's conversation prefills (prefillUser / // prefillAssistant / commitTurn) visible in the engine trace. They are // single-branch `Branch.prefill` calls made by Session — below the Trace @@ -73,19 +76,33 @@ export function* initAgents( // `warmDelta` is the role reserved for exactly this; `content` carries the // verbatim turn. Pure observability: runs after each prefill, never affects // it; a NullTraceWriter makes it a no-op when untraced. + // + // `attachments` is the trunk ingress — a turn the user attached pictures to. + // They arrive already normalized and committed: the caller runs the barrier + // BEFORE prefilling, so a failure produces no prefill at all rather than + // media in the cache that can never be replayed. Nothing is stored here; the + // roots are simply carried onto the trace. const session = new Session({ ctx, store, - onPrefill: ({ branchHandle, tokenCount, content }) => { + onPrefill: ({ branchHandle, cells, content, attachments: roots }) => { tw.write({ traceId: tw.nextId(), parentTraceId: null, ts: performance.now(), type: 'branch:prefill', branchHandle, - tokenCount, + cells, role: 'warmDelta', content, + // The SDK carries these STRUCTURALLY (`{digest, mediaType, size}`) + // because it has no attachment concept and must not grow one — the + // same layering rule that keeps agent concepts out of liblloyal. They + // are genuine roots: the caller got them from `prepareBatch` and + // prefilled with them in the same breath. This re-narrows what the + // boundary erased, and it is the only such claim made outside a store. + ...(roots && roots.length > 0 + ? { attachments: roots as readonly Attachment[] } : {}), }); }, }); @@ -95,6 +112,7 @@ export function* initAgents( yield* Store.set(store); yield* Events.set(events as unknown as Channel); yield* Trace.set(tw); + yield* Attachments.set(attachments); yield* ensure(function*() { const tw = yield* Trace.expect(); diff --git a/packages/agents/src/prepare-content.ts b/packages/agents/src/prepare-content.ts new file mode 100644 index 00000000..988fb5f2 --- /dev/null +++ b/packages/agents/src/prepare-content.ts @@ -0,0 +1,70 @@ +/** + * @file The media barrier: admit a whole batch, or admit none of it. + * + * The Operation layer built ON the content ports, kept out of them for the + * reason `trace-scope.ts` is kept out of `trace-writer.ts` — the ports are the + * contract, this is the pipeline that drives them. It stays here because a + * batch is cancelled by the SCOPE that owns it, and scopes are orchestration. + */ +import { call, useAbortSignal } from 'effection'; +import type { Operation } from 'effection'; +import type { + Attachment, AttachmentStore, ContentIngress, PreparedContent, +} from '@lloyal-labs/media'; +import { materialize } from '@lloyal-labs/media'; + +/** + * Admit a whole batch of raw media, or admit none of it. + * + * The barrier this enforces, in order: + * + * 1. **Prepare** every item, committing each root manifest. + * 2. **Materialize** every representation. + * 3. **Flatten** preserving attachment order, then representation order within + * each — markers correspond to REPRESENTATIONS, so a video contributes its + * frames here and an image contributes one. + * 4. Only then may a caller emit markers, build a delta, and prefill. + * + * A failure on item N leaves the content of items 1…N−1 in the store, + * unreachable by anything. That is harmless — content-addressed, unreferenced, + * the same orphan class the write-order invariant already accepts. What it + * must NOT leave is a half-admitted query: zero prefills, zero markers, zero + * published descriptors, unchanged KV. + * + * Sequential rather than concurrent: order is part of the contract, and + * nothing here is slow enough to trade that for. An Operation rather than an + * async function, so a halted scope cancels the batch BETWEEN items instead of + * leaving it running detached — the promise boundary into the ingress is + * crossed with `call()`, which is where cancellation is observed. + * + * **Scope this claim carefully.** This makes media *preparation* atomic with + * respect to the prefill. It does NOT make the prefill itself transactional — + * `decode_segments` is not atomic, so a failure DURING a prefill still + * poisons its branch and is handled by the prune-and-replay contract, not by + * this barrier. + * + * @throws Whatever ingest or materialization threw, unchanged — the caller + * needs the real reason, and must not proceed to prefill. + * + * @category Agents + */ +export function* prepareBatch( + ingress: ContentIngress, + store: AttachmentStore, + items: readonly Uint8Array[], +): Operation { + // The scope's own signal, hoisted once. `call()` makes a halt OBSERVABLE at + // this boundary but cannot stop the promise behind it — that is the leaked + // effect Effection's own docs warn about — so the signal is what actually + // reaches the ingress. A halted run stops occupying the normalizer's queue + // instead of holding a slot for work whose result nobody will read. + const signal = yield* useAbortSignal(); + const roots: Attachment[] = []; + for (const bytes of items) { + roots.push(yield* call(() => ingress.ingest(bytes, signal))); + } + // Resolve from the store rather than trusting what ingest returned, through + // the SAME call replay uses — so a batch that materializes here is one that + // can be rebuilt later, by construction rather than by assertion. + return materialize(store, roots); +} diff --git a/packages/agents/src/replay.ts b/packages/agents/src/replay.ts index 389bf50d..6b323ec5 100644 --- a/packages/agents/src/replay.ts +++ b/packages/agents/src/replay.ts @@ -1,8 +1,10 @@ import { call, ensure } from 'effection'; import type { Operation } from 'effection'; -import { Branch, buildTurnDelta } from '@lloyal-labs/sdk'; -import { Ctx, Store } from './context'; +import { Branch, buildTurnDelta, MEDIA_MARKER } from '@lloyal-labs/sdk'; +import { Ctx, Store, Attachments } from './context'; import type { TraceEvent } from './trace-types'; +import type { Attachment } from '@lloyal-labs/media'; +import { materialize } from '@lloyal-labs/media'; /** * Serialized state needed to reconstruct a Branch deterministically. @@ -21,6 +23,11 @@ import type { TraceEvent } from './trace-types'; export interface BranchCheckpoint { seedPrompt: string; turns: Array<{ userContent: string; assistantContent: string }>; + /** Images the spine was seeded with, in marker order — one per + * {@link MEDIA_MARKER} in `seedPrompt`. Absent for a text-seeded spine. + * These are references, not bytes: {@link reconstructBranch} resolves them + * through the run's attachment store. */ + seedAttachments?: readonly Attachment[]; } /** @@ -44,7 +51,24 @@ export function extractSpineSeed(events: TraceEvent[]): BranchCheckpoint { 'extractSpineSeed: no prompt:format event with role=spine found in trace', ); } - return { seedPrompt: seed.promptText, turns: [] }; + // The seed prompt and the images it marks arrive on two different events: + // `prompt:format` carries the text, `branch:prefill` carries the + // attachments. They are paired by scope — the spine setup emits both under + // the same parent trace id — which is what keeps a nested pool's spine from + // claiming an outer one's images. + const header = events.find( + (e): e is Extract => + e.type === 'branch:prefill' && + e.role === 'spineHeader' && + e.parentTraceId === seed.parentTraceId && + e.attachments !== undefined, + ); + + return { + seedPrompt: seed.promptText, + turns: [], + ...(header?.attachments ? { seedAttachments: header.attachments } : {}), + }; } /** @@ -73,7 +97,11 @@ export function extractSpineCheckpoint( if (opts.poolTraceId != null && e.parentTraceId !== opts.poolTraceId) continue; turns.push({ userContent: e.userContent, assistantContent: e.assistantContent }); } - return { seedPrompt: seed.seedPrompt, turns }; + return { + seedPrompt: seed.seedPrompt, + turns, + ...(seed.seedAttachments ? { seedAttachments: seed.seedAttachments } : {}), + }; } /** @@ -89,6 +117,12 @@ export function extractSpineCheckpoint( * stage (synth re-run, single-agent replay with modified prompt, etc.) against * the reconstructed KV state. * + * A spine seeded with images (`withSpine({ bitmaps })`) replays too, provided + * the run's attachment store still holds them and the active context has a + * projector loaded. Everything that cannot rebuild the ORIGINAL KV state + * throws rather than falling back to the text path — a marker tokenized as + * text is a different state wearing the same prompt. + * * @example Replay a pool-start (parallel orchestration) with a modified task * ```ts * const events = parseTrace(tracePath); @@ -119,12 +153,71 @@ export function extractSpineCheckpoint( export function* reconstructBranch(checkpoint: BranchCheckpoint): Operation { const ctx = yield* Ctx.expect(); const store = yield* Store.expect(); + const attachments = yield* Attachments.expect(); + + // A marker in the seed means this spine was seeded with images. Tokenizing + // that marker as text would rebuild a DIFFERENT KV state — marker tokens + // where the encoded image rows belong — so every path below either restores + // the bytes or throws. Silently degrading to text is the one thing it must + // never do, because the result looks like a successful replay. + const markers = checkpoint.seedPrompt.split(MEDIA_MARKER).length - 1; + + let bitmaps: Uint8Array[] = []; + if (markers > 0) { + const refs = checkpoint.seedAttachments ?? []; + if (refs.length === 0) { + throw new Error( + `reconstructBranch: this spine was seeded with ${markers} marker(s), ` + + 'but the checkpoint carries no attachment references. The trace ' + + 'records the marker, not the pixels, so its KV state cannot be ' + + 'rebuilt. Re-run the pool with the original images via ' + + 'withSpine({ bitmaps }).', + ); + } + // Runtime capability, checked before any KV is touched: a replay tool that + // built its context without `mmprojPath` would otherwise fail deep inside + // the native prefill, after the spine exists and with a worse message. + if (!ctx.supportsVision()) { + throw new Error( + 'reconstructBranch: this spine was seeded with images, but the active ' + + 'context has no vision projector loaded. Pass `mmprojPath` to ' + + 'createContext() to replay it.', + ); + } + // One resolution path, not two: `materialize` IS this walk, and replay + // reimplementing it by hand is how the two silently drift. A batch that + // materializes at ingress is one that can be rebuilt here, by construction + // rather than by assertion. + bitmaps = [...materialize(attachments, refs).bitmaps]; + + // The count that must match is REPRESENTATIONS, not attachments. One + // manifest is one image (1 representation) but also one video (N sampled + // frames) or one live capture — so comparing attachment count would reject + // every media type except a plain image. Checked AFTER resolution because + // only expanding the manifests reveals how many the seed actually holds. + if (bitmaps.length !== markers) { + throw new Error( + `reconstructBranch: the seed prompt has ${markers} media marker(s), ` + + `but its ${refs.length} attachment(s) expand to ${bitmaps.length} ` + + 'representation(s). Rebuilding would put a different number of ' + + 'images into the cache than the prompt marks — a different KV state ' + + 'wearing the same prompt.', + ); + } + } const spine = Branch.create(ctx, 0, {}); yield* ensure(() => { if (!spine.disposed) spine.pruneSubtreeSync(); }); - const seedTokens = ctx.tokenizeSync(checkpoint.seedPrompt, false); - yield* call(() => spine.prefill(seedTokens)); + // Routed by how the seed was built, not by what is convenient here: the + // multimodal path re-runs mtmd's tokenizer over the same prompt and bytes, + // which is what makes the rebuilt cells match the originals. + if (bitmaps.length > 0) { + yield* call(() => spine.prefillMultimodal(checkpoint.seedPrompt, bitmaps)); + } else { + const seedTokens = ctx.tokenizeSync(checkpoint.seedPrompt, false); + yield* call(() => spine.prefill(seedTokens)); + } for (const turn of checkpoint.turns) { const delta = buildTurnDelta(ctx, turn.userContent, turn.assistantContent); diff --git a/packages/agents/src/spine.ts b/packages/agents/src/spine.ts index e1b87350..fe7a9ea1 100644 --- a/packages/agents/src/spine.ts +++ b/packages/agents/src/spine.ts @@ -1,9 +1,11 @@ import { call } from "effection"; import type { Operation } from "effection"; -import { Branch, MEDIA_MARKER } from "@lloyal-labs/sdk"; +import { Branch, mediaContent } from "@lloyal-labs/sdk"; import type { SessionContext } from "@lloyal-labs/sdk"; -import { Ctx, Trace, TraceParent, SpineFmt } from "./context"; -import { traceScope } from "./trace-scope"; +import { Ctx, Trace, TraceParent, SpineFmt, Attachments, Ingress } from "./context"; +import type { Attachment } from "@lloyal-labs/media"; +import { prepareBatch } from "./prepare-content"; +import { useTraceScope } from "./trace-scope"; import { createToolkit } from "./toolkit"; import type { Tool } from "./Tool"; import type { SamplingParams } from "./types"; @@ -123,6 +125,8 @@ export function* withSpine( ): Operation { const ctx: SessionContext = yield* Ctx.expect(); const tw = yield* Trace.expect(); + const attachments = yield* Attachments.expect(); + const ingress = yield* Ingress.expect(); // Read parent trace ID — connects nested pools to the outer DISPATCH that spawned them let parentTraceId: number | null = null; @@ -133,7 +137,7 @@ export function* withSpine( /* no parent — top level */ } - const scope = traceScope(tw, parentTraceId, "withSpine", { + const scopeId = yield* useTraceScope(tw, parentTraceId, "withSpine", { hasParent: !!opts.parent, }); @@ -154,7 +158,7 @@ export function* withSpine( tw.write({ traceId: tw.nextId(), - parentTraceId: scope.traceId, + parentTraceId: scopeId, ts: performance.now(), type: "branch:create", branchHandle: spine.handle, @@ -167,11 +171,11 @@ export function* withSpine( yield* call(() => spine.prefill(prefillTokens)); tw.write({ traceId: tw.nextId(), - parentTraceId: scope.traceId, + parentTraceId: scopeId, ts: performance.now(), type: "branch:prefill", branchHandle: spine.handle, - tokenCount: prefillTokens.length, + cells: prefillTokens.length, role: "spineHeader", }); } @@ -185,18 +189,31 @@ export function* withSpine( let spineFmt: FormatConfig | null = null; if (opts.systemPrompt !== undefined) { const enableThinking = opts.enableThinking ?? true; - const bitmaps = opts.bitmaps ?? []; - // With bitmaps, the system content carries one media_marker part per - // image (the chat layer's native part type); the marker survives the - // template verbatim and the native walk replaces it with the image's - // encoded rows. - const systemContent = bitmaps.length > 0 - ? [ - { type: "text", text: opts.systemPrompt }, - ...bitmaps.map(() => ({ type: "media_marker", text: MEDIA_MARKER })), - ] - : opts.systemPrompt; - const messages = JSON.stringify([{ role: "system", content: systemContent }]); + + // THE BARRIER. Every image is normalized and committed BEFORE a single + // marker is emitted or any KV is touched, so a failure on image N leaves + // no markers, no prefill and no published descriptors — only unreachable + // content-addressed blobs, which are harmless. `bitmaps` below is what the + // projector will actually decode: the admitted representations, not the + // raw input, because those are the bytes whose cells replay must rebuild. + const raw = opts.bitmaps ?? []; + const prepared = raw.length > 0 + ? yield* prepareBatch(ingress, attachments, raw) + : { attachments: [], bitmaps: [] }; + const bitmaps = prepared.bitmaps as Uint8Array[]; + // Marker injection goes through the SDK's `mediaContent` — the one place + // media_marker parts are emitted — so the spine header, a user turn and a + // tool result cannot drift apart in how they mark media. It returns the + // bare string when there are no bitmaps, which is the text-path shape. + // + // The spine does not use a delta builder: it needs the whole + // FormattedChatResult for `spineFmt` (grammar/format/parser/triggers) and + // the messages JSON for the trace seed, neither of which a + // `MultimodalDelta` carries. Sharing the marker grammar is the part that + // matters; the rest of this assembly is legitimately spine-specific. + const messages = JSON.stringify([ + { role: "system", content: mediaContent(opts.systemPrompt, bitmaps) }, + ]); const fmtOpts: Record = { enableThinking, // Header ends at <|im_end|>; agents append <|im_start|>user…assistant @@ -208,48 +225,65 @@ export function* withSpine( fmtOpts.tools = createToolkit(opts.tools).toolsJson; } const formatted = ctx.formatChatSync(messages, fmtOpts); - // Header token count: JS-tokenized on the text path; on the multimodal - // path mtmd owns tokenization, so the count comes from the native - // prefill's return (below) and the trace events emit AFTER the prefill. - let headerTokenCount = 0; + // Spine-seed emission for trace replay (`extractSpineSeed`). Captures + // the rendered chat prompt verbatim so a later `reconstructBranch` + // can rebuild this exact KV state in a fresh context. + // + // WRITE-AHEAD, on BOTH rails: the seed says what this spine INTENDS to + // prefill, so a prefill that then fails still leaves a run that can be + // rebuilt — and a failed multimodal prefill poisons the branch, which is + // exactly when replay is the only way back. `branch:prefill` below is the + // other half of the pair and asserts the opposite: it is written only + // after the KV actually moved. + // + // `tokenCount` is omitted on the embedding rail — mtmd owns tokenization + // there and no honest count exists before the native call returns. The + // count that landed rides `branch:prefill`. + const writeSpineSeed = (tokenCount?: number): void => { + tw.write({ + traceId: tw.nextId(), + parentTraceId: scopeId, + ts: performance.now(), + type: "prompt:format", + promptText: formatted.prompt, + tokenCount, + messages, + tools: opts.tools && opts.tools.length > 0 + ? createToolkit(opts.tools).toolsJson + : undefined, + role: "spine", + }); + }; + + let headerCells = 0; + let attached: readonly Attachment[] | undefined; if (bitmaps.length > 0) { + writeSpineSeed(); const counts = yield* call(() => spine.prefillMultimodal(formatted.prompt, bitmaps)); - headerTokenCount = counts.tokensDecoded; + headerCells = counts.tokensDecoded; + // Already committed by the barrier above — this only carries the roots + // onto the trace. Recording used to happen HERE, after the prefill, so + // a failed write left media in the cache that could never be replayed. + attached = prepared.attachments; } else { const headerTokens = ctx.tokenizeSync(formatted.prompt, false); - headerTokenCount = headerTokens.length; + writeSpineSeed(headerTokens.length); + headerCells = headerTokens.length; if (headerTokens.length > 0) { yield* call(() => spine.prefill(headerTokens)); } } - // Spine-seed emission for trace replay (`extractSpineSeed`). Captures - // the rendered chat prompt verbatim so a later `reconstructBranch` - // can rebuild this exact KV state in a fresh context. The token-count - // `branch:prefill` below is informational; the spine seed is the - // prompt text on this event. - tw.write({ - traceId: tw.nextId(), - parentTraceId: scope.traceId, - ts: performance.now(), - type: "prompt:format", - promptText: formatted.prompt, - tokenCount: headerTokenCount, - messages, - tools: opts.tools && opts.tools.length > 0 - ? createToolkit(opts.tools).toolsJson - : undefined, - role: "spine", - }); - if (headerTokenCount > 0) { + if (headerCells > 0) { tw.write({ traceId: tw.nextId(), - parentTraceId: scope.traceId, + parentTraceId: scopeId, ts: performance.now(), type: "branch:prefill", branchHandle: spine.handle, - tokenCount: headerTokenCount, + cells: headerCells, role: "spineHeader", + ...(attached ? { attachments: attached } : {}), }); } spineFmt = { @@ -271,7 +305,7 @@ export function* withSpine( if (!spine.disposed) { tw.write({ traceId: tw.nextId(), - parentTraceId: scope.traceId, + parentTraceId: scopeId, ts: performance.now(), type: "branch:prune", branchHandle: spine.handle, @@ -279,6 +313,5 @@ export function* withSpine( }); spine.pruneSubtreeSync(); } - scope.close(); } } diff --git a/packages/agents/src/trace-scope.ts b/packages/agents/src/trace-scope.ts index a5f4fb73..e7e439c4 100644 --- a/packages/agents/src/trace-scope.ts +++ b/packages/agents/src/trace-scope.ts @@ -1,45 +1,55 @@ +import { resource } from 'effection'; +import type { Operation } from 'effection'; import type { TraceWriter } from './trace-writer'; import type { TraceId } from './trace-types'; /** - * Create matched `scope:open` / `scope:close` pairs for building the trace tree + * Open a named trace scope, closed automatically when the caller's scope exits. * - * Opens a named scope immediately and returns a handle to close it later. - * The close callback emits a `scope:close` event with the elapsed duration - * and flushes the writer, ensuring scope boundaries are always persisted. + * `scope:open` / `scope:close` are what give a trace its TREE, so a missing + * close is a malformed tree rather than a missing line. This is a RESOURCE for + * that reason: Effection documents three ways out of a scope — return, error + * and HALT — and the previous `{ traceId, close }` shape made closing + * something each caller had to remember on each of them. They did not: one + * closed in a `finally`, one closed during setup (so a throw in its drain loop + * skipped it), and one closed on return and on catch but not on halt. A + * cancelled run therefore left a scope open exactly when the trace is most + * worth reading. * - * Used throughout the runtime to bracket agent pools, tool dispatches, - * shared-root regions, and generation passes. + * Same correction, same reason, as `useTraceWriter`: a caller-must-close pair + * is the shape `resource()` exists to remove. + * + * Acquire it BEFORE the things it should contain — teardown runs in reverse, + * so the close lands after their own cleanup events and those stay inside the + * scope they belong to. * * @param writer - Active {@link TraceWriter} to emit events to * @param parentTraceId - Trace ID of the enclosing scope, or `null` for root scopes * @param name - Human-readable scope label (e.g. `"pool"`, `"tool:search"`) * @param meta - Optional key-value metadata attached to the `scope:open` event - * @returns Object with the allocated `traceId` and a `close` callback + * @returns The allocated `traceId`, for parenting events inside this scope * * @category Agents */ -export function traceScope( +export function useTraceScope( writer: TraceWriter, parentTraceId: TraceId | null, name: string, meta?: Record, -): { traceId: TraceId; close: () => void } { - const traceId = writer.nextId(); - const ts = performance.now(); - writer.write({ - traceId, parentTraceId, ts, - type: 'scope:open', name, meta, - }); - return { - traceId, - close() { +): Operation { + return resource(function* (provide) { + const traceId = writer.nextId(); + const ts = performance.now(); + writer.write({ traceId, parentTraceId, ts, type: 'scope:open', name, meta }); + try { + yield* provide(traceId); + } finally { writer.write({ traceId: writer.nextId(), parentTraceId: traceId, ts: performance.now(), type: 'scope:close', name, durationMs: performance.now() - ts, }); writer.flush(); - }, - }; + } + }); } diff --git a/packages/agents/src/trace-types.ts b/packages/agents/src/trace-types.ts index dc2841e1..f4656ba2 100644 --- a/packages/agents/src/trace-types.ts +++ b/packages/agents/src/trace-types.ts @@ -1,4 +1,5 @@ import type { ToolHistoryEntry } from './Agent'; +import type { Attachment } from '@lloyal-labs/media'; /** * Monotonically increasing trace ID @@ -46,7 +47,13 @@ export type TraceEvent = agentId?: number; promptText: string; taskContent?: string; - tokenCount: number; + /** What the prompt tokenizes to, when that is knowable BEFORE the + * prefill it seeds. Absent on the embedding rail: mtmd owns + * tokenization there, and this event is written write-ahead so a + * failed prefill still leaves something to replay from. The cost that + * actually landed is `branch:prefill.tokenCount`, which is written + * only after the KV moved. */ + tokenCount?: number; messages: string; tools?: string; grammar?: string; @@ -64,7 +71,16 @@ export type TraceEvent = | TraceEventBase & { type: 'branch:prefill'; branchHandle: number; - tokenCount: number; + /** KV CELLS this prefill added — not tokens. + * + * The two are equal on the token rail and are NOT equal on the + * embedding rail: M-RoPE decouples cells from positions, so one image + * costs hundreds of cells while advancing position far less. `cells` is + * the general unit (a token occupies one cell), which is why it names + * the field for every role rather than only the media ones. Compare it + * against `SegmentSource::cells()` and `DecodeSegmentsResult::cells`, + * which share the word so the numbers can be compared. */ + cells: number; role: 'spineHeader' | 'agentSuffix' | 'toolResult' | 'warmDelta' | 'probe' | 'recovery'; probeText?: string; /** Verbatim prefilled text. Populated for `warmDelta` (session-trunk @@ -73,6 +89,23 @@ export type TraceEvent = * pool-side prefills (spineHeader/toolResult/recovery), whose text is * already recoverable from prompt:format / tool:result / pool:recovery*. */ content?: string; + /** Images that entered the cache in this prefill, in marker order. + * Present only when the run has an attachment store recording them; + * `digest` resolves to the bytes through it. The prefill's `role` + * names which ingress they came through, which is what replay needs + * to rebuild the same delta rather than a differently-shaped one. + * + * Deliberately no per-image cell count: image cost is not additive. + * A model that pairs images temporally charges the same cells for two + * as for one (measured on Qwen3.5: 1 and 2 images both cost 580 cells, + * 3 and 4 both cost 1142), so a per-image share would be a fiction. + * `tokenCount` above is the whole prefill's real cost. + * + * `readonly`, matching `PreparedContent.attachments` — every value that + * reaches this field comes from there, and the two disagreeing was the + * only reason three call sites cast. A trace event is a record; nothing + * downstream has any business mutating it. */ + attachments?: readonly Attachment[]; } | TraceEventBase & { type: 'branch:prune'; branchHandle: number; position: number } @@ -184,9 +217,36 @@ export type TraceEvent = type: 'pool:recoveryFailed'; agentId: number; reason: string; + /** The MODEL'S output, truncated — which is what makes this a recovery + * diagnostic and not a general failure event. Two admission failures + * used to be reported here with a native error string in this field, + * making its own invariant false; they now have + * {@link TraceEvent} `pool:settleFailed`. */ outputExcerpt: string; } + // ── Admission failure ─────────────────────── + /** A tool result could not enter the cache, on either rail, so the agent is + * DISCARDED — terminal `agent:failed`, branch pruned, never resumed. + * + * Separate from `pool:recoveryFailed` because it is a different event about + * a different thing: nothing was recovered, nothing was produced, and the + * string worth recording is the failure's own, not the model's. On the + * embedding rail the branch is additionally POISONED — `decode_segments` is + * not atomic and partial-range KV ops are meaningless on recurrent layers — + * which is why the contract is prune and replay from content, never + * resume. */ + | TraceEventBase & { + type: 'pool:settleFailed'; + agentId: number; + /** `media_prefill_failed` (the embedding rail, branch poisoned) or + * `tool_result_failed` (the result could not be processed). */ + reason: 'media_prefill_failed' | 'tool_result_failed'; + /** Why it failed, from the failure itself — a native decode message or a + * thrown error. NOT the model's output. */ + detail: string; + } + // ── Agent lifecycle span ───────────────────── // Trace mirrors of the bus events: `agent:spawn` opens the agent's span // (`parentAgentId` = the parent BRANCH handle — the spine for pool @@ -245,7 +305,9 @@ export type TraceEvent = // serial path it equals dispatch order; emitted uniformly either way. | TraceEventBase & { type: 'tool:settle_order'; - batch: Array<{ agentId: number; callId: string; tokenCount: number }>; + /** `cells`, not tokens — a media item's admission cost is measured, and + * this is the same number SETTLE spent against headroom. */ + batch: Array<{ agentId: number; callId: string; cells: number }>; } | TraceEventBase & { type: 'tool:error'; agentId: number; tool: string; error: string } // Transient tool failure (ToolRetryError — e.g. provider rate-limited). diff --git a/packages/agents/src/trace-writer.ts b/packages/agents/src/trace-writer.ts index c7499fdd..2882df04 100644 --- a/packages/agents/src/trace-writer.ts +++ b/packages/agents/src/trace-writer.ts @@ -40,7 +40,7 @@ export class NullTraceWriter implements TraceWriter { * * Buffers up to 64 events in memory before flushing to the underlying * file descriptor with `fs.writeSync`. Flush also occurs at every - * {@link traceScope} close boundary to guarantee scope pairs are + * {@link useTraceScope} close boundary to guarantee scope pairs are * persisted promptly. * * Construct with an open file descriptor (e.g. from `fs.openSync`). diff --git a/packages/agents/src/use-agent.ts b/packages/agents/src/use-agent.ts index ce075acd..96a91c02 100644 --- a/packages/agents/src/use-agent.ts +++ b/packages/agents/src/use-agent.ts @@ -6,7 +6,7 @@ import { Agent } from './Agent'; import { Ctx, Events, Trace } from './context'; import { useAgentPool } from './agent-pool'; import { createToolkit } from './toolkit'; -import { traceScope } from './trace-scope'; +import { useTraceScope } from './trace-scope'; import { parallel } from './orchestrators'; import type { Tool } from './Tool'; import type { AgentPolicy } from './AgentPolicy'; @@ -99,7 +99,7 @@ export function useAgent(opts: UseAgentOpts): Operation { const toolkit = createToolkit(opts.tools ?? [], opts.terminal); const warmParent = opts.parent ?? opts.session?.trunk ?? undefined; - const scope = traceScope(tw, null, 'useAgent', { + const scopeId = yield* useTraceScope(tw, null, 'useAgent', { hasTools: toolkit.tools.length > 0, hasParent: !!warmParent, }); @@ -152,8 +152,6 @@ export function useAgent(opts: UseAgentOpts): Operation { } const pool = next.value; - scope.close(); - yield* provide(pool.agents[0].agent); // Resource stays alive — branch alive for caller to fork from // ensure() prunes root on scope exit diff --git a/packages/agents/test/Agent.test.ts b/packages/agents/test/Agent.test.ts index 2c91dce2..5ed9b70a 100644 --- a/packages/agents/test/Agent.test.ts +++ b/packages/agents/test/Agent.test.ts @@ -2,10 +2,7 @@ import { describe, it, expect } from 'vitest'; import { Agent } from '../src/Agent'; import { createMockBranch } from './helpers/mock-branch'; -const FMT = { - format: 0, reasoningFormat: 0, generationPrompt: '', - parser: '', grammar: '', grammarLazy: false, grammarTriggers: [], -}; +import { FMT } from './helpers/format-config'; function makeAgent(opts?: { parent?: Agent; id?: number }) { const branch = createMockBranch({ handle: opts?.id ?? 1 }); @@ -137,7 +134,7 @@ describe('Agent', () => { const a = makeAgent(); a.recordToolResult({ name: 'web_search', args: 'test query', - resultTokenCount: 100, contextAfterPercent: 80, timestamp: 0, + resultCells: 100, contextAfterPercent: 80, timestamp: 0, }); expect(a.toolHistory).toHaveLength(1); expect(a.toolHistory[0].name).toBe('web_search'); @@ -147,7 +144,7 @@ describe('Agent', () => { describe('walkAncestors', () => { it('returns own data when no parent', () => { const a = makeAgent(); - a.recordToolResult({ name: 'search', args: 'q', resultTokenCount: 0, contextAfterPercent: 100, timestamp: 0 }); + a.recordToolResult({ name: 'search', args: 'q', resultCells: 0, contextAfterPercent: 100, timestamp: 0 }); const result = a.walkAncestors((agent) => agent.toolHistory); expect(result).toHaveLength(1); expect(result[0].name).toBe('search'); @@ -155,13 +152,13 @@ describe('Agent', () => { it('traverses self → parent → grandparent', () => { const grandparent = makeAgent({ id: 1 }); - grandparent.recordToolResult({ name: 'gp', args: '', resultTokenCount: 0, contextAfterPercent: 100, timestamp: 0 }); + grandparent.recordToolResult({ name: 'gp', args: '', resultCells: 0, contextAfterPercent: 100, timestamp: 0 }); const parent = makeAgent({ id: 2, parent: grandparent }); - parent.recordToolResult({ name: 'p', args: '', resultTokenCount: 0, contextAfterPercent: 100, timestamp: 0 }); + parent.recordToolResult({ name: 'p', args: '', resultCells: 0, contextAfterPercent: 100, timestamp: 0 }); const child = makeAgent({ id: 3, parent }); - child.recordToolResult({ name: 'c', args: '', resultTokenCount: 0, contextAfterPercent: 100, timestamp: 0 }); + child.recordToolResult({ name: 'c', args: '', resultCells: 0, contextAfterPercent: 100, timestamp: 0 }); const names = child.walkAncestors((a) => a.toolHistory).map((h) => h.name); expect(names).toEqual(['c', 'p', 'gp']); diff --git a/packages/agents/test/AgentPolicy.test.ts b/packages/agents/test/AgentPolicy.test.ts index b3e8affa..3857f965 100644 --- a/packages/agents/test/AgentPolicy.test.ts +++ b/packages/agents/test/AgentPolicy.test.ts @@ -4,10 +4,7 @@ import type { PolicyConfig } from '../src/AgentPolicy'; import { Agent } from '../src/Agent'; import { createMockBranch } from './helpers/mock-branch'; -const FMT = { - format: 0, reasoningFormat: 0, generationPrompt: '', - parser: '', grammar: '', grammarLazy: false, grammarTriggers: [], -}; +import { FMT } from './helpers/format-config'; const BASE_CONFIG: PolicyConfig = { maxTurns: 20, terminalToolName: 'report', hasNonTerminalTools: true }; @@ -18,7 +15,7 @@ function makeAgent(overrides?: { toolCallCount?: number; turns?: number; toolHis for (let i = 0; i < (overrides?.toolCallCount ?? 0); i++) a.incrementToolCalls(); for (let i = 0; i < (overrides?.turns ?? 0); i++) a.incrementTurns(); for (const h of overrides?.toolHistory ?? []) { - a.recordToolResult({ name: h.name, args: h.args, resultTokenCount: 100, contextAfterPercent: 80, timestamp: 0 }); + a.recordToolResult({ name: h.name, args: h.args, resultCells: 100, contextAfterPercent: 80, timestamp: 0 }); } return a; } @@ -275,20 +272,20 @@ describe('DefaultAgentPolicy', () => { describe('onRecovery', () => { it('returns skip when no recovery config', () => { - const result = policy.onRecovery(makeAgent({ toolCallCount: 5 })); + const result = policy.onRecovery!(makeAgent({ toolCallCount: 5 }), pressure()); expect(result).toEqual({ type: 'skip' }); }); it('returns skip when tokenCount < minTokens', () => { const p = new DefaultAgentPolicy({ recovery: { prompt: { system: 's', user: 'u' }, minTokens: 200 } }); const a = makeAgent({ toolCallCount: 5 }); // tokenCount=0 < 200 - expect(p.onRecovery(a)).toEqual({ type: 'skip' }); + expect(p.onRecovery!(a, pressure())).toEqual({ type: 'skip' }); }); it('returns skip when toolCallCount < minToolCalls', () => { const p = new DefaultAgentPolicy({ recovery: { prompt: { system: 's', user: 'u' }, minToolCalls: 5 } }); const a = makeAgent({ toolCallCount: 2 }); // 2 < 5 - expect(p.onRecovery(a)).toEqual({ type: 'skip' }); + expect(p.onRecovery!(a, pressure())).toEqual({ type: 'skip' }); }); it('returns extract with prompt when guard passes', () => { diff --git a/packages/agents/test/agent-pool.test.ts b/packages/agents/test/agent-pool.test.ts index a774ce6b..69f05f37 100644 --- a/packages/agents/test/agent-pool.test.ts +++ b/packages/agents/test/agent-pool.test.ts @@ -9,13 +9,19 @@ * transitions, trace events, event emissions, ToolContext fields, recovery. */ import { describe, it, expect } from 'vitest'; +import { MediaTool, PNG_BYTES, MEDIA_TEST_NCTX, mediaFailures } from './helpers/media'; import { run, createChannel, createSignal, spawn, each, scoped, call } from 'effection'; import type { Operation, Channel } from 'effection'; import { MockSessionContext, createMockSdk } from '../../sdk/test/MockSessionContext'; import type { ChatFormat, ParseChatOutputOptions, ParseChatOutputResult } from '@lloyal-labs/sdk'; import { useAgentPool } from '../src/agent-pool'; import { parallel } from '../src/orchestrators'; -import { Ctx, Store, Events, Trace, WindDown } from '../src/context'; +import { Ctx, Store, Events, Trace, WindDown, Attachments, Ingress } from '../src/context'; +import { MemoryAttachmentStore } from './helpers/memory-store'; +import { rawIngress } from './helpers/raw-ingress'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { Tool } from '../src/Tool'; import type { AgentPolicy } from '../src/AgentPolicy'; import type { AgentPoolResult, AgentEvent, ToolContext } from '../src/types'; @@ -50,6 +56,8 @@ async function runPool(opts: { windDownOn?: (ev: AgentEvent) => boolean; /** Last-chance ctx mutation hook — runs after fork/sample wiring, before the pool. */ mutateCtx?: (ctx: MockSessionContext) => void; + /** Install an ingress that refuses everything, to exercise the barrier. */ + refusingIngress?: boolean; }): Promise<{ result: AgentPoolResult; events: AgentEvent[]; @@ -106,6 +114,20 @@ async function runPool(opts: { const events: Channel = createChannel(); yield* Events.set(events as any); yield* Trace.set(traceWriter); + // A real store: media paths now REFUSE to run without one, because + // unaddressed media makes a run unreplayable. Tests that exercise them + // must be configured the way a real harness is. + const contentStore = new MemoryAttachmentStore(); + yield* Attachments.set(contentStore); + // Media now refuses to run without an ingress, because unnormalized, + // unaddressed bytes make a run unreplayable. Tests use a raw one — they + // exercise the rail, not the normalizer. + yield* Ingress.set( + opts.refusingIngress + ? { ingest: () => Promise.reject(new Error('ingress refused')) } + : rawIngress(contentStore), + ); + const windDownSignal = createSignal(); if (opts.windDownOn) yield* WindDown.set(windDownSignal); @@ -127,7 +149,7 @@ async function runPool(opts: { tools: opts.tools ?? new Map(), policy: opts.policy, maxTurns: opts.maxTurns ?? 100, - terminalToolName: opts.terminalToolName, + terminalToolName: opts.terminalTool, trace: opts.trace ?? false, pruneOnReturn: opts.pruneOnReturn ?? false, }); @@ -150,9 +172,12 @@ async function runPool(opts: { } /** Minimal policy stub — every method overridable */ +// `onProduced` stays required — it is the reason to build a stub at all. +// `onSettleReject` does not: the interface makes it optional and the pool calls +// it with `?.`, so demanding it here forced every caller to supply a hook the +// runtime never needs. function stubPolicy(overrides: Partial & { onProduced: AgentPolicy['onProduced']; - onSettleReject: AgentPolicy['onSettleReject']; }): AgentPolicy { return { onProduced: overrides.onProduced, @@ -1544,7 +1569,7 @@ describe('no-tool agent seams', () => { ...orig(msgs, fmtOpts), grammar: 'root ::= toolcall', grammarLazy: true, - grammarTriggers: [{ type: 1, value: '' }], + grammarTriggers: [{ type: 1, value: '', token: -1 }], }); }; @@ -1922,3 +1947,198 @@ describe('unlimited-context pressure serialization', () => { expect(ticks[0].pressure.nCtx).toBe(0); // finite fields stay numbers }); }); + +// ── The tool-result media ingress (PR-2) ──────────────────────────── +// +// A tool that returns image bytes is the third way media enters KV. The +// failure this guards is not an exception: `JSON.stringify` turns a 180 KB +// image into ~700k characters of digits, which prefills "successfully" and +// destroys the agent's context. Everything below asserts the bytes took the +// embedding rail instead, and that one bad image costs only its own agent. + + +/** Every agent calls the media tool once, then stops. */ +const oneToolCall = (toolName: string) => ({ + parseChatOutputFn: (raw: string) => { + if (!raw || raw === '') return { content: '', reasoningContent: '', toolCalls: [] }; + return { + content: '', reasoningContent: '', + toolCalls: [{ name: toolName, arguments: '{"q":"x"}', id: 'c1' }], + }; + }, + policy: stubPolicy({ + shouldExit: () => false, + onProduced: (_a: Agent, parsed: ParseChatOutputResult) => + parsed.toolCalls.length > 0 + ? { type: 'tool_call' as const, tc: parsed.toolCalls[0] } + : { type: 'idle' as const, reason: 'free_text_stop' }, + }), +}); + +describe('tool results carrying images', () => { + it('sends the bytes down the embedding rail, never through JSON', async () => { + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { ctx, events } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + forkTokenQueues: [[1, STOP, STOP]], + tools: toolMap, + ...oneToolCall('rasterize'), + }); + + // The rail: one multimodal prefill carrying one image. + expect(ctx.multimodalPrefills).toHaveLength(1); + expect(ctx.multimodalPrefills[0].bitmapCounts).toEqual([1]); + + // And the bytes are NOT in the text the model was handed. `137,80,78,71` + // is what this PNG's header serializes to as JSON digits — the exact + // corruption this ingress exists to prevent. + const shown = events.filter(e => e.type === 'agent:tool_result') + .map(e => (e as { result: string }).result).join(''); + expect(shown).not.toContain('137,80,78,71'); + expect(shown).not.toContain('_images'); + expect(shown).toContain('p1'); + }); + + it('fails only the agent whose image was bad', async () => { + // Two agents settle images in the same tick; the cohort call reports one + // failure. The sibling must still land — losing it would be the rejected- + // promise behaviour this ingress deliberately does not have. + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { events, ctx } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + taskCount: 2, + forkTokenQueues: [[1, STOP, STOP], [1, STOP, STOP]], + tools: toolMap, + mutateCtx: (c) => { + let seen = 0; + c.mockMultimodalError = () => (seen++ === 0 ? 'corrupt image data' : null); + }, + ...oneToolCall('rasterize'), + }); + + // Exactly one agent fails FOR THIS REASON — the sibling's own terminal + // (`recovery_skipped`, the stub policy's normal end) is not a media failure. + const failures = mediaFailures(events); + expect(failures).toHaveLength(1); + + // And that agent is really finished: its branch was pruned as poisoned, so + // waking it again would have it sample from a disposed branch. Nothing it + // emits may come after the failure. + // ...and the SURVIVOR still finishes. This is the property, stated as the + // run sees it: waking the poisoned agent — whose branch was pruned a few + // lines earlier — takes the whole run down with it, and the sibling that + // had nothing wrong with its image never reaches a terminal event at all. + const deadId = (failures[0] as { agentId: number }).agentId; + const survivorId = events + .filter(e => e.type === 'agent:spawn') + .map(e => (e as { agentId: number }).agentId) + .find(id => id !== deadId); + expect(survivorId).toBeDefined(); + expect(events.some(e => (e as { agentId?: number }).agentId === survivorId + && (e.type === 'agent:done' || e.type === 'agent:failed'))).toBe(true); + // The cohort was issued as ONE call with both entries — the sibling was + // not re-dispatched or lost. + expect(ctx.multimodalPrefills[0].bitmapCounts).toEqual([1, 1]); + }); + + it('re-activates an agent on a tick where ONLY media settled', async () => { + // The token-prefill list is empty on such a tick. An agent left parked + // here would sit in awaiting_tool with its result already in KV, and + // nothing would ever wake it. + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { events } = await runPool({ + forkTokenQueues: [[1, STOP, STOP]], + tools: toolMap, + ...oneToolCall('rasterize'), + }); + // `recovery_skipped` is the stub policy's normal terminal (it defines no + // onRecovery) — the media path must not add a failure of its own. + expect(mediaFailures(events)).toHaveLength(0); + expect(events.some(e => e.type === 'agent:tool_result')).toBe(true); + expect(events.some(e => e.type === 'agent:done')).toBe(true); + }); + + it('charges admission the MEASURED cells, so an oversized image defers', async () => { + // The sharp case for the cost expression: a media item's `prefillTokens` + // is empty (mtmd tokenizes downstream), so a cost read from it would be + // ZERO and every image would be admitted regardless of headroom — + // over-committing KV silently. It must be charged the measured cells. + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { ctx, events } = await runPool({ + // The scaffold's real default (harness.yml `context: 32768`), not a + // number squeezed until something breaks: starving nCtx to force the + // refusal stops the agent spawning at all, and then the test passes + // because NOTHING happened. The image price is the only lever here. + nCtx: MEDIA_TEST_NCTX, + forkTokenQueues: [[1, STOP, STOP]], + tools: toolMap, + // One image priced well past what is left. + mutateCtx: (c) => { c.mockImageCells = 100_000; }, + ...oneToolCall('rasterize'), + }); + + // The agent really ran and really called the tool... + expect(events.some(e => e.type === 'agent:tool_result')).toBe(true); + // ...and its image was still refused admission. Charged at zero it would + // have sailed through and landed here. + expect(ctx.multimodalPrefills).toHaveLength(0); + expect(mediaFailures(events)).toHaveLength(0); + }); + + it('fails the agent when ingress refuses, without retrying the tool', async () => { + // A post-tool ingress failure must NOT become a tool retry: the tool + // already ran and may have had an external side effect, so re-running it + // is not a neutral act. The agent fails through the normal path and its + // branch is pruned — never silently dropped, never repeated. + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { ctx, events } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + forkTokenQueues: [[1, STOP, STOP]], + tools: toolMap, + // An ingress that refuses stands in for a normalization or commit + // failure at the barrier — before admission, before any prefill. + refusingIngress: true, + ...oneToolCall('rasterize'), + }); + // Nothing reached the embedding rail... + expect(ctx.multimodalPrefills).toHaveLength(0); + // ...and the tool ran exactly ONCE. A post-tool failure must not be + // retried: the tool may already have had an external side effect. + expect(events.filter(e => e.type === 'agent:tool_call')).toHaveLength(1); + + // THE ASSERTION THIS TEST WAS MISSING. Both facts above stayed true while + // the whole pool was being torn down: the throw escaped `processCompletion` + // (called outside any try) into the tick loop's own catch, which closes the + // channel with a partial result, no trace and no `agent:failed`. The test + // passed for two years' worth of the wrong reason. + // + // The agent must FAIL — visibly, by name — rather than vanish. + expect(mediaFailures(events).length + events.filter(e => + e.type === 'agent:failed' + && (e as { reason?: string }).reason === 'tool_result_failed').length, + ).toBe(1); + // And it must be THIS agent, reported through the bus, not an absence. + const failed = events.find(e => e.type === 'agent:failed'); + expect(failed).toBeDefined(); + expect((failed as { agentId: number }).agentId).toBeTypeOf('number'); + }); + + it('tells the model when the runtime cannot see images', async () => { + // Dropping them silently would leave the agent reasoning about a picture + // it was never shown. The note goes where the model will read it. + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { ctx, events } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + forkTokenQueues: [[1, STOP, STOP]], + tools: toolMap, + mutateCtx: (c) => { c.mockSupportsVision = false; }, + ...oneToolCall('rasterize'), + }); + + expect(ctx.multimodalPrefills).toHaveLength(0); + const shown = events.filter(e => e.type === 'agent:tool_result') + .map(e => (e as { result: string }).result).join(''); + expect(shown).toContain('cannot see images'); + expect(shown).not.toContain('137,80,78,71'); + }); +}); diff --git a/packages/agents/test/attachments.test.ts b/packages/agents/test/attachments.test.ts new file mode 100644 index 00000000..07c01b64 --- /dev/null +++ b/packages/agents/test/attachments.test.ts @@ -0,0 +1,426 @@ +/** + * The attachments store and the replay path that depends on it. + * + * A trace records the media marker, not the pixels, so replaying a + * media-seeded spine means putting the original bytes back into the cache. + * The failure that matters is not an exception — it is a replay that SUCCEEDS + * having tokenized the marker as text, producing a different KV state behind + * an identical-looking prompt. Every case below that ends in a throw is + * guarding that one silent outcome. + */ +import { describe, it, expect } from 'vitest'; +import { run, scoped, call, createScope } from 'effection'; +import type { Operation } from 'effection'; +import type { ContentIngress } from '@lloyal-labs/media'; +import { mkdtempSync, existsSync, readdirSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { MockSessionContext } from '../../sdk/test/MockSessionContext'; +import { BranchStore } from '../../sdk/src/BranchStore'; +import { NullAttachmentStore } from '@lloyal-labs/media'; +import type { AttachmentStore } from '@lloyal-labs/media'; +import { MemoryAttachmentStore } from './helpers/memory-store'; +import { representationsOf, sourceOf, ATTACHMENT_ARTIFACT_TYPE, EMPTY_DESCRIPTOR, MANIFEST_TYPE } from '@lloyal-labs/media'; +import type { Attachment } from '@lloyal-labs/media'; + +/** A manifest descriptor, shaped the way a store would have returned one. + * `Attachment` is branded, so a test that fabricates a root says so here + * rather than in four places — and cannot pass a REPRESENTATION descriptor + * where a root belongs, which is the confusion the marker guard once + * shipped. */ +const attachmentRef = (digest: string, size = 9): Attachment => + ({ digest, mediaType: MANIFEST_TYPE, size }) as Attachment; +import { sniffMediaType } from '@lloyal-labs/media'; +import { materialize } from '@lloyal-labs/media'; +import { prepareBatch } from '../src/prepare-content'; +import { initAgents } from '../src/init'; +import { Branch } from '../../sdk/src/Branch'; +import { CapturingTraceWriter } from './helpers/capturing-trace'; +import { rawIngress } from './helpers/raw-ingress'; +import { reconstructBranch, extractSpineSeed, type BranchCheckpoint } from '../src/replay'; +import { Ctx, Store, Attachments } from '../src/context'; +import type { TraceEvent } from '../src/trace-types'; + +const MARKER = '<__media__>'; +const tmp = (): string => mkdtempSync(join(tmpdir(), 'lloyal-att-')); + +// Real magic bytes — the sniffer reads these, so a fake header would test +// nothing about the format table. +const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]); +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 9]); +const GIF = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); +const BMP = new Uint8Array([0x42, 0x4d, 7, 7]); +const JUNK = new Uint8Array([0x00, 0x01, 0x02, 0x03]); + +/** Store bytes as a one-representation attachment — the shape a + * non-normalizing ingress produces. */ +const attach = (store: AttachmentStore, bytes: Uint8Array) => + store.putAttachment({ representations: [store.putBlob(bytes, sniffMediaType(bytes))!] }); + +/** How many attachment manifests a store actually committed. Reads the store + * through its own surface rather than its internals, so it says the same + * thing whatever backs it. */ +function committedManifests(store: MemoryAttachmentStore): number { + let n = 0; + for (const digest of store.blobs.keys()) if (store.getManifest(digest)) n++; + return n; +} + +describe('extractSpineSeed', () => { + const seedEvent = (traceId: number, parentTraceId: number | null, text: string) => ({ + traceId, parentTraceId, ts: 0, type: 'prompt:format' as const, + promptText: text, tokenCount: 5, messages: '[]', role: 'spine' as const, + }); + const headerEvent = (parentTraceId: number | null, digest: string) => ({ + traceId: 99, parentTraceId, ts: 0, type: 'branch:prefill' as const, + branchHandle: 1, cells: 5, role: 'spineHeader' as const, + attachments: [attachmentRef(digest)], + }); + + it('pairs the seed with the attachments emitted in its own scope', () => { + const events: TraceEvent[] = [seedEvent(1, 7, `hi ${MARKER}`), headerEvent(7, 'd1')]; + expect(extractSpineSeed(events).seedAttachments).toEqual([attachmentRef('d1')]); + }); + + it('does not claim another pool spine’s images', () => { + // Two spines in one trace: the outer seed must not adopt the nested + // pool's attachments just because they appear in the same file. + const events: TraceEvent[] = [seedEvent(1, 7, 'text only'), headerEvent(42, 'other')]; + expect(extractSpineSeed(events).seedAttachments).toBeUndefined(); + }); +}); + +describe('reconstructBranch', () => { + // Typed as the CONTRACT, not the null object: the default is a + // NullAttachmentStore but callers pass a MemoryAttachmentStore, and + // inferring the parameter from the default would reject every one of them. + const withCtx = ( + fn: (ctx: MockSessionContext) => unknown, + store: AttachmentStore = new NullAttachmentStore(), + ) => + run(function*() { + const ctx = new MockSessionContext(); + return yield* scoped(function*() { + yield* Ctx.set(ctx as never); + yield* Store.set(new BranchStore(ctx as never)); + yield* Attachments.set(store); + return yield* (fn as (c: MockSessionContext) => Operation)(ctx); + }); + }); + + const cp = (over: Partial = {}): BranchCheckpoint => + ({ seedPrompt: 'plain seed', turns: [], ...over }); + + it('replays a text-only spine unchanged', async () => { + const branch = await withCtx(function*() { + return yield* reconstructBranch(cp()); + }); + expect(branch).toBeDefined(); + }); + + it('refuses a marker with no attachment references', async () => { + // The pre-attachments behaviour, preserved: a trace that recorded only + // the marker still cannot be replayed. + await expect(withCtx(function*() { + return yield* reconstructBranch(cp({ seedPrompt: `look ${MARKER}` })); + })).rejects.toThrow(/carries no attachment references/); + }); + + it('refuses when the context has no projector', async () => { + const dir = tmp(); + const store = new MemoryAttachmentStore(); + const ref = attach(store, PNG); + await expect(run(function*() { + const ctx = new MockSessionContext(); + ctx.mockSupportsVision = false; + return yield* scoped(function*() { + yield* Ctx.set(ctx as never); + yield* Store.set(new BranchStore(ctx as never)); + yield* Attachments.set(store); + return yield* reconstructBranch(cp({ + seedPrompt: `look ${MARKER}`, seedAttachments: [ref], + })); + }); + })).rejects.toThrow(/no vision projector/); + }); + + it('refuses when the bytes are gone from the store', async () => { + // The attachments directory moved or was pruned. Replaying the marker as + // text here would look like success and be a different KV state. + await expect(withCtx(function*() { + return yield* reconstructBranch(cp({ + seedPrompt: `look ${MARKER}`, + seedAttachments: [attachmentRef('sha256:' + 'e'.repeat(64))], + })); + }, new MemoryAttachmentStore())).rejects.toThrow(/not in the content store/); + }); + + it('replays N markers from ONE multi-representation attachment', async () => { + // The case the old guard rejected: it compared marker count against + // ATTACHMENT count, so a single manifest holding two sampled frames threw + // before resolution ran. One manifest is one image, one video, or one live + // capture — the count that must match is REPRESENTATIONS. + const store = new MemoryAttachmentStore(); + const video = store.putAttachment({ + representations: [ + store.putBlob(PNG, 'image/png', { 'ai.lloyal.derive.frame': '0' })!, + store.putBlob(JPEG, 'image/jpeg', { 'ai.lloyal.derive.frame': '1' })!, + ], + source: store.putBlob(GIF, 'video/mp4')!, + }); + const image = attach(store, BMP); + + const seen = await withCtx(function*(ctx: MockSessionContext) { + // Three markers, TWO attachment descriptors. + yield* reconstructBranch(cp({ + seedPrompt: `${MARKER} ${MARKER} and ${MARKER}`, + seedAttachments: [video, image], + })); + return ctx; + }, store) as MockSessionContext; + + expect(seen.multimodalPrefills[0].bitmapCounts).toEqual([3]); + }); + + it('refuses when representations and markers disagree', async () => { + const store = new MemoryAttachmentStore(); + const two = store.putAttachment({ + representations: [ + store.putBlob(PNG, 'image/png')!, + store.putBlob(JPEG, 'image/jpeg')!, + ], + }); + // One marker, one attachment — but it expands to two representations, so + // rebuilding would put two images where the prompt marks one. + await expect(withCtx(function*() { + return yield* reconstructBranch(cp({ + seedPrompt: `look ${MARKER}`, seedAttachments: [two], + })); + }, store)).rejects.toThrow(/expand to 2 representation/); + }); + + it('replays a media-seeded spine down the embedding rail', async () => { + const store = new MemoryAttachmentStore(); + const ref = attach(store, PNG); + const seen = await withCtx(function*(ctx: MockSessionContext) { + yield* reconstructBranch(cp({ + seedPrompt: `look ${MARKER}`, seedAttachments: [ref], + })); + return ctx; + }, store) as MockSessionContext; + // The proof is the rail, not the absence of a throw: a marker tokenized + // as text would go down the token path and never reach this array. Byte + // fidelity is the store round-trip's job, above. + expect(seen.multimodalPrefills).toHaveLength(1); + expect(seen.multimodalPrefills[0].bitmapCounts).toEqual([1]); + expect(seen.multimodalPrefills[0].prompts[0]).toContain(MARKER); + }); +}); + +describe('the trunk ingress (warmDelta)', () => { + const runTurn = (images: Uint8Array[]) => + run(function*() { + const ctx = new MockSessionContext(); + const tw = new CapturingTraceWriter(); + const store = new MemoryAttachmentStore(); + return yield* scoped(function*() { + const { session } = yield* initAgents(ctx as never, { + traceWriter: tw, + attachmentStore: store, + }); + session.trunk = Branch.create(ctx as never, 0, {}); + if (images.length > 0) { + // The real caller's order: BARRIER first, then prefill the admitted + // representations, then the observer records roots that are already + // committed. Nothing is stored after the prefill any more. + const prepared = yield* call(() => prepareBatch( + rawIngress(store), store, + images, + )); + yield* call(() => session.prefillUserMultimodal( + 'what is this?', + prepared.bitmaps as Uint8Array[], + { attachments: prepared.attachments }, + )); + } else { + yield* call(() => session.prefillUser('what is this?')); + } + return tw.events.filter( + (e): e is Extract => + e.type === 'branch:prefill' && e.role === 'warmDelta', + ); + }); + }); + + it('records the images a user attached to their turn', async () => { + const events = await runTurn([PNG, JPEG]); + expect(events).toHaveLength(1); + // This ingress records the RAW turn text, not the rendered prompt, so + // `content` carries no marker — the references are the only thing in the + // trace that says this turn had images at all. That is what makes them + // load-bearing here rather than decorative. + expect(events[0].content).toBe('what is this?'); + expect(events[0].content).not.toContain(MARKER); + expect(events[0].attachments).toHaveLength(2); + // Each reference is a MANIFEST — the per-image media types live on its + // layers, which is what makes video (one manifest, N frames) additive. + expect(events[0].attachments!.map(a => a.mediaType)) + .toEqual(Array(2).fill(MANIFEST_TYPE)); + }); + + it('projects trunk media ONCE, however many agents fork from it', async () => { + // The differentiator, asserted rather than assumed: the image is prefilled + // onto the trunk once, and every fork inherits those cells. N agents cost + // one projection, not N. + const ctx = new MockSessionContext(); + const store = new MemoryAttachmentStore(); + await run(function*() { + return yield* scoped(function*() { + yield* Ctx.set(ctx as never); + yield* Store.set(new BranchStore(ctx as never)); + yield* Attachments.set(store); + const { session } = yield* initAgents(ctx as never, { attachmentStore: store }); + session.trunk = Branch.create(ctx as never, 0, {}); + const prepared = yield* call(() => prepareBatch( + rawIngress(store), store, [PNG], + )); + yield* call(() => session.prefillUserMultimodal('what is this?', + prepared.bitmaps as Uint8Array[], { attachments: prepared.attachments })); + // Fork several branches off the trunk, as a pool would, then prune + // them the way a pool does on return — the trunk cannot dispose with + // live children. + const forks = Array.from({ length: 4 }, () => session.trunk!.forkSync()); + for (const f of forks) f.pruneSync(); + return null; + }); + }); + expect(ctx.multimodalPrefills).toHaveLength(1); + expect(ctx.multimodalPrefills[0].bitmapCounts).toEqual([1]); + }); + + it('leaves the field absent on a text-only turn', async () => { + const events = await runTurn([]); + expect(events).toHaveLength(1); + // The distinction being guarded is `[]` vs nothing: an empty array reads + // downstream as a media turn whose images all failed to store, which is a + // different claim from a text turn. (`undefined` vs an absent key is not + // guarded here and needs no guard — JSON.stringify drops both, and the + // trace is JSONL.) + expect(events[0].attachments).toBeUndefined(); + }); +}); + +describe('prepareBatch — the barrier before any prefill', () => { + it('a halted scope ABORTS the ingress it was waiting on', async () => { + // The conformance property for this whole boundary. `call()` makes a halt + // OBSERVABLE here, but it cannot stop the promise behind it — the leaked + // effect Effection's own docs warn about — so what actually reaches the + // non-Effection side is the signal. Without it a cancelled run keeps + // occupying the normalizer's queue with work nobody will read. + // + // `AbortSignal` is the currency precisely because it is NOT framework + // shaped: `@lloyal-labs/media` carries no Effection dependency, and the + // HTTP ingress route calls the same function from a plain Node handler. + const store = new MemoryAttachmentStore(); + let sawAbort = false; + let entered!: () => void; + const reachedIngress = new Promise((r) => { entered = r; }); + + const hanging: ContentIngress = { + ingest: (_bytes, signal) => new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + sawAbort = true; + reject(new Error('aborted')); + }); + entered(); + }), + }; + + const [scope, destroy] = createScope(); + // A halted task rejects; nothing here is asserting on its outcome. + scope.run(function*() { return yield* prepareBatch(hanging, store, [PNG]); }) + .catch(() => { /* halted */ }); + + await reachedIngress; + await destroy(); + + expect(sawAbort, 'the ingress never saw the scope go away').toBe(true); + }); + + /** An ingress that admits the first `failAt` items and then refuses. */ + const flaky = (store: AttachmentStore, failAt: number) => { + let n = 0; + return { + ingest: async (bytes: Uint8Array) => { + if (n++ === failAt) throw new Error('ingest refused item ' + failAt); + const rep = store.putBlob(bytes, sniffMediaType(bytes)); + return store.putAttachment({ representations: [rep] }); + }, + }; + }; + + it('flattens by REPRESENTATION, preserving order across attachments', async () => { + // Markers correspond to representations, not roots: a video contributes + // its frames, an image contributes one. + const store = new MemoryAttachmentStore(); + const video = store.putAttachment({ + representations: [store.putBlob(PNG, 'image/png')!, store.putBlob(JPEG, 'image/jpeg')!], + source: store.putBlob(GIF, 'video/mp4')!, + }); + const image = store.putAttachment({ representations: [store.putBlob(BMP, 'image/bmp')!] }); + + const prepared = materialize(store, [video, image]); + expect(prepared.attachments).toHaveLength(2); + expect(prepared.bitmaps).toHaveLength(3); + // Attachment order outer, representation order inner — and never the + // source, which the model did not see. + expect(prepared.bitmaps.map(b => Array.from(b.slice(0, 2)))) + .toEqual([[0x89, 0x50], [0xff, 0xd8], [0x42, 0x4d]]); + }); + + it('admits the whole batch or none of it', async () => { + const store = new MemoryAttachmentStore(); + const items = [PNG, JPEG, BMP]; + const ok = await run(function*() { return yield* prepareBatch(flaky(store, -1), store, items); }); + expect(ok.bitmaps).toHaveLength(3); + expect(ok.attachments).toHaveLength(3); + }); + + it('a failure on item N publishes NOTHING, though 1..N-1 may orphan', async () => { + const store = new MemoryAttachmentStore(); + const items = [PNG, JPEG, BMP]; + await expect(run(function*() { + return yield* prepareBatch(flaky(store, 2), store, items); + })).rejects.toThrow(/refused item 2/); + + // Items 0 and 1 committed before the failure. That is HARMLESS — + // content-addressed, referenced by nothing — and is the orphan class the + // write-order invariant already accepts. What matters is that the caller + // got nothing back, so it cannot have emitted a marker or prefilled. + expect(committedManifests(store)).toBe(2); + }); + + it('refuses the batch when no content store is installed', async () => { + // The gate that makes addressability a precondition rather than a + // best-effort record: a media-bearing run with no store fails HERE, before + // any KV is touched. + const store = new NullAttachmentStore(); + await expect(run(function*() { + return yield* prepareBatch( + { ingest: () => Promise.reject(new Error('unused')) }, store, [], + ); + })).resolves.toEqual({ attachments: [], bitmaps: [] }); // text-only: unaffected + }); + + it('keeps the index writer serialized by staying SYNCHRONOUS', () => { + // One shared store instance only guarantees a single serialized index + // writer while the read-modify-write has no yield point. Making these + // async would silently reintroduce the interleaving — so the property is + // asserted, not assumed. + const store = new MemoryAttachmentStore(); + const d = store.putBlob(PNG, 'image/png'); + expect(d).not.toBeInstanceOf(Promise); + expect(store.putAttachment({ representations: [d!] })).not.toBeInstanceOf(Promise); + expect(store.get(d!.digest)).not.toBeInstanceOf(Promise); + }); +}); diff --git a/packages/agents/test/authGuard.test.ts b/packages/agents/test/authGuard.test.ts index cd7da918..caae5ff6 100644 --- a/packages/agents/test/authGuard.test.ts +++ b/packages/agents/test/authGuard.test.ts @@ -33,19 +33,12 @@ */ import { describe, it, expect } from 'vitest'; +import type { ParsedToolCall } from '@lloyal-labs/sdk'; import { DefaultAgentPolicy, type PolicyConfig, type ToolGuard } from '../src/AgentPolicy'; import { Agent } from '../src/Agent'; import { createMockBranch } from './helpers/mock-branch'; -const FMT = { - format: 0, - reasoningFormat: 0, - generationPrompt: '', - parser: '', - grammar: '', - grammarLazy: false, - grammarTriggers: [], -}; +import { FMT } from './helpers/format-config'; const BASE: Omit = { maxTurns: 20, @@ -81,7 +74,7 @@ function makeAgent(opts: { agent.recordToolResult({ name: h.name, args: h.args, - resultTokenCount: 100, + resultCells: 100, contextAfterPercent: 80, timestamp: 0, }); @@ -103,8 +96,12 @@ function pressure(remaining = 5000, nCtx = 16384) { }; } -function tc(name: string, args: Record = {}) { - return { name, arguments: JSON.stringify(args) }; +/** A parsed tool call. Typed as the real `ParsedToolCall` so a field added to + * the contract fails here rather than silently producing a shape the pool + * would never see — `id` went missing exactly that way. Empty `id` is what + * models that emit no call id actually produce, which the contract allows. */ +function tc(name: string, args: Record = {}): ParsedToolCall { + return { name, arguments: JSON.stringify(args), id: '' }; } // §10.4 codification: the `P-no-ungranted-protected-dispatch` predicate diff --git a/packages/agents/test/helpers/format-config.ts b/packages/agents/test/helpers/format-config.ts new file mode 100644 index 00000000..78c47ed4 --- /dev/null +++ b/packages/agents/test/helpers/format-config.ts @@ -0,0 +1,29 @@ +import type { FormatConfig } from '../../src/Agent'; + +/** + * A neutral `FormatConfig` for tests that need one but do not care about it. + * + * One fixture rather than four: this literal was copied into `Agent.test.ts`, + * `AgentPolicy.test.ts`, `authGuard.test.ts` and `spawn-agents.test.ts`, and + * when `enableThinking` became required every copy drifted at once — + * invisibly, because no tsc project covered the tests. Typed as `FormatConfig` + * so the next added field fails HERE, once, instead of in four places or + * nowhere. + * + * `enableThinking: false` matches the agent-side default: an agent that has not + * opted in must not have `` prefill assumed, or the parser's + * `generation_prompt` diverges from actual KV state. + */ +export const FMT: FormatConfig = { + format: 0, + reasoningFormat: 0, + generationPrompt: '', + parser: '', + grammar: '', + grammarLazy: false, + grammarTriggers: [], + enableThinking: false, +}; + +/** The same fixture with fields overridden — for the few tests that vary one. */ +export const fmtWith = (over: Partial): FormatConfig => ({ ...FMT, ...over }); diff --git a/packages/agents/test/helpers/media.ts b/packages/agents/test/helpers/media.ts new file mode 100644 index 00000000..a9402710 --- /dev/null +++ b/packages/agents/test/helpers/media.ts @@ -0,0 +1,39 @@ +import type { Operation } from 'effection'; +import { MockTool } from './mock-tool'; +import { TOOL_MEDIA_KEY } from '../../src/Tool'; +import type { AgentEvent } from '../../src/types'; + +/** + * Shared media test fixtures. + * + * Extracted because they lived only inside `agent-pool.test.ts`, unexported, so + * the invariants harness could not reach them — which is half of why the + * invariants layer has no media coverage at all. One MediaTool, one fixture + * byte-string, one failure filter, used by both. + */ + +/** A tool returning image bytes under the framework's media key. */ +export class MediaTool extends MockTool { + constructor(private _bytes: Uint8Array[], name = 'rasterize') { super(name); } + *execute(): Operation { + // Through the constant, like a real tool author would: a fixture spelling + // the literal is a fixture that keeps passing after the key changes. + return { page: 'p1', [TOOL_MEDIA_KEY]: this._bytes }; + } +} + +/** A PNG header plus three bytes — enough for `sniffMediaType`, which reads + * magic bytes only. Not a decodable image: nothing in these tests decodes. */ +export const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); + +/** + * The scaffold default (`harness.yml` → `model.llm.context`, and + * `served-runtime.ts`'s `?? 32768`), so media tests run at the size real + * harnesses do rather than at whatever number makes an assertion go green. + */ +export const MEDIA_TEST_NCTX = 32768; + +/** Agents that failed specifically on the embedding rail. */ +export const mediaFailures = (events: AgentEvent[]): AgentEvent[] => + events.filter(e => e.type === 'agent:failed' + && (e as { reason?: string }).reason === 'media_prefill_failed'); diff --git a/packages/agents/test/helpers/memory-store.ts b/packages/agents/test/helpers/memory-store.ts new file mode 100644 index 00000000..bc35be2a --- /dev/null +++ b/packages/agents/test/helpers/memory-store.ts @@ -0,0 +1,57 @@ +import { ATTACHMENT_ARTIFACT_TYPE, commitManifest } from '@lloyal-labs/media'; +import type { Attachment, AttachmentManifest, Descriptor } from '@lloyal-labs/media'; +import type { AttachmentStore } from '@lloyal-labs/media'; +import { createHash } from 'node:crypto'; + +/** + * An AttachmentStore that keeps blobs in a Map. + * + * FOR TESTS ONLY, and deliberately NOT a second content store: manifests are + * committed through the same {@link commitManifest} the filesystem store uses, so this + * cannot drift into testing a shape production never writes. What it replaces + * is persistence — the OCI Image Layout on disk is rig's, and its conformance + * is tested there. Tests that only need a working store (replay, ingress, + * the barrier) use this and stay off the filesystem. + */ +export class MemoryAttachmentStore implements AttachmentStore { + readonly blobs = new Map(); + + putBlob(bytes: Uint8Array, mediaType: string, annotations?: Record): Descriptor { + const digest = 'sha256:' + createHash('sha256').update(bytes).digest('hex'); + this.blobs.set(digest, bytes); + return { mediaType, digest, size: bytes.byteLength, ...(annotations ? { annotations } : {}) }; + } + + putAttachment(parts: { + representations: readonly Descriptor[]; + source?: Descriptor; + config?: { bytes: Uint8Array; mediaType: string }; + annotations?: Record; + }): Attachment { + // Through the SAME commit sequence production uses, which is the point of + // this double: it replaces persistence, not the format. A double that + // reimplemented the sequence could drift into testing a shape nothing + // writes. + return commitManifest((bytes, mediaType) => this.putBlob(bytes, mediaType), parts); + } + + get(digest: string): Uint8Array | null { + return this.blobs.get(digest) ?? null; + } + + getManifest(digest: string): AttachmentManifest | null { + const bytes = this.get(digest); + if (!bytes) return null; + try { + // Every blob is a candidate — most are image bytes, not JSON. Mirrors + // the filesystem store: asking about a digest that is not a manifest is + // a normal question with a normal answer. + const parsed = JSON.parse(new TextDecoder().decode(bytes)) as AttachmentManifest; + return parsed?.artifactType === ATTACHMENT_ARTIFACT_TYPE && Array.isArray(parsed.layers) + ? parsed + : null; + } catch { + return null; + } + } +} diff --git a/packages/agents/test/helpers/raw-ingress.ts b/packages/agents/test/helpers/raw-ingress.ts new file mode 100644 index 00000000..d9f5e2ba --- /dev/null +++ b/packages/agents/test/helpers/raw-ingress.ts @@ -0,0 +1,26 @@ +import type { AttachmentStore } from '@lloyal-labs/media'; +import type { ContentIngress } from '@lloyal-labs/media'; +import { sniffMediaType } from '@lloyal-labs/media'; + +/** + * A ContentIngress that commits bytes verbatim — no normalization. + * + * FOR TESTS ONLY. A real ingress normalizes, which is what makes the stored + * representation the exact bytes the projector decoded; this one exists so a + * test can exercise the RAIL (markers, cells, position, ordering) without + * pulling `sharp` into the agents test run. It records the omission on the + * manifest so a stored artifact from a test is never mistaken for an admitted + * one. + */ +export function rawIngress(store: AttachmentStore): ContentIngress { + return { + ingest: async (bytes) => { + // The bytes decide, exactly as the real ingress does — this double + // differs in skipping NORMALIZATION, not in who names the type. + const rep = store.putBlob(bytes, sniffMediaType(bytes), { + 'ai.lloyal.derive.profile': 'test.raw', + }); + return store.putAttachment({ representations: [rep] }); + }, + }; +} diff --git a/packages/agents/test/invariants/harness.ts b/packages/agents/test/invariants/harness.ts index f2f7b1a5..2595cbfc 100644 --- a/packages/agents/test/invariants/harness.ts +++ b/packages/agents/test/invariants/harness.ts @@ -3,11 +3,15 @@ import type { Channel } from 'effection'; import { MockSessionContext } from '../../../sdk/test/MockSessionContext'; import { Branch } from '../../../sdk/src/Branch'; import { BranchStore } from '../../../sdk/src/BranchStore'; -import type { ChatFormat, ParseChatOutputOptions, ParseChatOutputResult } from '@lloyal-labs/sdk'; +import type { ChatFormat, ParseChatOutputOptions, ParseChatOutputResult, MultimodalPrefillResult } from '@lloyal-labs/sdk'; import { useAgentPool } from '../../src/agent-pool'; import type { Orchestrator } from '../../src/orchestrators'; import { parallel, chain } from '../../src/orchestrators'; -import { Ctx, Store, Events, Trace, WindDown, CancelAgent, Pause } from '../../src/context'; +import { Ctx, Store, Events, Trace, WindDown, CancelAgent, Pause, Attachments, Ingress } from '../../src/context'; +import type { AttachmentStore } from '@lloyal-labs/media'; +import type { ContentIngress } from '@lloyal-labs/media'; +import { MemoryAttachmentStore } from '../helpers/memory-store'; +import { rawIngress } from '../helpers/raw-ingress'; import type { AgentPolicy } from '../../src/AgentPolicy'; import type { AgentPoolResult, AgentEvent } from '../../src/types'; import type { TraceEvent } from '../../src/trace-types'; @@ -16,7 +20,7 @@ import { CapturingTraceWriter } from '../helpers/capturing-trace'; const STOP = 999; -export type NativeOp = 'prefill' | 'commit' | 'sample'; +export type NativeOp = 'prefill' | 'commit' | 'sample' | 'prefillMultimodal'; export interface NativeCall { seq: number; @@ -63,6 +67,34 @@ export class InstrumentedMockSessionContext extends MockSessionContext { }); } + /** + * The embedding rail, recorded like the token rail. + * + * Without this override every multimodal prefill is invisible in + * `nativeCalls`, so `I1_nativeStoreSingleFiber` and `I32_pauseHoldsNative` + * — both of which reason about native access — silently do not cover media + * at all. `tokenCount` is the CELL count the mock reports, which is the unit + * admission actually spends. + */ + async _storePrefillMultimodal( + handles: number[], + sepTokens: number[][], + prompts: string[], + bitmaps: Uint8Array[][], + ): Promise { + const tStart = performance.now(); + const seq = this._seq++; + const out = await super._storePrefillMultimodal(handles, sepTokens, prompts, bitmaps); + const tEnd = performance.now(); + this.nativeCalls.push({ + seq, op: 'prefillMultimodal', tStart, tEnd, + branchCount: handles.length, + // CELLS, not tokens — the unit admission actually spends on this rail. + tokenCount: out.reduce((n, r) => n + (r?.tokensDecoded ?? 0), 0), + }); + return out; + } + async _storeCommit(handles: number[], tokens: number[]): Promise { if (this.throwOnCommitToken != null && tokens.includes(this.throwOnCommitToken)) { throw new Error('llama_decode failed: no KV slot (mock OOM)'); @@ -160,6 +192,20 @@ export interface PoolSpec { * `maxLength` budget out of `jsonSchemaToGrammar`. */ instrument?: (ctx: InstrumentedMockSessionContext) => void; + /** + * The run's content store. Defaults to an in-memory one, so media WORKS by + * default — previously the harness set neither context, `Ingress` fell back + * to `NoContentIngress`, every media path rejected, and the throw unwound to + * the tick loop's own catch, which closes with a partial result and no + * error. A media invariant written against that would have passed vacuously. + */ + attachments?: AttachmentStore; + /** + * The run's ingress. Defaults to `rawIngress` over `attachments` — it + * commits bytes verbatim, exercising the RAIL without pulling `sharp` into + * the agents test run. Pass a rejecting one to exercise the barrier. + */ + ingress?: ContentIngress; /** Capture a thrown pool run into `PoolRun.error` instead of rejecting — for scenarios * that expect a throw AND need the events emitted before it. Default: re-throw (fail-loud). */ captureError?: boolean; @@ -176,8 +222,6 @@ export async function runPool(spec: PoolSpec): Promise { nCtx: spec.nCtx, cellsUsed: spec.cellsUsed, }); - spec.instrument?.(ctx); - // Wire scripted _branchSample: index by forkCount, advance per sample. let forkCount = 0; const branchForkIndex = new Map(); @@ -236,6 +280,14 @@ export async function runPool(spec: PoolSpec): Promise { return { content: script?.content ?? '', reasoningContent: '', toolCalls: [] }; }; + // AFTER the fork/sample/parse wiring, not before it. The previous call site + // ran first, so an `instrument` that overrode `_branchSample` or + // `parseChatOutput` was silently clobbered by the harness a few lines later + // — while its own docstring promised it was the same affordance the harness + // uses. Plain field writes (`mockImageCells`, `throwOnCommitToken`) were + // unaffected, which is why nothing noticed. + spec.instrument?.(ctx); + const trace = new CapturingTraceWriter(); const channelEvents: AgentEvent[] = []; @@ -255,6 +307,12 @@ export async function runPool(spec: PoolSpec): Promise { const events: Channel = createChannel(); yield* Events.set(events as any); yield* Trace.set(trace); + // Media contexts, always installed. A real harness that accepts media wires + // both; a harness that does not never reaches them, and an in-memory store + // costs a text-only run nothing. + const contentStore = spec.attachments ?? new MemoryAttachmentStore(); + yield* Attachments.set(contentStore); + yield* Ingress.set(spec.ingress ?? rawIngress(contentStore)); const windDownSignal = createSignal(); if (spec.windDownAfter) yield* WindDown.set(windDownSignal); const cancelSignal = createSignal<{ agentId: number }, void>(); diff --git a/packages/agents/test/invariants/predicates.ts b/packages/agents/test/invariants/predicates.ts index f40ed485..c44e9e3f 100644 --- a/packages/agents/test/invariants/predicates.ts +++ b/packages/agents/test/invariants/predicates.ts @@ -1,5 +1,6 @@ import type { AgentExitReason } from '../../src/types'; import type { PoolRun, NativeCall } from './harness'; +import type { AgentEvent } from '../../src/types'; import type { TraceEvent } from '../../src/trace-types'; export interface Violation { @@ -200,7 +201,7 @@ export function I30_exitReasonMatchesTrace(run: PoolRun): PredicateResult { const dropped = new Map(); for (const e of run.traceEvents) { if (e.type !== 'pool:agentDrop') continue; - const reason = (e as any).reason as string; + const reason = (e as { reason: AgentExitReason }).reason; if (!RECORDED_EXIT_REASONS.has(reason)) continue; dropped.set((e as any).agentId, reason); } @@ -305,3 +306,74 @@ export function I32_pauseHoldsNative(run: PoolRun): PredicateResult { } return ok(); } + +/** + * I33 Agent-failure-is-isolated — a SCENARIO predicate, not a global invariant. + * + * It cannot be global. A legitimate run may have one agent; every agent may + * fail independently; a sibling may have finished BEFORE the failure. "Some + * other agent reached a terminal event" is false in all three and says nothing + * about isolation. + * + * What isolation actually means is causal: an agent that was still LIVE at the + * moment another failed must go on to reach a terminal event of its own, and + * the pool must close normally. That distinguishes "one agent pruned, siblings + * survived" from "the failure took the run down with it" — which is exactly + * the shape the pool's outer catch produces, since it closes with a partial + * result and no error at all. + * + * @param run the pool run + * @param reason optional `agent:failed` reason to scope to (e.g. + * `'media_prefill_failed'`); omit to check every failure. + */ +export function I33_agentFailureIsIsolated( + run: PoolRun, + reason?: string, +): PredicateResult { + const evs = run.channelEvents; + const idOf = (e: AgentEvent): number | undefined => + (e as { agentId?: number }).agentId; + const TERMINAL = new Set(['agent:return', 'agent:recovered', 'agent:failed', 'agent:done']); + + // FIRST, and unconditionally: a torn-down run emits NO `agent:failed` at all, + // so keying the whole check off failures makes it vacuous exactly when the + // bug is present. Measured: a refused ingress yields spawn×2, one tool_call, + // zero failures, and no `pool:close` — the tick loop's outer catch closes the + // channel with a partial result and swallows the reason. + if (!run.traceEvents.some(e => e.type === 'pool:close')) { + return fail('I33', 'pool never emitted pool:close — the run was torn down, not completed'); + } + + const failures = evs + .map((e, i) => ({ e, i })) + .filter(({ e }) => e.type === 'agent:failed' + && (reason === undefined || (e as { reason?: string }).reason === reason)); + if (failures.length === 0) return ok(); + + for (const { e: failure, i: at } of failures) { + const deadId = idOf(failure); + // Live at the instant of the failure: spawned before it, and no terminal + // event of its own before it. Ordering is the whole point — a sibling that + // had already finished proves nothing about isolation. + const live = new Set(); + for (let j = 0; j < at; j++) { + const id = idOf(evs[j]); + if (id === undefined || id === deadId) continue; + if (evs[j].type === 'agent:spawn') live.add(id); + if (TERMINAL.has(evs[j].type)) live.delete(id); + } + for (const id of live) { + const survived = evs.slice(at + 1).some(e => idOf(e) === id && TERMINAL.has(e.type)); + if (!survived) { + return fail( + 'I33', + `agent ${id} was live when agent ${deadId} failed` + + `${reason ? ` (${reason})` : ''} and never reached a terminal event — ` + + 'the failure took its sibling down with it', + ); + } + } + } + + return ok(); +} diff --git a/packages/agents/test/invariants/scenarios/agent-cancel-no-sweep-recovery.scenario.test.ts b/packages/agents/test/invariants/scenarios/agent-cancel-no-sweep-recovery.scenario.test.ts index d9783e85..1d45e77b 100644 --- a/packages/agents/test/invariants/scenarios/agent-cancel-no-sweep-recovery.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/agent-cancel-no-sweep-recovery.scenario.test.ts @@ -7,7 +7,7 @@ * cancel and the branch stays non-disposed. The termination sweep recovers any agent left * `idle && !result && !branch.disposed` — so pre-fix it would `recoverInline()` the * cancelled agent, emitting a SECOND terminal event (here `agent:failed(recovery_skipped)`) - * after the `agent:failed(user_cancel)`. The `cancelledIds` guard excludes it. + * after the `agent:failed(user_cancel)`. The `discardedIds` guard excludes it. * * (PR #26 Copilot review, agent-pool.ts:1911.) */ diff --git a/packages/agents/test/invariants/scenarios/decision-matrix.scenario.test.ts b/packages/agents/test/invariants/scenarios/decision-matrix.scenario.test.ts index b66c6a8a..72143780 100644 --- a/packages/agents/test/invariants/scenarios/decision-matrix.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/decision-matrix.scenario.test.ts @@ -12,6 +12,8 @@ */ import { describe, it, expect } from 'vitest'; import { DefaultAgentPolicy } from '../../../src/AgentPolicy'; +import type { Agent } from '../../../src/Agent'; +import type { ParsedToolCall } from '@lloyal-labs/sdk'; import type { AgentPolicy } from '../../../src/AgentPolicy'; import { Tool } from '../../../src/Tool'; import type { Operation } from 'effection'; @@ -59,7 +61,7 @@ describe('decision matrix: scattered kill/nudge paths', () => { // Using `as any` because the interface requires the method; we're // simulating a buggy/legacy policy that doesn't implement it. const policy: AgentPolicy = { - onProduced: (_a, parsed) => { + onProduced: (_a: Agent, parsed: { content: string | null; toolCalls: ParsedToolCall[] }) => { if (parsed.toolCalls.length > 0) return { type: 'tool_call', tc: parsed.toolCalls[0] }; return { type: 'idle', reason: 'free_text_stop' }; }, diff --git a/packages/agents/test/invariants/scenarios/deferred-media-cost-is-honest.scenario.test.ts b/packages/agents/test/invariants/scenarios/deferred-media-cost-is-honest.scenario.test.ts new file mode 100644 index 00000000..e8df216d --- /dev/null +++ b/packages/agents/test/invariants/scenarios/deferred-media-cost-is-honest.scenario.test.ts @@ -0,0 +1,57 @@ +/** + * Scenario: a DEFERRED media item tells the policy what it actually costs. + * + * When SETTLE cannot admit an item it defers it, and the stall-break later + * asks the policy what to do — nudge the agent, or drop it. That decision is + * made from ONE number: the item's cost. + * + * A media item's `prefillTokens` is empty by construction: mtmd tokenizes + * downstream, so the delta stops at the string stage and the cost lives in + * `cells`. Reading `prefillTokens.length` therefore reports **0** — not + * "unknown", but a confident zero — and the policy decides whether to keep an + * agent alive on the basis that its pending result is free. + * + * This is what "the rail is re-derived at every use" costs when one site + * forgets to derive it. + */ +import { describe, it, expect } from 'vitest'; +import type { Agent } from '../../../src/Agent'; +import type { AgentPolicy, SettleAction } from '../../../src/AgentPolicy'; +import type { Tool } from '../../../src/Tool'; +import { runPool } from '../harness'; +import { MediaTool, PNG_BYTES, MEDIA_TEST_NCTX } from '../../helpers/media'; + +describe('scenario: a deferred media item reports its real cost', () => { + it('hands the policy cells, never a confident zero', async () => { + const costsSeen: number[] = []; + const policy: AgentPolicy = { + onProduced: (_a: Agent, parsed) => + parsed.toolCalls.length > 0 + ? { type: 'tool_call', tc: parsed.toolCalls[0] } + : { type: 'idle', reason: 'free_text_stop' }, + shouldExit: () => false, + onSettleReject: (_a, cost): SettleAction => { + costsSeen.push(cost); + return { type: 'idle', reason: 'pressure_settle_reject' }; + }, + }; + + // Enough images that the item cannot be admitted at this pressure, so it + // defers and the stall-break consults the policy. + const images = Array.from({ length: 400 }, () => PNG_BYTES); + + await runPool({ + nCtx: MEDIA_TEST_NCTX, + cellsUsed: MEDIA_TEST_NCTX - 2_000, + scripts: [{ tokens: [1, 999, 999], toolCall: { name: 'rasterize', arguments: '{}' } }], + policy, + tools: new Map([['rasterize', new MediaTool(images)]]), + }); + + expect(costsSeen.length, 'the item must have deferred for this to test anything') + .toBeGreaterThan(0); + expect(costsSeen, + 'the policy was told a media result costs nothing, and decided from that') + .not.toContain(0); + }); +}); diff --git a/packages/agents/test/invariants/scenarios/discarded-agent-not-resurrected.scenario.test.ts b/packages/agents/test/invariants/scenarios/discarded-agent-not-resurrected.scenario.test.ts new file mode 100644 index 00000000..8db25e95 --- /dev/null +++ b/packages/agents/test/invariants/scenarios/discarded-agent-not-resurrected.scenario.test.ts @@ -0,0 +1,79 @@ +/** + * Scenario: an agent DISCARDED on the embedding rail is not force-recovered. + * + * Two sets model "this agent is discarded" and they are not the same set: + * + * - `cancelledIds` — pool-lifetime, written ONLY on a user cancel. Guards the + * termination sweep and DRAIN. (Now `discardedIds`, written by all three + * discard paths — the fix this scenario locks.) + * - `poisoned` — local to ONE settle() call, written when a media prefill + * fails. Guards re-activation inside that tick only. + * + * Both do the identical three things at the point of discard — terminal + * `agent:failed`, `safePrune`, `transition('idle')` — but only the first is + * remembered past the tick. The gap is reachable because `safePrune` is a + * documented NO-OP on a branch with live children, so a poisoned agent that + * sub-spawned keeps `branch.disposed === false` and satisfies every condition + * the sweep tests: idle, no result, branch alive, not in the discarded set. + * + * The contract this locks: a branch the runtime called POISONED is never + * resumed. `decode_segments` is not atomic and partial-range KV ops are + * meaningless on recurrent layers, so recovery reads from a cache whose + * contents nothing describes. + */ +import { describe, it, expect } from 'vitest'; +import type { Agent } from '../../../src/Agent'; +import type { AgentPolicy } from '../../../src/AgentPolicy'; +import type { Tool } from '../../../src/Tool'; +import { runPool } from '../harness'; +import { MediaTool, PNG_BYTES, MEDIA_TEST_NCTX } from '../../helpers/media'; + +describe('scenario: a poisoned agent is never force-recovered', () => { + const policy: AgentPolicy = { + onProduced: (_a: Agent, parsed) => + parsed.toolCalls.length > 0 + ? { type: 'tool_call', tc: parsed.toolCalls[0] } + : { type: 'idle', reason: 'free_text_stop' }, + shouldExit: () => false, + }; + + it('gets ONE terminal event, not a second from the sweep', async () => { + const run = await runPool({ + nCtx: MEDIA_TEST_NCTX, + scripts: [{ tokens: [1, 999, 999], toolCall: { name: 'rasterize', arguments: '{}' } }], + policy, + tools: new Map([['rasterize', new MediaTool([PNG_BYTES])]]), + instrument: (ctx) => { + ctx.mockMultimodalError = () => 'decode_segments failed on image 0'; + const inner = ctx._storePrefillMultimodal.bind(ctx); + ctx._storePrefillMultimodal = async (handles, sep, prompts, bitmaps) => { + // A live child, so `safePrune`'s RESTRICT no-op fires and the branch + // survives the discard. This is what a sub-spawning agent looks like + // at the moment its media prefill fails — not a contrivance. + for (const h of handles) ctx._branchFork(h); + return inner(handles, sep, prompts, bitmaps); + }; + }, + }); + + const failures = run.channelEvents.filter(e => e.type === 'agent:failed'); + const media = failures.filter(e => (e as { reason?: string }).reason === 'media_prefill_failed'); + expect(media, 'the media prefill must have failed for this to test anything') + .toHaveLength(1); + const victim = (media[0] as { agentId: number }).agentId; + + // ONE terminal event per agent. Asserting on `branch:prefill role=recovery` + // instead would have been vacuous: the forced recovery FAILS here, and a + // failed recovery emits no prefill — the same shape of mistake as keying a + // predicate off an event a torn-down run never sends. + const forThisAgent = failures + .filter(e => (e as { agentId: number }).agentId === victim) + .map(e => (e as { reason?: string }).reason); + + expect(forThisAgent, + 'the pool announced this agent failed and then processed it again — a ' + + 'consumer sees two terminal events for one agent, and the second ran ' + + 'against a branch whose KV the runtime had already called unresumable') + .toEqual(['media_prefill_failed']); + }); +}); diff --git a/packages/agents/test/invariants/scenarios/media-ingress-failure-isolated.scenario.test.ts b/packages/agents/test/invariants/scenarios/media-ingress-failure-isolated.scenario.test.ts new file mode 100644 index 00000000..1ac65377 --- /dev/null +++ b/packages/agents/test/invariants/scenarios/media-ingress-failure-isolated.scenario.test.ts @@ -0,0 +1,56 @@ +/** + * Scenario: a tool returns images, the ingress refuses, and ONLY that agent dies. + * + * This is the first media scenario in the invariants layer, and it exists + * because the layer had none: 27 scenarios and 9 predicates, not one of them + * touching attachments, ingress or bitmaps. + * + * It also could not have been written before the harness installed the media + * contexts. Without them `Ingress` fell back to `NoContentIngress`, every media + * path rejected, and the throw unwound to the tick loop's own catch — which + * closes with a partial result and NO error. A scenario written against that + * would have passed while proving nothing. + * + * What this locks (I33): an agent that was live when its sibling's ingress + * failed goes on to reach a terminal event, and the pool closes normally. The + * failure is the agent's, not the run's. + */ +import { describe, it, expect } from 'vitest'; +import type { Agent } from '../../../src/Agent'; +import type { AgentPolicy } from '../../../src/AgentPolicy'; +import type { Tool } from '../../../src/Tool'; +import { runPool } from '../harness'; +import { I33_agentFailureIsIsolated } from '../predicates'; +import { formatResult } from '../predicates'; +import { MediaTool, PNG_BYTES, MEDIA_TEST_NCTX } from '../../helpers/media'; + +describe('scenario: a refused media ingress fails one agent, not the run', () => { + it('the live sibling still reaches a terminal event and the pool closes', async () => { + const policy: AgentPolicy = { + onProduced: (_a: Agent, parsed) => + parsed.toolCalls.length > 0 + ? { type: 'tool_call', tc: parsed.toolCalls[0] } + : { type: 'idle', reason: 'free_text_stop' }, + shouldExit: () => false, + }; + const tools = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + + const run = await runPool({ + nCtx: MEDIA_TEST_NCTX, + // Two agents, both calling the media tool: the cohort has a sibling to + // lose. One agent could not distinguish isolation from teardown. + scripts: [ + { tokens: [1, 999, 999], toolCall: { name: 'rasterize', arguments: '{}' } }, + { tokens: [1, 999, 999], toolCall: { name: 'rasterize', arguments: '{}' } }, + ], + policy, + tools, + // The barrier refuses — stands in for a normalization or commit failure, + // before admission and before any KV moves. + ingress: { ingest: () => Promise.reject(new Error('ingress refused')) }, + }); + + const r = I33_agentFailureIsIsolated(run); + expect(r.ok, formatResult('I33', r)).toBe(true); + }); +}); diff --git a/packages/agents/test/invariants/scenarios/media-prefill-failure-claims-no-kv.scenario.test.ts b/packages/agents/test/invariants/scenarios/media-prefill-failure-claims-no-kv.scenario.test.ts new file mode 100644 index 00000000..e4c6a9fe --- /dev/null +++ b/packages/agents/test/invariants/scenarios/media-prefill-failure-claims-no-kv.scenario.test.ts @@ -0,0 +1,96 @@ +/** + * Scenario: one entry of a media cohort fails natively, and the trace says so. + * + * `branch:prefill` is the event that asserts the KV CHANGED — replay and the + * dev panes both read it that way. SETTLE used to write it inside the + * admission loop, before either dispatch had run, so a poisoned entry left an + * event claiming cells that never landed. The sibling that DID land needs its + * event just as much, which is why this asserts both halves. + * + * The failure is native (`mockMultimodalError`), not an ingress refusal: the + * bytes were normalized and committed, admission passed, and `decode_segments` + * failed anyway — the one path where a branch is POISONED rather than merely + * unchanged. + */ +import { describe, it, expect } from 'vitest'; +import type { Agent } from '../../../src/Agent'; +import type { AgentPolicy } from '../../../src/AgentPolicy'; +import type { Tool } from '../../../src/Tool'; +import { runPool } from '../harness'; +import { I33_agentFailureIsIsolated, formatResult } from '../predicates'; +import { MediaTool, PNG_BYTES, MEDIA_TEST_NCTX, mediaFailures } from '../../helpers/media'; + +describe('scenario: a poisoned media prefill claims no KV', () => { + const policy: AgentPolicy = { + onProduced: (_a: Agent, parsed) => + parsed.toolCalls.length > 0 + ? { type: 'tool_call', tc: parsed.toolCalls[0] } + : { type: 'idle', reason: 'free_text_stop' }, + shouldExit: () => false, + }; + + /** Two agents settle media in one cohort; the FIRST entry fails natively. */ + const runOneFailing = () => runPool({ + nCtx: MEDIA_TEST_NCTX, + scripts: [ + { tokens: [1, 999, 999], toolCall: { name: 'rasterize', arguments: '{}' } }, + { tokens: [1, 999, 999], toolCall: { name: 'rasterize', arguments: '{}' } }, + ], + policy, + tools: new Map([['rasterize', new MediaTool([PNG_BYTES])]]), + instrument: (ctx) => { + let seen = 0; + ctx.mockMultimodalError = () => + seen++ === 0 ? 'decode_segments failed on image 0' : null; + }, + }); + + it('records exactly the entries that landed', async () => { + const run = await runOneFailing(); + + const failed = mediaFailures(run.channelEvents) + .map(e => (e as { agentId: number }).agentId); + expect(failed, 'expected exactly one media-rail failure').toHaveLength(1); + + // Agents settle repeatedly (the policy never exits), so this counts WHOSE + // prefills were recorded, not how many. The poisoned agent must appear in + // none of them; its sibling must appear. + const claimed = new Set(run.traceEvents + .filter(e => e.type === 'branch:prefill' && e.role === 'toolResult') + .map(e => (e as { branchHandle: number }).branchHandle)); + + expect(claimed.has(failed[0]), + 'the poisoned agent must leave no event claiming its cells landed').toBe(false); + expect(claimed.size, 'the surviving agent must still be recorded') + .toBeGreaterThan(0); + }); + + it('is not recorded as a RECOVERY failure', async () => { + // `pool:recoveryFailed` has a stated meaning — "produce completed but + // output unparseable", emitted by recoverInline — and `outputExcerpt` is + // the MODEL'S output. A native decode error is neither. Overloading the + // event makes the field's own invariant false and leaves a reader unable + // to tell an unparseable answer from a failed prefill without matching on + // `reason`. + const run = await runOneFailing(); + const victim = (mediaFailures(run.channelEvents)[0] as { agentId: number }).agentId; + + // Scoped to the POISONED agent. The surviving sibling legitimately reaches + // the termination sweep and can fail a real recovery there — a pool-wide + // count would have been asserting on that instead. + expect(run.traceEvents.filter( + e => e.type === 'pool:recoveryFailed' && (e as { agentId: number }).agentId === victim), + 'no recovery ran for this agent, so nothing may claim one failed').toHaveLength(0); + + const settle = run.traceEvents.filter(e => e.type === 'pool:settleFailed'); + expect(settle, 'the admission failure needs an event of its own').toHaveLength(1); + expect((settle[0] as { reason: string }).reason).toBe('media_prefill_failed'); + expect((settle[0] as { detail: string }).detail).toMatch(/decode_segments/); + }); + + it('fails only that agent', async () => { + const run = await runOneFailing(); + const r = I33_agentFailureIsIsolated(run, 'media_prefill_failed'); + expect(r.ok, formatResult('I33', r)).toBe(true); + }); +}); diff --git a/packages/agents/test/invariants/scenarios/no-projector-says-so.scenario.test.ts b/packages/agents/test/invariants/scenarios/no-projector-says-so.scenario.test.ts new file mode 100644 index 00000000..dcc37d56 --- /dev/null +++ b/packages/agents/test/invariants/scenarios/no-projector-says-so.scenario.test.ts @@ -0,0 +1,65 @@ +/** + * Scenario: a tool returns images to a model that cannot see them. + * + * The contract is that this is SAID, not silently dropped — an agent that + * reasons about a picture it was never shown produces confident nonsense, and + * nothing downstream can tell that is what happened. `_imageError` goes into + * the result the model reads, mirroring the rate-limit `exhausted` path. + * + * Untested until now, which is why it is a scenario rather than an assertion + * folded into an existing one: the behaviour is load-bearing and the only + * thing enforcing it was a comment. + */ +import { describe, it, expect } from 'vitest'; +import type { Agent } from '../../../src/Agent'; +import type { AgentPolicy } from '../../../src/AgentPolicy'; +import type { Tool } from '../../../src/Tool'; +import { TOOL_IMAGE_ERROR_KEY } from '../../../src/Tool'; +import { runPool } from '../harness'; +import { MediaTool, PNG_BYTES, MEDIA_TEST_NCTX } from '../../helpers/media'; + +describe('scenario: a model with no projector is TOLD, not silently shorted', () => { + const policy: AgentPolicy = { + onProduced: (_a: Agent, parsed) => + parsed.toolCalls.length > 0 + ? { type: 'tool_call', tc: parsed.toolCalls[0] } + : { type: 'idle', reason: 'free_text_stop' }, + shouldExit: () => false, + }; + + const runBlind = () => runPool({ + nCtx: MEDIA_TEST_NCTX, + scripts: [{ tokens: [1, 999, 999], toolCall: { name: 'rasterize', arguments: '{}' } }], + policy, + tools: new Map([['rasterize', new MediaTool([PNG_BYTES])]]), + instrument: (ctx) => { ctx.mockSupportsVision = false; }, + }); + + it('puts the reason in the result the model reads', async () => { + const run = await runBlind(); + + const results = run.channelEvents.filter(e => e.type === 'agent:tool_result'); + expect(results.length, 'the tool must still settle a result').toBeGreaterThan(0); + const body = (results[0] as { result: string }).result; + + expect(body).toContain(TOOL_IMAGE_ERROR_KEY); + expect(body).toMatch(/cannot see images/i); + }); + + it('never puts the BYTES on the token rail', async () => { + // The failure this guards is not a missing message but a destroyed + // prefill: a 180 KB image stringifies to ~700k characters of JSON digits. + const run = await runBlind(); + + const body = (run.channelEvents.find(e => e.type === 'agent:tool_result') as + { result: string }).result; + + expect(body).not.toContain('_images'); + expect(body.length).toBeLessThan(2_000); + }); + + it('takes the embedding rail for nobody', async () => { + const run = await runBlind(); + expect(run.nativeCalls.filter(c => c.op === 'prefillMultimodal')).toHaveLength(0); + }); +}); diff --git a/packages/agents/test/spawn-agents.test.ts b/packages/agents/test/spawn-agents.test.ts index 679e855d..b967aa08 100644 --- a/packages/agents/test/spawn-agents.test.ts +++ b/packages/agents/test/spawn-agents.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi } from 'vitest'; +import { FMT } from './helpers/format-config'; import { createToolkit } from '../src/toolkit'; import { Agent } from '../src/Agent'; import { MockTool } from './helpers/mock-tool'; @@ -350,7 +351,7 @@ describe('Agent.task', () => { const branch = createMockBranch(); const a = new Agent({ id: 1, parentId: 0, branch: branch as any, - fmt: { format: 0, reasoningFormat: 0, generationPrompt: '', parser: '', grammar: '', grammarLazy: false, grammarTriggers: [] }, + fmt: FMT, task: 'investigate speculative decoding on M3', }); expect(a.task).toBe('investigate speculative decoding on M3'); @@ -360,7 +361,7 @@ describe('Agent.task', () => { const branch = createMockBranch(); const a = new Agent({ id: 1, parentId: 0, branch: branch as any, - fmt: { format: 0, reasoningFormat: 0, generationPrompt: '', parser: '', grammar: '', grammarLazy: false, grammarTriggers: [] }, + fmt: FMT, }); expect(a.task).toBe(''); }); @@ -376,7 +377,7 @@ describe('Explore/exploit decoupled from lifecycle', () => { const branch = createMockBranch(); const a = new Agent({ id: 1, parentId: 0, branch: branch as any, - fmt: { format: 0, reasoningFormat: 0, generationPrompt: '', parser: '', grammar: '', grammarLazy: false, grammarTriggers: [] }, + fmt: FMT, }); a.transition('active'); a.incrementToolCalls(); @@ -408,7 +409,7 @@ describe('Explore/exploit decoupled from lifecycle', () => { const branch = createMockBranch(); const a = new Agent({ id: 1, parentId: 0, branch: branch as any, - fmt: { format: 0, reasoningFormat: 0, generationPrompt: '', parser: '', grammar: '', grammarLazy: false, grammarTriggers: [] }, + fmt: FMT, }); a.transition('active'); a.incrementToolCalls(); @@ -438,7 +439,7 @@ describe('Explore/exploit decoupled from lifecycle', () => { const policy = new DefaultAgentPolicy({ shouldExplore: { context: 0.4 } }); const a = new Agent({ id: 1, parentId: 0, branch: createMockBranch() as any, - fmt: { format: 0, reasoningFormat: 0, generationPrompt: '', parser: '', grammar: '', grammarLazy: false, grammarTriggers: [] }, + fmt: FMT, }); const highPressure = { diff --git a/packages/agents/test/spine-multimodal.test.ts b/packages/agents/test/spine-multimodal.test.ts new file mode 100644 index 00000000..91c93020 --- /dev/null +++ b/packages/agents/test/spine-multimodal.test.ts @@ -0,0 +1,161 @@ +/** + * `withSpine({ bitmaps })` — the spine ingress. + * + * The shipped feature had no coverage at all, so nothing caught that a + * refactor of the marker construction changed the rendered header. These lock + * the three things that would break silently: the images take the embedding + * rail rather than the token rail, one marker is emitted per image, and the + * branch's position advances by LESS than the cells consumed (the M-RoPE + * decoupling) because the count comes from the native return rather than from + * JS tokenization. + */ +import { describe, it, expect } from 'vitest'; +import { run } from 'effection'; +import { MockSessionContext } from '../../sdk/test/MockSessionContext'; +import { BranchStore } from '../../sdk/src/BranchStore'; +import { withSpine } from '../src/spine'; +import { extractSpineSeed } from '../src/replay'; +import { Ctx, Store, Trace, Attachments, Ingress } from '../src/context'; +import { MemoryAttachmentStore } from './helpers/memory-store'; +import { rawIngress } from './helpers/raw-ingress'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { CapturingTraceWriter } from './helpers/capturing-trace'; + +const SYSTEM = 'You are a research assistant.'; +const img = (n: number): Uint8Array[] => + Array.from({ length: n }, (_, i) => new Uint8Array([i, i + 1, i + 2])); + +const markerCount = (s: string): number => (s.match(/<__media__>/g) ?? []).length; + +/** The spine body both runners drive — identical wiring, so a failing run + * differs from a passing one only in what the context does. */ +function spineBody( + ctx: MockSessionContext, + trace: CapturingTraceWriter, + bitmaps: Uint8Array[] | undefined, +) { + const store = new BranchStore(ctx); + return function* () { + yield* Ctx.set(ctx as never); + yield* Store.set(store as never); + yield* Trace.set(trace); + // A real store: media paths now REFUSE to run without one, because + // unaddressed media makes a run unreplayable. Tests that exercise them + // must be configured the way a real harness is. + const contentStore = new MemoryAttachmentStore(); + yield* Attachments.set(contentStore); + // Media now refuses to run without an ingress, because unnormalized, + // unaddressed bytes make a run unreplayable. Tests use a raw one — they + // exercise the rail, not the normalizer. + yield* Ingress.set(rawIngress(contentStore)); + + return yield* withSpine( + { systemPrompt: SYSTEM, ...(bitmaps ? { bitmaps } : {}) }, + function* (spine) { + return { position: spine.position, cellsUsed: ctx.cellsUsed }; + }, + ); + }; +} + +async function runSpine(bitmaps: Uint8Array[] | undefined) { + const ctx = new MockSessionContext({ nCtx: 16384, cellsUsed: 0 }); + const trace = new CapturingTraceWriter(); + + // Captured INSIDE the body: withSpine registers an ensure() that prunes the + // spine on scope exit, and pruning decrements cellsUsed. Reading either after + // run() returns measures a torn-down spine. + const live = await run(spineBody(ctx, trace, bitmaps)); + + return { ctx, trace, ...live }; +} + +/** Drive a spine whose native multimodal prefill reports a failure, and keep + * the trace — which is the whole point: what a FAILED prefill leaves behind + * is what decides whether the run can be replayed. */ +async function runFailingSpine(bitmaps: Uint8Array[], why: string) { + const ctx = new MockSessionContext({ nCtx: 16384, cellsUsed: 0 }); + ctx.mockMultimodalError = () => why; + const trace = new CapturingTraceWriter(); + + const error = await run(spineBody(ctx, trace, bitmaps)).then( + () => null, + (e: unknown) => e as Error, + ); + + return { ctx, trace, error }; +} + +describe('withSpine({ bitmaps })', () => { + it('routes images down the embedding rail, not the token rail', async () => { + const { ctx } = await runSpine(img(1)); + + expect(ctx.multimodalPrefills).toHaveLength(1); + expect(ctx.multimodalPrefills[0].bitmapCounts).toEqual([1]); + }); + + it('takes the token rail when there are no bitmaps', async () => { + const { ctx } = await runSpine(undefined); + expect(ctx.multimodalPrefills).toHaveLength(0); + }); + + it('emits one marker per image into the system header', async () => { + const { ctx } = await runSpine(img(3)); + + const prompt = ctx.multimodalPrefills[0].prompts[0]; + expect(markerCount(prompt)).toBe(3); + expect(prompt).toContain(SYSTEM); + }); + + it('advances position by less than the cells consumed', async () => { + // The whole point of the embedding rail: an image occupies more KV cells + // than it advances position. If the spine ever went back to using a + // JS-tokenized count these would be equal and the gauge would drift. + const n = 2; + const { ctx, position, cellsUsed } = await runSpine(img(n)); + + const slackPerImage = ctx.mockImageCells - ctx.mockImagePositions; + expect(cellsUsed - position).toBe(n * slackPerImage); + expect(position).toBeLessThan(cellsUsed); + }); + + it('reports the header cell count from the native return, in the trace', async () => { + const { ctx, trace } = await runSpine(img(1)); + + const seed = trace.ofType('prompt:format').find((e) => e.role === 'spine'); + expect(seed, 'expected prompt:format with role=spine').toBeDefined(); + expect(markerCount(seed!.promptText)).toBe(1); + + const header = trace.ofType('branch:prefill').find((e) => e.role === 'spineHeader'); + expect(header, 'expected branch:prefill with role=spineHeader').toBeDefined(); + // The count must come from the native return, not be re-derived in JS. + expect(header!.cells).toBe(ctx.multimodalPrefills[0].results[0].tokensDecoded); + }); + + describe('when the native prefill fails', () => { + const WHY = 'clip encode failed on image 0'; + + it('fails the spine rather than continuing on an unprefilled branch', async () => { + const { error } = await runFailingSpine(img(1), WHY); + expect(error?.message ?? '').toContain(WHY); + }); + + it('still leaves a replayable seed in the trace', async () => { + // `prompt:format` is write-ahead INTENT — the prompt a replay rebuilds + // from. Emitting it only after a successful prefill means the one run + // that most needs reconstructing is the one that cannot be. + const { trace } = await runFailingSpine(img(1), WHY); + expect(() => extractSpineSeed(trace.events)).not.toThrow(); + }); + + it('claims no KV movement', async () => { + // `branch:prefill` asserts the cache CHANGED. After a poisoned prefill + // nothing landed, so a reader must not find one. + const { trace } = await runFailingSpine(img(1), WHY); + const header = trace.ofType('branch:prefill').find((e) => e.role === 'spineHeader'); + expect(header).toBeUndefined(); + }); + }); +}); diff --git a/packages/agents/test/tool-media.test.ts b/packages/agents/test/tool-media.test.ts new file mode 100644 index 00000000..0a184be4 --- /dev/null +++ b/packages/agents/test/tool-media.test.ts @@ -0,0 +1,52 @@ +/** + * `takeToolMedia` — the framework channel a tool returns images on. + * + * It used to delete the key in place, so what the model was told and what the + * trace recorded were both decided by WHERE the call sat relative to them. + * These lock the contract that replaced that: one input, two named halves, + * and the tool's own object left alone. + */ +import { describe, it, expect } from 'vitest'; +import { takeToolMedia, TOOL_MEDIA_KEY } from '../src/Tool'; +import { PNG_BYTES } from './helpers/media'; + +describe('takeToolMedia', () => { + it('splits the images out from what the model is told', () => { + const { media, result } = takeToolMedia({ page: 'p1', [TOOL_MEDIA_KEY]: [PNG_BYTES] }); + + expect(media).toEqual([PNG_BYTES]); + expect(result).toEqual({ page: 'p1' }); + }); + + it('leaves the tool\'s own object untouched', () => { + // The bytes must reach neither the model's JSON nor the trace. Deleting + // them in place made that a property of call order; this makes it a + // property of the function. + const returned = { page: 'p1', [TOOL_MEDIA_KEY]: [PNG_BYTES] }; + takeToolMedia(returned); + + expect(returned[TOOL_MEDIA_KEY]).toEqual([PNG_BYTES]); + }); + + it('drops entries that are not bytes, so markers and bitmaps stay in step', () => { + const { media } = takeToolMedia({ + [TOOL_MEDIA_KEY]: [PNG_BYTES, 'not-an-image', null, PNG_BYTES], + }); + + expect(media).toHaveLength(2); + }); + + it('returns a text-only result as-is, copying nothing', () => { + const returned = { page: 'p1' }; + const { media, result } = takeToolMedia(returned); + + expect(media).toEqual([]); + expect(result).toBe(returned); + }); + + it('ignores results that cannot carry the channel', () => { + for (const r of [null, undefined, 'text', 42, [PNG_BYTES]]) { + expect(takeToolMedia(r)).toEqual({ media: [], result: r }); + } + }); +}); diff --git a/packages/agents/test/trace-scope-halt.test.ts b/packages/agents/test/trace-scope-halt.test.ts new file mode 100644 index 00000000..8f277f8b --- /dev/null +++ b/packages/agents/test/trace-scope-halt.test.ts @@ -0,0 +1,57 @@ +/** + * A halted run still closes its trace scopes. + * + * `scope:open` / `scope:close` are what give the trace its TREE. Effection + * documents three ways out of a scope — return, error, and HALT — and + * `traceScope` returned a `{traceId, close}` pair, so closing was a thing each + * caller had to remember on each of those paths. Two of three callers closed + * on return (and one on error) but none on halt, so a cancelled run left an + * unclosed scope: a malformed tree at exactly the moment it is most worth + * reading. + * + * The shape is the one the plan already retired for `useTraceWriter` — a + * caller-must-close pair where `resource()` exists. + */ +import { describe, it, expect } from 'vitest'; +import { createScope, suspend } from 'effection'; +import { MockSessionContext } from '../../sdk/test/MockSessionContext'; +import { BranchStore } from '../../sdk/src/BranchStore'; +import { Ctx, Store, Trace, Events } from '../src/context'; +import { useAgent } from '../src/use-agent'; +import { CapturingTraceWriter } from './helpers/capturing-trace'; +import { createChannel } from 'effection'; +import type { AgentEvent } from '../src/types'; + +describe('a halted scope closes its trace scope', () => { + it('emits scope:close for every scope:open when the run is cancelled', async () => { + const ctx = new MockSessionContext({ nCtx: 16384, cellsUsed: 0 }); + const store = new BranchStore(ctx); + const trace = new CapturingTraceWriter(); + + const [scope, destroy] = createScope(); + let reached!: () => void; + const running = new Promise((r) => { reached = r; }); + + scope.run(function*() { + yield* Ctx.set(ctx as never); + yield* Store.set(store as never); + yield* Trace.set(trace); + yield* Events.set(createChannel()); + yield* useAgent({ systemPrompt: 'you are a test', task: 'do nothing', tools: [] }); + reached(); + // Held open so the scope is torn down from OUTSIDE — a halt, not a + // return. This is the path no caller covered. + yield* suspend(); + }).catch(() => { /* halted */ }); + + await running; + await destroy(); + + const opened = trace.ofType('scope:open').map(e => e.traceId); + const closed = trace.ofType('scope:close').map(e => e.parentTraceId); + + expect(opened.length, 'the run must have opened a scope').toBeGreaterThan(0); + expect(closed.sort(), 'a halted run left an unclosed scope in the trace') + .toEqual(opened.sort()); + }); +}); diff --git a/packages/agents/tsconfig.json b/packages/agents/tsconfig.json index ac884408..ffea5753 100644 --- a/packages/agents/tsconfig.json +++ b/packages/agents/tsconfig.json @@ -4,8 +4,15 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src/**/*.ts"], + "include": [ + "src/**/*.ts" + ], "references": [ - { "path": "../sdk" } + { + "path": "../sdk" + }, + { + "path": "../media" + } ] } diff --git a/packages/dev-tools/src/index.ts b/packages/dev-tools/src/index.ts index 4d389ccc..61f490fc 100644 --- a/packages/dev-tools/src/index.ts +++ b/packages/dev-tools/src/index.ts @@ -42,6 +42,11 @@ export interface DevControl { command: string; /** The command field carrying the selected value. */ field: string; + /** How to draw the choice. `segmented` (default) is the button row; + * `slider` is the same ordered `values`, stepped — right when they form a + * scale rather than a set, so the ordering is the information. Both + * dispatch the identical command, so this changes the picture only. */ + render?: 'segmented' | 'slider'; /** One clause shown beside the control, e.g. `applies next run`. */ note?: string; /** Read the current value out of the live config object. */ @@ -856,6 +861,8 @@ export const KEY_TIERS: Readonly> = { 'model.path': 'reload', 'model.reranker': 'reload', 'model.gpu': 'reload', + 'model.imageMinTokens': 'reload', + 'model.imageMaxTokens': 'reload', 'model.nCtx': 'boot', 'model.branches': 'boot', 'model.kvCache': 'boot', diff --git a/packages/dev-tools/src/react.tsx b/packages/dev-tools/src/react.tsx index a326fc78..7fbba605 100644 --- a/packages/dev-tools/src/react.tsx +++ b/packages/dev-tools/src/react.tsx @@ -1747,6 +1747,14 @@ const SETTING_META: Readonly> = { desc: 'flat runs one research wave over the plan; deep lets agents recurse into sub-plans.', how: 'Change it here; it applies to your next run.', }, + 'model.imageMaxTokens': { + desc: 'Ceiling on what ONE image costs in KV. Measured on Qwen3.5 with a 176 KB photo: 564 cells uncapped, 251 at 256. Lower it to fit more images into a context; auto lets the model metadata decide.', + how: 'Change it here and the runtime reloads on the new value. It does NOT shrink the projector\u2019s warmup allocation, so it will not rescue a boot that runs out of GPU memory before any image arrives.', + }, + 'model.imageMinTokens': { + desc: 'Floor on per-image detail. Grounding tasks need it high \u2014 llama.cpp warns Qwen-VL wants at least 1024 to read positions reliably.', + how: 'Change it here and the runtime reloads on the new value. Raise it if the model reads an image but places things wrongly in it.', + }, 'sources.outputDir': { desc: 'Where per-query run-dirs and the session trace are written. Empty means where the harness started.', how: 'Edit harness.yml → sources.outputDir; the next run picks it up.', @@ -1833,6 +1841,45 @@ function Settings({ m, controls, send }: { ); } +/** A DevControl drawn as a stepped slider: the steps ARE `ctl.values`, in the + * order the template declared them, so there is no numeric range to keep in + * sync with the option list and no value can be selected that the command + * would not accept. Commits on release, not on drag \u2014 each change reloads + * the runtime, and every intermediate step would be a reload nobody asked + * for. */ +function SteppedSlider({ ctl, value, onSelect, send }: { + ctl: DevControl; value: string | undefined; + onSelect: () => void; send: (c: unknown) => void; +}): ReactElement { + const at = Math.max(0, ctl.values.indexOf(value ?? '')); + const [dragging, setDragging] = useState(null); + const shown = dragging ?? at; + const commit = (i: number): void => { + setDragging(null); + if (ctl.values[i] !== value) { onSelect(); send({ type: ctl.command, [ctl.field]: ctl.values[i] }); } + }; + return ( + e.stopPropagation()} + style={{ display: 'flex', alignItems: 'center', gap: 10, width: 276, flex: 'none' }} + > + setDragging(Number(e.target.value))} + onPointerUp={(e) => commit(Number((e.target as HTMLInputElement).value))} + onKeyUp={(e) => commit(Number((e.target as HTMLInputElement).value))} + onBlur={() => setDragging(null)} + style={{ flex: 1, accentColor: C.text, cursor: 'pointer', minWidth: 0 }} + /> + {ctl.values[shown]} + + ); +} + function HarnessSettings({ m, controls, send, selKey, onSelect }: { m: PaneModel; controls: readonly DevControl[]; send: (c: unknown) => void; selKey: string; onSelect: (k: string) => void; @@ -1856,7 +1903,14 @@ function HarnessSettings({ m, controls, send, selKey, onSelect }: { {key} {ctl?.note && {ctl.note}} - {ctl ? ( + {ctl?.render === 'slider' ? ( + onSelect(key)} + send={send} + /> + ) : ctl ? ( {ctl.values.map((v) => ( Canonical version at https://docs.lloyal.ai/licensing/faq. +> This file is a synced copy. Edit the canonical source and re-run +> `scripts/sync-license-faq.sh` in lloyal-sdk to update all copies. + + +**You can build and sell commercial products using HDK.** + +> HDK is free to build products with; it is not free to become the +> replacement HDK platform. + +That single sentence is the entire restriction reduced to one line. The rest +of this page is illustration. + +## The short version + +HDK 3.0 runtime packages — `liblloyal`, `lloyal-node`, and the lloyal-sdk +packages (`agents`, `sdk`, `rig`, `abilities/corpus`, `abilities/web`) — are +**[Fair Source](https://fair.io)** under **FSL-1.1-Apache-2.0** (the Functional +Source License, Apache 2.0 future grant). + +Each version converts to Apache 2.0 two years after its release. The +restriction during those two years is narrow: **you cannot offer a competing +HDK runtime, a managed HDK service, or an alternative Ability distribution +channel.** Everything else — commercial use, redistribution, modification, +sale, embedding in shipped products — is freely permitted. + +The reason the channel restriction exists is consumer-protective: every Ability +listed on `apps.lloyal.ai` is reviewed by Lloyal Labs for tool-safety, +manifest conformance, and signature provenance before publication. Pinning +the Ability ecosystem to one verified channel keeps the AI-safety review meaningful +(consumers can rely on a single trust boundary) and prevents protocol +fragmentation (an Ability that works on one harness works on every harness). + +The `lloyal-ai` CLI is licensed under **MIT**; the `hdk-create-app` +scaffolder under **Apache 2.0**. Both are unrestricted, and neither is +part of the runtime stack. + +## Can I ship a commercial product built on HDK? + +**Yes.** This is the question everyone has and the answer is straightforward. +Concretely: + +- **Shipping a paid intelligent inbox app to consumers** — permitted ✅ +- **Selling an Excel-with-AI desktop app to enterprises** — permitted ✅ +- **Embedding HDK in a medical device sold commercially** — permitted ✅ +- **A consulting firm building a custom intelligent harness for a Fortune + 500 client, charging $500K for the engagement, deploying on client + infrastructure** — permitted ✅ +- **An indie dev shipping a paid productivity app on the Mac App Store** — + permitted ✅ +- **An OEM shipping HDK inside an infotainment system or industrial device** + — permitted ✅ +- **A startup building an end-user product on top of HDK** — including + vertical research apps, workflow tools, and agent applications — permitted + ✅, *as long as the product is not offering HDK itself as a substitute + runtime, managed HDK service, or competing Ability distribution channel*. +- **A research lab using HDK in published academic work** — permitted ✅ +- **Forking HDK on GitHub to learn, modify, demo, or contribute** — permitted ✅ +- **Running HDK internally inside your company for any business use** — + permitted ✅ + +If you are building something *with* HDK, you are almost certainly fine. + +## What is actually restricted? + +The restriction is narrow and specific: **don't become the replacement +platform vendor**. Concretely: + +- **AWS / Google / Microsoft launching "Bedrock Managed HDK" or "Vertex HDK" + as a hosted runtime service** — restricted ❌ +- **A competitor publishing "OpenHDK" as a forked harness runtime under a + different name** — restricted ❌ +- **A clean-room reimplementation of the HDK runtime in Python / Rust / Go + intended as a drop-in replacement** — restricted ❌ (Competing Use doesn't + require forking source code — a reimplementation that competes is the + same problem) +- **Launching `apps.competitor.com` as an alternative Ability distribution + channel** — restricted ❌ +- **Offering "managed HDK hosting" or "HDK-as-a-Service" as a competing + SaaS** — restricted ❌ +- **A hosted orchestration service exposing HDK-compatible APIs as a + substitute for the HDK runtime** — restricted ❌ + +Notice the pattern: every restricted scenario is "become the platform +vendor," not "build products with HDK." If your project doesn't compete +directly with the HDK runtime or its distribution channel, FSL doesn't +affect you. + +## Why does the LICENSE list four narrow Permitted Purposes then? + +You may read the FSL LICENSE and see this section: + +> Permitted Purposes specifically include using the Software: +> 1. for your internal use and access; +> 2. for non-commercial education; +> 3. for non-commercial research; and +> 4. in connection with professional services that you provide to a +> Licensee using the Software in accordance with these Terms and +> Conditions. + +A careful first reading can mistake this list for the *exclusive* set of +permitted uses — leading to the (incorrect) conclusion that commercial +product distribution is prohibited. + +It isn't. **The operative definition is broader.** Earlier in the same +section, the license states: + +> A "Permitted Purpose" is any purpose other than a Competing Use. + +The four enumerated items are **illustrative examples** added because those +specific cases are ones a careful reader might otherwise hesitate about +("is research permitted? is consulting permitted?"). The four items are +additive clarifications, not a closing of the open-ended definition. + +Sentry, who authored FSL and uses it on their own software, [confirms this +explicitly in their FAQ](https://fsl.software): + +> "You can do anything with FSL software except undermine its producer. You +> can run it for almost all purposes, study it, modify it, and distribute +> your changes…" + +If the license felt restrictive on first read, that's a documented +[FSL adoption hazard](https://fair.io) — many developers hit the same wall. +The answer is to read the "any purpose other than a Competing Use" line as +the operative definition and treat the four enumerated items as examples, +not as a closed list. + +## Will it become Apache 2.0? + +**Yes — automatically, on a per-version schedule.** Each released version of +the runtime stack converts to Apache 2.0 exactly two years after its release +date. The conversion is irrevocable and written into the license text — it's +not a promise from Lloyal Labs, it's a contractual clause. + +For example: if `lloyal-sdk @lloyal-labs/lloyal-agents` v3.0.0 is released +on 2026-06-01, that exact version becomes available under Apache 2.0 on +2028-06-01. Any consumer can elect to use that version under Apache 2.0 +from that date forward — Lloyal Labs takes no action; the grant is +automatic. + +New versions released after v3.0.0 start their own two-year clock from +their own release dates. There is no single global Change Date. + +## Is this OSI-approved open source? + +**No, and we want to be honest about that.** FSL is not OSI-approved +because the OSI definition of open source (clause 6, "No Discrimination +Against Fields of Endeavor") does not permit restrictions on specific +use cases. FSL restricts Competing Use. That restriction takes it out of +strict OSI compliance. + +FSL falls under the [Fair Source](https://fair.io) classification — +source-available licenses that are explicitly developer-friendly: +commercial use permitted, free redistribution, eventual open-source +conversion. Fair Source is a more developer-friendly framing than the +generic "source-available" label, which has been tainted by the +SSPL / Elastic / MongoDB relicensing trauma cycles. + +What this means practically: + +- **You can read the source.** ✅ +- **You can modify it.** ✅ +- **You can sell products built with it.** ✅ +- **You can redistribute it (with the same FSL terms).** ✅ +- **It will be Apache 2.0 in two years.** ✅ +- Some enterprise procurement policies that strictly require OSI-approved + licenses will require an exception for this. We're working on making + that exception easy to grant. + +## Why FSL specifically — why not stay Apache? + +HDK 3.0 introduces installable Abilities. Every Ability declares against a +specific Ability protocol — the bytes-locked intro, catalog format, +tool-selection rule, and boundary marker that the runtime renders into +the spine. Your Ability's reliability depends on every HDK runtime your users +install agreeing on the same protocol. + +Under a permissive license alone, the protocol is forkable. A +well-resourced redistributor could fork the runtime, modify the protocol +surface, and distribute a variant under a different name with captive +distribution. Ability developers then face a fragmented ecosystem: target one +protocol, target both, or pick the bigger distribution and abandon the +others. The cost of that split is paid by Ability developers in testing +burden, divergent behavior, and reliability degradation across runtimes. + +FSL's two-year Competing Use restriction is shaped to block that +fragmentation specifically. After the conversion, anyone can build +whatever they want — by which time the protocol has had enough time to +stabilize through ecosystem use and the protection is no longer the load- +bearing thing keeping it coherent. + +For the longer treatment of this argument, see +[Why FSL](./why-fsl). + +We could have used Apache 2.0 and tried to protect only the channel via +terms-of-service. We could have written a custom license. We chose +standard FSL because it's: + +- **Off-the-shelf** — no bespoke license review at every adopter +- **Recognizable** — Sentry, PowerSync, and others use it +- **Documented** — the FAQ, definitions, and edge cases have been + litigated publicly +- **Time-bounded** — the protocolual Apache 2.0 conversion is the answer + to the "is this just source-available forever?" critique +- **Pre-launch** — relicensing at HDK 3.0 launch is structurally + different from MongoDB / Elastic / HashiCorp relicensing under an + existing installed base, which is what causes the backlash cycle + +## What about the lloyal stack — what's under FSL and what's not? + +| Component | License | Why | +|---|---|---| +| `liblloyal` (C++ engine) | FSL-1.1-Apache-2.0 | Native primitives the runtime is built on | +| `lloyal-node` (N-API binding) | FSL-1.1-Apache-2.0 | The binding that lets Effection drive llama.cpp | +| `@lloyal-labs/lloyal-agents` | FSL-1.1-Apache-2.0 | Runtime framework | +| `@lloyal-labs/lloyal-sdk` | FSL-1.1-Apache-2.0 | Runtime framework | +| `@lloyal-labs/rig` | FSL-1.1-Apache-2.0 | Runtime framework — holds the Ability protocol | +| `@lloyal-labs/corpus`, `@lloyal-labs/web` | FSL-1.1-Apache-2.0 | Reference Abilities shipped in-tree | +| **`lloyal-ai`** (the CLI) | **MIT** | Scaffolder — unrestricted for scaffolding new harnesses and Abilities | +| **`hdk-create-app`** (when shipped) | **Apache 2.0** | Scaffolder — unrestricted, not part of the runtime stack | +| `llama.cpp` (vendored dependency) | MIT (unchanged) | External upstream library; we don't relicense their code | + +## Can I contribute to HDK? + +**Yes.** Contributions are welcome under the same FSL license terms. If you +submit a PR, you're granting Lloyal Labs the right to distribute your +contribution under FSL-1.1-Apache-2.0 (and automatically under Apache 2.0 +two years after each release that includes it). The CONTRIBUTING file in +each repo has the details. + +## I have a use case that's borderline — who do I ask? + +Email [legal@lloyal.ai](mailto:legal@lloyal.ai) (or open a discussion in the repo). The runtime +team will help you confirm whether your use case falls under Permitted +Purpose or Competing Use. We'd rather give you a quick yes than have you +worry about it. + +## Further reading + +- [FSL official site](https://fsl.software) — Sentry's canonical FSL + resources and FAQ +- [Fair Source](https://fair.io) — the category FSL belongs to +- [The FSL template, instantiated for each repo](./fsl-template) +- [Why we chose FSL over BSL, Apache, or a custom license](./why-fsl) + +## Is there a safe harbor for building products with the HDK? + +Yes. The [Lloyal Harness Builder Grant](https://github.com/lloyal-ai/hdk/blob/main/GRANT.md) +irrevocably guarantees that building, selling, and hosting Harnesses and Abilities +is a Permitted Purpose and never a Competing Use — even products that compete +head-on with Lloyal's own (including reasoning.run). Only three uses remain +restricted: offering the HDK itself as a developer framework, hosting the HDK +as-a-service for third-party developers, and operating a general-purpose Ability +distribution channel. Private/internal Ability distribution and your Harness's +own plugin system are explicitly permitted. diff --git a/packages/media/README.md b/packages/media/README.md new file mode 100644 index 00000000..45242c71 --- /dev/null +++ b/packages/media/README.md @@ -0,0 +1,248 @@ +# @lloyal-labs/media + +Content addressing for a harness: the **content-addressed storage format** a +run writes its media to, and the normalizer that decides which pixels are +admitted to it. + +Both live here because they are the same decision viewed from either end — what +a harness stores and what it normalizes — and the package is split by RUNTIME, +not by concept: + +| entry | holds | needs | +|---|---|---| +| `@lloyal-labs/media` | the OCI shapes, the store and ingress contracts, `materialize` | nothing — browser-safe, and a dependency root | +| `@lloyal-labs/media/node` | `FileAttachmentStore` (the layout on disk), `createImageIngress` (sharp) | `node:fs`; `sharp` as an optional peer | + +`.` cannot import `./node`, which is what keeps the first row true. + +**Three axes decide what belongs where**, and re-deriving them is how this ends +up merged again: + +- **format** — how bytes are addressed and laid out (OCI). This package. + Stable, published, nothing image-specific in it. +- **policy** — where a project puts them and what is gated. + `createProjectMediaStore` in `@lloyal-labs/rig`, plus the template. +- **stream** — what fits in an event and is therefore ALREADY in the trace. + +The boundary is **"too big for the event stream"**, not "media": text turns and +tool results ride the trace verbatim, while a 180 KB image would stringify to +~700k characters of JSON digits — which is why it is a marker plus a digest. + +**"Media" means two things here**, and both are load-bearing. OCI's sense is +*typed bytes* (`mediaType`, `sniffMediaType`, the `media/` directory); the +modality sense is *pictures and sound* (what a projector decodes). The format +half is indifferent to modality — a video or a rasterized page is the same +manifest graph as an image. + +--- + +## Why content-addressed at all + +A trace records the media **marker**, never the pixels. So a media-bearing run +cannot be replayed from the trace alone, and the rule that falls out is: + +> Anything that reaches model state must be addressable, or the run is not +> replayable. + +That makes the content store a **correctness requirement, not telemetry** — +which is why it is never gated behind a dev flag, and why it lives in the +project rather than beside a trace file. + +## The format is the OCI Image Layout + +Not "OCI-inspired". A store directory is a **valid OCI Image Layout**, so +`oras` and `crane` can push it to any registry with none of our code in the +path — which is what makes distribution a later, replaceable adapter rather +than a rewrite. + +**You do not have to take that on trust, and neither does a reviewer.** +`npm run verify:oci` builds a layout through the real ingress and drives `oras` +against it with none of our code involved, then drives our reader against a +layout `oras` itself wrote. It runs in CI on every push (job +`oci-conformance`). Seven checks: + +| | | +|---|---| +| 1 | the three entries `image-layout.md` requires, and only digest-named blobs | +| 2 | `oras` reads the manifest — `schemaVersion`, layers, roles | +| 3 | `oras` computes the SAME digest we recorded | +| 4 | the canonical empty config blob EXISTS and fetches as `{}` | +| 5 | every layer retrieves at its declared size | +| 6 | `oras cp` preserves the digest into a layout `oras` lays out itself | +| 7 | our `materialize()` — the exact call replay makes — rebuilds from that layout | + +Check 4 has its own line because it is the easy conformance bug: a puller +fetches the config like any other blob, so a manifest that only NAMES OCI's +canonical empty descriptor looks correct locally and fails everywhere else. + +``` +/ + oci-layout {"imageLayoutVersion":"1.0.0"} + index.json image index — the entry point + blobs/sha256/<64 hex> every blob, addressed by content +``` + +Specs this conforms to: + +| | | +|---|---| +| [image-layout.md](https://github.com/opencontainers/image-spec/blob/main/image-layout.md) | the three required entries above | +| [descriptor.md](https://github.com/opencontainers/image-spec/blob/main/descriptor.md) | `mediaType` + `digest` + `size`, digest as `:` | +| [manifest.md](https://github.com/opencontainers/image-spec/blob/main/manifest.md) | `schemaVersion: 2`, required `config`, `layers` with ≥1 entry, `artifactType` | +| [OCI Distribution](https://github.com/opencontainers/distribution-spec) | not implemented — see *Deferred* | + +## An attachment is a manifest, never a blob + +This is the load-bearing decision, and the one that is expensive to retrofit. + +An image today is one representation and perhaps a source. A video is a source +plus N sampled frames. A live capture is frames with **no** source. Because the +fold and the replay path hold a pointer to a *manifest*, each of those is an +additive change to this file and invisible above it. + +```jsonc +{ + "schemaVersion": 2, + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "artifactType": "application/vnd.lloyal.attachment.v1", + "config": { // OCI's canonical empty blob: + "mediaType": "application/vnd.oci.empty.v1+json", + "digest": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "size": 2 // an artifact manifest still REQUIRES a config + }, + "layers": [ + { "mediaType": "image/jpeg", "digest": "sha256:…", "size": 41022, + "annotations": { + "ai.lloyal.role": "representation", // what entered the cache + "ai.lloyal.derive.maxPixels": "4194304", + "ai.lloyal.derive.quality": "82" + } }, + { "mediaType": "image/png", "digest": "sha256:…", "size": 180311, + "annotations": { "ai.lloyal.role": "source" } } // what the user supplied + ] +} +``` + +### The `config` slot is not permanently empty + +An image has nothing to say beyond its layers, so its manifest carries OCI's +canonical empty blob. Timed media will not: a video needs a timeline — +timestamps, track descriptors, the sampling policy, frame-to-audio +correspondence — and [annotations are `map`](https://github.com/opencontainers/image-spec/blob/main/annotations.md), +so encoding that as JSON inside an annotation would be unvalidatable string +soup. A typed config blob is what OCI provides the slot for. + +`putAttachment({ config })` already accepts one. A reader branches on +`config.mediaType`, so introducing `application/vnd.lloyal.attachment.config.v1+json` +later is additive — existing image manifests keep the empty descriptor and stay +valid. + +### Annotations we define + +`org.opencontainers.*` is reserved by the spec, so ours are reverse-DNS under +`ai.lloyal`. + +| key | meaning | +|---|---| +| `ai.lloyal.role` | `representation` (entered the cache) or `source` (as supplied) | +| `ai.lloyal.derive.*` | the parameters a representation was derived **under** | + +**`ai.lloyal.derive.*` is a correctness requirement, not provenance.** +Normalization is parameterized — pixel ceiling, quality, the projector's own +token budget — so one source under two settings yields different pixels and +therefore different KV. Addressing the *derived* bytes and recording what +derived them is what stops a replay under changed config from silently +rebuilding a different cache state. + +Retaining a source is optional and meaningful: it is what permits +re-derivation later — a better sampler, or a model that reads video natively. +Omitting it is a legitimate choice for a large original. + +## Normalization + +`normalizeImage(bytes, opts)` guarantees two things the projector otherwise +enforces too late: + +- **Format** — the result is one of jpeg/png/bmp/gif. A file picker's `accept` + is advisory (drag-and-drop and paste bypass it), so without this an + unsupported file fails inside the decoder mid-run, on a branch already in + flight. +- **Size** — anything above the pixel ceiling is downscaled here rather than by + the projector, which would do it anyway *after* the bytes crossed a socket + and were decoded. The model sees the same pixels either way. + +It re-encodes only when it must, so an image already in an accepted format and +within the ceiling comes back untouched and is never degraded twice. + +### What normalization buys — and what it does not + +It buys **wire bytes** (at the default ceiling, ~73% on a large photo), **format +conformance**, and **decoder work**. It does **not** buy KV. At the default the +ceiling is the projector's own, so normalizing performs the downscale the +projector would have performed anyway, earlier — the model sees the same pixels +and the same cell count either way. Below the default it does change cells, but +that is a fidelity decision, not an optimization. + +### The admission policy + +Byte-identical pass-through is permitted only when ALL of these hold: + +| | | +|---|---| +| format | one the projector decodes | +| size | under the pixel ceiling | +| dimensions | known — read from the header when the decoder cannot read the file at all | +| EXIF orientation | identity (`1`) | +| colour | no ICC profile, or an sRGB one | + +Anything else is **derived**, and the original is retained as the `source` +layer. The last two are not precautionary. The projector loads through +`stb_image`, which contains no EXIF handling and ignores ICC entirely — so a +tag left on a pass-through is a tag nobody downstream reads, and a portrait +phone photo small enough to skip the ceiling would reach the model sideways +with nothing left to say so. Size was never what made that safe. + +**Known limit:** a non-sRGB profile forces derivation, and derivation strips +the profile, so every admitted representation ends up with one consistent +interpretation. The pixels are not converted — sharp/libvips performs no ICC +transform (measured). What this removes is the asymmetry, where colour handling +depended on whether an image happened to exceed the ceiling. + +Normalization is also where content is **inspected before it is addressed**, +which is why images need no separate staging area — the payload fits in memory. +Video is exactly where that stops being true. + +## Deferred, and why none of it is foreclosed + +| | | +|---|---| +| **OCI Distribution** | The layout is already pushable by the mature Go CLIs. A client of our own waits until content must move between placements. | +| **Resumable ingress** (tus) | Appears when a payload outgrows memory — as an adapter in front of this store, not a change to it. Note the trust boundary: untrusted bytes stage, get inspected, and only then are admitted. | +| **Video derivation** | The manifest already has the slot: source + N frame representations. What is missing is a decoder, and that decision carries real licensing and codec-patent weight. | +| **Live capture** | The *locator* is not addressable; every bounded frame that reaches model state still is. Attachments are already per-prefill rather than per-run, so a live run is many prefills — no growing manifest. | +| **Reachability GC** | Nothing here deletes. Deletion needs refcounting across briefs that may share a digest. | + +## Two invariants worth stating explicitly + +**`index.json` is a catalogue, never the runtime authority.** Resolution goes +straight to `blobs//`; nothing on the replay path reads the +index. A lost concurrent index update can hide an attachment from OCI tooling — +it can never invalidate a recorded run. + +**Write order is blobs → manifest → index.** A crash can leave orphan blobs, +which are harmless and unreferenced. It can never leave a committed manifest +pointing at content that is not there. + +## Known limitations + +- **`index.json` is a mutable shared root**, updated read-modify-write. Writes + are synchronous, so concurrent Sessions inside one host process serialize + safely; two *processes* writing one layout can lose an index entry. Blobs are + unaffected — content-addressed, written temp-then-rename — so the loss is + discoverability by other OCI tooling, never replay. +- **`skopeo` is untested, and is not owed.** It is container-image tooling and + is entitled to reject an artifact manifest whose config is not an image + config. `oras` is the artifact-native tool and is the one that settles this. +- **`sharp` is a peer concern.** `normalizeImage` requires it at call time + rather than importing it at module load, so a harness that never accepts an + image pays nothing and one that does gets a message naming the install. diff --git a/packages/media/package.json b/packages/media/package.json new file mode 100644 index 00000000..c186b71e --- /dev/null +++ b/packages/media/package.json @@ -0,0 +1,59 @@ +{ + "name": "@lloyal-labs/media", + "version": "0.1.0", + "description": "Content addressing for a harness \u2014 an OCI layout, and the image normalizer that feeds it", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/lloyal-ai/hdk.git", + "directory": "packages/media" + }, + "homepage": "https://github.com/lloyal-ai/hdk/tree/main/packages/media#readme", + "bugs": { + "url": "https://github.com/lloyal-ai/hdk/issues" + }, + "keywords": [ + "oci", + "content-addressable", + "multimodal", + "vision", + "image", + "sharp" + ], + "license": "SEE LICENSE IN LICENSE", + "type": "commonjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./node": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" + } + }, + "scripts": { + "build": "tsc -b" + }, + "files": [ + "dist/", + "README.md", + "LICENSE", + "LICENSE-FAQ.md" + ], + "devDependencies": { + "sharp": "^0.35.4" + }, + "peerDependencies": { + "sharp": "^0.35.4" + }, + "peerDependenciesMeta": { + "sharp": { + "optional": true + } + } +} diff --git a/packages/media/src/attachment.ts b/packages/media/src/attachment.ts new file mode 100644 index 00000000..698f67b4 --- /dev/null +++ b/packages/media/src/attachment.ts @@ -0,0 +1,267 @@ +/** + * @file The OCI shapes an attachment is made of. + * + * Pure data and pure functions — no filesystem, no `node:` anything — so the + * consumers that only need to NAME an attachment (`trace-types.ts`, + * `replay.ts`, `agent-pool.ts`) do not pull a Node store into their module + * graph. Mirrors `trace-types.ts` beside `trace-writer.ts`. + * + * **The on-disk format is the OCI Image Layout**, and conformance is the point + * — not inspiration. A directory written under these shapes is a valid OCI + * artifact store that `oras`, `crane` and `skopeo` can push to any registry + * with no client of ours in the path. See `packages/media/README.md` for the + * format, the annotations we define, and the conformance notes. + */ + +/** The OCI media type of an image manifest — what an attachment manifest IS. + * + * Named because two places state it: the {@link AttachmentManifest} type and + * the store that writes the field. A literal in both is a literal that can + * drift, and they live in different files now. */ +export const MANIFEST_TYPE = 'application/vnd.oci.image.manifest.v1+json' as const; + +/** + * An OCI content descriptor — a pointer to one blob. + * + * Conforms to image-spec `descriptor.md`: `mediaType`, `digest` and `size` are + * required and `annotations` is the OPTIONAL free-form map. Field ORDER is + * irrelevant to conformance but digest form is not — `:`. + * + * @category Media + */ +export interface Descriptor { + mediaType: string; + /** `sha256:<64 hex>` — algorithm-prefixed, as OCI writes it. */ + digest: string; + size: number; + annotations?: Record; +} + +/** + * OCI's canonical empty blob — content `{}`, two bytes. + * + * An artifact manifest still REQUIRES a `config`, so the spec defines this to + * fill the slot when an artifact has no config of its own. The digest is + * fixed by the spec; it is not computed. + * + * @category Media + */ +export const EMPTY_DESCRIPTOR: Descriptor = { + mediaType: 'application/vnd.oci.empty.v1+json', + digest: 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a', + size: 2, +}; + +/** What `artifactType` declares these manifests to be — the reverse-DNS name + * that lets any OCI reader tell our artifacts from container images. */ +export const ATTACHMENT_ARTIFACT_TYPE = 'application/vnd.lloyal.attachment.v1'; + +/** + * A digest in the only form this store writes or accepts. + * + * Exported because the shape is an OCI FORMAT rule, not a transport rule — an + * HTTP route validating a digest is enforcing this, not a policy of its own, + * and a second regex elsewhere is a second place to get it wrong. + * + * @category Media + */ +export const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; + +/** Which part a layer plays. `org.opencontainers.*` is reserved by the spec, + * so ours are reverse-DNS under `ai.lloyal`. */ +export const ROLE_ANNOTATION = 'ai.lloyal.role'; +/** Prefix for the parameters a representation was derived UNDER. */ +export const DERIVE_PREFIX = 'ai.lloyal.derive.'; + +/** + * One attachment, as an OCI artifact manifest. + * + * The indirection is the whole design. An image today is one representation + * and maybe a source; a video is a source plus N sampled frames; a live + * capture is frames with no source. Because the fold and the replay path hold + * a pointer to THIS rather than to a blob, each of those is an additive change + * here and invisible above it. + * + * `layers` carries every blob belonging to the attachment, each tagged by + * {@link ROLE_ANNOTATION}: `representation` for what actually entered the + * cache, `source` for what the user supplied. Retaining a source is what + * permits re-derivation later — a better sampler, or a model that reads video + * natively — and omitting it is a legitimate choice for a large original. + * + * **Derivation parameters live on each representation** ({@link DERIVE_PREFIX}) + * and that is a correctness requirement, not provenance. Normalization is + * parameterized — pixel ceiling, quality, the projector's token budget — so + * one source under two settings yields different pixels and therefore + * different KV. Addressing the derived bytes and recording what derived them + * is what stops a replay under changed config from silently rebuilding a + * different cache state. + * + * @category Media + */ +export interface AttachmentManifest { + schemaVersion: 2; + mediaType: typeof MANIFEST_TYPE; + artifactType: typeof ATTACHMENT_ARTIFACT_TYPE; + config: Descriptor; + layers: Descriptor[]; + annotations?: Record; +} + +declare const ROOT: unique symbol; + +/** + * A reference to an attachment: the descriptor of its {@link AttachmentManifest}. + * + * **Branded, so an attachment is not interchangeable with any other + * descriptor.** It was a bare alias, and the design's central rule — an + * attachment references a MANIFEST, never a blob — rested on nobody making the + * mistake. That rule has already been broken once here: a replay guard + * compared marker count against ATTACHMENT count while the code below it + * flattened each manifest's REPRESENTATIONS, so one two-frame video threw + * before it could be rebuilt. Both quantities were `Descriptor[]`, and nothing + * could have told them apart. + * + * The brand is a type-level phantom — `declare const` emits nothing — so an + * `Attachment` is still a plain descriptor at runtime and widens to + * {@link Descriptor} for free. Narrowing the other way is deliberate: only a + * store that just composed and committed the manifest may assert it. + * + * @category Media + */ +export type Attachment = Descriptor & { readonly [ROOT]: true }; + +/** + * Narrow an untrusted descriptor to an attachment root, or refuse it. + * + * The ONE way a `Descriptor` becomes an `Attachment` outside a store that just + * committed one — and it exists because there is now a boundary that needs it: + * a browser uploads over the content plane, gets a root back, and sends it in + * a command. That descriptor arrives as JSON from a client, so it is a CLAIM + * about content, not a fact about it. + * + * Checks only what a descriptor can be judged on by itself: a well-formed + * digest, and a media type that says it points at a manifest. Whether the + * manifest is actually THERE is not a question a type can answer — + * `materialize` asks the store and throws if it is not, which is the check + * that matters and the one that cannot be forged. A digest is identity, never + * authorization. + * + * @category Media + */ +export function asAttachment(d: Descriptor): Attachment | null { + return DIGEST_PATTERN.test(d.digest) && d.mediaType === MANIFEST_TYPE + ? (d as Attachment) + : null; +} + +/** The layers that entered the cache, in marker order — what replay needs. */ +export function representationsOf(m: AttachmentManifest): Descriptor[] { + return m.layers.filter(l => l.annotations?.[ROLE_ANNOTATION] !== 'source'); +} + +/** The original the representations were derived from, when it was retained. */ +export function sourceOf(m: AttachmentManifest): Descriptor | undefined { + return m.layers.find(l => l.annotations?.[ROLE_ANNOTATION] === 'source'); +} + +/** + * Assemble a conformant manifest from semantic parts. + * + * MODULE-INTERNAL: {@link commitManifest} is the only way to make one from + * outside, because composing a manifest WITHOUT writing its config blob is + * exactly the conformance bug, and a public pure composer is an invitation to + * do that. Kept separate from the commit sequence because it is the one part + * that touches no store — what an attachment manifest IS, as opposed to how it + * is committed. + * + * `null` when there is no representation: `layers` must hold at least one + * descriptor to be valid, and an attachment with nothing that reached the + * cache is meaningless anyway. + */ +export function composeManifest(parts: { + representations: readonly Descriptor[]; + source?: Descriptor; + config: Descriptor; + annotations?: Record; +}): AttachmentManifest | null { + if (parts.representations.length === 0) return null; + const tag = (d: Descriptor, role: string): Descriptor => ({ + ...d, + annotations: { ...(d.annotations ?? {}), [ROLE_ANNOTATION]: role }, + }); + return { + schemaVersion: 2, + mediaType: MANIFEST_TYPE, + artifactType: ATTACHMENT_ARTIFACT_TYPE, + config: parts.config, + layers: [ + ...parts.representations.map(r => tag(r, 'representation')), + ...(parts.source ? [tag(parts.source, 'source')] : []), + ], + ...(parts.annotations ? { annotations: parts.annotations } : {}), + }; +} + +/** + * Commit an attachment: config blob, then manifest, in that order. + * + * The whole sequence, parameterized only by HOW bytes are stored — which is + * the sole real difference between a filesystem store and an in-memory one. + * `putAttachment`'s body was byte-identical in both before this, so the rules + * below were rules each store happened to follow rather than rules the format + * enforces, and a third store would have had to rediscover them: + * + * 1. **The config blob is WRITTEN, never merely named.** A puller fetches it + * like any other blob, so a manifest referencing OCI's canonical empty + * descriptor without storing `{}` looks correct locally and fails + * everywhere else. This is the conformance trap `verify:oci` gives its own + * check; it is also the step with no local consequence, which is exactly + * why it is the one that gets dropped. + * 2. **Blobs first, manifest second.** A crash may leave orphan blobs — + * harmless, unreferenced, content-addressed — but never a committed + * manifest pointing at content that is not there. + * 3. **An attachment needs a representation.** `layers` must hold at least one + * descriptor to be valid, and an attachment where nothing reached the cache + * is meaningless anyway. + * + * The representation and source blobs are written by the CALLER before this, + * for reason 2 — they are the content; this commits the record of it. + * + * @param putBlob - The store's one primitive. Throws on failure, like every + * write in this contract. + * @throws If there is no representation, or any write fails. + * + * @category Media + */ +export function commitManifest( + putBlob: (bytes: Uint8Array, mediaType: string) => Descriptor, + parts: { + representations: readonly Descriptor[]; + source?: Descriptor; + config?: { bytes: Uint8Array; mediaType: string }; + annotations?: Record; + }, +): Attachment { + if (parts.representations.length === 0) { + throw new Error( + 'commitManifest: no representation. A manifest needs at least one layer ' + + 'to be valid, and an attachment where nothing reached the cache is ' + + 'meaningless.', + ); + } + const enc = new TextEncoder(); + // Written either way. For the empty case the descriptor is the canonical + // constant rather than the write's return, so the manifest names exactly + // what every other OCI tool expects to find. + const config = parts.config + ? putBlob(parts.config.bytes, parts.config.mediaType) + : (putBlob(enc.encode('{}'), EMPTY_DESCRIPTOR.mediaType), EMPTY_DESCRIPTOR); + + const manifest = composeManifest({ ...parts, config }); + if (!manifest) { + // Unreachable: the only null case is the empty-representations one, refused + // above. Kept so a future rule added to composeManifest cannot pass here. + throw new Error('commitManifest: composeManifest refused these parts.'); + } + return putBlob(enc.encode(JSON.stringify(manifest)), MANIFEST_TYPE) as Attachment; +} diff --git a/packages/media/src/file-store.ts b/packages/media/src/file-store.ts new file mode 100644 index 00000000..4ec4d172 --- /dev/null +++ b/packages/media/src/file-store.ts @@ -0,0 +1,176 @@ +/** + * @file The OCI Image Layout, on the filesystem. + * + * FORMAT, not policy: how bytes are addressed and laid out. Where a project + * puts them is `createProjectMediaStore` in rig. Needs `node:fs`, so it is + * reachable only through `@lloyal-labs/media/node` — the package root stays + * browser-safe. + */ +import { ATTACHMENT_ARTIFACT_TYPE, commitManifest, DIGEST_PATTERN } from './attachment'; +import type { Attachment, AttachmentManifest, Descriptor } from './attachment'; +import type { AttachmentStore } from './store'; +import { createHash, randomBytes } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const LAYOUT_VERSION = '1.0.0'; +const INDEX_TYPE = 'application/vnd.oci.image.index.v1+json'; + +/** + * An OCI Image Layout on the filesystem. + * + * Writes the three things `image-layout.md` requires — `oci-layout`, + * `index.json`, and `blobs//` — so the directory is a + * valid layout rather than a private format that resembles one. Paths are + * derivable from the digest alone: the type travels on the descriptor, so + * there is no extension to guess. + * + * Created on the first write, so a text-only run leaves nothing behind. + * + * **Known limitation:** `index.json` is a mutable shared root, updated + * read-modify-write. The writes are synchronous, so concurrent Sessions inside + * ONE host process serialize safely; two processes writing the same layout can + * still lose an index entry. Blobs are unaffected (content-addressed, written + * temp-then-rename), so the loss is discoverability, not data. Reachability GC + * is deliberately absent — nothing here deletes. + * + * @category Media + */ +export class FileAttachmentStore implements AttachmentStore { + private _dir: string; + private _ready = false; + + /** @param dir - The layout root. */ + constructor(dir: string) { + this._dir = dir; + } + + /** `sha256:` → `/blobs/sha256/`. Null for an algorithm this + * build does not implement, rather than building a path out of an + * unvalidated string. */ + private _pathFor(digest: string): string | null { + if (!DIGEST_PATTERN.test(digest)) return null; + const hex = digest.slice('sha256:'.length); + return join(this._dir, 'blobs', 'sha256', hex); + } + + /** Create the layout skeleton once: the blob dir, the `oci-layout` marker, + * and an empty index if none exists. */ + private _ensureLayout(): void { + if (this._ready) return; + mkdirSync(join(this._dir, 'blobs', 'sha256'), { recursive: true }); + // Staging lives OUTSIDE the algorithm directory. Every entry under + // `blobs//` must be a blob whose filename is its own encoded + // digest, so a `.tmp` there is a malformed entry that a crash would + // make permanent — and tooling enumerating blobs would trip over it. + mkdirSync(join(this._dir, '.tmp'), { recursive: true }); + const marker = join(this._dir, 'oci-layout'); + if (!existsSync(marker)) { + writeFileSync(marker, JSON.stringify({ imageLayoutVersion: LAYOUT_VERSION })); + } + const index = join(this._dir, 'index.json'); + if (!existsSync(index)) { + writeFileSync(index, JSON.stringify({ schemaVersion: 2, mediaType: INDEX_TYPE, manifests: [] })); + } + this._ready = true; + } + + putBlob( + bytes: Uint8Array, + mediaType: string, + annotations?: Record, + ): Descriptor { + const digest = 'sha256:' + createHash('sha256').update(bytes).digest('hex'); + // Lazy, and deliberately so: a text-only run must leave the layout + // untouched. Its errors now PROPAGATE, which is what tells "this store was + // never usable" (a read-only volume) apart from "this write failed" (a full + // disk) — the classification the old catch-all erased, without paying for + // it by creating directories a run may never need. + this._ensureLayout(); + const file = this._pathFor(digest)!; + // Content-addressed, so a file already at this path IS these bytes. + // Temp-then-rename so a reader never sees a half-written blob under a + // digest that promises the whole of it. + if (!existsSync(file)) { + // Matches `writeJsonAtomic` (rig/src/config-node.ts), which is the + // repo's reference for this: a RANDOM suffix so concurrent writers never + // collide on a guessable name (pid alone is deterministic and recycled), + // `wx` so a planted file or symlink at that path fails the write instead + // of being followed, and cleanup so a crash leaves no stray. The rename + // is same-filesystem and therefore atomic. + const tmp = join( + this._dir, '.tmp', `${digest.slice(7)}.${randomBytes(4).toString('hex')}`, + ); + try { + writeFileSync(tmp, bytes, { flag: 'wx' }); + renameSync(tmp, file); + } catch (e) { + try { rmSync(tmp, { force: true }); } catch { /* best effort */ } + throw e; + } + } + return { mediaType, digest, size: bytes.byteLength, ...(annotations ? { annotations } : {}) }; + } + + putAttachment(parts: { + representations: readonly Descriptor[]; + source?: Descriptor; + config?: { bytes: Uint8Array; mediaType: string }; + annotations?: Record; + }): Attachment { + // The SEQUENCE is format, so it lives with the format: config blob, then + // manifest, blobs before the record that references them. What is left + // here is the only thing a filesystem store does differently — how bytes + // land, and the index entry, which no other store has. + const ref = commitManifest((bytes, mediaType) => this.putBlob(bytes, mediaType), parts); + this._index(ref); + return ref; + } + + /** Append a manifest descriptor to `index.json`, deduped by digest. */ + private _index(ref: Descriptor): void { + try { + const file = join(this._dir, 'index.json'); + const idx = JSON.parse(readFileSync(file, 'utf8')) as { manifests: Descriptor[] }; + if (idx.manifests.some(m => m.digest === ref.digest)) return; + idx.manifests.push(ref); + const tmp = join(this._dir, '.tmp', `index.${randomBytes(4).toString('hex')}`); + try { + writeFileSync(tmp, JSON.stringify(idx, null, 2), { flag: 'wx' }); + renameSync(tmp, file); + } catch (e) { + try { rmSync(tmp, { force: true }); } catch { /* best effort */ } + throw e; + } + } catch { + // The blob landed; only its index entry did not. Our own resolution is + // by digest, so this costs discoverability by other OCI tooling, not + // replay. + } + } + + get(digest: string): Uint8Array | null { + try { + const file = this._pathFor(digest); + if (!file) return null; + return new Uint8Array(readFileSync(file)); + } catch { + return null; + } + } + + getManifest(digest: string): AttachmentManifest | null { + const bytes = this.get(digest); + if (!bytes) return null; + try { + const parsed = JSON.parse(new TextDecoder().decode(bytes)) as AttachmentManifest; + // Check the artifact type rather than guessing: the version in it exists + // to let a future build refuse a shape it does not understand. + return parsed?.artifactType === ATTACHMENT_ARTIFACT_TYPE && Array.isArray(parsed.layers) + ? parsed + : null; + } catch { + return null; + } + } +} diff --git a/packages/media/src/image.ts b/packages/media/src/image.ts new file mode 100644 index 00000000..1d371126 --- /dev/null +++ b/packages/media/src/image.ts @@ -0,0 +1,589 @@ +/** + * @file Normalize an image before it reaches a vision projector. + * + * Node only, and one implementation on purpose: every ingress a harness has — + * a CLI argument, an Electron upload, a browser upload over wss — arrives at + * harness code running in Node, so one place covers all of them. + * + * A browser-side pass would additionally save wire bytes on the web target + * (base64 inflates an upload by 4/3 before any Node code sees it). That is an + * optimization for a remote host, not a second half of this contract, and it + * would be a second implementation to keep in agreement — so it is + * deliberately not here. + */ + +import type { Attachment, Descriptor } from './attachment'; +import { DERIVE_PREFIX } from './attachment'; +import type { AttachmentStore } from './store'; +import type { ContentIngress } from './ingress'; +import { PROJECTOR_FORMATS, sniffMediaType, UNKNOWN_MEDIA_TYPE } from './media-type'; + +/** + * The default pixel ceiling — mtmd's own (`image_max_pixels`, 2048²). + * + * An image above this is downscaled by the projector no matter what, so + * shipping the original wastes bytes on the wire, work in the decoder, and + * nothing gained: the model sees the same pixels either way. + * + * @category Media + */ +export const DEFAULT_MAX_PIXELS = 4_194_304; + +/** + * The decompression-bomb ceiling — what we are willing to DECODE, as opposed + * to {@link DEFAULT_MAX_PIXELS}, which is what we are willing to ADMIT. + * + * Stated beside its sibling so the two are visibly related: this one is ~24× + * larger, because a 17 MP camera photo is an ordinary input that must derive + * successfully, while a 100 MP one is a file crafted to exhaust a host. Nothing + * set this before, so the only bound was sharp's own ~268 MP default — a number + * chosen by a library that does not know what a projector will accept. + * + * @category Media + */ +export const MAX_INPUT_PIXELS = 100_000_000; + +/** + * How long one decode may take. + * + * Distinct from the HTTP body timeout, which bounds TRANSFER: a fully received + * 200 KB file can still take unbounded time to decode. `sharp.timeout()` was + * available and unused. + * + * @category Media + */ +export const NORMALIZE_TIMEOUT_SECONDS = 20; + +/** + * How many images may be normalized at once, process-wide. + * + * The cap belongs to the PROCESS, not the request: each concurrent + * normalization holds a fully decoded bitmap in memory, so N simultaneous + * uploads cost N bitmaps regardless of how small the encoded bytes were. A + * served host takes uploads from anyone who can reach it. + * + * @category Media + */ +export const MAX_CONCURRENT_NORMALIZATIONS = 4; + +/** + * How long an image may WAIT for a permit before the host refuses it. + * + * An unbounded queue is not a bound: it is a memory leak with a politer name, + * and it turns any permit accounting bug into a process that hangs forever + * instead of failing. A busy host should say it is busy. + * + * Still earns its keep now that {@link NormalizeOpts.signal} exists: a caller + * inside a scope gives up the moment that scope halts, but the HTTP ingress + * route runs in a plain request handler and passes no signal, so this is the + * only bound it has. + * + * Sized well above the worst legitimate wait — a full queue of + * {@link NORMALIZE_TIMEOUT_SECONDS} decodes — so reaching it means genuine + * overload or a bug, never ordinary contention. + * + * @category Media + */ +export const PERMIT_WAIT_TIMEOUT_MS = 60_000; + +/** + * @category Media + */ +export interface NormalizeOpts { + /** Downscale (preserving aspect) until width × height fits. + * Default {@link DEFAULT_MAX_PIXELS}. */ + maxPixels?: number; + /** JPEG quality for re-encodes, 1–100. Default 82 — visually clean at a + * fraction of the bytes, and a projector is not a photo editor. */ + quality?: number; + /** + * Give up when this aborts. + * + * A plain `AbortSignal` rather than anything framework-shaped, because this + * is a BOUNDARY: the harness calls in from inside an Effection scope (which + * hands out a scope-linked signal via `useAbortSignal()`), and the HTTP + * ingress route calls in from a plain Node request handler. One standard + * primitive serves both, and this package stays free of either. + * + * Bounds the WAIT for a normalization slot, not the decode — sharp exposes + * no abort, so {@link NORMALIZE_TIMEOUT_SECONDS} remains the only bound on + * work already underway. + */ + signal?: AbortSignal; +} + +/** + * An image, ready for a projector. + * + * @category Media + */ +interface NormalizedBase { + bytes: Uint8Array; + /** One of {@link PROJECTOR_FORMATS} — guaranteed, or normalizing threw. */ + mime: string; + /** What the INPUT was, established by decoding it — never by anything a + * caller declared. Equal to `mime` on a pass-through and different only + * when a derivation happened, which is exactly when a source layer is + * written and needs a type of its own. + * + * sharp identifies eight formats where the pure signature table identifies + * four, so on the decodable path this is the better answer as well as the + * only trustworthy one. */ + sourceMime: string; + /** What the input measured, so a caller can report what it saved. */ + originalByteLength: number; +} + +/** + * An image, ready for a projector. + * + * **Dimensions are always known.** This was a discriminated union while one + * path could not claim them — the sharp-unreadable hand-off measured nothing — + * and the union existed to stop `createImageIngress` writing the string + * `"undefined"` into a derivation annotation. That path now reads dimensions + * from the header before admitting anything, because a ceiling nobody checks is + * not a ceiling, so the correlation the union encoded no longer exists and a + * union that discriminates nothing is just a second shape to read. + * + * @category Media + */ +export type NormalizedImage = NormalizedBase & { + /** Whether the bytes were re-encoded. Decides whether a source layer is + * worth retaining and whether a derivation record is truthful — NOT whether + * the dimensions are known. */ + derived: boolean; + width: number; + height: number; +}; + +/** + * Bring an image within a projector's reach: a format it decodes, at a size + * it will not immediately shrink. + * + * **Throws on an image it cannot make acceptable**, rather than passing the + * bytes through. A file that reaches the projector unreadable fails inside the + * decoder mid-run, with a worse message and a branch already in flight; a + * caller here can still tell the user which file to replace. + * + * @category Media + */ +export type NormalizeImage = ( + bytes: Uint8Array, + opts?: NormalizeOpts, +) => Promise; + +/** + * A process-wide gate of {@link MAX_CONCURRENT_NORMALIZATIONS} permits. + * + * Module-level on purpose: the resource being protected is host memory, which + * is shared by every session and every request, so a per-call or per-store + * limiter would not bound anything. + */ +const gate = { + permits: MAX_CONCURRENT_NORMALIZATIONS, + waiting: [] as { grant: () => void; refuse: (e: Error) => void }[], +}; + +/** The shape a caller can recognise without knowing this module. Matches what + * `fetch` throws on abort, because that is what callers already handle. */ +const aborted = (): Error => { + const e = new Error('normalizeImage: aborted'); + e.name = 'AbortError'; + return e; +}; + +/** + * Take one of {@link MAX_CONCURRENT_NORMALIZATIONS} permits. + * + * `signal` is how a caller that has GIVEN UP stops occupying the queue. That + * matters more than it sounds: an abandoned request holding a slot is a slot a + * live request cannot have, so under load the queue fills with work whose + * results nobody will read. Callers inside an Effection scope get this for + * free — the scope's own signal aborts on halt. + * + * An in-flight sharp decode cannot be interrupted (sharp exposes no abort; + * {@link NORMALIZE_TIMEOUT_SECONDS} is the only bound on it), so the signal + * covers the WAIT and the moment before work starts, which is where a + * cancelled caller's cost actually accumulates. + */ +async function acquire(signal?: AbortSignal): Promise<() => void> { + if (signal?.aborted) throw aborted(); + + if (gate.permits > 0) { + gate.permits--; + } else { + await new Promise((resolve, reject) => { + const leave = () => { + const at = gate.waiting.indexOf(entry); + if (at >= 0) gate.waiting.splice(at, 1); + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + }; + const onAbort = () => { leave(); reject(aborted()); }; + const entry = { + grant: () => { leave(); resolve(); }, + refuse: (e: Error) => { leave(); reject(e); }, + }; + const timer = setTimeout(() => entry.refuse(new Error( + `normalizeImage: waited ${PERMIT_WAIT_TIMEOUT_MS}ms for one of ` + + `${MAX_CONCURRENT_NORMALIZATIONS} normalization slots and none came free.`, + )), PERMIT_WAIT_TIMEOUT_MS); + // Do not hold the process open on account of a queued upload. + timer.unref?.(); + signal?.addEventListener('abort', onAbort, { once: true }); + gate.waiting.push(entry); + }); + } + + // Granted, but the caller may have given up while it waited. Releasing here + // hands the permit straight to the next in line instead of spending it on + // work nobody is waiting for. + if (signal?.aborted) { + const next = gate.waiting.shift(); + if (next) next.grant(); else gate.permits++; + throw aborted(); + } + + let released = false; + return () => { + // Idempotent: a double release would MANUFACTURE a permit, which is the + // same bug as leaking one with the sign flipped. + if (released) return; + released = true; + const next = gate.waiting.shift(); + if (next) next.grant(); else gate.permits++; + }; +} + +/** + * Does this ICC profile describe sRGB? + * + * Reads the profile's `desc` tag, which is the only field that answers it: + * `metadata().space` reports the PIXEL encoding and says `srgb` for a Display + * P3 image too, so it cannot be used here (verified, sharp 0.35.4). + * + * Zero bytes are stripped before matching because a modern profile stores the + * description as `mluc` — UTF-16BE — where an older one uses ASCII. Anything + * unparseable answers FALSE, which costs a re-encode and never a wrong colour. + */ +/* + * KNOWN LIMIT, decided rather than overlooked (2026-09-01). + * + * A non-sRGB profile forces DERIVATION, and derivation strips the profile — so + * every admitted representation ends up with one consistent interpretation. + * The pixels are NOT converted: sharp 0.35.4 / libvips 8.18.6 performs no ICC + * transform, measured three ways (`.toColourspace('srgb')`, + * `.withIccProfile('srgb')`, `.pipelineColourspace('rgb16')`) on a saturated + * green where P3 and sRGB diverge sharply — all three returned the input + * pixels unchanged. + * + * So a Display P3 photo still reaches the model as P3 numbers read as sRGB. + * That is what happens on every path today regardless, because `stb_image` + * ignores ICC entirely. What this fixes is the ASYMMETRY: colour handling used + * to depend on whether the image happened to exceed the pixel ceiling, which + * nobody would predict. Do not add a `.toColourspace()` call believing it + * converts — it does not. + */ +function isSrgbProfile(icc: Uint8Array): boolean { + try { + const view = new DataView(icc.buffer, icc.byteOffset, icc.byteLength); + const count = view.getUint32(128); + for (let i = 0; i < count; i++) { + const entry = 132 + i * 12; + const sig = String.fromCharCode(...icc.slice(entry, entry + 4)); + if (sig !== 'desc') continue; + const at = view.getUint32(entry + 4); + const size = view.getUint32(entry + 8); + const text = String.fromCharCode(...icc.slice(at, at + Math.min(size, 256))) + .replace(/\0+/g, ''); + return /sRGB/i.test(text); + } + } catch { + // A malformed profile is not an sRGB profile. + } + return false; +} + +/** + * Dimensions from a BMP header — a read, not a decode. + * + * Needed because BMP is the one format the projector reads and sharp does not, + * so it takes the hand-off path where nothing has measured it. Passing bytes + * through unmeasured would break the ceiling this function's own contract + * promises. `null` when the header is not there to read. + */ +function bmpDimensions(bytes: Uint8Array): { width: number; height: number } | null { + // 'BM', then a 14-byte file header, then a DIB header whose width and height + // sit at 18 and 22 as signed little-endian 32-bit integers. + if (bytes.byteLength < 26 || bytes[0] !== 0x42 || bytes[1] !== 0x4d) return null; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const width = view.getInt32(18, true); + // Height is negative for a top-down bitmap; the magnitude is what counts. + const height = Math.abs(view.getInt32(22, true)); + if (width <= 0 || height <= 0) return null; + return { width, height }; +} + +/** + * Normalize an image for a vision projector. + * + * Two guarantees, both of which the projector otherwise enforces too late: + * + * - **Format.** The result is one of {@link PROJECTOR_FORMATS}. A file picker's + * `accept` is advisory — drag-and-drop and paste bypass it — so without this + * an unsupported file fails inside the decoder, mid-run, on a branch already + * in flight. + * - **Size.** Anything above `maxPixels` is downscaled here rather than by the + * projector, which would do it anyway after the bytes had already crossed a + * socket and been decoded. The model sees the same pixels either way. + * + * Re-encodes only when it must: an image already in an accepted format and + * already within the ceiling comes back with its original bytes untouched, so + * normalizing costs nothing in the common case and never degrades an image + * twice across repeated calls. + * + * @throws If the bytes are not a decodable image — the caller can still name + * the file to replace, which nothing downstream can. + * + * @category Media + */ +export const normalizeImage: NormalizeImage = async (bytes, opts = {}) => { + const maxPixels = opts.maxPixels ?? DEFAULT_MAX_PIXELS; + const quality = opts.quality ?? 82; + + // Required at call time, not imported at module load: `sharp` is a native + // dependency, and a harness that never accepts an image should not pay its + // load. The message names the package because the failure is a missing + // install, not a bad picture. + // eslint-disable-next-line @typescript-eslint/no-require-imports + let sharp: typeof import('sharp').default; + try { + // sharp's CJS export is the callable itself; its types put it on `default`. + sharp = require('sharp'); + } catch { + throw new Error( + 'normalizeImage: `sharp` is not installed. Add it to the harness that ' + + 'accepts image uploads (npm i sharp), or hand the projector bytes ' + + `that are already ${PROJECTOR_FORMATS.map(f => f.replace(/^image\//, '')).join('/')} ` + + 'and within its pixel ceiling.', + ); + } + + // A decompression bomb is a small file that declares an enormous image. + // Without this the only bound was sharp's ~268 MP default. + // Everything from here holds a decoded bitmap, so it runs under the + // process-wide gate. Released in a `finally`: a permit leaked on a FAILING + // upload is the failure mode that matters — a handful of bad files would + // exhaust the gate permanently and wedge the host, and bad files are exactly + // what arrives in volume. + const release = await acquire(opts.signal); + try { + const input = sharp(bytes, { animated: false, limitInputPixels: MAX_INPUT_PIXELS }) + .timeout({ seconds: NORMALIZE_TIMEOUT_SECONDS }); + let meta; + try { + meta = await input.metadata(); + } catch (err) { + // sharp and the projector read OVERLAPPING but different sets. sharp reads + // webp/heif/tiff/svg, which the projector cannot; the projector reads + // bmp/tga/psd/hdr/pic/pnm, which sharp cannot. Refusing everything sharp + // fails on would cost a user their whole query for a file the model reads + // perfectly well. + // + // Handing bytes over is safe: `MtmdSource`'s constructor decodes every + // bitmap BEFORE `mtmd_tokenize` and before any `decode_segments`, throwing + // with the offending index and the branch untouched + // (`liblloyal/include/lloyal/mtmd.hpp`). An unreadable file fails at the + // same phase either way — this only decides who reports it. + // + // But only what we can IDENTIFY. The gate is the INTERSECTION of the two + // lists: sniffable AND projector-readable. Of the projector's exotics that + // is bmp alone — tga/psd/hdr/pic/pnm have no signature here, so they are + // refused rather than admitted unidentified and unmeasured. An earlier + // version tested `PROJECTOR_FORMATS.includes(...)` while that constant was + // DERIVED from the sniff table, so it read as covering six formats and + // covered one. + const sniffed = sniffMediaType(bytes); + if (sniffed === UNKNOWN_MEDIA_TYPE || !PROJECTOR_FORMATS.includes(sniffed)) throw err; + + // Identified, but nothing has MEASURED it — and the ceiling is part of the + // contract for every admitted representation, not only for what sharp can + // read. A header parse is cheap and is not a decode. Refusing names the + // file the user must replace; passing it through would break a promise + // this function makes, silently, on the one path nobody can downscale. + const dims = bmpDimensions(bytes); + if (!dims) { + throw new Error( + `normalizeImage: ${sniffed} is a format the projector reads but sharp ` + + 'cannot, and its dimensions could not be established from the header, ' + + 'so it cannot be admitted under a pixel ceiling.', + ); + } + if (dims.width * dims.height > maxPixels) { + throw new Error( + `normalizeImage: ${dims.width}x${dims.height} exceeds the ${maxPixels}-pixel ` + + `ceiling, and ${sniffed} cannot be downscaled here — sharp does not read ` + + 'it. Convert it to PNG or JPEG first.', + ); + } + return { + bytes, derived: false, mime: sniffed, sourceMime: sniffed, + width: dims.width, height: dims.height, originalByteLength: bytes.byteLength, + }; + } + // EXIF orientations 5-8 mean the stored pixels are transposed relative to + // how the image is meant to be seen. Since `.autoOrient()` below rotates + // BEFORE the resize, every measurement here has to be in DISPLAY terms — a + // ceiling computed on stored dimensions would be applied to a rotated image + // and miss by the aspect ratio. + const swapped = (meta.orientation ?? 1) >= 5; + const w = (swapped ? meta.height : meta.width) ?? 0; + const h = (swapped ? meta.width : meta.height) ?? 0; + if (w === 0 || h === 0) { + throw new Error('normalizeImage: not a decodable image (no dimensions)'); + } + + // sharp names the format exactly as the mime subtype (`jpeg`, not `jpg`), + // so this is a prefix and not a translation table. + const mime = meta.format ? `image/${meta.format}` : ''; + const pixels = w * h; + + // THE ADMISSION POLICY. Byte-identical pass-through is permitted only when + // every one of these holds; otherwise the image is DERIVED and the original + // is retained as the source layer. + // + // The last two are not precautionary. `stb_image.h` — what mtmd loads with — + // contains zero EXIF/orientation matches and mtmd applies no rotation around + // `stbi_load_from_memory`; it ignores ICC entirely. So a tag we leave on is a + // tag NOBODY downstream reads, and a phone JPEG small enough to pass through + // reaches the model sideways with nothing left to say so. Size was never what + // made that safe or unsafe — which is exactly the asymmetry this removes. + const orientation = meta.orientation ?? 1; + const colourSafe = !meta.icc || isSrgbProfile(meta.icc); + const admissible = + PROJECTOR_FORMATS.includes(mime) + && pixels <= maxPixels + && orientation === 1 + && colourSafe; + + if (admissible) { + return { bytes, derived: false, mime, sourceMime: mime, width: w, height: h, originalByteLength: bytes.byteLength }; + } + + // Preserve aspect: sharp fits inside the box, so deriving one side from the + // area ratio and letting it compute the other keeps the ratio exact. + const scale = pixels > maxPixels ? Math.sqrt(maxPixels / pixels) : 1; + const width = Math.max(1, Math.floor(w * scale)); + + const out = await input + // Apply EXIF orientation to the PIXELS before anything else. Re-encoding + // drops the tag (sharp strips metadata by default), so without this a + // portrait phone photo — `Orientation=6`, the common case — reaches the + // projector rotated 90° with nothing left to say so. Irrecoverable: the + // model sees a sideways image and cannot know it. + .autoOrient() + .resize({ width, withoutEnlargement: true }) + .jpeg({ quality }) + .toBuffer({ resolveWithObject: true }); + + return { + bytes: new Uint8Array(out.data), + derived: true, + mime: 'image/jpeg', + sourceMime: mime, + width: out.info.width, + height: out.info.height, + originalByteLength: bytes.byteLength, + }; + } finally { + release(); + } +}; + +/** + * Identifies the normalization that produced a representation. + * + * Bumped whenever the pipeline's OUTPUT BYTES could change for the same input + * and options — a different resize kernel, a different encoder default. It is + * part of the derivation identity because "was this derived under the + * parameters now in force?" cannot be answered from the options alone. + */ +const PROFILE = 'image.v1'; + +/** + * The image ingress: normalize, commit source + representation, return the root. + * + * This is the ONE place raw media becomes admitted content, shared by all three + * ingresses — a browser upload arriving over HTTP, a spine's standing + * reference material, and a tool's result. Wiring it into only one of them + * would leave the others feeding unnormalized bytes to the projector with no + * derivation recorded. + * + * **Order is commit-then-return, and callers must prefill only after.** + * Normalizing and committing before the prefill means a failure costs nothing + * but an orphan blob; committing after would mean a failed write leaves media + * in the cache that can never be replayed. The write-order invariant already + * accepts orphans — "harmless orphan blobs, never a committed manifest + * pointing at absent content." + * + * **The source is retained by default**, so a representation can be re-derived + * later under a better sampler or for a model that reads the original + * natively. It is skipped only when normalization was a no-op and the two + * would be the same blob. + * + * @param store - The project content store. + * @param opts - Normalization options; recorded verbatim into the manifest. + * + * @category Media + */ +export function createImageIngress( + store: AttachmentStore, + opts: NormalizeOpts = {}, +): ContentIngress { + return { + async ingest(bytes: Uint8Array, signal?: AbortSignal): Promise { + // Admission converts what the projector cannot read and lets everything + // else through untouched — it is not a validation gate. It is not a + // resource boundary either: the transport bounds body size before this. + const norm = await normalizeImage(bytes, { ...opts, ...(signal ? { signal } : {}) }); + + // A derivation record describes a derivation that HAPPENED. Writing it + // on a pass-through would annotate bytes nobody re-encoded with a + // quality and a ceiling that never applied to them — and since these + // annotations exist so a later reader can ask "was this derived under + // the parameters now in force?", a false one is worse than none. + const derived: Record = norm.derived + ? { + [`${DERIVE_PREFIX}profile`]: PROFILE, + [`${DERIVE_PREFIX}maxPixels`]: String(opts.maxPixels ?? DEFAULT_MAX_PIXELS), + [`${DERIVE_PREFIX}quality`]: String(opts.quality ?? 82), + [`${DERIVE_PREFIX}width`]: String(norm.width), + [`${DERIVE_PREFIX}height`]: String(norm.height), + [`${DERIVE_PREFIX}format`]: norm.mime, + } + : {}; + + // No null check: a store write that cannot happen THROWS, carrying the + // real reason. This used to translate a null into a generic sentence, + // which is how "read-only volume" and "disk full" became the same + // message — the store knows which; only it ever did. + const representation = store.putBlob(norm.bytes, norm.mime, derived); + + // No derivation, no source layer: the two blobs would be byte-identical + // and the second would say nothing. `normalizeImage` decides. + let source: Descriptor | undefined; + if (norm.derived) { + // A failed source write is a failure, not an absence. `?? undefined` + // here meant a retained original could vanish and the manifest commit + // anyway — the user's own file dropped, silently, while the + // representation's failure threw. One convention, both writes. + source = store.putBlob(bytes, norm.sourceMime); + } + + return store.putAttachment({ + representations: [representation], + ...(source ? { source } : {}), + }); + }, + }; +} diff --git a/packages/media/src/index.ts b/packages/media/src/index.ts new file mode 100644 index 00000000..ff1af849 --- /dev/null +++ b/packages/media/src/index.ts @@ -0,0 +1,30 @@ +/** + * @file `@lloyal-labs/media` — content, and the media that becomes it. + * + * This entry is **pure and browser-safe**: OCI shapes, the two ports a harness + * injects, and the resolver replay runs. It imports nothing from the rest of + * the HDK, which is what makes it a dependency root — `agents` and `rig` both + * depend on it, and it depends on neither. + * + * Anything needing a runtime — the image normalizer (`sharp`), the filesystem + * layout — is behind `@lloyal-labs/media/node`. The split is the boundary, not + * a convention: `.` cannot import `./node`. + * + * **Two senses of "media" meet here.** OCI's sense is *typed bytes* + * (`mediaType`, `sniffMediaType`); the modality sense is *pictures and sound* + * (what a projector decodes). The format half is indifferent to modality — a + * video or a rasterized page is the same manifest graph as an image. + */ +export { + asAttachment, ATTACHMENT_ARTIFACT_TYPE, commitManifest, DERIVE_PREFIX, DIGEST_PATTERN, + EMPTY_DESCRIPTOR, MANIFEST_TYPE, representationsOf, ROLE_ANNOTATION, sourceOf, +} from './attachment'; +export type { Attachment, AttachmentManifest, Descriptor } from './attachment'; + +export { NullAttachmentStore } from './store'; +export type { AttachmentStore } from './store'; + +export { materialize, NoContentIngress } from './ingress'; +export type { ContentIngress, PreparedContent } from './ingress'; + +export { PROJECTOR_FORMATS, sniffMediaType, UNKNOWN_MEDIA_TYPE } from './media-type'; diff --git a/packages/media/src/ingress.ts b/packages/media/src/ingress.ts new file mode 100644 index 00000000..db3620d2 --- /dev/null +++ b/packages/media/src/ingress.ts @@ -0,0 +1,128 @@ +/** + * @file Where raw media becomes admitted content, and content becomes bytes. + * + * The PORT (`ContentIngress`) and the pure resolver (`materialize`) — the two + * halves that need nothing but this package. Whoever drives them across a + * batch is an orchestration concern and lives with the orchestrator. + */ +import { representationsOf } from './attachment'; +import type { Attachment } from './attachment'; +import type { AttachmentStore } from './store'; + +/** + * Admitted content, ready for a delta builder. + * + * Deliberately NOT a delta: the builders are role-specific — a user turn, a + * spine header and a tool result each need a different prompt, callId or + * separator — so returning one would drag prompt composition into the content + * layer. This is the seam where all three converge, and they differ only in + * how the raw bytes arrived. + * + * @category Media + */ +export interface PreparedContent { + /** Roots, in ingest order — what the trace and the fold carry. */ + attachments: readonly Attachment[]; + /** Every root's representations, flattened in order — the EXACT bytes to + * hand a builder and then the projector. One image contributes one; a video + * contributes its sampled frames. */ + bitmaps: readonly Uint8Array[]; +} + +/** + * Expand root descriptors to the exact bytes that were admitted. + * + * The source is never returned: it is what the user brought, not what the + * model saw, and replaying it would rebuild different cells under different + * derivation parameters. + * + * Needs only the store, so it is safe on every runtime — unlike ingest, which + * needs a native normalizer. + * + * @throws If any root or blob is missing. Silent degradation here would + * rebuild a different KV state behind an identical-looking prompt. + * + * @category Media + */ +export function materialize( + store: AttachmentStore, + roots: readonly Attachment[], +): PreparedContent { + const bitmaps: Uint8Array[] = []; + for (const root of roots) { + const manifest = store.getManifest(root.digest); + if (!manifest) { + throw new Error( + `materialize: attachment manifest ${root.digest.slice(0, 19)}… is not ` + + 'in the content store.', + ); + } + for (const rep of representationsOf(manifest)) { + const bytes = store.get(rep.digest); + if (!bytes) { + throw new Error( + `materialize: blob ${rep.digest.slice(0, 19)}… (${rep.mediaType}) is ` + + 'referenced by a manifest but missing from the content store.', + ); + } + bitmaps.push(bytes); + } + } + return { attachments: roots, bitmaps }; +} + +/** + * Turns raw bytes into admitted content: normalize, commit, return the root. + * + * One implementation serves all three ingresses. Injected rather than imported + * because normalizing needs a native dependency (`sharp`) that this entry must + * never pull — it is browser-safe by construction, and `agents`, which drives + * it, has zero static `node:` imports — and because the HTTP layer must not + * decide what "admitted" means. + * + * @category Media + */ +export interface ContentIngress { + /** Normalize, commit, and return the root. + * + * **Takes only the bytes.** The type is not an argument because the bytes + * answer it and nothing else may: every caller used to sniff, pass the + * answer in, and have the ingress decode and prefer its own — the same + * question asked at four call sites and then re-asked here. The HTTP route + * was worse than redundant, forwarding a client's `Content-Type` header as + * authority over content it did not produce. + * + * `signal` is the second parameter and does not undermine the first rule: + * the type is DATA ABOUT THE CONTENT, which the bytes answer, while this is + * LIFETIME, which only the caller knows. A plain `AbortSignal` because this + * is a boundary — an Effection scope hands one out via `useAbortSignal()`, + * and a Node request handler makes its own, so one standard primitive + * serves both and this package depends on neither. + * + * REJECTS rather than resolving to nothing — the async form of the one + * convention {@link AttachmentStore} states: a write that cannot happen is + * a failure and says why. Resolving to null here would let a caller emit a + * marker for content nothing can resolve. */ + ingest(bytes: Uint8Array, signal?: AbortSignal): Promise; +} + +/** + * The default ingress: inert for text, loud for media. + * + * A harness that never accepts media pays nothing and never sees this. One + * that does, without installing an ingress service, fails HERE — before any + * prefill — rather than quietly feeding unnormalized, unaddressed bytes to the + * projector and producing a run that cannot be replayed. + * + * @category Media + */ +export class NoContentIngress implements ContentIngress { + ingest(): Promise { + return Promise.reject(new Error( + 'No content ingress installed, so this media cannot be normalized or ' + + 'addressed — and unaddressed media makes the run unreplayable. ' + + 'Install one (`createImageIngress` from @lloyal-labs/media/node) and ' + + 'set it on the Ingress context.', + )); + } +} diff --git a/packages/media/src/media-type.ts b/packages/media/src/media-type.ts new file mode 100644 index 00000000..3a0bfc09 --- /dev/null +++ b/packages/media/src/media-type.ts @@ -0,0 +1,76 @@ +/** + * @file Identify an image format from its leading bytes. + * + * Its own file because it depends on nothing else in the content surface, and + * because `spine.ts` and `agent-pool.ts` already import it on its own — the + * callers treated it as a separate module before it was one. + */ + +/** + * The image formats a vision projector decodes, by their leading bytes. + * + * A table rather than a chain of ifs. Anything unmatched is still stored — + * validating pixels belongs to the normalizer that runs before ingress, and to + * the decoder, both of which fail with a better message than this could. + */ +const SIGNATURES: ReadonlyArray<{ mediaType: string; magic: readonly number[] }> = [ + { mediaType: 'image/jpeg', magic: [0xff, 0xd8, 0xff] }, + { mediaType: 'image/png', magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] }, + { mediaType: 'image/gif', magic: [0x47, 0x49, 0x46, 0x38] }, + { mediaType: 'image/bmp', magic: [0x42, 0x4d] }, +]; + +/** + * Best-effort media type from leading bytes, for a caller that has none. + * + * @category Media + */ +/** What {@link sniffMediaType} returns when no signature matches. Named because + * callers branch on it — a bare literal at each site is how a sentinel and its + * producer drift apart. */ +export const UNKNOWN_MEDIA_TYPE = 'application/octet-stream'; + +export function sniffMediaType(bytes: Uint8Array): string { + return SIGNATURES.find(s => s.magic.every((b, i) => bytes[i] === b))?.mediaType + ?? UNKNOWN_MEDIA_TYPE; +} + +/** + * The image formats the vision projector decodes. + * + * **Its own list, deliberately NOT derived from {@link SIGNATURES}.** These are + * two different questions and they have different answers: + * + * - `SIGNATURES` — "what can I identify from leading bytes?" Four formats. + * - `PROJECTOR_FORMATS` — "what will mtmd decode?" Nine. + * + * Ground truth for this list is the kernel, not an assumption: + * `mtmd-helper.cpp` loads through `stbi_load_from_memory` and sets no + * `STBI_NO_*` / `STBI_ONLY_*` defines, so every stb_image decoder is compiled + * in. An earlier version derived this from the sniff table with a `.map()` and + * a comment claiming the two "cannot drift apart" — true, and precisely the + * problem: it made them provably equal when they are not, and a rescue path + * that named six formats could reach exactly one. + * + * Admitting bytes needs BOTH answers: a format we can identify AND the + * projector can read. That intersection is computed where it is used, not + * baked in here. + * + * @category Media + */ +export const PROJECTOR_FORMATS: readonly string[] = [ + // Sniffable (see SIGNATURES) and projector-readable. + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/bmp', + // Projector-readable, but we carry no signature for them — so they are + // refused at ingress rather than handed over unidentified. Listed because + // this constant answers "what does the decoder read?", which is a fact about + // mtmd and stays true whatever we can sniff. + 'image/x-tga', + 'image/vnd.adobe.photoshop', + 'image/vnd.radiance', + 'image/x-softimage-pic', + 'image/x-portable-anymap', +]; diff --git a/packages/media/src/node.ts b/packages/media/src/node.ts new file mode 100644 index 00000000..66d57c51 --- /dev/null +++ b/packages/media/src/node.ts @@ -0,0 +1,15 @@ +/** + * @file `@lloyal-labs/media/node` — the half that needs a runtime. + * + * Two independent things live behind this entry, and they are separate files + * for the same reason they are separate concerns: the normalizer decides what + * pixels are admitted, the layout decides how bytes are stored, and neither + * changes when the other does. + * + * `sharp` is an OPTIONAL peer. A consumer that only reads and writes content + * never installs it; one that normalizes does, and the manifest says so. + */ +export { createImageIngress, DEFAULT_MAX_PIXELS, normalizeImage } from './image'; +export type { NormalizedImage, NormalizeImage, NormalizeOpts } from './image'; + +export { FileAttachmentStore } from './file-store'; diff --git a/packages/media/src/store.ts b/packages/media/src/store.ts new file mode 100644 index 00000000..f8cd7cbc --- /dev/null +++ b/packages/media/src/store.ts @@ -0,0 +1,124 @@ +/** + * @file The content-store contract, and the default that refuses media. + * + * The invariant it exists for: **anything that reaches model state must be + * addressable, or the run is not replayable.** A trace records the media + * marker, never the pixels, so without a content store a media-bearing run + * cannot be rebuilt — which makes addressability a correctness requirement, + * not telemetry, and is why it is never gated behind a dev flag. + * + * Pure — this entry is browser-safe by construction. The filesystem + * implementation is `FileAttachmentStore` in `@lloyal-labs/media/node`, and a + * project opens one through `createProjectMediaStore` in `@lloyal-labs/rig`: + * the LAYOUT is format and ships with this package, while WHERE a project + * keeps it is harness policy and stays with the harness. + */ + +import type { Attachment, AttachmentManifest, Descriptor } from './attachment'; + +/** + * Content store for a run's media. + * + * Read from the {@link Attachments} Effection context, which defaults to + * {@link NullAttachmentStore}. + * + * **One failure convention: writes THROW, lookups return nothing.** A write + * that cannot happen is a failure and says why; asking about content that was + * never stored is a normal question with a normal answer. + * + * This doc used to say the opposite — "writes must not throw", the cost + * deferred to a replay that refuses loudly — while the null object below it + * threw, the filesystem store returned null, and the one production caller + * converted every null back into a throw with a message that had lost the + * actual reason. Three conventions for one question. + * + * The deferral argument does not survive contact with the order of writes: + * when the REPRESENTATION write fails, `putAttachment` never runs, so no + * manifest exists and replay has nothing to refuse. The safety net it appealed + * to is not there, and the run carries on with media in the cache and no + * record of it — the exact silent outcome the addressability rule exists to + * prevent. + * + * @category Media + */ +export interface AttachmentStore { + /** Store bytes, return their descriptor. Idempotent by content. + * + * @throws If the bytes could not be stored, carrying the underlying reason. */ + putBlob(bytes: Uint8Array, mediaType: string, annotations?: Record): Descriptor; + /** Compose a conformant manifest from semantic parts, store it, and index + * it. Callers never author a manifest themselves, so a non-conformant one + * cannot reach disk. + * + * @throws If there is no representation, or the manifest could not be + * stored. `layers` must hold at least one descriptor to be valid, + * and an attachment where nothing reached the cache is meaningless. */ + putAttachment(parts: { + representations: readonly Descriptor[]; + source?: Descriptor; + /** Typed structured metadata about the attachment as a whole, stored as + * its own blob. Omit for an image, which has nothing to say beyond its + * layers — the manifest then carries {@link EMPTY_DESCRIPTOR}. + * + * This slot exists because annotations are `map`, and + * timed media will need more than strings: a timeline, track + * descriptors, the sampling policy, frame-to-audio correspondence. + * Encoding that as JSON inside an annotation would be unvalidatable, and + * a typed config blob is what OCI provides the slot for. A reader + * branches on `config.mediaType`, so adding one later is additive. */ + config?: { bytes: Uint8Array; mediaType: string }; + annotations?: Record; + }): Attachment; + /** Resolve blob bytes by digest. `null` when this digest was never stored. + * + * Resolution goes STRAIGHT to `blobs//` and never + * consults `index.json`: the index is an export and discovery catalogue, + * not the runtime authority. A lost concurrent index update can therefore + * hide an attachment from OCI tooling, but it can never invalidate a + * recorded run. */ + get(digest: string): Uint8Array | null; + /** Resolve and validate a manifest. `null` when absent, unparsable, or not + * an artifact type this build understands. */ + getManifest(digest: string): AttachmentManifest | null; +} + +/** + * The default store: inert for text, loud for media. + * + * A text-only run never reaches a write here and pays nothing — which is why + * this stays the context default and `.expect()` never throws. + * + * Every write throws, because there is no such thing as a successful write to + * a store that does not exist. A harness that accepts media installs a real + * one; a harness that does not is told at the first image rather than at + * replay, months later. Reads return nothing, like any other store asked for + * content it does not hold. + * + * @category Media + */ +export class NullAttachmentStore implements AttachmentStore { + // Every method carries the FULL contract signature even where it ignores the + // arguments. Narrowing them (`putBlob(bytes)`, `get()`) still satisfies + // `implements`, but a caller holding the concrete type then sees a different + // API than the interface promises — `putBlob(bytes, mediaType)` fails to + // compile against the null object while compiling against every other store. + putBlob( + _bytes: Uint8Array, + _mediaType?: string, + _annotations?: Record, + ): Descriptor { + throw new Error( + 'No content store installed, so this media cannot be addressed — and ' + + 'unaddressed media makes the run unreplayable. Install a store ' + + '(`createProjectMediaStore` from @lloyal-labs/rig) and set it on the ' + + 'Attachments context.', + ); + } + putAttachment(_parts?: unknown): Attachment { + throw new Error( + 'No content store installed, so this attachment cannot be committed.', + ); + } + get(_digest?: string): Uint8Array | null { return null; } + getManifest(_digest?: string): AttachmentManifest | null { return null; } +} diff --git a/packages/media/test/commit-manifest.test.ts b/packages/media/test/commit-manifest.test.ts new file mode 100644 index 00000000..f17d53a0 --- /dev/null +++ b/packages/media/test/commit-manifest.test.ts @@ -0,0 +1,112 @@ +/** + * The commit sequence — the rule every store would otherwise re-implement. + * + * `putAttachment`'s body was byte-identical in the filesystem store and the + * in-memory double: decide the config, write it, compose, write the manifest. + * Only the last step differs between stores, and only by HOW bytes are stored. + * A third store would have to reproduce the rest, and the step most easily + * dropped is the one with no local consequence — writing the canonical empty + * config blob. A manifest that merely NAMES it looks correct to us and fails + * every puller, which is why `verify:oci` gives it a check of its own. + */ +import { describe, it, expect } from 'vitest'; +import { asAttachment, commitManifest, EMPTY_DESCRIPTOR, MANIFEST_TYPE, representationsOf, sourceOf } from '../src/index'; +import type { Descriptor } from '../src/index'; +import { createHash } from 'node:crypto'; + +/** The minimum a store is: bytes in, descriptor out. */ +const recorder = () => { + const written: { mediaType: string; bytes: Uint8Array }[] = []; + const putBlob = (bytes: Uint8Array, mediaType: string): Descriptor => { + written.push({ mediaType, bytes }); + return { + mediaType, + digest: 'sha256:' + createHash('sha256').update(bytes).digest('hex'), + size: bytes.byteLength, + }; + }; + return { written, putBlob }; +}; + +const REP: Descriptor = { mediaType: 'image/jpeg', digest: 'sha256:' + 'a'.repeat(64), size: 10 }; + +describe('commitManifest', () => { + it('WRITES the canonical empty config, never only names it', () => { + const { written, putBlob } = recorder(); + + const ref = commitManifest(putBlob, { representations: [REP] }); + + const config = written.find(w => w.mediaType === EMPTY_DESCRIPTOR.mediaType); + expect(config, 'the config blob was named but never stored').toBeDefined(); + expect(new TextDecoder().decode(config!.bytes)).toBe('{}'); + expect(ref.mediaType).toBe(MANIFEST_TYPE); + }); + + it('writes the config BEFORE the manifest that references it', () => { + // Blobs first, manifest second: a crash may leave orphan blobs, never a + // committed manifest pointing at content that is not there. + const { written, putBlob } = recorder(); + + commitManifest(putBlob, { representations: [REP] }); + + expect(written.map(w => w.mediaType)) + .toEqual([EMPTY_DESCRIPTOR.mediaType, MANIFEST_TYPE]); + }); + + it('stores a caller-supplied config and references THAT digest', () => { + const { written, putBlob } = recorder(); + const bytes = new TextEncoder().encode('{"timeline":[]}'); + + const ref = commitManifest(putBlob, { + representations: [REP], + config: { bytes, mediaType: 'application/vnd.lloyal.timeline.v1+json' }, + }); + + const stored = written.find(w => w.mediaType.includes('timeline')); + expect(stored, 'a supplied config must be stored like any other blob').toBeDefined(); + const manifest = JSON.parse(new TextDecoder().decode( + written.find(w => w.mediaType === MANIFEST_TYPE)!.bytes)); + expect(manifest.config.digest).not.toBe(EMPTY_DESCRIPTOR.digest); + expect(manifest.config.size).toBe(bytes.byteLength); + expect(ref.digest).toMatch(/^sha256:[0-9a-f]{64}$/); + }); + + it('refuses an attachment with no representation', () => { + const { putBlob } = recorder(); + expect(() => commitManifest(putBlob, { representations: [] })) + .toThrow(/no representation/i); + }); + + it('tags layer roles so replay can tell them apart', () => { + const { written, putBlob } = recorder(); + const source: Descriptor = { mediaType: 'image/png', digest: 'sha256:' + 'b'.repeat(64), size: 99 }; + + commitManifest(putBlob, { representations: [REP], source }); + + const manifest = JSON.parse(new TextDecoder().decode( + written.find(w => w.mediaType === MANIFEST_TYPE)!.bytes)); + expect(representationsOf(manifest)).toHaveLength(1); + expect(sourceOf(manifest)?.digest).toBe(source.digest); + }); +}); + +describe('asAttachment — the untrusted-wire boundary', () => { + it('accepts a well-formed manifest descriptor', () => { + expect(asAttachment({ mediaType: MANIFEST_TYPE, digest: 'sha256:' + 'f'.repeat(64), size: 1 })) + .not.toBeNull(); + }); + + it('refuses a descriptor that points at a BLOB, not a manifest', () => { + // The design's central rule: an attachment references a manifest. A client + // sending a representation digest would otherwise have it treated as a + // root and expanded as one. + expect(asAttachment({ mediaType: 'image/jpeg', digest: 'sha256:' + 'f'.repeat(64), size: 1 })) + .toBeNull(); + }); + + it('refuses a malformed digest rather than building a path from it', () => { + for (const digest of ['../../etc/passwd', 'sha512:' + 'a'.repeat(64), 'sha256:xyz', '']) { + expect(asAttachment({ mediaType: MANIFEST_TYPE, digest, size: 1 }), digest).toBeNull(); + } + }); +}); diff --git a/packages/media/test/file-store.test.ts b/packages/media/test/file-store.test.ts new file mode 100644 index 00000000..478d2596 --- /dev/null +++ b/packages/media/test/file-store.test.ts @@ -0,0 +1,182 @@ +/** + * The filesystem content store — conformance of the OCI Image Layout it writes. + * + * Here rather than in `agents` because the layout is irreducibly Node and the + * implementation lives with rig's other filesystem mechanics. What `agents` + * still owns is the SHAPE (`commitManifest`) and the contract; those are pure + * and tested there. The failure this guards is a directory that resembles a + * layout without being one — `oras`/`crane`/`skopeo` read it with none of our + * code in the path, so "close enough" is indistinguishable from broken. + */ +import { describe, it, expect } from 'vitest'; +import { mkdtempSync, existsSync, readdirSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { FileAttachmentStore } from '../src/file-store'; +import { + representationsOf, sourceOf, sniffMediaType, ATTACHMENT_ARTIFACT_TYPE, EMPTY_DESCRIPTOR, +} from '../src/index'; + +const tmp = (): string => mkdtempSync(join(tmpdir(), 'lloyal-att-')); +const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]); +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 9]); +const GIF = new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61]); +const BMP = new Uint8Array([0x42, 0x4d, 7, 7]); +const JUNK = new Uint8Array([0x00, 0x01, 0x02, 0x03]); + +describe('FileAttachmentStore — an OCI Image Layout', () => { + it('writes the three entries image-layout.md requires', () => { + const dir = tmp(); + const s = new FileAttachmentStore(dir); + s.putBlob(JPEG, 'image/jpeg'); + expect(JSON.parse(readFileSync(join(dir, 'oci-layout'), 'utf8'))) + .toEqual({ imageLayoutVersion: '1.0.0' }); + const idx = JSON.parse(readFileSync(join(dir, 'index.json'), 'utf8')); + expect(idx.schemaVersion).toBe(2); + expect(idx.mediaType).toBe('application/vnd.oci.image.index.v1+json'); + // Deterministic path: the type is on the descriptor, so nothing has to + // guess an extension. + expect(existsSync(join(dir, 'blobs', 'sha256'))).toBe(true); + }); + + it('never puts a non-blob entry inside the algorithm directory', () => { + // Every entry under `blobs//` must be a blob whose filename IS + // its encoded digest. Staging a write as `.tmp` there would leave a + // malformed entry behind on a crash, and would trip any tool enumerating + // blobs — so staging lives at the layout root, which the spec allows. + const dir = tmp(); + const s = new FileAttachmentStore(dir); + s.putAttachment({ representations: [s.putBlob(JPEG, 'image/jpeg')!] }); + for (const name of readdirSync(join(dir, 'blobs', 'sha256'))) { + expect(name).toMatch(/^[0-9a-f]{64}$/); + } + expect(existsSync(join(dir, '.tmp'))).toBe(true); + }); + + it('round-trips a blob under an algorithm-prefixed digest', () => { + const dir = tmp(); + const s = new FileAttachmentStore(dir); + const d = s.putBlob(JPEG, 'image/jpeg'); + expect(d.digest).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(d.size).toBe(JPEG.byteLength); + expect(d.mediaType).toBe('image/jpeg'); + expect(Array.from(s.get(d.digest)!)).toEqual(Array.from(JPEG)); + }); + + it('composes a conformant artifact manifest', () => { + const dir = tmp(); + const s = new FileAttachmentStore(dir); + const rep = s.putBlob(PNG, 'image/png', { 'ai.lloyal.derive.quality': '82' }); + const src = s.putBlob(JPEG, 'image/jpeg'); + const att = s.putAttachment({ representations: [rep], source: src }); + + expect(att.mediaType).toBe('application/vnd.oci.image.manifest.v1+json'); + const m = s.getManifest(att.digest)!; + expect(m.schemaVersion).toBe(2); + expect(m.artifactType).toBe(ATTACHMENT_ARTIFACT_TYPE); + // An artifact manifest still REQUIRES a config; OCI's canonical empty blob + // fills the slot — and must EXIST, not merely be named, because a puller + // fetches it like any other blob. + expect(m.config).toEqual(EMPTY_DESCRIPTOR); + expect(Array.from(s.get(EMPTY_DESCRIPTOR.digest)!)).toEqual([0x7b, 0x7d]); + expect(m.layers).toHaveLength(2); + }); + + it('separates what entered the cache from what the user supplied', () => { + const dir = tmp(); + const s = new FileAttachmentStore(dir); + const rep = s.putBlob(PNG, 'image/png'); + const src = s.putBlob(JPEG, 'image/jpeg'); + const m = s.getManifest(s.putAttachment({ representations: [rep], source: src }).digest)!; + // The distinction replay depends on: the DERIVED bytes were decoded, so + // those are what a rebuild must use. + expect(representationsOf(m).map(d => d.digest)).toEqual([rep.digest]); + expect(sourceOf(m)!.digest).toBe(src.digest); + }); + + it('keeps derivation parameters on the representation', () => { + const dir = tmp(); + // Not provenance: the same source under two settings is different pixels + // and therefore different KV, so what derived it is part of the record. + const s = new FileAttachmentStore(dir); + const rep = s.putBlob(PNG, 'image/png', { 'ai.lloyal.derive.maxPixels': '262144' }); + const m = s.getManifest(s.putAttachment({ representations: [rep] }).digest)!; + expect(representationsOf(m)[0].annotations!['ai.lloyal.derive.maxPixels']).toBe('262144'); + }); + + it('indexes each manifest once, deduped by digest', () => { + const dir = tmp(); + const s = new FileAttachmentStore(dir); + const rep = s.putBlob(PNG, 'image/png'); + const a = s.putAttachment({ representations: [rep] }); + const b = s.putAttachment({ representations: [rep] }); + expect(b.digest).toBe(a.digest); + expect(JSON.parse(readFileSync(join(dir, 'index.json'), 'utf8')).manifests).toHaveLength(1); + }); + + it('refuses a manifest with no representation', () => { + const dir = tmp(); + // `layers` must hold at least one descriptor to be valid, and an + // attachment where nothing reached the cache is meaningless anyway. + expect(() => new FileAttachmentStore(dir).putAttachment({ representations: [] })) + .toThrow(/no representation/i); + }); + + it('sniffs the formats mtmd decodes, and stores unknown bytes anyway', () => { + const dir = tmp(); + expect(sniffMediaType(JPEG)).toBe('image/jpeg'); + expect(sniffMediaType(PNG)).toBe('image/png'); + expect(sniffMediaType(GIF)).toBe('image/gif'); + expect(sniffMediaType(BMP)).toBe('image/bmp'); + // Validating pixels belongs to the normalizer and the decoder, both of + // which fail better than this could — so unknown bytes still store. + expect(sniffMediaType(JUNK)).toBe('application/octet-stream'); + const s = new FileAttachmentStore(dir); + const d = s.putBlob(JUNK, sniffMediaType(JUNK)); + expect(Array.from(s.get(d.digest)!)).toEqual(Array.from(JUNK)); + }); + + it('dedupes by content — the same bytes twice is one file', () => { + const dir = tmp(); + const s = new FileAttachmentStore(dir); + const a = s.putBlob(PNG, 'image/png'); + const b = s.putBlob(new Uint8Array(PNG), 'image/png'); + expect(b.digest).toBe(a.digest); + expect(readdirSync(join(dir, 'blobs', 'sha256'))).toHaveLength(1); + }); + + it('creates nothing until something is stored', () => { + const dir = join(tmp(), 'content'); + const s = new FileAttachmentStore(dir); + expect(existsSync(dir)).toBe(false); // a text-only run leaves no directory + s.putBlob(JPEG, 'image/jpeg'); + expect(existsSync(dir)).toBe(true); + }); + + it('returns null for an unknown digest, a bad digest, and an unwritable dir', () => { + const dir = tmp(); + const s = new FileAttachmentStore(dir); + expect(s.get('sha256:' + 'b'.repeat(64))).toBeNull(); + // Never build a path out of an unvalidated string. + expect(s.get('../../etc/passwd')).toBeNull(); + expect(s.get('sha512:' + 'c'.repeat(64))).toBeNull(); + }); + + it('reports a disk failure with the reason, rather than as an absence', () => { + // A write that fails must say so, and say WHY. Returning null defers the + // failure to replay — except there is nothing there to defer TO: when the + // representation write fails, `putAttachment` never runs, so no manifest + // exists and replay has nothing to refuse. The run would simply carry on + // with media in the cache and no record of it. + expect(() => new FileAttachmentStore('/proc/nonexistent/nope').putBlob(JPEG, 'image/jpeg')) + .toThrow(/ENOENT|ENOTDIR|EACCES|EROFS/); + }); + + it('refuses a manifest whose artifact type it does not understand', () => { + const dir = tmp(); + const s = new FileAttachmentStore(dir); + const d = s.putBlob(new TextEncoder().encode('{"artifactType":"application/vnd.other"}'), + 'application/vnd.oci.image.manifest.v1+json'); + expect(s.getManifest(d.digest)).toBeNull(); + }); +}); diff --git a/packages/media/test/normalize.test.ts b/packages/media/test/normalize.test.ts new file mode 100644 index 00000000..8d53b795 --- /dev/null +++ b/packages/media/test/normalize.test.ts @@ -0,0 +1,356 @@ +/** + * The normalizer — the one place `sharp` actually runs. + * + * This suite exists because it did not: the package shipped with no tests, so + * every claim about format conversion, the pixel ceiling and orientation was + * unverified, and three defects survived (see the plan). The failures that + * matter here are silent ones — an image that reaches the projector rotated, + * or metadata that survives when the user assumed it did not. + */ +import { describe, it, expect } from 'vitest'; +import sharp from 'sharp'; +import { normalizeImage, DEFAULT_MAX_PIXELS, MAX_CONCURRENT_NORMALIZATIONS } from '../src/image'; +import type { NormalizedImage } from '../src/image'; +// Its own list of nine, sourced from stb_image — NOT derived from the sniff +// table, which knows four. Deriving it was a defect: the pass-through gate read +// as covering six formats and covered one. +import { PROJECTOR_FORMATS } from '../src/index'; + +const solid = (width: number, height: number, format: 'jpeg' | 'png' | 'webp' | 'tiff' = 'jpeg') => + sharp({ create: { width, height, channels: 3, background: '#0a7' } })[format]().toBuffer() + .then((b) => new Uint8Array(b)); + +const meta = (b: Uint8Array) => sharp(Buffer.from(b)).metadata(); + +/** Assert a derivation happened. Stating the premise here beats asserting on + * dimensions downstream — if a case stops deriving, this fails with the + * reason instead of a confusing size mismatch three lines later. */ +function derived(r: NormalizedImage): NormalizedImage { + if (!r.derived) throw new Error('expected normalizeImage to derive, but it passed through'); + return r; +} + +describe('normalizeImage', () => { + it('passes conforming bytes through UNTOUCHED', async () => { + // Byte identity is the contract: the caller compares `norm.bytes !== bytes` + // to decide whether a source layer is worth retaining at all. + const src = await solid(64, 64); + const out = await normalizeImage(src, {}); + expect(out.bytes).toBe(src); + expect(out.mime).toBe('image/jpeg'); + expect(out.originalByteLength).toBe(src.byteLength); + }); + + it('converts a format the projector cannot decode', async () => { + // The real justification for the dependency: sharp READS webp/tiff/heif/ + // svg, none of which mtmd decodes. Without this the file fails inside the + // decoder mid-run, on a branch already in flight. + for (const format of ['webp', 'tiff'] as const) { + const out = await normalizeImage(await solid(64, 64, format), {}); + expect(PROJECTOR_FORMATS).toContain(out.mime); + expect(out.mime).toBe('image/jpeg'); + } + }); + + it('holds the pixel ceiling', async () => { + const out = derived(await normalizeImage(await solid(800, 600), { maxPixels: 10_000 })); + expect(out.width * out.height).toBeLessThanOrEqual(10_000); + // Aspect preserved: sharp fits inside the box. + expect(out.width / out.height).toBeCloseTo(800 / 600, 1); + }); + + it('defaults its ceiling to the projector’s own', async () => { + expect(DEFAULT_MAX_PIXELS).toBe(2048 * 2048); + }); + + it('applies EXIF orientation to the PIXELS, and still honours the ceiling', async () => { + // A phone writes Orientation=6 for a portrait shot: stored 400x200, + // displayed 200x400. Re-encoding drops the tag, so if the pixels are not + // rotated here the model sees it sideways with nothing left to say so. + const base = await solid(400, 200); + const tagged = new Uint8Array( + await sharp(Buffer.from(base)).withMetadata({ orientation: 6 }).jpeg().toBuffer(), + ); + expect((await meta(tagged)).orientation).toBe(6); + + const out = derived(await normalizeImage(tagged, { maxPixels: 10_000 })); + // Rotated: the result is portrait, as a viewer would show it. + expect(out.height).toBeGreaterThan(out.width); + // And the ceiling is measured on what the projector will actually receive — + // computing it from the STORED dimensions overshoots by the aspect ratio. + expect(out.width * out.height).toBeLessThanOrEqual(10_000); + expect((await meta(out.bytes)).orientation ?? 1).toBe(1); + }); + + it('hands BMP to the model rather than refusing it', async () => { + // sharp cannot READ bmp, but stb_image — which is what mtmd loads with — + // can. Refusing here would cost the user their whole query for a file the + // model reads fine. Safe because `MtmdSource`'s constructor decodes every + // bitmap before tokenize and before any decode_segments, so an unreadable + // file fails at the same phase either way, branch untouched. + const bmp = Buffer.alloc(54 + 48, 0xff); + bmp.write('BM', 0); + bmp.writeUInt32LE(54, 10); bmp.writeUInt32LE(40, 14); + bmp.writeInt32LE(4, 18); bmp.writeInt32LE(4, 22); + bmp.writeUInt16LE(1, 26); bmp.writeUInt16LE(24, 28); + const src = new Uint8Array(bmp); + + const out = await normalizeImage(src, {}); + expect(out.bytes).toBe(src); // handed over verbatim + expect(out.derived).toBe(false); + expect(out.mime).toBe('image/bmp'); + // Dimensions ARE claimed now — read from the header, not decoded. This + // assertion was the opposite until the admission policy landed: an + // unmeasured pass-through cannot honour a pixel ceiling, and this is the + // one path where nothing can downscale after the fact. + expect(out.width).toBe(4); + expect(out.height).toBe(4); + }); + + it('still refuses bytes NO decoder in the stack can read', async () => { + // The pass-through is for formats the PROJECTOR reads. Junk is still junk, + // and failing at ingress beats failing in the projector. + await expect(normalizeImage(new Uint8Array([1, 2, 3, 4]), {})) + .rejects.toThrow(/unsupported image format|not a decodable image/); + }); + + it('records a derivation only when one happened', async () => { + const conforming = await solid(64, 64); + expect((await normalizeImage(conforming, {})).derived).toBe(false); + expect((await normalizeImage(conforming, { maxPixels: 1_000 })).derived).toBe(true); + expect((await normalizeImage(await solid(64, 64, 'webp'), {})).derived).toBe(true); + }); + + it('reports what it saved', async () => { + const src = await solid(1200, 900); + const out = await normalizeImage(src, { maxPixels: 10_000 }); + expect(out.originalByteLength).toBe(src.byteLength); + expect(out.bytes.byteLength).toBeLessThan(out.originalByteLength); + }); +}); + +describe('the two format lists are different questions', () => { + it('PROJECTOR_FORMATS states what the DECODER reads, not what we can sniff', () => { + // Ground truth: mtmd loads via `stbi_load_from_memory` with no STBI_NO_* + // defines, so the projector reads stb_image's full set. The sniff table + // recognises four of them. Deriving one list from the other made them + // provably equal — which is how a rescue path that names six formats + // shipped able to rescue exactly one. + expect(PROJECTOR_FORMATS).toEqual(expect.arrayContaining([ + 'image/jpeg', 'image/png', 'image/gif', 'image/bmp', + 'image/x-tga', 'image/vnd.adobe.photoshop', 'image/vnd.radiance', + 'image/x-softimage-pic', 'image/x-portable-anymap', + ])); + // The sniffable set is a STRICT SUBSET. If this ever becomes an equality, + // someone has re-derived one from the other and the braid is back. + const sniffable = ['image/jpeg', 'image/png', 'image/gif', 'image/bmp']; + expect(PROJECTOR_FORMATS.length).toBeGreaterThan(sniffable.length); + for (const f of sniffable) expect(PROJECTOR_FORMATS).toContain(f); + }); + + it('hands over only what it can IDENTIFY — the rest are refused, not guessed', async () => { + // BMP: projector-readable AND sniffable → handed over. + const bmp = Buffer.alloc(54 + 48, 0xff); + bmp.write('BM', 0); + bmp.writeUInt32LE(54, 10); bmp.writeUInt32LE(40, 14); + bmp.writeInt32LE(4, 18); bmp.writeInt32LE(4, 22); + bmp.writeUInt16LE(1, 26); bmp.writeUInt16LE(24, 28); + const out = await normalizeImage(new Uint8Array(bmp), {}); + expect(out.derived).toBe(false); + expect(out.mime).toBe('image/bmp'); + + // A projector-readable format we have NO signature for (TGA has no magic + // bytes worth the name) sniffs as octet-stream and is refused rather than + // passed through blind. Honest: nothing measured it, so nothing may admit + // it under a contract that promises a ceiling. + const tgaish = new Uint8Array([0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 4, 0, 4, 0, 24, 0]); + await expect(normalizeImage(tgaish, {})).rejects.toThrow(); + }); +}); + +describe('KNOWN DEFECT — pinned, not endorsed', () => { + it('DEFECT: metadata retention depends on whether the image hit the ceiling', async () => { + // Re-encoding strips EXIF/ICC (sharp's default); the pass-through path + // returns the source bytes verbatim and keeps everything — GPS included. + // So a user's photo metadata survives or not according to its pixel count, + // which nobody would predict. The fix is a deliberate policy either way. + const withExif = new Uint8Array( + await sharp(Buffer.from(await solid(64, 64))).withMetadata({ orientation: 1 }).jpeg().toBuffer(), + ); + const passed = await normalizeImage(withExif, {}); + expect(passed.bytes).toBe(withExif); + expect((await meta(passed.bytes)).exif).toBeDefined(); + + const reencoded = await normalizeImage(withExif, { maxPixels: 1_000 }); + expect((await meta(reencoded.bytes)).exif).toBeUndefined(); + }); + + describe('the admission policy — what may pass through BYTE-IDENTICAL', () => { + // Pass-through is permitted only when every one of these holds: the format + // is projector-supported, the pixels are under the ceiling, the dimensions + // are known, EXIF orientation is identity, and the colour interpretation is + // safe. Anything else DERIVES and keeps the original as the source layer. + // + // The reason is one fact about the decoder, verified in the vendored tree: + // `stb_image.h` contains zero EXIF/orientation matches and mtmd applies no + // rotation around `stbi_load_from_memory` (its only `rotate` is RoPE math + // in clip.cpp). It also ignores ICC entirely. So anything we pass through + // untouched is interpreted RAW — and a tag we leave on is a tag nobody + // downstream will ever read. + + it('derives an image whose EXIF orientation is not identity, even UNDER the ceiling', async () => { + // THE case the previous orientation test could not cover: it forced the + // resize path with `maxPixels: 10_000`, so a phone JPEG small enough to + // pass through reached the model sideways and the suite stayed green. + const base = await solid(200, 100); + const tagged = new Uint8Array( + await sharp(Buffer.from(base)).withMetadata({ orientation: 6 }).jpeg().toBuffer(), + ); + expect((await meta(tagged)).orientation).toBe(6); + // Comfortably under the default ceiling — nothing about SIZE forces this. + expect(200 * 100).toBeLessThan(DEFAULT_MAX_PIXELS); + + const out = derived(await normalizeImage(tagged, {})); + + expect(out.height).toBeGreaterThan(out.width); + expect((await meta(out.bytes)).orientation ?? 1).toBe(1); + }); + + it('passes an identity-orientation image through untouched', async () => { + // The other half: the rule must not derive everything. A tag of 1 says + // the stored pixels ARE the displayed pixels, so there is nothing to fix. + const base = await solid(200, 100); + const tagged = new Uint8Array( + await sharp(Buffer.from(base)).withMetadata({ orientation: 1 }).jpeg().toBuffer(), + ); + + const out = await normalizeImage(tagged, {}); + + expect(out.derived).toBe(false); + expect(out.bytes).toBe(tagged); + }); + + it('derives a non-sRGB profile to sRGB, and passes an sRGB one through', async () => { + // `meta.space` is 'srgb' for a Display P3 image too — libvips reports the + // PIXEL encoding, not the interpretation — so the profile itself is the + // only signal. Left alone, P3 pixels reach a decoder that assumes sRGB + // and the colours are simply wrong. + const base = await solid(200, 100); + const p3 = new Uint8Array( + await sharp(Buffer.from(base)).withIccProfile('p3').jpeg().toBuffer(), + ); + const srgb = new Uint8Array( + await sharp(Buffer.from(base)).withIccProfile('srgb').jpeg().toBuffer(), + ); + + expect((await normalizeImage(p3, {})).derived).toBe(true); + expect((await normalizeImage(srgb, {})).derived).toBe(false); + }); + + it('refuses bytes it cannot measure, rather than passing them through unbounded', async () => { + // sharp cannot read BMP, so the hand-off path is the only one that sees + // it — and it used to hand the bytes over with no dimensions at all, + // under a contract that promises a ceiling. A header parse is cheap and + // is not a decode. + const truncated = Buffer.alloc(20, 0); + truncated.write('BM', 0); + + await expect(normalizeImage(new Uint8Array(truncated), {})) + .rejects.toThrow(/dimensions/i); + }); + + it('refuses a sharp-unreadable image that is over the ceiling', async () => { + // The ceiling is part of the contract for every admitted representation, + // and we cannot downscale what we cannot decode. Refusing names the file; + // passing it through would break the promise silently. + const bmp = Buffer.alloc(54, 0); + bmp.write('BM', 0); + bmp.writeUInt32LE(54, 10); bmp.writeUInt32LE(40, 14); + bmp.writeInt32LE(4000, 18); bmp.writeInt32LE(4000, 22); + + await expect(normalizeImage(new Uint8Array(bmp), { maxPixels: 10_000 })) + .rejects.toThrow(/ceiling|too large/i); + }); + }); + + describe('the process-wide gate', () => { + // The resource is host memory: each concurrent normalization holds a fully + // decoded bitmap, so N simultaneous uploads cost N bitmaps however small + // the encoded bytes were. A served host takes uploads from anyone who can + // reach it. + + it('does not deadlock when more images arrive than there are permits', async () => { + const src = await solid(80, 60); + const many = Array.from({ length: MAX_CONCURRENT_NORMALIZATIONS * 3 }, + () => normalizeImage(src, { maxPixels: 2_000 })); + + const out = await Promise.all(many); + + expect(out).toHaveLength(MAX_CONCURRENT_NORMALIZATIONS * 3); + expect(out.every((o) => o.derived)).toBe(true); + // An explicit timeout so a leaked permit FAILS here rather than hanging + // the run: a wedged gate never settles, and a hang names nothing in a CI + // log while a timeout names this test. + }, 15_000); + + it('refuses an already-aborted caller without spending a permit', async () => { + const src = await solid(40, 40); + + await expect(normalizeImage(src, { signal: AbortSignal.abort() })) + .rejects.toThrow(/abort/i); + + // The permit must not have been consumed — if it were, enough abandoned + // callers would starve the gate exactly as a leak does. + const after = await normalizeImage(src, {}); + expect(after.mime).toBe('image/jpeg'); + }, 15_000); + + it('lets a QUEUED caller give up BEFORE any slot frees', async () => { + // The case the signal exists for, and the assertion has to be about + // ORDER — asserting only that the queued call rejects proves nothing, + // because it would also reject after waiting for a slot it no longer + // wants and noticing on arrival. That is precisely the behaviour the + // signal replaces, so the test has to tell the two apart. + // + // The event to compare against is the FIRST slot freeing, not the last: + // a caller that waits its turn is granted as soon as any one of the four + // ahead completes. + const src = await solid(400, 300); + const controller = new AbortController(); + const order: string[] = []; + + const busy = Array.from({ length: MAX_CONCURRENT_NORMALIZATIONS }, + () => normalizeImage(src, { maxPixels: 5_000 }) + .then((v) => { order.push('a-slot-freed'); return v; })); + const queued = normalizeImage(src, { maxPixels: 5_000, signal: controller.signal }) + .then(() => { order.push('queued-resolved'); }, + (e: Error) => { order.push('gave-up'); expect(e.message).toMatch(/abort/i); }); + + // Synchronous: the four ahead have taken every permit and none can have + // finished, so the fifth is certainly still waiting when this fires. + controller.abort(); + await Promise.all([queued, ...busy]); + + expect(order[0], `expected the abort to land first, got ${order.join(' → ')}`) + .toBe('gave-up'); + // And the gate is intact afterwards. + expect((await normalizeImage(src, { maxPixels: 5_000 })).derived).toBe(true); + }, 20_000); + + it('releases a permit when normalization FAILS', async () => { + // The failure mode that actually bites: a permit leaked on a rejecting + // upload. Enough bad files and the gate is exhausted permanently and the + // host wedges — and bad files are exactly what arrives in volume. So + // fail more times than there are permits, then require a good image to + // still get through. + const junk = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + for (let i = 0; i < MAX_CONCURRENT_NORMALIZATIONS + 2; i++) { + await expect(normalizeImage(junk, {})).rejects.toThrow(); + } + + const out = await normalizeImage(await solid(40, 40), {}); + + expect(out.mime).toBe('image/jpeg'); + }, 15_000); + }); +}); \ No newline at end of file diff --git a/packages/media/test/store.test.ts b/packages/media/test/store.test.ts new file mode 100644 index 00000000..2c9a7d58 --- /dev/null +++ b/packages/media/test/store.test.ts @@ -0,0 +1,38 @@ +/** + * The default content store — the one a harness gets when it installs none. + * + * Its whole job is the asymmetry: a text-only run must pay nothing and notice + * nothing, while media must fail LOUDLY and early. Returning null for media + * would let the caller prefill anyway and produce KV that can never be + * replayed — the silent outcome every guard in this package exists to prevent. + */ +import { describe, it, expect } from 'vitest'; +import { NullAttachmentStore } from '../src/store'; + +const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 1, 2, 3]); +const ABSENT = 'sha256:' + 'c'.repeat(64); + +describe('NullAttachmentStore', () => { + it('is inert for text', () => { + // "Inert" is about a text-only run never REACHING a write: this is the + // context default, so `.expect()` must never throw and lookups must answer + // like any other store asked for content it does not hold. + const s = new NullAttachmentStore(); + + expect(s.get(ABSENT)).toBeNull(); + expect(s.getManifest(ABSENT)).toBeNull(); + }); + + it('refuses every write, including an empty one', () => { + // There is no such thing as a successful write to a store that does not + // exist. The empty-bytes case used to return null — a second convention + // for a call no production path makes, and the one place this class + // answered a write with an absence. + const s = new NullAttachmentStore(); + + expect(() => s.putBlob(JPEG, 'image/jpeg')).toThrow(/No content store installed/); + expect(() => s.putBlob(new Uint8Array(0), 'application/octet-stream')) + .toThrow(/No content store installed/); + expect(() => s.putAttachment({ representations: [] })).toThrow(/No content store/); + }); +}); diff --git a/packages/media/tsconfig.json b/packages/media/tsconfig.json new file mode 100644 index 00000000..152ea8e0 --- /dev/null +++ b/packages/media/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": [ + "src/**/*.ts" + ], + "references": [] +} diff --git a/packages/rig/package.json b/packages/rig/package.json index 2719fc7a..9c4497c9 100644 --- a/packages/rig/package.json +++ b/packages/rig/package.json @@ -47,7 +47,8 @@ "effection": "^4.0.2", "ignore": "^7.0.5", "linkedom": "^0.18.12", - "semver": "^7.8.1" + "semver": "^7.8.1", + "@lloyal-labs/media": "^0.1.0" }, "peerDependencies": { "@lloyal-labs/lloyal.node": "^3.1.1" diff --git a/packages/rig/src/content-routes.ts b/packages/rig/src/content-routes.ts new file mode 100644 index 00000000..184c1022 --- /dev/null +++ b/packages/rig/src/content-routes.ts @@ -0,0 +1,334 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { representationsOf, DIGEST_PATTERN } from '@lloyal-labs/media'; +import type { AttachmentStore, Descriptor } from '@lloyal-labs/media'; + +/** Thrown when a body exceeds the cap, so the caller can answer 413 rather + * than a bare connection reset — a client that sends too much deserves to be + * told which limit it hit. */ +class TooLarge extends Error {} + +/** 8 MiB. Generous for an image, small enough that a stray POST cannot exhaust + * the host. Video ingress will not reuse this number — it needs resumable + * transfer, not a bigger ceiling. */ +const DEFAULT_MAX_UPLOAD = 8 * 1024 * 1024; + +/** 30s. Long enough for 8 MiB on a slow connection, short enough that a stalled + * upload does not hold a handler for the life of the process. */ +const DEFAULT_UPLOAD_TIMEOUT_MS = 30_000; + +/** Thrown when an upload outruns {@link ContentRoutesOpts.uploadTimeoutMs}. */ +class TooSlow extends Error {} + +/** + * @category Runtime + */ +export interface ContentRoutesOpts { + /** The project's content store. Reads resolve through it; nothing else. */ + store: AttachmentStore; + /** + * Normalize, commit, and return the root descriptor for an upload. + * + * Injected because the HTTP layer must not decide what "admitted" means, and + * because normalization is a native dependency (`sharp`) that a harness + * accepting no media should never load. **Absent ⇒ POST answers 501.** It + * deliberately does NOT fall back to committing the raw bytes: an upload + * that skipped normalization is not admitted content, and storing it as + * though it were would put unvalidated pixels behind a digest the fold + * trusts. + * + * **Bytes only, no declared type.** This route used to forward the request's + * `Content-Type` header — a value the client writes and nothing verifies — + * as authority over content the client did not produce. The bytes answer + * that question, and the ingress is where they are decoded. + */ + ingest?: (bytes: Uint8Array, signal?: AbortSignal) => Promise; + /** Ceiling on a single upload body. @default 8 MiB */ + maxUploadBytes?: number; + /** + * Ceiling on how long one upload may take, end to end. + * + * A byte cap alone does not bound a request: a client that opens a POST and + * then trickles — or sends nothing at all — holds the promise, the socket and + * the handler open indefinitely, and enough of them starve the host without + * ever exceeding a single limit. Total duration rather than idle time, + * because an idle timer is reset by exactly the one byte a slow-loris sends. + * + * @default 30s + */ + uploadTimeoutMs?: number; + /** + * Exact origin permitted to call these routes cross-origin, e.g. + * `http://localhost:5173`. Omitted ⇒ NO CORS headers at all, which is the + * right default: in development Vite proxies content so requests stay + * same-origin, and `*` on a route that serves a tenant's uploads is not a + * default anyone should inherit. + */ + allowedOrigin?: string; +} + +/** + * The content plane: HTTP carries bytes, the WebSocket carries references. + * + * Mount beside a `WebSocketServer` on ONE `http.Server`. Returns a predicate — + * true when it handled the request — so a host can compose it with whatever + * else it serves. + * + * ``` + * POST /v1/media/ingress upload → normalize → root descriptor + * GET /v1/media//representations/ the bytes the model actually saw + * HEAD /v1/content/ existence, for pre-flight dedupe + * ``` + * + * **A digest is identity, not authorization.** These routes authenticate + * nothing; they are safe only behind the loopback default or a fronting proxy. + * There is deliberately no enumeration route — HEAD answers about a digest you + * already hold, and never lists what the store contains. + * + * **Nothing thrown here may escape.** A handler that throws inside the + * server's `request` emit would become an uncaught exception and take the + * whole process down — the resident model and every live Session with it. Same + * reasoning as the driver's per-socket `error` handler. + * + * @category Runtime + */ +/** + * Read a request body bounded in BOTH bytes and time, settling exactly once. + * + * Module-level rather than a closure inside the router: it knows nothing about + * digests, manifests or content, and lived inside the route factory only to + * capture two numbers. Out here it is independently testable, and the router + * is a router again instead of four regex branches wrapped around a promise + * state machine. + * + * Two independent limits, because they fail differently. `maxBytes` is checked + * against the DECLARED `Content-Length` and again against the real stream — a + * client may lie, so the stream is the authority. `timeoutMs` is TOTAL + * duration, not idle time: an idle timer is reset by exactly the one byte a + * slow-loris sends. + * + * Every path here can fire more than once — `data` keeps emitting after the cap + * is hit, `error` can follow `aborted` — so `settle` runs once and always + * clears the timer with it. + */ +function readBounded( + req: IncomingMessage, + limits: { maxBytes: number; timeoutMs: number }, +): Promise { + const { maxBytes, timeoutMs } = limits; + return new Promise((resolve, reject) => { + const declared = Number(req.headers['content-length'] ?? NaN); + if (Number.isFinite(declared) && declared > maxBytes) { + reject(new TooLarge(`upload exceeds ${maxBytes} bytes`)); + return; + } + const chunks: Buffer[] = []; + let seen = 0; + let done = false; + const settle = (f: () => void): void => { + if (done) return; + done = true; + clearTimeout(timer); + f(); + }; + const timer = setTimeout( + () => settle(() => reject(new TooSlow(`upload exceeded ${timeoutMs}ms`))), + timeoutMs, + ); + // `unref` so a pending upload timer never by itself keeps the process + // alive; the socket is what should hold it open, not our clock. + timer.unref?.(); + req.on('data', (c: Buffer) => { + if (done) return; + seen += c.length; + if (seen > maxBytes) { + // Stop reading but do NOT destroy yet: the response has to reach the + // client first, or it sees a reset with no explanation. + req.pause(); + settle(() => reject(new TooLarge(`upload exceeds ${maxBytes} bytes`))); + return; + } + chunks.push(c); + }); + // A client that disconnects mid-upload must settle this promise, or the + // handler leaks a pending await for the life of the process. + req.on('aborted', () => settle(() => reject(new Error('upload aborted')))); + req.on('error', (e) => settle(() => reject(e))); + req.on('end', () => settle(() => resolve(new Uint8Array(Buffer.concat(chunks))))); + }); +} + +export function createContentRoutes( + opts: ContentRoutesOpts, +): (req: IncomingMessage, res: ServerResponse) => boolean { + const maxUpload = opts.maxUploadBytes ?? DEFAULT_MAX_UPLOAD; + const uploadTimeout = opts.uploadTimeoutMs ?? DEFAULT_UPLOAD_TIMEOUT_MS; + + /** CORS headers, added only when an origin is configured. */ + const head = (extra: Record = {}): Record => { + const h = { ...extra }; + // Only when explicitly configured. See `allowedOrigin`. + if (opts.allowedOrigin) { + h['Access-Control-Allow-Origin'] = opts.allowedOrigin; + h['Vary'] = 'Origin'; + } + return h; + }; + + const fail = (res: ServerResponse, code: number, message: string): void => { + if (res.headersSent) { res.end(); return; } + const body = JSON.stringify({ error: message }); + res.writeHead(code, head({ + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(body)), + })); + res.end(body); + }; + + /** Read a bounded body, refusing early on a declared length that already + * exceeds the cap and again on the real bytes — a client may lie about + * `Content-Length`, so the stream is the authority. */ + + /** + * Caching headers for a content-addressed blob. + * + * `private` because project media is a tenant's own content and has no place + * in a shared or CDN cache. `immutable` alone does not establish freshness — + * it only promises the body will not change — so it rides with an explicit + * `max-age`. The digest IS the validator, so it doubles as the `ETag` and + * makes conditional requests exact rather than heuristic. `nosniff` matters + * more here than usual: these are user-supplied bytes served under a type we + * sniffed, and a browser guessing something executable from them is the + * failure to prevent. + */ + const contentHeaders = (digest: string, extra: Record): Record => + head({ + 'Cache-Control': 'private, max-age=31536000, immutable', + 'ETag': `"${digest}"`, + 'X-Content-Type-Options': 'nosniff', + ...extra, + }); + + /** A client holding this exact digest already has the only body it can be. */ + const fresh = (req: IncomingMessage, digest: string): boolean => + (req.headers['if-none-match'] ?? '') === `"${digest}"`; + + const serveBlob = ( + req: IncomingMessage, res: ServerResponse, d: Descriptor, bodyless: boolean, + ): void => { + if (fresh(req, d.digest)) { + res.writeHead(304, contentHeaders(d.digest, {})); + res.end(); + return; + } + const bytes = opts.store.get(d.digest); + if (!bytes) { fail(res, 404, 'blob not in store'); return; } + res.writeHead(200, contentHeaders(d.digest, { + 'Content-Type': d.mediaType, + 'Content-Length': String(bytes.byteLength), + })); + if (bodyless) res.end(); else res.end(Buffer.from(bytes)); + }; + + return (req, res) => { + const url = req.url ?? ''; + if (!url.startsWith('/v1/media/') && !url.startsWith('/v1/content/')) return false; + + // Contained here so a route failure cannot reach the server's `request` + // emit and kill the process. + try { + const path = url.split('?')[0]; + const method = req.method ?? 'GET'; + + if (method === 'OPTIONS') { + res.writeHead(204, head({ + 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', + })); + res.end(); + return true; + } + + // HEAD /v1/content/ — existence only. Answers about a digest the + // caller already holds; it never reveals what else is stored. + // + // HEAD-ONLY, deliberately. GET was accepted here and answered 200 with a + // `Content-Length` and an empty body — a protocol violation. The fix is + // to refuse GET rather than to serve the bytes: this route addresses raw + // blobs by digest, so serving them would hand out any blob including a + // retained SOURCE layer, defeating the reason + // `/v1/media//representations/` resolves through the + // manifest at all. Bytes have exactly one door, and it is that one. + const exists = /^\/v1\/content\/([^/]+)$/.exec(path); + if (exists && method === 'HEAD') { + const digest = decodeURIComponent(exists[1]); + if (!DIGEST_PATTERN.test(digest)) { fail(res, 400, 'malformed digest'); return true; } + // Reads the WHOLE blob to answer a yes/no question, because + // `AttachmentStore` offers no `size`/`has`. On the one route whose + // purpose is to AVOID moving bytes, a dedupe pre-flight against an + // 8 MiB image costs 8 MiB resident. Adding `size(digest)` beside `get` + // belongs with the store-contract phase, not here. + const bytes = opts.store.get(digest); + if (!bytes) { fail(res, 404, 'not found'); return true; } + res.writeHead(200, contentHeaders(digest, { + 'Content-Length': String(bytes.byteLength), + })); + res.end(); + return true; + } + + // GET /v1/media//representations/ — the bytes the model + // actually saw. Resolves THROUGH the manifest and only over its + // representations, so a source layer can never be served by mistake. + const rep = /^\/v1\/media\/([^/]+)\/representations\/(\d+)$/.exec(path); + if (rep && (method === 'GET' || method === 'HEAD')) { + const digest = decodeURIComponent(rep[1]); + if (!DIGEST_PATTERN.test(digest)) { fail(res, 400, 'malformed digest'); return true; } + const manifest = opts.store.getManifest(digest); + if (!manifest) { fail(res, 404, 'no such attachment manifest'); return true; } + const reps = representationsOf(manifest); + const i = Number(rep[2]); + if (!Number.isInteger(i) || i < 0 || i >= reps.length) { + fail(res, 404, `representation ${i} of ${reps.length}`); + return true; + } + serveBlob(req, res, reps[i], method === 'HEAD'); + return true; + } + + // POST /v1/media/ingress — bytes in, root descriptor out. + if (path === '/v1/media/ingress' && method === 'POST') { + if (!opts.ingest) { + fail(res, 501, 'no ingress service installed on this host'); + return true; + } + readBounded(req, { maxBytes: maxUpload, timeoutMs: uploadTimeout }) + .then((bytes) => opts.ingest!(bytes)) + .then((descriptor) => { + const body = JSON.stringify(descriptor); + res.writeHead(201, head({ + 'Content-Type': 'application/json', + 'Content-Length': String(Buffer.byteLength(body)), + })); + res.end(body); + }) + .catch((e: unknown) => { + const tooLarge = e instanceof TooLarge; + const tooSlow = e instanceof TooSlow; + const code = tooLarge ? 413 : tooSlow ? 408 : 400; + fail(res, code, e instanceof Error ? e.message : 'ingress failed'); + // Now that the status is on the wire, stop the upload. A stalled + // client will not close on its own — that is the whole problem — + // so the timeout path has to drop the socket just as the cap does. + if (tooLarge || tooSlow) req.destroy(); + }); + return true; + } + + fail(res, 405, 'unsupported method or path'); + return true; + } catch (e) { + fail(res, 500, e instanceof Error ? e.message : 'content route failed'); + return true; + } + }; +} diff --git a/packages/rig/src/media-store.ts b/packages/rig/src/media-store.ts new file mode 100644 index 00000000..99c18da2 --- /dev/null +++ b/packages/rig/src/media-store.ts @@ -0,0 +1,48 @@ +/** + * @file Where a project keeps its content — policy, not format. + * + * The OCI layout itself lives in `@lloyal-labs/media/node`, beside the format + * it implements. What stays here is the one decision a harness owns: which + * directory a project's media lands in. Splitting them is what makes that + * boundary visible rather than arguable — the layout is a published spec and + * moves rarely; where a project puts its files is template vocabulary. + */ +import { FileAttachmentStore } from '@lloyal-labs/media/node'; +import type { AttachmentStore } from '@lloyal-labs/media'; +import { join } from 'node:path'; + +/** Where a project keeps its media, relative to the project root. */ +export const MEDIA_DIR = 'media'; + +/** + * Open a project's content store — the OCI Image Layout at `/media/`. + * + * Separate from {@link useTraceWriter} because the two differ in every + * dimension that matters, and bundling them behind one directory and one flag + * hid that: + * + * | | location | gated | lifetime | + * |---|---|---|---| + * | trace | `sources.outputDir` | `LLOYAL_DEV` | one per session | + * | content | `/media/` | never | durable, project-scoped | + * + * **Never dev-gated**, because addressability is a REPLAY requirement rather + * than telemetry: media that reaches the cache unaddressed produces a run that + * cannot be rebuilt. `media/` sits beside `models/` — its precedent — and NOT + * under `sources.outputDir`, which is where run OUTPUT goes while media is + * INPUT, and which may point outside the project entirely. + * + * **Call this ONCE per process** and share the instance: a served host builds + * it alongside the host and injects it into every materialised Session. One + * object, not one per Session pointing at the same directory. The store's + * index commit is synchronous, so sharing is hygiene rather than a race fix — + * but keeping it synchronous is what makes that true. + * + * @param projectRoot - Where `harness.yml` was found. NOT `process.cwd()`, + * which is wherever the operator happened to start. + * + * @category Runtime + */ +export function createProjectMediaStore(projectRoot: string): AttachmentStore { + return new FileAttachmentStore(join(projectRoot, MEDIA_DIR)); +} diff --git a/packages/rig/src/models.ts b/packages/rig/src/models.ts index eae4bc63..0c71c2d3 100644 --- a/packages/rig/src/models.ts +++ b/packages/rig/src/models.ts @@ -342,3 +342,71 @@ async function streamOne( opts.onProgress?.(got, total, url); return dest; } + + +/** + * What a runtime boot needs on disk before it can create a context. + * + * @category Models + */ +export interface RuntimeModels { + /** The reasoning model — verified, local, ready for `createContext`. */ + modelPath: string; + /** The vision projector, when this llm has one. Absent ⇒ a text-only + * runtime, which is a normal outcome and never an error. */ + mmprojPath?: string; +} + +/** + * Resolve the models a runtime boots with — the reasoning model and, when the + * catalog pairs one with it, its vision projector. + * + * Every target that boots a runtime needs both, resolved the same way, which + * is why this is not each target's job: the CLI boot and the served host had + * independent copies of the pairing logic, and only one of them was ever + * updated when vision landed — so the served host ran text-only however + * capable its model was. + * + * **Vision is implicit by design.** The catalog pairs a projector with each + * vision-capable llm, so choosing a model chooses vision with it; + * `config.mmproj` only overrides that pairing. A text-only model has no + * pairing, `mmprojPath` comes back undefined, and `createContext` then reports + * `supportsVision() === false` rather than failing. + * + * Not the reranker: the CLI provisions it through the abilities that declare + * it (`provisionAbilityModels`) while a served host resolves it directly, so + * it is genuinely each boot's own business. + * + * @param opts.config - The layered config's model block. A saved `path` + * outranks the manifest's catalog id, matching how the + * config layering resolves every other field. + * @param opts.llmId - The manifest's `model.llm.id` — what selects the pairing. + * + * @category Models + */ +export async function resolveRuntimeModels(opts: { + projectRoot: string; + config: { path?: string | undefined; mmproj?: string | undefined }; + llmId: string | undefined; + onProgress?: (role: ModelRole, got: number, total: number) => void; +}): Promise { + const { projectRoot, config, llmId, onProgress } = opts; + + const modelPath = await resolveModel({ + projectRoot, + role: 'llm', + spec: config.path ? { path: config.path } : { id: llmId }, + ...(onProgress ? { onProgress: (g: number, t: number) => onProgress('llm', g, t) } : {}), + }); + + const mmprojId = config.mmproj ?? (llmId ? catalogEntry('llm', llmId)?.mmproj : undefined); + if (!mmprojId) return { modelPath }; + + const mmprojPath = await resolveModel({ + projectRoot, + role: 'mmproj', + spec: { id: mmprojId }, + ...(onProgress ? { onProgress: (g: number, t: number) => onProgress('mmproj', g, t) } : {}), + }); + return { modelPath, mmprojPath }; +} diff --git a/packages/rig/src/node.ts b/packages/rig/src/node.ts index b7c972bc..8ba542de 100644 --- a/packages/rig/src/node.ts +++ b/packages/rig/src/node.ts @@ -6,7 +6,16 @@ * * Per-source bundles (web, corpus) live in their own packages * (`@lloyal-labs/web-ability`, `@lloyal-labs/corpus-ability`); rig now owns - * only cross-ability primitives (chunking, types, tools, reranker). + * only cross-ability primitives (chunking, types, tools, reranker) and the + * substrate a harness mounts under all of them — config, traces, and the + * content plane (`createProjectMediaStore` + `createContentRoutes`). + * + * The content plane is here, rather than in `binding` beside the run and + * session planes, because it resolves through an `AttachmentStore` and so + * needs `@lloyal-labs/lloyal-agents` — and `binding` is deliberately + * dependency-free, with `wss()` taking a structural socket rather than + * importing one. Its `node:http` types are `import type` only, erased at + * compile time, so they add nothing to any bundle. * * @packageDocumentation * @category Rig @@ -24,7 +33,14 @@ export { loadResources, chunkResources, resolveCorpusInput } from './resources'; // Node-only: model catalog + verified project-local resolution/fetch // (requires node:fs / node:crypto / streaming fetch) -export { MODEL_CATALOG, catalogEntry, resolveModel, fetchVerified } from './models'; +export { MODEL_CATALOG, catalogEntry, resolveModel, resolveRuntimeModels, fetchVerified } from './models'; +export type { RuntimeModels } from './models'; +export { useTraceWriter } from './trace-sink'; +export { createProjectMediaStore, MEDIA_DIR } from './media-store'; +// Node-only: the content plane — HTTP carries bytes, the WebSocket carries +// references. Mount beside a `WebSocketServer` on one `http.Server`. +export { createContentRoutes } from './content-routes'; +export type { ContentRoutesOpts } from './content-routes'; export type { ModelRole, ModelCatalogEntry, diff --git a/packages/rig/src/runner.ts b/packages/rig/src/runner.ts index b066a8ba..bc4faf45 100644 --- a/packages/rig/src/runner.ts +++ b/packages/rig/src/runner.ts @@ -23,7 +23,9 @@ import { createContext, createSignal } from 'effection'; import type { Signal } from 'effection'; import { NullTraceWriter } from '@lloyal-labs/lloyal-agents'; +import { NullAttachmentStore } from '@lloyal-labs/media'; import type { TraceWriter, BranchCheckpoint } from '@lloyal-labs/lloyal-agents'; +import type { AttachmentStore } from '@lloyal-labs/media'; /** One config rung, per field: which layer of * `cli > env > harness.json > harness.yml > default` supplied the value. @@ -78,6 +80,18 @@ export interface RunnerDevOpts { dev?: boolean; } +/** + * The project's content store, injected by the boot. + * + * Deliberately NOT in {@link RunnerDevOpts}: addressability is a replay + * requirement rather than telemetry, so unlike the trace sink this is never + * gated on `dev`. Filing it beside `dev?` was what made that easy to miss. + * Omitted ⇒ the Null store, which is inert for text and throws on media. + */ +export interface RunnerContentOpts { + attachmentStore?: AttachmentStore; +} + /** * Boot-owned config plumbing, injected like the dev sink. The boot that * layered the config passes the computed per-field `origin`, and — edge only — @@ -130,6 +144,9 @@ export interface Runner< pauseRun: Signal; /** Observability sink threaded into `initAgents`. */ traceWriter: TraceWriter; + /** Image sink threaded into `initAgents` alongside {@link traceWriter} — + * what makes a media-bearing run replayable. */ + attachmentStore: AttachmentStore; /** True when the boot mounted dev observability (trace sink + pool * epistemics). Read this, never `process.env` — harness code stays * portable across bindings. */ @@ -226,7 +243,7 @@ function restoreFrozen( function makeRunner< C extends BaseHarnessConfig, O extends Record, ->(cfg: C, opts: RunnerDevOpts & RunnerConfigOpts, servedPath: null): Runner { +>(cfg: C, opts: RunnerDevOpts & RunnerContentOpts & RunnerConfigOpts, servedPath: null): Runner { let sessionConfig = structuredClone(cfg); let sessionOrigin = { ...opts.origin }; const windDown = createSignal(); @@ -274,6 +291,7 @@ function makeRunner< cancelAgent, pauseRun, traceWriter: opts.traceWriter ?? new NullTraceWriter(), + attachmentStore: opts.attachmentStore ?? new NullAttachmentStore(), dev: opts.dev ?? false, replayCheckpoint: null, findingsMaxChars: undefined, @@ -291,11 +309,11 @@ function makeRunner< export function makeServedRunner< C extends BaseHarnessConfig, O extends Record, ->(cfg: C, opts: RunnerDevOpts & RunnerConfigOpts): Runner { +>(cfg: C, opts: RunnerDevOpts & RunnerContentOpts & RunnerConfigOpts): Runner { // Served never persists — drop an accidentally-passed persist so a driver // can share one opts object between placements without leaking writes. const { persist: _persist, ...rest } = opts; - return makeRunner(cfg, rest as RunnerDevOpts & RunnerConfigOpts, null); + return makeRunner(cfg, rest as RunnerDevOpts & RunnerContentOpts & RunnerConfigOpts, null); } /** @@ -306,6 +324,6 @@ export function makeServedRunner< export function makeEdgeRunner< C extends BaseHarnessConfig, O extends Record, ->(cfg: C, opts: RunnerDevOpts & RunnerConfigOpts): Runner { +>(cfg: C, opts: RunnerDevOpts & RunnerContentOpts & RunnerConfigOpts): Runner { return makeRunner(cfg, opts, null); } diff --git a/packages/rig/src/tools/delegate.ts b/packages/rig/src/tools/delegate.ts index 6d16710c..dc8a2bf6 100644 --- a/packages/rig/src/tools/delegate.ts +++ b/packages/rig/src/tools/delegate.ts @@ -6,7 +6,7 @@ import { CallingAgent, agentPool, parallel, - traceScope, + useTraceScope, } from '@lloyal-labs/lloyal-agents'; import type { JsonSchema, @@ -203,7 +203,7 @@ export class DelegateTool extends Tool> { } const opts = this._poolOpts; - const scope = traceScope(tw, null, `delegate:${this.name}`, { taskCount: tasks.length, filtered: filtered?.length ?? 0 }); + yield* useTraceScope(tw, null, `delegate:${this.name}`, { taskCount: tasks.length, filtered: filtered?.length ?? 0 }); const pool = yield* agentPool({ ...opts, @@ -222,7 +222,6 @@ export class DelegateTool extends Tool> { totalToolCalls: pool.totalToolCalls, ...(filtered ? { filtered } : {}), }; - scope.close(); return result; } } diff --git a/packages/rig/src/trace-sink.ts b/packages/rig/src/trace-sink.ts new file mode 100644 index 00000000..0d7a95fd --- /dev/null +++ b/packages/rig/src/trace-sink.ts @@ -0,0 +1,68 @@ +/** + * @file A session's trace file, as an Effection resource. + * + * Split from the project content store it used to share a file with: the two + * differ in location, gating and lifetime, and bundling them behind one + * directory and one flag is what hid that difference. + */ +import { NullTraceWriter, JsonlTraceWriter } from '@lloyal-labs/lloyal-agents'; +import type { TraceWriter } from '@lloyal-labs/lloyal-agents'; +import { resource } from 'effection'; +import type { Operation } from 'effection'; +import { mkdirSync, openSync, closeSync } from 'node:fs'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; + +/** + * Open a session's trace file, closing it when the scope exits. + * + * A resource rather than a `{ writer, close }` pair: handing a caller a + * `close()` to remember is exactly the cleanup-by-discipline that `resource()` + * removes, and a forgotten one leaks a descriptor for the life of the process + * — which on a served host means per session. + * + * **Tracing is observability, never a dependency.** `dev: false`, or any + * failure to open, yields the Null writer rather than throwing: a harness that + * cannot write a trace must still run. Deliberately unlike the content store + * below, whose absence is a hard failure for media — see + * {@link createProjectMediaStore}. + * + * The random id keeps concurrent writers apart and `"wx"` refuses to truncate + * an existing file. + * + * @param outputDir - Where the trace lands (`sources.outputDir`). Created if + * missing. + * @param dev - False ⇒ the Null writer, at zero cost. + * + * @category Runtime + */ +export function useTraceWriter(outputDir: string, dev: boolean): Operation { + return resource(function* (provide) { + let fd: number | undefined; + let writer: TraceWriter = new NullTraceWriter(); + if (dev) { + try { + mkdirSync(outputDir, { recursive: true }); + const ts = new Date().toISOString().replace(/[:.]/g, '-'); + fd = openSync(join(outputDir, `trace-${ts}-${randomUUID().slice(0, 8)}.jsonl`), 'wx'); + writer = new JsonlTraceWriter(fd); + } catch { + // Tracing is observability, never a dependency: a harness that cannot + // open a trace must still run. + fd = undefined; + } + } + try { + yield* provide(writer); + } finally { + writer.flush(); + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + /* already closed */ + } + } + } + }); +} diff --git a/packages/rig/test/content-routes.test.ts b/packages/rig/test/content-routes.test.ts new file mode 100644 index 00000000..a37ae4f3 --- /dev/null +++ b/packages/rig/test/content-routes.test.ts @@ -0,0 +1,256 @@ +/** + * The content plane: HTTP carries bytes, the WebSocket carries references. + * + * The failure this guards is not a crash — it is media bytes finding their way + * back onto a JSON command frame, or a route resolving something the model + * never saw (a source layer instead of the admitted representation). + */ +import { describe, it, expect } from 'vitest'; +import { createServer, type Server } from 'node:http'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AddressInfo } from 'node:net'; +import { FileAttachmentStore } from '@lloyal-labs/media/node'; +import { createContentRoutes } from '../src/content-routes'; + +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); +const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 9, 9, 9]); + +/** A store holding one attachment: a normalized representation plus the source + * it came from — the shape that makes "never serve the source" testable. */ +function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'lloyal-routes-')); + const store = new FileAttachmentStore(dir); + const rep = store.putBlob(PNG, 'image/png', { 'ai.lloyal.derive.quality': '82' }); + const source = store.putBlob(JPEG, 'image/jpeg'); + const root = store.putAttachment({ representations: [rep], source }); + return { store, root, rep, source }; +} + +async function withServer( + opts: Parameters[0], + fn: (base: string) => Promise, +): Promise { + const routes = createContentRoutes(opts); + const server: Server = createServer((req, res) => { + if (routes(req, res)) return; + res.writeHead(404); res.end(); + }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const { port } = server.address() as AddressInfo; + try { + return await fn(`http://127.0.0.1:${port}`); + } finally { + await new Promise((r) => server.close(() => r())); + } +} + +describe('content routes', () => { + it('serves the admitted representation, never the source', async () => { + const { store, root, source } = fixture(); + await withServer({ store }, async (base) => { + const res = await fetch(`${base}/v1/media/${root.digest}/representations/0`); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toBe('image/png'); + expect(new Uint8Array(await res.arrayBuffer())).toEqual(PNG); + + // The source is a layer of the same manifest, but it is NOT a + // representation — the model never saw it, so this route must not reach + // it under any index. + expect((await fetch(`${base}/v1/media/${root.digest}/representations/1`)).status).toBe(404); + // Nor is the source's own digest addressable as a manifest. + expect((await fetch(`${base}/v1/media/${source.digest}/representations/0`)).status).toBe(404); + }); + }); + + it('caches privately, validates by digest, and refuses MIME sniffing', async () => { + const { store, root, rep } = fixture(); + await withServer({ store }, async (base) => { + const url = `${base}/v1/media/${root.digest}/representations/0`; + const res = await fetch(url); + const cc = res.headers.get('cache-control') ?? ''; + // `private`: a tenant's own media has no place in a shared or CDN cache. + expect(cc).toContain('private'); + // `immutable` alone promises the body will not change, not freshness — + // it needs an explicit max-age beside it. + expect(cc).toContain('immutable'); + expect(cc).toMatch(/max-age=\d+/); + // These are user-supplied bytes served under a sniffed type. + expect(res.headers.get('x-content-type-options')).toBe('nosniff'); + // The digest IS the validator, so conditional requests are exact. + expect(res.headers.get('etag')).toBe(`"${rep.digest}"`); + + const again = await fetch(url, { headers: { 'If-None-Match': `"${rep.digest}"` } }); + expect(again.status).toBe(304); + expect(await again.text()).toBe(''); + }); + }); + + it('answers existence by digest without enumerating the store', async () => { + const { store, rep } = fixture(); + await withServer({ store }, async (base) => { + const hit = await fetch(`${base}/v1/content/${rep.digest}`, { method: 'HEAD' }); + expect(hit.status).toBe(200); + expect(await hit.text()).toBe(''); // HEAD carries no body + + const miss = await fetch(`${base}/v1/content/sha256:${'b'.repeat(64)}`, { method: 'HEAD' }); + expect(miss.status).toBe(404); + + // There is deliberately no listing route. + expect((await fetch(`${base}/v1/content/`)).status).toBe(405); + }); + }); + + it('rejects malformed digests and path traversal before touching the store', async () => { + const { store } = fixture(); + await withServer({ store }, async (base) => { + for (const bad of [ + 'sha256:zzzz', 'sha512:' + 'a'.repeat(64), 'notadigest', + encodeURIComponent('../../etc/passwd'), + encodeURIComponent('sha256:' + 'a'.repeat(64) + '/../../x'), + ]) { + const res = await fetch(`${base}/v1/content/${bad}`, { method: 'HEAD' }); + expect([400, 404, 405]).toContain(res.status); + expect(res.status).not.toBe(200); + } + }); + }); + + it('refuses uploads with no ingress service rather than storing raw bytes', async () => { + // Committing an un-normalized upload would put unvalidated pixels behind a + // digest the fold trusts. 501 until normalization is injected. + const { store } = fixture(); + await withServer({ store }, async (base) => { + const res = await fetch(`${base}/v1/media/ingress`, { + method: 'POST', body: PNG, headers: { 'Content-Type': 'image/png' }, + }); + expect(res.status).toBe(501); + }); + }); + + it('answers 413 on an oversize body instead of resetting the connection', async () => { + const { store } = fixture(); + let seen: number | null = null; + await withServer( + { store, maxUploadBytes: 16, ingest: async (b) => { seen = b.byteLength; throw new Error('x'); } }, + async (base) => { + const res = await fetch(`${base}/v1/media/ingress`, { + method: 'POST', body: new Uint8Array(1024), headers: { 'Content-Type': 'image/png' }, + }); + expect(res.status).toBe(413); + // The stream is the authority: ingest must never have run. + expect(seen).toBeNull(); + }, + ); + }); + + it('sends no CORS header unless an origin is configured', async () => { + const { store, root } = fixture(); + const path = (r: { digest: string }) => `/v1/media/${r.digest}/representations/0`; + await withServer({ store }, async (base) => { + const res = await fetch(`${base}${path(root)}`); + // `*` on a route serving a tenant's uploads is not a default to inherit. + expect(res.headers.get('access-control-allow-origin')).toBeNull(); + }); + await withServer({ store, allowedOrigin: 'http://localhost:5173' }, async (base) => { + const res = await fetch(`${base}${path(root)}`); + expect(res.headers.get('access-control-allow-origin')).toBe('http://localhost:5173'); + expect(res.headers.get('vary')).toBe('Origin'); + }); + }); + + it('leaves non-content paths alone for the host to handle', async () => { + const { store } = fixture(); + await withServer({ store }, async (base) => { + expect((await fetch(`${base}/anything-else`)).status).toBe(404); + }); + }); + + it('bounds an upload in TIME, not only in bytes', async () => { + const { store } = fixture(); + // A client that opens a POST, declares a modest length, and then never + // sends the body. Under a byte cap alone this holds the handler, the + // promise and the socket for the life of the process — enough of them + // starve the host without any single limit being exceeded. + await withServer( + { store, ingest: async () => ({ mediaType: 'image/png', digest: 'sha256:' + '0'.repeat(64), size: 1 }), uploadTimeoutMs: 150 }, + async (base) => { + const stalled = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array([0x89])); // one byte, then silence + }, + }); + const res = await fetch(`${base}/v1/media/ingress`, { + method: 'POST', + headers: { 'content-type': 'image/png' }, + body: stalled, + // @ts-expect-error undici-only: required to send a stream body + duplex: 'half', + }); + // 408, not 413 and not 400 — the client is told which limit it hit. + expect(res.status).toBe(408); + expect((await res.json() as { error: string }).error).toContain('150ms'); + }, + ); + }, 10_000); + + it('leaves a normal upload untouched by the timeout', async () => { + const { store } = fixture(); + let ingested: Uint8Array | null = null; + await withServer( + { + store, + uploadTimeoutMs: 5_000, + ingest: async (bytes) => { + ingested = bytes; + return { mediaType: 'image/png', digest: 'sha256:' + '1'.repeat(64), size: bytes.byteLength }; + }, + }, + async (base) => { + const res = await fetch(`${base}/v1/media/ingress`, { + method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG, + }); + expect(res.status).toBe(201); + expect(ingested).toEqual(PNG); + }, + ); + }); + + it('refuses GET on the existence route — it is HEAD-only by design', async () => { + // `/v1/content/` answers EXISTENCE. Serving its bytes would hand + // out any blob by digest, including a retained SOURCE layer — defeating + // the reason `/representations/` resolves through the manifest at all. + // Before this, GET was accepted and replied 200 with a `Content-Length` + // and an EMPTY body: a protocol violation and a hole in the same breath. + const { store, rep } = fixture(); + await withServer({ store }, async (base) => { + const head = await fetch(`${base}/v1/content/${rep.digest}`, { method: 'HEAD' }); + expect(head.status).toBe(200); + + const get = await fetch(`${base}/v1/content/${rep.digest}`); + expect(get.status).toBe(405); + expect(new Uint8Array(await get.arrayBuffer()).byteLength).toBeGreaterThan(0); // a real error body + }); + }); + + it('declares a Content-Length that matches the body it sends', async () => { + // Nothing in the repo asserted this — `content-length` appeared in zero + // test assertions across every package — which is how a route that + // declared N bytes and sent zero survived. + const { store, root } = fixture(); + await withServer({ store }, async (base) => { + const url = `${base}/v1/media/${root.digest}/representations/0`; + + const get = await fetch(url); + const body = new Uint8Array(await get.arrayBuffer()); + expect(Number(get.headers.get('content-length'))).toBe(body.byteLength); + + // HEAD declares the same length and sends nothing — which is correct for + // HEAD, and the exact shape the existence route was wrongly using for GET. + const head = await fetch(url, { method: 'HEAD' }); + expect(Number(head.headers.get('content-length'))).toBe(body.byteLength); + expect(new Uint8Array(await head.arrayBuffer()).byteLength).toBe(0); + }); + }); +}); diff --git a/packages/rig/test/define-ability.test.ts b/packages/rig/test/define-ability.test.ts index b1b39b6a..870df665 100644 --- a/packages/rig/test/define-ability.test.ts +++ b/packages/rig/test/define-ability.test.ts @@ -22,7 +22,7 @@ import { Source } from '@lloyal-labs/lloyal-agents'; import type { Ability } from '@lloyal-labs/lloyal-agents'; import { defineAbility } from '../src/define-ability'; import type { AbilitySetup } from '../src/define-ability'; -import type { AbilityManifest } from '../src/ability-types'; +import type { AbilityManifest } from '@lloyal-labs/lloyal-agents'; // ── Test fixtures ──────────────────────────────────────────────── diff --git a/packages/rig/test/keyless-search.test.ts b/packages/rig/test/keyless-search.test.ts index d1e4a4ce..9e9617e7 100644 --- a/packages/rig/test/keyless-search.test.ts +++ b/packages/rig/test/keyless-search.test.ts @@ -470,7 +470,10 @@ describe("createKeylessSearchProvider — timeout cancellation", () => { // → Marginalia branch will also fetchWithTimeout (same hanging fetch). To // avoid hanging the test, resolve the (now-aborted) primary fetch as a // safety net. - if (resolveFetchOuter) resolveFetchOuter(htmlResponse(503, "")); + // Re-assert the type: `resolveFetchOuter` is assigned only inside the + // promise executor, which control-flow analysis cannot order against + // this line, so it narrows to `null` here and the call becomes `never`. + (resolveFetchOuter as ((res: Response) => void) | null)?.(htmlResponse(503, "")); // Fire any subsequent sleeps (Marginalia request timeout etc). await clock.flushAll(); diff --git a/packages/rig/test/models.test.ts b/packages/rig/test/models.test.ts index 0dd728e8..1e0b72bf 100644 --- a/packages/rig/test/models.test.ts +++ b/packages/rig/test/models.test.ts @@ -48,7 +48,7 @@ function mockFetch(map: Record): typeof fetch { const v = map[url]; if (v instanceof Error) throw v; if (v === undefined) throw new Error(`no mock for ${url}`); - return new Response(v); + return new Response(v as unknown as BodyInit); }) as unknown as typeof fetch; } diff --git a/packages/rig/test/plan-routing-key.test.ts b/packages/rig/test/plan-routing-key.test.ts index 855c7cb9..ff8b751f 100644 --- a/packages/rig/test/plan-routing-key.test.ts +++ b/packages/rig/test/plan-routing-key.test.ts @@ -73,7 +73,7 @@ describe('buildPlanSchema — the decode grammar', () => { const names = [...PROTOCOLS]; const { properties } = taskItems(buildPlanSchema(names, 5)); names.push('injected_protocol'); - const e = (properties[TASK_ROUTING_KEY] as { enum: string[] }).enum; + const e = (properties[TASK_ROUTING_KEY] as unknown as { enum: string[] }).enum; expect(e).not.toContain('injected_protocol'); }); @@ -85,7 +85,7 @@ describe('buildPlanSchema — the decode grammar', () => { it('caps the task array at maxTasks', () => { const props = buildPlanSchema(PROTOCOLS, 3).properties as Record; - expect((props.tasks as { maxItems: number }).maxItems).toBe(3); + expect((props.tasks as unknown as { maxItems: number }).maxItems).toBe(3); }); }); diff --git a/packages/rig/test/provision.test.ts b/packages/rig/test/provision.test.ts index e11c003b..2512b779 100644 --- a/packages/rig/test/provision.test.ts +++ b/packages/rig/test/provision.test.ts @@ -22,7 +22,7 @@ const { resolveModel, createReranker, fakeReranker } = vi.hoisted(() => { const fakeReranker = { id: 'fake-reranker' } as unknown as Reranker; return { fakeReranker, - resolveModel: vi.fn(async () => '/fake/models/reranker/qwen3-reranker-0.6b-q8.gguf'), + resolveModel: vi.fn(async (_spec?: unknown, _opts?: unknown) => '/fake/models/reranker/qwen3-reranker-0.6b-q8.gguf'), createReranker: vi.fn(() => (function* () { return fakeReranker; diff --git a/packages/rig/test/registry.test.ts b/packages/rig/test/registry.test.ts index 2ba1fb43..696d9596 100644 --- a/packages/rig/test/registry.test.ts +++ b/packages/rig/test/registry.test.ts @@ -51,7 +51,6 @@ function fakeApp(opts: { }): Ability { const manifest: AbilityManifest = { name: opts.name, - version: '1.0.0', abilityProtocolVersion: opts.abilityProtocolVersion ?? '3.0', protocol: { name: opts.protocolName ?? `${opts.name}_research`, @@ -62,11 +61,10 @@ function fakeApp(opts: { }; return { name: opts.name, - version: '1.0.0', manifest, source: { name: opts.name } as Ability['source'], tools: [], - agent: 'test agent template', + skill: 'test agent template', configSchema: opts.configSchema, }; } diff --git a/packages/rig/test/reranker-options.test.ts b/packages/rig/test/reranker-options.test.ts index aa8d5190..5637c4e7 100644 --- a/packages/rig/test/reranker-options.test.ts +++ b/packages/rig/test/reranker-options.test.ts @@ -22,7 +22,14 @@ const { createContext, fakeCtx } = vi.hoisted(() => { tokenize: async () => [1], dispose: vi.fn(), }; - return { fakeCtx, createContext: vi.fn(async () => fakeCtx) }; + // Declare the parameter: `vi.fn(async () => …)` types `mock.calls` as an + // EMPTY tuple, so `calls[0][0]` is a type error and every read needs a cast + // through `undefined`. Naming the argument is what makes the assertions below + // check a real shape instead of an `unknown`. + return { + fakeCtx, + createContext: vi.fn(async (_opts: Record) => fakeCtx), + }; }); // The native binding and Rerank's boot gates both need a real model; neither is @@ -63,21 +70,21 @@ describe('createReranker — KV precision', () => { it('requests q4_0 for both KV types when the caller specifies neither', async () => { await load(); expect(createContext).toHaveBeenCalledTimes(1); - const args = createContext.mock.calls[0][0] as Record; + const args = createContext.mock.calls[0][0]; expect(args.typeK).toBe('q4_0'); expect(args.typeV).toBe('q4_0'); }); it('requests the caller\'s KV types when given', async () => { await load({ typeK: 'f16', typeV: 'f16' }); - const args = createContext.mock.calls[0][0] as Record; + const args = createContext.mock.calls[0][0]; expect(args.typeK).toBe('f16'); expect(args.typeV).toBe('f16'); }); it('still honours the sizing options', async () => { await load({ nSeqMax: 6, nCtx: 2048 }); - const args = createContext.mock.calls[0][0] as Record; + const args = createContext.mock.calls[0][0]; expect(args.nSeqMax).toBe(6); expect(args.nCtx).toBe(2048); // nBatch derives from the two above when not given. diff --git a/packages/rig/test/runner-substrate.test.ts b/packages/rig/test/runner-substrate.test.ts index e3e26c23..f41b26bd 100644 --- a/packages/rig/test/runner-substrate.test.ts +++ b/packages/rig/test/runner-substrate.test.ts @@ -207,6 +207,6 @@ describe('mergeConfig / markSession / rung', () => { expect(rung(undefined, undefined, null, 42)).toBe('yml'); expect(rung(undefined, 0, null, 42)).toBe('env'); // 0 is a value expect(rung(null, null, null, null)).toBe('default'); - expect(rung('x', 1, 'f', 'y')).toBe('cli'); + expect(rung('x', 1, 'f', 'y')).toBe('cli'); }); }); diff --git a/packages/rig/test/spine-render.test.ts b/packages/rig/test/spine-render.test.ts index 02b2f330..3442f88e 100644 --- a/packages/rig/test/spine-render.test.ts +++ b/packages/rig/test/spine-render.test.ts @@ -71,7 +71,6 @@ function makeAbility(opts: { const protocolName = opts.protocolName ?? `${opts.name}_research`; const manifest: AbilityManifest = { name: opts.name, - version: '1.0.0', abilityProtocolVersion: '3.0', protocol: { name: protocolName, @@ -81,7 +80,6 @@ function makeAbility(opts: { }; return { name: opts.name, - version: '1.0.0', manifest, source: { name: opts.name } as Ability['source'], tools: [], diff --git a/packages/rig/test/verification-properties.test.ts b/packages/rig/test/verification-properties.test.ts index a811a842..ef45a097 100644 --- a/packages/rig/test/verification-properties.test.ts +++ b/packages/rig/test/verification-properties.test.ts @@ -55,13 +55,11 @@ const appArb = fc.record({ }).map(({ name, protocolName, useWhen, tools }): Ability => { const manifest: AbilityManifest = { name, - version: '1.0.0', abilityProtocolVersion: '3.0', protocol: { name: protocolName, useWhen, tools: [...new Set(tools)] }, }; return { name, - version: '1.0.0', manifest, source: { name } as Ability['source'], tools: [], diff --git a/packages/rig/tsconfig.json b/packages/rig/tsconfig.json index a37f21d6..ed4303be 100644 --- a/packages/rig/tsconfig.json +++ b/packages/rig/tsconfig.json @@ -2,12 +2,29 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "baseUrl": ".", + "paths": { + "@lloyal-labs/media/node": [ + "../media/dist/node.d.ts" + ] + } }, - "include": ["src/**/*.ts"], + "include": [ + "src/**/*.ts" + ], "references": [ - { "path": "../sdk" }, - { "path": "../agents" }, - { "path": "../channel-verify" } + { + "path": "../sdk" + }, + { + "path": "../agents" + }, + { + "path": "../channel-verify" + }, + { + "path": "../media" + } ] } diff --git a/packages/sdk/src/Branch.ts b/packages/sdk/src/Branch.ts index bd0d532d..cffa59b7 100644 --- a/packages/sdk/src/Branch.ts +++ b/packages/sdk/src/Branch.ts @@ -219,10 +219,14 @@ export class Branch { * child attends it with zero re-encode. * * @param prompt - Templated prompt containing the media markers - * @param bitmaps - Encoded image bytes (jpg/png/bmp/gif), one per marker + * @param bitmaps - Encoded image bytes the projector decodes, one per marker * @param sepTokens - Optional leading token run (e.g. a turn separator) * @returns Counts — see `MultimodalPrefillResult` (JS can't know * multimodal token counts; the native walk reports them) + * @throws If the prefill failed. The cohort form reports failure per entry + * because a rejected promise would lose which branches landed; a cohort of + * ONE has nothing to lose, and returning a zero-count result would let a + * caller carry on against a branch the failure POISONED. */ async prefillMultimodal( prompt: string, @@ -232,6 +236,9 @@ export class Branch { this._ensureNotDisposed(); const [result] = await this._ctx._storePrefillMultimodal( [this._handle], [sepTokens], [prompt], [bitmaps]); + if (result.error) { + throw new Error(`Branch.prefillMultimodal: ${result.error}`); + } return result; } diff --git a/packages/sdk/src/BranchStore.ts b/packages/sdk/src/BranchStore.ts index e4b04cc4..2276ef7e 100644 --- a/packages/sdk/src/BranchStore.ts +++ b/packages/sdk/src/BranchStore.ts @@ -1,5 +1,6 @@ import type { Branch } from './Branch'; -import type { SessionContext } from './types'; +import type { SessionContext, MultimodalPrefillResult } from './types'; +import type { MultimodalDelta } from './deltas'; /** * High-throughput multi-branch decode operations @@ -134,6 +135,44 @@ export class BranchStore { await this._ctx._storePrefill(handles, tokenArrays); } + /** + * Prefill multimodal deltas across branches in one call. + * + * The embedding-rail mirror of {@link prefill}. `llama_batch` is + * token-XOR-embd, so an image is always its own dispatch and these cannot be + * bin-packed with token prefills — they are a separate call, not a separate + * strategy. How many dispatches (and vision-tower encodes) the cohort costs + * is the native worker's business, which is what lets that get cheaper later + * without any caller changing. + * + * **Reports failures rather than throwing.** Unlike {@link prefill}, a bad + * entry does not reject the call: it comes back with `error` set on its own + * result, and the rest still land. A rejected promise would lose which + * branches were mutated, and every caller here needs that — see + * {@link MultimodalPrefillResult.error}. A failed entry's branch is + * POISONED: prune it and replay from content. + * + * @param entries - One `[branch, delta]` pair per prefill, in dispatch order + * @returns One result per entry, positionally + * @throws Only if a branch is disposed — a caller bug, not an input failure + */ + async prefillMultimodal( + entries: [Branch, MultimodalDelta][], + ): Promise { + const handles: number[] = []; + const sepTokens: number[][] = []; + const prompts: string[] = []; + const bitmaps: Uint8Array[][] = []; + for (const [branch, delta] of entries) { + if (branch.disposed) throw new Error('BranchStore.prefillMultimodal: branch is disposed'); + handles.push(branch.handle); + sepTokens.push(delta.sep); + prompts.push(delta.prompt); + bitmaps.push(delta.bitmaps); + } + return this._ctx._storePrefillMultimodal(handles, sepTokens, prompts, bitmaps); + } + /** * Retain only the winner branch — evict all other leases and free their slots. * diff --git a/packages/sdk/src/Session.ts b/packages/sdk/src/Session.ts index 9e4176c6..b75930b9 100644 --- a/packages/sdk/src/Session.ts +++ b/packages/sdk/src/Session.ts @@ -10,15 +10,31 @@ import { buildUserDelta, buildUserDeltaMultimodal, buildAssistantDelta, buildToo * agents-layer tracer emits a `branch:prefill` event) WITHOUT coupling * Session to any trace type — the callback is sdk-native. Pure * observability: it runs after the prefill and never affects it. `content` - * is the verbatim turn text; `tokenCount` is the prefilled delta length. + * is the verbatim turn text; `cells` is what the prefill added to the cache. * * @category Branching */ export type TrunkPrefillObserver = (info: { role: 'user' | 'assistant' | 'turn' | 'tool'; content: string; - tokenCount: number; + /** KV CELLS the prefill added — not tokens. + * + * Equal on the token rail, where this is the delta length. NOT equal on the + * multimodal path, which reports `tokensDecoded` — itself documented as + * "KV cells added", a name inherited from the native contract and shared + * with lloyal.node, so it cannot be corrected from here. This field is + * sdk-native and can be, so it is. */ + cells: number; branchHandle: number; + /** Roots of the content this prefill put into the cache, in marker order — + * present only on the multimodal path. + * + * Descriptors, NOT bytes: by the time a prefill happens the caller has + * already normalized and committed the content, because media that reaches + * the cache unaddressed produces a run that cannot be replayed. So there is + * nothing left to store here — only a reference to record. Structural on + * purpose: the SDK has no attachment concept and does not want one. */ + attachments?: readonly { digest: string; mediaType: string; size: number }[]; }) => void; /** @@ -104,7 +120,7 @@ export class Session { async prefillUser(content: string, opts: { tools?: string } = {}): Promise { const tokens = buildUserDelta(this._ctx, content, opts); await this._trunk!.prefill(tokens); - this._onPrefill?.({ role: 'user', content, tokenCount: tokens.length, branchHandle: this._trunk!.handle }); + this._onPrefill?.({ role: 'user', content, cells: tokens.length, branchHandle: this._trunk!.handle }); } /** @@ -117,18 +133,44 @@ export class Session { * * Requires a context created with `mmprojPath`. * + * Handles warm/cold internally, like {@link commitTurn} and unlike + * {@link prefillUser}: + * - **Warm** (trunk exists): appends separator + delta to the existing trunk + * - **Cold** (no trunk): creates a branch at position 0, prefills WITHOUT a + * separator (fresh branch — no prior turn to separate from), promotes it + * + * The cold path is what a composer needs. An image attached to the FIRST + * question has no trunk yet, and the trunk is otherwise not established + * until a run ends; without this the image could only reach the model as a + * per-agent copy. Landing it on the trunk first is what lets every agent + * forked from it attend the same encoded rows. + * * @param content - User message text - * @param images - Encoded image bytes (jpg/png/bmp/gif) + * @param images - Encoded image bytes in a format the projector decodes * @param opts - Optional tools JSON string */ async prefillUserMultimodal( content: string, images: Uint8Array[], - opts: { tools?: string } = {}, + opts: { + tools?: string; + /** Roots for the content in `images`, already committed by the caller's + * barrier. Passed through to the prefill observer so the trace records + * what was admitted; the Session itself never inspects them. */ + attachments?: readonly { digest: string; mediaType: string; size: number }[]; + } = {}, ): Promise { const { sep, prompt, bitmaps } = buildUserDeltaMultimodal(this._ctx, content, images, opts); - const { tokensDecoded } = await this._trunk!.prefillMultimodal(prompt, bitmaps, sep); - this._onPrefill?.({ role: 'user', content, tokenCount: tokensDecoded, branchHandle: this._trunk!.handle }); + const attachments = opts.attachments; + if (this._trunk) { + const { tokensDecoded } = await this._trunk.prefillMultimodal(prompt, bitmaps, sep); + this._onPrefill?.({ role: 'user', content, cells: tokensDecoded, branchHandle: this._trunk.handle, ...(attachments ? { attachments } : {}) }); + } else { + const trunk = Branch.create(this._ctx, 0, {}); + const { tokensDecoded } = await trunk.prefillMultimodal(prompt, bitmaps, []); + await this.promote(trunk); + this._onPrefill?.({ role: 'user', content, cells: tokensDecoded, branchHandle: trunk.handle, ...(attachments ? { attachments } : {}) }); + } } /** @@ -149,7 +191,7 @@ export class Session { async prefillAssistant(content: string, opts: { enableThinking?: boolean } = {}): Promise { const tokens = buildAssistantDelta(this._ctx, content, opts); await this._trunk!.prefill(tokens); - this._onPrefill?.({ role: 'assistant', content, tokenCount: tokens.length, branchHandle: this._trunk!.handle }); + this._onPrefill?.({ role: 'assistant', content, cells: tokens.length, branchHandle: this._trunk!.handle }); } /** @@ -161,7 +203,7 @@ export class Session { async prefillToolResult(resultStr: string, callId: string): Promise { const tokens = buildToolResultDelta(this._ctx, resultStr, callId); await this._trunk!.prefill(tokens); - this._onPrefill?.({ role: 'tool', content: resultStr, tokenCount: tokens.length, branchHandle: this._trunk!.handle }); + this._onPrefill?.({ role: 'tool', content: resultStr, cells: tokens.length, branchHandle: this._trunk!.handle }); } /** @@ -181,7 +223,7 @@ export class Session { // conversations; no thinking blocks should be embedded. const tokens = buildTurnDelta(this._ctx, query, response, { enableThinking: false }); await this._trunk.prefill(tokens); - this._onPrefill?.({ role: 'turn', content: `${query}\n\n${response}`, tokenCount: tokens.length, branchHandle: this._trunk.handle }); + this._onPrefill?.({ role: 'turn', content: `${query}\n\n${response}`, cells: tokens.length, branchHandle: this._trunk.handle }); } else { // Cold path: create trunk at position 0, prefill without separator // (fresh branch — no prior turn to separate from), then promote. @@ -196,7 +238,7 @@ export class Session { const trunk = Branch.create(this._ctx, 0, {}); await trunk.prefill(tokens); await this.promote(trunk); - this._onPrefill?.({ role: 'turn', content: `${query}\n\n${response}`, tokenCount: tokens.length, branchHandle: trunk.handle }); + this._onPrefill?.({ role: 'turn', content: `${query}\n\n${response}`, cells: tokens.length, branchHandle: trunk.handle }); } } diff --git a/packages/sdk/src/deltas.ts b/packages/sdk/src/deltas.ts index f8eed178..c0e1ae4d 100644 --- a/packages/sdk/src/deltas.ts +++ b/packages/sdk/src/deltas.ts @@ -12,6 +12,35 @@ import type { SessionContext } from './types'; */ export const MEDIA_MARKER = '<__media__>'; +/** + * Chat content carrying one media marker per image + * + * The ONE place `media_marker` parts are emitted. Every ingress — a user turn, + * a spine header, a tool result — renders its text through this, so the marker + * grammar cannot drift between them. + * + * Returns the bare string when there are no images, so a caller can route text + * and multimodal content through the same expression without branching. + * + * Structured parts, never string splicing: `media_marker` is the chat layer's + * native part type, and the part-joiner owns newline hygiene around markers. + * + * @param text - The message text the markers follow + * @param images - One marker is emitted per entry; bytes are not read here + * + * @category Agents + */ +export function mediaContent( + text: string, + images: readonly Uint8Array[], +): string | Array<{ type: string; text: string }> { + if (images.length === 0) return text; + return [ + { type: 'text', text }, + ...images.map(() => ({ type: 'media_marker', text: MEDIA_MARKER })), + ]; +} + /** * A multimodal turn delta — the string-stage counterpart of a token delta * @@ -27,7 +56,7 @@ export interface MultimodalDelta { sep: number[]; /** Templated prompt containing one {@link MEDIA_MARKER} per image */ prompt: string; - /** Encoded image bytes (jpg/png/bmp/gif), one per marker, in order */ + /** Encoded image bytes in a format the projector decodes, one per marker, in order */ bitmaps: Uint8Array[]; } @@ -120,10 +149,7 @@ export function buildUserDeltaMultimodal( const fmtOpts: Record = {}; if (opts.tools) fmtOpts.tools = opts.tools; if (opts.enableThinking !== undefined) fmtOpts.enableThinking = opts.enableThinking; - const userContent = [ - { type: 'text', text: content }, - ...images.map(() => ({ type: 'media_marker', text: MEDIA_MARKER })), - ]; + const userContent = mediaContent(content, images); const { prompt } = ctx.formatChatSync( JSON.stringify([ { role: 'system', content: opts.system ?? '' }, @@ -254,3 +280,83 @@ export function buildToolResultDelta( } return [...sep, ...delta, ...genTokens]; } + +/** + * Build a multimodal delta for a tool result carrying images + * + * The multimodal counterpart of {@link buildToolResultDelta}: a tool that + * returns media (a rasterized document page, a rendered chart) has its result + * text rendered with one marker per image, and the delta stops at the string + * stage because mtmd owns tokenization. + * + * The generation prompt is concatenated onto the prompt STRING rather than + * tokenized and appended as {@link buildToolResultDelta} does. On the token + * path the caller owns tokenization and can append ids; here mtmd tokenizes + * the whole prompt, so anything appended after the fact would never reach it. + * + * @param ctx - Active session context (created with `mmprojPath`) + * @param resultStr - JSON-serialized tool result, with the media stripped out + * @param callId - Tool call identifier from the model's parsed output + * @param images - Encoded image bytes, one marker emitted per image + * @param opts - Optional thinking flag; see {@link DeltaOpts} + * @returns Delta ready for {@link Branch.prefillMultimodal} + * + * @category Agents + */ +export function buildToolResultDeltaMultimodal( + ctx: SessionContext, + resultStr: string, + callId: string, + images: Uint8Array[], + opts: DeltaOpts = {}, +): MultimodalDelta { + const sep = ctx.getTurnSeparator(); + const fmtOpts: Record = {}; + if (opts.enableThinking !== undefined) fmtOpts.enableThinking = opts.enableThinking; + const { prompt, generationPrompt } = ctx.formatChatSync( + JSON.stringify([ + { role: 'system', content: '' }, + { role: 'tool', content: mediaContent(resultStr, images), tool_call_id: callId }, + ]), + fmtOpts, + ); + const withGen = + generationPrompt && !prompt.endsWith(generationPrompt) + ? prompt + generationPrompt + : prompt; + return { sep, prompt: withGen, bitmaps: images }; +} + +/** + * KV cells a multimodal delta will consume, measured before it decodes + * + * The admission cost. `decode_segments` is not atomic, so a caller that + * discovers the overflow midway has poisoned the branch and must prune it; + * one that refuses up front has spent nothing. Text can be measured by + * tokenizing it — the token path gets its count for free from + * `prefillTokens.length` — but an image cannot, because the caller holds bytes + * and the row count depends on the projector's geometry. Without this, media + * is the one input that reaches KV ungated. + * + * Deliberately NOT computed inside the delta builders: they are pure + * `formatChatSync` composition, and this does native work (bitmap decode plus + * tokenization). Measuring where a cost is actually needed keeps that work off + * every caller that only wants to build a delta. + * + * Cells, not positions or tokens: a KV budget is spent in cells, and under + * M-RoPE an image costs far more cells than it advances position. The number + * is directly comparable with `ContextPressure.headroom` and with the + * `tokensDecoded` the prefill reports back. + * + * @param ctx - Active session context (created with `mmprojPath`) + * @param delta - Built by any of the multimodal delta builders + * @returns Cells the prefill would add — sep + text + image rows + * + * @category Agents + */ +export async function deltaCells( + ctx: SessionContext, + delta: MultimodalDelta, +): Promise { + return ctx._cellsMultimodal(delta.sep, delta.prompt, delta.bitmaps); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index dba91303..96149b81 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -4,7 +4,9 @@ export { BranchStore } from './BranchStore'; export { Session } from './Session'; export { Rerank, RerankCalibrationError, RerankInternalError, RETRIEVAL_INSTRUCTION } from './Rerank'; export type { RerankOpts, RerankTruncation, RerankInstruction } from './Rerank'; -export { buildUserDelta, buildUserDeltaMultimodal, buildAssistantDelta, buildToolResultDelta, buildTurnDelta, MEDIA_MARKER } from './deltas'; +export { buildUserDelta, buildUserDeltaMultimodal, buildAssistantDelta, buildToolResultDelta, + buildToolResultDeltaMultimodal, buildTurnDelta, mediaContent, deltaCells, + MEDIA_MARKER } from './deltas'; export type { DeltaOpts, MultimodalDelta } from './deltas'; // ── Enums + constants ──────────────────────────────────────── diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 24349a98..cd8a105e 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -289,6 +289,18 @@ export interface MultimodalPrefillResult { tokensDecoded: number; /** Branch position advance (< tokensDecoded under M-RoPE with images) */ positionAdvance: number; + /** Why THIS entry failed, when it did — the cohort keeps going. + * + * A rejected promise would lose which entries landed, and the caller needs + * that: six agents settling images must not lose five because one page was + * corrupt, and pruning the right branch requires knowing which one it was. + * Set ⇒ this branch is POISONED, not merely unchanged: `decode_segments` is + * not atomic and partial-range KV ops are meaningless on recurrent layers, + * so the contract is prune and replay from content, never resume. + * + * `Branch.prefillMultimodal` throws instead of setting this — it is a + * cohort of one, where a throw is the friendlier shape. */ + error?: string; } /** @@ -1541,6 +1553,15 @@ export interface SessionContext { bitmaps: Uint8Array[][], ): Promise; + /** @internal — KV cells a multimodal prefill WOULD consume, known before + * anything decodes. Pays bitmap decode + tokenization, not the vision-tower + * encode. Wrapped by {@link deltaCells}. */ + _cellsMultimodal( + sepTokens: number[], + prompt: string, + bitmaps: Uint8Array[], + ): Promise; + /** @internal — additively merge experts' logits_snapshot into dst's: * dst[t] += alpha * sum(experts[i][t]). Pure CPU op, no GPU dispatch. */ _storeMergeLogits(dstHandle: number, srcHandles: number[], alpha: number): void; diff --git a/packages/sdk/test/MockSessionContext.ts b/packages/sdk/test/MockSessionContext.ts index 774b0804..aaa6f05b 100644 --- a/packages/sdk/test/MockSessionContext.ts +++ b/packages/sdk/test/MockSessionContext.ts @@ -45,6 +45,7 @@ import type { FormatChatOptions, ParseChatOutputResult, ParseChatOutputOptions, + MultimodalPrefillResult, } from '../src/types'; import { Branch } from '../src/Branch'; import { BranchStore } from '../src/BranchStore'; @@ -124,7 +125,12 @@ export class MockSessionContext implements SessionContext { _branchPrune(handle: number): void { const b = this._branches.get(handle); - if (!b) return; + // A stale handle is INERT, mirroring the kernel: `reset_slot` bumps the + // slot generation ("Prevent ABA") and `BranchStore::get` refuses a handle + // whose generation no longer matches, so `prune()` returns early. Pruning + // twice is therefore safe THERE, and this mock must not be stricter than + // the thing it stands in for. + if (!b || b.disposed) return; // Decrement cellsUsed by unique cells (matches C++ BranchStore::release) const unique = Math.max(0, b.position - b.forkHead); this.cellsUsed = Math.max(0, this.cellsUsed - unique); @@ -187,6 +193,101 @@ export class MockSessionContext implements SessionContext { } } + /** + * Multimodal prefill. Records every call so a test can assert that an + * ingress routed here rather than down the token rail. + * + * Models the property that separates this rail from the token one: an image + * occupies more KV cells than it advances position (under M-RoPE, cells = + * rows while position advances by max(nx, ny)). `mockImageCells` and + * `mockImagePositions` stand in for the projector's geometry, so a test can + * assert cells and position independently instead of assuming they match. + */ + readonly multimodalPrefills: Array<{ + handles: number[]; + sepTokens: number[][]; + prompts: string[]; + bitmapCounts: number[]; + /** What the call reported back — the counts a caller must not re-derive. */ + results: MultimodalPrefillResult[]; + }> = []; + + /** Cells one mock image occupies. */ + mockImageCells = 16; + /** Position advance one mock image costs — deliberately below mockImageCells. */ + mockImagePositions = 4; + + async _storePrefillMultimodal( + handles: number[], + sepTokens: number[][], + prompts: string[], + bitmaps: Uint8Array[][], + ): Promise { + const out: MultimodalPrefillResult[] = []; + this.multimodalPrefills.push({ + handles: [...handles], + sepTokens: sepTokens.map((s) => [...s]), + prompts: [...prompts], + bitmapCounts: bitmaps.map((b) => b.length), + results: out, + }); + + for (let i = 0; i < handles.length; i++) { + // Per-entry failure, the property the native worker guarantees: a bad + // image reports on ITS OWN result and the cohort keeps going. + const failure = this.mockMultimodalError?.(prompts[i], bitmaps[i]) ?? null; + if (failure) { + out.push({ tokensDecoded: 0, positionAdvance: 0, error: failure }); + continue; + } + const tokensDecoded = this._mockCells(sepTokens[i], prompts[i], bitmaps[i].length); + const positionAdvance = this._mockPositions(sepTokens[i], prompts[i], bitmaps[i].length); + + const b = this._branches.get(handles[i]); + if (b && !b.disposed) { + b.position += positionAdvance; + this.cellsUsed += tokensDecoded; + } + out.push({ tokensDecoded, positionAdvance }); + } + return out; + } + + /** Fail selected cohort entries. Returns a message to fail that entry, null + * to let it through — lets a test drive the one-bad-image-among-siblings + * case the native worker's per-entry try/catch exists for. */ + mockMultimodalError?: (prompt: string, bitmaps: Uint8Array[]) => string | null; + + /** Cells one multimodal prefill consumes. Text stands in at one cell per 4 + * chars, matching tokenizeSync, minus the markers the native walk replaces + * with image rows. Shared by the prefill and the cost query so the mock + * cannot quote one number and charge another — the property the real + * `MtmdSource::cells()` guarantees by counting before it encodes. */ + private _mockCells(sep: number[], prompt: string, markers: number): number { + const textCells = Math.ceil(prompt.replace(/<__media__>/g, '').length / 4); + return sep.length + textCells + markers * this.mockImageCells; + } + + /** Position advance for the same prefill — deliberately below the cell count + * (the M-RoPE decoupling this mock exists to model). */ + private _mockPositions(sep: number[], prompt: string, markers: number): number { + const textCells = Math.ceil(prompt.replace(/<__media__>/g, '').length / 4); + return sep.length + textCells + markers * this.mockImagePositions; + } + + async _cellsMultimodal( + sepTokens: number[], + prompt: string, + bitmaps: Uint8Array[], + ): Promise { + return this._mockCells(sepTokens, prompt, bitmaps.length); + } + + /** Whether the mock stands in for a vision-capable projector. */ + mockSupportsVision = true; + supportsVision(): boolean { return this.mockSupportsVision; } + supportsAudio(): boolean { return false; } + _storeMergeLogits(_dstHandle: number, _srcHandles: number[], _alpha: number): void { /* mock no-op */ } diff --git a/packages/sdk/test/branch-double-free.test.ts b/packages/sdk/test/branch-double-free.test.ts new file mode 100644 index 00000000..9609d513 --- /dev/null +++ b/packages/sdk/test/branch-double-free.test.ts @@ -0,0 +1,62 @@ +/** + * `Branch.disposed` can LIE, and why that is survivable. + * + * `pruneSubtreeSync()` frees an entire subtree natively but sets the local + * `_disposed` flag only on the receiver — so every descendant `Branch` OBJECT + * is left stale-but-undisposed. Any holder of those objects (an agent pool + * tearing down agents, where a sub-spawned agent's branch is a child of its + * spawner's) will read `disposed === false` for a slot that is already gone. + * + * That is survivable ONLY because the kernel is generation-checked: + * `reset_slot` bumps the slot generation ("Prevent ABA") and `BranchStore::get` + * refuses a handle whose generation no longer matches, so `prune()` returns + * early. These lock both halves — the flag lies, and the second free is inert — + * because a change to either would turn a benign staleness into a real + * use-after-free, and nothing else in the suite states the dependency. + */ +import { describe, it, expect } from 'vitest'; +import { MockSessionContext } from './MockSessionContext'; +import { Branch } from '../src/Branch'; + +const ctx = () => new MockSessionContext({ nCtx: 4096, cellsUsed: 0 }); + +describe('subtree pruning and stale branch objects', () => { + it('leaves DESCENDANT objects stale-but-undisposed', () => { + const c = ctx(); + const parent = Branch.create(c as never, 0, {}); + const child = parent.forkSync(); + + parent.pruneSubtreeSync(); + + expect(parent.disposed).toBe(true); + expect(child.disposed, 'the child was freed natively but its object does not know') + .toBe(false); + }); + + it('treats a second free of that stale object as a NO-OP', () => { + // The property the staleness above depends on. If the kernel ever stopped + // generation-checking, this is the test that should fail first. + const c = ctx(); + const parent = Branch.create(c as never, 0, {}); + const child = parent.forkSync(); + parent.pruneSubtreeSync(); + + expect(() => child.pruneSubtreeSync()).not.toThrow(); + }); + + it('a liveness-checked, children-first pass frees each slot once', () => { + // What a teardown holding many branches should do regardless: ask the + // CONTEXT whether children are live, and let each object set its own flag. + const c = ctx(); + const parent = Branch.create(c as never, 0, {}); + const child = parent.forkSync(); + + const safe = (b: Branch): void => { + if (!b.disposed && b.children.length === 0) b.pruneSync(); + }; + for (const b of [parent, child].reverse()) safe(b); + + expect(child.disposed).toBe(true); + expect(parent.disposed).toBe(true); + }); +}); diff --git a/packages/sdk/test/deltas-multimodal.test.ts b/packages/sdk/test/deltas-multimodal.test.ts new file mode 100644 index 00000000..e0d7416f --- /dev/null +++ b/packages/sdk/test/deltas-multimodal.test.ts @@ -0,0 +1,186 @@ +/** + * The multimodal delta surface. + * + * These lock the two properties that distinguish it from the token path and + * that nothing else asserts: markers are emitted structurally (one per image, + * in order) rather than spliced, and the delta stops at the STRING stage + * because mtmd owns tokenization downstream. + */ +import { describe, it, expect } from 'vitest'; +import { MockSessionContext } from './MockSessionContext'; +import { + mediaContent, + buildUserDeltaMultimodal, + buildToolResultDeltaMultimodal, + deltaCells, + MEDIA_MARKER, +} from '../src/deltas'; +import { Branch } from '../src/Branch'; +import { Session } from '../src/Session'; +import { BranchStore } from '../src/BranchStore'; + +const img = (n: number): Uint8Array[] => + Array.from({ length: n }, (_, i) => new Uint8Array([i, i + 1])); + +const markerCount = (s: string): number => (s.match(/<__media__>/g) ?? []).length; + +describe('mediaContent', () => { + it('returns the bare string when there are no images', () => { + // The text-path shape, so a caller can route both through one expression. + expect(mediaContent('hello', [])).toBe('hello'); + }); + + it('emits one marker part per image, after the text, in order', () => { + const parts = mediaContent('describe these', img(3)); + expect(Array.isArray(parts)).toBe(true); + expect(parts).toEqual([ + { type: 'text', text: 'describe these' }, + { type: 'media_marker', text: MEDIA_MARKER }, + { type: 'media_marker', text: MEDIA_MARKER }, + { type: 'media_marker', text: MEDIA_MARKER }, + ]); + }); + + it('emits structured parts, never a spliced string', () => { + // Splicing would put the marker in the text part, where the part-joiner's + // newline hygiene never sees it. + const parts = mediaContent('x', img(1)) as Array<{ type: string; text: string }>; + expect(parts[0].text).toBe('x'); + expect(parts[0].text).not.toContain(MEDIA_MARKER); + }); +}); + +describe('buildUserDeltaMultimodal', () => { + it('carries the turn separator, one marker per image, and the bytes', () => { + const ctx = new MockSessionContext(); + const images = img(2); + const d = buildUserDeltaMultimodal(ctx, 'what is in these?', images); + + expect(d.sep).toEqual(ctx.getTurnSeparator()); + expect(markerCount(d.prompt)).toBe(2); + expect(d.prompt).toContain('what is in these?'); + expect(d.bitmaps).toBe(images); + }); + + it('stops at the string stage — the prompt is not tokenized here', () => { + // mtmd owns tokenization; tokenizing here would double-tokenize. + const ctx = new MockSessionContext(); + const d = buildUserDeltaMultimodal(ctx, 'q', img(1)); + expect(typeof d.prompt).toBe('string'); + }); +}); + +describe('buildToolResultDeltaMultimodal', () => { + it('marks one image per bitmap and preserves the call id', () => { + const ctx = new MockSessionContext(); + const d = buildToolResultDeltaMultimodal(ctx, '{"page":4}', 'call_7', img(1)); + + expect(markerCount(d.prompt)).toBe(1); + expect(d.prompt).toContain('call_7'); + expect(d.prompt).toContain('{\\"page\\":4}'); + }); + + it('concatenates the generation prompt onto the STRING, not as tokens', () => { + // The text builder appends tokenized generation-prompt ids. Here mtmd + // tokenizes the whole prompt, so anything appended afterwards would never + // reach it — the suffix has to be in the string. + const ctx = new MockSessionContext(); + const orig = ctx.formatChatSync.bind(ctx); + ctx.formatChatSync = (m: string, o?: never) => ({ + ...orig(m, o), + generationPrompt: '<|im_start|>assistant\n', + }); + + const d = buildToolResultDeltaMultimodal(ctx, '{}', 'c1', img(1)); + expect(d.prompt.endsWith('<|im_start|>assistant\n')).toBe(true); + }); + + it('does not double-append a generation prompt already present', () => { + const ctx = new MockSessionContext(); + const orig = ctx.formatChatSync.bind(ctx); + ctx.formatChatSync = (m: string, o?: never) => { + const r = orig(m, o); + return { ...r, prompt: r.prompt + 'GEN', generationPrompt: 'GEN' }; + }; + + const d = buildToolResultDeltaMultimodal(ctx, '{}', 'c1', img(1)); + expect(d.prompt.match(/GEN/g)?.length).toBe(1); + }); +}); + +describe('deltaCells — the quote must equal the charge', () => { + it('quotes exactly what the prefill goes on to consume', async () => { + const ctx = new MockSessionContext(); + const branch = Branch.create(ctx as never, 0, {}); + const delta = buildUserDeltaMultimodal(ctx as never, 'what is this?', img(2)); + + const quote = await deltaCells(ctx as never, delta); + const { tokensDecoded } = await branch.prefillMultimodal( + delta.prompt, delta.bitmaps, delta.sep, + ); + + // Equality, not proximity: admission spends this number against headroom + // before the prefill runs, so a low quote over-commits KV and a high one + // wastes it. What this locks is the SDK wiring — that deltaCells hands the + // cost query the SAME (sep, prompt, bitmaps) triple the prefill hands the + // store. Dropping `sep`, or passing the caller's `images` instead of the + // delta's `bitmaps`, diverges here. + // + // It does NOT prove the native count: that is MtmdSource::cells() reading + // the tokenizer before any encode, verified separately on real weights + // across 1-5 images — where image cost is non-linear (1 and 2 images both + // cost 580 cells), which is why it is measured rather than estimated. + expect(quote).toBe(tokensDecoded); + }); + + it('prices the sep tokens too, not just the prompt', async () => { + const ctx = new MockSessionContext(); + const delta = buildUserDeltaMultimodal(ctx as never, 'hi', img(1)); + expect(delta.sep.length).toBeGreaterThan(0); + const withSep = await deltaCells(ctx as never, delta); + const withoutSep = await deltaCells(ctx as never, { ...delta, sep: [] }); + expect(withSep - withoutSep).toBe(delta.sep.length); + }); +}); + +describe('prefillUserMultimodal — cold bootstrap', () => { + const session = (ctx: MockSessionContext) => + new Session({ ctx: ctx as never, store: new BranchStore(ctx as never) }); + + it('creates and promotes a trunk when there is none', async () => { + // The composer case: an image on the FIRST question, before any run has + // established a trunk. Without this the call threw on `this._trunk!`. + const ctx = new MockSessionContext(); + const s = session(ctx); + expect(s.trunk).toBeNull(); + + await s.prefillUserMultimodal('what is this?', img(1)); + + expect(s.trunk).not.toBeNull(); + expect(ctx.multimodalPrefills).toHaveLength(1); + // No separator on a fresh branch — there is no prior turn to separate + // from, matching commitTurn's cold path. + expect(ctx.multimodalPrefills[0].sepTokens[0]).toEqual([]); + }); + + it('appends to an existing trunk WITH the separator', async () => { + const ctx = new MockSessionContext(); + const s = session(ctx); + s.trunk = Branch.create(ctx as never, 0, {}); + + await s.prefillUserMultimodal('and this?', img(1)); + + expect(ctx.multimodalPrefills).toHaveLength(1); + // The warm path must separate the turn, or it runs into the previous one. + expect(ctx.multimodalPrefills[0].sepTokens[0].length).toBeGreaterThan(0); + }); + + it('puts the image on the TRUNK, so forks share its rows', async () => { + // The property the cold path exists for: agents fork from the trunk, so + // an image landed here is encoded once and attended by all of them. + const ctx = new MockSessionContext(); + const s = session(ctx); + await s.prefillUserMultimodal('what is this?', img(1)); + expect(ctx.multimodalPrefills[0].handles[0]).toBe(s.trunk!.handle); + }); +}); diff --git a/packages/sdk/test/rerank-instruction.test.ts b/packages/sdk/test/rerank-instruction.test.ts index 83d8f4b4..8b05904e 100644 --- a/packages/sdk/test/rerank-instruction.test.ts +++ b/packages/sdk/test/rerank-instruction.test.ts @@ -19,7 +19,11 @@ import { Rerank, RerankCalibrationError, RETRIEVAL_INSTRUCTION } from '@lloyal-l import type { SessionContext, RerankInstruction } from '@lloyal-labs/sdk'; import { MockSessionContext } from './MockSessionContext'; -const CUSTOM: RerankInstruction = { +// `satisfies`, not an annotation: `RerankInstruction.smokeTest` is optional, so +// annotating widens this fixture to `T | undefined` and every `{ ...CUSTOM +// .smokeTest }` below becomes a spread of a possibly-undefined. `satisfies` +// keeps the contract check AND the narrow literal type that says it is present. +const CUSTOM = { text: 'Judge whether the statement is entailed by the evidence', smokeTest: { query: 'the assessor attended on 12 March', @@ -28,7 +32,7 @@ const CUSTOM: RerankInstruction = { // The mock's default logits give a gap of 4.0, so this passes. minGap: 1.0, }, -}; +} satisfies RerankInstruction; const mock = (): MockSessionContext => new MockSessionContext(); diff --git a/scripts/verify-oci-conformance.sh b/scripts/verify-oci-conformance.sh new file mode 100755 index 00000000..17c1745d --- /dev/null +++ b/scripts/verify-oci-conformance.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# Drive `oras` against a layout OUR code wrote, and our reader against a layout +# ORAS wrote. Nothing here asserts conformance by reading our own spec notes. +# +# Why a script and not a vitest case: the whole point is that none of our code +# is in the path. `oras` is the artifact-native tool, so if it can fetch, copy +# and re-lay-out what we write, the format claim holds independently of what we +# believe about it. +# +# The fixture is GENERATED with sharp rather than committed or borrowed: this +# repo has no image fixture, and the one the manual round-trip used lives in +# another repository that CI never checks out. +# +# `skopeo` is deliberately NOT run and is not owed: it is container-image +# tooling and is entitled to reject an artifact manifest whose config is not an +# image config. +set -euo pipefail + +if ! command -v oras >/dev/null 2>&1; then + echo "oras is not installed — this check cannot run." + echo " macOS: brew install oras CI: see .github/workflows/ci.yml" + exit 1 +fi +echo "oras: $(oras version | head -1)" + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT + +echo "── building ──" +( cd "$REPO" && npx tsc -b packages/media >/dev/null ) + +echo "── writing a layout through the REAL ingress (sharp → FileAttachmentStore) ──" +# A tall image, comfortably over the ceiling we pass, so the DERIVED branch runs +# and the manifest carries both a representation and a retained source — the +# two-layer case, which is the one with something to get wrong. +node -e " + const sharp = require('sharp'); + const { createImageIngress, FileAttachmentStore } = require('$REPO/packages/media/dist/node.js'); + (async () => { + const src = await sharp({ create: { + width: 900, height: 600, channels: 3, background: { r: 30, g: 90, b: 160 }, + } }).jpeg({ quality: 92 }).toBuffer(); + const store = new FileAttachmentStore('$WORK/layout'); + const ingress = createImageIngress(store, { maxPixels: 65536 }); + const root = await ingress.ingest(new Uint8Array(src)); + require('fs').writeFileSync('$WORK/root.txt', root.digest); + console.log(' root ' + root.digest.slice(0, 26) + '… (source ' + src.length + ' B)'); + })().catch(e => { console.error(e); process.exit(1); }); +" +ROOT="$(cat "$WORK/root.txt")" +LAYOUT="$WORK/layout" + +# `:tag` after a shell variable MUST be braced. Unbraced, zsh reads `:c` as a +# history modifier and silently rewrites the path — which cost a false FAIL on +# the manual run and would mislead anyone re-running the proof by hand. +REF="${LAYOUT}:probe" + +echo +echo "1. the layout has exactly the three entries image-layout.md requires" +test -f "$LAYOUT/oci-layout" || { echo " FAILED: no oci-layout marker"; exit 1; } +test -f "$LAYOUT/index.json" || { echo " FAILED: no index.json"; exit 1; } +test -d "$LAYOUT/blobs/sha256" || { echo " FAILED: no blobs/sha256"; exit 1; } +# Every entry under blobs// must be named by its own encoded digest. +# A staging file left here would be a malformed entry a crash makes permanent. +# `ls`, not `find -printf`: the latter is GNU-only and silently degrades on +# macOS, where a developer re-running this proof by hand would get a check that +# passes without looking at anything. +BAD="$(ls "$LAYOUT/blobs/sha256")" +if echo "$BAD" | grep -qvE '^[0-9a-f]{64}$'; then + echo " FAILED: non-digest entry in blobs/sha256:"; echo "$BAD"; exit 1 +fi +echo " ok — oci-layout, index.json, blobs/sha256 with $(echo "$BAD" | wc -l | tr -d ' ') digest-named blobs" + +echo "2. oras reads our manifest, with none of our code in the path" +if ! MAN="$(oras manifest fetch --oci-layout "${LAYOUT}@${ROOT}" 2>&1)"; then + echo " FAILED: oras cannot read the manifest we wrote." + echo " oras said: $MAN" + exit 1 +fi +echo "$MAN" | grep -q '"artifactType": *"application/vnd.lloyal.attachment.v1"' \ + || { echo " FAILED: artifactType missing"; echo "$MAN"; exit 1; } +if ! LAYERS="$(echo "$MAN" | node -e " + let s=''; process.stdin.on('data',d=>s+=d).on('end',()=>{ + const m = JSON.parse(s); + if (m.schemaVersion !== 2) { console.error('schemaVersion is ' + m.schemaVersion + ', must be 2'); process.exit(1); } + if (!Array.isArray(m.layers) || m.layers.length < 1) { console.error('layers must hold at least one descriptor'); process.exit(1); } + const roles = m.layers.map(l => (l.annotations||{})['ai.lloyal.role']).join(','); + if (roles !== 'representation,source') { console.error('layer roles are [' + roles + '], expected [representation,source] — replay reads the layers BY ROLE, so an untagged layer is a source it will feed the projector, or a representation it will skip'); process.exit(1); } + console.log(m.layers.length + ' layers [' + roles + '], config ' + m.config.digest.slice(0,19) + '…'); + }); +" 2>&1)"; then + echo " FAILED: the manifest oras read is not the shape we promise." + echo " $LAYERS" + exit 1 +fi +echo " ok — $LAYERS" + +echo "3. oras computes the SAME digest we recorded" +GOT="$(oras manifest fetch --oci-layout --descriptor "${LAYOUT}@${ROOT}" 2>/dev/null \ + | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).digest))" || true)" +if [ "$GOT" != "$ROOT" ]; then + echo " FAILED: oras computes '$GOT' where we recorded '$ROOT'." + echo " The digest IS the identity — a trace referencing ours would be" + echo " unresolvable by any other tool." + exit 1 +fi +echo " ok — ${GOT:0:26}…" + +echo "4. the empty config blob EXISTS and is exactly {} — the conformance trap" +# A manifest that only NAMES the canonical empty config fails any puller, which +# fetches it like any other blob. This is the easy bug and the reason it is a +# separate check. +CFG="$(echo "$MAN" | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).config.digest))")" +if [ "$CFG" != 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a' ]; then + echo " FAILED: config digest is '$CFG', not OCI's canonical empty descriptor." + exit 1 +fi +if ! BODY="$(oras blob fetch --oci-layout --output - "${LAYOUT}@${CFG}" 2>&1)"; then + echo " FAILED: the config blob is NAMED by the manifest but is not IN the layout." + echo " This is the easy conformance bug and the reason it gets its own" + echo " check: a puller fetches the config like any other blob, so a" + echo " manifest that only names the canonical empty descriptor fails" + echo " every puller while looking correct to us." + echo " oras said: $BODY" + exit 1 +fi +if [ "$BODY" != '{}' ]; then + echo " FAILED: the config blob is '$BODY', not {}"; exit 1 +fi +echo " ok — canonical empty config, fetched as {}" + +echo "5. every layer blob retrieves by digest" +echo "$MAN" | node -e " + let s=''; process.stdin.on('data',d=>s+=d).on('end',()=> + JSON.parse(s).layers.forEach(l => console.log(l.digest + ' ' + l.size))); +" | while read -r DIG SIZE; do + N="$(oras blob fetch --oci-layout --output - "${LAYOUT}@${DIG}" 2>/dev/null | wc -c | tr -d ' ' || echo 0)" + if [ "$N" != "$SIZE" ]; then + echo " FAILED: layer $DIG retrieves $N bytes, the manifest declares $SIZE." + echo " A size that disagrees with the blob breaks any puller that" + echo " preallocates, and replay would rebuild different pixels." + exit 1 + fi +done +echo " ok — all layers retrieve at their declared size" + +echo "6. oras cp preserves the digest into a layout it lays out ITSELF" +if ! CP="$(oras cp --from-oci-layout --to-oci-layout "${LAYOUT}@${ROOT}" "${WORK}/copy:probe" 2>&1)"; then + echo " FAILED: oras could not copy our layout." + echo " $CP" + exit 1 +fi +test -f "$WORK/copy/oci-layout" || { echo " FAILED: oras wrote no oci-layout"; exit 1; } +CPD="$(oras manifest fetch --oci-layout --descriptor "$WORK/copy@${ROOT}" \ + | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).digest))")" +[ "$CPD" = "$ROOT" ] || { echo " FAILED: digest changed on copy"; exit 1; } +echo " ok — oras-written layout, digest preserved end to end" + +echo "7. OUR reader on the ORAS-written layout — the exact call replay makes" +node -e " + const { FileAttachmentStore } = require('$REPO/packages/media/dist/node.js'); + const { materialize } = require('$REPO/packages/media/dist/index.js'); + const store = new FileAttachmentStore('$WORK/copy'); + const manifest = store.getManifest('$ROOT'); + if (!manifest) throw new Error('our reader could not read the oras-written manifest'); + const out = materialize(store, [{ digest: '$ROOT', mediaType: manifest.mediaType, size: 0 }]); + if (out.bitmaps.length !== 1) throw new Error('expected one representation, got ' + out.bitmaps.length); + if (out.bitmaps[0].byteLength === 0) throw new Error('materialized an empty bitmap'); + console.log(' ok — materialize() rebuilt ' + out.bitmaps[0].byteLength + ' B from a layout oras wrote'); +" + +echo +echo "OCI CONFORMANCE VERIFIED" diff --git a/scripts/verify-packed-install.sh b/scripts/verify-packed-install.sh new file mode 100755 index 00000000..512df7a8 --- /dev/null +++ b/scripts/verify-packed-install.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# Prove @lloyal-labs/media works FROM ITS PUBLISHED ARTIFACT, not the worktree. +# +# The three claims below cannot be checked any other way. A workspace test +# resolves through symlinks to `src`/`dist` and will pass no matter what the +# manifest declares — which is exactly how `sharp` sat in `devDependencies` +# (never installed for consumers) while the README promised it was a peer +# concern, leaving every published `normalizeImage` call throwing. +# +# It also independently catches the stale-neighbour hazard this repo has hit +# six times: a packed install has no symlinks, so a package that silently +# resolved a REGISTRY copy of its neighbour fails here and nowhere else. +# +# 1. the browser-safe `.` entry imports with NO sharp present, and reaches +# neither sharp nor `node:` — STRUCTURALLY, by what it requires +# 2. the manifest tells a consumer that sharp is an optional peer +# 3. `./node` normalizes from the packed build once sharp is added +# +# Claim 1 reads the packed `dist/index.js`'s own requires rather than trusting +# that the import succeeded: in Node it would succeed either way. The `.`/`./node` +# split is what makes that check meaningful — before it, purity rested on a +# call-time `require` that any future static import would have silently undone. +set -euo pipefail + +REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CAT="${CAT_FIXTURE:-$HOME/dev/apps/lloyal-node/liblloyal/tests/fixtures/cat.jpg}" +WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT +echo "workdir: $WORK" + +echo "── building ──" +( cd "$REPO" && npx tsc -b packages/media >/dev/null ) + +# media has NO internal dependencies — that is the point of the phase-8 layout, +# and packing it alone is what proves it. A missing neighbour would fail the +# install here rather than resolving through a workspace symlink. +echo "── packing media alone (it is a dependency root) ──" +mkdir -p "$WORK/tars" +( cd "$REPO/packages/media" && npm pack --pack-destination "$WORK/tars" --silent >/dev/null ) +ls "$WORK/tars" + +echo "── clean consumer, NO sharp ──" +mkdir -p "$WORK/consumer" && cd "$WORK/consumer" +cat > package.json <<'JSON' +{ "name": "packed-consumer", "version": "1.0.0", "private": true } +JSON +npm install --silent --no-audit --no-fund "$WORK"/tars/*.tgz + +echo +echo "1. the '.' entry is browser-safe: no sharp, no node:, no internal deps" +node -e " + const m = require('@lloyal-labs/media'); + if (typeof m.sniffMediaType !== 'function') throw new Error('root entry did not load'); + if (typeof m.NullAttachmentStore !== 'function') throw new Error('root entry is incomplete'); + try { require.resolve('sharp'); throw new Error('sharp IS installed — this case proves nothing'); } + catch (e) { if (e.code !== 'MODULE_NOT_FOUND') throw e; } + + // Structural, not incidental: walk what the packed root actually requires. + const fs = require('fs'), path = require('path'); + const dir = path.dirname(require.resolve('@lloyal-labs/media')); + const seen = new Set(); + (function walk(file) { + if (seen.has(file)) return; seen.add(file); + for (const [, spec] of fs.readFileSync(file, 'utf8').matchAll(/require\\([\"']([^\"']+)[\"']\\)/g)) { + if (spec.startsWith('node:') || spec === 'sharp' || spec.startsWith('@lloyal-labs/')) { + throw new Error(path.basename(file) + ' reaches ' + spec + ' — the root entry is not browser-safe'); + } + if (spec.startsWith('.')) walk(require.resolve(path.resolve(path.dirname(file), spec))); + } + })(require.resolve('@lloyal-labs/media')); + console.log(' ok — ' + seen.size + ' modules, none reaching sharp, node: or a sibling package'); +" + +echo "2. the manifest declares sharp as an OPTIONAL PEER" +node -e " + const p = JSON.parse(require('fs').readFileSync('node_modules/@lloyal-labs/media/package.json', 'utf8')); + const peer = (p.peerDependencies||{}).sharp; + const opt = ((p.peerDependenciesMeta||{}).sharp||{}).optional; + if (!peer) throw new Error('sharp is not a peerDependency — a consumer cannot discover it'); + if (opt !== true) throw new Error('sharp peer is not marked optional'); + if ((p.devDependencies||{}).sharp && !peer) throw new Error('devDependency only: never installed for consumers'); + if (!(p.files||[]).includes('README.md')) throw new Error('README.md missing from files — the format spec never ships'); + console.log(' ok — peer '+peer+', optional, README packaged'); +" + +echo "3. './node' normalizes from the packed build once sharp is installed" +npm install --silent --no-audit --no-fund sharp@^0.35.4 +node -e " + const { normalizeImage, FileAttachmentStore } = require('@lloyal-labs/media/node'); + if (typeof FileAttachmentStore !== 'function') throw new Error('the node entry is incomplete'); + const fs = require('fs'); + const src = new Uint8Array(fs.readFileSync('$CAT')); + normalizeImage(src, { maxPixels: 65536 }).then(o => { + if (!o.derived) throw new Error('expected a derivation at this ceiling'); + if (!(o.bytes.byteLength < src.byteLength)) throw new Error('no reduction'); + console.log(' ok — '+src.byteLength+' B -> '+o.bytes.byteLength+' B, '+o.width+'x'+o.height); + }).catch(e => { console.error(' FAILED: '+e.message); process.exit(1); }); +" +echo +echo "PACKED INSTALL VERIFIED" diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 00000000..f44c27a7 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,62 @@ +{ + // Type-checks the test suites, which no other project covers: every package + // tsconfig is `include: ["src/**/*.ts"]` with `rootDir: "src"`, and vitest + // transpiles through esbuild without checking. The consequence was that a + // double could claim `implements AttachmentStore`, drift from the interface, + // and keep passing — and that a change to a shared type produced zero errors + // from the 97 test files that use it. + // + // `paths` maps every workspace package to its SOURCE, not its `dist`. That is + // load-bearing, not a convenience: tests import a mix of `../../sdk/src/X` + // and `@lloyal-labs/sdk`, and without this the two resolve to different + // declarations of the same type ("`src/BranchStore` is not assignable to + // `dist/BranchStore`"). Mapping to source gives one identity, and it also + // resolves subpath entries (`rig/node`) that `moduleResolution: "Node"` + // cannot read from an `exports` map. + "extends": "./tsconfig.base.json", + "compilerOptions": { + // Vitest transpiles with esbuild in ESM, so the tests legitimately use + // `import.meta` and top-level await; the CommonJS default inherited from + // the base config rejects both. DOM is here because several rig tests mock + // `fetch` and WebCrypto (`RequestInfo`, `CryptoKeyPair`) — web types the + // Node-only src projects rightly do not carry. + // "Preserve" (TS 5.4+) accepts all three things this program contains: + // `import.meta` and top-level await in the vitest suites, AND the + // `import x = require()` in `rig/src/resources/files.ts` that ESNext + // rejects. Source files are reachable from the tests, so this project must + // tolerate what their own projects already compile. + "module": "Preserve", + // `downlevelIteration` for the same reason: DOM brings `NodeListOf`, which + // `rig/src/sources/chunking.ts` spreads. + "downlevelIteration": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "noEmit": true, + "composite": false, + "declaration": false, + "declarationMap": false, + "baseUrl": ".", + "paths": { + "@lloyal-labs/sdk": ["packages/sdk/src/index.ts"], + "@lloyal-labs/lloyal-agents": ["packages/agents/src/index.ts"], + "@lloyal-labs/rig": ["packages/rig/src/index.ts"], + "@lloyal-labs/rig/node": ["packages/rig/src/node.ts"], + "@lloyal-labs/media": ["packages/media/src/index.ts"], + "@lloyal-labs/media/node": ["packages/media/src/node.ts"], + "@lloyal-labs/binding": ["packages/binding/src/index.ts"], + "@lloyal-labs/binding/node": ["packages/binding/src/node.ts"], + "@lloyal-labs/binding/web": ["packages/binding/src/web.ts"], + "@lloyal-labs/channel-verify": ["packages/channel-verify/src/index.ts"], + "@lloyal-labs/dev-tools": ["packages/dev-tools/src/index.ts"], + "@lloyal-labs/dev-tools/node": ["packages/dev-tools/src/node.ts"], + "@lloyal-labs/host": ["packages/host/src/index.ts"], + "@lloyal-labs/relay": ["packages/relay/src/index.ts"], + "@lloyal-labs/corpus-ability": ["packages/abilities/corpus/src/index.ts"], + "@lloyal-labs/web-ability": ["packages/abilities/web/src/index.ts"], + "@lloyal-labs/wikipedia-ability": ["packages/abilities/wikipedia/src/index.ts"] + } + }, + "include": [ + "packages/*/test/**/*.ts", + "packages/abilities/*/test/**/*.ts" + ] +} From bc79e24321971841c5f0facbeea76cb53a326ed0 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Tue, 1 Sep 2026 13:41:19 +1000 Subject: [PATCH 04/69] fix(rig): let an ability's icon reach the surface that renders it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AbilityDescriptor` declared `iconUrl` and `AbilityManifest` carried one, but the builder hardcoded `undefined` — so the field has always been dead and every surface fell back to a glyph, whether or not the ability named a mark. Read it off the manifest, which `describe` already has in hand. A distributed ability can now be recognised by its own mark rather than by its position in a list; absent still means glyph, so nothing that renders one changes. --- packages/rig/src/ability-descriptors.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/rig/src/ability-descriptors.ts b/packages/rig/src/ability-descriptors.ts index 57668107..d6479836 100644 --- a/packages/rig/src/ability-descriptors.ts +++ b/packages/rig/src/ability-descriptors.ts @@ -24,7 +24,9 @@ export interface AbilityDescriptor { title: string; /** manifest.hints?.description ?? protocol.useWhen */ description: string; - /** catalog metadata.iconUrl (apps.lloyal.ai asset) — else undefined → glyph. */ + /** `manifest.iconUrl` — an ability may name its own mark, and a catalog + * entry (apps.lloyal.ai asset) is one source of it. Absent → the surface + * falls back to a glyph. */ iconUrl?: string; /** manifest.protocol.tools — the protocol's tool-name list. */ tools: string[]; @@ -64,6 +66,7 @@ export function* buildAbilityDescriptors( type ManifestLike = { name: string; hints?: { shortName?: string; description?: string }; + iconUrl?: string; protocol: { name: string; useWhen: string; tools: readonly string[] }; configSchema?: unknown; }; @@ -77,7 +80,7 @@ function describe( name: manifest.name, title: manifest.hints?.shortName ?? manifest.protocol.name, description: manifest.hints?.description ?? manifest.protocol.useWhen, - iconUrl: undefined, + iconUrl: manifest.iconUrl, tools: [...manifest.protocol.tools], entitlements: [], configSchema: manifest.configSchema, From ad36992670a23b5d3a0bec412d1b85537ea51374 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Tue, 1 Sep 2026 19:36:30 +1000 Subject: [PATCH 05/69] alpha channel: cut 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arc's five packages take prerelease versions under the `alpha` dist-tag — media 0.2.0-alpha.0, sdk 3.2.0-alpha.0, agents 6.0.0-alpha.0 (a MAJOR: the content vocabulary left for @lloyal-labs/media), rig 5.6.0-alpha.0, dev-tools 0.5.0-alpha.0 — with exact internal pins, because semver ranges EXCLUDE prereleases and a caret would produce uninstallable dependents. Untouched siblings keep their stables; the publish loop's npm-view guard skips them. The release workflow learns two things it was missing: media joins the dist-verify, the typecheck list and the publish loop (right after channel-verify — sdk, agents and rig all depend on it), and the dist-tag now follows each package's own version, so an -alpha.N can never move `latest`. scripts/cut-alpha.mjs cuts the next set: bases come from the registry at cut time, so a stable that ships mid-arc self-corrects on the next cut. --- .github/workflows/release.yml | 24 +++++- packages/abilities/corpus/package.json | 6 +- packages/abilities/web/package.json | 4 +- packages/abilities/wikipedia/package.json | 4 +- packages/agents/package.json | 8 +- packages/dev-tools/package.json | 6 +- packages/media/package.json | 4 +- packages/rig/package.json | 10 +-- packages/sdk/package.json | 2 +- scripts/cut-alpha.mjs | 93 +++++++++++++++++++++++ 10 files changed, 135 insertions(+), 26 deletions(-) create mode 100644 scripts/cut-alpha.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40e81f39..d178bba5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,10 @@ jobs: - name: Verify dist outputs run: | + test -f packages/media/dist/index.js + test -f packages/media/dist/index.d.ts + test -f packages/media/dist/node.js + test -f packages/media/dist/node.d.ts test -f packages/sdk/dist/index.js test -f packages/sdk/dist/index.d.ts test -f packages/agents/dist/index.js @@ -63,7 +67,7 @@ jobs: test -f packages/channel-verify/dist/esm/package.json - name: Typecheck - run: npx tsc -b packages/sdk packages/agents packages/rig packages/channel-verify packages/binding packages/relay packages/host + run: npx tsc -b packages/media packages/sdk packages/agents packages/rig packages/channel-verify packages/binding packages/relay packages/host - name: Publish packages if: success() && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && !inputs.skip_publish)) @@ -73,14 +77,26 @@ jobs: # rig pointing at a version that does not exist on the registry — # breaking fresh installs until someone repaired the partial release. # It has no dependencies of its own, so it is safe at the head. - for pkg in packages/channel-verify packages/sdk packages/agents packages/rig packages/binding packages/relay packages/host packages/dev-tools; do + # media sits right after channel-verify: sdk, agents and rig all + # depend on it, so it must exist on the registry before they do. + for pkg in packages/channel-verify packages/media packages/sdk packages/agents packages/rig packages/binding packages/relay packages/host packages/dev-tools; do name=$(node -p "require('./$pkg/package.json').name") version=$(node -p "require('./$pkg/package.json').version") + # The dist-tag follows each package's OWN version: an -alpha.N + # version publishes under the alpha channel and can never move + # `latest`. Committed on an arc branch, this is what makes + # `npx lloyal-ai@alpha` possible without touching production. + case "$version" in + *-alpha*) tag=alpha ;; + *-beta*) tag=beta ;; + *-rc*) tag=rc ;; + *) tag=latest ;; + esac if npm view "$name@$version" version >/dev/null 2>&1; then echo "⏭ $name@$version already published, skipping" else - echo "Publishing $name@$version..." - npm publish --workspace "$pkg" --access public --provenance + echo "Publishing $name@$version (tag: $tag)..." + npm publish --workspace "$pkg" --access public --provenance --tag "$tag" fi done env: diff --git a/packages/abilities/corpus/package.json b/packages/abilities/corpus/package.json index bb565bcc..76373b21 100644 --- a/packages/abilities/corpus/package.json +++ b/packages/abilities/corpus/package.json @@ -25,9 +25,9 @@ "build": "tsc -b" }, "peerDependencies": { - "@lloyal-labs/lloyal-agents": "^5.3.0", - "@lloyal-labs/lloyal.node": "^3.1.1", - "@lloyal-labs/rig": "^5.0.0", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.0", + "@lloyal-labs/lloyal.node": "3.2.0-alpha.0", + "@lloyal-labs/rig": "5.6.0-alpha.0", "effection": "^4.0.2" } } diff --git a/packages/abilities/web/package.json b/packages/abilities/web/package.json index d733fc08..c07e645c 100644 --- a/packages/abilities/web/package.json +++ b/packages/abilities/web/package.json @@ -29,8 +29,8 @@ "linkedom": "^0.18.12" }, "peerDependencies": { - "@lloyal-labs/lloyal-agents": "^5.3.0", - "@lloyal-labs/rig": "^5.0.0", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.0", + "@lloyal-labs/rig": "5.6.0-alpha.0", "effection": "^4.0.2" } } diff --git a/packages/abilities/wikipedia/package.json b/packages/abilities/wikipedia/package.json index aa52a7a2..87510c5a 100644 --- a/packages/abilities/wikipedia/package.json +++ b/packages/abilities/wikipedia/package.json @@ -24,8 +24,8 @@ "build": "tsc -b" }, "peerDependencies": { - "@lloyal-labs/lloyal-agents": "^5.0.0", - "@lloyal-labs/rig": "^5.0.0", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.0", + "@lloyal-labs/rig": "5.6.0-alpha.0", "effection": "^4.0.2" } } diff --git a/packages/agents/package.json b/packages/agents/package.json index f03618a3..a2f89bd7 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -1,7 +1,7 @@ { "name": "@lloyal-labs/lloyal-agents", - "version": "5.5.1", - "description": "Multi-agent inference inside the decode loop \u2014 structured concurrency over shared KV state", + "version": "6.0.0-alpha.0", + "description": "Multi-agent inference inside the decode loop — structured concurrency over shared KV state", "main": "dist/index.js", "types": "dist/index.d.ts", "publishConfig": { @@ -31,10 +31,10 @@ "build": "tsc -b" }, "dependencies": { - "@lloyal-labs/sdk": "^3.0.0", + "@lloyal-labs/sdk": "3.2.0-alpha.0", "effection": "^4.0.2", "eta": "^4.5.1", - "@lloyal-labs/media": "^0.1.0" + "@lloyal-labs/media": "0.2.0-alpha.0" }, "files": [ "dist/", diff --git a/packages/dev-tools/package.json b/packages/dev-tools/package.json index dbb79b6f..be449a4b 100644 --- a/packages/dev-tools/package.json +++ b/packages/dev-tools/package.json @@ -1,7 +1,7 @@ { "name": "@lloyal-labs/dev-tools", - "version": "0.4.3", - "description": "The dev pane for scaffolded harnesses \u2014 timeline, sources, and settings over the event bus, gated by the runner's dev signal", + "version": "0.5.0-alpha.0", + "description": "The dev pane for scaffolded harnesses — timeline, sources, and settings over the event bus, gated by the runner's dev signal", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -52,7 +52,7 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { - "@lloyal-labs/rig": "^5.3.0", + "@lloyal-labs/rig": "5.6.0-alpha.0", "zustand": "^5.0.15" }, "peerDependencies": { diff --git a/packages/media/package.json b/packages/media/package.json index c186b71e..d6cf965e 100644 --- a/packages/media/package.json +++ b/packages/media/package.json @@ -1,7 +1,7 @@ { "name": "@lloyal-labs/media", - "version": "0.1.0", - "description": "Content addressing for a harness \u2014 an OCI layout, and the image normalizer that feeds it", + "version": "0.2.0-alpha.0", + "description": "Content addressing for a harness — an OCI layout, and the image normalizer that feeds it", "main": "dist/index.js", "types": "dist/index.d.ts", "publishConfig": { diff --git a/packages/rig/package.json b/packages/rig/package.json index 9c4497c9..3c0c8798 100644 --- a/packages/rig/package.json +++ b/packages/rig/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/rig", - "version": "5.5.0", + "version": "5.6.0-alpha.0", "description": "Retrieval-Interleaved Generation for lloyal-agents", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -41,17 +41,17 @@ }, "dependencies": { "@lloyal-labs/channel-verify": "^0.3.1", - "@lloyal-labs/lloyal-agents": "^5.0.0", - "@lloyal-labs/sdk": "^3.1.0", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.0", + "@lloyal-labs/sdk": "3.2.0-alpha.0", "@mozilla/readability": "^0.6.0", "effection": "^4.0.2", "ignore": "^7.0.5", "linkedom": "^0.18.12", "semver": "^7.8.1", - "@lloyal-labs/media": "^0.1.0" + "@lloyal-labs/media": "0.2.0-alpha.0" }, "peerDependencies": { - "@lloyal-labs/lloyal.node": "^3.1.1" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.0" }, "files": [ "dist/", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 883e96d6..075bb980 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/sdk", - "version": "3.1.0", + "version": "3.2.0-alpha.0", "description": "Backend-agnostic TypeScript SDK for the lloyal inference platform", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/scripts/cut-alpha.mjs b/scripts/cut-alpha.mjs new file mode 100644 index 00000000..08f7d0c4 --- /dev/null +++ b/scripts/cut-alpha.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** + * Cut an alpha SET for this repo's arc branch. + * + * Only the packages the arc touched get alpha versions — untouched siblings + * keep their published stables, and the release loop's npm-view guard skips + * them. Each cut package's base is `bump(latest-on-the-registry)`, so a + * stable that ships mid-arc self-corrects at the next cut; the `-alpha.N` + * suffix is the SET id, shared across every package in the cut. + * + * Versions and exact internal pins are COMMITTED on the arc branch: the set + * is recorded in git, the workspace still resolves locally for dev, and the + * merge back to main resolves them to the real stable bump. + * + * Run locally: node scripts/cut-alpha.mjs --cut 0 [--dry-run] + */ +import { execSync } from 'node:child_process'; +import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; + +const cutIdx = process.argv.indexOf('--cut'); +if (cutIdx === -1) throw new Error('required: --cut '); +const CUT = Number(process.argv[cutIdx + 1]); +const DRY = process.argv.includes('--dry-run'); + +/** What this arc touched → how far its next version moves. agents is a + * MAJOR: the arc removed 18 public exports (the content vocabulary moved + * to @lloyal-labs/media). */ +const CUTS = { + 'packages/media': 'minor', + 'packages/sdk': 'minor', + 'packages/agents': 'major', + 'packages/rig': 'minor', + 'packages/dev-tools': 'minor', +}; +/** Cross-repo deps that are ALSO being cut this arc (rig depends on the + * binding). Must match lloyal.node's own cut level. */ +const EXTERNAL = { '@lloyal-labs/lloyal.node': 'minor' }; + +const bump = (v, level) => { + const [maj, min] = v.split('.').map(Number); + return level === 'major' ? `${maj + 1}.0.0` : `${maj}.${min + 1}.0`; +}; +/** Registry base, or the local manifest's for a package npm has never seen. + * NOTE: npm cannot CREATE a package name from CI (interactive 2FA) — a + * brand-new package (media, on this arc) needs ONE manual `npm publish` + * before the first cut's workflow run can succeed. */ +const latest = (name, fallback) => { + try { + return execSync(`npm view ${name}@latest version`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + } catch { + console.log(` (${name} not on the registry yet — base ${fallback}, needs one manual first publish)`); + return fallback; + } +}; + +const alphas = {}; +for (const [dir, level] of Object.entries(CUTS)) { + const pkg = JSON.parse(readFileSync(`${dir}/package.json`, 'utf8')); + const base = pkg.version.split('-')[0]; // a prior cut's -alpha.N is not a base + alphas[pkg.name] = `${bump(latest(pkg.name, base), level)}-alpha.${CUT}`; +} +for (const [name, level] of Object.entries(EXTERNAL)) { + alphas[name] = `${bump(latest(name, '0.0.0'), level)}-alpha.${CUT}`; +} + +console.log(`cut ${CUT}${DRY ? ' (dry run)' : ''}:`); +for (const [n, v] of Object.entries(alphas)) console.log(` ${n} -> ${v}`); + +const dirs = ['packages', 'packages/abilities'].flatMap((root) => + readdirSync(root) + .map((d) => `${root}/${d}`) + .filter((d) => existsSync(`${d}/package.json`)), +); +for (const dir of dirs) { + const path = `${dir}/package.json`; + const pkg = JSON.parse(readFileSync(path, 'utf8')); + let changed = false; + if (dir in CUTS && pkg.version !== alphas[pkg.name]) { + console.log(` ${path}: version ${pkg.version} -> ${alphas[pkg.name]}`); + pkg.version = alphas[pkg.name]; + changed = true; + } + for (const field of ['dependencies', 'peerDependencies']) { + for (const dep of Object.keys(pkg[field] ?? {})) { + if (alphas[dep] && pkg[field][dep] !== alphas[dep]) { + console.log(` ${path}: ${dep} ${pkg[field][dep]} -> ${alphas[dep]} (exact)`); + pkg[field][dep] = alphas[dep]; + changed = true; + } + } + } + if (changed && !DRY) writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); +} From 31ea09cedb72ba6f4561e6a1759c9c3b9c5bef80 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Tue, 1 Sep 2026 19:56:09 +1000 Subject: [PATCH 06/69] docs(media): the README leads with the outcome, not the argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens with what becomes true when you attach an image — replay from the exact bytes, inspectable forever, a valid OCI Image Layout any registry already hosts — and adds the quickstart the page never had. Two corrections ride along: the admission section no longer claims an unsupported file "fails mid-run on a branch already in flight" (MtmdSource rejects before any decode, branch untouched — admission decides who reports it), and pass-through is now stated once, conditional on the full five-axis policy, instead of a two-axis summary the policy table below contradicted. --- packages/media/README.md | 300 ++++++++++++++++++--------------------- 1 file changed, 142 insertions(+), 158 deletions(-) diff --git a/packages/media/README.md b/packages/media/README.md index 45242c71..71b1063d 100644 --- a/packages/media/README.md +++ b/packages/media/README.md @@ -1,65 +1,82 @@ # @lloyal-labs/media -Content addressing for a harness: the **content-addressed storage format** a -run writes its media to, and the normalizer that decides which pixels are -admitted to it. - -Both live here because they are the same decision viewed from either end — what -a harness stores and what it normalizes — and the package is split by RUNTIME, -not by concept: - -| entry | holds | needs | -|---|---|---| -| `@lloyal-labs/media` | the OCI shapes, the store and ingress contracts, `materialize` | nothing — browser-safe, and a dependency root | -| `@lloyal-labs/media/node` | `FileAttachmentStore` (the layout on disk), `createImageIngress` (sharp) | `node:fs`; `sharp` as an optional peer | +Attach an image to a run and three things become true: the run **replays** from +the exact bytes the model originally saw, those bytes stay **inspectable** for +as long as the project exists, and the store holding them is a **valid OCI +Image Layout** — `oras` pushes a run's media to any registry with none of this +package's code in the path. The artifact infrastructure you already operate +can host your model's inputs. + +The mechanism is content addressing at the point of admission. A trace records +a reference for every image, never the pixels, so the store is what makes a +media-bearing run reproducible — it is part of the run's correctness and is +always on. Everything is addressed by digest, so attaching the same file twice +writes nothing new. + +## Quickstart + +```bash +npm i @lloyal-labs/media +npm i sharp # only in a process that accepts image uploads — see below +``` -`.` cannot import `./node`, which is what keeps the first row true. +```ts +import { materialize } from '@lloyal-labs/media'; +import { FileAttachmentStore, createImageIngress } from '@lloyal-labs/media/node'; -**Three axes decide what belongs where**, and re-deriving them is how this ends -up merged again: +const store = new FileAttachmentStore('media'); // a valid OCI Image Layout +const ingress = createImageIngress(store); // normalize → address → commit -- **format** — how bytes are addressed and laid out (OCI). This package. - Stable, published, nothing image-specific in it. -- **policy** — where a project puts them and what is gated. - `createProjectMediaStore` in `@lloyal-labs/rig`, plus the template. -- **stream** — what fits in an event and is therefore ALREADY in the trace. +const attachment = await ingress.ingest(bytes); // one root descriptor +const { bitmaps } = materialize(store, [attachment]); // the exact admitted pixels +``` -The boundary is **"too big for the event stream"**, not "media": text turns and -tool results ride the trace verbatim, while a 180 KB image would stringify to -~700k characters of JSON digits — which is why it is a marker plus a digest. +`ingest` admits bytes — converting, downscaling and stripping as the admission +policy below requires — commits them, and returns a root descriptor small +enough to ride any wire. `materialize` is the inverse: given roots, it returns +the exact bytes to hand the projector (the model's vision encoder). Replay is +`materialize` called later. -**"Media" means two things here**, and both are load-bearing. OCI's sense is -*typed bytes* (`mediaType`, `sniffMediaType`, the `media/` directory); the -modality sense is *pictures and sound* (what a projector decodes). The format -half is indifferent to modality — a video or a rasterized page is the same -manifest graph as an image. +In a lloyal harness you rarely wire this yourself: `@lloyal-labs/rig` +constructs the project store, and the runtime carries attachments through +trace and replay. This package is the format and the gate. ---- +## Two entry points, split by runtime -## Why content-addressed at all +| entry | holds | needs | +|---|---|---| +| `@lloyal-labs/media` | the OCI shapes, the store and ingress contracts, `materialize` | nothing — browser-safe, zero dependencies | +| `@lloyal-labs/media/node` | `FileAttachmentStore` (the layout on disk), `createImageIngress` (sharp) | `node:fs`; `sharp` as an optional peer | -A trace records the media **marker**, never the pixels. So a media-bearing run -cannot be replayed from the trace alone, and the rule that falls out is: +The root entry never reaches `node:` or `sharp` — enforced by +`npm run verify:packed`, which walks the packed artifact's require graph on +every push. `sharp` is required at call time, not module load, so a process +that never accepts an image pays nothing, and one that does gets an error +naming the install. -> Anything that reaches model state must be addressable, or the run is not -> replayable. +Where things live: the **format** — how bytes are addressed and laid out — is +this package. The **policy** — where a project keeps its store — is +`createProjectMediaStore` in `@lloyal-labs/rig`. The trace carries only +references, because a 180 KB image would stringify to ~700k characters of JSON +digits in an event stream sized for text. -That makes the content store a **correctness requirement, not telemetry** — -which is why it is never gated behind a dev flag, and why it lives in the -project rather than beside a trace file. +One vocabulary note: `mediaType` is OCI's word for *typed bytes* and applies +to every blob, text included; "media" elsewhere in this README means the +modality — pictures, and eventually sound. The format half is indifferent to +modality: a video or a rasterized page is the same manifest graph as an image. -## The format is the OCI Image Layout +## The store is an OCI Image Layout -Not "OCI-inspired". A store directory is a **valid OCI Image Layout**, so -`oras` and `crane` can push it to any registry with none of our code in the -path — which is what makes distribution a later, replaceable adapter rather -than a rewrite. +``` +/ + oci-layout {"imageLayoutVersion":"1.0.0"} + index.json image index — the entry point + blobs/sha256/<64 hex> every blob, addressed by content +``` -**You do not have to take that on trust, and neither does a reviewer.** -`npm run verify:oci` builds a layout through the real ingress and drives `oras` -against it with none of our code involved, then drives our reader against a -layout `oras` itself wrote. It runs in CI on every push (job -`oci-conformance`). Seven checks: +`npm run verify:oci` is the receipt. It builds a layout through the real +ingress, drives `oras` against it with none of our code involved, then drives +our reader against a layout `oras` itself wrote — in CI, on every push: | | | |---|---| @@ -72,17 +89,10 @@ layout `oras` itself wrote. It runs in CI on every push (job | 7 | our `materialize()` — the exact call replay makes — rebuilds from that layout | Check 4 has its own line because it is the easy conformance bug: a puller -fetches the config like any other blob, so a manifest that only NAMES OCI's +fetches the config like any other blob, so a manifest that only *names* OCI's canonical empty descriptor looks correct locally and fails everywhere else. -``` -/ - oci-layout {"imageLayoutVersion":"1.0.0"} - index.json image index — the entry point - blobs/sha256/<64 hex> every blob, addressed by content -``` - -Specs this conforms to: +Specs, per requirement: | | | |---|---| @@ -91,14 +101,13 @@ Specs this conforms to: | [manifest.md](https://github.com/opencontainers/image-spec/blob/main/manifest.md) | `schemaVersion: 2`, required `config`, `layers` with ≥1 entry, `artifactType` | | [OCI Distribution](https://github.com/opencontainers/distribution-spec) | not implemented — see *Deferred* | -## An attachment is a manifest, never a blob - -This is the load-bearing decision, and the one that is expensive to retrofit. +## One manifest shape, from image to video to live capture -An image today is one representation and perhaps a source. A video is a source -plus N sampled frames. A live capture is frames with **no** source. Because the -fold and the replay path hold a pointer to a *manifest*, each of those is an -additive change to this file and invisible above it. +An attachment references a **manifest**, and the manifest is where modality +lives. An image is one representation and perhaps a source. A video is a +source plus N sampled frames. A live capture is frames with no source. Each of +those is the same graph, so extending to a new modality changes this package +and nothing above it. ```jsonc { @@ -113,7 +122,7 @@ additive change to this file and invisible above it. "layers": [ { "mediaType": "image/jpeg", "digest": "sha256:…", "size": 41022, "annotations": { - "ai.lloyal.role": "representation", // what entered the cache + "ai.lloyal.role": "representation", // what the model saw "ai.lloyal.derive.maxPixels": "4194304", "ai.lloyal.derive.quality": "82" } }, @@ -123,126 +132,101 @@ additive change to this file and invisible above it. } ``` -### The `config` slot is not permanently empty - -An image has nothing to say beyond its layers, so its manifest carries OCI's -canonical empty blob. Timed media will not: a video needs a timeline — -timestamps, track descriptors, the sampling policy, frame-to-audio -correspondence — and [annotations are `map`](https://github.com/opencontainers/image-spec/blob/main/annotations.md), -so encoding that as JSON inside an annotation would be unvalidatable string -soup. A typed config blob is what OCI provides the slot for. - -`putAttachment({ config })` already accepts one. A reader branches on -`config.mediaType`, so introducing `application/vnd.lloyal.attachment.config.v1+json` -later is additive — existing image manifests keep the empty descriptor and stay -valid. +The `config` slot is empty for an image, which has nothing to say beyond its +layers. Timed media will use it: a timeline — timestamps, track descriptors, +sampling policy — is typed structured data, and +[annotations are `map`](https://github.com/opencontainers/image-spec/blob/main/annotations.md), +so the config blob is where it belongs. `putAttachment({ config })` already +accepts one, and a reader branches on `config.mediaType`, so introducing a +typed config later leaves every existing manifest valid. ### Annotations we define -`org.opencontainers.*` is reserved by the spec, so ours are reverse-DNS under +`org.opencontainers.*` is reserved by the spec; ours are reverse-DNS under `ai.lloyal`. | key | meaning | |---|---| -| `ai.lloyal.role` | `representation` (entered the cache) or `source` (as supplied) | +| `ai.lloyal.role` | `representation` (what the model saw) or `source` (as supplied) | | `ai.lloyal.derive.*` | the parameters a representation was derived **under** | -**`ai.lloyal.derive.*` is a correctness requirement, not provenance.** -Normalization is parameterized — pixel ceiling, quality, the projector's own -token budget — so one source under two settings yields different pixels and -therefore different KV. Addressing the *derived* bytes and recording what -derived them is what stops a replay under changed config from silently -rebuilding a different cache state. - -Retaining a source is optional and meaningful: it is what permits -re-derivation later — a better sampler, or a model that reads video natively. -Omitting it is a legitimate choice for a large original. - -## Normalization - -`normalizeImage(bytes, opts)` guarantees two things the projector otherwise -enforces too late: - -- **Format** — the result is one of jpeg/png/bmp/gif. A file picker's `accept` - is advisory (drag-and-drop and paste bypass it), so without this an - unsupported file fails inside the decoder mid-run, on a branch already in - flight. -- **Size** — anything above the pixel ceiling is downscaled here rather than by - the projector, which would do it anyway *after* the bytes crossed a socket - and were decoded. The model sees the same pixels either way. - -It re-encodes only when it must, so an image already in an accepted format and -within the ceiling comes back untouched and is never degraded twice. - -### What normalization buys — and what it does not - -It buys **wire bytes** (at the default ceiling, ~73% on a large photo), **format -conformance**, and **decoder work**. It does **not** buy KV. At the default the -ceiling is the projector's own, so normalizing performs the downscale the -projector would have performed anyway, earlier — the model sees the same pixels -and the same cell count either way. Below the default it does change cells, but -that is a fidelity decision, not an optimization. +Normalization is parameterized — pixel ceiling, quality — so one source under +two settings yields different pixels. Addressing the *derived* bytes and +recording what derived them is what keeps a replay exact after the +configuration changes. Retaining the source is what permits re-deriving later +— a better sampler, or a model that reads the original natively — and +omitting it is a legitimate choice for a large original. -### The admission policy +## Admission -Byte-identical pass-through is permitted only when ALL of these hold: +`ingest` (and `normalizeImage` underneath it) applies one policy. +Byte-identical pass-through happens only when ALL of these hold: | | | |---|---| | format | one the projector decodes | -| size | under the pixel ceiling | -| dimensions | known — read from the header when the decoder cannot read the file at all | +| size | under the pixel ceiling (default 4,194,304 px — the projector's own) | +| dimensions | known — read from the header when sharp cannot decode the file | | EXIF orientation | identity (`1`) | | colour | no ICC profile, or an sRGB one | -Anything else is **derived**, and the original is retained as the `source` -layer. The last two are not precautionary. The projector loads through -`stb_image`, which contains no EXIF handling and ignores ICC entirely — so a -tag left on a pass-through is a tag nobody downstream reads, and a portrait -phone photo small enough to skip the ceiling would reach the model sideways -with nothing left to say so. Size was never what made that safe. +Anything else is **derived** — re-encoded as JPEG, downscaled to the ceiling, +orientation applied to the pixels, profile stripped — with the original +retained as the `source` layer. The result is always `jpeg`/`png`/`gif`/`bmp`. +An image that passes all five comes back untouched and is never degraded +twice. + +The last two axes exist because the projector loads through `stb_image`, which +has no EXIF handling and ignores ICC entirely. A tag left on a pass-through is +a tag nothing downstream reads — so a portrait phone photo small enough to +skip the ceiling would reach the model sideways. Orientation is therefore +applied to pixels at admission, whatever the size. + +What admission buys: **wire bytes** (~73% on a large photo at the default +ceiling), **format conversion** (webp/heic/tiff arrive from real users; the +projector reads none of them), and failures that happen **at the door, with +the file named** — before anything is committed or any branch of the run has +state to lose. At the default ceiling it does not change what the model sees: +the ceiling is the projector's own, so admission performs the projector's +downscale earlier. Below the default the ceiling becomes a fidelity dial. + +**Known limit:** a non-sRGB profile forces derivation and derivation strips +the profile, so every admitted representation carries one consistent +interpretation — but the pixels are not converted. sharp/libvips performs no +ICC transform (measured), and neither does anything downstream. + +Admission is also where content is inspected **before** it is addressed, +which is why images need no staging area — the payload fits in memory. Video +is exactly where that stops being true (see *Deferred*). + +## Two invariants -**Known limit:** a non-sRGB profile forces derivation, and derivation strips -the profile, so every admitted representation ends up with one consistent -interpretation. The pixels are not converted — sharp/libvips performs no ICC -transform (measured). What this removes is the asymmetry, where colour handling -depended on whether an image happened to exceed the ceiling. +**`index.json` is a catalogue, never the runtime authority.** Resolution goes +straight to `blobs//`; nothing on the replay path reads +the index. A lost concurrent index update can hide an attachment from OCI +tooling — it can never invalidate a recorded run. -Normalization is also where content is **inspected before it is addressed**, -which is why images need no separate staging area — the payload fits in memory. -Video is exactly where that stops being true. +**Write order is blobs → manifest → index.** A crash can leave orphan blobs, +which are harmless and unreferenced. It can never leave a committed manifest +pointing at content that is not there. -## Deferred, and why none of it is foreclosed +## Deferred | | | |---|---| | **OCI Distribution** | The layout is already pushable by the mature Go CLIs. A client of our own waits until content must move between placements. | -| **Resumable ingress** (tus) | Appears when a payload outgrows memory — as an adapter in front of this store, not a change to it. Note the trust boundary: untrusted bytes stage, get inspected, and only then are admitted. | +| **Resumable ingress** (tus) | Appears when a payload outgrows memory — as an adapter in front of this store, not a change to it. Untrusted bytes stage, get inspected, and only then are admitted. | | **Video derivation** | The manifest already has the slot: source + N frame representations. What is missing is a decoder, and that decision carries real licensing and codec-patent weight. | -| **Live capture** | The *locator* is not addressable; every bounded frame that reaches model state still is. Attachments are already per-prefill rather than per-run, so a live run is many prefills — no growing manifest. | -| **Reachability GC** | Nothing here deletes. Deletion needs refcounting across briefs that may share a digest. | - -## Two invariants worth stating explicitly - -**`index.json` is a catalogue, never the runtime authority.** Resolution goes -straight to `blobs//`; nothing on the replay path reads the -index. A lost concurrent index update can hide an attachment from OCI tooling — -it can never invalidate a recorded run. - -**Write order is blobs → manifest → index.** A crash can leave orphan blobs, -which are harmless and unreferenced. It can never leave a committed manifest -pointing at content that is not there. +| **Live capture** | The *locator* is not addressable; every bounded frame that reaches the model still is. Attachments are per-prefill rather than per-run, so a live run is many prefills — no growing manifest. | +| **Reachability GC** | Nothing here deletes. Deletion needs refcounting across runs that may share a digest. | ## Known limitations -- **`index.json` is a mutable shared root**, updated read-modify-write. Writes - are synchronous, so concurrent Sessions inside one host process serialize - safely; two *processes* writing one layout can lose an index entry. Blobs are - unaffected — content-addressed, written temp-then-rename — so the loss is - discoverability by other OCI tooling, never replay. -- **`skopeo` is untested, and is not owed.** It is container-image tooling and - is entitled to reject an artifact manifest whose config is not an image - config. `oras` is the artifact-native tool and is the one that settles this. -- **`sharp` is a peer concern.** `normalizeImage` requires it at call time - rather than importing it at module load, so a harness that never accepts an - image pays nothing and one that does gets a message naming the install. +- **`index.json` is a mutable shared root**, updated read-modify-write. + Writes are synchronous, so concurrent sessions inside one process serialize + safely; two *processes* writing one layout can lose an index entry — a loss + of discoverability, bounded by the first invariant above. Blobs are + unaffected: content-addressed, written temp-then-rename. +- **`skopeo` is untested.** It is container-image tooling and is entitled to + reject an artifact manifest whose config is not an image config. `oras` is + the artifact-native tool and is the one the conformance suite drives. From 3f8d4806648d23f9d43affa77ffd21ebad66c951 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Tue, 1 Sep 2026 21:32:21 +1000 Subject: [PATCH 07/69] review: fourteen findings, each source-verified before it was fixed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness, each with the test that pins it: - a failed multimodal prefill now prunes and clears the Session trunk (warm) and the never-promoted branch (cold) instead of leaving poisoned KV installed or leaking the slot - a literal media marker in user/system/tool text is defanged at the delta builders — the native splitter would count it and desynchronize markers from bitmaps - takeToolMedia strips a MALFORMED media channel instead of serializing byte indices onto the token rail - the spine seed carries its attachment roots WRITE-AHEAD, and extractSpineSeed falls back to them — a spine whose prefill failed is exactly the one replay must rebuild, and it had no roots to rebuild from - probe branch:prefill events are buffered until the batched dispatch lands, keeping the success-only event contract - asAttachment validates the complete untrusted-JSON shape; getManifest validates every layer descriptor instead of crashing representationsOf on a corrupt manifest - one upload deadline now spans body transfer AND ingress via AbortSignal - a custom model path no longer infers a catalog projector for a model that is not running Surface honesty: NoContentIngress keeps the full contract signature; verify:packed generates its fixture instead of reaching into a sibling checkout; the range control tells assistive tech the value, not the index; two stale tokenCount cross-references; the agents README media example now shows the store + ingress installs it actually needs. --- packages/agents/README.md | 9 ++- packages/agents/src/Tool.ts | 10 ++- packages/agents/src/agent-pool.ts | 13 +++- packages/agents/src/replay.ts | 7 +- packages/agents/src/spine.ts | 7 ++ packages/agents/src/trace-types.ts | 10 ++- packages/agents/test/agent-pool.test.ts | 47 ++++++++++++++ packages/agents/test/attachments.test.ts | 19 ++++++ packages/agents/test/tool-media.test.ts | 13 ++++ packages/dev-tools/src/react.tsx | 1 + packages/media/src/attachment.ts | 14 ++-- packages/media/src/file-store.ts | 17 ++++- packages/media/src/ingress.ts | 5 +- packages/media/test/attachment.test.ts | 45 +++++++++++++ packages/media/test/file-store.test.ts | 30 +++++++++ packages/rig/src/content-routes.ts | 15 ++++- packages/rig/src/models.ts | 8 ++- packages/rig/test/content-routes.test.ts | 24 +++++++ packages/sdk/src/Session.ts | 30 +++++++-- packages/sdk/src/deltas.ts | 18 ++++-- packages/sdk/test/deltas-multimodal.test.ts | 71 +++++++++++++++++++++ scripts/verify-packed-install.sh | 13 +++- 22 files changed, 396 insertions(+), 30 deletions(-) create mode 100644 packages/media/test/attachment.test.ts diff --git a/packages/agents/README.md b/packages/agents/README.md index 03ab3a28..b24f0b8d 100644 --- a/packages/agents/README.md +++ b/packages/agents/README.md @@ -92,9 +92,16 @@ yield* withSpine( `withSpine` creates the spine branch, passes it to the body, and guarantees cleanup via `try/finally` — the spine cannot leak out of the block. Effection enforces the lifetime. -Images share the same way. Pass `bitmaps` (with a context created with `mmprojPath`) and the spine header decodes them once — one media marker per image — into the shared prefix: +Images share the same way. Media takes two installs beside `mmprojPath`: a content store (an image that reaches KV must be addressable, or the run cannot replay) and an ingress to normalize and commit it — the defaults refuse media so an unaddressable run fails before any KV moves. With those in place, the spine header decodes the images once — one media marker per image — into the shared prefix: ```typescript +import { initAgents, withSpine, Ingress } from '@lloyal-labs/lloyal-agents'; +import { FileAttachmentStore, createImageIngress } from '@lloyal-labs/media/node'; + +const store = new FileAttachmentStore('media'); +const handle = yield* initAgents(ctx, { attachmentStore: store }); +yield* Ingress.set(createImageIngress(store)); + yield* withSpine( { systemPrompt: PLAYBOOKS, tools, bitmaps: [screenshot] }, function* (spine) { /* every agent forked from spine attends the image */ }, diff --git a/packages/agents/src/Tool.ts b/packages/agents/src/Tool.ts index 83b8309d..4e5100c2 100644 --- a/packages/agents/src/Tool.ts +++ b/packages/agents/src/Tool.ts @@ -227,10 +227,16 @@ export function takeToolMedia( if (!result || typeof result !== 'object' || Array.isArray(result)) { return { media: [], result }; } + if (!(TOOL_MEDIA_KEY in result)) return { media: [], result }; const { [TOOL_MEDIA_KEY]: raw, ...rest } = result as Record; - if (!Array.isArray(raw)) return { media: [], result }; + // The reserved key never survives into the serialized result, even when its + // value is malformed — returning the original would JSON-encode byte + // indices onto the token rail, the exact failure this helper exists to + // prevent. An invalid value is simply zero media entries. return { - media: raw.filter((b): b is Uint8Array => b instanceof Uint8Array), + media: Array.isArray(raw) + ? raw.filter((b): b is Uint8Array => b instanceof Uint8Array) + : [], result: rest, }; } diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index 2e493a9d..45ffcf02 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -1414,19 +1414,26 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0) { yield* call(() => store.prefill(probePairs)); + // Success-only, like every branch:prefill: written after the + // batched dispatch landed, so a rejected prefill leaves no event + // claiming cells that never moved. + for (const m of probeMeta) { + tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), + type: 'branch:prefill', branchHandle: m.id, + cells: m.cells, role: 'probe', probeText: m.probeText }); + } } // Re-activate. An `extracting` agent (parallel recovery, queued by diff --git a/packages/agents/src/replay.ts b/packages/agents/src/replay.ts index 6b323ec5..bdfa84b4 100644 --- a/packages/agents/src/replay.ts +++ b/packages/agents/src/replay.ts @@ -64,10 +64,15 @@ export function extractSpineSeed(events: TraceEvent[]): BranchCheckpoint { e.attachments !== undefined, ); + // Prefer the success-only copy on `branch:prefill`; fall back to the + // seed's write-ahead roots. A spine whose multimodal prefill FAILED has + // only the fallback — and that failure is exactly when replay is the only + // way back, so refusing it for want of roots would defeat the write-ahead. + const roots = header?.attachments ?? seed.attachments; return { seedPrompt: seed.promptText, turns: [], - ...(header?.attachments ? { seedAttachments: header.attachments } : {}), + ...(roots && roots.length > 0 ? { seedAttachments: roots } : {}), }; } diff --git a/packages/agents/src/spine.ts b/packages/agents/src/spine.ts index fe7a9ea1..60734fcb 100644 --- a/packages/agents/src/spine.ts +++ b/packages/agents/src/spine.ts @@ -247,6 +247,13 @@ export function* withSpine( type: "prompt:format", promptText: formatted.prompt, tokenCount, + // Roots ride the seed WRITE-AHEAD: the barrier committed the content + // before any prefill, so a failed multimodal prefill still leaves a + // seed that replay can rebuild from. `branch:prefill` below keeps + // the success-only copy. + ...(prepared.attachments.length > 0 + ? { attachments: prepared.attachments } + : {}), messages, tools: opts.tools && opts.tools.length > 0 ? createToolkit(opts.tools).toolsJson diff --git a/packages/agents/src/trace-types.ts b/packages/agents/src/trace-types.ts index f4656ba2..14c2c00d 100644 --- a/packages/agents/src/trace-types.ts +++ b/packages/agents/src/trace-types.ts @@ -51,9 +51,15 @@ export type TraceEvent = * prefill it seeds. Absent on the embedding rail: mtmd owns * tokenization there, and this event is written write-ahead so a * failed prefill still leaves something to replay from. The cost that - * actually landed is `branch:prefill.tokenCount`, which is written + * actually landed is `branch:prefill.cells`, which is written * only after the KV moved. */ tokenCount?: number; + /** Roots for the images this seed's markers stand for, in marker + * order — written WRITE-AHEAD like the event itself, which is the + * point: the barrier commits content before any prefill, so a + * prefill that then fails still leaves a seed replay can rebuild + * from. The success-only copy rides `branch:prefill.attachments`. */ + attachments?: readonly Attachment[]; messages: string; tools?: string; grammar?: string; @@ -99,7 +105,7 @@ export type TraceEvent = * A model that pairs images temporally charges the same cells for two * as for one (measured on Qwen3.5: 1 and 2 images both cost 580 cells, * 3 and 4 both cost 1142), so a per-image share would be a fiction. - * `tokenCount` above is the whole prefill's real cost. + * `cells` above is the whole prefill's real cost. * * `readonly`, matching `PreparedContent.attachments` — every value that * reaches this field comes from there, and the two disagreeing was the diff --git a/packages/agents/test/agent-pool.test.ts b/packages/agents/test/agent-pool.test.ts index 69f05f37..993f2d44 100644 --- a/packages/agents/test/agent-pool.test.ts +++ b/packages/agents/test/agent-pool.test.ts @@ -1604,6 +1604,53 @@ describe('no-tool agent seams', () => { }); }); +describe('probe prefill — buffered emission still writes on success', () => { + // The probe's `branch:prefill` is success-only: buffered through the + // batched dispatch and written after it lands. This pins the success + // half — the event survives the buffering with its cells and text. + // (The failure half is structural: the writes are lexically after the + // `yield* call(...)`, so a rejected dispatch cannot reach them.) + class ProbingTool extends SpyTool { + override probe(): string { return 'probe reflection'; } + } + + const probePolicy = () => stubPolicy({ + shouldExit: () => false, + onProduced: (_a, parsed) => { + if (parsed.toolCalls.length > 0) return { type: 'tool_call', tc: parsed.toolCalls[0] }; + if (parsed.content) return { type: 'free_text_return', content: parsed.content }; + return { type: 'idle', reason: 'free_text_stop' }; + }, + }); + + it('emits branch:prefill role=probe after the dispatch, with the probe text', async () => { + const tools = new Map(); + tools.set('web_search', new ProbingTool()); + + const { trace } = await runPool({ + forkTokenQueues: [[1, STOP]], + // Keyed off the RAW text, not a call counter: the pool parses partials + // during produce, so counting calls hands the tool call to a partial + // parse and the final parse ends the turn without it. Turn 1 produced + // token 1 ('t1'); turn 2 produced nothing. + parseChatOutputFn: (raw) => + raw.includes('t1') + ? { content: '', reasoningContent: '', toolCalls: [{ name: 'web_search', arguments: '{}', id: 'c1' }] } + : { content: 'done', reasoningContent: '', toolCalls: [] }, + policy: probePolicy(), + tools, + trace: true, + }); + + const probes = trace.events.filter( + (e) => e.type === 'branch:prefill' && e.role === 'probe', + ); + expect(probes).toHaveLength(1); + expect((probes[0] as { probeText?: string }).probeText).toBe('probe reflection'); + expect((probes[0] as { cells: number }).cells).toBeGreaterThan(0); + }); +}); + // ── Group 8: transient tool failure — park + retry (ToolRetryError) ── // A tool throwing ToolRetryError parks its agent (awaiting_tool, skipped by // PRODUCE — no turns/tokens/KV) and re-executes after the delay. Strategy diff --git a/packages/agents/test/attachments.test.ts b/packages/agents/test/attachments.test.ts index 07c01b64..abf74f08 100644 --- a/packages/agents/test/attachments.test.ts +++ b/packages/agents/test/attachments.test.ts @@ -88,6 +88,25 @@ describe('extractSpineSeed', () => { const events: TraceEvent[] = [seedEvent(1, 7, 'text only'), headerEvent(42, 'other')]; expect(extractSpineSeed(events).seedAttachments).toBeUndefined(); }); + + it('falls back to the seed’s write-ahead roots when the prefill never landed', () => { + // A spine whose multimodal prefill FAILED has a seed but no + // `branch:prefill` — and that failure is exactly when replay is the only + // way back. The barrier committed the content before the prefill, so the + // write-ahead roots are real. + const events: TraceEvent[] = [ + { ...seedEvent(1, 7, `hi ${MARKER}`), attachments: [attachmentRef('d9')] }, + ]; + expect(extractSpineSeed(events).seedAttachments).toEqual([attachmentRef('d9')]); + }); + + it('prefers the success-only copy over the write-ahead one', () => { + const events: TraceEvent[] = [ + { ...seedEvent(1, 7, `hi ${MARKER}`), attachments: [attachmentRef('ahead')] }, + headerEvent(7, 'landed'), + ]; + expect(extractSpineSeed(events).seedAttachments).toEqual([attachmentRef('landed')]); + }); }); describe('reconstructBranch', () => { diff --git a/packages/agents/test/tool-media.test.ts b/packages/agents/test/tool-media.test.ts index 0a184be4..1b70be11 100644 --- a/packages/agents/test/tool-media.test.ts +++ b/packages/agents/test/tool-media.test.ts @@ -49,4 +49,17 @@ describe('takeToolMedia', () => { expect(takeToolMedia(r)).toEqual({ media: [], result: r }); } }); + + it('strips a MALFORMED channel rather than serializing it', () => { + // `_images: Uint8Array` (not an array of them) used to return the + // original object — JSON-encoding every byte index onto the token rail, + // the exact failure this helper exists to prevent. The reserved key + // never survives; an invalid value is zero media entries. + for (const bad of [PNG_BYTES, 'nope', 42, { 0: 1 }]) { + const { media, result } = takeToolMedia({ page: 'p1', [TOOL_MEDIA_KEY]: bad }); + expect(media).toEqual([]); + expect(result).toEqual({ page: 'p1' }); + expect(Object.keys(result as object)).not.toContain(TOOL_MEDIA_KEY); + } + }); }); diff --git a/packages/dev-tools/src/react.tsx b/packages/dev-tools/src/react.tsx index 7fbba605..e86a6853 100644 --- a/packages/dev-tools/src/react.tsx +++ b/packages/dev-tools/src/react.tsx @@ -1866,6 +1866,7 @@ function SteppedSlider({ ctl, value, onSelect, send }: { setDragging(Number(e.target.value))} onPointerUp={(e) => commit(Number((e.target as HTMLInputElement).value))} onKeyUp={(e) => commit(Number((e.target as HTMLInputElement).value))} diff --git a/packages/media/src/attachment.ts b/packages/media/src/attachment.ts index 698f67b4..aa344ba3 100644 --- a/packages/media/src/attachment.ts +++ b/packages/media/src/attachment.ts @@ -139,8 +139,10 @@ export type Attachment = Descriptor & { readonly [ROOT]: true }; * a command. That descriptor arrives as JSON from a client, so it is a CLAIM * about content, not a fact about it. * - * Checks only what a descriptor can be judged on by itself: a well-formed - * digest, and a media type that says it points at a manifest. Whether the + * Checks the full shape a descriptor can be judged on by itself — the input + * is parsed JSON, not a `Descriptor` anyone typed: an object, a well-formed + * digest, a media type that says it points at a manifest, and a sane size. + * Whether the * manifest is actually THERE is not a question a type can answer — * `materialize` asks the store and throws if it is not, which is the check * that matters and the one that cannot be forged. A digest is identity, never @@ -148,8 +150,12 @@ export type Attachment = Descriptor & { readonly [ROOT]: true }; * * @category Media */ -export function asAttachment(d: Descriptor): Attachment | null { - return DIGEST_PATTERN.test(d.digest) && d.mediaType === MANIFEST_TYPE +export function asAttachment(d: unknown): Attachment | null { + if (typeof d !== 'object' || d === null) return null; + const { digest, mediaType, size } = d as Record; + return typeof digest === 'string' && DIGEST_PATTERN.test(digest) && + mediaType === MANIFEST_TYPE && + typeof size === 'number' && Number.isSafeInteger(size) && size >= 0 ? (d as Attachment) : null; } diff --git a/packages/media/src/file-store.ts b/packages/media/src/file-store.ts index 4ec4d172..5ff878cc 100644 --- a/packages/media/src/file-store.ts +++ b/packages/media/src/file-store.ts @@ -165,8 +165,21 @@ export class FileAttachmentStore implements AttachmentStore { try { const parsed = JSON.parse(new TextDecoder().decode(bytes)) as AttachmentManifest; // Check the artifact type rather than guessing: the version in it exists - // to let a future build refuse a shape it does not understand. - return parsed?.artifactType === ATTACHMENT_ARTIFACT_TYPE && Array.isArray(parsed.layers) + // to let a future build refuse a shape it does not understand. And + // validate every layer as a full descriptor — a corrupt or hand-made + // manifest (`layers: [null]`, a missing digest) must be refused HERE, + // not crash `representationsOf` or hand malformed descriptors to the + // HTTP routes. Role semantics stay as documented: a layer without a + // role annotation is a representation. + const layerOk = (l: unknown): boolean => { + if (typeof l !== 'object' || l === null) return false; + const { digest: d, mediaType, size } = l as Record; + return typeof d === 'string' && DIGEST_PATTERN.test(d) && + typeof mediaType === 'string' && + typeof size === 'number' && Number.isSafeInteger(size) && size >= 0; + }; + return parsed?.artifactType === ATTACHMENT_ARTIFACT_TYPE && + Array.isArray(parsed.layers) && parsed.layers.every(layerOk) ? parsed : null; } catch { diff --git a/packages/media/src/ingress.ts b/packages/media/src/ingress.ts index db3620d2..fee6890c 100644 --- a/packages/media/src/ingress.ts +++ b/packages/media/src/ingress.ts @@ -117,7 +117,10 @@ export interface ContentIngress { * @category Media */ export class NoContentIngress implements ContentIngress { - ingest(): Promise { + // Full contract signature, unused: a caller holding the CONCRETE type can + // still write `ingress.ingest(bytes)` — the same reason + // NullAttachmentStore keeps full signatures in store.ts. + ingest(_bytes?: Uint8Array, _signal?: AbortSignal): Promise { return Promise.reject(new Error( 'No content ingress installed, so this media cannot be normalized or ' + 'addressed — and unaddressed media makes the run unreplayable. ' + diff --git a/packages/media/test/attachment.test.ts b/packages/media/test/attachment.test.ts new file mode 100644 index 00000000..2e0249e9 --- /dev/null +++ b/packages/media/test/attachment.test.ts @@ -0,0 +1,45 @@ +/** + * `asAttachment` — the untrusted JSON boundary. + * + * The input arrives as parsed JSON from a client (a browser uploads over the + * content plane, gets a root back, and sends it in a command), so it is a + * CLAIM about content, not a `Descriptor` anyone typed. The function must + * validate the complete shape before applying the brand — and must never + * throw on junk, because junk is exactly what a boundary receives. + */ +import { describe, it, expect } from 'vitest'; +import { asAttachment, MANIFEST_TYPE } from '../src/attachment'; + +const DIGEST = 'sha256:' + 'a'.repeat(64); +const VALID = { digest: DIGEST, mediaType: MANIFEST_TYPE, size: 9 }; + +describe('asAttachment', () => { + it('brands the complete, well-formed shape', () => { + expect(asAttachment(VALID)).toBe(VALID); + }); + + it('refuses non-objects without throwing', () => { + for (const junk of [null, undefined, 'sha256:abc', 42, true]) { + expect(asAttachment(junk)).toBeNull(); + } + }); + + it('refuses a missing or malformed digest', () => { + expect(asAttachment({ ...VALID, digest: undefined })).toBeNull(); + expect(asAttachment({ ...VALID, digest: 'sha256:short' })).toBeNull(); + expect(asAttachment({ ...VALID, digest: 42 })).toBeNull(); + }); + + it('refuses a media type that does not point at a manifest', () => { + expect(asAttachment({ ...VALID, mediaType: 'image/jpeg' })).toBeNull(); + expect(asAttachment({ ...VALID, mediaType: undefined })).toBeNull(); + }); + + it('refuses a missing or insane size', () => { + expect(asAttachment({ ...VALID, size: undefined })).toBeNull(); + expect(asAttachment({ ...VALID, size: NaN })).toBeNull(); + expect(asAttachment({ ...VALID, size: -1 })).toBeNull(); + expect(asAttachment({ ...VALID, size: 1.5 })).toBeNull(); + expect(asAttachment({ ...VALID, size: '9' })).toBeNull(); + }); +}); diff --git a/packages/media/test/file-store.test.ts b/packages/media/test/file-store.test.ts index 478d2596..82d542d2 100644 --- a/packages/media/test/file-store.test.ts +++ b/packages/media/test/file-store.test.ts @@ -179,4 +179,34 @@ describe('FileAttachmentStore — an OCI Image Layout', () => { 'application/vnd.oci.image.manifest.v1+json'); expect(s.getManifest(d.digest)).toBeNull(); }); + + it('refuses a corrupt or hand-made manifest instead of serving it', () => { + // `layers: [null]` used to pass the shallow check and then crash + // `representationsOf`; a layer without a digest would hand the HTTP + // routes a descriptor that cannot resolve. Refused HERE, uniformly. + const dir = tmp(); + const s = new FileAttachmentStore(dir); + const manifest = (layers: unknown): string => + JSON.stringify({ artifactType: ATTACHMENT_ARTIFACT_TYPE, layers }); + const put = (json: string) => + s.putBlob(new TextEncoder().encode(json), 'application/vnd.oci.image.manifest.v1+json'); + + for (const layers of [ + [null], + ['a-string'], + [{ mediaType: 'image/jpeg', size: 3 }], // no digest + [{ digest: 'sha256:short', mediaType: 'image/jpeg', size: 3 }], // bad digest + [{ digest: 'sha256:' + 'a'.repeat(64), size: 3 }], // no mediaType + [{ digest: 'sha256:' + 'a'.repeat(64), mediaType: 'image/jpeg', size: -1 }], + ]) { + expect(s.getManifest(put(manifest(layers)).digest)).toBeNull(); + } + + // Control: a fully-formed layer passes — the validation refuses junk, + // not foreign-but-valid manifests. + const ok = put(manifest([ + { digest: 'sha256:' + 'b'.repeat(64), mediaType: 'image/jpeg', size: 3 }, + ])); + expect(s.getManifest(ok.digest)).not.toBeNull(); + }); }); diff --git a/packages/rig/src/content-routes.ts b/packages/rig/src/content-routes.ts index 184c1022..ac655c79 100644 --- a/packages/rig/src/content-routes.ts +++ b/packages/rig/src/content-routes.ts @@ -47,6 +47,12 @@ export interface ContentRoutesOpts { /** * Ceiling on how long one upload may take, end to end. * + * One deadline spans body transfer AND ingress: the signal handed to + * `ingest` aborts at the same ceiling, so a completed body cannot hold the + * handler while normalization queues behind other work. (A decode already + * inside sharp has no abort — the 408 still goes out at the deadline; only + * that decode runs on.) + * * A byte cap alone does not bound a request: a client that opens a POST and * then trickles — or sends nothing at all — holds the promise, the socket and * the handler open indefinitely, and enough of them starve the host without @@ -301,8 +307,10 @@ export function createContentRoutes( fail(res, 501, 'no ingress service installed on this host'); return true; } + const ctrl = new AbortController(); + const deadline = setTimeout(() => ctrl.abort(), uploadTimeout); readBounded(req, { maxBytes: maxUpload, timeoutMs: uploadTimeout }) - .then((bytes) => opts.ingest!(bytes)) + .then((bytes) => opts.ingest!(bytes, ctrl.signal)) .then((descriptor) => { const body = JSON.stringify(descriptor); res.writeHead(201, head({ @@ -313,14 +321,15 @@ export function createContentRoutes( }) .catch((e: unknown) => { const tooLarge = e instanceof TooLarge; - const tooSlow = e instanceof TooSlow; + const tooSlow = e instanceof TooSlow || ctrl.signal.aborted; const code = tooLarge ? 413 : tooSlow ? 408 : 400; fail(res, code, e instanceof Error ? e.message : 'ingress failed'); // Now that the status is on the wire, stop the upload. A stalled // client will not close on its own — that is the whole problem — // so the timeout path has to drop the socket just as the cap does. if (tooLarge || tooSlow) req.destroy(); - }); + }) + .finally(() => clearTimeout(deadline)); return true; } diff --git a/packages/rig/src/models.ts b/packages/rig/src/models.ts index 0c71c2d3..962a361a 100644 --- a/packages/rig/src/models.ts +++ b/packages/rig/src/models.ts @@ -399,7 +399,13 @@ export async function resolveRuntimeModels(opts: { ...(onProgress ? { onProgress: (g: number, t: number) => onProgress('llm', g, t) } : {}), }); - const mmprojId = config.mmproj ?? (llmId ? catalogEntry('llm', llmId)?.mmproj : undefined); + // A path override points the runtime at bytes the catalog knows nothing + // about, so the catalog's projector pairing does not apply: inferring one + // from the id would load a projector for a model that is not running — + // wrong dimensions at best, a failed context at worst. Vision with a + // custom path takes an explicit `config.mmproj`. + const mmprojId = config.mmproj ?? + (config.path ? undefined : llmId ? catalogEntry('llm', llmId)?.mmproj : undefined); if (!mmprojId) return { modelPath }; const mmprojPath = await resolveModel({ diff --git a/packages/rig/test/content-routes.test.ts b/packages/rig/test/content-routes.test.ts index a37ae4f3..e34bec46 100644 --- a/packages/rig/test/content-routes.test.ts +++ b/packages/rig/test/content-routes.test.ts @@ -217,6 +217,30 @@ describe('content routes', () => { ); }); + it('the deadline spans ingress, not only the body', async () => { + // A completed body used to stop the clock: normalization then ran with + // no signal and could hold the handler far beyond the configured + // ceiling. One AbortController now spans both halves — an ingress that + // honours its signal is cut off at the same deadline. + const { store } = fixture(); + await withServer( + { + store, + uploadTimeoutMs: 150, + ingest: (_bytes, signal) => + new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(new Error('ingress aborted at deadline'))); + }), + }, + async (base) => { + const res = await fetch(`${base}/v1/media/ingress`, { + method: 'POST', headers: { 'content-type': 'image/png' }, body: PNG, + }); + expect(res.status).toBe(408); // told which limit it hit, not a generic 400 + }, + ); + }, 10_000); + it('refuses GET on the existence route — it is HEAD-only by design', async () => { // `/v1/content/` answers EXISTENCE. Serving its bytes would hand // out any blob by digest, including a retained SOURCE layer — defeating diff --git a/packages/sdk/src/Session.ts b/packages/sdk/src/Session.ts index b75930b9..45a05f61 100644 --- a/packages/sdk/src/Session.ts +++ b/packages/sdk/src/Session.ts @@ -163,13 +163,33 @@ export class Session { const { sep, prompt, bitmaps } = buildUserDeltaMultimodal(this._ctx, content, images, opts); const attachments = opts.attachments; if (this._trunk) { - const { tokensDecoded } = await this._trunk.prefillMultimodal(prompt, bitmaps, sep); - this._onPrefill?.({ role: 'user', content, cells: tokensDecoded, branchHandle: this._trunk.handle, ...(attachments ? { attachments } : {}) }); + const trunk = this._trunk; + try { + const { tokensDecoded } = await trunk.prefillMultimodal(prompt, bitmaps, sep); + this._onPrefill?.({ role: 'user', content, cells: tokensDecoded, branchHandle: trunk.handle, ...(attachments ? { attachments } : {}) }); + } catch (e) { + // A failed multimodal prefill POISONS the branch — decode_segments + // is not atomic, and partial-range KV ops are meaningless on + // recurrent layers. Leaving it installed would let the next turn + // resume invalid KV; prune (subtree — poisoned KV invalidates + // anything forked from it) and clear, so the failure surfaces once, + // here. + trunk.pruneSubtreeSync(); + this._trunk = null; + throw e; + } } else { const trunk = Branch.create(this._ctx, 0, {}); - const { tokensDecoded } = await trunk.prefillMultimodal(prompt, bitmaps, []); - await this.promote(trunk); - this._onPrefill?.({ role: 'user', content, cells: tokensDecoded, branchHandle: trunk.handle, ...(attachments ? { attachments } : {}) }); + try { + const { tokensDecoded } = await trunk.prefillMultimodal(prompt, bitmaps, []); + await this.promote(trunk); + this._onPrefill?.({ role: 'user', content, cells: tokensDecoded, branchHandle: trunk.handle, ...(attachments ? { attachments } : {}) }); + } catch (e) { + // Never promoted — prune so the failed cold bootstrap does not leak + // the branch slot. + trunk.pruneSubtreeSync(); + throw e; + } } } diff --git a/packages/sdk/src/deltas.ts b/packages/sdk/src/deltas.ts index c0e1ae4d..861fc630 100644 --- a/packages/sdk/src/deltas.ts +++ b/packages/sdk/src/deltas.ts @@ -12,6 +12,13 @@ import type { SessionContext } from './types'; */ export const MEDIA_MARKER = '<__media__>'; +/** Defang a literal media marker in model-visible text. The native layer + * splits the rendered prompt on EVERY literal occurrence, so text that + * happens to contain the marker would desynchronize markers and bitmaps — + * more markers than images fails the prefill; an off-by-one mispairs them. + * Applied wherever text enters a multimodal prompt. */ +const defangMarker = (text: string): string => text.split(MEDIA_MARKER).join(''); + /** * Chat content carrying one media marker per image * @@ -36,7 +43,7 @@ export function mediaContent( ): string | Array<{ type: string; text: string }> { if (images.length === 0) return text; return [ - { type: 'text', text }, + { type: 'text', text: defangMarker(text) }, ...images.map(() => ({ type: 'media_marker', text: MEDIA_MARKER })), ]; } @@ -149,10 +156,13 @@ export function buildUserDeltaMultimodal( const fmtOpts: Record = {}; if (opts.tools) fmtOpts.tools = opts.tools; if (opts.enableThinking !== undefined) fmtOpts.enableThinking = opts.enableThinking; - const userContent = mediaContent(content, images); + // Defanged even with zero images: this delta always lands via the + // multimodal prefill, whose native splitter sees the whole prompt — + // system content included. + const userContent = mediaContent(defangMarker(content), images); const { prompt } = ctx.formatChatSync( JSON.stringify([ - { role: 'system', content: opts.system ?? '' }, + { role: 'system', content: defangMarker(opts.system ?? '') }, { role: 'user', content: userContent }, ]), fmtOpts @@ -316,7 +326,7 @@ export function buildToolResultDeltaMultimodal( const { prompt, generationPrompt } = ctx.formatChatSync( JSON.stringify([ { role: 'system', content: '' }, - { role: 'tool', content: mediaContent(resultStr, images), tool_call_id: callId }, + { role: 'tool', content: mediaContent(defangMarker(resultStr), images), tool_call_id: callId }, ]), fmtOpts, ); diff --git a/packages/sdk/test/deltas-multimodal.test.ts b/packages/sdk/test/deltas-multimodal.test.ts index e0d7416f..a3500391 100644 --- a/packages/sdk/test/deltas-multimodal.test.ts +++ b/packages/sdk/test/deltas-multimodal.test.ts @@ -184,3 +184,74 @@ describe('prefillUserMultimodal — cold bootstrap', () => { expect(ctx.multimodalPrefills[0].handles[0]).toBe(s.trunk!.handle); }); }); + +describe('marker defang — a literal marker in text never desynchronizes', () => { + // The native layer splits the rendered prompt on EVERY literal + // `<__media__>`, so text containing the marker would yield more markers + // than bitmaps and fail (or mispair) the prefill. + const HOSTILE = `look at ${MEDIA_MARKER} this`; + + it('mediaContent defangs the text part', () => { + const parts = mediaContent(HOSTILE, img(2)); + expect(Array.isArray(parts)).toBe(true); + const text = (parts as Array<{ type: string; text: string }>)[0].text; + expect(text).not.toContain(MEDIA_MARKER); + expect(text).toContain('look at'); + }); + + it('a user delta carries exactly one marker per image, whatever the text says', () => { + const ctx = new MockSessionContext(); + const d = buildUserDeltaMultimodal(ctx, HOSTILE, img(1), { system: HOSTILE }); + expect((d.prompt.match(/<__media__>/g) ?? []).length).toBe(1); + }); + + it('zero images means ZERO markers — even from hostile text', () => { + // This delta still lands via the multimodal prefill, whose splitter + // sees the whole prompt: one stray literal would mean 1 marker ≠ 0 bitmaps. + const ctx = new MockSessionContext(); + const d = buildUserDeltaMultimodal(ctx, HOSTILE, []); + expect((d.prompt.match(/<__media__>/g) ?? []).length).toBe(0); + }); + + it('a tool-result delta defangs the result string', () => { + const ctx = new MockSessionContext(); + const d = buildToolResultDeltaMultimodal(ctx, JSON.stringify({ page: HOSTILE }), 'c1', img(1)); + expect((d.prompt.match(/<__media__>/g) ?? []).length).toBe(1); + }); +}); + +describe('prefillUserMultimodal — a failed prefill never leaves a poisoned trunk', () => { + const mkSession = (ctx: MockSessionContext) => + new Session({ ctx: ctx as never, store: new BranchStore(ctx as never) }); + + it('warm: prunes and clears the trunk, then rethrows', async () => { + const ctx = new MockSessionContext(); + const s = mkSession(ctx); + await s.prefillUserMultimodal('first', img(1)); // establishes the trunk + const trunkHandle = s.trunk!.handle; + + const pruned: number[] = []; + const origPrune = ctx._branchPrune.bind(ctx); + ctx._branchPrune = (h: number) => { pruned.push(h); origPrune(h); }; + ctx.mockMultimodalError = () => 'decode exploded'; + + await expect(s.prefillUserMultimodal('second', img(1))).rejects.toThrow('decode exploded'); + // The branch is poisoned (decode_segments is not atomic); leaving it + // installed would let the next turn resume invalid KV. + expect(s.trunk).toBeNull(); + expect(pruned).toContain(trunkHandle); + }); + + it('cold: prunes the never-promoted branch, leaking no slot', async () => { + const ctx = new MockSessionContext(); + const s = mkSession(ctx); + const pruned: number[] = []; + const origPrune = ctx._branchPrune.bind(ctx); + ctx._branchPrune = (h: number) => { pruned.push(h); origPrune(h); }; + ctx.mockMultimodalError = () => 'decode exploded'; + + await expect(s.prefillUserMultimodal('first', img(1))).rejects.toThrow('decode exploded'); + expect(s.trunk).toBeNull(); + expect(pruned).toContain(ctx.multimodalPrefills[0].handles[0]); + }); +}); diff --git a/scripts/verify-packed-install.sh b/scripts/verify-packed-install.sh index 512df7a8..a2fc50da 100755 --- a/scripts/verify-packed-install.sh +++ b/scripts/verify-packed-install.sh @@ -23,10 +23,21 @@ set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CAT="${CAT_FIXTURE:-$HOME/dev/apps/lloyal-node/liblloyal/tests/fixtures/cat.jpg}" +CAT="${CAT_FIXTURE:-}" WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT echo "workdir: $WORK" +# Self-contained: generate the fixture with sharp, as verify-oci does, rather +# than reaching into an unrelated checkout. CAT_FIXTURE still overrides. +if [ -z "$CAT" ]; then + CAT="$WORK/fixture.jpg" + ( cd "$REPO" && node -e " + require('sharp')({ create: { width: 640, height: 480, channels: 3, + background: { r: 200, g: 120, b: 40 } } }) + .jpeg({ quality: 90 }).toFile('$CAT').then(() => console.log('generated fixture')); + " ) +fi + echo "── building ──" ( cd "$REPO" && npx tsc -b packages/media >/dev/null ) From 203bc6cb83b1f8d0c07cde5b07b1dc4127e2d57c Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Tue, 1 Sep 2026 23:00:29 +1000 Subject: [PATCH 08/69] refactor(agents): replay's two halves get two names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `replayTurns` is the delta-replay primitive under `reconstructBranch`, exported on its own: seed REBUILD verifies it can restore the recorded state or throws; delta replay is provenance-blind — the branch may be a fresh rebuild or a fork of live, resident state, and the primitive neither knows nor checks. It owns nothing about lifetime. `reconstructBranch` keeps its exact contract and becomes visibly a composition: seed rebuild, then replayTurns. A caller continuing from a live fork composes forkSync + replayTurns and owns the prefix contract by construction — no mode flag, no guards that conditionally skip. Test pins the fork case, the one reconstructBranch itself never exercises. --- packages/agents/src/index.ts | 2 +- packages/agents/src/replay.ts | 34 +++++++++++++++++++++--- packages/agents/test/attachments.test.ts | 22 ++++++++++++++- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 1ac931ef..4714d2e1 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -49,7 +49,7 @@ export type { PromptState, PromptSection, PromptStep } from './prompt'; export { reduce } from './combinators'; export { parallel, chain, fanout, dag } from './orchestrators'; export type { SpawnSpec, ChainStep, DAGNode, Orchestrator, PoolContext } from './orchestrators'; -export { extractSpineSeed, extractSpineCheckpoint, reconstructBranch } from './replay'; +export { extractSpineSeed, extractSpineCheckpoint, reconstructBranch, replayTurns } from './replay'; export type { BranchCheckpoint } from './replay'; export type { Toolkit } from './toolkit'; diff --git a/packages/agents/src/replay.ts b/packages/agents/src/replay.ts index bdfa84b4..9dc707ac 100644 --- a/packages/agents/src/replay.ts +++ b/packages/agents/src/replay.ts @@ -224,10 +224,36 @@ export function* reconstructBranch(checkpoint: BranchCheckpoint): Operation spine.prefill(seedTokens)); } - for (const turn of checkpoint.turns) { - const delta = buildTurnDelta(ctx, turn.userContent, turn.assistantContent); - yield* call(() => store.prefill([[spine, delta]])); - } + yield* replayTurns(spine, checkpoint.turns); return spine; } + +/** + * Apply checkpointed turn deltas to a branch, in order. + * + * The delta-replay primitive under {@link reconstructBranch}, exported on its + * own because the two halves of replay have different contracts and callers: + * seed REBUILD verifies it can restore the recorded state or throws + * (`reconstructBranch`, above); delta replay is provenance-blind — the branch + * may be a fresh seed rebuild or a fork of live, resident state, and this + * function neither knows nor checks. A caller continuing from a live fork + * owns the prefix contract by construction: it forked the very branch whose + * state the checkpoint's turns extend. + * + * Owns nothing about lifetime — no `ensure`, no prune. The workbench ties + * the rebuilt spine to its scope; a pool ties a fork to its own bookkeeping. + * + * @category Agents + */ +export function* replayTurns( + branch: Branch, + turns: BranchCheckpoint['turns'], +): Operation { + const ctx = yield* Ctx.expect(); + const store = yield* Store.expect(); + for (const turn of turns) { + const delta = buildTurnDelta(ctx, turn.userContent, turn.assistantContent); + yield* call(() => store.prefill([[branch, delta]])); + } +} diff --git a/packages/agents/test/attachments.test.ts b/packages/agents/test/attachments.test.ts index abf74f08..50f185af 100644 --- a/packages/agents/test/attachments.test.ts +++ b/packages/agents/test/attachments.test.ts @@ -37,7 +37,7 @@ import { initAgents } from '../src/init'; import { Branch } from '../../sdk/src/Branch'; import { CapturingTraceWriter } from './helpers/capturing-trace'; import { rawIngress } from './helpers/raw-ingress'; -import { reconstructBranch, extractSpineSeed, type BranchCheckpoint } from '../src/replay'; +import { reconstructBranch, extractSpineSeed, replayTurns, type BranchCheckpoint } from '../src/replay'; import { Ctx, Store, Attachments } from '../src/context'; import type { TraceEvent } from '../src/trace-types'; @@ -137,6 +137,26 @@ describe('reconstructBranch', () => { expect(branch).toBeDefined(); }); + it('replayTurns is provenance-blind — deltas land on a fork of live state', async () => { + // The primitive's contract: it applies turns to WHATEVER branch it is + // given — a fresh seed rebuild or a fork of a resident spine — without + // guards about the prefix. The fork case is the one reconstructBranch + // itself never exercises. + await withCtx(function*(ctx) { + const spine = yield* reconstructBranch(cp()); + const fork = spine.forkSync(); + let prefills = 0; + const orig = ctx._storePrefill.bind(ctx); + ctx._storePrefill = async (h, t) => { prefills++; return orig(h, t); }; + yield* replayTurns(fork, [ + { userContent: 'u1', assistantContent: 'a1' }, + { userContent: 'u2', assistantContent: 'a2' }, + ]); + expect(prefills).toBe(2); + return null; + }); + }); + it('refuses a marker with no attachment references', async () => { // The pre-attachments behaviour, preserved: a trace that recorded only // the marker still cannot be replayed. From 13dfeafcf85a11f3d9c0e9a33aecea1009246cb7 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Wed, 2 Sep 2026 00:16:05 +1000 Subject: [PATCH 09/69] fix(sdk): streamed text is assembled at UTF-8 boundaries, not token edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A BPE piece is a byte sequence that can end mid-character; converting each piece to a string independently replaced both halves with U+FFFD — every multi-byte character split across tokens (÷, emoji, CJK) streamed as diamonds and was persisted that way in reports and traces. The fix is a value-level fold: splitCompleteUtf8 emits up to the last complete character boundary and holds the 0-3 byte tail; produceSync derives text purely from (held, piece); commit installs the successor tail — including the batched BranchStore.commit the agent pool uses — and forks inherit the tail mid-character. The binding gains one passthrough method (tokenToBytes) so bytes survive to the layer that assembles them. Property tests: any chunking of a corpus reproduces it exactly; junk is decided immediately, never held; produce stays a pure observation. Verified on real weights: "12 �� 4 = 3 ✅ ���" → "12 ÷ 4 = 3 ✅ 📋". --- packages/sdk/src/Branch.ts | 29 ++++- packages/sdk/src/BranchStore.ts | 1 + packages/sdk/src/types.ts | 11 ++ packages/sdk/src/utf8.ts | 92 +++++++++++++ packages/sdk/test/MockSessionContext.ts | 1 + packages/sdk/test/utf8.test.ts | 163 ++++++++++++++++++++++++ 6 files changed, 295 insertions(+), 2 deletions(-) create mode 100644 packages/sdk/src/utf8.ts create mode 100644 packages/sdk/test/utf8.test.ts diff --git a/packages/sdk/src/Branch.ts b/packages/sdk/src/Branch.ts index cffa59b7..04cadbc6 100644 --- a/packages/sdk/src/Branch.ts +++ b/packages/sdk/src/Branch.ts @@ -1,5 +1,6 @@ import type { SessionContext, SamplingParams, Produced, GrammarTrigger, MultimodalPrefillResult } from './types'; import { GrammarTriggerType } from './types'; +import { splitCompleteUtf8, concatBytes } from './utf8'; /** * Options for {@link Branch.fork} / {@link Branch.forkSync}. @@ -72,11 +73,14 @@ export class Branch { private _ctx: SessionContext; private _handle: number; private _disposed: boolean; + /** Incomplete UTF-8 tail from the last committed token (0–3 bytes). */ + private _held: Uint8Array; constructor(ctx: SessionContext, handle: number) { this._ctx = ctx; this._handle = handle; this._disposed = false; + this._held = new Uint8Array(0); } /** @@ -144,7 +148,9 @@ export class Branch { this._ensureNotDisposed(); const cloneLogits = opts?.cloneLogits ?? true; const newHandle = this._ctx._branchFork(this._handle, cloneLogits); - return new Branch(this._ctx, newHandle); + const child = new Branch(this._ctx, newHandle); + child._held = this._held; // a fork continues the parent's text stream mid-character + return child; } /** @@ -534,6 +540,11 @@ export class Branch { * Async contract: local branches resolve immediately; cloud branches * may perform an HTTP round-trip. Use {@link produceSync} when you know * the branch is local and want zero-overhead sampling. + * + * `text` is UTF-8 boundary-aligned: a multi-byte character split across + * token pieces is held back and emitted whole by the produce() that + * completes it. The held tail advances on {@link commit} — produce() is a + * pure observation and may be called repeatedly. */ async produce(): Promise { return this.produceSync(); @@ -550,7 +561,7 @@ export class Branch { const token = this.sample(); return { token, - text: this._ctx.tokenToText(token), + text: splitCompleteUtf8(concatBytes(this._held, this._ctx.tokenToBytes(token))).complete, isStop: this._ctx.isStopToken(token), }; } @@ -569,6 +580,20 @@ export class Branch { async commit(token: number): Promise { this._ensureNotDisposed(); await this._ctx._storeCommit([this._handle], [token]); + this._advanceText(token); + } + + /** + * Advance the UTF-8 held tail for a committed token. + * + * Internal: called by {@link commit} and by `BranchStore.commit` for the + * batched form. produce() derives text from the CURRENT tail without + * installing; this installs the successor. Rides commit — not produce — + * because a token may be produced more than once but is committed exactly + * once, and the tail is stream state. + */ + _advanceText(token: number): void { + this._held = splitCompleteUtf8(concatBytes(this._held, this._ctx.tokenToBytes(token))).tail; } // ===== METRICS ===== diff --git a/packages/sdk/src/BranchStore.ts b/packages/sdk/src/BranchStore.ts index 2276ef7e..2142cd84 100644 --- a/packages/sdk/src/BranchStore.ts +++ b/packages/sdk/src/BranchStore.ts @@ -108,6 +108,7 @@ export class BranchStore { tokens.push(token); } await this._ctx._storeCommit(handles, tokens); + for (const [branch, token] of entries) branch._advanceText(token); } /** diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index cd8a105e..ecc6a702 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -750,6 +750,17 @@ export interface SessionContext { */ tokenToText(token: number): string; + /** + * Raw bytes of a single token's text piece. + * + * The byte-level twin of {@link tokenToText}. A BPE piece is a byte + * sequence, not a string — it can end or begin mid-character — so per-token + * string conversion tears multi-byte UTF-8 into U+FFFD. Streaming callers + * (Branch.produceSync) assemble text from bytes at character boundaries + * instead. + */ + tokenToBytes(token: number): Uint8Array; + /** * Check if token is a model stop token * diff --git a/packages/sdk/src/utf8.ts b/packages/sdk/src/utf8.ts new file mode 100644 index 00000000..f66e4241 --- /dev/null +++ b/packages/sdk/src/utf8.ts @@ -0,0 +1,92 @@ +/** + * UTF-8 boundary splitting for token streams. + * + * A BPE token piece is a byte sequence, not a string — a multi-byte character + * can end one piece and begin the next. Converting each piece to a string + * independently replaces both halves with U+FFFD. The fix is a value-level + * fold: hold the incomplete trailing bytes (at most 3 — a character is at most + * 4 bytes) and emit only up to the last complete character boundary; the held + * tail completes with the next piece. + * + * Pure functions on values. The stateful half — WHERE the tail lives and WHEN + * it advances — belongs to {@link Branch}: `produceSync` derives text without + * installing, `commit` installs the successor tail. + */ + +const DECODER = new TextDecoder(); // non-fatal: provably-invalid bytes become U+FFFD + +const EMPTY = new Uint8Array(0); + +/** A byte sequence split at its last complete UTF-8 character boundary. */ +export interface Utf8Split { + /** + * Everything up to the boundary, decoded. Bytes that can never complete a + * character (a stray continuation, an invalid lead, a lead followed by a + * non-continuation) are already known to be junk and decode to U+FFFD here + * rather than being held forever. + */ + complete: string; + /** + * The trailing bytes of a character still in flight: a valid lead plus any + * continuations, 0–3 bytes. Always a fresh copy — never a view into the + * input. + */ + tail: Uint8Array; +} + +/** Total byte count a UTF-8 lead byte promises; 0 for a continuation or invalid lead. */ +function seqLen(b: number): number { + if (b < 0x80) return 1; // ASCII + if (b >= 0xc2 && b <= 0xdf) return 2; + if (b >= 0xe0 && b <= 0xef) return 3; + if (b >= 0xf0 && b <= 0xf4) return 4; + return 0; // continuation (0x80–0xbf) or invalid lead (0xc0/0xc1, 0xf5–0xff) +} + +const isContinuation = (b: number): boolean => (b & 0xc0) === 0x80; + +/** + * Split `bytes` at the last complete UTF-8 character boundary. + * + * Only the final 3 bytes can belong to an incomplete character (a complete + * character is at most 4 bytes), so the scan is O(1): walk back to the last + * non-continuation byte in that window, read the sequence length its high + * bits promise, and hold the sequence only when it is a valid prefix that + * runs past the end. Everything else — including junk — is decided now. + */ +export function splitCompleteUtf8(bytes: Uint8Array): Utf8Split { + const n = bytes.length; + let holdFrom = n; + + const windowStart = Math.max(0, n - 3); + for (let i = n - 1; i >= windowStart; i--) { + if (isContinuation(bytes[i])) continue; // walk back to this sequence's lead + const need = seqLen(bytes[i]); + if (need > n - i) { + // The lead promises more bytes than remain. Hold it only if what + // follows is all continuations — otherwise the sequence is already + // broken and waiting would never mend it. + let validPrefix = true; + for (let j = i + 1; j < n; j++) { + if (!isContinuation(bytes[j])) { validPrefix = false; break; } + } + if (validPrefix) holdFrom = i; + } + break; // the last lead settles it, whichever way + } + + return { + complete: DECODER.decode(bytes.subarray(0, holdFrom)), + tail: holdFrom === n ? EMPTY : bytes.slice(holdFrom), + }; +} + +/** Concatenate two byte sequences. */ +export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { + if (a.length === 0) return b; + if (b.length === 0) return a; + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} diff --git a/packages/sdk/test/MockSessionContext.ts b/packages/sdk/test/MockSessionContext.ts index aaa6f05b..4ef42771 100644 --- a/packages/sdk/test/MockSessionContext.ts +++ b/packages/sdk/test/MockSessionContext.ts @@ -371,6 +371,7 @@ export class MockSessionContext implements SessionContext { isStopToken(token: number): boolean { return token === this.stopToken; } tokenToText(token: number): string { return `t${token}`; } + tokenToBytes(token: number): Uint8Array { return new TextEncoder().encode(this.tokenToText(token)); } getEogToken(): number { return this.stopToken; } getTurnSeparator(): number[] { return [0]; } diff --git a/packages/sdk/test/utf8.test.ts b/packages/sdk/test/utf8.test.ts new file mode 100644 index 00000000..526a2ec1 --- /dev/null +++ b/packages/sdk/test/utf8.test.ts @@ -0,0 +1,163 @@ +/** + * UTF-8 boundary splitting — the pure core, then the Branch protocol on top. + * + * The property under test: for ANY chunking of a valid byte stream, the + * held-tail fold emits exactly the original text, every emission is whole + * characters, and the tail never exceeds 3 bytes. Bytes that can never + * complete a character decode to U+FFFD immediately rather than being held. + */ +import { describe, expect, it } from 'vitest'; +import { splitCompleteUtf8, concatBytes } from '../src/utf8'; +import { Branch } from '../src/Branch'; +import { MockSessionContext } from './MockSessionContext'; + +const ENC = new TextEncoder(); + +// 1-, 2-, 3- and 4-byte characters, mixed with ASCII. +const CORPUS = 'a ÷ b ✅ 📋 → é 한 𐍈 end\n'; +const CORPUS_BYTES = ENC.encode(CORPUS); + +/** Run `bytes` through the held-tail fold in chunks of the given widths. */ +function stream(bytes: Uint8Array, widths: number[]): string { + let held: Uint8Array = new Uint8Array(0); + let out = ''; + let at = 0; + for (const w of widths) { + const { complete, tail } = splitCompleteUtf8(concatBytes(held, bytes.subarray(at, at + w))); + expect(tail.length).toBeLessThanOrEqual(3); + out += complete; + held = tail; + at += w; + } + expect(at).toBe(bytes.length); + expect(held.length).toBe(0); // the corpus ends on a character boundary + return out; +} + +describe('splitCompleteUtf8 — the fold reproduces the stream under any chunking', () => { + it('byte-by-byte (every boundary torn)', () => { + expect(stream(CORPUS_BYTES, Array(CORPUS_BYTES.length).fill(1))).toBe(CORPUS); + }); + + it('every two-chunk split', () => { + const n = CORPUS_BYTES.length; + for (let k = 1; k < n; k++) { + expect(stream(CORPUS_BYTES, [k, n - k])).toBe(CORPUS); + } + }); + + it('every fixed width from 1 to 7', () => { + const n = CORPUS_BYTES.length; + for (let w = 1; w <= 7; w++) { + const widths: number[] = []; + for (let at = 0; at < n; at += w) widths.push(Math.min(w, n - at)); + expect(stream(CORPUS_BYTES, widths)).toBe(CORPUS); + } + }); + + it('whole input at once is the identity', () => { + const { complete, tail } = splitCompleteUtf8(CORPUS_BYTES); + expect(complete).toBe(CORPUS); + expect(tail.length).toBe(0); + }); +}); + +describe('splitCompleteUtf8 — junk is decided now, never held', () => { + it('a lone continuation byte becomes U+FFFD', () => { + const { complete, tail } = splitCompleteUtf8(new Uint8Array([0x80])); + expect(complete).toBe('�'); + expect(tail.length).toBe(0); + }); + + it('an invalid lead (0xf5) becomes U+FFFD', () => { + const { complete, tail } = splitCompleteUtf8(new Uint8Array([0xf5])); + expect(complete).toBe('�'); + expect(tail.length).toBe(0); + }); + + it('a lead followed by a non-continuation is broken, not incomplete', () => { + // 0xe2 promises 3 bytes; 'A' is not a continuation. Waiting cannot mend it. + const { complete, tail } = splitCompleteUtf8(new Uint8Array([0xe2, 0x41])); + expect(complete).toBe('�A'); + expect(tail.length).toBe(0); + }); + + it('a stray continuation after a complete character decodes beside it', () => { + const { complete, tail } = splitCompleteUtf8(concatBytes(ENC.encode('é'), new Uint8Array([0x80]))); + expect(complete).toBe('é�'); + expect(tail.length).toBe(0); + }); + + it('a genuinely incomplete character IS held, and completes', () => { + // '€' is e2 82 ac. + const first = splitCompleteUtf8(new Uint8Array([0x61, 0xe2, 0x82])); + expect(first.complete).toBe('a'); + expect(Array.from(first.tail)).toEqual([0xe2, 0x82]); + const second = splitCompleteUtf8(concatBytes(first.tail, new Uint8Array([0xac]))); + expect(second.complete).toBe('€'); + expect(second.tail.length).toBe(0); + }); +}); + +describe('Branch produce/commit — text is boundary-aligned, tail advances on commit', () => { + // 📋 is f0 9f 93 8b — torn across two token pieces. + const PIECES: Record = { + 1: new Uint8Array([0xf0, 0x9f]), + 2: new Uint8Array([0x93, 0x8b]), + }; + + function tornCtx(): { ctx: MockSessionContext; next: (t: number) => void } { + const ctx = new MockSessionContext(); + ctx.tokenToBytes = (t: number) => PIECES[t] ?? ENC.encode(`t${t}`); + let current = 1; + ctx._branchSample = () => current; + return { ctx, next: (t: number) => { current = t; } }; + } + + it('emits nothing for the torn half, the whole character on completion', async () => { + const { ctx, next } = tornCtx(); + const b = Branch.create(ctx, 0); + + const p1 = b.produceSync(); + expect(p1.text).toBe(''); // half a character is not text yet + await b.commit(p1.token); + + next(2); + const p2 = b.produceSync(); + expect(p2.text).toBe('📋'); // completed by the second piece + }); + + it('produce is a pure observation — repeated calls do not advance the tail', async () => { + const { ctx } = tornCtx(); + const b = Branch.create(ctx, 0); + + expect(b.produceSync().text).toBe(''); + expect(b.produceSync().text).toBe(''); // same answer, no double-advance + }); + + it('the batched commit advances every branch tail (the agent-pool path)', async () => { + const { BranchStore } = await import('../src/BranchStore'); + const { ctx, next } = tornCtx(); + const store = new BranchStore(ctx); + const a = Branch.create(ctx, 0); + const b = Branch.create(ctx, 0); + + const pa = a.produceSync(); + const pb = b.produceSync(); + await store.commit([[a, pa.token], [b, pb.token]]); + + next(2); + expect(a.produceSync().text).toBe('📋'); + expect(b.produceSync().text).toBe('📋'); + }); + + it('a fork continues the parent stream mid-character', async () => { + const { ctx, next } = tornCtx(); + const b = Branch.create(ctx, 0); + await b.commit(b.produceSync().token); // parent holds [f0 9f] + + const child = b.forkSync({ cloneLogits: false }); + next(2); + expect(child.produceSync().text).toBe('📋'); // child completes it + }); +}); From f760bb205f34dc6e01e045003e2bed9c5f1a51f1 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Wed, 2 Sep 2026 00:31:08 +1000 Subject: [PATCH 10/69] =?UTF-8?q?feat(agents):=20the=20self-healing=20ladd?= =?UTF-8?q?er=20=E2=80=94=20the=20rc=20classifies=20the=20settle=20outcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/self-healing.md, wave A. llama_decode's return code arrives as data (decodeRcOf — the one reader; Branch.prefillMultimodal forwards it through its re-wrap) and SETTLE answers each class with the cheapest response that preserves the run: - rc 1 (no KV slot, state restored — the branch is INTACT): the item DEFERS and re-dispatches next tick, riding through the stall-break on its own budget; MAX_DEFER_ATTEMPTS escalates to a per-agent terminal. On the token rail this replaces a latent whole-pool death — the batched settle dispatch was unwrapped, and one capacity reject took every agent down. - rc -1 (invalid input, restored): the item is dropped and the model is told what it did not see, on the same channel as the no-projector note. - rc 2 / < -1 (partial ubatches remain): the existing poison path, unchanged, now with the rc on pool:settleFailed. A tripwire counts consecutive fatals and stops the ladder — a backend in a sticky error state fails every decode, and deferring there burns budget for nothing. SETTLE bookkeeping (settle order, tool history, re-activation, branch:prefill) moved to success-only — it runs after the dispatch lands, so the record only ever describes what happened. New trace event pool:agentDefer carries rc, attempt and a pressure snapshot (the diagnostic that separates honest fullness from fragmentation). Six ladder tests; the ride-through and the tripwire are mutation-verified. --- packages/agents/src/agent-pool.ts | 208 ++++++++++++++++++++---- packages/agents/src/trace-types.ts | 23 +++ packages/agents/test/agent-pool.test.ts | 169 +++++++++++++++++++ packages/sdk/src/Branch.ts | 6 +- packages/sdk/src/index.ts | 2 +- packages/sdk/src/types.ts | 21 +++ packages/sdk/test/MockSessionContext.ts | 18 +- 7 files changed, 404 insertions(+), 43 deletions(-) diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index 45ffcf02..b2d27cd1 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -6,7 +6,7 @@ import type { BranchStore } from '@lloyal-labs/sdk'; import { Ctx, Store, Trace, TraceParent, CallingAgent, SpineFmt, GrantStoreCtx, WindDown, CancelAgent, Pause, Attachments, Ingress } from './context'; import { prepareBatch } from './prepare-content'; import type { FormatConfig } from './Agent'; -import { buildToolResultDelta, buildToolResultDeltaMultimodal, buildTurnDelta, buildUserDelta, deltaCells } from '@lloyal-labs/sdk'; +import { buildToolResultDelta, buildToolResultDeltaMultimodal, buildTurnDelta, buildUserDelta, decodeRcOf, deltaCells } from '@lloyal-labs/sdk'; import type { MultimodalDelta } from '@lloyal-labs/sdk'; import type { Attachment } from '@lloyal-labs/media'; import { useTraceScope } from './trace-scope'; @@ -39,6 +39,16 @@ import type { // awaiting_tool → idle (settle reject + kill) // idle → disposed (branch pruned) +// ── Self-healing ladder knobs (docs/self-healing.md) ───────────────────── +/** rc==1 (no KV slot, branch intact) deferrals per agent before the item + * escalates to the terminal path. */ +const MAX_DEFER_ATTEMPTS = 3; +/** Consecutive fatal rcs (2 or < -1) before the pool stops laddering: a + * backend in a sticky error state (Metal after an OOM) fails every decode, + * and deferring or healing there burns budget for nothing. Reset by any + * successful dispatch. */ +const BACKEND_TRIPWIRE_N = 3; + /** Minimal event sender interface — accepts any Channel close type */ type EventSender = { send(value: AgentEvent): Operation }; @@ -1073,6 +1083,14 @@ export function useAgentPool(opts: AgentPoolOptions): Operation(); + // ── Self-healing ladder state (docs/self-healing.md) ── + /** rc==1 deferrals per agent; cleared when a settle lands. */ + const deferAttempts = new Map(); + /** Consecutive fatal rcs across dispatches; reset by any success. */ + let consecutiveFatalRc = 0; + /** Set at BACKEND_TRIPWIRE_N — the ladder stops, failures go terminal. */ + let backendSuspect = false; + // Pool-level branch cleanup — ensures orphan-branch cleanup even when // spawns are lazy and the orchestrator's spawn scope exits early. // @@ -1282,12 +1300,12 @@ export function useAgentPool(opts: AgentPoolOptions): Operation(); const deferred: SettledTool[] = []; + const poisoned = new Set(); + + /** Success-only bookkeeping, run AFTER a dispatch landed — the same + * discipline `writePrefilled` already follows. Nothing here runs for + * a deferred or failed entry, so the trace, the tool history and the + * re-activation list all describe only what actually happened. */ + const bookSettled = ( + a: Agent, src: SettledTool, cells: number, refs?: readonly Attachment[], + ): void => { + settledAgents.push(a); + settledOrder.push({ agentId: a.id, callId: src.callId, cells }); + if (src.probe) itemProbes.set(a.id, src.probe); + deferAttempts.delete(a.id); + const postSettle = new ContextPressure(ctx, pressureOpts); + a.recordToolResult({ + name: src.toolName, args: src.args, + resultCells: cells, + contextAfterPercent: postSettle.percentAvailable, + timestamp: performance.now(), + }); + writePrefilled(a, cells, refs); + }; + + /** rc==1: no KV slot, state restored — the branch is INTACT. The item + * re-enters via the deferral stream (`pendingSettled` next tick). */ + const writeDeferred = (a: Agent, rc: number, attempt: number): void => { + const p = new ContextPressure(ctx, pressureOpts); + tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), + type: 'pool:agentDefer', agentId: a.id, rc, attempt, + pressure: { remaining: finiteOrNull(p.remaining), cellsUsed: p.cellsUsed, + nCtx: p.nCtx, headroom: finiteOrNull(p.headroom) } }); + }; + + /** The terminal path — the ladder's bottom rung. Prune-and-discard is + * safe whatever the rc said: pruning an intact branch is harmless, + * and a poisoned one must never be resumed. */ + function* failSettled( + a: Agent, + reason: 'media_prefill_failed' | 'tool_result_failed', + detail: string, + rc?: number, + ): Operation { + poisoned.add(a.id); // skip re-activation THIS tick + discardedIds.add(a.id); // and never resurrect it in any later one + tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), + type: 'pool:settleFailed', agentId: a.id, reason, + detail: detail.slice(0, 200), ...(rc !== undefined ? { rc } : {}) }); + yield* poolChannel.send({ type: 'agent:failed', agentId: a.id, reason }); + safePrune(a, tw, poolScopeId); + a.transition('idle'); + } for (const item of items) { const a = agentById.get(item.agentId); @@ -1341,30 +1410,51 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0) { - yield* call(() => store.prefill( - tokenItems.map(t => [t.agent.branch, t.tokens] as [Branch, number[]]))); - counters.warmPrefillCalls++; - counters.warmPrefillBranches += tokenItems.length; - for (const t of tokenItems) writePrefilled(t.agent, t.cells); + try { + yield* call(() => store.prefill( + tokenItems.map(t => [t.agent.branch, t.tokens] as [Branch, number[]]))); + counters.warmPrefillCalls++; + counters.warmPrefillBranches += tokenItems.length; + consecutiveFatalRc = 0; + for (const t of tokenItems) bookSettled(t.agent, t.src, t.cells); + } catch (err) { + const rc = decodeRcOf(err); + if (rc === 1 && !backendSuspect) { + // No KV slot for the batch; state restored — every branch is + // INTACT. Re-queue the items whole: a sibling finishing frees + // cells and they settle on a later tick. This used to take the + // entire pool down. + for (const t of tokenItems) { + const attempt = (deferAttempts.get(t.agent.id) ?? 0) + 1; + deferAttempts.set(t.agent.id, attempt); + if (attempt > MAX_DEFER_ATTEMPTS) { + yield* failSettled(t.agent, 'tool_result_failed', + `deferral exhausted after ${MAX_DEFER_ATTEMPTS} attempts: ${err instanceof Error ? err.message : String(err)}`, rc); + } else { + writeDeferred(t.agent, rc, attempt); + deferred.push(t.src); + } + } + } else { + // Fatal (2 / < -1), rc-less, or the tripwire is up: today's + // behavior — the tick throws and the pool scope tears down — + // now with the rc preserved on the error for the postmortem. + if (rc === 2 || (rc !== undefined && rc < -1)) consecutiveFatalRc++; + throw err; + } + } } // The third dispatch. Media cannot share the token batch, so it goes as @@ -1372,11 +1462,12 @@ export function useAgentPool(opts: AgentPoolOptions): Operation(); + // Per-item outcomes, not a rejected promise: one agent's failure must + // not cost its siblings their prefills. Each entry classifies by the + // llama_decode rc the worker attached (docs/self-healing.md): 1 and -1 + // left the branch INTACT (state restored); only 2 / < -1 poison it + // (decode_segments is not atomic, and partial-range KV ops are + // meaningless on recurrent layers) — those are pruned, never resumed. if (mediaItems.length > 0) { const results = yield* call(() => store.prefillMultimodal(mediaItems.map(m => [m.agent.branch, m.delta] as [Branch, MultimodalDelta]))); @@ -1384,20 +1475,57 @@ export function useAgentPool(opts: AgentPoolOptions): Operation MAX_DEFER_ATTEMPTS) { + yield* failSettled(a, 'media_prefill_failed', + `deferral exhausted after ${MAX_DEFER_ATTEMPTS} attempts: ${r.error}`, rc); + } else { + writeDeferred(a, rc, attempt); + deferred.push(m.src); + } + continue; + } + + if (rc === -1 && !backendSuspect) { + // Invalid input, state restored — the branch is intact and the + // item is deterministic: retrying loops. Drop it and tell the + // model what it did not see, on the same channel the + // no-projector path already uses. + const note = { + [TOOL_IMAGE_ERROR_KEY]: + `${m.src.toolName} returned media the decoder rejected as invalid input. ` + + `Work from the text, or use a different source.`, + }; + const noteTokens = buildToolResultDelta( + ctx, JSON.stringify(note), m.src.callId, + { enableThinking: a.fmt.enableThinking }); + yield* call(() => store.prefill([[a.branch, noteTokens]])); + bookSettled(a, m.src, noteTokens.length); + continue; + } + + // Poisoned (2 / < -1), an rc-less failure, or the tripwire is up. + if (rc === 2 || (rc !== undefined && rc < -1)) { + consecutiveFatalRc++; + if (consecutiveFatalRc >= BACKEND_TRIPWIRE_N) backendSuspect = true; + } + yield* failSettled(a, 'media_prefill_failed', + backendSuspect + ? `${r.error} [backend suspect: ${consecutiveFatalRc} consecutive fatal decodes — recreate the backend]` + : r.error, + rc); } } @@ -2316,6 +2444,14 @@ export function useAgentPool(opts: AgentPoolOptions): Operation { }); }); +// ── Self-healing ladder: the rc classifies the settle outcome ── +// docs/self-healing.md. llama_decode's contract: rc 1 and -1 restore state +// (the branch is INTACT); only 2 / < -1 leave partial ubatches (poisoned). +// The ladder answers each class with the cheapest response that preserves +// the run: defer / drop-item / terminal. +describe('self-healing ladder', () => { + const ladderPolicy = () => stubPolicy({ + shouldExit: () => false, + onProduced: (_a, parsed) => { + if (parsed.toolCalls.length > 0) return { type: 'tool_call', tc: parsed.toolCalls[0] }; + if (parsed.content) return { type: 'free_text_return', content: parsed.content }; + return { type: 'idle', reason: 'free_text_stop' }; + }, + }); + /** Turn 1 (raw contains 't1') calls the tool; later turns finish. Keyed + * off raw because the pool parses partials — a call counter misfires. */ + const callTool = (name: string) => ({ + parseChatOutputFn: (raw: string) => + raw.includes('t1') + ? { content: '', reasoningContent: '', toolCalls: [{ name, arguments: '{}', id: 'c1' }] } + : { content: 'done', reasoningContent: '', toolCalls: [] }, + policy: ladderPolicy(), + }); + const rcError = (msg: string, rc: number): Error => Object.assign(new Error(msg), { rc }); + const ladderFailures = (events: AgentEvent[]) => + events.filter(e => e.type === 'agent:failed' && + ((e as { reason?: string }).reason === 'tool_result_failed' || + (e as { reason?: string }).reason === 'media_prefill_failed')); + + it('token rail rc 1: defers intact and lands on retry — this used to kill the pool', async () => { + // Armed by the tool's own execution: the first _storePrefill AFTER the + // tool ran is the settle dispatch (call-counting would hit the harness's + // root prefill and the spawn suffix instead). + const spy = new SpyTool(); + const tools = new Map([['web_search', spy]]); + const { events, trace } = await runPool({ + forkTokenQueues: [[1, STOP, STOP]], + ...callTool('web_search'), + tools, trace: true, + mutateCtx: (ctx) => { + let thrown = 0; + const orig = ctx._storePrefill.bind(ctx); + ctx._storePrefill = async (h, t) => { + if (spy.capturedContexts.length > 0 && thrown === 0) { + thrown++; + throw rcError('find_slot: no KV slot for the batch', 1); + } + return orig(h, t); + }; + }, + }); + const defers = trace.events.filter(e => e.type === 'pool:agentDefer'); + expect(defers).toHaveLength(1); + expect((defers[0] as { rc: number }).rc).toBe(1); + expect((defers[0] as { attempt: number }).attempt).toBe(1); + expect(ladderFailures(events)).toHaveLength(0); + // The retried settle actually landed — success-only, so its event exists. + expect(trace.events.some(e => e.type === 'branch:prefill' + && (e as { role?: string }).role === 'toolResult')).toBe(true); + }); + + it('deferral exhausts into a PER-AGENT terminal, never pool death', async () => { + const spy = new SpyTool(); + const tools = new Map([['web_search', spy]]); + const { events, trace } = await runPool({ + forkTokenQueues: [[1, STOP, STOP]], + ...callTool('web_search'), + tools, trace: true, + mutateCtx: (ctx) => { + const orig = ctx._storePrefill.bind(ctx); + ctx._storePrefill = async (h, t) => { + // Every settle dispatch after the tool ran hits capacity, forever. + if (spy.capturedContexts.length > 0) { + throw rcError('find_slot: no KV slot for the batch', 1); + } + return orig(h, t); + }; + }, + }); + // MAX_DEFER_ATTEMPTS defers, then the escalation — and the run RETURNED, + // which is the property: a capacity storm costs one agent, not the pool. + const defers = trace.events.filter(e => e.type === 'pool:agentDefer'); + expect(defers).toHaveLength(3); + const failures = ladderFailures(events); + expect(failures).toHaveLength(1); + expect((failures[0] as { reason: string }).reason).toBe('tool_result_failed'); + const settleFailed = trace.events.find(e => e.type === 'pool:settleFailed'); + expect((settleFailed as { rc?: number }).rc).toBe(1); + }); + + it('media rc -1: the item is dropped, the note lands, the agent continues', async () => { + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { events, trace } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + forkTokenQueues: [[1, STOP, STOP]], + ...callTool('rasterize'), + tools: toolMap, trace: true, + mutateCtx: (c) => { + c.mockMultimodalError = () => ({ message: 'invalid bitmap geometry', rc: -1 }); + }, + }); + // State was restored: nothing died, nothing was pruned for this. + expect(ladderFailures(events)).toHaveLength(0); + expect(trace.events.some(e => e.type === 'pool:settleFailed')).toBe(false); + // The substitute note prefilled as tokens — a landed toolResult event. + expect(trace.events.some(e => e.type === 'branch:prefill' + && (e as { role?: string }).role === 'toolResult' + && (e as { cells: number }).cells > 0)).toBe(true); + expect(events.some(e => e.type === 'agent:done')).toBe(true); + }); + + it('media rc 1: the cohort entry defers and settles on a later tick', async () => { + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { events, trace } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + forkTokenQueues: [[1, STOP, STOP]], + ...callTool('rasterize'), + tools: toolMap, trace: true, + mutateCtx: (c) => { + let seen = 0; + c.mockMultimodalError = () => (seen++ === 0 ? { message: 'no KV slot', rc: 1 } : null); + }, + }); + const defers = trace.events.filter(e => e.type === 'pool:agentDefer'); + expect(defers).toHaveLength(1); + expect(ladderFailures(events)).toHaveLength(0); + expect(trace.events.some(e => e.type === 'branch:prefill' + && (e as { role?: string }).role === 'toolResult')).toBe(true); + }); + + it('media fatal rc: today\'s poison path, with the rc on the record', async () => { + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { events, trace } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + forkTokenQueues: [[1, STOP, STOP]], + ...callTool('rasterize'), + tools: toolMap, trace: true, + mutateCtx: (c) => { + let seen = 0; + c.mockMultimodalError = () => (seen++ === 0 ? { message: 'compute failed', rc: -3 } : null); + }, + }); + expect(mediaFailures(events)).toHaveLength(1); + const settleFailed = trace.events.find(e => e.type === 'pool:settleFailed'); + expect((settleFailed as { rc?: number }).rc).toBe(-3); + }); + + it('the tripwire: consecutive fatals mark the backend suspect', async () => { + // Three agents, three fatal decodes in one cohort — a workload does not + // do that, a dead backend does. The third failure names it. + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { events, trace } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + taskCount: 3, + forkTokenQueues: [[1, STOP, STOP], [1, STOP, STOP], [1, STOP, STOP]], + ...callTool('rasterize'), + tools: toolMap, trace: true, + mutateCtx: (c) => { + c.mockMultimodalError = () => ({ message: 'command buffer failed', rc: -3 }); + }, + }); + expect(mediaFailures(events)).toHaveLength(3); + const details = trace.events + .filter(e => e.type === 'pool:settleFailed') + .map(e => (e as { detail: string }).detail); + expect(details.some(d => d.includes('backend suspect'))).toBe(true); + }); +}); + // ── Group 8: transient tool failure — park + retry (ToolRetryError) ── // A tool throwing ToolRetryError parks its agent (awaiting_tool, skipped by // PRODUCE — no turns/tokens/KV) and re-executes after the delay. Strategy diff --git a/packages/sdk/src/Branch.ts b/packages/sdk/src/Branch.ts index 04cadbc6..929192bf 100644 --- a/packages/sdk/src/Branch.ts +++ b/packages/sdk/src/Branch.ts @@ -243,7 +243,11 @@ export class Branch { const [result] = await this._ctx._storePrefillMultimodal( [this._handle], [sepTokens], [prompt], [bitmaps]); if (result.error) { - throw new Error(`Branch.prefillMultimodal: ${result.error}`); + // Forward the rc as data — a re-wrap that dropped it would strip the + // classification callers gate on (decodeRcOf reads it back). + const err = new Error(`Branch.prefillMultimodal: ${result.error}`); + if (result.rc !== undefined) (err as Error & { rc?: number }).rc = result.rc; + throw err; } return result; } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 96149b81..25ee6226 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -10,7 +10,7 @@ export { buildUserDelta, buildUserDeltaMultimodal, buildAssistantDelta, buildToo export type { DeltaOpts, MultimodalDelta } from './deltas'; // ── Enums + constants ──────────────────────────────────────── -export { PoolingType, CHAT_FORMAT_CONTENT_ONLY, CHAT_FORMAT_GENERIC, ReasoningFormat, GrammarTriggerType } from './types'; +export { PoolingType, CHAT_FORMAT_CONTENT_ONLY, CHAT_FORMAT_GENERIC, ReasoningFormat, GrammarTriggerType, decodeRcOf } from './types'; // ── Types ──────────────────────────────────────────────────── export type { ChatFormat } from './types'; diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index ecc6a702..177b47dc 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -289,6 +289,12 @@ export interface MultimodalPrefillResult { tokensDecoded: number; /** Branch position advance (< tokensDecoded under M-RoPE with images) */ positionAdvance: number; + /** `llama_decode`'s raw return code when this entry failed with one — + * the classification a caller acts on: `1` no KV slot (state restored, + * the branch is INTACT — retry later); `-1` invalid batch (restored); + * `2` aborted / `< -1` fatal (partial ubatches remain — POISONED). + * Absent on success and for failures that never reached llama_decode. */ + rc?: number; /** Why THIS entry failed, when it did — the cohort keeps going. * * A rejected promise would lose which entries landed, and the caller needs @@ -303,6 +309,21 @@ export interface MultimodalPrefillResult { error?: string; } +/** + * Read the `llama_decode` return code off a rejected native call, when the + * binding attached one. The ONE place the rejection's shape is known — every + * consumer classifies through this, never by matching message text. + * + * @category Branching + */ +export function decodeRcOf(err: unknown): number | undefined { + if (typeof err === 'object' && err !== null && 'rc' in err) { + const rc = (err as { rc: unknown }).rc; + if (typeof rc === 'number' && Number.isInteger(rc)) return rc; + } + return undefined; +} + /** * Options for chat template formatting * diff --git a/packages/sdk/test/MockSessionContext.ts b/packages/sdk/test/MockSessionContext.ts index 4ef42771..90bd33bb 100644 --- a/packages/sdk/test/MockSessionContext.ts +++ b/packages/sdk/test/MockSessionContext.ts @@ -237,7 +237,11 @@ export class MockSessionContext implements SessionContext { // image reports on ITS OWN result and the cohort keeps going. const failure = this.mockMultimodalError?.(prompts[i], bitmaps[i]) ?? null; if (failure) { - out.push({ tokensDecoded: 0, positionAdvance: 0, error: failure }); + const f = typeof failure === 'string' ? { message: failure } : failure; + out.push({ + tokensDecoded: 0, positionAdvance: 0, error: f.message, + ...(f.rc !== undefined ? { rc: f.rc } : {}), + }); continue; } const tokensDecoded = this._mockCells(sepTokens[i], prompts[i], bitmaps[i].length); @@ -253,10 +257,14 @@ export class MockSessionContext implements SessionContext { return out; } - /** Fail selected cohort entries. Returns a message to fail that entry, null - * to let it through — lets a test drive the one-bad-image-among-siblings - * case the native worker's per-entry try/catch exists for. */ - mockMultimodalError?: (prompt: string, bitmaps: Uint8Array[]) => string | null; + /** Fail selected cohort entries. Returns a message (optionally with the + * llama_decode rc, as the native worker attaches it) to fail that entry, + * null to let it through — lets a test drive the one-bad-image-among- + * siblings case and the rc-classified self-healing ladder. */ + mockMultimodalError?: ( + prompt: string, + bitmaps: Uint8Array[], + ) => string | { message: string; rc?: number } | null; /** Cells one multimodal prefill consumes. Text stands in at one cell per 4 * chars, matching tokenizeSync, minus the markers the native walk replaces From 542644c361c319507aa3dced841678b693513409 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Wed, 2 Sep 2026 00:38:32 +1000 Subject: [PATCH 11/69] =?UTF-8?q?feat(agents):=20heal=20=E2=80=94=20a=20po?= =?UTF-8?q?isoned=20agent=20is=20warm-respawned=20from=20its=20record?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/self-healing.md, wave B. The poison costs the agent its branch, not its task: within budget (one heal per lineage) and with the backend healthy, the pool forks the spine — the prefix, seed images included, rides the fork for free — replays the agent's record, and admits the replacement as a NEW agent. The original's agent:failed stands; the lineage rides pool:agentHeal {of, agentId, rc, attempt, pressure}. The record is the trace, held where heal can reach it: every piece was already emitted (agent:turn.rawOutput, tool:result, probeText) — the pool now keeps the same data as one ordered AgentTurnRecord list per agent, alongside the retained spawn spec (the birth certificate the reboot design also wants). replayAgentTurns is the agent-shaped sibling of replayTurns: assistant deltas, tool-result deltas (media-bearing ones resolve through materialize and the throwing single-branch prefill), probe prefills — provenance-blind and lifetime-free. Heals drain at the SPAWN phase, suffix batched with ordinary spawns, on the loop fiber. A replay that cannot land discards the half-built replacement; a replacement that poisons again goes terminal — a second failure on replayed state is evidence, not bad luck. Three tests: the warm respawn end to end, the budget, and the record-replay primitive across all four delta kinds. The enqueue is mutation-verified. --- packages/agents/src/agent-pool.ts | 135 ++++++++++++++++++++++- packages/agents/src/index.ts | 3 +- packages/agents/src/replay.ts | 59 +++++++++- packages/agents/src/trace-types.ts | 19 ++++ packages/agents/test/agent-pool.test.ts | 55 +++++++++ packages/agents/test/attachments.test.ts | 32 +++++- 6 files changed, 294 insertions(+), 9 deletions(-) diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index b2d27cd1..d3824f5d 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -19,6 +19,8 @@ import { NullTraceWriter } from './trace-writer'; import type { TraceEvent } from './trace-types'; import type { AgentPolicy, IdleReason, ToolRetryAction } from './AgentPolicy'; import { Agent } from './Agent'; +import { replayAgentTurns } from './replay'; +import type { AgentTurnRecord } from './replay'; import { DefaultAgentPolicy, RECOVERY_PREFILL_OVERHEAD, BATCH_BUFFER } from './AgentPolicy'; import type { PolicyConfig } from './AgentPolicy'; import { Tool, ToolRetryError, takeToolMedia, TOOL_CONTEXT_KEY, TOOL_IMAGE_ERROR_KEY } from './Tool'; @@ -48,6 +50,9 @@ const MAX_DEFER_ATTEMPTS = 3; * and deferring or healing there burns budget for nothing. Reset by any * successful dispatch. */ const BACKEND_TRIPWIRE_N = 3; +/** Heals per lineage. A replacement that poisons AGAIN goes terminal — a + * second failure on replayed state is evidence, not bad luck. */ +const MAX_HEAL_ATTEMPTS = 1; /** Minimal event sender interface — accepts any Channel close type */ type EventSender = { send(value: AgentEvent): Operation }; @@ -60,13 +65,16 @@ type SettledTool = { probe?: string; } & ( /** The token rail: the result tokenized here and prefills as tokens. */ - | { rail: 'token'; prefillTokens: number[]; media?: never } + | { rail: 'token'; prefillTokens: number[]; media?: never; resultStr?: string } /** The embedding rail. `llama_batch` is token-XOR-embd, so this cannot join * a token batch — a separate call, not a separate strategy. The delta stops * at the string stage because mtmd tokenizes downstream, which is why the * cost had to be MEASURED. */ | { rail: 'media'; + /** The tool-result string the delta was built from — the heal record's + * replay material (docs/self-healing.md). */ + resultStr?: string; prefillTokens?: never; media: { delta: MultimodalDelta; cells: number; attachments: readonly Attachment[] }; } @@ -893,6 +901,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation([ 'pool:agentNudge', 'tool:authReject', 'pool:agentDrop', 'branch:prune', + 'pool:agentDefer', 'pool:agentHeal', 'pool:settleFailed', // The compiled per-agent prompt SUFFIX. In shared-spine mode the // system+tool header lives on the spine prefix (inherited via fork) // and is deliberately not repeated here — the mirror carries what @@ -1090,6 +1099,25 @@ export function useAgentPool(opts: AgentPoolOptions): Operation(); + const recordFor = (id: number): AgentTurnRecord[] => { + let r = turnRecordsById.get(id); + if (!r) { r = []; turnRecordsById.set(id, r); } + return r; + }; + /** The birth certificate — what a heal reproduces (seed, tools, ability, + * the spec's exact text). Recorded at the SPAWN drain. */ + const specById = new Map(); + /** Heal count per lineage (a replacement inherits its original's + 1). */ + const healAttemptOf = new Map(); + const pendingHeals: { + spec: AgentTaskSpec; records: AgentTurnRecord[]; + of: number; rc?: number; attempt: number; + }[] = []; // Pool-level branch cleanup — ensures orphan-branch cleanup even when // spawns are lazy and the orchestrator's spawn scope exits early. @@ -1329,7 +1357,15 @@ export function useAgentPool(opts: AgentPoolOptions): Operation { + const resultStr = resultStrOverride ?? src.resultStr; + if (resultStr) { + recordFor(a.id).push({ + kind: 'toolResult', resultStr, callId: src.callId, + ...(refs && refs.length > 0 ? { attachments: refs } : {}), + }); + } settledAgents.push(a); settledOrder.push({ agentId: a.id, callId: src.callId, cells }); if (src.probe) itemProbes.set(a.id, src.probe); @@ -1508,11 +1544,13 @@ export function useAgentPool(opts: AgentPoolOptions): Operation store.prefill([[a.branch, noteTokens]])); - bookSettled(a, m.src, noteTokens.length); + // The record carries what LANDED — the note, not the dropped item. + bookSettled(a, m.src, noteTokens.length, undefined, noteStr); continue; } @@ -1526,6 +1564,20 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0 || pendingExtends.length > 0) { + if (pendingSpawns.length > 0 || pendingExtends.length > 0 || pendingHeals.length > 0) { const drainedSpawns = pendingSpawns.splice(0, pendingSpawns.length); const drainedExtends = pendingExtends .splice(0, pendingExtends.length) .filter(e => !e.discarded); + // Heals fork the spine and batch their suffix prefills with the + // spawns — a heal IS a spawn wearing a lineage (docs/self-healing.md). + // The record replay runs after the batch, per replacement. + const drainedHeals: { + h: (typeof pendingHeals)[number]; + agent: Agent; suffixTokens: number[]; formattedPrompt: string; + }[] = []; + for (const h of pendingHeals.splice(0)) { + const setup = yield* setupAgent(spine, h.spec, ctx, enableThinking, runNow); + drainedHeals.push({ h, ...setup }); + } + const prefillPairs: [Branch, number[]][] = [ ...drainedSpawns.map(s => [s.agent.branch, s.suffixTokens] as [Branch, number[]]), + ...drainedHeals.map(d => [d.agent.branch, d.suffixTokens] as [Branch, number[]]), ...drainedExtends.map(e => [spine, e.tokens] as [Branch, number[]]), ]; @@ -2123,6 +2189,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation ({ name: tc.name, arguments: tc.arguments })), }); + recordFor(a.id).push({ kind: 'assistant', text: a.rawOutput }); // Policy decides what to do with the parsed output const action = policy.onProduced(a, parsed, pressure, policyConfig); diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 4714d2e1..efec7154 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -49,7 +49,8 @@ export type { PromptState, PromptSection, PromptStep } from './prompt'; export { reduce } from './combinators'; export { parallel, chain, fanout, dag } from './orchestrators'; export type { SpawnSpec, ChainStep, DAGNode, Orchestrator, PoolContext } from './orchestrators'; -export { extractSpineSeed, extractSpineCheckpoint, reconstructBranch, replayTurns } from './replay'; +export { extractSpineSeed, extractSpineCheckpoint, reconstructBranch, replayTurns, replayAgentTurns } from './replay'; +export type { AgentTurnRecord } from './replay'; export type { BranchCheckpoint } from './replay'; export type { Toolkit } from './toolkit'; diff --git a/packages/agents/src/replay.ts b/packages/agents/src/replay.ts index 9dc707ac..079e7378 100644 --- a/packages/agents/src/replay.ts +++ b/packages/agents/src/replay.ts @@ -1,6 +1,9 @@ import { call, ensure } from 'effection'; import type { Operation } from 'effection'; -import { Branch, buildTurnDelta, MEDIA_MARKER } from '@lloyal-labs/sdk'; +import { + Branch, buildAssistantDelta, buildToolResultDelta, + buildToolResultDeltaMultimodal, buildTurnDelta, MEDIA_MARKER, +} from '@lloyal-labs/sdk'; import { Ctx, Store, Attachments } from './context'; import type { TraceEvent } from './trace-types'; import type { Attachment } from '@lloyal-labs/media'; @@ -257,3 +260,57 @@ export function* replayTurns( yield* call(() => store.prefill([[branch, delta]])); } } + +/** + * One KV delta in an AGENT's life since its spawn — the heal record + * (docs/self-healing.md). Every piece is already on the trace in its own + * event (`agent:turn.rawOutput`, `tool:result`, `branch:prefill.probeText`); + * this is the same data as one ordered, replayable list. + */ +export type AgentTurnRecord = + | { kind: 'assistant'; text: string } + | { + kind: 'toolResult'; resultStr: string; callId: string; + /** Roots for a media-bearing result — resolved through the run's + * attachment store at replay, exactly as the seed's are. */ + attachments?: readonly Attachment[]; + } + | { kind: 'probe'; text: string }; + +/** + * Replay an agent's recorded deltas onto a branch, in order. + * + * The agent-shaped sibling of {@link replayTurns} — an agent's KV timeline + * is assistant turns, tool-result deltas and probe prefills, not + * user/assistant pairs. Same contract: provenance-blind (the branch is + * typically a fork of the live spine, whose prefix — seed images included — + * rides for free), lifetime-free, and media-bearing turns resolve through + * `materialize()` with the throwing single-branch prefill, so a record whose + * content is gone fails loudly rather than replaying a marker as text. + */ +export function* replayAgentTurns( + branch: Branch, + records: readonly AgentTurnRecord[], + opts: { enableThinking?: boolean } = {}, +): Operation { + const ctx = yield* Ctx.expect(); + const store = yield* Store.expect(); + const attachments = yield* Attachments.expect(); + for (const r of records) { + if (r.kind === 'assistant') { + const tokens = buildAssistantDelta(ctx, r.text, opts); + yield* call(() => store.prefill([[branch, tokens]])); + } else if (r.kind === 'probe') { + const tokens = ctx.tokenizeSync(r.text, false); + if (tokens.length > 0) yield* call(() => store.prefill([[branch, tokens]])); + } else if (r.attachments && r.attachments.length > 0) { + const { bitmaps } = materialize(attachments, r.attachments); + const delta = buildToolResultDeltaMultimodal( + ctx, r.resultStr, r.callId, [...bitmaps], opts); + yield* call(() => branch.prefillMultimodal(delta.prompt, delta.bitmaps, delta.sep)); + } else { + const tokens = buildToolResultDelta(ctx, r.resultStr, r.callId, opts); + yield* call(() => store.prefill([[branch, tokens]])); + } + } +} diff --git a/packages/agents/src/trace-types.ts b/packages/agents/src/trace-types.ts index df9548fd..5d15ced9 100644 --- a/packages/agents/src/trace-types.ts +++ b/packages/agents/src/trace-types.ts @@ -276,6 +276,25 @@ export type TraceEvent = }; } + /** A poisoned agent was HEALED: its branch was pruned, a replacement + * forked from the spine, and its record (suffix + turns + tool results, + * media included) replayed onto the fork — docs/self-healing.md. The + * replacement is a NEW agent (new branch = new id); `of` is the lineage + * for display. The original's `agent:failed` stands. */ + | TraceEventBase & { + type: 'pool:agentHeal'; + /** The poisoned original. */ + of: number; + /** The replacement. */ + agentId: number; + rc?: number; + attempt: number; + pressure: { + remaining: number | null; cellsUsed: number; + nCtx: number; headroom: number | null; + }; + } + // ── Agent lifecycle span ───────────────────── // Trace mirrors of the bus events: `agent:spawn` opens the agent's span // (`parentAgentId` = the parent BRANCH handle — the spine for pool diff --git a/packages/agents/test/agent-pool.test.ts b/packages/agents/test/agent-pool.test.ts index 3c5137e1..82c612ed 100644 --- a/packages/agents/test/agent-pool.test.ts +++ b/packages/agents/test/agent-pool.test.ts @@ -1818,6 +1818,61 @@ describe('self-healing ladder', () => { .map(e => (e as { detail: string }).detail); expect(details.some(d => d.includes('backend suspect'))).toBe(true); }); + + it('heal: a poisoned agent is warm-respawned from its record', async () => { + // Fatal rc poisons the original; the ladder queues a heal. The + // replacement forks the spine, replays the record (the original's + // turn-1 text), and runs to its own terminal — the original's + // agent:failed stands, the lineage rides pool:agentHeal. + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { events, trace } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + forkTokenQueues: [[1, STOP, STOP], [STOP]], + ...callTool('rasterize'), + tools: toolMap, trace: true, + mutateCtx: (c) => { + let seen = 0; + c.mockMultimodalError = () => + (seen++ === 0 ? { message: 'compute failed', rc: -3 } : null); + }, + }); + expect(mediaFailures(events)).toHaveLength(1); + const heals = trace.events.filter(e => e.type === 'pool:agentHeal'); + expect(heals).toHaveLength(1); + const heal = heals[0] as { of: number; agentId: number; rc?: number; attempt: number }; + expect(heal.rc).toBe(-3); + expect(heal.attempt).toBe(1); + expect(heal.agentId).not.toBe(heal.of); + // The replacement is a real agent: it spawned and reached a terminal. + const spawns = events.filter(e => e.type === 'agent:spawn'); + expect(spawns).toHaveLength(2); + expect(events.some(e => e.type === 'agent:done' + && (e as { agentId: number }).agentId === heal.agentId)).toBe(true); + // The record replayed: the replacement's fork got prefills beyond its + // suffix (the assistant turn), visible as its agentSuffix prompt:format + // carrying the SAME task as the original's. + const suffixes = trace.events.filter(e => e.type === 'prompt:format' + && (e as { role?: string }).role === 'agentSuffix'); + expect(suffixes).toHaveLength(2); + }); + + it('heal budget: a replacement that poisons again goes terminal, no third agent', async () => { + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { events, trace } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + forkTokenQueues: [[1, STOP, STOP], [1, STOP, STOP]], + ...callTool('rasterize'), + tools: toolMap, trace: true, + mutateCtx: (c) => { + c.mockMultimodalError = () => ({ message: 'compute failed', rc: -3 }); + }, + }); + // Two poisons (original + replacement), ONE heal — the second failure on + // replayed state is evidence, not bad luck. + expect(mediaFailures(events)).toHaveLength(2); + expect(trace.events.filter(e => e.type === 'pool:agentHeal')).toHaveLength(1); + expect(events.filter(e => e.type === 'agent:spawn')).toHaveLength(2); + }); }); // ── Group 8: transient tool failure — park + retry (ToolRetryError) ── diff --git a/packages/agents/test/attachments.test.ts b/packages/agents/test/attachments.test.ts index 50f185af..09f96a46 100644 --- a/packages/agents/test/attachments.test.ts +++ b/packages/agents/test/attachments.test.ts @@ -37,7 +37,7 @@ import { initAgents } from '../src/init'; import { Branch } from '../../sdk/src/Branch'; import { CapturingTraceWriter } from './helpers/capturing-trace'; import { rawIngress } from './helpers/raw-ingress'; -import { reconstructBranch, extractSpineSeed, replayTurns, type BranchCheckpoint } from '../src/replay'; +import { reconstructBranch, extractSpineSeed, replayTurns, replayAgentTurns, type AgentTurnRecord, type BranchCheckpoint } from '../src/replay'; import { Ctx, Store, Attachments } from '../src/context'; import type { TraceEvent } from '../src/trace-types'; @@ -157,6 +157,36 @@ describe('reconstructBranch', () => { }); }); + it('replayAgentTurns replays an agent-shaped record — assistant, tool result, probe, media', async () => { + // The agent's KV timeline is not user/assistant pairs: assistant turns, + // tool-result deltas and probe prefills, with a media-bearing result + // resolving through the store exactly as a seed does. + const store = new MemoryAttachmentStore(); + const root = attach(store, PNG); + await withCtx(function*(ctx) { + const spine = yield* reconstructBranch(cp()); + const fork = spine.forkSync(); + let tokenPrefills = 0; + const orig = ctx._storePrefill.bind(ctx); + ctx._storePrefill = async (h, t) => { tokenPrefills++; return orig(h, t); }; + + const records: AgentTurnRecord[] = [ + { kind: 'assistant', text: 'looking at the chart rasterize' }, + { kind: 'toolResult', resultStr: '{"page":"p1"}', callId: 'c1' }, + { kind: 'probe', text: 'what stands out?' }, + { kind: 'toolResult', resultStr: '{"page":"p2"}', callId: 'c2', attachments: [root] }, + ]; + yield* replayAgentTurns(fork, records, { enableThinking: false }); + + // Three token deltas (assistant, tool result, probe)… + expect(tokenPrefills).toBe(3); + // …and the media record went down the embedding rail with the stored bytes. + expect(ctx.multimodalPrefills).toHaveLength(1); + expect(ctx.multimodalPrefills[0].bitmapCounts).toEqual([1]); + return null; + }, store); + }); + it('refuses a marker with no attachment references', async () => { // The pre-attachments behaviour, preserved: a trace that recorded only // the marker still cannot be replayed. From 73f07685d865bde633aa47d8743da16adca37a77 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Wed, 2 Sep 2026 01:43:22 +1000 Subject: [PATCH 12/69] fix(agents): heal replays up to the last COMPLETED transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on real weights, not in mocks: the record's tail is the poisoned transaction itself — an assistant turn whose tool call never settled — and replaying it left the replacement dangling mid-call (it emitted a stray think and stopped instead of re-driving the tool). The heal enqueue now drops trailing assistant entries, so the replacement regenerates that turn and calls the tool itself. Proven by repro-heal.mjs: injected fatal on the first media cohort → pool:agentHeal → the replacement re-called the tool against the real kernel and answered the image question. --- packages/agents/src/agent-pool.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index d3824f5d..409f7a02 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -1573,8 +1573,19 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0 && records[records.length - 1].kind === 'assistant') { + records.pop(); + } pendingHeals.push({ - spec: healSpec, records: recordFor(a.id).slice(), + spec: healSpec, records, of: a.id, ...(rc !== undefined ? { rc } : {}), attempt: healAttempt, }); } From 1ca577bea97f57434005b5ad76f9be70e2b51fec Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Wed, 2 Sep 2026 02:11:15 +1000 Subject: [PATCH 13/69] alpha: cut 1 Cut 0's media was burned before the review fixes landed (the publish-loop guard skips existing versions, so pressing the button would have shipped a stale @lloyal-labs/media). One set id for the whole cut, as designed: everything moves to -alpha.1 with exact pins. --- packages/abilities/corpus/package.json | 6 +++--- packages/abilities/web/package.json | 4 ++-- packages/abilities/wikipedia/package.json | 4 ++-- packages/agents/package.json | 6 +++--- packages/dev-tools/package.json | 4 ++-- packages/media/package.json | 2 +- packages/rig/package.json | 10 +++++----- packages/sdk/package.json | 2 +- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/abilities/corpus/package.json b/packages/abilities/corpus/package.json index 76373b21..1ef5be5f 100644 --- a/packages/abilities/corpus/package.json +++ b/packages/abilities/corpus/package.json @@ -25,9 +25,9 @@ "build": "tsc -b" }, "peerDependencies": { - "@lloyal-labs/lloyal-agents": "6.0.0-alpha.0", - "@lloyal-labs/lloyal.node": "3.2.0-alpha.0", - "@lloyal-labs/rig": "5.6.0-alpha.0", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.1", + "@lloyal-labs/lloyal.node": "3.2.0-alpha.1", + "@lloyal-labs/rig": "5.6.0-alpha.1", "effection": "^4.0.2" } } diff --git a/packages/abilities/web/package.json b/packages/abilities/web/package.json index c07e645c..b4bc98ce 100644 --- a/packages/abilities/web/package.json +++ b/packages/abilities/web/package.json @@ -29,8 +29,8 @@ "linkedom": "^0.18.12" }, "peerDependencies": { - "@lloyal-labs/lloyal-agents": "6.0.0-alpha.0", - "@lloyal-labs/rig": "5.6.0-alpha.0", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.1", + "@lloyal-labs/rig": "5.6.0-alpha.1", "effection": "^4.0.2" } } diff --git a/packages/abilities/wikipedia/package.json b/packages/abilities/wikipedia/package.json index 87510c5a..781bd101 100644 --- a/packages/abilities/wikipedia/package.json +++ b/packages/abilities/wikipedia/package.json @@ -24,8 +24,8 @@ "build": "tsc -b" }, "peerDependencies": { - "@lloyal-labs/lloyal-agents": "6.0.0-alpha.0", - "@lloyal-labs/rig": "5.6.0-alpha.0", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.1", + "@lloyal-labs/rig": "5.6.0-alpha.1", "effection": "^4.0.2" } } diff --git a/packages/agents/package.json b/packages/agents/package.json index a2f89bd7..6b45b44a 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/lloyal-agents", - "version": "6.0.0-alpha.0", + "version": "6.0.0-alpha.1", "description": "Multi-agent inference inside the decode loop — structured concurrency over shared KV state", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -31,10 +31,10 @@ "build": "tsc -b" }, "dependencies": { - "@lloyal-labs/sdk": "3.2.0-alpha.0", + "@lloyal-labs/sdk": "3.2.0-alpha.1", "effection": "^4.0.2", "eta": "^4.5.1", - "@lloyal-labs/media": "0.2.0-alpha.0" + "@lloyal-labs/media": "0.3.0-alpha.1" }, "files": [ "dist/", diff --git a/packages/dev-tools/package.json b/packages/dev-tools/package.json index be449a4b..26e346c4 100644 --- a/packages/dev-tools/package.json +++ b/packages/dev-tools/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/dev-tools", - "version": "0.5.0-alpha.0", + "version": "0.5.0-alpha.1", "description": "The dev pane for scaffolded harnesses — timeline, sources, and settings over the event bus, gated by the runner's dev signal", "type": "module", "main": "dist/index.js", @@ -52,7 +52,7 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { - "@lloyal-labs/rig": "5.6.0-alpha.0", + "@lloyal-labs/rig": "5.6.0-alpha.1", "zustand": "^5.0.15" }, "peerDependencies": { diff --git a/packages/media/package.json b/packages/media/package.json index d6cf965e..36038425 100644 --- a/packages/media/package.json +++ b/packages/media/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/media", - "version": "0.2.0-alpha.0", + "version": "0.3.0-alpha.1", "description": "Content addressing for a harness — an OCI layout, and the image normalizer that feeds it", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/rig/package.json b/packages/rig/package.json index 3c0c8798..b23fdf47 100644 --- a/packages/rig/package.json +++ b/packages/rig/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/rig", - "version": "5.6.0-alpha.0", + "version": "5.6.0-alpha.1", "description": "Retrieval-Interleaved Generation for lloyal-agents", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -41,17 +41,17 @@ }, "dependencies": { "@lloyal-labs/channel-verify": "^0.3.1", - "@lloyal-labs/lloyal-agents": "6.0.0-alpha.0", - "@lloyal-labs/sdk": "3.2.0-alpha.0", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.1", + "@lloyal-labs/sdk": "3.2.0-alpha.1", "@mozilla/readability": "^0.6.0", "effection": "^4.0.2", "ignore": "^7.0.5", "linkedom": "^0.18.12", "semver": "^7.8.1", - "@lloyal-labs/media": "0.2.0-alpha.0" + "@lloyal-labs/media": "0.3.0-alpha.1" }, "peerDependencies": { - "@lloyal-labs/lloyal.node": "3.2.0-alpha.0" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.1" }, "files": [ "dist/", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 075bb980..c38de009 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/sdk", - "version": "3.2.0-alpha.0", + "version": "3.2.0-alpha.1", "description": "Backend-agnostic TypeScript SDK for the lloyal inference platform", "main": "dist/index.js", "types": "dist/index.d.ts", From 6ba9065cc566846edb884e416b767186de333893 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Wed, 2 Sep 2026 02:16:45 +1000 Subject: [PATCH 14/69] alpha: media rides its pending 0.2.0 base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cut script bumped from registry latest, but media's latest IS an alpha (the manual first publish stamps latest — npm behavior). An alpha is not a base: the stable it prefigures hasn't shipped, so its release triple is the pending base, unbumped. Cut 1 for media is 0.2.0-alpha.1, not 0.3.0-alpha.1. --- packages/agents/package.json | 2 +- packages/media/package.json | 2 +- packages/rig/package.json | 2 +- scripts/cut-alpha.mjs | 9 +++++++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/agents/package.json b/packages/agents/package.json index 6b45b44a..74a07ddb 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -34,7 +34,7 @@ "@lloyal-labs/sdk": "3.2.0-alpha.1", "effection": "^4.0.2", "eta": "^4.5.1", - "@lloyal-labs/media": "0.3.0-alpha.1" + "@lloyal-labs/media": "0.2.0-alpha.1" }, "files": [ "dist/", diff --git a/packages/media/package.json b/packages/media/package.json index 36038425..57e48805 100644 --- a/packages/media/package.json +++ b/packages/media/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/media", - "version": "0.3.0-alpha.1", + "version": "0.2.0-alpha.1", "description": "Content addressing for a harness — an OCI layout, and the image normalizer that feeds it", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/rig/package.json b/packages/rig/package.json index b23fdf47..b3c73643 100644 --- a/packages/rig/package.json +++ b/packages/rig/package.json @@ -48,7 +48,7 @@ "ignore": "^7.0.5", "linkedom": "^0.18.12", "semver": "^7.8.1", - "@lloyal-labs/media": "0.3.0-alpha.1" + "@lloyal-labs/media": "0.2.0-alpha.1" }, "peerDependencies": { "@lloyal-labs/lloyal.node": "3.2.0-alpha.1" diff --git a/scripts/cut-alpha.mjs b/scripts/cut-alpha.mjs index 08f7d0c4..a7fc0387 100644 --- a/scripts/cut-alpha.mjs +++ b/scripts/cut-alpha.mjs @@ -40,6 +40,11 @@ const bump = (v, level) => { const [maj, min] = v.split('.').map(Number); return level === 'major' ? `${maj + 1}.0.0` : `${maj}.${min + 1}.0`; }; +/** A prerelease `latest` (a manual first alpha publish stamps latest — npm + * behavior) is not a base to bump FROM: the stable it prefigures hasn't + * shipped, so its release triple IS the pending base. A stable latest + * bumps by the arc's level. */ +const nextBase = (reg, level) => (reg.includes('-') ? reg.split('-')[0] : bump(reg, level)); /** Registry base, or the local manifest's for a package npm has never seen. * NOTE: npm cannot CREATE a package name from CI (interactive 2FA) — a * brand-new package (media, on this arc) needs ONE manual `npm publish` @@ -57,10 +62,10 @@ const alphas = {}; for (const [dir, level] of Object.entries(CUTS)) { const pkg = JSON.parse(readFileSync(`${dir}/package.json`, 'utf8')); const base = pkg.version.split('-')[0]; // a prior cut's -alpha.N is not a base - alphas[pkg.name] = `${bump(latest(pkg.name, base), level)}-alpha.${CUT}`; + alphas[pkg.name] = `${nextBase(latest(pkg.name, base), level)}-alpha.${CUT}`; } for (const [name, level] of Object.entries(EXTERNAL)) { - alphas[name] = `${bump(latest(name, '0.0.0'), level)}-alpha.${CUT}`; + alphas[name] = `${nextBase(latest(name, '0.0.0'), level)}-alpha.${CUT}`; } console.log(`cut ${CUT}${DRY ? ' (dry run)' : ''}:`); From 05eec11a24b0c3a6f296401f64b584c5f124b30a Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Wed, 2 Sep 2026 15:07:39 +1000 Subject: [PATCH 15/69] feat(dev-tools,agents,rig,sdk): trace mirror at the writer boundary + session/spine timeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rig's trace writer now mirrors every write onto the dev bus, with attribution carried in the event data itself (agentId/callId); the pool tee and its bridge are gone. The pane folds the mirror into three scope rails — session, spine, lanes — with elbow connectors for every orchestrator shape, spine growth ticks from spine:extend, and a tokens-saved line on the cache-read convention: every fork-inherited token counts; the spine's own build is the cache write. --- packages/agents/src/agent-pool.ts | 103 +--- packages/agents/src/index.ts | 1 + packages/agents/src/init.ts | 5 +- packages/agents/src/orchestrators.ts | 23 +- packages/agents/src/trace-types.ts | 23 +- packages/agents/src/types.ts | 26 +- packages/agents/test/invariants/predicates.ts | 48 +- ....ts => trace-attribution.scenario.test.ts} | 39 +- packages/agents/test/trace-tee.test.ts | 108 ++-- packages/dev-tools/src/index.ts | 232 +++++++- packages/dev-tools/src/react.tsx | 522 +++++++++++++++++- packages/dev-tools/src/store.ts | 5 + packages/dev-tools/test/model.test.ts | 202 ++++++- packages/rig/src/trace-sink.ts | 38 +- packages/rig/test/trace-sink.test.ts | 71 +++ packages/sdk/src/Session.ts | 8 +- 16 files changed, 1240 insertions(+), 214 deletions(-) rename packages/agents/test/invariants/scenarios/{trace-tee-mirrors.scenario.test.ts => trace-attribution.scenario.test.ts} (58%) create mode 100644 packages/rig/test/trace-sink.test.ts diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index 409f7a02..2f359e6f 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -11,11 +11,7 @@ import type { MultimodalDelta } from '@lloyal-labs/sdk'; import type { Attachment } from '@lloyal-labs/media'; import { useTraceScope } from './trace-scope'; -/** Brands a tee-wrapping TraceWriter so a nested pool never wraps it again - * (see the teeOn comment at the tee construction). */ -const TEE_MARK = Symbol.for('lloyal.traceTee'); import type { TraceWriter } from './trace-writer'; -import { NullTraceWriter } from './trace-writer'; import type { TraceEvent } from './trace-types'; import type { AgentPolicy, IdleReason, ToolRetryAction } from './AgentPolicy'; import { Agent } from './Agent'; @@ -861,78 +857,34 @@ export function useAgentPool(opts: AgentPoolOptions): Operation(); - if (teeOn) { - yield* spawn(function*() { - for (const ev of yield* each(traceBridge)) { - yield* poolChannel.send(ev); - yield* each.next(); - } - }); - } - const MIRRORED_POOL_EVENTS = new Set([ - 'pool:agentNudge', 'tool:authReject', 'pool:agentDrop', 'branch:prune', - 'pool:agentDefer', 'pool:agentHeal', 'pool:settleFailed', - // The compiled per-agent prompt SUFFIX. In shared-spine mode the - // system+tool header lives on the spine prefix (inherited via fork) - // and is deliberately not repeated here — the mirror carries what - // this spawn formatted, honestly labeled by the pane. agentId is - // the attribution. - 'prompt:format', - // The dispatch record carries explore/exploit + callId — the live - // consumer keys retrieval metadata off it. - 'tool:dispatch', - ]); - const tw: TraceWriter = !teeOn ? baseTw : Object.assign({ - nextId: () => baseTw.nextId(), - flush: () => baseTw.flush(), - write: (event: TraceEvent) => { - baseTw.write(event); - if (!MIRRORED_POOL_EVENTS.has(event.type)) return; - const e = event as { agentId?: number; branchHandle?: number }; - try { - traceBridge.send({ type: 'agent:trace', agentId: e.agentId ?? e.branchHandle ?? -1, event }); - } catch { /* mirror is best-effort — never disrupt the write */ } - }, - }, { [TEE_MARK]: true }); - const toolTee = (agentId: number, callId: string, dispatchTraceId: number): TraceWriter => Object.assign({ - nextId: () => baseTw.nextId(), - flush: () => baseTw.flush(), - write: (event: TraceEvent) => { - const stamped = event.parentTraceId == null ? { ...event, parentTraceId: dispatchTraceId } : event; - baseTw.write(stamped); - try { traceBridge.send({ type: 'agent:trace', agentId, callId, event: stamped }); } catch { /* best-effort */ } - }, - }, { [TEE_MARK]: true }); + // ── Dispatch attribution ──────────────────────────────────── + // dispatch() sets a per-dispatch tee as the Trace context for the tool's + // execution, stamping the dispatching agent + call INTO the event data: + // `agentId`, `callId`, and a real `parentTraceId` replacing the + // abilities' hardcoded null. Attribution lives in the record itself, so + // every sink reads the same fields — the file, and the dev pane via the + // writer-boundary mirror (rig's `useTraceWriter`). That mirror is where + // the bus tee moved: ONE mirror at the boundary every write crosses, + // instead of per-layer mirrors with per-layer allowlists (session-level + // writes like the trunk's `warmDelta` never reached the old pool tee). + // Only-if-absent semantics keep a nested pool's (DelegateTool) inner + // attribution intact: its tee stamps first, this one defers. + const toolTee = (agentId: number, callId: string, dispatchTraceId: number): TraceWriter => ({ + nextId: () => tw.nextId(), + flush: () => tw.flush(), + write: (event: TraceEvent) => tw.write({ + ...event, + agentId: event.agentId ?? agentId, + callId: event.callId ?? callId, + parentTraceId: event.parentTraceId ?? dispatchTraceId, + }), + }); const { spine, orchestrate, toolsJson, tools, maxTurns = 100, terminalToolName, trace = false, pruneOnReturn = false, enableThinking = true, eagerGrammar } = opts; // Tool index map for trace — position in toolkit array @@ -1200,6 +1152,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0 ? { after: spec.after } : {}), parent, assignedAbility: spec.assignedAbility, }; @@ -2032,7 +1985,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation fanoutTool.execute(toolArgs, toolContext)); }); @@ -2061,7 +2014,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0 ? { after: s.task.after } : {}) }); } // Finish the heals: replay each replacement's record onto its fork @@ -2281,7 +2234,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0 ? { after: h.spec.after } : {}) }); } } diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index efec7154..deb49b64 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -73,6 +73,7 @@ export type { DivergeAttempt, DivergeResult, AgentEvent, + AgentTraceEvent, } from './types'; export type { diff --git a/packages/agents/src/init.ts b/packages/agents/src/init.ts index 252762b2..49f025f4 100644 --- a/packages/agents/src/init.ts +++ b/packages/agents/src/init.ts @@ -85,7 +85,7 @@ export function* initAgents( const session = new Session({ ctx, store, - onPrefill: ({ branchHandle, cells, content, attachments: roots }) => { + onPrefill: ({ branchHandle, cells, content, attachments: roots, role: speaker, query, response }) => { tw.write({ traceId: tw.nextId(), parentTraceId: null, @@ -94,7 +94,10 @@ export function* initAgents( branchHandle, cells, role: 'warmDelta', + speaker, content, + ...(query !== undefined ? { query } : {}), + ...(response !== undefined ? { response } : {}), // The SDK carries these STRUCTURALLY (`{digest, mediaType, size}`) // because it has no attachment concept and must not grow one — the // same layering rule that keeps agent concepts out of liblloyal. They diff --git a/packages/agents/src/orchestrators.ts b/packages/agents/src/orchestrators.ts index 9ea68474..7cc4f775 100644 --- a/packages/agents/src/orchestrators.ts +++ b/packages/agents/src/orchestrators.ts @@ -16,6 +16,12 @@ export interface SpawnSpec { systemPrompt: string; /** PRNG seed for sampler diversity. */ seed?: number; + /** + * Agent ids whose completion gated this spawn — the DAG's dependency + * edges, resolved at spawn time. The `ability`-label class: non-enforcing, + * carried on `agent:spawn` for the trace and the dev pane only. + */ + after?: number[]; /** Parent branch to fork from. Falls back to ctx.spine. */ parent?: Branch; /** @@ -230,6 +236,10 @@ export const dag = (nodes: DAGNode[]): Orchestrator => { // if node A throws, every task awaiting A's Task receives the // same error, and structured concurrency halts the rest. const tasks = new Map>(); + // Node id → spawned agent id, filled as each node forks. A dependent's + // deps have all completed (and therefore registered) before it spawns, + // so the lookup below never races. + const agentIds = new Map(); function* runNode(n: DAGNode): Operation { // Gate: wait for every declared dep's task to complete. The map is @@ -239,9 +249,16 @@ export const dag = (nodes: DAGNode[]): Orchestrator => { for (const depId of n.dependsOn ?? []) { yield* tasks.get(depId)!; } - const agent = yield* ctx.waitFor( - yield* ctx.spawn({ ...n.task, parent: n.task.parent ?? ctx.spine }), - ); + const after = (n.dependsOn ?? []) + .map((d) => agentIds.get(d)) + .filter((x): x is number => typeof x === 'number'); + const spawned = yield* ctx.spawn({ + ...n.task, + parent: n.task.parent ?? ctx.spine, + ...(after.length > 0 ? { after } : {}), + }); + agentIds.set(n.id, spawned.id); + const agent = yield* ctx.waitFor(spawned); if (agent.result && n.userContent) { yield* ctx.extendSpine(n.userContent, agent.result); } diff --git a/packages/agents/src/trace-types.ts b/packages/agents/src/trace-types.ts index 5d15ced9..afd4d138 100644 --- a/packages/agents/src/trace-types.ts +++ b/packages/agents/src/trace-types.ts @@ -17,6 +17,17 @@ interface TraceEventBase { traceId: TraceId; parentTraceId: TraceId | null; ts: number; // performance.now() + /** Attribution, in the DATA rather than any envelope: the pool's dispatch + * tee stamps the dispatching agent onto every write made while a tool + * executes; write sites that know their agent (an agentSuffix + * `prompt:format`, the pool's intervention events) stamp it themselves. + * Only-if-absent semantics let an inner (nested-pool) stamp win. Readers + * — the file, the dev pane's writer-boundary mirror — take attribution + * from these fields; nothing re-derives it downstream. */ + agentId?: number; + /** The dispatch this write belongs to, stamped alongside {@link agentId} + * for tool-scoped writes. */ + callId?: string; } /** @@ -42,9 +53,6 @@ export type TraceEvent = // ── Prompt events ─────────────────────────── | TraceEventBase & { type: 'prompt:format'; - /** The spawned agent this prompt seeds (role 'agentSuffix' writes) — - * the tee's attribution key; absent on spine/generate writes. */ - agentId?: number; promptText: string; taskContent?: string; /** What the prompt tokenizes to, when that is knowable BEFORE the @@ -88,6 +96,15 @@ export type TraceEvent = * which share the word so the numbers can be compared. */ cells: number; role: 'spineHeader' | 'agentSuffix' | 'toolResult' | 'warmDelta' | 'probe' | 'recovery'; + /** Which conversation side a `warmDelta` belongs to — carried from the + * Session's own prefill call (`prefillUser` / `prefillAssistant` / + * tool-result / `commitTurn`'s whole exchange), never inferred from + * the text. Absent on non-warmDelta roles. */ + speaker?: 'user' | 'assistant' | 'tool' | 'turn'; + /** A committed exchange's halves (`speaker: 'turn'`), verbatim — + * structural so no reader re-splits the joined `content`. */ + query?: string; + response?: string; probeText?: string; /** Verbatim prefilled text. Populated for `warmDelta` (session-trunk * conversation turns) so the spine's accreting content is visible in diff --git a/packages/agents/src/types.ts b/packages/agents/src/types.ts index 17f52180..1e04feba 100644 --- a/packages/agents/src/types.ts +++ b/packages/agents/src/types.ts @@ -140,6 +140,12 @@ export interface AgentTaskSpec { seed?: number; /** Parent branch to fork from (required by {@link useAgentPool}) */ parent?: Branch; + /** + * Agent ids whose completion gated this spawn — the DAG's dependency + * edges, resolved by the orchestrator. Non-enforcing (the `ability`-label + * class): carried onto `agent:spawn` for the trace and the dev pane only. + */ + after?: number[]; /** * Non-enforcing label naming the Ability this spawn nominally belongs to * Carried for trace attribution (`tool:authReject`) and @@ -487,7 +493,10 @@ export interface DivergeResult { * @category Agents */ export type AgentEvent = - | { type: 'agent:spawn'; agentId: number; parentAgentId: number } + /** `after`: agent ids whose completion gated this spawn (DAG dependency + * edges, resolved by the orchestrator — never inferred). Absent outside + * DAG pools. */ + | { type: 'agent:spawn'; agentId: number; parentAgentId: number; after?: number[] } | { type: 'agent:produce'; agentId: number; text: string; tokenCount: number; entropy?: number; surprisal?: number } | { type: 'agent:tool_call'; agentId: number; tool: string; args: string } | { type: 'agent:tool_result'; agentId: number; tool: string; result: string; contextAvailablePercent?: number } @@ -508,9 +517,14 @@ export type AgentEvent = * abandoned rather than waited out — the drain reports with what agents * HAVE. A UI's cue to show the run as finishing. */ | { type: 'run:windingDown' } - /** Dev-gated trace tee: a trace event mirrored onto the bus, stamped with - * the agent it belongs to. Tool-scoped writes carry the `callId` of the - * dispatch that produced them; pool-side interventions (nudges, drops, - * auth rejections, prunes) mirror without one. Emitted only when a real - * (non-Null) TraceWriter is active — production streams never carry it. */ + /** Dev-gated trace mirror: a trace event carried onto the bus, attributed. + * Emitted at the WRITER boundary — rig's `useTraceWriter`, when a dev + * boot hands it the bus — so a live consumer sees exactly what the file + * sees: every write, session-level `warmDelta` included. `agentId` and + * `callId` are read off the event's own attribution fields (stamped by + * the pool's dispatch tee); `-1` marks a write no agent owns. Production + * streams never carry it: the mirror exists only on dev boots. */ | { type: 'agent:trace'; agentId: number; callId?: string; event: TraceEvent }; + +/** The `agent:trace` bus envelope — what the writer-boundary mirror sends. */ +export type AgentTraceEvent = Extract; diff --git a/packages/agents/test/invariants/predicates.ts b/packages/agents/test/invariants/predicates.ts index c44e9e3f..9052f244 100644 --- a/packages/agents/test/invariants/predicates.ts +++ b/packages/agents/test/invariants/predicates.ts @@ -227,40 +227,34 @@ export function I30_exitReasonMatchesTrace(run: PoolRun): PredicateResult { return ok(); } -/** - * I31 Trace-tee mirror-completeness: with a real TraceWriter active, every - * POOL-side write of a mirrored type reaches the bus exactly once as an - * `agent:trace` envelope wrapping the SAME event (matched by traceId), with - * the envelope's agentId agreeing with the event's own attribution. The - * live consumer (the dev pane) must be able to trust that what it sees is - * what the file recorded — no dropped mirrors, no duplicates, no - * mis-attribution. - */ -const MIRRORED_TYPES = new Set([ - 'pool:agentNudge', 'tool:authReject', 'pool:agentDrop', 'branch:prune', 'tool:dispatch', +/** Trace types that are ABOUT one agent's work — each must carry its owner + * in the record itself (`agentId`, or `branchHandle` for branch events). + * The writer-boundary mirror (rig's `useTraceWriter`) attributes envelopes + * from exactly these fields; an unowned write here would reach the pane + * as agentId -1. */ +const ATTRIBUTED_TYPES = new Set([ + 'pool:agentNudge', 'tool:authReject', 'pool:agentDrop', 'branch:prune', + 'tool:dispatch', ]); -export function I31_traceTeeMirrors(run: PoolRun): PredicateResult { - const mirrors = new Map(); +/** + * I31 — trace attribution completeness. Attribution lives in the DATA: + * every agent-owned trace write carries its owner on the record itself, so + * the writer-boundary mirror (rig's `useTraceWriter`, tested in rig) can + * attribute what it carries — and the POOL bus carries no `agent:trace` + * envelopes at all: the pool stamps, it does not mirror. + */ +export function I31_traceAttribution(run: PoolRun): PredicateResult { for (const ev of run.channelEvents) { - if (ev.type !== 'agent:trace' || !ev.event) continue; - if (mirrors.has(ev.event.traceId)) { - return fail('I31', `trace event ${ev.event.traceId} (${ev.event.type}) mirrored more than once`); + if (ev.type === 'agent:trace') { + return fail('I31', 'the pool bus carried an agent:trace envelope — the mirror lives at the writer boundary, not in the pool'); } - mirrors.set(ev.event.traceId, { agentId: ev.agentId, event: ev.event }); } for (const te of run.traceEvents) { - if (!MIRRORED_TYPES.has(te.type)) continue; - const m = mirrors.get(te.traceId); - if (!m) { - return fail('I31', `pool wrote ${te.type} (traceId ${te.traceId}) but no agent:trace mirror reached the bus`); - } + if (!ATTRIBUTED_TYPES.has(te.type)) continue; const owner = (te as any).agentId ?? (te as any).branchHandle; - if (typeof owner === 'number' && m.agentId !== owner) { - return fail( - 'I31', - `${te.type} (traceId ${te.traceId}) belongs to agent ${owner} but its mirror is stamped agentId=${m.agentId}`, - ); + if (typeof owner !== 'number') { + return fail('I31', `${te.type} (traceId ${te.traceId}) carries no attribution — a live mirror could not attribute it`); } } return ok(); diff --git a/packages/agents/test/invariants/scenarios/trace-tee-mirrors.scenario.test.ts b/packages/agents/test/invariants/scenarios/trace-attribution.scenario.test.ts similarity index 58% rename from packages/agents/test/invariants/scenarios/trace-tee-mirrors.scenario.test.ts rename to packages/agents/test/invariants/scenarios/trace-attribution.scenario.test.ts index 4ea725b0..d1b946bf 100644 --- a/packages/agents/test/invariants/scenarios/trace-tee-mirrors.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/trace-attribution.scenario.test.ts @@ -1,22 +1,24 @@ /** - * Scenario: the trace tee's mirror-completeness invariant (I31). + * Scenario: trace attribution completeness (I31). * - * With a real TraceWriter active, every pool-side write of a mirrored type - * (`pool:agentNudge`, `tool:authReject`, `pool:agentDrop`, `branch:prune`, - * `tool:dispatch`) reaches the bus exactly once as an `agent:trace` - * envelope wrapping the same event, attributed to the right agent. The dev - * pane trusts the mirror to BE the file — this locks that equivalence. + * Attribution lives in the event DATA, not an envelope: every agent-owned + * pool write (`pool:agentNudge`, `tool:authReject`, `pool:agentDrop`, + * `branch:prune`, `tool:dispatch`) carries its owner on the record itself, + * and the pool bus carries NO `agent:trace` envelopes — the live mirror + * moved to the writer boundary (rig's `useTraceWriter`, tested in rig), + * where it attributes envelopes from exactly these stamped fields. The dev + * pane trusts the record to name its owner — this locks that. * - * Two run shapes exercise the allowlist: - * - a normal tool run (tool:dispatch mirrors, prunes on return), - * - an oversized-result run (settle_reject nudge + drop mirrors). + * Two run shapes exercise the stamped types: + * - a normal tool run (tool:dispatch, prunes on return), + * - an oversized-result run (settle_reject nudge + drop writes). */ import { describe, it, expect } from 'vitest'; import { Tool } from '../../../src/Tool'; import type { Operation } from 'effection'; import type { JsonSchema } from '../../../src/types'; import { DefaultAgentPolicy } from '../../../src/AgentPolicy'; -import { I31_traceTeeMirrors, formatResult } from '../predicates'; +import { I31_traceAttribution, formatResult } from '../predicates'; import { runPool, STOP } from '../harness'; class SmallTool extends Tool<{ query: string }> { @@ -33,8 +35,8 @@ class BigResultTool extends Tool<{ query: string }> { *execute(): Operation { return { results: ['x'.repeat(8000)] }; } } -describe('scenario: trace-tee mirror completeness (I31)', () => { - it('a tool run mirrors every allowlisted pool write, attributed', async () => { +describe('scenario: trace attribution completeness (I31)', () => { + it('a tool run stamps every agent-owned write; no envelopes on the pool bus', async () => { const run = await runPool({ nCtx: 16384, cellsUsed: 1000, @@ -46,16 +48,16 @@ describe('scenario: trace-tee mirror completeness (I31)', () => { tools: new Map([['web_search', new SmallTool()]]), terminalToolName: 'report', maxTurns: 5, - trace: true, }); - // The dispatch itself is on the allowlist — at least one mirror exists. - expect(run.channelEvents.some(e => e.type === 'agent:trace')).toBe(true); - const r = I31_traceTeeMirrors(run); + // The dispatch write itself is stamped — attribution present in the file. + const dispatch = run.traceEvents.find(e => e.type === 'tool:dispatch'); + expect(dispatch && (dispatch as { agentId?: number }).agentId).toBeGreaterThan(0); + const r = I31_traceAttribution(run); expect(r.ok, formatResult('I31', r)).toBe(true); }); - it('an oversized-result run mirrors the nudge and drop writes too', async () => { + it('an oversized-result run stamps the nudge and drop writes too', async () => { const run = await runPool({ nCtx: 4096, cellsUsed: 3000, @@ -70,10 +72,9 @@ describe('scenario: trace-tee mirror completeness (I31)', () => { tools: new Map([['web_search', new BigResultTool()]]), terminalToolName: 'report', maxTurns: 5, - trace: true, }); - const r = I31_traceTeeMirrors(run); + const r = I31_traceAttribution(run); expect(r.ok, formatResult('I31', r)).toBe(true); }); }); diff --git a/packages/agents/test/trace-tee.test.ts b/packages/agents/test/trace-tee.test.ts index 53ccd67e..80f4737f 100644 --- a/packages/agents/test/trace-tee.test.ts +++ b/packages/agents/test/trace-tee.test.ts @@ -1,16 +1,16 @@ /** - * The dev-gated trace tee — trace writes mirrored onto the bus as - * `agent:trace`, attributed. Three contracts: + * Dispatch attribution — the pool stamps WHO a trace write belongs to into + * the event DATA, not an envelope. Three contracts: * * 1. A TOOL-scoped write (the ability's own `Trace.expect().write(...)`) - * reaches the bus stamped with agentId + callId, and its - * `parentTraceId: null` is replaced by the dispatch trace id — in the - * FILE write too, not just the mirror. - * 2. POOL-side intervention writes (`pool:agentNudge` here) mirror with - * the agentId read off the event, and the nudge now names the call it - * replaced (tool/args/guard). - * 3. With a NullTraceWriter the tee is INERT: no `agent:trace` ever - * reaches the bus — production streams never carry mirrors. + * lands in the FILE stamped with the dispatching agent's `agentId`, the + * dispatch `callId`, and its hardcoded `parentTraceId: null` replaced + * by the dispatch trace id — real lineage in the record itself. + * 2. Only-if-absent: a write that already carries attribution (a nested + * pool's inner stamp) keeps it; only the null parent is repaired. + * 3. The pool bus carries NO `agent:trace` envelopes — the live mirror + * lives at the writer boundary (rig's `useTraceWriter`, tested there), + * which reads these same stamped fields. */ import { describe, it, expect } from 'vitest'; import { run, createChannel, scoped } from 'effection'; @@ -20,7 +20,6 @@ import { useAgentPool } from '../src/agent-pool'; import { parallel } from '../src/orchestrators'; import { Ctx, Store, Events, Trace } from '../src/context'; import { Tool } from '../src/Tool'; -import { NullTraceWriter } from '../src/trace-writer'; import type { AgentPolicy, ProduceAction, SettleAction } from '../src/AgentPolicy'; import type { AgentEvent, JsonSchema } from '../src/types'; import { CapturingTraceWriter } from './helpers/capturing-trace'; @@ -42,6 +41,23 @@ class TracingTool extends Tool<{ q: string }> { } } +/** A tool whose write ALREADY carries attribution — the nested-pool shape. + * The dispatch tee must keep the inner stamp and repair only the parent. */ +class PreStampedTool extends Tool<{ q: string }> { + readonly name = 'tracing_tool'; + readonly protected = false; + readonly description = 'writes a pre-attributed trace event'; + readonly parameters: JsonSchema = { type: 'object', properties: { q: { type: 'string' } }, required: ['q'] }; + *execute(): Operation { + const tw = yield* Trace.expect(); + tw.write({ + traceId: tw.nextId(), parentTraceId: null, ts: 1, agentId: 777, callId: 'inner', + type: 'rerank:start', query: 'q', chunkCount: 3, tool: 'tracing_tool', + }); + return { ok: true }; + } +} + /** One tool-call turn, then stop. */ function toolOncePolicy(action?: (turn: number) => ProduceAction): AgentPolicy { let turn = 0; @@ -56,7 +72,7 @@ function toolOncePolicy(action?: (turn: number) => ProduceAction): AgentPolicy { }; } -async function runPool(writer: CapturingTraceWriter | NullTraceWriter, policy: AgentPolicy, tools: Map, trace = true) { +async function runPool(writer: CapturingTraceWriter, policy: AgentPolicy, tools: Map) { const { ctx, store, root } = createMockSdk({ nCtx: 16384, cellsUsed: 1000 }); // Every turn parses as one tracing_tool call — the PRODUCE nudge path reads // the parsed call to name the rejected tool on the trace event. @@ -80,7 +96,6 @@ async function runPool(writer: CapturingTraceWriter | NullTraceWriter, policy: A tools, policy, maxTurns: 10, - trace, }); let next = yield* sub.next(); while (!next.done) { events.push(next.value); next = yield* sub.next(); } @@ -90,71 +105,48 @@ async function runPool(writer: CapturingTraceWriter | NullTraceWriter, policy: A return events; } -describe('trace tee', () => { - it('mirrors a tool-scoped write onto the bus, attributed and re-parented', async () => { +describe('dispatch attribution', () => { + it('stamps a tool-scoped write with agent, call, and dispatch lineage — in the file', async () => { const writer = new CapturingTraceWriter(); - const events = await runPool(writer, toolOncePolicy(), new Map([['tracing_tool', new TracingTool()]])); + await runPool(writer, toolOncePolicy(), new Map([['tracing_tool', new TracingTool()]])); - const mirrors = events.filter(e => e.type === 'agent:trace'); - const rerank = mirrors.find(m => m.type === 'agent:trace' && m.event.type === 'rerank:start'); - expect(rerank).toBeDefined(); - expect(rerank!.type === 'agent:trace' && rerank!.agentId).toBeGreaterThan(0); - expect(rerank!.type === 'agent:trace' && rerank!.callId).toBe('call_a'); - // The stamp reaches the FILE write too — no null parents left behind. const fileEvent = writer.ofType('rerank:start')[0]; - expect(fileEvent.parentTraceId).not.toBeNull(); - // ...and it names the dispatch that caused it. + expect(fileEvent).toBeDefined(); + expect(fileEvent.agentId).toBeGreaterThan(0); + expect(fileEvent.callId).toBe('call_a'); + // No null parents left behind — the stamp names the dispatch that caused it. const dispatch = writer.ofType('tool:dispatch')[0]; expect(fileEvent.parentTraceId).toBe(dispatch.traceId); }); - it('mirrors pool-side nudges, naming the rejected call and guard', async () => { + it('keeps inner attribution (nested-pool shape); repairs only the parent', async () => { const writer = new CapturingTraceWriter(); - const events = await runPool(writer, toolOncePolicy((turn) => { + await runPool(writer, toolOncePolicy(), new Map([['tracing_tool', new PreStampedTool()]])); + + const fileEvent = writer.ofType('rerank:start')[0]; + expect(fileEvent.agentId).toBe(777); + expect(fileEvent.callId).toBe('inner'); + expect(fileEvent.parentTraceId).toBe(writer.ofType('tool:dispatch')[0].traceId); + }); + + it('stamps pool-side nudges with their agent, naming the rejected call and guard', async () => { + const writer = new CapturingTraceWriter(); + await runPool(writer, toolOncePolicy((turn) => { if (turn === 1) return { type: 'nudge', message: 'This URL was already fetched. Try a different source.', guard: 'url_dedup' }; return { type: 'idle', reason: 'free_text_stop' }; }), new Map([['tracing_tool', new TracingTool()]])); - const mirror = events.find(e => e.type === 'agent:trace' && e.event.type === 'pool:agentNudge'); - expect(mirror).toBeDefined(); const nudge = writer.ofType('pool:agentNudge')[0]; + expect(nudge).toBeDefined(); expect(nudge.guard).toBe('url_dedup'); expect(nudge.tool).toBe('tracing_tool'); expect(nudge.args).toBe('{"q":"x"}'); }); - it('is inert under NullTraceWriter — no agent:trace on the bus', async () => { - const events = await runPool(new NullTraceWriter(), toolOncePolicy(), new Map([['tracing_tool', new TracingTool()]])); + it('the pool bus carries no agent:trace — the mirror lives at the writer boundary', async () => { + const events = await runPool(new CapturingTraceWriter(), toolOncePolicy(), new Map([['tracing_tool', new TracingTool()]])); expect(events.some(e => e.type === 'agent:trace')).toBe(false); // The stream itself still flowed normally. expect(events.some(e => e.type === 'agent:tool_result')).toBe(true); }); - - it('a real writer WITHOUT the dev trace flag stays inert on the bus', async () => { - const writer = new CapturingTraceWriter(); - const events = await runPool( - writer, - toolOncePolicy(), - new Map([['tracing_tool', new TracingTool()]]), - false, - ); - // the file still gets its writes — only the MIRROR is dev-gated - expect(writer.events.length).toBeGreaterThan(0); - expect(events.some(e => e.type === 'agent:trace')).toBe(false); - }); - - it('an already-teed ambient writer is not wrapped again (nested pools)', async () => { - const writer = Object.assign(new CapturingTraceWriter(), { - [Symbol.for('lloyal.traceTee')]: true, - }); - const events = await runPool( - writer, - toolOncePolicy(), - new Map([['tracing_tool', new TracingTool()]]), - true, - ); - // the file writes still land; no SECOND mirror is minted here - expect(writer.events.length).toBeGreaterThan(0); - expect(events.some(e => e.type === 'agent:trace')).toBe(false); - }); }); diff --git a/packages/dev-tools/src/index.ts b/packages/dev-tools/src/index.ts index 61f490fc..c8bd79db 100644 --- a/packages/dev-tools/src/index.ts +++ b/packages/dev-tools/src/index.ts @@ -69,6 +69,16 @@ export interface AgentLane { * {@link RunFraming} declaration; null when its wire marks nothing. * Never guessed. */ role: string | null; + /** Agent ids whose completion gated this spawn — the DAG's dependency + * edges, from the orchestrator via `agent:spawn.after`. Never inferred. */ + after?: number[]; + /** Tokens already resident on this lane's prefix at its fork — snapshotted + * the moment the spawn folds, never reconstructed. Top-level: the trunk + * inheritance + spine growth so far. Recursive: the parent lane's own + * inheritance + what it had produced by then (its suffix is not counted — + * an honest undercount). Absent when the pane cannot attribute the fork + * (a pre-spine spawn such as a planner). */ + inherited?: number; spawnedAt: number; doneAt: number | null; /** Terminal outcome, when known. `failed` carries the reason. */ @@ -232,6 +242,12 @@ export interface RunFraming { * the halt-and-resubmit path emits no close event. */ open: readonly string[]; close: readonly string[]; + /** Where the user's instruction lives on THIS harness's wire — the event + * type and the field carrying its text (and optionally the field carrying + * its media descriptors). Declared, never guessed: undeclared harnesses + * get a spine row folded from runtime events alone, with no instruction + * shown. */ + instruction?: { event: string; field: string; attachments?: string }; } export const DEFAULT_FRAMING: RunFraming = { @@ -241,10 +257,42 @@ export const DEFAULT_FRAMING: RunFraming = { 'research:start': 'research', 'synthesize:start': 'synth', }, - open: ['preflight:start', 'plan:start', 'query'], + // Declared in WIRE order: preflight (when it runs) precedes the query + // event, which the pipeline sends before the planner phase marker. An + // out-of-order declaration makes the supersede heuristic fire a second + // resetRun on every run (query idx > plan idx), wiping run-scoped state. + open: ['preflight:start', 'query', 'plan:start'], close: ['complete', 'ui:error', 'ui:composer'], + instruction: { event: 'query', field: 'query', attachments: 'attachments' }, }; +/** One session-trunk turn (`branch:prefill role='warmDelta'` mirror) — the + * verbatim conversation delta the spine accreted, with what it cost. */ +export interface TrunkTurn { + at: number; + /** Which conversation side — from the Session's own prefill call, never + * inferred from the text. `turn` is a committed whole exchange. */ + speaker?: 'user' | 'assistant' | 'tool' | 'turn'; + /** Verbatim prefilled text (the trunk conversation turn). */ + content: string; + /** A committed exchange's halves (`speaker: 'turn'`) — never re-split + * from `content`. */ + query?: string; + response?: string; + /** KV cells the prefill added. */ + cells: number; + /** The images that entered with it — roots, resolvable to bytes through + * the bridge's `representationUrl` when the harness exposes one. */ + attachments: { digest: string; mediaType?: string }[]; + /** Wall time of the run this delta settled out of — folded from the run + * anchor at arrival. Absent when no run was open. */ + wallMs?: number; + /** What the run's agents spent (sum of its lanes' cumulative counters) to + * produce this delta — the numerator of the distillation ratio whose + * denominator is `cells`. Absent with `wallMs`. */ + agentTokens?: number; +} + export interface PaneModel { /** The current run's phase cursor — set by the framing's marker events; * tags each spawn with a role. */ @@ -293,6 +341,43 @@ export interface PaneModel { * "applied for this session"). Undefined until a save happens. */ lastSavedTo: string | null | undefined; lanes: Map; + /** The session trunk's own turns — the conversation the spine accretes. + * Session-lived: deliberately NOT cleared by `resetRun`, because the + * next run rides the same trunk this one grew. */ + trunk: TrunkTurn[]; + /** The run's SPINE, run-scoped: the shared root every agent forks from. + * Folded from runtime events alone (`branch:prefill role='spineHeader'`, + * `prompt:format role='spine'`) — universal to any pool harness — plus, + * when the harness DECLARES it ({@link RunFraming.instruction}), the + * user's instruction verbatim. This is the prompt's home in the pane + * even when a run later fails. Cleared by `resetRun`. */ + spine: { + /** The user's instruction, as submitted — null until (unless) the + * harness's declared instruction event arrives. Never guessed. */ + query: string | null; + attachments: { digest: string }[]; + /** First-seen moment (instruction arrival or first header prefill). */ + at: number; + /** KV cells the spine header prefills added (outer + inner pools). */ + spineCells: number; + /** The compiled spine header (system + tools), verbatim. */ + headerText: string | null; + headerTokens: number; + spineAt: number | null; + /** Every moment the spine grew, with what it grew by — the seed first, + * then each extension (a chain step, a fanout widening). The bar's + * tick marks. */ + growth: { at: number; tokens: number }[]; + /** Positions inherited at fork — 0 on a cold start, the trunk's length + * on a warm one. From `branch:create role='spine'` position; first + * spine only (the inner pool's spine forks from the outer). */ + inherited?: number; + /** Parsed from the STRUCTURED `tools` field the runtime emits on + * `prompt:format role='spine'` — reading data, not scraping markup. */ + tools?: { name: string; description: string }[]; + /** The system message text, from the structured `messages` field. */ + systemText?: string | null; + } | null; retrievals: Retrieval[]; pressure: PressurePoint[]; /** Host samples (`host:resources`, dev-gated boots only): the harness @@ -333,6 +418,8 @@ export function createPaneModel(): PaneModel { origin: null, lastSavedTo: undefined, lanes: new Map(), + trunk: [], + spine: null, retrievals: [], pressure: [], host: [], @@ -351,6 +438,7 @@ export function createPaneModel(): PaneModel { const MAX_PRESSURE_POINTS = 20_000; const MAX_RETRIEVALS = 500; const MAX_INTERVENTIONS = 200; +const MAX_TRUNK = 200; const MAX_EPISTEMICS = 4096; const MAX_HOST = 600; @@ -385,12 +473,23 @@ function resetRun(m: PaneModel, now: number): void { m.host = []; m.interventions = []; m.plan = null; + m.spine = null; m.runStartAt = now; m.runEndedAt = null; m.pausedAt = null; m.windingDownAt = null; } +/** The spine record, created by whichever signal arrives first — the + * harness's declared instruction event or the runtime's own header + * prefill. Runtime-only harnesses still get a spine row. */ +function ensureSpine(m: PaneModel, now: number): NonNullable { + if (!m.spine) { + m.spine = { query: null, attachments: [], at: now, spineCells: 0, headerText: null, headerTokens: 0, spineAt: null, growth: [] }; + } + return m.spine; +} + /** The retrieval a mirrored trace event belongs to: by callId WITHIN the * agent (callIds are per-agent counters — call_0 exists in every agent, so * a global match attaches one agent's funnel to another's call), else the @@ -435,6 +534,25 @@ export function foldEvent( const phaseLabel = framing.phases[ev.type]; if (phaseLabel !== undefined) m.runPhase = phaseLabel; + // The user's instruction — read only where the harness DECLARED it lives + // (framing.instruction), never guessed from event shapes. First one per + // run wins: a re-plan re-emits the same event and must not reseed. + const instr = framing.instruction; + if (instr && ev.type === instr.event && (m.spine === null || m.spine.query === null)) { + const s = ensureSpine(m, now); + const q = (ev as Record)[instr.field]; + if (typeof q === 'string') s.query = q; + if (instr.attachments) { + const a = (ev as Record)[instr.attachments]; + if (Array.isArray(a)) { + s.attachments = (a as unknown[]).flatMap((x) => { + const r = x as { digest?: unknown }; + return typeof r.digest === 'string' ? [{ digest: r.digest }] : []; + }); + } + } + } + switch (ev.type) { case 'plan': { const intent = typeof ev.intent === 'string' ? ev.intent : 'research'; @@ -506,6 +624,20 @@ export function foldEvent( return; } case 'agent:spawn': { + const afterIds = Array.isArray(ev.after) + ? (ev.after as unknown[]).filter((x): x is number => typeof x === 'number') + : []; + // Inherited-at-fork, snapshotted NOW: a recursive fork carries its + // parent's attention state; a top-level fork carries trunk + the spine + // as grown so far. Pre-spine forks get nothing — no guessed parentage. + const parentLane = typeof ev.parentAgentId === 'number' ? m.lanes.get(ev.parentAgentId) : undefined; + let inheritedAtFork: number | undefined; + if (parentLane) { + inheritedAtFork = (parentLane.inherited ?? 0) + parentLane.tokenCount; + } else if (m.spine && m.spine.spineAt !== null && now + 250 >= m.spine.spineAt) { + inheritedAtFork = (m.spine.inherited ?? 0) + + m.spine.growth.reduce((n2, g) => (g.at <= now + 250 ? n2 + g.tokens : n2), 0); + } if (typeof ev.agentId !== 'number') return; // type-only frame — never corrupt the lane map const id = ev.agentId as number; m.lanes.set(id, { @@ -516,6 +648,8 @@ export function foldEvent( doneAt: null, outcome: 'running', tokenCount: 0, + ...(afterIds.length > 0 ? { after: afterIds } : {}), + ...(inheritedAtFork !== undefined ? { inherited: inheritedAtFork } : {}), inflightTool: null, report: null, reportSource: null, @@ -647,6 +781,72 @@ export function foldEvent( const te = ev.event as ({ type: string } & Record) | undefined; if (!te) return; switch (te.type) { + case 'branch:create': { + // Cold vs warm, from the run's own record: the FIRST spine's fork + // position. The inner pool's spine forks from the outer — skip it. + if (te.role !== 'spine') return; + { + const s = ensureSpine(m, now); + if (s.inherited === undefined && typeof te.position === 'number') s.inherited = te.position; + } + return; + } + + case 'spine:extend': { + // A settled contribution committed onto the spine mid-run (chain + // steps). Later forks inherit it — the growth entry is what makes + // their inherited-at-fork snapshot and the paid-once sum honest. + const s = ensureSpine(m, now); + const grew = typeof te.deltaTokens === 'number' ? te.deltaTokens : 0; + s.spineCells += grew; + if (grew > 0) s.growth.push({ at: now, tokens: grew }); + return; + } + + case 'branch:prefill': { + // The spine's seed — runtime truth, no harness assumption. + if (te.role === 'spineHeader') { + const s = ensureSpine(m, now); + const grew = typeof te.cells === 'number' ? te.cells : 0; + s.spineCells += grew; + if (grew > 0) s.growth.push({ at: now, tokens: grew }); + if (s.spineAt === null) s.spineAt = now; + return; + } + // The session trunk's own turns (role warmDelta) — visible beside + // the runs they feed. Session-lived: resetRun leaves m.trunk alone. + if (te.role !== 'warmDelta') return; + const speaker = te.speaker; + // Run-derived stats ride the response side: how long the run took + // and what its agents spent to produce what the trunk kept. + const responseSide = speaker === 'assistant' || speaker === 'turn' || speaker === undefined; + const stats = responseSide && m.runStartAt !== null + ? { + wallMs: Math.max(0, now - m.runStartAt), + agentTokens: [...m.lanes.values()].reduce((n, l) => n + l.tokenCount, 0), + } + : {}; + m.trunk.push({ + at: now, + ...stats, + ...(speaker === 'user' || speaker === 'assistant' || speaker === 'tool' || speaker === 'turn' + ? { speaker } : {}), + ...(typeof te.query === 'string' ? { query: te.query } : {}), + ...(typeof te.response === 'string' ? { response: te.response } : {}), + content: typeof te.content === 'string' ? te.content : '', + cells: typeof te.cells === 'number' ? te.cells : 0, + attachments: Array.isArray(te.attachments) + ? (te.attachments as unknown[]).flatMap((a) => { + const r = a as { digest?: unknown; mediaType?: unknown }; + return typeof r.digest === 'string' + ? [{ digest: r.digest, ...(typeof r.mediaType === 'string' ? { mediaType: r.mediaType } : {}) }] + : []; + }) + : [], + }); + if (m.trunk.length > MAX_TRUNK) m.trunk.shift(); + return; + } case 'pool:agentNudge': { m.interventions.push({ kind: typeof te.guard === 'string' ? 'guard' : 'nudge', @@ -674,6 +874,36 @@ export function foldEvent( return; } case 'prompt:format': { + // The compiled spine header — what position 0 actually holds. + if (te.role === 'spine') { + const s = ensureSpine(m, now); + if (typeof te.promptText === 'string') s.headerText = te.promptText; + if (typeof te.tokenCount === 'number') s.headerTokens = te.tokenCount; + if (typeof te.tools === 'string') { + try { + const arr = JSON.parse(te.tools) as unknown[]; + if (Array.isArray(arr)) { + s.tools = arr.flatMap((x) => { + const f = (x as { function?: unknown }).function ?? x; + const g = f as { name?: unknown; description?: unknown }; + return g && typeof g.name === 'string' + ? [{ name: g.name, description: typeof g.description === 'string' ? g.description : '' }] + : []; + }); + } + } catch { /* not JSON — leave unparsed */ } + } + if (typeof te.messages === 'string') { + try { + const ms = JSON.parse(te.messages) as { role?: unknown; content?: unknown }[]; + if (Array.isArray(ms)) { + const sys = ms.find((mm) => mm && mm.role === 'system' && typeof mm.content === 'string'); + s.systemText = sys ? (sys.content as string) : null; + } + } catch { /* not JSON */ } + } + return; + } const lane = m.lanes.get(agentId); if (lane && typeof te.promptText === 'string') { lane.prompt = { diff --git a/packages/dev-tools/src/react.tsx b/packages/dev-tools/src/react.tsx index e86a6853..d8daf598 100644 --- a/packages/dev-tools/src/react.tsx +++ b/packages/dev-tools/src/react.tsx @@ -480,17 +480,26 @@ export function DevPane({ bridge, controls = [], title, runCommands = {}, framin ); } - return shell( setOpen(false)} />); + return shell( + setOpen(false)} + // Thumbnails resolve through the bridge when the harness exposes a + // content route; the pane never learns the transport, only the URL. + mediaUrl={bridge.representationUrl ? (d, i) => bridge.representationUrl!(d, i) : undefined} + />, + ); } // ═══ the docked pane ═══ -function Pane({ store, m, rev, controls, title, runCommands, onClose }: { +function Pane({ store, m, rev, controls, title, runCommands, onClose, mediaUrl }: { store: DevStore; m: PaneModel; rev: number; controls: readonly DevControl[]; title?: string; onClose: () => void; runCommands: NonNullable; + mediaUrl?: (digest: string, index?: number) => string; }): ReactElement { const [tab, setTab] = useState('timeline'); - const [selAgent, setSelAgent] = useState(null); + const [selAgent, setSelAgent] = useState(null); const [feedW, setFeedW] = useState(feedWidthPref); const [paneH, setPaneH] = useState(paneHeightPref); const toolColor = useToolColors(); @@ -622,7 +631,19 @@ function Pane({ store, m, rev, controls, title, runCommands, onClose }: { {tab === 'timeline' && (
- {selAgent !== null && m.lanes.has(selAgent) && ( + {selAgent === 'spine' && m.spine && ( + <> + { feedWidthPref = w; setFeedW(w); }} /> + setSelAgent(null)} width={feedW} mediaUrl={mediaUrl} toolColor={toolColor} /> + + )} + {selAgent === 'trunk' && m.trunk.length > 0 && ( + <> + { feedWidthPref = w; setFeedW(w); }} /> + setSelAgent(null)} width={feedW} mediaUrl={mediaUrl} /> + + )} + {typeof selAgent === 'number' && m.lanes.has(selAgent) && ( <> { feedWidthPref = w; setFeedW(w); }} /> setSelAgent(null)} onJump={setSelAgent} nowMs={store.getState().paintedAt} width={feedW} send={(c) => store.send(c)} canCancel={!!runCommands.cancelAgent} /> @@ -677,7 +698,7 @@ const SPAN_LIVE = 75; // seconds visible while following function Timeline({ m, rev, store, selAgent, onSelect, toolColor }: { m: PaneModel; rev: number; store: DevStore; - selAgent: number | null; onSelect: (id: number | null) => void; + selAgent: number | 'trunk' | 'spine' | null; onSelect: (id: number | 'trunk' | 'spine' | null) => void; toolColor: (t: string) => string; }): ReactElement { const [follow, setFollow] = useState(true); @@ -778,7 +799,9 @@ function Timeline({ m, rev, store, selAgent, onSelect, toolColor }: {
{lanes.length ? `${lanes.length} agents` : ''}
-
+ {/* overflow hidden: a tick past the window edge must clip, not paint + over the sibling feed panel (absolute children ignore siblings). */} +
{ticks.map((t) => ( {t >= 60 ? `${Math.floor(t / 60)}m${t % 60 ? String(t % 60).padStart(2, '0') : ''}` : `${t}s`} @@ -823,6 +846,176 @@ function Timeline({ m, rev, store, selAgent, onSelect, toolColor }: { )}
+ {(() => { + // Fork topology, drawn. Pure px()/ROW_H geometry — no DOM reads — + // and absolutely positioned INSIDE the scroll container, so curves + // ride with their rows through scroll, pan, and every resize. + const rows: Array<'session' | 'spine' | number> = []; + if (m.trunk.length > 0) rows.push('session'); + if (m.spine) rows.push('spine'); + for (const l of lanes) rows.push(l.agentId); + const rowIdx = new Map<'session' | 'spine' | number, number>(rows.map((r, i) => [r, i])); + const yMid = (r: 'session' | 'spine' | number): number => (rowIdx.get(r) as number) * ROW_H + 19; + const paths: ReactElement[] = []; + if (m.spine && rowIdx.has('spine')) { + const sx = px(secOf(m.spine.spineAt ?? m.spine.at)); + // Warm lineage: the session's conversation feeds the spine. + if (rowIdx.has('session') && (m.spine.inherited ?? 0) > 0 && sx >= GUTTER) { + const sy = (rowIdx.get('session') as number) * ROW_H + 22; + const ty = (rowIdx.get('spine') as number) * ROW_H + 19; + const e = sx - 10; const r = 4; + paths.push( + , + ); + } + // Every lane hangs off the spine at its true spawn moment. + const spineRow = rowIdx.get('spine') as number; + for (const l of lanes) { + // Only lanes that forked AFTER the spine existed hang off it — + // a planner forks from the session, and the pane draws no + // parentage it does not know. + if (m.spine.spineAt === null || l.spawnedAt + 250 < m.spine.spineAt) continue; + const x = px(secOf(l.spawnedAt)); + if (x < GUTTER || !on(secOf(l.spawnedAt))) continue; + const laneRow = rowIdx.get(l.agentId) as number; + // Nested elbows: drop at a small left offset (wider for deeper + // rows, so simultaneous forks read as nested guides), rounded + // corner, horizontal entry into the bar's left edge. + const off = 8 + (laneRow - spineRow - 1) * 5; + const sy = spineRow * ROW_H + 23; + const ty = laneRow * ROW_H + 19; + const e = x - off; const r = 4; + paths.push( + , + ); + } + } + // DAG dependency edges — declared by the orchestrator, never inferred. + for (const l of lanes) { + for (const dep of l.after ?? []) { + const d = m.lanes.get(dep); + if (!d || d.doneAt === null || !rowIdx.has(dep)) continue; + const x2 = px(secOf(l.spawnedAt)); + if (x2 < GUTTER) continue; + const x1 = Math.max(px(secOf(d.doneAt)), GUTTER); + const y1 = yMid(dep); const y2 = yMid(l.agentId); + const e = Math.max(x2 - 8, x1 + 4); const r = 4; + const vdir = y2 > y1 ? 1 : -1; + paths.push( + , + ); + } + } + if (paths.length === 0) return null; + return ( + + {paths} + + ); + })()} + {/* the session trunk — same lane grammar as the agents; the content + lives in its feed, exactly like an agent's. Session-lived. */} + {m.trunk.length > 0 && ( +
onSelect(selAgent === 'trunk' ? null : 'trunk')} + onKeyDown={keyActivate(() => onSelect(selAgent === 'trunk' ? null : 'trunk'))} + style={{ + position: 'relative', height: ROW_H, display: 'flex', cursor: 'pointer', + borderBottom: `1px solid ${C.hair}`, + background: selAgent === 'trunk' ? C.chromeBg : undefined, + boxShadow: selAgent === 'trunk' ? `inset 3px 0 0 ${C.text}` : undefined, + }} + > +
+ session + {m.trunk.length} turn{m.trunk.length === 1 ? '' : 's'} +
+
+ {m.trunk.map((t, i) => { + const s = secOf(t.at); + if (!on(s)) return null; + // One diamond per side, colored by who spoke; a committed + // exchange ('turn') lands both at once and shows the pair. + const sides = t.speaker === 'turn' ? [TRUNK_QUERY, TRUNK_RESPONSE] + : t.speaker === 'user' ? [TRUNK_QUERY] + : t.speaker === 'tool' ? [TRUNK_TOOL] + : [TRUNK_RESPONSE]; + return ( + + {sides.map((c, j) => ( + + ))} + {t.attachments.length > 0 && ( + 1 ? 17 : 8), top: 11, fontSize: 9.5, color: C.dim }}> + 📎{t.attachments.length} + + )} + + ); + })} +
+
+ )} + {/* the run's spine — the shared root every lane forks from. Run-scoped, + so a failed run still shows what asked for it. */} + {m.spine && ( +
onSelect(selAgent === 'spine' ? null : 'spine')} + onKeyDown={keyActivate(() => onSelect(selAgent === 'spine' ? null : 'spine'))} + style={{ + position: 'relative', height: ROW_H, display: 'flex', cursor: 'pointer', + borderBottom: `1px solid ${C.hair}`, + background: selAgent === 'spine' ? C.chromeBg : undefined, + boxShadow: selAgent === 'spine' ? `inset 3px 0 0 ${C.text}` : undefined, + }} + > +
+ spine + + {(m.spine.spineCells || m.spine.headerTokens) > 0 + ? `${(m.spine.spineCells || m.spine.headerTokens).toLocaleString()} tokens` : 'seeding…'} + +
+
+ {(() => { + // The spine as a BAR — alive for the whole run — with a tick + // wherever it grew (the seed, then each extension). + const s0 = secOf(m.spine.spineAt ?? m.spine.at); + const x0 = Math.max(px(s0), GUTTER); + const x1 = Math.max(px(Math.min(endS, w1)), x0 + 4); + if (px(s0) > px(w1)) return null; + return ( + + + {m.spine.growth.map((g, gi) => { + const gs = secOf(g.at); + if (!on(gs)) return null; + return ( + + ); + })} + {m.spine.attachments.length > 0 && ( + + 📎{m.spine.attachments.length} + + )} + + ); + })()} +
+
+ )} {lanes.map((l) => (
- {reportOpen && lane.report !== null && ( -
- {lane.report} + {reportOpen && lane.report !== null && (() => { + // Raw never renders — the report splits into the + // shared reasoning box + what the agent actually said. + const { reasoning, said } = splitThink(lane.report); + return ( +
+ {reasoning && toggle('report-reasoning')} />} +
{said}
+
+ ); + })()} +
+ )} +
+ + ); +} + +/** One timeline row — session, spine, and every lane share it. The + * connector overlay derives its geometry from this same constant, which is + * what makes curves survive any resize without DOM measurement. */ +const ROW_H = 38; + +/** Trunk marker colors — one per conversation side, same family as the + * lanes: the query is the deep blue everything forks from, the response the + * light one. Tools are grey. */ +const TRUNK_QUERY = '#174ea6'; +const TRUNK_RESPONSE = '#5f8fd9'; +const TRUNK_TOOL = '#9aa0a6'; + +/** Split verbatim model text into its reasoning and its said text. The pane + * never renders raw `` markup anywhere — reasoning is shown behind + * {@link Reasoning}. An unclosed block (a cut stream) still splits clean. */ +function splitThink(text: string): { reasoning: string; said: string } { + const parts = text.split(/([\s\S]*?)(?:<\/think>|$)/); + return { + reasoning: parts.filter((_, k) => k % 2 === 1).join('\n').trim(), + said: parts.filter((_, k) => k % 2 === 0).join('').trim(), + }; +} + +/** The expandable reasoning box — one look for every surface that carries + * model thinking (trunk turns, agent reports). Collapsed by default. */ +function Reasoning({ text, open, onToggle }: { text: string; open: boolean; onToggle: () => void }): ReactElement { + return ( +
+
+ {open ? '▾' : '▸'} + reasoning +
+ {open && ( +
{text}
+ )} +
+ ); +} + +/** The spine's feed — the run's root: the user's instruction verbatim + * (only when the harness declared where it lives — see + * {@link RunFraming.instruction}), then the compiled header that seeded + * position 0. This is where a prompt survives when a run fails. */ +function SpineFeed({ m, onClose, width, mediaUrl, toolColor }: { + m: PaneModel; onClose: () => void; width: number; + mediaUrl?: (digest: string, index?: number) => string; + toolColor: (t: string) => string; +}): ReactElement | null { + // Substance visible by default — only the raw bytes stay behind a click. + const [headerOpen, setHeaderOpen] = useState(true); + const [compiledOpen, setCompiledOpen] = useState(false); + const k = m.spine; + if (!k) return null; + return ( +
+
+ spine + shared agent memory + {k.inherited !== undefined && ( + {k.inherited > 0 ? `warm · ${k.inherited.toLocaleString()} tok from trunk` : 'cold start'} + )} + {k.headerTokens > 0 && (() => { + const nCtx = m.pressure.length > 0 ? m.pressure[m.pressure.length - 1].nCtx : 0; + const pct = nCtx > 0 ? Math.round((k.headerTokens / nCtx) * 1000) / 10 : null; + return {k.headerTokens.toLocaleString()} tok{pct !== null ? ` · ${pct}% ctx` : ''}; + })()} + + +
+
+ {k.query !== null && ( +
+
+
+ query + the user's instruction, as submitted +
+ {k.attachments.length > 0 && ( +
+ {k.attachments.map((a, j) => mediaUrl ? ( + {a.digest.slice(0, + ) : ( + {a.digest.slice(0, 19)}\u2026 + ))}
)} +
{k.query}
+
)} + {(k.tools && k.tools.length > 0) || k.systemText || k.headerText ? ( +
+
setHeaderOpen((v) => !v)} onKeyDown={keyActivate(() => setHeaderOpen((v) => !v))} + style={{ display: 'flex', alignItems: 'baseline', gap: 7, cursor: 'pointer', padding: '2px 0' }} + title="the shared header prefilled at position 0 — every agent inherits it via fork" + > + {headerOpen ? '▾' : '▸'} + system + tools +
+ {headerOpen && ( +
+ {k.tools && k.tools.length > 0 && ( +
+ {k.tools.map((t2) => ( +
+ + + + {t2.name} + + {t2.description} + +
+ ))} +
+ )} + {k.systemText && ( +
+
system
+
{k.systemText}
+
+ )} + {k.headerText && ( +
+
setCompiledOpen((v) => !v)} onKeyDown={keyActivate(() => setCompiledOpen((v) => !v))} + style={{ display: 'flex', alignItems: 'baseline', gap: 7, cursor: 'pointer', padding: '2px 0' }} + title="the exact rendered template — what position 0 actually holds" + > + {compiledOpen ? '▾' : '▸'} + compiled +
+ {compiledOpen && } +
+ )} +
+ )} + {(() => { + // Full accrual: what every fork inherited for free, minus the + // one shared copy actually prefilled this run. Correct for + // chain extensions, warm trunk inheritance, and recursive + // forks — parallel degenerates to header × (n − 1). + // Only lanes that actually forked the spine count as sharers — + // the same attribution the connectors draw. A pre-spine lane + // (the planner) never inherited it and never shows here. + // Saved follows the cache-read convention: every token an agent + // forked instead of prefilling counts. The spine's own build is + // the cache write — never netted out of the headline. + const sharers = [...m.lanes.values()].filter((l2) => l2.inherited !== undefined); + const saved = sharers.reduce((n2, l2) => n2 + (l2.inherited ?? 0), 0); + if (saved <= 0 && sharers.length < 2) return null; + return ( +
+ {sharers.length >= 2 && ( + <>shared by {sharers.length} agents{saved > 0 ? ' · ' : ''} + )} + {saved > 0 && ( + <>tokens saved {saved.toLocaleString()} + )} +
+ ); + })()} +
+ ) : null} +
+
+ ); +} + +/** The trunk's feed — the same panel grammar as {@link AgentFeed}, for the + * session's own conversation: each `warmDelta` turn verbatim, its KV cost, + * and the media that entered with it (thumbnails when the bridge exposes a + * content resolver, digest chips otherwise). */ +function TrunkFeed({ m, onClose, width, mediaUrl }: { + m: PaneModel; onClose: () => void; width: number; + mediaUrl?: (digest: string, index?: number) => string; +}): ReactElement { + const [thinkOpen, setThinkOpen] = useState>(() => new Set()); + // Responses default COLLAPSED — stats stay visible, the body (reasoning + + // text) expands on click. A committed exchange's response half can be a + // whole brief; the feed must not pay its render until asked. + const [respOpen, setRespOpen] = useState>(() => new Set()); + const toggleResp = (i: number): void => { + setRespOpen((prev) => { + const next = new Set(prev); + if (next.has(i)) next.delete(i); else next.add(i); + return next; + }); + }; + const toggleThink = (i: number): void => { + setThinkOpen((prev) => { + const next = new Set(prev); + if (next.has(i)) next.delete(i); else next.add(i); + return next; + }); + }; + return ( +
+
+ session + the conversation + + +
+
+ {m.trunk.map((t, i) => { + // Query and response render as their OWN blocks — a committed + // exchange (`speaker: 'turn'`) carries both halves structurally, + // and single-sided deltas carry one. Raw never renders: + // the response side splits into the shared reasoning box. + const isTurn = t.speaker === 'turn'; + const q = isTurn ? (t.query ?? '') : t.speaker === 'user' ? t.content : ''; + const rRaw = isTurn ? (t.response ?? '') : (t.speaker === 'assistant' || t.speaker === 'tool' || t.speaker === undefined) ? t.content : ''; + const { reasoning, said } = splitThink(rRaw); + const meta = ( + {t.cells.toLocaleString()} tokens + ); + return ( +
+ {(q || t.attachments.length > 0) && ( +
+
+ query + {meta} +
+ {t.attachments.length > 0 && ( +
+ {t.attachments.map((a, j) => mediaUrl ? ( + {a.mediaType + ) : ( + {a.mediaType ?? 'media'} · {a.digest.slice(0, 19)}… + ))} +
+ )} + {q && ( +
{q}
+ )} +
+ )} + {rRaw && ( +
+
toggleResp(i)} onKeyDown={keyActivate(() => toggleResp(i))} + style={{ display: 'flex', gap: 7, alignItems: 'baseline', marginBottom: 3, cursor: 'pointer' }} + > + {respOpen.has(i) ? '\u25be' : '\u25b8'} + {t.speaker === 'tool' ? 'tool' : 'response'} + {!q && meta} + {(t.agentTokens ?? 0) > 0 && ( + {(t.agentTokens as number).toLocaleString()} tokens + )} + {t.wallMs !== undefined && ( + {fmtS(t.wallMs / 1000)} + )} +
+ {respOpen.has(i) && ( +
+ {reasoning && toggleThink(i)} />} + {said && ( +
{said}
+ )} +
+ )} +
+ )} +
+ ); + })}
); diff --git a/packages/dev-tools/src/store.ts b/packages/dev-tools/src/store.ts index 21988060..f47b01c6 100644 --- a/packages/dev-tools/src/store.ts +++ b/packages/dev-tools/src/store.ts @@ -25,6 +25,11 @@ export interface DevBridge { * interface-typed wire unions for no safety gain. */ onEvent(cb: (envelope: { ev: { type: string } }) => void): () => void; send(command: unknown): void; + /** Optional content resolver — a URL whose GET serves a representation of + * the digest's media. The web bridge already exposes it on + * `window.harness`; when absent the pane shows digest chips instead of + * thumbnails. */ + representationUrl?(digest: string, index?: number): string; } /** Live-edge quantum, ms — capsules grow and repaints happen on this grid. */ diff --git a/packages/dev-tools/test/model.test.ts b/packages/dev-tools/test/model.test.ts index b1914249..d5ba8fcf 100644 --- a/packages/dev-tools/test/model.test.ts +++ b/packages/dev-tools/test/model.test.ts @@ -148,13 +148,13 @@ describe('run phases + the planner truth', () => { const m = createPaneModel(); foldEvent(m, { type: 'agent:spawn', agentId: 2, parentAgentId: 1 }, 5); foldEvent(m, { type: 'agent:tick', cellsUsed: 1, nCtx: 10 }, 6); - foldEvent(m, { type: 'plan:start', query: 'next', mode: 'flat' }, 100); + foldEvent(m, { type: 'query', query: 'next', warm: false }, 100); expect(m.lanes.size).toBe(0); expect(m.pressure.length).toBe(0); expect(m.runStartAt).toBe(100); - // plan:start then query back-to-back must not double-reset a fresh run. + // WIRE order: query then plan:start, back-to-back — must not double-reset. foldEvent(m, { type: 'agent:spawn', agentId: 4, parentAgentId: 1 }, 101); - foldEvent(m, { type: 'query', query: 'next', warm: false }, 101); + foldEvent(m, { type: 'plan:start', query: 'next', mode: 'flat' }, 101); expect(m.lanes.size).toBe(1); }); }); @@ -288,10 +288,10 @@ describe('plan structure + clarify continuation', () => { const lane = m.lanes.get(2)!; expect(lane.clarify).toEqual({ questions: ['Which sense?'], askedAt: 3000, answeredAt: null }); expect(lane.outcome).toBe('running'); - // The user answers minutes later — the continuation's paired - // plan:start → query must BOTH leave the run intact. - foldEvent(m, { type: 'plan:start' }, 120_000); - foldEvent(m, { type: 'query', text: 'the answer' }, 120_040); + // The user answers minutes later — the continuation's paired markers + // (WIRE order: query then plan:start) must BOTH leave the run intact. + foldEvent(m, { type: 'query', text: 'the answer' }, 120_000); + foldEvent(m, { type: 'plan:start' }, 120_040); expect(m.lanes.size).toBe(1); expect(lane.clarify!.answeredAt).toBe(120_000); expect(m.runStartAt).toBe(1000); @@ -441,3 +441,191 @@ describe('retrieval metadata attribution', () => { expect(m.retrievals.find((r) => r.agentId === 3)!.admission).not.toBeNull(); }); }); + +describe('trunk (warmDelta mirrors)', () => { + it('folds a warmDelta prefill into the trunk; a run reset keeps it; other roles ignored', () => { + const m = createPaneModel(); + foldEvent(m, { type: 'agent:trace', agentId: 3, event: { + traceId: 1, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 3, cells: 42, role: 'warmDelta', speaker: 'user', content: 'what is this?', + attachments: [{ digest: 'd', mediaType: 'image/png', size: 1 }], + } }, 100); + expect(m.trunk).toHaveLength(1); + expect(m.trunk[0]).toMatchObject({ speaker: 'user', content: 'what is this?', cells: 42, attachments: [{ digest: 'd', mediaType: 'image/png' }] }); + // A new run resets lanes — never the trunk: the next run rides it. + foldEvent(m, { type: 'query' }, 200); + expect(m.trunk).toHaveLength(1); + // Non-warm roles are not trunk turns. + foldEvent(m, { type: 'agent:trace', agentId: 4, event: { + traceId: 2, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 4, cells: 5, role: 'toolResult', + } }, 300); + expect(m.trunk).toHaveLength(1); + }); + + it('a committed exchange carries its halves structurally', () => { + const m = createPaneModel(); + foldEvent(m, { type: 'agent:trace', agentId: 1, event: { + traceId: 5, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 1, cells: 90, role: 'warmDelta', speaker: 'turn', + content: 'Q?\n\nA.', query: 'Q?', response: 'A.', + } }, 50); + expect(m.trunk[0]).toMatchObject({ speaker: 'turn', query: 'Q?', response: 'A.', cells: 90 }); + }); + + it("a response folds the run wall time and the agents' spend", () => { + const m = createPaneModel(); + foldEvent(m, { type: 'query' }, 1000); + foldEvent(m, { type: 'agent:spawn', agentId: 9 }, 1100); + foldEvent(m, { type: 'agent:produce', agentId: 9, tokenCount: 500 }, 1200); + foldEvent(m, { type: 'agent:trace', agentId: 3, event: { + traceId: 9, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 3, cells: 120, role: 'warmDelta', speaker: 'assistant', content: 'done', + } }, 29_000); + expect(m.trunk[0]).toMatchObject({ wallMs: 28_000, agentTokens: 500 }); + // The query side carries no run stats. + foldEvent(m, { type: 'agent:trace', agentId: 3, event: { + traceId: 10, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 3, cells: 30, role: 'warmDelta', speaker: 'user', content: 'next?', + } }, 30_000); + expect(m.trunk[1].wallMs).toBeUndefined(); + }); +}); + +describe('the spine row', () => { + it('folds the declared instruction verbatim + runtime header; resets per run', () => { + const m = createPaneModel(); + foldEvent(m, { type: 'query', query: 'why?', attachments: [{ digest: 'sha256:aa', mediaType: 'x', size: 1 }] }, 100); + expect(m.spine).toMatchObject({ query: 'why?', attachments: [{ digest: 'sha256:aa' }], spineCells: 0 }); + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 1, parentTraceId: null, ts: 0, type: 'prompt:format', role: 'spine', + promptText: 'SYS+TOOLS', tokenCount: 782, + messages: JSON.stringify([{ role: 'system', content: 'Be brief.' }]), + tools: JSON.stringify([{ type: 'function', function: { name: 'web_search', description: 'Search the web.' } }]), + } }, 150); + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 2, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 65537, cells: 782, role: 'spineHeader', + } }, 200); + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 3, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 65538, cells: 2, role: 'spineHeader', + } }, 250); + expect(m.spine).toMatchObject({ + headerText: 'SYS+TOOLS', headerTokens: 782, spineCells: 784, spineAt: 200, + growth: [{ at: 200, tokens: 782 }, { at: 250, tokens: 2 }], + tools: [{ name: 'web_search', description: 'Search the web.' }], + systemText: 'Be brief.', + }); + // Cold vs warm from the run's own record — first spine's fork position. + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 4, parentTraceId: null, ts: 0, type: 'branch:create', + branchHandle: 65537, parentHandle: 7, position: 1094, role: 'spine', + } }, 260); + expect(m.spine).toMatchObject({ inherited: 1094 }); + // A new run replaces the spine wholesale. + foldEvent(m, { type: 'query', query: 'again?' }, 900); + expect(m.spine).toMatchObject({ query: 'again?', spineCells: 0, headerText: null }); + }); + + it('a re-emitted instruction never reseeds the run', () => { + const m = createPaneModel(); + foldEvent(m, { type: 'query', query: 'first' }, 100); + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 2, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 1, cells: 700, role: 'spineHeader', + } }, 200); + // a re-plan re-emits `query` WITHOUT opening a new run… but `query` is an + // open marker, so simulate the mid-run re-emit via the guard directly: + // query already set → the event must not touch the spine. + m.runOpen = true; m.lastOpenIdx = 0; + foldEvent(m, { type: 'plan', intent: 'research', tasks: [] }, 250); + expect(m.spine).toMatchObject({ query: 'first', spineCells: 700 }); + }); + + it('a fork snapshots what it inherited — spine growth, trunk warmth, parent state', () => { + const m = createPaneModel(); + foldEvent(m, { type: 'query', query: 'q' }, 10); + // planner-style pre-spine spawn: no attribution, no inherited + foldEvent(m, { type: 'agent:spawn', agentId: 2, parentAgentId: 1 }, 20); + expect(m.lanes.get(2)!.inherited).toBeUndefined(); + // warm spine seeds: trunk 900 + header 782, then extends by 118 + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 1, parentTraceId: null, ts: 0, type: 'branch:create', + branchHandle: 9, parentHandle: 7, position: 900, role: 'spine', + } }, 30); + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 2, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 9, cells: 782, role: 'spineHeader', + } }, 40); + foldEvent(m, { type: 'agent:spawn', agentId: 3, parentAgentId: 1 }, 50); + expect(m.lanes.get(3)!.inherited).toBe(900 + 782); + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 3, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 9, cells: 118, role: 'spineHeader', + } }, 60); + foldEvent(m, { type: 'agent:spawn', agentId: 4, parentAgentId: 1 }, 70); + expect(m.lanes.get(4)!.inherited).toBe(900 + 782 + 118); + // recursive fork: parent's inheritance + what it had produced by then + foldEvent(m, { type: 'agent:produce', agentId: 3, tokenCount: 250 }, 80); + foldEvent(m, { type: 'agent:spawn', agentId: 5, parentAgentId: 3 }, 90); + expect(m.lanes.get(5)!.inherited).toBe(900 + 782 + 250); + }); + + it('a chain: spine:extend grows the spine and later forks inherit it', () => { + // Numbers from a real Investigate trace: seed 784, step-1 commit 1761. + const m = createPaneModel(); + foldEvent(m, { type: 'query', query: 'q' }, 10); + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 1, parentTraceId: null, ts: 0, type: 'branch:create', + branchHandle: 65537, parentHandle: null, position: 0, role: 'spine', + } }, 20); + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 2, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 65537, cells: 784, role: 'spineHeader', + } }, 30); + foldEvent(m, { type: 'agent:spawn', agentId: 3, parentAgentId: 65537 }, 40); + expect(m.lanes.get(3)!.inherited).toBe(784); + // step 1 settles — its contribution is committed onto the spine + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 3, parentTraceId: null, ts: 0, type: 'spine:extend', + deltaTokens: 1761, positionAfter: 2545, + } }, 50); + expect(m.spine!.growth.map((g) => g.tokens)).toEqual([784, 1761]); + expect(m.spine!.spineCells).toBe(784 + 1761); + foldEvent(m, { type: 'agent:spawn', agentId: 65539, parentAgentId: 65537 }, 60); + expect(m.lanes.get(65539)!.inherited).toBe(784 + 1761); + // step 3 after another extension: the first extension now nets a save + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 4, parentTraceId: null, ts: 0, type: 'spine:extend', + deltaTokens: 500, positionAfter: 3045, + } }, 70); + foldEvent(m, { type: 'agent:spawn', agentId: 65540, parentAgentId: 65537 }, 80); + expect(m.lanes.get(65540)!.inherited).toBe(784 + 1761 + 500); + // tokens saved = cache-read convention: every fork-inherited token + // counts; the spine's own build is the cache write, never netted out. + const saved = [...m.lanes.values()].reduce((n, l) => n + (l.inherited ?? 0), 0); + expect(saved).toBe(784 + (784 + 1761) + (784 + 1761 + 500)); + }); + + it('a DAG spawn carries its dependency edges onto the lane', () => { + const m = createPaneModel(); + foldEvent(m, { type: 'query', query: 'q' }, 10); + foldEvent(m, { type: 'agent:spawn', agentId: 2, parentAgentId: 1 }, 20); + foldEvent(m, { type: 'agent:spawn', agentId: 3, parentAgentId: 1, after: [2] }, 30); + expect(m.lanes.get(2)!.after).toBeUndefined(); + expect(m.lanes.get(3)!.after).toEqual([2]); + }); + + it('an undeclared harness still gets a runtime-only spine', () => { + const m = createPaneModel(); + const bare = { phases: {}, open: ['sheet:task'], close: ['done'] }; + foldEvent(m, { type: 'sheet:task', instruction: 'sum col B' }, 50, bare); + expect(m.spine).toBeNull(); + foldEvent(m, { type: 'agent:trace', agentId: -1, event: { + traceId: 9, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 7, cells: 300, role: 'spineHeader', + } }, 80, bare); + expect(m.spine).toMatchObject({ query: null, spineCells: 300 }); + }); +}); diff --git a/packages/rig/src/trace-sink.ts b/packages/rig/src/trace-sink.ts index 0d7a95fd..261f83e7 100644 --- a/packages/rig/src/trace-sink.ts +++ b/packages/rig/src/trace-sink.ts @@ -6,7 +6,7 @@ * directory and one flag is what hid that difference. */ import { NullTraceWriter, JsonlTraceWriter } from '@lloyal-labs/lloyal-agents'; -import type { TraceWriter } from '@lloyal-labs/lloyal-agents'; +import type { TraceWriter, TraceEvent, AgentTraceEvent } from '@lloyal-labs/lloyal-agents'; import { resource } from 'effection'; import type { Operation } from 'effection'; import { mkdirSync, openSync, closeSync } from 'node:fs'; @@ -27,16 +27,31 @@ import { randomUUID } from 'node:crypto'; * below, whose absence is a hard failure for media — see * {@link createProjectMediaStore}. * + * **`send` is the dev pane's live mirror.** Pass the boot's event bus and + * every write is ALSO carried onto it as an `agent:trace` envelope — the one + * mirror in the system, at the boundary every write already crosses, so a + * live consumer sees exactly what the file sees (the session trunk's + * `warmDelta` turns included). Attribution is read off the event's own + * stamped fields. Dev-gated with the writer; it mirrors even when the file + * failed to open, because pane observability is not a disk dependency. The + * scaffold hands it the same `events.send` it already gives + * `startHostResources` — no wiring concept crosses the third surface. + * * The random id keeps concurrent writers apart and `"wx"` refuses to truncate * an existing file. * * @param outputDir - Where the trace lands (`sources.outputDir`). Created if * missing. * @param dev - False ⇒ the Null writer, at zero cost. + * @param send - Dev-pane mirror: receives every write as `agent:trace`. * * @category Runtime */ -export function useTraceWriter(outputDir: string, dev: boolean): Operation { +export function useTraceWriter( + outputDir: string, + dev: boolean, + send?: (ev: AgentTraceEvent) => void, +): Operation { return resource(function* (provide) { let fd: number | undefined; let writer: TraceWriter = new NullTraceWriter(); @@ -51,6 +66,25 @@ export function useTraceWriter(outputDir: string, dev: boolean): Operation base.nextId(), + flush: () => base.flush(), + write: (event: TraceEvent) => { + base.write(event); + const e = event as TraceEvent & { branchHandle?: number }; + try { + send({ + type: 'agent:trace', + agentId: e.agentId ?? e.branchHandle ?? -1, + ...(e.callId !== undefined ? { callId: e.callId } : {}), + event, + }); + } catch { /* the mirror is best-effort — never disrupt the write */ } + }, + }; + } } try { yield* provide(writer); diff --git a/packages/rig/test/trace-sink.test.ts b/packages/rig/test/trace-sink.test.ts new file mode 100644 index 00000000..21518f9e --- /dev/null +++ b/packages/rig/test/trace-sink.test.ts @@ -0,0 +1,71 @@ +/** + * The writer-boundary mirror — `useTraceWriter`'s third parameter is the dev + * pane's live feed. Contracts: + * + * 1. With `dev` and a `send`, EVERY write is carried as an `agent:trace` + * envelope whose attribution comes off the event's own fields + * (`agentId`, else `branchHandle`, else -1; `callId` when present) — + * and the file write still lands. + * 2. Without `dev` the writer is Null and nothing mirrors — production + * streams never carry envelopes. + * 3. A failed file open does not silence the mirror: pane observability + * is not a disk dependency. + */ +import { describe, it, expect } from 'vitest'; +import { run } from 'effection'; +import { mkdtempSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { useTraceWriter } from '../src/trace-sink'; +import type { AgentTraceEvent, TraceEvent } from '@lloyal-labs/lloyal-agents'; + +const ev = (over: Record): TraceEvent => ({ + traceId: 1, parentTraceId: null, ts: 0, type: 'scope:open', name: 'x', + ...over, +} as unknown as TraceEvent); + +describe('useTraceWriter mirror', () => { + it('mirrors every write, attributed off the event data, and still writes the file', async () => { + const dir = mkdtempSync(join(tmpdir(), 'trace-sink-')); + const sent: AgentTraceEvent[] = []; + await run(function* () { + const tw = yield* useTraceWriter(dir, true, (e) => sent.push(e)); + tw.write(ev({ agentId: 7, callId: 'call_1' })); + tw.write(ev({ traceId: 2, type: 'branch:prefill', branchHandle: 3, cells: 10, role: 'warmDelta', content: 'hi' })); + tw.write(ev({ traceId: 3 })); + tw.flush(); + }); + expect(sent.map((s) => s.agentId)).toEqual([7, 3, -1]); + expect(sent[0].callId).toBe('call_1'); + expect(sent[1].callId).toBeUndefined(); + expect(sent[1].event.type).toBe('branch:prefill'); + const file = readdirSync(dir).find((f) => f.startsWith('trace-')); + expect(file).toBeDefined(); + expect(readFileSync(join(dir, file!), 'utf8').trim().split('\n')).toHaveLength(3); + }); + + it('dev off: Null writer, no mirror', async () => { + const sent: AgentTraceEvent[] = []; + await run(function* () { + const tw = yield* useTraceWriter(mkdtempSync(join(tmpdir(), 'trace-sink-')), false, (e) => sent.push(e)); + tw.write(ev({ agentId: 1 })); + return undefined; + }); + expect(sent).toHaveLength(0); + }); + + it('a failed file open does not silence the mirror', async () => { + const sent: AgentTraceEvent[] = []; + // A path that cannot be created: a directory under an existing FILE. + const dir = mkdtempSync(join(tmpdir(), 'trace-sink-')); + const blocked = join(dir, 'occupied'); + writeFileSync(blocked, ''); + await run(function* () { + const tw = yield* useTraceWriter(join(blocked, 'sub'), true, (e) => sent.push(e)); + tw.write(ev({ agentId: 1 })); + return undefined; + }); + expect(sent).toHaveLength(1); + expect(sent[0].agentId).toBe(1); + }); +}); diff --git a/packages/sdk/src/Session.ts b/packages/sdk/src/Session.ts index 45a05f61..e6f5c649 100644 --- a/packages/sdk/src/Session.ts +++ b/packages/sdk/src/Session.ts @@ -17,6 +17,10 @@ import { buildUserDelta, buildUserDeltaMultimodal, buildAssistantDelta, buildToo export type TrunkPrefillObserver = (info: { role: 'user' | 'assistant' | 'turn' | 'tool'; content: string; + /** The halves of a committed exchange (`role: 'turn'`), verbatim as the + * caller passed them — structural so no consumer re-splits the join. */ + query?: string; + response?: string; /** KV CELLS the prefill added — not tokens. * * Equal on the token rail, where this is the delta length. NOT equal on the @@ -243,7 +247,7 @@ export class Session { // conversations; no thinking blocks should be embedded. const tokens = buildTurnDelta(this._ctx, query, response, { enableThinking: false }); await this._trunk.prefill(tokens); - this._onPrefill?.({ role: 'turn', content: `${query}\n\n${response}`, cells: tokens.length, branchHandle: this._trunk.handle }); + this._onPrefill?.({ role: 'turn', content: `${query}\n\n${response}`, query, response, cells: tokens.length, branchHandle: this._trunk.handle }); } else { // Cold path: create trunk at position 0, prefill without separator // (fresh branch — no prior turn to separate from), then promote. @@ -258,7 +262,7 @@ export class Session { const trunk = Branch.create(this._ctx, 0, {}); await trunk.prefill(tokens); await this.promote(trunk); - this._onPrefill?.({ role: 'turn', content: `${query}\n\n${response}`, cells: tokens.length, branchHandle: trunk.handle }); + this._onPrefill?.({ role: 'turn', content: `${query}\n\n${response}`, query, response, cells: tokens.length, branchHandle: trunk.handle }); } } From 14cc4f88ad90d61cc0c3f5828a8ad18eda94abf1 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Thu, 3 Sep 2026 01:38:44 +1000 Subject: [PATCH 16/69] =?UTF-8?q?feat(sdk,agents):=20the=20trunk=20release?= =?UTF-8?q?=20observer=20=E2=80=94=20the=20other=20half=20of=20the=20prefi?= =?UTF-8?q?ll=20pair?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session's prefill observer reported turns ENTERING the trunk's KV; nothing reported a trunk leaving it, so Session-level prunes (dispose, promote moving the crown off a live trunk, the multimodal poison path) were invisible — trunk generations appeared in the trace but never left, and diagnosing a doc-switch meant native refcounts and pool-pressure arithmetic. TrunkReleaseObserver fires after each release with the handle and position; initAgents bridges it to the same branch:prune vocabulary the pool already writes. Release info is captured BEFORE the prune (the getters are not for disposed branches). Unit-tested over the mock: dispose fires once then goes silent, release+rebirth yields a fresh generation, promote distinguishes supersede from re-crown. MockSessionContext's relative imports gain .js extensions (proper ESM — required by NodeNext consumers; bundler-resolution consumers unaffected). --- packages/agents/src/init.ts | 15 +++++ packages/sdk/src/Session.ts | 32 ++++++++- packages/sdk/test/MockSessionContext.ts | 8 +-- packages/sdk/test/session-release.test.ts | 80 +++++++++++++++++++++++ 4 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 packages/sdk/test/session-release.test.ts diff --git a/packages/agents/src/init.ts b/packages/agents/src/init.ts index 49f025f4..c991a106 100644 --- a/packages/agents/src/init.ts +++ b/packages/agents/src/init.ts @@ -108,6 +108,21 @@ export function* initAgents( ? { attachments: roots as readonly Attachment[] } : {}), }); }, + // The release half of the pair: Session-level prunes (dispose, and + // promote moving the crown off a live trunk) happen below the pool's + // instrumentation, so nothing else emits for them — without this the + // trace shows trunk generations appearing but never leaving the KV. + // Same `branch:prune` vocabulary the pool writes for its own branches. + onRelease: ({ branchHandle, position }) => { + tw.write({ + traceId: tw.nextId(), + parentTraceId: null, + ts: performance.now(), + type: 'branch:prune', + branchHandle, + position, + }); + }, }); const events: Channel = createChannel(); diff --git a/packages/sdk/src/Session.ts b/packages/sdk/src/Session.ts index e6f5c649..443e6fcf 100644 --- a/packages/sdk/src/Session.ts +++ b/packages/sdk/src/Session.ts @@ -41,6 +41,23 @@ export type TrunkPrefillObserver = (info: { attachments?: readonly { digest: string; mediaType: string; size: number }[]; }) => void; +/** + * Observer invoked after the Session releases a trunk branch — the other + * half of the pair with {@link TrunkPrefillObserver}: prefills report what + * entered the trunk's KV, releases report a trunk leaving it. Fired by + * {@link Session.dispose} (trunk pruned), by {@link Session.promote} when + * the crown moves off a live trunk (retainOnly frees it), and by the + * multimodal poison path (a failed prefill prunes the trunk it poisoned). + * Pure observability: it runs after the prune and never affects it. + * + * @category Branching + */ +export type TrunkReleaseObserver = (info: { + branchHandle: number; + /** The branch's position (cells decoded) at release. */ + position: number; +}) => void; + /** * Session - Trunk lifecycle + conversation delta helpers * @@ -77,12 +94,14 @@ export class Session { private _store: BranchStore; private _trunk: Branch | null; private _onPrefill?: TrunkPrefillObserver; + private _onRelease?: TrunkReleaseObserver; - constructor({ ctx, store, onPrefill }: { ctx: SessionContext; store: BranchStore; onPrefill?: TrunkPrefillObserver }) { + constructor({ ctx, store, onPrefill, onRelease }: { ctx: SessionContext; store: BranchStore; onPrefill?: TrunkPrefillObserver; onRelease?: TrunkReleaseObserver }) { this._ctx = ctx; this._store = store; this._trunk = null; this._onPrefill = onPrefill; + this._onRelease = onRelease; } /** Current trunk branch */ @@ -101,8 +120,15 @@ export class Session { * Safe even if winner is the only branch (resets topology, no-op on KV). */ async promote(winner: Branch): Promise { + // Capture the outgoing trunk's identity BEFORE retainOnly frees it — + // the getters are not for disposed branches. + const old = this._trunk; + const released = old !== null && old !== winner && !old.disposed + ? { branchHandle: old.handle, position: old.position } + : null; await this._store.retainOnly(winner); this._trunk = winner; + if (released) this._onRelease?.(released); } /** @@ -110,7 +136,9 @@ export class Session { */ async dispose(): Promise { if (this._trunk && !this._trunk.disposed) { + const released = { branchHandle: this._trunk.handle, position: this._trunk.position }; await this._trunk.prune(); + this._onRelease?.(released); } this._trunk = null; } @@ -178,8 +206,10 @@ export class Session { // resume invalid KV; prune (subtree — poisoned KV invalidates // anything forked from it) and clear, so the failure surfaces once, // here. + const released = { branchHandle: trunk.handle, position: trunk.position }; trunk.pruneSubtreeSync(); this._trunk = null; + this._onRelease?.(released); throw e; } } else { diff --git a/packages/sdk/test/MockSessionContext.ts b/packages/sdk/test/MockSessionContext.ts index 90bd33bb..5f8e5925 100644 --- a/packages/sdk/test/MockSessionContext.ts +++ b/packages/sdk/test/MockSessionContext.ts @@ -46,10 +46,10 @@ import type { ParseChatOutputResult, ParseChatOutputOptions, MultimodalPrefillResult, -} from '../src/types'; -import { Branch } from '../src/Branch'; -import { BranchStore } from '../src/BranchStore'; -import { Session } from '../src/Session'; +} from '../src/types.js'; +import { Branch } from '../src/Branch.js'; +import { BranchStore } from '../src/BranchStore.js'; +import { Session } from '../src/Session.js'; /** Internal branch state tracked by the mock */ interface BranchState { diff --git a/packages/sdk/test/session-release.test.ts b/packages/sdk/test/session-release.test.ts new file mode 100644 index 00000000..c1024564 --- /dev/null +++ b/packages/sdk/test/session-release.test.ts @@ -0,0 +1,80 @@ +/** + * The trunk release observer — the other half of the pair with the prefill + * observer. Prefills report what entered the trunk's KV; releases report a + * trunk leaving it. The contracts: + * + * 1. `dispose()` of a live trunk fires ONCE, with the handle and position + * the trunk held at release; a trunkless dispose fires nothing. + * 2. `promote(winner)` fires for the superseded live trunk — and does NOT + * fire when the winner already IS the trunk (re-crowning is a topology + * reset, not a release). + * 3. Each cold `commitTurn` after a release opens a NEW generation: a + * fresh handle, observed by the prefill side. Release + rebirth is how + * a consumer (the dev pane) draws trunk-generation boundaries. + */ +import { describe, it, expect } from 'vitest'; +import { Branch, BranchStore, Session } from '../src/index'; +import { MockSessionContext } from './MockSessionContext'; +import type { SessionContext } from '../src/types'; + +type Release = { branchHandle: number; position: number }; +type Prefill = { role: string; branchHandle: number; cells: number }; + +function makeSession() { + const ctx = new MockSessionContext() as unknown as SessionContext; + const store = new BranchStore(ctx); + const releases: Release[] = []; + const prefills: Prefill[] = []; + const session = new Session({ + ctx, + store, + onPrefill: ({ role, branchHandle, cells }) => prefills.push({ role, branchHandle, cells }), + onRelease: (info) => releases.push(info), + }); + return { ctx, store, session, releases, prefills }; +} + +describe('Session trunk release observer', () => { + it('dispose of a live trunk fires once with its handle and position', async () => { + const { session, releases, prefills } = makeSession(); + await session.commitTurn('q1', 'a1'); // cold: creates + promotes the trunk + expect(releases).toEqual([]); // birth is not a release + const born = prefills[0].branchHandle; + const cells = prefills[0].cells; + + await session.dispose(); + expect(releases).toHaveLength(1); + expect(releases[0].branchHandle).toBe(born); + expect(releases[0].position).toBeGreaterThanOrEqual(cells); + expect(session.trunk).toBeNull(); + + await session.dispose(); // trunkless — nothing left to release + expect(releases).toHaveLength(1); + }); + + it('release + rebirth opens a new generation (fresh handle)', async () => { + const { session, releases, prefills } = makeSession(); + await session.commitTurn('q1', 'a1'); + const gen1 = prefills[0].branchHandle; + await session.dispose(); + await session.commitTurn('q2', 'a2'); // cold again — the next generation + const gen2 = prefills[1].branchHandle; + expect(gen2).not.toBe(gen1); + expect(releases).toHaveLength(1); + expect(releases[0].branchHandle).toBe(gen1); + }); + + it('promote fires for a superseded live trunk, not for a re-crowned one', async () => { + const { ctx, session, releases } = makeSession(); + const first = Branch.create(ctx, 0, {}); + session.trunk = first; + await session.promote(first); // winner IS the trunk — no release + expect(releases).toEqual([]); + + const winner = first.forkSync(); + await session.promote(winner); // the crown moves — first is freed + expect(releases).toHaveLength(1); + expect(releases[0].branchHandle).toBe(first.handle); + expect(session.trunk).toBe(winner); + }); +}); From d62f84a9cb9674853101c7acc488743be7b0b871 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Thu, 3 Sep 2026 01:38:44 +1000 Subject: [PATCH 17/69] feat(dev-tools): the session panel shows the RESIDENT conversation only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trunk feed was an append-only log of every turn-commit the mirror ever emitted — it spanned documents, so the pane implied KV residency it did not have. With releases now on the wire, the fold deletes a released trunk's turns (handle-matched, never a blanket clear: an agent branch's prune must not touch the feed, and the match is order-independent with the next generation's first commit). The panel now means what it shows: the conversation the model can currently attend. Model test walks fold → unrelated prune → the trunk's own release → a clean next generation. --- packages/dev-tools/src/index.ts | 19 ++++++++++++++--- packages/dev-tools/test/model.test.ts | 30 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/dev-tools/src/index.ts b/packages/dev-tools/src/index.ts index c8bd79db..91b36351 100644 --- a/packages/dev-tools/src/index.ts +++ b/packages/dev-tools/src/index.ts @@ -281,6 +281,9 @@ export interface TrunkTurn { response?: string; /** KV cells the prefill added. */ cells: number; + /** The trunk branch this turn accreted onto — the key the release fold + * deletes by when that branch leaves the KV. */ + branchHandle?: number; /** The images that entered with it — roots, resolvable to bytes through * the bridge's `representationUrl` when the harness exposes one. */ attachments: { digest: string; mediaType?: string }[]; @@ -341,9 +344,11 @@ export interface PaneModel { * "applied for this session"). Undefined until a save happens. */ lastSavedTo: string | null | undefined; lanes: Map; - /** The session trunk's own turns — the conversation the spine accretes. - * Session-lived: deliberately NOT cleared by `resetRun`, because the - * next run rides the same trunk this one grew. */ + /** The RESIDENT conversation — the turns of the trunk the model can + * currently attend. Not cleared by `resetRun` (the next run rides the + * live trunk), but a released trunk's turns are REMOVED when its + * `branch:prune` folds: dead cells feed nothing, so the feed never + * shows them. History across trunks is the harness's concern. */ trunk: TrunkTurn[]; /** The run's SPINE, run-scoped: the shared root every agent forks from. * Folded from runtime events alone (`branch:prefill role='spineHeader'`, @@ -835,6 +840,7 @@ export function foldEvent( ...(typeof te.response === 'string' ? { response: te.response } : {}), content: typeof te.content === 'string' ? te.content : '', cells: typeof te.cells === 'number' ? te.cells : 0, + ...(typeof te.branchHandle === 'number' ? { branchHandle: te.branchHandle } : {}), attachments: Array.isArray(te.attachments) ? (te.attachments as unknown[]).flatMap((a) => { const r = a as { digest?: unknown; mediaType?: unknown }; @@ -918,6 +924,13 @@ export function foldEvent( const handle = typeof te.branchHandle === 'number' ? te.branchHandle : agentId; const lane = m.lanes.get(handle); if (lane) lane.prunedAt = now; + // The same prune, seen by the trunk feed: this handle's turns + // left the KV with it, so they leave the feed — the feed shows + // the resident conversation, nothing else. Handle-matched (never + // a blanket clear): an agent branch's prune must not touch the + // trunk, and the match is order-independent with the next + // generation's first commit. + m.trunk = m.trunk.filter((t) => t.branchHandle !== handle); return; } case 'tool:dispatch': { diff --git a/packages/dev-tools/test/model.test.ts b/packages/dev-tools/test/model.test.ts index d5ba8fcf..4b1bd423 100644 --- a/packages/dev-tools/test/model.test.ts +++ b/packages/dev-tools/test/model.test.ts @@ -473,6 +473,36 @@ describe('trunk (warmDelta mirrors)', () => { expect(m.trunk[0]).toMatchObject({ speaker: 'turn', query: 'Q?', response: 'A.', cells: 90 }); }); + it('a released trunk leaves the feed; an unrelated prune does not touch it', () => { + const m = createPaneModel(); + for (const [id, q] of [[1, 'Q1'], [2, 'Q2']] as const) { + foldEvent(m, { type: 'agent:trace', agentId: 7, event: { + traceId: id, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 7, cells: 10, role: 'warmDelta', speaker: 'turn', + content: `${q}\n\nA`, query: q, response: 'A', + } }, id * 100); + } + expect(m.trunk).toHaveLength(2); + // An agent branch's prune is not the trunk's business. + foldEvent(m, { type: 'agent:trace', agentId: 3, event: { + traceId: 3, parentTraceId: null, ts: 0, type: 'branch:prune', branchHandle: 3, position: 500, + } }, 300); + expect(m.trunk).toHaveLength(2); + // The trunk's own release: its turns left the KV, so they leave the feed. + foldEvent(m, { type: 'agent:trace', agentId: 7, event: { + traceId: 4, parentTraceId: null, ts: 0, type: 'branch:prune', branchHandle: 7, position: 22, + } }, 400); + expect(m.trunk).toHaveLength(0); + // The next generation starts clean. + foldEvent(m, { type: 'agent:trace', agentId: 9, event: { + traceId: 5, parentTraceId: null, ts: 0, type: 'branch:prefill', + branchHandle: 9, cells: 8, role: 'warmDelta', speaker: 'turn', + content: 'Q3\n\nA3', query: 'Q3', response: 'A3', + } }, 500); + expect(m.trunk).toHaveLength(1); + expect(m.trunk[0].query).toBe('Q3'); + }); + it("a response folds the run wall time and the agents' spend", () => { const m = createPaneModel(); foldEvent(m, { type: 'query' }, 1000); From 154616aaf6182e6b2dfac4433594a73aacdb43cf Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Thu, 3 Sep 2026 13:55:34 +1000 Subject: [PATCH 18/69] =?UTF-8?q?feat(sdk):=20the=20mock=20becomes=20the?= =?UTF-8?q?=20published=20testing=20surface=20=E2=80=94=20dist/testing.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MockSessionContext lived under test/, which the files whitelist never ships — so a scaffolded harness could not run a behavioural suite over it (its import only ever resolved through workspace symlinks). Promoted to src/testing.ts and compiled into dist; the deep path @lloyal-labs/sdk/dist/testing.js resolves plainly (no exports map). Every in-repo consumer (sdk tests, agents suites + invariants) moves to the same entry, so there is ONE mock with one address. --- packages/agents/test/agent-pool.test.ts | 2 +- packages/agents/test/attachments.test.ts | 2 +- packages/agents/test/invariants/harness.ts | 2 +- .../scenarios/spine-prefix-sharing.scenario.test.ts | 2 +- packages/agents/test/replay.test.ts | 2 +- packages/agents/test/spine-multimodal.test.ts | 2 +- packages/agents/test/trace-scope-halt.test.ts | 2 +- packages/agents/test/trace-tee.test.ts | 2 +- .../{test/MockSessionContext.ts => src/testing.ts} | 13 ++++++++----- packages/sdk/test/branch-double-free.test.ts | 2 +- packages/sdk/test/deltas-multimodal.test.ts | 2 +- packages/sdk/test/rerank-instruction.test.ts | 2 +- packages/sdk/test/session-release.test.ts | 2 +- packages/sdk/test/utf8.test.ts | 2 +- 14 files changed, 21 insertions(+), 18 deletions(-) rename packages/sdk/{test/MockSessionContext.ts => src/testing.ts} (97%) diff --git a/packages/agents/test/agent-pool.test.ts b/packages/agents/test/agent-pool.test.ts index 82c612ed..f748e90f 100644 --- a/packages/agents/test/agent-pool.test.ts +++ b/packages/agents/test/agent-pool.test.ts @@ -12,7 +12,7 @@ import { describe, it, expect } from 'vitest'; import { MediaTool, PNG_BYTES, MEDIA_TEST_NCTX, mediaFailures } from './helpers/media'; import { run, createChannel, createSignal, spawn, each, scoped, call } from 'effection'; import type { Operation, Channel } from 'effection'; -import { MockSessionContext, createMockSdk } from '../../sdk/test/MockSessionContext'; +import { MockSessionContext, createMockSdk } from '../../sdk/src/testing.js'; import type { ChatFormat, ParseChatOutputOptions, ParseChatOutputResult } from '@lloyal-labs/sdk'; import { useAgentPool } from '../src/agent-pool'; import { parallel } from '../src/orchestrators'; diff --git a/packages/agents/test/attachments.test.ts b/packages/agents/test/attachments.test.ts index 09f96a46..a028ae1d 100644 --- a/packages/agents/test/attachments.test.ts +++ b/packages/agents/test/attachments.test.ts @@ -15,7 +15,7 @@ import type { ContentIngress } from '@lloyal-labs/media'; import { mkdtempSync, existsSync, readdirSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { MockSessionContext } from '../../sdk/test/MockSessionContext'; +import { MockSessionContext } from '../../sdk/src/testing.js'; import { BranchStore } from '../../sdk/src/BranchStore'; import { NullAttachmentStore } from '@lloyal-labs/media'; import type { AttachmentStore } from '@lloyal-labs/media'; diff --git a/packages/agents/test/invariants/harness.ts b/packages/agents/test/invariants/harness.ts index 2595cbfc..003ae986 100644 --- a/packages/agents/test/invariants/harness.ts +++ b/packages/agents/test/invariants/harness.ts @@ -1,6 +1,6 @@ import { run, createChannel, scoped, createSignal, sleep, call, spawn } from 'effection'; import type { Channel } from 'effection'; -import { MockSessionContext } from '../../../sdk/test/MockSessionContext'; +import { MockSessionContext } from '../../../sdk/src/testing.js'; import { Branch } from '../../../sdk/src/Branch'; import { BranchStore } from '../../../sdk/src/BranchStore'; import type { ChatFormat, ParseChatOutputOptions, ParseChatOutputResult, MultimodalPrefillResult } from '@lloyal-labs/sdk'; diff --git a/packages/agents/test/invariants/scenarios/spine-prefix-sharing.scenario.test.ts b/packages/agents/test/invariants/scenarios/spine-prefix-sharing.scenario.test.ts index f2edd7d7..53e2ccdd 100644 --- a/packages/agents/test/invariants/scenarios/spine-prefix-sharing.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/spine-prefix-sharing.scenario.test.ts @@ -22,7 +22,7 @@ import { describe, it, expect } from 'vitest'; import { run, createChannel, scoped } from 'effection'; import type { Channel } from 'effection'; -import { MockSessionContext } from '../../../../sdk/test/MockSessionContext'; +import { MockSessionContext } from '../../../../sdk/src/testing.js'; import { Branch } from '../../../../sdk/src/Branch'; import { BranchStore } from '../../../../sdk/src/BranchStore'; import type { ChatFormat, ParseChatOutputOptions, ParseChatOutputResult } from '@lloyal-labs/sdk'; diff --git a/packages/agents/test/replay.test.ts b/packages/agents/test/replay.test.ts index 1cdcbbc0..750a143e 100644 --- a/packages/agents/test/replay.test.ts +++ b/packages/agents/test/replay.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect } from 'vitest'; import { run, scoped, createChannel } from 'effection'; import type { Channel } from 'effection'; -import { MockSessionContext } from '../../sdk/test/MockSessionContext'; +import { MockSessionContext } from '../../sdk/src/testing.js'; import { BranchStore } from '../../sdk/src/BranchStore'; import { extractSpineSeed, diff --git a/packages/agents/test/spine-multimodal.test.ts b/packages/agents/test/spine-multimodal.test.ts index 91c93020..341d11a5 100644 --- a/packages/agents/test/spine-multimodal.test.ts +++ b/packages/agents/test/spine-multimodal.test.ts @@ -11,7 +11,7 @@ */ import { describe, it, expect } from 'vitest'; import { run } from 'effection'; -import { MockSessionContext } from '../../sdk/test/MockSessionContext'; +import { MockSessionContext } from '../../sdk/src/testing.js'; import { BranchStore } from '../../sdk/src/BranchStore'; import { withSpine } from '../src/spine'; import { extractSpineSeed } from '../src/replay'; diff --git a/packages/agents/test/trace-scope-halt.test.ts b/packages/agents/test/trace-scope-halt.test.ts index 8f277f8b..99cd5cfb 100644 --- a/packages/agents/test/trace-scope-halt.test.ts +++ b/packages/agents/test/trace-scope-halt.test.ts @@ -14,7 +14,7 @@ */ import { describe, it, expect } from 'vitest'; import { createScope, suspend } from 'effection'; -import { MockSessionContext } from '../../sdk/test/MockSessionContext'; +import { MockSessionContext } from '../../sdk/src/testing.js'; import { BranchStore } from '../../sdk/src/BranchStore'; import { Ctx, Store, Trace, Events } from '../src/context'; import { useAgent } from '../src/use-agent'; diff --git a/packages/agents/test/trace-tee.test.ts b/packages/agents/test/trace-tee.test.ts index 80f4737f..7c563c67 100644 --- a/packages/agents/test/trace-tee.test.ts +++ b/packages/agents/test/trace-tee.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect } from 'vitest'; import { run, createChannel, scoped } from 'effection'; import type { Operation, Channel } from 'effection'; -import { createMockSdk } from '../../sdk/test/MockSessionContext'; +import { createMockSdk } from '../../sdk/src/testing.js'; import { useAgentPool } from '../src/agent-pool'; import { parallel } from '../src/orchestrators'; import { Ctx, Store, Events, Trace } from '../src/context'; diff --git a/packages/sdk/test/MockSessionContext.ts b/packages/sdk/src/testing.ts similarity index 97% rename from packages/sdk/test/MockSessionContext.ts rename to packages/sdk/src/testing.ts index 5f8e5925..f46669e6 100644 --- a/packages/sdk/test/MockSessionContext.ts +++ b/packages/sdk/src/testing.ts @@ -1,5 +1,8 @@ /** - * Type-safe in-memory SessionContext mock for testing. + * The sdk's PUBLISHED testing surface — a type-safe in-memory + * SessionContext mock. Import it as `@lloyal-labs/sdk/dist/testing.js`: + * scaffolded harnesses drive their behavioural suites over it, and the + * in-repo suites (sdk, agents invariants) use the same entry. * * Implements the full {@link SessionContext} interface with a branch-tracking * state machine. All SDK classes ({@link Branch}, {@link BranchStore}, @@ -46,10 +49,10 @@ import type { ParseChatOutputResult, ParseChatOutputOptions, MultimodalPrefillResult, -} from '../src/types.js'; -import { Branch } from '../src/Branch.js'; -import { BranchStore } from '../src/BranchStore.js'; -import { Session } from '../src/Session.js'; +} from './types.js'; +import { Branch } from './Branch.js'; +import { BranchStore } from './BranchStore.js'; +import { Session } from './Session.js'; /** Internal branch state tracked by the mock */ interface BranchState { diff --git a/packages/sdk/test/branch-double-free.test.ts b/packages/sdk/test/branch-double-free.test.ts index 9609d513..f9c2b960 100644 --- a/packages/sdk/test/branch-double-free.test.ts +++ b/packages/sdk/test/branch-double-free.test.ts @@ -15,7 +15,7 @@ * use-after-free, and nothing else in the suite states the dependency. */ import { describe, it, expect } from 'vitest'; -import { MockSessionContext } from './MockSessionContext'; +import { MockSessionContext } from '../src/testing.js'; import { Branch } from '../src/Branch'; const ctx = () => new MockSessionContext({ nCtx: 4096, cellsUsed: 0 }); diff --git a/packages/sdk/test/deltas-multimodal.test.ts b/packages/sdk/test/deltas-multimodal.test.ts index a3500391..5f4c4ec2 100644 --- a/packages/sdk/test/deltas-multimodal.test.ts +++ b/packages/sdk/test/deltas-multimodal.test.ts @@ -7,7 +7,7 @@ * because mtmd owns tokenization downstream. */ import { describe, it, expect } from 'vitest'; -import { MockSessionContext } from './MockSessionContext'; +import { MockSessionContext } from '../src/testing.js'; import { mediaContent, buildUserDeltaMultimodal, diff --git a/packages/sdk/test/rerank-instruction.test.ts b/packages/sdk/test/rerank-instruction.test.ts index 8b05904e..cf3d0439 100644 --- a/packages/sdk/test/rerank-instruction.test.ts +++ b/packages/sdk/test/rerank-instruction.test.ts @@ -17,7 +17,7 @@ import { describe, it, expect } from 'vitest'; import { Rerank, RerankCalibrationError, RETRIEVAL_INSTRUCTION } from '@lloyal-labs/sdk'; import type { SessionContext, RerankInstruction } from '@lloyal-labs/sdk'; -import { MockSessionContext } from './MockSessionContext'; +import { MockSessionContext } from '../src/testing.js'; // `satisfies`, not an annotation: `RerankInstruction.smokeTest` is optional, so // annotating widens this fixture to `T | undefined` and every `{ ...CUSTOM diff --git a/packages/sdk/test/session-release.test.ts b/packages/sdk/test/session-release.test.ts index c1024564..35d33747 100644 --- a/packages/sdk/test/session-release.test.ts +++ b/packages/sdk/test/session-release.test.ts @@ -14,7 +14,7 @@ */ import { describe, it, expect } from 'vitest'; import { Branch, BranchStore, Session } from '../src/index'; -import { MockSessionContext } from './MockSessionContext'; +import { MockSessionContext } from '../src/testing.js'; import type { SessionContext } from '../src/types'; type Release = { branchHandle: number; position: number }; diff --git a/packages/sdk/test/utf8.test.ts b/packages/sdk/test/utf8.test.ts index 526a2ec1..d2a34c05 100644 --- a/packages/sdk/test/utf8.test.ts +++ b/packages/sdk/test/utf8.test.ts @@ -9,7 +9,7 @@ import { describe, expect, it } from 'vitest'; import { splitCompleteUtf8, concatBytes } from '../src/utf8'; import { Branch } from '../src/Branch'; -import { MockSessionContext } from './MockSessionContext'; +import { MockSessionContext } from '../src/testing.js'; const ENC = new TextEncoder(); From fd49cbf8ee2c897366d79294b9fd99299441a680 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Thu, 3 Sep 2026 23:27:41 +1000 Subject: [PATCH 19/69] =?UTF-8?q?fix(sdk,agents):=20defer=20only=20what=20?= =?UTF-8?q?is=20intact=20=E2=80=94=20DecodeError{rc,=20partial}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool re-queued a whole prefill cohort on rc 1 ("no KV slot, state restored"). llama_decode restores only the call it rejects; the kernel's chunked paths may already have landed earlier chunks, and re-prefilling those branches decodes their tokens twice onto advanced positions. The kernel now reports that fact as `partial` and the binding forwards it; this is the layer that acts on it. sdk: decodeRcOf becomes decodeErrorOf(): DecodeError | undefined — the same two fields and the same name as the kernel's, so one shape reads the same at every layer. MultimodalPrefillResult carries `partial` beside `rc`; Branch.prefillMultimodal forwards it on rethrow; the testing mock forwards it and its mockMultimodalError accepts it. Docs rewritten to the one rule: intact iff rc === 1 && !partial. Public rename — major. agents: token rail — rc 1 with partial takes the per-agent terminal (failSettled, the deferral-exhaustion path) instead of a deferral; rc 1 without it defers as before. Media rail — the rc 1 and rc -1 "intact" branches require !partial; anything else is the existing poison path. Tests, red first: "token rail rc 1 + partial: nothing is re-queued" and "media rc 1 + partial: no deferral" (each deferred once before). --- packages/agents/src/agent-pool.ts | 33 +++++++++++----- packages/agents/test/agent-pool.test.ts | 50 +++++++++++++++++++++++++ packages/sdk/src/Branch.ts | 6 +-- packages/sdk/src/BranchStore.ts | 5 ++- packages/sdk/src/index.ts | 4 +- packages/sdk/src/testing.ts | 6 +-- packages/sdk/src/types.ts | 48 +++++++++++++++++------- 7 files changed, 118 insertions(+), 34 deletions(-) diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index 2f359e6f..f00bda49 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -6,7 +6,7 @@ import type { BranchStore } from '@lloyal-labs/sdk'; import { Ctx, Store, Trace, TraceParent, CallingAgent, SpineFmt, GrantStoreCtx, WindDown, CancelAgent, Pause, Attachments, Ingress } from './context'; import { prepareBatch } from './prepare-content'; import type { FormatConfig } from './Agent'; -import { buildToolResultDelta, buildToolResultDeltaMultimodal, buildTurnDelta, buildUserDelta, decodeRcOf, deltaCells } from '@lloyal-labs/sdk'; +import { buildToolResultDelta, buildToolResultDeltaMultimodal, buildTurnDelta, buildUserDelta, decodeErrorOf, deltaCells } from '@lloyal-labs/sdk'; import type { MultimodalDelta } from '@lloyal-labs/sdk'; import type { Attachment } from '@lloyal-labs/media'; import { useTraceScope } from './trace-scope'; @@ -1419,8 +1419,19 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0) { const results = yield* call(() => store.prefillMultimodal(mediaItems.map(m => [m.agent.branch, m.delta] as [Branch, MultimodalDelta]))); @@ -1473,7 +1485,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation= BACKEND_TRIPWIRE_N) backendSuspect = true; diff --git a/packages/agents/test/agent-pool.test.ts b/packages/agents/test/agent-pool.test.ts index f748e90f..bfa3edeb 100644 --- a/packages/agents/test/agent-pool.test.ts +++ b/packages/agents/test/agent-pool.test.ts @@ -1741,6 +1741,56 @@ describe('self-healing ladder', () => { expect((settleFailed as { rc?: number }).rc).toBe(1); }); + it('token rail rc 1 + partial: an earlier chunk landed, so nothing is re-queued', async () => { + // The kernel's rule (liblloyal DecodeError): intact ⇔ rc == 1 && !partial. + // With `partial` set, some branches in the cohort advanced and the error + // does not say which; re-queuing the cohort whole would decode the landed + // ones twice onto advanced positions. The cohort fails and heals instead. + const spy = new SpyTool(); + const tools = new Map([['web_search', spy]]); + const { events, trace } = await runPool({ + forkTokenQueues: [[1, STOP, STOP]], + ...callTool('web_search'), + tools, trace: true, + mutateCtx: (ctx) => { + let thrown = 0; + const orig = ctx._storePrefill.bind(ctx); + ctx._storePrefill = async (h, t) => { + if (spy.capturedContexts.length > 0 && thrown === 0) { + thrown++; + throw Object.assign(rcError('find_slot: no KV slot for the batch', 1), { partial: true }); + } + return orig(h, t); + }; + }, + }); + expect(trace.events.filter(e => e.type === 'pool:agentDefer')).toHaveLength(0); + const failures = ladderFailures(events); + expect(failures).toHaveLength(1); + expect((failures[0] as { reason: string }).reason).toBe('tool_result_failed'); + const settleFailed = trace.events.find(e => e.type === 'pool:settleFailed'); + expect((settleFailed as { rc?: number }).rc).toBe(1); + }); + + it('media rc 1 + partial: an earlier chunk landed — not intact, so no deferral', async () => { + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { events, trace } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + forkTokenQueues: [[1, STOP, STOP]], + ...callTool('rasterize'), + tools: toolMap, trace: true, + mutateCtx: (c) => { + let seen = 0; + c.mockMultimodalError = () => + (seen++ === 0 ? { message: 'no KV slot', rc: 1, partial: true } : null); + }, + }); + expect(trace.events.filter(e => e.type === 'pool:agentDefer')).toHaveLength(0); + expect(mediaFailures(events)).toHaveLength(1); + const settleFailed = trace.events.find(e => e.type === 'pool:settleFailed'); + expect((settleFailed as { rc?: number }).rc).toBe(1); + }); + it('media rc -1: the item is dropped, the note lands, the agent continues', async () => { const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); const { events, trace } = await runPool({ diff --git a/packages/sdk/src/Branch.ts b/packages/sdk/src/Branch.ts index 929192bf..4c730cc5 100644 --- a/packages/sdk/src/Branch.ts +++ b/packages/sdk/src/Branch.ts @@ -243,10 +243,10 @@ export class Branch { const [result] = await this._ctx._storePrefillMultimodal( [this._handle], [sepTokens], [prompt], [bitmaps]); if (result.error) { - // Forward the rc as data — a re-wrap that dropped it would strip the - // classification callers gate on (decodeRcOf reads it back). + // Forward rc and partial as data — a re-wrap that dropped them would + // strip the classification callers gate on (decodeErrorOf reads them back). const err = new Error(`Branch.prefillMultimodal: ${result.error}`); - if (result.rc !== undefined) (err as Error & { rc?: number }).rc = result.rc; + if (result.rc !== undefined) Object.assign(err, { rc: result.rc, partial: result.partial === true }); throw err; } return result; diff --git a/packages/sdk/src/BranchStore.ts b/packages/sdk/src/BranchStore.ts index 2142cd84..88d2fae6 100644 --- a/packages/sdk/src/BranchStore.ts +++ b/packages/sdk/src/BranchStore.ts @@ -150,8 +150,9 @@ export class BranchStore { * entry does not reject the call: it comes back with `error` set on its own * result, and the rest still land. A rejected promise would lose which * branches were mutated, and every caller here needs that — see - * {@link MultimodalPrefillResult.error}. A failed entry's branch is - * POISONED: prune it and replay from content. + * {@link MultimodalPrefillResult.error}. A failed entry's `rc` and `partial` + * say whether its branch is still intact ({@link DecodeError}); anything but + * `rc === 1 && !partial` is POISONED: prune it and replay from content. * * @param entries - One `[branch, delta]` pair per prefill, in dispatch order * @returns One result per entry, positionally diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 25ee6226..a5fb1b4b 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -10,7 +10,7 @@ export { buildUserDelta, buildUserDeltaMultimodal, buildAssistantDelta, buildToo export type { DeltaOpts, MultimodalDelta } from './deltas'; // ── Enums + constants ──────────────────────────────────────── -export { PoolingType, CHAT_FORMAT_CONTENT_ONLY, CHAT_FORMAT_GENERIC, ReasoningFormat, GrammarTriggerType, decodeRcOf } from './types'; +export { PoolingType, CHAT_FORMAT_CONTENT_ONLY, CHAT_FORMAT_GENERIC, ReasoningFormat, GrammarTriggerType, decodeErrorOf } from './types'; // ── Types ──────────────────────────────────────────────────── export type { ChatFormat } from './types'; @@ -32,7 +32,7 @@ export type { AdvancedSamplingParams, SamplingParams, SessionContext, - MultimodalPrefillResult, + MultimodalPrefillResult, DecodeError, Produced, RerankOptions, RerankResult, diff --git a/packages/sdk/src/testing.ts b/packages/sdk/src/testing.ts index f46669e6..7d19e53b 100644 --- a/packages/sdk/src/testing.ts +++ b/packages/sdk/src/testing.ts @@ -243,7 +243,7 @@ export class MockSessionContext implements SessionContext { const f = typeof failure === 'string' ? { message: failure } : failure; out.push({ tokensDecoded: 0, positionAdvance: 0, error: f.message, - ...(f.rc !== undefined ? { rc: f.rc } : {}), + ...(f.rc !== undefined ? { rc: f.rc, partial: f.partial === true } : {}), }); continue; } @@ -261,13 +261,13 @@ export class MockSessionContext implements SessionContext { } /** Fail selected cohort entries. Returns a message (optionally with the - * llama_decode rc, as the native worker attaches it) to fail that entry, + * llama_decode rc and partial flag, as the native worker attaches them) to fail that entry, * null to let it through — lets a test drive the one-bad-image-among- * siblings case and the rc-classified self-healing ladder. */ mockMultimodalError?: ( prompt: string, bitmaps: Uint8Array[], - ) => string | { message: string; rc?: number } | null; + ) => string | { message: string; rc?: number; partial?: boolean } | null; /** Cells one multimodal prefill consumes. Text stands in at one cell per 4 * chars, matching tokenizeSync, minus the markers the native walk replaces diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index 177b47dc..afe8c718 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -289,20 +289,24 @@ export interface MultimodalPrefillResult { tokensDecoded: number; /** Branch position advance (< tokensDecoded under M-RoPE with images) */ positionAdvance: number; - /** `llama_decode`'s raw return code when this entry failed with one — - * the classification a caller acts on: `1` no KV slot (state restored, - * the branch is INTACT — retry later); `-1` invalid batch (restored); - * `2` aborted / `< -1` fatal (partial ubatches remain — POISONED). - * Absent on success and for failures that never reached llama_decode. */ + /** `llama_decode`'s raw return code when this entry failed with one. With + * {@link partial} it classifies the failure — the rule is on + * {@link DecodeError}. Absent on success and for failures that never + * reached llama_decode. */ rc?: number; + /** True when an earlier chunk of this entry landed before the failing call: + * the branch moved, so it is not intact even though `rc` says the failing + * call restored state. Present exactly when `rc` is. */ + partial?: boolean; /** Why THIS entry failed, when it did — the cohort keeps going. * * A rejected promise would lose which entries landed, and the caller needs * that: six agents settling images must not lose five because one page was * corrupt, and pruning the right branch requires knowing which one it was. - * Set ⇒ this branch is POISONED, not merely unchanged: `decode_segments` is - * not atomic and partial-range KV ops are meaningless on recurrent layers, - * so the contract is prune and replay from content, never resume. + * Set ⇒ classify by `rc` and `partial` ({@link DecodeError}): intact only + * when `rc === 1 && !partial`; anything else is POISONED — prune and replay + * from content, never resume (`decode_segments` is not atomic and + * partial-range KV ops are meaningless on recurrent layers). * * `Branch.prefillMultimodal` throws instead of setting this — it is a * cohort of one, where a throw is the friendlier shape. */ @@ -310,16 +314,32 @@ export interface MultimodalPrefillResult { } /** - * Read the `llama_decode` return code off a rejected native call, when the - * binding attached one. The ONE place the rejection's shape is known — every - * consumer classifies through this, never by matching message text. + * What a failed `llama_decode` left behind, as the kernel reports it (the + * same two fields as liblloyal's `DecodeError`): `rc` classifies the failing + * call — `1` no KV slot, `-1` invalid batch (both restored that call), `2` + * aborted, `< -1` fatal — and `partial` says whether earlier chunks of the + * same operation landed. One rule, true on every path: the branch is intact + * iff `rc === 1 && !partial` — retry once the KV has room. Anything else ⇒ + * prune the branch and replay. * * @category Branching */ -export function decodeRcOf(err: unknown): number | undefined { +export interface DecodeError { + rc: number; + partial: boolean; +} + +/** + * Read the {@link DecodeError} off a rejected native call, when the binding + * attached one. The ONE place the rejection's shape is known — every consumer + * classifies through this, never by matching message text. + * + * @category Branching + */ +export function decodeErrorOf(err: unknown): DecodeError | undefined { if (typeof err === 'object' && err !== null && 'rc' in err) { - const rc = (err as { rc: unknown }).rc; - if (typeof rc === 'number' && Number.isInteger(rc)) return rc; + const { rc, partial } = err as { rc: unknown; partial?: unknown }; + if (typeof rc === 'number' && Number.isInteger(rc)) return { rc, partial: partial === true }; } return undefined; } From 0641716a1b5c3583a62ae67527b2bb624d5f4e19 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Fri, 4 Sep 2026 01:30:35 +1000 Subject: [PATCH 20/69] fix(sdk,agents,media,rig): every review finding behind a test that was red first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sdk — utf8: a prefix that can never become valid UTF-8 (E0 80, ED A0, F0 80, F4 90) is decided now, not held: the second byte is checked against its lead's range before the tail is retained. deltas: a multimodal delta snapshots its images — the prompt fixed its marker count when the delta was built, and the bitmaps must be that set at prefill whatever the caller does to its array. Docs: the failing call restores state for rc 1 AND rc -1; intact iff that and !partial. media — the BMP hand-off path checked only the caller's maxPixels; the absolute MAX_INPUT_PIXELS ceiling sharp enforces everywhere else now applies there too, before the admission ceiling. rig — content routes: a malformed percent escape answers 400 (client input), not 500; the upload deadline WINS the race — it aborts the signal and rejects, so a decode that ignores the signal cannot hold the response past the ceiling (408 goes out, the late result is discarded); `ingest` returns an Attachment root, so an injected ingress cannot hand the route a bare descriptor. agents — the spine's setup after branch creation (header prefill, the media barrier, the multimodal prefill) now lives inside the scope that prunes it: a failed prefill cannot leak a slot or a poisoned branch. tool:result records `cells` — one unit for both rails; an image's cells are not tokens. agent:spawn carries `after` on the trace as the spec promised. prepareBatch overlaps its ingests (`all`, input order kept); the normalizer already bounds itself process-wide. release plumbing — scripts/cut-alpha.lib.mjs is the cutter's pure core, tested: parseCut rejects anything but a non-negative integer; the local fallback applies ONLY on a registry 404 and every other failure aborts the cut; planAlphas is a golden of today's set; rewriteManifest stamps a cut package and leaves everything else (the abilities) untouched — their peer range ships as their own release. sdk is a MAJOR (SessionContext gained required members; decodeRcOf became decodeErrorOf). The script attempts the lockfile regeneration after stamping and says loudly when an external pin is not published yet. ci.yml verifies the oras archive against the release digest before it runs. A workspace-lockfile test pins that the lockfile records the manifests it freezes — red until the lloyal.node alpha publishes, the same moment this branch's install turns green. --- .github/workflows/ci.yml | 3 + packages/agents/src/agent-pool.ts | 11 +- packages/agents/src/prepare-content.ts | 10 +- packages/agents/src/spine.ts | 260 +++++++++--------- packages/agents/src/trace-types.ts | 9 +- packages/agents/test/agent-pool.test.ts | 15 + packages/agents/test/prepare-content.test.ts | 39 +++ packages/agents/test/spine-multimodal.test.ts | 13 + .../agents/test/workspace-lockfile.test.ts | 27 ++ packages/media/src/image.ts | 9 + packages/media/test/normalize.test.ts | 15 +- packages/rig/src/content-routes.ts | 39 ++- packages/rig/test/content-routes.test.ts | 43 ++- packages/sdk/src/BranchStore.ts | 3 +- packages/sdk/src/deltas.ts | 6 +- packages/sdk/src/types.ts | 19 +- packages/sdk/src/utf8.ts | 20 +- packages/sdk/test/deltas-multimodal.test.ts | 14 +- packages/sdk/test/utf8.test.ts | 12 + scripts/cut-alpha.lib.mjs | 70 +++++ scripts/cut-alpha.mjs | 106 ++++--- scripts/cut-alpha.test.ts | 95 +++++++ vitest.config.ts | 2 +- 23 files changed, 615 insertions(+), 225 deletions(-) create mode 100644 packages/agents/test/prepare-content.test.ts create mode 100644 packages/agents/test/workspace-lockfile.test.ts create mode 100644 scripts/cut-alpha.lib.mjs mode change 100644 => 100755 scripts/cut-alpha.mjs create mode 100644 scripts/cut-alpha.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 606e8efc..51f42433 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,9 @@ jobs: VERSION=1.3.0 curl -fsSL -o oras.tar.gz \ "https://github.com/oras-project/oras/releases/download/v${VERSION}/oras_${VERSION}_linux_amd64.tar.gz" + # A pinned version is not a verified artifact: check the archive + # against the release's published digest before anything runs. + echo "6cdc692f929100feb08aa8de584d02f7bcc30ec7d88bc2adc2054d782db57c64 oras.tar.gz" | sha256sum -c - tar -xzf oras.tar.gz oras sudo mv oras /usr/local/bin/ oras version diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index f00bda49..a05037f5 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -1426,7 +1426,8 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0 ? { after: s.task.after } : {}), }); yield* poolChannel.send({ type: 'agent:spawn', agentId: s.agent.id, parentAgentId: s.agent.parentId, ...(s.task.after && s.task.after.length > 0 ? { after: s.task.after } : {}) }); } @@ -2246,6 +2248,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0 ? { after: h.spec.after } : {}), }); yield* poolChannel.send({ type: 'agent:spawn', agentId: agent.id, parentAgentId: agent.parentId, ...(h.spec.after && h.spec.after.length > 0 ? { after: h.spec.after } : {}) }); } @@ -2644,7 +2647,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation ingress.ingest(bytes, signal))); - } + // Concurrent, in input order: normalization is the expensive step and the + // normalizer already bounds itself process-wide, so a batch of N must not + // cost the sum of N decodes while permits sit idle. `all` keeps the order. + const roots: Attachment[] = yield* all(items.map((bytes) => call(() => ingress.ingest(bytes, signal)))); // Resolve from the store rather than trusting what ingest returned, through // the SAME call replay uses — so a batch that materializes here is one that // can be rebuilt later, by construction rather than by assertion. diff --git a/packages/agents/src/spine.ts b/packages/agents/src/spine.ts index 60734fcb..7394f027 100644 --- a/packages/agents/src/spine.ts +++ b/packages/agents/src/spine.ts @@ -167,145 +167,147 @@ export function* withSpine( role: "spine", }); - if (prefillTokens.length > 0) { - yield* call(() => spine.prefill(prefillTokens)); - tw.write({ - traceId: tw.nextId(), - parentTraceId: scopeId, - ts: performance.now(), - type: "branch:prefill", - branchHandle: spine.handle, - cells: prefillTokens.length, - role: "spineHeader", - }); - } - - // Shared role+tools mode: format the chat header once and prefill onto - // the spine. Agents forking from this spine inherit system+tools tokens - // via metadata-only prefix-share (no per-spawn re-prefill). The resulting - // FormatConfig is stashed on SpineFmt so setupAgent can detect shared - // mode and copy parser/grammar/format/triggers without re-emitting the - // tool schemas in each agent's suffix. - let spineFmt: FormatConfig | null = null; - if (opts.systemPrompt !== undefined) { - const enableThinking = opts.enableThinking ?? true; - - // THE BARRIER. Every image is normalized and committed BEFORE a single - // marker is emitted or any KV is touched, so a failure on image N leaves - // no markers, no prefill and no published descriptors — only unreachable - // content-addressed blobs, which are harmless. `bitmaps` below is what the - // projector will actually decode: the admitted representations, not the - // raw input, because those are the bytes whose cells replay must rebuild. - const raw = opts.bitmaps ?? []; - const prepared = raw.length > 0 - ? yield* prepareBatch(ingress, attachments, raw) - : { attachments: [], bitmaps: [] }; - const bitmaps = prepared.bitmaps as Uint8Array[]; - // Marker injection goes through the SDK's `mediaContent` — the one place - // media_marker parts are emitted — so the spine header, a user turn and a - // tool result cannot drift apart in how they mark media. It returns the - // bare string when there are no bitmaps, which is the text-path shape. - // - // The spine does not use a delta builder: it needs the whole - // FormattedChatResult for `spineFmt` (grammar/format/parser/triggers) and - // the messages JSON for the trace seed, neither of which a - // `MultimodalDelta` carries. Sharing the marker grammar is the part that - // matters; the rest of this assembly is legitimately spine-specific. - const messages = JSON.stringify([ - { role: "system", content: mediaContent(opts.systemPrompt, bitmaps) }, - ]); - const fmtOpts: Record = { - enableThinking, - // Header ends at <|im_end|>; agents append <|im_start|>user…assistant - // markers as their suffix. Without this, the template would emit a - // trailing assistant generation prompt and corrupt the boundary. - addGenerationPrompt: false, - }; - if (opts.tools && opts.tools.length > 0) { - fmtOpts.tools = createToolkit(opts.tools).toolsJson; - } - const formatted = ctx.formatChatSync(messages, fmtOpts); - // Spine-seed emission for trace replay (`extractSpineSeed`). Captures - // the rendered chat prompt verbatim so a later `reconstructBranch` - // can rebuild this exact KV state in a fresh context. - // - // WRITE-AHEAD, on BOTH rails: the seed says what this spine INTENDS to - // prefill, so a prefill that then fails still leaves a run that can be - // rebuilt — and a failed multimodal prefill poisons the branch, which is - // exactly when replay is the only way back. `branch:prefill` below is the - // other half of the pair and asserts the opposite: it is written only - // after the KV actually moved. - // - // `tokenCount` is omitted on the embedding rail — mtmd owns tokenization - // there and no honest count exists before the native call returns. The - // count that landed rides `branch:prefill`. - const writeSpineSeed = (tokenCount?: number): void => { - tw.write({ - traceId: tw.nextId(), - parentTraceId: scopeId, - ts: performance.now(), - type: "prompt:format", - promptText: formatted.prompt, - tokenCount, - // Roots ride the seed WRITE-AHEAD: the barrier committed the content - // before any prefill, so a failed multimodal prefill still leaves a - // seed that replay can rebuild from. `branch:prefill` below keeps - // the success-only copy. - ...(prepared.attachments.length > 0 - ? { attachments: prepared.attachments } - : {}), - messages, - tools: opts.tools && opts.tools.length > 0 - ? createToolkit(opts.tools).toolsJson - : undefined, - role: "spine", - }); - }; - - let headerCells = 0; - let attached: readonly Attachment[] | undefined; - if (bitmaps.length > 0) { - writeSpineSeed(); - const counts = yield* call(() => - spine.prefillMultimodal(formatted.prompt, bitmaps)); - headerCells = counts.tokensDecoded; - // Already committed by the barrier above — this only carries the roots - // onto the trace. Recording used to happen HERE, after the prefill, so - // a failed write left media in the cache that could never be replayed. - attached = prepared.attachments; - } else { - const headerTokens = ctx.tokenizeSync(formatted.prompt, false); - writeSpineSeed(headerTokens.length); - headerCells = headerTokens.length; - if (headerTokens.length > 0) { - yield* call(() => spine.prefill(headerTokens)); - } - } - if (headerCells > 0) { + // From here the branch exists: every step — header prefill, the media + // barrier, the multimodal prefill — runs INSIDE the scope that prunes it, + // so a failure on any of them cannot leak a slot or a poisoned branch. + try { + if (prefillTokens.length > 0) { + yield* call(() => spine.prefill(prefillTokens)); tw.write({ traceId: tw.nextId(), parentTraceId: scopeId, ts: performance.now(), type: "branch:prefill", branchHandle: spine.handle, - cells: headerCells, + cells: prefillTokens.length, role: "spineHeader", - ...(attached ? { attachments: attached } : {}), }); } - spineFmt = { - format: formatted.format, - reasoningFormat: formatted.reasoningFormat, - generationPrompt: formatted.generationPrompt, - parser: formatted.parser, - grammar: formatted.grammar, - grammarLazy: formatted.grammarLazy, - grammarTriggers: formatted.grammarTriggers, - enableThinking, - }; - } - try { + // Shared role+tools mode: format the chat header once and prefill onto + // the spine. Agents forking from this spine inherit system+tools tokens + // via metadata-only prefix-share (no per-spawn re-prefill). The resulting + // FormatConfig is stashed on SpineFmt so setupAgent can detect shared + // mode and copy parser/grammar/format/triggers without re-emitting the + // tool schemas in each agent's suffix. + let spineFmt: FormatConfig | null = null; + if (opts.systemPrompt !== undefined) { + const enableThinking = opts.enableThinking ?? true; + + // THE BARRIER. Every image is normalized and committed BEFORE a single + // marker is emitted or any KV is touched, so a failure on image N leaves + // no markers, no prefill and no published descriptors — only unreachable + // content-addressed blobs, which are harmless. `bitmaps` below is what the + // projector will actually decode: the admitted representations, not the + // raw input, because those are the bytes whose cells replay must rebuild. + const raw = opts.bitmaps ?? []; + const prepared = raw.length > 0 + ? yield* prepareBatch(ingress, attachments, raw) + : { attachments: [], bitmaps: [] }; + const bitmaps = prepared.bitmaps as Uint8Array[]; + // Marker injection goes through the SDK's `mediaContent` — the one place + // media_marker parts are emitted — so the spine header, a user turn and a + // tool result cannot drift apart in how they mark media. It returns the + // bare string when there are no bitmaps, which is the text-path shape. + // + // The spine does not use a delta builder: it needs the whole + // FormattedChatResult for `spineFmt` (grammar/format/parser/triggers) and + // the messages JSON for the trace seed, neither of which a + // `MultimodalDelta` carries. Sharing the marker grammar is the part that + // matters; the rest of this assembly is legitimately spine-specific. + const messages = JSON.stringify([ + { role: "system", content: mediaContent(opts.systemPrompt, bitmaps) }, + ]); + const fmtOpts: Record = { + enableThinking, + // Header ends at <|im_end|>; agents append <|im_start|>user…assistant + // markers as their suffix. Without this, the template would emit a + // trailing assistant generation prompt and corrupt the boundary. + addGenerationPrompt: false, + }; + if (opts.tools && opts.tools.length > 0) { + fmtOpts.tools = createToolkit(opts.tools).toolsJson; + } + const formatted = ctx.formatChatSync(messages, fmtOpts); + // Spine-seed emission for trace replay (`extractSpineSeed`). Captures + // the rendered chat prompt verbatim so a later `reconstructBranch` + // can rebuild this exact KV state in a fresh context. + // + // WRITE-AHEAD, on BOTH rails: the seed says what this spine INTENDS to + // prefill, so a prefill that then fails still leaves a run that can be + // rebuilt — and a failed multimodal prefill poisons the branch, which is + // exactly when replay is the only way back. `branch:prefill` below is the + // other half of the pair and asserts the opposite: it is written only + // after the KV actually moved. + // + // `tokenCount` is omitted on the embedding rail — mtmd owns tokenization + // there and no honest count exists before the native call returns. The + // count that landed rides `branch:prefill`. + const writeSpineSeed = (tokenCount?: number): void => { + tw.write({ + traceId: tw.nextId(), + parentTraceId: scopeId, + ts: performance.now(), + type: "prompt:format", + promptText: formatted.prompt, + tokenCount, + // Roots ride the seed WRITE-AHEAD: the barrier committed the content + // before any prefill, so a failed multimodal prefill still leaves a + // seed that replay can rebuild from. `branch:prefill` below keeps + // the success-only copy. + ...(prepared.attachments.length > 0 + ? { attachments: prepared.attachments } + : {}), + messages, + tools: opts.tools && opts.tools.length > 0 + ? createToolkit(opts.tools).toolsJson + : undefined, + role: "spine", + }); + }; + + let headerCells = 0; + let attached: readonly Attachment[] | undefined; + if (bitmaps.length > 0) { + writeSpineSeed(); + const counts = yield* call(() => + spine.prefillMultimodal(formatted.prompt, bitmaps)); + headerCells = counts.tokensDecoded; + // Already committed by the barrier above — this only carries the roots + // onto the trace. Recording used to happen HERE, after the prefill, so + // a failed write left media in the cache that could never be replayed. + attached = prepared.attachments; + } else { + const headerTokens = ctx.tokenizeSync(formatted.prompt, false); + writeSpineSeed(headerTokens.length); + headerCells = headerTokens.length; + if (headerTokens.length > 0) { + yield* call(() => spine.prefill(headerTokens)); + } + } + if (headerCells > 0) { + tw.write({ + traceId: tw.nextId(), + parentTraceId: scopeId, + ts: performance.now(), + type: "branch:prefill", + branchHandle: spine.handle, + cells: headerCells, + role: "spineHeader", + ...(attached ? { attachments: attached } : {}), + }); + } + spineFmt = { + format: formatted.format, + reasoningFormat: formatted.reasoningFormat, + generationPrompt: formatted.generationPrompt, + parser: formatted.parser, + grammar: formatted.grammar, + grammarLazy: formatted.grammarLazy, + grammarTriggers: formatted.grammarTriggers, + enableThinking, + }; + } if (spineFmt) yield* SpineFmt.set(spineFmt); return yield* body(spine, prefillTokens.length); } finally { diff --git a/packages/agents/src/trace-types.ts b/packages/agents/src/trace-types.ts index afd4d138..f1847c7e 100644 --- a/packages/agents/src/trace-types.ts +++ b/packages/agents/src/trace-types.ts @@ -318,7 +318,9 @@ export type TraceEvent = // spawns), `agent:done` ends it at the drop or return. Recovery events // (`pool:recovery*`) may follow `agent:done` for the same agent — a span // consumer that wants the recovery tail extends to the last such event. - | TraceEventBase & { type: 'agent:spawn'; agentId: number; parentAgentId: number } + | TraceEventBase & { type: 'agent:spawn'; agentId: number; parentAgentId: number; + /** DAG dependency edges the spec declared (`AgentTaskSpec.after`); absent when none. */ + after?: number[] } | TraceEventBase & { type: 'agent:done'; agentId: number } // ── Agent per-turn output ──────────────────── @@ -360,7 +362,10 @@ export type TraceEvent = agentId: number; tool: string; result: unknown; - prefillTokenCount: number; + /** KV cells the settled result cost — tokens on the token rail, cells on + * the media rail (an image's cells are not tokens: M-RoPE makes the + * units differ), so the one unit every prefill event already speaks. */ + cells: number; durationMs: number; } // Fan-out determinism: the ORDERED tool results scatter-prefilled in one diff --git a/packages/agents/test/agent-pool.test.ts b/packages/agents/test/agent-pool.test.ts index bfa3edeb..49185cd2 100644 --- a/packages/agents/test/agent-pool.test.ts +++ b/packages/agents/test/agent-pool.test.ts @@ -1791,6 +1791,21 @@ describe('self-healing ladder', () => { expect((settleFailed as { rc?: number }).rc).toBe(1); }); + it('the tool:result trace records media cost as CELLS, never under a token name', async () => { + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const { trace } = await runPool({ + nCtx: MEDIA_TEST_NCTX, + forkTokenQueues: [[1, STOP, STOP]], + ...callTool('rasterize'), + tools: toolMap, trace: true, + }); + const ev = trace.events.find(e => e.type === 'tool:result') as Record | undefined; + expect(ev).toBeDefined(); + // Image cells are not tokens (M-RoPE makes the units differ); one unit, one name. + expect(typeof ev!.cells).toBe('number'); + expect('prefillTokenCount' in ev!).toBe(false); + }); + it('media rc -1: the item is dropped, the note lands, the agent continues', async () => { const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); const { events, trace } = await runPool({ diff --git a/packages/agents/test/prepare-content.test.ts b/packages/agents/test/prepare-content.test.ts new file mode 100644 index 00000000..b2c357d2 --- /dev/null +++ b/packages/agents/test/prepare-content.test.ts @@ -0,0 +1,39 @@ +/** + * prepareBatch — the media barrier before a prefill. Normalization is the + * expensive step (seconds per image), and the normalizer already bounds + * itself process-wide, so a batch must let its items overlap: results in + * input order, execution not serialized. + */ +import { describe, it, expect } from 'vitest'; +import { run } from 'effection'; +import type { ContentIngress } from '@lloyal-labs/media'; +import { prepareBatch } from '../src/prepare-content'; +import { MemoryAttachmentStore } from './helpers/memory-store'; +import { rawIngress } from './helpers/raw-ingress'; + +const img = (n: number): Uint8Array[] => + Array.from({ length: n }, (_, i) => new Uint8Array([i, i + 1, i + 2, 0x89])); + +describe('prepareBatch', () => { + it('overlaps ingests instead of running them one after another', async () => { + const store = new MemoryAttachmentStore(); + const raw = rawIngress(store); + let inflight = 0; + let peak = 0; + const ingress: ContentIngress = { + ingest: async (bytes, signal) => { + inflight++; + peak = Math.max(peak, inflight); + try { + await new Promise((r) => setTimeout(r, 20)); + return await raw.ingest(bytes, signal); + } finally { + inflight--; + } + }, + }; + const prepared = await run(function* () { return yield* prepareBatch(ingress, store, img(4)); }); + expect(prepared.bitmaps).toHaveLength(4); + expect(peak).toBeGreaterThan(1); + }); +}); diff --git a/packages/agents/test/spine-multimodal.test.ts b/packages/agents/test/spine-multimodal.test.ts index 341d11a5..e5bea146 100644 --- a/packages/agents/test/spine-multimodal.test.ts +++ b/packages/agents/test/spine-multimodal.test.ts @@ -60,6 +60,19 @@ function spineBody( }; } +describe('withSpine failure', () => { + it('a failed multimodal prefill prunes the spine before the error escapes', async () => { + const ctx = new MockSessionContext({ nCtx: 16384, cellsUsed: 0 }); + ctx.mockMultimodalError = () => ({ message: 'no KV slot', rc: 1 }); + const trace = new CapturingTraceWriter(); + await expect(run(spineBody(ctx, trace, img(1)))).rejects.toThrow(/no KV slot/); + // The branch the spine allocated is gone: its prune is on the trace and no + // cells remain — the setup after branch creation is inside the pruning scope. + expect(trace.events.some(e => e.type === 'branch:prune')).toBe(true); + expect(ctx.cellsUsed).toBe(0); + }); +}); + async function runSpine(bitmaps: Uint8Array[] | undefined) { const ctx = new MockSessionContext({ nCtx: 16384, cellsUsed: 0 }); const trace = new CapturingTraceWriter(); diff --git a/packages/agents/test/workspace-lockfile.test.ts b/packages/agents/test/workspace-lockfile.test.ts new file mode 100644 index 00000000..28641a56 --- /dev/null +++ b/packages/agents/test/workspace-lockfile.test.ts @@ -0,0 +1,27 @@ +/** + * The committed lockfile must describe the committed manifests. The alpha + * cutter rewrites versions and exact pins across the workspace; a lockfile + * that still records the previous set makes a frozen install (`npm ci`) + * refuse — or install something other than what the manifests say. + * + * Anchored in the agents package because its entry is the one that drifts + * first (a MAJOR on this arc), but the check is workspace-wide. + */ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const ROOT = join(__dirname, '..', '..', '..'); +const CUT = ['packages/media', 'packages/sdk', 'packages/agents', 'packages/rig', 'packages/dev-tools']; + +describe('workspace lockfile', () => { + it('records every cut package at the version its manifest declares', () => { + const lock = JSON.parse(readFileSync(join(ROOT, 'package-lock.json'), 'utf8')) as { + packages: Record; + }; + for (const dir of CUT) { + const manifest = JSON.parse(readFileSync(join(ROOT, dir, 'package.json'), 'utf8')) as { version: string }; + expect(lock.packages[dir]?.version, dir).toBe(manifest.version); + } + }); +}); diff --git a/packages/media/src/image.ts b/packages/media/src/image.ts index 1d371126..ec7f27d0 100644 --- a/packages/media/src/image.ts +++ b/packages/media/src/image.ts @@ -419,6 +419,15 @@ export const normalizeImage: NormalizeImage = async (bytes, opts = {}) => { 'so it cannot be admitted under a pixel ceiling.', ); } + // The ABSOLUTE ceiling first — the same one sharp enforces on every other + // format through limitInputPixels. A caller may raise maxPixels; it may + // not open a decompression-bomb door the sharp path keeps shut. + if (dims.width * dims.height > MAX_INPUT_PIXELS) { + throw new Error( + `normalizeImage: ${dims.width}x${dims.height} exceeds the absolute ` + + `${MAX_INPUT_PIXELS}-pixel ceiling.`, + ); + } if (dims.width * dims.height > maxPixels) { throw new Error( `normalizeImage: ${dims.width}x${dims.height} exceeds the ${maxPixels}-pixel ` + diff --git a/packages/media/test/normalize.test.ts b/packages/media/test/normalize.test.ts index 8d53b795..1d4ab018 100644 --- a/packages/media/test/normalize.test.ts +++ b/packages/media/test/normalize.test.ts @@ -9,7 +9,7 @@ */ import { describe, it, expect } from 'vitest'; import sharp from 'sharp'; -import { normalizeImage, DEFAULT_MAX_PIXELS, MAX_CONCURRENT_NORMALIZATIONS } from '../src/image'; +import { normalizeImage, DEFAULT_MAX_PIXELS, MAX_CONCURRENT_NORMALIZATIONS, MAX_INPUT_PIXELS } from '../src/image'; import type { NormalizedImage } from '../src/image'; // Its own list of nine, sourced from stb_image — NOT derived from the sniff // table, which knows four. Deriving it was a defect: the pass-through gate read @@ -82,6 +82,19 @@ describe('normalizeImage', () => { expect((await meta(out.bytes)).orientation ?? 1).toBe(1); }); + it('refuses a BMP above the ABSOLUTE ceiling even when maxPixels is raised past it', async () => { + // sharp guards its own path with limitInputPixels = MAX_INPUT_PIXELS; the + // BMP hand-off has only the header to go on, and a caller-raised maxPixels + // must not open a decompression-bomb door the sharp path keeps shut. + const bmp = Buffer.alloc(54 + 48, 0xff); + bmp.write('BM', 0); + bmp.writeUInt32LE(54, 10); bmp.writeUInt32LE(40, 14); + bmp.writeInt32LE(20_000, 18); bmp.writeInt32LE(20_000, 22); // 4e8 pixels + bmp.writeUInt16LE(1, 26); bmp.writeUInt16LE(24, 28); + await expect(normalizeImage(new Uint8Array(bmp), { maxPixels: MAX_INPUT_PIXELS * 10 })) + .rejects.toThrow(/ceiling|exceeds/); + }); + it('hands BMP to the model rather than refusing it', async () => { // sharp cannot READ bmp, but stb_image — which is what mtmd loads with — // can. Refusing here would cost the user their whole query for a file the diff --git a/packages/rig/src/content-routes.ts b/packages/rig/src/content-routes.ts index ac655c79..96f42809 100644 --- a/packages/rig/src/content-routes.ts +++ b/packages/rig/src/content-routes.ts @@ -1,5 +1,5 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import { representationsOf, DIGEST_PATTERN } from '@lloyal-labs/media'; +import { representationsOf, DIGEST_PATTERN, type Attachment } from '@lloyal-labs/media'; import type { AttachmentStore, Descriptor } from '@lloyal-labs/media'; /** Thrown when a body exceeds the cap, so the caller can answer 413 rather @@ -19,6 +19,12 @@ const DEFAULT_UPLOAD_TIMEOUT_MS = 30_000; /** Thrown when an upload outruns {@link ContentRoutesOpts.uploadTimeoutMs}. */ class TooSlow extends Error {} +/** `decodeURIComponent` throws on a malformed escape; that is the client's + * input, not a server fault, so the caller answers 400 on `null`. */ +const decodeSegment = (s: string): string | null => { + try { return decodeURIComponent(s); } catch { return null; } +}; + /** * @category Runtime */ @@ -41,7 +47,7 @@ export interface ContentRoutesOpts { * as authority over content the client did not produce. The bytes answer * that question, and the ingress is where they are decoded. */ - ingest?: (bytes: Uint8Array, signal?: AbortSignal) => Promise; + ingest?: (bytes: Uint8Array, signal?: AbortSignal) => Promise; /** Ceiling on a single upload body. @default 8 MiB */ maxUploadBytes?: number; /** @@ -266,8 +272,8 @@ export function createContentRoutes( // manifest at all. Bytes have exactly one door, and it is that one. const exists = /^\/v1\/content\/([^/]+)$/.exec(path); if (exists && method === 'HEAD') { - const digest = decodeURIComponent(exists[1]); - if (!DIGEST_PATTERN.test(digest)) { fail(res, 400, 'malformed digest'); return true; } + const digest = decodeSegment(exists[1]); + if (digest === null || !DIGEST_PATTERN.test(digest)) { fail(res, 400, 'malformed digest'); return true; } // Reads the WHOLE blob to answer a yes/no question, because // `AttachmentStore` offers no `size`/`has`. On the one route whose // purpose is to AVOID moving bytes, a dedupe pre-flight against an @@ -287,8 +293,8 @@ export function createContentRoutes( // representations, so a source layer can never be served by mistake. const rep = /^\/v1\/media\/([^/]+)\/representations\/(\d+)$/.exec(path); if (rep && (method === 'GET' || method === 'HEAD')) { - const digest = decodeURIComponent(rep[1]); - if (!DIGEST_PATTERN.test(digest)) { fail(res, 400, 'malformed digest'); return true; } + const digest = decodeSegment(rep[1]); + if (digest === null || !DIGEST_PATTERN.test(digest)) { fail(res, 400, 'malformed digest'); return true; } const manifest = opts.store.getManifest(digest); if (!manifest) { fail(res, 404, 'no such attachment manifest'); return true; } const reps = representationsOf(manifest); @@ -307,10 +313,23 @@ export function createContentRoutes( fail(res, 501, 'no ingress service installed on this host'); return true; } + // ONE deadline for transfer AND ingress, and it wins the race: it + // aborts the signal and REJECTS, so a decode that ignores the signal + // (sharp, once inside) cannot hold the response past the ceiling. The + // late result of such a decode is discarded, never written. const ctrl = new AbortController(); - const deadline = setTimeout(() => ctrl.abort(), uploadTimeout); - readBounded(req, { maxBytes: maxUpload, timeoutMs: uploadTimeout }) - .then((bytes) => opts.ingest!(bytes, ctrl.signal)) + let timer: NodeJS.Timeout | undefined; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => { + ctrl.abort(); + reject(new TooSlow(`upload exceeded ${uploadTimeout}ms end to end`)); + }, uploadTimeout); + }); + Promise.race([ + readBounded(req, { maxBytes: maxUpload, timeoutMs: uploadTimeout }) + .then((bytes) => opts.ingest!(bytes, ctrl.signal)), + deadline, + ]) .then((descriptor) => { const body = JSON.stringify(descriptor); res.writeHead(201, head({ @@ -329,7 +348,7 @@ export function createContentRoutes( // so the timeout path has to drop the socket just as the cap does. if (tooLarge || tooSlow) req.destroy(); }) - .finally(() => clearTimeout(deadline)); + .finally(() => clearTimeout(timer)); return true; } diff --git a/packages/rig/test/content-routes.test.ts b/packages/rig/test/content-routes.test.ts index e34bec46..7c35de31 100644 --- a/packages/rig/test/content-routes.test.ts +++ b/packages/rig/test/content-routes.test.ts @@ -13,6 +13,12 @@ import { join } from 'node:path'; import { AddressInfo } from 'node:net'; import { FileAttachmentStore } from '@lloyal-labs/media/node'; import { createContentRoutes } from '../src/content-routes'; +import type { Attachment } from '@lloyal-labs/media'; + +/** A fabricated ROOT for ingress fakes — branded here, once, so a fake cannot + * hand the route a bare descriptor by accident (the contract the route keeps). */ +const fakeRoot = (digest: string, size: number): Attachment => + ({ mediaType: 'image/png', digest, size }) as Attachment; const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]); const JPEG = new Uint8Array([0xff, 0xd8, 0xff, 9, 9, 9]); @@ -47,6 +53,39 @@ async function withServer( } describe('content routes', () => { + it('answers a malformed percent escape with 400, never 500', async () => { + const { store } = fixture(); + await withServer({ store }, async (base) => { + // decodeURIComponent throws on these; that is client input, not a server fault. + const head = await fetch(`${base}/v1/content/%`, { method: 'HEAD' }); + expect(head.status).toBe(400); + const rep = await fetch(`${base}/v1/media/%/representations/0`); + expect(rep.status).toBe(400); + }); + }); + + it('sends 408 at the deadline even when the ingress ignores its signal', async () => { + const { store } = fixture(); + await withServer( + // An ingress that never settles and never looks at the signal — a decode + // already inside sharp behaves exactly like this. + { store, uploadTimeoutMs: 150, ingest: () => new Promise(() => {}) }, + async (base) => { + const res = await fetch(`${base}/v1/media/ingress`, { + method: 'POST', body: PNG, signal: AbortSignal.timeout(3_000), + }); + expect(res.status).toBe(408); + }, + ); + }); + + it('the ingress contract returns an attachment ROOT, not any descriptor (type-level)', () => { + const plain = { mediaType: 'image/png', digest: 'sha256:' + '0'.repeat(64), size: 1 }; + // @ts-expect-error a bare Descriptor must not satisfy `ingest` — only a manifest root may come back + const routes = createContentRoutes({ store: fixture().store, ingest: async () => plain }); + expect(typeof routes).toBe('function'); + }); + it('serves the admitted representation, never the source', async () => { const { store, root, source } = fixture(); await withServer({ store }, async (base) => { @@ -174,7 +213,7 @@ describe('content routes', () => { // promise and the socket for the life of the process — enough of them // starve the host without any single limit being exceeded. await withServer( - { store, ingest: async () => ({ mediaType: 'image/png', digest: 'sha256:' + '0'.repeat(64), size: 1 }), uploadTimeoutMs: 150 }, + { store, ingest: async () => fakeRoot('sha256:' + '0'.repeat(64), 1), uploadTimeoutMs: 150 }, async (base) => { const stalled = new ReadableStream({ start(controller) { @@ -204,7 +243,7 @@ describe('content routes', () => { uploadTimeoutMs: 5_000, ingest: async (bytes) => { ingested = bytes; - return { mediaType: 'image/png', digest: 'sha256:' + '1'.repeat(64), size: bytes.byteLength }; + return fakeRoot('sha256:' + '1'.repeat(64), bytes.byteLength); }, }, async (base) => { diff --git a/packages/sdk/src/BranchStore.ts b/packages/sdk/src/BranchStore.ts index 88d2fae6..16554bbe 100644 --- a/packages/sdk/src/BranchStore.ts +++ b/packages/sdk/src/BranchStore.ts @@ -152,7 +152,8 @@ export class BranchStore { * branches were mutated, and every caller here needs that — see * {@link MultimodalPrefillResult.error}. A failed entry's `rc` and `partial` * say whether its branch is still intact ({@link DecodeError}); anything but - * `rc === 1 && !partial` is POISONED: prune it and replay from content. + * a restored call (`rc` 1 or -1) with `!partial` is POISONED: prune it and + * replay from content. * * @param entries - One `[branch, delta]` pair per prefill, in dispatch order * @returns One result per entry, positionally diff --git a/packages/sdk/src/deltas.ts b/packages/sdk/src/deltas.ts index 861fc630..34202bc0 100644 --- a/packages/sdk/src/deltas.ts +++ b/packages/sdk/src/deltas.ts @@ -167,7 +167,9 @@ export function buildUserDeltaMultimodal( ]), fmtOpts ); - return { sep, prompt, bitmaps: images }; + // A snapshot: the prompt fixed its marker count here, and the bitmaps must + // still be that set at prefill, whatever the caller does to its array. + return { sep, prompt, bitmaps: [...images] }; } /** @@ -334,7 +336,7 @@ export function buildToolResultDeltaMultimodal( generationPrompt && !prompt.endsWith(generationPrompt) ? prompt + generationPrompt : prompt; - return { sep, prompt: withGen, bitmaps: images }; + return { sep, prompt: withGen, bitmaps: [...images] }; } /** diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index afe8c718..90a328d4 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -304,9 +304,10 @@ export interface MultimodalPrefillResult { * that: six agents settling images must not lose five because one page was * corrupt, and pruning the right branch requires knowing which one it was. * Set ⇒ classify by `rc` and `partial` ({@link DecodeError}): intact only - * when `rc === 1 && !partial`; anything else is POISONED — prune and replay - * from content, never resume (`decode_segments` is not atomic and - * partial-range KV ops are meaningless on recurrent layers). + * when the failing call restored state (`rc` 1 or -1) and `!partial`; + * anything else is POISONED — prune and replay from content, never resume + * (`decode_segments` is not atomic and partial-range KV ops are + * meaningless on recurrent layers). * * `Branch.prefillMultimodal` throws instead of setting this — it is a * cohort of one, where a throw is the friendlier shape. */ @@ -316,11 +317,13 @@ export interface MultimodalPrefillResult { /** * What a failed `llama_decode` left behind, as the kernel reports it (the * same two fields as liblloyal's `DecodeError`): `rc` classifies the failing - * call — `1` no KV slot, `-1` invalid batch (both restored that call), `2` - * aborted, `< -1` fatal — and `partial` says whether earlier chunks of the - * same operation landed. One rule, true on every path: the branch is intact - * iff `rc === 1 && !partial` — retry once the KV has room. Anything else ⇒ - * prune the branch and replay. + * call — `1` no KV slot and `-1` invalid batch both restored that call, `2` + * aborted and `< -1` fatal did not — and `partial` says whether earlier + * chunks of the same operation landed. One rule, true on every path: the + * branch is INTACT iff the failing call restored state (`rc` 1 or -1) and + * `!partial`. Intact with 1 is a capacity wait — retry once the KV has room; + * intact with -1 is the input — do not resend the same delta. Anything else + * ⇒ prune the branch and replay. * * @category Branching */ diff --git a/packages/sdk/src/utf8.ts b/packages/sdk/src/utf8.ts index f66e4241..909ba569 100644 --- a/packages/sdk/src/utf8.ts +++ b/packages/sdk/src/utf8.ts @@ -45,6 +45,18 @@ function seqLen(b: number): number { const isContinuation = (b: number): boolean => (b & 0xc0) === 0x80; +/** Whether `b` may follow `lead` as the SECOND byte. Four leads narrow the + * range (RFC 3629 §4): E0 forbids overlong forms, ED forbids surrogates, F0 + * forbids overlong forms, F4 forbids code points above U+10FFFF. Everything + * else takes any continuation byte. */ +function secondByteOk(lead: number, b: number): boolean { + if (lead === 0xe0) return b >= 0xa0 && b <= 0xbf; + if (lead === 0xed) return b >= 0x80 && b <= 0x9f; + if (lead === 0xf0) return b >= 0x90 && b <= 0xbf; + if (lead === 0xf4) return b >= 0x80 && b <= 0x8f; + return isContinuation(b); +} + /** * Split `bytes` at the last complete UTF-8 character boundary. * @@ -64,11 +76,13 @@ export function splitCompleteUtf8(bytes: Uint8Array): Utf8Split { const need = seqLen(bytes[i]); if (need > n - i) { // The lead promises more bytes than remain. Hold it only if what - // follows is all continuations — otherwise the sequence is already - // broken and waiting would never mend it. + // follows is a valid prefix — the second byte within its lead's range, + // the rest continuations. Otherwise the sequence is already broken and + // waiting would never mend it. let validPrefix = true; for (let j = i + 1; j < n; j++) { - if (!isContinuation(bytes[j])) { validPrefix = false; break; } + const ok = j === i + 1 ? secondByteOk(bytes[i], bytes[j]) : isContinuation(bytes[j]); + if (!ok) { validPrefix = false; break; } } if (validPrefix) holdFrom = i; } diff --git a/packages/sdk/test/deltas-multimodal.test.ts b/packages/sdk/test/deltas-multimodal.test.ts index 5f4c4ec2..c3f4d9ae 100644 --- a/packages/sdk/test/deltas-multimodal.test.ts +++ b/packages/sdk/test/deltas-multimodal.test.ts @@ -59,7 +59,19 @@ describe('buildUserDeltaMultimodal', () => { expect(d.sep).toEqual(ctx.getTurnSeparator()); expect(markerCount(d.prompt)).toBe(2); expect(d.prompt).toContain('what is in these?'); - expect(d.bitmaps).toBe(images); + expect(d.bitmaps).toEqual(images); + }); + + it('snapshots the images — a later mutation cannot desync markers from bitmaps', () => { + // The prompt fixed its marker count when the delta was built; the bitmaps + // must be the same set at prefill time, whatever the caller does to its + // array in between. + const ctx = new MockSessionContext(); + const images = img(2); + const d = buildUserDeltaMultimodal(ctx, 'q', images); + images.push(new Uint8Array([9, 9, 9])); + expect(d.bitmaps).toHaveLength(2); + expect(markerCount(d.prompt)).toBe(d.bitmaps.length); }); it('stops at the string stage — the prompt is not tokenized here', () => { diff --git a/packages/sdk/test/utf8.test.ts b/packages/sdk/test/utf8.test.ts index d2a34c05..b338ee00 100644 --- a/packages/sdk/test/utf8.test.ts +++ b/packages/sdk/test/utf8.test.ts @@ -88,6 +88,18 @@ describe('splitCompleteUtf8 — junk is decided now, never held', () => { expect(tail.length).toBe(0); }); + it('a prefix that can never become valid UTF-8 is decided now, not held', () => { + // UTF-8 constrains the SECOND byte per lead: E0 needs A0–BF (else overlong), + // ED needs 80–9F (else a surrogate), F0 needs 90–BF (else overlong), F4 + // needs 80–8F (else above U+10FFFF). Holding these would delay the U+FFFD + // and lose it if the stream ends here — contrary to the contract above. + for (const prefix of [[0xe0, 0x80], [0xed, 0xa0], [0xf0, 0x80], [0xf4, 0x90]]) { + const { complete, tail } = splitCompleteUtf8(new Uint8Array(prefix)); + expect(tail.length, `prefix ${prefix.map((b) => b.toString(16)).join(' ')}`).toBe(0); + expect(complete).toBe('\uFFFD\uFFFD'); + } + }); + it('a genuinely incomplete character IS held, and completes', () => { // '€' is e2 82 ac. const first = splitCompleteUtf8(new Uint8Array([0x61, 0xe2, 0x82])); diff --git a/scripts/cut-alpha.lib.mjs b/scripts/cut-alpha.lib.mjs new file mode 100644 index 00000000..2807abd0 --- /dev/null +++ b/scripts/cut-alpha.lib.mjs @@ -0,0 +1,70 @@ +/** + * The alpha cutter's pure core. `cut-alpha.mjs` is I/O around these — the + * registry lookup and the manifest writes — so everything that can be wrong + * about a cut is decidable here, and tested without a registry or a checkout. + */ + +/** `--cut `: a non-negative integer, nothing else. `Number()` would take + * a missing or garbled value as NaN and stamp `-alpha.NaN` everywhere. */ +export function parseCut(arg) { + if (arg === undefined || !/^\d+$/.test(String(arg))) { + throw new Error(`--cut must be a non-negative integer (got ${JSON.stringify(arg ?? null)})`); + } + return Number(arg); +} + +/** A prerelease `latest` (a manual first alpha publish stamps latest — npm + * behavior) is not a base to bump FROM: the stable it prefigures has not + * shipped, so its release triple IS the pending base. A stable latest bumps + * by the arc's level. */ +export function nextBase(reg, level) { + if (reg.includes('-')) return reg.split('-')[0]; + const [maj, min] = reg.split('.').map(Number); + return level === 'major' ? `${maj + 1}.0.0` : `${maj}.${min + 1}.0`; +} + +/** Is this `npm view` failure the registry saying "no such package"? Only + * that answer earns the local fallback — a transient network, auth or + * rate-limit failure must abort the cut, or stale bases get stamped as if + * they were the registry's word. */ +export const isNotFound = (err) => + err?.code === 'E404' || /\bE404\b|404 Not Found/.test(`${err?.stderr ?? ''}\n${err?.message ?? ''}`); + +/** The registry's latest for `name`, via `view(name)` (returns the version + * string, or throws npm's error). Falls back to `fallback` ONLY on 404. */ +export function latestVersion(name, fallback, view) { + try { + return String(view(name)).trim(); + } catch (err) { + if (!isNotFound(err)) throw err; + console.log(` (${name} not on the registry yet — base ${fallback}, needs one manual first publish)`); + return fallback; + } +} + +/** The set: `{ name → x.y.z-alpha. }` for every package in `packages` + * (`{ name, level, fallback }`), bases resolved through `view`. */ +export function planAlphas({ cut, packages, view }) { + const alphas = {}; + for (const { name, level, fallback } of packages) { + alphas[name] = `${nextBase(latestVersion(name, fallback, view), level)}-alpha.${cut}`; + } + return alphas; +} + +/** Rewrite one manifest object in place: its `version` when this package is + * in the cut (`version` given), and every dependency/peer that names a cut + * package to the exact alpha. A package OUTSIDE the cut is never touched — + * its published version cannot carry new contents, so changed pins there + * would be pins nobody can install. Returns whether anything changed. */ +export function rewriteManifest(pkg, { version, alphas }) { + if (version === undefined) return false; + let changed = false; + if (pkg.version !== version) { pkg.version = version; changed = true; } + for (const field of ['dependencies', 'peerDependencies']) { + for (const dep of Object.keys(pkg[field] ?? {})) { + if (alphas[dep] && pkg[field][dep] !== alphas[dep]) { pkg[field][dep] = alphas[dep]; changed = true; } + } + } + return changed; +} diff --git a/scripts/cut-alpha.mjs b/scripts/cut-alpha.mjs old mode 100644 new mode 100755 index a7fc0387..53bebc24 --- a/scripts/cut-alpha.mjs +++ b/scripts/cut-alpha.mjs @@ -10,24 +10,34 @@ * * Versions and exact internal pins are COMMITTED on the arc branch: the set * is recorded in git, the workspace still resolves locally for dev, and the - * merge back to main resolves them to the real stable bump. + * merge back to main resolves them to the real stable bump. The lockfile is + * regenerated to match — a lockfile describing the previous set makes a + * frozen install refuse. That step needs every external pin published + * (lloyal.node's alpha first); until then it reports and leaves the old + * lockfile in place. + * + * The pure core (parseCut, planAlphas, rewriteManifest) lives in + * cut-alpha.lib.mjs and is tested there. * * Run locally: node scripts/cut-alpha.mjs --cut 0 [--dry-run] */ import { execSync } from 'node:child_process'; import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; +import { parseCut, planAlphas, rewriteManifest } from './cut-alpha.lib.mjs'; const cutIdx = process.argv.indexOf('--cut'); -if (cutIdx === -1) throw new Error('required: --cut '); -const CUT = Number(process.argv[cutIdx + 1]); +const CUT = parseCut(cutIdx === -1 ? undefined : process.argv[cutIdx + 1]); const DRY = process.argv.includes('--dry-run'); /** What this arc touched → how far its next version moves. agents is a * MAJOR: the arc removed 18 public exports (the content vocabulary moved - * to @lloyal-labs/media). */ + * to @lloyal-labs/media). sdk is a MAJOR: SessionContext gained required + * members (tokenToBytes, supportsVision/Audio, the multimodal natives) and + * decodeRcOf became decodeErrorOf — a third-party context stops + * type-checking, so this is not a minor. */ const CUTS = { 'packages/media': 'minor', - 'packages/sdk': 'minor', + 'packages/sdk': 'major', 'packages/agents': 'major', 'packages/rig': 'minor', 'packages/dev-tools': 'minor', @@ -36,63 +46,47 @@ const CUTS = { * binding). Must match lloyal.node's own cut level. */ const EXTERNAL = { '@lloyal-labs/lloyal.node': 'minor' }; -const bump = (v, level) => { - const [maj, min] = v.split('.').map(Number); - return level === 'major' ? `${maj + 1}.0.0` : `${maj}.${min + 1}.0`; -}; -/** A prerelease `latest` (a manual first alpha publish stamps latest — npm - * behavior) is not a base to bump FROM: the stable it prefigures hasn't - * shipped, so its release triple IS the pending base. A stable latest - * bumps by the arc's level. */ -const nextBase = (reg, level) => (reg.includes('-') ? reg.split('-')[0] : bump(reg, level)); -/** Registry base, or the local manifest's for a package npm has never seen. - * NOTE: npm cannot CREATE a package name from CI (interactive 2FA) — a - * brand-new package (media, on this arc) needs ONE manual `npm publish` - * before the first cut's workflow run can succeed. */ -const latest = (name, fallback) => { - try { - return execSync(`npm view ${name}@latest version`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); - } catch { - console.log(` (${name} not on the registry yet — base ${fallback}, needs one manual first publish)`); - return fallback; - } -}; +const view = (name) => + execSync(`npm view ${name}@latest version`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); -const alphas = {}; -for (const [dir, level] of Object.entries(CUTS)) { - const pkg = JSON.parse(readFileSync(`${dir}/package.json`, 'utf8')); - const base = pkg.version.split('-')[0]; // a prior cut's -alpha.N is not a base - alphas[pkg.name] = `${nextBase(latest(pkg.name, base), level)}-alpha.${CUT}`; -} -for (const [name, level] of Object.entries(EXTERNAL)) { - alphas[name] = `${nextBase(latest(name, '0.0.0'), level)}-alpha.${CUT}`; -} +const manifestOf = (dir) => JSON.parse(readFileSync(`${dir}/package.json`, 'utf8')); +const cutPackages = Object.entries(CUTS).map(([dir, level]) => { + const pkg = manifestOf(dir); + // A prior cut's -alpha.N is not a base; the manifest's release triple is. + return { dir, name: pkg.name, level, fallback: pkg.version.split('-')[0] }; +}); +const alphas = planAlphas({ + cut: CUT, + packages: [ + ...cutPackages.map(({ name, level, fallback }) => ({ name, level, fallback })), + ...Object.entries(EXTERNAL).map(([name, level]) => ({ name, level, fallback: '0.0.0' })), + ], + view, +}); console.log(`cut ${CUT}${DRY ? ' (dry run)' : ''}:`); for (const [n, v] of Object.entries(alphas)) console.log(` ${n} -> ${v}`); -const dirs = ['packages', 'packages/abilities'].flatMap((root) => - readdirSync(root) - .map((d) => `${root}/${d}`) - .filter((d) => existsSync(`${d}/package.json`)), -); -for (const dir of dirs) { +// Only the cut packages are rewritten. Abilities keep their published +// versions and their peer ranges; the peer-range change they need ships as +// their own release. +const nameOf = Object.fromEntries(cutPackages.map((p) => [p.dir, p.name])); +for (const dir of readdirSync('packages').map((d) => `packages/${d}`).filter((d) => d in CUTS && existsSync(`${d}/package.json`))) { const path = `${dir}/package.json`; - const pkg = JSON.parse(readFileSync(path, 'utf8')); - let changed = false; - if (dir in CUTS && pkg.version !== alphas[pkg.name]) { - console.log(` ${path}: version ${pkg.version} -> ${alphas[pkg.name]}`); - pkg.version = alphas[pkg.name]; - changed = true; + const pkg = manifestOf(dir); + const before = JSON.stringify(pkg); + if (rewriteManifest(pkg, { version: alphas[nameOf[dir]], alphas })) { + console.log(` ${path}: ${JSON.parse(before).version} -> ${pkg.version}, pins exact`); + if (!DRY) writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); } - for (const field of ['dependencies', 'peerDependencies']) { - for (const dep of Object.keys(pkg[field] ?? {})) { - if (alphas[dep] && pkg[field][dep] !== alphas[dep]) { - console.log(` ${path}: ${dep} ${pkg[field][dep]} -> ${alphas[dep]} (exact)`); - pkg[field][dep] = alphas[dep]; - changed = true; - } - } +} + +if (!DRY) { + try { + execSync('npm install --package-lock-only --ignore-scripts --no-audit --no-fund', { stdio: 'inherit' }); + console.log(' package-lock.json regenerated for the set'); + } catch { + console.log(' package-lock.json NOT regenerated: an external pin is not published yet ' + + '(lloyal.node alpha first). Re-run `npm install --package-lock-only` once it is, and commit the lockfile with the pins.'); } - if (changed && !DRY) writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); } diff --git a/scripts/cut-alpha.test.ts b/scripts/cut-alpha.test.ts new file mode 100644 index 00000000..e9b9bbfe --- /dev/null +++ b/scripts/cut-alpha.test.ts @@ -0,0 +1,95 @@ +/** + * The alpha cutter's pure core. The script is I/O around these: `npm view` + * and file rewrites. Everything that can be wrong about a cut is decidable + * here without a registry or a checkout. + */ +import { describe, it, expect } from 'vitest'; +import { parseCut, latestVersion, planAlphas, rewriteManifest } from './cut-alpha.lib.mjs'; + +const e404 = Object.assign(new Error('npm ERR! code E404'), { stderr: 'npm ERR! code E404\nnpm ERR! 404 Not Found' }); +const reset = Object.assign(new Error('npm ERR! code ECONNRESET'), { stderr: 'npm ERR! code ECONNRESET' }); + +describe('parseCut', () => { + it('accepts a non-negative integer and nothing else', () => { + expect(parseCut('2')).toBe(2); + expect(parseCut('0')).toBe(0); + for (const bad of [undefined, '', 'x', '-1', '1.5', 'NaN']) { + expect(() => parseCut(bad as string), String(bad)).toThrow(/--cut/); + } + }); +}); + +describe('latestVersion', () => { + it('falls back ONLY on a registry 404; every other failure aborts the cut', () => { + expect(latestVersion('@x/new', '0.1.0', () => { throw e404; })).toBe('0.1.0'); + expect(() => latestVersion('@x/sdk', '3.1.0', () => { throw reset; })).toThrow(/ECONNRESET/); + expect(latestVersion('@x/sdk', '3.1.0', () => '3.1.4\n')).toBe('3.1.4'); + }); +}); + +describe('planAlphas', () => { + it('is a golden: the set the templates pin today', () => { + const registry: Record = { + '@lloyal-labs/sdk': '3.1.0', '@lloyal-labs/lloyal-agents': '5.5.1', '@lloyal-labs/rig': '5.5.0', + '@lloyal-labs/dev-tools': '0.4.3', '@lloyal-labs/lloyal.node': '3.1.1', + }; + const view = (name: string) => { if (name in registry) return registry[name]; throw e404; }; + const alphas = planAlphas({ + cut: 1, + packages: [ + { name: '@lloyal-labs/media', level: 'minor', fallback: '0.1.0' }, + { name: '@lloyal-labs/sdk', level: 'minor', fallback: '0.0.0' }, + { name: '@lloyal-labs/lloyal-agents', level: 'major', fallback: '0.0.0' }, + { name: '@lloyal-labs/rig', level: 'minor', fallback: '0.0.0' }, + { name: '@lloyal-labs/dev-tools', level: 'minor', fallback: '0.0.0' }, + { name: '@lloyal-labs/lloyal.node', level: 'minor', fallback: '0.0.0' }, + ], + view, + }); + expect(alphas).toEqual({ + '@lloyal-labs/media': '0.2.0-alpha.1', + '@lloyal-labs/sdk': '3.2.0-alpha.1', + '@lloyal-labs/lloyal-agents': '6.0.0-alpha.1', + '@lloyal-labs/rig': '5.6.0-alpha.1', + '@lloyal-labs/dev-tools': '0.5.0-alpha.1', + '@lloyal-labs/lloyal.node': '3.2.0-alpha.1', + }); + }); + + it('a prerelease latest is the pending base, never bumped again', () => { + const alphas = planAlphas({ + cut: 3, + packages: [{ name: '@lloyal-labs/media', level: 'minor', fallback: '0.0.0' }], + view: () => '0.2.0-alpha.0', + }); + expect(alphas['@lloyal-labs/media']).toBe('0.2.0-alpha.3'); + }); +}); + +describe('rewriteManifest', () => { + const alphas = { '@lloyal-labs/sdk': '3.2.0-alpha.1', '@lloyal-labs/lloyal-agents': '6.0.0-alpha.1' }; + + it('stamps a cut package: its version and its exact internal pins, deps and peers alike', () => { + const pkg = { + name: '@lloyal-labs/rig', version: '5.5.0', + dependencies: { '@lloyal-labs/sdk': '^3.1.0', effection: '^4' }, + peerDependencies: { '@lloyal-labs/lloyal-agents': '^5' }, + }; + const changed = rewriteManifest(pkg, { version: '5.6.0-alpha.1', alphas }); + expect(changed).toBe(true); + expect(pkg.version).toBe('5.6.0-alpha.1'); + expect(pkg.dependencies['@lloyal-labs/sdk']).toBe('3.2.0-alpha.1'); + expect(pkg.dependencies.effection).toBe('^4'); + expect(pkg.peerDependencies['@lloyal-labs/lloyal-agents']).toBe('6.0.0-alpha.1'); + }); + + it('leaves a package outside the cut untouched — its published version cannot carry new contents', () => { + const pkg = { + name: '@lloyal-labs/web-ability', version: '2.0.1', + peerDependencies: { '@lloyal-labs/lloyal-agents': '^5' }, + }; + const before = JSON.stringify(pkg); + expect(rewriteManifest(pkg, { version: undefined, alphas })).toBe(false); + expect(JSON.stringify(pkg)).toBe(before); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index f31eb613..a4c36ceb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - include: ['packages/*/test/**/*.test.ts', 'packages/abilities/*/test/**/*.test.ts'], + include: ['packages/*/test/**/*.test.ts', 'packages/abilities/*/test/**/*.test.ts', 'scripts/*.test.ts'], globals: true, }, }); From 862048056baa636e41c9b05354f7a21c159068c3 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Fri, 4 Sep 2026 03:44:58 +1000 Subject: [PATCH 21/69] =?UTF-8?q?fix(sdk,media,scripts):=20review=20round?= =?UTF-8?q?=20on=200641716=20=E2=80=94=20each=20finding=20tested=20first?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sdk — an external prefill ends the held UTF-8 tail. The tail advanced only on generated-token commits and was copied on fork, never cleared by Branch.prefill, BranchStore.prefill or the multimodal prefills; a turn that stopped mid-character left its fragment to be glued onto the NEXT turn's first bytes ("�t3" for "t3"). `_endTail()` runs after every landed external prefill, single and batched; forks and commits keep the tail. Tests: the single and the batched (settle) path. media — the official ingress commits nothing after the caller gave up. A decode already inside sharp cannot be interrupted, so the signal could fire during normalization and the ingress still wrote the blob and the manifest after the route had answered 408. The signal is checked again after normalization and before the first store write. Test: an abort that lands during the decode rejects with AbortError and the store sees no putBlob/putAttachment. scripts/cut-alpha — three: parseCut requires a SAFE integer (a long digit string became Infinity); a package the registry has never seen keeps its pending prerelease as the base (the manifest's -alpha.N is not stripped, so cut 2 continues 0.2.0-alpha.2 instead of bumping to 0.3.0); and exact pins follow the set in EVERY workspace manifest again, with only a cut package's version moving — 0641716 had excluded the abilities, which would have failed the workspace install at cut 2 (their peers still naming -alpha.1). The abilities are workspace members whose release goes through the signed catalog, not this repo's npm loop; that release bumps their versions. scripts/*.test.ts is now type-checked with the rest, and the core carries JSDoc types so the checked test infers the set's shape. agents — prepare-content's doc said sequential; the code overlaps ingests in input order. The doc now says what the code does. --- packages/agents/src/prepare-content.ts | 13 ++++---- packages/media/src/image.ts | 5 ++++ packages/media/test/ingress.test.ts | 41 ++++++++++++++++++++++++++ packages/sdk/src/Branch.ts | 13 ++++++++ packages/sdk/src/BranchStore.ts | 7 ++++- packages/sdk/test/utf8.test.ts | 25 ++++++++++++++++ scripts/cut-alpha.lib.mjs | 28 ++++++++++++------ scripts/cut-alpha.mjs | 22 ++++++++------ scripts/cut-alpha.test.ts | 30 ++++++++++++++----- tsconfig.test.json | 5 +++- 10 files changed, 157 insertions(+), 32 deletions(-) create mode 100644 packages/media/test/ingress.test.ts diff --git a/packages/agents/src/prepare-content.ts b/packages/agents/src/prepare-content.ts index 64624c41..6d9f45d6 100644 --- a/packages/agents/src/prepare-content.ts +++ b/packages/agents/src/prepare-content.ts @@ -31,11 +31,14 @@ import { materialize } from '@lloyal-labs/media'; * must NOT leave is a half-admitted query: zero prefills, zero markers, zero * published descriptors, unchanged KV. * - * Sequential rather than concurrent: order is part of the contract, and - * nothing here is slow enough to trade that for. An Operation rather than an - * async function, so a halted scope cancels the batch BETWEEN items instead of - * leaving it running detached — the promise boundary into the ingress is - * crossed with `call()`, which is where cancellation is observed. + * Concurrent, in input order: the ingests overlap (`all`), because + * normalization is the expensive step and the normalizer already bounds + * itself process-wide — a batch of N must not cost the sum of N decodes while + * permits sit idle — and `all` returns results in the order given, so order + * stays part of the contract. An Operation rather than an async function, so + * a halted scope cancels the whole batch instead of leaving it running + * detached — the promise boundary into the ingress is crossed with `call()`, + * and the scope's signal reaches the ingress itself. * * **Scope this claim carefully.** This makes media *preparation* atomic with * respect to the prefill. It does NOT make the prefill itself transactional — diff --git a/packages/media/src/image.ts b/packages/media/src/image.ts index ec7f27d0..59e6cf66 100644 --- a/packages/media/src/image.ts +++ b/packages/media/src/image.ts @@ -555,6 +555,11 @@ export function createImageIngress( // else through untouched — it is not a validation gate. It is not a // resource boundary either: the transport bounds body size before this. const norm = await normalizeImage(bytes, { ...opts, ...(signal ? { signal } : {}) }); + // A decode already inside sharp cannot be interrupted, so the signal may + // have fired while it ran. The caller gave up; the route has answered + // 408. Nothing may be committed on its behalf now — the decode was + // discarded work, and it stays that way. + if (signal?.aborted) throw aborted(); // A derivation record describes a derivation that HAPPENED. Writing it // on a pass-through would annotate bytes nobody re-encoded with a diff --git a/packages/media/test/ingress.test.ts b/packages/media/test/ingress.test.ts new file mode 100644 index 00000000..87510883 --- /dev/null +++ b/packages/media/test/ingress.test.ts @@ -0,0 +1,41 @@ +/** + * createImageIngress — the official ingress. One promise the HTTP route makes + * on its behalf: after the end-to-end deadline nothing is committed. sharp + * cannot be interrupted mid-decode, so the ingress must look at the signal + * again AFTER normalization and BEFORE the first store write. + */ +import { describe, it, expect, vi } from 'vitest'; +import sharp from 'sharp'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { FileAttachmentStore } from '../src/node'; +import { createImageIngress } from '../src/image'; + +const png = () => + sharp({ create: { width: 64, height: 64, channels: 3, background: '#0a7' } }).png().toBuffer() + .then((b) => new Uint8Array(b)); + +describe('createImageIngress', () => { + it('commits nothing when the signal aborted during the decode', async () => { + const store = new FileAttachmentStore(mkdtempSync(join(tmpdir(), 'ingress-'))); + const putBlob = vi.spyOn(store, 'putBlob'); + const putAttachment = vi.spyOn(store, 'putAttachment'); + const ingress = createImageIngress(store); + + // Not yet aborted when ingest starts (the queue admits it); aborted on the + // next macrotask, which lands while sharp is inside the decode. + const ctrl = new AbortController(); + setTimeout(() => ctrl.abort(), 0); + + await expect(ingress.ingest(await png(), ctrl.signal)).rejects.toMatchObject({ name: 'AbortError' }); + expect(putBlob).not.toHaveBeenCalled(); + expect(putAttachment).not.toHaveBeenCalled(); + }); + + it('commits normally when nobody gave up', async () => { + const store = new FileAttachmentStore(mkdtempSync(join(tmpdir(), 'ingress-'))); + const root = await createImageIngress(store).ingest(await png(), new AbortController().signal); + expect(store.getManifest(root.digest)).toBeTruthy(); + }); +}); diff --git a/packages/sdk/src/Branch.ts b/packages/sdk/src/Branch.ts index 4c730cc5..352828ef 100644 --- a/packages/sdk/src/Branch.ts +++ b/packages/sdk/src/Branch.ts @@ -210,6 +210,18 @@ export class Branch { async prefill(tokens: number[]): Promise { this._ensureNotDisposed(); await this._ctx._storePrefill([this._handle], [tokens]); + this._endTail(); + } + + /** + * An external prefill ends the current text stream: a fragment held from + * the previous turn can never be completed by the next one, so it must not + * be glued onto that turn's first bytes. Forks and generated-token commits + * keep the tail; only new content entering the KV clears it. + * @internal + */ + _endTail(): void { + this._held = new Uint8Array(0); } /** @@ -249,6 +261,7 @@ export class Branch { if (result.rc !== undefined) Object.assign(err, { rc: result.rc, partial: result.partial === true }); throw err; } + this._endTail(); return result; } diff --git a/packages/sdk/src/BranchStore.ts b/packages/sdk/src/BranchStore.ts index 16554bbe..380bdbf6 100644 --- a/packages/sdk/src/BranchStore.ts +++ b/packages/sdk/src/BranchStore.ts @@ -134,6 +134,7 @@ export class BranchStore { tokenArrays.push(tokens); } await this._ctx._storePrefill(handles, tokenArrays); + for (const [branch] of entries) branch._endTail(); } /** @@ -173,7 +174,11 @@ export class BranchStore { prompts.push(delta.prompt); bitmaps.push(delta.bitmaps); } - return this._ctx._storePrefillMultimodal(handles, sepTokens, prompts, bitmaps); + const results = await this._ctx._storePrefillMultimodal(handles, sepTokens, prompts, bitmaps); + // A landed entry ended its branch's text stream; a failed one is pruned + // by the caller, tail and all. + results.forEach((r, i) => { if (!r.error) entries[i][0]._endTail(); }); + return results; } /** diff --git a/packages/sdk/test/utf8.test.ts b/packages/sdk/test/utf8.test.ts index b338ee00..5bb7a2e7 100644 --- a/packages/sdk/test/utf8.test.ts +++ b/packages/sdk/test/utf8.test.ts @@ -163,6 +163,31 @@ describe('Branch produce/commit — text is boundary-aligned, tail advances on c expect(b.produceSync().text).toBe('📋'); }); + it('an external prefill ends the held tail — a torn fragment cannot cross turns', async () => { + // A generation that stops mid-character leaves [f0 9f] held. A tool + // result is then prefilled and the NEXT turn begins; its first bytes must + // not be glued to the previous turn's fragment. + const { ctx, next } = tornCtx(); + const b = Branch.create(ctx, 0); + await b.commit(b.produceSync().token); // holds [f0 9f] + await b.prefill([7]); // an external delta lands + next(3); + expect(b.produceSync().text).toBe('t3'); // not "\uFFFDt3" + }); + + it('the batched prefill ends every branch tail (the agent-pool settle path)', async () => { + const { BranchStore } = await import('../src/BranchStore'); + const { ctx, next } = tornCtx(); + const store = new BranchStore(ctx); + const a = Branch.create(ctx, 0); + const b = Branch.create(ctx, 0); + await store.commit([[a, a.produceSync().token], [b, b.produceSync().token]]); + await store.prefill([[a, [7]], [b, [8]]]); + next(3); + expect(a.produceSync().text).toBe('t3'); + expect(b.produceSync().text).toBe('t3'); + }); + it('a fork continues the parent stream mid-character', async () => { const { ctx, next } = tornCtx(); const b = Branch.create(ctx, 0); diff --git a/scripts/cut-alpha.lib.mjs b/scripts/cut-alpha.lib.mjs index 2807abd0..71e958fb 100644 --- a/scripts/cut-alpha.lib.mjs +++ b/scripts/cut-alpha.lib.mjs @@ -7,10 +7,11 @@ /** `--cut `: a non-negative integer, nothing else. `Number()` would take * a missing or garbled value as NaN and stamp `-alpha.NaN` everywhere. */ export function parseCut(arg) { - if (arg === undefined || !/^\d+$/.test(String(arg))) { + const n = arg === undefined || !/^\d+$/.test(String(arg)) ? NaN : Number(arg); + if (!Number.isSafeInteger(n)) { throw new Error(`--cut must be a non-negative integer (got ${JSON.stringify(arg ?? null)})`); } - return Number(arg); + return n; } /** A prerelease `latest` (a manual first alpha publish stamps latest — npm @@ -42,9 +43,16 @@ export function latestVersion(name, fallback, view) { } } -/** The set: `{ name → x.y.z-alpha. }` for every package in `packages` - * (`{ name, level, fallback }`), bases resolved through `view`. */ +/** + * The set: `{ name → x.y.z-alpha. }` for every package in `packages`, + * bases resolved through `view`. + * @param {{ cut: number, + * packages: Array<{ name: string, level: 'major' | 'minor', fallback: string }>, + * view: (name: string) => string }} plan + * @returns {Record} + */ export function planAlphas({ cut, packages, view }) { + /** @type {Record} */ const alphas = {}; for (const { name, level, fallback } of packages) { alphas[name] = `${nextBase(latestVersion(name, fallback, view), level)}-alpha.${cut}`; @@ -54,13 +62,15 @@ export function planAlphas({ cut, packages, view }) { /** Rewrite one manifest object in place: its `version` when this package is * in the cut (`version` given), and every dependency/peer that names a cut - * package to the exact alpha. A package OUTSIDE the cut is never touched — - * its published version cannot carry new contents, so changed pins there - * would be pins nobody can install. Returns whether anything changed. */ + * package to the exact alpha — in EVERY workspace manifest, because the + * workspace must resolve as one set (a peer still naming the previous + * -alpha.N fails the install). A member outside the cut keeps its version: + * the npm loop skips already-published versions, and the abilities ship + * through the signed catalog, whose release moves theirs. Returns whether + * anything changed. */ export function rewriteManifest(pkg, { version, alphas }) { - if (version === undefined) return false; let changed = false; - if (pkg.version !== version) { pkg.version = version; changed = true; } + if (version !== undefined && pkg.version !== version) { pkg.version = version; changed = true; } for (const field of ['dependencies', 'peerDependencies']) { for (const dep of Object.keys(pkg[field] ?? {})) { if (alphas[dep] && pkg[field][dep] !== alphas[dep]) { pkg[field][dep] = alphas[dep]; changed = true; } diff --git a/scripts/cut-alpha.mjs b/scripts/cut-alpha.mjs index 53bebc24..056bce19 100755 --- a/scripts/cut-alpha.mjs +++ b/scripts/cut-alpha.mjs @@ -52,8 +52,9 @@ const view = (name) => const manifestOf = (dir) => JSON.parse(readFileSync(`${dir}/package.json`, 'utf8')); const cutPackages = Object.entries(CUTS).map(([dir, level]) => { const pkg = manifestOf(dir); - // A prior cut's -alpha.N is not a base; the manifest's release triple is. - return { dir, name: pkg.name, level, fallback: pkg.version.split('-')[0] }; + // For a package the registry has never seen, the manifest IS the base — a + // prior cut's -alpha.N is the pending release, continued, never bumped again. + return { dir, name: pkg.name, level, fallback: pkg.version }; }); const alphas = planAlphas({ cut: CUT, @@ -67,16 +68,19 @@ const alphas = planAlphas({ console.log(`cut ${CUT}${DRY ? ' (dry run)' : ''}:`); for (const [n, v] of Object.entries(alphas)) console.log(` ${n} -> ${v}`); -// Only the cut packages are rewritten. Abilities keep their published -// versions and their peer ranges; the peer-range change they need ships as -// their own release. +// Every workspace manifest follows the set's exact pins; only a cut package's +// version moves. The abilities are members too (their peers name the set) but +// ship through the signed catalog, not this repo's npm loop — their release +// bumps their versions there. const nameOf = Object.fromEntries(cutPackages.map((p) => [p.dir, p.name])); -for (const dir of readdirSync('packages').map((d) => `packages/${d}`).filter((d) => d in CUTS && existsSync(`${d}/package.json`))) { +const dirs = ['packages', 'packages/abilities'].flatMap((root) => + readdirSync(root).map((d) => `${root}/${d}`).filter((d) => existsSync(`${d}/package.json`))); +for (const dir of dirs) { const path = `${dir}/package.json`; const pkg = manifestOf(dir); - const before = JSON.stringify(pkg); - if (rewriteManifest(pkg, { version: alphas[nameOf[dir]], alphas })) { - console.log(` ${path}: ${JSON.parse(before).version} -> ${pkg.version}, pins exact`); + const before = JSON.parse(JSON.stringify(pkg)); + if (rewriteManifest(pkg, { version: dir in CUTS ? alphas[nameOf[dir]] : undefined, alphas })) { + console.log(` ${path}: ${before.version} -> ${pkg.version}, pins exact`); if (!DRY) writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n'); } } diff --git a/scripts/cut-alpha.test.ts b/scripts/cut-alpha.test.ts index e9b9bbfe..ebe9f48e 100644 --- a/scripts/cut-alpha.test.ts +++ b/scripts/cut-alpha.test.ts @@ -13,8 +13,8 @@ describe('parseCut', () => { it('accepts a non-negative integer and nothing else', () => { expect(parseCut('2')).toBe(2); expect(parseCut('0')).toBe(0); - for (const bad of [undefined, '', 'x', '-1', '1.5', 'NaN']) { - expect(() => parseCut(bad as string), String(bad)).toThrow(/--cut/); + for (const bad of [undefined, '', 'x', '-1', '1.5', 'NaN', '9'.repeat(400)]) { + expect(() => parseCut(bad as string), String(bad).slice(0, 12)).toThrow(/--cut/); } }); }); @@ -56,6 +56,17 @@ describe('planAlphas', () => { }); }); + it('a prerelease FALLBACK is the pending base too — a package not yet on the registry keeps its set', () => { + // The manifest already says 0.2.0-alpha.1 after cut 1; a 404 at cut 2 must + // continue 0.2.0-alpha.2, not treat 0.2.0 as a shipped stable and bump it. + const alphas = planAlphas({ + cut: 2, + packages: [{ name: '@lloyal-labs/media', level: 'minor', fallback: '0.2.0-alpha.1' }], + view: () => { throw e404; }, + }); + expect(alphas['@lloyal-labs/media']).toBe('0.2.0-alpha.2'); + }); + it('a prerelease latest is the pending base, never bumped again', () => { const alphas = planAlphas({ cut: 3, @@ -83,13 +94,18 @@ describe('rewriteManifest', () => { expect(pkg.peerDependencies['@lloyal-labs/lloyal-agents']).toBe('6.0.0-alpha.1'); }); - it('leaves a package outside the cut untouched — its published version cannot carry new contents', () => { + it('a workspace member outside the cut keeps its version but its pins follow the set', () => { + // The workspace must resolve as one set: an ability whose peer still named + // -alpha.1 after cut 2 would fail the install. Its VERSION is not the + // cutter's to move — abilities ship through the signed catalog, and their + // release bumps it there. const pkg = { name: '@lloyal-labs/web-ability', version: '2.0.1', - peerDependencies: { '@lloyal-labs/lloyal-agents': '^5' }, + peerDependencies: { '@lloyal-labs/lloyal-agents': '6.0.0-alpha.0', effection: '^4' }, // the previous set }; - const before = JSON.stringify(pkg); - expect(rewriteManifest(pkg, { version: undefined, alphas })).toBe(false); - expect(JSON.stringify(pkg)).toBe(before); + expect(rewriteManifest(pkg, { version: undefined, alphas })).toBe(true); + expect(pkg.version).toBe('2.0.1'); + expect(pkg.peerDependencies['@lloyal-labs/lloyal-agents']).toBe(alphas['@lloyal-labs/lloyal-agents']); + expect(pkg.peerDependencies.effection).toBe('^4'); }); }); diff --git a/tsconfig.test.json b/tsconfig.test.json index f44c27a7..4c24952e 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -31,6 +31,8 @@ "downlevelIteration": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "noEmit": true, + // The cutter's core is plain ESM JavaScript; its test imports it. + "allowJs": true, "composite": false, "declaration": false, "declarationMap": false, @@ -57,6 +59,7 @@ }, "include": [ "packages/*/test/**/*.ts", - "packages/abilities/*/test/**/*.ts" + "packages/abilities/*/test/**/*.ts", + "scripts/*.test.ts" ] } From 7cda0ad2c9537fb4ce553704a230555d8846596f Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Fri, 4 Sep 2026 12:57:28 +1000 Subject: [PATCH 22/69] fix(agents): let an in-flight decode settle before the pool prunes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A queued store decode keeps writing its seq on the libuv thread after Effection halts the loop fiber — halt drops the JS promise but cannot recall the call. Pool teardown's safePrune then races it: release -> tenancy evict -> seq_rm strips a seq the decode is still filling, so the lease returns to the vacant pool dirty (the "vacant is clean" invariant fork's seq_cp asserts on), the pressure gauge drifts, or two writers on one context segfault. Reachable on cancel/quit, where the research harness halts the run task while the context lives on to serve the next query. waitUntilSettled wraps a native-backed promise so its operation exits only once the decode has settled: until(p) in the body, and until(Promise.allSettled([p])) in the finally, which Effection runs on halt and lets yield. The wait is bounded to one step or one prefill on the serial loop fiber. The nine store and deltaCells awaits in the pool move onto it; tool.execute stays on call, it touches no store. No change to the pool's ensure, initAgents, or the orchestrator shapes. Red first: combinators.test.ts proves halt waits for the promise to settle; agent-pool-teardown.test.ts holds a commit, halts mid-flight, and asserts no prune precedes the settle. --- packages/agents/src/agent-pool.ts | 19 +-- packages/agents/src/combinators.ts | 32 ++++++ packages/agents/src/index.ts | 2 +- .../agents/test/agent-pool-teardown.test.ts | 108 ++++++++++++++++++ packages/agents/test/combinators.test.ts | 47 ++++++++ 5 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 packages/agents/test/agent-pool-teardown.test.ts create mode 100644 packages/agents/test/combinators.test.ts diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index a05037f5..326df24f 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -1,5 +1,6 @@ import { resource, call, ensure, createSignal, createChannel, spawn, scoped, each, sleep, action, race } from 'effection'; import type { Operation, Subscription, Task, Signal } from 'effection'; +import { waitUntilSettled } from './combinators'; import type { Branch } from '@lloyal-labs/sdk'; import { CHAT_FORMAT_CONTENT_ONLY, CHAT_FORMAT_GENERIC, GrammarTriggerType, type ParsedToolCall, type SessionContext } from '@lloyal-labs/sdk'; import type { BranchStore } from '@lloyal-labs/sdk'; @@ -470,7 +471,7 @@ function* recoverInline( let producedTokens = 0; try { yield* scoped(function*() { - yield* call(() => store.prefill([[agent.branch, tokens]])); + yield* waitUntilSettled(store.prefill([[agent.branch, tokens]])); if (terminalGrammar) agent.branch.setGrammar(terminalGrammar); tw.write({ @@ -485,7 +486,7 @@ function* recoverInline( if (isStop) break; output += text; producedTokens++; - yield* call(() => store.commit([[agent.branch, token]])); + yield* waitUntilSettled(store.commit([[agent.branch, token]])); yield* events.send({ type: 'agent:produce', agentId: agent.id, text, tokenCount: producedTokens }); } @@ -1412,7 +1413,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0) { try { - yield* call(() => store.prefill( + yield* waitUntilSettled(store.prefill( tokenItems.map(t => [t.agent.branch, t.tokens] as [Branch, number[]]))); counters.warmPrefillCalls++; counters.warmPrefillBranches += tokenItems.length; @@ -1471,7 +1472,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0) { - const results = yield* call(() => + const results = yield* waitUntilSettled( store.prefillMultimodal(mediaItems.map(m => [m.agent.branch, m.delta] as [Branch, MultimodalDelta]))); counters.warmPrefillCalls++; counters.warmPrefillBranches += mediaItems.length; @@ -1514,7 +1515,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation store.prefill([[a.branch, noteTokens]])); + yield* waitUntilSettled(store.prefill([[a.branch, noteTokens]])); // The record carries what LANDED — the note, not the dropped item. bookSettled(a, m.src, noteTokens.length, undefined, noteStr); continue; @@ -1583,7 +1584,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0) { - yield* call(() => store.prefill(probePairs)); + yield* waitUntilSettled(store.prefill(probePairs)); // Success-only, like every branch:prefill: written after the // batched dispatch landed, so a rejected prefill leaves no event // claiming cells that never moved. @@ -1907,7 +1908,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation deltaCells(ctx, delta)), + cells: yield* waitUntilSettled(deltaCells(ctx, delta)), attachments: prepared.attachments, }; } else { @@ -2145,7 +2146,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0) { - yield* call(() => store.prefill(prefillPairs)); + yield* waitUntilSettled(store.prefill(prefillPairs)); } } catch (err) { for (const e of drainedExtends) e.reject(err as Error); @@ -2473,7 +2474,7 @@ export function useAgentPool(opts: AgentPoolOptions): Operation 0) { try { - yield* call(() => store.commit(entries)); + yield* waitUntilSettled(store.commit(entries)); } catch (e) { // Decode OOM (concurrent in-loop reports exhausted KV) tears down the pool. // This batch is where admitted extractors decode their reports; unlike the diff --git a/packages/agents/src/combinators.ts b/packages/agents/src/combinators.ts index 4a30ea84..efbce0f1 100644 --- a/packages/agents/src/combinators.ts +++ b/packages/agents/src/combinators.ts @@ -1,3 +1,4 @@ +import { until } from 'effection'; import type { Operation } from 'effection'; /** @@ -37,3 +38,34 @@ export function* reduce( } return acc; } + + +/** + * Yield on a promise-backed operation, but exit only once the promise has + * SETTLED — even when the enclosing operation is halted. + * + * A native store decode (`store.commit`, `store.prefill`) is queued onto the + * libuv thread pool and cannot be recalled. `until(p)` alone abandons `p` on + * halt: the JS side moves on while the batch keeps writing the context's KV. + * If teardown then prunes or disposes, two writers touch one seq — a lease + * handed back dirty, or a segfault. + * + * So the issuing operation owns the decode's lifetime. `until(p)` in the body + * carries the result; the `finally` — which Effection guarantees to run on + * halt and lets us yield within — waits for `p` to settle, swallowing its + * outcome (a body rejection is already the caller's). The wait is bounded to + * the one call in flight: on the serial loop fiber that is a single step or a + * single prefill. + * + * @param p - The promise returned by a native-backed SDK call. + * @returns The resolved value on the normal path; the body rejection propagates. + * + * @category Agents + */ +export function* waitUntilSettled(p: Promise): Operation { + try { + return yield* until(p); + } finally { + yield* until(Promise.allSettled([p])); + } +} diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index deb49b64..ad3b9ed9 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -46,7 +46,7 @@ export { admitChunks } from './admission'; export type { AdmitOpts, AdmitResult, AdmitSelect, AdmittedPassage } from './admission'; export { composePrompt, renderPrompt, renderTemplate } from './prompt'; export type { PromptState, PromptSection, PromptStep } from './prompt'; -export { reduce } from './combinators'; +export { reduce, waitUntilSettled } from './combinators'; export { parallel, chain, fanout, dag } from './orchestrators'; export type { SpawnSpec, ChainStep, DAGNode, Orchestrator, PoolContext } from './orchestrators'; export { extractSpineSeed, extractSpineCheckpoint, reconstructBranch, replayTurns, replayAgentTurns } from './replay'; diff --git a/packages/agents/test/agent-pool-teardown.test.ts b/packages/agents/test/agent-pool-teardown.test.ts new file mode 100644 index 00000000..6bd6f36b --- /dev/null +++ b/packages/agents/test/agent-pool-teardown.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { run, spawn, sleep, until, createChannel, createSignal } from 'effection'; +import type { Channel } from 'effection'; +import { createMockSdk } from '../../sdk/src/testing.js'; +import { useAgentPool } from '../src/agent-pool'; +import { parallel } from '../src/orchestrators'; +import { Ctx, Store, Events, Trace, Attachments, Ingress } from '../src/context'; +import { MemoryAttachmentStore } from './helpers/memory-store'; +import { rawIngress } from './helpers/raw-ingress'; +import { CapturingTraceWriter } from './helpers/capturing-trace'; +import type { AgentEvent } from '../src/types'; +import type { AgentPolicy } from '../src/AgentPolicy'; + +const STOP = 999; + +/** + * A queued store decode keeps writing the context after the loop fiber is + * halted. If pool teardown prunes while that call is in flight, two writers + * touch one seq. The pool must let the in-flight commit SETTLE before it + * prunes — bounded to one step or one prefill on the serial loop fiber. + * + * Red before waitUntilSettled wraps the store calls: on halt the loop fiber's + * bare `until` is abandoned, `safePrune` runs while the commit is still + * pending, and the recorded order is prune-before-settle. + */ +describe('pool teardown vs in-flight decode', () => { + it('waits for an in-flight commit to settle before pruning', async () => { + const { ctx, store, root } = createMockSdk({ nCtx: 16384, cellsUsed: 1000 }); + + // One agent, two live tokens then stop → guarantees a COMMIT tick to hold. + let forkCount = 0; + const forkIndex = new Map(); + const sampleCount = new Map(); + const origFork = ctx._branchFork.bind(ctx); + ctx._branchFork = (parent: number): number => { + const h = origFork(parent); + forkIndex.set(h, forkCount++); + sampleCount.set(h, 0); + return h; + }; + ctx._branchSample = (h: number): number => { + const i = sampleCount.get(h) ?? 0; + sampleCount.set(h, i + 1); + return i < 2 ? 100 + i : STOP; // T, T, STOP + }; + + const order: string[] = []; + let releaseCommit!: () => void; + const commitGate = new Promise((r) => { releaseCommit = r; }); + let markIssued!: () => void; + const issued = new Promise((r) => { markIssued = r; }); + + const origCommit = ctx._storeCommit.bind(ctx); + let heldOnce = false; + ctx._storeCommit = async (handles: number[], tokens: number[]): Promise => { + if (!heldOnce) { + heldOnce = true; + order.push('commit:issued'); + markIssued(); + await commitGate; // stand in for a long llama_decode still on the thread + order.push('commit:settled'); + } + return origCommit(handles, tokens); + }; + const origPrune = ctx._branchPrune.bind(ctx); + ctx._branchPrune = (h: number): void => { order.push('prune'); return origPrune(h); }; + + const traceWriter = new CapturingTraceWriter(); + await root.prefill(ctx.tokenizeSync('system prompt')); + + await run(function* () { + yield* Ctx.set(ctx as never); + yield* Store.set(store); + const events: Channel = createChannel(); + yield* Events.set(events as never); + yield* Trace.set(traceWriter); + const contentStore = new MemoryAttachmentStore(); + yield* Attachments.set(contentStore); + yield* Ingress.set(rawIngress(contentStore)); + + const drain = yield* spawn(function* () { + const sub = yield* useAgentPool({ + spine: root, + orchestrate: parallel([{ content: 'Task 0', systemPrompt: 'You are an agent.', seed: 0 }]), + toolsJson: '', + tools: new Map(), + policy: { onProduced: () => ({ type: 'idle', reason: 'free_text_stop' }) } as AgentPolicy, + maxTurns: 100, + pruneOnReturn: true, + }); + let next = yield* sub.next(); + while (!next.done) next = yield* sub.next(); + return next.value; + }); + + yield* until(issued); // a commit is now in flight + const haltDone = drain.halt(); // begin teardown + releaseCommit(); // let the held decode finish + yield* until(haltDone); + }); + + const settled = order.indexOf('commit:settled'); + const firstPrune = order.indexOf('prune'); + expect(settled).toBeGreaterThanOrEqual(0); + // The load-bearing assertion: no prune before the in-flight commit settled. + expect(firstPrune === -1 || settled < firstPrune).toBe(true); + }); +}); diff --git a/packages/agents/test/combinators.test.ts b/packages/agents/test/combinators.test.ts new file mode 100644 index 00000000..072bbb8e --- /dev/null +++ b/packages/agents/test/combinators.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { run, spawn, sleep, until } from 'effection'; +import { waitUntilSettled } from '../src/combinators'; + +/** + * waitUntilSettled is the one Effection helper that survives halt: a queued + * native decode cannot be recalled, so the operation that issued it must not + * exit until the promise has an outcome. Bare `until(p)` abandons the promise + * on halt; this must wait for it. + */ +describe('waitUntilSettled', () => { + it('returns the resolved value on the normal path', async () => { + const v = await run(function* () { + return yield* waitUntilSettled(Promise.resolve(42)); + }); + expect(v).toBe(42); + }); + + it('halt does not complete until the in-flight promise settles', async () => { + await run(function* () { + let settled = false; + const p = new Promise((r) => setTimeout(() => { settled = true; r(); }, 120)); + const task = yield* spawn(function* () { + yield* waitUntilSettled(p); + }); + yield* sleep(10); + // Halt mid-flight. With a bare `until`, this resolves at ~10ms with the + // promise still pending. waitUntilSettled must hold until it settles. + yield* task.halt(); + expect(settled).toBe(true); + }); + }); + + it('a rejection during the halt wait surfaces nothing', async () => { + await run(function* () { + const p = new Promise((_r, reject) => setTimeout(() => reject(new Error('boom')), 60)); + p.catch(() => {}); // keep the environment's unhandled-rejection guard quiet + const task = yield* spawn(function* () { + try { yield* waitUntilSettled(p); } catch { /* body rejection is the caller's */ } + }); + yield* sleep(10); + // Should resolve cleanly once the promise settles (rejected), no throw here. + yield* task.halt(); + yield* until(p.then(() => 'ok', () => 'settled-rejected')); + }); + }); +}); From 573461b4e2b5b4b2c32cf7008e1ae90035423035 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Fri, 4 Sep 2026 15:50:05 +1000 Subject: [PATCH 23/69] fix(agents,media,rig): every decode site waits to settle; ingress, queue, store and note corrections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The waitUntilSettled rule is per site: an operation that issues a native decode must not exit before the decode settles, or teardown prunes a seq the batch is still writing. 7cda0ad covered the pool's nine sites; a full read found thirteen more in spine, replay, diverge and use-agent, and a new invariant test now greps every Effection package for a bare call(() => …prefill()) so the rule is checked rather than remembered. Unused call imports go with it. The ingress abort test proved the wrong guard: its abort fired during the fixture's own await, so ingest saw a dead signal and threw at the gate's preflight, never reaching the post-normalization check the fix added. The test now builds the fixture, starts ingest, then aborts, so that check is structurally the only abort point left; with it removed the test fails. The invalid-media note carried only the error key while telling the model to work from the text. It now spreads the tool's media-stripped result under the key, the shape the no-projector path already uses. On this rail that result is always a plain object, so the parse is unguarded and the invariant is stated. Normalization permits were capped but the waiting queue was not, so a burst parked a closure, a timer, a listener and the image bytes per arrival for the full wait timeout. A fixed queue depth beside the permit count refuses past it with an EBUSY error, and the content route answers 503 for that rather than 400. The content store served bytes by filename alone. Replay rebuilds KV from those bytes under the original digest, so a torn write or bit rot would rebuild the wrong cells silently. get() takes an optional verify that rehashes, and materialize, the replay read path, asks for it; the HTTP serve path keeps trusting the name. The backend tripwire is left as it was: the counter resets on a success, the flag latches for the run, and a host restart is the recovery, per the self-healing design. --- packages/agents/src/agent-pool.ts | 8 ++++ packages/agents/src/combinators.ts | 8 +++- packages/agents/src/diverge.ts | 9 ++-- packages/agents/src/replay.ts | 17 +++---- packages/agents/src/spine.ts | 9 ++-- packages/agents/src/use-agent.ts | 3 +- packages/agents/test/agent-pool.test.ts | 27 +++++++++++- packages/agents/test/helpers/memory-store.ts | 6 ++- .../test/native-await-invariant.test.ts | 40 +++++++++++++++++ packages/media/src/file-store.ts | 9 +++- packages/media/src/image.ts | 20 +++++++++ packages/media/src/ingress.ts | 7 ++- packages/media/src/store.ts | 9 +++- packages/media/test/file-store-verify.test.ts | 44 +++++++++++++++++++ packages/media/test/ingress.test.ts | 16 +++++-- packages/media/test/normalize.test.ts | 25 ++++++++++- packages/rig/src/content-routes.ts | 5 ++- packages/rig/test/content-routes.test.ts | 15 +++++++ 18 files changed, 243 insertions(+), 34 deletions(-) create mode 100644 packages/agents/test/native-await-invariant.test.ts create mode 100644 packages/media/test/file-store-verify.test.ts diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index 326df24f..a93ce1cc 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -1506,7 +1506,15 @@ export function useAgentPool(opts: AgentPoolOptions): Operation; const note = { + ...told, [TOOL_IMAGE_ERROR_KEY]: `${m.src.toolName} returned media the decoder rejected as invalid input. ` + `Work from the text, or use a different source.`, diff --git a/packages/agents/src/combinators.ts b/packages/agents/src/combinators.ts index efbce0f1..c264f491 100644 --- a/packages/agents/src/combinators.ts +++ b/packages/agents/src/combinators.ts @@ -54,8 +54,12 @@ export function* reduce( * carries the result; the `finally` — which Effection guarantees to run on * halt and lets us yield within — waits for `p` to settle, swallowing its * outcome (a body rejection is already the caller's). The wait is bounded to - * the one call in flight: on the serial loop fiber that is a single step or a - * single prefill. + * the one call the issuing operation has in flight — a single step or a single + * prefill — so halting stays speedy in the only sense available. + * + * Every Effection site that awaits a store decode goes through this; the + * invariant test in `test/native-await-invariant.test.ts` refuses a bare + * `call(() => …prefill())` so the rule is checked, not remembered. * * @param p - The promise returned by a native-backed SDK call. * @returns The resolved value on the normal path; the body rejection propagates. diff --git a/packages/agents/src/diverge.ts b/packages/agents/src/diverge.ts index 7f642aff..e88f4f35 100644 --- a/packages/agents/src/diverge.ts +++ b/packages/agents/src/diverge.ts @@ -1,5 +1,6 @@ -import { call, ensure } from 'effection'; +import { ensure } from 'effection'; import type { Operation } from 'effection'; +import { waitUntilSettled } from './combinators'; import { Branch } from '@lloyal-labs/sdk'; import { Ctx, Store } from './context'; import { ContextPressure } from './agent-pool'; @@ -35,7 +36,7 @@ import type { DivergeOptions, DivergeResult, DivergeAttempt } from './types'; * params: { temperature: 0.7 }, * }); * // verified.best is the lowest-perplexity branch, still alive - * yield* call(() => session.promote(verified.best)); + * yield* waitUntilSettled( session.promote(verified.best)); * ``` * * @category Agents @@ -56,7 +57,7 @@ export function* diverge(opts: DivergeOptions): Operation { if (!opts.prompt) throw new Error('diverge() requires either opts.parent or opts.prompt'); const tokens = ctx.tokenizeSync(opts.prompt); root = Branch.create(ctx, 0, opts.params ?? {}); - yield* call(() => root.prefill(tokens)); + yield* waitUntilSettled( root.prefill(tokens)); prefixLength = tokens.length; ownRoot = true; // If we created the root, ensure it's cleaned up @@ -105,7 +106,7 @@ export function* diverge(opts: DivergeOptions): Operation { a.tokenCount++; } if (entries.length === 0) break; - yield* call(() => store.commit(entries)); + yield* waitUntilSettled( store.commit(entries)); steps++; } diff --git a/packages/agents/src/replay.ts b/packages/agents/src/replay.ts index 079e7378..e1f9ccd7 100644 --- a/packages/agents/src/replay.ts +++ b/packages/agents/src/replay.ts @@ -1,5 +1,6 @@ -import { call, ensure } from 'effection'; +import { ensure } from 'effection'; import type { Operation } from 'effection'; +import { waitUntilSettled } from './combinators'; import { Branch, buildAssistantDelta, buildToolResultDelta, buildToolResultDeltaMultimodal, buildTurnDelta, MEDIA_MARKER, @@ -221,10 +222,10 @@ export function* reconstructBranch(checkpoint: BranchCheckpoint): Operation 0) { - yield* call(() => spine.prefillMultimodal(checkpoint.seedPrompt, bitmaps)); + yield* waitUntilSettled( spine.prefillMultimodal(checkpoint.seedPrompt, bitmaps)); } else { const seedTokens = ctx.tokenizeSync(checkpoint.seedPrompt, false); - yield* call(() => spine.prefill(seedTokens)); + yield* waitUntilSettled( spine.prefill(seedTokens)); } yield* replayTurns(spine, checkpoint.turns); @@ -257,7 +258,7 @@ export function* replayTurns( const store = yield* Store.expect(); for (const turn of turns) { const delta = buildTurnDelta(ctx, turn.userContent, turn.assistantContent); - yield* call(() => store.prefill([[branch, delta]])); + yield* waitUntilSettled( store.prefill([[branch, delta]])); } } @@ -299,18 +300,18 @@ export function* replayAgentTurns( for (const r of records) { if (r.kind === 'assistant') { const tokens = buildAssistantDelta(ctx, r.text, opts); - yield* call(() => store.prefill([[branch, tokens]])); + yield* waitUntilSettled( store.prefill([[branch, tokens]])); } else if (r.kind === 'probe') { const tokens = ctx.tokenizeSync(r.text, false); - if (tokens.length > 0) yield* call(() => store.prefill([[branch, tokens]])); + if (tokens.length > 0) yield* waitUntilSettled( store.prefill([[branch, tokens]])); } else if (r.attachments && r.attachments.length > 0) { const { bitmaps } = materialize(attachments, r.attachments); const delta = buildToolResultDeltaMultimodal( ctx, r.resultStr, r.callId, [...bitmaps], opts); - yield* call(() => branch.prefillMultimodal(delta.prompt, delta.bitmaps, delta.sep)); + yield* waitUntilSettled( branch.prefillMultimodal(delta.prompt, delta.bitmaps, delta.sep)); } else { const tokens = buildToolResultDelta(ctx, r.resultStr, r.callId, opts); - yield* call(() => store.prefill([[branch, tokens]])); + yield* waitUntilSettled( store.prefill([[branch, tokens]])); } } } diff --git a/packages/agents/src/spine.ts b/packages/agents/src/spine.ts index 7394f027..db444959 100644 --- a/packages/agents/src/spine.ts +++ b/packages/agents/src/spine.ts @@ -1,5 +1,6 @@ -import { call } from "effection"; + import type { Operation } from "effection"; +import { waitUntilSettled } from "./combinators"; import { Branch, mediaContent } from "@lloyal-labs/sdk"; import type { SessionContext } from "@lloyal-labs/sdk"; import { Ctx, Trace, TraceParent, SpineFmt, Attachments, Ingress } from "./context"; @@ -172,7 +173,7 @@ export function* withSpine( // so a failure on any of them cannot leak a slot or a poisoned branch. try { if (prefillTokens.length > 0) { - yield* call(() => spine.prefill(prefillTokens)); + yield* waitUntilSettled( spine.prefill(prefillTokens)); tw.write({ traceId: tw.nextId(), parentTraceId: scopeId, @@ -270,7 +271,7 @@ export function* withSpine( let attached: readonly Attachment[] | undefined; if (bitmaps.length > 0) { writeSpineSeed(); - const counts = yield* call(() => + const counts = yield* waitUntilSettled( spine.prefillMultimodal(formatted.prompt, bitmaps)); headerCells = counts.tokensDecoded; // Already committed by the barrier above — this only carries the roots @@ -282,7 +283,7 @@ export function* withSpine( writeSpineSeed(headerTokens.length); headerCells = headerTokens.length; if (headerTokens.length > 0) { - yield* call(() => spine.prefill(headerTokens)); + yield* waitUntilSettled( spine.prefill(headerTokens)); } } if (headerCells > 0) { diff --git a/packages/agents/src/use-agent.ts b/packages/agents/src/use-agent.ts index 96a91c02..f13b3488 100644 --- a/packages/agents/src/use-agent.ts +++ b/packages/agents/src/use-agent.ts @@ -1,5 +1,6 @@ import { resource, ensure, call, scoped } from 'effection'; import type { Operation } from 'effection'; +import { waitUntilSettled } from './combinators'; import { Branch } from '@lloyal-labs/sdk'; import type { Session, SessionContext } from '@lloyal-labs/sdk'; import { Agent } from './Agent'; @@ -115,7 +116,7 @@ export function useAgent(opts: UseAgentOpts): Operation { const prefillTokens = warmParent ? ctx.getTurnSeparator() : []; if (prefillTokens.length > 0) { - yield* call(() => root.prefill(prefillTokens)); + yield* waitUntilSettled( root.prefill(prefillTokens)); } // Eager grammar from schema. Compile here, but apply it on the GENERATING diff --git a/packages/agents/test/agent-pool.test.ts b/packages/agents/test/agent-pool.test.ts index 49185cd2..5c75fac0 100644 --- a/packages/agents/test/agent-pool.test.ts +++ b/packages/agents/test/agent-pool.test.ts @@ -22,7 +22,7 @@ import { rawIngress } from './helpers/raw-ingress'; import { mkdtempSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { Tool } from '../src/Tool'; +import { Tool, TOOL_IMAGE_ERROR_KEY } from '../src/Tool'; import type { AgentPolicy } from '../src/AgentPolicy'; import type { AgentPoolResult, AgentEvent, ToolContext } from '../src/types'; import type { Agent } from '../src/Agent'; @@ -1791,6 +1791,31 @@ describe('self-healing ladder', () => { expect((settleFailed as { rc?: number }).rc).toBe(1); }); + it('invalid media keeps the tool text beside the error key — "work from the text" has text', async () => { + const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); + const toolTurns: string[] = []; + await runPool({ + nCtx: MEDIA_TEST_NCTX, forkTokenQueues: [[1, STOP, STOP]], + ...callTool('rasterize'), tools: toolMap, + mutateCtx: (c) => { + c.mockMultimodalError = () => ({ message: 'invalid input', rc: -1, partial: false }); + const target = c as unknown as { formatChatSync: (m: string, o?: unknown) => unknown }; + const orig = target.formatChatSync.bind(c); + target.formatChatSync = (messages: string, o?: unknown) => { + for (const m of JSON.parse(messages) as Array<{ role: string; content: string }>) { + if (m.role === 'tool') toolTurns.push(m.content); + } + return orig(messages, o); + }; + }, + }); + const note = toolTurns.find(t => t.includes(TOOL_IMAGE_ERROR_KEY)); + expect(note).toBeDefined(); + const parsed = JSON.parse(note!) as Record; + expect(parsed.page).toBe('p1'); // the tool's text survived + expect(typeof parsed[TOOL_IMAGE_ERROR_KEY]).toBe('string'); + }); + it('the tool:result trace records media cost as CELLS, never under a token name', async () => { const toolMap = new Map([['rasterize', new MediaTool([PNG_BYTES])]]); const { trace } = await runPool({ diff --git a/packages/agents/test/helpers/memory-store.ts b/packages/agents/test/helpers/memory-store.ts index bc35be2a..19ed5efe 100644 --- a/packages/agents/test/helpers/memory-store.ts +++ b/packages/agents/test/helpers/memory-store.ts @@ -35,8 +35,10 @@ export class MemoryAttachmentStore implements AttachmentStore { return commitManifest((bytes, mediaType) => this.putBlob(bytes, mediaType), parts); } - get(digest: string): Uint8Array | null { - return this.blobs.get(digest) ?? null; + get(digest: string, opts?: { verify?: boolean }): Uint8Array | null { + const bytes = this.blobs.get(digest) ?? null; + if (!bytes || !opts?.verify) return bytes; + return 'sha256:' + createHash('sha256').update(bytes).digest('hex') === digest ? bytes : null; } getManifest(digest: string): AttachmentManifest | null { diff --git a/packages/agents/test/native-await-invariant.test.ts b/packages/agents/test/native-await-invariant.test.ts new file mode 100644 index 00000000..0331a293 --- /dev/null +++ b/packages/agents/test/native-await-invariant.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +/** + * A native decode queued on the libuv thread pool cannot be recalled: Effection + * halt drops the JS promise while the batch keeps writing the context. Every + * operation that issues one must exit only once it has settled — that is + * `waitUntilSettled`. A bare `call(() => …prefill())` or `until(…commit())` + * reintroduces the teardown race this rule exists to close, so the rule is + * checked mechanically here rather than remembered. + */ +const REPO = join(__dirname, '..', '..', '..'); +const ROOTS = ['packages/agents/src', 'packages/rig/src']; +const DECODES = 'prefill|prefillMultimodal|prefillUser|prefillUserMultimodal|prefillAssistant|commit|commitTurn|retainOnly|promote'; +const BARE = new RegExp(String.raw`\b(?:call|until)\(\s*(?:\(\)\s*=>\s*)?(?:[\w.]+\.(?:${DECODES})|deltaCells)\(`, 'g'); + +function* tsFiles(dir: string): Generator { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) yield* tsFiles(p); + else if (name.endsWith('.ts') && !name.endsWith('.d.ts')) yield p; + } +} + +describe('native decodes are awaited with waitUntilSettled', () => { + it('no Effection package wraps a store decode in a bare call() or until()', () => { + const hits: string[] = []; + for (const root of ROOTS) { + for (const file of tsFiles(join(REPO, root))) { + const src = readFileSync(file, 'utf8'); + for (const m of src.matchAll(BARE)) { + const line = src.slice(0, m.index).split('\n').length; + hits.push(`${relative(REPO, file)}:${line} ${m[0].replace(/\s+/g, ' ')}`); + } + } + } + expect(hits, `bare native awaits:\n ${hits.join('\n ')}`).toEqual([]); + }); +}); diff --git a/packages/media/src/file-store.ts b/packages/media/src/file-store.ts index 5ff878cc..a94afa1c 100644 --- a/packages/media/src/file-store.ts +++ b/packages/media/src/file-store.ts @@ -149,11 +149,16 @@ export class FileAttachmentStore implements AttachmentStore { } } - get(digest: string): Uint8Array | null { + get(digest: string, opts?: { verify?: boolean }): Uint8Array | null { try { const file = this._pathFor(digest); if (!file) return null; - return new Uint8Array(readFileSync(file)); + const bytes = new Uint8Array(readFileSync(file)); + if (opts?.verify) { + const actual = 'sha256:' + createHash('sha256').update(bytes).digest('hex'); + if (actual !== digest) return null; + } + return bytes; } catch { return null; } diff --git a/packages/media/src/image.ts b/packages/media/src/image.ts index 59e6cf66..b340b7d3 100644 --- a/packages/media/src/image.ts +++ b/packages/media/src/image.ts @@ -86,6 +86,14 @@ export const MAX_CONCURRENT_NORMALIZATIONS = 4; */ export const PERMIT_WAIT_TIMEOUT_MS = 60_000; +/** + * How many callers may WAIT for a permit. Permits bound the decoded bitmaps; + * this bounds the encoded ones parked behind them. Past it the answer is an + * immediate busy, so a burst costs at most (permits + queue) images of memory + * instead of one image per arrival for {@link PERMIT_WAIT_TIMEOUT_MS}. + */ +export const MAX_QUEUED_NORMALIZATIONS = MAX_CONCURRENT_NORMALIZATIONS * 4; + /** * @category Media */ @@ -192,6 +200,17 @@ const aborted = (): Error => { return e; }; +/** Thrown when the queue is full. `code` is Node's own errno name for the + * condition, so an HTTP layer can answer 503 without importing this module. */ +function busy(): Error { + const e = new Error( + `normalizeImage: ${MAX_CONCURRENT_NORMALIZATIONS} normalizations in flight and ` + + `${MAX_QUEUED_NORMALIZATIONS} queued — busy, retry later.`, + ); + (e as Error & { code: string }).code = 'EBUSY'; + return e; +} + /** * Take one of {@link MAX_CONCURRENT_NORMALIZATIONS} permits. * @@ -212,6 +231,7 @@ async function acquire(signal?: AbortSignal): Promise<() => void> { if (gate.permits > 0) { gate.permits--; } else { + if (gate.waiting.length >= MAX_QUEUED_NORMALIZATIONS) throw busy(); await new Promise((resolve, reject) => { const leave = () => { const at = gate.waiting.indexOf(entry); diff --git a/packages/media/src/ingress.ts b/packages/media/src/ingress.ts index fee6890c..b00d4b00 100644 --- a/packages/media/src/ingress.ts +++ b/packages/media/src/ingress.ts @@ -58,11 +58,14 @@ export function materialize( ); } for (const rep of representationsOf(manifest)) { - const bytes = store.get(rep.digest); + // Verified: replay rebuilds KV from these bytes under this digest, so + // bytes that drifted on disk must refuse here, not decode as something else. + const bytes = store.get(rep.digest, { verify: true }); if (!bytes) { throw new Error( `materialize: blob ${rep.digest.slice(0, 19)}… (${rep.mediaType}) is ` + - 'referenced by a manifest but missing from the content store.', + 'referenced by a manifest but missing from the content store, or its ' + + 'bytes no longer hash to its digest.', ); } bitmaps.push(bytes); diff --git a/packages/media/src/store.ts b/packages/media/src/store.ts index f8cd7cbc..c27154aa 100644 --- a/packages/media/src/store.ts +++ b/packages/media/src/store.ts @@ -75,8 +75,13 @@ export interface AttachmentStore { * consults `index.json`: the index is an export and discovery catalogue, * not the runtime authority. A lost concurrent index update can therefore * hide an attachment from OCI tooling, but it can never invalidate a - * recorded run. */ - get(digest: string): Uint8Array | null; + * recorded run. + * + * `verify` rehashes the bytes and answers `null` when they no longer match + * the digest — bit rot or a torn write. Replay asks for it, because + * rebuilding cells from drifted bytes under the original digest is a + * silent divergence; the HTTP serve path trusts the name. */ + get(digest: string, opts?: { verify?: boolean }): Uint8Array | null; /** Resolve and validate a manifest. `null` when absent, unparsable, or not * an artifact type this build understands. */ getManifest(digest: string): AttachmentManifest | null; diff --git a/packages/media/test/file-store-verify.test.ts b/packages/media/test/file-store-verify.test.ts new file mode 100644 index 00000000..620ac2ff --- /dev/null +++ b/packages/media/test/file-store-verify.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import sharp from 'sharp'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { FileAttachmentStore } from '../src/node'; +import { createImageIngress } from '../src/image'; +import { materialize } from '../src/ingress'; +import { representationsOf } from '../src/attachment'; + +/** + * The store is content-addressed: a digest names bytes. Serving by filename + * alone trusts the disk. Bit rot or a torn write would hand replay different + * pixels under the original digest and it would rebuild the wrong cells with + * no complaint. Replay verifies; the hot HTTP path keeps trusting the name. + */ +const png = () => + sharp({ create: { width: 32, height: 32, channels: 3, background: '#a70' } }).png().toBuffer() + .then((b) => new Uint8Array(b)); + +const blobPath = (dir: string, digest: string) => + join(dir, 'blobs', 'sha256', digest.slice('sha256:'.length)); + +describe('content-addressed reads', () => { + it('get() serves by name; get(digest, { verify: true }) refuses bytes that no longer hash to it', () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-')); + const store = new FileAttachmentStore(dir); + const rep = store.putBlob(new Uint8Array([1, 2, 3, 4]), 'application/octet-stream'); + writeFileSync(blobPath(dir, rep.digest), new Uint8Array([9, 9, 9, 9])); + + expect(store.get(rep.digest)).toEqual(new Uint8Array([9, 9, 9, 9])); + expect(store.get(rep.digest, { verify: true })).toBeNull(); + }); + + it('replay refuses a representation whose bytes drifted from their digest', async () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-')); + const store = new FileAttachmentStore(dir); + const root = await createImageIngress(store).ingest(await png(), new AbortController().signal); + const rep = representationsOf(store.getManifest(root.digest)!)[0]; + writeFileSync(blobPath(dir, rep.digest), new Uint8Array([0xff, 0xd8, 0xff, 0x00])); + + expect(() => materialize(store, [root])).toThrow(/digest/i); + }); +}); diff --git a/packages/media/test/ingress.test.ts b/packages/media/test/ingress.test.ts index 87510883..2f5d4e52 100644 --- a/packages/media/test/ingress.test.ts +++ b/packages/media/test/ingress.test.ts @@ -23,12 +23,20 @@ describe('createImageIngress', () => { const putAttachment = vi.spyOn(store, 'putAttachment'); const ingress = createImageIngress(store); - // Not yet aborted when ingest starts (the queue admits it); aborted on the - // next macrotask, which lands while sharp is inside the decode. + // Fixture FIRST: nothing may abort while it is being built, or the + // signal is already dead when ingest() is entered and the throw comes + // from the gate's preflight — a different guard than the one under test. + const bytes = await png(); const ctrl = new AbortController(); - setTimeout(() => ctrl.abort(), 0); + // ingest() runs synchronously through normalizeImage up to the gate's + // `await acquire()`; with permits free, acquire's preflight and its + // post-grant check have both already run by the time this returns. + const pending = ingress.ingest(bytes, ctrl.signal); + // So the only abort check left is the one AFTER normalization, before the + // first store write. Aborting here proves THAT guard, not the preflight. + ctrl.abort(); - await expect(ingress.ingest(await png(), ctrl.signal)).rejects.toMatchObject({ name: 'AbortError' }); + await expect(pending).rejects.toMatchObject({ name: 'AbortError' }); expect(putBlob).not.toHaveBeenCalled(); expect(putAttachment).not.toHaveBeenCalled(); }); diff --git a/packages/media/test/normalize.test.ts b/packages/media/test/normalize.test.ts index 1d4ab018..29c5d5c6 100644 --- a/packages/media/test/normalize.test.ts +++ b/packages/media/test/normalize.test.ts @@ -9,7 +9,7 @@ */ import { describe, it, expect } from 'vitest'; import sharp from 'sharp'; -import { normalizeImage, DEFAULT_MAX_PIXELS, MAX_CONCURRENT_NORMALIZATIONS, MAX_INPUT_PIXELS } from '../src/image'; +import { normalizeImage, DEFAULT_MAX_PIXELS, MAX_CONCURRENT_NORMALIZATIONS, MAX_QUEUED_NORMALIZATIONS, MAX_INPUT_PIXELS } from '../src/image'; import type { NormalizedImage } from '../src/image'; // Its own list of nine, sourced from stb_image — NOT derived from the sniff // table, which knows four. Deriving it was a defect: the pass-through gate read @@ -350,6 +350,29 @@ describe('KNOWN DEFECT — pinned, not endorsed', () => { expect((await normalizeImage(src, { maxPixels: 5_000 })).derived).toBe(true); }, 20_000); + it('refuses IMMEDIATELY once the queue is full, rather than holding bytes for the wait timeout', async () => { + // Permits bound the decoded bitmaps; without a bound on the QUEUE, a burst + // parks every extra arrival — closure, timer, abort listener and its bytes — + // for up to PERMIT_WAIT_TIMEOUT_MS. Past the depth the answer is an immediate + // busy, so memory is bounded by (permits + depth) images and nothing more. + const src = await solid(400, 300); + const order: string[] = []; + const busy = Array.from({ length: MAX_CONCURRENT_NORMALIZATIONS }, + () => normalizeImage(src, { maxPixels: 5_000 }) + .then((v) => { order.push('a-slot-freed'); return v; })); + const queued = Array.from({ length: MAX_QUEUED_NORMALIZATIONS }, + () => normalizeImage(src, { maxPixels: 5_000 })); + const overflow = normalizeImage(src, { maxPixels: 5_000 }) + .then(() => { order.push('overflow-resolved'); }, + (e: Error) => { order.push('overflow-refused'); expect(e.message).toMatch(/busy/i); }); + + await Promise.all([overflow, ...queued, ...busy]); + expect(order[0], `expected the overflow to be refused first, got ${order.join(' → ')}`) + .toBe('overflow-refused'); + // And the gate is intact afterwards. + expect((await normalizeImage(src, { maxPixels: 5_000 })).derived).toBe(true); + }, 30_000); + it('releases a permit when normalization FAILS', async () => { // The failure mode that actually bites: a permit leaked on a rejecting // upload. Enough bad files and the gate is exhausted permanently and the diff --git a/packages/rig/src/content-routes.ts b/packages/rig/src/content-routes.ts index 96f42809..681aafc3 100644 --- a/packages/rig/src/content-routes.ts +++ b/packages/rig/src/content-routes.ts @@ -341,7 +341,10 @@ export function createContentRoutes( .catch((e: unknown) => { const tooLarge = e instanceof TooLarge; const tooSlow = e instanceof TooSlow || ctrl.signal.aborted; - const code = tooLarge ? 413 : tooSlow ? 408 : 400; + // A full normalization queue is overload: retryable, not a client + // fault and not ours. `EBUSY` is the errno the ingress sets for it. + const busy = typeof e === 'object' && e !== null && (e as { code?: unknown }).code === 'EBUSY'; + const code = tooLarge ? 413 : tooSlow ? 408 : busy ? 503 : 400; fail(res, code, e instanceof Error ? e.message : 'ingress failed'); // Now that the status is on the wire, stop the upload. A stalled // client will not close on its own — that is the whole problem — diff --git a/packages/rig/test/content-routes.test.ts b/packages/rig/test/content-routes.test.ts index 7c35de31..842bfdf4 100644 --- a/packages/rig/test/content-routes.test.ts +++ b/packages/rig/test/content-routes.test.ts @@ -184,6 +184,21 @@ describe('content routes', () => { ); }); + it('answers 503 when the ingress is busy, and keeps the connection', async () => { + // A full normalization queue is overload, not a bad request and not a + // server fault: 503 tells the client to retry, and the socket stays up. + const { store } = fixture(); + await withServer( + { store, ingest: async () => { throw Object.assign(new Error('normalizeImage: busy'), { code: 'EBUSY' }); } }, + async (base) => { + const res = await fetch(`${base}/v1/media/ingress`, { + method: 'POST', body: new Uint8Array(64), headers: { 'Content-Type': 'image/png' }, + }); + expect(res.status).toBe(503); + }, + ); + }); + it('sends no CORS header unless an origin is configured', async () => { const { store, root } = fixture(); const path = (r: { digest: string }) => `/v1/media/${r.digest}/representations/0`; From d2d99e898239b6f748f346ed5478409ef32fcbc5 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Fri, 4 Sep 2026 19:43:30 +1000 Subject: [PATCH 24/69] fix(media,rig): the content store verifies every read; the verify option is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round made verification a per-call flag that replay switched on. Two findings followed directly from that shape: the manifest lookup beside it never got the flag, so a manifest rewritten to point at other valid blobs replayed silently under the original trace root, and the null store's `get` narrowed against the interface it sits under. The flag had also left half of the original finding open — the HTTP route still served drifted bytes under a matching digest ETag. A content-addressed store has one promise: the bytes you get are the bytes the name says. That is a property of the store, not a choice each caller makes. `AttachmentStore.get(digest)` now returns the bytes that hash to the name or null; drifted and absent are the same answer. The file store rehashes on every read and `getManifest` inherits it. `materialize` drops the option. The null store matches the interface with no change. The in-memory test double drops the flag; a Map cannot rot. The cost argument that justified the split did not hold: representations are JPEG at mtmd's 2048² ceiling, sha256 measured 0.18 ms at 0.2 MB and 0.80 ms at 2 MB, and the route already caches by digest ETag. Red first, all three failing on the previous commit: a plain `get` returned the corrupted bytes; a manifest forged to reference another image's valid blob replayed through `materialize` without a throw; the content route served a corrupted blob with 200 and its ETag. Follow-on, not here: `putBlob` skips the write when the path exists, so a drifted blob is refused loudly forever and re-ingesting the same image does not repair it. Always writing through the existing temp-then-rename would make that self-healing. --- packages/agents/test/helpers/memory-store.ts | 8 ++-- packages/media/src/file-store.ts | 14 +++--- packages/media/src/ingress.ts | 14 +++--- packages/media/src/store.ts | 21 +++++---- packages/media/test/file-store-verify.test.ts | 43 ++++++++++++++----- packages/rig/test/content-routes.test.ts | 15 ++++++- 6 files changed, 78 insertions(+), 37 deletions(-) diff --git a/packages/agents/test/helpers/memory-store.ts b/packages/agents/test/helpers/memory-store.ts index 19ed5efe..07b9116d 100644 --- a/packages/agents/test/helpers/memory-store.ts +++ b/packages/agents/test/helpers/memory-store.ts @@ -35,10 +35,10 @@ export class MemoryAttachmentStore implements AttachmentStore { return commitManifest((bytes, mediaType) => this.putBlob(bytes, mediaType), parts); } - get(digest: string, opts?: { verify?: boolean }): Uint8Array | null { - const bytes = this.blobs.get(digest) ?? null; - if (!bytes || !opts?.verify) return bytes; - return 'sha256:' + createHash('sha256').update(bytes).digest('hex') === digest ? bytes : null; + // A Map cannot rot, so there is nothing to rehash: the read rule is the + // file store's and is tested against the file store. + get(digest: string): Uint8Array | null { + return this.blobs.get(digest) ?? null; } getManifest(digest: string): AttachmentManifest | null { diff --git a/packages/media/src/file-store.ts b/packages/media/src/file-store.ts index a94afa1c..07a17b65 100644 --- a/packages/media/src/file-store.ts +++ b/packages/media/src/file-store.ts @@ -88,7 +88,8 @@ export class FileAttachmentStore implements AttachmentStore { // it by creating directories a run may never need. this._ensureLayout(); const file = this._pathFor(digest)!; - // Content-addressed, so a file already at this path IS these bytes. + // Content-addressed, so a file already at this path IS these bytes — and + // if it has drifted, `get` refuses it; this write does not repair it. // Temp-then-rename so a reader never sees a half-written blob under a // digest that promises the whole of it. if (!existsSync(file)) { @@ -149,16 +150,15 @@ export class FileAttachmentStore implements AttachmentStore { } } - get(digest: string, opts?: { verify?: boolean }): Uint8Array | null { + get(digest: string): Uint8Array | null { try { const file = this._pathFor(digest); if (!file) return null; const bytes = new Uint8Array(readFileSync(file)); - if (opts?.verify) { - const actual = 'sha256:' + createHash('sha256').update(bytes).digest('hex'); - if (actual !== digest) return null; - } - return bytes; + // The name is a promise about the bytes, kept on every read (see the + // contract). Sub-millisecond for a normalized representation. + const actual = 'sha256:' + createHash('sha256').update(bytes).digest('hex'); + return actual === digest ? bytes : null; } catch { return null; } diff --git a/packages/media/src/ingress.ts b/packages/media/src/ingress.ts index b00d4b00..48171511 100644 --- a/packages/media/src/ingress.ts +++ b/packages/media/src/ingress.ts @@ -39,8 +39,9 @@ export interface PreparedContent { * Needs only the store, so it is safe on every runtime — unlike ingest, which * needs a native normalizer. * - * @throws If any root or blob is missing. Silent degradation here would - * rebuild a different KV state behind an identical-looking prompt. + * @throws If any root or blob is missing or has drifted from its digest. + * Silent degradation here would rebuild a different KV state behind + * an identical-looking prompt. * * @category Media */ @@ -54,13 +55,14 @@ export function materialize( if (!manifest) { throw new Error( `materialize: attachment manifest ${root.digest.slice(0, 19)}… is not ` + - 'in the content store.', + 'in the content store, or its bytes no longer hash to its digest.', ); } for (const rep of representationsOf(manifest)) { - // Verified: replay rebuilds KV from these bytes under this digest, so - // bytes that drifted on disk must refuse here, not decode as something else. - const bytes = store.get(rep.digest, { verify: true }); + // Replay rebuilds KV from these bytes under this digest. The store + // refuses bytes that drifted from their name, so null here means absent + // or drifted — either must refuse, not decode as something else. + const bytes = store.get(rep.digest); if (!bytes) { throw new Error( `materialize: blob ${rep.digest.slice(0, 19)}… (${rep.mediaType}) is ` + diff --git a/packages/media/src/store.ts b/packages/media/src/store.ts index c27154aa..5d7478c3 100644 --- a/packages/media/src/store.ts +++ b/packages/media/src/store.ts @@ -69,7 +69,7 @@ export interface AttachmentStore { config?: { bytes: Uint8Array; mediaType: string }; annotations?: Record; }): Attachment; - /** Resolve blob bytes by digest. `null` when this digest was never stored. + /** Resolve blob bytes by digest: the bytes that HASH TO IT, or `null`. * * Resolution goes STRAIGHT to `blobs//` and never * consults `index.json`: the index is an export and discovery catalogue, @@ -77,13 +77,18 @@ export interface AttachmentStore { * hide an attachment from OCI tooling, but it can never invalidate a * recorded run. * - * `verify` rehashes the bytes and answers `null` when they no longer match - * the digest — bit rot or a torn write. Replay asks for it, because - * rebuilding cells from drifted bytes under the original digest is a - * silent divergence; the HTTP serve path trusts the name. */ - get(digest: string, opts?: { verify?: boolean }): Uint8Array | null; - /** Resolve and validate a manifest. `null` when absent, unparsable, or not - * an artifact type this build understands. */ + * Every read rehashes what it found; bytes that no longer match the name — + * bit rot, a torn write, a rewritten file — answer `null` exactly as an + * absent blob does. A property of the store, not a per-call option: replay + * rebuilding cells from drifted bytes under the original digest is a silent + * divergence, and the HTTP route serving them under that digest's ETag is + * the same lie one hop later. (Verification was briefly a flag that replay + * switched on; the manifest lookup beside it never got the flag, which left + * the root-to-bytes chain unverified. One rule, no door.) */ + get(digest: string): Uint8Array | null; + /** Resolve and validate a manifest. `null` when absent, drifted (it is read + * through {@link get}), unparsable, or not an artifact type this build + * understands. */ getManifest(digest: string): AttachmentManifest | null; } diff --git a/packages/media/test/file-store-verify.test.ts b/packages/media/test/file-store-verify.test.ts index 620ac2ff..00680505 100644 --- a/packages/media/test/file-store-verify.test.ts +++ b/packages/media/test/file-store-verify.test.ts @@ -9,36 +9,59 @@ import { materialize } from '../src/ingress'; import { representationsOf } from '../src/attachment'; /** - * The store is content-addressed: a digest names bytes. Serving by filename - * alone trusts the disk. Bit rot or a torn write would hand replay different - * pixels under the original digest and it would rebuild the wrong cells with - * no complaint. Replay verifies; the hot HTTP path keeps trusting the name. + * The store is content-addressed: a digest names bytes, and a read answers + * those bytes or nothing. Serving by filename alone would trust the disk — + * bit rot, a torn write, or a rewritten manifest would hand replay different + * pixels under the original digest, and it would rebuild the wrong cells with + * no complaint. EVERY read verifies; there is no unverified door, so the + * manifest a trace root names is held to the same rule as the bytes it lists. */ -const png = () => - sharp({ create: { width: 32, height: 32, channels: 3, background: '#a70' } }).png().toBuffer() +const png = (background: string) => + sharp({ create: { width: 32, height: 32, channels: 3, background } }).png().toBuffer() .then((b) => new Uint8Array(b)); const blobPath = (dir: string, digest: string) => join(dir, 'blobs', 'sha256', digest.slice('sha256:'.length)); describe('content-addressed reads', () => { - it('get() serves by name; get(digest, { verify: true }) refuses bytes that no longer hash to it', () => { + it('get() refuses bytes that no longer hash to the name — drifted and absent are the same answer', () => { const dir = mkdtempSync(join(tmpdir(), 'verify-')); const store = new FileAttachmentStore(dir); const rep = store.putBlob(new Uint8Array([1, 2, 3, 4]), 'application/octet-stream'); writeFileSync(blobPath(dir, rep.digest), new Uint8Array([9, 9, 9, 9])); - expect(store.get(rep.digest)).toEqual(new Uint8Array([9, 9, 9, 9])); - expect(store.get(rep.digest, { verify: true })).toBeNull(); + expect(store.get(rep.digest)).toBeNull(); }); it('replay refuses a representation whose bytes drifted from their digest', async () => { const dir = mkdtempSync(join(tmpdir(), 'verify-')); const store = new FileAttachmentStore(dir); - const root = await createImageIngress(store).ingest(await png(), new AbortController().signal); + const root = await createImageIngress(store).ingest(await png('#a70'), new AbortController().signal); const rep = representationsOf(store.getManifest(root.digest)!)[0]; writeFileSync(blobPath(dir, rep.digest), new Uint8Array([0xff, 0xd8, 0xff, 0x00])); expect(() => materialize(store, [root])).toThrow(/digest/i); }); + + it('replay refuses a manifest rewritten to point at other, valid blobs', async () => { + const dir = mkdtempSync(join(tmpdir(), 'verify-')); + const store = new FileAttachmentStore(dir); + const ingress = createImageIngress(store); + const signal = new AbortController().signal; + const a = await ingress.ingest(await png('#a70'), signal); + const b = await ingress.ingest(await png('#07a'), signal); + const manifestA = store.getManifest(a.digest)!; + const repB = representationsOf(store.getManifest(b.digest)!)[0]; + const repsA = new Set(representationsOf(manifestA).map((d) => d.digest)); + // Every blob the forged manifest names is genuine and hashes to its own + // digest — only the manifest lies about which of them belong to root A. + const forged = { + ...manifestA, + layers: manifestA.layers.map((l) => (repsA.has(l.digest) ? repB : l)), + }; + writeFileSync(blobPath(dir, a.digest), JSON.stringify(forged)); + expect(store.get(repB.digest)).not.toBeNull(); + + expect(() => materialize(store, [a])).toThrow(/manifest/i); + }); }); diff --git a/packages/rig/test/content-routes.test.ts b/packages/rig/test/content-routes.test.ts index 842bfdf4..e52ddac5 100644 --- a/packages/rig/test/content-routes.test.ts +++ b/packages/rig/test/content-routes.test.ts @@ -7,7 +7,7 @@ */ import { describe, it, expect } from 'vitest'; import { createServer, type Server } from 'node:http'; -import { mkdtempSync } from 'node:fs'; +import { mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AddressInfo } from 'node:net'; @@ -31,7 +31,7 @@ function fixture() { const rep = store.putBlob(PNG, 'image/png', { 'ai.lloyal.derive.quality': '82' }); const source = store.putBlob(JPEG, 'image/jpeg'); const root = store.putAttachment({ representations: [rep], source }); - return { store, root, rep, source }; + return { dir, store, root, rep, source }; } async function withServer( @@ -103,6 +103,17 @@ describe('content routes', () => { }); }); + it('never serves drifted bytes under their digest — a corrupted blob is 404, not a false ETag', async () => { + const { dir, store, root, rep } = fixture(); + // The digest is the ETag and the cache key: bytes that no longer hash to + // it must not go out under it. The store refuses them; the route says 404. + writeFileSync(join(dir, 'blobs', 'sha256', rep.digest.slice('sha256:'.length)), new Uint8Array([9, 9, 9])); + await withServer({ store }, async (base) => { + const res = await fetch(`${base}/v1/media/${root.digest}/representations/0`); + expect(res.status).toBe(404); + }); + }); + it('caches privately, validates by digest, and refuses MIME sniffing', async () => { const { store, root, rep } = fixture(); await withServer({ store }, async (base) => { From b40f195568f397cc5a8c0d41e53adfa68a611718 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Fri, 4 Sep 2026 19:50:53 +1000 Subject: [PATCH 25/69] fix(scripts,abilities): the cut leaves range peers alone; the abilities admit the set; the golden reads the real table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every scaffold install against the alpha set failed on the abilities: the catalog's web, corpus and wikipedia declare a peer of `^5.0.0` on agents, the scaffold pins `6.0.0-alpha.N`, and npm refuses (ERESOLVE) — the red on the CLI's scaffold jobs. Their code is unchanged this arc; only the range said no. The abilities now carry peers that admit both the stable a user has today and every prerelease of the next major: `^5.0.0 || >=6.0.0-0 <7.0.0` on agents, `^5.5.0 || >=5.6.0-0 <6.0.0` on rig, and for corpus `^3.1.1 || >=3.2.0-0 <4.0.0` on the binding. The `-0` comparator is what admits the prerelease — a range only matches a prerelease when one comparator names that exact major.minor.patch with a prerelease tag, so a plain `>=5 <7` rejects 6.0.0-alpha.2. Versions bump for the catalog release: web 2.0.2, corpus 2.0.2, wikipedia 2.0.1. One release serves every cut and the eventual stable. The cutter therefore stops writing the set's exact pin over a range peer. Dependencies still always take the exact alpha (a range would exclude the set); a peer follows only when it is already an exact pin from a previous set, which is rig's peer on the binding. A range peer is authored compatibility and is not the cutter's to move. The arc table moves into the pure core and is exported, and the golden imports it. The old golden kept its own copy, said sdk was a minor while the script said major, and stayed green while the cutter stamped 4.0.0. Tests, red first: a range peer was rewritten; the ability peers did not admit what the next cut stamps; the golden could not see the table. Now an admission test checks every ability peer against the next cut's output and the stable before it, with the semver library npm resolves with. --- packages/abilities/corpus/package.json | 8 +- packages/abilities/web/package.json | 6 +- packages/abilities/wikipedia/package.json | 6 +- scripts/cut-alpha.lib.mjs | 69 ++++++++++++-- scripts/cut-alpha.mjs | 41 ++------- scripts/cut-alpha.test.ts | 107 +++++++++++++++++----- 6 files changed, 160 insertions(+), 77 deletions(-) diff --git a/packages/abilities/corpus/package.json b/packages/abilities/corpus/package.json index 1ef5be5f..0528f8cc 100644 --- a/packages/abilities/corpus/package.json +++ b/packages/abilities/corpus/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/corpus-ability", - "version": "2.0.1", + "version": "2.0.2", "private": true, "description": "HDK reference app — local-corpus research (Source + tools + contract). Distributed via the signed-bundle channel, never public npm.", "license": "SEE LICENSE IN LICENSE", @@ -25,9 +25,9 @@ "build": "tsc -b" }, "peerDependencies": { - "@lloyal-labs/lloyal-agents": "6.0.0-alpha.1", - "@lloyal-labs/lloyal.node": "3.2.0-alpha.1", - "@lloyal-labs/rig": "5.6.0-alpha.1", + "@lloyal-labs/lloyal-agents": "^5.0.0 || >=6.0.0-0 <7.0.0", + "@lloyal-labs/lloyal.node": "^3.1.1 || >=3.2.0-0 <4.0.0", + "@lloyal-labs/rig": "^5.5.0 || >=5.6.0-0 <6.0.0", "effection": "^4.0.2" } } diff --git a/packages/abilities/web/package.json b/packages/abilities/web/package.json index b4bc98ce..309210dd 100644 --- a/packages/abilities/web/package.json +++ b/packages/abilities/web/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/web-ability", - "version": "2.0.1", + "version": "2.0.2", "private": true, "description": "HDK reference app — web research (Source + tools + contract). Distributed via the signed-bundle channel, never public npm.", "license": "SEE LICENSE IN LICENSE", @@ -29,8 +29,8 @@ "linkedom": "^0.18.12" }, "peerDependencies": { - "@lloyal-labs/lloyal-agents": "6.0.0-alpha.1", - "@lloyal-labs/rig": "5.6.0-alpha.1", + "@lloyal-labs/lloyal-agents": "^5.0.0 || >=6.0.0-0 <7.0.0", + "@lloyal-labs/rig": "^5.5.0 || >=5.6.0-0 <6.0.0", "effection": "^4.0.2" } } diff --git a/packages/abilities/wikipedia/package.json b/packages/abilities/wikipedia/package.json index 781bd101..b2dcf6a1 100644 --- a/packages/abilities/wikipedia/package.json +++ b/packages/abilities/wikipedia/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/wikipedia-ability", - "version": "2.0.0", + "version": "2.0.1", "private": true, "description": "HDK reference app — Wikipedia research (search + fetch over Wikipedia's public REST). No auth required. Distributed via the signed-channel, never public npm.", "license": "SEE LICENSE IN LICENSE", @@ -24,8 +24,8 @@ "build": "tsc -b" }, "peerDependencies": { - "@lloyal-labs/lloyal-agents": "6.0.0-alpha.1", - "@lloyal-labs/rig": "5.6.0-alpha.1", + "@lloyal-labs/lloyal-agents": "^5.0.0 || >=6.0.0-0 <7.0.0", + "@lloyal-labs/rig": "^5.5.0 || >=5.6.0-0 <6.0.0", "effection": "^4.0.2" } } diff --git a/scripts/cut-alpha.lib.mjs b/scripts/cut-alpha.lib.mjs index 71e958fb..4955d14e 100644 --- a/scripts/cut-alpha.lib.mjs +++ b/scripts/cut-alpha.lib.mjs @@ -4,6 +4,49 @@ * about a cut is decidable here, and tested without a registry or a checkout. */ +/** What this arc touched → how far its next version moves. agents is a + * MAJOR: the arc removed 18 public exports (the content vocabulary moved + * to @lloyal-labs/media). sdk is a MAJOR: SessionContext gained required + * members (tokenToBytes, supportsVision/Audio, the multimodal natives) and + * decodeRcOf became decodeErrorOf — a third-party context stops + * type-checking, so this is not a minor. + * + * Exported so the golden test reads THIS table rather than a copy of it: a + * copy once said sdk was a minor while this said major, and stayed green. + * @type {Record} */ +export const CUTS = { + 'packages/media': 'minor', + 'packages/sdk': 'major', + 'packages/agents': 'major', + 'packages/rig': 'minor', + 'packages/dev-tools': 'minor', +}; +/** Cross-repo deps that are ALSO being cut this arc (rig depends on the + * binding). Must match lloyal.node's own cut level. + * @type {Record} */ +export const EXTERNAL = { '@lloyal-labs/lloyal.node': 'minor' }; + +/** + * The cut's package list for `planAlphas`, from the tables and a manifest + * reader. For a package the registry has never seen, the manifest IS the + * base — a prior cut's -alpha.N is the pending release, continued, never + * bumped again. Externals live in another repo, so they have no manifest + * here and no local fallback. + * @param {Record} cuts dir → level + * @param {Record} external name → level + * @param {(dir: string) => { name: string, version: string }} manifestOf + * @returns {Array<{ dir?: string, name: string, level: 'major' | 'minor', fallback: string }>} + */ +export function arcPackages(cuts, external, manifestOf) { + return [ + ...Object.entries(cuts).map(([dir, level]) => { + const pkg = manifestOf(dir); + return { dir, name: pkg.name, level, fallback: pkg.version }; + }), + ...Object.entries(external).map(([name, level]) => ({ name, level, fallback: '0.0.0' })), + ]; +} + /** `--cut `: a non-negative integer, nothing else. `Number()` would take * a missing or garbled value as NaN and stamp `-alpha.NaN` everywhere. */ export function parseCut(arg) { @@ -60,20 +103,30 @@ export function planAlphas({ cut, packages, view }) { return alphas; } +/** An exact version, as a prior cut stamps it — as opposed to a RANGE. */ +const EXACT = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/; + /** Rewrite one manifest object in place: its `version` when this package is - * in the cut (`version` given), and every dependency/peer that names a cut - * package to the exact alpha — in EVERY workspace manifest, because the - * workspace must resolve as one set (a peer still naming the previous - * -alpha.N fails the install). A member outside the cut keeps its version: - * the npm loop skips already-published versions, and the abilities ship - * through the signed catalog, whose release moves theirs. Returns whether - * anything changed. */ + * in the cut (`version` given); every DEPENDENCY that names a cut package + * to the exact alpha, in EVERY workspace manifest, because the workspace + * must resolve as one set and a range excludes prereleases; and a PEER only + * when it is already an exact pin from a previous set (rig's peer on the + * binding). A peer that is a range is authored compatibility and stays: an + * ability ships through the signed catalog to stable and alpha users alike, + * so its peer admits both (`^5.0.0 || >=6.0.0-0 <7.0.0`) and no cut may + * write the set's pin over it. A member outside the cut keeps its version: + * the npm loop skips already-published versions, and the catalog release + * moves an ability's. Returns whether anything changed. */ export function rewriteManifest(pkg, { version, alphas }) { let changed = false; if (version !== undefined && pkg.version !== version) { pkg.version = version; changed = true; } for (const field of ['dependencies', 'peerDependencies']) { for (const dep of Object.keys(pkg[field] ?? {})) { - if (alphas[dep] && pkg[field][dep] !== alphas[dep]) { pkg[field][dep] = alphas[dep]; changed = true; } + const current = pkg[field][dep]; + if (!alphas[dep] || current === alphas[dep]) continue; + if (field === 'peerDependencies' && !EXACT.test(current)) continue; + pkg[field][dep] = alphas[dep]; + changed = true; } } return changed; diff --git a/scripts/cut-alpha.mjs b/scripts/cut-alpha.mjs index 056bce19..524f7086 100755 --- a/scripts/cut-alpha.mjs +++ b/scripts/cut-alpha.mjs @@ -16,54 +16,25 @@ * (lloyal.node's alpha first); until then it reports and leaves the old * lockfile in place. * - * The pure core (parseCut, planAlphas, rewriteManifest) lives in - * cut-alpha.lib.mjs and is tested there. + * The pure core — the arc table, parseCut, planAlphas, rewriteManifest — + * lives in cut-alpha.lib.mjs and is tested there. * * Run locally: node scripts/cut-alpha.mjs --cut 0 [--dry-run] */ import { execSync } from 'node:child_process'; import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs'; -import { parseCut, planAlphas, rewriteManifest } from './cut-alpha.lib.mjs'; +import { CUTS, EXTERNAL, arcPackages, parseCut, planAlphas, rewriteManifest } from './cut-alpha.lib.mjs'; const cutIdx = process.argv.indexOf('--cut'); const CUT = parseCut(cutIdx === -1 ? undefined : process.argv[cutIdx + 1]); const DRY = process.argv.includes('--dry-run'); -/** What this arc touched → how far its next version moves. agents is a - * MAJOR: the arc removed 18 public exports (the content vocabulary moved - * to @lloyal-labs/media). sdk is a MAJOR: SessionContext gained required - * members (tokenToBytes, supportsVision/Audio, the multimodal natives) and - * decodeRcOf became decodeErrorOf — a third-party context stops - * type-checking, so this is not a minor. */ -const CUTS = { - 'packages/media': 'minor', - 'packages/sdk': 'major', - 'packages/agents': 'major', - 'packages/rig': 'minor', - 'packages/dev-tools': 'minor', -}; -/** Cross-repo deps that are ALSO being cut this arc (rig depends on the - * binding). Must match lloyal.node's own cut level. */ -const EXTERNAL = { '@lloyal-labs/lloyal.node': 'minor' }; - const view = (name) => execSync(`npm view ${name}@latest version`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }); const manifestOf = (dir) => JSON.parse(readFileSync(`${dir}/package.json`, 'utf8')); -const cutPackages = Object.entries(CUTS).map(([dir, level]) => { - const pkg = manifestOf(dir); - // For a package the registry has never seen, the manifest IS the base — a - // prior cut's -alpha.N is the pending release, continued, never bumped again. - return { dir, name: pkg.name, level, fallback: pkg.version }; -}); -const alphas = planAlphas({ - cut: CUT, - packages: [ - ...cutPackages.map(({ name, level, fallback }) => ({ name, level, fallback })), - ...Object.entries(EXTERNAL).map(([name, level]) => ({ name, level, fallback: '0.0.0' })), - ], - view, -}); +const arc = arcPackages(CUTS, EXTERNAL, manifestOf); +const alphas = planAlphas({ cut: CUT, packages: arc, view }); console.log(`cut ${CUT}${DRY ? ' (dry run)' : ''}:`); for (const [n, v] of Object.entries(alphas)) console.log(` ${n} -> ${v}`); @@ -72,7 +43,7 @@ for (const [n, v] of Object.entries(alphas)) console.log(` ${n} -> ${v}`); // version moves. The abilities are members too (their peers name the set) but // ship through the signed catalog, not this repo's npm loop — their release // bumps their versions there. -const nameOf = Object.fromEntries(cutPackages.map((p) => [p.dir, p.name])); +const nameOf = Object.fromEntries(arc.filter((p) => p.dir).map((p) => [p.dir, p.name])); const dirs = ['packages', 'packages/abilities'].flatMap((root) => readdirSync(root).map((d) => `${root}/${d}`).filter((d) => existsSync(`${d}/package.json`))); for (const dir of dirs) { diff --git a/scripts/cut-alpha.test.ts b/scripts/cut-alpha.test.ts index ebe9f48e..2a92acd4 100644 --- a/scripts/cut-alpha.test.ts +++ b/scripts/cut-alpha.test.ts @@ -4,7 +4,12 @@ * here without a registry or a checkout. */ import { describe, it, expect } from 'vitest'; -import { parseCut, latestVersion, planAlphas, rewriteManifest } from './cut-alpha.lib.mjs'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { satisfies } from 'semver'; +import { + CUTS, EXTERNAL, arcPackages, parseCut, latestVersion, planAlphas, rewriteManifest, +} from './cut-alpha.lib.mjs'; const e404 = Object.assign(new Error('npm ERR! code E404'), { stderr: 'npm ERR! code E404\nnpm ERR! 404 Not Found' }); const reset = Object.assign(new Error('npm ERR! code ECONNRESET'), { stderr: 'npm ERR! code ECONNRESET' }); @@ -28,27 +33,31 @@ describe('latestVersion', () => { }); describe('planAlphas', () => { - it('is a golden: the set the templates pin today', () => { + it('is a golden over the REAL arc table: what cut 1 stamps from the registry as it stood', () => { + // The table is imported, not copied: an earlier version of this test kept + // its own list, said sdk was a minor while the script said major, and + // stayed green while the cutter stamped 4.0.0. A golden that cannot see + // the table it is a golden OF proves nothing. const registry: Record = { '@lloyal-labs/sdk': '3.1.0', '@lloyal-labs/lloyal-agents': '5.5.1', '@lloyal-labs/rig': '5.5.0', '@lloyal-labs/dev-tools': '0.4.3', '@lloyal-labs/lloyal.node': '3.1.1', }; + const manifests: Record = { + 'packages/media': { name: '@lloyal-labs/media', version: '0.1.0' }, + 'packages/sdk': { name: '@lloyal-labs/sdk', version: '3.1.0' }, + 'packages/agents': { name: '@lloyal-labs/lloyal-agents', version: '5.5.1' }, + 'packages/rig': { name: '@lloyal-labs/rig', version: '5.5.0' }, + 'packages/dev-tools': { name: '@lloyal-labs/dev-tools', version: '0.4.3' }, + }; const view = (name: string) => { if (name in registry) return registry[name]; throw e404; }; const alphas = planAlphas({ cut: 1, - packages: [ - { name: '@lloyal-labs/media', level: 'minor', fallback: '0.1.0' }, - { name: '@lloyal-labs/sdk', level: 'minor', fallback: '0.0.0' }, - { name: '@lloyal-labs/lloyal-agents', level: 'major', fallback: '0.0.0' }, - { name: '@lloyal-labs/rig', level: 'minor', fallback: '0.0.0' }, - { name: '@lloyal-labs/dev-tools', level: 'minor', fallback: '0.0.0' }, - { name: '@lloyal-labs/lloyal.node', level: 'minor', fallback: '0.0.0' }, - ], + packages: arcPackages(CUTS, EXTERNAL, (dir: string) => manifests[dir]), view, }); expect(alphas).toEqual({ '@lloyal-labs/media': '0.2.0-alpha.1', - '@lloyal-labs/sdk': '3.2.0-alpha.1', + '@lloyal-labs/sdk': '4.0.0-alpha.1', '@lloyal-labs/lloyal-agents': '6.0.0-alpha.1', '@lloyal-labs/rig': '5.6.0-alpha.1', '@lloyal-labs/dev-tools': '0.5.0-alpha.1', @@ -80,7 +89,11 @@ describe('planAlphas', () => { describe('rewriteManifest', () => { const alphas = { '@lloyal-labs/sdk': '3.2.0-alpha.1', '@lloyal-labs/lloyal-agents': '6.0.0-alpha.1' }; - it('stamps a cut package: its version and its exact internal pins, deps and peers alike', () => { + it('stamps a cut package: its version and its exact internal DEPENDENCIES; a range dependency becomes the pin', () => { + // A dependency is a resolution instruction and a range excludes + // prereleases, so `^3.1.0` on sdk would fail the install against the set; + // it becomes the exact alpha. A range PEER is a compatibility statement and + // is not the cutter's to rewrite (see below). const pkg = { name: '@lloyal-labs/rig', version: '5.5.0', dependencies: { '@lloyal-labs/sdk': '^3.1.0', effection: '^4' }, @@ -91,21 +104,67 @@ describe('rewriteManifest', () => { expect(pkg.version).toBe('5.6.0-alpha.1'); expect(pkg.dependencies['@lloyal-labs/sdk']).toBe('3.2.0-alpha.1'); expect(pkg.dependencies.effection).toBe('^4'); - expect(pkg.peerDependencies['@lloyal-labs/lloyal-agents']).toBe('6.0.0-alpha.1'); + expect(pkg.peerDependencies['@lloyal-labs/lloyal-agents']).toBe('^5'); + }); + + it('an EXACT peer from the previous set follows the new one (rig peers on the binding this way)', () => { + const pkg = { + name: '@lloyal-labs/rig', version: '5.6.0-alpha.0', + peerDependencies: { '@lloyal-labs/lloyal.node': '3.2.0-alpha.0' }, // the previous set + }; + const set = { ...alphas, '@lloyal-labs/lloyal.node': '3.2.0-alpha.1' }; + expect(rewriteManifest(pkg, { version: '5.6.0-alpha.1', alphas: set })).toBe(true); + expect(pkg.peerDependencies['@lloyal-labs/lloyal.node']).toBe('3.2.0-alpha.1'); }); - it('a workspace member outside the cut keeps its version but its pins follow the set', () => { - // The workspace must resolve as one set: an ability whose peer still named - // -alpha.1 after cut 2 would fail the install. Its VERSION is not the - // cutter's to move — abilities ship through the signed catalog, and their - // release bumps it there. + it('a RANGE peer is authored compatibility and is left alone', () => { + // An ability ships through the signed catalog to stable AND alpha users + // alike, so its peer is a range that admits both — not the set's exact + // pin, which the cutter must therefore not write over it. + const range = '^5.0.0 || >=6.0.0-0 <7.0.0'; const pkg = { - name: '@lloyal-labs/web-ability', version: '2.0.1', - peerDependencies: { '@lloyal-labs/lloyal-agents': '6.0.0-alpha.0', effection: '^4' }, // the previous set + name: '@lloyal-labs/web-ability', version: '2.0.2', + peerDependencies: { '@lloyal-labs/lloyal-agents': range, effection: '^4' }, + }; + expect(rewriteManifest(pkg, { version: undefined, alphas })).toBe(false); + expect(pkg.peerDependencies['@lloyal-labs/lloyal-agents']).toBe(range); + }); +}); + +describe('the abilities admit the set', () => { + // The install that fails on this is the front door: `lloyal new` vendors an + // ability from the catalog and npm checks its peers against the scaffold's + // exact alpha pins. A range admits a prerelease only when one comparator + // names that exact major.minor.patch with a prerelease tag — so `>=5 <7` + // rejects 6.0.0-alpha.2 and `>=6.0.0-0` is what admits it. Checked with the + // semver library npm itself resolves with. + const ROOT = join(__dirname, '..'); + const manifestOf = (dir: string) => + JSON.parse(readFileSync(join(ROOT, dir, 'package.json'), 'utf8')) as { + name: string; version: string; peerDependencies?: Record; }; - expect(rewriteManifest(pkg, { version: undefined, alphas })).toBe(true); - expect(pkg.version).toBe('2.0.1'); - expect(pkg.peerDependencies['@lloyal-labs/lloyal-agents']).toBe(alphas['@lloyal-labs/lloyal-agents']); - expect(pkg.peerDependencies.effection).toBe('^4'); + + it('every ability peer on a set member admits the version the NEXT cut stamps, and the stable before it', () => { + const registry: Record = { + '@lloyal-labs/sdk': '3.1.0', '@lloyal-labs/lloyal-agents': '5.5.1', '@lloyal-labs/rig': '5.5.0', + '@lloyal-labs/dev-tools': '0.4.3', '@lloyal-labs/lloyal.node': '3.1.1', + }; + const view = (name: string) => { if (name in registry) return registry[name]; throw e404; }; + const stamped = planAlphas({ cut: 99, packages: arcPackages(CUTS, EXTERNAL, manifestOf), view }); + for (const dir of ['packages/abilities/web', 'packages/abilities/corpus', 'packages/abilities/wikipedia']) { + const peers = manifestOf(dir).peerDependencies ?? {}; + for (const [name, range] of Object.entries(peers)) { + if (!(name in stamped)) continue; + expect(satisfies(stamped[name], range), `${dir}: ${name} ${range} admits ${stamped[name]}`).toBe(true); + expect(satisfies(registry[name], range), `${dir}: ${name} ${range} admits stable ${registry[name]}`).toBe(true); + } + } + }); + + it('the trap is real: a plain range excludes the prerelease, the -0 comparator admits it', () => { + expect(satisfies('6.0.0-alpha.2', '>=5.0.0 <7.0.0')).toBe(false); + expect(satisfies('6.0.0-alpha.2', '^5.0.0 || >=6.0.0-0 <7.0.0')).toBe(true); + expect(satisfies('5.5.1', '^5.0.0 || >=6.0.0-0 <7.0.0')).toBe(true); + expect(satisfies('7.0.0-alpha.1', '^5.0.0 || >=6.0.0-0 <7.0.0')).toBe(false); }); }); From e3182fe549be477aa327b35cb3cc92ef411d3184 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Fri, 4 Sep 2026 20:26:31 +1000 Subject: [PATCH 26/69] =?UTF-8?q?alpha:=20cut=202=20=E2=80=94=20the=20set?= =?UTF-8?q?=20is=20stamped=20alpha.2;=20sdk=20moves=20to=204.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit media 0.2.0-alpha.2, sdk 4.0.0-alpha.2, agents 6.0.0-alpha.2, rig 5.6.0-alpha.2, dev-tools 0.5.0-alpha.2, with every internal dependency pinned exactly to the set and the binding pinned at 3.2.0-alpha.2. sdk is a major from this cut: SessionContext gained required members and decodeRcOf became decodeErrorOf, so a third-party context stops type-checking against it. The abilities' peers are ranges now and are untouched by the cut; their versions moved with their catalog release. The lockfile is regenerated in a follow-up commit once the binding's alpha is on the registry — until then `npm install --package-lock-only` cannot resolve the pin, which the cutter reports rather than papering over. --- packages/agents/package.json | 6 +++--- packages/dev-tools/package.json | 4 ++-- packages/media/package.json | 2 +- packages/rig/package.json | 10 +++++----- packages/sdk/package.json | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/agents/package.json b/packages/agents/package.json index 74a07ddb..80f530f8 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/lloyal-agents", - "version": "6.0.0-alpha.1", + "version": "6.0.0-alpha.2", "description": "Multi-agent inference inside the decode loop — structured concurrency over shared KV state", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -31,10 +31,10 @@ "build": "tsc -b" }, "dependencies": { - "@lloyal-labs/sdk": "3.2.0-alpha.1", + "@lloyal-labs/sdk": "4.0.0-alpha.2", "effection": "^4.0.2", "eta": "^4.5.1", - "@lloyal-labs/media": "0.2.0-alpha.1" + "@lloyal-labs/media": "0.2.0-alpha.2" }, "files": [ "dist/", diff --git a/packages/dev-tools/package.json b/packages/dev-tools/package.json index 26e346c4..48265d51 100644 --- a/packages/dev-tools/package.json +++ b/packages/dev-tools/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/dev-tools", - "version": "0.5.0-alpha.1", + "version": "0.5.0-alpha.2", "description": "The dev pane for scaffolded harnesses — timeline, sources, and settings over the event bus, gated by the runner's dev signal", "type": "module", "main": "dist/index.js", @@ -52,7 +52,7 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { - "@lloyal-labs/rig": "5.6.0-alpha.1", + "@lloyal-labs/rig": "5.6.0-alpha.2", "zustand": "^5.0.15" }, "peerDependencies": { diff --git a/packages/media/package.json b/packages/media/package.json index 57e48805..a8e73ddb 100644 --- a/packages/media/package.json +++ b/packages/media/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/media", - "version": "0.2.0-alpha.1", + "version": "0.2.0-alpha.2", "description": "Content addressing for a harness — an OCI layout, and the image normalizer that feeds it", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/rig/package.json b/packages/rig/package.json index b3c73643..6de57836 100644 --- a/packages/rig/package.json +++ b/packages/rig/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/rig", - "version": "5.6.0-alpha.1", + "version": "5.6.0-alpha.2", "description": "Retrieval-Interleaved Generation for lloyal-agents", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -41,17 +41,17 @@ }, "dependencies": { "@lloyal-labs/channel-verify": "^0.3.1", - "@lloyal-labs/lloyal-agents": "6.0.0-alpha.1", - "@lloyal-labs/sdk": "3.2.0-alpha.1", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.2", + "@lloyal-labs/sdk": "4.0.0-alpha.2", "@mozilla/readability": "^0.6.0", "effection": "^4.0.2", "ignore": "^7.0.5", "linkedom": "^0.18.12", "semver": "^7.8.1", - "@lloyal-labs/media": "0.2.0-alpha.1" + "@lloyal-labs/media": "0.2.0-alpha.2" }, "peerDependencies": { - "@lloyal-labs/lloyal.node": "3.2.0-alpha.1" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.2" }, "files": [ "dist/", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index c38de009..d516b316 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/sdk", - "version": "3.2.0-alpha.1", + "version": "4.0.0-alpha.2", "description": "Backend-agnostic TypeScript SDK for the lloyal inference platform", "main": "dist/index.js", "types": "dist/index.d.ts", From d1d170462711773a2c1a71d88ba31b56279215b2 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Fri, 4 Sep 2026 21:58:53 +1000 Subject: [PATCH 27/69] =?UTF-8?q?alpha:=20the=20workspace=20resolves=20as?= =?UTF-8?q?=20one=20set=20=E2=80=94=20devDependencies=20follow=20the=20cut?= =?UTF-8?q?,=20the=20lockfile=20records=20the=20binding,=20and=20peer=20re?= =?UTF-8?q?solution=20is=20scoped=20out=20until=20the=20binding=20admits?= =?UTF-8?q?=20prereleases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hdk release failed at `npm install`: the published binding declares peers `>=3.0.0` on agents and sdk, and a plain range never admits a prerelease, so inside this workspace — where the binding arrives through rig's peer rather than as a root dependency — npm refused 6.0.0-alpha.2 and 4.0.0-alpha.2. A scaffold is unaffected: it declares all three at its root, which npm treats as the user's choice. Beside that sat a second range that could not see the set: sdk and host developed against the binding through `^3.1.1`, which resolves to the published stable. Any fresh workspace install tested the arc against the old binding; the symlink to the lloyal-node checkout hid it all arc. The cutter now pins devDependencies on set members exactly, the same as dependencies, and the lockfile test asserts the binding the workspace resolved is the one rig pins. `.npmrc` sets legacy-peer-deps for this workspace only, with the reason and its removal point written beside it: the binding's next release admits the prerelease tuples (`>=3.0.0 || >=4.0.0-0 <5.0.0` on sdk, `>=3.0.0 || >=6.0.0-0 <7.0.0` on agents) and the file goes. Every internal pin here is exact and the lockfile test holds the set, so peer resolution was adding nothing the workspace does not already assert. Red first: the cutter left a devDependency range alone; the lockfile recorded the stable binding; the cut packages were absent from it. --- .npmrc | 11 + package-lock.json | 255 ++++++++++-------- .../agents/test/workspace-lockfile.test.ts | 18 +- packages/host/package.json | 4 +- packages/sdk/package.json | 2 +- scripts/cut-alpha.lib.mjs | 14 +- scripts/cut-alpha.test.ts | 15 ++ 7 files changed, 188 insertions(+), 131 deletions(-) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 00000000..c67ee7d2 --- /dev/null +++ b/.npmrc @@ -0,0 +1,11 @@ +; The published binding (@lloyal-labs/lloyal.node 3.2.0-alpha.2) declares peers +; `>=3.0.0` on agents and sdk. A plain range never admits a prerelease, so inside +; this workspace — where the binding arrives through rig's peer, not as a root +; dependency — npm refuses the alpha set (ERESOLVE). Every internal pin here is +; exact and the lockfile test holds the set, so peer resolution adds nothing +; the workspace does not already assert. Scaffolds are unaffected: they declare +; agents, sdk and the binding at their root. +; +; REMOVE THIS FILE when the binding's next release admits the prerelease tuples +; (`>=3.0.0 || >=4.0.0-0 <5.0.0` on sdk, `>=3.0.0 || >=6.0.0-0 <7.0.0` on agents). +legacy-peer-deps=true diff --git a/package-lock.json b/package-lock.json index 4f208602..6acaa933 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -1072,9 +1072,10 @@ "link": true }, "node_modules/@lloyal-labs/lloyal.node": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node/-/lloyal.node-3.1.1.tgz", - "integrity": "sha512-+eAtY0G2uWESW1d7GZPlQATl2gLatwdlv6mgXwi0WAD0M5esQz2KdIHz8Vjc3JaFp7md6qclWfrA0txtMUhuhg==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node/-/lloyal.node-3.2.0-alpha.2.tgz", + "integrity": "sha512-Df/C7lke3wRbqYd8trlqfJ2eGwjld+f88iXbZhzRbmeMYmXDHJ1PSqsOhxTIlWy0P3MaeteyuVS09UTGWYocHA==", + "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { "@lloyal-labs/tsampler": "^0.2.0", @@ -1084,19 +1085,19 @@ "node": ">=24.0.0" }, "optionalDependencies": { - "@lloyal-labs/lloyal.node-darwin-arm64": "3.1.1", - "@lloyal-labs/lloyal.node-darwin-x64": "3.1.1", - "@lloyal-labs/lloyal.node-linux-arm64": "3.1.1", - "@lloyal-labs/lloyal.node-linux-arm64-cuda": "3.1.1", - "@lloyal-labs/lloyal.node-linux-arm64-vulkan": "3.1.1", - "@lloyal-labs/lloyal.node-linux-x64": "3.1.1", - "@lloyal-labs/lloyal.node-linux-x64-cuda": "3.1.1", - "@lloyal-labs/lloyal.node-linux-x64-vulkan": "3.1.1", - "@lloyal-labs/lloyal.node-win32-arm64": "3.1.1", - "@lloyal-labs/lloyal.node-win32-arm64-vulkan": "3.1.1", - "@lloyal-labs/lloyal.node-win32-x64": "3.1.1", - "@lloyal-labs/lloyal.node-win32-x64-cuda": "3.1.1", - "@lloyal-labs/lloyal.node-win32-x64-vulkan": "3.1.1" + "@lloyal-labs/lloyal.node-darwin-arm64": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-darwin-x64": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-linux-arm64": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-linux-arm64-cuda": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-linux-arm64-vulkan": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-linux-x64": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-linux-x64-cuda": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-linux-x64-vulkan": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-win32-arm64": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-win32-arm64-vulkan": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-win32-x64": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-win32-x64-cuda": "3.2.0-alpha.2", + "@lloyal-labs/lloyal.node-win32-x64-vulkan": "3.2.0-alpha.2" }, "peerDependencies": { "@lloyal-labs/lloyal-agents": ">=3.0.0", @@ -1104,12 +1105,13 @@ } }, "node_modules/@lloyal-labs/lloyal.node-darwin-arm64": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-darwin-arm64/-/lloyal.node-darwin-arm64-3.1.1.tgz", - "integrity": "sha512-RQnGfmDMSLsGZY1SSHxp+Qm5T895XBbRJD3CXMcwEjTRIIsucsJVYjJO/FihW4sLM7zcPepeyAVMYMmiLp5c6w==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-darwin-arm64/-/lloyal.node-darwin-arm64-3.2.0-alpha.2.tgz", + "integrity": "sha512-vv+LfEIL/TS0fjzkW/TUC/mjrlPa3XBQn9pmV4r02YFTpt4+9PT/VatJujx8hwo7WOmV6TeenPc/YlWr6ZJUxw==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1117,12 +1119,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-darwin-x64": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-darwin-x64/-/lloyal.node-darwin-x64-3.1.1.tgz", - "integrity": "sha512-tDV0ju2Uz5ThmheI8YWdPL24rLVE52ZslnjjXADf9nt8zfx3rOrpm+VKI4zezEo7/oMWotIffOzlYz4X7WG38w==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-darwin-x64/-/lloyal.node-darwin-x64-3.2.0-alpha.2.tgz", + "integrity": "sha512-voSS4Jn+lhChr+qFMPdYRs0MjE6eDP88XrUlp4P4Goch8R3ZFrKFzUXtws4Inhu4qWHUnngqweiegz+3uPXVYg==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1130,12 +1133,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-linux-arm64": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64/-/lloyal.node-linux-arm64-3.1.1.tgz", - "integrity": "sha512-ou1LzcF2qrd1i4nIIMggWmKaeVCkjeyhrY/jmtkwe6OW0Hk3ojC8AxOS2d/WtOYr1/Pwkb320YAhTksiwhvqbg==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64/-/lloyal.node-linux-arm64-3.2.0-alpha.2.tgz", + "integrity": "sha512-wOjIbRAqNaIkUxAL0Al4jK8TJHa1jlgH4IsQepBLPdbUTW7zdib2nNW+zZWnK+zetBPiLUsBly4oVVgVxLGIPg==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1143,12 +1147,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-linux-arm64-cuda": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64-cuda/-/lloyal.node-linux-arm64-cuda-3.1.1.tgz", - "integrity": "sha512-QdysfkocSDZQgIcv//gTKFMUdn/58zq98mH4mfTvm1tvRO5SGYPns4g4w3XLeL33DyRsqM+ze/0QrEaEGz5NAQ==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64-cuda/-/lloyal.node-linux-arm64-cuda-3.2.0-alpha.2.tgz", + "integrity": "sha512-VIPdTwUnQp6+vJtaqhq42DetZJMqoAuud+RY8UoJvWGSK96TsAZlp4RyAc5E87QDFLHhkwfSVfNvZgn5q5FxJA==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1156,12 +1161,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-linux-arm64-vulkan": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64-vulkan/-/lloyal.node-linux-arm64-vulkan-3.1.1.tgz", - "integrity": "sha512-NG202IhvDlYwK0f/qGguMLiy9bIyzm7igiakXEchf1p6fEAOUuQnqPqYztMX2t1aC4wXqmYst9RKu8R0lolhMw==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64-vulkan/-/lloyal.node-linux-arm64-vulkan-3.2.0-alpha.2.tgz", + "integrity": "sha512-9elCpjWYOZsxFjf+PpIZ8TyG0H40RfT1g4Nyq5UBJiJd7YB3OVnm7Ac+dooUVAUKXfWv360Zl1yc5j6Le94D6w==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1169,12 +1175,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-linux-x64": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-x64/-/lloyal.node-linux-x64-3.1.1.tgz", - "integrity": "sha512-dwlXj7LvoRrHAsMH2938Uh2iJFuVuiKtYjVFflrLXjkMmPGG7ekwlOc8OZLyCJQe1R1n1cjQGHpxfjvLPzlKcw==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-x64/-/lloyal.node-linux-x64-3.2.0-alpha.2.tgz", + "integrity": "sha512-3kkhW9kUF/3jPBZdYvFas0vcA0xl50bAk9r+lMWfI1wGdNYN8V5U4dDD+e53MOjah/KwJbvL4b5GiTwp6gl+rA==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1182,12 +1189,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-linux-x64-cuda": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-x64-cuda/-/lloyal.node-linux-x64-cuda-3.1.1.tgz", - "integrity": "sha512-jTnGn8bQfK51IQ7TftoFh59mO/HQ078b3uZ+ZHfjaLrt1Cg+/D+rUBHNXCqlAWMwLG5L/F3m33KTBCGpH2dW5g==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-x64-cuda/-/lloyal.node-linux-x64-cuda-3.2.0-alpha.2.tgz", + "integrity": "sha512-oevwYBQgtTcQFOqWFpDRvhq+riccAcD4EDHehkGchp3/c9at/OloFrRzBDoK2KC3CxUMXupkQu7eAA0bfnJfiQ==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1195,12 +1203,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-linux-x64-vulkan": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-x64-vulkan/-/lloyal.node-linux-x64-vulkan-3.1.1.tgz", - "integrity": "sha512-RZjFAgChDBuzrLqHvXrOwsWZLtODUHNxOXKQ+JYXzo2zw9BMzcKCLJzkIViCty75xRrCEBMg/IHFXrbs0MKrGQ==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-x64-vulkan/-/lloyal.node-linux-x64-vulkan-3.2.0-alpha.2.tgz", + "integrity": "sha512-GrXSBmZle4vX4bioIoyQHGU7z7UCUnsmfks/wt+5l8Ll8ogcubOxjtFqyhFvxoM1NICgV0Z2irOsBxqA+WJppQ==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1208,12 +1217,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-win32-arm64": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-arm64/-/lloyal.node-win32-arm64-3.1.1.tgz", - "integrity": "sha512-iHGfbAUVtxqyKpJFAZGwqo20wu6vAVRLY/V40dBIxmKWMbZtGxEezhqFSTBdDbD59wQrpUDZ490RZ7Whn8e2wg==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-arm64/-/lloyal.node-win32-arm64-3.2.0-alpha.2.tgz", + "integrity": "sha512-BwpG4w9KnPb2/lOKQ8T22QQBQiuKJzfXaKF08bqVG2MnPYAzp/Sa9lhmKHZ3SMbpB2FiXal+SwdIzowWSRRjnQ==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1221,12 +1231,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-win32-arm64-vulkan": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-arm64-vulkan/-/lloyal.node-win32-arm64-vulkan-3.1.1.tgz", - "integrity": "sha512-ESRHX6czTovG7ILqXA7B5DRuhFmOOxripEOIfiwAbw3fAKYIlfrzdukKhDzTnW0oP8vcaLNQz9LuMwVLFjS1uw==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-arm64-vulkan/-/lloyal.node-win32-arm64-vulkan-3.2.0-alpha.2.tgz", + "integrity": "sha512-IQsGuB4cg1hssTw42Tyt1Wx4ghcEk17j3QZWV+PDKqj4RCcvDsZc1XTiCbEFFfrfb6noSk76xru+cvRxKPL2xQ==", "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1234,12 +1245,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-win32-x64": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64/-/lloyal.node-win32-x64-3.1.1.tgz", - "integrity": "sha512-55MaHQpyMe5lUOCvRD/xBl5/Vg9Qp8mkHsKnL6w+8QJqlvErfnv3aX6ktcj1GNfu5Pj0IzsQn8N3/GF3wVj+Kw==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64/-/lloyal.node-win32-x64-3.2.0-alpha.2.tgz", + "integrity": "sha512-kFyT5BkJLqGpvQPwUF0rIgEvfs4f2DADljenzZ6ez7usRBNf8tLJ+2VCcbxYjKOa7CaU8bsbwMmCjiEGEz37Mg==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1247,12 +1259,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-win32-x64-cuda": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64-cuda/-/lloyal.node-win32-x64-cuda-3.1.1.tgz", - "integrity": "sha512-DHcykhe3XqicgbWDwnHE2kkU+QdJd5d9amRrYOrLSwJtZttOqBi02WSHMPcRmEgORnmrLKNkhScKAxw57nSw0Q==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64-cuda/-/lloyal.node-win32-x64-cuda-3.2.0-alpha.2.tgz", + "integrity": "sha512-tZ6QyYzX6A/K2W3UBT8oWvGnX0njWVDxDVgGLP0XNhS2ybZjRTxkZgbnuSURgHAqChpZUb6jOWdO8OD4E+Nf4w==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1260,12 +1273,13 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-win32-x64-vulkan": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64-vulkan/-/lloyal.node-win32-x64-vulkan-3.1.1.tgz", - "integrity": "sha512-CjVQs/OkIWC7f7PfBVoCtskuTs0orgpTcGIJajnU2+GZRg17gsOHJ6A3kmPEvDV2AjAIShGPAZTZAtCEY3pnKg==", + "version": "3.2.0-alpha.2", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64-vulkan/-/lloyal.node-win32-x64-vulkan-3.2.0-alpha.2.tgz", + "integrity": "sha512-wM7OTXLziVsyQ+CArwHm4CPg4IgozBHR0GGorEvkJWV7Zzo4+qqTQmE1N12w5gTGmF6dRGEfkZXxoU7cXPB5fQ==", "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1292,6 +1306,7 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@lloyal-labs/tsampler/-/tsampler-0.2.0.tgz", "integrity": "sha512-Kfs233gBJ7Sz3ncPv+hp1ZMDtbT3hihb+R7DDQ13b6lhyhxmc8Q/Of4AN+X0y3Ce3qAe9BGMJmeDPYOeLJdmbA==", + "dev": true, "license": "Apache-2.0" }, "node_modules/@lloyal-labs/web-ability": { @@ -1688,7 +1703,7 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1825,7 +1840,7 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "environment": "^1.0.0" @@ -1841,7 +1856,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1854,7 +1869,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1884,7 +1899,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -1943,7 +1958,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -1956,7 +1971,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18.20 <19 || >=20.10" @@ -1969,7 +1984,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "restore-cursor": "^4.0.0" @@ -1985,7 +2000,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "slice-ansi": "^9.0.0", @@ -2002,7 +2017,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "convert-to-spaces": "^2.0.1" @@ -2022,7 +2037,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -2071,7 +2086,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -2189,7 +2204,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2209,7 +2224,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", - "devOptional": true, + "dev": true, "license": "MIT", "workspaces": [ "docs", @@ -2264,7 +2279,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2362,7 +2377,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -2488,7 +2503,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -2501,7 +2516,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", @@ -2551,7 +2566,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "^1.3.1" @@ -2567,7 +2582,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "is-in-ci": "cli.js" @@ -2940,7 +2955,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -2985,6 +3000,7 @@ "version": "8.9.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", + "dev": true, "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" @@ -3024,7 +3040,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -3040,7 +3056,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -3133,6 +3149,7 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3142,7 +3159,7 @@ "version": "0.33.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -3158,7 +3175,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -3209,7 +3226,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/semver": { @@ -3285,14 +3302,14 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/slice-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.3", @@ -3319,7 +3336,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" @@ -3346,7 +3363,7 @@ "version": "8.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -3363,7 +3380,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -3379,7 +3396,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -3392,7 +3409,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -3476,7 +3493,7 @@ "version": "5.8.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", - "devOptional": true, + "dev": true, "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -3748,7 +3765,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "string-width": "^8.1.0" @@ -3764,7 +3781,7 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.3", @@ -3781,7 +3798,7 @@ "version": "8.21.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3819,7 +3836,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/zustand": { @@ -3853,46 +3870,46 @@ }, "packages/abilities/corpus": { "name": "@lloyal-labs/corpus-ability", - "version": "2.0.1", + "version": "2.0.2", "license": "SEE LICENSE IN LICENSE", "peerDependencies": { - "@lloyal-labs/lloyal-agents": "^5.3.0", - "@lloyal-labs/lloyal.node": "^3.1.1", - "@lloyal-labs/rig": "^5.0.0", + "@lloyal-labs/lloyal-agents": "^5.0.0 || >=6.0.0-0 <7.0.0", + "@lloyal-labs/lloyal.node": "^3.1.1 || >=3.2.0-0 <4.0.0", + "@lloyal-labs/rig": "^5.5.0 || >=5.6.0-0 <6.0.0", "effection": "^4.0.2" } }, "packages/abilities/web": { "name": "@lloyal-labs/web-ability", - "version": "2.0.1", + "version": "2.0.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@mozilla/readability": "^0.6.0", "linkedom": "^0.18.12" }, "peerDependencies": { - "@lloyal-labs/lloyal-agents": "^5.3.0", - "@lloyal-labs/rig": "^5.0.0", + "@lloyal-labs/lloyal-agents": "^5.0.0 || >=6.0.0-0 <7.0.0", + "@lloyal-labs/rig": "^5.5.0 || >=5.6.0-0 <6.0.0", "effection": "^4.0.2" } }, "packages/abilities/wikipedia": { "name": "@lloyal-labs/wikipedia-ability", - "version": "2.0.0", + "version": "2.0.1", "license": "SEE LICENSE IN LICENSE", "peerDependencies": { - "@lloyal-labs/lloyal-agents": "^5.0.0", - "@lloyal-labs/rig": "^5.0.0", + "@lloyal-labs/lloyal-agents": "^5.0.0 || >=6.0.0-0 <7.0.0", + "@lloyal-labs/rig": "^5.5.0 || >=5.6.0-0 <6.0.0", "effection": "^4.0.2" } }, "packages/agents": { "name": "@lloyal-labs/lloyal-agents", - "version": "5.5.1", + "version": "6.0.0-alpha.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@lloyal-labs/media": "^0.1.0", - "@lloyal-labs/sdk": "^3.0.0", + "@lloyal-labs/media": "0.2.0-alpha.2", + "@lloyal-labs/sdk": "4.0.0-alpha.2", "effection": "^4.0.2", "eta": "^4.5.1" } @@ -3912,10 +3929,10 @@ }, "packages/dev-tools": { "name": "@lloyal-labs/dev-tools", - "version": "0.4.3", + "version": "0.5.0-alpha.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@lloyal-labs/rig": "^5.3.0", + "@lloyal-labs/rig": "5.6.0-alpha.2", "zustand": "^5.0.15" }, "peerDependencies": { @@ -3937,13 +3954,13 @@ "effection": "^4.0.2" }, "devDependencies": { - "@lloyal-labs/lloyal.node": "^3.1.1", - "@lloyal-labs/sdk": "^3.0.3" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.2", + "@lloyal-labs/sdk": "4.0.0-alpha.2" } }, "packages/media": { "name": "@lloyal-labs/media", - "version": "0.1.0", + "version": "0.2.0-alpha.2", "license": "SEE LICENSE IN LICENSE", "devDependencies": { "sharp": "^0.35.4" @@ -3967,13 +3984,13 @@ }, "packages/rig": { "name": "@lloyal-labs/rig", - "version": "5.5.0", + "version": "5.6.0-alpha.2", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@lloyal-labs/channel-verify": "^0.3.1", - "@lloyal-labs/lloyal-agents": "^5.0.0", - "@lloyal-labs/media": "^0.1.0", - "@lloyal-labs/sdk": "^3.1.0", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.2", + "@lloyal-labs/media": "0.2.0-alpha.2", + "@lloyal-labs/sdk": "4.0.0-alpha.2", "@mozilla/readability": "^0.6.0", "effection": "^4.0.2", "ignore": "^7.0.5", @@ -3981,15 +3998,15 @@ "semver": "^7.8.1" }, "peerDependencies": { - "@lloyal-labs/lloyal.node": "^3.1.1" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.2" } }, "packages/sdk": { "name": "@lloyal-labs/sdk", - "version": "3.1.0", + "version": "4.0.0-alpha.2", "license": "SEE LICENSE IN LICENSE", "devDependencies": { - "@lloyal-labs/lloyal.node": "^3.1.1" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.2" } } } diff --git a/packages/agents/test/workspace-lockfile.test.ts b/packages/agents/test/workspace-lockfile.test.ts index 28641a56..40a70916 100644 --- a/packages/agents/test/workspace-lockfile.test.ts +++ b/packages/agents/test/workspace-lockfile.test.ts @@ -15,13 +15,25 @@ const ROOT = join(__dirname, '..', '..', '..'); const CUT = ['packages/media', 'packages/sdk', 'packages/agents', 'packages/rig', 'packages/dev-tools']; describe('workspace lockfile', () => { + const lock = JSON.parse(readFileSync(join(ROOT, 'package-lock.json'), 'utf8')) as { + packages: Record; + }; + it('records every cut package at the version its manifest declares', () => { - const lock = JSON.parse(readFileSync(join(ROOT, 'package-lock.json'), 'utf8')) as { - packages: Record; - }; for (const dir of CUT) { const manifest = JSON.parse(readFileSync(join(ROOT, dir, 'package.json'), 'utf8')) as { version: string }; expect(lock.packages[dir]?.version, dir).toBe(manifest.version); } }); + + it('resolved the binding rig pins, not a stable a devDependency range let in', () => { + // rig peers on the binding EXACTLY; sdk and host used to develop against + // `^3.1.1`, which resolves to the published stable — so the workspace + // tested the arc against the old binding while the symlink hid it. + const rig = JSON.parse(readFileSync(join(ROOT, 'packages/rig/package.json'), 'utf8')) as { + peerDependencies: Record; + }; + expect(lock.packages['node_modules/@lloyal-labs/lloyal.node']?.version) + .toBe(rig.peerDependencies['@lloyal-labs/lloyal.node']); + }); }); diff --git a/packages/host/package.json b/packages/host/package.json index ebcd9219..95e84ab9 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -39,8 +39,8 @@ "effection": "^4.0.2" }, "devDependencies": { - "@lloyal-labs/sdk": "^3.0.3", - "@lloyal-labs/lloyal.node": "^3.1.1" + "@lloyal-labs/sdk": "4.0.0-alpha.2", + "@lloyal-labs/lloyal.node": "3.2.0-alpha.2" }, "files": [ "dist/", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index d516b316..a06a40a3 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -35,6 +35,6 @@ "LICENSE-FAQ.md" ], "devDependencies": { - "@lloyal-labs/lloyal.node": "^3.1.1" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.2" } } diff --git a/scripts/cut-alpha.lib.mjs b/scripts/cut-alpha.lib.mjs index 4955d14e..7c4c825a 100644 --- a/scripts/cut-alpha.lib.mjs +++ b/scripts/cut-alpha.lib.mjs @@ -107,11 +107,13 @@ export function planAlphas({ cut, packages, view }) { const EXACT = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/; /** Rewrite one manifest object in place: its `version` when this package is - * in the cut (`version` given); every DEPENDENCY that names a cut package - * to the exact alpha, in EVERY workspace manifest, because the workspace - * must resolve as one set and a range excludes prereleases; and a PEER only - * when it is already an exact pin from a previous set (rig's peer on the - * binding). A peer that is a range is authored compatibility and stays: an + * in the cut (`version` given); every DEPENDENCY and DEVDEPENDENCY that names + * a cut package to the exact alpha, in EVERY workspace manifest, because the + * workspace must resolve as one set and a range excludes prereleases (sdk + * and host develop against the binding through a devDependency — `^3.1.1` + * there resolved the published stable while the checkout symlink hid it); + * and a PEER only when it is already an exact pin from a previous set + * (rig's peer on the binding). A peer that is a range is authored compatibility and stays: an * ability ships through the signed catalog to stable and alpha users alike, * so its peer admits both (`^5.0.0 || >=6.0.0-0 <7.0.0`) and no cut may * write the set's pin over it. A member outside the cut keeps its version: @@ -120,7 +122,7 @@ const EXACT = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/; export function rewriteManifest(pkg, { version, alphas }) { let changed = false; if (version !== undefined && pkg.version !== version) { pkg.version = version; changed = true; } - for (const field of ['dependencies', 'peerDependencies']) { + for (const field of ['dependencies', 'devDependencies', 'peerDependencies']) { for (const dep of Object.keys(pkg[field] ?? {})) { const current = pkg[field][dep]; if (!alphas[dep] || current === alphas[dep]) continue; diff --git a/scripts/cut-alpha.test.ts b/scripts/cut-alpha.test.ts index 2a92acd4..4def725e 100644 --- a/scripts/cut-alpha.test.ts +++ b/scripts/cut-alpha.test.ts @@ -117,6 +117,21 @@ describe('rewriteManifest', () => { expect(pkg.peerDependencies['@lloyal-labs/lloyal.node']).toBe('3.2.0-alpha.1'); }); + it('a devDependency on a set member follows the set too — the workspace must test against ITS binding', () => { + // sdk and host develop against the binding through a devDependency. A + // range there (`^3.1.1`) resolves to the published stable, so a fresh + // workspace install tested the arc against the OLD binding — hidden all + // arc by the symlink to the lloyal-node checkout. + const pkg = { + name: '@lloyal-labs/sdk', version: '4.0.0-alpha.1', + devDependencies: { '@lloyal-labs/lloyal.node': '^3.1.1', vitest: '^4' }, + }; + const set = { ...alphas, '@lloyal-labs/lloyal.node': '3.2.0-alpha.1' }; + expect(rewriteManifest(pkg, { version: '4.0.0-alpha.1', alphas: set })).toBe(true); + expect(pkg.devDependencies['@lloyal-labs/lloyal.node']).toBe('3.2.0-alpha.1'); + expect(pkg.devDependencies.vitest).toBe('^4'); + }); + it('a RANGE peer is authored compatibility and is left alone', () => { // An ability ships through the signed catalog to stable AND alpha users // alike, so its peer is a range that admits both — not the set's exact From 57bb8fb35c98580daef61d35ccc908aebd842628 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Sat, 5 Sep 2026 00:26:39 +1000 Subject: [PATCH 28/69] =?UTF-8?q?alpha:=20cut=203=20=E2=80=94=20the=20set?= =?UTF-8?q?=20at=20alpha.3=20beside=20a=20binding=20whose=20peers=20admit?= =?UTF-8?q?=20it;=20the=20peer=20flag=20goes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The binding republished as 3.2.0-alpha.3 with peers that name the prerelease tuples, so the set id moves with it: media 0.2.0-alpha.3, sdk 4.0.0-alpha.3, agents 6.0.0-alpha.3, rig 5.6.0-alpha.3, dev-tools 0.5.0-alpha.3, every internal dependency and devDependency pinned exactly, the binding at 3.2.0-alpha.3. The lockfile is regenerated against the registry as it stands and records the whole set, the binding included, with `.npmrc` deleted: the workspace now resolves on its own terms, which is what the peer flag had been standing in for. The workspace-lockfile test holds both facts. --- .npmrc | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .npmrc diff --git a/.npmrc b/.npmrc deleted file mode 100644 index c67ee7d2..00000000 --- a/.npmrc +++ /dev/null @@ -1,11 +0,0 @@ -; The published binding (@lloyal-labs/lloyal.node 3.2.0-alpha.2) declares peers -; `>=3.0.0` on agents and sdk. A plain range never admits a prerelease, so inside -; this workspace — where the binding arrives through rig's peer, not as a root -; dependency — npm refuses the alpha set (ERESOLVE). Every internal pin here is -; exact and the lockfile test holds the set, so peer resolution adds nothing -; the workspace does not already assert. Scaffolds are unaffected: they declare -; agents, sdk and the binding at their root. -; -; REMOVE THIS FILE when the binding's next release admits the prerelease tuples -; (`>=3.0.0 || >=4.0.0-0 <5.0.0` on sdk, `>=3.0.0 || >=6.0.0-0 <7.0.0` on agents). -legacy-peer-deps=true From 2338315892e5aa96e268f17f969766d101c34505 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Sat, 5 Sep 2026 00:27:39 +1000 Subject: [PATCH 29/69] =?UTF-8?q?alpha:=20cut=203=20=E2=80=94=20the=20set?= =?UTF-8?q?=20at=20alpha.3=20beside=20a=20binding=20whose=20peers=20admit?= =?UTF-8?q?=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit carried only the `.npmrc` deletion: the staging step failed on that already-deleted path and committed what was staged. This is the rest of the cut. The binding republished as 3.2.0-alpha.3 with peers that name the prerelease tuples, so the set id moves with it: media 0.2.0-alpha.3, sdk 4.0.0-alpha.3, agents 6.0.0-alpha.3, rig 5.6.0-alpha.3, dev-tools 0.5.0-alpha.3, every internal dependency and devDependency pinned exactly, the binding at 3.2.0-alpha.3. The lockfile is regenerated against the registry as it stands and records the whole set, the binding included, with no peer flag: the workspace resolves on its own terms, which is what the flag had been standing in for. The workspace-lockfile test holds both. --- package-lock.json | 259 ++++++++++++++------------------ packages/agents/package.json | 6 +- packages/dev-tools/package.json | 4 +- packages/host/package.json | 4 +- packages/media/package.json | 2 +- packages/rig/package.json | 10 +- packages/sdk/package.json | 4 +- 7 files changed, 126 insertions(+), 163 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6acaa933..a076b3a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "version": "0.3.0", "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.1", @@ -1072,10 +1072,9 @@ "link": true }, "node_modules/@lloyal-labs/lloyal.node": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node/-/lloyal.node-3.2.0-alpha.2.tgz", - "integrity": "sha512-Df/C7lke3wRbqYd8trlqfJ2eGwjld+f88iXbZhzRbmeMYmXDHJ1PSqsOhxTIlWy0P3MaeteyuVS09UTGWYocHA==", - "dev": true, + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node/-/lloyal.node-3.2.0-alpha.3.tgz", + "integrity": "sha512-bHBLeHtfskHbBAyYxuvpnzORQvao7r+0K9nsxzNAVFfOFGmKXei5EIx9FfaOwuiVS/mVWGO5npRz+EtspGMPVA==", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@lloyal-labs/tsampler": "^0.2.0", @@ -1085,47 +1084,32 @@ "node": ">=24.0.0" }, "optionalDependencies": { - "@lloyal-labs/lloyal.node-darwin-arm64": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-darwin-x64": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-linux-arm64": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-linux-arm64-cuda": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-linux-arm64-vulkan": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-linux-x64": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-linux-x64-cuda": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-linux-x64-vulkan": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-win32-arm64": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-win32-arm64-vulkan": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-win32-x64": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-win32-x64-cuda": "3.2.0-alpha.2", - "@lloyal-labs/lloyal.node-win32-x64-vulkan": "3.2.0-alpha.2" + "@lloyal-labs/lloyal.node-darwin-arm64": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-darwin-x64": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-linux-arm64": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-linux-arm64-cuda": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-linux-arm64-vulkan": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-linux-x64": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-linux-x64-cuda": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-linux-x64-vulkan": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-win32-arm64": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-win32-arm64-vulkan": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-win32-x64": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-win32-x64-cuda": "3.2.0-alpha.3", + "@lloyal-labs/lloyal.node-win32-x64-vulkan": "3.2.0-alpha.3" }, "peerDependencies": { - "@lloyal-labs/lloyal-agents": ">=3.0.0", - "@lloyal-labs/sdk": ">=3.0.0" + "@lloyal-labs/lloyal-agents": ">=3.0.0 || >=6.0.0-0 <7.0.0", + "@lloyal-labs/sdk": ">=3.0.0 || >=4.0.0-0 <5.0.0" } }, "node_modules/@lloyal-labs/lloyal.node-darwin-arm64": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-darwin-arm64/-/lloyal.node-darwin-arm64-3.2.0-alpha.2.tgz", - "integrity": "sha512-vv+LfEIL/TS0fjzkW/TUC/mjrlPa3XBQn9pmV4r02YFTpt4+9PT/VatJujx8hwo7WOmV6TeenPc/YlWr6ZJUxw==", + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-darwin-arm64/-/lloyal.node-darwin-arm64-3.2.0-alpha.3.tgz", + "integrity": "sha512-0kZj6zpoIeGnv9HSleXJjzrJCHBObH1w6NXMFdvOgm6rkVDO1upNWbiXI9lVgiuaOT6FaLKdJZMOk4qyDpifVw==", "cpu": [ "arm64" ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lloyal-labs/lloyal.node-darwin-x64": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-darwin-x64/-/lloyal.node-darwin-x64-3.2.0-alpha.2.tgz", - "integrity": "sha512-voSS4Jn+lhChr+qFMPdYRs0MjE6eDP88XrUlp4P4Goch8R3ZFrKFzUXtws4Inhu4qWHUnngqweiegz+3uPXVYg==", - "cpu": [ - "x64" - ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1133,13 +1117,12 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-linux-arm64": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64/-/lloyal.node-linux-arm64-3.2.0-alpha.2.tgz", - "integrity": "sha512-wOjIbRAqNaIkUxAL0Al4jK8TJHa1jlgH4IsQepBLPdbUTW7zdib2nNW+zZWnK+zetBPiLUsBly4oVVgVxLGIPg==", + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64/-/lloyal.node-linux-arm64-3.2.0-alpha.3.tgz", + "integrity": "sha512-CNJMXx8ZknfizN/GEl/9ckcm/o5otj5nIaQGX0axnN4SWHWujN/dLkKwhaB0WSQ2TE/IZQgwu9TC+/8GiebNtA==", "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1147,13 +1130,12 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-linux-arm64-cuda": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64-cuda/-/lloyal.node-linux-arm64-cuda-3.2.0-alpha.2.tgz", - "integrity": "sha512-VIPdTwUnQp6+vJtaqhq42DetZJMqoAuud+RY8UoJvWGSK96TsAZlp4RyAc5E87QDFLHhkwfSVfNvZgn5q5FxJA==", + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64-cuda/-/lloyal.node-linux-arm64-cuda-3.2.0-alpha.3.tgz", + "integrity": "sha512-IC2f0qImcym2OXkfK2swuV5eLgztzHqbZOQjMwB1AjmXHQ4Xb3vKg5rzZKZ0TrMzqkwzfrxtQh7fFHttAn9Fjg==", "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1161,27 +1143,12 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-linux-arm64-vulkan": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64-vulkan/-/lloyal.node-linux-arm64-vulkan-3.2.0-alpha.2.tgz", - "integrity": "sha512-9elCpjWYOZsxFjf+PpIZ8TyG0H40RfT1g4Nyq5UBJiJd7YB3OVnm7Ac+dooUVAUKXfWv360Zl1yc5j6Le94D6w==", + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-arm64-vulkan/-/lloyal.node-linux-arm64-vulkan-3.2.0-alpha.3.tgz", + "integrity": "sha512-8F5rpn1x5FQjD7EXO3m/kwU+i33ZCP2goQGjzh67D+S/6KWvIK0L84WW/QO4+8mDOsc7HinMbUXxPEuU879/rg==", "cpu": [ "arm64" ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lloyal-labs/lloyal.node-linux-x64": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-x64/-/lloyal.node-linux-x64-3.2.0-alpha.2.tgz", - "integrity": "sha512-3kkhW9kUF/3jPBZdYvFas0vcA0xl50bAk9r+lMWfI1wGdNYN8V5U4dDD+e53MOjah/KwJbvL4b5GiTwp6gl+rA==", - "cpu": [ - "x64" - ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1189,13 +1156,12 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-linux-x64-cuda": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-x64-cuda/-/lloyal.node-linux-x64-cuda-3.2.0-alpha.2.tgz", - "integrity": "sha512-oevwYBQgtTcQFOqWFpDRvhq+riccAcD4EDHehkGchp3/c9at/OloFrRzBDoK2KC3CxUMXupkQu7eAA0bfnJfiQ==", + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-x64-cuda/-/lloyal.node-linux-x64-cuda-3.2.0-alpha.3.tgz", + "integrity": "sha512-HmsUtZ//XQdximFw2y5SnCe/ALgwNLuhIq9wr1jji4GKB1IREieI61Hqexhi1fRWc/UQpmaRJz9cKtWwH6CCHw==", "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1203,13 +1169,12 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-linux-x64-vulkan": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-x64-vulkan/-/lloyal.node-linux-x64-vulkan-3.2.0-alpha.2.tgz", - "integrity": "sha512-GrXSBmZle4vX4bioIoyQHGU7z7UCUnsmfks/wt+5l8Ll8ogcubOxjtFqyhFvxoM1NICgV0Z2irOsBxqA+WJppQ==", + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-linux-x64-vulkan/-/lloyal.node-linux-x64-vulkan-3.2.0-alpha.3.tgz", + "integrity": "sha512-vaQjJmhQlQU0wUGNFk7s8z9rHIZSC3aYsyMNmtD/E7xLvmerc0heoXUgEpIapQZ9A/f982Wugo0IfU6faCYYdA==", "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1217,13 +1182,12 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-win32-arm64": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-arm64/-/lloyal.node-win32-arm64-3.2.0-alpha.2.tgz", - "integrity": "sha512-BwpG4w9KnPb2/lOKQ8T22QQBQiuKJzfXaKF08bqVG2MnPYAzp/Sa9lhmKHZ3SMbpB2FiXal+SwdIzowWSRRjnQ==", + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-arm64/-/lloyal.node-win32-arm64-3.2.0-alpha.3.tgz", + "integrity": "sha512-APH6Xht1r/prGoCWZxTpAD4mgZGFZXoNdFgF7xXl8pHJ+aRvfysFJZr/J2FYHBzh0KkRLOoTbLd8CstGAk/Ubg==", "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1231,13 +1195,12 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-win32-arm64-vulkan": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-arm64-vulkan/-/lloyal.node-win32-arm64-vulkan-3.2.0-alpha.2.tgz", - "integrity": "sha512-IQsGuB4cg1hssTw42Tyt1Wx4ghcEk17j3QZWV+PDKqj4RCcvDsZc1XTiCbEFFfrfb6noSk76xru+cvRxKPL2xQ==", + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-arm64-vulkan/-/lloyal.node-win32-arm64-vulkan-3.2.0-alpha.3.tgz", + "integrity": "sha512-n/os67vWlwXOj0aR1k0+4H0RN7KRpPsO5B2UChG4U97uBCRR6se9XRsoRSPdotRmsjxV/u0Ujmv9WY1r+1lwNA==", "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1245,13 +1208,12 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-win32-x64": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64/-/lloyal.node-win32-x64-3.2.0-alpha.2.tgz", - "integrity": "sha512-kFyT5BkJLqGpvQPwUF0rIgEvfs4f2DADljenzZ6ez7usRBNf8tLJ+2VCcbxYjKOa7CaU8bsbwMmCjiEGEz37Mg==", + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64/-/lloyal.node-win32-x64-3.2.0-alpha.3.tgz", + "integrity": "sha512-ji2YWYhSJxnRG4ilq8UoqRuM575u0/kfzoQfL30VN4C7OjvJRvSTT+bnb4uaZyIcLT6xy3nYiw88/CSS/25D5w==", "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1259,13 +1221,12 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-win32-x64-cuda": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64-cuda/-/lloyal.node-win32-x64-cuda-3.2.0-alpha.2.tgz", - "integrity": "sha512-tZ6QyYzX6A/K2W3UBT8oWvGnX0njWVDxDVgGLP0XNhS2ybZjRTxkZgbnuSURgHAqChpZUb6jOWdO8OD4E+Nf4w==", + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64-cuda/-/lloyal.node-win32-x64-cuda-3.2.0-alpha.3.tgz", + "integrity": "sha512-UNW3Guw8Miz09d1gNC8gl7U87WvOinBjlpYapv2vQVMM9K4bH1Npyk3qZMMpdgdObh9LsB9q/iGsFbluypUBIQ==", "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1273,19 +1234,24 @@ ] }, "node_modules/@lloyal-labs/lloyal.node-win32-x64-vulkan": { - "version": "3.2.0-alpha.2", - "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64-vulkan/-/lloyal.node-win32-x64-vulkan-3.2.0-alpha.2.tgz", - "integrity": "sha512-wM7OTXLziVsyQ+CArwHm4CPg4IgozBHR0GGorEvkJWV7Zzo4+qqTQmE1N12w5gTGmF6dRGEfkZXxoU7cXPB5fQ==", + "version": "3.2.0-alpha.3", + "resolved": "https://registry.npmjs.org/@lloyal-labs/lloyal.node-win32-x64-vulkan/-/lloyal.node-win32-x64-vulkan-3.2.0-alpha.3.tgz", + "integrity": "sha512-tGI+QwrAt3YM+vsxNAcQuEr9gsbhxaiIkuIAU4lnD8olW9CLFF6F3Cj2+7VaEjSTb58b0KLWmxF2pUHP/uTq5g==", "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ "win32" ] }, + "node_modules/@lloyal-labs/lloyal.node/node_modules/@lloyal-labs/lloyal.node-darwin-x64": { + "optional": true + }, + "node_modules/@lloyal-labs/lloyal.node/node_modules/@lloyal-labs/lloyal.node-linux-x64": { + "optional": true + }, "node_modules/@lloyal-labs/media": { "resolved": "packages/media", "link": true @@ -1306,7 +1272,6 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/@lloyal-labs/tsampler/-/tsampler-0.2.0.tgz", "integrity": "sha512-Kfs233gBJ7Sz3ncPv+hp1ZMDtbT3hihb+R7DDQ13b6lhyhxmc8Q/Of4AN+X0y3Ce3qAe9BGMJmeDPYOeLJdmbA==", - "dev": true, "license": "Apache-2.0" }, "node_modules/@lloyal-labs/web-ability": { @@ -1703,7 +1668,7 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1840,7 +1805,7 @@ "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "environment": "^1.0.0" @@ -1856,7 +1821,7 @@ "version": "6.3.0", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -1869,7 +1834,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -1899,7 +1864,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -1958,7 +1923,7 @@ "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.17.0 || ^14.13 || >=16.0.0" @@ -1971,7 +1936,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18.20 <19 || >=20.10" @@ -1984,7 +1949,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "restore-cursor": "^4.0.0" @@ -2000,7 +1965,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "slice-ansi": "^9.0.0", @@ -2017,7 +1982,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "convert-to-spaces": "^2.0.1" @@ -2037,7 +2002,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -2086,7 +2051,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -2204,7 +2169,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -2224,7 +2189,7 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", - "dev": true, + "devOptional": true, "license": "MIT", "workspaces": [ "docs", @@ -2279,7 +2244,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -2377,7 +2342,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -2503,7 +2468,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12" @@ -2516,7 +2481,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", @@ -2566,7 +2531,7 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "get-east-asian-width": "^1.3.1" @@ -2582,7 +2547,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "is-in-ci": "cli.js" @@ -2955,7 +2920,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -3000,7 +2965,6 @@ "version": "8.9.2", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", - "dev": true, "license": "MIT", "engines": { "node": "^18 || ^20 || >= 21" @@ -3040,7 +3004,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -3056,7 +3020,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -3149,7 +3113,6 @@ "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3159,7 +3122,7 @@ "version": "0.33.0", "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -3175,7 +3138,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -3226,7 +3189,7 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/semver": { @@ -3302,14 +3265,14 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/slice-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.3", @@ -3336,7 +3299,7 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" @@ -3363,7 +3326,7 @@ "version": "8.2.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "get-east-asian-width": "^1.5.0", @@ -3380,7 +3343,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^6.2.2" @@ -3396,7 +3359,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=20" @@ -3409,7 +3372,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -3493,7 +3456,7 @@ "version": "5.8.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", - "dev": true, + "devOptional": true, "license": "(MIT OR CC0-1.0)", "dependencies": { "tagged-tag": "^1.0.0" @@ -3765,7 +3728,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "string-width": "^8.1.0" @@ -3781,7 +3744,7 @@ "version": "10.0.1", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^6.2.3", @@ -3798,7 +3761,7 @@ "version": "8.21.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -3836,7 +3799,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/zustand": { @@ -3905,11 +3868,11 @@ }, "packages/agents": { "name": "@lloyal-labs/lloyal-agents", - "version": "6.0.0-alpha.2", + "version": "6.0.0-alpha.3", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@lloyal-labs/media": "0.2.0-alpha.2", - "@lloyal-labs/sdk": "4.0.0-alpha.2", + "@lloyal-labs/media": "0.2.0-alpha.3", + "@lloyal-labs/sdk": "4.0.0-alpha.3", "effection": "^4.0.2", "eta": "^4.5.1" } @@ -3929,10 +3892,10 @@ }, "packages/dev-tools": { "name": "@lloyal-labs/dev-tools", - "version": "0.5.0-alpha.2", + "version": "0.5.0-alpha.3", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "@lloyal-labs/rig": "5.6.0-alpha.2", + "@lloyal-labs/rig": "5.6.0-alpha.3", "zustand": "^5.0.15" }, "peerDependencies": { @@ -3954,13 +3917,13 @@ "effection": "^4.0.2" }, "devDependencies": { - "@lloyal-labs/lloyal.node": "3.2.0-alpha.2", - "@lloyal-labs/sdk": "4.0.0-alpha.2" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.3", + "@lloyal-labs/sdk": "4.0.0-alpha.3" } }, "packages/media": { "name": "@lloyal-labs/media", - "version": "0.2.0-alpha.2", + "version": "0.2.0-alpha.3", "license": "SEE LICENSE IN LICENSE", "devDependencies": { "sharp": "^0.35.4" @@ -3984,13 +3947,13 @@ }, "packages/rig": { "name": "@lloyal-labs/rig", - "version": "5.6.0-alpha.2", + "version": "5.6.0-alpha.3", "license": "SEE LICENSE IN LICENSE", "dependencies": { "@lloyal-labs/channel-verify": "^0.3.1", - "@lloyal-labs/lloyal-agents": "6.0.0-alpha.2", - "@lloyal-labs/media": "0.2.0-alpha.2", - "@lloyal-labs/sdk": "4.0.0-alpha.2", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.3", + "@lloyal-labs/media": "0.2.0-alpha.3", + "@lloyal-labs/sdk": "4.0.0-alpha.3", "@mozilla/readability": "^0.6.0", "effection": "^4.0.2", "ignore": "^7.0.5", @@ -3998,15 +3961,15 @@ "semver": "^7.8.1" }, "peerDependencies": { - "@lloyal-labs/lloyal.node": "3.2.0-alpha.2" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.3" } }, "packages/sdk": { "name": "@lloyal-labs/sdk", - "version": "4.0.0-alpha.2", + "version": "4.0.0-alpha.3", "license": "SEE LICENSE IN LICENSE", "devDependencies": { - "@lloyal-labs/lloyal.node": "3.2.0-alpha.2" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.3" } } } diff --git a/packages/agents/package.json b/packages/agents/package.json index 80f530f8..3560317d 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/lloyal-agents", - "version": "6.0.0-alpha.2", + "version": "6.0.0-alpha.3", "description": "Multi-agent inference inside the decode loop — structured concurrency over shared KV state", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -31,10 +31,10 @@ "build": "tsc -b" }, "dependencies": { - "@lloyal-labs/sdk": "4.0.0-alpha.2", + "@lloyal-labs/sdk": "4.0.0-alpha.3", "effection": "^4.0.2", "eta": "^4.5.1", - "@lloyal-labs/media": "0.2.0-alpha.2" + "@lloyal-labs/media": "0.2.0-alpha.3" }, "files": [ "dist/", diff --git a/packages/dev-tools/package.json b/packages/dev-tools/package.json index 48265d51..5326035a 100644 --- a/packages/dev-tools/package.json +++ b/packages/dev-tools/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/dev-tools", - "version": "0.5.0-alpha.2", + "version": "0.5.0-alpha.3", "description": "The dev pane for scaffolded harnesses — timeline, sources, and settings over the event bus, gated by the runner's dev signal", "type": "module", "main": "dist/index.js", @@ -52,7 +52,7 @@ "build": "tsc -p tsconfig.json" }, "dependencies": { - "@lloyal-labs/rig": "5.6.0-alpha.2", + "@lloyal-labs/rig": "5.6.0-alpha.3", "zustand": "^5.0.15" }, "peerDependencies": { diff --git a/packages/host/package.json b/packages/host/package.json index 95e84ab9..0b8373b7 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -39,8 +39,8 @@ "effection": "^4.0.2" }, "devDependencies": { - "@lloyal-labs/sdk": "4.0.0-alpha.2", - "@lloyal-labs/lloyal.node": "3.2.0-alpha.2" + "@lloyal-labs/sdk": "4.0.0-alpha.3", + "@lloyal-labs/lloyal.node": "3.2.0-alpha.3" }, "files": [ "dist/", diff --git a/packages/media/package.json b/packages/media/package.json index a8e73ddb..9185d105 100644 --- a/packages/media/package.json +++ b/packages/media/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/media", - "version": "0.2.0-alpha.2", + "version": "0.2.0-alpha.3", "description": "Content addressing for a harness — an OCI layout, and the image normalizer that feeds it", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/rig/package.json b/packages/rig/package.json index 6de57836..2822f179 100644 --- a/packages/rig/package.json +++ b/packages/rig/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/rig", - "version": "5.6.0-alpha.2", + "version": "5.6.0-alpha.3", "description": "Retrieval-Interleaved Generation for lloyal-agents", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -41,17 +41,17 @@ }, "dependencies": { "@lloyal-labs/channel-verify": "^0.3.1", - "@lloyal-labs/lloyal-agents": "6.0.0-alpha.2", - "@lloyal-labs/sdk": "4.0.0-alpha.2", + "@lloyal-labs/lloyal-agents": "6.0.0-alpha.3", + "@lloyal-labs/sdk": "4.0.0-alpha.3", "@mozilla/readability": "^0.6.0", "effection": "^4.0.2", "ignore": "^7.0.5", "linkedom": "^0.18.12", "semver": "^7.8.1", - "@lloyal-labs/media": "0.2.0-alpha.2" + "@lloyal-labs/media": "0.2.0-alpha.3" }, "peerDependencies": { - "@lloyal-labs/lloyal.node": "3.2.0-alpha.2" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.3" }, "files": [ "dist/", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index a06a40a3..0b6db974 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@lloyal-labs/sdk", - "version": "4.0.0-alpha.2", + "version": "4.0.0-alpha.3", "description": "Backend-agnostic TypeScript SDK for the lloyal inference platform", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -35,6 +35,6 @@ "LICENSE-FAQ.md" ], "devDependencies": { - "@lloyal-labs/lloyal.node": "3.2.0-alpha.2" + "@lloyal-labs/lloyal.node": "3.2.0-alpha.3" } } From 355c67a0bd45c0983076f8ad08c307d18ca3d709 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Sat, 5 Sep 2026 18:17:41 +1000 Subject: [PATCH 30/69] agents: the pool becomes a scheduler One pure schedule over one pressure reading per tick, one executor that owns every decode, one interpreter of its outputs, one projection to the wire. The loop is 384 lines instead of 2,797. Recovery is a pending item, serial or cohort, lifted from the policy's recoveryShape when the pool opens; the agent carries its own record (failed, spec, records, heal and defer attempts); the tick boundary is re-cut at commit, with settle and dispatch of tick T leading spawn, produce and commit of T+1, and produce-phase verdicts read the pressure derived from that tick's admissions. The dead trace vocabulary goes with it: generate:*, diverge:*, source:* events, the stop_token / time_exceeded / time_nudge reasons, the diverge helpers, Agent's async iterator and the no-op Source.bind. A scan test now holds every remaining event type and reason literal to an emit site in agents, rig or an ability. Every scenario, property and unit test passes, and a byte-level diff of the trace and channel streams across the 32 scenarios differs from the old loop only where it disagreed with itself: every recovery turn stamps role 'recovery', one agent:tick per prune pass, serial recovery is counted in steps and cumulative produce totals and writes tool:settle_order, a tick's produce events precede its stop decisions, cohort idle drops set exitReason, and the rc -1 media note lands as the next admission. Two agent-pool tests that pinned the old cohort prefill role now read the shape off tool:settle_order batches. --- .../abilities/web/src/tools/fetch-page.ts | 2 +- .../abilities/web/src/tools/keyless-search.ts | 4 +- packages/agents/README.md | 20 +- packages/agents/src/Agent.ts | 65 +- packages/agents/src/AgentPolicy.ts | 17 +- packages/agents/src/agent-pool.ts | 2825 ++--------------- packages/agents/src/apply.ts | 381 +++ packages/agents/src/chunk.ts | 2 +- packages/agents/src/context.ts | 8 +- packages/agents/src/diverge.ts | 146 - packages/agents/src/emit.ts | 295 ++ packages/agents/src/execute.ts | 664 ++++ packages/agents/src/index.ts | 7 +- packages/agents/src/pressure.ts | 146 + packages/agents/src/replay.ts | 16 +- packages/agents/src/scheduler.ts | 274 ++ packages/agents/src/source.ts | 5 +- packages/agents/src/spine.ts | 9 +- packages/agents/src/state.ts | 264 ++ packages/agents/src/trace-types.ts | 51 +- packages/agents/src/trace-writer.ts | 8 +- packages/agents/src/types.ts | 101 +- packages/agents/src/use-agent.ts | 4 +- packages/agents/test/Agent.test.ts | 51 +- packages/agents/test/AgentPolicy.test.ts | 16 +- packages/agents/test/agent-pool.test.ts | 4 +- .../test/agent-transitions.prop.test.ts | 71 + packages/agents/test/authGuard.test.ts | 16 +- packages/agents/test/helpers/mock-branch.ts | 7 - packages/agents/test/invariants/README.md | 19 +- packages/agents/test/invariants/harness.ts | 7 - packages/agents/test/invariants/predicates.ts | 42 +- .../test/invariants/pressure.prop.test.ts | 14 +- ...-cancel-no-sweep-recovery.scenario.test.ts | 6 +- .../authGuard-rejection.scenario.test.ts | 6 +- .../chain-cohort-recovery.scenario.test.ts | 52 + .../concurrent-extend-spine.scenario.test.ts | 4 +- ...ardLimit-nBatch-invariant.scenario.test.ts | 2 +- ...fill-failure-claims-no-kv.scenario.test.ts | 2 +- ...e-message-includes-budget.scenario.test.ts | 2 + .../parallel-recovery.scenario.test.ts | 75 +- ...covery-agent-done-oneshot.scenario.test.ts | 4 +- .../scenarios/recovery-fails.scenario.test.ts | 12 +- .../recovery-oom-no-orphan.scenario.test.ts | 4 +- .../recovery-skip-terminal.scenario.test.ts | 6 +- .../tick-active-count.scenario.test.ts | 44 + .../scenarios/wind-down.scenario.test.ts | 37 +- .../xss-cross-ability-prose.scenario.test.ts | 6 +- packages/agents/test/scheduler.test.ts | 226 ++ packages/agents/test/spawn-agents.test.ts | 31 +- packages/agents/test/trace-vocabulary.test.ts | 85 + packages/rig/src/sources/types.ts | 10 +- 52 files changed, 2942 insertions(+), 3233 deletions(-) create mode 100644 packages/agents/src/apply.ts delete mode 100644 packages/agents/src/diverge.ts create mode 100644 packages/agents/src/emit.ts create mode 100644 packages/agents/src/execute.ts create mode 100644 packages/agents/src/pressure.ts create mode 100644 packages/agents/src/scheduler.ts create mode 100644 packages/agents/src/state.ts create mode 100644 packages/agents/test/agent-transitions.prop.test.ts create mode 100644 packages/agents/test/invariants/scenarios/chain-cohort-recovery.scenario.test.ts create mode 100644 packages/agents/test/invariants/scenarios/tick-active-count.scenario.test.ts create mode 100644 packages/agents/test/scheduler.test.ts create mode 100644 packages/agents/test/trace-vocabulary.test.ts diff --git a/packages/abilities/web/src/tools/fetch-page.ts b/packages/abilities/web/src/tools/fetch-page.ts index b0edcd71..bb98dd15 100644 --- a/packages/abilities/web/src/tools/fetch-page.ts +++ b/packages/abilities/web/src/tools/fetch-page.ts @@ -64,7 +64,7 @@ export class FetchPageTool extends Tool<{ url: string; query?: string }> { this._tokenBudget = opts?.tokenBudget ?? 2048; } - /** Inject reranker for chunk scoring. Call from Source.bind(). */ + /** Inject reranker for chunk scoring. Called by the source at construction. */ setReranker(reranker: Reranker): void { this._reranker = reranker; } diff --git a/packages/abilities/web/src/tools/keyless-search.ts b/packages/abilities/web/src/tools/keyless-search.ts index 17ad8f84..1dfeb3f8 100644 --- a/packages/abilities/web/src/tools/keyless-search.ts +++ b/packages/abilities/web/src/tools/keyless-search.ts @@ -355,8 +355,8 @@ function dedupResults(results: SearchResult[]): SearchResult[] { * ```ts * yield* initAgents(ctx); * const provider = yield* createKeylessSearchProvider(); - * const web = new WebSource(provider); - * yield* web.bind({ reranker }); + * const reranker = yield* RerankerCtx.expect(); + * const web = new WebSource(provider, { reranker }); * ``` * * @category Rig diff --git a/packages/agents/README.md b/packages/agents/README.md index b24f0b8d..1a56ebd3 100644 --- a/packages/agents/README.md +++ b/packages/agents/README.md @@ -58,7 +58,7 @@ main(function* () { ); yield* initAgents(ctx); - // Ctx, Store, Events now set — useAgent(), agentPool(), diverge() + // Ctx, Store, Events now set — useAgent() and agentPool() // find them automatically. Session + context disposed on scope exit. }); ``` @@ -169,23 +169,6 @@ The framework provides hallucination detection at two levels. Enable `trace: true` on agent pools to capture entropy and surprisal on every `agent:produce` event. -**Multi-branch semantic comparison.** `diverge()` forks N branches from a shared frontier, generates independently, and returns all outputs with their perplexity scores: - -```typescript -const result = yield* diverge({ - parent: root, // shared frontier - attempts: 3, // fork 3 branches - params: { temperature: 0.7 }, -}); -// result.best — lowest-perplexity branch, still alive -// result.attempts — all branches with output, ppl, token count -// Losers already pruned. Winner's branch is the caller's responsibility. -``` - -The harness decides how to compare. `diverge()` returns all outputs with their perplexity scores — the harness can apply any equivalence measure: bigram overlap, embedding similarity, or model-based evaluation. Where branches agree, the model is confident; where they diverge, hallucination risk is high. - -This directly operationalizes the semantic entropy work from Farquhar et al. ([Nature, 2024](https://www.nature.com/articles/s41586-024-07421-0)) — but as a runtime primitive, not a post-hoc metric. The key constraint: divergence from a common computational ancestor is signal. Divergence from independently-constructed contexts is sampling variance. This measurement is only meaningful because agents share a frontier. - ## Session Accumulation `Session.commitTurn(query, answer)` extends the trunk with a new query–answer pair. Future queries fork from this trunk — its KV cache already contains everything the prior turn established. @@ -274,7 +257,6 @@ import { useAgent, agent, // single-agent helpers agentPool, // multi-agent pool with a swappable orchestrator useAgentPool, // lower-level Effection resource (advanced) - diverge, // multi-branch perplexity selection parallel, chain, fanout, dag, reduce, // orchestrators / combinators withSpine, // scoped spine branch with guaranteed teardown Tool, Source, diff --git a/packages/agents/src/Agent.ts b/packages/agents/src/Agent.ts index 1e54bf88..d5b18774 100644 --- a/packages/agents/src/Agent.ts +++ b/packages/agents/src/Agent.ts @@ -1,7 +1,8 @@ import type { Branch, SessionContext, ParseChatOutputResult } from '@lloyal-labs/sdk'; import type { GrammarTrigger } from '@lloyal-labs/sdk'; import { createSignal, type Signal } from 'effection'; -import type { TraceToken, AgentExitReason } from './types'; +import type { TraceToken, AgentExitReason, AgentTaskSpec } from './types'; +import type { AgentTurnRecord } from './replay'; // ── Status ────────────────────────────────────────────────── @@ -10,10 +11,14 @@ import type { TraceToken, AgentExitReason } from './types'; * * - `idle`: created but not yet generating, OR finished but branch still * alive (extraction window for recovery) - * - `active`: generating tokens (between PRODUCE start and stop token) - * - `awaiting_tool`: tool call parsed, waiting for result in SETTLE + * - `active`: generating tokens (between a sample and its stop token) + * - `awaiting_tool`: a tool call, nudge or recovery turn is pending admission * - `disposed`: branch pruned, agent no longer usable * + * `extracting` is a separate, one-way latch orthogonal to status: an agent + * producing its forced recovery report is `awaiting_tool` while the turn is + * pending and `active` while it decodes. + * * @category Agents */ export type AgentStatus = 'idle' | 'active' | 'awaiting_tool' | 'disposed'; @@ -26,7 +31,7 @@ export type AgentStatus = 'idle' | 'active' | 'awaiting_tool' | 'disposed'; export type ResultSource = | 'voluntary_return' // agent voluntarily returned via the terminal tool | 'free_text' // agent emitted prose without tool call - | 'recovery' // extracted post-idle via the recovery path (recoverInline) + | 'recovery' // extracted by a forced recovery turn after a drop | 'nudge' // agent returned after nudge injection | 'tool_error'; // tool threw, error captured as findings @@ -176,6 +181,29 @@ export class Agent { /** The agent that called the tool which spawned this agent's pool (null for top-level) */ readonly parent: Agent | null = null; + // ── Pool bookkeeping (one fact, one place — on the agent it is about) ── + + /** + * The terminal `agent:failed` reason, once one has been announced. An agent + * with this set is DISCARDED: never force-recovered by the close sweep, + * never handed a late tool completion. `null` while it is live or finished + * on its own terms. + */ + failed: string | null = null; + /** The branch holds cells nothing will read again; the next prune pass + * reclaims it (a leaf only — a branch with live children keeps its prefix). */ + pruneRequested = false; + /** The spec this agent was born from — what a heal reproduces. */ + spec: AgentTaskSpec | null = null; + /** Every KV delta since spawn, in order — the heal's replay material. */ + readonly records: AgentTurnRecord[] = []; + /** How many times this lineage has been healed (a replacement inherits +1). */ + healAttempt = 0; + /** rc==1 deferrals of this agent's pending item; cleared when one lands. */ + deferAttempts = 0; + /** Serial recovery: the report is uncapped and one runs at a time. */ + recoverySerial = false; + // ── Constructor ───────────────────────────────────────── constructor(opts: { @@ -216,6 +244,7 @@ export class Agent { /** * Transition to a new status. Enforces valid transitions: * - idle → active (first produce) + * - idle → awaiting_tool (the close sweep's forced recovery turn) * - active → awaiting_tool (tool call parsed) * - active → idle (stop token, report, or kill) * - awaiting_tool → active (tool result settled) @@ -227,7 +256,7 @@ export class Agent { transition(to: AgentStatus): void { const from = this._status; const valid = - (from === 'idle' && (to === 'active' || to === 'disposed')) || + (from === 'idle' && (to === 'active' || to === 'awaiting_tool' || to === 'disposed')) || (from === 'active' && (to === 'awaiting_tool' || to === 'idle')) || (from === 'awaiting_tool' && (to === 'active' || to === 'idle')); if (!valid) { @@ -265,11 +294,13 @@ export class Agent { /** Tokens produced in the CURRENT turn — what the voluntary report cap checks. */ get turnTokens(): number { return this._tokenCount - this._turnTokenBase; } /** Mark the agent as producing its recovery report (idempotent, one-way). Records - * the per-report budget `b` and snapshots the token base for the cap. */ - markExtracting(budget: number): void { + * the per-report budget `b` (Infinity = uncapped) and snapshots the token base + * for the cap. */ + markExtracting(budget: number, serial = false): void { this._extracting = true; this._recoveryBudget = budget; this._recoveryTokenBase = this._tokenCount; + this.recoverySerial = serial; } /** Accumulate generated token text into the current turn */ @@ -419,26 +450,6 @@ export class Agent { return !this.fmt.grammarLazy || !this.fmt.grammar; } - // ── Async iteration ───────────────────────────────────── - - /** - * Async iterator — delegates to Branch, accumulates state - * - * Each yielded token is already committed to KV (Branch's commit-before-yield - * semantics). Agent accumulates rawOutput and tokenCount as tokens flow. - * - * Available for Layer 1 users who create Agents directly and want to - * stream with state accumulation. The pool's tick loop does NOT use this - * iterator — it calls `produceSync()`/`store.commit()` directly for - * batched multi-agent generation. - */ - async *[Symbol.asyncIterator](): AsyncIterableIterator<{ token: number; text: string }> { - for await (const produced of this.branch) { - this.accumulateToken(produced.text); - yield produced; - } - } - // ── Lifecycle ─────────────────────────────────────────── /** Mark agent as disposed — called by pool when branch is pruned */ diff --git a/packages/agents/src/AgentPolicy.ts b/packages/agents/src/AgentPolicy.ts index 25ff20f0..ff692e07 100644 --- a/packages/agents/src/AgentPolicy.ts +++ b/packages/agents/src/AgentPolicy.ts @@ -1,12 +1,12 @@ import type { Agent, ToolHistoryEntry } from './Agent'; import type { ToolRetryError } from './Tool'; -import { ContextPressure } from './agent-pool'; +import { ContextPressure } from './pressure'; import type { ParsedToolCall } from '@lloyal-labs/sdk'; import type { PressureThresholds } from './types'; import { renderTemplate } from './prompt'; // Recovery-phase KV accounting constants. These size the hardLimit reserve -// allocation for recoverInline: the prefill cost of the recovery prompt + +// allocation for a recovery turn: the prefill cost of the recovery prompt + // room for llama.cpp's batch workspace. Used to compute the budget // communicated to the model in its recovery prompt. export const RECOVERY_PREFILL_OVERHEAD = 150; @@ -331,7 +331,7 @@ export interface AgentPolicy { /** * Recovery reap shape. - * `'staggered'` (default) — recover one agent at a time (`recoverInline`), + * `'staggered'` (default) — recover one agent at a time (serial recovery), * pruning each before the next so every report gets the full freed headroom * (uncapped, lossless; the high-effort path). * `'parallel'` — recover killed-without-result agents IN-LOOP: the recovery turn @@ -437,7 +437,7 @@ export interface DefaultAgentPolicyOpts { /** KV context budget (tokens remaining). softLimit = nudge floor, hardLimit = kill floor. * COUPLING (non-obvious): RECOVERY budgets from the `hardLimit` RESERVE, not `softLimit`. * The forced-report budget `b` and the SETTLE admission for an extracting agent draw from - * `remaining − hardLimit` (see {@link AgentPolicy.onRecovery} + agent-pool `handleRecover`), + * `remaining − hardLimit` (see {@link AgentPolicy.onRecovery} + the scheduler's `planRecovery`), * so recovery may decode the soft reserve down to `hardLimit`. `softLimit` is the model * NUDGE floor, reserved for downstream work (synth) — raising it nudges EARLIER but does * NOT shorten recovery reports. (`softLimit` is advisory: it gates the wrap-up nudge + @@ -524,16 +524,11 @@ export class DefaultAgentPolicy implements AgentPolicy { } /** Recovery reap shape. The pool reads this to pick in-loop bin-packed recovery - * (`parallel`) vs the blocking per-agent `recoverInline` (`staggered`). */ + * (`parallel`) vs one serial report at a time (`staggered`). */ get recoveryShape(): 'staggered' | 'parallel' { return this._recoveryShape; } - /** Flip the reap shape at runtime — wind-down sets `'parallel'`. */ - setRecoveryShape(shape: 'staggered' | 'parallel'): void { - this._recoveryShape = shape; - } - /** Explicit per-report token budget for in-loop recovery (undefined = adaptive, * a headroom share across live agents). Rendered into the prompt + enforced by * the pool's token-stop. */ @@ -613,7 +608,7 @@ export class DefaultAgentPolicy implements AgentPolicy { if (!this._nudgedThisTick) { this._nudgedThisTick = true; // Budget the model can emit before `pressure.critical` kills it. - // Overshoot → kill → recoverInline extracts from the hardLimit reserve. + // Overshoot → kill → the recovery turn extracts from the hardLimit reserve. // Expressed in words (not tokens) because tokenizers vary across // models but words are universal. Under-advertised + rounded down // so the model has slack on the ceiling. diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index a93ce1cc..49b6b8f0 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -1,845 +1,59 @@ -import { resource, call, ensure, createSignal, createChannel, spawn, scoped, each, sleep, action, race } from 'effection'; +import { resource, ensure, createSignal, createChannel, spawn, each, sleep, action, race } from 'effection'; import type { Operation, Subscription, Task, Signal } from 'effection'; -import { waitUntilSettled } from './combinators'; -import type { Branch } from '@lloyal-labs/sdk'; -import { CHAT_FORMAT_CONTENT_ONLY, CHAT_FORMAT_GENERIC, GrammarTriggerType, type ParsedToolCall, type SessionContext } from '@lloyal-labs/sdk'; -import type { BranchStore } from '@lloyal-labs/sdk'; -import { Ctx, Store, Trace, TraceParent, CallingAgent, SpineFmt, GrantStoreCtx, WindDown, CancelAgent, Pause, Attachments, Ingress } from './context'; -import { prepareBatch } from './prepare-content'; -import type { FormatConfig } from './Agent'; -import { buildToolResultDelta, buildToolResultDeltaMultimodal, buildTurnDelta, buildUserDelta, decodeErrorOf, deltaCells } from '@lloyal-labs/sdk'; -import type { MultimodalDelta } from '@lloyal-labs/sdk'; -import type { Attachment } from '@lloyal-labs/media'; +import type { SessionContext, BranchStore } from '@lloyal-labs/sdk'; +import { buildTurnDelta } from '@lloyal-labs/sdk'; +import { Ctx, Store, Trace, TraceParent, GrantStoreCtx, WindDown, CancelAgent, Pause, Attachments, Ingress } from './context'; import { useTraceScope } from './trace-scope'; - -import type { TraceWriter } from './trace-writer'; -import type { TraceEvent } from './trace-types'; -import type { AgentPolicy, IdleReason, ToolRetryAction } from './AgentPolicy'; -import { Agent } from './Agent'; -import { replayAgentTurns } from './replay'; -import type { AgentTurnRecord } from './replay'; -import { DefaultAgentPolicy, RECOVERY_PREFILL_OVERHEAD, BATCH_BUFFER } from './AgentPolicy'; +import type { Agent } from './Agent'; +import { DefaultAgentPolicy } from './AgentPolicy'; import type { PolicyConfig } from './AgentPolicy'; -import { Tool, ToolRetryError, takeToolMedia, TOOL_CONTEXT_KEY, TOOL_IMAGE_ERROR_KEY } from './Tool'; -import type { - PressureThresholds, - AgentTaskSpec, - AgentPoolOptions, - AgentPoolResult, - AgentEvent, - ToolContext, -} from './types'; - -// ── Agent state transitions ──────────────────────────────────── -// idle → active (first produce) -// active → awaiting_tool (tool call parsed) -// active → idle (stop token, report, or kill) -// awaiting_tool → active (tool result settled) -// awaiting_tool → idle (settle reject + kill) -// idle → disposed (branch pruned) - -// ── Self-healing ladder knobs (docs/self-healing.md) ───────────────────── -/** rc==1 (no KV slot, branch intact) deferrals per agent before the item - * escalates to the terminal path. */ -const MAX_DEFER_ATTEMPTS = 3; -/** Consecutive fatal rcs (2 or < -1) before the pool stops laddering: a - * backend in a sticky error state (Metal after an OOM) fails every decode, - * and deferring or healing there burns budget for nothing. Reset by any - * successful dispatch. */ -const BACKEND_TRIPWIRE_N = 3; -/** Heals per lineage. A replacement that poisons AGAIN goes terminal — a - * second failure on replayed state is evidence, not bad luck. */ -const MAX_HEAL_ATTEMPTS = 1; - -/** Minimal event sender interface — accepts any Channel close type */ -type EventSender = { send(value: AgentEvent): Operation }; - -type SettledTool = { - agentId: number; - toolName: string; - callId: string; - args: string; - probe?: string; -} & ( - /** The token rail: the result tokenized here and prefills as tokens. */ - | { rail: 'token'; prefillTokens: number[]; media?: never; resultStr?: string } - /** The embedding rail. `llama_batch` is token-XOR-embd, so this cannot join - * a token batch — a separate call, not a separate strategy. The delta stops - * at the string stage because mtmd tokenizes downstream, which is why the - * cost had to be MEASURED. */ - | { - rail: 'media'; - /** The tool-result string the delta was built from — the heal record's - * replay material (docs/self-healing.md). */ - resultStr?: string; - prefillTokens?: never; - media: { delta: MultimodalDelta; cells: number; attachments: readonly Attachment[] }; - } -); - -/** - * What admission spends on this item — the ONE place that answers it. - * - * It used to be re-derived wherever it was needed, and one site forgot: the - * stall-break passed `prefillTokens.length` to `policy.onSettleReject`, which - * for a media item is `[]` and therefore **0**. Not "unknown" — a confident - * zero, from which the policy decided whether an agent was worth keeping. A - * union plus one accessor is what makes that site impossible to write. - */ -function settledCells(item: SettledTool): number { - return item.rail === 'media' ? item.media.cells : item.prefillTokens.length; -} - - -/** - * A fan-out tool's completion, pushed by its off-fiber child onto - * `completedTools` and processed on the loop fiber in DRAIN. Carries - * everything DRAIN needs to run the post-processing that the inline path runs - * inline — that post-processing tokenizes/reads the main `llama_context`, so it - * must stay on the loop fiber, never in the child. - */ -type ToolCompletion = - | { kind: 'result'; agent: Agent; tc: ParsedToolCall; callId: string; dispatchTraceId: number; toolT0: number; result: unknown } - | { kind: 'retry'; agent: Agent; tc: ParsedToolCall; callId: string; dispatchTraceId: number; toolT0: number; retryAttempt: number; err: ToolRetryError } - | { kind: 'error'; agent: Agent; tc: ParsedToolCall; callId: string; dispatchTraceId: number; err: Error }; - -/** Default cap on concurrent fan-out tool children (Effection has no semaphore - * — a FIFO counting gate enforces it). Overridable per pool via - * {@link AgentPoolOptions.maxConcurrentTools}. Inline tools don't count: the - * loop fiber already serializes them. */ -const DEFAULT_MAX_CONCURRENT_TOOLS = 8; - -/** Normalize a thrown value to an `Error`. Tools — especially third-party — - * may throw non-Error values (`throw 'rate limited'`, `throw { code: 500 }`); - * an `err as Error` cast would leave `.message` undefined in the `tool:error` - * trace and the agent's result. */ -function toError(err: unknown): Error { - return err instanceof Error ? err : new Error(String(err)); -} - -/** FIFO counting gate: acquire before a fan-out child's `execute`, release in - * an `ensure`. A halt while queued runs the action cleanup (drops the waiter); - * a halt before acquire returns never released, so callers guard release with - * a `took` flag. */ -interface Permits { acquire(): Operation; release(): void } -function makePermits(n: number): Permits { - let available = n; - const waiters: Array<() => void> = []; - return { - *acquire(): Operation { - if (available > 0) { available--; return; } - yield* action((resolve) => { - const w = () => resolve(); - waiters.push(w); - return () => { const i = waiters.indexOf(w); if (i >= 0) waiters.splice(i, 1); }; - }); - }, - release(): void { - const w = waiters.shift(); - if (w) w(); else available++; - }, - }; -} - -/** - * Immutable KV budget snapshot for one tick of the agent loop - * - * Frozen at phase boundaries (PRODUCE, SETTLE, DISPATCH) so that all - * decisions within a phase are evaluated against the same baseline. - * Without this, items processed earlier in a loop would see different - * pressure than items processed later — making reject/nudge/kill - * decisions order-dependent and nondeterministic. - * - * Created from `SessionContext._storeKvPressure()` which returns - * `{ nCtx, cellsUsed, remaining }` where `remaining = nCtx - cellsUsed`. - * `cellsUsed` tracks unique KV cells per branch — incremented on - * `decode_each` / `decode_scatter`, decremented on release by - * `position - fork_head` (unique cells above the fork point), reset on - * bulk ops like `retainOnly` and `drain`. - * - * Two thresholds partition `remaining` into three zones: - * - * ``` - * ┌──────────────────────────────────────────────────────┐ - * │ nCtx │ - * │ ┌──────────┬───────────────────┬──────────────────┐ │ - * │ │cellsUsed │ headroom > 0 │ softLimit │ │ - * │ │ (in use) │ (new work OK) │ (reserved) │ │ - * │ └──────────┴───────────────────┴──────────────────┘ │ - * │ ◄── remaining ──► │ │ - * │ │ │ - * │ headroom = remaining - softLimit │ - * │ critical = remaining < hardLimit │ - * └──────────────────────────────────────────────────────┘ - * ``` - * - * - **headroom > 0** — room for new work (tool results, generation) - * - **headroom ≤ 0** — over budget. SETTLE rejects tool results, PRODUCE - * hard-cuts non-terminal tool calls. Terminal tools still pass. - * - **critical** — remaining below hardLimit. Agents killed before - * `produceSync()` to prevent llama_decode crashes. - * - * @category Agents - */ -export class ContextPressure { - /** Default softLimit: 1024 tokens reserved for downstream work */ - static readonly DEFAULT_SOFT_LIMIT = 1024; - /** - * Default hardLimit: 512 tokens — matches llama.cpp's default `n_batch`. - * The pool validates at startup that `hardLimit >= nBatch`; the default - * is sized to satisfy the invariant for the default llama.cpp context. - * Recovery fits within the `hardLimit` reserve. - */ - static readonly DEFAULT_HARD_LIMIT = 512; - /** - * Assumed `nBatch` when the native binding doesn't expose it. - * Pool startup validates `pressureThresholds.hardLimit >= this`. - * TODO: once `SessionContext.nBatch` is exposed (lloyal.node - * follow-up), read from ctx.nBatch instead. - */ - static readonly ASSUMED_N_BATCH = 512; - - /** Total KV cache capacity, in CELLS. 0 when no context limit. - * - * Not positions — the two diverge on the embedding rail. Under M-RoPE an - * image occupies far more cells than it advances position (measured on - * Qwen3.5: 564 cells for 32 positions, ~18x), so budgeting from a branch's - * position would under-count an image by that factor. Every number on this - * class is cells, and `cellsUsed` is what the cache actually reports. */ - readonly nCtx: number; - /** KV cells currently in use (monotonic within a pool run). */ - readonly cellsUsed: number; - /** - * KV slots remaining (`nCtx - cellsUsed`). - * Infinity when nCtx ≤ 0 (no context limit). - */ - readonly remaining: number; - /** Remaining KV floor — tokens reserved for downstream work */ - readonly softLimit: number; - /** Crash-prevention floor — agents killed when remaining drops below */ - readonly hardLimit: number; - - constructor(ctx: SessionContext, opts?: PressureThresholds) { - const p = ctx._storeKvPressure(); - this.nCtx = p.nCtx; - this.cellsUsed = p.cellsUsed; - this.remaining = p.nCtx <= 0 ? Infinity : p.remaining; - this.softLimit = opts?.softLimit ?? ContextPressure.DEFAULT_SOFT_LIMIT; - this.hardLimit = opts?.hardLimit ?? ContextPressure.DEFAULT_HARD_LIMIT; - } - - /** - * Tokens available for new work: `remaining - softLimit`. - * Positive means room to accept tool results or continue generating. - * Negative means over budget — SETTLE rejects, PRODUCE hard-cuts. - */ - get headroom(): number { return this.remaining - this.softLimit; } - - /** `remaining < hardLimit` — agent must not call `produceSync()`. */ - get critical(): boolean { return this.remaining < this.hardLimit; } - - /** Can `tokenCount` tokens fit while staying above softLimit? */ - canFit(tokenCount: number): boolean { return tokenCount <= this.headroom; } - - /** - * KV available as 0–100 integer. Single source of truth for the - * percentage shown to agents (`contextAvailablePercent`), recorded - * on tool history (`contextAfterPercent`), and used by - * `policy.shouldExplore()`. - */ - get percentAvailable(): number { - return this.nCtx > 0 - ? Math.max(0, Math.round((this.remaining / this.nCtx) * 100)) - : 100; - } -} - -/** The grammar that forces an agent's recovery output to be a valid call to the - * pool's TERMINAL tool — whatever the harness designated (`report`, `submit`, - * `finish`, …; the framework never assumes a specific tool). Same native path - * every other call uses: `formatChat`'s tool grammar constrains generation, and - * `parseChatOutput` (chat_out / `common_chat_parse`) decodes the resulting Hermes - * tool-call into `{ name, arguments }`. Computed once per pool from the terminal - * tool's schema; `null` when the pool has no terminal tool (then recovery is a - * no-op — there is no structured result to force). - * - * Built with `toolChoice: 'auto'` (root rule = the bare tool-call) and applied - * EAGERLY via `branch.setGrammar` at recovery — that forces a valid terminal call - * from token 0. `'required'` is WRONG here: it prefixes the grammar's root with the - * generation prompt (`"<|im_start|>assistant\n" …`), and since the recovery turn has - * already prefilled that prompt, a required grammar double-emits it and the model - * wanders into raw template tokens. Verified against the real model: required-eager - * → `<|im_start|>assistant……` prose; auto-eager → ``. */ -type TerminalGrammar = string; - -function buildTerminalGrammar(ctx: SessionContext, terminalTool: Tool): TerminalGrammar { +import { ContextPressure } from './pressure'; +import { Emitter } from './emit'; +import { DefaultScheduler } from './scheduler'; +import { Applier } from './apply'; +import { Executor, setupAgent, makePermits, pruneAll, DEFAULT_MAX_CONCURRENT_TOOLS } from './execute'; +import type { Tool } from './Tool'; +import { + type Pending, type TickState, type ToolCompletion, type Ladder, emptyPending, +} from './state'; +import type { PoolContext } from './orchestrators'; +import type { AgentTaskSpec, AgentPoolOptions, AgentPoolResult, AgentEvent, PressureThresholds } from './types'; + +export { ContextPressure } from './pressure'; + +/** The grammar that forces a recovery output to be a valid call to the pool's + * TERMINAL tool. `toolChoice: 'auto'` — the root rule is the bare call; + * `'required'` would re-emit the generation prompt the recovery turn already + * prefilled. `null` when the pool has no terminal tool. */ +function buildTerminalGrammar(ctx: SessionContext, terminalTool: Tool): string { return ctx.formatChatSync( JSON.stringify([{ role: 'system', content: '' }, { role: 'user', content: '' }]), { tools: JSON.stringify([terminalTool.schema]), toolChoice: 'auto', enableThinking: false }, ).grammar; } -// Adaptive per-report budget bounds for in-loop recovery when no explicit -// `policy.reportBudget` is set: `b` = a fair share of current headroom across the -// live agents, clamped to [MIN, MAX]. MIN keeps a forced report from being uselessly -// short under pressure; MAX stops one agent with a huge context from being told to -// write an essay. -const MIN_REPORT_BUDGET = 128; -const MAX_REPORT_BUDGET = 2048; - -/** An unlimited context reads `remaining`/`headroom` as Infinity, which JSON - * cannot carry — serialize it as an explicit null (the trace types declare - * these fields nullable) instead of letting JSONL coerce it silently. */ -function finiteOrNull(x: number): number | null { - return Number.isFinite(x) ? x : null; -} - -/** Prune an agent's branch only when it is a childless leaf. `Branch.pruneSync` - * is RESTRICT-mode (`Branch.ts`: throws when the branch has live children — they - * still need its KV prefix), so a recovered agent that sub-spawned must NOT be - * pruned: skip it and let the children's own teardown reclaim the lineage. - * Harvests the branch's perplexity first ({@link Agent.harvestMetrics}) — the - * metrics die with the branch, and `pool:close` reads the harvest. */ -function safePrune(a: Agent, tw: TraceWriter, parentTraceId: number | null): void { - a.harvestMetrics(); - if (!a.branch.disposed && a.branch.children.length === 0) { - // The KV free is real and observable: record it (position read BEFORE the - // prune — the branch is disposed after) so the trace shows frees, not - // only growth (#104). - tw.write({ traceId: tw.nextId(), parentTraceId, ts: performance.now(), - type: 'branch:prune', branchHandle: a.branch.handle, position: a.branch.position }); - a.branch.pruneSync(); - } -} - -/** Extract the terminal-tool result string from a parsed (possibly TRUNCATED) - * tool call. A clean call's `arguments` is valid JSON → `.result`. The token-stop - * backstop cuts mid-call, so `parseChatOutput` yields unclosed JSON like - * `{"result":"…partial` → `JSON.parse` throws; recover the `result` body from the - * partial and JSON-unescape it (dropping a dangling backslash) rather than leaking - * the `{"result":"` wrapper into the finding. Falls back to the raw arguments when - * there is no `result` key (a non-`{result}` terminal tool — matches - * `DefaultAgentPolicy._handleTerminalTool`). */ -function extractTerminalResult(args: string): string { - try { - const r = JSON.parse(args).result; - if (typeof r === 'string') return r; - } catch { /* truncated or non-JSON — salvage the partial below */ } - const m = args.match(/"result"\s*:\s*"((?:[^"\\]|\\.)*)/); - if (m) { - try { return JSON.parse(`"${m[1].replace(/\\+$/, '')}"`); } catch { /* fall through to raw */ } - } - return args; -} - -/** The recovery turn (`onRecovery`'s system+user prompt) as a branch-prefill - * delta — a thin alias over the shared `buildUserDelta` builder (system + user - * turn, no thinking). Used by `recoverInline` (staggered) and `handleRecover` - * (the in-loop parallel path). */ -function recoveryPromptTokens( - ctx: SessionContext, - recovery: { prompt: { system: string; user: string } }, -): number[] { - return buildUserDelta(ctx, recovery.prompt.user, { - system: recovery.prompt.system, - enableThinking: false, - }); -} - -/** Parse a finished recovery branch's output, set the agent's result (source - * `'recovery'`), emit `agent:recovered`, and write the recovery traces. - * Returns true iff a result was extracted. Shared by both reap shapes. */ -function* finishRecovery( - agent: Agent, - output: string, - producedTokens: number, - events: EventSender, - tw: TraceWriter, - parentTraceId: number, - ctx: SessionContext, - terminalToolName: string | undefined, -): Operation { - tw.write({ - traceId: tw.nextId(), parentTraceId, ts: performance.now(), - type: 'pool:recoveryProduce', agentId: agent.id, - tokenCount: producedTokens, outputLength: output.length, - }); - // The forced recovery output is a TERMINAL-tool call in the model's native - // Hermes syntax — decode it with the same parser the agent uses for every turn, - // and extract the result with the same convention as a voluntary terminal return - // (the `result` arg, falling back to the raw arguments), never assuming a specific - // tool's shape. See AgentPolicy `_handleTerminalTool`. - const parsed = ctx.parseChatOutput(output, agent.fmt.format, { - reasoningFormat: agent.fmt.reasoningFormat, - generationPrompt: agent.fmt.generationPrompt, - parser: agent.fmt.parser, - }); - // When a terminal tool is designated, the report MUST be that tool's call — never fall - // back to a non-terminal call (that would set the result from the wrong args). With no - // terminal tool, take whatever the model produced (matches `_handleTerminalTool`). - const call = terminalToolName - ? parsed.toolCalls.find(c => c.name === terminalToolName) - : parsed.toolCalls[0]; - if (call) { - const result = extractTerminalResult(call.arguments); - if (result) { - agent.setResult(stripDanglingToolCall(result), 'recovery'); - yield* events.send({ type: 'agent:recovered', agentId: agent.id, result: agent.result! }); - tw.write({ - traceId: tw.nextId(), parentTraceId, ts: performance.now(), - type: 'pool:recoveryReturn', agentId: agent.id, - resultLength: result.length, - }); - return true; - } - } - const reason = call ? 'empty_terminal_result' : 'no_terminal_call'; - tw.write({ - traceId: tw.nextId(), parentTraceId, ts: performance.now(), - type: 'pool:recoveryFailed', agentId: agent.id, - reason, - outputExcerpt: output.slice(0, 200), - }); - // Terminal UI signal: the agent stopped at the drop (`agent:done`) and is shown - // "writing report"; without this it never leaves that state (eternal spinner). - // `agent:failed` is the failure twin of `agent:recovered` — the consumer marks - // the task failed instead of hanging. See trace `pool:recoveryFailed`. - yield* events.send({ type: 'agent:failed', agentId: agent.id, reason }); - return false; -} - -/** Finish an in-loop (`parallel`) recovery report — both the normal stop token and - * the token-stop backstop land here: extract + set the result, idle the agent, - * child-safe-prune the dead branch (freeing its KV for siblings), and tick the - * UI. `producedTokens` = the report's OWN tokens (`recoveryTokens`), since the - * cumulative `tokenCount` includes the agent's whole research run. */ -function* completeExtraction( - a: Agent, events: EventSender, tw: TraceWriter, parentTraceId: number, - ctx: SessionContext, pressureOpts: PressureThresholds, terminalToolName: string | undefined, -): Operation { - yield* finishRecovery(a, a.rawOutput, a.recoveryTokens, events, tw, parentTraceId, ctx, terminalToolName); - a.transition('idle'); - safePrune(a, tw, parentTraceId); - const postPressure = new ContextPressure(ctx, pressureOpts); - yield* events.send({ type: 'agent:tick', cellsUsed: postPressure.cellsUsed, nCtx: postPressure.nCtx }); -} - -/** - * Inline recovery for a single killed agent (trailing stop). - * - * Prefills the recovery prompt into the agent's own branch, forces the eager - * terminal-tool grammar, generates to stop token, extracts the result via - * `parseChatOutput`, and prunes the branch — all before the tick loop continues. - * The freed KV lets remaining agents keep researching. - * - * Returns true if the agent produced a result. - */ -function* recoverInline( - agent: Agent, - policy: AgentPolicy, - ctx: SessionContext, - store: BranchStore, - tw: TraceWriter, - parentTraceId: number, - events: EventSender, - pressureOpts: PressureThresholds, - terminalGrammar: TerminalGrammar | null, - terminalToolName: string | undefined, -): Operation { - // Fresh snapshot — the policy uses this to compute the recovery budget - // (reflected in the rendered prompt via `<%= it.budget %>`). - const recovery = policy.onRecovery?.(agent, new ContextPressure(ctx, pressureOpts)); - if (!recovery || recovery.type === 'skip') { - // Skip = policy judged the agent too thin to force a report. `agent:done` already - // fired at the drop, so emit a terminal event here too — else the agent orphans (no - // report row ever streams, timer never freezes). Nothing to salvage; fail it cleanly. - const reason = 'recovery_skipped'; - tw.write({ traceId: tw.nextId(), parentTraceId, ts: performance.now(), - type: 'pool:recoveryFailed', agentId: agent.id, reason, outputExcerpt: agent.rawOutput.slice(0, 200) }); - yield* events.send({ type: 'agent:failed', agentId: agent.id, reason }); - safePrune(agent, tw, parentTraceId); - return false; - } - - const tokens = recoveryPromptTokens(ctx, recovery); - - // Recovery runs in its own scope — if prefill or decode fails (KV - // exhaustion), the scope tears down cleanly. The recoveryProduce/Return/ - // Failed traces make silent recovery failures observable. - let reported = false; - let output = ''; - let producedTokens = 0; - try { - yield* scoped(function*() { - yield* waitUntilSettled(store.prefill([[agent.branch, tokens]])); - if (terminalGrammar) agent.branch.setGrammar(terminalGrammar); - - tw.write({ - traceId: tw.nextId(), parentTraceId, ts: performance.now(), - type: 'branch:prefill', branchHandle: agent.id, - cells: tokens.length, role: 'recovery', - }); - - // Single-agent produce/commit loop - for (;;) { - const { token, text, isStop } = agent.branch.produceSync(); - if (isStop) break; - output += text; - producedTokens++; - yield* waitUntilSettled(store.commit([[agent.branch, token]])); - yield* events.send({ type: 'agent:produce', agentId: agent.id, text, tokenCount: producedTokens }); - } - - reported = yield* finishRecovery(agent, output, producedTokens, events, tw, parentTraceId, ctx, terminalToolName); - }); - } catch (e) { - // Scope teardown (KV exhaustion during prefill/decode) — finishRecovery - // never ran, so emit the failure trace + the terminal UI signal here. - const reason = `scope_error: ${(e as Error).message ?? 'unknown'}`; - tw.write({ - traceId: tw.nextId(), parentTraceId, ts: performance.now(), - type: 'pool:recoveryFailed', agentId: agent.id, - reason, - outputExcerpt: output.slice(0, 200), - }); - // The agent is already shown "writing report" (agent:done fired at the drop); - // mark it failed so the UI renders a terminal state instead of an eternal spinner. - yield* events.send({ type: 'agent:failed', agentId: agent.id, reason }); - } - - // Always prune after scope exits (success or failure) — child-safe. - safePrune(agent, tw, parentTraceId); - - // Emit tick so TUI updates pressure percentage after prune - const postPressure = new ContextPressure(ctx, pressureOpts); - yield* events.send({ type: 'agent:tick', cellsUsed: postPressure.cellsUsed, nCtx: postPressure.nCtx }); - - return reported; -} - -// ── PRODUCE action handlers ───────────────────────────────────── -// Each handler encapsulates state transitions, events, and trace for one -// policy action outcome. The PRODUCE switch dispatches to these. - -/** - * Strip a trailing UNCLOSED `` fragment from text captured as an - * agent result. When generation is cut mid-tool-call-emission (produce - * budget, pressure, maxTurns), the parser finds no complete call and the - * raw tail — `…\n…` with no closing - * tags — rides into `a.result` verbatim. Any downstream consumer that - * injects results into another agent's prompt (synth findings, delegation - * returns) then carries a literal in-context demonstration of emitting tool - * calls, priming no-tool agents to imitate it (observed: - * trace-2026-06-11T00-02, agent 65539 → synth rabbit hole). - * - * Complete `` blocks are left alone — they are - * either parsed before reaching a capture path or deliberate quoting. - */ -function stripDanglingToolCall(text: string): string { - return text.replace(/(?:(?!<\/tool_call>)[\s\S])*$/, '').trimEnd(); -} - -/** Trace mirror of the bus `agent:done` — the end of the agent's span. Fires - * at the drop or return; recovery events may follow for the same agent. - * Always written BEFORE the bus send: the send suspends on subscriber - * backpressure (which would inflate the span-end ts), and a cancellation - * mid-send must not lose the trace endpoint of an already-recorded drop. */ -function traceAgentDone(tw: TraceWriter, parentTraceId: number | null, agentId: number): void { - tw.write({ traceId: tw.nextId(), parentTraceId, ts: performance.now(), type: 'agent:done', agentId }); -} - -function* handleFreeTextReturn( - a: Agent, content: string, events: EventSender, - tw: TraceWriter, parentTraceId: number | null, -): Operation { - a.setResult(stripDanglingToolCall(content), 'free_text'); - a.transition('idle'); - traceAgentDone(tw, parentTraceId, a.id); - yield* events.send({ type: 'agent:return', agentId: a.id, result: a.result! }); - yield* events.send({ type: 'agent:done', agentId: a.id }); -} - -function* handleIdleDrop( - a: Agent, reason: IdleReason, events: EventSender, - tw: TraceWriter, parentTraceId: number, -): Operation { - a.transition('idle'); - if (reason !== 'free_text_stop') { - a.exitReason = reason === 'max_turns' ? 'maxTurns' : 'pressure_softcut'; - tw.write({ traceId: tw.nextId(), parentTraceId, ts: performance.now(), - type: 'pool:agentDrop', agentId: a.id, - reason: reason === 'max_turns' ? 'maxTurns' : 'pressure_softcut' }); - } - traceAgentDone(tw, parentTraceId, a.id); - yield* events.send({ type: 'agent:done', agentId: a.id }); -} - -function* handleNudge( - a: Agent, message: string, tc: ParsedToolCall | undefined, - ctx: SessionContext, tools: Map, -): Operation { - const callId = tc?.id || `call_${a.toolCallCount}`; - const nudgeResult = { error: message }; - a.incrementTurns(); - a.transition('awaiting_tool'); - const prefillTokens = buildToolResultDelta(ctx, JSON.stringify(nudgeResult), callId, { enableThinking: a.fmt.enableThinking }); - const probe = tools?.get(tc?.name || '')?.probe(nudgeResult) ?? undefined; - a.resetTurn(); - return { rail: 'token', agentId: a.id, prefillTokens, toolName: tc?.name || '', callId, args: tc?.arguments || '', probe }; -} - -function* handleReturn( - a: Agent, result: string, tc: ParsedToolCall, terminalToolName: string, - pruneOnReturn: boolean, events: EventSender, - tw: TraceWriter, parentTraceId: number | null, -): Operation { - a.setResult(stripDanglingToolCall(result), 'voluntary_return'); - a.transition('idle'); - a.incrementToolCalls(); - yield* events.send({ type: 'agent:tool_call', agentId: a.id, tool: terminalToolName, args: tc.arguments }); - traceAgentDone(tw, parentTraceId, a.id); - yield* events.send({ type: 'agent:return', agentId: a.id, result: a.result! }); - yield* events.send({ type: 'agent:done', agentId: a.id }); - if (pruneOnReturn && !a.branch.disposed) { - a.harvestMetrics(); - tw.write({ traceId: tw.nextId(), parentTraceId, ts: performance.now(), - type: 'branch:prune', branchHandle: a.branch.handle, position: a.branch.position }); - a.branch.pruneSync(); - } -} - -/** - * Is the agent already emitting the terminal (report) tool? Then it is producing - * its OWN report — it must never get a recovery turn bolted on, because that - * discards the in-flight report and re-prompts it from scratch (the "report - * resets and restarts" failure). Both kill paths — wind-down and pressure/time — - * guard on this; the agent is left to finish via the normal `isStop`→return path. - */ -function isEmittingTerminal(agent: Agent, terminalToolName: string | undefined): boolean { - return terminalToolName != null && agent.currentTool === terminalToolName; -} - -/** - * Parallel recovery (`recoveryShape: 'parallel'`): turn a killed-without-result - * agent into an in-loop report instead of the blocking `recoverInline`. Mirrors - * `handleNudge`'s shape — build the recovery turn-delta, park the agent - * `awaiting_tool`, mark it `extracting`, return a `SettledTool`. SETTLE then - * re-activates it with the native terminal-tool grammar (the grammar-swap); the - * report decodes bin-packed in the tick loop, capped at budget `b` by the prompt - * advisory + the PRODUCE token-stop, which routes the finished/cut report to - * `finishRecovery`. The caller emits `pool:agentDrop` + - * `agent:done` first (same order as the `recoverInline` kill path). - * - * Returns the `SettledTool` to queue for SETTLE (push onto `nudges`), or `null` - * after pruning + idling the agent when the policy declines to recover it. - */ -function* handleRecover( - a: Agent, policy: AgentPolicy, ctx: SessionContext, - pressureOpts: PressureThresholds, aliveCount: number, - events: EventSender, tw: TraceWriter, parentTraceId: number, -): Operation { - // Per-report budget `b`: the prompt advisory (onRecovery's budget arg) and the - // token-stop backstop share it. Size it so the WHOLE cohort's prefill+decode fits the - // RECOVERY RESERVE in ONE batched tick — then nothing defers and no findings are lost. - // Each of the `aliveCount` agents consumes ~its turn prompt (≈RECOVERY_PREFILL_OVERHEAD) - // + up to `b` report cells, so aliveCount·(OVERHEAD + b) ≤ (remaining − hardLimit) − BATCH_BUFFER - // ⟹ b ≤ (remaining − hardLimit − BATCH_BUFFER)/aliveCount − OVERHEAD. Sizing against - // `remaining − hardLimit` (the documented recovery reserve — what the nudge advisory, - // onSettleReject, and staggered recoverInline all use), NOT `remaining − softLimit`: - // softLimit is just the model nudge floor, so recovery is allowed to decode the soft - // reserve down to hardLimit (the SETTLE admission grants the same band). Dividing by the - // alive count (not just this tick's cohort) reserves room for siblings that could still - // need recovery. An explicit `policy.reportBudget` is CLAMPED to that ceiling so it can - // never exceed what fits. Computed here so the prompt's word advisory matches the token-stop. - const pressure = new ContextPressure(ctx, pressureOpts); - const fits = Math.floor((pressure.remaining - pressure.hardLimit - BATCH_BUFFER) / Math.max(1, aliveCount)) - RECOVERY_PREFILL_OVERHEAD; - const b = policy.reportBudget != null - // Explicit cap: honor the consumer's choice, clamped DOWN so it never exceeds - // what fits (when there's positive room) — the MIN floor does NOT raise it. - ? (fits > 0 ? Math.min(policy.reportBudget, fits) : policy.reportBudget) - // Adaptive: a fair share of headroom across the live agents, clamped to [MIN, MAX]. - : Math.min(MAX_REPORT_BUDGET, Math.max(MIN_REPORT_BUDGET, fits)); - const recovery = policy.onRecovery?.(a, pressure, b); - if (!recovery || recovery.type === 'skip') { - // Recovery skipped — the policy judged the agent too thin to force a report - // (e.g. `DefaultAgentPolicy` skips below its minTokens/minToolCalls floor). But - // `agent:done` ALREADY fired at the drop, so we MUST still emit a terminal event - // here — otherwise the consumer orphans the agent (eternal "recovering" state, no - // report row, timer never freezes). There's nothing to salvage; fail it cleanly. - const reason = 'recovery_skipped'; - tw.write({ traceId: tw.nextId(), parentTraceId, ts: performance.now(), - type: 'pool:recoveryFailed', agentId: a.id, reason, outputExcerpt: a.rawOutput.slice(0, 200) }); - yield* events.send({ type: 'agent:failed', agentId: a.id, reason }); - safePrune(a, tw, parentTraceId); - a.transition('idle'); - return null; - } - const prefillTokens = recoveryPromptTokens(ctx, recovery); - a.incrementTurns(); - if (a.status === 'active') a.transition('awaiting_tool'); - a.markExtracting(b); - a.resetTurn(); - // Synthetic but identifiable settle identifiers. A recovery turn isn't a real tool - // call, but blank toolName/callId would emit a blank `tool:settle_order` entry and a - // blank ToolHistoryEntry; label them so the trace + history are self-describing - // (callId is unique per agent → keeps any callId-keyed replay oracle deterministic). - return { rail: 'token', agentId: a.id, prefillTokens, toolName: 'recovery', callId: `recovery:${a.id}`, args: '' }; -} - -/** - * Fork an agent from a parent branch with its own system prompt and task. - * - * Generator — uses sync native calls so Effection sees everything. - * On scope exit (error, cancellation), `ensure()` prunes the branch - * automatically — the orphaned-branch leak is structurally impossible. - */ -function* setupAgent( - parent: Branch, - task: AgentTaskSpec, - ctx: SessionContext, - enableThinking: boolean, - clock?: () => number, -): Operation<{ agent: Agent; suffixTokens: number[]; formattedPrompt: string }> { - // Probe shared-mode. When set, the spine already has the [system + tools] - // chat header prefilled and we MUST NOT re-emit them in the agent's - // suffix — the bytes are already in attention via fork prefix-share. The - // new agent inherits parser/grammar/format/triggers from sharedFmt so - // tool dispatch keeps working. - let sharedFmt: FormatConfig | null = null; - try { sharedFmt = (yield* SpineFmt.get()) ?? null; } catch { /* not in shared mode */ } - - // Compose the messages to format into the suffix. In shared mode with - // an empty per-spec systemPrompt, drop the system message — the role - // lives at the spine, the agent only contributes a user turn. With a - // non-empty per-spec systemPrompt, include it: the agent's KV will - // contain TWO system messages in lineage, which Qwen3 handles (recovery - // ships on the same multi-system pattern). - const messages = sharedFmt && task.systemPrompt === '' - ? [{ role: 'user', content: task.content }] - : [ - { role: 'system', content: task.systemPrompt }, - { role: 'user', content: task.content }, - ]; - - const fmtOpts: Record = { enableThinking }; - // Tools belong at the spine in shared mode; emitting them again here - // would re-prefill the same schema bytes for nothing. - if (task.tools && !sharedFmt) fmtOpts.tools = task.tools; - const fmt = ctx.formatChatSync(JSON.stringify(messages), fmtOpts); - // Tool-support guard runs only on the non-shared path. Shared mode's - // spine already passed the equivalent check at withSpine setup. - if (task.tools && !sharedFmt - && (fmt.format === CHAT_FORMAT_CONTENT_ONLY || fmt.format === CHAT_FORMAT_GENERIC)) { - // Error before fork — no branch to clean up - throw new Error('Model does not support tool calling. Please use a model with native tool support (e.g. Qwen3, Llama 3.x, Mistral).'); - } - const branch = parent.forkSync(); - const sep = ctx.getTurnSeparator(); - const suffixTokens = [...sep, ...ctx.tokenizeSync(fmt.prompt, false)]; - if (task.seed != null) branch.reseedSampler(task.seed); - - // Read calling agent from Effection context (set during outer pool's DISPATCH) - let callingAgent: Agent | null = null; - try { const a = yield* CallingAgent.get(); if (a) callingAgent = a; } catch { /* top-level — no caller */ } - - // The spawn's ability membership is now a non-enforcing label: - // the authGuard gates tools by `Tool.protected` + session grants at the - // pool level, not by ability-scoped allow-lists. The label is carried for - // trace attribution (`tool:authReject`) and harness UI only. - const assignedAbility: string | null = task.assignedAbility ?? null; - - // In shared mode the new agent's parser/grammar/format/triggers come - // from the spine's pre-computed fmt — those fields know about the tool - // set that's in attention via the inherited prefix. In non-shared - // mode, fresh fmt drives those fields (existing behavior). - const fmtConfig: FormatConfig = sharedFmt - ? { - format: sharedFmt.format, - reasoningFormat: sharedFmt.reasoningFormat, - generationPrompt: sharedFmt.generationPrompt, - parser: sharedFmt.parser, - grammar: sharedFmt.grammar, - grammarLazy: sharedFmt.grammarLazy, - grammarTriggers: sharedFmt.grammarTriggers, - enableThinking, - } - : { - format: fmt.format, - reasoningFormat: fmt.reasoningFormat, - generationPrompt: fmt.generationPrompt, - parser: fmt.parser, - grammar: fmt.grammar, - grammarLazy: fmt.grammarLazy, - grammarTriggers: fmt.grammarTriggers, - enableThinking, - }; - - const agent = new Agent({ - id: branch.handle, - parentId: parent.handle, - branch, - parent: callingAgent, - task: task.content, - fmt: fmtConfig, - assignedAbility, - clock, - }); - - return { agent, suffixTokens, formattedPrompt: fmt.prompt }; -} - /** - * Concurrent agent generation loop as an Effection resource + * Concurrent agent generation loop as an Effection resource. * - * Runs N agents in parallel using a phased tick loop over shared - * {@link BranchStore} infrastructure. Each agent forks from a parent - * branch, generates tokens, invokes tools, and reports findings. - * - * **Tick loop (per tick):** SPAWN+EXTEND (drain queued spawns/extends + - * pending cancels) → PRODUCE (sample all active agents via `produceSync()`, - * no async gap) → COMMIT (single `store.commit()` for all produced tokens) → - * DRAIN (post-process completed fan-out tool results) → SETTLE (drain settled - * tool results, batch prefill, reset grammars) → DISPATCH (execute collected - * tool calls). + * The pool is a scheduler over one shared KV cache. Each tick: the loop + * OBSERVES (reclaims pruned branches, holds while paused, drains fan-out + * completions, samples pressure once), the {@link DefaultScheduler} decides + * what runs from that one value, the {@link Applier} enacts the decisions, + * the {@link Executor} runs them against the store in a fixed order — + * admitted prefills, tool dispatch, spawns, sampling, ONE batched commit — + * and the applier interprets what came back. Every trace record and channel + * event is a projection of one of those steps ({@link Emitter}). * * **Dispatch is per-agent serial, inter-agent concurrent.** Each agent has at - * most one tool in flight — PRODUCE emits one call, then parks the agent - * `awaiting_tool` until its result settles (the barrier that yields the - * decision boundary). Inline tools run on this loop fiber, so `llama_context` - * access is exclusive by single-fiber discipline. A `Tool.fanout` tool runs - * OFF the loop fiber (bounded by a permit gate), issues no main-context op, - * and has its result tokenized/prefilled later in DRAIN + SETTLE on the loop - * fiber — so the store is only ever touched from the tick loop, never - * concurrently. + * most one tool in flight — it parks `awaiting_tool` until the result is + * admitted (the barrier that yields the decision boundary). Inline tools run + * on this fiber; a `Tool.fanout` tool runs on a child, and its result is + * tokenized and admitted here, so the store is only ever touched from this + * fiber. * * **Resource semantics:** `provide()` suspends after all agents complete, - * keeping branches alive so the caller can fork from them (e.g. for - * verification). Branches are pruned when the scope exits — each branch's - * `ensure()` from `setupAgent` handles cleanup automatically. - * - * For automatic branch cleanup on return, use {@link runAgents} instead. - * - * @param opts - Pool configuration: tasks, tools, sampling params, max turns - * @returns Agent pool result with per-agent findings and aggregate statistics - * - * @example Spine with agent pool - * ```typescript - * const pool = yield* withSpine( - * { systemPrompt: RESEARCH_PROMPT, tools: toolsJson }, - * function*(spine) { - * return yield* useAgentPool({ - * tasks: questions.map(q => ({ - * systemPrompt: RESEARCH_PROMPT, - * content: q, - * tools: toolsJson, - * parent: spine, - * })), - * tools: toolMap, - * maxTurns: 6, - * }); - * }, - * ); - * ``` + * keeping branches alive so the caller can fork from them. Branches are + * pruned when the scope exits. * * @category Agents */ @@ -849,95 +63,43 @@ export function useAgentPool(opts: AgentPoolOptions): Operation(); - // Bridge for onProgress callbacks — Signal is correct here (external callback). - // A spawned forwarder drains the bridge into the poolChannel with proper scope context. - const progressBridge = createSignal(); + // Bridge for onProgress callbacks — an external, non-Effection callback. + const progress = createSignal(); yield* spawn(function*() { - for (const ev of yield* each(progressBridge)) { + for (const ev of yield* each(progress)) { yield* poolChannel.send(ev); yield* each.next(); } }); const tw = yield* Trace.expect(); - // The run's image sink — a tool result's pictures are recorded here for - // the same reason the other two ingresses record theirs: the trace keeps - // the marker, this keeps what it stood for. const attachments = yield* Attachments.expect(); const ingress = yield* Ingress.expect(); - // ── Dispatch attribution ──────────────────────────────────── - // dispatch() sets a per-dispatch tee as the Trace context for the tool's - // execution, stamping the dispatching agent + call INTO the event data: - // `agentId`, `callId`, and a real `parentTraceId` replacing the - // abilities' hardcoded null. Attribution lives in the record itself, so - // every sink reads the same fields — the file, and the dev pane via the - // writer-boundary mirror (rig's `useTraceWriter`). That mirror is where - // the bus tee moved: ONE mirror at the boundary every write crosses, - // instead of per-layer mirrors with per-layer allowlists (session-level - // writes like the trunk's `warmDelta` never reached the old pool tee). - // Only-if-absent semantics keep a nested pool's (DelegateTool) inner - // attribution intact: its tee stamps first, this one defers. - const toolTee = (agentId: number, callId: string, dispatchTraceId: number): TraceWriter => ({ - nextId: () => tw.nextId(), - flush: () => tw.flush(), - write: (event: TraceEvent) => tw.write({ - ...event, - agentId: event.agentId ?? agentId, - callId: event.callId ?? callId, - parentTraceId: event.parentTraceId ?? dispatchTraceId, - }), - }); const { spine, orchestrate, toolsJson, tools, maxTurns = 100, terminalToolName, trace = false, pruneOnReturn = false, enableThinking = true, eagerGrammar } = opts; - // Tool index map for trace — position in toolkit array const toolIndexMap = new Map([...tools.keys()].map((name, i) => [name, i])); - const toolkitSize = tools.size; - const poolT0 = performance.now(); let poolParentTraceId: number | null = null; try { const p = yield* TraceParent.get(); if (p != null) poolParentTraceId = p; } catch { /* top level */ } - // Optional graceful wind-down signal: the consumer `.send()`s it (e.g. a - // "Wrap up" command) to drain the pool to a fast best-effort answer — stop - // spawning, reap active agents, let in-flight tools settle, then fold. Absent - // ⇒ no wind-down (today's behaviour). See the WindDown context. + // The three consumer signals are optional capabilities: absent ⇒ no + // wind-down / cancel / pause. let windDownSignal: Signal | null = null; - try { windDownSignal = (yield* WindDown.get()) ?? null; } catch { /* no wind-down provided */ } - // Optional per-agent cancel signal: the consumer `.send({agentId})`s it (e.g. a - // per-card ×) to discard ONE live agent — halt its in-flight tool, emit a terminal - // agent:failed (user_cancel), prune its branch to reclaim KV. Absent ⇒ no cancel. + try { windDownSignal = (yield* WindDown.get()) ?? null; } catch { /* none */ } let cancelSignal: Signal<{ agentId: number }, void> | null = null; - try { cancelSignal = (yield* CancelAgent.get()) ?? null; } catch { /* no cancel provided */ } - // Optional pause signal: while true the tick loop HOLDS at the tick - // boundary. See the Pause context. Absent ⇒ no pause capability. + try { cancelSignal = (yield* CancelAgent.get()) ?? null; } catch { /* none */ } let pauseSignal: Signal | null = null; - try { pauseSignal = (yield* Pause.get()) ?? null; } catch { /* no pause provided */ } + try { pauseSignal = (yield* Pause.get()) ?? null; } catch { /* none */ } const poolScopeId = yield* useTraceScope(tw, poolParentTraceId, 'pool', { maxTurns, terminalToolName }); + const emit = new Emitter(tw, poolChannel, poolScopeId); - // Whether the pool's tool registry contains tools besides the terminal tool. - // When false, agents are allowed to call the terminal tool as their first - // action (e.g. reporter sub-agents that only have `report()`). When true, - // the first tool call must be a non-terminal tool to prevent agents from - // immediately reporting without doing any work. - // - // IMPORTANT: this checks the pool's `tools` registry, not individual task - // schemas (`task.tools`). A reporter pool must pass only the terminal tool - // in its registry — passing the full tool map makes this flag true and - // traps reporters in an infinite rejection loop. + // Whether the registry holds tools besides the terminal one: when not, an + // agent may report as its first action (a reporter sub-agent). const hasNonTerminalTools = terminalToolName ? [...tools.keys()].some(k => k !== terminalToolName) : tools.size > 0; - - // The eager terminal-tool grammar that forces a recovered agent to emit a - // schema-valid terminal call (whatever the harness designated as terminal). - // Computed once from the terminal tool's schema; `null` when there is no terminal - // tool — recovery still runs, but with no grammar to force a schema-valid call the - // agent decodes unconstrained and `finishRecovery` extracts via `parseChatOutput` - // (or emits `agent:failed` when no call is parseable). It does NOT no-op. const terminalTool = terminalToolName ? tools.get(terminalToolName) : undefined; const terminalGrammar = terminalTool ? buildTerminalGrammar(ctx, terminalTool) : null; const policy = opts.policy ?? new DefaultAgentPolicy(); - // ── Pause state: two values and a pure function ────────────────── - // `paused` is fed by the watcher below; `pausedTotal` accumulates inside - // the hold. The run clock derives from them — policy time budgets and - // agent.startedAt stamps measure RUN time, never a pause. Retry parks - // and trace `ts` stay on the wall clock (external-world time). + + // The run clock: wall time minus paused spans. Policy budgets and + // `agent.startedAt` read it; trace `ts` and retry parks stay on the wall. let paused = false; let pausedTotal = 0; const runNow = (): number => performance.now() - pausedTotal; @@ -945,265 +107,98 @@ export function useAgentPool(opts: AgentPoolOptions): Operation= nBatch (${nBatch}). ` + `Recovery reserves hardLimit cells for its own decode; if smaller than nBatch, the next batch ` + - `allocation will OOM. Increase policy.budget.context.hardLimit to at least ${nBatch}.` + `allocation will OOM. Increase policy.budget.context.hardLimit to at least ${nBatch}.`, ); } - // authGuard inputs, resolved once per pool: - // • protectedTools — names this pool's registry flags `Tool.protected`. - // • grants — protected names the session is authorized to call, read - // from GrantStoreCtx. Absent store = fail-closed (no grants). - // When nothing is protected (the common case) the authGuard never fires. - const protectedTools = new Set( - [...tools].filter(([, t]) => t.protected).map(([name]) => name), - ); + // authGuard inputs, resolved once: protected names and the session's grants. + const protectedTools = new Set([...tools].filter(([, t]) => t.protected).map(([name]) => name)); let grants: ReadonlySet = new Set(); if (protectedTools.size > 0) { try { const grantStore = yield* GrantStoreCtx.expect(); grants = new Set(yield* grantStore.granted()); - } catch { /* no grant store on context — fail-closed (no grants) */ } + } catch { /* no grant store — fail-closed */ } } - const policyConfig: PolicyConfig = { - maxTurns, terminalToolName, hasNonTerminalTools, protectedTools, grants, - }; + const config: PolicyConfig = { maxTurns, terminalToolName, hasNonTerminalTools, protectedTools, grants }; - // ── Orchestrator-driven setup ──────────────────────────── - // Agents are spawned lazily via `ctx.spawn` from the orchestrator. - // The tick loop iterates over whatever agents are currently active. - // decode_each batches across all active agents regardless of spawn order. + // ── The pool's state ───────────────────────────────────────── const agents: Agent[] = []; - const agentById = new Map(); - - // Pending spawns — populated by PoolContext.spawn, drained by the tick - // loop's SPAWN phase. Queuing here lets multiple orchestrator-issued - // spawns batch into ONE store.prefill call (continuous tree batching), - // and guarantees that all native store operations are issued from the - // tick loop's single fiber — never concurrently with other store work. - interface PendingSpawn { - agent: Agent; - suffixTokens: number[]; - formattedPrompt: string; - task: AgentTaskSpec; - } - const pendingSpawns: PendingSpawn[] = []; - - // Pending extends — populated by PoolContext.extendSpine, drained in the - // same SPAWN phase as pendingSpawns so extend-onto-spine and fork-suffix - // prefills batch into one native store.prefill call. Cross-fiber - // rendezvous uses action(): each extendSpine call suspends on its own - // resolve/reject closure, which the drain resolves after prefill lands. - // Fixes the pre-fix race where extendSpine called store.prefill directly - // from the orchestrator fiber, concurrently with the tick loop's native - // work (same class of bug that 50a0baf fixed for spawn). - interface PendingExtend { - tokens: number[]; - userContent: string; - assistantContent: string; - resolve: (deltaTokens: number) => void; - reject: (err: Error) => void; - discarded: boolean; - } - const pendingExtends: PendingExtend[] = []; - - // Pending cancels — agentIds enqueued by the CancelAgent watcher, drained on the - // loop fiber before PRODUCE (a stable point: spawns settled, no decode in flight). - // Single-fiber discipline: the halt + prune runs on the tick, never from the - // watcher fiber, so it can't race the tick's native store work. + const pending: Pending = emptyPending(); + const ladder: Ladder = { consecutiveFatalRc: 0, backendSuspect: false }; + const counters = { warmPrefillCalls: 0, warmPrefillBranches: 0 }; + const totals = { toolCalls: 0, steps: 0 }; + const inflight = new Map>(); + const completed: ToolCompletion[] = []; const pendingCancels: number[] = []; + /** One wake for everything that can make a waiting tick runnable. */ + const wake = createSignal(); + let windingDown = false; + let orchestratorDone = false; + let orchestratorError: unknown = null; - // Agents that have received a TERMINAL `agent:failed` and are fully - // DISCARDED. Downstream phases must never resurrect one: the termination - // sweep must not force-recover it (its branch may still be alive — - // `safePrune` is a documented no-op on a branch with live children), and - // DRAIN must not emit tool events for a completion that lands afterwards. - // - // TWO paths write it, and only one used to. A user cancel - // (`drainCancels`) and a poisoned media prefill (SETTLE) do the identical - // three things at the point of discard — terminal `agent:failed`, - // `safePrune`, `transition('idle')` — but only the cancel was remembered - // past the tick, so a poisoned agent still satisfied every condition the - // sweep tests and was recovered on a branch the runtime had just called - // unresumable. The observable symptom was TWO terminal events for one - // agent: `media_prefill_failed`, then `recovery_skipped`. - // - // NOT the same set as SETTLE's local `poisoned`, which answers a different - // question — "did this agent's prefill land in THIS tick?" — and is used - // to skip re-activation. One fact needs one name; two facts keep two. - const discardedIds = new Set(); - - // ── Self-healing ladder state (docs/self-healing.md) ── - /** rc==1 deferrals per agent; cleared when a settle lands. */ - const deferAttempts = new Map(); - /** Consecutive fatal rcs across dispatches; reset by any success. */ - let consecutiveFatalRc = 0; - /** Set at BACKEND_TRIPWIRE_N — the ladder stops, failures go terminal. */ - let backendSuspect = false; - /** Per-agent KV-delta record since spawn — heal's replay material. One - * shape, two sinks: every piece is ALREADY on the trace (agent:turn's - * rawOutput, tool:result, branch:prefill's probeText); this holds the - * same data where heal can read it back same-process. */ - const turnRecordsById = new Map(); - const recordFor = (id: number): AgentTurnRecord[] => { - let r = turnRecordsById.get(id); - if (!r) { r = []; turnRecordsById.set(id, r); } - return r; - }; - /** The birth certificate — what a heal reproduces (seed, tools, ability, - * the spec's exact text). Recorded at the SPAWN drain. */ - const specById = new Map(); - /** Heal count per lineage (a replacement inherits its original's + 1). */ - const healAttemptOf = new Map(); - const pendingHeals: { - spec: AgentTaskSpec; records: AgentTurnRecord[]; - of: number; rc?: number; attempt: number; - }[] = []; - - // Pool-level branch cleanup — ensures orphan-branch cleanup even when - // spawns are lazy and the orchestrator's spawn scope exits early. - // - // `safePrune`, not `pruneSync` and not `pruneSubtreeSync`. - // - // `pruneSync()` (the original) throws on a branch with live children — - // inside an `ensure()`, where a throw unwinds teardown and can mask - // whatever the run was already failing on. That is the real defect here. - // - // `pruneSubtreeSync()` (what briefly replaced it) is not a memory bug — - // the kernel is generation-checked, so freeing a stale handle is inert — - // but it is still the wrong tool: it frees OTHER AGENTS' branches as a - // side effect while leaving their `Branch` objects reading - // `disposed === false`, so every later reader of those objects is working - // from a flag that lies. It is right in `spine.ts` / `use-agent.ts`, where - // the branch owns its subtree and no sibling object aliases a descendant. - // - // `safePrune` does neither: it asks the CONTEXT whether children are live - // (disposed-filtered, so a freed child stops counting) and lets each - // branch set its own flag. REVERSED, so children are reached before their - // parents — agents are spawned parent-first, so a parent can become - // prunable in the same pass. Whatever this cannot free is freed when the - // context itself goes. - yield* ensure(() => { - for (let i = agents.length - 1; i >= 0; i--) { - safePrune(agents[i], tw, poolScopeId); - } + // Teardown frees every leaf branch, children first. + yield* ensure(() => { pruneAll(agents, emit); }); + + emit.trace({ kind: 'opened', pressure: new ContextPressure(ctx, pressureOpts) }); + + // Recovery shape and report budget are cohort decisions: read once off the + // policy (where callers configure them) and handed to the scheduler. + const scheduler = new DefaultScheduler({ + recovery: policy.recoveryShape === 'parallel' ? 'cohort' : 'serial', + reportBudget: policy.reportBudget, + terminalToolName, config, + }, ctx, tools); + const applier = new Applier({ + ctx, policy, config, tools, emit, pending, ladder, + recovery: policy.recoveryShape === 'parallel' ? 'cohort' : 'serial', + reportBudget: policy.reportBudget, terminalToolName, pruneOnReturn, pressureOpts, totals, }); - - // Lazy grammar setup — applied inside ctx.spawn after prefill completes. - const applyLazyGrammar = (a: Agent): void => { - // Eager grammar (schema-based agents like the planner) takes priority - // over lazy tool-call grammar. Qwen3.5's chat template emits a lazy - // tool-call grammar even when no tools are passed (a non-empty - // fmt.grammar with a `` trigger), which would otherwise - // overwrite a schema grammar set elsewhere — the planner would still - // be unconstrained. With eager set, we use the strict schema grammar - // and skip the (no-tools-anyway) lazy trigger. - if (eagerGrammar) { - a.branch.setGrammar(eagerGrammar); - } else if (tools.size > 0 && a.fmt.grammar && a.fmt.grammarLazy && a.fmt.grammarTriggers.length > 0) { - // tools.size guard: with an empty toolkit there is nothing to - // dispatch, but the template still emits a tool-call grammar (see - // above). Installing it would not BLOCK the `` trigger — - // lazy grammars activate on the trigger, they don't prevent it — - // but once triggered it FORCES syntactic completion of a full call - // the model may have sampled into by accident. A no-tool agent - // (synth, eval) must be free to wander back to prose instead. - const triggers = a.fmt.grammarTriggers.map(t => { - if (t.type === GrammarTriggerType.WORD) { - const nlIdx = t.value.indexOf('\n'); - if (nlIdx >= 0 && nlIdx < t.value.length - 1) { - return { ...t, value: t.value.slice(0, nlIdx + 1) }; - } - } - return t; - }); - a.branch.setGrammarLazy(a.fmt.grammar, triggers); - } - }; - - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:open', agentCount: 0, taskSuffixTokens: [], - pressure: (() => { - const p = new ContextPressure(ctx, pressureOpts); - return { remaining: finiteOrNull(p.remaining), softLimit: p.softLimit, headroom: finiteOrNull(p.headroom) }; - })(), + const executor = new Executor({ + ctx, store, tools, emit, tw, pending, agents, inflight, + permits: makePermits(opts.maxConcurrentTools ?? DEFAULT_MAX_CONCURRENT_TOOLS), + completed, wake, progress, scorer: opts.scorer, toolIndexMap, toolkitSize: tools.size, + terminalGrammar, eagerGrammar, enableThinking, spine, runNow, counters, totals, policy, + pressureOpts, ingress, attachments, ladder, trace, }); - // ── PoolContext — orchestrator's API surface ───────────── - const poolContext: import('./orchestrators').PoolContext = { + // ── PoolContext — the orchestrator's API ───────────────────── + const poolContext: PoolContext = { spine, *spawn(spec) { const parent = spec.parent ?? spine; const task: AgentTaskSpec = { - systemPrompt: spec.systemPrompt, - content: spec.content, - tools: toolsJson, - seed: spec.seed, + systemPrompt: spec.systemPrompt, content: spec.content, tools: toolsJson, seed: spec.seed, ...(spec.after && spec.after.length > 0 ? { after: spec.after } : {}), - parent, - assignedAbility: spec.assignedAbility, + parent, assignedAbility: spec.assignedAbility, }; - - // Synchronous setup — fork, tokenize suffix, pressure check. - // No native store call yet; that's the tick loop's SPAWN phase's job. + // Fork now (metadata only); the suffix prefill and the activation are + // the scheduler's. Suspend until admitted — or rejected for pressure. const { agent, suffixTokens, formattedPrompt } = yield* setupAgent(parent, task, ctx, enableThinking, runNow); - - const pressure = new ContextPressure(ctx, pressureOpts); - // Reserve for batch-mates: spawns/extends admitted earlier this tick - // haven't prefilled yet, so raw pressure doesn't see them. Without - // the reservation, N individually-valid spawns cram N suffixes into - // one SPAWN-phase prefill and every agent dies pressure_softcut on - // turn 0 (trace-2026-06-11T06-21: 6 × 4,819-token suffixes vs 32k). - const reserved = - pendingSpawns.reduce((acc, ps) => acc + ps.suffixTokens.length, 0) + - pendingExtends.reduce((acc, pe) => acc + (pe.discarded ? 0 : pe.tokens.length), 0); - if (!pressure.canFit(reserved + suffixTokens.length)) { - agent.branch.pruneSync(); - agent.dispose(); - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentDrop', agentId: agent.id, reason: 'pressure_init', - }); - throw new Error(`useAgentPool: cannot fit agent suffix (${suffixTokens.length} tokens) under current pressure`); - } - - // Enqueue for SPAWN phase. The tick loop will batch this with any - // other pending spawns into ONE store.prefill, transition to active, - // write trace events, and emit agent:spawn. Return the agent - // immediately — waitFor() is keyed off a transition, not a status - // snapshot, so the pre-activation 'idle' status doesn't race with - // the real terminal-idle signal. - pendingSpawns.push({ agent, suffixTokens, formattedPrompt, task }); - agents.push(agent); - agentById.set(agent.id, agent); - - return agent; + const admitted = yield* action((resolve, reject) => { + const req = { agent, suffixTokens, formattedPrompt, task, resolve, reject, discarded: false }; + pending.spawns.push(req); + wake.send(); + return () => { req.discarded = true; }; + }); + return admitted; }, *waitFor(agent) { - // Agent completion = terminal 'idle' OR 'disposed'. Pre-activation - // 'idle' (the constructor default) would be a false positive, so we - // wait for a TRANSITION signal rather than checking status.snapshot. - // The SPAWN phase transitions 'idle' → 'active' when it activates the - // agent; subsequent transitions lead to a terminal 'idle' or 'disposed'. - const stream = yield* each(agent.statusSignal); - // Only short-circuit for already-disposed — no further signal is coming. - if (agent.status === 'disposed') return agent; - for (const s of stream) { + // `spawn` resolves only once the agent is active, so `idle` here is + // terminal, never the pre-activation default. Check BEFORE subscribing: + // `each` blocks until the next emission, and a transition that already + // fired is not replayed. + if (agent.status === 'idle' || agent.status === 'disposed') return agent; + for (const s of yield* each(agent.statusSignal)) { if (s === 'idle' || s === 'disposed') return agent; yield* each.next(); } @@ -1212,23 +207,11 @@ export function useAgentPool(opts: AgentPoolOptions): Operation((resolve, reject) => { - const req: PendingExtend = { - tokens: turnTokens, - userContent, - assistantContent, - resolve, - reject, - discarded: false, - }; - pendingExtends.push(req); + const req = { tokens, userContent, assistantContent, resolve, reject, discarded: false }; + pending.extends.push(req); + wake.send(); return () => { req.discarded = true; }; }); }, @@ -1238,14 +221,9 @@ export function useAgentPool(opts: AgentPoolOptions): Operation { - const settlePressure = new ContextPressure(ctx, pressureOpts); - let headroom = settlePressure.headroom; - // Recovery (extracting) items may spend the softLimit reserve DOWN TO hardLimit — - // the documented recovery reserve (agent-pool.ts ContextPressure docstring; same - // floor the nudge advisory + staggered recoverInline already use). So an extracting - // item's admission budget is `headroom + reserveBand` (= remaining − hardLimit); - // plain tool-result (new research) items stay gated at `headroom` (preserve softLimit). - const reserveBand = settlePressure.softLimit - settlePressure.hardLimit; - - // The two admitted-item lists. Each entry carries what its `branch:prefill` - // will need, because that event is written AFTER the dispatch it describes - // — it asserts the KV moved, and on the media rail an entry can fail. - const tokenItems: { agent: Agent; tokens: number[]; cells: number; src: SettledTool }[] = []; - // Media rides its own list: `llama_batch` is token-XOR-embd, so these - // cannot join the token batch — a separate call, not a separate strategy. - const mediaItems: { - agent: Agent; delta: MultimodalDelta; cells: number; - attachments?: readonly Attachment[]; src: SettledTool; - }[] = []; - - /** One `branch:prefill` for an entry that LANDED. Never called for a - * deferred or poisoned one: nothing moved for those. */ - const writePrefilled = ( - a: Agent, cells: number, refs?: readonly Attachment[], - ): void => { - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'branch:prefill', branchHandle: a.id, - cells, role: 'toolResult', ...(refs ? { attachments: refs } : {}) }); - }; - const settledAgents: Agent[] = []; - const settledOrder: { agentId: number; callId: string; cells: number }[] = []; - const itemProbes = new Map(); - const deferred: SettledTool[] = []; - const poisoned = new Set(); - - /** Success-only bookkeeping, run AFTER a dispatch landed — the same - * discipline `writePrefilled` already follows. Nothing here runs for - * a deferred or failed entry, so the trace, the tool history and the - * re-activation list all describe only what actually happened. */ - const bookSettled = ( - a: Agent, src: SettledTool, cells: number, refs?: readonly Attachment[], - resultStrOverride?: string, - ): void => { - const resultStr = resultStrOverride ?? src.resultStr; - if (resultStr) { - recordFor(a.id).push({ - kind: 'toolResult', resultStr, callId: src.callId, - ...(refs && refs.length > 0 ? { attachments: refs } : {}), - }); - } - settledAgents.push(a); - settledOrder.push({ agentId: a.id, callId: src.callId, cells }); - if (src.probe) itemProbes.set(a.id, src.probe); - deferAttempts.delete(a.id); - const postSettle = new ContextPressure(ctx, pressureOpts); - a.recordToolResult({ - name: src.toolName, args: src.args, - resultCells: cells, - contextAfterPercent: postSettle.percentAvailable, - timestamp: performance.now(), - }); - writePrefilled(a, cells, refs); - }; - - /** rc==1: no KV slot, state restored — the branch is INTACT. The item - * re-enters via the deferral stream (`pendingSettled` next tick). */ - const writeDeferred = (a: Agent, rc: number, attempt: number): void => { - const p = new ContextPressure(ctx, pressureOpts); - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentDefer', agentId: a.id, rc, attempt, - pressure: { remaining: finiteOrNull(p.remaining), cellsUsed: p.cellsUsed, - nCtx: p.nCtx, headroom: finiteOrNull(p.headroom) } }); - }; - - /** The terminal path — the ladder's bottom rung. Prune-and-discard is - * safe whatever the rc said: pruning an intact branch is harmless, - * and a poisoned one must never be resumed. */ - function* failSettled( - a: Agent, - reason: 'media_prefill_failed' | 'tool_result_failed', - detail: string, - rc?: number, - ): Operation { - poisoned.add(a.id); // skip re-activation THIS tick - discardedIds.add(a.id); // and never resurrect it in any later one - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:settleFailed', agentId: a.id, reason, - detail: detail.slice(0, 200), ...(rc !== undefined ? { rc } : {}) }); - yield* poolChannel.send({ type: 'agent:failed', agentId: a.id, reason }); - safePrune(a, tw, poolScopeId); - a.transition('idle'); - } - - for (const item of items) { - const a = agentById.get(item.agentId); - if (!a || a.status === 'idle') continue; - - // Admission cost. A recovery item (the agent is `extracting`) reserves the - // REPORT room too (prompt + b) — it will decode up to `recoveryBudget` tokens - // AFTER this prefill — and is budgeted against `remaining − hardLimit` - // (`headroom + reserveBand`), i.e. it may consume the softLimit reserve down to - // the hardLimit floor (the documented recovery reserve). `b` is sized in - // handleRecover so aliveCount·(prompt + b) ≤ remaining − hardLimit, so this gate - // ADMITS ALL N in the normal case: no wave, no defer (decode is O(1) in branch - // count, they batch in one tick). It bites ONLY when KV is genuinely too tight - // (`b` floored at MIN, (prompt + b)·N > remaining − hardLimit): the overflow - // defers → stall-break → serial `recoverInline` (uncapped, prune-between, - // lossless), never a report-decode overflow. Plain tool-result items reserve - // only their prompt and stay gated at `headroom` (preserve the softLimit reserve). - // A media item's cost is the MEASURED cell count, not a token length: - // its `prefillTokens` is empty because mtmd tokenizes downstream. - const itemCells = settledCells(item); - const cost = a.extracting ? itemCells + a.recoveryBudget : itemCells; - const budget = a.extracting ? headroom + reserveBand : headroom; - if (cost > budget) { - // Defer — siblings may finish and free KV, letting this result - // settle next tick (staggered-exit for parallel orchestration). - // Policy is consulted at stall-break time, not here: invoking - // it eagerly would break "wait for a sibling to report and - // free cells" by nudging/dropping on first over-headroom. - deferred.push(item); - continue; - } - - // Committed by the barrier at the delta-build seam; nothing is stored - // here. A tick's worth of orphan is fine — SETTLE may DEFER this item - // for headroom, so a manifest can exist before its prefill lands. - if (item.rail === 'media') { - mediaItems.push({ - agent: a, delta: item.media.delta, cells: itemCells, - attachments: item.media.attachments, src: item, - }); - } else { - tokenItems.push({ agent: a, tokens: item.prefillTokens, cells: itemCells, src: item }); - } - // Admission only RESERVES here; the bookkeeping (settle order, tool - // history, re-activation list, branch:prefill) runs after the - // dispatch lands — success-only, like the events it feeds. - headroom -= cost; - } - - if (tokenItems.length > 0) { - try { - yield* waitUntilSettled(store.prefill( - tokenItems.map(t => [t.agent.branch, t.tokens] as [Branch, number[]]))); - counters.warmPrefillCalls++; - counters.warmPrefillBranches += tokenItems.length; - consecutiveFatalRc = 0; - for (const t of tokenItems) bookSettled(t.agent, t.src, t.cells); - } catch (err) { - const de = decodeErrorOf(err); - const rc = de?.rc; - if (rc === 1 && de?.partial && !backendSuspect) { - // No KV slot for a LATER chunk: the chunks before it landed and - // moved their branches' books, and the error does not say which. - // Re-queuing the cohort whole would decode the landed ones twice - // onto advanced positions, so the cohort takes the per-agent - // terminal instead — the kernel's rule: intact ⇔ the failing call - // restored state (rc 1 or -1) and nothing before it landed. - for (const t of tokenItems) { - yield* failSettled(t.agent, 'tool_result_failed', - `partial prefill: ${err instanceof Error ? err.message : String(err)}`, rc); - } - } else if (rc === 1 && !backendSuspect) { - // No KV slot for the batch; state restored — every branch is - // INTACT. Re-queue the items whole: a sibling finishing frees - // cells and they settle on a later tick. This used to take the - // entire pool down. - for (const t of tokenItems) { - const attempt = (deferAttempts.get(t.agent.id) ?? 0) + 1; - deferAttempts.set(t.agent.id, attempt); - if (attempt > MAX_DEFER_ATTEMPTS) { - yield* failSettled(t.agent, 'tool_result_failed', - `deferral exhausted after ${MAX_DEFER_ATTEMPTS} attempts: ${err instanceof Error ? err.message : String(err)}`, rc); - } else { - writeDeferred(t.agent, rc, attempt); - deferred.push(t.src); - } - } - } else { - // Fatal (2 / < -1), rc-less, or the tripwire is up: today's - // behavior — the tick throws and the pool scope tears down — - // now with the rc preserved on the error for the postmortem. - if (rc === 2 || (rc !== undefined && rc < -1)) consecutiveFatalRc++; - throw err; - } - } - } - - // The third dispatch. Media cannot share the token batch, so it goes as - // one cohort call in the same position and style as the two around it — - // how many dispatches (and vision-tower encodes) that costs stays the - // native worker's business, so making it cheaper later touches no JS. - // - // Per-item outcomes, not a rejected promise: one agent's failure must - // not cost its siblings their prefills. Each entry classifies by the - // rc and partial flag the worker attached (docs/self-healing.md): 1 and - // -1 restored the failing call, so the branch is INTACT unless `partial` - // says an earlier chunk landed; 2 / < -1 poison it (decode_segments is - // not atomic, and partial-range KV ops are meaningless on recurrent - // layers). Anything not intact is pruned, never resumed. - if (mediaItems.length > 0) { - const results = yield* waitUntilSettled( - store.prefillMultimodal(mediaItems.map(m => [m.agent.branch, m.delta] as [Branch, MultimodalDelta]))); - counters.warmPrefillCalls++; - counters.warmPrefillBranches += mediaItems.length; - for (let i = 0; i < mediaItems.length; i++) { - const m = mediaItems[i]; - const r = results[i]; - if (!r?.error) { - consecutiveFatalRc = 0; - bookSettled(m.agent, m.src, m.cells, m.attachments); - continue; - } - const a = m.agent; - const rc = r.rc; - - if (rc === 1 && !r.partial && !backendSuspect) { - // Intact — re-queue for a later tick, budgeted. - const attempt = (deferAttempts.get(a.id) ?? 0) + 1; - deferAttempts.set(a.id, attempt); - if (attempt > MAX_DEFER_ATTEMPTS) { - yield* failSettled(a, 'media_prefill_failed', - `deferral exhausted after ${MAX_DEFER_ATTEMPTS} attempts: ${r.error}`, rc); - } else { - writeDeferred(a, rc, attempt); - deferred.push(m.src); - } - continue; - } - - if (rc === -1 && !r.partial && !backendSuspect) { - // Invalid input, state restored — the branch is intact and the - // item is deterministic: retrying loops. Drop it and tell the - // model what it did not see, on the same channel the - // no-projector path already uses. - // "Work from the text" needs the text: `resultStr` is the tool's - // media-stripped result, the same object the no-projector path - // decorates before it stringifies. On this rail it is always a - // plain object — `takeToolMedia` yields media only from one, and - // `processCompletion` always sets it — so parse and add the key. - // A note that is only the key drops the answer. - const told = JSON.parse(m.src.resultStr!) as Record; - const note = { - ...told, - [TOOL_IMAGE_ERROR_KEY]: - `${m.src.toolName} returned media the decoder rejected as invalid input. ` + - `Work from the text, or use a different source.`, - }; - const noteStr = JSON.stringify(note); - const noteTokens = buildToolResultDelta( - ctx, noteStr, m.src.callId, - { enableThinking: a.fmt.enableThinking }); - yield* waitUntilSettled(store.prefill([[a.branch, noteTokens]])); - // The record carries what LANDED — the note, not the dropped item. - bookSettled(a, m.src, noteTokens.length, undefined, noteStr); - continue; - } - - // Poisoned (2 / < -1), partial (an earlier chunk landed), an rc-less - // failure, or the tripwire is up. - if (rc === 2 || (rc !== undefined && rc < -1)) { - consecutiveFatalRc++; - if (consecutiveFatalRc >= BACKEND_TRIPWIRE_N) backendSuspect = true; - } - yield* failSettled(a, 'media_prefill_failed', - backendSuspect - ? `${r.error} [backend suspect: ${consecutiveFatalRc} consecutive fatal decodes — recreate the backend]` - : r.error, - rc); - - // HEAL (docs/self-healing.md): the poison cost this agent its - // branch, not its task. Within budget and with the backend healthy, - // queue a warm respawn — fork the spine (the prefix, seed images - // included, rides for free), replay the record, re-admit. Drained - // at the SPAWN phase, on the loop fiber, like everything else. - const healAttempt = (healAttemptOf.get(a.id) ?? 0) + 1; - const healSpec = specById.get(a.id); - if (!backendSuspect && healAttempt <= MAX_HEAL_ATTEMPTS && healSpec) { - // Replay up to the LAST COMPLETED TRANSACTION. The record's tail - // is the poisoned transaction itself — an assistant turn whose - // tool call never settled — and replaying it would leave the - // replacement dangling mid-call (observed on real weights: the - // model emits a stray think and stops instead of re-calling). - // Dropping the tail lets the replacement REGENERATE that turn - // and drive the tool itself. - const records = recordFor(a.id).slice(); - while (records.length > 0 && records[records.length - 1].kind === 'assistant') { - records.pop(); - } - pendingHeals.push({ - spec: healSpec, records, - of: a.id, ...(rc !== undefined ? { rc } : {}), attempt: healAttempt, - }); - } - } - } - - // Re-activation runs over everything admitted this tick, on either rail. - // Guarding it on `tokenItems` would strand a tick whose items were ALL - // media: those agents would sit in awaiting_tool with their results - // already in KV, and nothing would ever wake them. - if (settledAgents.length > 0) { - // Fan-out determinism: record the canonical scatter order so the replay - // settle-order oracle can reproduce this exact interleaving. On the - // serial path this equals dispatch order; the event is emitted uniformly. - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'tool:settle_order', batch: settledOrder }); - - // Probe prefill from DISPATCH or nudge-replacement. - const probePairs: [Branch, number[]][] = []; - const probeMeta: { id: number; cells: number; probeText: string }[] = []; - for (const a of settledAgents) { - if (poisoned.has(a.id)) continue; - const probe = itemProbes.get(a.id); - if (probe) { - const probeTokens = ctx.tokenizeSync(probe, false); - probePairs.push([a.branch, probeTokens]); - probeMeta.push({ id: a.id, cells: probeTokens.length, probeText: probe }); - } - } - if (probePairs.length > 0) { - yield* waitUntilSettled(store.prefill(probePairs)); - // Success-only, like every branch:prefill: written after the - // batched dispatch landed, so a rejected prefill leaves no event - // claiming cells that never moved. - for (const m of probeMeta) { - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'branch:prefill', branchHandle: m.id, - cells: m.cells, role: 'probe', probeText: m.probeText }); - recordFor(m.id).push({ kind: 'probe', text: m.probeText }); - } - } - - // Re-activate. An `extracting` agent (parallel recovery, queued by - // handleRecover) gets the eager terminal-tool grammar instead of the lazy - // tool-call grammar — the grammar-swap (#77). This forces a schema-valid - // terminal call that `parseChatOutput` decodes; the report then decodes - // bin-packed in the tick loop alongside live siblings. - for (const a of settledAgents) { - if (poisoned.has(a.id)) continue; - a.transition('active'); - a.resetTurn(); - if (a.extracting && terminalGrammar) { - a.branch.setGrammar(terminalGrammar); - } else { - applyLazyGrammar(a); - } - } - } - - return deferred; - } - - /** Transient-failure parking: a ToolRetryError'd call waits here with its - * agent in `awaiting_tool` (PRODUCE skips it — no turns, no tokens, no - * KV) until `notBefore`, then re-enters DISPATCH. Whether to park and - * for how long is the POLICY's call (`onToolRetry`); this queue is - * pure mechanism, like SETTLE's deferral. Keep retry delays above the - * provider's own breaker cooldown or the retry lands on an open - * breaker. */ - const pendingRetries: { - agent: Agent; tc: ParsedToolCall; callId: string; - notBefore: number; attempt: number; - }[] = []; - - // ── Fan-out dispatch state ─────────────────────────────────── - // A `Tool.fanout` tool runs on a child fiber OFF the loop fiber; its child - // pushes a ToolCompletion here on finish, and the loop fiber drains + - // post-processes them in the DRAIN phase. A plain array is the same - // cross-fiber rendezvous as pendingSpawns/pendingExtends — a child `push` - // is atomic w.r.t. the single-threaded event loop and only the loop fiber - // splices, so no lock is needed. Inline (`fanout` unset) tools never touch - // this; with no tool flagged the whole mechanism is inert (today's path). - const completedTools: ToolCompletion[] = []; - // agentId → its in-flight tool child (≤1 per agent: PRODUCE emits one call - // then parks the agent in awaiting_tool). Powers the termination guard now; - // targeted wind-down halt later. - const inflightTasks = new Map>(); - // Fired by a child on completion so the all-parked nap wakes immediately. - const toolWake = createSignal(); - const permits = makePermits(opts.maxConcurrentTools ?? DEFAULT_MAX_CONCURRENT_TOOLS); - function* awaitToolCompletion(): Operation { - const sub = yield* toolWake; - yield* sub.next(); - } - - // ── Graceful wind-down (drain) ────────────────────────────────────── - // A pool-local flag the PRODUCE reap-branch + termination sweep read. The - // watcher flips it ONCE when the consumer's WindDown signal fires, halts the - // orchestrator (stop spawning — its `finally` sets orchestratorDone), and - // wakes any all-parked nap via toolWake. The reap is pool-internal (no policy - // surface); in-flight tools are NOT halted (they drain) — only `halt` aborts. - // Parked RETRIES are abandoned at the next DISPATCH (see Phase 4): a drain - // reports with what agents have, it never waits out a rate-limit park. The - // flip is announced as `pool:windDown` (trace) + `run:windingDown` (bus). - let windingDown = false; + // ── Signals ────────────────────────────────────────────────── if (windDownSignal) { const wd = windDownSignal; yield* spawn(function*() { const sub = yield* wd; yield* sub.next(); - // Halt the orchestrator BEFORE flipping windingDown. The reap branch is - // gated on windingDown, and a reap's idle-transition fires the agent's - // statusSignal — which would resume the orchestrator's waitFor and let it - // spawn/extend the next task. Halting first guarantees it's dead before - // any reap can fire (the SEGV invariant: orchestrator halted before any - // idle-transition). halt() resolves only after teardown completes (its - // `finally` sets orchestratorDone); windingDown + toolWake are then set - // synchronously (no yield between), so the woken loop always sees both. + // Halt the orchestrator BEFORE flipping: a reap's idle transition would + // otherwise resume its waitFor and let it spawn against a draining pool. yield* orchestratorTask.halt(); windingDown = true; - toolWake.send(); - // Announce the flip (trace + bus) — the consumer's cue to show the - // run as finishing. Emitted here, not at the first reap: with every - // agent parked in a retry there IS no immediate reap, and the click - // would read as ignored. - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:windDown' }); - yield* poolChannel.send({ type: 'run:windingDown' }); + wake.send(); + yield* emit.emit({ kind: 'windingDown' }); }); } - - // ── Targeted cancel (per-agent) ───────────────────────────────────── - // The consumer `.send({agentId})`s CancelAgent (e.g. a per-card ×). Each emission - // enqueues onto pendingCancels + wakes the loop; the tick drains it (below) → - // halt that agent's in-flight tool + emit a terminal agent:failed + prune. Fires - // repeatedly for individual agents, unlike WindDown (fire-once, whole cohort). The - // orchestrator is NOT halted — siblings keep running. if (cancelSignal) { const cs = cancelSignal; yield* spawn(function*() { @@ -1707,44 +257,10 @@ export function useAgentPool(opts: AgentPoolOptions): Operation { - for (const id of pendingCancels.splice(0)) { - const a = agentById.get(id); - if (!a || (a.status !== 'active' && a.status !== 'awaiting_tool')) continue; - const tool = inflightTasks.get(id); - if (tool) yield* tool.halt(); - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentDrop', agentId: id, reason: 'user_cancel' }); - yield* poolChannel.send({ type: 'agent:failed', agentId: id, reason: 'user_cancel' }); - discardedIds.add(id); - a.transition('idle'); - safePrune(a, tw, poolScopeId); - } - } - - // ── Pause watcher ─────────────────────────────────────────────────── - // Multi-fire like CancelAgent (pause toggles repeatedly). `pauseWake` - // releases the hold on play; `toolWake` breaks an all-parked nap so the - // loop reaches the hold promptly on pause. Sequencing conflicts - // (wind-down while paused) are the consumer's to refuse — the pool - // holds while paused, regardless. - const pauseWake = createSignal(); if (pauseSignal) { const ps = pauseSignal; yield* spawn(function*() { @@ -1753,1005 +269,96 @@ export function useAgentPool(opts: AgentPoolOptions): Operation { - const a = c.agent; - const detail = err instanceof Error ? err.message : String(err); - tw.write({ - traceId: tw.nextId(), parentTraceId: c.dispatchTraceId, ts: performance.now(), - type: 'pool:settleFailed', agentId: a.id, reason: 'tool_result_failed', - detail: detail.slice(0, 200), - }); - yield* poolChannel.send({ type: 'agent:failed', agentId: a.id, reason: 'tool_result_failed' }); - discardedIds.add(a.id); - safePrune(a, tw, poolScopeId); - a.transition('idle'); - } - - function* processCompletion(c: ToolCompletion): Operation { - const { agent, tc, callId, dispatchTraceId } = c; - - // Discarded by a user cancel while this tool was in flight: drop the completion - // silently — no tool:result / agent:tool_result, no result set. The agent already - // got its terminal agent:failed(user_cancel); a late tool event would contradict it. - if (discardedIds.has(agent.id)) return null; - - if (c.kind === 'error') { - agent.transition('idle'); - agent.setResult(`Tool error: ${c.err.message}`, 'tool_error'); - tw.write({ traceId: tw.nextId(), parentTraceId: dispatchTraceId, ts: performance.now(), - type: 'tool:error', agentId: agent.id, tool: tc.name, - error: c.err.message }); - return null; - } - - if (c.kind === 'retry') { - const attempt = c.retryAttempt; - // Strategy is the policy's: park-and-retry (optionally overriding the - // tool's delay estimate) or fail the call so the model can pivot. Hook - // absent → one retry at the tool's estimate. - const retryAction: ToolRetryAction = - policy.onToolRetry?.(agent, tc.name, c.err, attempt) - ?? (attempt <= 1 ? { type: 'retry' } : { type: 'fail' }); - if (retryAction.type === 'retry') { - // Park: no SettledTool, nothing prefilled — the agent's KV never sees - // transient infrastructure weather. Emitted as an `agent:tool_retry` - // event (+ `tool:retry` trace) so a consumer can distinguish a - // waiting agent from a hung one. - const afterMs = retryAction.afterMs ?? c.err.retryAfterMs; - pendingRetries.push({ - agent, tc, callId, - notBefore: performance.now() + afterMs, - attempt, - }); - yield* poolChannel.send({ - type: 'agent:tool_retry', agentId: agent.id, tool: tc.name, - retryAfterMs: afterMs, attempt, - }); - tw.write({ traceId: tw.nextId(), parentTraceId: dispatchTraceId, ts: performance.now(), - type: 'tool:retry', agentId: agent.id, tool: tc.name, - callId, retryAfterMs: afterMs, attempt }); - return null; - } - // Policy chose fail — the outage is now a fact the model needs. Settle - // an honest, directive result through the normal path (NOT the - // tool_error path, which kills the agent's run). - const exhausted = { - error: retryAction.message - ?? `${tc.name} is currently unavailable (rate-limited; retry failed). ` + - `Do not call ${tc.name} again — use other sources or proceed with your current findings.`, - }; - const resultStr = JSON.stringify(exhausted); - yield* poolChannel.send({ type: 'agent:tool_result', agentId: agent.id, tool: tc.name, result: resultStr }); - const prefillTokens = buildToolResultDelta(ctx, resultStr, callId, { enableThinking: agent.fmt.enableThinking }); - tw.write({ traceId: tw.nextId(), parentTraceId: dispatchTraceId, ts: performance.now(), - type: 'tool:result', agentId: agent.id, tool: tc.name, - result: exhausted, cells: prefillTokens.length, - durationMs: performance.now() - c.toolT0 }); - return { rail: 'token', agentId: agent.id, prefillTokens, toolName: tc.name, callId, args: tc.arguments, probe: undefined, resultStr }; - } - - // c.kind === 'result' - const result = c.result; - const tool = tools.get(tc.name); - const postToolPressure = new ContextPressure(ctx, pressureOpts); - const contextAvailablePercent = postToolPressure.percentAvailable; - if (result && typeof result === 'object' && !Array.isArray(result)) { - (result as Record)[TOOL_CONTEXT_KEY] = contextAvailablePercent; - const resultObj = result as Record; - if (Array.isArray(resultObj.results)) { - agent.addNestedResults((resultObj.results as unknown[]).filter((f): f is string => typeof f === 'string')); - } - if (Array.isArray(resultObj.nestedResults)) { - agent.addNestedResults((resultObj.nestedResults as unknown[]).filter((f): f is string => typeof f === 'string')); - } - } - // Images come OUT before serializing — see TOOL_MEDIA_KEY. A model with - // no projector cannot be handed them, and dropping them silently would - // leave the agent reasoning about a picture it was never shown, so say - // so in the result text instead: an honest failure the model can read, - // the same shape the rate-limit path uses above. - const { media, result: told } = takeToolMedia(result); - if (media.length > 0 && !ctx.supportsVision()) { - (told as Record)[TOOL_IMAGE_ERROR_KEY] = - `${tc.name} returned ${media.length} image(s), but this model cannot see images. ` + - `Work from the text, or use a different source.`; - } - const resultStr = JSON.stringify(told); - yield* poolChannel.send({ type: 'agent:tool_result', agentId: agent.id, tool: tc.name, result: resultStr, contextAvailablePercent }); - - // Two rails, one seam. The token rail tokenizes here; the embedding rail - // stops at the string stage because mtmd tokenizes downstream, and its - // cost has to be MEASURED (image cost is non-linear — a per-image - // estimate over-commits) before SETTLE can spend it against headroom. - // Measured on the loop fiber, never inside a fan-out `execute()`. - let prefillTokens: number[] = []; - let mediaItem: { delta: MultimodalDelta; cells: number; attachments: readonly Attachment[] } | undefined; - if (media.length > 0 && ctx.supportsVision()) { - // THE BARRIER for this ingress: the whole batch is normalized and - // committed before a marker exists, before admission, before any KV - // moves. `delta` then carries the ADMITTED representations, so the - // cells measured here are the cells replay will rebuild. - // - // A failure is NOT a tool retry: the tool already ran and may have had - // an external side effect, so re-running it is not a neutral act. The - // agent fails through the existing recovery path instead, and its - // branch is pruned — never silently dropped, and never repeated. - const prepared = yield* prepareBatch(ingress, attachments, media); - const delta = buildToolResultDeltaMultimodal( - ctx, resultStr, callId, prepared.bitmaps as Uint8Array[], - { enableThinking: agent.fmt.enableThinking }); - mediaItem = { - delta, - cells: yield* waitUntilSettled(deltaCells(ctx, delta)), - attachments: prepared.attachments, - }; - } else { - prefillTokens = buildToolResultDelta(ctx, resultStr, callId, { enableThinking: agent.fmt.enableThinking }); - } - // `told` throughout, never `result`: the probe reads what the model was - // told, and the trace records it. Image bytes reach the cache down the - // embedding rail and belong in neither. - const probe = tool?.probe(told) ?? undefined; - tw.write({ traceId: tw.nextId(), parentTraceId: dispatchTraceId, ts: performance.now(), - type: 'tool:result', agentId: agent.id, tool: tc.name, - result: told, cells: mediaItem?.cells ?? prefillTokens.length, - durationMs: performance.now() - c.toolT0 }); - const common = { agentId: agent.id, toolName: tc.name, callId, args: tc.arguments, probe, resultStr }; - return mediaItem - ? { rail: 'media', ...common, media: mediaItem } - : { rail: 'token', ...common, prefillTokens }; - } - - /** DISPATCH: run inline tools on the loop fiber, spawn fan-out tools off it. - * Inline results return for next tick's SETTLE; fan-out completions arrive - * via `completedTools` and are processed in DRAIN. */ - function* dispatch(calls: { agent: Agent; tc: ParsedToolCall; retryAttempt?: number; retryCallId?: string }[]): Operation { - const results: SettledTool[] = []; - - for (const { agent, tc, retryAttempt, retryCallId } of calls) { - let toolArgs: Record; - try { toolArgs = JSON.parse(tc.arguments); } catch { toolArgs = {}; } - const callId = retryCallId ?? (tc.id || `call_${agent.toolCallCount}`); - - // Retries re-execute the SAME call — turn/tool-call counters and the - // agent:tool_call event belong to the original attempt only. - if (retryAttempt === undefined) { - agent.incrementToolCalls(); - totalToolCalls++; - agent.incrementTurns(); - - yield* poolChannel.send({ type: 'agent:tool_call', agentId: agent.id, tool: tc.name, args: tc.arguments }); - } - - const tool = tools.get(tc.name); - const dispatchPressure = new ContextPressure(ctx, pressureOpts); - const explore = policy.shouldExplore?.(agent, dispatchPressure) ?? true; - - const dispatchTraceId = tw.nextId(); - const toolT0 = performance.now(); - tw.write({ - traceId: dispatchTraceId, parentTraceId: poolScopeId, ts: toolT0, - type: 'tool:dispatch', agentId: agent.id, tool: tc.name, - toolIndex: toolIndexMap.get(tc.name) ?? -1, toolkitSize, - args: toolArgs, callId, - explore, percentAvailable: dispatchPressure.percentAvailable, - }); - const peerHistory = agents - .filter(a => a.id !== agent.id) - .flatMap(a => a.toolHistory); - const toolContext: ToolContext = { - agentId: agent.id, branch: agent.branch, - onProgress: (p: { filled: number; total: number }) => { - progressBridge.send({ type: 'agent:tool_progress', agentId: agent.id, tool: tc.name, filled: p.filled, total: p.total }); - }, - scorer: opts.scorer, explore, - pressurePercentAvailable: dispatchPressure.percentAvailable, - peerHistory, - }; - - // ── execute ── - if (tool?.fanout) { - // Fan-out: spawn OFF the loop fiber. The child runs ONLY execute() (a - // fanout tool issues no main-context op); the post-processing — which - // tokenizes/reads the main ctx — runs in DRAIN on the loop fiber. The - // agent stays awaiting_tool until its result settles. The child is a - // child task of the tick-loop task, so pool teardown / wind-down - // halts it (→ cancellableFetch aborts) for free. - const fanoutTool = tool; // narrowed non-null by tool?.fanout - inflightTasks.set(agent.id, yield* spawn(function*() { - let took = false; - try { - // Own this agent's inflightTasks entry: remove it on ANY exit — - // completion OR halt (wind-down/teardown). A halt unwinds via - // ensure (not catch), so it pushes no completion and DRAIN never - // runs for it; without this the stale entry keeps `fanoutQuiet` - // false and the loop never terminates. - yield* ensure(() => { inflightTasks.delete(agent.id); }); - yield* ensure(() => { if (took) permits.release(); }); - yield* permits.acquire(); took = true; - // Per-tool TRACE/CALLER context set INSIDE the child so concurrent - // tools never clobber each other's (stronger isolation than the - // shared loop-fiber set the inline path uses). - yield* TraceParent.set(dispatchTraceId); - yield* CallingAgent.set(agent); - yield* Trace.set(toolTee(agent.id, callId, dispatchTraceId)); - const result: unknown = yield* scoped(function*() { - return yield* call(() => fanoutTool.execute(toolArgs, toolContext)); - }); - completedTools.push({ kind: 'result', agent, tc, callId, dispatchTraceId, toolT0, result }); - } catch (err) { - // A halt unwinds via ensure/finally, NOT catch — a halted child - // skips the push (its result correctly discarded); catch only ever - // sees real tool errors (incl. ToolRetryError). - if (err instanceof ToolRetryError) { - completedTools.push({ kind: 'retry', agent, tc, callId, dispatchTraceId, toolT0, retryAttempt: (retryAttempt ?? 0) + 1, err }); - } else { - completedTools.push({ kind: 'error', agent, tc, callId, dispatchTraceId, err: toError(err) }); - } - } finally { - toolWake.send(); - } - })); - continue; - } - - // ── inline (default) ── - // Run execute + post-process now, on the loop fiber — functionally the - // pre-fan-out path. Required for any tool that decodes on the main - // context (delegate, plan) and for the unknown-tool fallback below. - let completion: ToolCompletion; - try { - yield* TraceParent.set(dispatchTraceId); - yield* CallingAgent.set(agent); - yield* Trace.set(toolTee(agent.id, callId, dispatchTraceId)); - - // Unknown-tool messaging branches on toolkit emptiness: a no-tool - // agent emitting tool calls is imitating markup from its context - // (inherited spine KV or contaminated findings) — a generic - // "Unknown tool" error reads as transient and invites rephrased - // retries until maxTurns (observed: trace-2026-06-11T00-02 synth, - // 10 turns of mimicry). The directive form names the actual - // situation so the model can recover in one turn. - const result: unknown = yield* scoped(function*() { - return yield* call(() => - tool ? tool.execute(toolArgs, toolContext) : Promise.resolve({ - error: tools.size === 0 - ? 'No tools are available to this agent. Do not emit tool calls — write your answer directly as plain text.' - : `Unknown tool: ${tc.name}`, - }) - ); - }); - completion = { kind: 'result', agent, tc, callId, dispatchTraceId, toolT0, result }; - } catch (err) { - completion = err instanceof ToolRetryError - ? { kind: 'retry', agent, tc, callId, dispatchTraceId, toolT0, retryAttempt: (retryAttempt ?? 0) + 1, err } - : { kind: 'error', agent, tc, callId, dispatchTraceId, err: toError(err) }; - } - let settled: SettledTool | null = null; - try { - settled = yield* processCompletion(completion); - } catch (err) { - // One agent's post-tool failure must not take the tick — or its - // siblings — with it. See failCompletion. - yield* failCompletion(completion, err); - } - if (settled) results.push(settled); - } - - return results; - } - - // ── Four-phase tick loop ───────────────────────────────── - let pendingSettled: SettledTool[] = []; - - // ── Four-phase tick loop ───────────────────────────────── - let recoveryAttempted = false; - for (;;) { - // -- Pause: hold at the tick boundary. Branches stay resident; tool - // completions queue as data and settle on the first tick after play. - // Policy time reads runNow (this hold is excluded); retry parks stay - // on the wall clock — rate limits elapse in the real world. - if (paused) { - const heldAt = performance.now(); - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: heldAt, type: 'pool:pause' }); - yield* poolChannel.send({ type: 'run:paused' }); - // Subscribe BEFORE re-checking `paused`: emissions buffer on a live - // subscription, so a play (or wake) landing during subscription setup - // is never missed. toolWake is raced too — a user cancel arriving - // mid-hold drains HERE, on this suspended loop fiber (no decode in - // flight — the safest prune there is): pause, evaluate trajectories, - // cull the off-track agent, play. Pause holds progression, not the axe. - const pauseSub = yield* pauseWake; - const toolSub = yield* toolWake; - while (paused) { - yield* race([pauseSub.next(), toolSub.next()]); - if (pendingCancels.length > 0) yield* drainCancels(); - } - const pausedMs = performance.now() - heldAt; - pausedTotal += pausedMs; - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), type: 'pool:resume', pausedMs }); - yield* poolChannel.send({ type: 'run:resumed', pausedMs }); - } - - // Idle until orchestrator enqueues work (spawn or extend) or completes. - // Include pendingExtends: the final extend after the last task in chain - // mode must drain before the loop exits, otherwise the orchestrator fiber - // is left suspended on a dead action. - if ( - agents.length === 0 - && pendingSpawns.length === 0 - && pendingExtends.length === 0 - ) { - if (orchestratorDone) break; - yield* sleep(1); - continue; - } - - // -- Phase 0: SPAWN+EXTEND -- drain pending spawns AND pending extends, - // batching all fork-suffix prefills and extend-onto-spine prefills into - // ONE native store.prefill call. All store-level native calls in this - // pool are issued from this fiber (the tick loop), never concurrently - // with the orchestrator's fiber. Piggybacking extend in this phase - // preserves the continuous-tree-batching invariant (one GPU round-trip - // per tick) and naturally atomic-orders both kinds of work. - if (pendingSpawns.length > 0 || pendingExtends.length > 0 || pendingHeals.length > 0) { - const drainedSpawns = pendingSpawns.splice(0, pendingSpawns.length); - const drainedExtends = pendingExtends - .splice(0, pendingExtends.length) - .filter(e => !e.discarded); - - // Heals fork the spine and batch their suffix prefills with the - // spawns — a heal IS a spawn wearing a lineage (docs/self-healing.md). - // The record replay runs after the batch, per replacement. - const drainedHeals: { - h: (typeof pendingHeals)[number]; - agent: Agent; suffixTokens: number[]; formattedPrompt: string; - }[] = []; - for (const h of pendingHeals.splice(0)) { - const setup = yield* setupAgent(spine, h.spec, ctx, enableThinking, runNow); - drainedHeals.push({ h, ...setup }); - } - - const prefillPairs: [Branch, number[]][] = [ - ...drainedSpawns.map(s => [s.agent.branch, s.suffixTokens] as [Branch, number[]]), - ...drainedHeals.map(d => [d.agent.branch, d.suffixTokens] as [Branch, number[]]), - ...drainedExtends.map(e => [spine, e.tokens] as [Branch, number[]]), - ]; + // ── The tick loop ──────────────────────────────────────────── + yield* spawn(function*() { + try { + const wakeSub = yield* wake; + let tick = 0; + let wasPaused = false; + let heldAt = 0; + let idleTicks = 0; - try { - if (prefillPairs.length > 0) { - yield* waitUntilSettled(store.prefill(prefillPairs)); + for (;;) { + // OBSERVE — reclaim, hold, drain, sample. + if (executor.prunePass() > 0) { + yield* emit.emit({ kind: 'kvTick', pressure: new ContextPressure(ctx, pressureOpts) }); } - } catch (err) { - for (const e of drainedExtends) e.reject(err as Error); - throw err; - } - - // Resolve extend requests with the delta token count. spine.position - // has advanced by the sum of extend token counts at this point. - for (const e of drainedExtends) { - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'spine:extend', - userContent: e.userContent, - assistantContent: e.assistantContent, - deltaTokens: e.tokens.length, - positionAfter: spine.position, - }); - e.resolve(e.tokens.length); - } - - for (const s of drainedSpawns) { - specById.set(s.agent.id, s.task); - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'branch:create', branchHandle: s.agent.id, parentHandle: s.agent.parentId, - position: s.agent.forkHead, role: 'agentFork', - }); - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'prompt:format', agentId: s.agent.id, promptText: s.formattedPrompt, - taskContent: s.task.content, tokenCount: s.suffixTokens.length, - messages: JSON.stringify([ - { role: 'system', content: s.task.systemPrompt }, - { role: 'user', content: s.task.content }, - ]), - tools: s.task.tools, role: 'agentSuffix', - }); - applyLazyGrammar(s.agent); - // transition fires agent.statusSignal — ctx.spawn's subscriber is waiting on this. - s.agent.transition('active'); - // Trace before the suspending bus send — same contract as - // traceAgentDone: the span's start must not absorb subscriber - // backpressure or vanish on a cancellation mid-send. - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'agent:spawn', agentId: s.agent.id, parentAgentId: s.agent.parentId, - ...(s.task.after && s.task.after.length > 0 ? { after: s.task.after } : {}), - }); - yield* poolChannel.send({ type: 'agent:spawn', agentId: s.agent.id, parentAgentId: s.agent.parentId, ...(s.task.after && s.task.after.length > 0 ? { after: s.task.after } : {}) }); - } - - // Finish the heals: replay each replacement's record onto its fork - // (the suffix batched above; the prefix rode the fork), then admit it - // as a NEW agent. The original's `agent:failed` stands — this is a - // lineage, not a resurrection. - for (const { h, agent, suffixTokens, formattedPrompt } of drainedHeals) { - agents.push(agent); - agentById.set(agent.id, agent); - specById.set(agent.id, h.spec); - healAttemptOf.set(agent.id, h.attempt); - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'branch:create', branchHandle: agent.id, parentHandle: agent.parentId, - position: agent.forkHead, role: 'agentFork', - }); - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'prompt:format', agentId: agent.id, promptText: formattedPrompt, - taskContent: h.spec.content, tokenCount: suffixTokens.length, - messages: JSON.stringify([ - { role: 'system', content: h.spec.systemPrompt }, - { role: 'user', content: h.spec.content }, - ]), - tools: h.spec.tools, role: 'agentSuffix', - }); - try { - yield* replayAgentTurns(agent.branch, h.records, - { enableThinking: agent.fmt.enableThinking }); - } catch (e) { - // The replay could not land (capacity, missing content, a second - // decode failure). Best-effort ends here: the original already - // failed honestly; discard the half-built replacement. - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentDrop', agentId: agent.id, reason: 'pressure_init' }); - if (!agent.branch.disposed) safePrune(agent, tw, poolScopeId); - agent.transition('idle'); - discardedIds.add(agent.id); - continue; + if (paused && !wasPaused) { + heldAt = performance.now(); + yield* emit.emit({ kind: 'paused', ts: heldAt }); + wasPaused = true; } - const hp = new ContextPressure(ctx, pressureOpts); - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentHeal', of: h.of, agentId: agent.id, - ...(h.rc !== undefined ? { rc: h.rc } : {}), attempt: h.attempt, - pressure: { remaining: finiteOrNull(hp.remaining), cellsUsed: hp.cellsUsed, - nCtx: hp.nCtx, headroom: finiteOrNull(hp.headroom) }, - }); - applyLazyGrammar(agent); - agent.transition('active'); - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'agent:spawn', agentId: agent.id, parentAgentId: agent.parentId, - ...(h.spec.after && h.spec.after.length > 0 ? { after: h.spec.after } : {}), - }); - yield* poolChannel.send({ type: 'agent:spawn', agentId: agent.id, parentAgentId: agent.parentId, ...(h.spec.after && h.spec.after.length > 0 ? { after: h.spec.after } : {}) }); - } - } - - // If all we had was pending spawns, and none of them activated (shouldn't happen - // normally — SPAWN always transitions to active), nothing to produce. Loop back. - if (agents.length === 0) continue; - - // -- Targeted cancel (user_cancel) — drained at the stable point. - if (pendingCancels.length > 0) yield* drainCancels(); - - // -- Phase 1: PRODUCE -- sample from active agents, collect tool calls - policy.resetTick?.(); - const pressure = new ContextPressure(ctx, pressureOpts); - // Live agents = the in-flight set that could still need recovery: `active` - // (researching / producing a forced report) or `awaiting_tool`. Explicitly NOT - // `idle` (done-with-result, dropped, OR just-spawned-not-yet-activated) and NOT - // `disposed`. The in-loop recovery budget `b` is a fair share of headroom across - // them, so the whole cohort's reports fit without one agent claiming it all. - const aliveCount = agents.filter(x => x.status === 'active' || x.status === 'awaiting_tool').length; - - // A VOLUNTARY terminal report is bounded too: past the cap a stream is - // repeating, not deepening. The word advisory (tokenBudgetAsWords) is - // the primary cap; this is the same guillotine recovery reports get. - const voluntaryReportCap = Math.min(policy.reportBudget ?? MAX_REPORT_BUDGET, MAX_REPORT_BUDGET); - - const entries: [Branch, number][] = []; - const toolCalls: { agent: Agent; tc: ParsedToolCall }[] = []; - const nudges: SettledTool[] = []; - - for (const a of agents) { - if (a.status !== 'active') continue; - - // Wind-down (drain): recover active agents IN-LOOP (bin-packed for a fast - // drain) instead of deferring to a sweep — wind-down always bin-packs, - // regardless of effort. An agent mid-terminal-tool (emitting its voluntary - // report) is left to finish — same guard as shouldExit's terminal - // protection; an already-extracting agent is left to finish its report. - // SEGV-safe: the orchestrator was halted before windingDown flipped, so no - // concurrent spawn/prefill races handleRecover's prefill. - if (windingDown && !a.extracting && !isEmittingTerminal(a, terminalToolName)) { - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentDrop', agentId: a.id, reason: 'wind_down' }); - traceAgentDone(tw, poolScopeId, a.id); - yield* poolChannel.send({ type: 'agent:done', agentId: a.id }); - const settled = yield* handleRecover(a, policy, ctx, pressureOpts, aliveCount, poolChannel, tw, poolScopeId); - if (settled) nudges.push(settled); - continue; - } - - // Kill on pressure/time. An extracting agent (producing its forced - // recovery report) is exempt — its token-stop cap (with `b` sized so the whole - // cohort fits headroom) keeps it within KV; if it overshoots, the tick-loop - // catch yields partials. - const policyExit = policy.shouldExit?.(a, pressure); - if (!a.extracting && (policyExit ?? pressure.critical)) { - // Entry above requires `policyExit ?? pressure.critical` to be truthy, so exactly - // two cases reach here: the policy said exit, or it abstained (undefined) and - // pressure is critical. A policy returning `false` never enters — `??` falls - // through only on null/undefined. The old third branch was unreachable. - // Entry requires `policyExit ?? pressure.critical` truthy, so the old third - // branch was unreachable: policyExit===false never enters (`??` falls through - // only on null/undefined), and policyExit===undefined enters only when - // critical. Precedence is kept — when BOTH hold, pressure is the cause and - // the policy merely agreed. - const exitReason = pressure.critical ? 'pressure_critical' as const - : 'policy_exit' as const; - a.exitReason = exitReason; - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentDrop', agentId: a.id, reason: exitReason }); - traceAgentDone(tw, poolScopeId, a.id); - yield* poolChannel.send({ type: 'agent:done', agentId: a.id }); - if (isEmittingTerminal(a, terminalToolName)) { - // The agent was already producing its OWN report when the critical kill - // fired. DON'T prefill a fresh recovery turn — that discards the in-flight - // report and restarts it from scratch (the "report resets + restarts" bug), - // then re-decodes into the already-exhausted KV and fails. Salvage the - // partial terminal call it has already emitted (parseChatOutput handles the - // truncation); no further decode is needed. - // producedTokens = the report turn's tokens, not cumulative: `resetTurn` - // clears `rawOutput` (not `tokenCount`), so `rawOutput` is just the in-flight - // report — report-scoped + consistent with the in-loop path's `recoveryTokens`. - yield* finishRecovery(a, a.rawOutput, ctx.tokenizeSync(a.rawOutput, false).length, poolChannel, tw, poolScopeId, ctx, terminalToolName); - a.transition('idle'); - safePrune(a, tw, poolScopeId); - } else if (policy.recoveryShape === 'parallel') { - // In-loop: inject the recovery turn; SETTLE re-activates it (capped - // report grammar) and the report decodes bin-packed with live agents. - const settled = yield* handleRecover(a, policy, ctx, pressureOpts, aliveCount, poolChannel, tw, poolScopeId); - if (settled) nudges.push(settled); - } else { - // Staggered: blocking recoverInline, BEFORE the idle transition — - // otherwise the statusSignal fires 'idle' mid-recovery, waitFor - // returns early, the orchestrator resumes + prefills the next task - // while this branch is still decoding → concurrent native call → SEGV. - yield* recoverInline(a, policy, ctx, store, tw, poolScopeId, poolChannel, pressureOpts, terminalGrammar, terminalToolName); - a.transition('idle'); - } - continue; - } - - // Token-stop backstop: an extracting agent that has produced its full report - // budget is force-finished here (its partial tool-call salvaged via - // parseChatOutput) rather than decoding further — this BOUNDS each in-loop - // report so a non-compliant model can't blow past the prompt's word advisory - // and exhaust KV. The prompt budget is the primary cap; this is the guillotine. - if (a.extracting && a.recoveryTokens >= a.recoveryBudget) { - yield* completeExtraction(a, poolChannel, tw, poolScopeId, ctx, pressureOpts, terminalToolName); - continue; - } - - // The voluntary report's guillotine: an agent emitting its OWN - // terminal call past the cap is force-finished exactly like the - // pressure-kill salvage — the partial call parses (truncation- - // tolerant), the report lands, the branch is pruned. Without this, - // a degenerating report decodes until KV death. - if (!a.extracting && isEmittingTerminal(a, terminalToolName) && a.turnTokens >= voluntaryReportCap) { - a.exitReason = 'report_cap'; - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentDrop', agentId: a.id, reason: 'report_cap' }); - traceAgentDone(tw, poolScopeId, a.id); - yield* poolChannel.send({ type: 'agent:done', agentId: a.id }); - yield* finishRecovery(a, a.rawOutput, a.turnTokens, poolChannel, tw, poolScopeId, ctx, terminalToolName); - a.transition('idle'); - safePrune(a, tw, poolScopeId); - continue; - } - - const { token, text, isStop } = a.branch.produceSync(); - if (isStop) { - if (a.extracting) { - // The forced recovery report finished — extract + idle + child-safe - // prune (the KV is dead weight). `agent:done` already fired at recovery - // entry; completeExtraction emits `agent:recovered`. - yield* completeExtraction(a, poolChannel, tw, poolScopeId, ctx, pressureOpts, terminalToolName); + if (paused && pendingCancels.length === 0) { + // Hold: nothing decodes until play. A cancel arriving mid-hold runs + // as a hold tick (reclamation needs no decode). + yield* wakeSub.next(); continue; } - const parsed = a.finalize(ctx); - - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'agent:turn', agentId: a.id, turn: a.turns, - rawOutput: a.rawOutput, - parsedContent: parsed.content || null, - parsedToolCalls: parsed.toolCalls.map(tc => ({ name: tc.name, arguments: tc.arguments })), - }); - recordFor(a.id).push({ kind: 'assistant', text: a.rawOutput }); - - // Policy decides what to do with the parsed output - const action = policy.onProduced(a, parsed, pressure, policyConfig); - - switch (action.type) { - case 'free_text_return': - yield* handleFreeTextReturn(a, action.content, poolChannel, tw, poolScopeId); - continue; - case 'idle': - // Parallel: recover in-loop at the stop (no termination sweep for - // parallel). Staggered: idle now, recovered at the sweep. - if (policy.recoveryShape === 'parallel') { - if (action.reason !== 'free_text_stop') { - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentDrop', agentId: a.id, - reason: action.reason === 'max_turns' ? 'maxTurns' : 'pressure_softcut' }); - } - traceAgentDone(tw, poolScopeId, a.id); - yield* poolChannel.send({ type: 'agent:done', agentId: a.id }); - const settled = yield* handleRecover(a, policy, ctx, pressureOpts, aliveCount, poolChannel, tw, poolScopeId); - if (settled) nudges.push(settled); - } else { - yield* handleIdleDrop(a, action.reason, poolChannel, tw, poolScopeId); - } - continue; - case 'nudge': - // authGuard rejection: emit the structured - // tool:authReject event BEFORE the generic agentNudge so a - // single trace pass captures attribution + rejection context. - if (action.guard === 'auth_reject') { - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'tool:authReject', - agentId: a.id, - assignedAbility: a.assignedAbility, - attemptedTool: parsed.toolCalls[0].name, - lineageHistory: a.walkAncestors((x) => x.toolHistory), - }); - } - nudges.push(yield* handleNudge(a, action.message, parsed.toolCalls[0], ctx, tools)); - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentNudge', agentId: a.id, reason: 'nudge', message: action.message, - tool: parsed.toolCalls[0]?.name, args: parsed.toolCalls[0]?.arguments, guard: action.guard }); - continue; - case 'return': - yield* handleReturn(a, action.result, parsed.toolCalls[0], terminalToolName!, pruneOnReturn, poolChannel, tw, poolScopeId); - totalToolCalls++; - continue; - case 'tool_call': - a.transition('awaiting_tool'); - toolCalls.push({ agent: a, tc: action.tc }); - a.resetTurn(); - continue; - } - } - - entries.push([a.branch, token]); - if (trace) { - const entropy = a.branch.modelEntropy(); - const surprisal = a.branch.modelSurprisal(token); - a.accumulateTokenWithTrace(text, entropy, surprisal); - a.observe(ctx); - yield* poolChannel.send({ - type: 'agent:produce', agentId: a.id, text, tokenCount: a.tokenCount, - entropy, surprisal, - }); - } else { - a.accumulateToken(text); - a.observe(ctx); - yield* poolChannel.send({ type: 'agent:produce', agentId: a.id, text, tokenCount: a.tokenCount }); - } - } - - // -- Phase 2: COMMIT -- batch-decode produced tokens - if (entries.length > 0) { - try { - yield* waitUntilSettled(store.commit(entries)); - } catch (e) { - // Decode OOM (concurrent in-loop reports exhausted KV) tears down the pool. - // This batch is where admitted extractors decode their reports; unlike the - // blocking `recoverInline` path (its own scope_error catch), an in-loop - // extractor here would be orphaned with NO terminal event → eternal "writing - // report" spinner. Announce each in-flight extractor failed BEFORE propagating - // (the KV is exhausted — the run can't continue, so rethrow after). - const reason = `scope_error: ${(e as Error).message ?? 'unknown'}`; - for (const a of agents) { - if (!a.extracting || a.status !== 'active') continue; - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:recoveryFailed', agentId: a.id, reason, outputExcerpt: a.rawOutput.slice(0, 200) }); - yield* poolChannel.send({ type: 'agent:failed', agentId: a.id, reason }); - } - throw e; - } - steps++; - const commitPressure = new ContextPressure(ctx, pressureOpts); - // One `pool:tick` per batched decode — the trace-side pressure series - // (the bus `agent:tick` below is its live twin). - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:tick', phase: 'COMMIT', - activeAgents: agents.filter(x => x.status === 'active' || x.status === 'awaiting_tool').length, - pressure: { - remaining: finiteOrNull(commitPressure.remaining), cellsUsed: commitPressure.cellsUsed, - nCtx: commitPressure.nCtx, headroom: finiteOrNull(commitPressure.headroom), - }, - }); - yield* poolChannel.send({ type: 'agent:tick', cellsUsed: commitPressure.cellsUsed, nCtx: commitPressure.nCtx }); - } - - // -- Phase 2.5: DRAIN -- post-process fan-out tools that finished since - // the last tick, ON THE LOOP FIBER (their tokenize/ctx reads happen here, - // never in the child). Each becomes a SettledTool for THIS tick's SETTLE. - const newlySettled: SettledTool[] = []; - if (completedTools.length > 0) { - for (const c of completedTools.splice(0)) { - // The inflightTasks entry was already removed by the child's `ensure` - // (runs synchronously on completion before the loop resumes — and on - // halt too, which is the case DRAIN can't see). DRAIN just post-processes. - let settled: SettledTool | null = null; - try { - settled = yield* processCompletion(c); - } catch (err) { - yield* failCompletion(c, err); - } - if (settled) newlySettled.push(settled); - } - } - - // -- Phase 3: SETTLE (settle what fits, defer what doesn't) - const toSettle = [...pendingSettled, ...nudges, ...newlySettled]; - const deferred = toSettle.length > 0 ? yield* settle(toSettle) : []; - - // Stall-breaker: `deferred` has items but no active siblings can free - // KV. Consult policy per deferred item — the policy is the "last - // resort" decision point (staggered-exit for parallel orchestration - // still works because defer-on-oversize above lets items wait while - // siblings are active; only when ALL siblings are awaiting_tool or - // idle do we reach here). Distinct drop reasons: - // - `pressure_settle_reject` — policy said idle, or nudge but the - // nudge payload itself doesn't fit (policy suggestion infeasible). - // - `settle_stall_break` — policy hook absent (legacy fallback). - if (deferred.length > 0 && !agents.some(a => a.status === 'active')) { - const stallPressure = new ContextPressure(ctx, pressureOpts); - let stallHeadroom = stallPressure.headroom; - const resolved: SettledTool[] = []; - - for (const item of deferred) { - const a = agentById.get(item.agentId); - if (!a || a.status !== 'awaiting_tool' || a.branch.disposed) continue; - - // rc-deferred items (docs/self-healing.md) ride THROUGH the - // stall-break: their retry is a re-DISPATCH — a transient rc 1 can - // clear without a sibling freeing KV — and they carry their own - // budget (MAX_DEFER_ATTEMPTS terminates them within bounded ticks). - // Headroom-deferred items have no deferAttempts entry and keep the - // existing policy consult below. - if (deferAttempts.has(item.agentId)) { resolved.push(item); continue; } - - const action = policy.onSettleReject?.(a, settledCells(item), stallPressure, policyConfig); - - if (action?.type === 'nudge') { - // Record the policy's decision regardless of whether the - // nudge itself fits — the event captures "policy consulted, - // returned nudge" which is separate from "nudge was actionable". - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentNudge', agentId: a.id, reason: 'settle_reject', message: action.message, - tool: item.toolName, args: item.args, - }); - const nudgeResult = { error: action.message }; - const nudgeTokens = buildToolResultDelta(ctx, JSON.stringify(nudgeResult), item.callId, { enableThinking: a.fmt.enableThinking }); - if (nudgeTokens.length <= stallHeadroom) { - const probe = tools.get(item.toolName)?.probe(nudgeResult) ?? undefined; - a.incrementTurns(); - resolved.push({ - rail: 'token', - agentId: a.id, - prefillTokens: nudgeTokens, - toolName: item.toolName, - callId: item.callId, - args: item.args, - probe, - }); - stallHeadroom -= nudgeTokens.length; - continue; - } - // Nudge doesn't fit — policy's suggestion is infeasible, fall through to drop. - } - - // Drop. Reason: policy-said-idle OR nudge-didn't-fit → - // `pressure_settle_reject` (policy path). Policy hook absent → - // `settle_stall_break` (legacy fallback). - const reason: 'pressure_settle_reject' | 'settle_stall_break' = - action ? 'pressure_settle_reject' : 'settle_stall_break'; - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:agentDrop', agentId: a.id, reason, - }); - // `agent:done` is one-shot. An already-`extracting` agent got here via the - // critical-kill path, which ALREADY emitted `agent:done` at the kill; its - // recovery turn deferred and re-surfaced at the stall-break. Re-announcing it - // done would double-emit (violating the invariant `agent-pool.test.ts` asserts). - if (!a.extracting) { - traceAgentDone(tw, poolScopeId, a.id); - yield* poolChannel.send({ type: 'agent:done', agentId: a.id }); + if (!paused && wasPaused) { + const pausedMs = performance.now() - heldAt; + pausedTotal += pausedMs; + yield* emit.emit({ kind: 'resumed', pausedMs }); + wasPaused = false; } - if (policy.recoveryShape === 'parallel' && !a.extracting) { - // In-loop (first attempt): queue the recovery turn for next tick's - // SETTLE (alongside the surviving nudges). Agent is awaiting_tool → no - // transition. - const settled = yield* handleRecover(a, policy, ctx, pressureOpts, aliveCount, poolChannel, tw, poolScopeId); - if (settled) resolved.push(settled); - } else { - // Staggered — OR a parallel agent ALREADY extracting whose recovery - // turn couldn't fit headroom (reaching the stall-break with no active - // siblings to free KV). Fall back to blocking recoverInline, which - // extracts within the hardLimit reserve. BEFORE transition → - // single-fiber store discipline. This is what prevents the - // defer→stall→re-queue non-terminating loop when the turn never fits. - yield* recoverInline(a, policy, ctx, store, tw, poolScopeId, poolChannel, pressureOpts, terminalGrammar, terminalToolName); - a.transition('idle'); + for (const c of completed.splice(0)) yield* executor.intake(c); + if (idleTicks > 0 && !paused && pendingCancels.length === 0) { + // Nothing ran last tick: wait for a wake or the next parked retry. + const nextDue = pending.retries.length > 0 + ? Math.min(...pending.retries.map(r => r.notBefore)) - performance.now() + : 50; + yield* race([sleep(Math.max(1, Math.min(50, nextDue))), wakeSub.next()]); + for (const c of completed.splice(0)) yield* executor.intake(c); } - } - - // Replace deferred with the surviving (nudged) items for next tick. - deferred.length = 0; - deferred.push(...resolved); - } - - // -- Phase 4: DISPATCH - // Wind-down abandons parked retries: the drain reports with what agents - // HAVE — waiting out infrastructure weather (a rate-limit park can be - // 60–90s) to gather MORE evidence contradicts it, and the reap can't - // touch an awaiting_tool agent until its park settles. Settle an honest - // failure through the normal path instead; the agent turns active on - // settle and the next tick's reap recovers its report. An agent - // cancelled WHILE parked left its entry behind (it is idle/pruned) — - // discarded here rather than re-executed. - const abandoned: SettledTool[] = []; - if (windingDown && pendingRetries.length > 0) { - for (const r of pendingRetries.splice(0)) { - if (r.agent.status !== 'awaiting_tool') continue; - const result = { error: - `${r.tc.name} is unavailable (rate-limited) and the run is winding down — ` + - `report your findings with what you have.` }; - const resultStr = JSON.stringify(result); - yield* poolChannel.send({ type: 'agent:tool_result', agentId: r.agent.id, tool: r.tc.name, result: resultStr }); - const prefillTokens = buildToolResultDelta(ctx, resultStr, r.callId, { enableThinking: r.agent.fmt.enableThinking }); - tw.write({ traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'tool:result', agentId: r.agent.id, tool: r.tc.name, - result, cells: prefillTokens.length, durationMs: 0 }); - abandoned.push({ rail: 'token', agentId: r.agent.id, prefillTokens, toolName: r.tc.name, callId: r.callId, args: r.tc.arguments, probe: undefined }); - } - } - // Due retries re-enter first — their agents have been parked since the - // ToolRetryError and re-execute the same call (same callId, no counter - // increments). - const nowTs = performance.now(); - const dueRetries: typeof pendingRetries = []; - for (let i = pendingRetries.length - 1; i >= 0; i--) { - if (pendingRetries[i].notBefore <= nowTs) dueRetries.unshift(...pendingRetries.splice(i, 1)); - } - const dispatched = yield* dispatch([ - ...dueRetries.map(r => ({ agent: r.agent, tc: r.tc, retryAttempt: r.attempt, retryCallId: r.callId })), - ...toolCalls, - ]); - - // Deferred + new dispatch results → next tick's SETTLE - pendingSettled = [...deferred, ...dispatched, ...abandoned]; + const state: TickState = { + tick: tick++, + now: runNow(), + pressure: new ContextPressure(ctx, pressureOpts), + agents, + pending, + signals: { paused, windDown: windingDown, cancelled: pendingCancels.splice(0), orchestratorDone }, + inflight: new Set(inflight.keys()), + }; - // -- Termination + recovery - // Wait for the orchestrator to finish before closing — it may spawn more agents. - const allIdle = agents.every(a => a.status === 'idle' || a.status === 'disposed'); - // Don't exit while a fan-out tool is still in flight or a completion is - // waiting to drain. An awaiting_tool agent already keeps allIdle false, - // but this guards the edge where its agent was killed mid-flight. - const fanoutQuiet = completedTools.length === 0 && inflightTasks.size === 0; - if (allIdle && orchestratorDone && fanoutQuiet) { - if (!recoveryAttempted) { - recoveryAttempted = true; - // Staggered leftovers reach here idle-without-result (killed by - // max_turns / time / free_text_stop). `parallel` + wind-down already - // recovered in-loop at each agent's stop (handleRecover), so this loop - // is a no-op for them. One at a time → maximum per-report headroom - // (the lossless path). - for (const a of agents) { - // A DISCARDED agent — user-cancelled or media-poisoned — is never - // force-recovered, even when its branch could not be pruned - // (non-leaf/recursed → safePrune no-op'd). - if (a.status === 'idle' && !a.result && !a.branch.disposed && !discardedIds.has(a.id)) { - yield* recoverInline(a, policy, ctx, store, tw, poolScopeId, poolChannel, pressureOpts, terminalGrammar, terminalToolName); - } + // SCHEDULE — one pure decision over one value. + const S = scheduler.schedule(state, policy); + Object.assign(pending, S.remaining); + yield* applier.applySchedule(S); + if (S.close) { + if (orchestratorError) throw orchestratorError; + break; } - } - if (orchestratorError) throw orchestratorError; - break; - } - if (allIdle && !orchestratorDone) { - // All current agents done but orchestrator may spawn more. - yield* sleep(1); - } - - // All-parked: nothing active, nothing to settle/drain this tick — only - // outstanding retries and/or in-flight fan-out tools. Without this the - // loop busy-spins (parked agents are awaiting_tool, so the allIdle sleep - // above never fires). Cap the nap at 50ms so orchestrator spawns/extends - // are picked up promptly; wake early when a fan-out tool completes. - if ( - (pendingRetries.length > 0 || inflightTasks.size > 0) - && pendingSettled.length === 0 - && completedTools.length === 0 - && pendingSpawns.length === 0 - && pendingExtends.length === 0 - && !agents.some(a => a.status === 'active') - ) { - const nextDue = pendingRetries.length > 0 - ? Math.min(...pendingRetries.map(r => r.notBefore)) - : performance.now() + 50; - const nap = Math.max(1, Math.min(50, nextDue - performance.now())); - if (inflightTasks.size > 0) { - yield* race([sleep(nap), awaitToolCompletion()]); - } else { - yield* sleep(nap); - } + // EXECUTE, then APPLY what came back. + const out = yield* executor.run(S); + yield* applier.applyOutputs(out, S); + + // Quiet = nothing ran and nothing admissible waits: only parked + // retries, in-flight tools, or an orchestrator that may still spawn. + const ran = S.prefills.length + S.spawns.length + S.extends.length + S.heals.length + + S.dispatch.length + S.decode.length + S.drops.length + S.finishes.length + + S.halts.length + S.stall.length + (S.sweep ? 1 : 0) + S.abandoned.length; + const waiting = pending.items.length + pending.dispatches.length + pending.spawns.length + + pending.extends.length + pending.heals.length; + idleTicks = ran === 0 && waiting === 0 ? idleTicks + 1 : 0; + } + + emit.trace({ kind: 'closed', agents, steps: totals.steps, durationMs: performance.now() - poolT0 }); + yield* poolChannel.close(result()); + } catch { + // A decode failed beyond the ladder, or the orchestrator threw: close + // with what exists. No `pool:close` — its absence is the signal. + yield* poolChannel.close(result()); } - } - - // ── Close channel with result — consumers get AgentPoolResult as close value ─────── - // Branch cleanup is handled by each branch's ensure() from setupAgent — - // when this resource's scope exits, all ensure() callbacks fire. - tw.write({ - traceId: tw.nextId(), parentTraceId: poolScopeId, ts: performance.now(), - type: 'pool:close', - agents: agents.map(a => ({ - agentId: a.id, tokenCount: a.tokenCount, - toolCallCount: a.toolCallCount, result: a.result, - // Disposed → the pre-prune harvest (0 only if the branch died outside - // the pool's prune paths — scope teardown mid-run). - ppl: a.branch.disposed ? (a.finalPpl ?? 0) : a.branch.perplexity, - })), - totalTokens: agents.reduce((s, a) => s + a.tokenCount, 0), - steps, durationMs: performance.now() - poolT0, }); - const result: AgentPoolResult = { - agents: agents.map(a => ({ + /** The per-agent results — the same record on the normal and partial paths. */ + function result(): AgentPoolResult { + return { + agents: agents.map(a => ({ agentId: a.id, parentAgentId: a.parentId, branch: a.branch, @@ -2765,32 +372,12 @@ export function useAgentPool(opts: AgentPoolOptions): Operation s + a.tokenCount, 0), - totalToolCalls, - steps, - counters, - }; - - yield* poolChannel.close(result); - - } catch { - // KV exhaustion or other decode failure — close with partial results - const partial: AgentPoolResult = { - agents: agents.map(a => ({ - agentId: a.id, parentAgentId: a.parentId, branch: a.branch, agent: a, - result: a.result, exitReason: a.exitReason, toolCallCount: a.toolCallCount, tokenCount: a.tokenCount, - ppl: a.branch.disposed ? (a.finalPpl ?? 0) : a.branch.perplexity, - samplingPpl: a.branch.disposed ? (a.finalSamplingPpl ?? 0) : a.branch.samplingPerplexity, - trace: trace ? a.traceBuffer : undefined, - nestedResults: [...a.nestedResults], - })), - totalTokens: agents.reduce((s, a) => s + a.tokenCount, 0), - totalToolCalls, steps, counters, - }; - yield* poolChannel.close(partial); - } - - }); // end spawn — tick loop + totalTokens: agents.reduce((s, a) => s + a.tokenCount, 0), + totalToolCalls: totals.toolCalls, + steps: totals.steps, + counters, + }; + } yield* provide(subscription); }); diff --git a/packages/agents/src/apply.ts b/packages/agents/src/apply.ts new file mode 100644 index 00000000..91f7bac8 --- /dev/null +++ b/packages/agents/src/apply.ts @@ -0,0 +1,381 @@ +import type { Operation } from 'effection'; +import type { SessionContext, ParsedToolCall, ParseChatOutputResult } from '@lloyal-labs/sdk'; +import { buildToolResultDelta, buildUserDelta, decodeErrorOf } from '@lloyal-labs/sdk'; +import type { Agent } from './Agent'; +import type { AgentPolicy, PolicyConfig } from './AgentPolicy'; +import type { Tool } from './Tool'; +import { TOOL_IMAGE_ERROR_KEY } from './Tool'; +import type { Emitter } from './emit'; +import { ContextPressure } from './pressure'; +import { planRecovery } from './scheduler'; +import { + type Schedule, type Outputs, type Pending, type Drop, type RecoveryPlan, type PrefillItem, + type PrefillOutcome, type Ladder, type DropReason, + alive, classifyRc, isFatalRc, MAX_DEFER_ATTEMPTS, BACKEND_TRIPWIRE_N, MAX_HEAL_ATTEMPTS, +} from './state'; +import type { PressureThresholds } from './types'; + +/** + * The interpreter: turns decisions and outcomes into agent transitions. + * + * Two entry points, one per half of the tick. {@link Applier.applySchedule} + * enacts what the scheduler decided BEFORE the store runs (drops, the + * stall-break, the sweep); {@link Applier.applyOutputs} interprets what the + * store gave back (the ladder on failed prefills, stopped agents through + * `policy.onProduced`, the commit). Both write agents only through their + * methods and announce every change through the one {@link Emitter}. + */ + +export interface ApplyDeps { + ctx: SessionContext; + policy: AgentPolicy; + config: PolicyConfig; + tools: Map; + emit: Emitter; + pending: Pending; + ladder: Ladder; + recovery: 'serial' | 'cohort'; + reportBudget?: number; + terminalToolName?: string; + pruneOnReturn: boolean; + pressureOpts: PressureThresholds; + totals: { toolCalls: number; steps: number }; +} + +/** Strip a trailing UNCLOSED `` fragment from text captured as an + * agent result — a truncated call must not ride into another agent's prompt + * as an in-context demonstration of emitting tool calls. Complete blocks + * are left alone. */ +export function stripDanglingToolCall(text: string): string { + return text.replace(/(?:(?!<\/tool_call>)[\s\S])*$/, '').trimEnd(); +} + +/** Extract the terminal-tool result string from a parsed (possibly TRUNCATED) + * tool call: valid JSON → `.result`; a token-stop cuts mid-call, so salvage + * the `result` body from the partial and unescape it; else the raw arguments + * (a non-`{result}` terminal tool). */ +export function extractTerminalResult(args: string): string { + try { + const r = JSON.parse(args).result; + if (typeof r === 'string') return r; + } catch { /* truncated or non-JSON — salvage the partial below */ } + const m = args.match(/"result"\s*:\s*"((?:[^"\\]|\\.)*)/); + if (m) { + try { return JSON.parse(`"${m[1].replace(/\\+$/, '')}"`); } catch { /* fall through to raw */ } + } + return args; +} + +export class Applier { + constructor(private readonly d: ApplyDeps) {} + + // ── Before the store runs ────────────────────────────────────── + + *applySchedule(S: Schedule): Operation { + for (const drop of S.drops) yield* this.enactDrop(drop, S); + for (const a of S.finishes) yield* this.finishExtraction(a); + for (const o of S.stall) { + // The nudge record captures "policy consulted, returned nudge" whether + // or not the nudge was actionable; the drop it fell into follows. + if (o.nudge) { + yield* this.d.emit.emit({ kind: 'nudged', agent: o.agent, reason: 'settle_reject', message: o.nudge.message, tool: o.nudge.tool, args: o.nudge.args }); + if (o.nudge.replacement) o.agent.incrementTurns(); + } + if (o.drop) yield* this.enactDrop(o.drop, S); + } + for (const r of S.abandoned) { + // The drain reports with what agents HAVE: an honest failure settles + // through the normal path and the next reap recovers the report. + const result = { error: + `${r.tc.name} is unavailable (rate-limited) and the run is winding down — ` + + `report your findings with what you have.` }; + const resultStr = JSON.stringify(result); + yield* this.d.emit.emit({ kind: 'toolTold', agent: r.agent, tool: r.tc.name, resultStr }); + const tokens = buildToolResultDelta(this.d.ctx, resultStr, r.callId, { enableThinking: r.agent.fmt.enableThinking }); + this.d.emit.trace({ kind: 'toolResult', agent: r.agent, tool: r.tc.name, result, cells: tokens.length, durationMs: 0 }); + this.d.pending.items.push({ kind: 'toolResult', rail: 'token', agent: r.agent, tokens, toolName: r.tc.name, callId: r.callId, args: r.tc.arguments }); + } + for (const req of S.rejectedSpawns) { + // A fork that never entered the pool: free it and tell the orchestrator. + req.agent.branch.pruneSync(); + req.agent.dispose(); + if (req.discarded) continue; + this.d.emit.trace({ kind: 'drop', agent: req.agent, reason: 'pressure_init', done: false }); + req.reject(new Error(`useAgentPool: cannot fit agent suffix (${req.suffixTokens.length} tokens) under current pressure`)); + } + if (S.sweep) yield* this.recover(S.sweep.agent, S.sweep.recovery, null); + } + + /** One drop, whatever decided it: the record, then the recovery it carries. */ + *enactDrop(d: Drop, S: Schedule): Operation { + const a = d.agent; + if (d.reason === 'user_cancel') { + yield* this.d.emit.emit({ kind: 'cancelled', agent: a }); + a.failed = 'user_cancel'; + a.transition('idle'); + a.pruneRequested = true; + return; + } + if (d.exitReason) a.exitReason = d.exitReason; + // An agent that simply idles (no recovery) transitions first, so a + // waiting orchestrator resumes on the same edge it always has. + if (d.recovery.type === 'none' && a.status !== 'idle') a.transition('idle'); + yield* this.d.emit.emit({ kind: 'drop', agent: a, reason: d.reason, done: d.done }); + yield* this.recover(a, d.recovery, d.reason); + void S; + } + + /** Enact a recovery plan for an agent whose span has ended. */ + *recover(a: Agent, plan: RecoveryPlan, reason: DropReason | null): Operation { + switch (plan.type) { + case 'none': + return; + case 'salvage': { + // Mid-terminal-call: parse what it already emitted; no further decode. + // `rawOutput` is the report turn alone (resetTurn cleared the rest). + const produced = reason === 'report_cap' ? a.turnTokens : this.d.ctx.tokenizeSync(a.rawOutput, false).length; + yield* this.finishRecovery(a, a.rawOutput, produced); + a.transition('idle'); + a.pruneRequested = true; + return; + } + case 'skip': { + // `agent:done` already fired; without a terminal event the consumer + // would orphan the agent in an eternal "recovering" state. + yield* this.d.emit.emit({ kind: 'recoveryFailed', agent: a, reason: 'recovery_skipped', outputExcerpt: a.rawOutput.slice(0, 200) }); + a.failed = 'recovery_skipped'; + a.pruneRequested = true; + if (a.status !== 'idle') a.transition('idle'); + return; + } + case 'extract': { + // The recovery turn is a pending item. The agent is parked BEFORE + // anything else happens, so it never passes through `idle` on the way + // — an orchestrator waiting on it would otherwise resume against a + // result that does not exist yet. + const tokens = buildUserDelta(this.d.ctx, plan.action.prompt.user, { system: plan.action.prompt.system, enableThinking: false }); + a.incrementTurns(); + if (a.status !== 'awaiting_tool') a.transition('awaiting_tool'); + a.markExtracting(plan.budget, plan.serial); + a.resetTurn(); + this.d.pending.items.push({ kind: 'recovery', rail: 'token', agent: a, tokens, toolName: 'recovery', callId: `recovery:${a.id}`, args: '' }); + return; + } + } + } + + /** A finished (or token-stopped) in-loop report: extract, idle, free the branch. */ + *finishExtraction(a: Agent): Operation { + yield* this.finishRecovery(a, a.rawOutput, a.recoveryTokens); + a.transition('idle'); + a.pruneRequested = true; + } + + /** Parse a recovery output, set the result (source `recovery`), announce. */ + *finishRecovery(a: Agent, output: string, producedTokens: number): Operation { + yield* this.d.emit.emit({ kind: 'recoveryProduce', agent: a, tokenCount: producedTokens, outputLength: output.length }); + const parsed = this.d.ctx.parseChatOutput(output, a.fmt.format, { + reasoningFormat: a.fmt.reasoningFormat, generationPrompt: a.fmt.generationPrompt, parser: a.fmt.parser, + }); + // With a terminal tool designated the report MUST be that tool's call; + // without one, whatever the model produced. + const terminal = this.d.terminalToolName; + const call = terminal ? parsed.toolCalls.find(c => c.name === terminal) : parsed.toolCalls[0]; + if (call) { + const result = extractTerminalResult(call.arguments); + if (result) { + a.setResult(stripDanglingToolCall(result), 'recovery'); + yield* this.d.emit.emit({ kind: 'recovered', agent: a, result: a.result! }); + return true; + } + } + const reason = call ? 'empty_terminal_result' : 'no_terminal_call'; + yield* this.d.emit.emit({ kind: 'recoveryFailed', agent: a, reason, outputExcerpt: output.slice(0, 200) }); + a.failed = reason; + return false; + } + + // ── After the store ran ──────────────────────────────────────── + + *applyOutputs(out: Outputs, S: Schedule): Operation { + if (out.tokenRail && !out.tokenRail.outcome.ok) yield* this.tokenRailFailed(out.tokenRail.items, out.tokenRail.outcome); + for (const { item, outcome } of out.mediaRail) if (!outcome.ok) yield* this.mediaEntryFailed(item, outcome); + + for (const p of out.produced) { + if (!p.isStop) continue; + yield* this.stopped(p.agent, p.parsed, S); + } + + if (out.committed) { + this.d.totals.steps++; + yield* this.d.emit.emit({ kind: 'tick', activeAgents: this.countAlive(S), pressure: out.commitPressure! }); + } + if (out.fatal) { + if (out.fatal.phase === 'commit') { + // KV exhausted mid-report: announce each in-flight extractor failed + // BEFORE the pool closes partial, else the UI spins on "writing report". + const reason = `scope_error: ${(out.fatal.err as Error)?.message ?? 'unknown'}`; + for (const a of this.agentsOf(S)) { + if (!a.extracting || a.status !== 'active') continue; + yield* this.d.emit.emit({ kind: 'recoveryFailed', agent: a, reason, outputExcerpt: a.rawOutput.slice(0, 200) }); + a.failed = reason; + } + } + throw out.fatal.err; + } + } + + private countAlive(S: Schedule): number { + return this.agentsOf(S).filter(alive).length; + } + + /** Every agent the pool holds — the scheduler's view of the roster. */ + private agentsOf(S: Schedule): readonly Agent[] { + return S.roster; + } + + /** The agent hit its stop token: the turn is over; the policy decides. */ + private *stopped(a: Agent, parsed: ParseChatOutputResult | null, S: Schedule): Operation { + if (a.extracting || !parsed) { yield* this.finishExtraction(a); return; } + yield* this.d.emit.emit({ kind: 'turn', agent: a, parsed }); + a.records.push({ kind: 'assistant', text: a.rawOutput }); + const action = this.d.policy.onProduced(a, parsed, S.pressure, this.d.config); + switch (action.type) { + case 'free_text_return': + a.setResult(stripDanglingToolCall(action.content), 'free_text'); + a.transition('idle'); + yield* this.d.emit.emit({ kind: 'returned', agent: a, via: 'free_text' }); + return; + case 'idle': { + const reason: DropReason | null = action.reason === 'free_text_stop' ? null + : action.reason === 'max_turns' ? 'maxTurns' : 'pressure_softcut'; + const exitReason = reason === 'maxTurns' || reason === 'pressure_softcut' ? reason : undefined; + const mode = S.mode; + yield* this.enactDrop({ + agent: a, reason, done: true, exitReason, + recovery: mode === 'cohort' + ? planRecovery(a, this.d.policy, S.pressure, S.alive, 'cohort', this.d.reportBudget) + : { type: 'none' }, + }, S); + return; + } + case 'nudge': { + const tc = parsed.toolCalls[0] as ParsedToolCall | undefined; + if (action.guard === 'auth_reject') yield* this.d.emit.emit({ kind: 'authRejected', agent: a, attemptedTool: parsed.toolCalls[0].name }); + yield* this.nudge(a, action.message, tc); + yield* this.d.emit.emit({ kind: 'nudged', agent: a, reason: 'nudge', message: action.message, tool: tc?.name, args: tc?.arguments, guard: action.guard }); + return; + } + case 'return': { + const tc = parsed.toolCalls[0]; + a.setResult(stripDanglingToolCall(action.result), 'voluntary_return'); + a.transition('idle'); + a.incrementToolCalls(); + this.d.totals.toolCalls++; + yield* this.d.emit.emit({ kind: 'returned', agent: a, via: { tool: this.d.terminalToolName!, args: tc.arguments } }); + if (this.d.pruneOnReturn) a.pruneRequested = true; + return; + } + case 'tool_call': + a.transition('awaiting_tool'); + this.d.pending.dispatches.push({ agent: a, tc: action.tc }); + a.resetTurn(); + return; + } + } + + /** Replace a rejected call with a compact error payload the model reads next turn. */ + private *nudge(a: Agent, message: string, tc: ParsedToolCall | undefined): Operation { + const callId = tc?.id || `call_${a.toolCallCount}`; + const nudgeResult = { error: message }; + a.incrementTurns(); + a.transition('awaiting_tool'); + const tokens = buildToolResultDelta(this.d.ctx, JSON.stringify(nudgeResult), callId, { enableThinking: a.fmt.enableThinking }); + const probe = this.d.tools.get(tc?.name || '')?.probe(nudgeResult) ?? undefined; + a.resetTurn(); + this.d.pending.items.push({ kind: 'nudge', rail: 'token', agent: a, tokens, toolName: tc?.name || '', callId, args: tc?.arguments || '', probe }); + } + + // ── The ladder ───────────────────────────────────────────────── + + private *tokenRailFailed(items: PrefillItem[], o: PrefillOutcome & { ok: false }): Operation { + switch (classifyRc(o.rc, o.partial, this.d.ladder.backendSuspect)) { + case 'fail': + // An earlier chunk landed and the error does not say which: the cohort + // takes the per-agent terminal rather than decode landed cells twice. + for (const it of items) yield* this.failSettled(it.agent, 'tool_result_failed', `partial prefill: ${o.message}`, o.rc); + return; + case 'defer': + for (const it of items) yield* this.defer(it, o.rc!, `deferral exhausted after ${MAX_DEFER_ATTEMPTS} attempts: ${o.message}`, 'tool_result_failed'); + return; + case 'fatal': + if (isFatalRc(o.rc)) this.d.ladder.consecutiveFatalRc++; + throw Object.assign(new Error(o.message), { rc: o.rc, partial: o.partial }); + } + } + + private *mediaEntryFailed(it: PrefillItem, o: PrefillOutcome & { ok: false }): Operation { + const a = it.agent; + if (o.rc === 1 && !o.partial && !this.d.ladder.backendSuspect) { + yield* this.defer(it, o.rc, `deferral exhausted after ${MAX_DEFER_ATTEMPTS} attempts: ${o.message}`, 'media_prefill_failed'); + return; + } + if (o.rc === -1 && !o.partial && !this.d.ladder.backendSuspect) { + // Invalid input, state restored: the item is deterministic and retrying + // loops. Tell the model what it did not see, on the same channel the + // no-projector path uses; the note lands as the next admission. + const told = JSON.parse(it.resultStr!) as Record; + const note = { ...told, [TOOL_IMAGE_ERROR_KEY]: + `${it.toolName} returned media the decoder rejected as invalid input. Work from the text, or use a different source.` }; + const noteStr = JSON.stringify(note); + const tokens = buildToolResultDelta(this.d.ctx, noteStr, it.callId, { enableThinking: a.fmt.enableThinking }); + this.d.pending.items.push({ kind: 'toolResult', rail: 'token', agent: a, tokens, toolName: it.toolName, callId: it.callId, args: it.args, probe: it.probe, resultStr: noteStr }); + return; + } + if (isFatalRc(o.rc)) { + this.d.ladder.consecutiveFatalRc++; + if (this.d.ladder.consecutiveFatalRc >= BACKEND_TRIPWIRE_N) this.d.ladder.backendSuspect = true; + } + const detail = this.d.ladder.backendSuspect + ? `${o.message} [backend suspect: ${this.d.ladder.consecutiveFatalRc} consecutive fatal decodes — recreate the backend]` + : o.message; + yield* this.failSettled(a, 'media_prefill_failed', detail, o.rc); + // HEAL: the poison cost the agent its branch, not its task. Replay up to + // the last COMPLETED transaction — the poisoned turn's own assistant text + // is dropped so the replacement regenerates it and drives the tool itself. + const attempt = a.healAttempt + 1; + if (!this.d.ladder.backendSuspect && attempt <= MAX_HEAL_ATTEMPTS && a.spec) { + const records = a.records.slice(); + while (records.length > 0 && records[records.length - 1].kind === 'assistant') records.pop(); + this.d.pending.heals.push({ spec: a.spec, records, of: a.id, ...(o.rc !== undefined ? { rc: o.rc } : {}), attempt }); + } + } + + private *defer(it: PrefillItem, rc: number, exhaustedDetail: string, reason: 'media_prefill_failed' | 'tool_result_failed'): Operation { + const a = it.agent; + const attempt = ++a.deferAttempts; + if (attempt > MAX_DEFER_ATTEMPTS) { yield* this.failSettled(a, reason, exhaustedDetail, rc); return; } + yield* this.d.emit.emit({ kind: 'deferred', agent: a, rc, attempt, pressure: new ContextPressure(this.d.ctx, this.d.pressureOpts) }); + this.d.pending.items.push(it); + } + + *failSettled(a: Agent, reason: 'media_prefill_failed' | 'tool_result_failed', detail: string, rc?: number): Operation { + yield* failSettled(this.d.emit, a, reason, detail, rc); + } +} + +/** The ladder's bottom rung: the agent is DISCARDED — announced, pruned, never + * resumed. Shared with the executor's intake, whose failures carry the + * dispatch as their trace parent. */ +export function* failSettled( + emit: Emitter, a: Agent, reason: 'media_prefill_failed' | 'tool_result_failed', + detail: string, rc?: number, parentTraceId?: number, +): Operation { + yield* emit.emit({ kind: 'settleFailed', agent: a, reason, detail, rc, parentTraceId }); + a.failed = reason; + a.pruneRequested = true; + if (a.status !== 'idle') a.transition('idle'); +} + +export { decodeErrorOf }; +export type { ParseChatOutputResult }; diff --git a/packages/agents/src/chunk.ts b/packages/agents/src/chunk.ts index bf7a52e7..f0777ac2 100644 --- a/packages/agents/src/chunk.ts +++ b/packages/agents/src/chunk.ts @@ -91,7 +91,7 @@ export interface ScoredResult { * Cross-encoder reranker for scoring corpus chunks against a query. * * Abilities obtain the harness-wide reranker via `RerankerCtx.expect()` at - * factory time — `source.bind({reranker})` is no longer the mechanism. + * factory time and hand it to their sources and tools at construction. * Implementations tokenize chunks up front via {@link tokenizeChunks}, * then stream progressive results from {@link score}. */ diff --git a/packages/agents/src/context.ts b/packages/agents/src/context.ts index 42a28b22..d2cb15db 100644 --- a/packages/agents/src/context.ts +++ b/packages/agents/src/context.ts @@ -139,10 +139,8 @@ export const SpineFmt = createContext('lloyal.spineFmt', nu * third-party abilities) read this via `yield* RerankerCtx.expect()` at * construction time and pass it to their `Source` / search tools. * - * Replaces the per-source `source.bind({reranker})` pattern — chunks - * tokenized by one reranker can't be re-bound to another without - * re-tokenization, so one cross-encoder per harness - * is the invariant. + * One reranker per harness is the invariant: chunks tokenized by one + * reranker can't be re-scored by another without re-tokenization. * * @category Contract */ @@ -252,7 +250,7 @@ export const CancelAgent = createContext>('llo * clock — rate limits elapse in the real world. * * Pause takes effect at the next tick boundary: an in-flight recovery decode - * (`recoverInline`, the termination sweep) completes first. Lifecycle + * (the close-time recovery sweep) completes first. Lifecycle * sequencing is the harness's job — the pool holds while paused regardless of * other signals; conflicting commands (wind-down while paused) are the * consumer's to refuse. Absent context = no pause capability. diff --git a/packages/agents/src/diverge.ts b/packages/agents/src/diverge.ts deleted file mode 100644 index e88f4f35..00000000 --- a/packages/agents/src/diverge.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { ensure } from 'effection'; -import type { Operation } from 'effection'; -import { waitUntilSettled } from './combinators'; -import { Branch } from '@lloyal-labs/sdk'; -import { Ctx, Store } from './context'; -import { ContextPressure } from './agent-pool'; -import type { DivergeOptions, DivergeResult, DivergeAttempt } from './types'; - -/** - * Multi-branch perplexity selection as an Effection operation - * - * Forks N branches from a parent (or a fresh root), generates to EOG via - * batched {@link BranchStore.commit}, then selects the lowest-perplexity - * attempt. Loser branches are pruned; the caller receives the best branch - * still alive. - * - * When `opts.parent` is provided, the parent branch is NOT pruned — it's - * owned by the calling scope. Only the forked attempt branches (losers) - * are pruned. The caller owns the winning branch's lifecycle, typically - * via {@link Session.promote}. - * - * Cleanup is structured: each forked branch registers an `ensure()` callback - * that prunes it on scope exit. Winners are marked disposed-safe (already - * pruned or ownership transferred) before the ensure fires. - * - * @param opts - Diverge options specifying parent or prompt, attempt count, - * and sampling parameters - * @returns Result containing the best branch, all attempt outputs, and - * aggregate statistics - * - * @example Verify with perplexity selection - * ```typescript - * const verified = yield* diverge({ - * prompt: verifyPrompt, - * attempts: 3, - * params: { temperature: 0.7 }, - * }); - * // verified.best is the lowest-perplexity branch, still alive - * yield* waitUntilSettled( session.promote(verified.best)); - * ``` - * - * @category Agents - */ -export function* diverge(opts: DivergeOptions): Operation { - const ctx = yield* Ctx.expect(); - const store = yield* Store.expect(); - - // If parent provided, fork from it. Otherwise create a fresh root. - let root: Branch; - let ownRoot = false; - let prefixLength: number; - - if (opts.parent) { - root = opts.parent; - prefixLength = root.position; - } else { - if (!opts.prompt) throw new Error('diverge() requires either opts.parent or opts.prompt'); - const tokens = ctx.tokenizeSync(opts.prompt); - root = Branch.create(ctx, 0, opts.params ?? {}); - yield* waitUntilSettled( root.prefill(tokens)); - prefixLength = tokens.length; - ownRoot = true; - // If we created the root, ensure it's cleaned up - yield* ensure(() => { - if (ownRoot && !root.disposed) { - try { root.pruneSync(); } catch { /* children may remain */ } - } - }); - } - - const live: { branch: Branch; output: string; done: boolean; tokenCount: number; ppl: number }[] = []; - - for (let i = 0; i < opts.attempts; i++) { - const branch = root.forkSync(); - // Each forked branch gets its own ensure() for structured cleanup - yield* ensure(() => { - if (!branch.disposed) { - try { branch.pruneSync(); } catch { /* already gone */ } - } - }); - branch.reseedSampler((opts.seedBase ?? 2000) + i); - live.push({ branch, output: '', done: false, tokenCount: 0, ppl: Infinity }); - } - - // Batched generation — produceSync/commit loop - let steps = 0; - for (;;) { - const pressure = new ContextPressure(ctx); - if (pressure.critical) { - for (const a of live) { if (!a.done) a.done = true; } - break; - } - - const entries: [Branch, number][] = []; - for (const a of live) { - if (a.done) continue; - const { token, text, isStop } = a.branch.produceSync(); - if (isStop) { - const p = a.branch.perplexity; - a.ppl = Number.isFinite(p) ? p : Infinity; - a.done = true; - continue; - } - entries.push([a.branch, token]); - a.output += text; - a.tokenCount++; - } - if (entries.length === 0) break; - yield* waitUntilSettled( store.commit(entries)); - steps++; - } - - // Select by lowest perplexity (most coherent) - const bestIdx = live.reduce((bi, a, i) => a.ppl <= live[bi].ppl ? i : bi, 0); - - // Prune losers now — winner stays alive as caller's result. - // ensure() will be a no-op for these since they're already disposed. - for (let i = 0; i < live.length; i++) { - if (i !== bestIdx && !live[i].branch.disposed) { - live[i].branch.pruneSync(); - } - } - - // If we created root and it's no longer needed, prune it now. - // (ensure() will be a no-op since it checks disposed) - if (ownRoot && !root.disposed && root.children.length === 0) { - root.pruneSync(); - } - - const totalTokens = live.reduce((s, a) => s + a.tokenCount, 0); - const attempts: DivergeAttempt[] = live.map(a => ({ - branch: a.branch, - output: a.output, - tokenCount: a.tokenCount, - ppl: a.ppl, - })); - - return { - best: live[bestIdx].branch, - bestOutput: live[bestIdx].output, - attempts, - totalTokens, - steps, - prefixLength, - }; -} diff --git a/packages/agents/src/emit.ts b/packages/agents/src/emit.ts new file mode 100644 index 00000000..5529b6fa --- /dev/null +++ b/packages/agents/src/emit.ts @@ -0,0 +1,295 @@ +import type { Operation } from 'effection'; +import type { ParseChatOutputResult } from '@lloyal-labs/sdk'; +import type { Attachment } from '@lloyal-labs/media'; +import type { Agent } from './Agent'; +import type { AgentEvent } from './types'; +import type { TraceEvent } from './trace-types'; +import type { TraceWriter } from './trace-writer'; +import type { DropReason } from './state'; +import { type ContextPressure, finiteOrNull, pressureRecord } from './pressure'; + +/** + * The projection from what the pool DID to what the wire says. + * + * Every trace record and every channel event the pool emits is produced here, + * from a {@link Transition} value, as an ORDERED list of emissions. Today's + * contract — the event types, their fields, and the order within a sequence + * (a return is `agent:done` on the trace, then `agent:return` on the bus, + * then `agent:done` on the bus) — is this one function. A different wire + * format is a different projection, never a second emitter in the loop. + * + * Attribution lives in the data: agent-owned records carry `agentId` (or + * `branchHandle`) on the record itself; the writer never re-derives it. + */ + +type DistOmit = T extends unknown ? Omit : never; + +/** A trace event minus the writer's envelope. `ts` defaults to now, + * `parentTraceId` to the pool scope, `traceId` to the next id. */ +export type TraceBody = DistOmit & { + traceId?: number; ts?: number; parentTraceId?: number | null; +}; + +export type Emission = { trace: TraceBody } | { bus: AgentEvent }; + +/** The unit of change the pool announces. */ +export type Transition = + // ── the agent's span ── + | { kind: 'drop'; agent: Agent; reason: DropReason | null; done: boolean } + | { kind: 'returned'; agent: Agent; via: { tool: string; args: string } | 'free_text' } + | { kind: 'cancelled'; agent: Agent } + | { kind: 'spawned'; agent: Agent; after?: number[] } + | { kind: 'created'; agent: Agent } + | { kind: 'formatted'; agent: Agent; promptText: string; taskContent: string; tokenCount: number; systemPrompt: string; tools?: string } + | { kind: 'turn'; agent: Agent; parsed: ParseChatOutputResult } + | { kind: 'produced'; agent: Agent; text: string; entropy?: number; surprisal?: number } + // ── recovery ── + | { kind: 'recoveryProduce'; agent: Agent; tokenCount: number; outputLength: number } + | { kind: 'recovered'; agent: Agent; result: string } + | { kind: 'recoveryFailed'; agent: Agent; reason: string; outputExcerpt: string } + // ── admission and the ladder ── + | { kind: 'settleFailed'; agent: Agent; reason: 'media_prefill_failed' | 'tool_result_failed'; detail: string; rc?: number; parentTraceId?: number } + | { kind: 'deferred'; agent: Agent; rc: number; attempt: number; pressure: ContextPressure } + | { kind: 'healed'; of: number; agent: Agent; rc?: number; attempt: number; pressure: ContextPressure } + | { kind: 'prefilled'; agent: Agent; cells: number; role: 'toolResult' | 'recovery' | 'probe'; attachments?: readonly Attachment[]; probeText?: string } + | { kind: 'settleOrder'; batch: Array<{ agentId: number; callId: string; cells: number }> } + | { kind: 'pruned'; agent: Agent; position: number } + // ── nudges and guards ── + | { kind: 'nudged'; agent: Agent; reason: 'nudge' | 'settle_reject'; message: string; tool?: string; args?: string; guard?: string } + | { kind: 'authRejected'; agent: Agent; attemptedTool: string } + // ── tools ── + | { kind: 'toolCalled'; agent: Agent; tool: string; args: string } + | { kind: 'dispatched'; traceId: number; ts: number; agent: Agent; tool: string; toolIndex: number; toolkitSize: number; args: Record; callId: string; explore: boolean; percentAvailable: number } + /** The model is TOLD the result — the bus event, sent before the barrier + * measures it, so a consumer sees the tool answer even if admission fails. */ + | { kind: 'toolTold'; agent: Agent; tool: string; resultStr: string; contextAvailablePercent?: number } + /** The result's record on the trace, written once its cost is known. */ + | { kind: 'toolResult'; agent: Agent; tool: string; result: unknown; cells: number; durationMs: number; parentTraceId?: number } + | { kind: 'toolRetry'; agent: Agent; tool: string; callId: string; retryAfterMs: number; attempt: number; parentTraceId: number } + | { kind: 'toolError'; agent: Agent; tool: string; error: string; parentTraceId: number } + // ── the spine and the pool ── + | { kind: 'extended'; userContent: string; assistantContent: string; deltaTokens: number; positionAfter: number } + | { kind: 'opened'; pressure: ContextPressure } + | { kind: 'closed'; agents: readonly Agent[]; steps: number; durationMs: number } + | { kind: 'tick'; activeAgents: number; pressure: ContextPressure } + | { kind: 'kvTick'; pressure: ContextPressure } + | { kind: 'paused'; ts: number } + | { kind: 'resumed'; pausedMs: number } + | { kind: 'windingDown' }; + +const agentDone = (agent: Agent): Emission[] => [ + // Trace before the suspending bus send: the send waits on subscriber + // backpressure, which must not inflate the span-end ts. + { trace: { type: 'agent:done', agentId: agent.id } }, + { bus: { type: 'agent:done', agentId: agent.id } }, +]; + +/** Branch metrics for the close record: the pre-prune harvest once the branch is gone. */ +const pplOf = (a: Agent): number => a.branch.disposed ? (a.finalPpl ?? 0) : a.branch.perplexity; + +export function project(t: Transition): Emission[] { + switch (t.kind) { + case 'drop': { + const out: Emission[] = []; + if (t.reason) out.push({ trace: { type: 'pool:agentDrop', agentId: t.agent.id, reason: t.reason } }); + if (t.done) out.push(...agentDone(t.agent)); + return out; + } + case 'returned': { + const a = t.agent; + const out: Emission[] = []; + if (t.via !== 'free_text') out.push({ bus: { type: 'agent:tool_call', agentId: a.id, tool: t.via.tool, args: t.via.args } }); + out.push({ trace: { type: 'agent:done', agentId: a.id } }); + out.push({ bus: { type: 'agent:return', agentId: a.id, result: a.result! } }); + out.push({ bus: { type: 'agent:done', agentId: a.id } }); + return out; + } + case 'cancelled': + // No `agent:done`: the UI resolves straight to "cancelled" with no recovering flash. + return [ + { trace: { type: 'pool:agentDrop', agentId: t.agent.id, reason: 'user_cancel' } }, + { bus: { type: 'agent:failed', agentId: t.agent.id, reason: 'user_cancel' } }, + ]; + case 'spawned': { + const after = t.after && t.after.length > 0 ? { after: t.after } : {}; + return [ + { trace: { type: 'agent:spawn', agentId: t.agent.id, parentAgentId: t.agent.parentId, ...after } }, + { bus: { type: 'agent:spawn', agentId: t.agent.id, parentAgentId: t.agent.parentId, ...after } }, + ]; + } + case 'created': + return [{ trace: { type: 'branch:create', branchHandle: t.agent.id, parentHandle: t.agent.parentId, position: t.agent.forkHead, role: 'agentFork' } }]; + case 'formatted': + return [{ trace: { + type: 'prompt:format', agentId: t.agent.id, promptText: t.promptText, + taskContent: t.taskContent, tokenCount: t.tokenCount, + messages: JSON.stringify([ + { role: 'system', content: t.systemPrompt }, + { role: 'user', content: t.taskContent }, + ]), + tools: t.tools, role: 'agentSuffix', + } }]; + case 'turn': + return [{ trace: { + type: 'agent:turn', agentId: t.agent.id, turn: t.agent.turns, + rawOutput: t.agent.rawOutput, + parsedContent: t.parsed.content || null, + parsedToolCalls: t.parsed.toolCalls.map(tc => ({ name: tc.name, arguments: tc.arguments })), + } }]; + case 'produced': + return [{ bus: { + type: 'agent:produce', agentId: t.agent.id, text: t.text, tokenCount: t.agent.tokenCount, + ...(t.entropy !== undefined ? { entropy: t.entropy, surprisal: t.surprisal } : {}), + } }]; + case 'recoveryProduce': + return [{ trace: { type: 'pool:recoveryProduce', agentId: t.agent.id, tokenCount: t.tokenCount, outputLength: t.outputLength } }]; + case 'recovered': + return [ + { bus: { type: 'agent:recovered', agentId: t.agent.id, result: t.result } }, + { trace: { type: 'pool:recoveryReturn', agentId: t.agent.id, resultLength: t.result.length } }, + ]; + case 'recoveryFailed': + // `agent:failed` is the failure twin of `agent:recovered`: the UI leaves + // "writing report" instead of spinning forever. + return [ + { trace: { type: 'pool:recoveryFailed', agentId: t.agent.id, reason: t.reason, outputExcerpt: t.outputExcerpt } }, + { bus: { type: 'agent:failed', agentId: t.agent.id, reason: t.reason } }, + ]; + case 'settleFailed': + return [ + { trace: { + type: 'pool:settleFailed', agentId: t.agent.id, reason: t.reason, + detail: t.detail.slice(0, 200), ...(t.rc !== undefined ? { rc: t.rc } : {}), + ...(t.parentTraceId !== undefined ? { parentTraceId: t.parentTraceId } : {}), + } }, + { bus: { type: 'agent:failed', agentId: t.agent.id, reason: t.reason } }, + ]; + case 'deferred': + return [{ trace: { type: 'pool:agentDefer', agentId: t.agent.id, rc: t.rc, attempt: t.attempt, pressure: pressureRecord(t.pressure) } }]; + case 'healed': + return [{ trace: { + type: 'pool:agentHeal', of: t.of, agentId: t.agent.id, + ...(t.rc !== undefined ? { rc: t.rc } : {}), attempt: t.attempt, pressure: pressureRecord(t.pressure), + } }]; + case 'prefilled': + return [{ trace: { + type: 'branch:prefill', branchHandle: t.agent.id, cells: t.cells, role: t.role, + ...(t.attachments ? { attachments: t.attachments } : {}), + ...(t.probeText !== undefined ? { probeText: t.probeText } : {}), + } }]; + case 'settleOrder': + return [{ trace: { type: 'tool:settle_order', batch: t.batch } }]; + case 'pruned': + return [{ trace: { type: 'branch:prune', branchHandle: t.agent.branch.handle, position: t.position } }]; + case 'nudged': + return [{ trace: { + type: 'pool:agentNudge', agentId: t.agent.id, reason: t.reason, message: t.message, + tool: t.tool, args: t.args, ...(t.guard !== undefined ? { guard: t.guard } : {}), + } }]; + case 'authRejected': + return [{ trace: { + type: 'tool:authReject', agentId: t.agent.id, assignedAbility: t.agent.assignedAbility, + attemptedTool: t.attemptedTool, lineageHistory: t.agent.walkAncestors(x => x.toolHistory), + } }]; + case 'toolCalled': + return [{ bus: { type: 'agent:tool_call', agentId: t.agent.id, tool: t.tool, args: t.args } }]; + case 'dispatched': + return [{ trace: { + traceId: t.traceId, ts: t.ts, + type: 'tool:dispatch', agentId: t.agent.id, tool: t.tool, toolIndex: t.toolIndex, + toolkitSize: t.toolkitSize, args: t.args, callId: t.callId, + explore: t.explore, percentAvailable: t.percentAvailable, + } }]; + case 'toolTold': + return [{ bus: { + type: 'agent:tool_result', agentId: t.agent.id, tool: t.tool, result: t.resultStr, + ...(t.contextAvailablePercent !== undefined ? { contextAvailablePercent: t.contextAvailablePercent } : {}), + } }]; + case 'toolResult': + return [{ trace: { + ...(t.parentTraceId !== undefined ? { parentTraceId: t.parentTraceId } : {}), + type: 'tool:result', agentId: t.agent.id, tool: t.tool, result: t.result, + cells: t.cells, durationMs: t.durationMs, + } }]; + case 'toolRetry': + return [ + { bus: { type: 'agent:tool_retry', agentId: t.agent.id, tool: t.tool, retryAfterMs: t.retryAfterMs, attempt: t.attempt } }, + { trace: { parentTraceId: t.parentTraceId, type: 'tool:retry', agentId: t.agent.id, tool: t.tool, callId: t.callId, retryAfterMs: t.retryAfterMs, attempt: t.attempt } }, + ]; + case 'toolError': + return [{ trace: { parentTraceId: t.parentTraceId, type: 'tool:error', agentId: t.agent.id, tool: t.tool, error: t.error } }]; + case 'extended': + return [{ trace: { type: 'spine:extend', userContent: t.userContent, assistantContent: t.assistantContent, deltaTokens: t.deltaTokens, positionAfter: t.positionAfter } }]; + case 'opened': + return [{ trace: { + type: 'pool:open', agentCount: 0, taskSuffixTokens: [], + pressure: { remaining: finiteOrNull(t.pressure.remaining), softLimit: t.pressure.softLimit, headroom: finiteOrNull(t.pressure.headroom) }, + } }]; + case 'closed': + return [{ trace: { + type: 'pool:close', + agents: t.agents.map(a => ({ agentId: a.id, tokenCount: a.tokenCount, toolCallCount: a.toolCallCount, result: a.result, ppl: pplOf(a) })), + totalTokens: t.agents.reduce((s, a) => s + a.tokenCount, 0), + steps: t.steps, durationMs: t.durationMs, + } }]; + case 'tick': + return [ + { trace: { type: 'pool:tick', phase: 'COMMIT', activeAgents: t.activeAgents, pressure: pressureRecord(t.pressure) } }, + { bus: { type: 'agent:tick', cellsUsed: t.pressure.cellsUsed, nCtx: t.pressure.nCtx } }, + ]; + case 'kvTick': + return [{ bus: { type: 'agent:tick', cellsUsed: t.pressure.cellsUsed, nCtx: t.pressure.nCtx } }]; + case 'paused': + return [ + { trace: { ts: t.ts, type: 'pool:pause' } }, + { bus: { type: 'run:paused' } }, + ]; + case 'resumed': + return [ + { trace: { type: 'pool:resume', pausedMs: t.pausedMs } }, + { bus: { type: 'run:resumed', pausedMs: t.pausedMs } }, + ]; + case 'windingDown': + return [ + { trace: { type: 'pool:windDown' } }, + { bus: { type: 'run:windingDown' } }, + ]; + } +} + +/** The one writer: projects a transition and lands its emissions in order. */ +export class Emitter { + constructor( + private readonly tw: TraceWriter, + private readonly channel: { send(ev: AgentEvent): Operation }, + private readonly scopeId: number, + ) {} + + *emit(t: Transition): Operation { + for (const e of project(t)) { + if ('trace' in e) this.write(e.trace); + else yield* this.channel.send(e.bus); + } + } + + /** Trace-only emission, synchronous — for records written where no + * operation may suspend (inside a tight sampling loop). */ + trace(t: Transition): void { + for (const e of project(t)) { + if ('trace' in e) this.write(e.trace); + else throw new Error(`emit.trace: ${t.kind} projects a bus event; use emit()`); + } + } + + private write(body: TraceBody): void { + const { traceId, ts, parentTraceId, ...rest } = body; + this.tw.write({ + traceId: traceId ?? this.tw.nextId(), + parentTraceId: parentTraceId === undefined ? this.scopeId : parentTraceId, + ts: ts ?? performance.now(), + ...rest, + } as TraceEvent); + } + + nextId(): number { return this.tw.nextId(); } +} diff --git a/packages/agents/src/execute.ts b/packages/agents/src/execute.ts new file mode 100644 index 00000000..1c9e98fd --- /dev/null +++ b/packages/agents/src/execute.ts @@ -0,0 +1,664 @@ +import { call, ensure, spawn, scoped, action } from 'effection'; +import type { Operation, Task, Signal } from 'effection'; +import type { Branch, BranchStore, SessionContext, ParsedToolCall, MultimodalDelta } from '@lloyal-labs/sdk'; +import { + CHAT_FORMAT_CONTENT_ONLY, CHAT_FORMAT_GENERIC, GrammarTriggerType, + buildToolResultDelta, buildToolResultDeltaMultimodal, decodeErrorOf, deltaCells, +} from '@lloyal-labs/sdk'; +import type { Attachment, AttachmentStore, ContentIngress } from '@lloyal-labs/media'; +import { waitUntilSettled } from './combinators'; +import { Trace, TraceParent, CallingAgent, SpineFmt } from './context'; +import { Agent, type FormatConfig } from './Agent'; +import type { AgentPolicy, ToolRetryAction } from './AgentPolicy'; +import { Tool, ToolRetryError, takeToolMedia, TOOL_CONTEXT_KEY, TOOL_IMAGE_ERROR_KEY } from './Tool'; +import type { Emitter } from './emit'; +import { ContextPressure } from './pressure'; +import { prepareBatch } from './prepare-content'; +import { replayAgentTurns } from './replay'; +import { failSettled } from './apply'; +import type { EntailmentScorer } from './source'; +import type { TraceWriter } from './trace-writer'; +import type { TraceEvent } from './trace-types'; +import { + type Schedule, type Outputs, type Pending, type PrefillItem, type ToolCompletion, + type DispatchRequest, type Ladder, classifyRc, prunable, +} from './state'; +import type { AgentTaskSpec, AgentEvent, ToolContext, PressureThresholds } from './types'; + +/** + * The one module that issues decodes. + * + * `prefill`, `prefillMultimodal` and `commit` are the three ways cells enter + * the cache; every caller in the package — the pool's executor, the spine's + * header, replay, the single-agent root — goes through them, so the + * settle-before-exit discipline (`waitUntilSettled`) is kept in one place and + * the single-fiber invariant is a property of the module, not of vigilance. + * + * {@link Executor.run} runs a {@link Schedule} in one fixed order: + * halts → admitted prefills → tool dispatch → spawns/extends/heals → sampling + * → the batched commit. Prefills complete before any agent samples. + */ + +// ── The decode primitives ────────────────────────────────────── + +export function* prefill(store: BranchStore, pairs: [Branch, number[]][]): Operation { + yield* waitUntilSettled(store.prefill(pairs)); +} + +export function* prefillMultimodal(store: BranchStore, pairs: [Branch, MultimodalDelta][]) { + return yield* waitUntilSettled(store.prefillMultimodal(pairs)); +} + +export function* commit(store: BranchStore, entries: [Branch, number][]): Operation { + yield* waitUntilSettled(store.commit(entries)); +} + +/** A branch's own prefill (the spine header, a single-branch replay). */ +export function* prefillBranch(branch: Branch, tokens: number[]): Operation { + yield* waitUntilSettled(branch.prefill(tokens)); +} + +export function* prefillBranchMultimodal(branch: Branch, prompt: string, bitmaps: Uint8Array[], sep?: number[]) { + return yield* waitUntilSettled(branch.prefillMultimodal(prompt, bitmaps, sep)); +} + +/** The cells a multimodal delta will cost — measured, never estimated. */ +export function* measureCells(ctx: SessionContext, delta: MultimodalDelta): Operation { + return yield* waitUntilSettled(deltaCells(ctx, delta)); +} + +// ── Concurrency gate for fan-out tools ───────────────────────── + +/** Default cap on concurrent fan-out tool children. */ +export const DEFAULT_MAX_CONCURRENT_TOOLS = 8; + +/** FIFO counting gate: acquire before a fan-out child's `execute`, release in + * an `ensure`. A halt while queued runs the action cleanup (drops the waiter). */ +export interface Permits { acquire(): Operation; release(): void } +export function makePermits(n: number): Permits { + let available = n; + const waiters: Array<() => void> = []; + return { + *acquire(): Operation { + if (available > 0) { available--; return; } + yield* action((resolve) => { + const w = () => resolve(); + waiters.push(w); + return () => { const i = waiters.indexOf(w); if (i >= 0) waiters.splice(i, 1); }; + }); + }, + release(): void { + const w = waiters.shift(); + if (w) w(); else available++; + }, + }; +} + +function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} + +// ── Forking an agent ─────────────────────────────────────────── + +/** + * Fork an agent from a parent branch with its own system prompt and task. + * Metadata only — no decode. The suffix prefill is the executor's. + */ +export function* setupAgent( + parent: Branch, task: AgentTaskSpec, ctx: SessionContext, enableThinking: boolean, clock?: () => number, +): Operation<{ agent: Agent; suffixTokens: number[]; formattedPrompt: string }> { + // Shared mode: the spine already carries the [system + tools] header; the + // agent inherits parser/grammar/format/triggers and contributes a user turn. + let sharedFmt: FormatConfig | null = null; + try { sharedFmt = (yield* SpineFmt.get()) ?? null; } catch { /* not in shared mode */ } + + const messages = sharedFmt && task.systemPrompt === '' + ? [{ role: 'user', content: task.content }] + : [ + { role: 'system', content: task.systemPrompt }, + { role: 'user', content: task.content }, + ]; + const fmtOpts: Record = { enableThinking }; + if (task.tools && !sharedFmt) fmtOpts.tools = task.tools; + const fmt = ctx.formatChatSync(JSON.stringify(messages), fmtOpts); + if (task.tools && !sharedFmt + && (fmt.format === CHAT_FORMAT_CONTENT_ONLY || fmt.format === CHAT_FORMAT_GENERIC)) { + throw new Error('Model does not support tool calling. Please use a model with native tool support (e.g. Qwen3, Llama 3.x, Mistral).'); + } + const branch = parent.forkSync(); + const suffixTokens = [...ctx.getTurnSeparator(), ...ctx.tokenizeSync(fmt.prompt, false)]; + if (task.seed != null) branch.reseedSampler(task.seed); + + let callingAgent: Agent | null = null; + try { const a = yield* CallingAgent.get(); if (a) callingAgent = a; } catch { /* top-level — no caller */ } + + const src = sharedFmt ?? fmt; + const fmtConfig: FormatConfig = { + format: src.format, reasoningFormat: src.reasoningFormat, generationPrompt: src.generationPrompt, + parser: src.parser, grammar: src.grammar, grammarLazy: src.grammarLazy, grammarTriggers: src.grammarTriggers, + enableThinking, + }; + const agent = new Agent({ + id: branch.handle, parentId: parent.handle, branch, parent: callingAgent, + task: task.content, fmt: fmtConfig, assignedAbility: task.assignedAbility ?? null, clock, + }); + return { agent, suffixTokens, formattedPrompt: fmt.prompt }; +} + +// ── The executor ─────────────────────────────────────────────── + +export interface ExecDeps { + ctx: SessionContext; + store: BranchStore; + tools: Map; + emit: Emitter; + tw: TraceWriter; + pending: Pending; + agents: Agent[]; + inflight: Map>; + permits: Permits; + completed: ToolCompletion[]; + wake: Signal; + progress: Signal; + scorer?: EntailmentScorer; + toolIndexMap: Map; + toolkitSize: number; + terminalGrammar: string | null; + eagerGrammar?: string; + enableThinking: boolean; + spine: Branch; + runNow: () => number; + counters: { warmPrefillCalls: number; warmPrefillBranches: number }; + totals: { toolCalls: number; steps: number }; + policy: AgentPolicy; + pressureOpts: PressureThresholds; + ingress: ContentIngress; + attachments: AttachmentStore; + ladder: Ladder; + trace: boolean; +} + +export class Executor { + constructor(private readonly d: ExecDeps) {} + + *run(S: Schedule): Operation { + const out: Outputs = { tokenRail: null, mediaRail: [], produced: [], committed: false, commitPressure: null, fatal: null }; + const d = this.d; + + // 0. Halts — a cancelled agent's in-flight tool is aborted. + for (const a of S.halts) { + const t = d.inflight.get(a.id); + if (t) yield* t.halt(); + } + if (S.hold) return out; + + // 1. Admitted prefills: the token rail, the media rail, probes, re-activation. + const landed = yield* this.settle(S.prefills, out); + if (out.fatal) return out; + + // 2. Tool dispatch — inline on this fiber, fan-out on a child. + for (const req of S.dispatch) yield* this.dispatch(req); + + // 3. Spawns, extends and heals: one batched prefill onto forks and the spine. + const born = yield* this.spawn(S, out); + if (out.fatal) return out; + + // 4. Sampling — the scheduled decode set, in roster order. Agents that + // became active in THIS step (admitted items, spawns, heals) sample next + // tick, after the scheduler has had its say on them. + void landed; void born; + const set = new Set(S.decode); + const entries: [Branch, number][] = []; + for (const a of d.agents) { + if (!set.has(a) || a.status !== 'active') continue; + const { token, text, isStop } = a.branch.produceSync(); + if (isStop) { + // The strict parse belongs to the sample: it reads the parser state the + // sample left behind, before any sibling samples. + const parsed = a.extracting ? null : a.finalize(d.ctx); + out.produced.push({ agent: a, token, text, isStop, parsed }); + continue; + } + entries.push([a.branch, token]); + if (d.trace) { + const entropy = a.branch.modelEntropy(); + const surprisal = a.branch.modelSurprisal(token); + a.accumulateTokenWithTrace(text, entropy, surprisal); + a.observe(d.ctx); + yield* d.emit.emit({ kind: 'produced', agent: a, text, entropy, surprisal }); + } else { + a.accumulateToken(text); + a.observe(d.ctx); + yield* d.emit.emit({ kind: 'produced', agent: a, text }); + } + } + + // 5. One batched decode for every produced token. + if (entries.length > 0) { + try { + yield* commit(d.store, entries); + out.committed = true; + out.commitPressure = new ContextPressure(d.ctx, d.pressureOpts); + } catch (err) { + out.fatal = { phase: 'commit', err }; + } + } + return out; + } + + /** Prefill the admitted items; book and re-activate what landed. */ + private *settle(items: PrefillItem[], out: Outputs): Operation { + const d = this.d; + const landed: Agent[] = []; + const order: { agentId: number; callId: string; cells: number }[] = []; + const probes = new Map(); + const tokenItems = items.filter((it): it is PrefillItem & { rail: 'token' } => it.rail === 'token'); + const mediaItems = items.filter((it): it is PrefillItem & { rail: 'media' } => it.rail === 'media'); + + /** Success-only bookkeeping: the record, the tool history, the trace. */ + const book = (it: PrefillItem, cells: number, refs?: readonly Attachment[]): void => { + const a = it.agent; + if (it.resultStr) { + a.records.push({ kind: 'toolResult', resultStr: it.resultStr, callId: it.callId, + ...(refs && refs.length > 0 ? { attachments: refs } : {}) }); + } + landed.push(a); + order.push({ agentId: a.id, callId: it.callId, cells }); + if (it.probe) probes.set(a.id, it.probe); + a.deferAttempts = 0; + const after = new ContextPressure(d.ctx, d.pressureOpts); + a.recordToolResult({ name: it.toolName, args: it.args, resultCells: cells, + contextAfterPercent: after.percentAvailable, timestamp: performance.now() }); + d.emit.trace({ kind: 'prefilled', agent: a, cells, + role: it.kind === 'recovery' ? 'recovery' : 'toolResult', attachments: refs }); + }; + + if (tokenItems.length > 0) { + try { + yield* prefill(d.store, tokenItems.map(t => [t.agent.branch, t.tokens] as [Branch, number[]])); + d.counters.warmPrefillCalls++; + d.counters.warmPrefillBranches += tokenItems.length; + d.ladder.consecutiveFatalRc = 0; + for (const t of tokenItems) book(t, t.tokens.length); + out.tokenRail = { items: tokenItems, outcome: { ok: true } }; + } catch (err) { + const de = decodeErrorOf(err); + out.tokenRail = { items: tokenItems, outcome: { ok: false, rc: de?.rc, partial: de?.partial, message: toError(err).message } }; + // A fatal rc ends the tick here, as it always did; the interpreter + // records it and the pool closes partial. + if (classifyRc(de?.rc, de?.partial, d.ladder.backendSuspect) === 'fatal') { + out.fatal = { phase: 'prefill', err }; + return landed; + } + } + } + if (mediaItems.length > 0) { + const results = yield* prefillMultimodal(d.store, mediaItems.map(m => [m.agent.branch, m.media.delta] as [Branch, MultimodalDelta])); + d.counters.warmPrefillCalls++; + d.counters.warmPrefillBranches += mediaItems.length; + for (let i = 0; i < mediaItems.length; i++) { + const m = mediaItems[i]; + const r = results[i]; + if (!r?.error) { + d.ladder.consecutiveFatalRc = 0; + book(m, m.media.cells, m.media.attachments); + out.mediaRail.push({ item: m, outcome: { ok: true } }); + } else { + out.mediaRail.push({ item: m, outcome: { ok: false, rc: r.rc, partial: r.partial, message: r.error } }); + } + } + } + + if (landed.length > 0) { + d.emit.trace({ kind: 'settleOrder', batch: order }); + const probePairs: [Branch, number[]][] = []; + const probeMeta: { agent: Agent; cells: number; text: string }[] = []; + for (const a of landed) { + const text = probes.get(a.id); + if (!text) continue; + const tokens = d.ctx.tokenizeSync(text, false); + probePairs.push([a.branch, tokens]); + probeMeta.push({ agent: a, cells: tokens.length, text }); + } + if (probePairs.length > 0) { + yield* prefill(d.store, probePairs); + for (const m of probeMeta) { + d.emit.trace({ kind: 'prefilled', agent: m.agent, cells: m.cells, role: 'probe', probeText: m.text }); + m.agent.records.push({ kind: 'probe', text: m.text }); + } + } + // Re-activate. An extracting agent gets the eager terminal-tool grammar + // (the grammar-swap); everyone else the lazy tool-call grammar. + for (const a of landed) { + a.transition('active'); + a.resetTurn(); + if (a.extracting && d.terminalGrammar) a.branch.setGrammar(d.terminalGrammar); + else this.applyLazyGrammar(a); + } + } + return landed; + } + + /** Spawns, extends and heals land as one prefill; the new agents activate. */ + private *spawn(S: Schedule, out: Outputs): Operation { + const d = this.d; + const born: Agent[] = []; + if (S.spawns.length === 0 && S.extends.length === 0 && S.heals.length === 0) return born; + + const heals: { h: Schedule['heals'][number]; agent: Agent; suffixTokens: number[]; formattedPrompt: string }[] = []; + for (const h of S.heals) { + const setup = yield* setupAgent(d.spine, h.spec, d.ctx, d.enableThinking, d.runNow); + heals.push({ h, ...setup }); + } + const pairs: [Branch, number[]][] = [ + ...S.spawns.map(s => [s.agent.branch, s.suffixTokens] as [Branch, number[]]), + ...heals.map(x => [x.agent.branch, x.suffixTokens] as [Branch, number[]]), + ...S.extends.map(e => [d.spine, e.tokens] as [Branch, number[]]), + ]; + try { + if (pairs.length > 0) yield* prefill(d.store, pairs); + } catch (err) { + for (const e of S.extends) e.reject(toError(err)); + out.fatal = { phase: 'prefill', err }; + return born; + } + + for (const e of S.extends) { + d.emit.trace({ kind: 'extended', userContent: e.userContent, assistantContent: e.assistantContent, + deltaTokens: e.tokens.length, positionAfter: d.spine.position }); + e.resolve(e.tokens.length); + } + for (const s of S.spawns) { + const a = s.agent; + a.spec = s.task; + d.agents.push(a); + yield* this.activate(a, s.formattedPrompt, s.task, s.suffixTokens.length); + s.resolve(a); + born.push(a); + } + for (const { h, agent, suffixTokens, formattedPrompt } of heals) { + agent.spec = h.spec; + agent.healAttempt = h.attempt; + d.agents.push(agent); + d.emit.trace({ kind: 'created', agent }); + d.emit.trace({ kind: 'formatted', agent, promptText: formattedPrompt, taskContent: h.spec.content, + tokenCount: suffixTokens.length, systemPrompt: h.spec.systemPrompt, tools: h.spec.tools }); + try { + yield* replayAgentTurns(agent.branch, h.records, { enableThinking: agent.fmt.enableThinking }); + } catch { + // The replay could not land. The original already failed honestly; + // the half-built replacement is discarded. + d.emit.trace({ kind: 'drop', agent, reason: 'pressure_init', done: false }); + agent.failed = 'pressure_init'; + agent.pruneRequested = true; + continue; + } + d.emit.trace({ kind: 'healed', of: h.of, agent, rc: h.rc, attempt: h.attempt, pressure: new ContextPressure(d.ctx, d.pressureOpts) }); + this.applyLazyGrammar(agent); + agent.transition('active'); + yield* d.emit.emit({ kind: 'spawned', agent, after: h.spec.after }); + born.push(agent); + } + return born; + } + + private *activate(a: Agent, formattedPrompt: string, task: AgentTaskSpec, tokenCount: number): Operation { + const d = this.d; + d.emit.trace({ kind: 'created', agent: a }); + d.emit.trace({ kind: 'formatted', agent: a, promptText: formattedPrompt, taskContent: task.content, + tokenCount, systemPrompt: task.systemPrompt, tools: task.tools }); + this.applyLazyGrammar(a); + // The transition fires the agent's statusSignal — a waiting orchestrator resumes here. + a.transition('active'); + yield* d.emit.emit({ kind: 'spawned', agent: a, after: task.after }); + } + + /** Eager grammar (schema agents) beats the lazy tool-call grammar; with no + * tools the template's tool grammar is deliberately NOT installed, so a + * no-tool agent is free to wander back to prose. */ + applyLazyGrammar(a: Agent): void { + const d = this.d; + if (d.eagerGrammar) { + a.branch.setGrammar(d.eagerGrammar); + } else if (d.tools.size > 0 && a.fmt.grammar && a.fmt.grammarLazy && a.fmt.grammarTriggers.length > 0) { + const triggers = a.fmt.grammarTriggers.map(t => { + if (t.type === GrammarTriggerType.WORD) { + const nlIdx = t.value.indexOf('\n'); + if (nlIdx >= 0 && nlIdx < t.value.length - 1) return { ...t, value: t.value.slice(0, nlIdx + 1) }; + } + return t; + }); + a.branch.setGrammarLazy(a.fmt.grammar, triggers); + } + } + + // ── Tools ──────────────────────────────────────────────────── + + /** Attribution tee: stamps the dispatching agent + call INTO the event data, + * only if absent, so a nested pool's inner stamp wins. */ + private tee(agentId: number, callId: string, dispatchTraceId: number): TraceWriter { + const tw = this.d.tw; + return { + nextId: () => tw.nextId(), + flush: () => tw.flush(), + write: (event: TraceEvent) => tw.write({ + ...event, + agentId: event.agentId ?? agentId, + callId: event.callId ?? callId, + parentTraceId: event.parentTraceId ?? dispatchTraceId, + }), + }; + } + + private *dispatch(req: DispatchRequest): Operation { + const d = this.d; + const { agent, tc, retryAttempt, retryCallId } = req; + let toolArgs: Record; + try { toolArgs = JSON.parse(tc.arguments); } catch { toolArgs = {}; } + const callId = retryCallId ?? (tc.id || `call_${agent.toolCallCount}`); + + // Retries re-execute the SAME call — counters and the bus event belong to the first attempt. + if (retryAttempt === undefined) { + agent.incrementToolCalls(); + d.totals.toolCalls++; + agent.incrementTurns(); + yield* d.emit.emit({ kind: 'toolCalled', agent, tool: tc.name, args: tc.arguments }); + } + + const tool = d.tools.get(tc.name); + const tee = this.tee.bind(this); + const reading = new ContextPressure(d.ctx, d.pressureOpts); + const explore = d.policy.shouldExplore?.(agent, reading) ?? true; + const dispatchTraceId = d.emit.nextId(); + const toolT0 = performance.now(); + d.emit.trace({ kind: 'dispatched', traceId: dispatchTraceId, ts: toolT0, agent, tool: tc.name, + toolIndex: d.toolIndexMap.get(tc.name) ?? -1, toolkitSize: d.toolkitSize, args: toolArgs, callId, + explore, percentAvailable: reading.percentAvailable }); + const peerHistory = d.agents.filter(a => a.id !== agent.id).flatMap(a => a.toolHistory); + const toolContext: ToolContext = { + agentId: agent.id, branch: agent.branch, + onProgress: (p: { filled: number; total: number }) => { + d.progress.send({ type: 'agent:tool_progress', agentId: agent.id, tool: tc.name, filled: p.filled, total: p.total }); + }, + scorer: d.scorer, explore, + pressurePercentAvailable: reading.percentAvailable, + peerHistory, + }; + + if (tool?.fanout) { + // Off the loop fiber. The child runs ONLY execute(); its completion is + // interpreted on this fiber when the loop next observes. + const fanoutTool = tool; + d.inflight.set(agent.id, yield* spawn(function*() { + let took = false; + try { + yield* ensure(() => { d.inflight.delete(agent.id); }); + yield* ensure(() => { if (took) d.permits.release(); }); + yield* d.permits.acquire(); took = true; + yield* TraceParent.set(dispatchTraceId); + yield* CallingAgent.set(agent); + yield* Trace.set(tee(agent.id, callId, dispatchTraceId)); + const result: unknown = yield* scoped(function*() { + return yield* call(() => fanoutTool.execute(toolArgs, toolContext)); + }); + d.completed.push({ kind: 'result', agent, tc, callId, dispatchTraceId, toolT0, result }); + } catch (err) { + // A halt unwinds via ensure, not catch: a halted child pushes nothing. + if (err instanceof ToolRetryError) { + d.completed.push({ kind: 'retry', agent, tc, callId, dispatchTraceId, toolT0, retryAttempt: (retryAttempt ?? 0) + 1, err }); + } else { + d.completed.push({ kind: 'error', agent, tc, callId, dispatchTraceId, err: toError(err) }); + } + } finally { + d.wake.send(); + } + })); + return; + } + + // Inline: run and interpret now, on this fiber. Required for any tool + // that decodes on the main context (delegate, plan). + let completion: ToolCompletion; + try { + yield* TraceParent.set(dispatchTraceId); + yield* CallingAgent.set(agent); + yield* Trace.set(tee(agent.id, callId, dispatchTraceId)); + const result: unknown = yield* scoped(function*() { + return yield* call(() => + tool ? tool.execute(toolArgs, toolContext) : Promise.resolve({ + error: d.tools.size === 0 + ? 'No tools are available to this agent. Do not emit tool calls — write your answer directly as plain text.' + : `Unknown tool: ${tc.name}`, + }), + ); + }); + completion = { kind: 'result', agent, tc, callId, dispatchTraceId, toolT0, result }; + } catch (err) { + completion = err instanceof ToolRetryError + ? { kind: 'retry', agent, tc, callId, dispatchTraceId, toolT0, retryAttempt: (retryAttempt ?? 0) + 1, err } + : { kind: 'error', agent, tc, callId, dispatchTraceId, err: toError(err) }; + } + yield* this.intake(completion); + } + + /** + * Interpret one tool completion ON THE LOOP FIBER: the result becomes a + * pending item (tokenized here, or measured on the embedding rail), a + * transient failure parks a retry, a hard error ends the agent. Shared by + * the inline path and the fan-out drain. + */ + *intake(c: ToolCompletion): Operation { + try { + yield* this.intakeInner(c); + } catch (err) { + // The media barrier is the live case. One agent's failure must not take + // the tick — or its siblings — with it. + yield* failSettled(this.d.emit, c.agent, 'tool_result_failed', toError(err).message, undefined, c.dispatchTraceId); + } + } + + private *intakeInner(c: ToolCompletion): Operation { + const d = this.d; + const { agent, tc, callId, dispatchTraceId } = c; + // Discarded while the tool ran: a late event would contradict its terminal one. + if (agent.failed !== null) return; + + if (c.kind === 'error') { + agent.transition('idle'); + agent.setResult(`Tool error: ${c.err.message}`, 'tool_error'); + d.emit.trace({ kind: 'toolError', agent, tool: tc.name, error: c.err.message, parentTraceId: dispatchTraceId }); + return; + } + if (c.kind === 'retry') { + const attempt = c.retryAttempt; + const retryAction: ToolRetryAction = + d.policy.onToolRetry?.(agent, tc.name, c.err, attempt) + ?? (attempt <= 1 ? { type: 'retry' } : { type: 'fail' }); + if (retryAction.type === 'retry') { + const afterMs = retryAction.afterMs ?? c.err.retryAfterMs; + d.pending.retries.push({ agent, tc, callId, notBefore: performance.now() + afterMs, attempt }); + yield* d.emit.emit({ kind: 'toolRetry', agent, tool: tc.name, callId, retryAfterMs: afterMs, attempt, parentTraceId: dispatchTraceId }); + return; + } + const exhausted = { + error: retryAction.message + ?? `${tc.name} is currently unavailable (rate-limited; retry failed). ` + + `Do not call ${tc.name} again — use other sources or proceed with your current findings.`, + }; + const resultStr = JSON.stringify(exhausted); + yield* d.emit.emit({ kind: 'toolTold', agent, tool: tc.name, resultStr }); + const tokens = buildToolResultDelta(d.ctx, resultStr, callId, { enableThinking: agent.fmt.enableThinking }); + d.emit.trace({ kind: 'toolResult', agent, tool: tc.name, result: exhausted, cells: tokens.length, + durationMs: performance.now() - c.toolT0, parentTraceId: dispatchTraceId }); + d.pending.items.push({ kind: 'toolResult', rail: 'token', agent, tokens, toolName: tc.name, callId, args: tc.arguments, resultStr }); + return; + } + + const result = c.result; + const tool = d.tools.get(tc.name); + const contextAvailablePercent = new ContextPressure(d.ctx, d.pressureOpts).percentAvailable; + if (result && typeof result === 'object' && !Array.isArray(result)) { + const obj = result as Record; + obj[TOOL_CONTEXT_KEY] = contextAvailablePercent; + if (Array.isArray(obj.results)) agent.addNestedResults((obj.results as unknown[]).filter((f): f is string => typeof f === 'string')); + if (Array.isArray(obj.nestedResults)) agent.addNestedResults((obj.nestedResults as unknown[]).filter((f): f is string => typeof f === 'string')); + } + // Images come OUT before serializing; a model with no projector is TOLD. + const { media, result: told } = takeToolMedia(result); + if (media.length > 0 && !d.ctx.supportsVision()) { + (told as Record)[TOOL_IMAGE_ERROR_KEY] = + `${tc.name} returned ${media.length} image(s), but this model cannot see images. ` + + `Work from the text, or use a different source.`; + } + const resultStr = JSON.stringify(told); + yield* d.emit.emit({ kind: 'toolTold', agent, tool: tc.name, resultStr, contextAvailablePercent }); + const common = { agent, toolName: tc.name, callId, args: tc.arguments, resultStr, probe: tool?.probe(told) ?? undefined }; + let item: PrefillItem; + if (media.length > 0 && d.ctx.supportsVision()) { + // THE BARRIER: normalized and committed before a marker exists, before + // admission, before any KV moves. A failure here is not a tool retry. + const prepared = yield* prepareBatch(d.ingress, d.attachments, media); + const delta = buildToolResultDeltaMultimodal(d.ctx, resultStr, callId, prepared.bitmaps as Uint8Array[], { enableThinking: agent.fmt.enableThinking }); + const cells = yield* measureCells(d.ctx, delta); + item = { kind: 'toolResult', rail: 'media', ...common, media: { delta, cells, attachments: prepared.attachments } }; + } else { + const tokens = buildToolResultDelta(d.ctx, resultStr, callId, { enableThinking: agent.fmt.enableThinking }); + item = { kind: 'toolResult', rail: 'token', ...common, tokens }; + } + d.emit.trace({ kind: 'toolResult', agent, tool: tc.name, result: told, + cells: item.rail === 'media' ? item.media.cells : item.tokens.length, + durationMs: performance.now() - c.toolT0, parentTraceId: dispatchTraceId }); + d.pending.items.push(item); + } + + // ── Reclamation ────────────────────────────────────────────── + + /** Prune every branch that is owed a prune and is a childless leaf. Returns + * how many were freed. A branch with live children keeps its prefix; the + * request stands until the children go. */ + prunePass(): number { + let n = 0; + for (const a of this.d.agents) { + if (!prunable(a)) { if (a.pruneRequested && a.branch.disposed) a.pruneRequested = false; continue; } + a.harvestMetrics(); + if (a.branch.children.length > 0) continue; + this.d.emit.trace({ kind: 'pruned', agent: a, position: a.branch.position }); + a.branch.pruneSync(); + a.pruneRequested = false; + n++; + } + return n; + } +} + +/** Teardown: free every agent branch that is still a leaf, children first. */ +export function pruneAll(agents: readonly Agent[], emit: Emitter): void { + for (let i = agents.length - 1; i >= 0; i--) { + const a = agents[i]; + a.harvestMetrics(); + if (!a.branch.disposed && a.branch.children.length === 0) { + emit.trace({ kind: 'pruned', agent: a, position: a.branch.position }); + a.branch.pruneSync(); + } + } +} diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index ad3b9ed9..d6da3009 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -26,8 +26,8 @@ export { useAgent, agent } from './use-agent'; export type { UseAgentOpts } from './use-agent'; export { agentPool } from './create-agent-pool'; export type { CreateAgentPoolOpts } from './create-agent-pool'; -export { diverge } from './diverge'; -export { useAgentPool, ContextPressure } from './agent-pool'; +export { useAgentPool } from './agent-pool'; +export { ContextPressure } from './pressure'; export { createToolkit } from './toolkit'; export { initAgents } from './init'; export { withSpine } from './spine'; @@ -69,9 +69,6 @@ export type { AgentPoolOptions, AgentResult, AgentPoolResult, - DivergeOptions, - DivergeAttempt, - DivergeResult, AgentEvent, AgentTraceEvent, } from './types'; diff --git a/packages/agents/src/pressure.ts b/packages/agents/src/pressure.ts new file mode 100644 index 00000000..70862dcf --- /dev/null +++ b/packages/agents/src/pressure.ts @@ -0,0 +1,146 @@ +import type { SessionContext } from '@lloyal-labs/sdk'; +import type { PressureThresholds } from './types'; + +/** What the store reports: `{ nCtx, cellsUsed, remaining }`, `remaining = nCtx - cellsUsed`. */ +type KvReading = { nCtx: number; cellsUsed: number; remaining: number }; + +/** + * Immutable KV budget snapshot — a VALUE the scheduler decides against. + * + * The pool samples it once per tick (`TickState.pressure`) and derives the + * post-admission value arithmetically with {@link minus}; effect records read + * the store again at landing time. Either way a snapshot never changes after + * it is taken, so every decision made against one is order-independent. + * + * Created from `SessionContext._storeKvPressure()` which returns + * `{ nCtx, cellsUsed, remaining }` where `remaining = nCtx - cellsUsed`. + * `cellsUsed` tracks unique KV cells per branch — incremented on + * `decode_each` / `decode_scatter`, decremented on release by + * `position - fork_head` (unique cells above the fork point), reset on + * bulk ops like `retainOnly` and `drain`. + * + * Two thresholds partition `remaining` into three zones: + * + * ``` + * ┌──────────────────────────────────────────────────────┐ + * │ nCtx │ + * │ ┌──────────┬───────────────────┬──────────────────┐ │ + * │ │cellsUsed │ headroom > 0 │ softLimit │ │ + * │ │ (in use) │ (new work OK) │ (reserved) │ │ + * │ └──────────┴───────────────────┴──────────────────┘ │ + * │ ◄── remaining ──► │ │ + * │ │ │ + * │ headroom = remaining - softLimit │ + * │ critical = remaining < hardLimit │ + * └──────────────────────────────────────────────────────┘ + * ``` + * + * - **headroom > 0** — room for new work (tool results, generation) + * - **headroom ≤ 0** — over budget. Admission defers tool results, the + * policy hard-cuts non-terminal tool calls. Terminal tools still pass. + * - **critical** — remaining below hardLimit. Agents are dropped before they + * sample again, to prevent llama_decode crashes. + * + * @category Agents + */ +export class ContextPressure { + /** Default softLimit: 1024 tokens reserved for downstream work */ + static readonly DEFAULT_SOFT_LIMIT = 1024; + /** + * Default hardLimit: 512 tokens — matches llama.cpp's default `n_batch`. + * The pool validates at startup that `hardLimit >= nBatch`; the default + * is sized to satisfy the invariant for the default llama.cpp context. + * Recovery fits within the `hardLimit` reserve. + */ + static readonly DEFAULT_HARD_LIMIT = 512; + /** + * Assumed `nBatch` when the native binding doesn't expose it. + * Pool startup validates `pressureThresholds.hardLimit >= this`. + */ + static readonly ASSUMED_N_BATCH = 512; + + /** Total KV cache capacity, in CELLS. 0 when no context limit. + * + * Not positions — the two diverge on the embedding rail. Under M-RoPE an + * image occupies far more cells than it advances position (measured on + * Qwen3.5: 564 cells for 32 positions, ~18x), so budgeting from a branch's + * position would under-count an image by that factor. Every number on this + * class is cells, and `cellsUsed` is what the cache actually reports. */ + readonly nCtx: number; + /** KV cells currently in use (monotonic within a pool run). */ + readonly cellsUsed: number; + /** + * KV slots remaining (`nCtx - cellsUsed`). + * Infinity when nCtx ≤ 0 (no context limit). + */ + readonly remaining: number; + /** Remaining KV floor — tokens reserved for downstream work */ + readonly softLimit: number; + /** Crash-prevention floor — agents killed when remaining drops below */ + readonly hardLimit: number; + + /** Sample the store now (`ctx`), or freeze a reading that was already taken. */ + constructor(source: SessionContext | KvReading, opts?: PressureThresholds) { + const p = typeof (source as SessionContext)._storeKvPressure === 'function' + ? (source as SessionContext)._storeKvPressure() + : (source as KvReading); + this.nCtx = p.nCtx; + this.cellsUsed = p.cellsUsed; + this.remaining = p.nCtx <= 0 ? Infinity : p.remaining; + this.softLimit = opts?.softLimit ?? ContextPressure.DEFAULT_SOFT_LIMIT; + this.hardLimit = opts?.hardLimit ?? ContextPressure.DEFAULT_HARD_LIMIT; + } + + /** + * The snapshot after `cells` more cells are spent (negative = freed). The + * scheduler derives the post-admission value from its own ledger instead of + * reading the store again mid-decision; the thresholds ride along. + */ + minus(cells: number): ContextPressure { + return new ContextPressure( + { nCtx: this.nCtx, cellsUsed: this.cellsUsed + cells, remaining: this.nCtx - (this.cellsUsed + cells) }, + { softLimit: this.softLimit, hardLimit: this.hardLimit }, + ); + } + + /** + * Tokens available for new work: `remaining - softLimit`. + * Positive means room to accept tool results or continue generating. + * Negative means over budget — admission defers, the policy hard-cuts. + */ + get headroom(): number { return this.remaining - this.softLimit; } + + /** `remaining < hardLimit` — the agent must not sample again. */ + get critical(): boolean { return this.remaining < this.hardLimit; } + + /** Can `tokenCount` tokens fit while staying above softLimit? */ + canFit(tokenCount: number): boolean { return tokenCount <= this.headroom; } + + /** + * KV available as 0–100 integer. Single source of truth for the + * percentage shown to agents (`contextAvailablePercent`), recorded + * on tool history (`contextAfterPercent`), and used by + * `policy.shouldExplore()`. + */ + get percentAvailable(): number { + return this.nCtx > 0 + ? Math.max(0, Math.round((this.remaining / this.nCtx) * 100)) + : 100; + } +} + +/** An unlimited context reads `remaining`/`headroom` as Infinity, which JSON + * cannot carry — the trace declares those fields nullable. */ +export function finiteOrNull(x: number): number | null { + return Number.isFinite(x) ? x : null; +} + +/** The four-field pressure record several trace events carry. */ +export function pressureRecord(p: ContextPressure): { + remaining: number | null; cellsUsed: number; nCtx: number; headroom: number | null; +} { + return { + remaining: finiteOrNull(p.remaining), cellsUsed: p.cellsUsed, + nCtx: p.nCtx, headroom: finiteOrNull(p.headroom), + }; +} diff --git a/packages/agents/src/replay.ts b/packages/agents/src/replay.ts index e1f9ccd7..fb1897a4 100644 --- a/packages/agents/src/replay.ts +++ b/packages/agents/src/replay.ts @@ -1,6 +1,6 @@ import { ensure } from 'effection'; import type { Operation } from 'effection'; -import { waitUntilSettled } from './combinators'; +import { prefill, prefillBranch, prefillBranchMultimodal } from './execute'; import { Branch, buildAssistantDelta, buildToolResultDelta, buildToolResultDeltaMultimodal, buildTurnDelta, MEDIA_MARKER, @@ -222,10 +222,10 @@ export function* reconstructBranch(checkpoint: BranchCheckpoint): Operation 0) { - yield* waitUntilSettled( spine.prefillMultimodal(checkpoint.seedPrompt, bitmaps)); + yield* prefillBranchMultimodal(spine, checkpoint.seedPrompt, bitmaps); } else { const seedTokens = ctx.tokenizeSync(checkpoint.seedPrompt, false); - yield* waitUntilSettled( spine.prefill(seedTokens)); + yield* prefillBranch(spine, seedTokens); } yield* replayTurns(spine, checkpoint.turns); @@ -258,7 +258,7 @@ export function* replayTurns( const store = yield* Store.expect(); for (const turn of turns) { const delta = buildTurnDelta(ctx, turn.userContent, turn.assistantContent); - yield* waitUntilSettled( store.prefill([[branch, delta]])); + yield* prefill(store, [[branch, delta]]); } } @@ -300,18 +300,18 @@ export function* replayAgentTurns( for (const r of records) { if (r.kind === 'assistant') { const tokens = buildAssistantDelta(ctx, r.text, opts); - yield* waitUntilSettled( store.prefill([[branch, tokens]])); + yield* prefill(store, [[branch, tokens]]); } else if (r.kind === 'probe') { const tokens = ctx.tokenizeSync(r.text, false); - if (tokens.length > 0) yield* waitUntilSettled( store.prefill([[branch, tokens]])); + if (tokens.length > 0) yield* prefill(store, [[branch, tokens]]); } else if (r.attachments && r.attachments.length > 0) { const { bitmaps } = materialize(attachments, r.attachments); const delta = buildToolResultDeltaMultimodal( ctx, r.resultStr, r.callId, [...bitmaps], opts); - yield* waitUntilSettled( branch.prefillMultimodal(delta.prompt, delta.bitmaps, delta.sep)); + yield* prefillBranchMultimodal(branch, delta.prompt, delta.bitmaps, delta.sep); } else { const tokens = buildToolResultDelta(ctx, r.resultStr, r.callId, opts); - yield* waitUntilSettled( store.prefill([[branch, tokens]])); + yield* prefill(store, [[branch, tokens]]); } } } diff --git a/packages/agents/src/scheduler.ts b/packages/agents/src/scheduler.ts new file mode 100644 index 00000000..637ff950 --- /dev/null +++ b/packages/agents/src/scheduler.ts @@ -0,0 +1,274 @@ +import type { SessionContext } from '@lloyal-labs/sdk'; +import { buildToolResultDelta } from '@lloyal-labs/sdk'; +import type { Agent } from './Agent'; +import type { AgentPolicy, PolicyConfig } from './AgentPolicy'; +import { RECOVERY_PREFILL_OVERHEAD, BATCH_BUFFER } from './AgentPolicy'; +import type { Tool } from './Tool'; +import { type ContextPressure } from './pressure'; +import { + type TickState, type Schedule, type Pending, type PrefillItem, type RecoveryPlan, + type StallOutcome, type Drop, emptyPending, itemCells, alive, +} from './state'; + +/** + * The cohort decisions, as one pure function. + * + * The per-agent policy ({@link AgentPolicy}) answers questions about ONE + * agent given a pressure value and never sees the cohort. Everything the + * pool decides about the cohort — admission, kill ordering, recovery + * concurrency, spawn gating, the stall-break — is here, reading a + * {@link TickState} and returning a {@link Schedule}. Nothing here touches + * the store or the wire; the one tokenizer call (a nudge's size) is + * deterministic. + */ + +/** Adaptive per-report budget bounds for cohort recovery when no explicit + * `reportBudget` is set: a fair share of headroom across the live agents, + * clamped to [MIN, MAX]. */ +export const MIN_REPORT_BUDGET = 128; +export const MAX_REPORT_BUDGET = 2048; + +export interface SchedulerOptions { + /** + * How reaped agents recover. `serial` (the high-effort path): one at a + * time, ungated, uncapped — each report owns the freed headroom. + * `cohort`: every reap's recovery turn is admitted against the recovery + * reserve and decodes bin-packed with live siblings under a per-report + * budget. Wind-down forces `cohort`. + */ + recovery: 'serial' | 'cohort'; + /** Explicit per-report cap for cohort recovery and the voluntary report + * guillotine; absent = adaptive. */ + reportBudget?: number; + terminalToolName?: string; + config: PolicyConfig; +} + +export interface Scheduler { + schedule(state: TickState, policy: AgentPolicy): Schedule; +} + +/** Is the agent already emitting the terminal (report) tool? Then it is + * producing its OWN report — it must never get a recovery turn bolted on. */ +export function emittingTerminal(a: Agent, terminalToolName: string | undefined): boolean { + return terminalToolName != null && a.currentTool === terminalToolName; +} + +/** + * How a dropped agent recovers — the ONE place the per-report budget `b` is + * sized, shared by every drop site (schedule-time and produce-time alike). + * + * Cohort: `aliveCount·(OVERHEAD + b) ≤ (remaining − hardLimit) − BATCH_BUFFER`, + * so the whole cohort's prefill+decode fits the recovery reserve in one tick; + * an explicit `reportBudget` is clamped DOWN to that ceiling. Serial: the + * policy derives its own full-headroom advisory and nothing caps the report. + */ +export function planRecovery( + a: Agent, policy: AgentPolicy, pressure: ContextPressure, aliveCount: number, + mode: 'serial' | 'cohort', reportBudget: number | undefined, +): RecoveryPlan { + let budget: number; + let action; + if (mode === 'cohort') { + const fits = Math.floor((pressure.remaining - pressure.hardLimit - BATCH_BUFFER) / Math.max(1, aliveCount)) - RECOVERY_PREFILL_OVERHEAD; + budget = reportBudget != null + ? (fits > 0 ? Math.min(reportBudget, fits) : reportBudget) + : Math.min(MAX_REPORT_BUDGET, Math.max(MIN_REPORT_BUDGET, fits)); + action = policy.onRecovery?.(a, pressure, budget); + } else { + budget = Infinity; + action = policy.onRecovery?.(a, pressure); + } + if (!action || action.type === 'skip') return { type: 'skip' }; + return { type: 'extract', action, budget, serial: mode === 'serial' }; +} + +export class DefaultScheduler implements Scheduler { + constructor( + private readonly opts: SchedulerOptions, + private readonly ctx: SessionContext, + private readonly tools: Map, + ) {} + + schedule(state: TickState, policy: AgentPolicy): Schedule { + const { pressure: P0, pending, signals } = state; + const remaining: Pending = emptyPending(); + const S: Schedule = { + hold: false, halts: [], drops: [], finishes: [], + spawns: [], rejectedSpawns: [], extends: [], heals: [], + prefills: [], stall: [], abandoned: [], sweep: null, dispatch: [], decode: [], + pressure: P0, alive: 0, remaining, mode: this.opts.recovery, roster: state.agents, close: false, + }; + + // 0. Cancels — always, paused or not. Reclamation needs no decode. + for (const id of signals.cancelled) { + const a = state.agents.find(x => x.id === id); + if (!a || !alive(a)) continue; + if (state.inflight.has(id)) S.halts.push(a); + S.drops.push({ agent: a, reason: 'user_cancel', done: false, recovery: { type: 'none' } }); + } + if (signals.paused) { + // A hold keeps everything waiting exactly where it is. + S.hold = true; + S.remaining = pending; + return S; + } + + policy.resetTick?.(); + const mode: 'serial' | 'cohort' = signals.windDown ? 'cohort' : this.opts.recovery; + S.mode = mode; + const terminal = this.opts.terminalToolName; + + // 1. Admission — one FIFO ledger for spawns and items. + let headroom = P0.headroom; + const band = P0.softLimit - P0.hardLimit; + let spent = 0; + for (const req of pending.spawns) { + if (req.discarded) { S.rejectedSpawns.push(req); continue; } + if (req.suffixTokens.length <= headroom) { + S.spawns.push(req); headroom -= req.suffixTokens.length; spent += req.suffixTokens.length; + } else { + S.rejectedSpawns.push(req); + } + } + for (const e of pending.extends) { + if (e.discarded) continue; + S.extends.push(e); spent += e.tokens.length; + } + S.heals.push(...pending.heals); + + // A serial report already DECODING (active) blocks the next; one that is + // merely awaiting its turn is the candidate this pass admits. + let serialInFlight = state.agents.some(a => a.extracting && a.recoverySerial && a.status === 'active'); + const deferred: PrefillItem[] = []; + for (const it of pending.items) { + const a = it.agent; + if (a.status === 'idle' || a.status === 'disposed') continue; // the agent is gone; so is its item + const cells = itemCells(it); + if (it.kind === 'recovery' && a.recoverySerial) { + // Serial recovery is ungated (the report owns the freed headroom) and + // one at a time (the next waits for this one's prune). + if (serialInFlight) { remaining.items.push(it); continue; } + S.prefills.push(it); spent += cells; serialInFlight = true; + continue; + } + // A recovery item reserves the REPORT room too (prompt + b) and may spend + // the softLimit reserve down to hardLimit — the documented recovery band. + // A plain result reserves only its own cells and stays above softLimit. + const cost = a.extracting ? cells + a.recoveryBudget : cells; + const budget = a.extracting ? headroom + band : headroom; + if (cost > budget) { deferred.push(it); continue; } + S.prefills.push(it); headroom -= cost; spent += cells; + } + // The post-admission value every produce-phase decision reads. + const Pd = P0.minus(spent); + S.pressure = Pd; + + // 2. Produce-phase verdicts, in agents order (the policy's per-tick + // stagger relies on that order). + S.alive = state.agents.filter(alive).length + S.spawns.length + S.heals.length; + const cap = Math.min(this.opts.reportBudget ?? MAX_REPORT_BUDGET, MAX_REPORT_BUDGET); + for (const a of state.agents) { + if (a.status !== 'active') continue; + if (S.drops.some(d => d.agent === a)) continue; // cancelled above + if (signals.windDown && !a.extracting && !emittingTerminal(a, terminal)) { + S.drops.push({ agent: a, reason: 'wind_down', done: true, recovery: this.plan(a, policy, P0, S.alive, mode) }); + continue; + } + const exit = policy.shouldExit?.(a, Pd); + // `??`: a policy returning `false` vetoes; abstaining defers to pressure. + if (!a.extracting && (exit ?? Pd.critical)) { + const reason = Pd.critical ? 'pressure_critical' as const : 'policy_exit' as const; + S.drops.push({ + agent: a, reason, done: true, exitReason: reason, + recovery: emittingTerminal(a, terminal) ? { type: 'salvage' } : this.plan(a, policy, P0, S.alive, mode), + }); + continue; + } + if (a.extracting && a.recoveryTokens >= a.recoveryBudget) { S.finishes.push(a); continue; } + if (!a.extracting && emittingTerminal(a, terminal) && a.turnTokens >= cap) { + S.drops.push({ agent: a, reason: 'report_cap', done: true, exitReason: 'report_cap', recovery: { type: 'salvage' } }); + continue; + } + S.decode.push(a); + } + + // 3. Retries: due ones re-dispatch; wind-down abandons the rest. + const wall = performance.now(); + for (const r of pending.retries) { + if (r.agent.status !== 'awaiting_tool') continue; // cancelled while parked + if (signals.windDown) { S.abandoned.push(r); continue; } + if (r.notBefore <= wall) S.dispatch.push({ agent: r.agent, tc: r.tc, retryAttempt: r.attempt, retryCallId: r.callId }); + else remaining.retries.push(r); + } + S.dispatch.push(...pending.dispatches); + + // 4. Stall-break: deferred items with no sibling left to free KV. + const reactivating = S.prefills.length > 0 || S.spawns.length > 0 || S.heals.length > 0; + if (deferred.length > 0 && S.decode.length === 0 && !reactivating) { + let stallHeadroom = P0.headroom; + for (const it of deferred) { + const a = it.agent; + if (a.status !== 'awaiting_tool' || a.branch.disposed) continue; + // rc-deferred items ride through: their retry is a re-dispatch with + // its own budget (MAX_DEFER_ATTEMPTS), not a headroom problem. + if (a.deferAttempts > 0) { remaining.items.push(it); continue; } + const action = policy.onSettleReject?.(a, itemCells(it), P0, this.opts.config); + const reason = action ? 'pressure_settle_reject' as const : 'settle_stall_break' as const; + if (it.kind === 'recovery') { + // An extracting agent whose cohort turn never fit: its span already + // ended at the kill, so no second `agent:done`; the turn is re-planned + // serial so the report decodes from the reserve, one at a time. + S.stall.push({ agent: a, nudge: null, drop: { agent: a, reason, done: false, recovery: this.plan(a, policy, P0, S.alive, 'serial') } }); + continue; + } + let nudge: StallOutcome['nudge'] = null; + if (action?.type === 'nudge') { + const nudgeResult = { error: action.message }; + const tokens = buildToolResultDelta(this.ctx, JSON.stringify(nudgeResult), it.callId, { enableThinking: a.fmt.enableThinking }); + const fits = tokens.length <= stallHeadroom; + const replacement: PrefillItem | null = fits ? { + kind: 'nudge', rail: 'token', agent: a, tokens, + toolName: it.toolName, callId: it.callId, args: it.args, + probe: this.tools.get(it.toolName)?.probe(nudgeResult) ?? undefined, + } : null; + nudge = { message: action.message, tool: it.toolName, args: it.args, replacement }; + if (replacement) { remaining.items.push(replacement); stallHeadroom -= tokens.length; } + } + // The policy's suggestion was infeasible (or it said idle, or it is absent): drop. + const drop: Drop | null = nudge?.replacement ? null + : { agent: a, reason, done: true, recovery: this.plan(a, policy, P0, S.alive, mode) }; + S.stall.push({ agent: a, nudge, drop }); + } + } else { + remaining.items.push(...deferred); + } + + // 5. Close, or the close-time sweep: one serial recovery per tick for an + // agent that idled without a result and was never discarded. + const allIdle = state.agents.every(a => a.status === 'idle' || a.status === 'disposed'); + const nothingWaiting = + remaining.items.length === 0 && remaining.retries.length === 0 && + S.prefills.length === 0 && S.spawns.length === 0 && S.extends.length === 0 && + S.heals.length === 0 && S.dispatch.length === 0 && S.decode.length === 0 && + S.drops.length === 0 && S.stall.length === 0 && S.finishes.length === 0 && S.abandoned.length === 0 && + state.inflight.size === 0; + if (signals.orchestratorDone && allIdle && nothingWaiting) { + const c = state.agents.find(a => + a.status === 'idle' && !a.result && !a.branch.disposed && a.failed === null && !a.extracting); + if (c) { + S.sweep = { agent: c, recovery: this.plan(c, policy, P0, 1, 'serial') }; + } else { + // The prune pass runs before every schedule; anything still owed a + // prune is a branch with live children, which the close cannot free. + S.close = true; + } + } + return S; + } + + private plan(a: Agent, policy: AgentPolicy, P0: ContextPressure, aliveCount: number, mode: 'serial' | 'cohort'): RecoveryPlan { + return planRecovery(a, policy, P0, aliveCount, mode, this.opts.reportBudget); + } +} + diff --git a/packages/agents/src/source.ts b/packages/agents/src/source.ts index a33b5c1f..1b69cab4 100644 --- a/packages/agents/src/source.ts +++ b/packages/agents/src/source.ts @@ -77,7 +77,7 @@ export abstract class Source { /** Data access tools provided by this source */ abstract get tools(): Tool[]; - /** Reranker instance, set during {@link bind}. Used by {@link createScorer}. */ + /** Reranker instance, injected at construction by the ability factory. Used by {@link createScorer}. */ protected _reranker: ScorerReranker | null = null; /** * Minimum entailment score for delegation to proceed. @@ -131,9 +131,6 @@ export abstract class Source { }; } - /** Late-bind runtime deps not available at construction. Called before tools are used. */ - *bind(_ctx: TCtx): Operation {} - /** Post-use chunks for reranking. Called after agents have used the tools. */ getChunks(): TChunk[] { return []; } diff --git a/packages/agents/src/spine.ts b/packages/agents/src/spine.ts index db444959..32e686d6 100644 --- a/packages/agents/src/spine.ts +++ b/packages/agents/src/spine.ts @@ -1,6 +1,6 @@ import type { Operation } from "effection"; -import { waitUntilSettled } from "./combinators"; +import { prefillBranch, prefillBranchMultimodal } from "./execute"; import { Branch, mediaContent } from "@lloyal-labs/sdk"; import type { SessionContext } from "@lloyal-labs/sdk"; import { Ctx, Trace, TraceParent, SpineFmt, Attachments, Ingress } from "./context"; @@ -173,7 +173,7 @@ export function* withSpine( // so a failure on any of them cannot leak a slot or a poisoned branch. try { if (prefillTokens.length > 0) { - yield* waitUntilSettled( spine.prefill(prefillTokens)); + yield* prefillBranch(spine, prefillTokens); tw.write({ traceId: tw.nextId(), parentTraceId: scopeId, @@ -271,8 +271,7 @@ export function* withSpine( let attached: readonly Attachment[] | undefined; if (bitmaps.length > 0) { writeSpineSeed(); - const counts = yield* waitUntilSettled( - spine.prefillMultimodal(formatted.prompt, bitmaps)); + const counts = yield* prefillBranchMultimodal(spine, formatted.prompt, bitmaps); headerCells = counts.tokensDecoded; // Already committed by the barrier above — this only carries the roots // onto the trace. Recording used to happen HERE, after the prefill, so @@ -283,7 +282,7 @@ export function* withSpine( writeSpineSeed(headerTokens.length); headerCells = headerTokens.length; if (headerTokens.length > 0) { - yield* waitUntilSettled( spine.prefill(headerTokens)); + yield* prefillBranch(spine, headerTokens); } } if (headerCells > 0) { diff --git a/packages/agents/src/state.ts b/packages/agents/src/state.ts new file mode 100644 index 00000000..c379ad56 --- /dev/null +++ b/packages/agents/src/state.ts @@ -0,0 +1,264 @@ +import type { ParsedToolCall, MultimodalDelta } from '@lloyal-labs/sdk'; +import type { Attachment } from '@lloyal-labs/media'; +import type { Agent } from './Agent'; +import type { ContextPressure } from './pressure'; +import type { RecoveryAction } from './AgentPolicy'; +import type { AgentTaskSpec, AgentExitReason } from './types'; +import type { AgentTurnRecord } from './replay'; +import type { TraceEvent } from './trace-types'; + +/** + * The pool's vocabulary as VALUES — what the scheduler reads, what it + * returns, and what the loop carries between ticks. Nothing in this file + * touches the store or the wire. + * + * The idiom is the continuous-batching scheduler: per tick a pure + * `schedule(state)` returns a {@link Schedule}; `execute` runs it against the + * store; `apply` interprets what came back. The agent's own record lives on + * {@link Agent}; these are the records that exist BETWEEN agents. + */ + +/** A `pool:agentDrop` reason — the wire union is the vocabulary. */ +export type DropReason = Extract['reason']; + +// ── Pending work ──────────────────────────────────────────────── + +/** + * Something waiting to enter an agent's cache: a tool result, a nudge + * standing in for one, or a recovery turn. Every item knows its cost in + * CELLS — media included, which is why the cost was measured upstream and + * is never re-derived here. + */ +export type PrefillItem = { + kind: 'toolResult' | 'nudge' | 'recovery'; + agent: Agent; + toolName: string; + callId: string; + args: string; + probe?: string; + /** The tool-result string the delta was built from — the heal record's + * replay material. Absent for nudges and recovery turns. */ + resultStr?: string; +} & ( + /** The token rail: the delta tokenized here and prefilled as tokens. */ + | { rail: 'token'; tokens: number[]; media?: never } + /** The embedding rail. `llama_batch` is token-XOR-embd, so this cannot + * join a token batch — a separate call, not a separate strategy. */ + | { rail: 'media'; tokens?: never; media: { delta: MultimodalDelta; cells: number; attachments: readonly Attachment[] } } +); + +/** What admission spends on this item — the ONE place that answers it. */ +export function itemCells(item: PrefillItem): number { + return item.rail === 'media' ? item.media.cells : item.tokens.length; +} + +/** A transient tool failure parked until `notBefore` (wall clock). */ +export interface RetryPark { + agent: Agent; tc: ParsedToolCall; callId: string; notBefore: number; attempt: number; +} + +/** A tool call the model made; dispatched next tick. */ +export interface DispatchRequest { + agent: Agent; tc: ParsedToolCall; retryAttempt?: number; retryCallId?: string; +} + +/** An orchestrator's `spawn`: the agent is forked and its suffix tokenized; + * the suffix prefill and the activation are scheduler work. The + * orchestrator suspends on `resolve`/`reject` until then. */ +export interface SpawnRequest { + agent: Agent; suffixTokens: number[]; formattedPrompt: string; task: AgentTaskSpec; + resolve: (agent: Agent) => void; reject: (err: Error) => void; discarded: boolean; +} + +/** An orchestrator's `extendSpine`, prefilled onto the spine with the spawns. */ +export interface ExtendRequest { + tokens: number[]; userContent: string; assistantContent: string; + resolve: (deltaTokens: number) => void; reject: (err: Error) => void; discarded: boolean; +} + +/** A poisoned agent's warm respawn: a fresh fork of the spine that replays + * the original's record (docs/self-healing.md). */ +export interface HealRequest { + spec: AgentTaskSpec; records: AgentTurnRecord[]; of: number; rc?: number; attempt: number; +} + +/** Everything that is waiting, by kind. ONE record the loop owns; the + * scheduler reads it and returns what remains after this tick's admissions. */ +export interface Pending { + items: PrefillItem[]; + retries: RetryPark[]; + dispatches: DispatchRequest[]; + spawns: SpawnRequest[]; + extends: ExtendRequest[]; + heals: HealRequest[]; +} + +export function emptyPending(): Pending { + return { items: [], retries: [], dispatches: [], spawns: [], extends: [], heals: [] }; +} + +// ── The tick's inputs and outputs ─────────────────────────────── + +/** Everything `schedule()` may read. Built once per tick. */ +export interface TickState { + tick: number; + /** The run clock — wall time minus paused spans. */ + now: number; + /** ONE sample, taken after the previous tick's effects landed. */ + pressure: ContextPressure; + agents: readonly Agent[]; + pending: Pending; + signals: { + paused: boolean; + windDown: boolean; + /** User cancels queued since the last tick. */ + cancelled: readonly number[]; + orchestratorDone: boolean; + }; + /** Agents with a fan-out tool child still running. */ + inflight: ReadonlySet; +} + +/** + * How a dropped agent gets its findings out, decided with the drop: + * - `salvage`: it was mid-terminal-call; parse what it already emitted. + * - `extract`: prefill the recovery prompt; the report decodes in-loop under + * `budget` (Infinity = serial, uncapped). + * - `skip`: the policy declined; the agent fails cleanly. + * - `none`: nothing to recover (a cancel). + */ +export type RecoveryPlan = + | { type: 'salvage' } + | { type: 'extract'; action: Extract; budget: number; serial: boolean } + | { type: 'skip' } + | { type: 'none' }; + +/** A decision to stop an agent, with everything the enactment needs. */ +export interface Drop { + agent: Agent; + /** `null` = the agent stopped on its own terms (free text, no call): the + * span still ends, but no `pool:agentDrop` record is written. */ + reason: DropReason | null; + /** Whether this drop ends the agent's span (`agent:done`). Cancels and a + * re-drop of an already-extracting agent do not. */ + done: boolean; + exitReason?: AgentExitReason; + recovery: RecoveryPlan; +} + +/** One deferred item's fate at the stall-break, in the order it is announced: + * the policy's nudge (recorded whether or not it fit), then the drop the item + * fell into when it did not. */ +export interface StallOutcome { + agent: Agent; + nudge: { message: string; tool: string; args: string; replacement: PrefillItem | null } | null; + drop: Drop | null; +} + +/** + * What runs this tick — the scheduler's output. The phases are FIELDS; + * `execute` runs them in one fixed order. + */ +export interface Schedule { + /** Paused: only cancels' halts run; nothing decodes. */ + hold: boolean; + /** Agents whose in-flight fan-out tool is halted (cancels). */ + halts: Agent[]; + /** Schedule-time drops, in decision order. */ + drops: Drop[]; + /** Extracting agents whose report hit its token-stop: finish without sampling. */ + finishes: Agent[]; + spawns: SpawnRequest[]; + rejectedSpawns: SpawnRequest[]; + extends: ExtendRequest[]; + heals: HealRequest[]; + /** Admitted items, in admission order. */ + prefills: PrefillItem[]; + /** Stall-break outcomes for items that could not be admitted, in item order. */ + stall: StallOutcome[]; + /** Wind-down: parked retries settled as an honest failure instead of waited out. */ + abandoned: RetryPark[]; + /** The close-time sweep: one idle-without-result agent recovers serially, with no drop record. */ + sweep: { agent: Agent; recovery: RecoveryPlan } | null; + dispatch: DispatchRequest[]; + /** Agents that sample this tick: active now and not dropped. Agents the + * execute step itself re-activates (admitted items, spawns, heals) join + * the decode set as they land. */ + decode: Agent[]; + /** The post-admission pressure — what produce-phase and dispatch decisions read. */ + pressure: ContextPressure; + /** Agents that could still need recovery this tick (`active`|`awaiting` + * plus this tick's spawns) — the divisor of the cohort report budget. */ + alive: number; + /** What is still waiting after this tick's admissions. */ + remaining: Pending; + /** The recovery mode this tick decided under (wind-down forces `cohort`). */ + mode: 'serial' | 'cohort'; + /** The roster the decisions were made over. */ + roster: readonly Agent[]; + /** Nothing left to do: the pool closes after this tick. */ + close: boolean; +} + +/** A tool's completion, carried from wherever it ran to the intake. */ +export type ToolCompletion = + | { kind: 'result'; agent: Agent; tc: ParsedToolCall; callId: string; dispatchTraceId: number; toolT0: number; result: unknown } + | { kind: 'retry'; agent: Agent; tc: ParsedToolCall; callId: string; dispatchTraceId: number; toolT0: number; retryAttempt: number; err: import('./Tool').ToolRetryError } + | { kind: 'error'; agent: Agent; tc: ParsedToolCall; callId: string; dispatchTraceId: number; err: Error }; + +/** One admitted prefill's fate, as the store reported it. */ +export type PrefillOutcome = + | { ok: true } + | { ok: false; rc?: number; partial?: boolean; message: string }; + +/** What the store gave back for one tick. */ +export interface Outputs { + /** The token-rail cohort's outcome (one prefill call, one outcome). */ + tokenRail: { items: PrefillItem[]; outcome: PrefillOutcome } | null; + /** The media rail's per-entry outcomes. */ + mediaRail: { item: PrefillItem; outcome: PrefillOutcome }[]; + /** What each sampled agent produced; only the stops need interpreting. + * `parsed` is the strict parse taken at the sample (null for an extracting + * agent, whose report is parsed by the recovery path). */ + produced: { agent: Agent; token: number; text: string; isStop: boolean; parsed: import('@lloyal-labs/sdk').ParseChatOutputResult | null }[]; + /** The commit landed (`steps` counts these), with the reading taken as it did. */ + committed: boolean; + commitPressure: ContextPressure | null; + /** A decode failed beyond the ladder: a fatal prefill rc, or the commit + * (KV exhausted). The pool closes partial. */ + fatal: { phase: 'prefill' | 'commit'; err: unknown } | null; +} + +// ── Terminal helpers ──────────────────────────────────────────── + +/** An agent whose branch still holds cells nothing will read again. */ +export function prunable(a: Agent): boolean { + return a.pruneRequested && !a.branch.disposed; +} + +export function alive(a: Agent): boolean { + return a.status === 'active' || a.status === 'awaiting_tool'; +} + +/** + * The self-healing ladder's one classification (docs/self-healing.md): + * rc 1 restored the failing call and nothing before it landed → the branch + * is INTACT and the item may re-queue; rc 1 with an earlier chunk landed + * → the cohort cannot be re-queued whole (it would decode landed chunks + * twice) → fail; rc 2 / < −1 / no rc / tripwire up → fatal. + */ +export function classifyRc(rc: number | undefined, partial: boolean | undefined, backendSuspect: boolean): 'defer' | 'fail' | 'fatal' { + if (backendSuspect || rc !== 1) return 'fatal'; + return partial ? 'fail' : 'defer'; +} + +/** A fatal rc as the ladder counts it: 2, or below −1. */ +export function isFatalRc(rc: number | undefined): boolean { + return rc === 2 || (rc !== undefined && rc < -1); +} + +/** Self-healing ladder state shared by the interpreter and the executor. */ +export interface Ladder { consecutiveFatalRc: number; backendSuspect: boolean } +export const MAX_DEFER_ATTEMPTS = 3; +export const BACKEND_TRIPWIRE_N = 3; +export const MAX_HEAL_ATTEMPTS = 1; diff --git a/packages/agents/src/trace-types.ts b/packages/agents/src/trace-types.ts index f1847c7e..973ab636 100644 --- a/packages/agents/src/trace-types.ts +++ b/packages/agents/src/trace-types.ts @@ -35,9 +35,11 @@ interface TraceEventBase { * * Every variant extends {@link TraceEventBase} with a `type` discriminant. * Events cover the full lifecycle of agent execution: scope open/close, - * prompt formatting, branch creation/prefill/prune, generation start/end, - * agent pool ticks, tool dispatch/result, diverge attempts, reranker - * passes, and source bindings. + * prompt formatting, branch creation/prefill/prune, agent pool ticks, tool + * dispatch/result, reranker passes and retrieval scoring. + * + * Every variant declared here has an emit site — `test/trace-vocabulary.test.ts` + * holds the file to that. * * Written to a {@link TraceWriter} throughout the runtime. Consumers * (e.g. {@link JsonlTraceWriter}) serialize events to JSONL for @@ -71,7 +73,7 @@ export type TraceEvent = messages: string; tools?: string; grammar?: string; - role: 'spine' | 'agentSuffix' | 'generate' | 'diverge' | 'toolResultDelta'; + role: 'spine' | 'agentSuffix'; } // ── Branch events ─────────────────────────── @@ -80,7 +82,7 @@ export type TraceEvent = branchHandle: number; parentHandle: number | null; position: number; - role: 'root' | 'spine' | 'agentFork' | 'divergeAttempt'; + role: 'root' | 'spine' | 'agentFork'; } | TraceEventBase & { type: 'branch:prefill'; @@ -108,7 +110,7 @@ export type TraceEvent = probeText?: string; /** Verbatim prefilled text. Populated for `warmDelta` (session-trunk * conversation turns) so the spine's accreting content is visible in - * the trace — parallels `generate:end.output`. Omitted for the + * the trace. Omitted for the * pool-side prefills (spineHeader/toolResult/recovery), whose text is * already recoverable from prompt:format / tool:result / pool:recovery*. */ content?: string; @@ -132,22 +134,6 @@ export type TraceEvent = } | TraceEventBase & { type: 'branch:prune'; branchHandle: number; position: number } - // ── Generation events ─────────────────────── - | TraceEventBase & { - type: 'generate:start'; - branchHandle: number; - hasGrammar: boolean; - hasParent: boolean; - role: string; - } - | TraceEventBase & { - type: 'generate:end'; - branchHandle: number; - tokenCount: number; - output: string; - parsed?: unknown; - } - // ── Agent pool events ─────────────────────── | TraceEventBase & { type: 'pool:open'; @@ -195,11 +181,9 @@ export type TraceEvent = | 'pressure_softcut' | 'pressure_settle_reject' | 'settle_stall_break' - | 'time_exceeded' | 'policy_exit' | 'maxTurns' | 'tool_error' - | 'stop_token' | 'wind_down' | 'user_cancel' | 'report_cap'; @@ -207,7 +191,7 @@ export type TraceEvent = | TraceEventBase & { type: 'pool:agentNudge'; agentId: number; - reason: 'pressure_softcut' | 'pressure_settle_reject' | 'settle_reject' | 'time_nudge' | 'nudge'; + reason: 'pressure_softcut' | 'pressure_settle_reject' | 'settle_reject' | 'nudge'; message?: string; /** The tool call the nudge replaced (PRODUCE nudges reject a parsed * call; settle_reject nudges replace an oversized result). Absent @@ -221,7 +205,7 @@ export type TraceEvent = } // ── Recovery diagnostics ──────────────────── - // Emitted by recoverInline so silent failures become visible in the + // Emitted by the recovery path so silent failures become visible in the // trace. A recovery prefill is always followed by exactly one of: // `pool:recoveryReturn` (parsed findings captured) or // `pool:recoveryFailed` (produce completed but output unparseable). @@ -413,16 +397,6 @@ export type TraceEvent = lineageHistory: readonly ToolHistoryEntry[]; } - // ── Diverge events ────────────────────────── - | TraceEventBase & { type: 'diverge:start'; attempts: number; prefixLength: number } - | TraceEventBase & { - type: 'diverge:end'; - bestIdx: number; - ppls: number[]; - outputs: string[]; - totalTokens: number; - } - // ── BM25 first-stage events (corpus ability) ───── | TraceEventBase & { type: 'bm25:start'; @@ -465,11 +439,6 @@ export type TraceEvent = totalScored?: number; } - // ── Source events (rig package) ───────────── - | TraceEventBase & { type: 'source:bind'; sourceName: string } - | TraceEventBase & { type: 'source:research'; sourceName: string; questions: string[] } - | TraceEventBase & { type: 'source:chunks'; sourceName: string; chunkCount: number } - // ── Entailment scoring events ────────────── | TraceEventBase & { type: 'entailment:search'; tool: string; query: string; [key: string]: unknown } | TraceEventBase & { type: 'entailment:search:reordered'; tool: string; after: Array<{ title: string; url: string }> } diff --git a/packages/agents/src/trace-writer.ts b/packages/agents/src/trace-writer.ts index 2882df04..60e5b775 100644 --- a/packages/agents/src/trace-writer.ts +++ b/packages/agents/src/trace-writer.ts @@ -38,10 +38,10 @@ export class NullTraceWriter implements TraceWriter { /** * JSONL file writer — one JSON object per line, buffered sync writes * - * Buffers up to 64 events in memory before flushing to the underlying - * file descriptor with `fs.writeSync`. Flush also occurs at every - * {@link useTraceScope} close boundary to guarantee scope pairs are - * persisted promptly. + * Buffers `bufferSize` events in memory before flushing to the underlying + * file descriptor with `fs.writeSync`; the default of 1 flushes every write. + * Flush also occurs at every {@link useTraceScope} close boundary to + * guarantee scope pairs are persisted promptly. * * Construct with an open file descriptor (e.g. from `fs.openSync`). * Write failures are silently swallowed — tracing must never crash diff --git a/packages/agents/src/types.ts b/packages/agents/src/types.ts index 1e04feba..20a14070 100644 --- a/packages/agents/src/types.ts +++ b/packages/agents/src/types.ts @@ -223,7 +223,7 @@ export interface PressureThresholds { } /** - * Configuration for {@link useAgentPool} and {@link runAgents} + * Configuration for {@link useAgentPool} * * @category Agents */ @@ -361,7 +361,7 @@ export interface AgentResult { /** * Aggregate result from a completed agent pool run * - * Returned by both {@link useAgentPool} and {@link runAgents}. Contains + * Returned by {@link useAgentPool}. Contains * per-agent results plus aggregate statistics for display and telemetry. * * @category Agents @@ -384,103 +384,6 @@ export interface AgentPoolResult { }; } -// ── Generate types ───────────────────────────────────────────── - -/** - * Options for single-branch {@link generate} - * - * @category Agents - */ -export interface GenerateOptions { - /** Pre-formatted prompt string (from `formatChat()` + `tokenize()`) */ - prompt: string; - /** GBNF grammar string for constrained generation */ - grammar?: string; - /** Sampling parameters */ - params?: SamplingParams; - /** Optional parser applied to the raw output string */ - parse?: (output: string) => unknown; - /** Fork from parent instead of creating a fresh root. Prompt is prefilled as a delta (with turn separator). */ - parent?: Branch; -} - -/** - * Result from single-branch {@link generate} - * - * @category Agents - */ -export interface GenerateResult { - /** Raw generated text */ - output: string; - /** Number of tokens generated */ - tokenCount: number; - /** Parsed output (present only when `parse` was provided in options) */ - parsed?: T; -} - -// ── Diverge types ────────────────────────────────────────────── - -/** - * Options for multi-branch {@link diverge} - * - * Either `parent` or `prompt` must be provided. When `parent` is given, - * branches fork from it and no new root is created. When only `prompt` - * is given, a fresh root is created, prefilled, and cleaned up on error. - * - * @category Agents - */ -export interface DivergeOptions { - /** Pre-formatted prompt for creating a fresh root (mutually exclusive with parent) */ - prompt?: string; - /** Number of parallel generation attempts */ - attempts: number; - /** Parent branch to fork from (mutually exclusive with prompt) */ - parent?: Branch; - /** Sampling parameters for all attempts */ - params?: SamplingParams; - /** Base seed for sampler diversity across attempts. @default 2000 */ - seedBase?: number; -} - -/** - * Single attempt result from {@link diverge} - * - * @category Agents - */ -export interface DivergeAttempt { - /** The attempt's branch (only the best branch survives after diverge) */ - branch: Branch; - /** Generated text for this attempt */ - output: string; - /** Number of tokens generated */ - tokenCount: number; - /** Model perplexity — lower indicates more coherent generation */ - ppl: number; -} - -/** - * Aggregate result from {@link diverge} - * - * The `best` branch is still alive; all other attempt branches have been - * pruned. The caller owns cleanup — typically via {@link Session.promote} - * to make the best branch the new conversation trunk. - * - * @category Agents - */ -export interface DivergeResult { - /** Lowest-perplexity branch — still alive, caller owns cleanup */ - best: Branch; - /** Text output from the best attempt */ - bestOutput: string; - /** All attempts (losers already pruned, branches disposed) */ - attempts: DivergeAttempt[]; - /** Sum of all attempt token counts */ - totalTokens: number; - /** Number of batched commit steps */ - steps: number; - /** Shared prefix length in tokens (for KV savings calculation) */ - prefixLength: number; -} // ── Runtime events ───────────────────────────────────────────── diff --git a/packages/agents/src/use-agent.ts b/packages/agents/src/use-agent.ts index f13b3488..2b680df1 100644 --- a/packages/agents/src/use-agent.ts +++ b/packages/agents/src/use-agent.ts @@ -1,6 +1,6 @@ import { resource, ensure, call, scoped } from 'effection'; import type { Operation } from 'effection'; -import { waitUntilSettled } from './combinators'; +import { prefillBranch } from './execute'; import { Branch } from '@lloyal-labs/sdk'; import type { Session, SessionContext } from '@lloyal-labs/sdk'; import { Agent } from './Agent'; @@ -116,7 +116,7 @@ export function useAgent(opts: UseAgentOpts): Operation { const prefillTokens = warmParent ? ctx.getTurnSeparator() : []; if (prefillTokens.length > 0) { - yield* waitUntilSettled( root.prefill(prefillTokens)); + yield* prefillBranch(root, prefillTokens); } // Eager grammar from schema. Compile here, but apply it on the GENERATING diff --git a/packages/agents/test/Agent.test.ts b/packages/agents/test/Agent.test.ts index 5ed9b70a..d8b9c464 100644 --- a/packages/agents/test/Agent.test.ts +++ b/packages/agents/test/Agent.test.ts @@ -59,9 +59,16 @@ describe('Agent', () => { expect(a.status).toBe('disposed'); }); - it('rejects idle → awaiting_tool', () => { + it('allows idle → awaiting_tool (the close sweep parks an idle agent on its recovery turn)', () => { const a = makeAgent(); - expect(() => a.transition('awaiting_tool')).toThrow('Invalid agent status transition'); + a.transition('awaiting_tool'); + expect(a.status).toBe('awaiting_tool'); + }); + + it('rejects active → disposed', () => { + const a = makeAgent(); + a.transition('active'); + expect(() => a.transition('disposed')).toThrow('Invalid agent status transition'); }); it('rejects disposed → active', () => { @@ -174,44 +181,4 @@ describe('Agent', () => { expect(a.uniqueCells).toBe(300); }); }); - - describe('async iterator', () => { - it('yields tokens from branch and accumulates state', async () => { - const branch = createMockBranch({ handle: 1 }); - branch._tokens = [ - { token: 10, text: 'hello' }, - { token: 20, text: ' world' }, - { token: 30, text: '!' }, - ]; - const a = new Agent({ id: 1, parentId: 0, branch: branch as any, fmt: FMT }); - - const collected: Array<{ token: number; text: string }> = []; - for await (const produced of a) { - collected.push(produced); - } - - expect(collected).toEqual([ - { token: 10, text: 'hello' }, - { token: 20, text: ' world' }, - { token: 30, text: '!' }, - ]); - expect(a.rawOutput).toBe('hello world!'); - expect(a.tokenCount).toBe(3); - }); - - it('yields nothing when branch has no tokens', async () => { - const branch = createMockBranch({ handle: 1 }); - branch._tokens = []; - const a = new Agent({ id: 1, parentId: 0, branch: branch as any, fmt: FMT }); - - const collected: Array<{ token: number; text: string }> = []; - for await (const produced of a) { - collected.push(produced); - } - - expect(collected).toEqual([]); - expect(a.rawOutput).toBe(''); - expect(a.tokenCount).toBe(0); - }); - }); }); diff --git a/packages/agents/test/AgentPolicy.test.ts b/packages/agents/test/AgentPolicy.test.ts index 3857f965..35e0bbf0 100644 --- a/packages/agents/test/AgentPolicy.test.ts +++ b/packages/agents/test/AgentPolicy.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'vitest'; import { DefaultAgentPolicy, defaultToolGuards } from '../src/AgentPolicy'; +import { ContextPressure } from '../src/pressure'; import type { PolicyConfig } from '../src/AgentPolicy'; import { Agent } from '../src/Agent'; import { createMockBranch } from './helpers/mock-branch'; @@ -20,18 +21,9 @@ function makeAgent(overrides?: { toolCallCount?: number; turns?: number; toolHis return a; } -function pressure(remaining = 5000, nCtx = 16384) { - return { - headroom: remaining - 1024, - critical: remaining < 128, - remaining, - nCtx, - cellsUsed: nCtx - remaining, - percentAvailable: nCtx > 0 ? Math.max(0, Math.round((remaining / nCtx) * 100)) : 100, - canFit: (n: number) => n <= remaining - 1024, - softLimit: 1024, - hardLimit: 128, - }; +/** A frozen pressure reading — the real value, not a hand-rolled twin. */ +function pressure(remaining = 5000, nCtx = 16384): ContextPressure { + return new ContextPressure({ nCtx, cellsUsed: nCtx - remaining, remaining }, { softLimit: 1024, hardLimit: 128 }); } describe('DefaultAgentPolicy', () => { diff --git a/packages/agents/test/agent-pool.test.ts b/packages/agents/test/agent-pool.test.ts index 5c75fac0..0a129c0b 100644 --- a/packages/agents/test/agent-pool.test.ts +++ b/packages/agents/test/agent-pool.test.ts @@ -1375,7 +1375,7 @@ describe('SPLIT-SEMANTICS GATE: voluntary vs recovery emission', () => { expect(recovered.length).toBe(0); }); - it('recovery extraction (recoverInline path) emits agent:recovered only', async () => { + it('recovery extraction (serial recovery path) emits agent:recovered only', async () => { // To trigger recovery's successful-extraction path we need: // - agent stops without producing a voluntary result (initial STOP) // - recovery's grammar-constrained generation produces tokens whose @@ -1405,7 +1405,7 @@ describe('SPLIT-SEMANTICS GATE: voluntary vs recovery emission', () => { // Token sequence for the single agent's branch: // [STOP, 100, STOP] // - first STOP: agent's PRODUCE phase hits stop; the main turn's parse goes - // through onProduced → idle (free_text_stop) → agent killed → recoverInline + // through onProduced → idle (free_text_stop) → the close sweep's serial recovery // - 100, STOP: recovery's produce/commit loop generates token 100, then STOP → // finishRecovery parses the recovery output via parseChatOutput, which (below) // returns the terminal `report` call → agent.setResult → agent:recovered diff --git a/packages/agents/test/agent-transitions.prop.test.ts b/packages/agents/test/agent-transitions.prop.test.ts new file mode 100644 index 00000000..4e7987dd --- /dev/null +++ b/packages/agents/test/agent-transitions.prop.test.ts @@ -0,0 +1,71 @@ +/** + * The agent lifecycle is a table. Every legal move succeeds and lands on its + * target; every other move throws and leaves the status untouched; `disposed` + * is terminal. Checked over random paths rather than hand-picked pairs. + */ +import { describe, it, expect } from 'vitest'; +import * as fc from 'fast-check'; +import { Agent, type AgentStatus } from '../src/Agent'; +import { createMockBranch } from './helpers/mock-branch'; +import { FMT } from './helpers/format-config'; + +const STATUSES: readonly AgentStatus[] = ['idle', 'active', 'awaiting_tool', 'disposed']; + +/** The legal moves — the one table `Agent.transition` enforces. */ +const LEGAL = new Set([ + 'idle>active', // first sample + 'idle>awaiting_tool', // the close sweep parks an idle agent on its recovery turn + 'idle>disposed', // branch pruned + 'active>awaiting_tool', // tool call, nudge or recovery turn pending + 'active>idle', // stop token, report, or kill + 'awaiting_tool>active', // the pending turn landed + 'awaiting_tool>idle', // dropped while waiting +]); + +function agent(): Agent { + return new Agent({ id: 1, parentId: 0, branch: createMockBranch({ handle: 1 }) as any, fmt: FMT }); +} + +describe('property: agent transitions follow the table', () => { + it('legal moves land; illegal moves throw and change nothing', () => { + fc.assert( + fc.property(fc.array(fc.constantFrom(...STATUSES), { maxLength: 24 }), (path) => { + const a = agent(); + for (const to of path) { + const from = a.status; + if (LEGAL.has(`${from}>${to}`)) { + a.transition(to); + expect(a.status).toBe(to); + } else { + expect(() => a.transition(to)).toThrow('Invalid agent status transition'); + expect(a.status).toBe(from); + } + } + }), + { numRuns: 200 }, + ); + }); + + it('disposed is terminal, however it was reached', () => { + fc.assert( + fc.property(fc.constantFrom(...STATUSES.filter(s => s !== 'disposed')), fc.constantFrom(...STATUSES), (via, to) => { + const a = agent(); + if (via !== 'idle') a.transition(via); + a.dispose(); + expect(a.status).toBe('disposed'); + expect(() => a.transition(to)).toThrow('Invalid agent status transition'); + }), + ); + }); + + it('startedAt stamps the first activation only', () => { + const a = agent(); + expect(a.startedAt).toBeNull(); + a.transition('active'); + const t0 = a.startedAt; + expect(t0).not.toBeNull(); + a.transition('awaiting_tool'); + a.transition('active'); + expect(a.startedAt).toBe(t0); + }); +}); diff --git a/packages/agents/test/authGuard.test.ts b/packages/agents/test/authGuard.test.ts index caae5ff6..87be6445 100644 --- a/packages/agents/test/authGuard.test.ts +++ b/packages/agents/test/authGuard.test.ts @@ -35,6 +35,7 @@ import { describe, it, expect } from 'vitest'; import type { ParsedToolCall } from '@lloyal-labs/sdk'; import { DefaultAgentPolicy, type PolicyConfig, type ToolGuard } from '../src/AgentPolicy'; +import { ContextPressure } from '../src/pressure'; import { Agent } from '../src/Agent'; import { createMockBranch } from './helpers/mock-branch'; @@ -82,18 +83,9 @@ function makeAgent(opts: { return agent; } -function pressure(remaining = 5000, nCtx = 16384) { - return { - headroom: remaining - 1024, - critical: remaining < 128, - remaining, - nCtx, - cellsUsed: nCtx - remaining, - percentAvailable: nCtx > 0 ? Math.max(0, Math.round((remaining / nCtx) * 100)) : 100, - canFit: (n: number) => n <= remaining - 1024, - softLimit: 1024, - hardLimit: 128, - }; +/** A frozen pressure reading — the real value, not a hand-rolled twin. */ +function pressure(remaining = 5000, nCtx = 16384): ContextPressure { + return new ContextPressure({ nCtx, cellsUsed: nCtx - remaining, remaining }, { softLimit: 1024, hardLimit: 128 }); } /** A parsed tool call. Typed as the real `ParsedToolCall` so a field added to diff --git a/packages/agents/test/helpers/mock-branch.ts b/packages/agents/test/helpers/mock-branch.ts index 930df8ce..5dd47d15 100644 --- a/packages/agents/test/helpers/mock-branch.ts +++ b/packages/agents/test/helpers/mock-branch.ts @@ -23,12 +23,5 @@ export function createMockBranch(opts?: { modelSurprisal: () => 1.0, forkSync() { return createMockBranch({ position, forkHead: position, handle: (opts?.handle ?? 1) + 1000 }); }, pruneSync() { disposed = true; }, - /** Mock async iterator — yields from a pre-set token sequence, then stops. */ - _tokens: [] as Array<{ token: number; text: string }>, - async *[Symbol.asyncIterator](): AsyncIterableIterator<{ token: number; text: string }> { - for (const t of this._tokens) { - yield t; - } - }, }; } diff --git a/packages/agents/test/invariants/README.md b/packages/agents/test/invariants/README.md index 948e92e6..d92bb6cf 100644 --- a/packages/agents/test/invariants/README.md +++ b/packages/agents/test/invariants/README.md @@ -64,14 +64,23 @@ learn the framework's contract. The full I1–I40 invariant catalog is tracked in the framework's planning notes (outside this repo). This directory implements them incrementally. -Implemented so far: +Implemented as predicates in `predicates.ts`, each wired into at least one test: -- I24 (SETTLE-policy-consulted) — `pressure.prop.test.ts` + - `scenarios/pressure-exit-via-settle-policy-nudge.scenario.test.ts` -- I25 (stall-break-distinct) — `scenarios/pressure-exit-via-stall-break.scenario.test.ts` +- I1 (native-store-single-fiber) — `fanout-*`, `parallel-recovery`, `wind-down`, `concurrent-extend-spine` +- I4 (SPAWN-batched) — `scenarios/concurrent-extend-spine.scenario.test.ts` +- I24 (SETTLE-policy-consulted) — `pressure.prop.test.ts` - I29 (recovery-diagnostic-complete) — `scenarios/recovery-fails.scenario.test.ts` +- I30 (exit-reason-matches-trace) — `exit-reason.prop.test.ts` +- I31 (trace-attribution) — `scenarios/trace-attribution.scenario.test.ts` +- I32 (pause-holds-native) — `scenarios/pause.scenario.test.ts` +- I33 (agent-failure-isolated) — `scenarios/media-*.scenario.test.ts` -Remaining invariants (I1–I23, I26–I28, I30–I40) land incrementally. +The stall-break reason distinction (once "I25") is asserted directly by +`scenarios/pressure-exit-via-stall-break.scenario.test.ts` and +`decision-matrix.scenario.test.ts`; the predicate that carried the number +failed on every correct run and was removed. + +Remaining invariants land incrementally. ## Running diff --git a/packages/agents/test/invariants/harness.ts b/packages/agents/test/invariants/harness.ts index 003ae986..9fa500e8 100644 --- a/packages/agents/test/invariants/harness.ts +++ b/packages/agents/test/invariants/harness.ts @@ -144,13 +144,6 @@ export interface PoolSpec { toolsJson?: string; /** The pool's terminal tool name — read by `runPool`. */ terminalToolName?: string; - /** - * @deprecated Dead field — `runPool` reads `terminalToolName`, not this. Kept so - * the 6 existing call sites (authGuard-rejection, xss-cross-ability-prose) still - * type-check; migrating them to `terminalToolName` changes their behaviour and is - * tracked in lloyal-ai/hdk#24. - */ - terminalTool?: string; maxTurns?: number; maxConcurrentTools?: number; taskCount?: number; diff --git a/packages/agents/test/invariants/predicates.ts b/packages/agents/test/invariants/predicates.ts index 9052f244..e6941eb4 100644 --- a/packages/agents/test/invariants/predicates.ts +++ b/packages/agents/test/invariants/predicates.ts @@ -46,9 +46,14 @@ export function I4_spawnBatched(run: PoolRun): PredicateResult { e => e.type === 'branch:create' && (e as any).role === 'agentFork', ).length; if (forks === 0) return ok(); - const firstPrefill = run.nativeCalls.find(c => c.op === 'prefill'); + // The harness prefills the root before any fork (ledger entry 0); the SPAWN + // batch is the first prefill that lands after the first agentFork create. + const firstFork = run.traceEvents.find( + e => e.type === 'branch:create' && (e as any).role === 'agentFork', + ) as { ts: number }; + const firstPrefill = run.nativeCalls.find(c => c.op === 'prefill' && c.tStart >= firstFork.ts); if (!firstPrefill) { - return fail('I4', `${forks} agentFork(s) but no store.prefill call recorded`); + return fail('I4', `${forks} agentFork(s) but no store.prefill call recorded after the first fork`); } if (firstPrefill.branchCount !== forks) { return fail( @@ -89,36 +94,6 @@ export function I24_settlePolicyConsulted( return ok(); } -/** - * I25 Stall-break-last-resort: settle_stall_break fires only when policy - * said nudge and the nudge itself re-deferred (or policy is absent). A drop - * with reason `settle_stall_break` must NOT occur when there exists an - * active agent at the time the decision was made. - * - * Weakly verified via: no two drops with reason 'settle_stall_break' can - * happen while another agent is still active in the trace. - * - * Strongly verified by inspecting production code paths — future work. - * For now, check that `settle_stall_break` is used at all (not collapsed - * with `pressure_settle_reject`). - */ -export function I25_stallBreakDistinct(run: PoolRun): PredicateResult { - const drops = run.traceEvents.filter(e => e.type === 'pool:agentDrop'); - const reasons = new Set(drops.map(d => (d as any).reason)); - const hasSettleReject = reasons.has('pressure_settle_reject'); - const hasStallBreak = reasons.has('settle_stall_break'); - const hasStallBreakReason = drops.some( - d => (d as any).reason === 'settle_stall_break', - ); - if (hasSettleReject && !hasStallBreak) { - return fail( - 'I25', - `pressure_settle_reject present but settle_stall_break never — reasons are collapsed into one`, - ); - } - return ok(); -} - /** * I29 Recovery-diagnostic-complete: every recovery attempt emits exactly * one of pool:recoveryReturn / pool:recoveryFailed after its @@ -153,7 +128,7 @@ export function I29_recoveryDiagnostic(run: PoolRun): PredicateResult { */ export function nudgeMessageContainsBudget( run: PoolRun, - reason?: 'settle_reject' | 'nudge' | 'pressure_softcut' | 'pressure_settle_reject' | 'time_nudge', + reason?: 'settle_reject' | 'nudge' | 'pressure_softcut' | 'pressure_settle_reject', ): PredicateResult { const nudges = run.traceEvents.filter(e => e.type === 'pool:agentNudge'); const filtered = reason @@ -195,6 +170,7 @@ const RECORDED_EXIT_REASONS = new Set([ 'policy_exit', 'pressure_softcut', 'maxTurns', + 'report_cap', ]); export function I30_exitReasonMatchesTrace(run: PoolRun): PredicateResult { diff --git a/packages/agents/test/invariants/pressure.prop.test.ts b/packages/agents/test/invariants/pressure.prop.test.ts index 4ffe88bb..8c27594b 100644 --- a/packages/agents/test/invariants/pressure.prop.test.ts +++ b/packages/agents/test/invariants/pressure.prop.test.ts @@ -17,6 +17,7 @@ import type { Operation } from 'effection'; import type { JsonSchema } from '../../src/types'; import type { AgentPolicy } from '../../src/AgentPolicy'; import { runPool, STOP } from './harness'; +import { I24_settlePolicyConsulted } from './predicates'; class SizedTool extends Tool<{ query: string }> { readonly name = 'web_search'; @@ -62,17 +63,8 @@ describe('property: pressure-driven exits', () => { maxTurns: 3, }); - const settleDrops = run.traceEvents.filter( - e => e.type === 'pool:agentDrop' - && (e as any).reason === 'pressure_settle_reject', - ); - - // Invariant: any pressure_settle_reject drop implies the policy - // was consulted. - if (settleDrops.length > 0) { - return onSettleRejectCalls >= 1; - } - return true; // no drop → invariant trivially holds + // Invariant: any settle-related drop implies the policy was consulted. + return I24_settlePolicyConsulted(run, onSettleRejectCalls).ok; }, ), { numRuns: 30, seed: 42 }, diff --git a/packages/agents/test/invariants/scenarios/agent-cancel-no-sweep-recovery.scenario.test.ts b/packages/agents/test/invariants/scenarios/agent-cancel-no-sweep-recovery.scenario.test.ts index 1d45e77b..f22d0d19 100644 --- a/packages/agents/test/invariants/scenarios/agent-cancel-no-sweep-recovery.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/agent-cancel-no-sweep-recovery.scenario.test.ts @@ -5,7 +5,7 @@ * `safePrune` only reclaims childless leaves. An agent that spawned a sub-agent (recursion * is a standing capability) has `branch.children.length > 0`, so `safePrune` no-ops at * cancel and the branch stays non-disposed. The termination sweep recovers any agent left - * `idle && !result && !branch.disposed` — so pre-fix it would `recoverInline()` the + * `idle && !result && !branch.disposed` — so pre-fix it would force-recover the * cancelled agent, emitting a SECOND terminal event (here `agent:failed(recovery_skipped)`) * after the `agent:failed(user_cancel)`. The `discardedIds` guard excludes it. * @@ -55,7 +55,7 @@ describe('scenario: cancelled non-leaf agent is not force-recovered by the sweep const aId = (cancelled[0] as { agentId: number }).agentId; // The cancelled agent must have EXACTLY ONE terminal event (the user_cancel) and NO - // recovery. Pre-fix, the sweep's recoverInline(A) emits a second agent:failed + // recovery. Pre-fix, the sweep's recovery of A emits a second agent:failed // (recovery_skipped) — this assertion is red then. const aFailures = run.channelEvents.filter( e => e.type === 'agent:failed' && (e as { agentId: number }).agentId === aId, @@ -65,7 +65,7 @@ describe('scenario: cancelled non-leaf agent is not force-recovered by the sweep expect( run.channelEvents.some(e => e.type === 'agent:recovered' && (e as { agentId: number }).agentId === aId), ).toBe(false); - // No recovery prefill ran for the cancelled agent (recoverInline never entered). + // No recovery prefill ran for the cancelled agent (the sweep never picked it). const aRecoveryPrefills = run.traceEvents.filter( e => e.type === 'branch:prefill' && (e as { role?: string }).role === 'recovery' diff --git a/packages/agents/test/invariants/scenarios/authGuard-rejection.scenario.test.ts b/packages/agents/test/invariants/scenarios/authGuard-rejection.scenario.test.ts index d78b9b65..f5fc06cb 100644 --- a/packages/agents/test/invariants/scenarios/authGuard-rejection.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/authGuard-rejection.scenario.test.ts @@ -50,7 +50,7 @@ describe('scenario: authGuard rejects protected tool calls without a grant (§10 }], policy: new DefaultAgentPolicy({ terminalToolName: 'report' }), tools, - terminalTool: 'report', + terminalToolName: 'report', trace: true, }); @@ -73,7 +73,7 @@ describe('scenario: authGuard rejects protected tool calls without a grant (§10 }], policy: new DefaultAgentPolicy({ terminalToolName: 'report' }), tools, - terminalTool: 'report', + terminalToolName: 'report', trace: true, }); @@ -107,7 +107,7 @@ describe('scenario: authGuard rejects protected tool calls without a grant (§10 }], policy: new DefaultAgentPolicy({ terminalToolName: 'report' }), tools, - terminalTool: 'report', + terminalToolName: 'report', trace: true, }); diff --git a/packages/agents/test/invariants/scenarios/chain-cohort-recovery.scenario.test.ts b/packages/agents/test/invariants/scenarios/chain-cohort-recovery.scenario.test.ts new file mode 100644 index 00000000..6aba2eb4 --- /dev/null +++ b/packages/agents/test/invariants/scenarios/chain-cohort-recovery.scenario.test.ts @@ -0,0 +1,52 @@ +/** + * Scenario: a chain step reads the RECOVERED result, never an empty one. + * + * `chain` waits for each agent, then extends the spine with `agent.result`. + * When the agent idles without reporting and recovery is cohort-shaped, the + * recovery turn is queued in the same step that ends the agent's span. The + * agent must be parked on that turn BEFORE anything else happens: if it passed + * through `idle` on the way, `waitFor` would resolve against `result === null`, + * the chain would skip its extension, and the next step would fork from a spine + * that never heard the findings. + * + * What this locks: with cohort recovery, every chain step's `spine:extend` + * carries the recovered result, and every agent reports. + */ +import { describe, it, expect } from 'vitest'; +import type { AgentPolicy } from '../../../src/AgentPolicy'; +import { runPool, STOP, chain } from '../harness'; + +const REPORT_CALL = { name: 'report', arguments: '{"result":"recovered"}' }; + +const policy: AgentPolicy = { + onProduced: () => ({ type: 'idle', reason: 'free_text_stop' }), + onSettleReject: () => ({ type: 'idle', reason: 'pressure_settle_reject' }), + onRecovery: () => ({ type: 'extract', prompt: { system: 's', user: 'u' } }), + shouldExit: () => false, + recoveryShape: 'parallel', +}; + +describe('scenario: chain + cohort recovery', () => { + it('every step extends the spine with the RECOVERED result', async () => { + // Each agent: one token, STOP (idle, no result) → recovery turn → one token, + // STOP; the recovery output parses to the terminal call. + const script = { tokens: [1, STOP, 1, STOP], content: 'prose', toolCall: REPORT_CALL }; + const run = await runPool({ + nCtx: 8192, cellsUsed: 0, + terminalToolName: 'report', + scripts: [script, script], + orchestrate: chain([0, 1], (i) => ({ + task: { content: `Task ${i}`, systemPrompt: 'You are an agent.', seed: i }, + userContent: `Task ${i}`, + })), + policy, + }); + + const extended = run.traceEvents + .filter((e): e is Extract => e.type === 'spine:extend') + .map(e => e.assistantContent); + expect(extended, 'each step must extend with what recovery produced').toEqual(['recovered', 'recovered']); + expect(run.channelEvents.filter(e => e.type === 'agent:recovered')).toHaveLength(2); + expect(run.result.agents.map(a => a.result)).toEqual(['recovered', 'recovered']); + }); +}); diff --git a/packages/agents/test/invariants/scenarios/concurrent-extend-spine.scenario.test.ts b/packages/agents/test/invariants/scenarios/concurrent-extend-spine.scenario.test.ts index e926a5ae..02663a9d 100644 --- a/packages/agents/test/invariants/scenarios/concurrent-extend-spine.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/concurrent-extend-spine.scenario.test.ts @@ -23,7 +23,7 @@ import type { Operation } from 'effection'; import type { AgentPolicy } from '../../../src/AgentPolicy'; import type { PoolContext } from '../../../src/orchestrators'; import { runPool, STOP } from '../harness'; -import { I1_nativeStoreSingleFiber } from '../predicates'; +import { I1_nativeStoreSingleFiber, I4_spawnBatched, formatResult } from '../predicates'; describe('scenario: concurrent extendSpine has no native-call overlap', () => { const minimalPolicy: AgentPolicy = { @@ -71,6 +71,8 @@ describe('scenario: concurrent extendSpine has no native-call overlap', () => { // I1: no native-call temporal overlap across the entire run. const i1 = I1_nativeStoreSingleFiber(run); expect(i1.ok, i1.violations.map(v => v.detail).join('\n')).toBe(true); + // I4: the three concurrent spawns land as ONE native prefill carrying three branches. + expect(formatResult('I4', I4_spawnBatched(run))).toBe('I4: ok'); // Each extend emitted its spine:extend trace event — the drain resolved // the rendezvous action for every request. diff --git a/packages/agents/test/invariants/scenarios/hardLimit-nBatch-invariant.scenario.test.ts b/packages/agents/test/invariants/scenarios/hardLimit-nBatch-invariant.scenario.test.ts index bcd4a956..82c8cc16 100644 --- a/packages/agents/test/invariants/scenarios/hardLimit-nBatch-invariant.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/hardLimit-nBatch-invariant.scenario.test.ts @@ -1,7 +1,7 @@ /** * Scenario: useAgentPool validates `hardLimit >= nBatch` at startup. * - * When `pressure.critical` fires, the kill path invokes `recoverInline` + * When `pressure.critical` fires, the kill path runs a recovery turn * which prefills + decodes within the `hardLimit` reserve. If hardLimit * is smaller than the context's nBatch (native batch allocation size), * recovery's decode will OOM with "no memory slot for batch of size N". diff --git a/packages/agents/test/invariants/scenarios/media-prefill-failure-claims-no-kv.scenario.test.ts b/packages/agents/test/invariants/scenarios/media-prefill-failure-claims-no-kv.scenario.test.ts index e4c6a9fe..04c1f696 100644 --- a/packages/agents/test/invariants/scenarios/media-prefill-failure-claims-no-kv.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/media-prefill-failure-claims-no-kv.scenario.test.ts @@ -67,7 +67,7 @@ describe('scenario: a poisoned media prefill claims no KV', () => { it('is not recorded as a RECOVERY failure', async () => { // `pool:recoveryFailed` has a stated meaning — "produce completed but - // output unparseable", emitted by recoverInline — and `outputExcerpt` is + // output unparseable", emitted by the recovery path — and `outputExcerpt` is // the MODEL'S output. A native decode error is neither. Overloading the // event makes the field's own invariant false and leaves a reader unable // to tell an unparseable answer from a failed prefill without matching on diff --git a/packages/agents/test/invariants/scenarios/nudge-message-includes-budget.scenario.test.ts b/packages/agents/test/invariants/scenarios/nudge-message-includes-budget.scenario.test.ts index 40ded119..b8bead60 100644 --- a/packages/agents/test/invariants/scenarios/nudge-message-includes-budget.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/nudge-message-includes-budget.scenario.test.ts @@ -17,6 +17,7 @@ import type { Operation } from 'effection'; import type { JsonSchema } from '../../../src/types'; import { DefaultAgentPolicy } from '../../../src/AgentPolicy'; import { runPool, STOP } from '../harness'; +import { nudgeMessageContainsBudget, formatResult } from '../predicates'; class BigResultTool extends Tool<{ query: string }> { readonly name = 'web_search'; @@ -51,6 +52,7 @@ describe('scenario: nudge message includes the remaining token budget', () => { e => e.type === 'pool:agentNudge' && (e as any).reason === 'settle_reject', ); expect(settleNudges.length).toBeGreaterThanOrEqual(1); + expect(formatResult('budget', nudgeMessageContainsBudget(run, 'settle_reject'))).toBe('budget: ok'); // Message pattern: "Tool result too large … within N words." // Tokens-to-words: floor(tokens * 0.7 / 10) * 10. Words are used in diff --git a/packages/agents/test/invariants/scenarios/parallel-recovery.scenario.test.ts b/packages/agents/test/invariants/scenarios/parallel-recovery.scenario.test.ts index 20ac3ab4..4cbc4f35 100644 --- a/packages/agents/test/invariants/scenarios/parallel-recovery.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/parallel-recovery.scenario.test.ts @@ -18,8 +18,8 @@ const REPORT_CALL = { name: 'report', arguments: '{"result":"recovered"}' }; /** * Every agent drops to idle WITHOUT a voluntary result on its first stop * (`free_text_stop`), so each is recovered: `parallel` injects the recovery turn - * IN-LOOP (handleRecover → SETTLE admission → bin-packed decode); `staggered` - * blocks via `recoverInline`. `reportBudget` is the FIXED per-report cap `b`. + * as a cohort (recovery item → admission → bin-packed decode); `staggered` + * recovers one at a time. `reportBudget` is the FIXED per-report cap `b`. */ function idleNoResultPolicy( shape: 'staggered' | 'parallel', @@ -35,14 +35,17 @@ function idleNoResultPolicy( }; } -// role='recovery' → the BLOCKING `recoverInline` path (staggered + the stall-break -// fallback). role='toolResult' → the IN-LOOP recovery turn prefilled through SETTLE -// (the parallel path). In these no-tool scenarios `toolResult` prefills are exactly -// the in-loop recovery turns. -const inlinePrefills = (r: PoolRun) => +// A recovery turn's prefill carries role='recovery' whatever the shape. The +// SHAPE is on the trace: `tool:settle_order` lists the items that landed in one +// admission, so cohort recovery shows one batch of N recovery turns and serial +// recovery shows N batches of one. +const recoveryPrefills = (r: PoolRun) => r.traceEvents.filter(e => e.type === 'branch:prefill' && (e as { role?: string }).role === 'recovery'); -const inLoopPrefills = (r: PoolRun) => - r.traceEvents.filter(e => e.type === 'branch:prefill' && (e as { role?: string }).role === 'toolResult'); +const recoveryBatches = (r: PoolRun): number[] => + r.traceEvents + .filter((e): e is Extract => e.type === 'tool:settle_order') + .map(e => e.batch.filter(b => b.callId.startsWith('recovery:')).length) + .filter(n => n > 0); const recoveryProduce = (r: PoolRun) => r.traceEvents.filter(e => e.type === 'pool:recoveryProduce'); const recoveryReturn = (r: PoolRun) => @@ -62,19 +65,19 @@ const idleScripts = () => idleScriptsN(N); * `b` (prompt advisory + token-stop), sized so the WHOLE cohort's prefill+decode * fits headroom in one tick — so every reaped agent recovers, nothing is deferred or * lost. `staggered` (high effort) is the lossless serial path — blocking - * `recoverInline`, uncapped — and is unchanged. + * serial, uncapped — and is unchanged. */ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { - it('recovers every parallel agent IN-LOOP via SETTLE — never the blocking recoverInline', async () => { + it('recovers every parallel agent as ONE cohort', async () => { const r = await runPool({ nCtx: 8192, cellsUsed: 0, scripts: idleScripts(), policy: idleNoResultPolicy('parallel'), }); - // Every agent's recovery turn was prefilled through SETTLE (role=toolResult)… - expect(inLoopPrefills(r).length).toBe(N); - // …and NONE went through the blocking private-loop recoverInline (role=recovery). - expect(inlinePrefills(r).length).toBe(0); + // Every agent's recovery turn was prefilled… + expect(recoveryPrefills(r).length).toBe(N); + // …as ONE cohort: a single admission carried all N recovery turns. + expect(recoveryBatches(r)).toEqual([N]); // Every agent extracted a result in-loop (no loss). expect(recoveryProduce(r).length).toBe(N); expect(recoveryReturn(r).length).toBe(N); @@ -86,7 +89,7 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { it('stall-regression: a killed agent\'s recovery decodes bin-packed in a COMMIT with a LIVE sibling', async () => { // Agent 0 keeps producing (the live sibling); agent 1 stops early and is // recovered MID-RUN. The regression (the bug this whole change fixes): the old - // recoverInline ran agent 1's recovery in a private blocking loop that froze + // serial recovery ran agent 1's report in a private blocking loop that froze // agent 0 for the duration. In-loop, agent 1's recovery tokens ride the tick's // batched COMMIT *with* agent 0's live tokens — proven by a branchCount≥2 commit // landing strictly inside agent 1's recovery window. @@ -100,10 +103,10 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { }); // The short agent recovers FIRST; derive its id from the first recovery-turn - // prefill (role=toolResult) rather than hardcoding a fork handle. Its recovery - // window runs from that prefill to its recovery extraction (pool:recoveryProduce). + // prefill rather than hardcoding a fork handle. Its recovery window runs from + // that prefill to its recovery extraction (pool:recoveryProduce). const prefill = r.traceEvents.find( - e => e.type === 'branch:prefill' && (e as { role?: string }).role === 'toolResult', + e => e.type === 'branch:prefill' && (e as { role?: string }).role === 'recovery', ); expect(prefill).toBeDefined(); const recoveringId = (prefill as { branchHandle: number }).branchHandle; @@ -121,8 +124,6 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { ); expect(coBatched.length).toBeGreaterThanOrEqual(1); - // And it never fell back to the blocking recoverInline path. - expect(inlinePrefills(r).length).toBe(0); expect(recoveryReturn(r).some(e => (e as { agentId?: number }).agentId === recoveringId)).toBe(true); expect(I1_nativeStoreSingleFiber(r).ok).toBe(true); }); @@ -153,7 +154,7 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { // passes the `pressure_init` spawn guard) RESEARCHES the KV down BELOW softLimit, then // reaps and must recover. At reap `headroom = remaining − softLimit < 0`, so the OLD // SETTLE gate (`cost > headroom`) DEFERS every recovery → stall-break → serial - // `recoverInline` (role=recovery). The NEW gate budgets extracting items against + // serial recovery. The NEW gate budgets extracting items against // `headroom + reserveBand` (= remaining − hardLimit) → it ADMITS in-loop (role=toolResult). // Born at remaining 3192 (> soft 3000); ~400 research commits drain it to ≈2750 // (headroom ≈ −250, but remaining − hardLimit ≈ 2240 — plenty for the report). @@ -162,11 +163,11 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { scripts: [{ tokens: [...Array(400).fill(1), STOP, 1, STOP], content: 'research then reap', toolCall: REPORT_CALL }], policy: { ...idleNoResultPolicy('parallel'), pressureThresholds: { softLimit: 3000, hardLimit: 512 } }, }); - // Recovered (no loss), IN-LOOP via SETTLE — NOT the serial recoverInline the old - // softLimit floor would have forced under this negative headroom. + // Recovered (no loss), admitted first try from the `remaining − hardLimit` band — + // never via the stall-break, which the old softLimit floor would have forced. expect(recoveryReturn(r).length).toBe(1); - expect(inLoopPrefills(r).length).toBeGreaterThanOrEqual(1); - expect(inlinePrefills(r).length).toBe(0); + expect(recoveryPrefills(r).length).toBeGreaterThanOrEqual(1); + expect(r.traceEvents.filter(e => e.type === 'pool:agentDrop').length).toBe(0); expect(I1_nativeStoreSingleFiber(r).ok).toBe(true); }); @@ -180,7 +181,7 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { scripts: [{ tokens: [1, STOP, 1, 1, 1, 1, 1, 1, 1, 1, STOP], content: 'x', toolCall: REPORT_CALL }], policy: idleNoResultPolicy('parallel', 4), }); - expect(inLoopPrefills(r).length).toBe(1); + expect(recoveryPrefills(r).length).toBe(1); // The report was cut at exactly the budget — not the 8 the script would produce. expect((recoveryProduce(r)[0] as { tokenCount: number }).tokenCount).toBe(4); // …and the (partial) call was still extracted — the cap bounds, never drops. @@ -193,7 +194,7 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { // tick (the flat+medium run reaped 4 agents at 358s). The old SETTLE admission charged // each recovery item (prompt + report-budget) against headroom and DEFERRED the // overflow — and when the pool terminated after the admitted ones finished, the - // deferred agents' findings were LOST. Now `b` is sized in handleRecover so + // deferred agents' findings were LOST. Now `b` is sized in planRecovery so // aliveCount·(prompt + b) ≤ headroom: the cohort's recovery turns all prefill + decode // together in ONE batched tick (O(1) in branch count), nothing defers, nothing is lost. // cellsUsed simulates a partly-filled KV so the adaptive sizing actually bites. @@ -203,8 +204,8 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { }); expect(recoveryReturn(r).length).toBe(N); // every agent recovered — ZERO loss (the headline) expect(recoveryProduce(r).length).toBe(N); - expect(inLoopPrefills(r).length).toBe(N); // …all in-loop in one tick (admitted together, not deferred) - expect(inlinePrefills(r).length).toBe(0); // …never the serial recoverInline fallback + expect(recoveryPrefills(r).length).toBe(N); + expect(recoveryBatches(r)).toEqual([N]); // …all admitted together, in one batch expect(I1_nativeStoreSingleFiber(r).ok).toBe(true); expect(r.result).toBeDefined(); }); @@ -245,8 +246,8 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { policy: alwaysExit, }); - // It entered recovery (in-loop) exactly once… - expect(inLoopPrefills(r).length).toBe(1); + // It entered recovery exactly once… + expect(recoveryPrefills(r).length).toBe(1); // …was reaped exactly ONCE (the initial kill), never re-killed while extracting… expect(r.traceEvents.filter(e => e.type === 'pool:agentDrop').length).toBe(1); // …and its report survived to completion (not lost). @@ -271,8 +272,8 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { expect(recoveryReturn(crowd).length).toBe(6); // …and the whole crowd still recovered }); - it('staggered (high effort) recovers via the blocking recoverInline path, UNCAPPED (lossless) — unchanged', async () => { - // The lossless path: each report serializes through recoverInline and owns full + it('staggered (high effort) recovers one agent at a time, UNCAPPED (lossless) — unchanged', async () => { + // The lossless path: each report is admitted alone and owns full // headroom — NO token-stop, so even with a reportBudget set the report runs to // its natural stop. This is what `parallel` trades away for responsiveness. const r = await runPool({ @@ -283,9 +284,9 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { policy: idleNoResultPolicy('staggered', 4), // budget set, but staggered ignores the token-stop }); - // Every agent recovered via recoverInline (role=recovery), none in-loop. - expect(inlinePrefills(r).length).toBe(N); - expect(inLoopPrefills(r).length).toBe(0); + // Every agent recovered one at a time: N recovery prefills, N batches of one. + expect(recoveryPrefills(r).length).toBe(N); + expect(recoveryBatches(r)).toEqual([1, 1, 1]); // Uncapped: each report produced all 8 tokens (the full script), NOT cut at b=4. for (const p of recoveryProduce(r)) expect((p as { tokenCount: number }).tokenCount).toBe(8); expect(recoveryReturn(r).length).toBe(N); diff --git a/packages/agents/test/invariants/scenarios/recovery-agent-done-oneshot.scenario.test.ts b/packages/agents/test/invariants/scenarios/recovery-agent-done-oneshot.scenario.test.ts index d08b83ea..772e2c73 100644 --- a/packages/agents/test/invariants/scenarios/recovery-agent-done-oneshot.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/recovery-agent-done-oneshot.scenario.test.ts @@ -4,7 +4,7 @@ * * Shape: a free-texting agent (recoveryShape 'parallel') crosses the hardLimit * → `pressure.critical` kills it in PRODUCE, emitting `agent:done` (kill). The - * kill path calls `handleRecover`, which marks the agent `extracting` + + * kill path plans a cohort recovery, which marks the agent `extracting` + * `awaiting_tool` and queues a recovery turn. Under critical, that turn's SETTLE * admission budget is `remaining − hardLimit < 0`, so it always DEFERS; with no * active siblings the stall-break drop block runs. @@ -36,7 +36,7 @@ describe('scenario: agent:done is one-shot through defer→stall-break recovery' // nCtx 700, default hardLimit 512: after root+suffix prefill (~31) + ~158 // committed tokens, remaining < 512 → pressure.critical fires. The agent emits - // no terminal call (no partialToolCall) → handleRecover, not salvage. Single + // no terminal call (no partialToolCall) → a recovery turn, not salvage. Single // agent → the deferred recovery reaches the stall-break with no active siblings. const run = await runPool({ nCtx: 700, diff --git a/packages/agents/test/invariants/scenarios/recovery-fails.scenario.test.ts b/packages/agents/test/invariants/scenarios/recovery-fails.scenario.test.ts index 356b8f3b..25c6315e 100644 --- a/packages/agents/test/invariants/scenarios/recovery-fails.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/recovery-fails.scenario.test.ts @@ -2,7 +2,7 @@ * Scenario: a recovery that yields no terminal-tool call → pool:recoveryFailed * * Shape: agent free-texts (no voluntary terminal call), policy says idle → agent - * dropped → recoverInline runs → the recovery decode produces output that + * dropped → the serial recovery turn runs → the recovery decode produces output that * parseChatOutput finds NO terminal call in → finishRecovery reports the failure * (not silent). * @@ -18,11 +18,12 @@ import { describe, it, expect } from 'vitest'; import type { AgentPolicy } from '../../../src/AgentPolicy'; import { runPool, STOP } from '../harness'; +import { I29_recoveryDiagnostic, formatResult } from '../predicates'; describe('scenario: recovery generates no terminal call', () => { - it('drop → recoverInline output has no terminal call → pool:recoveryFailed with excerpt', async () => { + it('drop → recovery output has no terminal call → pool:recoveryFailed with excerpt', async () => { const policy: AgentPolicy = { - // Free-text every turn → idle drop → recoverInline runs (default staggered). + // Free-text every turn → idle drop → serial recovery runs (default staggered). onProduced: () => ({ type: 'idle', reason: 'free_text_stop' }), onSettleReject: () => ({ type: 'idle', reason: 'pressure_settle_reject' }), onRecovery: () => ({ type: 'extract', prompt: { system: 's', user: 'u' } }), @@ -32,7 +33,7 @@ describe('scenario: recovery generates no terminal call', () => { const run = await runPool({ nCtx: 4096, cellsUsed: 3000, - // [1, STOP] initial turn → idle → recoverInline; [2, 3, STOP] recovery decode. + // [1, STOP] initial turn → idle → recovery turn; [2, 3, STOP] recovery decode. // The script declares NO toolCall, so the mock's parseChatOutput returns no // terminal call for the recovery output → finishRecovery fails (not silent). scripts: [{ tokens: [1, STOP, 2, 3, STOP], content: 'unparseable prose' }], @@ -41,11 +42,12 @@ describe('scenario: recovery generates no terminal call', () => { maxTurns: 5, }); - // Recovery prefill happened (the blocking recoverInline path, role=recovery). + // Recovery prefill happened (role=recovery). const recoveryPrefills = run.traceEvents.filter( e => e.type === 'branch:prefill' && (e as any).role === 'recovery', ); expect(recoveryPrefills.length).toBeGreaterThanOrEqual(1); + expect(formatResult('I29', I29_recoveryDiagnostic(run))).toBe('I29: ok'); // Every recovery prefill is followed by exactly one diagnostic event. const reports = run.traceEvents.filter(e => e.type === 'pool:recoveryReturn'); diff --git a/packages/agents/test/invariants/scenarios/recovery-oom-no-orphan.scenario.test.ts b/packages/agents/test/invariants/scenarios/recovery-oom-no-orphan.scenario.test.ts index 7d7768cc..a2d37645 100644 --- a/packages/agents/test/invariants/scenarios/recovery-oom-no-orphan.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/recovery-oom-no-orphan.scenario.test.ts @@ -5,7 +5,7 @@ * The COMMIT batch where admitted reports decode can throw (KV exhausted by * concurrent reports). The pool then tears down — but each in-flight extractor * must FIRST receive a terminal `agent:failed`, else the UI spins forever on - * "writing report". The blocking `recoverInline` path has its own `scope_error` + * "writing report". The serial path used to have its own `scope_error` * catch; this locks the same guarantee for the in-loop COMMIT path. */ import { describe, it, expect } from 'vitest'; @@ -22,7 +22,7 @@ describe('scenario: in-loop recovery decode OOM emits agent:failed (no orphan)', onRecovery: () => ({ type: 'extract', prompt: { system: 's', user: 'u' } }), }; - // Loose KV (nCtx 8000): free-text turn 1 (`1, 2, STOP`) → idle → `handleRecover` + // Loose KV (nCtx 8000): free-text turn 1 (`1, 2, STOP`) → idle → cohort recovery // (parallel) → the recovery is ADMITTED at SETTLE (fits, not deferred) → agent // re-activates as an in-loop extractor → its report (`3, 4, 777`) decodes in the // tick loop's COMMIT. The 777 sentinel makes that commit throw (mock decode OOM) diff --git a/packages/agents/test/invariants/scenarios/recovery-skip-terminal.scenario.test.ts b/packages/agents/test/invariants/scenarios/recovery-skip-terminal.scenario.test.ts index afba8de2..db9a1204 100644 --- a/packages/agents/test/invariants/scenarios/recovery-skip-terminal.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/recovery-skip-terminal.scenario.test.ts @@ -9,7 +9,7 @@ * streams) with a timer that never freezes. * * Locks: every agent that got `agent:done` gets a resolving terminal event, even on skip - * (both `handleRecover`, parallel, and `recoverInline`, staggered). + * (cohort and serial recovery alike). */ import { describe, it, expect } from 'vitest'; import type { AgentPolicy } from '../../../src/AgentPolicy'; @@ -25,7 +25,7 @@ describe('scenario: skipped recovery emits agent:failed (no orphan)', () => { }; // Free-text turn 1 (`1, 2, STOP`) → onProduced idle → the parallel idle path emits - // agent:done then calls handleRecover → onRecovery skip. + // agent:done then plans recovery → onRecovery skip. const run = await runPool({ nCtx: 4096, cellsUsed: 0, @@ -37,7 +37,7 @@ describe('scenario: skipped recovery emits agent:failed (no orphan)', () => { const done = run.channelEvents.filter(e => e.type === 'agent:done'); const failed = run.channelEvents.filter(e => e.type === 'agent:failed'); // agent:done fired once at the drop; the skip must be followed by a terminal agent:failed - // (pre-fix: handleRecover returned null silently → failed.length === 0 → orphan). + // (pre-fix: the skip was silent → failed.length === 0 → orphan). expect(done.length).toBe(1); expect(failed.length).toBe(1); expect((failed[0] as { reason: string }).reason).toBe('recovery_skipped'); diff --git a/packages/agents/test/invariants/scenarios/tick-active-count.scenario.test.ts b/packages/agents/test/invariants/scenarios/tick-active-count.scenario.test.ts new file mode 100644 index 00000000..8934d08e --- /dev/null +++ b/packages/agents/test/invariants/scenarios/tick-active-count.scenario.test.ts @@ -0,0 +1,44 @@ +/** + * Scenario: `pool:tick.activeAgents` counts the cohort AFTER the tick's + * decisions. + * + * The record has two contributors — the commit landing supplies the pressure + * reading, the interpretation of that tick's stops supplies the count — so an + * agent that returns on the tick another agent decodes is already out of the + * count on that tick's record. + */ +import { describe, it, expect } from 'vitest'; +import type { AgentPolicy } from '../../../src/AgentPolicy'; +import { runPool, STOP } from '../harness'; + +const REPORT_CALL = { name: 'report', arguments: '{"result":"done"}' }; + +const policy: AgentPolicy = { + onProduced: (_a, parsed) => + parsed.toolCalls.length > 0 + ? { type: 'return', result: 'done' } + : { type: 'idle', reason: 'free_text_stop' }, + shouldExit: () => false, + onRecovery: () => ({ type: 'skip' }), +}; + +describe('scenario: pool:tick counts after the tick', () => { + it('an agent that returns while its sibling decodes leaves the count on that tick', async () => { + const run = await runPool({ + nCtx: 16384, cellsUsed: 0, + terminalToolName: 'report', + scripts: [ + { tokens: [1, 1, 1, STOP], content: 'x' }, // A: three commits, then stops + { tokens: [1, STOP], toolCall: REPORT_CALL }, // B: one commit, then returns + ], + taskCount: 2, + policy, + }); + const counts = run.traceEvents + .filter((e): e is Extract => e.type === 'pool:tick') + .map(e => e.activeAgents); + // Tick 1: both decode. Tick 2: A decodes, B returns — B is already out. + // Tick 3: A alone. + expect(counts).toEqual([2, 1, 1]); + }); +}); diff --git a/packages/agents/test/invariants/scenarios/wind-down.scenario.test.ts b/packages/agents/test/invariants/scenarios/wind-down.scenario.test.ts index 57f49866..4f4391bf 100644 --- a/packages/agents/test/invariants/scenarios/wind-down.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/wind-down.scenario.test.ts @@ -32,10 +32,14 @@ const windDownDrops = (r: PoolRun) => r.traceEvents.filter(e => e.type === 'pool:agentDrop' && (e as { reason?: string }).reason === 'wind_down'); const recoveryPrefills = (r: PoolRun) => r.traceEvents.filter(e => e.type === 'branch:prefill' && (e as { role?: string }).role === 'recovery'); -// role='toolResult' prefills are the IN-LOOP recovery turns (handleRecover → SETTLE); -// in these no-tool scenarios they are exactly the in-loop reaps. -const inLoopPrefills = (r: PoolRun) => - r.traceEvents.filter(e => e.type === 'branch:prefill' && (e as { role?: string }).role === 'toolResult'); +// The shape is on the trace: `tool:settle_order` lists the items that landed in +// one admission — the reaped cohort's recovery turns as one batch of N; serial +// recovery as N batches of one. +const recoveryBatches = (r: PoolRun): number[] => + r.traceEvents + .filter((e): e is Extract => e.type === 'tool:settle_order') + .map(e => e.batch.filter(b => b.callId.startsWith('recovery:')).length) + .filter(n => n > 0); const spawnEvents = (r: PoolRun) => r.channelEvents.filter(e => e.type === 'agent:spawn'); const onFirstSpawn = (ev: { type: string }) => ev.type === 'agent:spawn'; @@ -51,9 +55,10 @@ describe('scenario: graceful wind-down (drain)', () => { // Every active agent was reaped SPECIFICALLY by wind-down (a distinct reason // from pressure/time/maxTurns), and reaped in one tick (no stagger). expect(windDownDrops(run).length).toBe(N); - // Every reaped agent had its recovery turn injected IN-LOOP (handleRecover → - // SETTLE, role=toolResult) — wind-down always bin-packs the drain. - expect(inLoopPrefills(run).length).toBe(N); + // Every reaped agent had its recovery turn admitted as ONE cohort — wind-down + // always bin-packs the drain. + expect(recoveryPrefills(run).length).toBe(N); + expect(recoveryBatches(run)).toEqual([N]); // The bin-packed recovery decode holds the single-fiber SEGV invariant. expect(I1_nativeStoreSingleFiber(run).ok).toBe(true); // The run terminated cleanly with a result. @@ -65,19 +70,19 @@ describe('scenario: graceful wind-down (drain)', () => { const windPar = await runPool({ nCtx: 8192, cellsUsed: 0, scripts: activeScriptsN(N), policy: activePolicy('parallel'), windDownAfter: onFirstSpawn }); // Baseline: the SAME staggered policy WITHOUT wind-down — agents run to STOP, // land idle-no-result, and the termination sweep recovers them one-at-a-time - // through the BLOCKING recoverInline (role=recovery, zero in-loop prefills). + // one at a time (the close sweep's serial recovery). const baseStag = await runPool({ nCtx: 8192, cellsUsed: 0, scripts: activeScriptsN(N), policy: activePolicy('staggered') }); - // Wind-down — staggered shape AND parallel shape alike — injects every reap's - // recovery turn IN-LOOP (role=toolResult): the staggered shape was overridden. - expect(inLoopPrefills(windStag).length).toBe(N); - expect(inLoopPrefills(windPar).length).toBe(N); + // Wind-down — staggered shape AND parallel shape alike — admits every reap's + // recovery turn as one cohort: the staggered shape was overridden. + expect(recoveryBatches(windStag)).toEqual([N]); + expect(recoveryBatches(windPar)).toEqual([N]); - // The staggered baseline (no wind-down) takes the blocking path instead — NO - // in-loop recovery turns, every recovery via recoverInline. That contrast is - // the proof wind-down forced the in-loop shape regardless of the policy. - expect(inLoopPrefills(baseStag).length).toBe(0); + // The staggered baseline (no wind-down) recovers one at a time instead — N + // recovery prefills, none co-admitted. That contrast is the proof wind-down + // forced the cohort shape regardless of the policy. expect(recoveryPrefills(baseStag).length).toBe(N); + expect(recoveryBatches(baseStag)).toEqual([1, 1, 1]); }); it('leaves an agent mid-terminal-tool to finish its voluntary report; reaps its free-text sibling', async () => { diff --git a/packages/agents/test/invariants/scenarios/xss-cross-ability-prose.scenario.test.ts b/packages/agents/test/invariants/scenarios/xss-cross-ability-prose.scenario.test.ts index b26f5eec..02a9e467 100644 --- a/packages/agents/test/invariants/scenarios/xss-cross-ability-prose.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/xss-cross-ability-prose.scenario.test.ts @@ -67,7 +67,7 @@ describe('scenario: cross-ability prose cannot escalate from open reads to prote }], policy: new DefaultAgentPolicy({ terminalToolName: 'report' }), tools, - terminalTool: 'report', + terminalToolName: 'report', trace: true, }); @@ -96,7 +96,7 @@ describe('scenario: cross-ability prose cannot escalate from open reads to prote }], policy: new DefaultAgentPolicy({ terminalToolName: 'report' }), tools, - terminalTool: 'report', + terminalToolName: 'report', trace: true, }); @@ -124,7 +124,7 @@ describe('scenario: cross-ability prose cannot escalate from open reads to prote }], policy: new DefaultAgentPolicy({ terminalToolName: 'report' }), tools, - terminalTool: 'report', + terminalToolName: 'report', trace: true, }); diff --git a/packages/agents/test/scheduler.test.ts b/packages/agents/test/scheduler.test.ts new file mode 100644 index 00000000..ab9de0f7 --- /dev/null +++ b/packages/agents/test/scheduler.test.ts @@ -0,0 +1,226 @@ +/** + * The scheduler is a pure function over one value. Each test hands it a + * hand-built tick state and reads the decision back — no store, no mock + * sampling, no event ordering. These are the decision-matrix cells as a table. + */ +import { describe, it, expect } from 'vitest'; +import { MockSessionContext } from '../../sdk/src/testing.js'; +import { Agent } from '../src/Agent'; +import { ContextPressure } from '../src/pressure'; +import { DefaultScheduler, type SchedulerOptions } from '../src/scheduler'; +import { emptyPending, type TickState, type PrefillItem, type Pending } from '../src/state'; +import type { AgentPolicy, PolicyConfig } from '../src/AgentPolicy'; +import type { AgentTaskSpec } from '../src/types'; +import { createMockBranch } from './helpers/mock-branch'; +import { FMT } from './helpers/format-config'; + +const config: PolicyConfig = { maxTurns: 10, terminalToolName: 'report', hasNonTerminalTools: true }; +const ctx = new MockSessionContext({ nCtx: 16384 }); + +function scheduler(over: Partial = {}): DefaultScheduler { + return new DefaultScheduler({ recovery: 'cohort', terminalToolName: 'report', config, ...over }, ctx as never, new Map()); +} + +function agent(id: number, status: 'active' | 'awaiting_tool' | 'idle' = 'active'): Agent { + const a = new Agent({ id, parentId: 0, branch: createMockBranch({ handle: id }) as never, fmt: FMT }); + if (status !== 'idle') a.transition('active'); + if (status === 'awaiting_tool') a.transition('awaiting_tool'); + return a; +} + +/** A tick state at `remaining` cells with the default thresholds (soft 1024, hard 512). */ +function state(agents: Agent[], remaining = 8000, over: Partial = {}, pending: Partial = {}): TickState { + return { + tick: 0, now: 0, + pressure: new ContextPressure({ nCtx: 16384, cellsUsed: 16384 - remaining, remaining }, { softLimit: 1024, hardLimit: 512 }), + agents, + pending: { ...emptyPending(), ...pending }, + signals: { paused: false, windDown: false, cancelled: [], orchestratorDone: false }, + inflight: new Set(), + ...over, + }; +} + +const quiet: AgentPolicy = { + onProduced: () => ({ type: 'idle', reason: 'free_text_stop' }), + shouldExit: () => false, +}; + +const recoveryItem = (a: Agent, tokens = 3): PrefillItem => + ({ kind: 'recovery', rail: 'token', agent: a, tokens: Array(tokens).fill(1), toolName: 'recovery', callId: `recovery:${a.id}`, args: '' }); +const resultItem = (a: Agent, tokens: number): PrefillItem => + ({ kind: 'toolResult', rail: 'token', agent: a, tokens: Array(tokens).fill(1), toolName: 'web_search', callId: 'c1', args: '{}' }); + +/** Latch `currentTool` the way the pool does: a partial parse that sees the terminal call. */ +function emitting(a: Agent, tool: string): Agent { + const c = new MockSessionContext({ nCtx: 16384 }); + c.parseChatOutput = () => ({ content: '', reasoningContent: '', toolCalls: [{ name: tool, arguments: '{}', id: 'c' }] }); + a.observe(c as never); + return a; +} + +describe('DefaultScheduler.schedule', () => { + it('a paused tick holds: only cancels are decided, everything else waits where it is', () => { + const a = agent(1); + const st = state([a], 8000, { signals: { paused: true, windDown: false, cancelled: [1], orchestratorDone: false }, inflight: new Set([1]) }); + const S = scheduler().schedule(st, quiet); + expect(S.hold).toBe(true); + expect(S.halts).toEqual([a]); + expect(S.drops).toEqual([{ agent: a, reason: 'user_cancel', done: false, recovery: { type: 'none' } }]); + expect(S.decode).toEqual([]); + expect(S.remaining).toBe(st.pending); + }); + + it('pressure is the cause when it and the policy both say stop; a `false` vetoes; abstaining defers to pressure', () => { + const critical = 100; // remaining < hardLimit + const says = (exit: boolean | undefined): AgentPolicy => ({ ...quiet, shouldExit: () => exit as boolean }); + + let S = scheduler().schedule(state([agent(1)], critical), says(true)); + expect(S.drops[0]).toMatchObject({ reason: 'pressure_critical', exitReason: 'pressure_critical', done: true }); + + S = scheduler().schedule(state([agent(1)], 8000), says(true)); + expect(S.drops[0]).toMatchObject({ reason: 'policy_exit', exitReason: 'policy_exit' }); + + const vetoed = agent(1); + S = scheduler().schedule(state([vetoed], critical), says(false)); + expect(S.drops).toEqual([]); + expect(S.decode).toEqual([vetoed]); + + S = scheduler().schedule(state([agent(1)], critical), { onProduced: quiet.onProduced }); + expect(S.drops[0]).toMatchObject({ reason: 'pressure_critical' }); + }); + + it('an agent producing its own report is salvaged, not re-prompted', () => { + const a = emitting(agent(1), 'report'); + const S = scheduler().schedule(state([a], 100), { ...quiet, shouldExit: () => true }); + expect(S.drops[0].recovery).toEqual({ type: 'salvage' }); + }); + + it('the voluntary report cap force-finishes a report at the budget', () => { + const a = emitting(agent(1), 'report'); + for (let i = 0; i < 4; i++) a.accumulateToken('x'); + const S = scheduler({ reportBudget: 4 }).schedule(state([a]), quiet); + expect(S.drops[0]).toMatchObject({ reason: 'report_cap', exitReason: 'report_cap', recovery: { type: 'salvage' } }); + }); + + it('an extracting agent is exempt from the kill and finishes at its token-stop', () => { + const a = agent(1); + a.markExtracting(3); + let S = scheduler().schedule(state([a], 100), { ...quiet, shouldExit: () => true }); + expect(S.drops).toEqual([]); + expect(S.decode).toEqual([a]); + for (let i = 0; i < 3; i++) a.accumulateToken('x'); + S = scheduler().schedule(state([a], 100), { ...quiet, shouldExit: () => true }); + expect(S.finishes).toEqual([a]); + expect(S.decode).toEqual([]); + }); + + it('serial recovery admits one turn at a time, ungated by headroom', () => { + const a = agent(1, 'awaiting_tool'); a.markExtracting(Infinity, true); + const b = agent(2, 'awaiting_tool'); b.markExtracting(Infinity, true); + // No headroom at all — serial still admits, because the report owns the freed cells. + let S = scheduler({ recovery: 'serial' }).schedule(state([a, b], 100, {}, { items: [recoveryItem(a), recoveryItem(b)] }), quiet); + expect(S.prefills.map(i => i.agent)).toEqual([a]); + expect(S.remaining.items.map(i => i.agent)).toEqual([b]); + + // One already decoding blocks the next. + const decoding = agent(3); decoding.markExtracting(Infinity, true); + S = scheduler({ recovery: 'serial' }).schedule(state([decoding, a], 8000, {}, { items: [recoveryItem(a)] }), quiet); + expect(S.prefills).toEqual([]); + expect(S.remaining.items.map(i => i.agent)).toEqual([a]); + }); + + it('a cohort recovery turn reserves prompt + budget against the recovery band; a plain result stays above softLimit', () => { + const live = agent(9); // an active sibling keeps the stall-break out of it + const r = agent(1, 'awaiting_tool'); r.markExtracting(1000); + // remaining 1524: headroom 500, band 512 → a recovery item may spend 1012. + let S = scheduler().schedule(state([live, r], 1524, {}, { items: [recoveryItem(r, 3)] }), quiet); + expect(S.prefills.map(i => i.agent)).toEqual([r]); // 3 + 1000 ≤ 1012 + expect(S.pressure.cellsUsed).toBe(state([], 1524).pressure.cellsUsed + 3); // only the prompt's cells are spent now + + const big = agent(2, 'awaiting_tool'); big.markExtracting(1100); + S = scheduler().schedule(state([live, big], 1524, {}, { items: [recoveryItem(big, 3)] }), quiet); + expect(S.prefills).toEqual([]); // 3 + 1100 > 1012 → deferred + expect(S.remaining.items.map(i => i.agent)).toEqual([big]); + + const t = agent(3, 'awaiting_tool'); + S = scheduler().schedule(state([live, t], 1524, {}, { items: [resultItem(t, 600)] }), quiet); + expect(S.prefills).toEqual([]); // 600 > headroom 500 + S = scheduler().schedule(state([live, t], 1524, {}, { items: [resultItem(t, 400)] }), quiet); + expect(S.prefills.map(i => i.agent)).toEqual([t]); + }); + + it('the stall-break names the hook: pressure_settle_reject with it, settle_stall_break without, a fitting nudge replaces the item', () => { + const mk = () => { const a = agent(1, 'awaiting_tool'); return a; }; + const oversized = (a: Agent) => state([a], 1524, {}, { items: [resultItem(a, 5000)] }); + + let a = mk(); + let S = scheduler().schedule(oversized(a), { ...quiet, onSettleReject: () => ({ type: 'idle', reason: 'pressure_settle_reject' }) }); + expect(S.stall).toHaveLength(1); + expect(S.stall[0].nudge).toBeNull(); + expect(S.stall[0].drop?.agent).toBe(a); + expect(S.stall[0].drop?.reason).toBe('pressure_settle_reject'); + expect(S.stall[0].drop?.done).toBe(true); + + a = mk(); + S = scheduler().schedule(oversized(a), quiet); + expect(S.stall[0].drop?.reason).toBe('settle_stall_break'); + + a = mk(); + S = scheduler().schedule(oversized(a), { ...quiet, onSettleReject: () => ({ type: 'nudge', message: 'report now' }) }); + expect(S.stall[0].nudge?.replacement?.kind).toBe('nudge'); + expect(S.stall[0].drop).toBeNull(); + expect(S.remaining.items).toEqual([S.stall[0].nudge!.replacement]); + }); + + it('wind-down forces the cohort shape and reaps every active agent that is not mid-report', () => { + const reporting = emitting(agent(1), 'report'); + const researching = agent(2); + const st = state([reporting, researching], 8000, { signals: { paused: false, windDown: true, cancelled: [], orchestratorDone: true } }); + const S = scheduler({ recovery: 'serial' }).schedule(st, quiet); + expect(S.mode).toBe('cohort'); + expect(S.drops.map(d => [d.agent.id, d.reason])).toEqual([[2, 'wind_down']]); + expect(S.decode).toEqual([reporting]); + }); + + it('retries re-dispatch when due and are abandoned on wind-down', () => { + const a = agent(1, 'awaiting_tool'); + const park = { agent: a, tc: { name: 'web_search', arguments: '{}', id: 'c1' }, callId: 'c1', notBefore: 0, attempt: 1 }; + let S = scheduler().schedule(state([a], 8000, {}, { retries: [park] }), quiet); + expect(S.dispatch).toEqual([{ agent: a, tc: park.tc, retryAttempt: 1, retryCallId: 'c1' }]); + + S = scheduler().schedule(state([a], 8000, { signals: { paused: false, windDown: true, cancelled: [], orchestratorDone: true } }, { retries: [park] }), quiet); + expect(S.abandoned).toEqual([park]); + expect(S.dispatch).toEqual([]); + }); + + it('the close sweep recovers the first idle agent without a result that was never discarded, then closes', () => { + const reported = agent(1, 'idle'); reported.setResult('r', 'voluntary_return'); + const discarded = agent(2, 'idle'); discarded.failed = 'user_cancel'; + const clean = agent(3, 'idle'); + const done = { paused: false, windDown: false, cancelled: [], orchestratorDone: true }; + const withRecovery: AgentPolicy = { ...quiet, onRecovery: () => ({ type: 'extract', prompt: { system: 's', user: 'u' } }) }; + + let S = scheduler().schedule(state([reported, discarded, clean], 8000, { signals: done }), withRecovery); + expect(S.sweep?.agent).toBe(clean); + expect(S.sweep?.recovery).toMatchObject({ type: 'extract', serial: true, budget: Infinity }); + expect(S.close).toBe(false); + + S = scheduler().schedule(state([reported, discarded], 8000, { signals: done }), withRecovery); + expect(S.sweep).toBeNull(); + expect(S.close).toBe(true); + }); + + it('cells admitted this tick lower the pressure the verdicts read', () => { + const seen: number[] = []; + const spy: AgentPolicy = { ...quiet, shouldExit: (_a, p) => { seen.push(p.cellsUsed); return false; } }; + const a = agent(1); + const task: AgentTaskSpec = { systemPrompt: 's', content: 'c' }; + const req = { agent: agent(2, 'idle'), suffixTokens: Array(100).fill(1), formattedPrompt: '', task, resolve: () => {}, reject: () => {}, discarded: false }; + const st = state([a], 8000, {}, { spawns: [req] }); + const S = scheduler().schedule(st, spy); + expect(S.spawns).toEqual([req]); + expect(seen).toEqual([st.pressure.cellsUsed + 100]); + expect(S.pressure.cellsUsed).toBe(st.pressure.cellsUsed + 100); + }); +}); diff --git a/packages/agents/test/spawn-agents.test.ts b/packages/agents/test/spawn-agents.test.ts index b967aa08..560575cd 100644 --- a/packages/agents/test/spawn-agents.test.ts +++ b/packages/agents/test/spawn-agents.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest'; import { FMT } from './helpers/format-config'; import { createToolkit } from '../src/toolkit'; +import { ContextPressure } from '../src/pressure'; import { Agent } from '../src/Agent'; import { MockTool } from './helpers/mock-tool'; import { createMockReranker } from './helpers/mock-reranker'; @@ -11,6 +12,11 @@ import { DefaultAgentPolicy } from '../src/AgentPolicy'; // ── Pure unit tests (no Effection) ────────────────────────── +/** A frozen pressure reading — the real value, not a hand-rolled twin. */ +function pressureAt(remaining: number, nCtx: number): ContextPressure { + return new ContextPressure({ nCtx, cellsUsed: nCtx - remaining, remaining }, { softLimit: 1024, hardLimit: 128 }); +} + describe('spawnAgents — toolkit composition', () => { // We can't call spawnAgents directly without Effection, but we can // test the toolkit composition logic by inspecting createToolkit output @@ -384,10 +390,7 @@ describe('Explore/exploit decoupled from lifecycle', () => { a.incrementToolCalls(); // Pressure at 45% — below context threshold (0.5) → exploit mode - const p = { - headroom: 5000, critical: false, remaining: 7372, nCtx: 16384, - cellsUsed: 9012, percentAvailable: 45, canFit: () => true, softLimit: 1024, hardLimit: 128, - }; + const p = pressureAt(7372, 16384); // shouldExplore = false (exploit) expect(policy.shouldExplore(a, p)).toBe(false); @@ -418,10 +421,7 @@ describe('Explore/exploit decoupled from lifecycle', () => { for (let i = 0; i < 25; i++) a.incrementTurns(); // Pressure at 60% — above threshold → explore mode - const p = { - headroom: 5000, critical: false, remaining: 9830, nCtx: 16384, - cellsUsed: 6554, percentAvailable: 60, canFit: () => true, softLimit: 1024, hardLimit: 128, - }; + const p = pressureAt(9830, 16384); // shouldExplore = true (explore) expect(policy.shouldExplore(a, p)).toBe(true); @@ -442,14 +442,8 @@ describe('Explore/exploit decoupled from lifecycle', () => { fmt: FMT, }); - const highPressure = { - headroom: 5000, critical: false, remaining: 12000, nCtx: 16384, - cellsUsed: 4384, percentAvailable: 73, canFit: () => true, softLimit: 1024, hardLimit: 128, - }; - const lowPressure = { - headroom: 5000, critical: false, remaining: 4915, nCtx: 16384, - cellsUsed: 11469, percentAvailable: 30, canFit: () => true, softLimit: 1024, hardLimit: 128, - }; + const highPressure = pressureAt(12000, 16384); + const lowPressure = pressureAt(4915, 16384); // High pressure: explore=true, shouldExit=false expect(policy.shouldExplore(a, highPressure)).toBe(true); @@ -460,10 +454,7 @@ describe('Explore/exploit decoupled from lifecycle', () => { expect(policy.shouldExit(a, lowPressure)).toBe(false); // Critical: shouldExit=true, explore is irrelevant but still computable - const criticalPressure = { - headroom: -900, critical: true, remaining: 100, nCtx: 16384, - cellsUsed: 16284, percentAvailable: 1, canFit: () => false, softLimit: 1024, hardLimit: 128, - }; + const criticalPressure = pressureAt(100, 16384); expect(policy.shouldExit(a, criticalPressure)).toBe(true); expect(policy.shouldExplore(a, criticalPressure)).toBe(false); }); diff --git a/packages/agents/test/trace-vocabulary.test.ts b/packages/agents/test/trace-vocabulary.test.ts new file mode 100644 index 00000000..00907440 --- /dev/null +++ b/packages/agents/test/trace-vocabulary.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * The trace vocabulary is a contract with every reader of trace.jsonl, so a + * variant that nothing writes is a promise nobody keeps. This scan holds the + * declared vocabulary in `trace-types.ts` to the code that emits it: every + * event `type` literal, and every `reason` literal of the two pool events that + * carry one, must appear as a string somewhere in an emitting package. + * + * Emitting packages are the runtime (`agents`), the rig, and the abilities — + * `dev-tools` only reads, so it is deliberately NOT scanned: a literal that + * exists only in a reducer would satisfy a text match while still being dead. + * + * Known limit: the match is on the bare string, so a reason shared by two + * events (e.g. `tool_error` as a result source and as a drop reason) is + * vouched for by either. That is the same test the audit ran by hand; it + * catches whole-vocabulary drift, not per-event drift. + */ +const REPO = join(__dirname, '..', '..', '..'); +const VOCABULARY = join(REPO, 'packages/agents/src/trace-types.ts'); +const ROOTS = [ + 'packages/agents/src', + 'packages/rig/src', + ...readdirSync(join(REPO, 'packages/abilities')) + .map(name => join('packages/abilities', name, 'src')) + .filter(dir => { try { return statSync(join(REPO, dir)).isDirectory(); } catch { return false; } }), +]; + +function* tsFiles(dir: string): Generator { + for (const name of readdirSync(dir)) { + const p = join(dir, name); + if (statSync(p).isDirectory()) yield* tsFiles(p); + else if (name.endsWith('.ts') && !name.endsWith('.d.ts')) yield p; + } +} + +function emittingSource(): string { + let all = ''; + for (const root of ROOTS) { + for (const file of tsFiles(join(REPO, root))) { + if (file === VOCABULARY) continue; + all += readFileSync(file, 'utf8'); + } + } + return all; +} + +/** The `reason:` union that follows a given `type:` literal in the vocabulary file. */ +function reasonsOf(vocabulary: string, type: string): string[] { + const start = vocabulary.indexOf(`type: '${type}'`); + if (start < 0) return []; + const block = vocabulary.slice(start, vocabulary.indexOf('}', start)); + const reasonAt = block.indexOf('reason:'); + if (reasonAt < 0) return []; + const union = block.slice(reasonAt, block.indexOf(';', reasonAt)); + return [...union.matchAll(/'([A-Za-z_]+)'/g)].map(m => m[1]); +} + +describe('trace vocabulary is emitted', () => { + const vocabulary = readFileSync(VOCABULARY, 'utf8'); + const emitted = emittingSource(); + // Either quote style: the runtime writes single-quoted literals, the abilities double. + const missing = (literals: string[]) => + literals.filter(l => !emitted.includes(`'${l}'`) && !emitted.includes(`"${l}"`)); + + it('every declared event type has an emit site', () => { + const types = [...new Set([...vocabulary.matchAll(/type: '([^']+)'/g)].map(m => m[1]))]; + expect(types.length).toBeGreaterThan(30); + expect(missing(types), 'declared in trace-types.ts, written nowhere').toEqual([]); + }); + + it('every pool:agentDrop reason has an emit site', () => { + const reasons = reasonsOf(vocabulary, 'pool:agentDrop'); + expect(reasons.length).toBeGreaterThan(5); + expect(missing(reasons), 'declared drop reasons, written nowhere').toEqual([]); + }); + + it('every pool:agentNudge reason has an emit site', () => { + const reasons = reasonsOf(vocabulary, 'pool:agentNudge'); + expect(reasons.length).toBeGreaterThan(1); + expect(missing(reasons), 'declared nudge reasons, written nowhere').toEqual([]); + }); +}); diff --git a/packages/rig/src/sources/types.ts b/packages/rig/src/sources/types.ts index ab31b255..ae167dd2 100644 --- a/packages/rig/src/sources/types.ts +++ b/packages/rig/src/sources/types.ts @@ -1,12 +1,10 @@ import type { Reranker } from '../tools/types'; /** - * Runtime context passed to {@link Source.bind} during pipeline setup. - * - * Carries the reranker instance needed by corpus sources to tokenize - * chunks and by web sources for fetch-page chunk scoring. Orchestration - * config (prompts, maxTurns, tools) belongs in {@link spawnAgents} opts, - * not in the source context. + * The reranker a source needs at construction — corpus sources to tokenize + * chunks, web sources for fetch-page chunk scoring. Ability factories read + * it from `RerankerCtx` and pass it in; orchestration config (prompts, + * maxTurns, tools) belongs to the pool, not to the source. * * @category Rig */ From 706a5664000295274cbed6b1f2f845479f2521f2 Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Sat, 5 Sep 2026 18:30:10 +1000 Subject: [PATCH 31/69] =?UTF-8?q?agents:=20recovery=20vocabulary=20?= =?UTF-8?q?=E2=80=94=20no=20"plan",=20no=20"report"=20in=20the=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "plan" belongs to the rig planner, so the scheduler's planRecovery / RecoveryPlan / plan() become recoveryFor / Recovery / recovery(). "report" is research-harness semantics; the runtime is terminal-tool agnostic, so the policy's reportBudget becomes recoveryBudget (the word the agent already uses), the adaptive bounds become MIN/MAX_RECOVERY_BUDGET, and the exit and drop reason report_cap becomes terminal_cap. The research template's two reportBudget lines move with the template migration; until then an explicit budget falls back to the adaptive one. --- packages/agents/src/Agent.ts | 8 +-- packages/agents/src/AgentPolicy.ts | 28 +++++----- packages/agents/src/agent-pool.ts | 4 +- packages/agents/src/apply.ts | 20 +++---- packages/agents/src/scheduler.ts | 52 +++++++++---------- packages/agents/src/state.ts | 6 +-- packages/agents/src/trace-types.ts | 2 +- packages/agents/src/types.ts | 2 +- packages/agents/test/invariants/predicates.ts | 2 +- .../parallel-recovery.scenario.test.ts | 20 +++---- ...covery-agent-done-oneshot.scenario.test.ts | 2 +- ...rompt-budget-substitution.scenario.test.ts | 2 +- .../recovery-skip-terminal.scenario.test.ts | 2 +- packages/agents/test/scheduler.test.ts | 4 +- 14 files changed, 77 insertions(+), 77 deletions(-) diff --git a/packages/agents/src/Agent.ts b/packages/agents/src/Agent.ts index d5b18774..e22f3bda 100644 --- a/packages/agents/src/Agent.ts +++ b/packages/agents/src/Agent.ts @@ -167,8 +167,8 @@ export class Agent { // `parallel` recovery path): PRODUCE routes its isStop to finishRecovery // instead of onProduced, and the kill/reap guards skip it. private _extracting = false; - // Per-report cap for the in-loop recovery report. `_recoveryBudget` is the token - // target the pool set (policy.reportBudget, else a headroom share across the live + // Per-recovery cap for the in-loop recovery report. `_recoveryBudget` is the token + // target the pool set (policy.recoveryBudget, else a headroom share across the live // agents); the token-stop fires once the report's own tokens reach it. // `_recoveryTokenBase` snapshots the cumulative `_tokenCount` at recovery entry so // the cap counts ONLY the report's tokens (resetTurn clears rawOutput, not _tokenCount). @@ -287,14 +287,14 @@ export class Agent { get parsed(): ParseChatOutputResult | null { return this._parsed; } /** Whether this agent is mid forced-recovery-report (in-loop parallel path). */ get extracting(): boolean { return this._extracting; } - /** The fixed per-report token cap for this recovery (set at {@link markExtracting}). */ + /** The fixed per-recovery token cap for this recovery (set at {@link markExtracting}). */ get recoveryBudget(): number { return this._recoveryBudget; } /** Tokens produced SINCE recovery entry — what the token-stop backstop checks. */ get recoveryTokens(): number { return this._tokenCount - this._recoveryTokenBase; } /** Tokens produced in the CURRENT turn — what the voluntary report cap checks. */ get turnTokens(): number { return this._tokenCount - this._turnTokenBase; } /** Mark the agent as producing its recovery report (idempotent, one-way). Records - * the per-report budget `b` (Infinity = uncapped) and snapshots the token base + * the per-recovery budget `b` (Infinity = uncapped) and snapshots the token base * for the cap. */ markExtracting(budget: number, serial = false): void { this._extracting = true; diff --git a/packages/agents/src/AgentPolicy.ts b/packages/agents/src/AgentPolicy.ts index ff692e07..f6e6993a 100644 --- a/packages/agents/src/AgentPolicy.ts +++ b/packages/agents/src/AgentPolicy.ts @@ -309,7 +309,7 @@ export interface AgentPolicy { * Optional — defaults to skip when absent. * * `budgetTokens` (optional) overrides the pressure-derived report budget — - * the in-loop (parallel / wind-down) recovery passes the per-report budget `b` + * the in-loop (parallel / wind-down) recovery passes the per-recovery budget `b` * so the prompt's advisory word count matches the pool's token-stop. Absent → * the budget is derived from `pressure.remaining` (the staggered / full-headroom * per-agent path). @@ -335,7 +335,7 @@ export interface AgentPolicy { * pruning each before the next so every report gets the full freed headroom * (uncapped, lossless; the high-effort path). * `'parallel'` — recover killed-without-result agents IN-LOOP: the recovery turn - * is bin-packed into the tick loop alongside live siblings, capped at a per-report + * is bin-packed into the tick loop alongside live siblings, capped at a per-recovery * budget `b` (the prompt's word advisory + the pool's token-stop), and SETTLE * admits only as many reports as fit `(prompt + b)` in current KV — the rest wave * to the next tick once the admitted ones prune. Wind-down always uses this shape @@ -343,11 +343,11 @@ export interface AgentPolicy { */ recoveryShape?: 'staggered' | 'parallel'; - /** Explicit per-report token budget for in-loop (parallel / wind-down) recovery — + /** Explicit per-recovery token budget for in-loop (parallel / wind-down) recovery — * the prompt's word advisory + the pool's token-stop. Absent → adaptive: a fair * share of current headroom across the live agents, clamped to a [min, max]. * Unused by `staggered` (full-length reports). */ - readonly reportBudget?: number; + readonly recoveryBudget?: number; } /** @@ -424,20 +424,20 @@ export interface DefaultAgentPolicyOpts { }; /** Recovery reap shape — see {@link AgentPolicy.recoveryShape}. @default 'staggered' */ recoveryShape?: 'staggered' | 'parallel'; - /** Explicit per-report token budget for in-loop (PARALLEL / wind-down) recovery — + /** Explicit per-recovery token budget for in-loop (PARALLEL / wind-down) recovery — * rendered into the recovery prompt (advisory "within N words") AND enforced by * the pool's token-stop (hard). Unused by `staggered` (full-length reports). The * consumer sets it per Effort level. @default unset → adaptive (a fair share of * current headroom across the live agents, clamped). */ - reportBudget?: number; + recoveryBudget?: number; /** Budget thresholds. softLimit = nudge, hardLimit = kill. * Same naming pattern for both resource types. * time budget is global across nesting levels (ms since policy creation). */ budget?: { /** KV context budget (tokens remaining). softLimit = nudge floor, hardLimit = kill floor. * COUPLING (non-obvious): RECOVERY budgets from the `hardLimit` RESERVE, not `softLimit`. - * The forced-report budget `b` and the SETTLE admission for an extracting agent draw from - * `remaining − hardLimit` (see {@link AgentPolicy.onRecovery} + the scheduler's `planRecovery`), + * The recovery budget `b` and the SETTLE admission for an extracting agent draw from + * `remaining − hardLimit` (see {@link AgentPolicy.onRecovery} + the scheduler's `recoveryFor`), * so recovery may decode the soft reserve down to `hardLimit`. `softLimit` is the model * NUDGE floor, reserved for downstream work (synth) — raising it nudges EARLIER but does * NOT shorten recovery reports. (`softLimit` is advisory: it gates the wrap-up nudge + @@ -463,7 +463,7 @@ export class DefaultAgentPolicy implements AgentPolicy { private _forceExploit = false; private _recovery: DefaultAgentPolicyOpts['recovery'] | null; private _recoveryShape: 'staggered' | 'parallel'; - private _reportBudget: number | null; + private _recoveryBudget: number | null; private _budget: DefaultAgentPolicyOpts['budget'] | null; private _terminalToolName: string | null; private _maxToolRetries: number; @@ -480,7 +480,7 @@ export class DefaultAgentPolicy implements AgentPolicy { ]; this._recovery = opts?.recovery ?? null; this._recoveryShape = opts?.recoveryShape ?? 'staggered'; - this._reportBudget = opts?.reportBudget ?? null; + this._recoveryBudget = opts?.recoveryBudget ?? null; this._budget = opts?.budget ?? null; this._terminalToolName = opts?.terminalToolName ?? null; this._maxToolRetries = opts?.maxToolRetries ?? 1; @@ -529,11 +529,11 @@ export class DefaultAgentPolicy implements AgentPolicy { return this._recoveryShape; } - /** Explicit per-report token budget for in-loop recovery (undefined = adaptive, + /** Explicit per-recovery token budget for in-loop recovery (undefined = adaptive, * a headroom share across live agents). Rendered into the prompt + enforced by * the pool's token-stop. */ - get reportBudget(): number | undefined { - return this._reportBudget ?? undefined; + get recoveryBudget(): number | undefined { + return this._recoveryBudget ?? undefined; } onProduced( @@ -715,7 +715,7 @@ export class DefaultAgentPolicy implements AgentPolicy { // (not tokens) and under-advertised so the model has slack — tokenizers // vary across models but words are universal. Rendered into the prompt // as `it.budget` so authors can reference it via `<%= it.budget %>`. - // In-loop recovery overrides this with its per-report budget `b` (a headroom + // In-loop recovery overrides this with its per-recovery budget `b` (a headroom // share across live agents) so the advisory matches the pool's token-stop // (graceful self-conclusion, not a guillotine). const budgetTokens = budgetTokensOverride diff --git a/packages/agents/src/agent-pool.ts b/packages/agents/src/agent-pool.ts index 49b6b8f0..d4453ac4 100644 --- a/packages/agents/src/agent-pool.ts +++ b/packages/agents/src/agent-pool.ts @@ -153,13 +153,13 @@ export function useAgentPool(opts: AgentPoolOptions): Operation { - switch (plan.type) { + /** Enact the recovery decided for an agent whose span has ended. */ + *recover(a: Agent, recovery: Recovery, reason: DropReason | null): Operation { + switch (recovery.type) { case 'none': return; case 'salvage': { // Mid-terminal-call: parse what it already emitted; no further decode. // `rawOutput` is the report turn alone (resetTurn cleared the rest). - const produced = reason === 'report_cap' ? a.turnTokens : this.d.ctx.tokenizeSync(a.rawOutput, false).length; + const produced = reason === 'terminal_cap' ? a.turnTokens : this.d.ctx.tokenizeSync(a.rawOutput, false).length; yield* this.finishRecovery(a, a.rawOutput, produced); a.transition('idle'); a.pruneRequested = true; @@ -153,10 +153,10 @@ export class Applier { // anything else happens, so it never passes through `idle` on the way // — an orchestrator waiting on it would otherwise resume against a // result that does not exist yet. - const tokens = buildUserDelta(this.d.ctx, plan.action.prompt.user, { system: plan.action.prompt.system, enableThinking: false }); + const tokens = buildUserDelta(this.d.ctx, recovery.action.prompt.user, { system: recovery.action.prompt.system, enableThinking: false }); a.incrementTurns(); if (a.status !== 'awaiting_tool') a.transition('awaiting_tool'); - a.markExtracting(plan.budget, plan.serial); + a.markExtracting(recovery.budget, recovery.serial); a.resetTurn(); this.d.pending.items.push({ kind: 'recovery', rail: 'token', agent: a, tokens, toolName: 'recovery', callId: `recovery:${a.id}`, args: '' }); return; @@ -254,7 +254,7 @@ export class Applier { yield* this.enactDrop({ agent: a, reason, done: true, exitReason, recovery: mode === 'cohort' - ? planRecovery(a, this.d.policy, S.pressure, S.alive, 'cohort', this.d.reportBudget) + ? recoveryFor(a, this.d.policy, S.pressure, S.alive, 'cohort', this.d.recoveryBudget) : { type: 'none' }, }, S); return; diff --git a/packages/agents/src/scheduler.ts b/packages/agents/src/scheduler.ts index 637ff950..c4fcf839 100644 --- a/packages/agents/src/scheduler.ts +++ b/packages/agents/src/scheduler.ts @@ -6,7 +6,7 @@ import { RECOVERY_PREFILL_OVERHEAD, BATCH_BUFFER } from './AgentPolicy'; import type { Tool } from './Tool'; import { type ContextPressure } from './pressure'; import { - type TickState, type Schedule, type Pending, type PrefillItem, type RecoveryPlan, + type TickState, type Schedule, type Pending, type PrefillItem, type Recovery, type StallOutcome, type Drop, emptyPending, itemCells, alive, } from './state'; @@ -22,24 +22,24 @@ import { * deterministic. */ -/** Adaptive per-report budget bounds for cohort recovery when no explicit - * `reportBudget` is set: a fair share of headroom across the live agents, +/** Adaptive per-recovery budget bounds for cohort recovery when no explicit + * `recoveryBudget` is set: a fair share of headroom across the live agents, * clamped to [MIN, MAX]. */ -export const MIN_REPORT_BUDGET = 128; -export const MAX_REPORT_BUDGET = 2048; +export const MIN_RECOVERY_BUDGET = 128; +export const MAX_RECOVERY_BUDGET = 2048; export interface SchedulerOptions { /** * How reaped agents recover. `serial` (the high-effort path): one at a * time, ungated, uncapped — each report owns the freed headroom. * `cohort`: every reap's recovery turn is admitted against the recovery - * reserve and decodes bin-packed with live siblings under a per-report + * reserve and decodes bin-packed with live siblings under a per-recovery * budget. Wind-down forces `cohort`. */ recovery: 'serial' | 'cohort'; - /** Explicit per-report cap for cohort recovery and the voluntary report + /** Explicit per-recovery cap for cohort recovery and the voluntary report * guillotine; absent = adaptive. */ - reportBudget?: number; + recoveryBudget?: number; terminalToolName?: string; config: PolicyConfig; } @@ -55,25 +55,25 @@ export function emittingTerminal(a: Agent, terminalToolName: string | undefined) } /** - * How a dropped agent recovers — the ONE place the per-report budget `b` is + * How a dropped agent recovers — the ONE place the per-recovery budget `b` is * sized, shared by every drop site (schedule-time and produce-time alike). * * Cohort: `aliveCount·(OVERHEAD + b) ≤ (remaining − hardLimit) − BATCH_BUFFER`, * so the whole cohort's prefill+decode fits the recovery reserve in one tick; - * an explicit `reportBudget` is clamped DOWN to that ceiling. Serial: the + * an explicit `recoveryBudget` is clamped DOWN to that ceiling. Serial: the * policy derives its own full-headroom advisory and nothing caps the report. */ -export function planRecovery( +export function recoveryFor( a: Agent, policy: AgentPolicy, pressure: ContextPressure, aliveCount: number, - mode: 'serial' | 'cohort', reportBudget: number | undefined, -): RecoveryPlan { + mode: 'serial' | 'cohort', recoveryBudget: number | undefined, +): Recovery { let budget: number; let action; if (mode === 'cohort') { const fits = Math.floor((pressure.remaining - pressure.hardLimit - BATCH_BUFFER) / Math.max(1, aliveCount)) - RECOVERY_PREFILL_OVERHEAD; - budget = reportBudget != null - ? (fits > 0 ? Math.min(reportBudget, fits) : reportBudget) - : Math.min(MAX_REPORT_BUDGET, Math.max(MIN_REPORT_BUDGET, fits)); + budget = recoveryBudget != null + ? (fits > 0 ? Math.min(recoveryBudget, fits) : recoveryBudget) + : Math.min(MAX_RECOVERY_BUDGET, Math.max(MIN_RECOVERY_BUDGET, fits)); action = policy.onRecovery?.(a, pressure, budget); } else { budget = Infinity; @@ -167,12 +167,12 @@ export class DefaultScheduler implements Scheduler { // 2. Produce-phase verdicts, in agents order (the policy's per-tick // stagger relies on that order). S.alive = state.agents.filter(alive).length + S.spawns.length + S.heals.length; - const cap = Math.min(this.opts.reportBudget ?? MAX_REPORT_BUDGET, MAX_REPORT_BUDGET); + const cap = Math.min(this.opts.recoveryBudget ?? MAX_RECOVERY_BUDGET, MAX_RECOVERY_BUDGET); for (const a of state.agents) { if (a.status !== 'active') continue; if (S.drops.some(d => d.agent === a)) continue; // cancelled above if (signals.windDown && !a.extracting && !emittingTerminal(a, terminal)) { - S.drops.push({ agent: a, reason: 'wind_down', done: true, recovery: this.plan(a, policy, P0, S.alive, mode) }); + S.drops.push({ agent: a, reason: 'wind_down', done: true, recovery: this.recovery(a, policy, P0, S.alive, mode) }); continue; } const exit = policy.shouldExit?.(a, Pd); @@ -181,13 +181,13 @@ export class DefaultScheduler implements Scheduler { const reason = Pd.critical ? 'pressure_critical' as const : 'policy_exit' as const; S.drops.push({ agent: a, reason, done: true, exitReason: reason, - recovery: emittingTerminal(a, terminal) ? { type: 'salvage' } : this.plan(a, policy, P0, S.alive, mode), + recovery: emittingTerminal(a, terminal) ? { type: 'salvage' } : this.recovery(a, policy, P0, S.alive, mode), }); continue; } if (a.extracting && a.recoveryTokens >= a.recoveryBudget) { S.finishes.push(a); continue; } if (!a.extracting && emittingTerminal(a, terminal) && a.turnTokens >= cap) { - S.drops.push({ agent: a, reason: 'report_cap', done: true, exitReason: 'report_cap', recovery: { type: 'salvage' } }); + S.drops.push({ agent: a, reason: 'terminal_cap', done: true, exitReason: 'terminal_cap', recovery: { type: 'salvage' } }); continue; } S.decode.push(a); @@ -217,9 +217,9 @@ export class DefaultScheduler implements Scheduler { const reason = action ? 'pressure_settle_reject' as const : 'settle_stall_break' as const; if (it.kind === 'recovery') { // An extracting agent whose cohort turn never fit: its span already - // ended at the kill, so no second `agent:done`; the turn is re-planned + // ended at the kill, so no second `agent:done`; the turn is re-decided // serial so the report decodes from the reserve, one at a time. - S.stall.push({ agent: a, nudge: null, drop: { agent: a, reason, done: false, recovery: this.plan(a, policy, P0, S.alive, 'serial') } }); + S.stall.push({ agent: a, nudge: null, drop: { agent: a, reason, done: false, recovery: this.recovery(a, policy, P0, S.alive, 'serial') } }); continue; } let nudge: StallOutcome['nudge'] = null; @@ -237,7 +237,7 @@ export class DefaultScheduler implements Scheduler { } // The policy's suggestion was infeasible (or it said idle, or it is absent): drop. const drop: Drop | null = nudge?.replacement ? null - : { agent: a, reason, done: true, recovery: this.plan(a, policy, P0, S.alive, mode) }; + : { agent: a, reason, done: true, recovery: this.recovery(a, policy, P0, S.alive, mode) }; S.stall.push({ agent: a, nudge, drop }); } } else { @@ -257,7 +257,7 @@ export class DefaultScheduler implements Scheduler { const c = state.agents.find(a => a.status === 'idle' && !a.result && !a.branch.disposed && a.failed === null && !a.extracting); if (c) { - S.sweep = { agent: c, recovery: this.plan(c, policy, P0, 1, 'serial') }; + S.sweep = { agent: c, recovery: this.recovery(c, policy, P0, 1, 'serial') }; } else { // The prune pass runs before every schedule; anything still owed a // prune is a branch with live children, which the close cannot free. @@ -267,8 +267,8 @@ export class DefaultScheduler implements Scheduler { return S; } - private plan(a: Agent, policy: AgentPolicy, P0: ContextPressure, aliveCount: number, mode: 'serial' | 'cohort'): RecoveryPlan { - return planRecovery(a, policy, P0, aliveCount, mode, this.opts.reportBudget); + private recovery(a: Agent, policy: AgentPolicy, P0: ContextPressure, aliveCount: number, mode: 'serial' | 'cohort'): Recovery { + return recoveryFor(a, policy, P0, aliveCount, mode, this.opts.recoveryBudget); } } diff --git a/packages/agents/src/state.ts b/packages/agents/src/state.ts index c379ad56..c41d70f1 100644 --- a/packages/agents/src/state.ts +++ b/packages/agents/src/state.ts @@ -127,7 +127,7 @@ export interface TickState { * - `skip`: the policy declined; the agent fails cleanly. * - `none`: nothing to recover (a cancel). */ -export type RecoveryPlan = +export type Recovery = | { type: 'salvage' } | { type: 'extract'; action: Extract; budget: number; serial: boolean } | { type: 'skip' } @@ -143,7 +143,7 @@ export interface Drop { * re-drop of an already-extracting agent do not. */ done: boolean; exitReason?: AgentExitReason; - recovery: RecoveryPlan; + recovery: Recovery; } /** One deferred item's fate at the stall-break, in the order it is announced: @@ -179,7 +179,7 @@ export interface Schedule { /** Wind-down: parked retries settled as an honest failure instead of waited out. */ abandoned: RetryPark[]; /** The close-time sweep: one idle-without-result agent recovers serially, with no drop record. */ - sweep: { agent: Agent; recovery: RecoveryPlan } | null; + sweep: { agent: Agent; recovery: Recovery } | null; dispatch: DispatchRequest[]; /** Agents that sample this tick: active now and not dropped. Agents the * execute step itself re-activates (admitted items, spawns, heals) join diff --git a/packages/agents/src/trace-types.ts b/packages/agents/src/trace-types.ts index 973ab636..388fba4e 100644 --- a/packages/agents/src/trace-types.ts +++ b/packages/agents/src/trace-types.ts @@ -186,7 +186,7 @@ export type TraceEvent = | 'tool_error' | 'wind_down' | 'user_cancel' - | 'report_cap'; + | 'terminal_cap'; } | TraceEventBase & { type: 'pool:agentNudge'; diff --git a/packages/agents/src/types.ts b/packages/agents/src/types.ts index 20a14070..0f3f0f4a 100644 --- a/packages/agents/src/types.ts +++ b/packages/agents/src/types.ts @@ -324,7 +324,7 @@ export type AgentExitReason = | 'policy_exit' | 'pressure_softcut' | 'maxTurns' - | 'report_cap'; + | 'terminal_cap'; export interface AgentResult { /** Stable agent identifier (branch handle at creation time) */ diff --git a/packages/agents/test/invariants/predicates.ts b/packages/agents/test/invariants/predicates.ts index e6941eb4..05ce6a53 100644 --- a/packages/agents/test/invariants/predicates.ts +++ b/packages/agents/test/invariants/predicates.ts @@ -170,7 +170,7 @@ const RECORDED_EXIT_REASONS = new Set([ 'policy_exit', 'pressure_softcut', 'maxTurns', - 'report_cap', + 'terminal_cap', ]); export function I30_exitReasonMatchesTrace(run: PoolRun): PredicateResult { diff --git a/packages/agents/test/invariants/scenarios/parallel-recovery.scenario.test.ts b/packages/agents/test/invariants/scenarios/parallel-recovery.scenario.test.ts index 4cbc4f35..dcce7101 100644 --- a/packages/agents/test/invariants/scenarios/parallel-recovery.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/parallel-recovery.scenario.test.ts @@ -19,11 +19,11 @@ const REPORT_CALL = { name: 'report', arguments: '{"result":"recovered"}' }; * Every agent drops to idle WITHOUT a voluntary result on its first stop * (`free_text_stop`), so each is recovered: `parallel` injects the recovery turn * as a cohort (recovery item → admission → bin-packed decode); `staggered` - * recovers one at a time. `reportBudget` is the FIXED per-report cap `b`. + * recovers one at a time. `recoveryBudget` is the FIXED per-recovery cap `b`. */ function idleNoResultPolicy( shape: 'staggered' | 'parallel', - reportBudget?: number, + recoveryBudget?: number, ): AgentPolicy { return { onProduced: () => ({ type: 'idle', reason: 'free_text_stop' }), @@ -31,7 +31,7 @@ function idleNoResultPolicy( onRecovery: () => ({ type: 'extract', prompt: { system: 's', user: 'u' } }), shouldExit: () => false, recoveryShape: shape, - ...(reportBudget !== undefined ? { reportBudget } : {}), + ...(recoveryBudget !== undefined ? { recoveryBudget } : {}), }; } @@ -61,7 +61,7 @@ const idleScripts = () => idleScriptsN(N); * recovery prompt injected IN-LOOP via the nudge/SETTLE path — prefilled as a * `toolResult`, re-activated with the native terminal-tool grammar, and decoded * BIN-PACKED in the tick loop alongside live siblings (one O(1) llama_decode per - * tick, regardless of how many recover at once). The per-report cap is the budget + * tick, regardless of how many recover at once). The per-recovery cap is the budget * `b` (prompt advisory + token-stop), sized so the WHOLE cohort's prefill+decode * fits headroom in one tick — so every reaped agent recovers, nothing is deferred or * lost. `staggered` (high effort) is the lossless serial path — blocking @@ -174,7 +174,7 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { it('token-stop caps an over-long report at the fixed budget `b` (salvaged, not lost)', async () => { // The cap that closes the deadlock: a non-compliant report that runs past its // word advisory is force-finished at `b` tokens rather than decoding unbounded. - // reportBudget=4; the recovery script would emit 8 tokens, but the token-stop + // recoveryBudget=4; the recovery script would emit 8 tokens, but the token-stop // fires at 4 — and the partial report is still salvaged (no loss). const r = await runPool({ nCtx: 8192, cellsUsed: 0, @@ -194,7 +194,7 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { // tick (the flat+medium run reaped 4 agents at 358s). The old SETTLE admission charged // each recovery item (prompt + report-budget) against headroom and DEFERRED the // overflow — and when the pool terminated after the admitted ones finished, the - // deferred agents' findings were LOST. Now `b` is sized in planRecovery so + // deferred agents' findings were LOST. Now `b` is sized in recoveryFor so // aliveCount·(prompt + b) ≤ headroom: the cohort's recovery turns all prefill + decode // together in ONE batched tick (O(1) in branch count), nothing defers, nothing is lost. // cellsUsed simulates a partly-filled KV so the adaptive sizing actually bites. @@ -256,12 +256,12 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { expect(r.result).toBeDefined(); }); - it('without an explicit reportBudget the cap ADAPTS to headroom ÷ live agents — more agents, shorter reports', async () => { + it('without an explicit recoveryBudget the cap ADAPTS to headroom ÷ live agents — more agents, shorter reports', async () => { // The default cap is a fair share of CURRENT headroom across the live agents, // not a fixed number: recovering alone licenses a longer report than recovering // as one of many. A recovery that would run long is token-stopped at that - // adaptive `b`, so the per-report token count is strictly smaller with more - // co-alive agents. (No reportBudget → the adaptive path.) + // adaptive `b`, so the per-recovery token count is strictly smaller with more + // co-alive agents. (No recoveryBudget → the adaptive path.) const longRecovery = (n: number) => Array.from({ length: n }, () => ({ tokens: [1, STOP, ...Array(2200).fill(1), STOP], content: 'x', toolCall: REPORT_CALL })); const solo = await runPool({ nCtx: 4096, cellsUsed: 0, scripts: longRecovery(1), policy: idleNoResultPolicy('parallel') }); @@ -274,7 +274,7 @@ describe('scenario: parallel recovery (in-loop via SETTLE)', () => { it('staggered (high effort) recovers one agent at a time, UNCAPPED (lossless) — unchanged', async () => { // The lossless path: each report is admitted alone and owns full - // headroom — NO token-stop, so even with a reportBudget set the report runs to + // headroom — NO token-stop, so even with a recoveryBudget set the report runs to // its natural stop. This is what `parallel` trades away for responsiveness. const r = await runPool({ nCtx: 8192, cellsUsed: 0, diff --git a/packages/agents/test/invariants/scenarios/recovery-agent-done-oneshot.scenario.test.ts b/packages/agents/test/invariants/scenarios/recovery-agent-done-oneshot.scenario.test.ts index 772e2c73..ab402be9 100644 --- a/packages/agents/test/invariants/scenarios/recovery-agent-done-oneshot.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/recovery-agent-done-oneshot.scenario.test.ts @@ -4,7 +4,7 @@ * * Shape: a free-texting agent (recoveryShape 'parallel') crosses the hardLimit * → `pressure.critical` kills it in PRODUCE, emitting `agent:done` (kill). The - * kill path plans a cohort recovery, which marks the agent `extracting` + + * kill path decides a cohort recovery, which marks the agent `extracting` + * `awaiting_tool` and queues a recovery turn. Under critical, that turn's SETTLE * admission budget is `remaining − hardLimit < 0`, so it always DEFERS; with no * active siblings the stall-break drop block runs. diff --git a/packages/agents/test/invariants/scenarios/recovery-prompt-budget-substitution.scenario.test.ts b/packages/agents/test/invariants/scenarios/recovery-prompt-budget-substitution.scenario.test.ts index 6d34427c..c9d0493c 100644 --- a/packages/agents/test/invariants/scenarios/recovery-prompt-budget-substitution.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/recovery-prompt-budget-substitution.scenario.test.ts @@ -92,7 +92,7 @@ describe('scenario: recovery prompt budget substitution', () => { const agent: any = { tokenCount: 200, toolCallCount: 5 }; - // The parallel fold passes its fixed per-report budget `b` as onRecovery's 3rd + // The parallel fold passes its fixed per-recovery budget `b` as onRecovery's 3rd // arg so the prompt advisory matches the grammar maxLength cap. b=200 → // words = floor(200 * 0.7 / 10) * 10 = 140 (NOT the pressure-derived ~5130). const overridden = policy.onRecovery(agent, mkPressure(8000), 200) as diff --git a/packages/agents/test/invariants/scenarios/recovery-skip-terminal.scenario.test.ts b/packages/agents/test/invariants/scenarios/recovery-skip-terminal.scenario.test.ts index db9a1204..8003bbe0 100644 --- a/packages/agents/test/invariants/scenarios/recovery-skip-terminal.scenario.test.ts +++ b/packages/agents/test/invariants/scenarios/recovery-skip-terminal.scenario.test.ts @@ -25,7 +25,7 @@ describe('scenario: skipped recovery emits agent:failed (no orphan)', () => { }; // Free-text turn 1 (`1, 2, STOP`) → onProduced idle → the parallel idle path emits - // agent:done then plans recovery → onRecovery skip. + // agent:done then decides recovery → onRecovery skip. const run = await runPool({ nCtx: 4096, cellsUsed: 0, diff --git a/packages/agents/test/scheduler.test.ts b/packages/agents/test/scheduler.test.ts index ab9de0f7..62a4d211 100644 --- a/packages/agents/test/scheduler.test.ts +++ b/packages/agents/test/scheduler.test.ts @@ -99,8 +99,8 @@ describe('DefaultScheduler.schedule', () => { it('the voluntary report cap force-finishes a report at the budget', () => { const a = emitting(agent(1), 'report'); for (let i = 0; i < 4; i++) a.accumulateToken('x'); - const S = scheduler({ reportBudget: 4 }).schedule(state([a]), quiet); - expect(S.drops[0]).toMatchObject({ reason: 'report_cap', exitReason: 'report_cap', recovery: { type: 'salvage' } }); + const S = scheduler({ recoveryBudget: 4 }).schedule(state([a]), quiet); + expect(S.drops[0]).toMatchObject({ reason: 'terminal_cap', exitReason: 'terminal_cap', recovery: { type: 'salvage' } }); }); it('an extracting agent is exempt from the kill and finishes at its token-stop', () => { From d2e62d6a47be6bc414512763657c0b8fde05621d Mon Sep 17 00:00:00 2001 From: lloyal-research Date: Sat, 5 Sep 2026 21:37:00 +1000 Subject: [PATCH 32/69] agents: review round one on the scheduler A cancel decided in a schedule now vetoes that schedule's work for the agent: no admission of its queued item, no retry re-dispatch, no dispatch. Before, the pool prefilled onto the cancelled agent, pruned its branch while it still read active, sampled it, and the outer catch closed the run partial with a live sibling lost and no error surfaced. A spawn batch that fails to land frees every fork it made and rejects every spawn suspended on it, through one discardSpawn shared with admission rejection. Before, the forks kept their KV sequence leases for the life of the context and the actions hung until the scope halted them. Extends are admitted against headroom like every other prefill: carried while KV can still be freed, rejected with the reason once nothing can, so the spine is never handed a delta that does not fit. Admitted extends land as one pair on the spine, since a batch may not carry a handle twice, and the sdk mock now refuses the batch the kernel refuses. The I4 predicate selects the last prefill before the first fork create. The pre-workspace root test/ and examples/ trees go, with their scripts, and a package-surface test holds every tracked import of @lloyal-labs/lloyal-agents to the index's exports. Each fix landed behind a test in test/invariants that failed first: I41 terminal-is-last, I42 no-leaked-branches, and three scenarios. --- README.md | 5 - examples/compare/README.md | 131 -- examples/compare/__compare-smoke.ts | 55 - examples/compare/harness.ts | 301 ---- examples/compare/main.ts | 314 ---- examples/compare/prompts/compare.eta | 19 - examples/compare/prompts/playbooks.eta | 141 -- examples/compare/prompts/research-corpus.eta | 20 - examples/compare/prompts/research-web.eta | 17 - examples/compare/prompts/synthesize.eta | 27 - examples/compare/tui/AgentCard.tsx | 180 -- examples/compare/tui/App.tsx | 159 -- examples/compare/tui/DagCanvas.tsx | 128 -- examples/compare/tui/EdgeRow.tsx | 40 - examples/compare/tui/__reducer-smoke.ts | 204 --- examples/compare/tui/__visual-smoke.tsx | 106 -- examples/compare/tui/colors.ts | 12 - examples/compare/tui/edge-router.ts | 78 - examples/compare/tui/event-bus.ts | 49 - examples/compare/tui/events.ts | 12 - examples/compare/tui/hooks/useElapsed.ts | 44 - examples/compare/tui/hooks/useEventStream.ts | 29 - examples/compare/tui/package.json | 3 - examples/compare/tui/reducer.ts | 258 --- examples/compare/tui/render.ts | 37 - examples/compare/tui/state.ts | 75 - examples/react-agent/harness.ts | 72 - examples/react-agent/main.ts | 206 --- examples/react-agent/tasks/research.md | 14 - examples/react-agent/tui.ts | 111 -- examples/reflection/harness.ts | 202 --- examples/reflection/main.ts | 209 --- examples/reflection/tasks/critique.md | 7 - examples/reflection/tasks/draft.md | 10 - examples/reflection/tasks/research.md | 14 - examples/reflection/tasks/revise.md | 1 - examples/reflection/tui.ts | 167 -- examples/shared/tui-ink/__bus-smoke.ts | 86 - examples/shared/tui-ink/__config-smoke.ts | 237 --- examples/shared/tui-ink/__reducer-smoke.ts | 649 ------- examples/shared/tui-ink/__visual-smoke.tsx | 169 -- examples/shared/tui-ink/colors.ts | 18 - examples/shared/tui-ink/commands.ts | 21 - examples/shared/tui-ink/components/Answer.tsx | 26 - examples/shared/tui-ink/components/App.tsx | 67 - .../shared/tui-ink/components/BootStatus.tsx | 86 - .../tui-ink/components/ClarifyPanel.tsx | 39 - examples/shared/tui-ink/components/Column.tsx | 247 --- .../shared/tui-ink/components/Composer.tsx | 332 ---- examples/shared/tui-ink/components/Eval.tsx | 31 - examples/shared/tui-ink/components/Footer.tsx | 82 - examples/shared/tui-ink/components/Header.tsx | 17 - .../shared/tui-ink/components/Narrative.tsx | 81 - examples/shared/tui-ink/components/Plan.tsx | 44 - .../shared/tui-ink/components/PlanReview.tsx | 147 -- .../tui-ink/components/PlanningSpinner.tsx | 33 - examples/shared/tui-ink/components/Synth.tsx | 53 - .../shared/tui-ink/components/TextInput.tsx | 188 -- examples/shared/tui-ink/components/Verify.tsx | 46 - examples/shared/tui-ink/config.ts | 274 --- examples/shared/tui-ink/event-bus.ts | 53 - examples/shared/tui-ink/events.ts | 79 - examples/shared/tui-ink/hooks/useCommand.ts | 20 - examples/shared/tui-ink/hooks/useElapsed.ts | 46 - .../shared/tui-ink/hooks/useEventStream.ts | 29 - examples/shared/tui-ink/index.ts | 26 - examples/shared/tui-ink/package.json | 3 - examples/shared/tui-ink/reducer.ts | 760 --------- examples/shared/tui-ink/render.ts | 27 - examples/shared/tui-ink/spinner-frames.ts | 15 - examples/shared/tui-ink/state.ts | 251 --- examples/shared/tui/agent-view.ts | 263 --- examples/shared/tui/gauge.ts | 19 - examples/shared/tui/index.ts | 12 - examples/shared/tui/page-stream.ts | 52 - examples/shared/tui/primitives.ts | 44 - examples/shared/tui/stats-view.ts | 35 - examples/shared/tui/tree.ts | 20 - examples/shared/tui/types.ts | 31 - package.json | 6 - packages/agents/src/apply.ts | 26 +- packages/agents/src/execute.ts | 20 +- packages/agents/src/orchestrators.ts | 4 +- packages/agents/src/scheduler.ts | 40 +- packages/agents/src/state.ts | 3 + packages/agents/test/invariants/README.md | 2 + packages/agents/test/invariants/harness.ts | 18 + packages/agents/test/invariants/predicates.ts | 110 +- ...t-cancel-with-queued-work.scenario.test.ts | 93 + ...extend-waits-for-headroom.scenario.test.ts | 83 + ...ch-failure-leaves-no-fork.scenario.test.ts | 85 + packages/agents/test/package-surface.test.ts | 64 + packages/agents/test/scheduler.test.ts | 37 + packages/sdk/src/testing.ts | 20 + test/__probe-rerank-prompt.ts | 69 - test/__rerank-bench.ts | 175 -- test/agents.ts | 1000 ----------- test/sdk.ts | 1516 ----------------- test/tsconfig.json | 19 - 99 files changed, 573 insertions(+), 11032 deletions(-) delete mode 100644 examples/compare/README.md delete mode 100644 examples/compare/__compare-smoke.ts delete mode 100644 examples/compare/harness.ts delete mode 100644 examples/compare/main.ts delete mode 100644 examples/compare/prompts/compare.eta delete mode 100644 examples/compare/prompts/playbooks.eta delete mode 100644 examples/compare/prompts/research-corpus.eta delete mode 100644 examples/compare/prompts/research-web.eta delete mode 100644 examples/compare/prompts/synthesize.eta delete mode 100644 examples/compare/tui/AgentCard.tsx delete mode 100644 examples/compare/tui/App.tsx delete mode 100644 examples/compare/tui/DagCanvas.tsx delete mode 100644 examples/compare/tui/EdgeRow.tsx delete mode 100644 examples/compare/tui/__reducer-smoke.ts delete mode 100644 examples/compare/tui/__visual-smoke.tsx delete mode 100644 examples/compare/tui/colors.ts delete mode 100644 examples/compare/tui/edge-router.ts delete mode 100644 examples/compare/tui/event-bus.ts delete mode 100644 examples/compare/tui/events.ts delete mode 100644 examples/compare/tui/hooks/useElapsed.ts delete mode 100644 examples/compare/tui/hooks/useEventStream.ts delete mode 100644 examples/compare/tui/package.json delete mode 100644 examples/compare/tui/reducer.ts delete mode 100644 examples/compare/tui/render.ts delete mode 100644 examples/compare/tui/state.ts delete mode 100644 examples/react-agent/harness.ts delete mode 100644 examples/react-agent/main.ts delete mode 100644 examples/react-agent/tasks/research.md delete mode 100644 examples/react-agent/tui.ts delete mode 100644 examples/reflection/harness.ts delete mode 100644 examples/reflection/main.ts delete mode 100644 examples/reflection/tasks/critique.md delete mode 100644 examples/reflection/tasks/draft.md delete mode 100644 examples/reflection/tasks/research.md delete mode 100644 examples/reflection/tasks/revise.md delete mode 100644 examples/reflection/tui.ts delete mode 100644 examples/shared/tui-ink/__bus-smoke.ts delete mode 100644 examples/shared/tui-ink/__config-smoke.ts delete mode 100644 examples/shared/tui-ink/__reducer-smoke.ts delete mode 100644 examples/shared/tui-ink/__visual-smoke.tsx delete mode 100644 examples/shared/tui-ink/colors.ts delete mode 100644 examples/shared/tui-ink/commands.ts delete mode 100644 examples/shared/tui-ink/components/Answer.tsx delete mode 100644 examples/shared/tui-ink/components/App.tsx delete mode 100644 examples/shared/tui-ink/components/BootStatus.tsx delete mode 100644 examples/shared/tui-ink/components/ClarifyPanel.tsx delete mode 100644 examples/shared/tui-ink/components/Column.tsx delete mode 100644 examples/shared/tui-ink/components/Composer.tsx delete mode 100644 examples/shared/tui-ink/components/Eval.tsx delete mode 100644 examples/shared/tui-ink/components/Footer.tsx delete mode 100644 examples/shared/tui-ink/components/Header.tsx delete mode 100644 examples/shared/tui-ink/components/Narrative.tsx delete mode 100644 examples/shared/tui-ink/components/Plan.tsx delete mode 100644 examples/shared/tui-ink/components/PlanReview.tsx delete mode 100644 examples/shared/tui-ink/components/PlanningSpinner.tsx delete mode 100644 examples/shared/tui-ink/components/Synth.tsx delete mode 100644 examples/shared/tui-ink/components/TextInput.tsx delete mode 100644 examples/shared/tui-ink/components/Verify.tsx delete mode 100644 examples/shared/tui-ink/config.ts delete mode 100644 examples/shared/tui-ink/event-bus.ts delete mode 100644 examples/shared/tui-ink/events.ts delete mode 100644 examples/shared/tui-ink/hooks/useCommand.ts delete mode 100644 examples/shared/tui-ink/hooks/useElapsed.ts delete mode 100644 examples/shared/tui-ink/hooks/useEventStream.ts delete mode 100644 examples/shared/tui-ink/index.ts delete mode 100644 examples/shared/tui-ink/package.json delete mode 100644 examples/shared/tui-ink/reducer.ts delete mode 100644 examples/shared/tui-ink/render.ts delete mode 100644 examples/shared/tui-ink/spinner-frames.ts delete mode 100644 examples/shared/tui-ink/state.ts delete mode 100644 examples/shared/tui/agent-view.ts delete mode 100644 examples/shared/tui/gauge.ts delete mode 100644 examples/shared/tui/index.ts delete mode 100644 examples/shared/tui/page-stream.ts delete mode 100644 examples/shared/tui/primitives.ts delete mode 100644 examples/shared/tui/stats-view.ts delete mode 100644 examples/shared/tui/tree.ts delete mode 100644 examples/shared/tui/types.ts create mode 100644 packages/agents/test/invariants/scenarios/agent-cancel-with-queued-work.scenario.test.ts create mode 100644 packages/agents/test/invariants/scenarios/extend-waits-for-headroom.scenario.test.ts create mode 100644 packages/agents/test/invariants/scenarios/spawn-batch-failure-leaves-no-fork.scenario.test.ts create mode 100644 packages/agents/test/package-surface.test.ts delete mode 100644 test/__probe-rerank-prompt.ts delete mode 100644 test/__rerank-bench.ts delete mode 100644 test/agents.ts delete mode 100644 test/sdk.ts delete mode 100644 test/tsconfig.json diff --git a/README.md b/README.md index 6bb991ea..1571d80d 100644 --- a/README.md +++ b/README.md @@ -242,11 +242,6 @@ packages/ corpus/ @lloyal-labs/corpus-ability — first-party local-corpus research Ability wikipedia/ @lloyal-labs/wikipedia-ability — first-party Wikipedia demo Ability channel-verify/ @lloyal-labs/channel-verify — canonical-JSON + Ed25519 channel verification (Apache 2.0, zero-dep) - -examples/ - compare/ DAG primer (Ability-protocol-shaped): parallel research → compare → synthesize - react-agent/ Pre-Ability-protocol `useAgent` baseline (mechanism demo, not a 3.0 reference) - reflection/ Pre-Ability-protocol `diverge` primer (research → draft → critique → revise) ``` `reasoning.run` is the production-grade reference harness — `npx reasoning.run` and read its source. The native binding [`@lloyal-labs/lloyal.node`](https://github.com/lloyal-ai/lloyal.node) lives in a separate repo and is pulled in as a dependency. diff --git a/examples/compare/README.md b/examples/compare/README.md deleted file mode 100644 index f791bff9..00000000 --- a/examples/compare/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# compare — DAG framework primer - -A 6-node DAG with explicit edges drawn between live streaming agent cards. The example exists to make `dag(...)` from `@lloyal-labs/lloyal-agents` *visceral*: spawn waves, multi-parent dependencies, and Continuous Context spine extension are all things you can point at as they happen. - -``` - research_web_X ──┐ ┌──▶ compare_axis_1 ──┐ - (web app) │ │ │ - ├──────────────────────────┼──▶ compare_axis_2 ──┼──▶ synthesize - research_corp_Y ─┘ │ │ - (corpus app) └──▶ compare_axis_3 ──┘ - - roots fan-in / fan-out sink - (parallel, no deps) (3 siblings sharing deps) -``` - -The two research lanes pull their `Source` instances from the HDK 3.0 App -registry — `@lloyal-labs/web-app` and `@lloyal-labs/corpus-app` are -enabled at boot, each contributing its tools to the shared pool. The DAG -is otherwise framework-only; the App contract just owns source -provisioning. - -Why this DAG matters pedagogically: - -- **Multi-parent dependencies.** Each `compare_axis_*` node depends on TWO research nodes simultaneously — `chain` and `fanout` can't express this. -- **Sibling parallelism with shared deps.** The three compare nodes fire the moment both research nodes complete, then run concurrently. -- **Multi-child convergence.** `synthesize` waits on all three siblings before spawning. -- **Spine extension is causal, not just sequential.** Each node's `userContent` is prefilled onto the spine via `ctx.extendSpine`. The compare nodes don't merely *follow* the research nodes — they *attend to* them. The edge in the diagram is the spine. - -## Run it - -```sh -export TAVILY_API_KEY=tvly-… - -npx tsx examples/compare/main.ts \ - --x "Rust's ownership model" \ - --y "Swift's automatic reference counting" \ - --corpus ~/Documents/swift-docs \ - --reranker ~/.cache/lloyal/models/qwen3-reranker-0.6b-q8_0.gguf \ - ~/.cache/lloyal/models/Qwen3.5-4B-Q4_K_M.gguf -``` - -Or via the workspace script: - -```sh -npm run examples:compare -- --x "…" --y "…" --corpus … --reranker … -``` - -## What you'll see - -In a TTY, an Ink TUI renders the topology with cards laid out in topological layers connected by orthogonal box-drawing edges. Cards stream tokens live; pending cards show a dotted background; completed cards collapse to a one-line summary. - -``` -╭ DAG · Rust ownership vs Swift ARC · 0:32 ────────────────────────╮ -│ 1840 tok · 18 tools │ -╰──────────────────────────────────────────────────────────────────╯ - -╭─ research_web_X · web · ●12 ───╮ ╭─ research_corp_Y · corpus · ●8 ─╮ -│ "The borrow checker enforces…" │ │ Reading examples/lifetimes.md │ -│ Fetched 3 pages │ │ Found Box at line 42 │ -│ ▮ analyzing… │ │ ▮ ARC at compile time… │ -╰──────────────┬─────────────────╯ ╰────────────┬────────────────────╯ - │ │ - ╭─────────────┬────────┬──────────╯ - │ │ │ - ╭─────────────────────┴──╮ ╭───┴──────╮ ╭─┴─────────────────╮ - │ compare_axis_1 │ │ axis_2 │ │ axis_3 │ - │ ···················· │ │ pending │ │ pending │ - ╰────────────┬───────────╯ ╰─────┬────╯ ╰────┬──────────────╯ - │ │ │ - ╰───────────────────┼───────────╯ - │ - ╭─────────────┴───────╮ - │ synthesize │ - │ pending │ - ╰─────────────────────╯ -``` - -Outside a TTY (pipe, CI, `--jsonl`), the same harness runs with stderr line events and a plain stdout final answer: - -```sh -npm run examples:compare -- --x "…" --y "…" --corpus … --reranker … > report.md -# stderr: -# [compare] +0.0s agent#1 spawned (parent agent#root) -# [compare] +0.0s agent#2 spawned (parent agent#root) -# [compare] +0.1s agent#1 → web_search -# … -# stdout: the synthesized markdown report -``` - -`--jsonl` streams the full event union (`dag:topology`, `dag:node:spawn`, all `agent:*` events, plus a `compare:done` payload) on stdout for piping into other tools. - -## Reading the code - -- `harness.ts` — DAG declaration + custom orchestrator (`dagWithEvents`) that mirrors `dag()` from `packages/agents/src/orchestrators.ts:209` but emits per-node lifecycle events. ~190 LOC. -- `main.ts` — CLI args, model load, App registry wiring (`createAppRegistry` + `createWebApp` + `createCorpusApp`), TUI mount or non-TTY fallback. ~210 LOC. -- `tui/` — self-contained Ink TUI: - - `DagCanvas.tsx` — topo sort into layers, layout cards, draw `EdgeRow` between layers - - `EdgeRow.tsx` + `edge-router.ts` — pure orthogonal box-drawing router (drop · bus · drop) - - `AgentCard.tsx` — fixed-width card with status header, streaming body, summary - - `state.ts` + `reducer.ts` + `events.ts` — pure reducer over `dag:*` and `agent:*` events - - `App.tsx` + `render.ts` — mount + header + canvas + final answer panel -- `prompts/research-web.eta`, `prompts/research-corpus.eta`, `prompts/compare.eta`, `prompts/synthesize.eta` — system + user prompts for each node type. - -## Smoke tests - -```sh -# Reducer + edge router (pure unit-style; no Ink imports): -npx tsx examples/compare/tui/__reducer-smoke.ts - -# Visual: drives synthetic events through the TUI to render three frozen states. -# Best viewed in a real terminal — when piped, terminal width detection is -# imperfect and edges may wrap. -npx tsx examples/compare/tui/__visual-smoke.tsx -``` - -## Flags - -| Flag | Default | Meaning | -|---|---|---| -| `--x ` | required | Subject researched on the live web | -| `--y ` | required | Subject researched in the local corpus | -| `--corpus ` | required | Local corpus directory (markdown files) | -| `--reranker ` | required | Reranker GGUF path | -| `` (positional) | required | LLM GGUF path | -| `--axes ` | `accuracy,performance,complexity` | Three comma-separated axes | -| `--max-turns ` | `10` | Max tool calls per agent | -| `--n-ctx ` | `32768` | LLM context window | -| `--jsonl` | off | Stream events as JSONL on stdout (skips TUI) | -| `--trace` | off | Dump full agent trace to `trace-.jsonl` | - -`TAVILY_API_KEY` must be set in the environment. diff --git a/examples/compare/__compare-smoke.ts b/examples/compare/__compare-smoke.ts deleted file mode 100644 index b5ce3892..00000000 --- a/examples/compare/__compare-smoke.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * No-model smoke for the App-registry wiring in compare/main.ts. - * - * The compare DAG itself needs a model + reranker to run end-to-end; - * that's out of scope for a smoke. What we *can* deterministically check - * is the wiring change introduced in Phase E: - * - createInMemoryConfigStore + createAppRegistry resolve cleanly. - * - createWebApp's factory enables successfully (the keyless fallback - * path activates when no tavilyKey is set). - * - The enabled web app exposes a Source with the manifest-declared - * tools (`web_search`, `fetch_page`). - * - registry.byName returns the same App instance. - * - * Corpus is intentionally skipped — its factory requires a real reranker - * from `RerankerCtx`. That path is covered by reasoning.run's boot flow, - * which is the integration test for the full wiring. - */ -import * as assert from 'node:assert/strict'; -import { main } from 'effection'; -import { - createAppRegistry, - createInMemoryConfigStore, -} from '@lloyal-labs/rig'; -import { createWebApp } from '@lloyal-labs/web-app'; - -main(function* () { - const configStore = createInMemoryConfigStore(); - // No tavilyKey — the web app falls back to keyless DuckDuckGo. - const registry = yield* createAppRegistry({ configStore }); - - const webApp = yield* registry.enable(createWebApp); - - // Manifest is the catalog source-of-truth. - assert.equal(webApp.manifest.name, 'web'); - assert.equal(webApp.manifest.protocol.name, 'web_research'); - assert.deepEqual( - [...webApp.manifest.protocol.tools].sort(), - ['fetch_page', 'web_search'], - ); - - // App.source carries the two tools the manifest declares. - const toolNames = webApp.source.tools.map((t) => t.name).sort(); - assert.deepEqual(toolNames, ['fetch_page', 'web_search']); - - // registry.byName resolves to the same App identity. - const looked = registry.byName('web'); - assert.equal(looked, webApp); - - // registry.enabled() includes the web app exactly once. - const enabled = registry.enabled(); - assert.equal(enabled.length, 1); - assert.equal(enabled[0]?.manifest.name, 'web'); -}); - -console.log('ok compare: web app registry wiring resolves keyless + exposes manifest tools'); diff --git a/examples/compare/harness.ts b/examples/compare/harness.ts deleted file mode 100644 index 24354f83..00000000 --- a/examples/compare/harness.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Compare harness — a 6-node DAG over two sources. - * - * This is the SDK's framework primer for `dag(...)`. The DAG below is the - * smallest topology that genuinely needs DAG (rather than chain or fanout): - * three siblings depend on TWO root nodes simultaneously, and a final node - * depends on all three siblings. - * - * research_web_X ──┐ ┌──▶ compare_axis_1 ──┐ - * (web app) │ │ │ - * ├───────────────────┼──▶ compare_axis_2 ──┼──▶ synthesize - * research_corp_Y ─┘ │ │ - * (corpus app) └──▶ compare_axis_3 ──┘ - * - * The orchestrator lazily spawns each node when its dependencies clear. - * Each node's `userContent` is prefilled onto the shared root via - * `ctx.extendSpine`, so dependent nodes see prior findings as conversation - * turns in their KV attention — that's why the compare nodes can read - * "Research findings on X" / "Research findings on Y" above their task. - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; -import { spawn } from "effection"; -import type { Operation, Task } from "effection"; -import type { Session } from "@lloyal-labs/sdk"; -import { - agentPool, - renderTemplate, - withSpine, -} from "@lloyal-labs/lloyal-agents"; -import type { - DAGNode, - Orchestrator, - Source, - AgentResult, -} from "@lloyal-labs/lloyal-agents"; -import { reportTool } from "@lloyal-labs/rig"; -import type { Chunk, Reranker, SourceContext } from "@lloyal-labs/rig"; - -// ── Prompt loading ────────────────────────────────────────────── - -function loadTemplate(name: string): string { - return fs.readFileSync( - path.resolve(__dirname, `prompts/${name}.eta`), - "utf8", - ); -} - -const RESEARCH_WEB = loadTemplate("research-web"); -const RESEARCH_CORPUS = loadTemplate("research-corpus"); -const COMPARE = loadTemplate("compare"); -const SYNTHESIZE = loadTemplate("synthesize"); -const PLAYBOOKS = loadTemplate("playbooks"); - -// ── Types ─────────────────────────────────────────────────────── - -/** - * Events the harness emits. Two are produced by the orchestrator - * (topology + per-node spawn) so a TUI can map agent ids back to DAG - * node ids; the third is a fatal-error notice main.ts uses to render - * an error panel without tearing the TUI down. - */ -export type DagEvent = - | { type: 'dag:topology'; nodes: { id: string; dependsOn: string[] }[]; t0Ms: number } - | { type: 'dag:node:spawn'; id: string; agentId: number; tMs: number } - | { type: 'compare:error'; message: string; stack?: string }; - -export interface CompareOpts { - x: string; - y: string; - axes: [string, string, string]; - maxTurns: number; - trace: boolean; - /** Optional: receive `dag:topology` + `dag:node:spawn` so a TUI can route - * subsequent `agent:*` events to the right card. No-op by default. */ - emitDagEvent?: (ev: DagEvent) => void; -} - -export interface CompareResult { - answer: string; - totalTokens: number; - totalToolCalls: number; - agents: readonly AgentResult[]; -} - -// ── Helpers ───────────────────────────────────────────────────── - -function getCorpusToc(sources: Source[]): string { - const corpus = sources.find( - (s) => - typeof (s as unknown as { promptData?: () => { toc: string } }) - .promptData === "function", - ); - if (!corpus) { - throw new Error( - "compare: requires the corpus app (one of the two research lanes is corpus-backed)", - ); - } - return (corpus as unknown as { promptData: () => { toc: string } }) - .promptData().toc; -} - -// ── Entry point ───────────────────────────────────────────────── - -/** - * Inline orchestrator that mirrors the framework's `dag()` (after its - * Task-as-Future refactor) but ALSO emits per-node lifecycle events. We - * inline rather than import because `dag()` doesn't expose a per-spawn - * event hook — replicating ~25 LOC is cheaper than threading a callback - * through the package API. - * - * Pattern (canonical Effection): each node runs as a child Task. The - * dependency edge "A depends on B" is encoded as `yield* tasks.get(B)` - * inside A's task body — Task extends Future extends Operation, - * so awaiting another task IS the cross-task rendezvous primitive. No - * mutable Sets, no race window. Failure in any node halts the rest via - * structured concurrency. - * - * Validation is skipped — the topology is hardcoded so cycles aren't - * possible by construction. - */ -function dagWithEvents( - nodes: DAGNode[], - emit: (ev: DagEvent) => void, -): Orchestrator { - return function* (ctx) { - emit({ - type: 'dag:topology', - t0Ms: performance.now(), - nodes: nodes.map((n) => ({ id: n.id, dependsOn: n.dependsOn ?? [] })), - }); - - const tasks = new Map>(); - - function* runNode(n: DAGNode): Operation { - // Gate: await every declared dep's task. Roots (no deps) start - // immediately; descendants unblock as their deps complete. - for (const depId of n.dependsOn ?? []) { - yield* tasks.get(depId)!; - } - const agent = yield* ctx.spawn({ - ...n.task, - parent: n.task.parent ?? ctx.root, - }); - emit({ - type: 'dag:node:spawn', - id: n.id, - agentId: agent.id, - tMs: performance.now(), - }); - yield* ctx.waitFor(agent); - if (agent.result && n.userContent) { - yield* ctx.extendSpine(n.userContent, agent.result); - } - } - - // Spawn every node up front (synchronous between iterations — the - // task bodies don't run until we yield below). Each spawned task - // immediately suspends on its first dep await (or runs, if it's a - // root). The Map is fully populated before any node body executes. - for (const n of nodes) { - tasks.set(n.id, yield* spawn(() => runNode(n))); - } - for (const t of tasks.values()) yield* t; - }; -} - -const SYNTH_NODE_ID = 'synthesize'; - -export function* handleCompare( - session: Session, - sources: Source[], - reranker: Reranker, - opts: CompareOpts, -): Operation { - const { x, y, axes, maxTurns, trace } = opts; - - // Capture the synth node's agent id from the orchestrator's spawn event - // so we can look it up in pool.agents at the end. The pool may include - // recovery agents beyond the 6 declared nodes, so spawn-order indexing - // doesn't work — agents are looked up by their stable agent.id. - let synthAgentId: number | null = null; - const emitOuter = opts.emitDagEvent ?? (() => {}); - const emit = (ev: DagEvent): void => { - if (ev.type === 'dag:node:spawn' && ev.id === SYNTH_NODE_ID) { - synthAgentId = ev.agentId; - } - emitOuter(ev); - }; - - // Bind sources, gather tools, pick primary scorer (mirrors deep-research:296-305). - for (const source of sources) yield* source.bind({ reranker }); - const allDataTools = sources.flatMap((s) => s.tools); - const tools = [...allDataTools, reportTool]; - const primaryScorer = sources[0].createScorer(`${x} vs ${y}`); - - const date = new Date().toISOString().slice(0, 10); - const corpusToc = getCorpusToc(sources); - - // ── DAG topology ────────────────────────────────────────────── - const nodes: DAGNode[] = [ - { - id: "research_web_X", - task: { - content: `Research subject: ${x}`, - systemPrompt: renderTemplate(RESEARCH_WEB, { - subject: x, - counterpart: y, - axes, - maxTurns, - date, - }), - seed: 1001, - }, - userContent: `Research findings on ${x}:`, - }, - { - id: "research_corp_Y", - task: { - content: `Research subject: ${y}`, - systemPrompt: renderTemplate(RESEARCH_CORPUS, { - subject: y, - counterpart: x, - axes, - toc: corpusToc, - maxTurns, - }), - seed: 1002, - }, - userContent: `Research findings on ${y}:`, - }, - ...axes.map((axis, i) => ({ - id: `compare_axis_${i + 1}`, - dependsOn: ["research_web_X", "research_corp_Y"], - task: { - content: `Compare ${x} vs ${y} on: ${axis}`, - systemPrompt: renderTemplate(COMPARE, { - x, - y, - axis, - }), - seed: 2000 + i, - }, - userContent: `Comparison along axis "${axis}":`, - })), - { - id: "synthesize", - dependsOn: ["compare_axis_1", "compare_axis_2", "compare_axis_3"], - task: { - content: `Write the final compare-and-contrast report on ${x} vs ${y}.`, - systemPrompt: renderTemplate(SYNTHESIZE, { - x, - y, - axes, - }), - seed: 3000, - }, - // No userContent — synthesize is terminal; nothing reads from its extension. - }, - ]; - - // ── Run the pool ────────────────────────────────────────────── - // The DAG declares the topology; the pool's tick loop batches decode - // across whatever agents are currently active. The spine is harness-owned - // (not nested inside agentPool) so spine extensions persist for any - // post-pool useAgent calls that fork querySpine. - const pool = yield* withSpine( - { - parent: session.trunk ?? undefined, - systemPrompt: PLAYBOOKS, - tools, // schemas decoded once into querySpine's KV - }, - function* (querySpine) { - return yield* agentPool({ - orchestrate: dagWithEvents(nodes, emit), - tools, // same tools, registered for runtime dispatch - parent: querySpine, - terminal: reportTool, - maxTurns, - pruneOnReturn: true, - scorer: primaryScorer, - trace, - }); - }, - ); - - // Find the synth agent by its captured id. The pool's agents array may - // include recovery agents beyond the declared nodes, so we can't rely on - // a fixed length or spawn-order index. - const synth = synthAgentId !== null - ? pool.agents.find((a) => a.agent.id === synthAgentId) - : undefined; - - return { - answer: synth?.result ?? "(no synthesis)", - totalTokens: pool.totalTokens, - totalToolCalls: pool.totalToolCalls, - agents: pool.agents, - }; -} diff --git a/examples/compare/main.ts b/examples/compare/main.ts deleted file mode 100644 index c781e34d..00000000 --- a/examples/compare/main.ts +++ /dev/null @@ -1,314 +0,0 @@ -#!/usr/bin/env node -/** - * Compare — DAG-centric framework primer for the lloyal SDK. - * - * Visualizes a 6-node DAG that: - * 1. researches X on the live web (web app: web_search + fetch_page) - * 2. researches Y in a local corpus (corpus app: grep + read_file + search) - * 3. compares X vs Y along three axes in parallel (after BOTH research - * lanes complete — the multi-parent edge is what makes this a DAG and - * not a chain or fanout) - * 4. synthesizes the three axis comparisons into a single argument - * - * In a TTY, mounts an Ink TUI that draws the topology as agent cards - * connected by orthogonal box-drawing edges. Cards stream tokens live; - * dependent cards light up the moment their parents report. - * - * Outside a TTY (pipe / `--jsonl`), falls back to one-line stderr events - * and a plain stdout final answer so it stays scriptable. - * - * export TAVILY_API_KEY=tvly-… - * npx tsx examples/compare/main.ts \ - * --x "Rust's ownership model" \ - * --y "Swift's automatic reference counting" \ - * --corpus ~/Documents/swift-docs \ - * --reranker ~/.cache/lloyal/models/Qwen3-Reranker-0.6B-Q8_0.gguf \ - * ~/.cache/lloyal/models/Qwen3.5-4B-Q4_K_M.gguf - */ - -import * as fs from "node:fs"; -import { parseArgs } from "node:util"; -import { - call, - each, - ensure, - main, - sleep, - spawn, -} from "effection"; -import type { Operation } from "effection"; -import { createContext } from "@lloyal-labs/lloyal.node"; -import { - initAgents, - JsonlTraceWriter, - RerankerCtx, -} from "@lloyal-labs/lloyal-agents"; -import type { AgentEvent, Source } from "@lloyal-labs/lloyal-agents"; -import type { Chunk, SourceContext } from "@lloyal-labs/rig"; -import { - createAppRegistry, - createInMemoryConfigStore, -} from "@lloyal-labs/rig"; -import { createReranker } from "@lloyal-labs/rig/node"; -import { createWebApp } from "@lloyal-labs/web-app"; -import { createCorpusApp } from "@lloyal-labs/corpus-app"; -import { handleCompare, type DagEvent } from "./harness"; - -// ── CLI args ───────────────────────────────────────────────────── - -const { values: flags, positionals } = parseArgs({ - args: process.argv.slice(2), - options: { - x: { type: "string" }, - y: { type: "string" }, - corpus: { type: "string" }, - reranker: { type: "string" }, - axes: { type: "string" }, - "max-turns": { type: "string" }, - "n-ctx": { type: "string" }, - jsonl: { type: "boolean", default: false }, - trace: { type: "boolean", default: false }, - }, - allowPositionals: true, -}); - -const modelPath = positionals[0]; -const x = flags.x; -const y = flags.y; -const corpusDir = flags.corpus; -const rerankerPath = flags.reranker; -const tavilyKey = process.env.TAVILY_API_KEY; -const trace = flags.trace; -const jsonlMode = flags.jsonl; - -const missing: string[] = []; -if (!modelPath) missing.push("positional model path"); -if (!x) missing.push("--x "); -if (!y) missing.push("--y "); -if (!corpusDir) missing.push("--corpus "); -if (!rerankerPath) missing.push("--reranker "); -if (!tavilyKey) missing.push("TAVILY_API_KEY env"); -if (missing.length) { - process.stderr.write(`Missing required: ${missing.join(", ")}\n`); - process.exit(2); -} - -const axesStr = flags.axes ?? "accuracy,performance,complexity"; -const axesArr = axesStr.split(",").map((a) => a.trim()).filter(Boolean); -if (axesArr.length !== 3) { - process.stderr.write( - `--axes must be exactly three comma-separated values; got ${axesArr.length}\n`, - ); - process.exit(2); -} -const axes: [string, string, string] = [axesArr[0], axesArr[1], axesArr[2]]; - -const maxTurns = flags["max-turns"] ? parseInt(flags["max-turns"], 10) : 10; -const nCtx = flags["n-ctx"] ? parseInt(flags["n-ctx"], 10) : 32768; - -const useTui = process.stdout.isTTY === true && !jsonlMode; - -// ── Source labels — fixed for the compare topology ─────────────── - -const SOURCE_LABELS: Record = { - research_web_X: "web", - research_corp_Y: "corpus", - compare_axis_1: `axis: ${axes[0]}`, - compare_axis_2: `axis: ${axes[1]}`, - compare_axis_3: `axis: ${axes[2]}`, - synthesize: "sink", -}; - -// ── Main ───────────────────────────────────────────────────────── - -main(function* () { - // Silence llama.cpp stderr in TUI mode so it doesn't tear the layout. - if (useTui) { - try { - fs.closeSync(2); - fs.openSync(process.platform === "win32" ? "\\\\.\\NUL" : "/dev/null", "w"); - } catch { - // non-fatal - } - } - - process.stderr.write(`[compare] loading model…\n`); - const ctx = yield* call(() => - createContext({ - modelPath: modelPath!, - nCtx, - nSeqMax: 64, - typeK: "q4_0", - typeV: "q4_0", - }), - ); - - // createReranker is now an Effection resource (RFC §6.1) — it disposes - // its SessionContext + Rerank automatically on scope exit, so no manual - // ensure() is needed. - const reranker = yield* createReranker(rerankerPath!, { nSeqMax: 8, nCtx: 16384 }); - - const traceWriter = trace - ? new JsonlTraceWriter(fs.openSync(`trace-${Date.now()}.jsonl`, "w")) - : undefined; - - const { session, events } = yield* initAgents(ctx, { traceWriter }); - - // Either the TUI bus or the stderr forwarder consumes `events`. We pick - // exactly one based on `useTui`. - let emitDagEvent: (ev: DagEvent) => void; - let renderTuiUnmount: (() => void) | null = null; - - if (useTui) { - // Dynamic import of the Ink-side modules — they're ESM (yoga-wasm-web - // top-level await) and we need to load them only when actually mounting. - const tuiMod = yield* call( - () => - import("./tui/render.js") as Promise, - ); - const busMod = yield* call( - () => - import("./tui/event-bus.js") as Promise, - ); - - const bus = busMod.createBus(); - const instance = tuiMod.render(bus as never, { - x: x!, - y: y!, - sourceLabels: SOURCE_LABELS, - }); - renderTuiUnmount = () => instance.unmount(); - yield* ensure(() => { renderTuiUnmount?.(); }); - - emitDagEvent = (ev) => bus.send(ev); - - // Forward all agent events from initAgents into the bus so the cards - // stream live. Spawn so we don't block the main pipeline. - yield* spawn(function* (): Operation { - for (const ev of yield* each(events)) { - bus.send(ev); - yield* each.next(); - } - }); - } else { - // Non-TTY: stderr line per lifecycle event + JSONL on stdout if --jsonl. - const t0 = performance.now(); - const elapsed = (): string => `${((performance.now() - t0) / 1000).toFixed(1)}s`; - let agentSeq = 0; - const seqByAgentId = new Map(); - - emitDagEvent = (ev) => { - if (jsonlMode) { - process.stdout.write(JSON.stringify(ev) + "\n"); - } else if (ev.type === "dag:topology") { - process.stderr.write( - `[compare] dag · ${ev.nodes.length} nodes · ${ev.nodes.filter((n) => n.dependsOn.length === 0).length} roots\n`, - ); - } - }; - - yield* spawn(function* (): Operation { - for (const ev of yield* each(events)) { - if (jsonlMode) { - process.stdout.write(JSON.stringify(ev) + "\n"); - } else if (ev.type === "agent:spawn") { - const seq = ++agentSeq; - seqByAgentId.set(ev.agentId, seq); - process.stderr.write( - `[compare] +${elapsed()} agent#${seq} spawned (parent agent#${seqByAgentId.get(ev.parentAgentId) ?? "root"})\n`, - ); - } else if (ev.type === "agent:return" || ev.type === "agent:recovered") { - const seq = seqByAgentId.get(ev.agentId) ?? "?"; - const verb = ev.type === "agent:recovered" ? "recovered" : "returned"; - process.stderr.write( - `[compare] +${elapsed()} agent#${seq} ${verb} (${ev.result.length} chars)\n`, - ); - } else if (ev.type === "agent:tool_call") { - const seq = seqByAgentId.get(ev.agentId) ?? "?"; - process.stderr.write(`[compare] +${elapsed()} agent#${seq} → ${ev.tool}\n`); - } - yield* each.next(); - } - }); - } - - // ── Build sources via the App registry (RFC §5.4) ───────────── - // Sources used to be constructed directly; under the 3.0 App protocol - // they are produced by app factories that bind the reranker from - // `RerankerCtx` and read provider config (Tavily key, corpus path) - // from `AppConfigStoreCtx`. The DAG below still treats them as plain - // `Source` instances — the contract change is upstream of handleCompare. - process.stderr.write(`[compare] loading corpus from ${corpusDir}…\n`); - yield* RerankerCtx.set(reranker); - const configStore = createInMemoryConfigStore(); - if (tavilyKey) yield* configStore.set("web", { tavilyKey }); - yield* configStore.set("corpus", { corpusPath: corpusDir! }); - const registry = yield* createAppRegistry({ configStore }); - const webApp = yield* registry.enable(createWebApp); - const corpusApp = yield* registry.enable(createCorpusApp); - // Pass the App-provided sources to handleCompare. Web first so - // primaryScorer (sources[0]) keeps using the web app's reranker call - // path — identical behaviour to the pre-registry construction. - const sources: Source[] = [ - webApp.source as unknown as Source, - corpusApp.source as unknown as Source, - ]; - - // ── Run the DAG ──────────────────────────────────────────────── - // Isolate the failable computation in a child scope. Without this, an - // assertion or decode error inside `handleCompare` propagates to main's - // .catch handler and tears the TUI down before the user can read the - // failure state. The pattern: try/catch the inner generator, emit a - // `compare:error` event so the reducer paints an error panel, and (in - // TUI mode) hold the screen for a few seconds before scope exit fires - // `ensure(unmount)` cleanups in LIFO order. - process.stderr.write( - `[compare] starting 6-node DAG · X="${x}" · Y="${y}" · axes=${axes.join("/")}\n`, - ); - - let result: { answer: string; totalTokens: number; totalToolCalls: number } | null = null; - let fatalError: Error | null = null; - - try { - result = yield* handleCompare(session, sources, reranker, { - x: x!, - y: y!, - axes, - maxTurns, - trace, - emitDagEvent, - }); - } catch (err) { - fatalError = err instanceof Error ? err : new Error(String(err)); - emitDagEvent({ - type: "compare:error", - message: fatalError.message, - stack: fatalError.stack, - }); - process.exitCode = 1; - } - - if (fatalError && useTui) { - // Hold the error frame visible — Ink doesn't yet support waiting for - // a keypress in our tooling, so we sleep. Three seconds is enough to - // read the panel; users impatient to dismiss can ^C. - yield* sleep(3000); - } - - // Final-answer routing only fires on success. - if (result && !useTui && !jsonlMode) { - process.stdout.write(result.answer); - if (!result.answer.endsWith("\n")) process.stdout.write("\n"); - } else if (result && jsonlMode) { - process.stdout.write( - JSON.stringify({ type: "compare:done", answer: result.answer }) + "\n", - ); - } -}).catch((err: unknown) => { - // Reachable only on errors that escape the inner try/catch — i.e. boot - // failures (model load, reranker, source binding). Don't `process.exit` - // synchronously; let pending `ensure` cleanups drain first. - const msg = err instanceof Error ? (err.stack ?? err.message) : String(err); - process.stderr.write(`Error: ${msg}\n`); - process.exitCode = 1; -}); diff --git a/examples/compare/prompts/compare.eta b/examples/compare/prompts/compare.eta deleted file mode 100644 index 1cb96dc5..00000000 --- a/examples/compare/prompts/compare.eta +++ /dev/null @@ -1,19 +0,0 @@ -Apply the **compare** playbook. - -You are an analyst writing a focused comparison of two subjects along ONE axis. - -Above this message are two prior research turns — one on **<%= it.x %>**, one on **<%= it.y %>**. Read them now: they are your factual vocabulary. - -Your axis: **<%= it.axis %>** - -PROCESS: -1. Re-read the prior research turns above. Inventory the entities, quantitative claims, and direct quotes each subject's research surfaced that are relevant to **<%= it.axis %>**. -2. State a one-sentence position on how the two subjects differ along **<%= it.axis %>** — derived from the findings, not from prior knowledge. The position must take a side: which subject is stronger on this axis, or what tradeoff each makes. -3. Support the position with 2–4 paragraphs of prose. Cite findings inline (named entities, quoted claims, specific numbers). When the subjects differ, name the difference concretely; do not hedge with "it depends." -4. Call report() with the full markdown comparison. Open with the position statement, then the supporting paragraphs. Do NOT introduce entities, claims, or numbers not present in the prior research turns. If the research is silent on something material to **<%= it.axis %>**, name that gap explicitly. ---- -Subject X: **<%= it.x %>** -Subject Y: **<%= it.y %>** -Axis: **<%= it.axis %>** - -Write the comparison now. diff --git a/examples/compare/prompts/playbooks.eta b/examples/compare/prompts/playbooks.eta deleted file mode 100644 index 5493e2e8..00000000 --- a/examples/compare/prompts/playbooks.eta +++ /dev/null @@ -1,141 +0,0 @@ -You are an assistant working as part of a multi-agent workflow. You have access to the tools below, grouped by playbook. You should only use the tools for a given playbook when that particular playbook is requested explicitly in your task instructions. - -# Playbooks - -## web_research -Tools: web_search, fetch_page -Use when: gathering evidence from the open web — verifying current claims, retrieving primary sources from URLs, surveying official documentation and authoritative discussion. - -## corpus_research -Tools: grep, read_file, search -Use when: investigating a local document corpus — finding occurrences of terms, reading specific files at line offsets, semantic retrieval over indexed corpus content. - -## compare -Tools: report -Use when: distilling research findings into a position on a single comparison axis. No retrieval — read what's already in the conversation history above. - -## synth -Tools: report -Use when: weaving multiple per-axis positions into a single coherent synthesis. No retrieval — read the prior research and comparison turns already in the conversation. - -# Tool selection rule - -The agent system message will tell you which playbook to apply. Use only that playbook's tools. The agent system message also carries an engineered PROCESS that dictates intra-playbook ordering. Follow that PROCESS, but constrained to the assigned playbook's palette. - -# Examples - -## GOOD: web_research applied correctly - -SYSTEM: -Apply the **web_research** playbook. -You are a thorough research assistant. PROCESS: 1) broad web_search; 2) fetch_page on top 2–3 results; 3) report. - -USER: -Research subject: Project Alpha - -ASSISTANT calls: web_search({"query": "Project Alpha architecture overview"}) -TOOL_RESULT: [search hits…] - -ASSISTANT calls: fetch_page({"url": "https://alpha.example.com/docs/architecture"}) -TOOL_RESULT: [page content…] - -ASSISTANT calls: report({"summary": "Project Alpha findings: …"}) - -✓ web_search and fetch_page both belong to web_research; PROCESS followed; report is the universal terminal. - -## GOOD: corpus_research applied correctly - -SYSTEM: -Apply the **corpus_research** playbook. -You are analyzing a local knowledge base. PROCESS: 1) grep or search; 2) read_file on matches; 3) report. - -USER: -Research subject: Project Beta - -ASSISTANT calls: grep({"pattern": "Project Beta"}) -TOOL_RESULT: [matching lines with file paths and line numbers…] - -ASSISTANT calls: read_file({"path": "docs/beta-overview.md", "offset": 40, "limit": 80}) -TOOL_RESULT: [file content…] - -ASSISTANT calls: report({"summary": "Project Beta findings: …"}) - -✓ grep, read_file, search are corpus_research's palette; report is the universal terminal. - -## GOOD: compare applied correctly - -SYSTEM: -Apply the **compare** playbook. -You are an analyst comparing two subjects on one axis. Read the two prior research turns above. Take a one-sentence position, support with cited prose. - -USER: -Compare Project Alpha vs Project Beta on: latency - -ASSISTANT calls: report({"comparison": "Project Alpha is faster on warm-path latency because… [citing prior research turns]"}) - -✓ compare's palette is {report} only. No retrieval — synthesized from prior research turns already in conversation. - -## GOOD: synth applied correctly - -SYSTEM: -Apply the **synth** playbook. -You are writing the final compare-and-contrast report. Read all prior research and comparison turns above. Synthesize into a thesis with per-axis support. - -USER: -Write the final compare-and-contrast report on Project Alpha vs Project Beta. - -ASSISTANT calls: report({"report": "Thesis: Project Alpha optimizes for latency at the cost of … [per-axis support, resolution paragraph, limitations]"}) - -✓ synth's palette is {report} only. - -## BAD: corpus_research requested, web_research tool used - -SYSTEM: -Apply the **corpus_research** playbook. -You are analyzing a local knowledge base. PROCESS: grep/search → read_file → report. - -USER: -Research subject: Project Beta - -ASSISTANT calls: web_search({"query": "Project Beta"}) - -✗ WRONG. web_search is in web_research's palette, not corpus_research's. Should have called grep, search, or read_file against the local corpus. - -## BAD: compare requested, retrieval tool used - -SYSTEM: -Apply the **compare** playbook. -You are an analyst comparing two subjects on one axis. - -USER: -Compare Project Alpha vs Project Beta on: latency - -ASSISTANT calls: grep({"pattern": "latency"}) - -✗ WRONG. compare's palette is {report} only. Read the prior research turns already in the conversation history; do not gather new evidence at the comparison stage. - -## BAD: web_research requested, report called without prior retrieval - -SYSTEM: -Apply the **web_research** playbook. -PROCESS: search → fetch → report. - -USER: -Research subject: Project Alpha - -ASSISTANT calls: report({"summary": "(no findings — no research performed)"}) - -✗ WRONG. Research playbooks require gathering evidence before reporting. report is the terminal — calling it without prior web_search/fetch_page produces an empty finding. - -## BAD: synth requested, retrieval tool used - -SYSTEM: -Apply the **synth** playbook. -You are writing the final report from prior comparison turns. - -USER: -Write the final compare-and-contrast report on Project Alpha vs Project Beta. - -ASSISTANT calls: fetch_page({"url": "https://alpha.example.com/"}) - -✗ WRONG. synth has no retrieval tools. If a prior turn was silent on something material, name the gap in the synthesis rather than running new research. diff --git a/examples/compare/prompts/research-corpus.eta b/examples/compare/prompts/research-corpus.eta deleted file mode 100644 index b82b2ae9..00000000 --- a/examples/compare/prompts/research-corpus.eta +++ /dev/null @@ -1,20 +0,0 @@ -Apply the **corpus_research** playbook. - -You are a research assistant analyzing a local document corpus for evidence about **<%= it.subject %>**. - -Available files: -<%= it.toc %> - -You have <%= it.maxTurns %> tool calls. - -If a tool returns an error about time limit, KV limit, or word limit, stop and call report() with your findings so far. - -PROCESS: -1. grep or search for terms directly tied to **<%= it.subject %>**. If grep returns zero matches, the exact pattern is absent — try broader keywords or use search. -2. read_file on every line that matches a relevant entity. Do not rely on grep/search summaries; they are truncated. -3. Identify specific claims to verify or details that look incomplete, then re-grep or read more. -4. Call report() with line-numbered direct quotes as evidence: 4–8 bullets covering (a) the headline mechanism, (b) named primitives present in the corpus, (c) at least one specific claim quoted verbatim, (d) source file paths and line numbers. State what the corpus confirmed AND what it did not address. ---- -Research subject: **<%= it.subject %>** - -Comparison context: this finding will be compared against **<%= it.counterpart %>** along three axes: <%= it.axes.join(", ") %>. Surface evidence relevant to those axes, but do not draw the comparison yourself — that's a downstream task. diff --git a/examples/compare/prompts/research-web.eta b/examples/compare/prompts/research-web.eta deleted file mode 100644 index 8d3aa7d3..00000000 --- a/examples/compare/prompts/research-web.eta +++ /dev/null @@ -1,17 +0,0 @@ -Apply the **web_research** playbook. - -You are a research assistant gathering authoritative information about **<%= it.subject %>** from the live web. - -You have <%= it.maxTurns %> tool calls. Today's date is <%= it.date %>. - -If a tool returns an error about time limit, KV limit, or word limit, stop and call report() with your findings so far. - -PROCESS: -1. Issue 1–2 broad web_search queries to surface surveys, official docs, and high-signal community discussion. Anchor queries on the current year. -2. fetch_page on the top 2–3 most information-dense links — official documentation, primary-source blog posts, well-cited threads. Do not analyze from snippets alone. -3. Extract concrete technical claims: design decisions, named primitives, quantitative tradeoffs, direct quotes from authoritative sources. -4. Call report() with: a 4–8 bullet summary covering (a) the headline mechanism, (b) named primitives, (c) at least one quantitative claim, (d) source URLs. State what you confirmed AND what the sources did not address. ---- -Research subject: **<%= it.subject %>** - -Comparison context: this finding will be compared against **<%= it.counterpart %>** along three axes: <%= it.axes.join(", ") %>. Surface evidence relevant to those axes, but do not draw the comparison yourself — that's a downstream task. diff --git a/examples/compare/prompts/synthesize.eta b/examples/compare/prompts/synthesize.eta deleted file mode 100644 index d45b7258..00000000 --- a/examples/compare/prompts/synthesize.eta +++ /dev/null @@ -1,27 +0,0 @@ -Apply the **synth** playbook. - -You are writing a final compare-and-contrast report on **<%= it.x %>** vs **<%= it.y %>**. - -Above this message are five prior research turns: -1. Research findings on **<%= it.x %>** -2. Research findings on **<%= it.y %>** -3. Comparison along axis: **<%= it.axes[0] %>** -4. Comparison along axis: **<%= it.axes[1] %>** -5. Comparison along axis: **<%= it.axes[2] %>** - -The three axis comparisons each took a position. Your job is to synthesize those positions into a single coherent thesis about how **<%= it.x %>** and **<%= it.y %>** differ as a whole — and what that pattern of difference implies for a reader choosing between them. - -GROUNDING (overrides everything else): every factual claim must be traceable to the research turns above. Do not introduce entities, quantitative claims, or quoted material the prior turns did not surface. If the research is silent on something material, name the gap. - -STRUCTURE: -1. **Thesis** (one paragraph) — a single position on the overall pattern of difference between **<%= it.x %>** and **<%= it.y %>**. Not a hedge, not a list of findings — a position derived from the three axis comparisons read together. -2. **Per-axis support** (three short sections, one per axis) — each restates the axis-level position and cites the load-bearing evidence in one tight paragraph. Heading should reflect what the axis says about the thesis, not just the axis name. -3. **Resolution** (one paragraph) — when do the differences along these axes flip the practical answer? Name the condition under which **<%= it.x %>** wins versus **<%= it.y %>**. -4. **Limitations** — bullet list of specific gaps in the research that would change the thesis if filled. - -Call report() with the full markdown report. ---- -Subjects: **<%= it.x %>** vs **<%= it.y %>** -Axes: <%= it.axes.join(", ") %> - -Write the report now. diff --git a/examples/compare/tui/AgentCard.tsx b/examples/compare/tui/AgentCard.tsx deleted file mode 100644 index e3ac013b..00000000 --- a/examples/compare/tui/AgentCard.tsx +++ /dev/null @@ -1,180 +0,0 @@ -/** - * One DAG-node card. Three rows above the body: - * - * ╭─ ● · ─╮ - * │ chars · tok · │ ← stats subheading (live) - * │ │ - * │ ... │ - * ╰─────────────────────────────────────────────────╯ - * - * The stats subheading is always present (with em-dashes for pending) and - * updates live during streaming — chars and tokens accumulate, elapsed - * ticks off `state.nowMs - node.startMs`. Done cards keep their tail - * visible (instead of collapsing to "✓ done") so the final output stays - * readable; the dot just flips ●→✓ and the border colors green. - */ - -import React from 'react'; -import { Box, Text } from 'ink'; -import type { NodeRuntime } from './state'; -import { colorForIndex } from './colors'; -import { formatElapsed } from './hooks/useElapsed'; - -export interface AgentCardProps { - node: NodeRuntime; - width: number; - bodyHeight: number; - /** Wall clock in performance.now()-units, propagated from state.nowMs. - * Used to compute elapsed for running cards. */ - nowMs: number; - /** Optional sub-label rendered after the node id (e.g. "web", "corpus"). */ - sourceLabel?: string; -} - -export const AgentCard: React.FC = ({ - node, - width, - bodyHeight, - nowMs, - sourceLabel, -}) => { - const color = node.status === 'pending' - ? 'gray' - : node.status === 'done' - ? 'green' - : colorForIndex(node.colorIndex); - - return ( - - - - - - ); -}; - -/** Render a fixed-width row using NBSPs so Ink's flex layout doesn't - * collapse trailing/leading whitespace. The row goes inside a Text with - * wrap="truncate-end" so width overflow doesn't reflow. */ -const FixedRow: React.FC<{ - width: number; - children: string; - color?: string; - bold?: boolean; - dim?: boolean; -}> = ({ width, children, color, bold, dim }) => { - // Pad to width with NBSP, truncate if strictly longer than width. - let padded: string; - if (children.length > width) { - padded = children.slice(0, Math.max(0, width - 1)) + '…'; - } else { - padded = children + ' '.repeat(width - children.length); - } - // Replace ASCII spaces with NBSP so Ink preserves them. - const protectedRow = padded.replace(/ /g, ' '); - return ( - - - {protectedRow} - - - ); -}; - -const CardHeader: React.FC<{ - node: NodeRuntime; - sourceLabel?: string; - color: string; - width: number; -}> = ({ node, sourceLabel, color, width }) => { - const dot = - node.status === 'done' ? '✓' : - node.status === 'running' ? '●' : '·'; - - const left = sourceLabel - ? `${dot} ${node.id} · ${sourceLabel}` - : `${dot} ${node.id}`; - - const right = node.status === 'running' && node.toolCalls > 0 - ? `●${node.toolCalls}${node.lastTool ? ' ' + truncate(node.lastTool, 12) : ''}` - : ''; - - // Compose: " ". - const inner = width - 2; // 1-col pad on each side - const rightTrimmed = truncate(right, Math.max(0, Math.floor(inner / 2))); - const leftMax = Math.max(0, inner - rightTrimmed.length - 1); - const leftTrimmed = truncate(left, leftMax); - const padCount = Math.max(0, inner - leftTrimmed.length - rightTrimmed.length); - const composed = ` ${leftTrimmed}${' '.repeat(padCount)}${rightTrimmed} `; - - return ( - - {composed} - - ); -}; - -const CardStats: React.FC<{ - node: NodeRuntime; - nowMs: number; - width: number; -}> = ({ node, nowMs, width }) => { - if (node.status === 'pending') { - return {' — chars · — tok · 00:00'}; - } - const elapsedMs = node.startMs === undefined - ? 0 - : (node.endMs ?? nowMs) - node.startMs; - const elapsed = formatElapsed(Math.max(0, elapsedMs)); - return ( - - {` ${node.charsProduced} chars · ${node.tokens} tok · ${elapsed}`} - - ); -}; - -const CardBody: React.FC<{ - node: NodeRuntime; - bodyHeight: number; - width: number; -}> = ({ node, bodyHeight, width }) => { - const lines: string[] = []; - - if (node.status === 'pending') { - while (lines.length < bodyHeight) { - lines.push(' ' + '·'.repeat(width - 2)); - } - } else { - // running and done: render the tail, bottom-aligned. The cursor on the - // last line marks an in-flight stream; done cards drop it. - const tail = node.tail.slice(-bodyHeight); - const padding = Math.max(0, bodyHeight - tail.length); - for (let i = 0; i < padding; i++) lines.push(''); - for (let i = 0; i < tail.length; i++) { - const isLast = i === tail.length - 1; - const txt = ' ' + (tail[i] || ''); - lines.push(isLast && node.status === 'running' ? txt + '▮' : txt); - } - } - - return ( - - {lines.slice(0, bodyHeight).map((line, i) => ( - - {line} - - ))} - - ); -}; - -function truncate(s: string, n: number): string { - if (n <= 0) return ''; - return s.length > n ? s.slice(0, n - 1) + '…' : s; -} diff --git a/examples/compare/tui/App.tsx b/examples/compare/tui/App.tsx deleted file mode 100644 index 3ba7cc1f..00000000 --- a/examples/compare/tui/App.tsx +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Top-level Ink component for the compare TUI. - * - * Layout: - * - * ┌ DAG · X vs Y · 0:32 ──────────────────────┐ - * │ 1840 tok · 18 tools │ - * └───────────────────────────────────────────┘ - * - * ← topology with live cards - * - * ← shown only after the sink reports - */ - -import React, { useEffect, useState } from 'react'; -import { Box, Text } from 'ink'; -import { useEventStream } from './hooks/useEventStream'; -import { useElapsed, formatElapsed, useTerminalSize } from './hooks/useElapsed'; -import { DagCanvas } from './DagCanvas'; -import type { AppState } from './state'; -import type { EventBus } from './event-bus'; -import type { WorkflowEvent } from './events'; - -export interface AppProps { - bus: EventBus; - bootstrap?: WorkflowEvent[]; - /** Subjects for the header. */ - x: string; - y: string; - /** Human source labels per node id (web/corpus/etc.). */ - sourceLabels?: Record; -} - -export const App: React.FC = ({ bus, bootstrap = [], x, y, sourceLabels }) => { - const state = useEventStream(bus, bootstrap); - const [cols] = useTerminalSize(); - - // Wall-clock anchor: snap to Date.now() when topology arrives. We DON'T - // use state.t0Ms directly because the harness emits performance.now() - // values for it (relative to process start, not unix epoch). - const [anchor, setAnchor] = useState(null); - useEffect(() => { - if (state.t0Ms !== null && anchor === null) setAnchor(Date.now()); - }, [state.t0Ms, anchor]); - const active = anchor !== null && state.finalAnswer === null; - const elapsed = useElapsed(anchor ?? Date.now(), active); - - const activeAgents = countActive(state); - - return ( - -
- - {state.fatalError !== null ? ( - - ) : state.finalAnswer !== null ? ( - - ) : null} - - ); -}; - -function countActive(state: AppState): number { - let n = 0; - for (const node of state.nodes.values()) if (node.status === 'running') n++; - return n; -} - -const ErrorPanel: React.FC<{ message: string; stack?: string; cols: number }> = ({ - message, - stack, - cols, -}) => ( - - ✗ fatal error - {message} - {stack && ( - - {stack.split('\n').slice(0, 4).join('\n')} - - )} - -); - -const Header: React.FC<{ - x: string; - y: string; - elapsedMs: number; - tokens: number; - toolCalls: number; - kvCellsUsed: number; - kvNCtx: number; - activeAgents: number; - cols: number; -}> = ({ x, y, elapsedMs, tokens, toolCalls, kvCellsUsed, kvNCtx, activeAgents, cols }) => { - const title = `DAG · ${truncate(x, 32)} vs ${truncate(y, 32)} · ${formatElapsed(elapsedMs)}`; - const pct = kvNCtx > 0 ? Math.round((kvCellsUsed / kvNCtx) * 100) : 0; - const gauge = gaugeBar(pct); - const gaugeC = gaugeColor(pct); - return ( - - {title} - - KV - {gauge} - {String(pct).padStart(2, ' ')}% - · - {tokens} tok - · - {toolCalls} tools - · - {activeAgents} active - - - ); -}; - -function gaugeBar(pct: number, width = 12): string { - const filled = Math.min(width, Math.max(0, Math.round((pct / 100) * width))); - return '█'.repeat(filled) + '░'.repeat(width - filled); -} - -function gaugeColor(pct: number): string { - if (pct >= 90) return 'red'; - if (pct >= 70) return 'yellow'; - return 'green'; -} - -const FinalAnswer: React.FC<{ text: string; cols: number }> = ({ text, cols }) => ( - - ✓ synthesis - {text} - -); - -function truncate(s: string, n: number): string { - return s.length > n ? s.slice(0, n - 1) + '…' : s; -} diff --git a/examples/compare/tui/DagCanvas.tsx b/examples/compare/tui/DagCanvas.tsx deleted file mode 100644 index adf6b316..00000000 --- a/examples/compare/tui/DagCanvas.tsx +++ /dev/null @@ -1,128 +0,0 @@ -/** - * Topology-aware canvas. Lays cards out by topological layer and draws - * orthogonal edges between consecutive layers. - * - * Layout math: - * - cardW = floor((cols - (maxLayerSize + 1)) / maxLayerSize) - * - per-layer card center column = gutter + i * (cardW + gutter) + cardW/2 - * - * For each adjacent layer pair, we render an EdgeRow (3 text lines) with - * the parent and child center columns. Edge endpoints stay aligned with - * card-bottom and card-top centers because cards are flexShrink=0 and - * have explicit widths. - */ - -import React from 'react'; -import { Box, Text } from 'ink'; -import type { AppState, NodeRuntime } from './state'; -import { AgentCard } from './AgentCard'; -import { EdgeRow, type EdgeEndpoint } from './EdgeRow'; - -const GUTTER = 1; -const MIN_CARD_WIDTH = 28; -const MAX_CARD_WIDTH = 56; -const BODY_HEIGHT = 6; - -export interface DagCanvasProps { - state: AppState; - cols: number; - /** Map from node id → human source label (e.g. "web", "corpus"). Optional. */ - sourceLabels?: Record; -} - -export const DagCanvas: React.FC = ({ state, cols, sourceLabels = {} }) => { - if (!state.topology) { - return Waiting for topology…; - } - const { layers } = state.topology; - const maxLayerSize = Math.max(...layers.map((l) => l.length)); - // Reserve a 4-col safety margin so the rightmost card doesn't get clipped - // by Ink's last-column write-then-newline behavior. - const safetyMargin = 4; - const usableCols = Math.max(MIN_CARD_WIDTH * maxLayerSize, cols - safetyMargin); - const cardW = Math.min( - MAX_CARD_WIDTH, - Math.max( - MIN_CARD_WIDTH, - Math.floor((usableCols - GUTTER * (maxLayerSize + 1)) / maxLayerSize), - ), - ); - - // Total canvas width in columns — used for centering layers and for the - // edge router's coordinate space. - const canvasW = (cardW + GUTTER) * maxLayerSize + GUTTER; - - // Compute card center cols per layer. The center of card i in a layer - // of N cards = leftPad + i * (cardW + GUTTER) + cardW/2, where leftPad - // centers the layer if it has fewer cards than the widest layer. - function centersFor(layerIds: string[]): number[] { - const n = layerIds.length; - const usedW = n * cardW + (n - 1) * GUTTER; - const leftPad = Math.floor((canvasW - usedW) / 2); - const out: number[] = []; - for (let i = 0; i < n; i++) { - out.push(leftPad + i * (cardW + GUTTER) + Math.floor(cardW / 2)); - } - return out; - } - - const elements: React.ReactNode[] = []; - for (let li = 0; li < layers.length; li++) { - const layer = layers[li]; - const centers = centersFor(layer); - elements.push(); - - if (li < layers.length - 1) { - const nextLayer = layers[li + 1]; - const nextCenters = centersFor(nextLayer); - const parents: EdgeEndpoint[] = layer.map((id, i) => ({ id, col: centers[i] })); - const children: EdgeEndpoint[] = nextLayer.map((id, i) => ({ id, col: nextCenters[i] })); - const edges = state.topology.edges.filter(([from, to]) => - layer.includes(from) && nextLayer.includes(to), - ); - elements.push( - , - ); - } - } - - return {elements}; -}; - -const LayerRow: React.FC<{ - layer: string[]; - state: AppState; - cardW: number; - canvasW: number; - centers: number[]; - sourceLabels: Record; - nowMs: number; -}> = ({ layer, state, cardW, centers, sourceLabels, nowMs }) => { - // Card centers were already chosen; turn them into per-card left-pads. - // Use empty as spacers so they survive flex - // layout (Text spacers between Box siblings get clipped). - const items: React.ReactNode[] = []; - let cursor = 0; - for (let i = 0; i < layer.length; i++) { - const id = layer[i]; - const node = state.nodes.get(id); - if (!node) continue; - const cardLeft = centers[i] - Math.floor(cardW / 2); - const gap = Math.max(0, cardLeft - cursor); - if (gap > 0) { - items.push(); - } - items.push( - , - ); - cursor = cardLeft + cardW; - } - return {items}; -}; diff --git a/examples/compare/tui/EdgeRow.tsx b/examples/compare/tui/EdgeRow.tsx deleted file mode 100644 index 5290fbe4..00000000 --- a/examples/compare/tui/EdgeRow.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * React wrapper around `routeEdges`. The pure routing logic lives in - * `./edge-router.ts` so smoke tests can exercise it without importing - * Ink (which pulls in yoga-wasm-web's top-level await). - */ - -import React from 'react'; -import { Box, Text } from 'ink'; -import { routeEdges, type EdgeEndpoint } from './edge-router'; - -export type { EdgeEndpoint } from './edge-router'; - -export interface EdgeRowProps { - parents: EdgeEndpoint[]; - children: EdgeEndpoint[]; - edges: [string, string][]; - width: number; -} - -export const EdgeRow: React.FC = ({ parents, children, edges, width }) => { - const { rows } = routeEdges(parents, children, edges, width); - return ( - - {rows.map((row, i) => )} - - ); -}; - -/** Ink's flex layout collapses ASCII spaces in children, which - * destroys column alignment for edge rows. We sidestep that by rendering - * every space (leading or trailing) as U+00A0 NBSP, then setting an - * explicit Box width and wrap="truncate-end" so flex doesn't re-compute. */ -const PaddedRow: React.FC<{ row: string; width: number }> = ({ row, width }) => { - const visible = row.replace(/ /g, ' '); - return ( - - {visible} - - ); -}; diff --git a/examples/compare/tui/__reducer-smoke.ts b/examples/compare/tui/__reducer-smoke.ts deleted file mode 100644 index 43366322..00000000 --- a/examples/compare/tui/__reducer-smoke.ts +++ /dev/null @@ -1,204 +0,0 @@ -/** - * Reducer + EdgeRow smoke test. - * - * No vitest dependency — runs directly under tsx as a script. Asserts that - * the reducer keeps the expected state shape across a representative - * sequence of events, and that the edge router produces the right glyphs - * for the canonical fan-out / fan-in / 1↔1 cases. - * - * npx tsx examples/compare/tui/__reducer-smoke.ts - */ - -import { reduce } from './reducer'; -import { initialState } from './state'; -import type { WorkflowEvent } from './events'; -import { routeEdges, type EdgeEndpoint } from './edge-router'; - -let failed = 0; -function assert(cond: unknown, label: string): void { - if (cond) { - process.stdout.write(` ✓ ${label}\n`); - } else { - process.stdout.write(` ✗ ${label}\n`); - failed++; - } -} - -function eq(actual: T, expected: T, label: string): void { - assert(JSON.stringify(actual) === JSON.stringify(expected), `${label} → ${JSON.stringify(actual)}`); -} - -// ───────────────────────────────────────────────────────────────── -// Reducer -// ───────────────────────────────────────────────────────────────── - -process.stdout.write('reducer\n'); - -const TOPOLOGY: WorkflowEvent = { - type: 'dag:topology', - t0Ms: 1000, - nodes: [ - { id: 'web', dependsOn: [] }, - { id: 'corpus', dependsOn: [] }, - { id: 'cmp_a', dependsOn: ['web', 'corpus'] }, - { id: 'cmp_b', dependsOn: ['web', 'corpus'] }, - { id: 'synth', dependsOn: ['cmp_a', 'cmp_b'] }, - ], -}; - -let s = reduce(initialState, TOPOLOGY); -assert(s.topology !== null, 'topology seeded'); -eq(s.topology!.layers, [['web', 'corpus'], ['cmp_a', 'cmp_b'], ['synth']], 'three topo layers'); -assert(s.nodes.size === 5, 'all 5 nodes present'); -assert([...s.nodes.values()].every((n) => n.status === 'pending'), 'all pending initially'); -eq(s.t0Ms, 1000, 't0Ms set'); - -s = reduce(s, { type: 'dag:node:spawn', id: 'web', agentId: 7, tMs: 1100 }); -assert(s.nodes.get('web')!.status === 'running', 'web running after spawn'); -eq(s.nodes.get('web')!.agentId, 7, 'web agent id captured'); -eq(s.agentToNode.get(7), 'web', 'agentToNode reverse lookup populated'); - -// `tokenCount` on agent:produce is the agent's running cumulative count -// (see packages/agents/src/agent-pool.ts:1002-1008), not a per-event delta. -// The reducer must REPLACE the node's tokens, not sum, and derive -// totalTokens by adding only positive deltas across agents. -s = reduce(s, { type: 'agent:produce', agentId: 7, text: 'searching for', tokenCount: 3 }); -s = reduce(s, { type: 'agent:produce', agentId: 7, text: ' rust ownership', tokenCount: 5 }); -eq(s.nodes.get('web')!.tail, ['searching for rust ownership'], 'tail extends last line'); -eq(s.nodes.get('web')!.tokens, 5, 'tokens take the latest cumulative value'); -eq(s.totalTokens, 5, 'totalTokens sums per-agent deltas'); - -s = reduce(s, { type: 'agent:produce', agentId: 7, text: '\nfetching pages', tokenCount: 7 }); -eq(s.nodes.get('web')!.tail, ['searching for rust ownership', 'fetching pages'], 'newline starts a new tail line'); -eq(s.nodes.get('web')!.tokens, 7, 'tokens advance with the cumulative count'); -eq(s.totalTokens, 7, 'totalTokens accumulates only the delta'); - -s = reduce(s, { - type: 'agent:tool_call', - agentId: 7, - tool: 'web_search', - args: '{"query":"rust ownership memory"}', -}); -const webTail = s.nodes.get('web')!.tail; -assert(webTail[webTail.length - 1].startsWith('→ web_search'), 'tool_call appends arrow chip'); -eq(s.nodes.get('web')!.toolCalls, 1, 'toolCalls increments'); -eq(s.nodes.get('web')!.lastTool, 'web_search', 'lastTool tracked'); -eq(s.totalToolCalls, 1, 'totalToolCalls accumulates'); - -s = reduce(s, { type: 'agent:return', agentId: 7, result: 'Findings on Rust ownership.' }); -assert(s.nodes.get('web')!.status === 'done', 'web flips to done on report'); -eq(s.nodes.get('web')!.reportChars, 'Findings on Rust ownership.'.length, 'reportChars stamped'); -eq(s.finalAnswer, null, 'web is not the sink — finalAnswer stays null'); - -// Spawn the sink directly to verify finalAnswer routing. -const TOPO_2: WorkflowEvent = { - type: 'dag:topology', - t0Ms: 0, - nodes: [{ id: 'a', dependsOn: [] }, { id: 'b', dependsOn: ['a'] }], -}; -let s2 = reduce(initialState, TOPO_2); -s2 = reduce(s2, { type: 'dag:node:spawn', id: 'b', agentId: 99, tMs: 50 }); -s2 = reduce(s2, { type: 'agent:return', agentId: 99, result: 'final.' }); -eq(s2.finalAnswer, 'final.', 'sink report populates finalAnswer'); - -// charsProduced accumulates over agent:produce events. -eq(s.nodes.get('web')!.charsProduced, - 'searching for'.length + ' rust ownership'.length + '\nfetching pages'.length, - 'charsProduced sums ev.text.length'); - -// agent:tick captures KV pressure for the header gauge. -const sTick = reduce(s, { type: 'agent:tick', cellsUsed: 1024, nCtx: 32768 }); -eq(sTick.kvCellsUsed, 1024, 'agent:tick stores cellsUsed'); -eq(sTick.kvNCtx, 32768, 'agent:tick stores nCtx'); - -// Fatal error event — TUI keeps state, just surfaces the error. -let s3 = reduce(s, { - type: 'compare:error', - message: 'pool exploded', - stack: 'Error: pool exploded\n at handleCompare:42', -}); -assert(s3.fatalError !== null, 'compare:error sets fatalError'); -eq(s3.fatalError!.message, 'pool exploded', 'fatalError carries message'); -assert(s3.nodes.size === s.nodes.size, 'compare:error preserves nodes'); -assert(s3.totalTokens === s.totalTokens, 'compare:error preserves running counts'); - -// ───────────────────────────────────────────────────────────────── -// EdgeRow router -// ───────────────────────────────────────────────────────────────── - -process.stdout.write('\nedge router\n'); - -function chars(s: string): string { - // visualize whitespace - return s.replace(/ /g, '·'); -} - -// 1 → 1: three vertical pipes -{ - const parents: EdgeEndpoint[] = [{ id: 'p', col: 5 }]; - const children: EdgeEndpoint[] = [{ id: 'c', col: 5 }]; - const { rows } = routeEdges(parents, children, [['p', 'c']], 12); - process.stdout.write(` 1↔1 row0 [${chars(rows[0])}]\n`); - process.stdout.write(` row1 [${chars(rows[1])}]\n`); - process.stdout.write(` row2 [${chars(rows[2])}]\n`); - assert(rows[0][5] === '│', '1↔1 row0 has │ at col 5'); - // When source=target col, busLeft=busRight=5; rounding logic ends up with - // either ┴ → ╰ or ┬ → ╭ depending on which branch fires first. Either way - // it must be a non-bus character. - const mid = rows[1][5]; - assert(mid !== '─' && mid !== ' ', `1↔1 row1[5] is a corner glyph (got ${mid})`); - assert(rows[2][5] === '│', '1↔1 row2 has │ at col 5'); -} - -// 2 → 3: fan-out (mirrors compare's research → compares) -{ - const parents: EdgeEndpoint[] = [ - { id: 'p1', col: 10 }, - { id: 'p2', col: 30 }, - ]; - const children: EdgeEndpoint[] = [ - { id: 'c1', col: 8 }, - { id: 'c2', col: 20 }, - { id: 'c3', col: 32 }, - ]; - const edges: [string, string][] = [ - ['p1', 'c1'], ['p1', 'c2'], ['p1', 'c3'], - ['p2', 'c1'], ['p2', 'c2'], ['p2', 'c3'], - ]; - const { rows } = routeEdges(parents, children, edges, 50); - process.stdout.write(` 2→3 row0 [${chars(rows[0])}]\n`); - process.stdout.write(` row1 [${chars(rows[1])}]\n`); - process.stdout.write(` row2 [${chars(rows[2])}]\n`); - assert(rows[0][10] === '│' && rows[0][30] === '│', 'fan-out row0 drops at parent cols'); - assert(rows[1][10] === '┴' && rows[1][30] === '┴', 'fan-out row1 has ┴ at parents'); - // leftmost involved col (8) is a child → ╭ ; rightmost (32) is also a child → ╮ - assert(rows[1][8] === '╭', 'leftmost end is rounded child corner ╭'); - assert(rows[1][32] === '╮', 'rightmost end is rounded child corner ╮'); - assert(rows[1][20] === '┬', 'middle child has ┬ tee'); - assert(rows[2][8] === '│' && rows[2][20] === '│' && rows[2][32] === '│', 'row2 drops at child cols'); -} - -// 3 → 1: fan-in -{ - const parents: EdgeEndpoint[] = [ - { id: 'p1', col: 8 }, - { id: 'p2', col: 20 }, - { id: 'p3', col: 32 }, - ]; - const children: EdgeEndpoint[] = [{ id: 'c', col: 20 }]; - const edges: [string, string][] = [['p1', 'c'], ['p2', 'c'], ['p3', 'c']]; - const { rows } = routeEdges(parents, children, edges, 50); - process.stdout.write(` 3→1 row0 [${chars(rows[0])}]\n`); - process.stdout.write(` row1 [${chars(rows[1])}]\n`); - process.stdout.write(` row2 [${chars(rows[2])}]\n`); - assert(rows[0][8] === '│' && rows[0][20] === '│' && rows[0][32] === '│', 'fan-in row0 drops from each parent'); - assert(rows[1][20] === '┼', 'middle col is both source and target → ┼'); - assert(rows[1][8] === '╰' && rows[1][32] === '╯', 'fan-in bus ends rounded'); - assert(rows[2][20] === '│', 'fan-in row2 drops into child'); -} - -if (failed > 0) { - process.stderr.write(`\nFAILED: ${failed} assertion(s)\n`); - process.exit(1); -} -process.stdout.write('\nall smokes passed\n'); diff --git a/examples/compare/tui/__visual-smoke.tsx b/examples/compare/tui/__visual-smoke.tsx deleted file mode 100644 index d23ad37d..00000000 --- a/examples/compare/tui/__visual-smoke.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Visual smoke for the compare TUI. Drives a synthetic event sequence that - * walks through: - * - * t=0 — topology arrives, all nodes pending - * t=200ms — root nodes (web, corpus) spawn - * t=600ms — both roots stream a few tokens + tool calls - * t=1500ms — both roots report; layer 1 (3 compares) spawns - * t=2200ms — compares stream - * t=3200ms — compares report; synth spawns - * t=4200ms — synth streams + reports → finalAnswer panel renders - * - * npx tsx examples/compare/tui/__visual-smoke.tsx - */ - -import { createBus } from './event-bus'; -import type { WorkflowEvent } from './events'; -import { render } from './render'; - -const bus = createBus(); - -const TOPOLOGY: { id: string; dependsOn: string[] }[] = [ - { id: 'research_web_X', dependsOn: [] }, - { id: 'research_corp_Y', dependsOn: [] }, - { id: 'compare_axis_1', dependsOn: ['research_web_X', 'research_corp_Y'] }, - { id: 'compare_axis_2', dependsOn: ['research_web_X', 'research_corp_Y'] }, - { id: 'compare_axis_3', dependsOn: ['research_web_X', 'research_corp_Y'] }, - { id: 'synthesize', dependsOn: ['compare_axis_1', 'compare_axis_2', 'compare_axis_3'] }, -]; - -const sourceLabels = { - research_web_X: 'web', - research_corp_Y: 'corpus', - compare_axis_1: 'axis 1', - compare_axis_2: 'axis 2', - compare_axis_3: 'axis 3', - synthesize: 'sink', -}; - -const instance = render(bus, { - x: "Rust's ownership model", - y: "Swift's automatic reference counting", - sourceLabels, -}); - -let now = 0; -function at(ms: number, ev: WorkflowEvent): void { - setTimeout(() => bus.send(ev), ms); - now = Math.max(now, ms); -} - -at(50, { type: 'dag:topology', t0Ms: 0, nodes: TOPOLOGY }); - -// Periodic KV pressure ticks — drive the header gauge. Real harnesses -// emit these from the agent-pool tick loop. -for (let t = 100; t <= 4500; t += 250) { - const pct = Math.min(0.85, t / 6000); // creeps from 0% toward ~85% - at(t, { type: 'agent:tick', cellsUsed: Math.round(32768 * pct), nCtx: 32768 }); -} - -at(200, { type: 'dag:node:spawn', id: 'research_web_X', agentId: 1, tMs: 200 }); -at(200, { type: 'dag:node:spawn', id: 'research_corp_Y', agentId: 2, tMs: 200 }); - -// Roots stream content. -at(400, { type: 'agent:produce', agentId: 1, text: 'Searching: rust ownership memory model', tokenCount: 8 }); -at(450, { type: 'agent:produce', agentId: 2, text: 'Reading examples/lifetimes.md', tokenCount: 6 }); -at(700, { type: 'agent:tool_call', agentId: 1, tool: 'web_search', args: '{"query":"rust borrow checker"}' }); -at(750, { type: 'agent:tool_call', agentId: 2, tool: 'grep', args: '{"pattern":"Box"}' }); -at(1000, { type: 'agent:tool_result', agentId: 1, tool: 'web_search', result: 'rust-lang.org/borrow.html (8 results)' }); -at(1050, { type: 'agent:tool_result', agentId: 2, tool: 'grep', result: 'examples/lifetimes.md:42: Box heap allocation' }); -at(1200, { type: 'agent:produce', agentId: 1, text: '\nThe borrow checker enforces…', tokenCount: 10 }); -at(1250, { type: 'agent:produce', agentId: 2, text: '\nARC at compile time…', tokenCount: 8 }); - -// Roots report; layer 1 spawns. -at(1500, { type: 'agent:return', agentId: 1, result: 'Web findings on Rust ownership across 3 fetched pages.' }); -at(1550, { type: 'agent:return', agentId: 2, result: 'Corpus findings on Swift ARC from 4 file reads.' }); - -at(1700, { type: 'dag:node:spawn', id: 'compare_axis_1', agentId: 3, tMs: 1700 }); -at(1700, { type: 'dag:node:spawn', id: 'compare_axis_2', agentId: 4, tMs: 1700 }); -at(1700, { type: 'dag:node:spawn', id: 'compare_axis_3', agentId: 5, tMs: 1700 }); - -at(2000, { type: 'agent:produce', agentId: 3, text: 'Both prevent use-after-free…', tokenCount: 6 }); -at(2050, { type: 'agent:produce', agentId: 4, text: 'Rust: zero-cost; ARC: runtime', tokenCount: 7 }); -at(2100, { type: 'agent:produce', agentId: 5, text: 'Rust requires explicit lifetimes', tokenCount: 6 }); - -at(3000, { type: 'agent:return', agentId: 3, result: 'Axis 1 (accuracy): both correct, different costs.' }); -at(3050, { type: 'agent:return', agentId: 4, result: 'Axis 2 (perf): Rust faster cold path.' }); -at(3100, { type: 'agent:return', agentId: 5, result: 'Axis 3 (complexity): Swift simpler day-1.' }); - -at(3300, { type: 'dag:node:spawn', id: 'synthesize', agentId: 6, tMs: 3300 }); -at(3700, { type: 'agent:produce', agentId: 6, text: '# Rust vs Swift: Memory Safety Through Different Trades', tokenCount: 12 }); -at(3900, { type: 'agent:produce', agentId: 6, text: '\nThe two languages converge on safety…', tokenCount: 10 }); -at(4500, { - type: 'agent:return', - agentId: 6, - result: - '# Rust vs Swift: Memory Safety Through Different Trades\n\n' + - 'The two languages converge on memory safety but diverge on cost: ' + - "Rust pushes proof obligations to the developer at compile time, " + - "while Swift's ARC defers them to runtime reference counting.", -}); - -setTimeout(() => { - instance.unmount(); - process.exit(0); -}, now + 1500); diff --git a/examples/compare/tui/colors.ts b/examples/compare/tui/colors.ts deleted file mode 100644 index ec41d96a..00000000 --- a/examples/compare/tui/colors.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Stable color assignment per node index. The DAG canvas paints each - * agent card's border in a node-stable color so the reader can track a - * specific lane visually as it streams. - */ - -export const agentColors = ['cyan', 'yellow', 'green', 'magenta', 'red', 'blue'] as const; - -export function colorForIndex(idx: number): string { - if (!Number.isFinite(idx) || idx < 0) return agentColors[0]; - return agentColors[idx % agentColors.length]; -} diff --git a/examples/compare/tui/edge-router.ts b/examples/compare/tui/edge-router.ts deleted file mode 100644 index a0d896b5..00000000 --- a/examples/compare/tui/edge-router.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Edge router — pure function. Lives in its own file (no Ink/React imports) - * so the smoke tests can call it without dragging yoga-wasm-web into the - * CJS module graph. - */ - -export interface EdgeEndpoint { - id: string; - col: number; -} - -export interface EdgeRouteResult { - rows: [string, string, string]; -} - -export function routeEdges( - parents: EdgeEndpoint[], - children: EdgeEndpoint[], - edges: [string, string][], - width: number, -): EdgeRouteResult { - const parentByCol = new Map(parents.map((p) => [p.id, p.col])); - const childByCol = new Map(children.map((c) => [c.id, c.col])); - - const sourceCols = new Set(); - const targetCols = new Set(); - for (const [from, to] of edges) { - const sc = parentByCol.get(from); - const tc = childByCol.get(to); - if (sc === undefined || tc === undefined) continue; - sourceCols.add(sc); - targetCols.add(tc); - } - - const rows: string[][] = [ - Array.from({ length: width }, () => ' '), - Array.from({ length: width }, () => ' '), - Array.from({ length: width }, () => ' '), - ]; - - if (sourceCols.size === 0 && targetCols.size === 0) { - return { rows: [rows[0].join(''), rows[1].join(''), rows[2].join('')] }; - } - - const involved = [...sourceCols, ...targetCols]; - const busLeft = Math.max(0, Math.min(...involved)); - const busRight = Math.min(width - 1, Math.max(...involved)); - - for (const c of sourceCols) { - if (c >= 0 && c < width) rows[0][c] = '│'; - } - - for (let c = busLeft; c <= busRight; c++) rows[1][c] = '─'; - for (const c of sourceCols) { - if (c < 0 || c >= width) continue; - rows[1][c] = targetCols.has(c) ? '┼' : '┴'; - } - for (const c of targetCols) { - if (c < 0 || c >= width) continue; - if (rows[1][c] === '┼') continue; - rows[1][c] = '┬'; - } - // Round the bus ends. - if (rows[1][busLeft] === '─') rows[1][busLeft] = '╭'; - else if (rows[1][busLeft] === '┴') rows[1][busLeft] = '╰'; - else if (rows[1][busLeft] === '┬') rows[1][busLeft] = '╭'; - if (rows[1][busRight] === '─') rows[1][busRight] = '╮'; - else if (rows[1][busRight] === '┴') rows[1][busRight] = '╯'; - else if (rows[1][busRight] === '┬') rows[1][busRight] = '╮'; - - for (const c of targetCols) { - if (c >= 0 && c < width) rows[2][c] = '│'; - } - - return { - rows: [rows[0].join(''), rows[1].join(''), rows[2].join('')], - }; -} diff --git a/examples/compare/tui/event-bus.ts b/examples/compare/tui/event-bus.ts deleted file mode 100644 index a36b4fe2..00000000 --- a/examples/compare/tui/event-bus.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Minimal replay-to-first-subscriber event bus. - * - * Motivating race: main.ts mounts Ink and immediately dispatches boot-phase - * events (config:loaded, download:start, ...). Ink's useEffect subscribes - * to the event stream in a microtask AFTER the first React commit. An - * unbuffered Signal drops any send that happens in that gap. - * - * This bus buffers while no subscriber exists. The FIRST subscriber - * synchronously receives every queued event, then live events stream as - * they arrive. Later subscribers get only live events — this is a - * replay-to-first-subscriber semantic (like a ReplaySubject that's - * drained on first consumption), not a general BehaviorSubject. - * - * The bus is a plain JS object — no Effection, no React. Callers bridge - * it to their framework of choice. `send` is synchronous, so it's safe - * to call from non-generator callbacks. - */ - -export interface EventBus { - send(event: T): void; - subscribe(handler: (event: T) => void): () => void; -} - -export function createBus(): EventBus { - let buffer: T[] | null = []; - const subscribers = new Set<(event: T) => void>(); - - return { - send(event: T): void { - if (buffer !== null) { - buffer.push(event); - return; - } - for (const handler of subscribers) handler(event); - }, - subscribe(handler: (event: T) => void): () => void { - subscribers.add(handler); - if (buffer !== null) { - const drained = buffer; - buffer = null; - for (const event of drained) handler(event); - } - return () => { - subscribers.delete(handler); - }; - }, - }; -} diff --git a/examples/compare/tui/events.ts b/examples/compare/tui/events.ts deleted file mode 100644 index fc93f25c..00000000 --- a/examples/compare/tui/events.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * Bus event union for the compare TUI. - * - * `DagEvent` is the canonical type the harness emits — defined once in - * `../harness.ts` (the producer). Here we just compose it with the - * runtime's `AgentEvent` to type the bus that the reducer consumes. - */ - -import type { AgentEvent } from '@lloyal-labs/lloyal-agents'; -import type { DagEvent } from '../harness'; - -export type WorkflowEvent = DagEvent | AgentEvent; diff --git a/examples/compare/tui/hooks/useElapsed.ts b/examples/compare/tui/hooks/useElapsed.ts deleted file mode 100644 index b7388194..00000000 --- a/examples/compare/tui/hooks/useElapsed.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Returns elapsed ms since `startedAt`, refreshing every 250ms while active. - * Used by the footer to render a live clock without firing React updates - * for every agent:produce event. - */ - -import { useEffect, useState } from 'react'; -import { useStdout } from 'ink'; - -export function useTerminalSize(): [number, number] { - const { stdout } = useStdout(); - const [size, setSize] = useState<[number, number]>(() => [ - stdout?.columns ?? 120, - stdout?.rows ?? 40, - ]); - - useEffect(() => { - if (!stdout) return; - const onResize = (): void => { - setSize([stdout.columns ?? 120, stdout.rows ?? 40]); - }; - stdout.on('resize', onResize); - return () => { stdout.off('resize', onResize); }; - }, [stdout]); - - return size; -} - -export function useElapsed(startedAt: number, active: boolean): number { - const [now, setNow] = useState(() => Date.now()); - useEffect(() => { - if (!active) return; - const id = setInterval(() => setNow(Date.now()), 250); - return () => clearInterval(id); - }, [active]); - return Math.max(0, now - startedAt); -} - -export function formatElapsed(ms: number): string { - const totalSeconds = Math.floor(ms / 1000); - const mm = Math.floor(totalSeconds / 60); - const ss = totalSeconds % 60; - return `${String(mm).padStart(2, '0')}:${String(ss).padStart(2, '0')}`; -} diff --git a/examples/compare/tui/hooks/useEventStream.ts b/examples/compare/tui/hooks/useEventStream.ts deleted file mode 100644 index b5d8b151..00000000 --- a/examples/compare/tui/hooks/useEventStream.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Bridge an EventBus to a React-rendered AppState. - * - * `bootstrap` seeds initial state synchronously before the first render. - * The EventBus handles the between-render-and-useEffect gap via buffering — - * any `send()` that happens before our useEffect subscribes is replayed - * to us on subscription. - */ - -import { useEffect, useReducer } from 'react'; -import type { WorkflowEvent } from '../events'; -import { initialState, type AppState } from '../state'; -import { reduce } from '../reducer'; -import type { EventBus } from '../event-bus'; - -export function useEventStream( - bus: EventBus, - bootstrap: WorkflowEvent[] = [], -): AppState { - const [state, dispatch] = useReducer(reduce, bootstrap, (events) => - events.reduce(reduce, initialState), - ); - - useEffect(() => { - return bus.subscribe(dispatch); - }, [bus]); - - return state; -} diff --git a/examples/compare/tui/package.json b/examples/compare/tui/package.json deleted file mode 100644 index 3dbc1ca5..00000000 --- a/examples/compare/tui/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/examples/compare/tui/reducer.ts b/examples/compare/tui/reducer.ts deleted file mode 100644 index 6d29c780..00000000 --- a/examples/compare/tui/reducer.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * Pure reducer over compare TUI events. - * - * Topology is seeded once on `dag:topology`. Subsequent events route to a - * specific node either by id (`dag:node:spawn`) or by `agentId → nodeId` - * lookup (all `agent:*` events). - * - * Tail buffer is bounded — the latest line gets appended/extended; once - * we exceed TAIL_MAX_LINES we drop from the front. - */ - -import type { WorkflowEvent } from './events'; -import type { AppState, NodeRuntime, Topology } from './state'; -import { initialState } from './state'; - -const TAIL_MAX_LINES = 6; -/** Hard cap on tail line length so a long tool result chip can't blow out the card. */ -const TAIL_LINE_MAX = 240; - -export function reduce(state: AppState, ev: WorkflowEvent): AppState { - switch (ev.type) { - case 'dag:topology': - return seedTopology(state, ev.nodes, ev.t0Ms); - - case 'dag:node:spawn': { - const node = state.nodes.get(ev.id); - if (!node) return state; - const next = new Map(state.nodes); - next.set(ev.id, { - ...node, - status: 'running', - agentId: ev.agentId, - startMs: ev.tMs, - }); - const agentToNode = new Map(state.agentToNode); - agentToNode.set(ev.agentId, ev.id); - return { ...state, nodes: next, agentToNode, nowMs: ev.tMs }; - } - - case 'agent:produce': { - const nodeId = state.agentToNode.get(ev.agentId); - if (!nodeId) return state; - const node = state.nodes.get(nodeId); - if (!node) return state; - // `tokenCount` is the agent's running total (not a delta) — see - // packages/agents/src/agent-pool.ts:1002-1008. Replace, don't sum. - const newTokens = ev.tokenCount ?? node.tokens; - const delta = Math.max(0, newTokens - node.tokens); - const next = new Map(state.nodes); - next.set(nodeId, { - ...node, - tail: appendTail(node.tail, ev.text), - tokens: newTokens, - charsProduced: node.charsProduced + ev.text.length, - }); - return { - ...state, - nodes: next, - totalTokens: state.totalTokens + delta, - }; - } - - case 'agent:tool_call': { - const nodeId = state.agentToNode.get(ev.agentId); - if (!nodeId) return state; - const node = state.nodes.get(nodeId); - if (!node) return state; - const next = new Map(state.nodes); - const argsPreview = previewArgs(ev.args); - const chip = `→ ${ev.tool}${argsPreview ? ' ' + argsPreview : ''}`; - next.set(nodeId, { - ...node, - toolCalls: node.toolCalls + 1, - lastTool: ev.tool, - // Tool-call chips replace whatever streaming line was in flight — - // they're a clean break in the body. - tail: pushTail(node.tail, chip), - }); - return { - ...state, - nodes: next, - totalToolCalls: state.totalToolCalls + 1, - }; - } - - case 'agent:tool_result': { - const nodeId = state.agentToNode.get(ev.agentId); - if (!nodeId) return state; - const node = state.nodes.get(nodeId); - if (!node) return state; - const preview = ev.result.split('\n')[0]?.slice(0, TAIL_LINE_MAX) ?? ''; - const next = new Map(state.nodes); - next.set(nodeId, { - ...node, - tail: pushTail(node.tail, `← ${preview}`), - }); - return { ...state, nodes: next }; - } - - case 'agent:return': { - const nodeId = state.agentToNode.get(ev.agentId); - if (!nodeId) return state; - const node = state.nodes.get(nodeId); - if (!node) return state; - const next = new Map(state.nodes); - const endMs = state.nowMs > 0 ? state.nowMs : performance.now(); - next.set(nodeId, { - ...node, - status: 'done', - endMs, - reportChars: ev.result.length, - }); - - // If this is the unique sink (no node depends on no-one downstream), - // its result is the final answer. - const isSink = state.topology - ? !state.topology.edges.some(([from]) => from === nodeId) - : false; - const finalAnswer = isSink ? ev.result : state.finalAnswer; - - return { ...state, nodes: next, finalAnswer }; - } - - case 'agent:done': - return state; - - case 'agent:tick': - return { - ...state, - nowMs: performance.now(), - kvCellsUsed: ev.cellsUsed, - kvNCtx: ev.nCtx, - }; - - case 'compare:error': - return { - ...state, - fatalError: { message: ev.message, stack: ev.stack }, - }; - - default: - return state; - } -} - -function seedTopology( - state: AppState, - nodes: { id: string; dependsOn: string[] }[], - t0Ms: number, -): AppState { - const layers = topoLayers(nodes); - const edges: [string, string][] = []; - for (const n of nodes) { - for (const d of n.dependsOn) edges.push([d, n.id]); - } - const topology: Topology = { layers, edges }; - - // Insert nodes in topo (layer-major) order so iterating the Map - // produces a consistent rendering order. - const map = new Map(); - let colorIdx = 0; - for (const layer of layers) { - for (const id of layer) { - const decl = nodes.find((n) => n.id === id)!; - map.set(id, { - id, - dependsOn: decl.dependsOn, - colorIndex: colorIdx++, - status: 'pending', - tail: [], - toolCalls: 0, - tokens: 0, - charsProduced: 0, - }); - } - } - - return { - ...initialState, - t0Ms, - nowMs: t0Ms, - nodes: map, - topology, - }; -} - -/** Topological layering: layer(n) = max(layer(d) for d in deps) + 1. */ -function topoLayers(nodes: { id: string; dependsOn: string[] }[]): string[][] { - const layerOf = new Map(); - const byId = new Map(nodes.map((n) => [n.id, n])); - function computeLayer(id: string, stack: string[]): number { - const cached = layerOf.get(id); - if (cached !== undefined) return cached; - if (stack.includes(id)) { - throw new Error(`compare: cycle detected: ${[...stack, id].join(' -> ')}`); - } - const n = byId.get(id); - if (!n) throw new Error(`compare: unknown node id ${id}`); - const deps = n.dependsOn; - const layer = deps.length === 0 - ? 0 - : Math.max(...deps.map((d) => computeLayer(d, [...stack, id]))) + 1; - layerOf.set(id, layer); - return layer; - } - for (const n of nodes) computeLayer(n.id, []); - const maxLayer = Math.max(...layerOf.values()); - const out: string[][] = Array.from({ length: maxLayer + 1 }, () => []); - // Preserve declaration order within a layer. - for (const n of nodes) out[layerOf.get(n.id)!].push(n.id); - return out; -} - -/** Append text to the tail buffer. Newlines split into separate lines. - * The last existing line absorbs leading text up to the first newline. */ -function appendTail(tail: string[], text: string): string[] { - if (text.length === 0) return tail; - const lines = text.split('\n'); - const next = [...tail]; - if (next.length === 0) { - next.push(''); - } - // Extend the last line with the first chunk. - next[next.length - 1] = (next[next.length - 1] + lines[0]).slice(0, TAIL_LINE_MAX); - for (let i = 1; i < lines.length; i++) { - next.push(lines[i].slice(0, TAIL_LINE_MAX)); - } - while (next.length > TAIL_MAX_LINES) next.shift(); - return next; -} - -/** Push a complete line as its own tail entry (used for tool chips). */ -function pushTail(tail: string[], line: string): string[] { - const next = [...tail, line.slice(0, TAIL_LINE_MAX)]; - while (next.length > TAIL_MAX_LINES) next.shift(); - return next; -} - -function previewArgs(rawArgs: string): string { - try { - const parsed = JSON.parse(rawArgs); - if (typeof parsed === 'string') return JSON.stringify(parsed); - if (parsed && typeof parsed === 'object') { - const first = Object.entries(parsed)[0]; - if (!first) return ''; - const [k, v] = first; - const vs = typeof v === 'string' ? v : JSON.stringify(v); - return `${k}=${truncate(vs, 40)}`; - } - return ''; - } catch { - return truncate(rawArgs, 40); - } -} - -function truncate(s: string, n: number): string { - return s.length > n ? s.slice(0, n - 1) + '…' : s; -} diff --git a/examples/compare/tui/render.ts b/examples/compare/tui/render.ts deleted file mode 100644 index 3622e296..00000000 --- a/examples/compare/tui/render.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Ink mount entry for the compare TUI. - * - * const instance = render(bus, { x, y, sourceLabels }); - * - * The bus MUST be a buffering EventBus (./event-bus.ts) so events sent - * between `render()` returning and React's useEffect firing aren't lost. - * `bootstrap` is an optional list of events replayed through the reducer - * BEFORE the first paint. - */ - -import React from 'react'; -import { render as inkRender, type Instance } from 'ink'; -import { App, type AppProps } from './App'; -import type { EventBus } from './event-bus'; -import type { WorkflowEvent } from './events'; - -export interface RenderOpts { - x: string; - y: string; - sourceLabels?: Record; - bootstrap?: WorkflowEvent[]; -} - -export function render( - bus: EventBus, - opts: RenderOpts, -): Instance { - const props: AppProps = { - bus, - bootstrap: opts.bootstrap, - x: opts.x, - y: opts.y, - sourceLabels: opts.sourceLabels, - }; - return inkRender(React.createElement(App, props)); -} diff --git a/examples/compare/tui/state.ts b/examples/compare/tui/state.ts deleted file mode 100644 index dbcc7aa1..00000000 --- a/examples/compare/tui/state.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * App state shape for the compare DAG TUI. - * - * Topology is fixed at startup (one `dag:topology` event seeds it), then - * each node's runtime fields evolve with the agent event stream. The - * reducer is pure — see reducer.ts. - */ - -export type NodeStatus = 'pending' | 'running' | 'done'; - -export interface NodeRuntime { - id: string; - dependsOn: string[]; - /** Color slot — assigned in topo order so the same node always gets the same color. */ - colorIndex: number; - status: NodeStatus; - agentId?: number; - startMs?: number; - endMs?: number; - /** Streaming buffer — last lines of agent:produce text, used as card body. */ - tail: string[]; - toolCalls: number; - lastTool?: string; - reportChars?: number; - tokens: number; - /** Total characters of streamed text (sum of agent:produce ev.text.length). - * Drives the live "N chars" stat in the card subheading. Persists past - * report; once done, we keep the running count so the user sees the - * same number that was visible during streaming. */ - charsProduced: number; -} - -export interface Topology { - /** Node ids grouped by topological layer (layer 0 = no deps). */ - layers: string[][]; - /** Edge list as [parentId, childId]. */ - edges: [string, string][]; -} - -export interface AppState { - /** Wall-clock ms when `dag:topology` arrived; null until then. */ - t0Ms: number | null; - /** Last update timestamp — used by elapsed display. */ - nowMs: number; - /** All nodes keyed by id. Iteration follows insertion order = topological order. */ - nodes: Map; - /** Reverse lookup for routing agent:* events to their node. */ - agentToNode: Map; - topology: Topology | null; - /** Synthesis result — populated when the unique sink node reports. */ - finalAnswer: string | null; - /** Aggregate counts for the header. */ - totalTokens: number; - totalToolCalls: number; - /** KV pressure from the most recent agent:tick. Drives the header gauge. */ - kvCellsUsed: number; - kvNCtx: number; - /** Fatal error reported by the harness. When non-null, App renders a - * red error panel below the DAG canvas instead of the synthesis. */ - fatalError: { message: string; stack?: string } | null; -} - -export const initialState: AppState = { - t0Ms: null, - nowMs: 0, - nodes: new Map(), - agentToNode: new Map(), - topology: null, - finalAnswer: null, - totalTokens: 0, - totalToolCalls: 0, - kvCellsUsed: 0, - kvNCtx: 0, - fatalError: null, -}; diff --git a/examples/react-agent/harness.ts b/examples/react-agent/harness.ts deleted file mode 100644 index 55ea3181..00000000 --- a/examples/react-agent/harness.ts +++ /dev/null @@ -1,72 +0,0 @@ -import * as path from 'node:path'; -import * as fs from 'node:fs'; -import type { Operation, Channel } from 'effection'; -import { Session } from '@lloyal-labs/sdk'; -import { - Ctx, useAgent, DefaultAgentPolicy, -} from '@lloyal-labs/lloyal-agents'; -import type { Tool } from '@lloyal-labs/lloyal-agents'; -import type { WorkflowEvent } from './tui'; -import { reportTool } from '@lloyal-labs/rig'; - -function loadTask(name: string): { system: string; user: string } { - const raw = fs.readFileSync(path.resolve(__dirname, `tasks/${name}.md`), 'utf8').trim(); - const sep = raw.indexOf('\n---\n'); - if (sep === -1) return { system: raw, user: '' }; - return { system: raw.slice(0, sep).trim(), user: raw.slice(sep + 5).trim() }; -} - -const RESEARCH = loadTask('research'); - -// ── Options ────────────────────────────────────────────────────── - -export interface HarnessOpts { - session: Session; - tools: Tool[]; - events: Channel; - maxTurns: number; - trace: boolean; -} - -// ── Workflow ───────────────────────────────────────────────────── - -export function* handleQuery(query: string, opts: HarnessOpts): Operation { - yield* opts.events.send({ type: 'query', query }); - - const t = performance.now(); - yield* opts.events.send({ type: 'research:start' }); - - const agent = yield* useAgent({ - systemPrompt: RESEARCH.system, - task: query, - tools: [...opts.tools], - terminal: reportTool, - maxTurns: opts.maxTurns, - trace: opts.trace, - policy: new DefaultAgentPolicy({ budget: { context: { softLimit: 2048 } } }), - }); - - const timeMs = performance.now() - t; - yield* opts.events.send({ - type: 'research:done', - agentId: agent.id, - ppl: agent.branch.perplexity, - tokenCount: agent.tokenCount, - toolCallCount: agent.toolCallCount, - timeMs, - }); - - const ctx = yield* Ctx.expect(); - const p = ctx._storeKvPressure(); - - yield* opts.events.send({ - type: 'answer', - text: agent.result ?? '(no findings)', - tokenCount: agent.tokenCount, - toolCalls: agent.toolCallCount, - timeMs, - ctxPct: Math.round(100 * p.cellsUsed / (p.nCtx || 1)), - ctxPos: p.cellsUsed, - ctxTotal: p.nCtx || 1, - }); -} diff --git a/examples/react-agent/main.ts b/examples/react-agent/main.ts deleted file mode 100644 index e2e892cc..00000000 --- a/examples/react-agent/main.ts +++ /dev/null @@ -1,206 +0,0 @@ -#!/usr/bin/env node -/** - * ReAct Agent — CLI entry point - * - * Single agent with corpus tools answers a question using the ReAct pattern. - * - * Usage: - * npx tsx examples/react-agent/main.ts [model-path] --corpus [--query ] [options] - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as readline from "node:readline"; -import { - main, - createSignal, - spawn, - each, - call, - action, -} from "effection"; -import { createContext } from "@lloyal-labs/lloyal.node"; -import type { SessionContext } from "@lloyal-labs/sdk"; -import { initAgents } from "@lloyal-labs/lloyal-agents"; -import { c, log, setJsonlMode, setVerboseMode, fmtSize, createView } from "./tui"; -import type { WorkflowEvent } from "./tui"; -import { loadResources, chunkResources, createReranker, createTools } from "@lloyal-labs/rig"; -import { handleQuery } from "./harness"; -import type { HarnessOpts } from "./harness"; - -// ── CLI args ───────────────────────────────────────────────────── - -const DEFAULT_MODEL = path.resolve( - __dirname, - "../../models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf", -); -const DEFAULT_RERANKER = path.resolve( - __dirname, - "../../models/qwen3-reranker-0.6b-q4_k_m.gguf", -); - -const args = process.argv.slice(2); -const jsonlMode = args.includes("--jsonl"); -const verbose = args.includes("--verbose"); -const trace = args.includes("--trace"); - -function argVal(flag: string): string | null { - const i = args.indexOf(flag); - return i !== -1 ? args[i + 1] : null; -} -const flagIndices = new Set( - ["--reranker", "--corpus", "--query"].flatMap((f) => { - const i = args.indexOf(f); - return i !== -1 ? [i, i + 1] : []; - }), -); - -const rerankModelPath = argVal("--reranker") || DEFAULT_RERANKER; -const corpusDir = argVal("--corpus"); -const initialQuery = argVal("--query"); -const modelPath = - args.find((a, i) => !a.startsWith("--") && !flagIndices.has(i)) || - DEFAULT_MODEL; - -if (!corpusDir) { - process.stdout.write( - `Usage: npx tsx examples/react-agent/main.ts [model-path] --corpus [--query ] [--reranker ]\nMissing: --corpus\n`, - ); - process.exit(1); -} - -if (jsonlMode) setJsonlMode(true); -if (verbose) setVerboseMode(true); -if (!verbose && !jsonlMode && !trace) { - try { - fs.closeSync(2); - fs.openSync(process.platform === "win32" ? "\\\\.\\NUL" : "/dev/null", "w"); - } catch { - /* non-fatal */ - } -} - -const MAX_TOOL_TURNS = 20; - -// ── Main ───────────────────────────────────────────────────────── - -main(function* () { - const resources = loadResources(corpusDir!); - const chunks = chunkResources(resources); - - const modelName = path.basename(modelPath).replace(/-Q\w+\.gguf$/, ""); - const rerankName = path - .basename(rerankModelPath) - .replace(/-q\w+\.gguf$/i, ""); - - log(); - log( - `${c.bold} ReAct Agent${c.reset} ${c.dim}\u2014 Single Agent with Tools${c.reset}`, - ); - log(); - log( - ` ${c.green}\u25cf${c.reset} Loading ${c.bold}${modelName}${c.reset} ${c.dim}(${fmtSize(fs.statSync(modelPath).size)}, KV: Q4_0)${c.reset}`, - ); - - const nCtx = parseInt(process.env.LLAMA_CTX_SIZE || "16384", 10); - const ctx: SessionContext = yield* call(() => - createContext({ - modelPath, - nCtx, - nSeqMax: 16, - typeK: "q4_0", - typeV: "q4_0", - }), - ); - - log( - ` ${c.green}\u25cf${c.reset} Loading ${c.bold}${rerankName}${c.reset} ${c.dim}(${fmtSize(fs.statSync(rerankModelPath).size)}, reranker)${c.reset}`, - ); - - // createReranker is now an Effection resource (RFC §6.1) — it disposes - // its SessionContext + Rerank automatically on scope exit, so no manual - // ensure() is needed. - const reranker = yield* createReranker(rerankModelPath, { nSeqMax: 8, nCtx: 4096 }); - yield* call(() => reranker.tokenizeChunks(chunks)); - - const corpusIsFile = - resources.length === 1 && fs.statSync(corpusDir!).isFile(); - const corpusLabel = corpusIsFile - ? path.basename(corpusDir!) - : `${path.basename(corpusDir!)}/ \u2014 ${resources.length} files`; - log( - ` ${c.dim} Corpus: ${corpusLabel} \u2192 ${chunks.length} chunks${c.reset}`, - ); - - const { toolMap, toolsJson } = createTools({ resources, chunks, reranker }); - const { session, events } = yield* initAgents(ctx); - - const view = createView({ - model: path.basename(modelPath), - reranker: path.basename(rerankModelPath), - chunkCount: chunks.length, - }); - yield* spawn(function* () { - yield* view.subscribe(events); - }); - - const harnessOpts: HarnessOpts = { - session, - toolMap, - toolsJson, - events, - maxTurns: MAX_TOOL_TURNS, - trace, - }; - - // Initial query - if (initialQuery) { - yield* handleQuery(initialQuery, harnessOpts); - if (jsonlMode) return; - } - - // REPL - log( - ` ${c.dim}Enter your question or /quit to exit${c.reset}`, - ); - log(); - - const inputSignal = createSignal(); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - rl.setPrompt(` ${c.dim}>${c.reset} `); - - yield* spawn(function* () { - yield* action((resolve) => { - rl.on("line", (line: string) => inputSignal.send(line.trim())); - rl.on("close", () => { - inputSignal.close(); - resolve(); - }); - return () => rl.close(); - }); - }); - - rl.prompt(); - for (const input of yield* each(inputSignal)) { - if (!input || input === "/quit") break; - try { - yield* handleQuery(input, harnessOpts); - } catch (err) { - log(` ${c.red}Error: ${(err as Error).message}${c.reset}`); - } - yield* each.next(); - try { - rl.prompt(); - } catch { - break; - } - } -}).catch((err: unknown) => { - process.stdout.write( - `Error: ${(err as Error).message}\n${(err as Error).stack}\n`, - ); - process.exit(1); -}); diff --git a/examples/react-agent/tasks/research.md b/examples/react-agent/tasks/research.md deleted file mode 100644 index 0f4e1a04..00000000 --- a/examples/react-agent/tasks/research.md +++ /dev/null @@ -1,14 +0,0 @@ -You are a research assistant analyzing a knowledge base. Your tools: -- **search**: semantic relevance ranking — discover related content by meaning -- **grep**: regex pattern matching — use for precise, exhaustive retrieval -- **read_file**: read specific line ranges — verify and get full context -- **report**: submit your final findings with evidence - -Research process: -1. Start with search to discover relevant content broadly. -2. Use grep with specific patterns to find precise references. -3. Read matching sections with read_file to verify in full context. -4. If gaps remain, search or grep with different terms. -5. When you have sufficient evidence, call report with your findings. Include line numbers and direct quotes as evidence. - -Be thorough but focused. Prioritize accuracy over speed. \ No newline at end of file diff --git a/examples/react-agent/tui.ts b/examples/react-agent/tui.ts deleted file mode 100644 index 371f1c8f..00000000 --- a/examples/react-agent/tui.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * ReAct Agent — TUI composition layer - * - * View layer coupling: Channel is the UI abstraction boundary. - * All runtime state flows through this typed event stream. This module is a - * terminal-specific renderer; a web UI would subscribe to the same channel - * directly. - */ - -import { each } from 'effection'; -import type { Channel, Operation } from 'effection'; -import type { AgentEvent, AgentPoolResult } from '@lloyal-labs/lloyal-agents'; -import type { OpTiming, ViewState, ViewHandler } from '../shared/tui/types'; -import { - c, log, emit, pad, statusClear, -} from '../shared/tui/primitives'; -import { createViewState, agentHandler, label, resetLabels } from '../shared/tui/agent-view'; - -// Re-export shared primitives for main.ts -export { c, log, setJsonlMode, setVerboseMode, fmtSize } from '../shared/tui/primitives'; -export type { OpTiming } from '../shared/tui/types'; - -// ── React-agent step events ────────────────────────────────────── - -export type StepEvent = - | { type: 'query'; query: string } - | { type: 'research:start' } - | { type: 'research:done'; agentId: number; ppl: number; tokenCount: number; toolCallCount: number; timeMs: number } - | { type: 'answer'; text: string; tokenCount: number; toolCalls: number; timeMs: number; ctxPct: number; ctxPos: number; ctxTotal: number }; - -export type WorkflowEvent = AgentEvent | StepEvent; - -// ── Handlers ───────────────────────────────────────────────────── - -function queryHandler(): ViewHandler { - return (ev) => { - if (ev.type !== 'query') return; - log(); - log(` ${c.dim}Query${c.reset}`); - log(` ${c.bold}${ev.query}${c.reset}`); - }; -} - -function researchHandler(state: ViewState): ViewHandler { - return (ev) => { - switch (ev.type) { - case 'research:start': { - log(`\n ${c.green}\u25cf${c.reset} ${c.bold}Research${c.reset} ${c.dim}1 agent${c.reset}`); - resetLabels(state); - break; - } - case 'research:done': { - statusClear(); - const pplStr = Number.isFinite(ev.ppl) ? ` \u00b7 ppl ${ev.ppl.toFixed(2)}` : ''; - log(` ${c.dim}\u2514${c.reset} ${c.yellow}${label(state, ev.agentId)}${c.reset} ${c.green}done${c.reset} ${c.dim}${ev.tokenCount} tok \u00b7 ${ev.toolCallCount} tools${pplStr}${c.reset}`); - log(` ${c.dim}${ev.tokenCount} tok \u00b7 ${ev.toolCallCount} tools \u00b7 ${(ev.timeMs / 1000).toFixed(1)}s${c.reset}`); - break; - } - } - }; -} - -function answerHandler(): ViewHandler { - return (ev) => { - if (ev.type !== 'answer') return; - - log(`\n ${c.dim}${'\u2500'.repeat(58)}${c.reset}\n`); - const prose = ev.text.trim() - .replace(/\*\*(.+?)\*\*/g, `${c.bold}$1${c.reset}`) - .split('\n').map((l: string) => ` ${l}`).join('\n'); - log(prose); - - // Stats - log(`\n ${c.dim}${'\u2501'.repeat(58)}${c.reset}`); - const left = `Research ${pad(ev.tokenCount, 5)} tok ${ev.toolCalls} tools`; - const right = `${pad((ev.timeMs / 1000).toFixed(1), 6)}s`; - log(` ${c.dim}${left}${' '.repeat(Math.max(1, 58 - left.length - right.length))}${right}${c.reset}`); - log(` ${c.dim}${'\u2501'.repeat(58)}${c.reset}`); - const ctxStr = `ctx: ${ev.ctxPct}% (${ev.ctxPos.toLocaleString()}/${ev.ctxTotal.toLocaleString()})`; - log(` ${c.dim}${' '.repeat(58 - ctxStr.length)}${ctxStr}${c.reset}`); - log(); - }; -} - -// ── createView ─────────────────────────────────────────────────── - -export interface ViewOpts { - model: string; - reranker: string; - chunkCount: number; -} - -export function createView(opts: ViewOpts) { - const state = createViewState(); - - const handlers: ViewHandler[] = [ - queryHandler(), - agentHandler(state), - researchHandler(state), - answerHandler(), - ]; - - return { - *subscribe(events: Channel): Operation { - for (const ev of yield* each(events)) { - for (const h of handlers) h(ev); - yield* each.next(); - } - }, - }; -} diff --git a/examples/reflection/harness.ts b/examples/reflection/harness.ts deleted file mode 100644 index 47f6ce42..00000000 --- a/examples/reflection/harness.ts +++ /dev/null @@ -1,202 +0,0 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import { call, ensure } from 'effection'; -import type { Operation, Channel } from 'effection'; -import { Branch, Session, buildUserDelta } from '@lloyal-labs/sdk'; -import type { SessionContext } from '@lloyal-labs/sdk'; -import { - Ctx, useAgent, diverge, DefaultAgentPolicy, -} from '@lloyal-labs/lloyal-agents'; -import type { Tool, Agent, DivergeResult } from '@lloyal-labs/lloyal-agents'; -import type { WorkflowEvent } from './tui'; -import { reportTool } from '@lloyal-labs/rig'; - -function loadTask(name: string): { system: string; user: string } { - const raw = fs.readFileSync(path.resolve(__dirname, `tasks/${name}.md`), 'utf8').trim(); - const sep = raw.indexOf('\n---\n'); - if (sep === -1) return { system: raw, user: '' }; - return { system: raw.slice(0, sep).trim(), user: raw.slice(sep + 5).trim() }; -} - -const RESEARCH = loadTask('research'); -const DRAFT = loadTask('draft'); -const CRITIQUE = loadTask('critique'); -const REVISE = loadTask('revise'); - -// ── Options ────────────────────────────────────────────────────── - -export interface HarnessOpts { - session: Session; - tools: Tool[]; - events: Channel; - maxTurns: number; - critiqueAttempts: number; - trace: boolean; -} - -// ── Phase 1: Research ──────────────────────────────────────────── - -function* research( - query: string, - opts: HarnessOpts, -): Operation<{ findings: string; agent: Agent; timeMs: number }> { - yield* opts.events.send({ type: 'research:start' }); - const t = performance.now(); - - const agent = yield* useAgent({ - systemPrompt: RESEARCH.system, - task: query, - tools: [...opts.tools], - terminal: reportTool, - maxTurns: opts.maxTurns, - trace: opts.trace, - policy: new DefaultAgentPolicy({ budget: { context: { softLimit: 2048 } } }), - }); - - const timeMs = performance.now() - t; - const findings = agent.result ?? '(no findings)'; - yield* opts.events.send({ - type: 'research:done', - agentId: agent.id, - ppl: agent.branch.perplexity, - tokenCount: agent.tokenCount, - toolCallCount: agent.toolCallCount, - timeMs, - }); - return { findings, agent, timeMs }; -} - -// ── Phase 2: Draft ─────────────────────────────────────────────── - -function* draft( - findings: string, - query: string, - opts: HarnessOpts, -): Operation<{ branch: Branch; output: string; tokenCount: number; timeMs: number }> { - const ctx: SessionContext = yield* Ctx.expect(); - const t = performance.now(); - - yield* opts.events.send({ type: 'draft:start' }); - - const branch = Branch.create(ctx, 0, { temperature: 0.6 }); - yield* ensure(() => { if (!branch.disposed) branch.pruneSync(); }); - - const userContent = DRAFT.user - .replace('{{findings}}', findings) - .replace('{{query}}', query); - - const messages = [ - { role: 'system', content: DRAFT.system }, - { role: 'user', content: userContent }, - ]; - const { prompt } = ctx.formatChatSync(JSON.stringify(messages)); - const tokens = ctx.tokenizeSync(prompt, true); - yield* call(() => branch.prefill(tokens)); - - let output = ''; - let tokenCount = 0; - for (;;) { - const { token, text, isStop } = branch.produceSync(); - if (isStop) break; - yield* call(() => branch.commit(token)); - output += text; - tokenCount++; - yield* opts.events.send({ type: 'draft:text', text }); - } - - const timeMs = performance.now() - t; - yield* opts.events.send({ type: 'draft:done', tokenCount, timeMs }); - return { branch, output, tokenCount, timeMs }; -} - -// ── Phase 3: Critique ──────────────────────────────────────────── - -function* critique( - draftBranch: Branch, - opts: HarnessOpts, -): Operation<{ branch: Branch; output: string; tokenCount: number; timeMs: number }> { - const ctx: SessionContext = yield* Ctx.expect(); - const t = performance.now(); - - yield* opts.events.send({ type: 'critique:start', attempts: opts.critiqueAttempts }); - - const critiqueRoot = draftBranch.forkSync(); - yield* ensure(() => { if (!critiqueRoot.disposed) critiqueRoot.pruneSync(); }); - const delta = buildUserDelta(ctx, CRITIQUE.user); - yield* call(() => critiqueRoot.prefill(delta)); - - const result: DivergeResult = yield* diverge({ - parent: critiqueRoot, - attempts: opts.critiqueAttempts, - params: { temperature: 0.7 }, - }); - - const timeMs = performance.now() - t; - yield* opts.events.send({ - type: 'critique:done', - output: result.bestOutput, - attempts: result.attempts.length, - tokenCount: result.totalTokens, - timeMs, - }); - return { branch: result.best, output: result.bestOutput, tokenCount: result.totalTokens, timeMs }; -} - -// ── Phase 4: Revise ────────────────────────────────────────────── - -function* revise( - critiqueBranch: Branch, - opts: HarnessOpts, -): Operation<{ output: string; tokenCount: number; timeMs: number }> { - const ctx: SessionContext = yield* Ctx.expect(); - const t = performance.now(); - - yield* opts.events.send({ type: 'revise:start' }); - - const reviseBranch = critiqueBranch.forkSync(); - yield* ensure(() => { if (!reviseBranch.disposed) reviseBranch.pruneSync(); }); - const delta = buildUserDelta(ctx, REVISE.user); - yield* call(() => reviseBranch.prefill(delta)); - - let output = ''; - let tokenCount = 0; - for (;;) { - const { token, text, isStop } = reviseBranch.produceSync(); - if (isStop) break; - yield* call(() => reviseBranch.commit(token)); - output += text; - tokenCount++; - yield* opts.events.send({ type: 'revise:text', text }); - } - - const timeMs = performance.now() - t; - yield* opts.events.send({ type: 'revise:done', tokenCount, timeMs }); - return { output, tokenCount, timeMs }; -} - -// ── Workflow composition ───────────────────────────────────────── - -export function* handleQuery(query: string, opts: HarnessOpts): Operation { - yield* opts.events.send({ type: 'query', query }); - - const r = yield* research(query, opts); - const d = yield* draft(r.findings, query, opts); - const cr = yield* critique(d.branch, opts); - const v = yield* revise(cr.branch, opts); - - const ctx: SessionContext = yield* Ctx.expect(); - const p = ctx._storeKvPressure(); - - yield* opts.events.send({ - type: 'stats', - timings: [ - { label: 'Research', tokens: r.agent.tokenCount, detail: `${r.agent.toolCallCount} tools`, timeMs: r.timeMs }, - { label: 'Draft', tokens: d.tokenCount, detail: '', timeMs: d.timeMs }, - { label: 'Critique', tokens: cr.tokenCount, detail: `${opts.critiqueAttempts} attempts`, timeMs: cr.timeMs }, - { label: 'Revise', tokens: v.tokenCount, detail: '', timeMs: v.timeMs }, - ], - ctxPct: Math.round(100 * p.cellsUsed / (p.nCtx || 1)), - ctxPos: p.cellsUsed, - ctxTotal: p.nCtx || 1, - }); -} diff --git a/examples/reflection/main.ts b/examples/reflection/main.ts deleted file mode 100644 index 5d85e9d6..00000000 --- a/examples/reflection/main.ts +++ /dev/null @@ -1,209 +0,0 @@ -#!/usr/bin/env node -/** - * Reflection — CLI entry point - * - * Research -> Draft -> Critique -> Revise. The critic forks from the draft's - * live branch. The reviser forks from the critic's branch. No re-prompting. - * - * Usage: - * npx tsx examples/reflection/main.ts [model-path] --corpus [--query ] [options] - */ - -import * as fs from "node:fs"; -import * as path from "node:path"; -import * as readline from "node:readline"; -import { - main, - createSignal, - spawn, - each, - call, - action, -} from "effection"; -import { createContext } from "@lloyal-labs/lloyal.node"; -import type { SessionContext } from "@lloyal-labs/sdk"; -import { initAgents } from "@lloyal-labs/lloyal-agents"; -import { c, log, setJsonlMode, setVerboseMode, fmtSize, createView } from "./tui"; -import type { WorkflowEvent } from "./tui"; -import { loadResources, chunkResources, createReranker, createTools } from "@lloyal-labs/rig"; -import { handleQuery } from "./harness"; -import type { HarnessOpts } from "./harness"; - -// ── CLI args ───────────────────────────────────────────────────── - -const DEFAULT_MODEL = path.resolve( - __dirname, - "../../models/Qwen3-4B-Instruct-2507-Q4_K_M.gguf", -); -const DEFAULT_RERANKER = path.resolve( - __dirname, - "../../models/qwen3-reranker-0.6b-q4_k_m.gguf", -); - -const args = process.argv.slice(2); -const jsonlMode = args.includes("--jsonl"); -const verbose = args.includes("--verbose"); -const trace = args.includes("--trace"); - -function argVal(flag: string): string | null { - const i = args.indexOf(flag); - return i !== -1 ? args[i + 1] : null; -} -const flagIndices = new Set( - ["--reranker", "--corpus", "--query"].flatMap((f) => { - const i = args.indexOf(f); - return i !== -1 ? [i, i + 1] : []; - }), -); - -const rerankModelPath = argVal("--reranker") || DEFAULT_RERANKER; -const corpusDir = argVal("--corpus"); -const initialQuery = argVal("--query"); -const modelPath = - args.find((a, i) => !a.startsWith("--") && !flagIndices.has(i)) || - DEFAULT_MODEL; - -if (!corpusDir) { - process.stdout.write( - `Usage: npx tsx examples/reflection/main.ts [model-path] --corpus [--query ] [--reranker ]\nMissing: --corpus\n`, - ); - process.exit(1); -} - -if (jsonlMode) setJsonlMode(true); -if (verbose) setVerboseMode(true); -if (!verbose && !jsonlMode && !trace) { - try { - fs.closeSync(2); - fs.openSync(process.platform === "win32" ? "\\\\.\\NUL" : "/dev/null", "w"); - } catch { - /* non-fatal */ - } -} - -const MAX_TOOL_TURNS = 20; -const CRITIQUE_ATTEMPTS = 3; - -// ── Main ───────────────────────────────────────────────────────── - -main(function* () { - const resources = loadResources(corpusDir!); - const chunks = chunkResources(resources); - - const modelName = path.basename(modelPath).replace(/-Q\w+\.gguf$/, ""); - const rerankName = path - .basename(rerankModelPath) - .replace(/-q\w+\.gguf$/i, ""); - - log(); - log( - `${c.bold} Reflection${c.reset} ${c.dim}\u2014 Research \u2192 Draft \u2192 Critique \u2192 Revise${c.reset}`, - ); - log(); - log( - ` ${c.green}\u25cf${c.reset} Loading ${c.bold}${modelName}${c.reset} ${c.dim}(${fmtSize(fs.statSync(modelPath).size)}, KV: Q4_0)${c.reset}`, - ); - - const nCtx = parseInt(process.env.LLAMA_CTX_SIZE || "16384", 10); - const ctx: SessionContext = yield* call(() => - createContext({ - modelPath, - nCtx, - nSeqMax: 16, - typeK: "q4_0", - typeV: "q4_0", - }), - ); - - log( - ` ${c.green}\u25cf${c.reset} Loading ${c.bold}${rerankName}${c.reset} ${c.dim}(${fmtSize(fs.statSync(rerankModelPath).size)}, reranker)${c.reset}`, - ); - - // createReranker is now an Effection resource (RFC §6.1) — it disposes - // its SessionContext + Rerank automatically on scope exit, so no manual - // ensure() is needed. - const reranker = yield* createReranker(rerankModelPath, { nSeqMax: 8, nCtx: 4096 }); - yield* call(() => reranker.tokenizeChunks(chunks)); - - const corpusIsFile = - resources.length === 1 && fs.statSync(corpusDir!).isFile(); - const corpusLabel = corpusIsFile - ? path.basename(corpusDir!) - : `${path.basename(corpusDir!)}/ \u2014 ${resources.length} files`; - log( - ` ${c.dim} Corpus: ${corpusLabel} \u2192 ${chunks.length} chunks${c.reset}`, - ); - - const { toolMap, toolsJson } = createTools({ resources, chunks, reranker }); - const { session, events } = yield* initAgents(ctx); - - const view = createView({ - model: path.basename(modelPath), - reranker: path.basename(rerankModelPath), - chunkCount: chunks.length, - }); - yield* spawn(function* () { - yield* view.subscribe(events); - }); - - const harnessOpts: HarnessOpts = { - session, - toolMap, - toolsJson, - events, - maxTurns: MAX_TOOL_TURNS, - critiqueAttempts: CRITIQUE_ATTEMPTS, - trace, - }; - - // Initial query - if (initialQuery) { - yield* handleQuery(initialQuery, harnessOpts); - if (jsonlMode) return; - } - - // REPL - log( - ` ${c.dim}Enter your question or /quit to exit${c.reset}`, - ); - log(); - - const inputSignal = createSignal(); - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }); - rl.setPrompt(` ${c.dim}>${c.reset} `); - - yield* spawn(function* () { - yield* action((resolve) => { - rl.on("line", (line: string) => inputSignal.send(line.trim())); - rl.on("close", () => { - inputSignal.close(); - resolve(); - }); - return () => rl.close(); - }); - }); - - rl.prompt(); - for (const input of yield* each(inputSignal)) { - if (!input || input === "/quit") break; - try { - yield* handleQuery(input, harnessOpts); - } catch (err) { - log(` ${c.red}Error: ${(err as Error).message}${c.reset}`); - } - yield* each.next(); - try { - rl.prompt(); - } catch { - break; - } - } -}).catch((err: unknown) => { - process.stdout.write( - `Error: ${(err as Error).message}\n${(err as Error).stack}\n`, - ); - process.exit(1); -}); diff --git a/examples/reflection/tasks/critique.md b/examples/reflection/tasks/critique.md deleted file mode 100644 index 1a2517c8..00000000 --- a/examples/reflection/tasks/critique.md +++ /dev/null @@ -1,7 +0,0 @@ -Critique the response above. Evaluate: -1. **Accuracy** — Are claims supported by the research findings? -2. **Completeness** — Are important aspects of the question left unaddressed? -3. **Logical coherence** — Does the reasoning flow logically? -4. **Unsupported claims** — Are there assertions without evidence? - -Be specific. Quote the parts you are critiquing. Suggest concrete improvements. \ No newline at end of file diff --git a/examples/reflection/tasks/draft.md b/examples/reflection/tasks/draft.md deleted file mode 100644 index 758001a6..00000000 --- a/examples/reflection/tasks/draft.md +++ /dev/null @@ -1,10 +0,0 @@ -You are a skilled writer who synthesizes research findings into clear, comprehensive responses. ---- -Based on the following research findings, write a comprehensive response to the question. - -Research findings: -{{findings}} - -Question: {{query}} - -Write a well-structured response that directly addresses the question using the evidence above. Include specific details and references where relevant. \ No newline at end of file diff --git a/examples/reflection/tasks/research.md b/examples/reflection/tasks/research.md deleted file mode 100644 index 0f4e1a04..00000000 --- a/examples/reflection/tasks/research.md +++ /dev/null @@ -1,14 +0,0 @@ -You are a research assistant analyzing a knowledge base. Your tools: -- **search**: semantic relevance ranking — discover related content by meaning -- **grep**: regex pattern matching — use for precise, exhaustive retrieval -- **read_file**: read specific line ranges — verify and get full context -- **report**: submit your final findings with evidence - -Research process: -1. Start with search to discover relevant content broadly. -2. Use grep with specific patterns to find precise references. -3. Read matching sections with read_file to verify in full context. -4. If gaps remain, search or grep with different terms. -5. When you have sufficient evidence, call report with your findings. Include line numbers and direct quotes as evidence. - -Be thorough but focused. Prioritize accuracy over speed. \ No newline at end of file diff --git a/examples/reflection/tasks/revise.md b/examples/reflection/tasks/revise.md deleted file mode 100644 index 8351d245..00000000 --- a/examples/reflection/tasks/revise.md +++ /dev/null @@ -1 +0,0 @@ -Revise the response incorporating the valid criticism above. Strengthen weak points, correct inaccuracies, and fill gaps. Keep what was already good. Write the complete revised response. \ No newline at end of file diff --git a/examples/reflection/tui.ts b/examples/reflection/tui.ts deleted file mode 100644 index d68100ce..00000000 --- a/examples/reflection/tui.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Reflection — TUI composition layer - * - * View layer coupling: Channel is the UI abstraction boundary. - * All runtime state flows through this typed event stream. This module is a - * terminal-specific renderer; a web UI would subscribe to the same channel - * directly. - */ - -import { each } from 'effection'; -import type { Channel, Operation } from 'effection'; -import type { AgentEvent } from '@lloyal-labs/lloyal-agents'; -import type { OpTiming, ViewState, ViewHandler } from '../shared/tui/types'; -import { - c, log, statusClear, -} from '../shared/tui/primitives'; -import { createViewState, agentHandler, label, resetLabels } from '../shared/tui/agent-view'; -import { statsHandler } from '../shared/tui/stats-view'; - -// Re-export shared primitives for main.ts -export { c, log, setJsonlMode, setVerboseMode, fmtSize } from '../shared/tui/primitives'; -export type { OpTiming } from '../shared/tui/types'; - -// ── Reflection step events ─────────────────────────────────────── - -export type StepEvent = - | { type: 'query'; query: string } - | { type: 'research:start' } - | { type: 'research:done'; agentId: number; ppl: number; tokenCount: number; toolCallCount: number; timeMs: number } - | { type: 'draft:start' } - | { type: 'draft:text'; text: string } - | { type: 'draft:done'; tokenCount: number; timeMs: number } - | { type: 'critique:start'; attempts: number } - | { type: 'critique:done'; output: string; attempts: number; tokenCount: number; timeMs: number } - | { type: 'revise:start' } - | { type: 'revise:text'; text: string } - | { type: 'revise:done'; tokenCount: number; timeMs: number } - | { type: 'stats'; timings: OpTiming[]; ctxPct: number; ctxPos: number; ctxTotal: number }; - -export type WorkflowEvent = AgentEvent | StepEvent; - -// ── Handlers ───────────────────────────────────────────────────── - -function queryHandler(): ViewHandler { - return (ev) => { - if (ev.type !== 'query') return; - log(); - log(` ${c.dim}Query${c.reset}`); - log(` ${c.bold}${ev.query}${c.reset}`); - }; -} - -function researchHandler(state: ViewState): ViewHandler { - return (ev) => { - switch (ev.type) { - case 'research:start': { - log(`\n ${c.green}\u25cf${c.reset} ${c.bold}Research${c.reset} ${c.dim}1 agent${c.reset}`); - resetLabels(state); - break; - } - case 'research:done': { - statusClear(); - const pplStr = Number.isFinite(ev.ppl) ? ` \u00b7 ppl ${ev.ppl.toFixed(2)}` : ''; - log(` ${c.dim}\u2514${c.reset} ${c.yellow}${label(state, ev.agentId)}${c.reset} ${c.green}done${c.reset} ${c.dim}${ev.tokenCount} tok \u00b7 ${ev.toolCallCount} tools${pplStr}${c.reset}`); - log(` ${c.dim}${ev.tokenCount} tok \u00b7 ${ev.toolCallCount} tools \u00b7 ${(ev.timeMs / 1000).toFixed(1)}s${c.reset}`); - break; - } - } - }; -} - -function draftHandler(): ViewHandler { - let charCount = 0; - return (ev) => { - switch (ev.type) { - case 'draft:start': - log(`\n ${c.green}\u25cf${c.reset} ${c.bold}Draft${c.reset}`); - process.stdout.write(` ${c.dim}`); - charCount = 0; - break; - case 'draft:text': - process.stdout.write(ev.text); - charCount += ev.text.length; - break; - case 'draft:done': - if (charCount > 0) process.stdout.write(`${c.reset}\n`); - log(` ${c.dim}${ev.tokenCount} tok \u00b7 ${(ev.timeMs / 1000).toFixed(1)}s${c.reset}`); - break; - } - }; -} - -function critiqueHandler(): ViewHandler { - return (ev) => { - switch (ev.type) { - case 'critique:start': - log(`\n ${c.green}\u25cf${c.reset} ${c.bold}Critique${c.reset} ${c.dim}${ev.attempts} attempts (perplexity selection)${c.reset}`); - break; - case 'critique:done': { - const cols = process.stdout.columns || 80; - const wrap = cols - 8; - const lines = ev.output.trim().split('\n'); - for (const line of lines.slice(0, 8)) { - const text = line.trim(); - if (!text) continue; - const display = text.length > wrap ? text.slice(0, wrap) + '\u2026' : text; - log(` ${c.dim}${display}${c.reset}`); - } - if (lines.length > 8) log(` ${c.dim}\u2026 ${lines.length - 8} more lines${c.reset}`); - log(` ${c.dim}${ev.tokenCount} tok \u00b7 ${(ev.timeMs / 1000).toFixed(1)}s${c.reset}`); - break; - } - } - }; -} - -function reviseHandler(): ViewHandler { - let charCount = 0; - return (ev) => { - switch (ev.type) { - case 'revise:start': - log(`\n ${c.green}\u25cf${c.reset} ${c.bold}Revise${c.reset}`); - log(`\n ${c.dim}${'\u2500'.repeat(58)}${c.reset}\n`); - process.stdout.write(' '); - charCount = 0; - break; - case 'revise:text': - process.stdout.write(ev.text); - charCount += ev.text.length; - break; - case 'revise:done': - if (charCount > 0) process.stdout.write('\n'); - break; - } - }; -} - -// ── createView ─────────────────────────────────────────────────── - -export interface ViewOpts { - model: string; - reranker: string; - chunkCount: number; -} - -export function createView(opts: ViewOpts) { - const state = createViewState(); - - const handlers: ViewHandler[] = [ - queryHandler(), - agentHandler(state), - researchHandler(state), - draftHandler(), - critiqueHandler(), - reviseHandler(), - statsHandler(), - ]; - - return { - *subscribe(events: Channel): Operation { - for (const ev of yield* each(events)) { - for (const h of handlers) h(ev); - yield* each.next(); - } - }, - }; -} diff --git a/examples/shared/tui-ink/__bus-smoke.ts b/examples/shared/tui-ink/__bus-smoke.ts deleted file mode 100644 index db494237..00000000 --- a/examples/shared/tui-ink/__bus-smoke.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Smoke tests for the replay-to-first-subscriber event bus. - * - * npx tsx examples/shared/tui-ink/__bus-smoke.ts - */ - -import assert from 'node:assert'; -import { createBus } from './event-bus'; - -function check(label: string, fn: () => void) { - try { - fn(); - process.stdout.write(`ok ${label}\n`); - } catch (err) { - process.stdout.write(`FAIL ${label}\n`); - process.stdout.write(` ${(err as Error).message}\n`); - process.exitCode = 1; - } -} - -check('events sent before subscribe are replayed on first subscribe', () => { - const bus = createBus(); - bus.send(1); - bus.send(2); - bus.send(3); - const seen: number[] = []; - bus.subscribe((n) => seen.push(n)); - assert.deepEqual(seen, [1, 2, 3]); -}); - -check('events sent after subscribe go live to the subscriber', () => { - const bus = createBus(); - const seen: number[] = []; - bus.subscribe((n) => seen.push(n)); - bus.send(1); - bus.send(2); - assert.deepEqual(seen, [1, 2]); -}); - -check('buffer + live mix: buffer drains first, then live follows', () => { - const bus = createBus(); - bus.send(1); - bus.send(2); - const seen: number[] = []; - bus.subscribe((n) => seen.push(n)); - bus.send(3); - bus.send(4); - assert.deepEqual(seen, [1, 2, 3, 4]); -}); - -check('second subscriber gets only live events — buffer is consumed once', () => { - const bus = createBus(); - bus.send(1); - bus.send(2); - const a: number[] = []; - const b: number[] = []; - bus.subscribe((n) => a.push(n)); - bus.subscribe((n) => b.push(n)); - bus.send(3); - assert.deepEqual(a, [1, 2, 3]); - assert.deepEqual(b, [3]); -}); - -check('unsubscribe stops delivering', () => { - const bus = createBus(); - const seen: number[] = []; - const unsub = bus.subscribe((n) => seen.push(n)); - bus.send(1); - unsub(); - bus.send(2); - assert.deepEqual(seen, [1]); -}); - -check('last unsubscribe followed by send: event is dropped (bus drained once)', () => { - const bus = createBus(); - const unsub = bus.subscribe(() => {}); - unsub(); - bus.send(42); // no subscribers — the bus already left buffer mode - const late: number[] = []; - bus.subscribe((n) => late.push(n)); - // The 42 is gone — we don't re-buffer after first drain. - assert.deepEqual(late, []); -}); - -process.stdout.write('---\n'); -process.stdout.write(process.exitCode ? 'FAILED\n' : 'all passed\n'); diff --git a/examples/shared/tui-ink/__config-smoke.ts b/examples/shared/tui-ink/__config-smoke.ts deleted file mode 100644 index de130a86..00000000 --- a/examples/shared/tui-ink/__config-smoke.ts +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Config smoke test — verifies load precedence, env-guarded writes, and - * auto-gitignore behavior against a scratch tmpdir. - * - * npx tsx examples/shared/tui-ink/__config-smoke.ts - */ - -import assert from 'node:assert'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { execSync } from 'node:child_process'; -import { loadConfig, saveConfig } from './config'; - -function check(label: string, fn: () => void) { - try { - fn(); - process.stdout.write(`ok ${label}\n`); - } catch (err) { - process.stdout.write(`FAIL ${label}\n`); - process.stdout.write(` ${(err as Error).message}\n`); - process.exitCode = 1; - } -} - -function scratchDir(label: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), `harness-smoke-${label}-`)); - return dir; -} - -check('load: missing file → defaults', () => { - const dir = scratchDir('missing'); - const { config, origin, loadedFromFile } = loadConfig( - path.join(dir, 'harness.json'), - {}, - {}, - ); - assert.equal(loadedFromFile, false); - assert.equal(config.defaults.reasoningMode, 'deep'); - assert.equal(config.sources.tavilyKey, undefined); - assert.equal(origin.tavilyKey, 'unset'); - assert.equal(origin.reasoningMode, 'default'); -}); - -check('load: env var supplies tavilyKey', () => { - const dir = scratchDir('env'); - const { config, origin } = loadConfig( - path.join(dir, 'harness.json'), - {}, - { TAVILY_API_KEY: 'tvly-env' }, - ); - assert.equal(config.sources.tavilyKey, 'tvly-env'); - assert.equal(origin.tavilyKey, 'env'); -}); - -check('load: file supplies tavilyKey when env absent', () => { - const dir = scratchDir('file'); - const file = path.join(dir, 'harness.json'); - fs.writeFileSync( - file, - JSON.stringify({ - version: 1, - sources: { tavilyKey: 'tvly-file' }, - defaults: { reasoningMode: 'flat' }, - }), - ); - const { config, origin } = loadConfig(file, {}, {}); - assert.equal(config.sources.tavilyKey, 'tvly-file'); - assert.equal(origin.tavilyKey, 'file'); - assert.equal(config.defaults.reasoningMode, 'flat'); - assert.equal(origin.reasoningMode, 'file'); -}); - -check('load: precedence CLI > env > file > default', () => { - const dir = scratchDir('prec'); - const file = path.join(dir, 'harness.json'); - fs.writeFileSync( - file, - JSON.stringify({ - version: 1, - sources: { tavilyKey: 'tvly-file' }, - defaults: { reasoningMode: 'flat' }, - }), - ); - const { config, origin } = loadConfig( - file, - { tavilyKey: 'tvly-cli', reasoningMode: 'deep' }, - { TAVILY_API_KEY: 'tvly-env' }, - ); - assert.equal(config.sources.tavilyKey, 'tvly-cli'); - assert.equal(origin.tavilyKey, 'cli'); - assert.equal(config.defaults.reasoningMode, 'deep'); - assert.equal(origin.reasoningMode, 'cli'); -}); - -check('save: creates file, then reload returns same values', () => { - const dir = scratchDir('save'); - const file = path.join(dir, 'harness.json'); - saveConfig( - { sources: { tavilyKey: 'tvly-abc', corpusPath: '/tmp/x' } }, - file, - {}, - ); - assert.equal(fs.existsSync(file), true); - const { config } = loadConfig(file, {}, {}); - assert.equal(config.sources.tavilyKey, 'tvly-abc'); - assert.equal(config.sources.corpusPath, '/tmp/x'); -}); - -check('save: env set → tavilyKey in patch is dropped', () => { - const dir = scratchDir('envguard'); - const file = path.join(dir, 'harness.json'); - const result = saveConfig( - { sources: { tavilyKey: 'tvly-should-be-skipped', corpusPath: '/tmp/y' } }, - file, - { TAVILY_API_KEY: 'tvly-env' }, - ); - assert.deepEqual(result.skipped, ['sources.tavilyKey']); - const raw = JSON.parse(fs.readFileSync(file, 'utf8')); - assert.equal(raw.sources.tavilyKey, undefined); - assert.equal(raw.sources.corpusPath, '/tmp/y'); -}); - -check('save: merges patch with existing file (other fields preserved)', () => { - const dir = scratchDir('merge'); - const file = path.join(dir, 'harness.json'); - saveConfig({ sources: { tavilyKey: 'tvly-a' } }, file, {}); - saveConfig({ sources: { corpusPath: '/tmp/z' } }, file, {}); - const raw = JSON.parse(fs.readFileSync(file, 'utf8')); - assert.equal(raw.sources.tavilyKey, 'tvly-a'); - assert.equal(raw.sources.corpusPath, '/tmp/z'); -}); - -check('save: first save in git repo appends to .gitignore', () => { - const dir = scratchDir('git'); - execSync('git init -q', { cwd: dir }); - const file = path.join(dir, 'harness.json'); - const r = saveConfig({ defaults: { reasoningMode: 'flat' } as never }, file, {}); - assert.equal(r.gitignored, true); - const gi = fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'); - assert.match(gi, /\bharness\.json\b/); -}); - -check('save: second save does not re-append to .gitignore', () => { - const dir = scratchDir('git-noop'); - execSync('git init -q', { cwd: dir }); - const file = path.join(dir, 'harness.json'); - saveConfig({ defaults: { reasoningMode: 'flat' } as never }, file, {}); - const r2 = saveConfig({ sources: { corpusPath: '/a' } }, file, {}); - assert.equal(r2.gitignored, false); - const gi = fs.readFileSync(path.join(dir, '.gitignore'), 'utf8'); - const matches = gi.match(/\bharness\.json\b/g) ?? []; - assert.equal(matches.length, 1); -}); - -check('nCtx precedence: CLI > env > file > default(undefined)', () => { - const dir = scratchDir('nctx'); - const file = path.join(dir, 'harness.json'); - - // No config, no env, no CLI → undefined. - let result = loadConfig(file, {}, {}); - assert.equal(result.config.model.nCtx, undefined); - assert.equal(result.origin.nCtx, 'default'); - - // File supplies → reads file. - fs.writeFileSync( - file, - JSON.stringify({ version: 1, model: { nCtx: 16384 } }), - ); - result = loadConfig(file, {}, {}); - assert.equal(result.config.model.nCtx, 16384); - assert.equal(result.origin.nCtx, 'file'); - - // Env overrides file. - result = loadConfig(file, {}, { LLAMA_CTX_SIZE: '24576' }); - assert.equal(result.config.model.nCtx, 24576); - assert.equal(result.origin.nCtx, 'env'); - - // CLI overrides env. - result = loadConfig( - file, - { nCtx: 65536 }, - { LLAMA_CTX_SIZE: '24576' }, - ); - assert.equal(result.config.model.nCtx, 65536); - assert.equal(result.origin.nCtx, 'cli'); - - // Bogus env silently ignored (no parseInt NaN leaking through). - result = loadConfig(file, {}, { LLAMA_CTX_SIZE: 'not-a-number' }); - assert.equal(result.config.model.nCtx, 16384); // fell back to file - assert.equal(result.origin.nCtx, 'file'); -}); - -check('nCtx save round-trip', () => { - const dir = scratchDir('nctx-save'); - const file = path.join(dir, 'harness.json'); - saveConfig({ model: { nCtx: 65536 } }, file, {}); - const raw = JSON.parse(fs.readFileSync(file, 'utf8')); - assert.equal(raw.model.nCtx, 65536); - const { config } = loadConfig(file, {}, {}); - assert.equal(config.model.nCtx, 65536); -}); - -check('save: empty-string source value deletes the key', () => { - const dir = scratchDir('clear'); - const file = path.join(dir, 'harness.json'); - saveConfig( - { sources: { tavilyKey: 'tvly-a', corpusPath: '/tmp/c' } }, - file, - {}, - ); - let raw = JSON.parse(fs.readFileSync(file, 'utf8')); - assert.equal(raw.sources.tavilyKey, 'tvly-a'); - assert.equal(raw.sources.corpusPath, '/tmp/c'); - - // Clear corpusPath with empty string. - saveConfig({ sources: { corpusPath: '' } }, file, {}); - raw = JSON.parse(fs.readFileSync(file, 'utf8')); - assert.equal(raw.sources.corpusPath, undefined); - assert.equal(raw.sources.tavilyKey, 'tvly-a'); // unrelated key preserved - - // Clear tavilyKey with empty string too. - saveConfig({ sources: { tavilyKey: '' } }, file, {}); - raw = JSON.parse(fs.readFileSync(file, 'utf8')); - assert.equal(raw.sources.tavilyKey, undefined); -}); - -check('save: non-git dir → gitignored=false, no .gitignore written', () => { - const dir = scratchDir('nogit'); - const file = path.join(dir, 'harness.json'); - const r = saveConfig({ sources: { corpusPath: '/b' } }, file, {}); - assert.equal(r.gitignored, false); - assert.equal(fs.existsSync(path.join(dir, '.gitignore')), false); -}); - -process.stdout.write('---\n'); -process.stdout.write(process.exitCode ? 'FAILED\n' : 'all passed\n'); diff --git a/examples/shared/tui-ink/__reducer-smoke.ts b/examples/shared/tui-ink/__reducer-smoke.ts deleted file mode 100644 index a4162347..00000000 --- a/examples/shared/tui-ink/__reducer-smoke.ts +++ /dev/null @@ -1,649 +0,0 @@ -/** - * Reducer smoke test — drives a synthetic event stream through reduce() - * and asserts the per-agent timeline shape. Not part of the runtime path. - * - * npx tsx examples/shared/tui-ink/__reducer-smoke.ts - */ - -import assert from 'node:assert'; -import { reduce } from './reducer'; -import { initialState } from './state'; -import type { WorkflowEvent } from './events'; - -function drive(events: WorkflowEvent[]) { - return events.reduce(reduce, initialState); -} - -function check(label: string, fn: () => void) { - try { - fn(); - process.stdout.write(`ok ${label}\n`); - } catch (err) { - process.stdout.write(`FAIL ${label}\n`); - process.stdout.write(` ${(err as Error).message}\n`); - process.exitCode = 1; - } -} - -check('query → phase=plan', () => { - const s = drive([{ type: 'query', query: 'hi', warm: false }]); - assert.equal(s.phase, 'plan'); - assert.equal(s.query, 'hi'); -}); - -check('plan with research intent → phase stays plan', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 't1' }, { description: 't2' }] as never, - clarifyQuestions: [], - tokenCount: 42, - timeMs: 1200, - }, - ]); - assert.equal(s.phase, 'plan'); - assert.equal(s.plan?.tasks.length, 2); -}); - -check('chain agent:spawn opens a timeline with a live think block', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'first task' }, { description: 'second task' }] as never, - clarifyQuestions: [], - tokenCount: 10, - timeMs: 100, - }, - { type: 'research:start', agentCount: 2, mode: 'deep' }, - { type: 'spine:task', taskIndex: 0, taskCount: 2, description: 'first task' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - ]); - const a = s.agents.get(1)!; - assert.equal(a.taskIndex, 0); - assert.equal(a.taskDescription, 'first task'); - assert.equal(a.timeline.length, 1); - assert.equal(a.timeline[0].kind, 'think'); - assert.equal((a.timeline[0] as { live: boolean }).live, true); - assert.equal(a.currentThinkId, a.timeline[0].id); - assert.deepEqual(s.researchAgentIds, [1]); -}); - -check('flat spawn order assigns taskIndex by spawn count', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [ - { description: 'A' }, - { description: 'B' }, - { description: 'C' }, - ] as never, - clarifyQuestions: [], - tokenCount: 10, - timeMs: 100, - }, - { type: 'research:start', agentCount: 3, mode: 'flat' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:spawn', agentId: 2, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:spawn', agentId: 3, parentAgentId: 0 } as WorkflowEvent, - ]); - assert.deepEqual(s.researchAgentIds, [1, 2, 3]); - assert.deepEqual([ - s.agents.get(1)?.taskIndex, - s.agents.get(2)?.taskIndex, - s.agents.get(3)?.taskIndex, - ], [0, 1, 2]); - assert.deepEqual([ - s.agents.get(1)?.taskDescription, - s.agents.get(2)?.taskDescription, - s.agents.get(3)?.taskDescription, - ], ['A', 'B', 'C']); -}); - -check('produce accumulates into the live think item', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'A' }] as never, - clarifyQuestions: [], - tokenCount: 1, - timeMs: 1, - }, - { type: 'research:start', agentCount: 1, mode: 'flat' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'Hello ', tokenCount: 1 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'world', tokenCount: 2 } as WorkflowEvent, - ]); - const think = s.agents.get(1)!.timeline[0] as { body: string; live: boolean }; - assert.equal(think.body, 'Hello world'); - assert.equal(think.live, true); -}); - -check(' closes the think and transitions agent to content', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'A' }] as never, - clarifyQuestions: [], - tokenCount: 1, - timeMs: 1, - }, - { type: 'research:start', agentCount: 1, mode: 'flat' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'Think header\nmore body', tokenCount: 3 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: '\n\nprose', tokenCount: 4 } as WorkflowEvent, - ]); - const a = s.agents.get(1)!; - const think = a.timeline[0] as { body: string; live: boolean; title: string }; - assert.equal(think.live, false); - assert.equal(think.body, 'Think header\nmore body'); - assert.equal(think.title, 'Think header'); - assert.equal(a.phase, 'content'); - assert.equal(a.currentThinkId, null); -}); - -check('tool_call appends a tool_call item and force-closes live think', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'A' }] as never, - clarifyQuestions: [], - tokenCount: 1, - timeMs: 1, - }, - { type: 'research:start', agentCount: 1, mode: 'flat' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'partial', tokenCount: 3 } as WorkflowEvent, - { - type: 'agent:tool_call', - agentId: 1, - tool: 'web_search', - args: '{"query":"voice latency"}', - } as WorkflowEvent, - ]); - const a = s.agents.get(1)!; - assert.equal(a.timeline.length, 2); - assert.equal(a.timeline[0].kind, 'think'); - assert.equal((a.timeline[0] as { live: boolean }).live, false); - assert.equal(a.timeline[1].kind, 'tool_call'); - assert.equal((a.timeline[1] as { tool: string }).tool, 'web_search'); - assert.equal((a.timeline[1] as { argsSummary: string }).argsSummary, '"voice latency"'); - assert.equal(a.phase, 'tool'); - assert.equal(a.pendingToolCallId, a.timeline[1].id); -}); - -check('tool_result pairs with last tool_call and increments sourceCount', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'A' }] as never, - clarifyQuestions: [], - tokenCount: 1, - timeMs: 1, - }, - { type: 'research:start', agentCount: 1, mode: 'flat' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:tool_call', agentId: 1, tool: 'web_search', args: '{}' } as WorkflowEvent, - { - type: 'agent:tool_result', - agentId: 1, - tool: 'web_search', - result: JSON.stringify([ - { url: 'https://livekit.io/voice', title: 'Voice agent' }, - { url: 'https://telnyx.com/ai', title: 'Telnyx AI' }, - { url: 'https://livekit.io/voice-2', title: 'Voice 2' }, - ]), - } as WorkflowEvent, - ]); - const a = s.agents.get(1)!; - const tr = a.timeline[a.timeline.length - 1] as { - kind: string; - hosts: string[]; - resultCount: number; - callId: number; - }; - assert.equal(tr.kind, 'tool_result'); - assert.deepEqual(tr.hosts.sort(), ['livekit.io', 'telnyx.com']); - assert.equal(tr.resultCount, 3); - assert.equal(tr.callId, a.timeline[1].id); - assert.equal(s.sourceCount, 2); - assert.equal(a.phase, 'idle'); -}); - -check('re-enter thinking after tool_result opens a new think item', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'A' }] as never, - clarifyQuestions: [], - tokenCount: 1, - timeMs: 1, - }, - { type: 'research:start', agentCount: 1, mode: 'flat' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'first', tokenCount: 3 } as WorkflowEvent, - { type: 'agent:tool_call', agentId: 1, tool: 'web_search', args: '{}' } as WorkflowEvent, - { type: 'agent:tool_result', agentId: 1, tool: 'web_search', result: '[]' } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'second', tokenCount: 4 } as WorkflowEvent, - ]); - const a = s.agents.get(1)!; - const thinks = a.timeline.filter((it) => it.kind === 'think'); - assert.equal(thinks.length, 2); - assert.equal((thinks[0] as { live: boolean }).live, false); - assert.equal((thinks[0] as { body: string }).body, 'first'); - assert.equal((thinks[1] as { live: boolean }).live, true); - assert.equal((thinks[1] as { body: string }).body, 'second'); -}); - -check('report item pushed at agent:return', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'A' }] as never, - clarifyQuestions: [], - tokenCount: 1, - timeMs: 1, - }, - { type: 'research:start', agentCount: 1, mode: 'flat' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'done thinking', tokenCount: 3 } as WorkflowEvent, - { type: 'agent:return', agentId: 1, result: 'Final findings paragraph.' } as WorkflowEvent, - ]); - const a = s.agents.get(1)!; - const last = a.timeline[a.timeline.length - 1]; - assert.equal(last.kind, 'report'); - assert.equal((last as { body: string }).body, 'Final findings paragraph.'); - assert.equal(a.phase, 'done'); -}); - -check('synth spawn/produce routes into synth.buffer, not an agent timeline', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'A' }] as never, - clarifyQuestions: [], - tokenCount: 1, - timeMs: 1, - }, - { type: 'research:start', agentCount: 1, mode: 'flat' }, - { type: 'research:done', totalTokens: 100, totalToolCalls: 3, timeMs: 2000 }, - { type: 'synthesize:start' }, - { type: 'agent:spawn', agentId: 7, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:produce', agentId: 7, text: 'The answer is ', tokenCount: 3 } as WorkflowEvent, - { type: 'agent:produce', agentId: 7, text: 'X.', tokenCount: 5 } as WorkflowEvent, - ]); - assert.equal(s.synth.buffer, 'The answer is X.'); - assert.equal(s.agents.get(7)?.timeline.length, 0); - assert.deepEqual(s.researchAgentIds, []); -}); - -check('chain dependencyHint set for taskIndex > 0', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'first' }, { description: 'second' }] as never, - clarifyQuestions: [], - tokenCount: 1, - timeMs: 1, - }, - { type: 'research:start', agentCount: 2, mode: 'deep' }, - { type: 'spine:task', taskIndex: 0, taskCount: 2, description: 'first' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - { type: 'spine:task', taskIndex: 1, taskCount: 2, description: 'second' }, - { type: 'agent:spawn', agentId: 2, parentAgentId: 0 } as WorkflowEvent, - ]); - assert.equal(s.agents.get(1)?.dependencyHint, null); - assert.equal(s.agents.get(2)?.dependencyHint, 'builds on Task 1'); -}); - -check('post- tokens stream into contentBuffer, cleared by tool_call', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'A' }] as never, - clarifyQuestions: [], - tokenCount: 1, - timeMs: 1, - }, - { type: 'research:start', agentCount: 1, mode: 'flat' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'thinking\n\n', tokenCount: 3 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'web_search({"query":"x"})', tokenCount: 4 } as WorkflowEvent, - ]); - const a = s.agents.get(1)!; - assert.equal(a.phase, 'content'); - assert.equal(a.contentBuffer.startsWith('\n\n'), true); - assert.match(a.contentBuffer, /web_search/); - - const s2 = reduce(s, { - type: 'agent:tool_call', - agentId: 1, - tool: 'web_search', - args: '{"query":"x"}', - } as WorkflowEvent); - assert.equal(s2.agents.get(1)?.contentBuffer, ''); - assert.equal(s2.agents.get(1)?.phase, 'tool'); -}); - -check('report path: content streams, then report event clears buffer + pushes structured item', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'A' }] as never, - clarifyQuestions: [], - tokenCount: 1, - timeMs: 1, - }, - { type: 'research:start', agentCount: 1, mode: 'flat' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'decided to report\n\n', tokenCount: 3 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: '\n{"name":"report","arguments":{"result":"The final ', tokenCount: 4 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'answer is X."}}\n', tokenCount: 5 } as WorkflowEvent, - ]); - const mid = s.agents.get(1)!; - assert.ok(mid.contentBuffer.length > 10, 'buffer accumulated'); - assert.match(mid.contentBuffer, /The final/); - - const s2 = reduce(s, { - type: 'agent:return', - agentId: 1, - result: 'The final answer is X.', - } as WorkflowEvent); - const a = s2.agents.get(1)!; - assert.equal(a.contentBuffer, ''); - assert.equal(a.phase, 'done'); - const last = a.timeline[a.timeline.length - 1]; - assert.equal(last.kind, 'report'); - assert.equal((last as { body: string }).body, 'The final answer is X.'); -}); - -check('agent:done sets phase=idle (not done) so recovery produces stream', () => { - const s = drive([ - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'A' }] as never, - clarifyQuestions: [], - tokenCount: 1, - timeMs: 1, - }, - { type: 'research:start', agentCount: 1, mode: 'flat' }, - { type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent, - { type: 'agent:produce', agentId: 1, text: 'unfinished thought', tokenCount: 3 } as WorkflowEvent, - { type: 'agent:done', agentId: 1 } as WorkflowEvent, - // Recovery streams tokens - { type: 'agent:produce', agentId: 1, text: 'recovery output', tokenCount: 5 } as WorkflowEvent, - ]); - const a = s.agents.get(1)!; - // The ORIGINAL think closed on agent:done; recovery opened a NEW think. - const thinks = a.timeline.filter((it) => it.kind === 'think'); - assert.equal(thinks.length, 2); - assert.equal((thinks[0] as { live: boolean; body: string }).live, false); - assert.equal((thinks[0] as { body: string }).body, 'unfinished thought'); - assert.equal((thinks[1] as { live: boolean; body: string }).live, true); - assert.equal((thinks[1] as { body: string }).body, 'recovery output'); - assert.equal(a.phase, 'thinking'); -}); - -check('config:loaded seeds config without forcing a uiPhase transition', () => { - const s = drive([ - { - type: 'config:loaded', - config: { - version: 1, - sources: { tavilyKey: 'tvly-x' }, - defaults: { reasoningMode: 'deep', verifyCount: 3, maxTurns: 10 }, - model: {}, - }, - origin: { - tavilyKey: 'file', - corpusPath: 'unset', - reasoningMode: 'file', - modelPath: 'default', - reranker: 'default', - nCtx: 'default', - }, - path: '/tmp/harness.json', - } as WorkflowEvent, - ]); - assert.equal(s.uiPhase, 'boot'); - assert.equal(s.config?.sources.tavilyKey, 'tvly-x'); - assert.equal(s.configOrigin?.tavilyKey, 'file'); -}); - -check('download:start → uiPhase=downloading + download entry added', () => { - const s = drive([ - { type: 'download:start', id: 'llm', label: 'LLM', sizeBytes: 1000 } as WorkflowEvent, - ]); - assert.equal(s.uiPhase, 'downloading'); - assert.equal(s.downloads.length, 1); - assert.equal(s.downloads[0].id, 'llm'); - assert.equal(s.downloads[0].done, false); -}); - -check('download:progress updates got/total for the matching id', () => { - const s = drive([ - { type: 'download:start', id: 'a', label: 'A', sizeBytes: 100 } as WorkflowEvent, - { type: 'download:start', id: 'b', label: 'B', sizeBytes: 200 } as WorkflowEvent, - { type: 'download:progress', id: 'a', got: 50, total: 100 } as WorkflowEvent, - ]); - const a = s.downloads.find((d) => d.id === 'a')!; - const b = s.downloads.find((d) => d.id === 'b')!; - assert.equal(a.got, 50); - assert.equal(b.got, 0); -}); - -check('download:complete marks entry done', () => { - const s = drive([ - { type: 'download:start', id: 'llm', label: 'LLM', sizeBytes: 100 } as WorkflowEvent, - { type: 'download:complete', id: 'llm' } as WorkflowEvent, - ]); - assert.equal(s.downloads[0].done, true); - // uiPhase stays 'downloading' — main.ts explicitly transitions to 'loading' - assert.equal(s.uiPhase, 'downloading'); -}); - -check('weights:start → uiPhase=loading + loadingLabel set', () => { - const s = drive([ - { type: 'weights:start', label: 'Loading Qwen3.5-4B…' } as WorkflowEvent, - ]); - assert.equal(s.uiPhase, 'loading'); - assert.equal(s.loadingLabel, 'Loading Qwen3.5-4B…'); -}); - -check('weights:label updates the label in place', () => { - const s = drive([ - { type: 'weights:start', label: 'a' } as WorkflowEvent, - { type: 'weights:label', label: 'b' } as WorkflowEvent, - ]); - assert.equal(s.loadingLabel, 'b'); -}); - -check('weights:done clears loadingLabel', () => { - const s = drive([ - { type: 'weights:start', label: 'a' } as WorkflowEvent, - { type: 'weights:done' } as WorkflowEvent, - ]); - assert.equal(s.loadingLabel, null); -}); - -check('plan:start → uiPhase=planning', () => { - const s = drive([ - { type: 'plan:start', query: 'hi', mode: 'deep' } as WorkflowEvent, - ]); - assert.equal(s.uiPhase, 'planning'); - assert.equal(s.query, 'hi'); -}); - -check('ui:plan_review → uiPhase=plan_review', () => { - const s = drive([ - { type: 'plan:start', query: 'hi', mode: 'deep' } as WorkflowEvent, - { type: 'ui:plan_review' } as WorkflowEvent, - ]); - assert.equal(s.uiPhase, 'plan_review'); -}); - -check('research:start → uiPhase=research; complete → uiPhase=done', () => { - const s = drive([ - { type: 'research:start', agentCount: 1, mode: 'deep' }, - { type: 'complete', data: {} }, - ]); - assert.equal(s.uiPhase, 'done'); -}); - -check('ui:composer with prefill sets composerPrefill', () => { - const s = drive([ - { type: 'ui:composer', prefill: 'last query' } as WorkflowEvent, - ]); - assert.equal(s.uiPhase, 'composer'); - assert.equal(s.composerPrefill, 'last query'); -}); - -check('config:updated produces a toast; skipped fields flagged', () => { - const cfg = { - version: 1 as const, - sources: { corpusPath: '/tmp/c' }, - defaults: { reasoningMode: 'deep' as const, verifyCount: 3, maxTurns: 10 }, - model: {}, - }; - const origin = { - tavilyKey: 'env' as const, - corpusPath: 'file' as const, - reasoningMode: 'file' as const, - modelPath: 'default' as const, - reranker: 'default' as const, - }; - const s = drive([ - { - type: 'config:updated', - config: cfg, - origin, - savedTo: '/tmp/harness.json', - gitignored: true, - skipped: [], - } as WorkflowEvent, - ]); - assert.ok(s.toast); - assert.match(s.toast!.message, /added to \.gitignore/); - assert.equal(s.toast!.tone, 'success'); - - const s2 = drive([ - { - type: 'config:updated', - config: cfg, - origin, - savedTo: '/tmp/harness.json', - gitignored: false, - skipped: ['sources.tavilyKey'], - } as WorkflowEvent, - ]); - assert.match(s2.toast!.message, /env active/); - assert.equal(s2.toast!.tone, 'warn'); -}); - -check('mode survives a re-plan round trip (plan:start → query → plan → ui:plan_review)', () => { - // Simulates pressing T in PlanReview: main sends plan:start with the new - // mode, runPlanner emits query then plan, main sends ui:plan_review. The - // query event must preserve mode so PlanReview's useState initializer - // sees the new choice on remount. - const s = drive([ - { type: 'plan:start', query: 'q', mode: 'flat' } as WorkflowEvent, - { type: 'query', query: 'q', warm: false }, - { - type: 'plan', - intent: 'research', - tasks: [{ description: 'A' }] as never, - clarifyQuestions: [], - tokenCount: 10, - timeMs: 100, - }, - { type: 'ui:plan_review' } as WorkflowEvent, - ]); - assert.equal(s.mode, 'flat'); - assert.equal(s.uiPhase, 'plan_review'); -}); - -check('pipeline timer: plan:start starts, plan_review pauses, research:start resumes, complete freezes', () => { - let s = reduce(initialState, { type: 'ui:composer' } as WorkflowEvent); - assert.equal(s.pipelineResumedAt, null); - assert.equal(s.pipelineElapsedMs, 0); - - // Fresh submission from composer — starts timer from zero. - s = reduce(s, { type: 'plan:start', query: 'q', mode: 'deep' } as WorkflowEvent); - assert.notEqual(s.pipelineResumedAt, null); - assert.equal(s.pipelineElapsedMs, 0); - - // Plan review → timer pauses, banking whatever ran. - s = reduce(s, { type: 'ui:plan_review' } as WorkflowEvent); - assert.equal(s.pipelineResumedAt, null); - assert.ok(s.pipelineElapsedMs >= 0); - const pauseSnapshot = s.pipelineElapsedMs; - - // Research accept → timer resumes with accumulator preserved. - s = reduce(s, { type: 'research:start', agentCount: 1, mode: 'deep' }); - assert.notEqual(s.pipelineResumedAt, null); - assert.equal(s.pipelineElapsedMs, pauseSnapshot); - - // Complete → freezes accumulator, clears resume. - s = reduce(s, { type: 'complete', data: {} }); - assert.equal(s.pipelineResumedAt, null); - assert.ok(s.pipelineElapsedMs >= pauseSnapshot); -}); - -check('pipeline timer: re-plan from plan_review keeps accumulator', () => { - let s = reduce(initialState, { type: 'ui:composer' } as WorkflowEvent); - s = reduce(s, { type: 'plan:start', query: 'q', mode: 'deep' } as WorkflowEvent); - s = reduce(s, { type: 'ui:plan_review' } as WorkflowEvent); - const afterFirstPlan = s.pipelineElapsedMs; - // User presses T → main emits plan:start again with new mode. - s = reduce(s, { type: 'plan:start', query: 'q', mode: 'flat' } as WorkflowEvent); - // Still running — accumulator preserved (no reset on re-plan). - assert.notEqual(s.pipelineResumedAt, null); - assert.equal(s.pipelineElapsedMs, afterFirstPlan); -}); - -check('ui:error drops to composer with error toast', () => { - const s = drive([ - { type: 'plan:start', query: 'x', mode: 'deep' } as WorkflowEvent, - { type: 'ui:error', message: 'planner failed' } as WorkflowEvent, - ]); - assert.equal(s.uiPhase, 'composer'); - assert.match(s.toast!.message, /planner failed/); - assert.equal(s.toast!.tone, 'error'); -}); - -check('agent:tick updates pressure', () => { - const s = drive([ - { type: 'agent:tick', cellsUsed: 4000, nCtx: 16384 } as WorkflowEvent, - ]); - assert.equal(s.pressure?.pct, 24); -}); - -process.stdout.write('---\n'); -process.stdout.write(process.exitCode ? 'FAILED\n' : 'all passed\n'); diff --git a/examples/shared/tui-ink/__visual-smoke.tsx b/examples/shared/tui-ink/__visual-smoke.tsx deleted file mode 100644 index f43dd79b..00000000 --- a/examples/shared/tui-ink/__visual-smoke.tsx +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Visual smoke test — drives the full TUI sequence from composer boot - * through plan review, research, and back to composer, using synthetic - * events. Mirrors what main.ts's command loop would emit. - * - * npx tsx examples/shared/tui-ink/__visual-smoke.tsx - */ - -import { main, createSignal, sleep, spawn, call, each } from 'effection'; -import { createBus } from './event-bus'; -import { render } from './render'; -import type { WorkflowEvent } from './events'; -import type { Command } from './commands'; -import type { ConfigOrigin } from './config'; - -main(function* () { - const bus = createBus(); - const commands = createSignal(); - - // Drain commands (a real main.ts would dispatch real work here). - yield* spawn(function* () { - for (const _cmd of yield* each(commands)) { - void _cmd; - yield* each.next(); - } - }); - - const instance = render(bus, (cmd) => commands.send(cmd)); - - const origin: ConfigOrigin = { - tavilyKey: 'file', - corpusPath: 'unset', - reasoningMode: 'default', - modelPath: 'default', - reranker: 'default', - }; - - yield* spawn(function* () { - yield* sleep(100); - - // ── Boot → composer ── - bus.send({ - type: 'config:loaded', - config: { - version: 1, - sources: { tavilyKey: 'tvly-saved-from-disk' }, - defaults: { reasoningMode: 'deep', verifyCount: 3, maxTurns: 10 }, - model: {}, - }, - origin, - path: '/tmp/harness.json', - } as WorkflowEvent); - - yield* sleep(600); - - // ── Submit query ── - bus.send({ - type: 'plan:start', - query: 'How do modern voice agents achieve sub-800ms latency on-device?', - mode: 'deep', - } as WorkflowEvent); - - yield* sleep(400); - - // ── Plan arrives ── - bus.send({ - type: 'plan', - intent: 'research', - tasks: [ - { description: 'Survey STT models and their latency profiles' }, - { description: 'Compare local LLM inference engines' }, - { description: 'Survey TTS models with expressive output' }, - ] as never, - clarifyQuestions: [], - tokenCount: 412, - timeMs: 1450, - }); - bus.send({ type: 'ui:plan_review' } as WorkflowEvent); - - yield* sleep(1200); - - // ── User accepts → research starts ── - bus.send({ type: 'research:start', agentCount: 3, mode: 'flat' }); - bus.send({ type: 'fanout:tasks', tasks: [] as never }); - bus.send({ type: 'agent:spawn', agentId: 1, parentAgentId: 0 } as WorkflowEvent); - bus.send({ type: 'agent:spawn', agentId: 2, parentAgentId: 0 } as WorkflowEvent); - bus.send({ type: 'agent:spawn', agentId: 3, parentAgentId: 0 } as WorkflowEvent); - - // Stream brief content into each column - const streams: [number, string][] = [ - [1, 'STT Research\nSurveying Whisper variants under INT4.'], - [2, 'LLM Engines\nComparing vLLM and faster-whisper.'], - [3, 'TTS Engines\nChecking CosyVoice and StyleTTS2.'], - ]; - for (const [id, text] of streams) { - for (const word of text.split(' ')) { - bus.send({ - type: 'agent:produce', - agentId: id, - text: word + ' ', - tokenCount: 0, - } as WorkflowEvent); - yield* sleep(12); - } - bus.send({ - type: 'agent:produce', - agentId: id, - text: '', - tokenCount: 30, - } as WorkflowEvent); - bus.send({ - type: 'agent:return', - agentId: id, - result: `Findings for agent ${id}: short report.`, - } as WorkflowEvent); - } - - bus.send({ type: 'research:done', totalTokens: 400, totalToolCalls: 0, timeMs: 1800 }); - - // Synth - bus.send({ type: 'synthesize:start' }); - bus.send({ type: 'agent:spawn', agentId: 10, parentAgentId: 0 } as WorkflowEvent); - for (const word of 'Voice agents stream STT, LLM, TTS overlapping for sub-800ms round-trip.'.split(' ')) { - bus.send({ - type: 'agent:produce', - agentId: 10, - text: word + ' ', - tokenCount: 0, - } as WorkflowEvent); - yield* sleep(14); - } - bus.send({ - type: 'synthesize:done', - agentId: 10, - ppl: 2.6, - tokenCount: 60, - toolCallCount: 0, - timeMs: 900, - }); - - bus.send({ type: 'verify:start', count: 3, mode: 'flat' }); - yield* sleep(300); - bus.send({ type: 'verify:done', count: 3, timeMs: 800 }); - bus.send({ - type: 'eval:done', - converged: true, - tokenCount: 18, - sampleCount: 3, - timeMs: 400, - }); - bus.send({ - type: 'stats', - timings: [], - ctxPct: 52, - ctxPos: 8500, - ctxTotal: 16384, - }); - bus.send({ type: 'complete', data: {} }); - - // ── Back to composer for follow-up ── - yield* sleep(800); - bus.send({ type: 'ui:composer' } as WorkflowEvent); - yield* sleep(800); - }); - - yield* sleep(15_000); - instance.unmount(); - yield* call(() => instance.waitUntilExit()); -}); diff --git a/examples/shared/tui-ink/colors.ts b/examples/shared/tui-ink/colors.ts deleted file mode 100644 index e6587196..00000000 --- a/examples/shared/tui-ink/colors.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Stable color assignment per agent label ("A0", "A1", …). - * Components use this to keep an agent's section header, status dot, and - * source chips visually consistent across the TUI. - */ - -export const agentColors = ['cyan', 'yellow', 'green', 'magenta', 'red', 'blue'] as const; - -export function colorForLabel(label: string): string { - const n = Number.parseInt(label.slice(1), 10); - if (!Number.isFinite(n) || n < 0) return agentColors[0]; - return agentColors[n % agentColors.length]; -} - -export function colorForTaskIndex(idx: number | null): string { - if (idx === null) return 'white'; - return agentColors[idx % agentColors.length]; -} diff --git a/examples/shared/tui-ink/commands.ts b/examples/shared/tui-ink/commands.ts deleted file mode 100644 index a9c4b60f..00000000 --- a/examples/shared/tui-ink/commands.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * UI → main.ts command boundary. - * - * The Ink component tree dispatches commands through the `useCommand` - * hook; main.ts drains them from an Effection Signal and runs the - * corresponding Operation (runPlanner, runResearch, saveConfig, ...). - * - * Keep the union small and explicit. No generic "send arbitrary event" - * escape hatch — that's what makes the UI <-> harness boundary auditable. - */ - -export type Command = - | { type: 'submit_query'; query: string; mode: 'flat' | 'deep' } - | { type: 'submit_clarification'; answer: string } - | { type: 'accept_plan' } - | { type: 'cancel_plan' } - | { type: 'edit_plan'; query: string } - | { type: 'change_mode'; mode: 'flat' | 'deep' } - | { type: 'set_tavily_key'; key: string } - | { type: 'set_corpus_path'; path: string } - | { type: 'quit' }; diff --git a/examples/shared/tui-ink/components/Answer.tsx b/examples/shared/tui-ink/components/Answer.tsx deleted file mode 100644 index 57b4d2ea..00000000 --- a/examples/shared/tui-ink/components/Answer.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import { Box, Text } from 'ink'; -import type { AppState } from '../state'; - -export interface AnswerProps { - state: AppState; -} - -/** - * The synth buffer already rendered the answer while streaming. We skip - * re-rendering it here if synth completed successfully with non-empty - * buffer — that's the same policy the ANSI TUI used (answerHandler - * short-circuits when synth streamed). - */ -export function Answer({ state }: AnswerProps): React.ReactElement | null { - if (!state.answer) return null; - if (state.synth.done && state.synth.buffer.trim().length > 0) return null; - return ( - - ─────────────────────────────────────── - - {state.answer.trim()} - - - ); -} diff --git a/examples/shared/tui-ink/components/App.tsx b/examples/shared/tui-ink/components/App.tsx deleted file mode 100644 index 734ba0a9..00000000 --- a/examples/shared/tui-ink/components/App.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { Box } from 'ink'; -import type { WorkflowEvent } from '../events'; -import { useEventStream } from '../hooks/useEventStream'; -import { CommandContext, type CommandDispatch } from '../hooks/useCommand'; -import type { EventBus } from '../event-bus'; -import { Header } from './Header'; -import { Narrative } from './Narrative'; -import { Synth } from './Synth'; -import { Verify } from './Verify'; -import { Eval } from './Eval'; -import { Answer } from './Answer'; -import { Footer } from './Footer'; -import { Composer } from './Composer'; -import { PlanReview } from './PlanReview'; -import { PlanningSpinner } from './PlanningSpinner'; -import { ClarifyPanel } from './ClarifyPanel'; -import { BootStatus } from './BootStatus'; - -export interface AppProps { - bus: EventBus; - dispatch: CommandDispatch; - /** Pre-render events — applied through the reducer before the first - * paint so the tree never renders with stale state. The bus buffers - * sends that happen before useEffect subscribes, so late events don't - * need bootstrapping. */ - bootstrap?: WorkflowEvent[]; -} - -export function App({ bus, dispatch, bootstrap }: AppProps): React.ReactElement { - const state = useEventStream(bus, bootstrap); - const showHeader = - state.uiPhase !== 'composer' && - state.uiPhase !== 'boot' && - state.uiPhase !== 'downloading' && - state.uiPhase !== 'loading' && - state.uiPhase !== 'planning' && - state.uiPhase !== 'plan_review' && - state.uiPhase !== 'clarifying'; // components below render their own header - - const showResults = state.uiPhase === 'research' || state.uiPhase === 'done'; - const showComposer = - state.uiPhase === 'composer' || - state.uiPhase === 'done' || - state.uiPhase === 'clarifying'; - - return ( - - - {showHeader &&
} - {(state.uiPhase === 'downloading' || state.uiPhase === 'loading') && ( - - )} - {state.uiPhase === 'planning' && } - {state.uiPhase === 'plan_review' && } - {state.uiPhase === 'clarifying' && } - {showResults && } - {showResults && } - {state.uiPhase === 'done' && } - {state.uiPhase === 'done' && } - {state.uiPhase === 'done' && } - {showComposer && } -