Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Corollary — **extend, never duplicate.** Before writing anything that complete
3. **Structural over hard-dep where possible.** `/tangle` and `/billing` take the tcloud client as a structural contract (no tcloud dep). Prefer that to a dep when the surface is small.
4. **Substrate-free is a feature.** `/runtime`, `/web`, `/crypto`, `/redact` import nothing — they're pure mechanism behind callback seams. Keep them that way.
5. **Additive subpaths.** New capability = new `./subpath` (entry in `tsup.config.ts` + `exports` in `package.json` + root barrel). Never a breaking change to an existing export.
6. **Reuse the engine primitive; never hand-roll one the engine already gives you.** Before adding ANY streaming / turn / session / durability primitive, grep the engines (`@tangle-network/sandbox`, `agent-runtime`, `agent-eval`) AND existing agent-app exports for one that already does it. A new primitive requires a written "why every existing one doesn't fit" — no exceptions. The recurring smell is the shell reinventing an engine capability and paying latency + drift for it. **Canonical case: streaming a sandbox agent run to a browser is the sandbox SDK's read-only JWT session gateway (`streamPrompt` + `lastEventId` replay) — durable replay + reconnect, browser-direct, lowest latency.** Do NOT hand-roll a Durable Object or the `/stream` turn-buffer to re-broadcast a sandbox session's turn events; the `/stream` turn-buffer + `/turn-stream` DO are the **sandbox-FREE path ONLY** (a copilot streaming its own runtime, where no sandbox session exists to attach to). Using them for a sandbox product is the smell (a caught instance: a fleet app streaming a sandbox turn through a hand-rolled `TurnStreamDO` instead of the gateway).
6. **Reuse the engine primitive; never hand-roll one the engine already gives you.** Before adding ANY streaming / turn / session / durability primitive, grep the engines (`@tangle-network/sandbox`, `agent-runtime`, `agent-eval`) AND existing agent-app exports for one that already does it. A new primitive requires a written "why every existing one doesn't fit" — no exceptions. The recurring smell is the shell reinventing an engine capability and paying latency + drift for it. **Canonical case: streaming a sandbox agent run to a browser is the sandbox SDK's session gateway — `box.mintScopedToken()` (`@tangle-network/sandbox`) mints the short-lived read-only JWT, and the browser connects with `SessionGatewayClient` (`@tangle-network/sandbox/session-gateway`, WebSocket, replay from `lastEventId`) — durable replay + reconnect, browser-direct, lowest latency.** The server-side leg is a *different* method and belongs to the same package: `box.streamPrompt()` is an SSE `AsyncGenerator` a worker consumes, and its `lastEventId` / `executionId` options are forwarded as `Last-Event-ID` / `X-Execution-ID` headers so the worker can reconnect. A browser never calls `streamPrompt`. Do NOT hand-roll a Durable Object or the `/stream` turn-buffer to re-broadcast a sandbox session's turn events; the `/stream` turn-buffer + `/turn-stream` DO are the **sandbox-FREE path ONLY** (a copilot streaming its own runtime, where no sandbox session exists to attach to). Using them for a sandbox product is the smell (a caught instance: a fleet app streaming a sandbox turn through a hand-rolled `TurnStreamDO` instead of the gateway).

## Module map

