Skip to content

Commit e6bccb8

Browse files
committed
Use serve mode instead of headless mode agent.
1 parent 7828d37 commit e6bccb8

14 files changed

Lines changed: 1050 additions & 354 deletions

‎README.md‎

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,21 @@
88

99
The trusted side of an agent platform: API server, controller, auth,
1010
billing, secrets. The untrusted side — the agent runtime itself — is
11-
`python-agent-harness`, executed as a **subprocess** with
12-
`python-agent-harness headless --json` and consumed as a JSON-lines
13-
event stream. The harness is never imported; the repos stay decoupled
14-
(the harness only needs to be on PATH of whatever runs the agent).
11+
`python-agent-harness`, executed as a **resident subprocess** per
12+
sandbox: `python-agent-harness serve`, a bidirectional JSON-lines
13+
protocol over stdin/stdout. The harness is never imported; the repos
14+
stay decoupled (the harness only needs to be on PATH of whatever runs
15+
the agent).
1516

1617
```
1718
Coding Space Server
1819
│
1920
┌──────▼─────────┐
2021
│ Agent Controller│ app.controller
2122
└──────┬─────────┘
22-
│ subprocess: python-agent-harness headless --json
23+
│ resident subprocess: python-agent-harness serve (JSONL over pipes)
2324
┌──────▼─────────┐
24-
│ Sandbox │ app.controller.runner (LocalRunner now, Docker later)
25+
│ Sandbox │ app.controller.runner (ServerRunner now, Docker later)
2526
│ agent process │
2627
│ tools, bash, … │
2728
└─────────────────┘
@@ -33,7 +34,7 @@ Controller agent-generated commands
3334
Auth (JWT) repository, builds
3435
Billing (usage ledger)
3536
Secrets (Fernet)
36-
Runner (sandbox manager contract; LocalRunner today, Docker later)
37+
Runner (sandbox manager contract; ServerRunner today, Docker later)
3738
```
3839

