Skip to content

Latest commit

 

History

History
751 lines (619 loc) · 40.5 KB

SUBAGENTS.md

File metadata and controls

751 lines (619 loc) · 40.5 KB

Task Decomposition & Sub-Agents

odek can spawn focused sub-agent OS processes for parallel, isolated work on independent sub-tasks. Each sub-agent is its own operating system process — not a goroutine, not a lightweight thread — with its own heap, its own config, and its own context window.

# Spawn a sub-agent directly
odek subagent --goal "Build JWT auth middleware in internal/middleware/auth.go" \
  --context "Uses gin, user model at internal/models/user.go"

# Machine-readable JSON on stdout, human-readable progress on stderr
# → {"status":"success","summary":"...","files_changed":[...],"tokens_used":4200,"iterations":3}

Why OS processes?

Approach Isolation Panic Safety Memory Testability
Goroutine (in-process) Shared heap One panic kills all Shared Easy
OS process Full isolation Independent Separate Via CLI
Docker container Kernel isolation Independent Heavy Slow

Sub-agents use OS processes (os/exec) — real isolation without Docker overhead. A panic in a sub-agent exits only that sub-agent (exit code 3). The parent agent continues unaffected.

Architecture

┌─────────────────────┐
│   Parent Agent       │
│   (ReAct loop)       │
└────────┬────────────┘
         │ delegate_tasks({ tasks: [...] })
         │
         ▼
┌─────────────────────────────────┐
│   delegateTasksTool              │
│   ────────────────────           │
│   • Writes each task to temp     │
│     file (avoids CLI arg limits) │
│   • Spawns odek subagent         │
│     per task via exec.Command    │
│   • Semaphore for concurrency    │
│   • Collects JSON from stdout    │
│   • Returns formatted summary    │
└──┬──────────┬──────────┬────────┘
   │          │          │
   ▼          ▼          ▼
┌──────┐ ┌──────┐ ┌──────┐
│ SA 1 │ │ SA 2 │ │ SA 3 │  ← OS processes (parallel)
└──────┘ └──────┘ └──────┘

When to decompose

Decompose a task when it has clear, independent sub-tasks with minimal cross-cutting concerns:

  • Good: "Build a user CRUD API" → { "Create user model", "Create handler", "Create routes" }
  • Bad: "Refactor the auth module to use JWT" — a single goal with tight coupling, better done in one shot
  • Bad: "Fix bug X" or "Review PR Y" — too small, overhead of spawning a sub-agent isn't worth it

Each sub-agent gets a fresh context — no parent history, no conversation state. It starts from the system prompt and its goal. Provide enough context so it doesn't need to re-discover the project structure.

Tool: delegate_tasks

The delegate_tasks tool is available in CLI, REPL, Web UI, Telegram, and headless scheduled runs. The agent calls it automatically when it identifies independent sub-tasks. odek mcp does not register it.

Schema

{
  "type": "object",
  "properties": {
    "tasks": {
      "type": "array",
      "minItems": 1,
      "maxItems": 8,
      "items": {
        "type": "object",
        "properties": {
          "goal":       { "type": "string" },  // Required. Specific goal for this sub-agent.
          "context":    { "type": "string" },  // Optional. Background: file paths, API contracts.
          "guidance":   { "type": "string" },  // Optional. How to approach the task — delivered in
                                               //   the request, NOT the system prompt (which is fixed).
          "trust_level": { "type": "string", "enum": ["trusted", "untrusted"] },
                                               // Optional. Trust level of the goal/context strings.
                                               //   "untrusted" when any portion derives from external
                                               //   content (fetched pages, files outside CWD, MCP output);
                                               //   untrusted tasks run with stricter approval defaults.
          "max_risk":   { "type": "string",
                          "enum": ["safe", "local_write", "system_write", "destructive",
                                   "code_execution", "network_egress", "network_upload", "install", "blocked"] },
                                               // Optional cap on the allowed risk class. Calls above the
                                               //   cap are denied without prompting — use for read-only
                                               //   fan-out tasks. Rank, low to high: safe, local_write,
                                               //   install, network_egress, network_upload,
                                               //   code_execution, system_write, persistence, unknown,
                                               //   destructive, blocked — so network_egress allows
                                               //   fetches but not uploads or code execution.
                                               //   Operator profiles.*.max_risk also
                                               //   accepts persistence, unknown, and unread_exec
                                               //   (validated at load; unread_exec is enforced by the
                                               //   trust lockdown, not this clamp).
          "profile":    { "type": "string" },  // Optional. Operator-defined capability profile name
                                               //   (top-level `profiles` config). Its max_risk, allowlist,
                                               //   and tool filter OVERRIDE the operator's global config
                                               //   for this sub-agent. Unknown names fail the task.
          "model":      { "type": "string", "minLength": 1, "maxLength": 256 }
                                               // Optional. Model ID supported by the parent's provider;
                                               //   omitted inherits the parent's model. Provider, endpoint,
                                               //   and credentials always remain those of the parent.
        },
        "required": ["goal"]
      }
    },
    "description": { "type": "string" }    // Optional. Logged for debugging.
  },
  "required": ["tasks"]
}