Expand All @@ -26,7 +26,7 @@ Corollary — **extend, never duplicate.** Before writing anything that complete
| Subpath | Owns (app-shell) | Composes (peer/structural) |
|---|---|---|
| `/tools` | structured agent→app side channel (`submit_proposal`/`schedule_followup`/`render_ui`/`add_citation`): OpenAI defs, MCP-server builder (`buildHttpMcpServer`/`buildAppToolMcpServer`), HTTP route handler, runtime executor, capability auth, `ToolInputError` | — |
| `/runtime` | `streamAppToolLoop`/`runAppToolLoop` (bounded turn tool-loop) + `resolveTangleModelConfig` + `toLoopEvents`/`createOpenAICompatStreamTurn` (sandbox-free browser/edge copilot adapter — OpenAI-compat stream → LoopEvents, fragmented tool-calls assembled) | ⚠️ turn-loop is a contribute-down candidate to agent-runtime; the OpenAI-compat adapter only maps stream shape (no HTTP client — see `examples/browser-copilot.md`) |
| `/runtime` | `streamAppToolLoop`/`runAppToolLoop` (bounded in-turn tool loop, **1:1 aliases** — see the collision note below) + `resolveTangleModelConfig` + `toLoopEvents`/`createOpenAICompatStreamTurn` (sandbox-free browser/edge copilot adapter — OpenAI-compat stream → LoopEvents, fragmented tool-calls assembled) | peer `@tangle-network/agent-runtime` — the contribute-down already happened: `src/runtime/loop.ts` is `export { runToolLoop as runAppToolLoop, streamToolLoop as streamAppToolLoop }`, so agent-app holds **zero** loop logic. The OpenAI-compat adapter only maps stream shape (no HTTP client — see `examples/browser-copilot.md`) |
| `/eval` | `producedFromToolEvents` (side-channel→`RuntimeEventLike` bridge) + `createTokenRecallChecker` | **re-exports** agent-eval's `verifyCompletion`/`extractProducedState`/`weightedComposite`/`createLlmCorrectnessChecker` |
| `/integrations` | hub `/exec` client + `resolveIntegrationAction` + `invokeIntegrationHub` (wiring) | peer-dep `@tangle-network/agent-integrations` (the engine/catalog) |
| `/interactions` | human-in-the-loop ask channel, both halves: the shared wire/persisted-part contract (`ChatInteraction`, part codecs, composer-as-answer routing, content-signature dedupe) + the server side — structural sidecar `/agents/sessions/{id}/interactions` client and `createInteractionAnswerRoute()` (list/answer endpoint factory: body validation, gone→410 mapping, duplicate-answer safety net, unblock verification, `resolveConnection` product seam) | peer `@tangle-network/agent-interface` (schema types only); connection is structural — no sandbox-SDK import. Server half must never reach client bundles (`tests/interactions-browser-safe.test.ts`) |
Expand All @@ -52,22 +52,59 @@ The sandbox runs full agent harnesses — skills, tools, sub-agents, MCP, bash,
1. **Intelligence and tooling live in the agent; durability and money live in the platform.** Reasoning, tool selection, installation, evidence gathering, content production → a prompt to an agent session. Surviving restarts, gating spend, pausing for approval → platform code (product or this shell).
2. **Prompts state intents, never implementations.** No shell commands, CLI flags, or install scripts inside system prompts, plan steps, or directives. Name the outcome and the evidence path; the executing agent chooses tools at execution time.
3. **No domain logic in execution infrastructure.** Engines, dispatchers, and schedulers must not pattern-match intents or embed per-vertical scripts. Vertical knowledge belongs in prompt directives and product content, the layers the agent reads. (Shell corollary of the engine/shell rule: domain is a parameter, never baked.)
4. **Don't rebuild harness or platform primitives.** The sandbox SDK already provides durable *session* execution: `dispatchPrompt({ detach: true })` runs the turn server-side after the caller disconnects, `findCompletedTurn(turnId)` is the idempotent completion check, `_sessionStatus`/`_sessionResult` poll lifecycle, and the session gateway mints read-only JWTs so browsers attach to live streams without the product worker. Autonomous/queue work must dispatch detached and poll — never hold an SSE stream open in a worker to learn that a session finished. What the SDK does NOT provide is multi-step *orchestration* (sequencing, gates, budgets, schedules) — that is the legitimate product/shell layer.
4. **Don't rebuild harness or platform primitives.** Every primitive named here belongs to `@tangle-network/sandbox` — the SDK already provides durable *session* execution. `box.driveTurn(message, opts)` (SDK ≥ 0.10.5) is the one an autonomous driver should tick: exactly one settle → poll → dispatch pass, returning `running` / `completed` / `failed`, never looping, never sleeping, never holding a connection open past the pass — agent-app wraps it as `driveSandboxTurn` (`/sandbox`). Underneath it: `box.dispatchPrompt({ detach: true })` runs the turn server-side after the caller disconnects; `box.findCompletedTurn(turnId, { sessionId })` is the idempotent completion check (`sessionId` is **required**); `box.session(id).status()` / `.result()` poll lifecycle — `_sessionStatus` / `_sessionResult` are `@internal` SDK plumbing, never call them from a product; `box.mintScopedToken()` + `SessionGatewayClient` let a browser attach to a live session with no product worker in the path. Autonomous/queue work must drive-or-dispatch detached and tick — never hold an SSE stream open in a worker to learn that a session finished. What the SDK does NOT provide is multi-step *orchestration* (sequencing, gates, budgets, schedules) — that is the legitimate product/shell layer.
5. **No text-block data channels.** Agent writes (proposals, tasks, records, plans) go through schema-validated tools that fail loud back to the model — never through `:::block` text conventions scraped from output after the fact (regex parsing drops malformed data silently). `:::` blocks may exist only as SYSTEM-authored render vocabulary: the platform writes them into persisted messages as UI card anchors; no prompt teaches an agent to author one. (The canonical pattern is tool + fail-loud validation + byte-compatible rows + system-side anchor.)
6. **Gate actions, not mechanics.** Approvals attach to what an action does (spend, publish, vault writes) classified from intent — not to literal commands.

