diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e6fd601..2a6605f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,8 @@ jobs: unit-tests: runs-on: ubuntu-latest + # A hung fixture is a red job, never a day-long run. + timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -51,7 +53,36 @@ 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" + # 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 + + - run: npm install + - run: npm run verify:oci gpu-tests: name: GPU Integration Tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40e81f39..91299de8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,10 +10,16 @@ on: description: 'Skip npm publish (for testing)' type: boolean default: true + runner: + description: 'Runner label. self-hosted when hosted minutes are unavailable.' + type: string + default: 'ubuntu-latest' jobs: build: - runs-on: ubuntu-latest + # `inputs` is null on the tag-push trigger, so the fallback is what keeps a + # release tag on hosted runners. An empty label queues forever, silently. + runs-on: ${{ inputs.runner || 'ubuntu-latest' }} permissions: contents: read id-token: write @@ -30,6 +36,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 +73,21 @@ 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 + + # Publishing authenticates through trusted publishing, and the OIDC token + # is minted by the Actions service for the job — including on a + # self-hosted runner. This runs unconditionally so a skip_publish + # rehearsal proves the plumbing before a real cut depends on it. The + # request URL only, never the token. + - name: OIDC available? + run: | + echo "runner environment: ${RUNNER_ENVIRONMENT:-}" + if [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then + echo "OIDC token endpoint present - trusted publishing can authenticate." + else + echo "::warning::No OIDC token in this job, so trusted publishing cannot authenticate." + fi - name: Publish packages if: success() && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && !inputs.skip_publish)) @@ -73,15 +97,44 @@ 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 + # A manual dispatch may only ever ship a prerelease. The tag falls + # through to `latest` for any version without a suffix, so one + # hand-edited manifest would otherwise move production. Releases + # go out through a v* tag, which is not affected by this. + if [ "$tag" = "latest" ] && [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "::error::$name@$version would publish to latest. A manual dispatch may only ship a prerelease; production goes through a v* tag." + exit 1 + fi + # npm verifies the sigstore bundle's runner claim and refuses + # anything but a hosted one: "Only github-hosted runners are + # supported when publishing with provenance" (422). Measured, on + # run 34142877384. Ask the environment rather than take a flag, + # so a hosted run keeps attestation with nothing to remember and + # a local one drops only what it cannot have. + echo "Publishing $name@$version (tag: $tag)..." + if [ "${RUNNER_ENVIRONMENT:-}" = "github-hosted" ]; then + npm publish --workspace "$pkg" --access public --provenance --tag "$tag" + else + echo "::warning::$name@$version published WITHOUT provenance (runner is ${RUNNER_ENVIRONMENT:-unknown})" + npm publish --workspace "$pkg" --access public --tag "$tag" + fi fi done - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 4d364cbb..c45e789a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist/ docs/_internal tmp/ docs/ +!packages/agents/docs/ trace-*.jsonl harness.json @@ -19,3 +20,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/README.md b/README.md index 6bb991ea..7afdc77f 100644 --- a/README.md +++ b/README.md @@ -211,10 +211,10 @@ The honest comparison is full stack against full stack. Each row of the right co ```typescript // Agent runtime import { - initAgents, useAgent, agent, agentPool, useAgentPool, diverge, + initAgents, useAgent, agent, agentPool, useAgentPool, parallel, chain, fanout, dag, reduce, withSpine, Tool, Source, DefaultAgentPolicy, - Ctx, Store, Events, AppRegistryCtx, AppConfigStoreCtx, GrantStoreCtx, RerankerCtx, + Ctx, Store, Events, AbilityRegistryCtx, AbilityConfigStoreCtx, GrantStoreCtx, RerankerCtx, } from "@lloyal-labs/lloyal-agents"; // Ability protocol + framework tools @@ -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 && } -