Output format

{
  "status": "success",            // "success" or "error"
  "summary": "Built JWT auth middleware with HS256 signing",
  "summary_truncated": true,      // present only when the headline was cut at 2048 runes
  "summary_runes": 3120,          // original headline length — present only when truncated
  "files_changed": ["internal/middleware/auth.go"],
  "tokens_used": 4200,
  "iterations": 3,
  "parent_session": "20260519-abc123"  // echoed back when --parent-session was passed
}

The summary is the headline channel: the child's final answer, capped at 2048 runes. Truncation is tail-keep — the END of the answer is preserved (verdicts, next actions, key decisions), and a leading … marks the cut. When summary_truncated is set, the parent-side render appends headline truncated (2048 of N runes shown) — fetch artifacts via artifact_read or re-run with a narrower goal (the artifact_read half is omitted in processes that do not have the tool, i.e. mid-tree parents). Both fields are omitempty: results that fit the headline produce byte-identical JSON to older versions.

The parent_session field is omitted when --parent-session was not supplied. Use it to correlate sub-agent results back to the originating parent session in logs, dashboards, or audit pipelines.

On failure:

{
  "status": "error",
  "error": "create agent: odek: no API key provided",
  "summary": "",
  "files_changed": [],
  "tokens_used": 0,
  "iterations": 0
}

What the tool does

  1. Deserializes the task array from the LLM's tool call
  2. Validates: rejects empty, >8 tasks, or malformed JSON
  3. Writes each task to a temp file (odek-task-*.json) — avoids CLI argument length limits (useful for 100KB+ context)
  4. Spawns odek subagent --task <file> --quiet for each task
  5. Limits concurrency via a process-wide buffered-channel semaphore (default: 3, max: configurable) — sibling delegate_tasks calls in one batch and concurrent odek serve sessions share the same bound, since provider plans are account-wide
  6. Collects JSON result from each subprocess stdout
  7. Returns a formatted summary with all sub-agent results tagged by task number

CLI: odek subagent

Direct invocation for testing and debugging:

# Basic
odek subagent --goal "List files in /tmp"

# With context
odek subagent --goal "Build auth middleware" --context "Uses gin framework"

# From file (for large context)
odek subagent --task /path/to/task.json

# With timeout and iteration limits
odek subagent --goal "Refactor main.go" --timeout 60 --max-iter 10

# Silent mode (suppresses emoji progress on stderr)
odek subagent --goal "Run tests" --quiet

# With parent session ID (for cross-session context)
odek subagent --goal "Continue refactoring" --parent-session "20260519-abc123"

Exit codes

Code Meaning When
0 Success Task completed normally, status: "success" in JSON
1 Task error Agent failed with a recoverable error, status: "error" in JSON
2 Timeout Context deadline exceeded (controlled by --timeout)
3 Setup failure Invalid flags, missing config, or internal panic

Every value-taking flag (--goal, --context, --task, --parent-session, --profile, --timeout, --max-iter) is a setup failure when its value is missing, so a wrapper with an empty variable cannot silently drop a requested capability profile.

Flags

