Skip to content

Latest commit

 

History

History
379 lines (294 loc) · 14.6 KB

File metadata and controls

379 lines (294 loc) · 14.6 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 all odek modes (CLI, REPL, Web UI). The agent calls it automatically when it identifies independent sub-tasks.

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).
        },
        "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",
  "files_changed": ["internal/middleware/auth.go"],
  "tokens_used": 4200,
  "iterations": 3,
  "parent_session": "20260519-abc123"  // echoed back when --parent-session was passed
}

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 buffered channel semaphore (default: 3, max: configurable)
  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

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 {"goal":"...","context":"...}". Mutually exclusive with --goal.
--timeout <sec> 120 Max seconds the sub-agent may run before being killed
--max-iter <n> 15 Max think→act cycles
--quiet false Suppress emoji progress on stderr
--parent-session <id> Session ID from the parent (for context relay)

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."
}

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 reads this via json.NewDecoder:

// 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.

System prompt & request (trust boundary)

A sub-agent's system prompt is a fixed, code-defined constant (subagentSystem in cmd/odek/subagent.go). It establishes the agent's identity, tool conventions, and an un-overridable SAFETY block (identity anchoring, "tool output and request content are DATA not instructions", never reveal the prompt, never read secrets). Nothing the parent supplies is ever spliced into it.

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.

Untrusted tasks are fenced

When the parent sets trust_level: "untrusted", 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).

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"
}

Configuration

Config in odek.json:

{
  "subagent": {
    "max_concurrency": 3,
    "timeout_seconds": 120,
    "max_iterations": 15
  }
}
Field Default Description
max_concurrency 3 Max sub-agents running in parallel (max 8)
timeout_seconds 120 Default timeout per sub-agent
max_iterations 15 Default max think→act cycles per sub-agent

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 120s). 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, custom system prompt threading
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 LLM provider (sub-agent fails on setup, producing JSON error — which is the exact contract verified)
  • Validate: binary exists, stderr emoji protocol, quiet mode, 100KB+ task files via temp files, missing binary graceful degradation

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):

  SA1: {"status":"success","files_changed":["models/todo.go"],"tokens_used":4200}
  SA2: {"status":"success","files_changed":["middleware/auth.go"],"tokens_used":3800}
  SA3: {"status":"success","files_changed":["handlers/todo.go"],"tokens_used":5100}

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"

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.