Skip to content

Commit acd4661

Browse files
committed
docs: Slim README to overview; move design detail into DESIGN.md
README now covers what it is, ports, run, and test only (and fixes the chat endpoint port: the external HTTP server, now 9190, not the webui port). The stub pair / pairing / teardown / tool-call detail moves to DESIGN.md.
1 parent b3e804e commit acd4661

2 files changed

Lines changed: 215 additions & 218 deletions

File tree

DESIGN.md

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# agent-backed-llm-server: design
2+
3+
How a stateless Chat Completions endpoint is bridged onto a durable, stateful
4+
CLI session. For what the app is and how to run it, see [README.md](README.md).
5+
6+
## Why a warm session, and the problem it creates
7+
8+
`claude` and `codex` are not chat models; they are stateful agents that keep a
9+
live session (working directory, edited files, their own context). To preserve
10+
that across turns, one long-running workflow owns one warm CLI process per
11+
conversation, exactly as the container does today.
12+
13+
But Chat Completions is **stateless**: there is no session id on the wire, and
14+
the client resends the whole message history every call. So the backend has to
15+
(1) figure out *which* warm session an incoming request belongs to, using only
16+
the history content, and (2) bridge a synchronous HTTP request/response onto a
17+
durable, long-running workflow.
18+
19+
## The stub pair
20+
21+
A **stub activity** is a special activity with no implementation, only a WIT
22+
signature. A workflow creates one as a child execution and obtains its execution
23+
id; from there it can be awaited, have its result injected, or be cancelled. That
24+
is all it is: a named, typed rendezvous identified by an execution id. Only a
25+
workflow can create one, so the session workflow owns creation. Once it exists,
26+
either side can drive it by id:
27+
28+
- an **external party (including this webhook)** can *inject* its result
29+
(`PUT /v1/executions/<id>/stub`) and *await* it
30+
(`GET /v1/executions/<id>?follow=true`, block-and-stream);
31+
- the **owning workflow** can *await* it, *inject* a result via the
32+
`stub-execution` REST activity, or *cancel* it. It can also race the await
33+
against a persistent sleep, which is how a session cleans itself up when the
34+
frontend goes idle (see [Idle and teardown](#idle-and-teardown)).
35+
36+
So the two roles below are a choice about who injects vs. who awaits for each
37+
stub, not a direction the primitive imposes.
38+
39+
A turn uses two stubs playing opposite roles:
40+
41+
- **`turn.request`** — inbound. The workflow submits it and awaits; the webhook
42+
fulfils it with the turn's new messages. Its params carry the routing info the
43+
webhook needs *before* it fulfils: the pairing key and the paired reply id.
44+
- **`turn.response`** — outbound. The workflow submits it, then self-fulfils it
45+
via the `stub-execution` REST call once `recv` produces the reply. The webhook
46+
reads it with `GET /v1/executions/<id>?follow=true` (block-and-stream).
47+
48+
```wit
49+
package agent-backed-llm:session;
50+
51+
interface turn {
52+
// What the webhook hands in for the next turn.
53+
variant next {
54+
delta(string), // JSON: messages appended since the last assistant reply
55+
teardown, // frontend abandoned the conversation / operator stop
56+
}
57+
58+
// INBOUND: workflow submits(response-id, expected-prefix-hash) & awaits;
59+
// webhook injects `next` by execution id.
60+
request: func(response-id: string, expected-prefix-hash: string)
61+
-> result<next, string>;
62+
63+
// OUTBOUND: workflow submits, then self-fulfils; webhook follows the result.
64+
// ok = JSON of the OpenAI assistant message { content?, tool_calls? }
65+
// err = structured failure (rate_limited{retry_after}, exited, ...)
66+
response: func() -> result<string, string>;
67+
}
68+
```
69+
70+
`deployment.toml`:
71+
72+
```toml
73+
[[activity_stub]]
74+
ffqn = "agent-backed-llm:session/turn.request"
75+
params = [
76+
{ name = "response-id", type = "string" },
77+
{ name = "expected-prefix-hash", type = "string" },
78+
]
79+
return_type = "result<variant { delta(string), teardown }, string>"
80+
81+
[[activity_stub]]
82+
ffqn = "agent-backed-llm:session/turn.response"
83+
params = []
84+
return_type = "result<string, string>"
85+
```
86+
87+
## The turn handshake
88+
89+
```
90+
session workflow, each turn:
91+
respId = joinSet.submit(turn.response, []) // child id created here
92+
reqId = raceSet.submit(turn.request, [respId, prefix_hash]) // params expose respId + key
93+
raceSet.submitDelay(idle) // persistent sleep
94+
winner = raceSet.joinNext() // request stub OR idle
95+
delta = obelisk.getResult(reqId) // -> delta | teardown
96+
session.send(delta); reply = session.recv()
97+
obelisk.stub(respId, { ok: replyJson }) // self-fulfil the reply
98+
// loop with the new committed history
99+
```
100+
101+
```
102+
webhook, each request:
103+
prefix_hash = hash(messages up to & including the last assistant message)
104+
if no assistant message in history: // turn 0
105+
obelisk.schedule(sessionId, workflow.session, [backend, systemPrompt])
106+
find that session's pending turn.request (by FFQN + session-id prefix)
107+
else:
108+
find any turn.request whose params.expected-prefix-hash == prefix_hash
109+
(NO MATCH => 409, see "mismatch")
110+
{ respId } = that stub's params
111+
PUT /v1/executions/<reqId>/stub { ok: { delta } } // deliver new messages
112+
reply = obelisk.get(respId) // block for the reply
113+
return it as the chat-completions response
114+
```
115+
116+
The two directions map onto plain runtime calls: the webhook submits the session
117+
with `obelisk.schedule`, delivers the delta with a REST `PUT .../stub`, and reads
118+
the reply with the blocking `obelisk.get(respId)`; the workflow self-fulfils the
119+
reply with `obelisk.stub(respId, …)`. Turn 0 differs only in how the session is
120+
located (by the id the webhook just scheduled, since there is no history hash to
121+
match yet); everything after is one code path. The webhook never creates a stub.
122+
123+
## Pairing without a header (and "mismatch fails")
124+
125+
The frontend sends no session id, so the backend keys sessions on the history
126+
itself. Each `turn.request` is submitted with `expected-prefix-hash` = a hash of
127+
the history the workflow has already committed (everything up to and including
128+
its own last assistant reply). That is exactly the prefix the client resends
129+
next turn, so the webhook pairs a request by matching
130+
`hash(incoming history up to its last assistant)` against the pending stubs.
131+
132+
- **No assistant message** in the history => turn 0 => start a new session.
133+
- **A match** => route the trailing messages (the delta) into that session.
134+
- **No match** but the history has assistant messages => the session is gone or
135+
the history diverged => **fail** (`409`). We are deliberately stricter than a
136+
real prefix cache: for a provider a miss just costs more, but here a miss means
137+
the stateful CLI session cannot be found.
138+
139+
Two identical histories in flight at once hash-collide onto one session; the
140+
webhook locks a session to one in-flight turn and rejects a second concurrent
141+
match.
142+
143+
## Idle and teardown
144+
145+
The workflow does not await `turn.request` forever. It races that await against a
146+
**persistent sleep** (the idle timeout). If the sleep wins, the frontend has gone
147+
quiet, so the workflow runs `session.cleanup` and ends. The sleep is durable, so
148+
an abandoned session is still reclaimed across a server restart. The `teardown`
149+
arm of `next` is for an explicit operator stop.
150+
151+
## Tool calls
152+
153+
The frontend sends its tools as standard OpenAI `tools`; the model returns
154+
standard `tool_calls`; a final answer is an assistant message with no
155+
`tool_calls`. The webhook renders `tools[]` into the CLI's system prompt as the
156+
`{"tool_calls":[...]}` / `{"final":...}` envelope instructions when it starts the
157+
session, and the container's `server.js` parses that envelope back into
158+
`tool_calls` on the way out. So the wire stays plain Chat Completions while the
159+
CLI keeps running its own inner FS/shell loop.
160+
161+
## Layout
162+
163+
```
164+
agent-server/ docker image: node + claude-code + codex + server.js (socket normalizer)
165+
activity/
166+
agent-start.js spawn the container, wait for the socket (claude.start / codex.start)
167+
agent-send.js send one agent-input (session.send)
168+
agent-recv.js drain one turn, typed reply (session.recv)
169+
agent-cleanup.js shut the server down, docker rm (session.cleanup)
170+
workflow/session.js long-running per-conversation loop (the stub pair, above)
171+
webhook/chat.js POST /v1/chat/completions (pairing + delta + reply)
172+
deployment.toml FFQNs, the stub pair, and the webhook allow-list
173+
server.toml moves the API/webui/external ports off the defaults (two instances)
174+
```
175+
176+
`server.js`, `entrypoint.sh`, and the four exec activities are carried over from
177+
`obelisk-agent` unchanged: the socket protocol between the activities and the
178+
container is the same. Only the driver on top (a webhook + a stub-driven
179+
workflow, instead of a self-contained agent loop) is new.
180+
181+
## Known limitations (v1)
182+
183+
- **Reply hold on rate limit.** When the CLI hits its subscription session limit,
184+
the workflow sleeps (durably) until reset before answering, and the HTTP request
185+
is held open (via the blocking `obelisk.get`) for that time. Frequent turns are
186+
fast; this only bites on a session-limit turn.
187+
- **Turn-0 retries can duplicate a session.** A brand-new conversation gets a
188+
fresh session id; if the very first request is retried before it is answered, a
189+
second session may start. The orphan is reclaimed by the idle sleep. Continuation
190+
(turn k) retries are idempotent (they re-pair by hash and re-read the reply).
191+
- **Pairing collisions.** Two conversations with an identical system prompt and
192+
identical history hash to the same key; the webhook serves one and a concurrent
193+
duplicate should be rejected/serialized. Unlikely in practice, noted for rigor.
194+
- **The pairing hash is a 64-bit non-cryptographic hash** (`hash64`, shared
195+
byte-for-byte by `workflow/session.js` and `webhook/chat.js`); keep the two
196+
copies in sync.

0 commit comments

Comments
 (0)