DESIGN.md@specs/graph-agents · git:20260811.d4dc255 · 2026-08-11 · sha256 8c5679e3bc15f8ca

DESIGN.md@specs/graph-agents git:20260811.d4dc255A

Immutable. This exact content never changes and is served at /api/v1/blob/8c5679e3bc15f8ca.

# Design: Graph-Based Agents for pi-go

## Overview

pi-go already runs on Google ADK Go (`google.golang.org/adk/v2 v2.1.0`) but uses only the
**agent loop** (`llmagent` + `runner.Runner` in `internal/agent/agent.go:368-385`). The
ADK **workflow graph engine** (`adk/v2/workflow`) ships in the same module and is never
imported. Meanwhile pi-go hand-rolls all orchestration as process spawning and
prompt-driven JSON pipelines.

This design adopts ADK's graph engine as the orchestration layer — **on top of** the
existing subprocess spawner, not replacing it. Deterministic pipelines (the `/run` spec
workflow, parallel research fan-out, review loops) become declared graphs with retries,
joins, routing, and cross-turn persistence. LLM-initiated single spawns stay as-is.

The reference checkout is `tmp/adk/adk-go` at v2.2.0-4-g817fdc0 — the same module pi-go
pins, four commits past v2.2.0.

## Current State (pi-go)

### What pi-go uses from ADK today

- `llmagent.New` — single agent loop (`internal/agent/agent.go:369`)
- `runner.Runner` — drives the run loop (`internal/agent/agent.go:385`)
- `session` — conversation state + events (`internal/session/store.go`)
- `tool` / `functiontool` / `mcptoolset` — tool surface
- `model` — provider abstraction

### What pi-go hand-rolls instead of using the graph engine

| ADK workflow primitive | pi-go's current equivalent | Where pi-go hand-rolls it |
|---|---|---|
| `RetryConfig` (backoff, jitter, `ShouldRetry`) | `maxRetries: 10` retry loop | `internal/tui/run.go:880-920` (`retryRun`) |
| `NodeConfig.Timeout` | per-agent timeout plumbing | `internal/subagent/orchestrator.go:438-441` |
| `JoinNode` fan-in barrier | manual parallel split + fan-in | `internal/tui/run.go:462-520` (`handleRunParallel`) |
| `Route`/`StringRoute` LLM-as-router | LLM manually picks subagent mode via JSON | `internal/tools/subagent.go:164-200` |
| `RequestInput`/`Resume` (HITL) | no orchestration-level pause | — |
| `ParallelWorker` + per-item sub-branches | `sync.WaitGroup` + pool semaphore | `internal/tools/subagent.go:317-456` |
| `WithMaxConcurrency` | `Pool` semaphore | `internal/subagent/pool.go` |
| `RunState` persisted in `session.State` | no cross-turn orchestration resume | — |
| `AgentNode` wrapping any `agent.Agent` | spawn `pi --mode json` subprocess | `internal/subagent/spawner.go:102-130` |

The engine also has validation pi-go lacks: cycle detection, unreachable nodes, duplicate
names, chat-mode wiring rules (`workflow/validation.go:197-240`).

### The architectural insight

pi-go's orchestration is **process-based and prompt-driven**: the LLM decides pipeline
shape at runtime by calling the `subagent` tool with `{tasks:[...]}` or `{chain:[...]}`
JSON, and each subagent is a fresh `pi` subprocess. ADK's workflow engine is
**graph-based and code-driven**: the pipeline is declared as nodes+edges, and the engine
executes it deterministically with retries, routing, joins, branch isolation, and
persistence.

They are complementary, not competing:

- **Keep** the `subagent` tool for LLM-initiated *single* spawns — that is the right
  primitive for "spawn an explore agent".
- **Move the deterministic pipelines into graphs**: parallel research (fan-out explore →
  JoinNode → synthesize), the `/run` spec workflow (task → gate → retry/merge), and
  plan→run→review.

## Detailed Requirements

### R1: Upgrade ADK to v2.2.0+

Bump `google.golang.org/adk/v2` from v2.1.0 to v2.2.0+ in `go.mod`. The tmp checkout is
v2.2.0-4-g817fdc0 and includes workflow fixes pi-go wants for graph adoption:

- `8bfc9ca` fix(workflow): propagate external context cancellation (#1258)
- `79b22d9` fix(runner): preserve nested workflow HITL resume responses (#1129)

The `workflow` package exists in v2.1.0, so this is a hardening step, not a prerequisite
for the API surface.

### R2: `SpawnNode` — wrap the subprocess spawner as a workflow node

New package `internal/graph/` (or `internal/workflow/`) with a custom `workflow.Node`:

```go
// SpawnNode wraps the subprocess spawner as a workflow node.
// Run calls Orchestrator.SpawnWithInput, forwards events via emit,
// and returns the aggregated result as Event.Output.
type SpawnNode struct {
    workflow.BaseNode
    orch  *subagent.Orchestrator
    agent subagent.AgentConfig   // name, role, worktree, timeout
    opts  SpawnNodeOptions       // worktree name, env, skip cleanup, ...
}

func NewSpawnNode(name string, orch *subagent.Orchestrator,
    agent subagent.AgentConfig, opts SpawnNodeOptions) *SpawnNode
```

Key behaviors:

- `Run(ctx agent.Context, input any)` — input is the task prompt (string) or a
  structured `{prompt, worktree_name, env}` payload.
- Calls `orch.SpawnWithInput(ctx, subagent.AgentInput{...})`, streams events through
  `emit` (so the TUI keeps its live pipeline view), and returns the final result text as
  `Event.Output`.
- **Retries move from `SpawnWithRetry` into the graph**: `NodeConfig.RetryConfig`
  (backoff, jitter, `ShouldRetry`) replaces the orchestrator's `maxRetries` loop.
- **Timeout moves into `NodeConfig.Timeout`** — the engine bounds a single activation;
  the orchestrator's absolute timeout stays as a backstop.
- Branch isolation: the engine's `WithUseSubBranch` / `WithOverrideBranch` give each
  spawned agent its own event-history scope, replacing the manual `branch` plumbing.

### R3: `/run` spec workflow as a graph (first slice)

Rebuild `internal/tui/run.go` orchestration as a declared graph. Today it is ~1100 lines
of bespoke logic: spawn task agent, retry up to 10×, parallel split into 2 agents, gate
checks, merge, backup branches.

**Graph shape (single-agent mode):**

```
START → taskAgent(SpawnNode, worktree) → gateCheck(FunctionNode) → merge(FunctionNode)
              ↑                                    │ fail
              └────────── retry (RetryConfig) ─────┘
```

**Graph shape (parallel mode):**

```
START → split(FunctionNode) ─┬→ taskAgentPart1(SpawnNode) ─┐
                             └→ taskAgentPart2(SpawnNode) ─┴→ JoinNode → gateCheck → merge
```

What the engine provides for free:

- `RetryConfig` — replaces `runState.maxRetries`/`retries` and `retryRun`
  (`internal/tui/run.go:880-920`). `ShouldRetry` predicate decides which failures retry.
- `JoinNode` — replaces `handleRunParallel`'s manual fan-in and `collapseParallel`
  (`internal/tui/run.go:96-99, 462-520`).
- `NodeConfig.Timeout` — replaces the 60-minute hard-coded spawn timeout.
- `RunState` persisted in `session.State` — cross-turn resume for interrupted runs.
  pi-go's `internal/session/store.go:780-830` already implements `session.State`, so the
  workflow's `RunState` persists with no new storage work.

What stays in the TUI:

- Event streaming to the chat view (the `waitForRunAgent` glue).
- Gate parsing from `PROMPT.md` (`## Gates` section) and gate execution — the gate
  commands run in the worktree via `exec.Command` as today.
- Merge/backup-branch bookkeeping (`createRunBackupBranch`, merge).
- The `runState` struct shrinks to: spec name, prompt, gates, agent events, phase.

**Acceptance bar:** behaviorally equivalent to today's `/run` — same gates, same retry
semantics (max 10 cycles), same parallel split (2 agents), same merge behavior — but
implemented as a declared graph, with cross-turn resume working. Existing
`internal/tui/run_*_test.go` tests must pass (adapted to the new structure).

### R4: Graph templates on the `subagent` tool

Expose a small set of named graph templates so the LLM picks a pipeline by name instead
of hand-rolling JSON:

```json
{ "template": "research",
  "topic": "how does auth work?",
  "agents": ["explore", "explore", "explore"] }
```

Templates (v1):

- **`research`** — fan-out N explore agents (one per sub-topic) → `JoinNode` → a
  synthesize agent. Replaces the LLM hand-rolling `{tasks:[...]}` and the
  `maxParallelTasks`/`maxChainSteps` caps (`internal/tools/subagent.go:31-33`).
- **`review`** — task agent → code-reviewer → fix loop (`loopagent` or a graph cycle
  with a route on "needs_fix" / "approved").
- **`sequential`** — the existing chain mode, now backed by `sequentialagent` or a
  `Chain` of `SpawnNode`s.

The engine's `WithMaxConcurrency` becomes the real concurrency limiter (replacing the
per-call `maxParallelTasks` cap); the orchestrator `Pool` stays as the process-level
budget.

### R5: Use `workflowagents` for simple compositions

`agent/workflowagents/` in the checkout has ready-made `sequentialagent`, `parallelagent`,
`loopagent`. The llm-auditor sample (`adk-samples/go/agents/llm-auditor/auditor/auditor.go`)
shows the critic→reviser pattern. Use these where the composition is a plain sequence,
fan-out, or loop; use the `workflow` package directly for routing, joins, and HITL.

### R6: HITL via `RequestInput`/`Resume`

The engine's `RequestInput` + `Resume` gives orchestration-level pause/resume. Today
pi-go has no orchestration-level pause — a subagent either runs to completion or is
killed. A `gateCheck` node that needs human approval (e.g. "merge this worktree?") can
emit a `RequestInput` and park the graph until the user answers. This is a natural fit
for the `/run` merge step and for kagent-style approval flows.

### R7: Borrow kagent patterns (reference only)

kagent (`tmp/adk/kagent`) shows production ADK wiring worth referencing:

- **A2A subagents with HITL propagation and live activity viewing**
  (`docs/architecture/a2a-subagents.md`) — pi-go already has A2A tools
  (`internal/tools/a2a.go`); the doc shows how to expose remote agents as first-class
  graph nodes with session continuity.
- **ACP shim** (`go/core/pkg/acpshim/`) — pi-go already spawns ACP subagents
  (claude/gemini/cursor/copilot); the shim pattern is a reference for wrapping them as
  graph nodes.
- **Memory tools** (`go/adk/pkg/memory/`) — reference for agent-controlled memory
  operations; pi-go already has `internal/memory/` and `internal/palace/`.

## Architecture Overview

```mermaid
graph TB
    subgraph "Agent Loop (unchanged)"
        LLM[llmagent + runner]
        TOOLS[Core Tools + subagent tool]
        LLM --> TOOLS
    end

    subgraph "Graph Engine (new)"
        WF[workflow.Workflow]
        SN[SpawnNode]
        JN[JoinNode]
        FN[FunctionNode]
        RC[RetryConfig / Timeout]
        RS[RunState in session.State]
        WF --> SN
        WF --> JN
        WF --> FN
        WF --> RC
        WF --> RS
    end

    subgraph "Subprocess Layer (unchanged)"
        ORC[Orchestrator]
        POOL[Pool]
        SP[Spawner]
        WT[WorktreeManager]
        ORC --> POOL
        ORC --> SP
        ORC --> WT
    end

    TOOLS -->|template: research/review| WF
    SN -->|SpawnWithInput| ORC
    TUI[TUI /run] --> WF
    TUI -->|event stream| LLM
```

### Key Architectural Decisions

1. **Graph on top of subprocesses, not in-process agents.** The `pi --mode json`
   subprocess model gives real isolation (sandbox, worktrees, crash containment) that
   in-process `AgentNode`s don't. `SpawnNode` wraps the existing spawner as a node body.
2. **Deterministic pipelines become graphs; LLM-initiated single spawns stay tools.**
   The `subagent` tool keeps single mode; templates route the deterministic shapes
   through the engine.
3. **Retries, timeouts, and concurrency move into the engine.** `RetryConfig`,
   `NodeConfig.Timeout`, and `WithMaxConcurrency` replace the hand-rolled
   `SpawnWithRetry` loop, per-agent timeout plumbing, and per-call caps.
4. **Cross-turn resume comes from `RunState` persistence.** pi-go's session store
   already implements `session.State`; the workflow's `RunState` persists with no new
   storage work. This subsumes the `/plan resume` mechanism for the run half.
5. **TUI stays thin.** Event streaming, gate parsing/execution, and merge bookkeeping
   remain in the TUI; orchestration state machines move to the graph.

## Components and Interfaces

### 1. `internal/graph/` (new package)

- `spawn_node.go` — `SpawnNode` (see R2).
- `templates.go` — graph template registry: `research`, `review`, `sequential`.
  Each template is a function `func(cfg TemplateConfig) ([]workflow.Edge, []workflow.Node, error)`.
- `run_spec.go` — the `/run` graph builder (see R3): takes spec name, prompt, gates,
  checklist; returns a `workflowagent`-backed agent or a `workflow.Workflow` the TUI
  drives directly.

### 2. `internal/tools/subagent.go` (modified)

- Add `Template string` to `SubagentInput`.
- Add `templateModeHandler` that resolves the template, builds the graph, and runs it
  through the engine, streaming events to the TUI via the existing `SubagentEventCallback`.
- Keep single/parallel/chain modes; parallel/chain may be re-expressed as templates
  later (R4).

### 3. `internal/tui/run.go` (modified)

- Replace the orchestration state machine with a graph run.
- Keep: gate parsing, gate execution, merge, backup branches, event streaming.
- Remove: `retryRun`, `handleRunParallel`'s manual fan-in, `collapseParallel`,
  `maxRetries`/`retries` fields.

### 4. `internal/agent/agent.go` (modified)

- `buildRunner` stays; optionally the root agent can be a `workflowagent` when a graph
  is active, but the default single-LLM-agent path is unchanged.

### 5. `go.mod` (modified)

- `google.golang.org/adk/v2` v2.1.0 → v2.2.0+.

## Acceptance Criteria

### ADK Upgrade
- Given `go.mod` pins v2.2.0+, when `make build` runs, then the binary builds.
- Given the workflow package is imported, when `go vet` runs, then no new findings.

### SpawnNode
- Given a `SpawnNode` wrapping the orchestrator, when the node runs, then it spawns a
  subagent, streams events, and returns the result as `Event.Output`.
- Given a failing subagent, when `RetryConfig` is set, then the node retries per the
  policy (backoff, jitter, `ShouldRetry`).
- Given a timeout in `NodeConfig.Timeout`, when the subagent exceeds it, then the node
  fails with a timeout error.

### /run Graph
- Given a valid spec with PROMPT.md, when `/run spec-name` executes, then the task
  agent spawns in an isolated worktree and events stream to the TUI in real-time.
- Given the agent completes and gates pass, then the worktree branch auto-merges.
- Given a gate fails with retries remaining, then the graph retries per `RetryConfig`
  in the same worktree.
- Given a gate fails past the retry budget, then the worktree is left intact and the
  failure is reported with the path.
- Given an interrupted run, when the user resumes the session, then the graph resumes
  from the paused node (RunState persistence).
- Given no `## Gates` section, then merge proceeds without validation.
- Given `specs/{spec-name}/PROMPT.md` does not exist, then an error shows with the list
  of available specs.
- Existing `internal/tui/run_*_test.go` tests pass (adapted to the new structure).

### Templates
- Given `{template: "research", topic: ..., agents: [...]}`, when the subagent tool
  runs, then N explore agents fan out, a JoinNode gathers results, and a synthesize
  agent merges them.
- Given `{template: "review", ...}`, when the tool runs, then task → code-reviewer →
  fix loop executes until approved or the loop budget is exhausted.
- Given a template with more agents than `WithMaxConcurrency`, when it runs, then
  concurrency is capped by the engine, not by a per-call constant.

### HITL
- Given a `gateCheck` node that emits a `RequestInput`, when the graph reaches it, then
  the graph parks and the TUI renders the prompt.
- Given the user answers, when the turn resumes, then the graph continues from the
  paused node.

## Out of Scope

- Replacing the subprocess spawner with in-process agents.
- Adopting `adk-utils-go` (Redis sessions, Postgres memory) — pi-go has its own
  memory/session layers.
- Adopting kagent's Kubernetes CRD model.
- Migrating pi-go's own agents to ACP servers (kagent's EP-XXXX-acp-integration is a
  reference, not a requirement).