Flag Default Description
--goal <string> — Required unless --task specified. The sub-agent's goal.
--context <string> "" Background context (file paths, design decisions)
--task <file> — JSON file with the task spec (see Task file format). Mutually exclusive with --goal.
--timeout <sec> 1800 Max seconds the sub-agent may run before being killed (hard max 1800 = 30 min)
--max-iter <n> 15 Max think→act cycles (hard max 100)
--quiet false Suppress emoji progress on stderr
--stream false Emit tool events as JSON lines on stdout while the run progresses (the final JSON result is also printed on stdout at exit)
--parent-session <id> — Session ID from the parent (for context relay)
--profile <name> — Select an operator-defined capability profile (top-level profiles config). The profile's max_risk/allowlist/tool filter override the operator's global config; unknown names fail the task

Task file format

For large prompts that exceed CLI argument length limits, use the --task flag with a JSON file:

{
  "goal": "Create a user registration endpoint in handlers/user.go",
  "context": "Uses gin. DB connection at internal/db/db.go. User struct in models/user.go: {ID, Email, Password, CreatedAt}. Password must be bcrypt-hashed. Returns 201 with user JSON on success.",
  "guidance": "",
  "trust_level": "trusted",
  "max_risk": "local_write",
  "profile": "",
  "parent_trust": "",
  "provider": "deepseek",
  "model": "deepseek-v4-flash",
  "base_url": "",
  "budget": {"max_runtime_seconds": 0, "max_tool_calls": 0, "max_input_tokens": 0, "max_output_tokens": 0, "max_cost_usd": 0}
}

All keys except goal are optional. trust_level, max_risk, profile, and model mirror the delegate_tasks task fields; parent_trust records the spawning agent's trust. Omitted models inherit the parent's model. Model availability is validated by the provider.

Through delegate_tasks, the model can select only a model ID on the parent's provider. The tool stamps the inherited provider and base URL and passes the API key through the existing file descriptor channel. Provider, endpoint, and credentials are not selectable tool arguments.

In share mode, budget carries the parent's remaining budget; the child enforces min(operator limits, inherited values) (zero values are ignored). A shared cost budget requires both input and output prices for the selected model, resolved through an exact limits.model_prices match with per-field flat-price fallback; missing prices fail closed.

The delegate_tasks tool always uses this file-based approach internally.

Output protocol

stdout (machine-readable)

Pure JSON. Always parseable — even on errors. The parent delegate_tasks tool parses this stream line-by-line (json.Unmarshal per line):

// Success
{"status":"success","summary":"Created handlers/user.go with POST /users","files_changed":["handlers/user.go"],"tokens_used":3200,"iterations":5}

// Error
{"status":"error","error":"no API key provided","summary":"","tokens_used":0,"iterations":0}

stderr (human-readable)

Emoji-prefixed progress for terminal users:

🔧 Sub-agent: Build JWT auth middleware
🧠 Need to understand the project structure...
🔧 shell: ls internal/
✅ Sub-agent complete: 4.2s, 3200 tokens, 5 iterations

Suppressed with --quiet.

  • At iteration boundaries, when the run crosses 50% / 75% / 90% of its iteration, wall-clock, tool-call, token, or cost budget, the engine appends a one-line budget hint to the corrections message and emits a budget_warning signal, so the model can pace itself and conclude cleanly instead of being cut off mid-work (subagent.announce_budget, default on). Parent runs use the top-level announce_budget toggle (also default on; --no-announce-budget / ODEK_ANNOUNCE_BUDGET=false); the two keys are independent.
  • The wall-clock budget is two-stage: a finalization window (min(15s, timeout/8)) before the hard kill is reserved for a bounded partial-progress summary — a timed-out sub-agent returns status: "partial", partial_reason: "time_budget" and a usable report instead of nothing. Iteration-budget exhaustion reports partial_reason: "iteration_budget"; a hard execution budget reports status: "budget_exhausted" (exit code 4).
  • Delegation depth is capped (subagent.max_depth, default 2, tracked via ODEK_SUBAGENT_DEPTH): a sub-agent at the cap refuses to fan out further.
  • With subagent.budget_inherit: "share", the parent writes its remaining budget into each task file and the child enforces min(operator limits, parent remaining) — a near-exhausted parent can no longer spawn children with fresh headroom.

System prompt & request (trust boundary)