### Choosing a session transport (product agent vs eval agent)

Three callers, three transports — picking wrong is how durability bugs and overbuilt workers happen:

Every primitive below is written `package → symbol`, because three packages ship similarly-named turn functions.

| Caller | Transport | Why |
| --- | --- | --- |
| **Interactive product turn** (chat, copilot) | `streamPrompt` held open for the turn; session-gateway read JWT for the browser to attach directly | A user is watching; worker lifetime ≈ turn length. The gateway replays buffered events on reconnect, so a dropped tab or worker restart loses nothing. |
| **Autonomous product work** (missions, queues, crons, scheduled jobs) | `dispatchPrompt({ detach: true })` + poll (`findCompletedTurn` / `_sessionStatus`) from a durable driver (CF Workflows, DO alarm, queue consumer) | No consumer exists and workers die in minutes. The platform executes the turn server-side; deterministic session/turn ids make crash re-dispatch a lookup, not a second agent run. Never hold an SSE stream open in a worker to learn that a session finished. |
| **Eval agent** (agent-eval loops, self-improve, CI) | `streamPrompt` / `runLoop` in a long-lived process | The harness IS the consumer and outlives the run; durability machinery adds nothing — reproducibility comes from scenarios and seeds, and a failed run is re-run, not resumed. |
| **Interactive product turn** (chat, copilot) | sandbox → `box.streamPrompt()` held open for the turn (agent-app wraps it as `streamSandboxPrompt`, `/sandbox`); for the browser leg, sandbox → `box.mintScopedToken()` + `SessionGatewayClient` (`@tangle-network/sandbox/session-gateway`) so the tab attaches directly | A user is watching; worker lifetime ≈ turn length. The gateway replays buffered events on reconnect, so a dropped tab or worker restart loses nothing. |
| **Autonomous product work** (missions, queues, crons, scheduled jobs) | sandbox → `box.driveTurn()` (agent-app wraps it as `driveSandboxTurn`, `/sandbox`) ticked from a durable driver (CF Workflows step, DO alarm, queue consumer). Drop to raw `box.dispatchPrompt({ detach: true })` + `box.findCompletedTurn(turnId, { sessionId })` / `box.session(id).status()` only when one pass is too coarse | No consumer exists and workers die in minutes. The platform executes the turn server-side; deterministic session/turn ids make crash re-dispatch a lookup, not a second agent run. Never hold an SSE stream open in a worker to learn that a session finished. |
| **Eval agent** (agent-eval loops, self-improve, CI) | For a sandboxed harness: sandbox → `box.streamPrompt()`. For an in-process model turn: agent-runtime (root) → `runToolLoop` / `streamToolLoop` (same functions as agent-app `/runtime`'s `runAppToolLoop` / `streamAppToolLoop`). Only for a multi-round, multi-candidate search: agent-runtime → `runLoop` from the **`/loops`** subpath — a different kernel, not a turn loop | The harness IS the consumer and outlives the run; durability machinery adds nothing — reproducibility comes from scenarios and seeds, and a failed run is re-run, not resumed. |

`agent-runtime` stays durability-free on purpose: it must run identically in a local eval process, CI, and a sandbox. Durable *session* execution is the sandbox platform's job; durable *orchestration* (sequencing, gates, budgets, schedules) is the product/shell layer above it.

### Turn execution primitives — which one, when

Fifteen entry points across three packages can plausibly "run a turn", and several of the names look interchangeable but are not.
Pick from this table before writing anything new; invariant 6 makes a *new* primitive require a written argument for why every row below fails.

| Primitive | Owner | What it does | USE WHEN |
| --- | --- | --- | --- |
| `box.streamPrompt(msg, opts)` | `@tangle-network/sandbox` | SSE `AsyncGenerator` over one sandbox session's events; `lastEventId`/`executionId` reconnect from a worker, `detach: true` keeps the run alive past the stream | A server holds a sandbox turn open and someone is watching it. Prefer `streamSandboxPrompt` — don't call this bare from a product. |
| `box.driveTurn(msg, opts)` | `@tangle-network/sandbox` (≥ 0.10.5) | ONE settle → poll → dispatch pass over a detached turn; returns `running` / `completed` / `failed` and returns immediately | A tick-based durable driver (Workflow step, DO alarm, queue consumer) advances an unattended turn. |
| `box.mintScopedToken()` + `SessionGatewayClient` | `@tangle-network/sandbox` + `/session-gateway` | Short-lived read-only JWT, then a WebSocket client that subscribes and replays from `lastEventId` | The browser should tail a sandbox session directly, with no product worker in the path. This is the invariant-6 canonical case. |
| `streamSandboxPrompt(shell, box, msg, opts)` | agent-app `/sandbox` | `box.streamPrompt` + the shell's profile / model / MCP / effort resolution, severed-stream fail-loud, `disallowQuestions` for unattended runs | Any product streaming a sandbox turn. |
| `runSandboxPrompt(shell, box, msg, opts)` | agent-app `/sandbox` | The same stream, aggregated down to one final `string` | An unattended non-chat turn whose only output is text (cron brief, judge run). Import it — three products still carry a local fork of this function. |
| `driveSandboxTurn(shell, box, msg, opts)` | agent-app `/sandbox` | `box.driveTurn` + profile/model resolution; returns `Outcome<TurnDriveResult>` so a retryable transport `fail` is distinguishable from a settled `.state` | A mission step / cron / queue tick advances a detached turn. This is the autonomous default. |
| `createBufferedTurnTap(opts)` | agent-app `/stream` | Sequence-numbers, coalesces, and persists a turn's events to a `TurnEventStore` while writing live — for a producer that owns iteration and hands you a push callback (agent-runtime `handleChatTurn`'s `hooks.onEvent`) | Your route's own NDJSON turn stream must survive a client disconnect and replay by seq. `createChatTurnRoutes` wires this by default. Not a substitute for the sandbox gateway when the browser should attach to the sandbox session itself. |
| `pumpBufferedTurn(opts)` | agent-app `/stream` | The same tap driven over an `AsyncIterable` you own; the returned promise goes to `ctx.waitUntil` so a disconnect can't kill the turn | Same as above, but you own the producer as an iterable rather than a callback. |
| `createSandboxChatProducer(opts)` | agent-app `/chat-routes` | Raw sandbox events → flattened client vocabulary + persisted parts, authoritative usage, composed error text, interaction finalization | You are filling `createChatTurnRoutes`' producer seam with a sandbox session. |
| `createChatTurnRoutes(opts)` | agent-app `/chat-routes` | The assembled server chat vertical: parse → `authorize` → producer → buffered tap → NDJSON response + replay + interaction answer endpoints + persistence | You are building a server chat turn at all. Reach for the individual modules only for what it doesn't cover. |
| `runDetachedTurn(opts)` | agent-app `/chat-routes` | Streams an already-detached run into the SAME turn-event buffer the interactive lane uses; idempotent (a `complete` turn returns its cached result) and crash-safe (a stale `running` turn is reconciled, not double-streamed) | An autonomous run must still be watchable live by a browser that opens the session mid-run. An unattended run MUST wire `declineInteraction` or a non-renderable ask deadlocks. |
| `runAppToolLoop` / `streamAppToolLoop` | agent-app `/runtime` | **Aliases, not an implementation** — `src/runtime/loop.ts` re-exports agent-runtime's `runToolLoop` / `streamToolLoop` verbatim | Your product drives an OpenAI-compatible model turn itself (browser/edge copilot) and wants the app-side vocabulary. Identical at runtime to the row below. |
| `runToolLoop` / `streamToolLoop` | `@tangle-network/agent-runtime` (**root** entry) | The real in-turn tool loop: one model turn → collect tool calls → dispatch → fold results back in OpenAI function-calling shape → re-run, bounded by `maxToolTurns` / `deadlineMs` / `maxCostUsd`. No rounds, no sandboxes | Same as above, importing the engine directly. Both spellings are fine; they are one function. |
| `handleChatTurn(input)` | `@tangle-network/agent-runtime` (**root** entry) | The turn engine `createChatTurnRoutes` composes — agent-app contributes zero loop logic on this path | You need the engine turn without agent-app's route assembly. |
| `runLoop(options)` | `@tangle-network/agent-runtime` **`/loops`** subpath | **Not a turn loop.** A multi-round, multi-candidate kernel: per round a driver plans N tasks, they run in N sandbox boxes at bounded concurrency, each output is parsed + validated, and results fold through `driver.decide`. Owns box lifecycle, lineage/fork, cost ledger, winner selection. Tagged `@experimental` | An eval or search harness explores candidates across rounds. Never for a chat turn. |

