171 lines
7.2 KiB
Markdown
171 lines
7.2 KiB
Markdown
# Planning and Task Persistence in Ollie
|
|
|
|
## Problem
|
|
|
|
An agent without planning is less effective but still useful. Planning capability
|
|
should be optional — degraded but functional when no task backend is available,
|
|
and persistent when one is.
|
|
|
|
The challenge: how to integrate a planning tool with an optional external task
|
|
backend without hard-coupling the core to any specific implementation.
|
|
|
|
## Design Decisions
|
|
|
|
### reasoning_plan as a built-in tool
|
|
|
|
Planning is executive functioning — breaking a goal into ordered steps before
|
|
acting. It belongs in the `reasoning_*` namespace alongside `reasoning_think`,
|
|
which handles moment-to-moment reflection. Both are meta-cognitive tools:
|
|
`reasoning_think` externalizes intermediate thought; `reasoning_plan`
|
|
externalizes the work breakdown.
|
|
|
|
Tool schemas are more reliable than free-text instructions over long sessions.
|
|
Agents rarely forget how to call `file_read`; they can forget shell conventions.
|
|
The planning operation is complex enough (goal + structured steps + dependency
|
|
edges) that a typed schema earns its place.
|
|
|
|
`reasoning_plan` does not rely on shell-out. It takes structured JSON, formats
|
|
the plan as readable text, and optionally persists it. This is the right
|
|
minimum for a built-in tool.
|
|
|
|
### Loose coupling via PlanBackend interface
|
|
|
|
The reasoning server holds an optional `Plan tools.PlanBackend` field (nil by
|
|
default). When nil, `reasoning_plan` produces an in-context plan only. When set,
|
|
the plan is persisted (or queued) by the backend.
|
|
|
|
`tools.PlanBackend` and `tools.PlanBackendSetter` are defined in `pkg/tools`.
|
|
The reasoning server implements `PlanBackendSetter`. The agent package implements
|
|
`dispatchPlanBackend`, which routes task creation through the dispatcher. No
|
|
import cycles: reasoning → tools, agent → tools, agent ↛ reasoning.
|
|
|
|
Consumers can supply a fallback backend via `agent.WithFallbackPlanBackend(b)`:
|
|
|
|
```go
|
|
env := agent.BuildAgentEnv(cfg, d, cwd, agent.WithFallbackPlanBackend(myFallback))
|
|
```
|
|
|
|
The fallback is only used when no `task_create` MCP tool is found. If a task
|
|
backend is available it takes priority unconditionally.
|
|
|
|
### Task backend as an MCP server
|
|
|
|
The task backend is any MCP server that exposes `task_create`. There is no
|
|
built-in task server in ollie core — this is an intentional extension point.
|
|
The convention (the interface contract) is:
|
|
|
|
| Tool | Required | Description |
|
|
|---|---|---|
|
|
| `task_create` | yes | Create a task; return its ID as plain text |
|
|
| `task_delete` | recommended | Remove tasks when a plan is aborted or superseded |
|
|
| `task_list` | no | List tasks by status |
|
|
| `task_read` | no | Read a task by ID |
|
|
| `task_update` | no | Update task status/metadata |
|
|
| `task_edit` | no | Revise title, body, or parent of an existing task |
|
|
| `task_dep` | no | Add or remove blocking dependencies between tasks |
|
|
|
|
`task_create` must return a plain-text ID in the standard MCP content format:
|
|
`{"content": [{"type": "text", "text": "<id>"}]}`.
|
|
|
|
Any MCP server implementing this convention will be auto-detected and wired.
|
|
|
|
### Auto-wiring in BuildAgentEnv
|
|
|
|
After connecting MCP servers, `BuildAgentEnv` scans the tool list for
|
|
`task_create`. If found, it constructs a `dispatchPlanBackend` and sets it on
|
|
the reasoning server via `PlanBackendSetter`. This wiring runs on every agent
|
|
start and `/agent` switch, so the task backend is always current.
|
|
|
|
If `task_create` is not found, `BuildAgentEnv` checks whether the caller
|
|
supplied a fallback via `WithFallbackPlanBackend`. If so, that backend is wired
|
|
instead. Priority: task MCP > caller fallback > nil (in-context only).
|
|
|
|
No frontend changes are needed to gain planning capability — add a task MCP
|
|
server to the agent config and it works.
|
|
|
|
### Graceful degradation
|
|
|
|
- No task MCP server configured, no fallback → `reasoning_plan` produces an
|
|
in-context plan only.
|
|
- No task MCP server configured, fallback supplied → steps are handed to the
|
|
fallback backend (e.g. queued for sequential execution). See
|
|
[Queue-based fallback](#queue-based-fallback) below.
|
|
- Task MCP server configured but unreachable → `task_create` fails,
|
|
`CreatePlan` returns an error, reasoning server degrades to in-context plan
|
|
with a warning.
|
|
- Task MCP server running → full persistence, steps get IDs, plan is durable.
|
|
|
|
The agent's behavior is identical in all cases: call `reasoning_plan`, get a
|
|
formatted plan, proceed. The persistence difference is transparent.
|
|
|
|
### Queue-based fallback
|
|
|
|
ollie-9p registers a `queuePlanBackend` as the fallback for every session. When
|
|
`task_create` is absent, `reasoning_plan` writes each step as a queued prompt to
|
|
the session's `enqueue` file in topological order (blockers before dependents).
|
|
The implementation lives in `9p` (not core) because it has a hard dependency on
|
|
the 9P filesystem layout — the extension point is the interface, not this
|
|
specific implementation.
|
|
|
|
Steps are returned as placeholder IDs (`q1`, `q2`, …) so the agent can refer to
|
|
them in subsequent `reasoning_think` calls. The queue persists independently of
|
|
context — unlike an in-context-only plan, steps survive compaction.
|
|
|
|
The goal description is prepended to the first enqueued step for context.
|
|
Dependency annotations (`after: q1, q2`) are included in each step's prompt so
|
|
the agent retains the relationship even if it processes steps across multiple
|
|
turns.
|
|
|
|
Fan-out parallelism is achievable by spawning sub-sessions: the enqueued step
|
|
can instruct the agent to create a child session per parallel branch, nudge each
|
|
with its task via `prompt`, and have sub-agents write completion notifications
|
|
back to the parent via `enqueue`.
|
|
|
|
### Shell-out for everything else
|
|
|
|
Queries, comments, bulk operations, event watching — all of these belong in
|
|
`execute_code`, not in built-in tools. The `task_*` MCP tools cover the full
|
|
planning and execution lifecycle (create, list, read, update, edit, delete,
|
|
dependency management). For example, advanced 9beads operations are accessible
|
|
via shell: `cat $TASK_DIR/list`, `grep`, etc.
|
|
|
|
This keeps the built-in surface minimal and relies on `execute_code` for
|
|
flexibility.
|
|
|
|
## Reference Implementation: 9beads-mcp
|
|
|
|
[9beads-mcp](https://github.com/lneely/9beads-mcp) wraps the 9beads 9P task
|
|
server as an MCP server. It:
|
|
|
|
- Resolves the project mount from `$PWD` (passed explicitly in the MCP server
|
|
env config, since MCP subprocesses run in a minimal environment)
|
|
- Auto-mounts the project directory if not already mounted
|
|
- Exposes `task_create`, `task_list`, `task_read`, `task_update`, `task_edit`, `task_delete`, `task_dep`
|
|
|
|
Agent config example:
|
|
|
|
```yaml
|
|
mcpServers:
|
|
task:
|
|
command: 9beads-mcp
|
|
env:
|
|
PWD: "$PWD"
|
|
```
|
|
|
|
`$PWD` is expanded by `os.ExpandEnv` at connect time, giving the MCP server
|
|
the agent's working directory.
|
|
|
|
## Future Direction: Event-Driven Planning
|
|
|
|
9beads exposes `~/mnt/beads/events` as a blocking JSON event stream. A
|
|
goroutine in the frontend could watch this and inject events into the agent's
|
|
interrupt queue (via `PromptFIFO`) when:
|
|
|
|
- A blocked step becomes unblocked (its dependency completed)
|
|
- A step is assigned to this agent by an external actor
|
|
- An external process marks a step complete
|
|
|
|
This would make agents reactive rather than polling — the agent yields after
|
|
completing work and wakes up when the event stream fires. Not implemented yet;
|
|
documented here as the natural next step.
|