- Graph-based memory (kagent EP-1256 explicitly lists it as a non-goal too).

## Reference

- `tmp/adk/adk-go/workflow/` — graph engine: `workflow.go` (Node/Edge/Route),
  `scheduler.go` (concurrency model), `state.go` (RunState/NodeState),
  `persistence.go` (resume), `retry.go`, `validation.go`, `edgebuilder.go`,
  `dynamic_node.go`, `join_node.go`, `parallel_worker.go`, `run_node.go`
- `tmp/adk/adk-go/agent/workflowagent/` — `workflowagent.New` (agent wrapper)
- `tmp/adk/adk-go/agent/workflowagents/` — `sequentialagent`, `parallelagent`, `loopagent`
- `tmp/adk/adk-go/examples/workflow/` — basic, routing/llm, dynamic/llm, complex
  (fan-out/fan-in research pipeline), hitl_simple, hitl_rerun
- `tmp/adk/adk-samples/go/agents/llm-auditor/` — sequential critic→reviser
- `tmp/adk/kagent/docs/architecture/a2a-subagents.md` — A2A subagent HITL + live activity
- `tmp/adk/kagent/go/core/pkg/acpshim/` — ACP shim pattern
- `internal/tui/run.go` — current hand-rolled /run orchestration
- `internal/tools/subagent.go` — current subagent tool with single/parallel/chain modes
- `internal/subagent/orchestrator.go` — spawner, pool, worktree, retry
- `specs/features/SOP/plan-command-sop/` — existing /plan + /run PDD pipeline