From dfbc3afc3ab8366982ea901d32d42a87a1143282 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 24 May 2026 14:56:42 -0600 Subject: [PATCH 1/2] feat(0.21.0): MCP delegations dispatch into caller's fleet workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agent-runtime-mcp` previously always called `client.create(...)` per delegation, spawning each worker as a SIBLING sandbox of the caller. Diffs landed on the worker's filesystem and the caller had to pull them back through the structured tool result. Switch to a placement abstraction. When the parent sandbox sets `TANGLE_FLEET_ID` on the MCP server's env, `delegate_code` and `delegate_research` now resolve workers via `client.fleets.get(id)` and dispatch onto existing fleet machines through `fleet.sandbox(machineId)`. The fleet's shared-workspace policy means the worker machine mounts the same filesystem as the caller — diffs land in-place, no cross-sandbox copy step. - `LoopSandboxClient` gains an optional `describePlacement(box)` so the kernel emits the new `loop.iteration.dispatch` trace event with either `{ placement: 'sibling', sandboxId }` or `{ placement: 'fleet', fleetId, machineId, sandboxId }`. Sandbox SDK consumers without the method default to sibling. - `DelegationExecutor` abstracts the two modes: `createSiblingSandboxExecutor({ client })` and `createFleetWorkspaceExecutor({ fleet, excludeMachineIds })`. The fleet executor round-robins through `fleet.ids` (skipping the coordinator) and records machineId-by-sandboxId so the trace event can recover the assignment when the kernel hands the SandboxInstance back. - `createDefaultCoderDelegate` accepts either `executor` (new) or `sandboxClient` (legacy shorthand — wraps in a sibling executor). - `detectExecutor({ sandboxClient, env })` picks the right executor from env vars; exported so custom bin entry points get the same detection. - The bin logs `fleet-aware delegation: fleetId=...` and the executor's `describe()` to stderr at startup so operators can confirm placement. - Fleet mode without `TANGLE_API_KEY` fails loud rather than silently degrading to sibling mode. Detection mechanism: `TANGLE_FLEET_ID` env var, with optional `TANGLE_FLEET_EXCLUDE_MACHINES` to skip the coordinator machine. 20 new tests cover sibling placement, fleet round-robin, machine-id recovery in the trace event, exclusion failure, custom selectMachine, sandbox-resolution errors, env detection with whitespace handling, and the structural-shape guards on `client.fleets.get`. Existing 215 tests pass unchanged. Build + typecheck + biome clean. --- README.md | 79 +++++++ package.json | 2 +- src/loops/index.ts | 2 + src/loops/run-loop.ts | 50 ++++- src/loops/trace.ts | 1 + src/loops/types.ts | 41 ++++ src/mcp/bin-helpers.ts | 104 +++++++++ src/mcp/bin.ts | 42 +++- src/mcp/delegates.ts | 28 ++- src/mcp/executor.ts | 161 ++++++++++++++ src/mcp/index.ts | 9 + src/mcp/server.ts | 2 +- tests/mcp/fleet-detection.test.ts | 137 ++++++++++++ tests/mcp/fleet-executor.test.ts | 354 ++++++++++++++++++++++++++++++ 14 files changed, 1003 insertions(+), 9 deletions(-) create mode 100644 src/mcp/bin-helpers.ts create mode 100644 src/mcp/executor.ts create mode 100644 tests/mcp/fleet-detection.test.ts create mode 100644 tests/mcp/fleet-executor.test.ts diff --git a/README.md b/README.md index 051c328..35b4e7c 100644 --- a/README.md +++ b/README.md @@ -219,10 +219,89 @@ delegate. Environment knobs: - `TANGLE_API_KEY` — required (unless both `MCP_DISABLE_*` are set) - `SANDBOX_BASE_URL` — sandbox-SDK base URL override +- `TANGLE_FLEET_ID` — switches placement from sibling-sandbox to fleet-workspace (see [Placement modes](#placement-modes)) +- `TANGLE_FLEET_EXCLUDE_MACHINES` — comma-separated machine ids to skip during fleet-mode round-robin (typically the coordinator) - `MCP_MAX_CONCURRENT_SANDBOXES` — kernel `maxConcurrency` cap (default 4) - `MCP_CODER_FANOUT_HARNESSES` — comma-separated harness ids for `variants > 1` - `MCP_DISABLE_CODER` / `MCP_DISABLE_RESEARCHER` — omit the matching tool +### Placement modes + +Where worker iterations land — sibling sandboxes vs the caller's fleet +workspace — is controlled by `TANGLE_FLEET_ID`. + +**Sibling-sandbox mode (default).** No `TANGLE_FLEET_ID` set. Every +`delegate_code` / `delegate_research` call invokes `sandboxClient.create(...)` +and runs the worker in a fresh sandbox. The worker's diff lives in the +worker's filesystem; the caller pulls it back via the structured tool +result. Use this when the MCP server runs as a standalone CLI mounted +outside a fleet (developer workflows, single-process integrations). + +**Fleet-workspace mode.** `TANGLE_FLEET_ID` set by the parent sandbox when +it launches the MCP server. Each delegation dispatches onto an existing +machine in that fleet via `fleet.sandbox(machineId).streamPrompt(...)`. +The fleet's shared-workspace policy means worker machines mount the same +filesystem as the caller — diffs land in-place, no cross-sandbox copy +step. The bin logs `fleet-aware delegation: fleetId=...` to stderr on +startup so the operator can confirm the placement. + +Pass `TANGLE_FLEET_ID` from a parent sandbox's `AgentProfile.mcpServers` +config: + +```ts +import { defineAgentProfile } from '@tangle-network/sandbox' + +const parentProfile = defineAgentProfile({ + name: 'tax-orchestrator', + mcp: { + 'agent-runtime': { + transport: 'stdio', + command: 'agent-runtime-mcp', + env: { + TANGLE_API_KEY: '${TANGLE_API_KEY}', + TANGLE_FLEET_ID: '${TANGLE_FLEET_ID}', // injected by orchestrator + TANGLE_FLEET_EXCLUDE_MACHINES: 'coordinator', // skip the machine running this MCP server + }, + }, + }, +}) +``` + +For non-bin entry points, wire an executor directly: + +```ts +import { Sandbox } from '@tangle-network/sandbox' +import { + createMcpServer, + createDefaultCoderDelegate, + createFleetWorkspaceExecutor, + createSiblingSandboxExecutor, + detectExecutor, +} from '@tangle-network/agent-runtime/mcp' + +const sandboxClient = new Sandbox({ apiKey: process.env.TANGLE_API_KEY! }) + +// Either pick automatically from env: +const executor = await detectExecutor({ sandboxClient }) + +// Or pin it explicitly: +const fleet = await sandboxClient.fleets.get(process.env.TANGLE_FLEET_ID!) +const fleetExecutor = createFleetWorkspaceExecutor({ + fleet, + excludeMachineIds: ['coordinator'], +}) + +const server = createMcpServer({ + coderDelegate: createDefaultCoderDelegate({ executor: fleetExecutor }), +}) +``` + +The kernel emits a `loop.iteration.dispatch` trace event for every +iteration: `{ placement: 'sibling', sandboxId }` in sibling mode, +`{ placement: 'fleet', fleetId, machineId, sandboxId }` in fleet mode. +Analyst loops use this to correlate worker activity with the caller's +machine. + ### Async semantics Coder + researcher delegations are **fire-and-poll**. The handler returns diff --git a/package.json b/package.json index f6215e5..500180c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tangle-network/agent-runtime", - "version": "0.20.4", + "version": "0.21.0", "description": "Reusable runtime lifecycle for domain-specific agents.", "homepage": "https://github.com/tangle-network/agent-runtime#readme", "repository": { diff --git a/src/loops/index.ts b/src/loops/index.ts index 8ace184..ae9fad3 100644 --- a/src/loops/index.ts +++ b/src/loops/index.ts @@ -35,10 +35,12 @@ export type { Iteration, LoopDecisionPayload, LoopEndedPayload, + LoopIterationDispatchPayload, LoopIterationEndedPayload, LoopIterationStartedPayload, LoopResult, LoopSandboxClient, + LoopSandboxPlacement, LoopStartedPayload, LoopTraceEmitter, LoopTraceEvent, diff --git a/src/loops/run-loop.ts b/src/loops/run-loop.ts index d359942..8968e76 100644 --- a/src/loops/run-loop.ts +++ b/src/loops/run-loop.ts @@ -8,7 +8,9 @@ * 2. For each task (parallel, bounded by `maxConcurrency`): * a. round-robin an `AgentRunSpec` from `agentRuns` * b. `sandboxClient.create({ backend: { profile }, ...overrides })` - * c. iterate `box.streamPrompt(taskToPrompt(task))` and collect events + * c. emit `loop.iteration.dispatch` with the placement + * (`{ sibling, sandboxId }` or `{ fleet, fleetId, machineId, sandboxId }`) + * d. iterate `box.streamPrompt(taskToPrompt(task))` and collect events * 3. `output.parse(events)` → typed `Output` * 4. `validator?.validate(output)` → `DefaultVerdict` * 5. Append `Iteration` to history; emit `loop.iteration.ended` @@ -36,6 +38,7 @@ import type { Iteration, LoopResult, LoopSandboxClient, + LoopSandboxPlacement, LoopTraceEmitter, LoopTraceEvent, LoopWinner, @@ -264,6 +267,20 @@ async function executeIteration(args: ExecuteIterationArgs(args: ExecuteIterationArgs 0 ? raw : undefined +} + async function createSandboxForSpec( client: LoopSandboxClient, spec: AgentRunSpec, diff --git a/src/loops/trace.ts b/src/loops/trace.ts index 8a537fb..5f835c7 100644 --- a/src/loops/trace.ts +++ b/src/loops/trace.ts @@ -14,6 +14,7 @@ export type { LoopDecisionPayload, LoopEndedPayload, + LoopIterationDispatchPayload, LoopIterationEndedPayload, LoopIterationStartedPayload, LoopStartedPayload, diff --git a/src/loops/types.ts b/src/loops/types.ts index 100bd34..bb070bb 100644 --- a/src/loops/types.ts +++ b/src/loops/types.ts @@ -149,10 +149,25 @@ export interface LoopResult { * `new Sandbox({ apiKey, baseUrl })` — declared as a structural type so * tests can pass a stub without instantiating the SDK. * + * `describePlacement` is optional. When present, the kernel calls it after + * each `create()` so the `loop.iteration.dispatch` trace event carries fleet + * coordinates (fleetId + machineId) instead of just the sibling sandboxId. + * Fleet-aware adapters set this; the raw `Sandbox` SDK class does not, and + * the kernel falls back to `{ placement: 'sibling', sandboxId: box.id }`. + * * @experimental */ export interface LoopSandboxClient { create(options?: CreateSandboxOptions): Promise + describePlacement?(box: SandboxInstance): LoopSandboxPlacement +} + +/** @experimental */ +export interface LoopSandboxPlacement { + kind: 'sibling' | 'fleet' + sandboxId?: string + fleetId?: string + machineId?: string } /** @experimental */ @@ -169,6 +184,12 @@ export type LoopTraceEvent = timestamp: number payload: LoopIterationStartedPayload } + | { + kind: 'loop.iteration.dispatch' + runId: string + timestamp: number + payload: LoopIterationDispatchPayload + } | { kind: 'loop.iteration.ended' runId: string @@ -193,6 +214,26 @@ export interface LoopIterationStartedPayload { taskHash: string } +/** + * Where the iteration's worker was placed. `sibling` = a fresh sandbox the + * kernel created via `sandboxClient.create`. `fleet` = an existing machine in + * a shared-workspace fleet — workers see the caller's filesystem and any diff + * they write lands on it directly. + * + * @experimental + */ +export interface LoopIterationDispatchPayload { + iterationIndex: number + agentRunName: string + placement: 'sibling' | 'fleet' + /** Set on every placement. Lets analyst loops correlate per-iteration logs. */ + sandboxId?: string + /** Set only when `placement === 'fleet'`. */ + fleetId?: string + /** Set only when `placement === 'fleet'`. */ + machineId?: string +} + /** @experimental */ export interface LoopIterationEndedPayload { iterationIndex: number diff --git a/src/mcp/bin-helpers.ts b/src/mcp/bin-helpers.ts new file mode 100644 index 0000000..2dda9dd --- /dev/null +++ b/src/mcp/bin-helpers.ts @@ -0,0 +1,104 @@ +/** + * @experimental + * + * Helpers extracted from `bin.ts` so the env-detection + executor-selection + * logic is unit-testable without spawning a subprocess. The bin imports from + * here; tests import from here directly. + */ + +import type { LoopSandboxClient } from '../loops' +import { + createFleetWorkspaceExecutor, + createSiblingSandboxExecutor, + type DelegationExecutor, + type FleetHandle, +} from './executor' + +/** @experimental */ +export interface DetectExecutorArgs { + sandboxClient: LoopSandboxClient + /** Raw env (defaults to `process.env`). Pass an explicit map for tests. */ + env?: Record + /** + * Override how a fleet handle is resolved from the client + fleet id. The + * default reads `client.fleets.get(fleetId)` and validates the returned + * shape against the structural `FleetHandle` contract. + */ + resolveFleet?: (client: LoopSandboxClient, fleetId: string) => Promise +} + +/** + * Pick the right executor for an MCP server invocation based on env vars. + * + * - `TANGLE_FLEET_ID` set → fleet-workspace placement; resolves the handle + * via `sandboxClient.fleets.get(...)`. + * - Otherwise → sibling-sandbox placement; each delegation creates a fresh + * sandbox via `sandboxClient.create(...)`. + * + * Fails loud (throws) when fleet mode is requested but the SDK shape is + * incompatible — the operator chose fleet semantics, silently degrading to + * sibling mode would lie about workspace topology. + * + * @experimental + */ +export async function detectExecutor(args: DetectExecutorArgs): Promise { + const env = args.env ?? process.env + const fleetId = parseFleetId(env.TANGLE_FLEET_ID) + if (!fleetId) { + return createSiblingSandboxExecutor({ client: args.sandboxClient }) + } + const resolveFleet = args.resolveFleet ?? defaultResolveFleet + const fleet = await resolveFleet(args.sandboxClient, fleetId) + const excludeMachineIds = parseList(env.TANGLE_FLEET_EXCLUDE_MACHINES) + return createFleetWorkspaceExecutor({ + fleet, + excludeMachineIds, + }) +} + +interface FleetsApi { + get(fleetId: string): Promise +} + +async function defaultResolveFleet( + sandboxClient: LoopSandboxClient, + fleetId: string, +): Promise { + const fleets = (sandboxClient as unknown as { fleets?: FleetsApi }).fleets + if (!fleets || typeof fleets.get !== 'function') { + throw new Error( + 'agent-runtime-mcp: the configured sandbox client does not expose `.fleets.get`; upgrade @tangle-network/sandbox to >= 0.2.1 or unset TANGLE_FLEET_ID.', + ) + } + const raw = await fleets.get(fleetId) + if (!raw || typeof raw !== 'object') { + throw new Error(`agent-runtime-mcp: fleets.get(${fleetId}) returned no handle`) + } + const handle = raw as Partial + if (typeof handle.fleetId !== 'string' || !Array.isArray(handle.ids)) { + throw new Error( + `agent-runtime-mcp: fleet handle for ${fleetId} is missing fleetId/ids — incompatible sandbox SDK shape`, + ) + } + if (typeof handle.sandbox !== 'function') { + throw new Error( + `agent-runtime-mcp: fleet handle for ${fleetId} is missing sandbox(machineId) — incompatible sandbox SDK shape`, + ) + } + return handle as FleetHandle +} + +function parseFleetId(raw: string | undefined): string | undefined { + if (typeof raw !== 'string') return undefined + const trimmed = raw.trim() + return trimmed.length > 0 ? trimmed : undefined +} + +function parseList(raw: string | undefined): string[] | undefined { + if (!raw) return undefined + const list = raw + .split(',') + .map((entry) => entry.trim()) + .filter(Boolean) + return list.length > 0 ? list : undefined +} diff --git a/src/mcp/bin.ts b/src/mcp/bin.ts index ff762d6..204ad9e 100644 --- a/src/mcp/bin.ts +++ b/src/mcp/bin.ts @@ -13,6 +13,16 @@ * Environment variables: * TANGLE_API_KEY required — passed to `new Sandbox({ apiKey })` * SANDBOX_BASE_URL optional — sandbox-SDK base URL override + * TANGLE_FLEET_ID optional — when set, delegations dispatch + * INTO this fleet's shared workspace instead + * of creating sibling sandboxes. Set by the + * parent sandbox when launching this MCP + * server so worker diffs land on the caller's + * filesystem with no cross-sandbox boundary. + * TANGLE_FLEET_EXCLUDE_MACHINES optional — comma-separated machine ids to + * skip during fleet-mode round-robin + * (typically the coordinator machine this + * MCP server is running on). * MCP_MAX_CONCURRENT_SANDBOXES default 4 — kernel maxConcurrency cap * MCP_CODER_FANOUT_HARNESSES comma-separated harness ids to use for variants > 1 * MCP_DISABLE_CODER set to `1` to omit `delegate_code` @@ -21,7 +31,9 @@ import type { LoopSandboxClient } from '../loops' import { runLoop } from '../loops' +import { detectExecutor } from './bin-helpers' import { createDefaultCoderDelegate, type ResearcherDelegate } from './delegates' +import type { DelegationExecutor } from './executor' import { createMcpServer } from './server' import type { ResearchOutputShape } from './types' @@ -30,6 +42,7 @@ async function main(): Promise { const maxConcurrency = parseConcurrency(process.env.MCP_MAX_CONCURRENT_SANDBOXES) const wantCoder = !process.env.MCP_DISABLE_CODER const wantResearcher = !process.env.MCP_DISABLE_RESEARCHER + const fleetId = parseFleetId(process.env.TANGLE_FLEET_ID) // Skip the sandbox client load entirely when no profile delegate needs it — // the feedback + status + history tools are queue-bound and require no @@ -37,6 +50,7 @@ async function main(): Promise { // self-introspection. const needsSandbox = wantCoder || wantResearcher let sandboxClient: LoopSandboxClient | undefined + let executor: DelegationExecutor | undefined if (needsSandbox) { const apiKey = process.env.TANGLE_API_KEY if (!apiKey && !process.env.AGENT_RUNTIME_MCP_ALLOW_NO_KEY) { @@ -45,21 +59,35 @@ async function main(): Promise { ) process.exit(2) } + // Fleet mode against a diagnostic stub is meaningless — the stub can't + // resolve a real fleet handle. Refuse rather than silently degrading, + // otherwise a fleet-mounted MCP would behave differently than configured. + if (fleetId && !apiKey) { + process.stderr.write( + 'agent-runtime-mcp: TANGLE_FLEET_ID was set but TANGLE_API_KEY is missing; cannot resolve fleet handle. Provide an api key or unset TANGLE_FLEET_ID.\n', + ) + process.exit(2) + } sandboxClient = await loadSandboxClient(apiKey) + executor = await detectExecutor({ sandboxClient }) + if (fleetId) { + process.stderr.write(`agent-runtime-mcp: fleet-aware delegation: fleetId=${fleetId}\n`) + } + process.stderr.write(`agent-runtime-mcp: delegation placement → ${executor.describe()}\n`) } const coderDelegate = - wantCoder && sandboxClient + wantCoder && executor ? createDefaultCoderDelegate({ - sandboxClient, + executor, fanoutHarnesses, maxConcurrency, }) : undefined const researcherDelegate = - wantResearcher && sandboxClient - ? await loadResearcherDelegate(sandboxClient, maxConcurrency) + wantResearcher && executor + ? await loadResearcherDelegate(executor.client, maxConcurrency) : undefined const server = createMcpServer({ coderDelegate, researcherDelegate }) @@ -218,6 +246,12 @@ function parseHarnesses(raw: string | undefined): string[] | undefined { return list.length > 0 ? list : undefined } +function parseFleetId(raw: string | undefined): string | undefined { + if (typeof raw !== 'string') return undefined + const trimmed = raw.trim() + return trimmed.length > 0 ? trimmed : undefined +} + function parseConcurrency(raw: string | undefined): number { if (!raw) return 4 const n = Number(raw) diff --git a/src/mcp/delegates.ts b/src/mcp/delegates.ts index 0e5d529..59b3040 100644 --- a/src/mcp/delegates.ts +++ b/src/mcp/delegates.ts @@ -19,6 +19,7 @@ import type { LoopSandboxClient } from '../loops' import { runLoop } from '../loops' import { coderProfile, multiHarnessCoderFanout } from '../profiles/coder' +import { createSiblingSandboxExecutor, type DelegationExecutor } from './executor' import type { CoderTask, DelegateCodeArgs, @@ -47,7 +48,18 @@ export type ResearcherDelegate = ( /** @experimental */ export interface CreateDefaultCoderDelegateOptions { - sandboxClient: LoopSandboxClient + /** + * Execution placement. Pass a {@link DelegationExecutor} (sibling or fleet) + * to control where worker iterations land. `sandboxClient` is a + * convenience shorthand that wraps the client in a sibling executor — pass + * one or the other, not both. + */ + executor?: DelegationExecutor + /** + * Convenience shorthand for sibling placement. Equivalent to + * `executor: createSiblingSandboxExecutor({ client: sandboxClient })`. + */ + sandboxClient?: LoopSandboxClient /** Default `['claude-code', 'codex', 'opencode/zai-coding-plan/glm-5.1']` when variants > 1. */ fanoutHarnesses?: string[] /** Hard cap on the kernel's per-batch concurrency. Default 4. */ @@ -64,7 +76,8 @@ export interface CreateDefaultCoderDelegateOptions { export function createDefaultCoderDelegate( options: CreateDefaultCoderDelegateOptions, ): CoderDelegate { - const sandboxClient = options.sandboxClient + const executor = resolveExecutor(options) + const sandboxClient = executor.client const fanoutHarnesses = options.fanoutHarnesses const maxConcurrency = options.maxConcurrency ?? 4 return async (args, ctx) => { @@ -127,6 +140,17 @@ function buildCoderGoal(args: DelegateCodeArgs): string { return [args.goal, '', '## Context', args.contextHint].join('\n') } +function resolveExecutor(options: CreateDefaultCoderDelegateOptions): DelegationExecutor { + if (options.executor && options.sandboxClient) { + throw new Error('createDefaultCoderDelegate: pass exactly one of `executor` or `sandboxClient`') + } + if (options.executor) return options.executor + if (options.sandboxClient) { + return createSiblingSandboxExecutor({ client: options.sandboxClient }) + } + throw new Error('createDefaultCoderDelegate: `executor` or `sandboxClient` is required') +} + /** * Single-shot driver — plan one task on iteration 0, stop after one * iteration. Used by the coder delegate when `variants <= 1`. Keeps the diff --git a/src/mcp/executor.ts b/src/mcp/executor.ts new file mode 100644 index 0000000..3ed322a --- /dev/null +++ b/src/mcp/executor.ts @@ -0,0 +1,161 @@ +/** + * @experimental + * + * Delegation executors — the layer between MCP delegates and the sandbox + * substrate. Each executor exposes a {@link LoopSandboxClient} the kernel + * consumes plus a placement tag so the trace pipeline can correlate workers + * with their physical placement. + * + * Two implementations ship in-box: + * + * - {@link createSiblingSandboxExecutor} — every delegation spawns a fresh + * sandbox sibling to the caller. Default when the MCP server runs as a + * standalone CLI mounted outside a fleet. + * + * - {@link createFleetWorkspaceExecutor} — delegations dispatch onto machines + * in the caller's existing fleet so worker diffs land directly on the + * caller's filesystem (the fleet's shared workspace). Selected when the + * parent sandbox passes `TANGLE_FLEET_ID` into the MCP server's env. + */ + +import type { CreateSandboxOptions, SandboxInstance } from '@tangle-network/sandbox' +import type { LoopSandboxClient, LoopSandboxPlacement } from '../loops' + +/** @experimental */ +export interface DelegationExecutor { + /** Sandbox client the kernel calls. Returned with `describePlacement` set. */ + readonly client: LoopSandboxClient + /** Best-effort one-liner used in stderr boot logs and diagnostics. */ + describe(): string +} + +/** @experimental */ +export interface SiblingSandboxExecutorOptions { + client: LoopSandboxClient +} + +/** + * Wrap a raw sandbox SDK client so the kernel emits + * `loop.iteration.dispatch` events with `{ placement: 'sibling', sandboxId }`. + * + * The returned client `.create()` delegates to the underlying client; the + * only added behavior is a `describePlacement` tag the kernel reads. + * + * @experimental + */ +export function createSiblingSandboxExecutor( + options: SiblingSandboxExecutorOptions, +): DelegationExecutor { + const underlying = options.client + const client: LoopSandboxClient = { + create(opts?: CreateSandboxOptions): Promise { + return underlying.create(opts) + }, + describePlacement(box: SandboxInstance): LoopSandboxPlacement { + return { kind: 'sibling', sandboxId: readId(box) } + }, + } + return { + client, + describe(): string { + return 'sibling-sandbox (each delegation = fresh sandbox via client.create)' + }, + } +} + +/** + * Minimal `SandboxFleet` surface the fleet executor calls. Declared + * structurally so tests can pass an in-memory stub without instantiating the + * sandbox SDK. + * + * @experimental + */ +export interface FleetHandle { + readonly fleetId: string + /** Machine ids in dispatch-eligible order. The executor round-robins. */ + readonly ids: ReadonlyArray + /** Resolve a machine id to its `SandboxInstance` — that machine is mounted + * on the fleet's shared workspace, so any diff the worker writes lands on + * every other fleet machine's filesystem too. */ + sandbox(machineId: string): Promise +} + +/** @experimental */ +export interface FleetWorkspaceExecutorOptions { + fleet: FleetHandle + /** + * Override the machine-selection policy. Default = round-robin across + * `fleet.ids`, skipping the optional `excludeMachineIds` set (typically the + * coordinator machine the MCP server is running on). + */ + selectMachine?: (call: { callIndex: number; ids: ReadonlyArray }) => string + /** + * Machine ids to skip during default round-robin. Set to the caller's own + * machineId so workers don't compete with the orchestrator on the same VM. + */ + excludeMachineIds?: ReadonlyArray +} + +/** + * Build an executor that resolves each delegated iteration to an existing + * machine in `fleet`. The fleet's shared-workspace policy means the worker + * machine sees the caller's filesystem — diffs land in-place with no + * cross-sandbox copy step. + * + * @experimental + */ +export function createFleetWorkspaceExecutor( + options: FleetWorkspaceExecutorOptions, +): DelegationExecutor { + const fleet = options.fleet + const exclude = new Set(options.excludeMachineIds ?? []) + let callIndex = 0 + // machineId-by-sandboxId, populated as we resolve machines so + // `describePlacement` can recover the assignment from the SandboxInstance + // the kernel hands back. + const placementBySandboxId = new Map() + + const client: LoopSandboxClient = { + async create(): Promise { + const ids = fleet.ids.filter((id) => !exclude.has(id)) + if (ids.length === 0) { + throw new Error( + `agent-runtime: fleet ${fleet.fleetId} has no eligible worker machines (ids=[${fleet.ids.join(',')}], excluded=[${[...exclude].join(',')}])`, + ) + } + const selector = options.selectMachine + const machineId = selector ? selector({ callIndex, ids }) : ids[callIndex % ids.length] + callIndex += 1 + if (typeof machineId !== 'string' || machineId.length === 0) { + throw new Error('agent-runtime: fleet executor selectMachine returned an empty machine id') + } + const box = await fleet.sandbox(machineId) + const sandboxId = readId(box) + if (sandboxId) placementBySandboxId.set(sandboxId, { machineId }) + return box + }, + describePlacement(box: SandboxInstance): LoopSandboxPlacement { + const sandboxId = readId(box) + const recorded = sandboxId ? placementBySandboxId.get(sandboxId) : undefined + return { + kind: 'fleet', + sandboxId, + fleetId: fleet.fleetId, + machineId: recorded?.machineId, + } + }, + } + + return { + client, + describe(): string { + const excluded = exclude.size > 0 ? ` (excluded=[${[...exclude].join(',')}])` : '' + return `fleet-workspace (fleetId=${fleet.fleetId}, machines=[${fleet.ids.join(',')}]${excluded})` + }, + } +} + +function readId(box: SandboxInstance): string | undefined { + const raw = (box as unknown as { id?: unknown }).id + return typeof raw === 'string' && raw.length > 0 ? raw : undefined +} diff --git a/src/mcp/index.ts b/src/mcp/index.ts index 6517e90..f0b208a 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -13,6 +13,8 @@ * sandbox client + run-loop topology. */ +export type { DetectExecutorArgs } from './bin-helpers' +export { detectExecutor } from './bin-helpers' export type { CoderDelegate, CreateDefaultCoderDelegateOptions, @@ -20,6 +22,13 @@ export type { ResearcherDelegate, } from './delegates' export { createDefaultCoderDelegate } from './delegates' +export type { + DelegationExecutor, + FleetHandle, + FleetWorkspaceExecutorOptions, + SiblingSandboxExecutorOptions, +} from './executor' +export { createFleetWorkspaceExecutor, createSiblingSandboxExecutor } from './executor' export type { FeedbackEvent, FeedbackStore } from './feedback-store' export { eventToSnapshot, InMemoryFeedbackStore } from './feedback-store' export type { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index ca03bdf..26b1155 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -121,7 +121,7 @@ export interface JsonRpcResponse { const PROTOCOL_VERSION = '2024-11-05' const DEFAULT_SERVER_NAME = 'agent-runtime-mcp' -const DEFAULT_SERVER_VERSION = '0.20.0' +const DEFAULT_SERVER_VERSION = '0.21.0' /** @experimental */ export function createMcpServer(options: McpServerOptions = {}): McpServer { diff --git a/tests/mcp/fleet-detection.test.ts b/tests/mcp/fleet-detection.test.ts new file mode 100644 index 0000000..21e1425 --- /dev/null +++ b/tests/mcp/fleet-detection.test.ts @@ -0,0 +1,137 @@ +import type { SandboxInstance } from '@tangle-network/sandbox' +import { describe, expect, it } from 'vitest' +import type { LoopSandboxClient } from '../../src/loops' +import { detectExecutor } from '../../src/mcp/bin-helpers' +import type { FleetHandle } from '../../src/mcp/executor' + +function stubClient(): LoopSandboxClient { + return { + async create(): Promise { + return null as unknown as SandboxInstance + }, + } +} + +function stubFleet(fleetId: string, ids: string[]): FleetHandle { + return { + fleetId, + ids, + async sandbox() { + return null as unknown as SandboxInstance + }, + } +} + +describe('detectExecutor', () => { + it('picks the sibling executor when TANGLE_FLEET_ID is unset', async () => { + const executor = await detectExecutor({ sandboxClient: stubClient(), env: {} }) + expect(executor.describe()).toMatch(/sibling-sandbox/) + }) + + it('treats whitespace-only TANGLE_FLEET_ID as unset', async () => { + const executor = await detectExecutor({ + sandboxClient: stubClient(), + env: { TANGLE_FLEET_ID: ' ' }, + }) + expect(executor.describe()).toMatch(/sibling-sandbox/) + }) + + it('picks the fleet executor when TANGLE_FLEET_ID is set and a handle resolves', async () => { + const fleet = stubFleet('fl_42', ['coordinator', 'worker-1', 'worker-2']) + const executor = await detectExecutor({ + sandboxClient: stubClient(), + env: { TANGLE_FLEET_ID: 'fl_42' }, + resolveFleet: async (_client, fleetId) => { + expect(fleetId).toBe('fl_42') + return fleet + }, + }) + expect(executor.describe()).toMatch(/fleet-workspace/) + expect(executor.describe()).toMatch(/fleetId=fl_42/) + }) + + it('passes TANGLE_FLEET_EXCLUDE_MACHINES into the fleet executor', async () => { + const fleet = stubFleet('fl_a', ['coordinator', 'worker-1']) + const executor = await detectExecutor({ + sandboxClient: stubClient(), + env: { + TANGLE_FLEET_ID: 'fl_a', + TANGLE_FLEET_EXCLUDE_MACHINES: 'coordinator,extra', + }, + resolveFleet: async () => fleet, + }) + expect(executor.describe()).toMatch(/excluded=\[coordinator,extra\]/) + }) + + it('uses the default fleet resolver against client.fleets.get', async () => { + const fleet = stubFleet('fl_z', ['m1']) + let observedFleetId: string | undefined + const client = { + async create(): Promise { + return null as unknown as SandboxInstance + }, + fleets: { + async get(id: string): Promise { + observedFleetId = id + return fleet + }, + }, + } as unknown as LoopSandboxClient + + const executor = await detectExecutor({ + sandboxClient: client, + env: { TANGLE_FLEET_ID: 'fl_z' }, + }) + expect(observedFleetId).toBe('fl_z') + expect(executor.describe()).toMatch(/fleetId=fl_z/) + }) + + it('throws when the client lacks .fleets.get but TANGLE_FLEET_ID is set', async () => { + await expect( + detectExecutor({ + sandboxClient: stubClient(), + env: { TANGLE_FLEET_ID: 'fl_missing' }, + }), + ).rejects.toThrow(/does not expose `\.fleets\.get`/) + }) + + it('throws when the fleet handle is structurally incompatible', async () => { + const client = { + async create(): Promise { + return null as unknown as SandboxInstance + }, + fleets: { + async get(): Promise { + return { fleetId: 'fl_bad' /* missing ids + sandbox() */ } + }, + }, + } as unknown as LoopSandboxClient + + await expect( + detectExecutor({ + sandboxClient: client, + env: { TANGLE_FLEET_ID: 'fl_bad' }, + }), + ).rejects.toThrow(/incompatible sandbox SDK shape/) + }) + + it('throws when fleets.get returns null', async () => { + const client = { + async create(): Promise { + return null as unknown as SandboxInstance + }, + fleets: { + async get(): Promise { + return null + }, + }, + } as unknown as LoopSandboxClient + + await expect( + detectExecutor({ + sandboxClient: client, + env: { TANGLE_FLEET_ID: 'fl_null' }, + }), + ).rejects.toThrow(/returned no handle/) + }) +}) diff --git a/tests/mcp/fleet-executor.test.ts b/tests/mcp/fleet-executor.test.ts new file mode 100644 index 0000000..0a3d048 --- /dev/null +++ b/tests/mcp/fleet-executor.test.ts @@ -0,0 +1,354 @@ +import type { + AgentProfile, + CreateSandboxOptions, + SandboxEvent, + SandboxInstance, +} from '@tangle-network/sandbox' +import { describe, expect, it } from 'vitest' +import { + type AgentRunSpec, + type LoopTraceEvent, + type OutputAdapter, + runLoop, +} from '../../src/loops' +import { createDefaultCoderDelegate } from '../../src/mcp/delegates' +import { + createFleetWorkspaceExecutor, + createSiblingSandboxExecutor, + type FleetHandle, +} from '../../src/mcp/executor' + +const profile: AgentProfile = { name: 'stub' } + +interface SimpleTask { + goal: string +} + +interface SimpleOutput { + prompt: string + machineId?: string +} + +function adapter(): OutputAdapter { + return { + parse(events) { + const last = events.at(-1) + const data = (last?.data ?? {}) as { prompt?: string; machineId?: string } + return { prompt: data.prompt ?? '', machineId: data.machineId } + }, + } +} + +function spec(name = 'agent'): AgentRunSpec { + return { + profile, + name, + taskToPrompt: (task) => task.goal, + } +} + +interface StubFleet extends FleetHandle { + prompts: Array<{ machineId: string; message: string }> + selections: string[] +} + +function stubFleet(machineIds: string[], opts?: { failOnSandbox?: boolean }): StubFleet { + const prompts: Array<{ machineId: string; message: string }> = [] + const selections: string[] = [] + const ids: ReadonlyArray = [...machineIds] + const handle: StubFleet = { + fleetId: 'fl_test', + ids, + prompts, + selections, + async sandbox(machineId: string): Promise { + if (opts?.failOnSandbox) throw new Error('sandbox-resolution-failed') + selections.push(machineId) + const sandboxId = `box_${machineId}` + const events: SandboxEvent[] = [ + { type: 'message.completed', data: { prompt: '', machineId, sandboxId } }, + ] + return { + id: sandboxId, + async *streamPrompt(message: string) { + prompts.push({ machineId, message }) + for (const e of events) { + yield { + ...e, + data: { ...(e.data as Record), prompt: message }, + } + } + }, + } as unknown as SandboxInstance + }, + } + return handle +} + +describe('createSiblingSandboxExecutor', () => { + it('produces a placement of kind=sibling carrying the sandbox id', async () => { + const events: LoopTraceEvent[] = [] + const fakeBox = { + id: 'box_sibling_1', + async *streamPrompt() { + yield { type: 'message.completed', data: {} } satisfies SandboxEvent + }, + } as unknown as SandboxInstance + const executor = createSiblingSandboxExecutor({ + client: { + async create(): Promise { + return fakeBox + }, + }, + }) + + await runLoop({ + driver: { + name: 'one-shot', + async plan(t, history) { + return history.length === 0 ? [t] : [] + }, + decide(h) { + return h.length > 0 ? 'pick-winner' : 'fail' + }, + }, + agentRun: spec(), + output: adapter(), + task: { goal: 'hi' }, + maxIterations: 1, + ctx: { + sandboxClient: executor.client, + traceEmitter: { + emit(e) { + events.push(e) + }, + }, + }, + }) + + const dispatch = events.find((e) => e.kind === 'loop.iteration.dispatch') + expect(dispatch).toBeDefined() + expect(dispatch?.payload).toMatchObject({ + placement: 'sibling', + sandboxId: 'box_sibling_1', + }) + expect((dispatch?.payload as { fleetId?: string }).fleetId).toBeUndefined() + }) + + it('describe() returns a stable human-readable tag', () => { + const executor = createSiblingSandboxExecutor({ + client: { + async create(): Promise { + return null as unknown as SandboxInstance + }, + }, + }) + expect(executor.describe()).toMatch(/sibling-sandbox/) + }) +}) + +describe('createFleetWorkspaceExecutor', () => { + it('round-robins machine selection across iterations', async () => { + const fleet = stubFleet(['coordinator', 'worker-1', 'worker-2']) + const executor = createFleetWorkspaceExecutor({ + fleet, + excludeMachineIds: ['coordinator'], + }) + const events: LoopTraceEvent[] = [] + + await runLoop({ + driver: { + name: 'fanout-3', + async plan(t, history) { + return history.length === 0 ? [t, t, t] : [] + }, + decide(h) { + return h.length > 0 ? 'pick-winner' : 'fail' + }, + }, + agentRun: spec(), + output: adapter(), + task: { goal: 'land a diff' }, + maxIterations: 3, + maxConcurrency: 1, // serial so round-robin order is deterministic + ctx: { + sandboxClient: executor.client, + traceEmitter: { + emit(e) { + events.push(e) + }, + }, + }, + }) + + expect(fleet.selections).toEqual(['worker-1', 'worker-2', 'worker-1']) + expect(fleet.prompts).toHaveLength(3) + expect(fleet.prompts[0]?.machineId).toBe('worker-1') + + const dispatches = events.filter((e) => e.kind === 'loop.iteration.dispatch') + expect(dispatches).toHaveLength(3) + for (const d of dispatches) { + expect(d.payload).toMatchObject({ placement: 'fleet', fleetId: 'fl_test' }) + } + const machineSeq = dispatches.map((d) => (d.payload as { machineId?: string }).machineId) + expect(machineSeq).toEqual(['worker-1', 'worker-2', 'worker-1']) + }) + + it('fails loud when every machine is excluded', async () => { + const fleet = stubFleet(['coordinator']) + const executor = createFleetWorkspaceExecutor({ + fleet, + excludeMachineIds: ['coordinator'], + }) + + await expect(executor.client.create({} as CreateSandboxOptions)).rejects.toThrow( + /no eligible worker machines/, + ) + }) + + it('honours a custom selectMachine policy', async () => { + const fleet = stubFleet(['worker-1', 'worker-2']) + const executor = createFleetWorkspaceExecutor({ + fleet, + selectMachine: ({ callIndex, ids }) => + ids[ids.length - 1 - (callIndex % ids.length)] ?? ids[0]!, + }) + + await executor.client.create() + await executor.client.create() + expect(fleet.selections).toEqual(['worker-2', 'worker-1']) + }) + + it('propagates sandbox-resolution errors', async () => { + const fleet = stubFleet(['worker-1'], { failOnSandbox: true }) + const executor = createFleetWorkspaceExecutor({ fleet }) + await expect(executor.client.create()).rejects.toThrow(/sandbox-resolution-failed/) + }) + + it('describe() reports fleetId, machines, and exclusions', () => { + const fleet = stubFleet(['coordinator', 'worker-1']) + const executor = createFleetWorkspaceExecutor({ + fleet, + excludeMachineIds: ['coordinator'], + }) + const tag = executor.describe() + expect(tag).toMatch(/fleetId=fl_test/) + expect(tag).toMatch(/coordinator,worker-1/) + expect(tag).toMatch(/excluded=\[coordinator\]/) + }) +}) + +describe('createDefaultCoderDelegate with executor', () => { + it('rejects when both executor and sandboxClient are passed', () => { + const fakeClient = { + async create(): Promise { + return null as unknown as SandboxInstance + }, + } + const executor = createSiblingSandboxExecutor({ client: fakeClient }) + expect(() => createDefaultCoderDelegate({ executor, sandboxClient: fakeClient })).toThrow( + /exactly one/, + ) + }) + + it('rejects when neither is passed', () => { + expect(() => createDefaultCoderDelegate({})).toThrow(/required/) + }) + + it('accepts the legacy sandboxClient shorthand (defaults to sibling)', () => { + const fakeClient = { + async create(): Promise { + return null as unknown as SandboxInstance + }, + } + const delegate = createDefaultCoderDelegate({ sandboxClient: fakeClient }) + expect(typeof delegate).toBe('function') + }) +}) + +describe('LoopSandboxClient placement default', () => { + it('falls back to sibling when the client has no describePlacement', async () => { + const events: LoopTraceEvent[] = [] + const fakeBox = { + id: 'box_anon', + async *streamPrompt() { + yield { type: 'message.completed', data: {} } satisfies SandboxEvent + }, + } as unknown as SandboxInstance + + await runLoop({ + driver: { + name: 'one-shot', + async plan(t, history) { + return history.length === 0 ? [t] : [] + }, + decide(h) { + return h.length > 0 ? 'pick-winner' : 'fail' + }, + }, + agentRun: spec(), + output: adapter(), + task: { goal: 'hi' }, + maxIterations: 1, + ctx: { + sandboxClient: { + async create(): Promise { + return fakeBox + }, + }, + traceEmitter: { + emit(e) { + events.push(e) + }, + }, + }, + }) + + const dispatch = events.find((e) => e.kind === 'loop.iteration.dispatch') + expect(dispatch?.payload).toMatchObject({ placement: 'sibling', sandboxId: 'box_anon' }) + }) + + it('ignores a describePlacement that throws and falls back to sibling', async () => { + const events: LoopTraceEvent[] = [] + const fakeBox = { + id: 'box_throw', + async *streamPrompt() { + yield { type: 'message.completed', data: {} } satisfies SandboxEvent + }, + } as unknown as SandboxInstance + + await runLoop({ + driver: { + name: 'one-shot', + async plan(t, history) { + return history.length === 0 ? [t] : [] + }, + decide(h) { + return h.length > 0 ? 'pick-winner' : 'fail' + }, + }, + agentRun: spec(), + output: adapter(), + task: { goal: 'hi' }, + maxIterations: 1, + ctx: { + sandboxClient: { + async create(): Promise { + return fakeBox + }, + describePlacement() { + throw new Error('adapter bug') + }, + }, + traceEmitter: { + emit(e) { + events.push(e) + }, + }, + }, + }) + + const dispatch = events.find((e) => e.kind === 'loop.iteration.dispatch') + expect(dispatch?.payload).toMatchObject({ placement: 'sibling', sandboxId: 'box_throw' }) + }) +}) From d78dfcc757818c9ed295e178db51f4faa74ae6c6 Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Sun, 24 May 2026 14:58:38 -0600 Subject: [PATCH 2/2] =?UTF-8?q?chore(deps):=20regenerate=20pnpm-lock.yaml?= =?UTF-8?q?=20=E2=80=94=20sync=20agent-knowledge=20optional=20peer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@tangle-network/agent-knowledge` is an optional peerDep added to package.json a few releases ago but pnpm-lock.yaml was never refreshed. CI runs `pnpm install --frozen-lockfile` which trips on the specifier mismatch and fails before any tests run. `pnpm install` with `autoInstallPeers: true` (the repo default) resolves the peer to 1.4.0; record that in the lockfile. No code or runtime change. --- pnpm-lock.yaml | 71 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1dd5e7..86061c4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@tangle-network/agent-eval': specifier: ^0.33.1 version: 0.33.1(typescript@5.9.3) + '@tangle-network/agent-knowledge': + specifier: '>=1.3.0 <2.0.0' + version: 1.4.0(typescript@5.9.3)(viem@2.48.8(typescript@5.9.3)(zod@4.4.2)) devDependencies: '@biomejs/biome': specifier: ^2.4.0 @@ -72,28 +75,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.15': resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.15': resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.15': resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.15': resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==} @@ -340,79 +339,66 @@ packages: resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.2': resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.2': resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.2': resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.2': resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.2': resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.2': resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.2': resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.2': resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.2': resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.2': resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.2': resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.2': resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.60.2': resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==} @@ -462,6 +448,11 @@ packages: '@scure/bip39@2.2.0': resolution: {integrity: sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==} + '@tangle-network/agent-eval@0.29.1': + resolution: {integrity: sha512-mzxCZqgFOlW7F4Ozv/tBfF87FSPm2GI71myZu/EuboT1gHtIdviEI+3dD/AHV8RO/CPOzYbrDWWt8HY7pduI/A==} + engines: {node: '>=20'} + hasBin: true + '@tangle-network/agent-eval@0.33.1': resolution: {integrity: sha512-VAbg1UkC480Xzfi2jqiFMQLYykWvDMO47UHx4bb2rOeiogN1zzM10kPst3OotM+k1B2lbu51uoVnKDBnqK8zcw==} engines: {node: '>=20'} @@ -472,6 +463,17 @@ packages: engines: {node: '>=20'} hasBin: true + '@tangle-network/agent-knowledge@1.4.0': + resolution: {integrity: sha512-n9ZPkkgCwveVkYBrjgAxEiBWzUgxzurYQVGZZNHU0YB4lhkyXdi1sMI5mL7VBIeVYNM6FPnA91zOdcBJbdFhWA==} + engines: {node: '>=20'} + hasBin: true + + '@tangle-network/agent-runtime@0.19.0': + resolution: {integrity: sha512-WbXEnPRPqeg27b+FWxIkoBCAgyPUWyJo7dgPIUcGWYX6O5FR6gcSBKDxvLorpAC5fKSh1mn3INcpXpuflPZKrA==} + engines: {node: '>=20'} + peerDependencies: + '@tangle-network/sandbox': '>=0.1.2 <0.3.0' + '@tangle-network/sandbox@0.1.2': resolution: {integrity: sha512-6TPH9QgCgou9Bhc1kzLNL4/PRiT1mjId6NONY5Le/KT2kh77cXH8KN3TTY/cU+/eW+WM5FYJOy32FWl2HShXbw==} peerDependencies: @@ -1231,6 +1233,19 @@ snapshots: '@noble/hashes': 2.2.0 '@scure/base': 2.2.0 + '@tangle-network/agent-eval@0.29.1(typescript@5.9.3)': + dependencies: + '@asteasolutions/zod-to-openapi': 8.5.0(zod@4.4.2) + '@ax-llm/ax': 19.0.45(zod@4.4.2) + '@hono/node-server': 2.0.1(hono@4.12.16) + '@tangle-network/tcloud': 0.4.6(typescript@5.9.3)(zod@4.4.2) + hono: 4.12.16 + zod: 4.4.2 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + '@tangle-network/agent-eval@0.33.1(typescript@5.9.3)': dependencies: '@asteasolutions/zod-to-openapi': 8.5.0(zod@4.4.2) @@ -1246,6 +1261,28 @@ snapshots: '@tangle-network/agent-integrations@0.25.7': {} + '@tangle-network/agent-knowledge@1.4.0(typescript@5.9.3)(viem@2.48.8(typescript@5.9.3)(zod@4.4.2))': + dependencies: + '@tangle-network/agent-eval': 0.29.1(typescript@5.9.3) + '@tangle-network/agent-runtime': 0.19.0(@tangle-network/sandbox@0.2.1(viem@2.48.8(typescript@5.9.3)(zod@4.4.2)))(typescript@5.9.3) + '@tangle-network/sandbox': 0.2.1(viem@2.48.8(typescript@5.9.3)(zod@4.4.2)) + zod: 4.4.2 + transitivePeerDependencies: + - bufferutil + - openai + - typescript + - utf-8-validate + - viem + + '@tangle-network/agent-runtime@0.19.0(@tangle-network/sandbox@0.2.1(viem@2.48.8(typescript@5.9.3)(zod@4.4.2)))(typescript@5.9.3)': + dependencies: + '@tangle-network/agent-eval': 0.33.1(typescript@5.9.3) + '@tangle-network/sandbox': 0.2.1(viem@2.48.8(typescript@5.9.3)(zod@4.4.2)) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + '@tangle-network/sandbox@0.1.2(viem@2.48.8(typescript@5.9.3)(zod@4.4.2))': optionalDependencies: viem: 2.48.8(typescript@5.9.3)(zod@4.4.2)