A sub-agent's system prompt is a fixed, code-defined constant (subagentSystem in cmd/odek/subagent.go). It composes three parts: a focused-task identity block, the same invariant security pillar the parent prompt carries (shared securityPillar: Safety, Execution provenance, and Indirect Prompt Injection sections), and sub-agent amendments that adapt principal-facing rules to a child with no principal channel — skip-and-report replaces confirmation, scope is the declared task, a value taken from untrusted content is used only when the declared task calls for it, a denied operation is never retried another way (nor one the request says was denied), the declared task stands in for the principal in the pillar's precedence order, and injection findings go into the final report. Nothing the parent supplies is ever spliced into it — not the parent's system prompt, not --system, not IDENTITY.md.

All parent-supplied strings travel in the user request instead, assembled by buildSubagentRequest():

Task: <goal>

Approach (guidance from the orchestrator):
<guidance>          # optional — how to tackle it, NOT an identity

Context:
<context>           # optional — file paths, API contracts, decisions

This separation is deliberate. The goal/guidance/context may contain text the parent ingested from untrusted sources (fetched pages, MCP output, files). Keeping them out of the system prompt means a prompt-injection payload can never rewrite the sub-agent's identity or strip its safety rules — at worst it's a hostile request, which the fixed SAFETY block tells the model to treat as data.

Approvals, denials & trust inheritance

Sub-agents are autonomous by design and never prompt for approvals — not even trusted ones. Prompt-class operations are denied (non_interactive: deny is forced for every sub-agent); the operator allowlist (exact pre-approved invocations) is the only path to prompt-class operations. That includes the network_upload class (file-backed request bodies, credentials on the command line, mutating HTTP methods, local-to-remote copies, listeners and tunnels): network_egress stays inside a network_egress cap, an upload does not, and untrusted tasks deny both. Untrusted sub-agents never load mcp_servers (MCP adapters do not danger-classify). Trusted and capped children do, under the child's DangerousConfig cap. See MCP.md.

Denials are not silent: each child reports the policy denials it observed in its result (denials: [{tool, class, reason}], capped at 20 with denials_total for the full count), and the parent emits one subagent_denied runtime event per denial — so the parent model can adapt ("network denied → fetch it myself") and operators get uniform visibility.

Trust is non-increasing downward: the delegate tool stamps the parent's effective trust into the task file (parent_trust), and the child runs at min(parent_trust, trust_level). A task tree rooted in untrusted content cannot launder itself into trusted children. Only the exact label trusted counts as trusted; an empty or unrecognised trust_level or parent_trust (Untrusted, trusted , low, ...) fails closed to untrusted.

The trust_level a model requests is honoured only on a clean run. delegate_tasks clamps every requested child to untrusted when the parent run is tainted. What taints (external content only):

  • any tool result that can carry external content — the engine wraps each tool's output as tool:<name> and records an ingest, so the first such call taints the run (file, shell, network, search, session, memory, skill, background and MCP tools, and every embedder tool);
  • @-refs, --ctx files, attachments, Telegram forwards/voice/captions/media, session_search results, sub-agent results, background-job notices;
  • project instructions: a loaded AGENTS.md is repository content, so a run in a repository with an AGENTS.md (and no_agents_md off) is tainted from its first iteration;
  • any registered MCP tool (see below);
  • resuming a session that was ever tainted (the session's sticky untrusted_ingested flag survives context and write-time trimming).

What does not taint: the engine's own derived context — the plan, the rolling digest (compaction), the memory block, re-wrapped persisted system messages, progress summaries, effect evidence, reviewed skills, extended-memory recall, and the return-after-break summary. (Episode recall does taint: an episode summarises another session and is admitted by the memory gate's per-tool rule, which is weaker than this taint.) These blocks are wrapped too, but they derive from history that was already taint-tracked when it entered; a tool can never produce their labels.

Pure built-in calls do not taint either. A first-party call whose output comes only from the model's own arguments or from operator-only config keeps its untrusted boundary, under the engine-derived label pure_tool:<name>, but records no ingest and does not taint. The pure calls are: math_eval; base64 inline encoding (content without decode/string; file mode and decoding taint); list_subagent_profiles; list_tools unless the listing shows an MCP server introduced by the project ./odek.json; and plan. For list_tools, purity covers the operator-written MCP command and argument strings only, not what those commands resolve to: the tool never runs them. config_view is not pure, because the resolved view includes values a project ./odek.json may set, and neither are diff, json_query, tree, checksum, head_tail (each reads the filesystem), clarify, send_message and speak. Purity is honoured only for registered first-party types (tool.RegisterPureOutputType, in an internal package): an embedder or MCP tool that implements PureOutputFor, a type that embeds a built-in, and any tool behind untrustedToolWrapper all still taint.