#### The `runLoop` name collision — read this before importing anything with "Loop" in it

Four different functions, and the names do not disambiguate them:

1. **`runLoop`** — `@tangle-network/agent-runtime/loops`. The multi-agent *round* kernel described above. It is **not** on the package root; the only `runLoop`-ish name on the root entry is `runLoopRunnerCli`.
2. **`runToolLoop` / `streamToolLoop`** — `@tangle-network/agent-runtime` **root**. The *in-turn* tool loop. Different file, different subpath, different job from (1).
3. **`runAppToolLoop` / `streamAppToolLoop`** — agent-app `/runtime`. A pure alias of (2); agent-app owns no loop code. The fleet is currently split — some products import the alias, others import (2) directly. Both resolve to the same function, so neither is a bug; do not "fix" one into the other opportunistically.
4. **`routerToolLoop`** — a *third* tool loop, shipped on the **same `/loops` subpath as (1)**. So the collision exists inside one subpath, not just across packages.

Quick test when reading code or docs: a loop described with **rounds / candidates / a driver** is (1); a loop described with **tool calls folded back into one turn** is (2)/(3)/(4).
If you are writing a chat turn, you want (2)/(3) — or better, no loop at all: let `createChatTurnRoutes` + `handleChatTurn` run the turn.

The test for new code: *"Could the agent in the sandbox do this itself if we told it the intent?"* If yes, write the prompt, not the wrapper.

## Develop
Expand Down
Loading