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
79 changes: 79 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
71 changes: 54 additions & 17 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/loops/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ export type {
Iteration,
LoopDecisionPayload,
LoopEndedPayload,
LoopIterationDispatchPayload,
LoopIterationEndedPayload,
LoopIterationStartedPayload,
LoopResult,
LoopSandboxClient,
LoopSandboxPlacement,
LoopStartedPayload,
LoopTraceEmitter,
LoopTraceEvent,
Expand Down
50 changes: 49 additions & 1 deletion src/loops/run-loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -36,6 +38,7 @@ import type {
Iteration,
LoopResult,
LoopSandboxClient,
LoopSandboxPlacement,
LoopTraceEmitter,
LoopTraceEvent,
LoopWinner,
Expand Down Expand Up @@ -264,6 +267,20 @@ async function executeIteration<Task, Output>(args: ExecuteIterationArgs<Task, O

try {
const box = await createSandboxForSpec(args.ctx.sandboxClient, spec, args.signal)
const placement = describePlacementSafe(args.ctx.sandboxClient, box)
await emitTrace(args.ctx.traceEmitter, {
kind: 'loop.iteration.dispatch',
runId: args.runId,
timestamp: args.now(),
payload: {
iterationIndex: args.item.index,
agentRunName: slot.agentRunName,
placement: placement.kind,
sandboxId: placement.sandboxId,
fleetId: placement.fleetId,
machineId: placement.machineId,
},
})
const message = spec.taskToPrompt(args.item.task)
const events: SandboxEvent[] = []
for await (const event of box.streamPrompt(message, { signal: args.signal })) {
Expand Down Expand Up @@ -303,6 +320,37 @@ async function executeIteration<Task, Output>(args: ExecuteIterationArgs<Task, O
}
}

function describePlacementSafe(
client: LoopSandboxClient,
box: SandboxInstance,
): LoopSandboxPlacement {
if (typeof client.describePlacement === 'function') {
try {
const result = client.describePlacement(box)
if (
result &&
typeof result === 'object' &&
(result.kind === 'sibling' || result.kind === 'fleet')
) {
return {
kind: result.kind,
sandboxId: result.sandboxId ?? readSandboxId(box),
fleetId: result.fleetId,
machineId: result.machineId,
}
}
} catch {
// Adapter bug must not corrupt the iteration; fall through to default.
}
}
return { kind: 'sibling', sandboxId: readSandboxId(box) }
}

function readSandboxId(box: SandboxInstance): string | undefined {
const raw = (box as unknown as { id?: unknown }).id
return typeof raw === 'string' && raw.length > 0 ? raw : undefined
}

async function createSandboxForSpec<Task>(
client: LoopSandboxClient,
spec: AgentRunSpec<Task>,
Expand Down
1 change: 1 addition & 0 deletions src/loops/trace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
export type {
LoopDecisionPayload,
LoopEndedPayload,
LoopIterationDispatchPayload,
LoopIterationEndedPayload,
LoopIterationStartedPayload,
LoopStartedPayload,
Expand Down
Loading
Loading