|
| 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