Skip to content

Commit 1878fd1

Browse files
committed
Add Serve mode.
1 parent 1117b62 commit 1878fd1

6 files changed

Lines changed: 1504 additions & 15 deletions

File tree

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
7979
- **MCP support** — optional MCP integration through the `[mcp]` extra. MCP tools become ordinary agent tools such as `mcp__<server>__<tool>`. Supports `stdio`, `streamable-http`, and `sse` transports.
8080
- **Slash commands** — built-in `/init`, `/review`, `/explain`, and other commands, plus custom commands loaded from `prompts/commands/*.md`.
8181
- **Custom agents** — switch the main agent's system prompt at runtime with `/agent`. Agent prompt files live in `prompts/agents/*.md`. Use `default_agent` in the config file to start sessions with a specific agent.
82+
- **Embeddable runtime boundaries** — drive the agent from programs: `headless --json` for one-shot CI/scripting (write-only JSONL stream), `serve` for hosting apps (resident process, bidirectional protocol: multi-turn memory, mid-run Q&A, protocol-level cancel).
8283

8384
## Inspired by opencode
8485

@@ -247,6 +248,48 @@ Interactive prompts are auto-answered (`confirm` → yes, `ask` →
247248
- Exit code is 0 on success, 1 when the prompt was empty (only failed
248249
`@file` references), the run raised an agent error, or the restore failed.
249250
251+
### Serve mode (resident JSONL server)
252+
253+
```sh
254+
python-agent-harness serve [--project DIR] [--answer-timeout SECONDS]
255+
```
256+
257+
A persistent, bidirectional runtime boundary for hosting applications
258+
(a web backend, an IDE, a CI driver). Unlike `headless --json` (one
259+
prompt per process, write-only stream), `serve` keeps the
260+
`Controller`/`Session` resident and speaks a request/response protocol
261+
over stdin/stdout — the same process boundary (containerizable), but
262+
the host can:
263+
264+
- submit multiple prompts over the process's lifetime — no per-turn
265+
interpreter spawn, and conversation history is retained between them
266+
(multi-turn memory);
267+
- answer the agent's mid-run questions (the `Question` tool and
268+
plan-exit confirmation) via an `answer` op;
269+
- cancel a run as a protocol message (no signal semantics).
270+
271+
Protocol (one JSON object per line):
272+
273+
```
274+
host → agent: {"op": "submit", "prompt": ..., "run_id": ...}
275+
{"op": "answer", "run_id": ..., "answers": [...]}
276+
{"op": "cancel", "run_id": ...}
277+
{"op": "ping"} | {"op": "shutdown"}
278+
agent → host: {"type": "ready"} first line
279+
{"seq": N, "type": "start"|"delta"|"notify"|"log", "run_id": ...}
280+
{"seq": N, "type": "result", "run_id": ..., "answer": ...,
281+
"errors": [...], "usage": {...}, "cancelled": bool}
282+
{"type": "error", "error": ...} protocol failures
283+
```
284+
285+
A mid-run question arrives as a `notify` with `kind: "ask"` (data has
286+
`kind: "ask"|"confirm"`); reply with `answer`. One run at a time; a
287+
`submit` while one is active is rejected with an `error` line.
288+
`--answer-timeout SECONDS` bounds how long a pending question waits
289+
for the host's answer (default: forever). The result line's shape is
290+
identical to `headless --json`'s, so a driver can speak both
291+
protocols with one parser.
292+
250293
### Slash commands
251294
252295
| Command | Description |

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ classifiers = [
2727
dependencies = [
2828
"rich>=13.0",
2929
"httpx>=0.27",
30+
"certifi>=2024.0",
3031
"prompt_toolkit>=3.0",
3132
]
3233

python_agent_harness/entry/cli.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
run interactive TUI agent session (default)
55
headless [prompt] non-interactive: submit one prompt, print the result
66
(--json emits the run as JSON lines instead)
7+
serve resident JSONL server over stdin/stdout: submit
8+
runs repeatedly, answer mid-run questions, cancel
79
config [--init] show effective LLM config / write a template file
810
911
Custom commands (prompts/commands/*.md) — like init, review,
@@ -233,6 +235,24 @@ def cmd_headless(args: argparse.Namespace) -> int:
233235
session.close()
234236

235237

238+
def cmd_serve(args: argparse.Namespace) -> int:
239+
project_dir = getattr(args, "project", None) or os.getcwd()
240+
session = make_session_with_mcp(
241+
project_dir,
242+
config_path=args.config,
243+
stream=False if getattr(args, "no_stream", False) else None,
244+
)
245+
try:
246+
from .server import run_serve
247+
248+
return run_serve(
249+
session,
250+
answer_timeout=float(getattr(args, "answer_timeout", 0.0) or 0.0),
251+
)
252+
finally:
253+
session.close()
254+
255+
236256
def cmd_config(args: argparse.Namespace) -> int:
237257
path = config._config_path(args.path)
238258
if args.init:
@@ -424,6 +444,30 @@ def build_parser() -> argparse.ArgumentParser:
424444
p_config.add_argument("--init", action="store_true", help="write a config template")
425445
p_config.add_argument("--path", metavar="PATH", help="config file path")
426446
p_config.set_defaults(func=cmd_config)
447+
448+
p_serve = sub.add_parser(
449+
"serve",
450+
help="resident JSONL server: multiple runs per process, mid-run Q&A",
451+
)
452+
_add_config_arg(p_serve, suppress=True)
453+
p_serve.add_argument(
454+
"--no-stream",
455+
action="store_true",
456+
help="disable streaming (one-shot responses; overrides config file)",
457+
)
458+
p_serve.add_argument(
459+
"--project",
460+
metavar="DIR",
461+
help="project directory (default: cwd)",
462+
)
463+
p_serve.add_argument(
464+
"--answer-timeout",
465+
metavar="SECONDS",
466+
type=float,
467+
default=0.0,
468+
help="give up waiting for a host answer after N seconds (0 = wait "
469+
'forever, the default; the host can answer via {"op": "answer"})',
470+
)
427471
return parser
428472

429473

@@ -434,6 +478,8 @@ def main(argv: list[str] | None = None) -> int:
434478
return cmd_run(args)
435479
if args.command == "headless":
436480
return cmd_headless(args)
481+
if args.command == "serve":
482+
return cmd_serve(args)
437483
if args.command == "config":
438484
return cmd_config(args)
439485
parser.print_help()

0 commit comments

Comments
 (0)