So trusted delegation is available when a run delegates before any tainting tool call, in a session that has never ingested external content, with no MCP server and no AGENTS.md loaded. MCP tool names, descriptions and schemas are third-party text in the tool catalogue — no ingest is ever recorded for them, yet a poisoned description can steer a delegate_tasks call. The secure default wins: while any MCP server is loaded, trusted delegation is unavailable and every child runs untrusted (no MCP, no bg_*, dangerous classes denied). To delegate trusted work, run without MCP servers (set "enabled": false on each server, or use a config without mcp_servers). Sub-agents that load MCP servers themselves clamp their own children the same way.

Capability profiles let the operator define named permission envelopes in the top-level profiles config (see CONFIG.md): a task selects one via profile: "name" and the profile's max_risk/allowlist/ tools override the corresponding global config for that sub-agent. Profiles are operator-authored (project config is stripped) and selecting one cannot lift the non-interactive deny or the trust lockdown — policy, not escalation. A curated starter set of 21 task profiles (builders, reviewers, judges, researchers, orchestrators, …) ships in profiles.template.json.

When a task selects no profile, the operator's default envelope applies: a built-in default profile (max_risk: "local_write") unless overridden via subagent.default_profile (a defined profile name, or "none" to disable). The precedence chain is --profile flag > task-file profile > default envelope, and "none" is honored only from operator config — a delegating parent can narrow its choice but never strip the operator's envelope. The parent model discovers available profiles (including descriptions and the effective default) via the built-in list_subagent_profiles tool.

A profile may be selected by the operator's direct odek subagent --profile flag or by the parent via the task file (delegate_tasks's profile field); the flag outranks the task file. Unknown names fail the task twice over: delegate_tasks validates before spawning a child, and the child re-validates against its own resolved config (defense in depth — also covering manual --task invocation).

Untrusted tasks are fenced

Whenever the child's effective trust is untrusted — the parent set trust_level: "untrusted", omitted trust_level, or is itself untrusted (trust never increases downward) — the entire request body is wrapped in an <untrusted_input> fence with a preamble telling the model to treat it as data, not instructions — in addition to the permission clamp applied by applySubagentTrust (see SECURITY.md). The fence keys on the effective trust, not on the label the parent's model declared.

Steering the approach

To influence how a sub-agent works, pass guidance (not a system prompt):

{
  "tasks": [
    {
      "goal": "Review middleware/auth.go for security issues",
      "guidance": "Look for token-validation gaps, timing attacks, and secret exposure."
    },
    {
      "goal": "Fix the OOM in parser.js",
      "guidance": "Find the root cause before changing code; prove the fix with a test."
    }
  ]
}

There is no system field — it was removed precisely because it let parent-controlled (and possibly injection-tainted) text become the sub-agent's identity. ODEK_SYSTEM / config system also do not apply to sub-agents; the boundary is intentionally fixed.

Task file format

The temp file written by delegate_tasks carries the request inputs, never a system prompt:

{
  "goal": "Create a user registration endpoint in handlers/user.go",
  "context": "Uses gin. DB connection at internal/db/db.go.",
  "guidance": "Validate inputs; return structured errors.",
  "trust_level": "trusted",
  "max_risk": "local_write",
  "profile": "",
  "parent_trust": "trusted",
  "budget": {"max_runtime_seconds": 0, "max_tool_calls": 0, "max_input_tokens": 0, "max_output_tokens": 0, "max_cost_usd": 0}
}

Configuration

Config in odek.json:

