Files
takopi/docs/adding-a-runner.md
T
2026-01-01 20:31:11 +04:00

179 lines
6.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Adding a Runner
This guide walks through adding a new engine to Takopi without changing the
domain model. Use the existing runners (Codex/Claude) as references.
## Quick checklist
1. Implement `Runner` in `src/takopi/runners/<engine>.py` (usually via
`JsonlSubprocessRunner`).
2. Emit Takopi events from `takopi.model` and implement resume helpers
(`format_resume`, `extract_resume`, `is_resume_line`).
3. Define `BACKEND = EngineBackend(...)` in the runner module (auto-discovered),
including `install_cmd` (and `cli_cmd` only if the binary name differs).
4. Extend tests (runner contract + engine-specific translation tests).
---
## Example: adding a `pi` engine
This is a concrete walkthrough for an imaginary CLI called `pi`. The goal is to
make it easy to drop in another engine without changing the Takopi domain model.
### 1) Decide engine identity + resume format
- Engine id: `"pi"` (used in config, resume tokens, and CLI subcommand).
- Canonical resume line: the engines own CLI resume command, e.g.
`` `pi --resume <session_id>` ``.
- Pick the resume line format you want to support and define a regex for it in
the runner (Claude is a good example). If you choose the
`"<engine> resume <token>"` shape, you can use that exact regex.
### 2) Implement `src/takopi/runners/pi.py`
Recommended: `JsonlSubprocessRunner`
For JSONL CLIs, this base class centralizes subprocess + JSONL plumbing,
lock timing, and completion semantics. Your runner usually only needs:
- `command()` (binary name)
- `build_args(...)`
- `translate(...)` (map one JSON object to a list of Takopi events)
Optional hooks for common variants:
- `stdin_payload(...)`: return `None` if the prompt is passed via argv
- `env(...)`: add or redact environment variables
- `invalid_json_events(...)`: customize the warning event
- `process_error_events(...)`: customize `rc != 0` handling
- `stream_end_events(...)`: customize stream-end fallback (no `CompletedEvent`)
- `handle_started_event(...)`: customize session-id validation
If you call `note_event(...)`, your state object must include `note_seq` or
override `next_note_id(...)`.
Skeleton outline (JSONL CLI):
```py
ENGINE: EngineId = "pi"
_RESUME_RE = re.compile(r"(?im)^\s*`?pi\s+--resume\s+(?P<token>[^`\\s]+)`?\\s*$")
@dataclass
class PiRunner(ResumeTokenMixin, JsonlSubprocessRunner):
engine: EngineId = ENGINE
resume_re: re.Pattern[str] = _RESUME_RE
pi_cmd: str = "pi"
model: str | None = None
allowed_tools: list[str] | None = None
def command(self) -> str:
return self.pi_cmd
def build_args(
self, prompt: str, resume: ResumeToken | None, *, state: Any
) -> list[str]:
_ = prompt, state
args = ["--jsonl", "--verbose"]
if resume is not None:
args.extend(["--resume", resume.value])
if self.model is not None:
args.extend(["--model", self.model])
if self.allowed_tools:
args.extend(["--allowed-tools", ",".join(self.allowed_tools)])
return args
def stdin_payload(
self, prompt: str, resume: ResumeToken | None, *, state: Any
) -> bytes | None:
_ = resume, state
return prompt.encode()
def translate(
self,
data: dict[str, Any],
*,
state: Any,
resume: ResumeToken | None,
found_session: ResumeToken | None,
) -> list[TakopiEvent]:
_ = state, resume, found_session
...
```
Key implementation notes:
- Use `BaseRunner` (or `JsonlSubprocessRunner`) for per-session serialization.
- Mix in `ResumeTokenMixin` (with a `resume_re`) or override
`format_resume` / `extract_resume` / `is_resume_line` so the runner owns
resume encoding/decoding.
- For JSONL CLIs, prefer `JsonlSubprocessRunner` and implement `command`,
`build_args`, and `translate` (override `stdin_payload` if the prompt should
be passed via argv instead of stdin).
- If you dont use `JsonlSubprocessRunner`, use `iter_jsonl(...)` +
`drain_stderr(...)` from `takopi.utils.streams`.
- **Minimal mode is supported:** start with exactly one `StartedEvent` and one
`CompletedEvent`. `ActionEvent`s are optional and can be added later. If you
do emit actions, you can emit only `phase="completed"` notes without tracking
pending state.
- **Do not truncate** tool outputs in the runner; pass full strings into events.
Truncation belongs in renderers.
### 3) Map Pi JSONL → Takopi events
Example Pi lines (imaginary):
```json
{"type":"session.start","session_id":"pi_01","model":"pi-large"}
{"type":"tool.use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}
{"type":"tool.result","tool_use_id":"toolu_1","content":"ok","is_error":false}
{"type":"final","session_id":"pi_01","ok":true,"answer":"Done."}
```
Mapping guidance:
- `session.start``StartedEvent(engine="pi", resume=<session_id>, title=<model>)`
- `tool.use``ActionEvent(phase="started")`
- `tool.result``ActionEvent(phase="completed")` and **pop** pending actions
- `final``CompletedEvent(ok, answer, resume)` (emit **exactly one**)
If Pi emits warnings/errors before the final event, surface them as completed
`ActionEvent`s (e.g., `kind="warning"`).
### 4) Expose the backend (auto-discovered)
Takopi discovers runners by importing modules in `takopi.runners` and looking
for a module-level `BACKEND: EngineBackend` (from `takopi.backends`).
At the bottom of `src/takopi/runners/pi.py`, define:
```py
BACKEND = EngineBackend(
id="pi",
build_runner=build_runner,
install_cmd="npm install -g @acme/pi-cli",
)
```
No changes to `engines.py` or `cli.py` are required.
Only modules that define `BACKEND` are treated as engines. Internal/testing
modules (like `mock.py`) should omit it.
If the CLI binary name differs from the engine id, set `cli_cmd="pi-cli"` on
the backend.
Example config (minimal):
```toml
[pi]
model = "pi-large"
allowed_tools = ["Bash", "Read"]
```
### 5) Tests + fixtures
- Add `tests/test_pi_runner.py` for translation behavior.
- Reuse `tests/test_runner_contract.py` to ensure lock/resume invariants.
- Add JSONL fixtures under `tests/fixtures/` for the Pi stream.