From ddf723ab9dda36784fe02105317da8b401c902d7 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 2 Aug 2026 23:06:53 -0600 Subject: [PATCH] feat(examples): four runnable graph topologies under examples/graphs with offline ledger proofs Each topology (peer review loop, best-of-N, watchdog steer, VB-shaped shot loop) is a <=25-LOC plain-data AgentGraph run through runGraph over the same offline scriptedBrain/leafSeam seams the kernel graph tests use; main() prints the edge ledger as the proof artifact, and tests/examples/graph-topologies.test.ts pins the decisive ledger facts (counts, outcomes, destinations) for all four. --- examples/README.md | 1 + examples/graphs/README.md | 30 ++++ examples/graphs/best-of-n.ts | 85 ++++++++++ examples/graphs/collaborates-review-loop.ts | 145 ++++++++++++++++ examples/graphs/shared.ts | 174 ++++++++++++++++++++ examples/graphs/shot-loop.ts | 110 +++++++++++++ examples/graphs/watchdog-steer.ts | 122 ++++++++++++++ tests/examples/graph-topologies.test.ts | 109 ++++++++++++ 8 files changed, 776 insertions(+) create mode 100644 examples/graphs/README.md create mode 100644 examples/graphs/best-of-n.ts create mode 100644 examples/graphs/collaborates-review-loop.ts create mode 100644 examples/graphs/shared.ts create mode 100644 examples/graphs/shot-loop.ts create mode 100644 examples/graphs/watchdog-steer.ts create mode 100644 tests/examples/graph-topologies.test.ts diff --git a/examples/README.md b/examples/README.md index 21b532ab..a65bdc09 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,6 +47,7 @@ TANGLE_API_KEY=... pnpm tsx examples/supervise/supervise.ts # 3. one function | 5 | [`supervise/`](./supervise/) | The one-call headline: `supervise(profile, goal)` runs a full supervisor with everything defaulted. Needs `TANGLE_API_KEY`. | | 6 | [`supervisor-loop/`](./supervisor-loop/) | The same supervisor over a real worker backend — cloud sandbox, local coding-CLI, or an MCP server — with the backend as the only knob you change. | | 7 | [`delegate/`](./delegate/) | `delegate(intent)`: the supervisor writes and spawns a worker that does real work on disk, and the run only settles once the file it was asked to create actually exists. Needs `TANGLE_API_KEY`. | +| 7b | [`graphs/`](./graphs/) | **Agent graphs**: four topologies (peer review loop, best-of-N, watchdog steer, shot loop) each authored as ≤25 lines of plain data and run through `runGraph`, printing the edge ledger — every traversal, delivered or not — as the proof. Offline. | ## Benchmarking — score agents against a check diff --git a/examples/graphs/README.md b/examples/graphs/README.md new file mode 100644 index 00000000..c1e5ab5d --- /dev/null +++ b/examples/graphs/README.md @@ -0,0 +1,30 @@ +# graphs — agent topologies as plain data + +Four runnable topologies for `runGraph` (the agent-graph layer over `supervise()`). +Each file's graph is a ≤25-line data literal — nodes are canonical `AgentProfile`s, edges are typed values carrying versioned registry directives — and each `main()` prints the EDGE LEDGER as the proof artifact: every traversal, its outcome (`delivered | stripped | empty | unpropagated`), its byte count, and the concrete worker it reached. + +All four run offline at $0 (scripted driver brain + in-process leaf workers, in [`shared.ts`](./shared.ts) — the same seams the kernel's own graph tests use). + +```bash +pnpm tsx examples/graphs/collaborates-review-loop.ts +pnpm tsx examples/graphs/best-of-n.ts +pnpm tsx examples/graphs/watchdog-steer.ts +pnpm tsx examples/graphs/shot-loop.ts +``` + +| Example | Topology | What the ledger proves | +|---|---|---| +| [`collaborates-review-loop.ts`](./collaborates-review-loop.ts) | root + implementer + reviewer; `analyzes` critique → reviewer, `analyzes` verdict → driver | Peer collaboration is MEDIATED: findings cross worker→worker only as a ledgered lens route (a direct worker-to-worker channel is not a first-class edge), and a route with no live target is `unpropagated`, never dropped. | +| [`best-of-n.ts`](./best-of-n.ts) | root + two candidate coder nodes, one `delegates` edge each, `maxLiveWorkers: 2` | Breadth is two edges in the data: exactly two delivered spawn traversals, winner decided by the deliverable. | +| [`watchdog-steer.ts`](./watchdog-steer.ts) | root + one builder with a live trace; shipped online detector panel (`watchTrace`) | Mid-run intervention: the detector fires while the worker runs, and the corrective steer lands as the delegates edge's second delivered traversal BEFORE settle. | +| [`shot-loop.ts`](./shot-loop.ts) | reviewer(root) ↔ coder; `delegates maxTraversals: 3`, `analyzes` verify → reviewer | The multishot loop as data: each shot and each verify report is one ledgered traversal, the shot budget lives on the edge, and the deliverable gates on the verdict. | + +The offline proof for all four (exact ledger counts, outcomes, destinations) lives in `tests/examples/graph-topologies.test.ts`. + +## Two ledger semantics worth knowing + +- A mid-run steer increments its delegates edge's traversal count but is only CAP-CHECKED at + spawn time — each steer consumes future spawn budget on that edge, so `maxTraversals: 3` + means "3 shots" only on a steer-free edge. +- `workerId` on a ledger row is the DESTINATION for delegates/steer/routed-analyzes rows, but + the SOURCE worker for driver-destined finding rows. diff --git a/examples/graphs/best-of-n.ts b/examples/graphs/best-of-n.ts new file mode 100644 index 00000000..0559b31c --- /dev/null +++ b/examples/graphs/best-of-n.ts @@ -0,0 +1,85 @@ +/** + * best-of-n — breadth as a topology: one delegates edge per candidate node. + * + * Two coder nodes with distinct ids and distinct profiles hang off one root. The driver spawns + * BOTH in a single turn (`maxLiveWorkers: 2` admits them concurrently), awaits both settles, and + * the run keeps the winner — the candidate whose settle passed the deliverable. The edge ledger + * shows exactly two delivered spawn traversals, one per candidate edge: breadth is two edges in + * the data, not a fan-out helper in code. + * + * Fully offline (scripted brain + leaf seam). Run: pnpm tsx examples/graphs/best-of-n.ts + */ + +import type { AgentProfile } from '@tangle-network/agent-interface' +import { + type AgentGraph, + promptHandle, + type RunGraphOptions, + runGraph, +} from '@tangle-network/agent-runtime/kernel' +import { leafSeam, printLedger, scriptedBrain } from './shared' + +const brief = promptHandle('delegates/worker-brief/v1') + +export function bestOfN(): { graph: AgentGraph; opts: RunGraphOptions } { + // ── The topology: plain data ── + const graph: AgentGraph = { + nodes: [ + { id: 'lead', profile: { name: 'lead', prompt: { systemPrompt: 'Keep the best.' } } }, + { id: 'coder-a', profile: { name: 'coder-a', prompt: { systemPrompt: 'Minimal diff.' } } }, + { id: 'coder-b', profile: { name: 'coder-b', prompt: { systemPrompt: 'Full rewrite.' } } }, + ], + edges: [ + { kind: 'delegates', from: 'lead', to: 'coder-a', directive: brief }, + { kind: 'delegates', from: 'lead', to: 'coder-b', directive: brief }, + ], + deliverable: { + describe: 'a passing candidate', + check: (out) => (out as { pass?: boolean } | undefined)?.pass === true, + }, + budget: { maxIterations: 30, maxTokens: 100_000 }, + } + + const received: AgentProfile[] = [] + const opts: RunGraphOptions = { + runId: 'bon', + maxLiveWorkers: 2, + makeWorkerAgent: leafSeam(received, { + // Candidate A fails its check; candidate B passes — the pick is decided by outcome. + 'coder-a': { shots: [{ out: { candidate: 'a', pass: false }, valid: false }] }, + 'coder-b': { shots: [{ out: { candidate: 'b', pass: true }, valid: true }] }, + }), + brain: scriptedBrain([ + { + // Both spawns in ONE driver turn — concurrent candidates under the conserved pool. + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { name: 'coder-a' }, task: 'attempt the fix' }, + }, + { + name: 'spawn_agent', + arguments: { profile: { name: 'coder-b' }, task: 'attempt the fix' }, + }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { content: 'done' }, + ]), + } + return { graph, opts } +} + +export async function main(): Promise { + const { graph, opts } = bestOfN() + const res = await runGraph(graph, opts) + printLedger('best-of-n', res) +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/examples/graphs/collaborates-review-loop.ts b/examples/graphs/collaborates-review-loop.ts new file mode 100644 index 00000000..1c40e5ad --- /dev/null +++ b/examples/graphs/collaborates-review-loop.ts @@ -0,0 +1,145 @@ +/** + * collaborates-review-loop — the PEER-COLLABORATION pattern expressible today. + * + * Two worker nodes under one root: an 'implementer' and a 'reviewer'. An analyzes edge (the + * 'critique' lens over the implementer's settle trace) routes findings to the REVIEWER as an + * authorized, ledgered steer; a second analyzes edge ('verdict' over the reviewer) routes the + * review verdict back to the DRIVER, which re-briefs the implementer with a second spawn. + * + * Say it plainly: a DIRECT worker-to-worker channel is not a first-class edge. Workers never + * address each other; what exists today is this MEDIATED form — findings travel worker → analyst + * lens → routed steer / driver re-brief — and every hop lands in the edge ledger, so nothing + * crosses between agents unobserved. + * + * The ledger this prints also shows the honest tail: when the re-briefed implementer settles, + * the critique lens fires again, but the reviewer has already settled — that traversal is + * ledgered `unpropagated` (unknown-worker), not silently dropped. + * + * Fully offline (scripted brain + leaf seam). Run: pnpm tsx examples/graphs/collaborates-review-loop.ts + */ + +import type { AgentProfile } from '@tangle-network/agent-interface' +import { + type AgentGraph, + type AnalystRegistry, + promptHandle, + type RunGraphOptions, + runGraph, +} from '@tangle-network/agent-runtime/kernel' +import { leafSeam, printLedger, scriptedBrain } from './shared' + +const brief = promptHandle('delegates/worker-brief/v1') +const report = promptHandle('analyzes/findings-report/v1') + +/** The two lenses are ENVIRONMENT (registry entries), never nodes in the graph. */ +const analysts: AnalystRegistry = { + kinds: [ + { id: 'critique', description: 'read the implementer trace, list defects', area: 'review' }, + { id: 'verdict', description: 'read the reviewer trace, extract the verdict', area: 'review' }, + ], + run: async (kindId) => + kindId === 'critique' + ? [{ claim: 'implementation lacks tests', severity: 'major' }] + : { verdict: 'needs-changes', brief: 'add the missing tests' }, +} + +export function collaboratesReviewLoop(): { graph: AgentGraph; opts: RunGraphOptions } { + // ── The topology: plain data ── + const graph: AgentGraph = { + nodes: [ + { id: 'driver', profile: { name: 'driver', prompt: { systemPrompt: 'Drive the loop.' } } }, + { id: 'implementer', profile: { name: 'implementer', prompt: { systemPrompt: 'Build.' } } }, + { id: 'reviewer', profile: { name: 'reviewer', prompt: { systemPrompt: 'Review.' } } }, + ], + edges: [ + { kind: 'delegates', from: 'driver', to: 'implementer', directive: brief }, + { kind: 'delegates', from: 'driver', to: 'reviewer', directive: brief }, + { + kind: 'analyzes', + analyst: 'critique', + over: ['implementer'], + to: 'reviewer', + directive: report, + }, + { kind: 'analyzes', analyst: 'verdict', over: ['reviewer'], to: 'driver', directive: report }, + ], + deliverable: { describe: 'the re-briefed implementation', check: (out) => out !== undefined }, + budget: { maxIterations: 40, maxTokens: 100_000 }, + } + + const received: AgentProfile[] = [] + const opts: RunGraphOptions = { + runId: 'collab', + analysts, + makeWorkerAgent: leafSeam(received, { + // Shot 1 is the draft; shot 2 (after the driver's re-brief) is the revision that wins. + implementer: { + withTrace: true, + shots: [ + { out: { revision: 1 }, valid: true, score: 0.5 }, + { out: { revision: 2 }, valid: true, score: 1 }, + ], + }, + // The reviewer stays LIVE until the routed critique steer arrives (that steer is what + // releases it), and exposes its own trace so the verdict lens can read it. + reviewer: { + awaitSteer: true, + withTrace: true, + shots: [{ out: { review: 'needs-changes' }, valid: true, score: 0.6 }], + }, + }), + brain: scriptedBrain([ + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { name: 'implementer' }, task: 'implement the feature' }, + }, + ], + }, + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { name: 'reviewer' }, task: 'review the implementation' }, + }, + ], + }, + // implementer settles → critique steers the live reviewer → reviewer settles → verdict + // finding reaches the driver. Four bus events: settled, finding, settled, finding. + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + // The re-brief: the driver folds the verdict into a second implementer spawn. + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { + profile: { name: 'implementer' }, + task: 'address the review verdict: add the missing tests', + }, + }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { content: 'done' }, + ]), + } + return { graph, opts } +} + +export async function main(): Promise { + const { graph, opts } = collaboratesReviewLoop() + const res = await runGraph(graph, opts) + printLedger('collaborates-review-loop', res) +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/examples/graphs/shared.ts b/examples/graphs/shared.ts new file mode 100644 index 00000000..a4c6aa69 --- /dev/null +++ b/examples/graphs/shared.ts @@ -0,0 +1,174 @@ +/** + * Shared OFFLINE seams for the graph examples — the same two seams the kernel's own graph tests + * use (`tests/kernel/graph.test.ts`): + * + * • `scriptedBrain` — a `ToolLoopChat` that plays a fixed sequence of driver turns, so the + * driver's tool calls (spawn / steer / await) are deterministic and cost $0. + * • `leafSeam` — a `MakeWorkerAgent` whose leaf executors settle instantly with configurable + * per-shot outputs and verdicts, optionally block until a steer arrives (`awaitSteer`), and + * optionally expose a live tool trace (`withTrace` / `storm`) for analysts and detectors. + * + * Only the leaf `act` and the driver brain are scripted. The graph machinery AROUND them — node + * pinning, directive delivery, the edge ledger, the journal twin — is the real shipped path + * (`runGraph` → `supervise()`), which is what makes every example an offline proof, not a mock + * of the system. + */ + +import type { AgentProfile } from '@tangle-network/agent-interface' +import { + type Agent, + type AgentSpec, + createPushTraceSource, + type Executor, + type ExecutorResult, + type GraphResult, + type MakeWorkerAgent, + type ToolLoopChat, + type TraceSource, +} from '@tangle-network/agent-runtime/kernel' + +// ── The scripted driver brain ────────────────────────────────────────────────── + +/** A scripted driver turn in the easy-to-write form (parsed tool args). */ +export interface ScriptedTurn { + content?: string + toolCalls?: Array<{ id?: string; name: string; arguments: Record }> +} + +/** Build a scripted `ToolLoopChat` brain from a fixed turn sequence: converts parsed tool args to + * the raw-string form the canonical tool loop parses and advances through the turns (repeating + * the last). */ +export function scriptedBrain(turns: ScriptedTurn[]): ToolLoopChat { + let i = 0 + return async () => { + const turn = turns[Math.min(i, turns.length - 1)] ?? {} + i += 1 + return { + ...(turn.content !== undefined ? { content: turn.content } : {}), + toolCalls: (turn.toolCalls ?? []).map((tc, j) => ({ + id: tc.id ?? `call-${i}-${j}`, + name: tc.name, + arguments: JSON.stringify(tc.arguments), + })), + } + } +} + +// ── The leaf seam ────────────────────────────────────────────────────────────── + +/** What one settle of a node should produce (per spawn ordinal; the last entry repeats). */ +export interface LeafShot { + out: unknown + valid: boolean + score?: number +} + +export interface LeafOptions { + /** Block settlement until a deliver() arrives — so a steer can reach a LIVE worker. */ + awaitSteer?: boolean + /** Expose a live tool-trace source, so settle-time analysts and online detectors have + * evidence to read. */ + withTrace?: boolean + /** Record this many IDENTICAL failing tool calls into the live trace at execute start — the + * stuck-loop storm the online detector panel fires on. Implies nothing without `withTrace`. */ + storm?: number + /** Per-spawn scripts for this node: spawn k settles with `shots[k]` (last entry repeats). + * Omit for a generic valid settle. */ + shots?: ReadonlyArray +} + +export interface LeafSeamHooks { + /** Called with each spawned node's live trace source (when `withTrace`), so an example can + * wire `watchTrace` over it — the online-watchdog seam. */ + onTraceSource?: (nodeId: string, source: TraceSource) => void +} + +/** A leaf-agent factory keyed by node name. Every spawned profile (what the graph pinned + the + * edge directive) is captured into `received` for inspection. */ +export function leafSeam( + received: AgentProfile[], + optsByNode: Record = {}, + hooks: LeafSeamHooks = {}, +): MakeWorkerAgent { + const attempts = new Map() + return (profile) => { + received.push(profile) + const name = profile.name ?? 'leaf' + const opts = optsByNode[name] ?? {} + const attempt = (attempts.get(name) ?? 0) + 1 + attempts.set(name, attempt) + const shot = opts.shots?.[Math.min(attempt - 1, opts.shots.length - 1)] + let release: (() => void) | undefined + const gate = opts.awaitSteer + ? new Promise((resolve) => { + release = resolve + }) + : undefined + const trace = opts.withTrace + ? createPushTraceSource({ runId: `leaf-${name}-${attempt}` }) + : undefined + if (trace) hooks.onTraceSource?.(name, trace.source) + let artifact: ExecutorResult | undefined + const ex: Executor = { + runtime: 'router', + ...(opts.awaitSteer + ? { + deliver: () => { + release?.() + return true + }, + } + : {}), + ...(trace ? { traceSource: () => trace.source } : {}), + async execute() { + if (trace) { + trace.record({ toolName: 'write_file', args: { path: `${name}.ts` }, status: 'ok' }) + for (let i = 0; i < (opts.storm ?? 0); i += 1) { + trace.record({ + toolName: 'bash', + args: { cmd: 'pnpm test' }, + status: 'error', + error: '1 failing', + }) + } + } + if (gate) await gate + const valid = shot ? shot.valid : true + artifact = { + outRef: `w:${name}:${attempt}`, + out: shot ? shot.out : { built: name, attempt }, + verdict: { valid, score: shot?.score ?? (valid ? 1 : 0) }, + spent: { iterations: 1, tokens: { input: 5, output: 5 }, usd: 0, ms: 0 }, + } + return artifact + }, + teardown: () => Promise.resolve({ destroyed: true }), + resultArtifact: () => { + if (!artifact) throw new Error(`leaf ${name}: no terminal artifact`) + return artifact + }, + } + const spec: AgentSpec = { profile, harness: null, executor: ex } + return { name, act: async () => undefined, executorSpec: spec } as Agent & { + executorSpec: AgentSpec + } + } +} + +// ── The proof artifact ───────────────────────────────────────────────────────── + +/** Print the EDGE LEDGER — every traversal, in occurrence order. This is each example's proof: + * who spawned whom, what crossed each edge, and what actually got delivered. */ +export function printLedger(tag: string, res: GraphResult): void { + console.log(`\n${tag} — result: ${res.result.kind} (runId: ${res.runId})`) + if (res.result.kind === 'winner') console.log(`winner out: ${JSON.stringify(res.result.out)}`) + console.log('EDGE LEDGER:') + for (const row of res.ledger) { + const worker = row.workerId !== undefined ? ` -> ${row.workerId}` : '' + const reason = row.reason !== undefined ? ` (${row.reason})` : '' + console.log(` #${row.traversal} ${row.edge} [${row.outcome}] ${row.bytes}B${worker}${reason}`) + } + if (res.exhaustedEdges.length > 0) { + console.log(`exhausted edges: ${res.exhaustedEdges.join(', ')}`) + } +} diff --git a/examples/graphs/shot-loop.ts b/examples/graphs/shot-loop.ts new file mode 100644 index 00000000..0bca6d11 --- /dev/null +++ b/examples/graphs/shot-loop.ts @@ -0,0 +1,110 @@ +/** + * shot-loop — the VB-shaped two-node resumed shot loop, as data. + * + * A 'reviewer' root drives one 'coder' worker. Each spawn of the coder is one SHOT; the + * delegates edge's `maxTraversals: 3` is the shot budget, enforced by the edge itself (the + * cyclic-graph backstop). After every shot the 'verify' lens reads the coder's settle trace and + * reports to the reviewer, whose next spawn folds that report into the next brief. The + * deliverable gates on the verdict — the run only settles a winner once a shot's tests pass. + * + * This is the graph form of the multishot/AgentDriver loop it subsumes: what a bespoke + * reviewer↔coder driver loop hardcodes (shot cap, verify step, re-brief) is here two edges and + * a cap in plain data, with every shot and every verify report in the edge ledger. + * + * Fully offline (scripted brain + leaf seam). Run: pnpm tsx examples/graphs/shot-loop.ts + */ + +import type { AgentProfile } from '@tangle-network/agent-interface' +import { + type AgentGraph, + type AnalystRegistry, + promptHandle, + type RunGraphOptions, + runGraph, +} from '@tangle-network/agent-runtime/kernel' +import { leafSeam, printLedger, scriptedBrain } from './shared' + +const brief = promptHandle('delegates/worker-brief/v1') +const report = promptHandle('analyzes/findings-report/v1') + +/** The verify lens is ENVIRONMENT: it reads the coder's trace, never sits in the graph. */ +const analysts: AnalystRegistry = { + kinds: [{ id: 'verify', description: 'read the coder trace, report test outcome', area: 'qa' }], + run: async () => [{ check: 'test-suite', observed: 'see the settled output' }], +} + +export function shotLoop(): { graph: AgentGraph; opts: RunGraphOptions } { + // ── The topology: plain data ── + const graph: AgentGraph = { + nodes: [ + { id: 'reviewer', profile: { name: 'reviewer', prompt: { systemPrompt: 'Verify.' } } }, + { id: 'coder', profile: { name: 'coder', prompt: { systemPrompt: 'Make tests pass.' } } }, + ], + edges: [ + { kind: 'delegates', from: 'reviewer', to: 'coder', directive: brief, maxTraversals: 3 }, + { kind: 'analyzes', analyst: 'verify', over: ['coder'], to: 'reviewer', directive: report }, + ], + deliverable: { + describe: 'coder output whose tests pass', + check: (out) => (out as { tests?: string } | undefined)?.tests === 'pass', + }, + budget: { maxIterations: 30, maxTokens: 100_000 }, + } + + const received: AgentProfile[] = [] + const opts: RunGraphOptions = { + runId: 'shots', + analysts, + makeWorkerAgent: leafSeam(received, { + // Shot 1 fails its tests; shot 2 (re-briefed from the verify report) passes. + coder: { + withTrace: true, + shots: [ + { out: { tests: 'fail' }, valid: false }, + { out: { tests: 'pass' }, valid: true }, + ], + }, + }), + brain: scriptedBrain([ + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { profile: { name: 'coder' }, task: 'shot 1: make the tests pass' }, + }, + ], + }, + // Shot 1 settles, then its verify report lands: two bus events. + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { + toolCalls: [ + { + name: 'spawn_agent', + arguments: { + profile: { name: 'coder' }, + task: 'shot 2: fix the failing suite the verifier reported', + }, + }, + ], + }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { toolCalls: [{ name: 'await_event', arguments: {} }] }, + { content: 'done' }, + ]), + } + return { graph, opts } +} + +export async function main(): Promise { + const { graph, opts } = shotLoop() + const res = await runGraph(graph, opts) + printLedger('shot-loop', res) +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/examples/graphs/watchdog-steer.ts b/examples/graphs/watchdog-steer.ts new file mode 100644 index 00000000..7998bd89 --- /dev/null +++ b/examples/graphs/watchdog-steer.ts @@ -0,0 +1,122 @@ +/** + * watchdog-steer — the mid-run intervention loop: detect a stuck worker WHILE it runs, correct + * it BEFORE it settles. + * + * The builder node exposes a live tool-trace source. The shipped online detector panel + * (`watchTrace` + `defaultToolDetectors` — the same streaming stuck-loop/error-streak kernel + * agent-eval ships) watches that trace and fires the moment the builder starts hammering the + * same failing command. The driver waits on that signal, composes a corrective instruction FROM + * it, and steers the still-live builder; the steer is the mid-run leg of the delegates edge and + * lands in the ledger like every other traversal. + * + * Wiring note, stated plainly: `supervise()` accepts `watchWorkers` to run this exact panel and + * raise bus `finding`s itself, but `RunGraphOptions` does not forward it — so this example wires + * the SAME shipped panel directly over the worker's trace source at the leaf seam and hands the + * signal to the driver brain. The corrective steer still flows driver → worker over the + * delegates edge, authorized and ledgered. + * + * Fully offline (reactive brain + leaf seam). Run: pnpm tsx examples/graphs/watchdog-steer.ts + */ + +import type { DetectorSignal } from '@tangle-network/agent-eval' +import type { AgentProfile } from '@tangle-network/agent-interface' +import { + type AgentGraph, + defaultToolDetectors, + promptHandle, + type RunGraphOptions, + runGraph, + type ToolLoopChat, + watchTrace, +} from '@tangle-network/agent-runtime/kernel' +import { leafSeam, printLedger } from './shared' + +const brief = promptHandle('delegates/worker-brief/v1') + +export function watchdogSteer(): { graph: AgentGraph; opts: RunGraphOptions } { + // ── The topology: plain data ── + const graph: AgentGraph = { + nodes: [ + { id: 'driver', profile: { name: 'driver', prompt: { systemPrompt: 'Watch and steer.' } } }, + { id: 'builder', profile: { name: 'builder', prompt: { systemPrompt: 'Build.' } } }, + ], + edges: [{ kind: 'delegates', from: 'driver', to: 'builder', directive: brief }], + deliverable: { describe: 'the built artifact', check: (out) => out !== undefined }, + budget: { maxIterations: 20, maxTokens: 50_000 }, + } + + // ── The watchdog: the online detector panel over the builder's LIVE trace ── + let fireSignal: (signal: DetectorSignal) => void + const firstSignal = new Promise((resolve) => { + fireSignal = resolve + }) + const received: AgentProfile[] = [] + const seam = leafSeam( + received, + // The builder blocks until a steer arrives, and its trace replays a stuck loop: the same + // failing `pnpm test` five times — the storm the repeated-action/error-streak panel catches. + { builder: { awaitSteer: true, withTrace: true, storm: 5 } }, + { + onTraceSource: (_nodeId, source) => { + watchTrace(source, { + detectors: defaultToolDetectors(), + onSignal: (signal) => fireSignal(signal), + }) + }, + }, + ) + + // ── The driver: spawn, WAIT for the watchdog, steer with the evidence, settle ── + let turn = 0 + const brain: ToolLoopChat = async () => { + turn += 1 + if (turn === 1) { + return { + toolCalls: [ + { + id: 'c1', + name: 'spawn_agent', + arguments: JSON.stringify({ profile: { name: 'builder' }, task: 'build the feature' }), + }, + ], + } + } + if (turn === 2) { + const signal = await firstSignal + return { + toolCalls: [ + { + id: 'c2', + name: 'steer_agent', + arguments: JSON.stringify({ + workerId: 'wd:s0', + instruction: + `Watchdog: ${signal.detector} fired (streak ${signal.streak}) — ${signal.reason}. ` + + 'Stop repeating the failing command and deliver what you have.', + }), + }, + ], + } + } + if (turn === 3) { + return { toolCalls: [{ id: 'c3', name: 'await_event', arguments: JSON.stringify({}) }] } + } + return { content: 'done', toolCalls: [] } + } + + const opts: RunGraphOptions = { runId: 'wd', makeWorkerAgent: seam, brain } + return { graph, opts } +} + +export async function main(): Promise { + const { graph, opts } = watchdogSteer() + const res = await runGraph(graph, opts) + printLedger('watchdog-steer', res) +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err) => { + console.error(err) + process.exit(1) + }) +} diff --git a/tests/examples/graph-topologies.test.ts b/tests/examples/graph-topologies.test.ts new file mode 100644 index 00000000..ffa6dc10 --- /dev/null +++ b/tests/examples/graph-topologies.test.ts @@ -0,0 +1,109 @@ +/** + * The four example graph topologies (`examples/graphs/`) run offline end-to-end, and each one's + * DECISIVE ledger facts hold — the counts, outcomes, and destinations that make the example's + * claim true, not just "it ran": + * + * 1. collaborates-review-loop — the mediated peer-collaboration pattern: the critique analyzes + * traversal is DELIVERED TO THE REVIEWER'S WORKER ID (worker→worker only ever via a ledgered + * lens route), the verdict traversal reaches the driver, the re-brief is a second delivered + * implementer spawn, and the post-settle critique fires `unpropagated` — observable, never + * silently dropped. + * 2. best-of-n — exactly two delivered spawn traversals, one per candidate edge, and the winner + * is the candidate whose settle passed the deliverable. + * 3. watchdog-steer — the corrective steer lands as the delegates edge's SECOND delivered + * traversal on the SAME live worker, before settle, carrying the detector's evidence. + * 4. shot-loop — two delivered shots under a 3-traversal cap (no exhaustion), each shot + * followed by a delivered verify traversal to the reviewer root; the winner is the shot + * whose tests pass. + */ + +import { runGraph } from '@tangle-network/agent-runtime/kernel' +import { describe, expect, it } from 'vitest' +import { bestOfN } from '../../examples/graphs/best-of-n' +import { collaboratesReviewLoop } from '../../examples/graphs/collaborates-review-loop' +import { shotLoop } from '../../examples/graphs/shot-loop' +import { watchdogSteer } from '../../examples/graphs/watchdog-steer' + +describe('examples/graphs — the four topologies run offline with truthful ledgers', () => { + it('collaborates-review-loop: every peer hop is mediated, ledgered, and addressed', async () => { + const { graph, opts } = collaboratesReviewLoop() + const res = await runGraph(graph, opts) + + expect(res.result.kind).toBe('winner') + if (res.result.kind === 'winner') expect(res.result.out).toEqual({ revision: 2 }) + + // The re-brief loop: two delivered implementer spawns, one delivered reviewer spawn. + const delegates = res.ledger.filter((row) => row.kind === 'delegates') + expect(delegates.map((row) => [row.edge, row.traversal, row.outcome, row.workerId])).toEqual([ + ['delegates:driver->implementer', 1, 'delivered', 'collab:s0'], + ['delegates:driver->reviewer', 1, 'delivered', 'collab:s1'], + ['delegates:driver->implementer', 2, 'delivered', 'collab:s2'], + ]) + + // The mediated peer channel: the critique findings were DELIVERED to the reviewer's live + // worker id — never a direct worker→worker edge, always a ledgered lens route. + const analyzes = res.ledger.filter((row) => row.kind === 'analyzes') + expect(analyzes.map((row) => [row.edge, row.traversal, row.outcome, row.workerId])).toEqual([ + ['analyzes:critique:implementer->reviewer', 1, 'delivered', 'collab:s1'], + ['analyzes:verdict:reviewer->driver', 1, 'delivered', 'collab:s1'], + ['analyzes:critique:implementer->reviewer', 2, 'unpropagated', 'reviewer'], + ]) + // The honest tail: the post-re-brief critique had no live reviewer to reach, and the ledger + // says so instead of dropping it. + expect(analyzes[2]!.reason).toBe('unknown-worker') + expect(res.exhaustedEdges).toEqual([]) + }) + + it('best-of-n: two delivered spawn traversals, winner decided by the deliverable', async () => { + const { graph, opts } = bestOfN() + const res = await runGraph(graph, opts) + + expect(res.result.kind).toBe('winner') + if (res.result.kind === 'winner') expect(res.result.out).toEqual({ candidate: 'b', pass: true }) + + expect(res.ledger).toHaveLength(2) + expect(res.ledger.map((row) => [row.edge, row.traversal, row.outcome, row.workerId])).toEqual([ + ['delegates:lead->coder-a', 1, 'delivered', 'bon:s0'], + ['delegates:lead->coder-b', 1, 'delivered', 'bon:s1'], + ]) + expect(res.exhaustedEdges).toEqual([]) + }) + + it('watchdog-steer: the corrective steer is the mid-run leg of the delegates edge', async () => { + const { graph, opts } = watchdogSteer() + const res = await runGraph(graph, opts) + + expect(res.result.kind).toBe('winner') + if (res.result.kind === 'winner') { + expect(res.result.out).toEqual({ built: 'builder', attempt: 1 }) + } + + // Same edge, same live worker: traversal 1 is the spawn, traversal 2 is the steer that + // landed BEFORE settle (the worker only settles once the steer releases it). + expect(res.ledger).toHaveLength(2) + expect(res.ledger.map((row) => [row.edge, row.traversal, row.outcome, row.workerId])).toEqual([ + ['delegates:driver->builder', 1, 'delivered', 'wd:s0'], + ['delegates:driver->builder', 2, 'delivered', 'wd:s0'], + ]) + // The steer carried the detector's evidence, not boilerplate. + expect(res.ledger[1]!.bytes).toBeGreaterThan('Watchdog: '.length) + expect(res.exhaustedEdges).toEqual([]) + }) + + it('shot-loop: two shots under the 3-traversal cap, each with a delivered verify report', async () => { + const { graph, opts } = shotLoop() + const res = await runGraph(graph, opts) + + expect(res.result.kind).toBe('winner') + if (res.result.kind === 'winner') expect(res.result.out).toEqual({ tests: 'pass' }) + + expect(res.ledger.map((row) => [row.edge, row.traversal, row.outcome, row.workerId])).toEqual([ + ['delegates:reviewer->coder', 1, 'delivered', 'shots:s0'], + ['analyzes:verify:coder->reviewer', 1, 'delivered', 'shots:s0'], + ['delegates:reviewer->coder', 2, 'delivered', 'shots:s1'], + ['analyzes:verify:coder->reviewer', 2, 'delivered', 'shots:s1'], + ]) + // The shot budget lives on the edge: 2 of 3 traversals used, nothing exhausted. + expect(res.exhaustedEdges).toEqual([]) + }) +})