{
  "subagent": {
    "max_concurrency": 3,
    "timeout_seconds": 1800,
    "max_iterations": 15
  }
}
Field Default Description
max_concurrency 3 (global default; falls back to it when unset here) Max sub-agents running in parallel (max 8)
timeout_seconds 1800 Default timeout per sub-agent; hard max 1800 (values above are clamped)
max_iterations 15 Default max think→act cycles per sub-agent
max_depth 2 Delegation nesting cap (1 = a sub-agent may not delegate further)
announce_budget true Inject budget-awareness hints (50/75/90% of budget) and announce effective limits in the sub-agent system prompt. Independent of the parent announce_budget config key
budget_inherit "operator" Budget inheritance for child runs: "operator" (full operator limits) or "share" (child enforces min of operator limits and the parent's remaining budget)

The subagent section is operator-only: a project-level ./odek.json cannot set it (ignored with a warning), so a cloned repository cannot relax sub-agent guards.

Security model

Risk Mitigation
Sub-agent hijacking Sub-agents are never prompted by the parent/user — they receive structured goal/context strings. No instruction injection path.
Runaway processes Hard timeout (--timeout, default 1800s = 30 min, hard max 1800s). Context cancellation kills via os.Process.Kill().
Resource exhaustion Concurrency semaphore (max max_concurrency). Sequential spawning. No fork bomb.
Panic propagation Each sub-agent is an OS process. Panic exits only that process with code 3 — parent sees the JSON error and continues.
Temp file leakage Each task file is defer os.Remove()'d after subprocess exit.

Testing

The sub-agent system has three test layers:

Layer Runner What's verified
Contract tests go test ./cmd/odek/ Flag parsing, JSON stdout protocol, exit codes, tool schema, config parsing, fixed system-prompt trust boundary (buildSubagentRequest carries goal/guidance/context; system prompt unaffected by parent input; untrusted fencing)
E2E tests ODEK_E2E=1 go test ./cmd/odek/ -run "TestE2E_" Real subprocess spawning, tool → binary pipeline, stderr protocol, concurrency, timeouts, fixed system-prompt boundary (no threading — parent input never reaches the system prompt)
Full suite go test -race ./... Every package, race-detector clean

E2E tests:

  • Build the odek binary once via TestMain
  • Test the full pipeline: tool.Call() → exec.Command("odek", "subagent", ...) → JSON stdout → parse
  • Require no live LLM provider: setup-failure tests verify error contracts, and a local mock provider verifies selected model IDs and model-specific cost accounting.
  • Validate: binary exists, stderr emoji protocol, quiet mode, 100KB+ task files via temp files, missing binary graceful degradation

Choosing a task model

The main agent can set tasks[].model in delegate_tasks independently for each child. Omit it to inherit the parent's current model. A single batch can mix explicit model selections and inherited models without changing the parent or sibling tasks.

For example, ask the main agent: “Run an adversarial review with a Flash model, and have another subagent review the test coverage using your current model.” For a parent configured to use DeepSeek, the tool arguments can be:

{
  "tasks": [
    {
      "goal": "Review the authentication change for bypasses and report findings",
      "model": "deepseek-v4-flash",
      "max_risk": "safe",
      "context": "Review the implementation and tests. Do not modify files."
    },
    {
      "goal": "Review authentication test coverage and report missing cases",
      "max_risk": "safe",
      "context": "Review the tests independently. Do not modify files."
    }
  ]
}

The first child requests deepseek-v4-flash; the second inherits the parent's model. Use an exact model ID supported by your provider. “Flash” and “cheaper” are task guidance, not built-in model aliases or automatic price selectors. The provider validates model availability; odek does not discover available models or switch providers for a task. Provider, endpoint, and credentials remain inherited from the parent.

Explicit model names must contain 1–256 characters after trimming surrounding spaces and must not contain control characters. Empty or invalid names reject the batch before any child starts. Omit model to request inheritance.

Model selection does not change capability profiles, trust restrictions, or execution limits. With a shared cost budget, both input and output prices must resolve for the selected model through limits.model_prices or the flat-price fallback; otherwise the child fails before making an LLM call. Configure per-model prices when comparing spend across different models.

Example: End-to-end flow

User: "Create a REST API for a todo app in Go with JWT auth"

Parent agent thinks:
- This has 3 independent parts: model, auth, handlers
- Each can be built in parallel
- Calls delegate_tasks

Tool call:
delegate_tasks({ tasks: [
  { goal: "Create Todo model with CRUD in models/todo.go",
    context: "Uses GORM, fields: {ID, Title, Done, CreatedAt}" },
  { goal: "Create JWT auth middleware in middleware/auth.go",
    context: "Uses gin, HS256 signing, secret from env JWT_SECRET" },
  { goal: "Create todo handlers in handlers/todo.go",
    context: "Uses gin, depends on models/todo.go. Routes: GET/POST /todos, PUT/DELETE /todos/:id" }
]})

3 sub-agents run in parallel:

  SA1: odek subagent --task /tmp/kode-task-001.json --quiet
  SA2: odek subagent --task /tmp/kode-task-002.json --quiet
  SA3: odek subagent --task /tmp/kode-task-003.json --quiet

All complete in ~5s (2 batches of 2, max_concurrency=3). The parent receives
each result rendered as fields — not raw JSON:

  SA1: status: success · ~4200 tokens
       summary: Created models/todo.go — GORM CRUD with all fields per spec.
       files changed: models/todo.go
  SA2: status: success · ~3800 tokens
       summary: JWT middleware done — HS256, secret from JWT_SECRET env.
       files changed: middleware/auth.go
  SA3: status: success · ~5100 tokens
       summary: Handlers wired for GET/POST/PUT/DELETE /todos.
       files changed: handlers/todo.go

Parent synthesizes: "Created 3 files:
  - models/todo.go — Todo model with CRUD
  - middleware/auth.go — JWT auth middleware with HS256
  - handlers/todo.go — REST handlers
  Total: 8 files changed, 13100 tokens, 5s parallel"

Result artifacts

The result contract is two-channel: the headline (≤ 2048 runes, tail-kept — the END of the child answer survives so verdicts and next actions reach the parent; a leading … marks the cut) carries status; the bulk rides files. The child is told this at request time: deliverables larger than a headline go as FLAT files into its per-task staging directory (.odek-artifacts/<task_id>/ inside the workspace — nested directories are discarded). The runner relocates them to ~/.odek/artifacts/<session>/<task>/, measures sha256/size, and returns odek.artifact-ref/v1 references with the result.

The parent sees one metadata line per artifact — id, media type, size, short hash, owning task, first-line summary — plus inlined content for small text artifacts (≤ 32 KiB) within a 128 KiB per-call inline budget (largest first; the rest degrade to their metadata line, never dropped). Everything larger is readable on demand via artifact_read:

artifact_read({ "id": "report" })                # first 64 KiB
artifact_read({ "id": "report", "offset": 65536 })  # continue paging

artifact_read is a parent-side tool; the model passes an id, never a path — resolution goes through the session registry of validated refs (ids are namespaced per parent session: a session never resolves or lists another session's artifacts, and ids never collide across sessions), and each read reports the owning task. Ids derive from filename stems, and first occurrence wins: if two tasks stage files with the same stem (e.g. both write report.md), the first task keeps the plain id and later duplicates register under <id>.t<task> aliases (report.t2, report.t3, …) — probing past any live entry, including real stems that collide with the alias namespace. Rendered artifact lines always show the effective id plus the task number, so the parent copies an id artifact_read can actually resolve. Refs that fail validation (wrong hash, path escape) are dropped with an explicit flag and never rendered.

Lifecycle: deleting a session deletes its artifacts (all paths — CLI, API, Telegram, retention sweep); the storage janitor backstop sweeps orphans after maintenance.artifacts_max_age_hours (default 24 hours, 0 = keep forever).

Wire contract (v2)

Clients (Web UI, bodek) consume sub-agent telemetry through three surfaces: live subagent_state WS frames, GET /api/subagents (registry snapshot — same fields as the frames), and the framed result envelope. All v2 fields are omitempty: old clients ignore them, and absent means "unavailable", never zero.

Phases. A task's phase moves queued → started → active → finished. queued is emitted by the parent the moment delegate_tasks accepts a task — before it acquires a concurrency slot — so an 8-task delegation on a 2-slot operator reads 2 live · 6 queued. Queued tasks count as neither active nor finished in the stats.

State frame fields. In addition to the v1 fields (task_id, task_idx, run_key, phase, status, step, iterations, tool, duration_seconds, tokens_used):

Field Carries Notes
goal task goal text redacted, clamped to 2048 chars server-side (model-controlled input)
profile profile id declared value while queued; the child's effective (post-clamp) value from started on
max_risk effective risk ceiling declared while queued; effective (post operator-profile resolution) from started
budget_seconds, budget_iterations enforced caps 0/absent = uncapped; present on started and active
budget_cost_usd enforced cost cap only when cost enforcement is active (cap + resolved prices)
cost_usd server-side cost estimate cumulative on live frames; final on finished
artifacts [{id, path, bytes}] terminal only — metadata from the result envelope; content never rides the wire

The framed result envelope adds cost_usd (final) and artifacts (the full odek.artifact-ref/v1 refs — a superset of the frame metadata).

Result authentication. Commands a child runs can write lines to the child's stdout, so the parent does not trust any line that merely looks like a result. For every spawn the parent mints a random nonce and hands it to the child over a private inherited descriptor (ODEK_SUBAGENT_FRAME_FD names the descriptor, never the value); the child reads and closes it at startup, before any tool runs, and stamps it as auth on its framed result. The parent takes the first framed result carrying the nonce as the task result and ignores every later result line, forged or duplicate. Only that authenticated frame may report usage for budget accounting: without one, the shared-budget grant is settled as unreported (never refunded), and an unauthenticated result line is kept for display only: its status becomes unverified (the claimed status is kept as claimed_status), the completion event and registry report unverified, and the parent announces the terminal state itself.

On Linux the child also makes itself non-dumpable (prctl(PR_SET_DUMPABLE, 0)) before reading its inherited descriptors, so a background command it left running cannot reopen its stdout, frame or key descriptor through /proc/<pid>/fd to steal the frame and replay the nonce. As a consequence sub-agent processes write no core dumps and cannot be attached to by same-uid debuggers or tracers (gdb, strace -p); root or CAP_SYS_PTRACE still can. Commands the child runs are not affected (the flag resets on execve).

Cost semantics (authoritative — do not re-derive). cost_usd values are computed server-side with the exact /api/usage math (per-million prices resolved for the child's model over provider-reported token totals). Zero or absent means "prices not configured" — clients must render cost as unavailable, never $0. Totals are split, not folded: the serve-lifetime tokens_in/tokens_out/estimated_cost_usd in /api/usage cover parent turns only (children are separate odek processes with their own sessions); sub-agent spend is broken out under subagents.tokens_used and, as of v2, subagents.cost_usd (lifetime sum of final per-task estimates). A client total including sub-agents is therefore estimated_cost_usd + subagents.cost_usd — or, per batch, the sum of cost_usd over final result envelopes only (cumulative state-frame values double-count on replay).

Blocking model — deny, never prompt. Sub-agents can never park waiting on an approval: the child runs non-interactive with deny forced for every operation that would prompt, and denied operations are listed in the result's denials array (capped, with a separate total). There is no waiting_approval state and none is planned; a card showing running with denied operations is working through them or will finish with them reported. Clients should treat error / timeout / cancelled as the only sticky outcomes.

Tips

  • Keep goals small — one file, one concern per sub-agent. If a goal spans 3 files, it's probably not a good decomposition boundary.
  • Provide file paths in context — saves the sub-agent from crawling the project tree.
  • Check the trade-off — spawning a sub-agent takes ~500ms. Don't delegate tasks that complete in 2 tool calls.
  • Observation: sub-agents work best for greenfield work (creating new files). Refactoring existing code often has too many implicit dependencies.

Persistent runtime logs

The unified operational log records delegated task lifecycle and timings when the parent CLI has logging enabled. Child records share the conversation's session_id and user-visible turn_id, while each invocation has its own run_id; root_run_id, parent_run_id, and task_id describe the delegation tree. Terminal task status and the parent's observed child-process exit status are separate fields. An unfinished tree row does not prove a child is still running. Records expire according to logging.max_age_hours. See Runtime logging.

Sandbox inheritance

When the parent's sandbox is active, every child spawned by delegate_tasks is started with ODEK_SANDBOX=true and sandboxes itself; if the child's sandbox cannot start the child fails instead of running on the host. The parent's container posture (read-only workspace, image, network, memory, cpus, user) and the project-sandbox approvals it was granted this session (keys bound to the project directory and the Dockerfile.odek content hash) ride in the task file, so a child's container is never weaker than the parent's and a y (once) approval covers the children it spawned; a changed Dockerfile is not covered. A parent running without a sandbox leaves children to their own configuration.

The parent's configured tool filter (the allow and deny sets, including --tool / --no-tool) is also written to the task file; the child applies it on top of its own config and profile filter, so delegation never regains a tool the parent was configured without. A parent with no filter writes none, and the child keeps its normal tool set.