3940
## Layout
@@ -49,13 +50,14 @@ app/
4950
schemas.py Pydantic request/response models
5051
routes/
5152
auth.py POST /auth/register /auth/login /auth/refresh /auth/me
52-
conversations.py CRUD + POST /{id}/runs (start) + GET /{id}/stream (SSE)
53+
conversations.py CRUD + POST /{id}/runs (start) + POST /{id}/answer
54+
+ GET /{id}/stream (SSE)
5355
billing.py usage summary (token ledger)
5456
secrets.py CRUD (write-only read: value never returned)
5557
controller/
56-
protocol.py Parse harness --json lines (seq/run_id/result/usage)
57-
manager.py Controller: start_run, event pump, subscribe, cancel
58-
runner.py Runner protocol + LocalRunner (subprocess exec, cancel)
58+
protocol.py Parse harness event lines (seq/run_id/result/usage)
59+
manager.py Controller: start_run, event pump, subscribe, cancel, answer
60+
runner.py Runner protocol + ServerRunner (resident serve process)
5961
static/index.html Minimal chat UI (EventSource -> runs, fetch -> API)
6062
```
6163

@@ -71,6 +73,26 @@ uvicorn app.main:app --reload # http://127.0.0.1:8000 (UI at /)
7173
`PAW_HARNESS__CMD` at the absolute binary if it is not. Auth: create
7274
a user via `/auth/register`, then log in; the UI does this for you.
7375

76+
## The serve protocol (harness side)
77+
78+
One sandbox = one long-lived `serve` process. The web side writes
79+
ops, the harness answers with events:
80+
81+
```
82+
host → harness: {"op": "submit", "prompt": ..., "run_id": ...}
83+
{"op": "answer", "run_id": ..., "answers": [...]}
84+
{"op": "cancel", "run_id": ...} / {"op": "ping"} / {"op": "shutdown"}
85+
harness → host: {"type": "ready"} then per-run
86+
start/delta/notify/log lines and a terminal
87+
{"type": "result", "answer": ..., "usage": ..., "cancelled": ...}
88+
```
89+
90+
Because the process is resident: conversation history persists across
91+
turns (multi-turn memory), no per-turn interpreter spawn, `answer`
92+
delivers the user's reply to a pending mid-run question (the agent's
93+
Question tool / PlanExit confirm), and cancel is a protocol message —
94+
no signal semantics.
95+
7496
## Configuration (env, prefix `PAW_`)
7597

7698
| var | default | note |
@@ -80,23 +102,24 @@ a user via `/auth/register`, then log in; the UI does this for you.
80102
| `PAW_ACCESS_TOKEN_MINUTES` | `30` | JWT access TTL |
81103
| `PAW_REFRESH_TOKEN_DAYS` | `14` | JWT refresh TTL |
82104
| `PAW_HARNESS__CMD` | `python-agent-harness` | harness binary |
83-
| `PAW_HARNESS__CWD` | `""` | agent workspace dir per run |
84-
| `PAW_HARNESS__MAX_ROUNDS` | unset | round budget forwarded to `--max-rounds` |
85-
| `PAW_HARNESS__TIMEOUT` | unset | wall-clock budget forwarded to `--timeout` |
86-
| `PAW_RUNNER` | `local` | `local` only; docker later |
105+
| `PAW_HARNESS__CWD` | `""` | agent workspace dir per sandbox |
106+
| `PAW_HARNESS__TIMEOUT` | unset | host-side wall-clock budget for one run |
107+
| `PAW_RUNNER` | `server` | `server` only; docker later |
87108
| `PAW_SANDBOX__TTL_SECONDS` | `300` | idle reaper TTL |
88109

89110
## Design notes
90111

91112
- **Decoupling**: the harness is a black-box binary driven by its
92-
documented `headless --json` protocol (`start`/`delta`/`notify`/
93-
`log`/`result` lines, `seq` for ordering, `run_id` for correlation,
94-
`usage` for billing). No imports, no shared state; the web side
95-
can be versioned and deployed independently.
96-
- **Runs are processes**: one conversation turn = one harness exec =
97-
one `Run` row; events stream to subscribers over SSE exactly as the
98-
harness emitted them (plus run lifecycle events), and the `result`
99-
line lands in the DB.
113+
documented JSONL protocols (the same `start`/`delta`/`notify`/
114+
`log`/`result` line shapes on the resident `serve` pipe and the
115+
one-shot `headless --json` pipe; `seq` for ordering, `run_id` for
116+
correlation, `usage` for billing). No imports, no shared state; the
117+
web side can be versioned and deployed independently.
118+
- **Runs are protocol turns, not process lifecycles**: one
119+
conversation turn = one `op:submit` = one `Run` row; the resident
120+
process survives the run and serves the next turn. Events stream to
121+
subscribers over SSE exactly as the harness emitted them (plus run
122+
lifecycle events), and the `result` line lands in the DB.
100123
- **Secrets** are Fernet-encrypted at rest and never returned by the
101124
API; they are meant to be injected into the sandbox environment by
102125
the sandbox manager (not exposed to agents via the API).

‎app/controller/__init__.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
from .manager import Controller
44
from .protocol import ProtocolEvent, RunOutcome, apply_event, parse_line, parse_stream
5-
from .runner import ExecResult, LocalRunner, Runner, get_runner
5+
from .runner import ExecResult, Runner, ServerRunner, get_runner
66

77
__all__ = [
88
"Controller",
99
"ExecResult",
10-
"LocalRunner",
1110
"ProtocolEvent",
1211
"RunOutcome",
1312
"Runner",
13+
"ServerRunner",
1414
"apply_event",
1515
"get_runner",
1616
"parse_line",

‎app/controller/manager.py‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,10 @@ def _worker() -> None:
133133
return run
134134

135135
def cancel_run(self, run_id: str) -> bool:
136-
"""Best-effort graceful cancel: signal the harness process via
137-
the runner (SIGINT path; tools get their salvage window) and
138-
mark the intent on the run row. The run's own exit (result
139-
line with ``cancelled: true``, or error) finalizes the row."""
136+
"""Best-effort graceful cancel: send the protocol cancel op to
137+
the resident harness process and mark the intent on the run
138+
row. The run's own exit (result line with ``cancelled: true``,
139+
or error) finalizes the row."""
140140
with self._lock:
141141
active = self._active.get(run_id)
142142
if active is None:
@@ -150,6 +150,22 @@ def cancel_run(self, run_id: str) -> bool:
150150
db.commit()
151151
return True
152152

153+
def deliver_answer(self, run_id: str, answers: list[str]) -> bool:
154+
"""Forward a user's answer to a pending mid-run question.
155+
156+
Returns False when the run is not live (unknown/finished); the
157+
route maps that to 409. The runner forwards the answer as an
158+
``op:answer`` protocol message to the resident harness process.
159+
"""
160+
with self._lock:
161+
active = self._active.get(run_id)
162+
if active is None:
163+
return False
164+
delivered = getattr(self._runner, "deliver_answer", None)
165+
if delivered is None:
166+
return False
167+
return bool(delivered(active.sandbox_id, run_id, answers))
168+
153169
# -- internals ---------------------------------------------------------
154170

155171
def _ensure_sandbox(self, db: Session, *, user_id: str, conversation_id: str) -> Sandbox:

‎app/controller/protocol.py‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
"""Protocol types and parsing for the harness ``headless --json`` stream.
2-
3-
The harness emits one JSON object per line on stdout
4-
(``start``/``delta``/``notify``/``log``/``result``, each carrying
5-
``seq`` and ``run_id``; ``result`` carries ``answer``, ``errors``,
6-
``usage``, ``model``, ``cancelled``). This module turns raw output
7-
into typed events — the *only* coupling point with the harness repo,
8-
and purely a data contract.
1+
"""Protocol types and parsing for the harness event stream.
2+
3+
The harness emits one JSON object per line on stdout (``start``/
4+
``delta``/``notify``/``log``/``result``, each carrying ``seq`` and
5+
``run_id``; ``result`` carries ``answer``, ``errors``, ``usage``,
6+
``model``, ``cancelled``) — the same line shapes on both the one-shot
7+
``headless --json`` pipe and the resident ``serve`` protocol. This
8+
module turns raw lines into typed events — the *only* coupling point
9+
with the harness repo, and purely a data contract.
910
"""
1011

1112
from __future__ import annotations
@@ -14,7 +15,7 @@
1415
from dataclasses import dataclass, field
1516
from typing import Any
1617

17-
LINE_TYPES = {"start", "delta", "notify", "log", "result"}
18+
LINE_TYPES = {"start", "delta", "notify", "log", "result", "error"}
1819

1920

2021
@dataclass
@@ -87,7 +88,12 @@ def apply_event(outcome: RunOutcome, event: ProtocolEvent) -> None:
8788
outcome.answer = str(event.data.get("answer", ""))
8889
errors = event.data.get("errors")
8990
if isinstance(errors, list):
90-
outcome.errors = [str(e) for e in errors]
91+
# the result line is canonical for its own errors, but must
92+
# not wipe protocol errors folded from earlier lines (e.g.
93+
# a rejected mid-run answer): merge, preserving order
94+
for e in (str(e) for e in errors):
95+
if e not in outcome.errors:
96+
outcome.errors.append(e)
9197
usage = event.data.get("usage")
9298
outcome.usage = dict(usage) if isinstance(usage, dict) else None
9399
outcome.model = event.data.get("model")
@@ -96,6 +102,13 @@ def apply_event(outcome: RunOutcome, event: ProtocolEvent) -> None:
96102
message = event.data.get("data")
97103
if message is not None and str(message) not in outcome.errors:
98104
outcome.errors.append(str(message))
105+
elif event.type == "error":
106+
# harness protocol-level failure (unknown op, stale answer, a
107+
# cancel racing the run's finish): keep it in the run's error
108+
# trail so it surfaces instead of vanishing
109+
message = event.data.get("error")
110+
if message is not None and str(message) not in outcome.errors:
111+
outcome.errors.append(str(message))
99112

100113

101114
def parse_stream(text: str) -> list[ProtocolEvent]:

0 commit comments

Comments
 (0)