diff --git a/.gitignore b/.gitignore index fe4af14..f19ca3a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,9 @@ # What an example under `examples/` is given or writes: its own dependency tree and the # lockfile that comes with it (each is a real npm application that installs the published # package with `npm install`, and a committed lockfile is what 1.0.0 buys), the model key its -# README tells a reader to put in `.env`, and the Workspace and agent directory its Gateway -# writes. The committed `.env.example` and `settings.json` beside them are not these: the -# `.env` line matches that one name and no other. +# README tells a reader to put in `.env`, and the Workspace, agent directory and Sessions its +# Agent Instance writes. The committed `.env.example` and `settings.json` beside them are not +# these: the `.env` line matches that one name and no other. # # These four lines also decide what Biome sees, because `biome.json` sets `vcs.useIgnoreFile`. # That is the whole of what keeps four dependency trees out of `biome check .`, and no Biome diff --git a/CLAUDE.md b/CLAUDE.md index 3fc8143..afbeea2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,10 +165,24 @@ Four ways that fails quietly, all of them a deployment's to get right: generation to the first render**, so `src/signals/template-handler.ts` calls `precompile` above it and throws the result away; dropping that puts the case back on a Signal. What it costs is live editing: changing a prompt is a rebuild. -- **Nothing in `src/agent-container/` knows about an Agent Implementation.** That directory is what - `docker run` takes and what to do with the result; `src/pi/` is the other half and imports from - it. Nothing imports back, no lint rule enforces that, and an import of `../pi/` from there is the - thing to refuse in review. +- **`switch_session` is create-or-resume, that behaviour is undocumented, and the `get_state` that + follows it in `src/pi/runtime.ts` is the only thing holding it to account.** A path the Agent + Instance has never seen becomes a fresh Session kept there; a path it has seen is loaded; and the + same `success: true` comes back either way, so the second step reads `sessionFile` out of the + instance's own state and compares it to what was asked for. Deleting that round trip costs a Run + nothing any test of the happy path would notice and buys a Prompt delivered into whichever Session + the previous connection happened to leave open. `success: true` carrying `cancelled: true`, which + is an extension of the Operator's refusing the switch, is the same failure by a documented route + and is refused two lines above it. +- **`sessionNames` in `src/pi/runtime.ts` is a transcription of `pi`'s own `assertValidSessionId`, + and nothing anywhere checks that it still matches.** The framework never had to carry one before: + `pi` was handed `--session-id` and refused a bad one itself, with its own message. A Session is + addressed **by path** over RPC now and `pi` opens any path it is handed, so the grammar has to + live on this side and a copy goes stale in silence. It is also the whole of the traversal + argument, there being no `/` in it and both ends required to be alphanumeric, so nothing resolves + a path and compares it against a prefix. Widening that character class is therefore a Signal + Handler's Session name climbing out of `sessionsDir` and not a cosmetic edit; re-read + `core/session-manager` against it whenever the pinned `pi` version moves. - **`src/http-client-tui/` is the one shipped directory that is not a subpath.** It is a `bin`, so the export map is untouched. **It has zero dependencies and must keep them**: a dependency added here lands in every consumer's install, and the answer to needing one is a second package. diff --git a/CONTEXT.md b/CONTEXT.md index 57ea98f..029abca 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -45,7 +45,7 @@ The HTTP server exposed outside the Gateway. Named for its exposure rather than _Avoid_: user server, external API, frontend **Agent server**: -The HTTP server only the Agent Implementation reaches, carrying the Signal Worker's Signal and Run routes plus whatever Producers expose to the agent. A mediation point, not a security boundary against the agent. +The HTTP server only the Agent Implementation reaches, carrying the Signal Worker's Signal and Run routes plus whatever Producers expose to the agent. A mediation point, not a security boundary against the agent. **It is one of two unauthenticated privileged addresses on the agent network, and they point opposite ways.** This one is inbound: the agent reaches it and no credential is asked for. The **Agent Instance**'s RPC is outbound: the Gateway reaches that and no credential is asked for there either, `pi`'s RPC having no authentication of any kind and a command set that includes `bash`. Which is worth saying plainly rather than leaving to be discovered, and is still not a new trust: the network is the boundary for both, it is the same boundary the Agent server has always rested on, and what changed is only that it now carries traffic in both directions. _Avoid_: internal API, private server, control plane **Db**: @@ -69,7 +69,7 @@ The Channel that exchanges NIP-17 private direct messages with Users over one Re _Avoid_: Nostr Messenger, Nostr transport, relay client, DM bridge, npub adapter **Users**: -The component that owns Users and nothing a User presents: the opaque id, the Attributes, and the reads of both. **It hashes no password, issues no Token and authenticates nobody**, all three having gone to Password Auth; what is left is identity, and every Auth is constructed with it. Its Agent server routes are read-only now that `POST /users` is gone, so the agent lists Users and creates none, and its one Public route is `GET /users/me`. It still owns Attributes, which is still where authorization lives. Not a Producer: it emits no Signals and holds no reference to the Signal Worker. A Component all the same, with a `start` and a `stop` that do nothing, because the Gateway's record holds every part and not only the ones that run. Write "the Users component" wherever the bare plural could be read as the people. **Two former names, and the history is the entry's value.** It was the **User Directory** first, the name every document used until the first rename swept them; the argument did not change, only the word. "Directory" was two words at once here, and the other one is a path the agent writes into: `agentDir` and `PI_CODING_AGENT_DIR` throughout. It also undersold a part that mostly writes, a directory being somewhere you look things up. It became the **User Manager** on this argument: **"Manager" was the vaguest word in this glossary, and it was taken knowing that**, since the other entries name what a thing is or does (a Signal Worker works Signals, a Mount Table is a table of mounts) where "manager" names a department, so it was declared the exception and not a precedent for the next entry. **User Registry** was the considered alternative and was dropped because it names registration, which is the one thing this part does not do, self-registration staying a Signal, and covers issuing and revoking no better than "Directory" did. The deciding argument was that "manager" is what this part reads as to the people who use it, so it was not a wart for a later reader to fix. No public name moved with that rename: it was prose that changed, and the component's identifiers already said Users. **That last sentence is why the wart was fixed after all**, and the component was renamed to **Users**: every identifier a consumer touches already said so and only the prose said Manager. These documents, the doc comments, the OpenAPI descriptions and the test names all say Users. +The component that owns Users and nothing a User presents: the opaque id, the Attributes, and the reads of both. **It hashes no password, issues no Token and authenticates nobody**, all three having gone to Password Auth; what is left is identity, and every Auth is constructed with it. Its Agent server routes are read-only now that `POST /users` is gone, so the agent lists Users and creates none, and its one Public route is `GET /users/me`. It still owns Attributes, which is still where authorization lives. Not a Producer: it emits no Signals and holds no reference to the Signal Worker. A Component all the same, with a `start` and a `stop` that do nothing, because the Gateway's record holds every part and not only the ones that run. Write "the Users component" wherever the bare plural could be read as the people. **Two former names, and the history is the entry's value.** It was the **User Directory** first, the name every document used until the first rename swept them; the argument did not change, only the word. "Directory" was two words at once here, and the other one is a path the agent writes into: `agentDir` and `PI_CODING_AGENT_DIR` throughout. It also undersold a part that mostly writes, a directory being somewhere you look things up. It became the **User Manager** on this argument: **"Manager" was the vaguest word in this glossary, and it was taken knowing that**, since the other entries name what a thing is or does (a Signal Worker works Signals, a Message log is a log of Messages) where "manager" names a department, so it was declared the exception and not a precedent for the next entry. **User Registry** was the considered alternative and was dropped because it names registration, which is the one thing this part does not do, self-registration staying a Signal, and covers issuing and revoking no better than "Directory" did. The deciding argument was that "manager" is what this part reads as to the people who use it, so it was not a wart for a later reader to fix. No public name moved with that rename: it was prose that changed, and the component's identifiers already said Users. **That last sentence is why the wart was fixed after all**, and the component was renamed to **Users**: every identifier a consumer touches already said so and only the prose said Manager. These documents, the doc comments, the OpenAPI descriptions and the test names all say Users. _Avoid_: User Manager, User Directory, User Registry, auth service, identity provider, IdP, user store, account system, user module **Auth**: @@ -97,28 +97,16 @@ The Producer that owns Schedules: recurrence, one-shots, cancellation, next-fire _Avoid_: cron, timer, job queue **Agent Implementation**: -The interchangeable agent program at the centre of the architecture. `pi` is the primary target; `openclaw` is the reference alternative. Formerly the **Agent Runtime**, the name used before the rename swept it. Renamed because "runtime" had come to mean three things at once, and this was the weakest of the three claims on it: `pi` is a program, and the word was wanted for what actually runs one. A fourth use of the word arrived later with the **Runtime Directory**, and it is not a fourth claim on the bare noun: it is a compound naming what a Runtime resolves its Mount Table against, which the rename is what made available. **Runtime** unqualified still means one thing. +The interchangeable agent program at the centre of the architecture. `pi` is the primary target; `openclaw` is the reference alternative. Formerly the **Agent Runtime**, the name used before the rename swept it. Renamed because "runtime" had come to mean three things at once, and this was the weakest of the three claims on it: `pi` is a program, and the word was wanted for what actually runs one. Two uses are what the rename left, and two is what there are: the **Runtime** interface below, and a container runtime, which is somebody else's product and never ours to name. **Runtime** unqualified always means the first. _Avoid_: agent runtime, engine, backend, model, LLM **Runtime**: -What the Signal Worker hands a Prompt to and gets an outcome back from, and the narrowest interface in the framework: one method. A construction option of the Worker's rather than a Component, and close to the only thing in the Gateway's design that is neither. It carries none of the Agent Implementation's own configuration, because what that reads on disk is the Operator's to place where it will look. Named for the Worker's own field, which has always been `runtime`. Formerly the **Runtime Adapter**; "adapter" is gone, since there is no longer a second kind of thing for it to adapt between. A Runtime that runs the agent in a container declares a **Runtime Directory** below and resolves its Mount Table against it, and writes in it nothing at all. +What the Signal Worker hands a Prompt to and gets an outcome back from, and the narrowest interface in the framework: one method. A construction option of the Worker's rather than a Component, and close to the only thing in the Gateway's design that is neither. It carries none of the Agent Implementation's own configuration, because what that reads on disk is the Operator's to place where it will look. Named for the Worker's own field, which has always been `runtime`, and the name means **what a Run is performed through**. Stating that outright is what makes keeping it honest rather than merely cheap: the other reading, runtime-as-execution-environment, was defensible while the framework started a container for each Run, and nothing in the framework runs an Agent Implementation any more, so the reading it would invite is now false. Formerly the **Runtime Adapter**; "adapter" is gone, since there is no longer a second kind of thing for it to adapt between. What a Runtime reaches is an **Agent Instance** below, which the Operator runs and a Runtime does not own: it opens a connection when a Prompt exists and closes it when the Run is over, and it starts nothing, stops nothing and configures nothing. _Avoid_: runtime adapter, driver, plugin, connector, backend -**Agent Container**: -The declaration of the container one Run happens in: the image, the Mount Table, the networks, the environment, the entry point, and the flags the framework does not model. Inert and agent-agnostic: it creates nothing, checks no path and starts nothing, resolving to container arguments and no more. Only the image is required, and a Mount Table declared on it names the **Runtime Directory** below, on the host, which is the only namespace this declaration has a name for. This term names the directory `src/agent-container/` and the subpath `@shutter-network/concorde/agent-container`, which carries it and the Agent Container Runtime; `/runtime` was rejected for that subpath, since **Runtime** below is a different thing. -_Avoid_: sandbox, box, environment, runtime config, container spec - -**Agent Container Runtime**: -The Runtime that runs an Agent Implementation as one fresh container per Run, generic over which one. It owns the whole of the container: the arguments, the confinement, the process, the redaction and the diagnosis of a failure. What an Agent Implementation adds to it is **one function**, which says what to put after the image, what to write on stdin, and how to read what comes back. `createPiRuntime` is that function plus two defaults. -_Avoid_: container adapter, executor, launcher, supervisor - -**Mount Table**: -The declaration of which directories and files the agent's container sees, and where each one comes from. One entry is a **Mount**. The Workspace is one of them; so is any file the Operator wants the agent to be unable to change. Optional: an image carrying its own configuration and keeping nothing between Runs mounts nothing, and the cost of that is only that no Session survives the container. It used to carry the container's user as well, on the argument that what is shared and who shares it are two halves of one fact; the user is no longer configuration at all. Every entry is written against the **Runtime Directory** below. -_Avoid_: volume, bind, share, sandbox, mount config - -**Runtime Directory**: -The directory a Runtime resolves its Mount Table against: one required host path, with every Mount written relative to it. Named on the host's side and nobody else's, because the container runtime's daemon is what resolves a bind source, so a Gateway that is itself in a container cannot in general reach this directory itself and must read anything it needs of its own from its image or from a path stated separately. `/` is a legal value, which is how a shared tree spanning more than one host mount is expressed. **The Runtime does not write there.** It creates nothing, checks nothing and opens nothing: the agent writes there, through the binds the Runtime declares, and the Operator creates the directories and places the files. Two of the four entries an example declares go the other way entirely, read-only files the Operator wrote for the agent to read and be unable to change. The fourth thing in this vocabulary with "runtime" in its name, which the **Agent Implementation** entry above accounts for. `RUNTIME_DIR_HOST` is the name an example gives the environment variable it reads this from, the suffix naming whose path to the directory it is. -_Avoid_: base dir, host root, shared tree, gateway path, mount root +**Agent Instance**: +A running Agent Implementation the Operator hosts, which the Gateway reaches over the RPC channel it accepts on. The Operator owns its whole environment (its image, its files, its model credential, its flags) and the framework starts it, stops it and configures it not at all. One connection is opened per Run and closed at the end of it; whether one process serves every connection or the listener starts one per connection is the Operator's arrangement, and nothing in the framework can tell the difference or depends on the answer. That last clause is the entry's one piece of work: with a listener that forks, "instance" names a thing that is a socket most of the time and a process only while a Run is happening, and the word is kept anyway because what a Runtime addresses is one place where the agent can be reached. **Agent Endpoint** was the considered alternative and collides with the **Agent server**, which is an address pointing the other way; **Agent Host** was refused because the **Operator** entry bans "host" outright. This term replaced four that named a mechanism the framework no longer has, the declaration of a container the Gateway started and of what that container could see on disk. None of it is gone as a practice: it moved into the Operator's compose file, which is where a container has always been better expressed, and the framework has no name for any of it now. +_Avoid_: agent container, agent endpoint, agent host, agent service, sidecar **OpenClaw daemon**: OpenClaw's own central process, which its own documentation calls "the Gateway". Always written as "the OpenClaw daemon" here — unqualified "Gateway" always means ours. @@ -149,7 +137,7 @@ One execution of the agent: a single Prompt, in one Session, producing whatever _Avoid_: turn, job, invocation, task **Workspace**: -The files and data that Signal Handlers and the agent share, as opposed to the Db, which the agent cannot touch directly. Global to a shared agent, not per Session. +The files and data that Signal Handlers and the agent share, as opposed to the Db, which the agent cannot touch directly. Global to a shared agent, not per Session. **The framework has no name for where it is.** The Gateway and the **Agent Instance** come to share it by the Operator's own arrangement, a volume or a bind or a filesystem both containers happen to see, and no option, no path and no check anywhere in the API is about it: nothing here can tell whether the two ends found each other, or notice that they did not. _Avoid_: scratch, working directory, shared state ## Identity @@ -201,7 +189,7 @@ _Avoid_: job, task, cron job, timer, alarm, reminder Owned by Signatures and Decisions, not the Signal Worker, except the Nostr identity, which is the Nostr Channel's and is here because the two identities are only understandable side by side. The first three terms after it are Signatures'; a Decision is Decisions'. **Signing identity**: -The Ed25519 keypair a shared agent's **commitments** are checked against. The public half is what a verifier of a Decision uses; the Operator holds the private half **in trust**, which is the same trust the Operator already holds, applied to one more asset. Never enters the Agent Container. **It is no longer the only keypair**: it once was, and this entry said "one shared agent, one keypair, and no second name for it", which the Nostr identity retired. Its audience is a third party who never touches the Gateway, and copying it forges commitments. +The Ed25519 keypair a shared agent's **commitments** are checked against. The public half is what a verifier of a Decision uses; the Operator holds the private half **in trust**, which is the same trust the Operator already holds, applied to one more asset. Never enters the Agent Instance. **It is no longer the only keypair**: it once was, and this entry said "one shared agent, one keypair, and no second name for it", which the Nostr identity retired. Its audience is a third party who never touches the Gateway, and copying it forges commitments. _Avoid_: agent key, service key, signing credential, certificate, identity provider **Nostr identity**: diff --git a/README.md b/README.md index aeb7f67..1226f1f 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ The framework gives you the Gateway. You state what your deployment is made of, A Gateway is a set of components. Four are core, and every deployment gets them: - A **Db**, the PostgreSQL connection every other component stores its state through. -- A **Signal Worker** that runs the agent, one thing at a time and never two at once. +- A **Signal Worker** that drives the agent, one thing at a time and never two at once. - An **Agent server**, the HTTP API the running agent calls back into. - A **Public server**, the HTTP API the parties' own clients talk to. @@ -50,10 +50,12 @@ stopped with it, and can serve its own routes on either server. ![The parts of a shared agent. A dashed boundary encloses the Gateway, holding the Db, the Signal Worker, the Agent server and the Public server, together with the Messenger and its two Channels -and Users with its two Auths. Outside it are the Agent Implementation, a person's client, and a -Nostr Relay.](./site/public/architecture.svg) +and Users with its two Auths. Outside it are the Agent Instance, a person's client, and a Nostr +Relay.](./site/public/architecture.svg) -Everything inside the dashed boundary is the Gateway. The +Everything inside the dashed boundary is the Gateway. The agent is drawn outside it because it is +outside it: you run the agent, and the Gateway connects to it over a TCP address that is the whole +of what it knows about it. The [architecture page](https://shutter-network.github.io/concorde/architecture) reads the picture part by part. @@ -82,16 +84,9 @@ import { createUsers } from "@shutter-network/concorde/users"; const tokenTtl = 30 * 24 * 60 * 60 * 1000; const runtime = createPiRuntime({ - image: process.env.AGENT_IMAGE!, - env: { AGENT_SERVER_URL: process.env.AGENT_SERVER_URL! }, - networks: [process.env.AGENT_NETWORK!], - mounts: { - runtimeDir: process.env.RUNTIME_DIR_HOST!, - entries: [ - { agentPath: "/workspace", path: "state/workspace" }, - { agentPath: "/workspace/AGENTS.md", path: "AGENTS.md", readOnly: true }, - ], - }, + host: process.env.AGENT_INSTANCE_HOST!, + port: Number(process.env.AGENT_INSTANCE_PORT), + sessionsDir: process.env.AGENT_SESSIONS_DIR!, }); const gateway = createGateway({ @@ -102,8 +97,8 @@ const gateway = createGateway({ port: Number(process.env.PUBLIC_PORT), }, agentListen: { - host: process.env.AGENT_HOST!, - port: Number(process.env.AGENT_PORT), + host: process.env.AGENT_SERVER_HOST!, + port: Number(process.env.AGENT_SERVER_PORT), }, extend: ({ db, agentServer, publicServer, worker }) => { const users = createUsers({ db, agentServer, publicServer }); @@ -133,10 +128,16 @@ Answer them by sending them a Message. Your final reply here reaches nobody.`, await gateway.start(); ``` -That is close to the whole of a working deployment. It waits for a message, and runs the agent in -a container each time one arrives. What -[`examples/00_minimal/main.ts`](./examples/00_minimal/main.ts) adds to it is the block that seeds -the first person and a signal handler for shutdown. +That is close to the whole of a working deployment. It waits for a message, and each time one +arrives it opens a connection to the agent, prompts it, and closes the connection when the agent +has settled. What [`examples/00_minimal/main.ts`](./examples/00_minimal/main.ts) adds to it is the +block that seeds the first person and a signal handler for shutdown. + +**You run the agent; the framework does not.** `createPiRuntime` takes an address and the +directory the agent keeps its Sessions in, and that is all it takes: no image, no model, no +credential and no path on your host. The agent is `pi` behind a listener in a container of your +own, on a private network with the Gateway, and every example's `compose.yml` is one service for +each of the two. Each component is its own import subpath, and the package root exports nothing. A component that owns tables ships them on a `/schema` subpath, which your own `drizzle.config.ts` applies. Your diff --git a/examples/00_minimal/.env.example b/examples/00_minimal/.env.example index 68e7a82..ae841da 100644 --- a/examples/00_minimal/.env.example +++ b/examples/00_minimal/.env.example @@ -1,4 +1,6 @@ # Copy this file to .env and fill in the one blank at the top. +# The model credential, and the Agent Instance's alone: it is set on `agent` in `compose.yml` +# and the Gateway is never given it. ANTHROPIC_API_KEY= # The seeded User's password, which `main.ts` sets from trusted code on the first boot and the diff --git a/examples/00_minimal/Dockerfile b/examples/00_minimal/Dockerfile index da9bef0..29d0336 100644 --- a/examples/00_minimal/Dockerfile +++ b/examples/00_minimal/Dockerfile @@ -1,7 +1,5 @@ FROM node:24-alpine -RUN apk add --no-cache docker-cli - WORKDIR /app COPY package.json ./ diff --git a/examples/00_minimal/Dockerfile.agent b/examples/00_minimal/Dockerfile.agent index 0031278..26211d5 100644 --- a/examples/00_minimal/Dockerfile.agent +++ b/examples/00_minimal/Dockerfile.agent @@ -1,10 +1,8 @@ FROM node:24-alpine -RUN apk add --no-cache curl +RUN apk add --no-cache curl socat -RUN npm install -g @earendil-works/pi-coding-agent@0.83.0 +RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 WORKDIR /workspace ENV PI_CODING_AGENT_DIR=/home/agent/.pi/agent - -ENTRYPOINT ["pi"] diff --git a/examples/00_minimal/README.md b/examples/00_minimal/README.md index 1c5c0b8..de3096d 100644 --- a/examples/00_minimal/README.md +++ b/examples/00_minimal/README.md @@ -45,6 +45,79 @@ second, so the agent's answer arrives a moment after it is sent. Ctrl-C leaves. docker compose down -v ``` +## The two services + +This deployment is two containers that matter, and the Gateway is one of them. `gateway` serves +the people and decides what the agent is asked; `agent` is the **Agent Instance**, which the +Operator runs and the Gateway merely connects to. Nothing here mounts a container runtime +socket and no host path is written anywhere, so the Gateway cannot start a container and has no +name for a directory the Docker daemon would resolve. That is the whole arrangement: the +Gateway holds a TCP address and nothing else about the agent. + +What `agent` starts is a listener rather than the agent: + +```yaml + command: + - socat + - TCP-LISTEN:4000,reuseaddr,fork + - EXEC:pi --mode rpc --no-approve +``` + +`fork` means one `pi` process per TCP connection, and the Gateway opens one connection per Run +and closes it when the agent has settled. So a Run is still a process of its own, started for it +and gone with it; what carries over from one Run to the next is this container and the +directories mounted into it, which is the Operator's arrangement and nobody else's. Four things +in those three lines are load-bearing: + +- **No commas may appear in the `EXEC:` argument.** Comma is `socat`'s own option separator, so + a comma anywhere in it is read as an option rather than as part of the command, and what runs + is quietly not what was written. The command is a list and not a string for the neighbouring + reason: no shell is involved, and `socat` splits `EXEC:` on spaces itself. +- **`--no-approve` is the Operator's to pass**, and this line is the only place it now appears. + It is the flag that stops a Run arranging for the next one to load configuration out of the + writable Workspace, and the framework cannot fasten it to a command line it does not write. +- **`PI_OFFLINE` was a framework default** back when the framework started the container. It is + an environment variable in this file now, and there is nowhere else left for it to be. +- **`socat`'s `stderr` option must never be added.** `EXEC` wires stdin and stdout to the + socket, and `pi` writes its diagnostics to `socat`'s stderr; merging the two puts non-JSON + into the record stream and fails every Run. + +`Dockerfile.agent` is that container's image: `pi` at a pinned version, `socat` to listen with, +and `curl`, because `pi` ships no HTTP client and reaching the Agent server is the agent's own +shell tool plus `curl`. It declares no `ENTRYPOINT`, since the process this container starts is +the listener and the agent is what the listener starts. + +**There is no healthcheck on `agent`, deliberately.** With `fork`, every connection starts a +`pi`, so a probe on an interval would boot and discard one for ever. A plain `depends_on` is +enough: an instance that is not listening is an ordinary failed Run carrying the address, not a +boot failure, and a Gateway that refused to start over it would take every other Party's access +down with the agent's. + +**The agent's environment is the Operator's, whole.** The image, the model credential, the files +`pi` reads, the flags it is started with: every one of them is written on `agent` in +`compose.yml`, and the Gateway is given none of it. `ANTHROPIC_API_KEY` is set there and in no +other service. `AGENTS.md` and `settings.json` are mounted read-only out of this directory, +which is how the agent learns that the Agent server exists and which model to talk to, and +mounting them read-only is what keeps a Run from rewriting the instructions the next Run reads. + +**The Gateway is told three things about the instance and nothing else:** + +```yaml + AGENT_INSTANCE_HOST: agent + AGENT_INSTANCE_PORT: "4000" + AGENT_SESSIONS_DIR: /sessions +``` + +`AGENT_SERVER_HOST` and `AGENT_SERVER_PORT` beside them point the other way entirely: they are +where the Gateway's own Agent server listens for the agent's `curl`. Two addresses cross on the +same network and the names are long so that neither can be read as the other. + +`AGENT_SESSIONS_DIR` is **the path the Agent Instance sees**, and it is the same string as the +target of the `./state/sessions:/sessions` mount on `agent`. It is written twice because nothing +can check it once: one end is resolved by `pi` and the other by the Docker daemon, and neither +can see the other's filesystem. A Session named `user_abc` is `/sessions/user_abc.jsonl` inside that +container and `state/sessions/user_abc.jsonl` here, which is where to read a transcript. + ## Look around - `main.ts` is the whole deployment: the Runtime, four components, one Handler, the prompt that @@ -52,5 +125,5 @@ docker compose down -v - The Gateway describes its own HTTP API, and the Public server is published at . That is 8081 and not 8080, so this stack and the other examples can run at the same time. -- `AGENTS.md` is mounted read-only into the agent's Workspace and is the only thing that tells - it the Agent server exists. +- `AGENTS.md` is mounted read-only into the Agent Instance's Workspace and is the only thing + that tells it the Agent server exists. diff --git a/examples/00_minimal/compose.yml b/examples/00_minimal/compose.yml index 29346f2..6a0ad8d 100644 --- a/examples/00_minimal/compose.yml +++ b/examples/00_minimal/compose.yml @@ -8,21 +8,15 @@ services: context: . dockerfile: Dockerfile environment: - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?copy .env.example to .env and put your model key in it} DATABASE_URL: *database-url USER_PASSWORD: ${USER_PASSWORD:?copy .env.example to .env} PUBLIC_HOST: 0.0.0.0 PUBLIC_PORT: "8081" - AGENT_HOST: 0.0.0.0 - AGENT_PORT: "7411" - AGENT_SERVER_URL: http://gateway:7411 - AGENT_IMAGE: concorde-minimal-agent:0.83.0 - AGENT_NETWORK: concorde_minimal_agent - RUNTIME_DIR_HOST: ${PWD} - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./state/workspace:/app/state/workspace - - ./state/agent:/app/state/agent + AGENT_SERVER_HOST: 0.0.0.0 + AGENT_SERVER_PORT: "7411" + AGENT_INSTANCE_HOST: agent + AGENT_INSTANCE_PORT: "4000" + AGENT_SESSIONS_DIR: /sessions ports: - "127.0.0.1:8081:8081" networks: [db, agent, public] @@ -31,10 +25,31 @@ services: condition: service_healthy migrate: condition: service_completed_successfully - agent-image: - condition: service_completed_successfully + agent: + condition: service_started stop_grace_period: 300s + agent: + build: + context: . + dockerfile: Dockerfile.agent + command: + - socat + - TCP-LISTEN:4000,reuseaddr,fork + - EXEC:pi --mode rpc --no-approve + environment: + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?copy .env.example to .env and put your model key in it} + AGENT_SERVER_URL: http://gateway:7411 + PI_OFFLINE: "1" + PI_CODING_AGENT_DIR: /home/agent/.pi/agent + volumes: + - ./state/workspace:/workspace + - ./state/agent:/home/agent/.pi/agent + - ./state/sessions:/sessions + - ./AGENTS.md:/workspace/AGENTS.md:ro + - ./settings.json:/home/agent/.pi/agent/settings.json:ro + networks: [agent] + migrate: build: context: . @@ -63,15 +78,6 @@ services: timeout: 3s retries: 20 - agent-image: - build: - context: . - dockerfile: Dockerfile.agent - image: concorde-minimal-agent:0.83.0 - command: ["--version"] - restart: "no" - networks: [agent] - tui: build: context: . diff --git a/examples/00_minimal/main.ts b/examples/00_minimal/main.ts index ccbc7d1..ee8176f 100644 --- a/examples/00_minimal/main.ts +++ b/examples/00_minimal/main.ts @@ -15,28 +15,19 @@ const password = process.env.USER_PASSWORD!; const tokenTtl = 30 * 24 * 60 * 60 * 1000; const runtime = createPiRuntime({ - image: process.env.AGENT_IMAGE!, - env: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!, - AGENT_SERVER_URL: process.env.AGENT_SERVER_URL!, - }, - networks: [process.env.AGENT_NETWORK!], - mounts: { - runtimeDir: process.env.RUNTIME_DIR_HOST!, - entries: [ - { agentPath: "/workspace", path: "state/workspace" }, - { agentPath: "/home/agent/.pi/agent", path: "state/agent" }, - { agentPath: "/workspace/AGENTS.md", path: "AGENTS.md", readOnly: true }, - { agentPath: "/home/agent/.pi/agent/settings.json", path: "settings.json", readOnly: true }, - ], - }, + host: process.env.AGENT_INSTANCE_HOST!, + port: Number(process.env.AGENT_INSTANCE_PORT), + sessionsDir: process.env.AGENT_SESSIONS_DIR!, }); const gateway = createGateway({ databaseUrl: process.env.DATABASE_URL!, runtime, publicListen: { host: process.env.PUBLIC_HOST!, port: Number(process.env.PUBLIC_PORT) }, - agentListen: { host: process.env.AGENT_HOST!, port: Number(process.env.AGENT_PORT) }, + agentListen: { + host: process.env.AGENT_SERVER_HOST!, + port: Number(process.env.AGENT_SERVER_PORT), + }, extend: ({ db, agentServer, publicServer, worker }) => { const users = createUsers({ db, agentServer, publicServer }); const passwordAuth = createPasswordAuth({ db, users, publicServer, tokenTtl }); diff --git a/examples/01_scheduler/.env.example b/examples/01_scheduler/.env.example index 1f560c8..6f47cec 100644 --- a/examples/01_scheduler/.env.example +++ b/examples/01_scheduler/.env.example @@ -1,2 +1,4 @@ # Copy this file to .env and fill in the one blank. Nothing else here needs a secret. +# The model credential, and the Agent Instance's alone: it is set on `agent` in `compose.yml` +# and the Gateway is never given it. ANTHROPIC_API_KEY= diff --git a/examples/01_scheduler/AGENTS.md b/examples/01_scheduler/AGENTS.md index 7eb17ea..8bae621 100644 --- a/examples/01_scheduler/AGENTS.md +++ b/examples/01_scheduler/AGENTS.md @@ -21,8 +21,8 @@ fired and carries the `data` whoever created it supplied. There is no Messenger Channel in this deployment, so nobody is waiting on a reply and nothing you say leaves the Gateway. -**Write what you did into your Workspace instead.** `/workspace` is a directory on the host -that survives the container. Append a line, do not rewrite a file, and keep it short. +**Write what you did into your Workspace instead.** `/workspace` is a mounted directory that +outlives any one Run. Append a line, do not rewrite a file, and keep it short. ## Schedules of your own diff --git a/examples/01_scheduler/Dockerfile b/examples/01_scheduler/Dockerfile index da9bef0..29d0336 100644 --- a/examples/01_scheduler/Dockerfile +++ b/examples/01_scheduler/Dockerfile @@ -1,7 +1,5 @@ FROM node:24-alpine -RUN apk add --no-cache docker-cli - WORKDIR /app COPY package.json ./ diff --git a/examples/01_scheduler/Dockerfile.agent b/examples/01_scheduler/Dockerfile.agent index 0031278..26211d5 100644 --- a/examples/01_scheduler/Dockerfile.agent +++ b/examples/01_scheduler/Dockerfile.agent @@ -1,10 +1,8 @@ FROM node:24-alpine -RUN apk add --no-cache curl +RUN apk add --no-cache curl socat -RUN npm install -g @earendil-works/pi-coding-agent@0.83.0 +RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 WORKDIR /workspace ENV PI_CODING_AGENT_DIR=/home/agent/.pi/agent - -ENTRYPOINT ["pi"] diff --git a/examples/01_scheduler/README.md b/examples/01_scheduler/README.md index e1c5e80..9e4314e 100644 --- a/examples/01_scheduler/README.md +++ b/examples/01_scheduler/README.md @@ -27,12 +27,86 @@ docker compose logs -f gateway The first fire arrives about twenty seconds after the Gateway is up, and the `cron` fires on every minute after that. Each one is a `Schedule fired` line, then a Signal, then a Run. -What the agent writes is in `state/workspace/`. +What the agent writes is in `state/workspace/`, and the transcript of each Session is a +`.jsonl` file in `state/sessions/`. ```sh docker compose down -v ``` +## The two services + +This deployment is two containers that matter, and the Gateway is one of them. `gateway` serves +the people and decides what the agent is asked; `agent` is the **Agent Instance**, which the +Operator runs and the Gateway merely connects to. Nothing here mounts a container runtime +socket and no host path is written anywhere, so the Gateway cannot start a container and has no +name for a directory the Docker daemon would resolve. That is the whole arrangement: the +Gateway holds a TCP address and nothing else about the agent. + +What `agent` starts is a listener rather than the agent: + +```yaml + command: + - socat + - TCP-LISTEN:4000,reuseaddr,fork + - EXEC:pi --mode rpc --no-approve +``` + +`fork` means one `pi` process per TCP connection, and the Gateway opens one connection per Run +and closes it when the agent has settled. So a Run is still a process of its own, started for it +and gone with it; what carries over from one Run to the next is this container and the +directories mounted into it, which is the Operator's arrangement and nobody else's. Four things +in those three lines are load-bearing: + +- **No commas may appear in the `EXEC:` argument.** Comma is `socat`'s own option separator, so + a comma anywhere in it is read as an option rather than as part of the command, and what runs + is quietly not what was written. The command is a list and not a string for the neighbouring + reason: no shell is involved, and `socat` splits `EXEC:` on spaces itself. +- **`--no-approve` is the Operator's to pass**, and this line is the only place it now appears. + It is the flag that stops a Run arranging for the next one to load configuration out of the + writable Workspace, and the framework cannot fasten it to a command line it does not write. +- **`PI_OFFLINE` was a framework default** back when the framework started the container. It is + an environment variable in this file now, and there is nowhere else left for it to be. +- **`socat`'s `stderr` option must never be added.** `EXEC` wires stdin and stdout to the + socket, and `pi` writes its diagnostics to `socat`'s stderr; merging the two puts non-JSON + into the record stream and fails every Run. + +`Dockerfile.agent` is that container's image: `pi` at a pinned version, `socat` to listen with, +and `curl`, because `pi` ships no HTTP client and reaching the Agent server is the agent's own +shell tool plus `curl`. It declares no `ENTRYPOINT`, since the process this container starts is +the listener and the agent is what the listener starts. + +**There is no healthcheck on `agent`, deliberately.** With `fork`, every connection starts a +`pi`, so a probe on an interval would boot and discard one for ever. A plain `depends_on` is +enough: an instance that is not listening is an ordinary failed Run carrying the address, not a +boot failure, and a Gateway that refused to start over it would take every other Party's access +down with the agent's. + +**The agent's environment is the Operator's, whole.** The image, the model credential, the files +`pi` reads, the flags it is started with: every one of them is written on `agent` in +`compose.yml`, and the Gateway is given none of it. `ANTHROPIC_API_KEY` is set there and in no +other service. `AGENTS.md` and `settings.json` are mounted read-only out of this directory, +which is how the agent learns that the Agent server exists and which model to talk to, and +mounting them read-only is what keeps a Run from rewriting the instructions the next Run reads. + +**The Gateway is told three things about the instance and nothing else:** + +```yaml + AGENT_INSTANCE_HOST: agent + AGENT_INSTANCE_PORT: "4000" + AGENT_SESSIONS_DIR: /sessions +``` + +`AGENT_SERVER_HOST` and `AGENT_SERVER_PORT` beside them point the other way entirely: they are +where the Gateway's own Agent server listens for the agent's `curl`. Two addresses cross on the +same network and the names are long so that neither can be read as the other. + +`AGENT_SESSIONS_DIR` is **the path the Agent Instance sees**, and it is the same string as the +target of the `./state/sessions:/sessions` mount on `agent`. It is written twice because nothing +can check it once: one end is resolved by `pi` and the other by the Docker daemon, and neither +can see the other's filesystem. A Session named `user_abc` is `/sessions/user_abc.jsonl` inside that +container and `state/sessions/user_abc.jsonl` here, which is where to read a transcript. + ## Look around - `main.ts` is the whole deployment: the Runtime, the one component, the Handler and the two @@ -41,5 +115,5 @@ docker compose down -v . Its document lists no routes, because a Channel is what puts one there and this example builds none. The Agent server's document is the interesting one, and the agent reads it itself. -- `AGENTS.md` is mounted read-only into the agent's Workspace and is the only thing that - tells it the Agent server exists. +- `AGENTS.md` is mounted read-only into the Agent Instance's Workspace and is the only thing + that tells it the Agent server exists. diff --git a/examples/01_scheduler/compose.yml b/examples/01_scheduler/compose.yml index 867777f..9bcccf8 100644 --- a/examples/01_scheduler/compose.yml +++ b/examples/01_scheduler/compose.yml @@ -8,20 +8,14 @@ services: context: . dockerfile: Dockerfile environment: - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?copy .env.example to .env and put your model key in it} DATABASE_URL: *database-url PUBLIC_HOST: 0.0.0.0 PUBLIC_PORT: "8080" - AGENT_HOST: 0.0.0.0 - AGENT_PORT: "7411" - AGENT_SERVER_URL: http://gateway:7411 - AGENT_IMAGE: concorde-scheduler-agent:0.83.0 - AGENT_NETWORK: concorde_scheduler_agent - RUNTIME_DIR_HOST: ${PWD} - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./state/workspace:/app/state/workspace - - ./state/agent:/app/state/agent + AGENT_SERVER_HOST: 0.0.0.0 + AGENT_SERVER_PORT: "7411" + AGENT_INSTANCE_HOST: agent + AGENT_INSTANCE_PORT: "4000" + AGENT_SESSIONS_DIR: /sessions ports: - "127.0.0.1:8080:8080" networks: [db, agent] @@ -30,10 +24,31 @@ services: condition: service_healthy migrate: condition: service_completed_successfully - agent-image: - condition: service_completed_successfully + agent: + condition: service_started stop_grace_period: 300s + agent: + build: + context: . + dockerfile: Dockerfile.agent + command: + - socat + - TCP-LISTEN:4000,reuseaddr,fork + - EXEC:pi --mode rpc --no-approve + environment: + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?copy .env.example to .env and put your model key in it} + AGENT_SERVER_URL: http://gateway:7411 + PI_OFFLINE: "1" + PI_CODING_AGENT_DIR: /home/agent/.pi/agent + volumes: + - ./state/workspace:/workspace + - ./state/agent:/home/agent/.pi/agent + - ./state/sessions:/sessions + - ./AGENTS.md:/workspace/AGENTS.md:ro + - ./settings.json:/home/agent/.pi/agent/settings.json:ro + networks: [agent] + migrate: build: context: . @@ -62,15 +77,6 @@ services: timeout: 3s retries: 20 - agent-image: - build: - context: . - dockerfile: Dockerfile.agent - image: concorde-scheduler-agent:0.83.0 - command: ["--version"] - restart: "no" - networks: [agent] - networks: db: name: concorde_scheduler_db diff --git a/examples/01_scheduler/main.ts b/examples/01_scheduler/main.ts index 9da43f1..1a26aa4 100644 --- a/examples/01_scheduler/main.ts +++ b/examples/01_scheduler/main.ts @@ -33,28 +33,19 @@ const scheduleFired: SignalHandler = { }; const runtime = createPiRuntime({ - image: process.env.AGENT_IMAGE!, - env: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!, - AGENT_SERVER_URL: process.env.AGENT_SERVER_URL!, - }, - networks: [process.env.AGENT_NETWORK!], - mounts: { - runtimeDir: process.env.RUNTIME_DIR_HOST!, - entries: [ - { agentPath: "/workspace", path: "state/workspace" }, - { agentPath: "/home/agent/.pi/agent", path: "state/agent" }, - { agentPath: "/workspace/AGENTS.md", path: "AGENTS.md", readOnly: true }, - { agentPath: "/home/agent/.pi/agent/settings.json", path: "settings.json", readOnly: true }, - ], - }, + host: process.env.AGENT_INSTANCE_HOST!, + port: Number(process.env.AGENT_INSTANCE_PORT), + sessionsDir: process.env.AGENT_SESSIONS_DIR!, }); const gateway = createGateway({ databaseUrl: process.env.DATABASE_URL!, runtime, publicListen: { host: process.env.PUBLIC_HOST!, port: Number(process.env.PUBLIC_PORT) }, - agentListen: { host: process.env.AGENT_HOST!, port: Number(process.env.AGENT_PORT) }, + agentListen: { + host: process.env.AGENT_SERVER_HOST!, + port: Number(process.env.AGENT_SERVER_PORT), + }, extend: ({ db, worker, agentServer }) => ({ scheduler: createScheduler({ db, worker, agentServer }), }), diff --git a/examples/02_decisions/.env.example b/examples/02_decisions/.env.example index 18f466a..03e3721 100644 --- a/examples/02_decisions/.env.example +++ b/examples/02_decisions/.env.example @@ -1,4 +1,6 @@ # Copy this file to .env and fill in the one blank at the top. +# The model credential, and the Agent Instance's alone: it is set on `agent` in `compose.yml` +# and the Gateway is never given it. ANTHROPIC_API_KEY= # The two seeded passwords, which `main.ts` sets from trusted code on the first boot and the two diff --git a/examples/02_decisions/Dockerfile b/examples/02_decisions/Dockerfile index da9bef0..29d0336 100644 --- a/examples/02_decisions/Dockerfile +++ b/examples/02_decisions/Dockerfile @@ -1,7 +1,5 @@ FROM node:24-alpine -RUN apk add --no-cache docker-cli - WORKDIR /app COPY package.json ./ diff --git a/examples/02_decisions/Dockerfile.agent b/examples/02_decisions/Dockerfile.agent index 0031278..26211d5 100644 --- a/examples/02_decisions/Dockerfile.agent +++ b/examples/02_decisions/Dockerfile.agent @@ -1,10 +1,8 @@ FROM node:24-alpine -RUN apk add --no-cache curl +RUN apk add --no-cache curl socat -RUN npm install -g @earendil-works/pi-coding-agent@0.83.0 +RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 WORKDIR /workspace ENV PI_CODING_AGENT_DIR=/home/agent/.pi/agent - -ENTRYPOINT ["pi"] diff --git a/examples/02_decisions/README.md b/examples/02_decisions/README.md index 39637c5..b9fba68 100644 --- a/examples/02_decisions/README.md +++ b/examples/02_decisions/README.md @@ -151,14 +151,87 @@ something obtains a perfectly valid Decision. What it rules out is denial. docker compose down -v ``` +## The two services + +This deployment is two containers that matter, and the Gateway is one of them. `gateway` serves +the people and decides what the agent is asked; `agent` is the **Agent Instance**, which the +Operator runs and the Gateway merely connects to. Nothing here mounts a container runtime +socket and no host path is written anywhere, so the Gateway cannot start a container and has no +name for a directory the Docker daemon would resolve. That is the whole arrangement: the +Gateway holds a TCP address and nothing else about the agent. + +What `agent` starts is a listener rather than the agent: + +```yaml + command: + - socat + - TCP-LISTEN:4000,reuseaddr,fork + - EXEC:pi --mode rpc --no-approve +``` + +`fork` means one `pi` process per TCP connection, and the Gateway opens one connection per Run +and closes it when the agent has settled. So a Run is still a process of its own, started for it +and gone with it; what carries over from one Run to the next is this container and the +directories mounted into it, which is the Operator's arrangement and nobody else's. Four things +in those three lines are load-bearing: + +- **No commas may appear in the `EXEC:` argument.** Comma is `socat`'s own option separator, so + a comma anywhere in it is read as an option rather than as part of the command, and what runs + is quietly not what was written. The command is a list and not a string for the neighbouring + reason: no shell is involved, and `socat` splits `EXEC:` on spaces itself. +- **`--no-approve` is the Operator's to pass**, and this line is the only place it now appears. + It is the flag that stops a Run arranging for the next one to load configuration out of the + writable Workspace, and the framework cannot fasten it to a command line it does not write. +- **`PI_OFFLINE` was a framework default** back when the framework started the container. It is + an environment variable in this file now, and there is nowhere else left for it to be. +- **`socat`'s `stderr` option must never be added.** `EXEC` wires stdin and stdout to the + socket, and `pi` writes its diagnostics to `socat`'s stderr; merging the two puts non-JSON + into the record stream and fails every Run. + +`Dockerfile.agent` is that container's image: `pi` at a pinned version, `socat` to listen with, +and `curl`, because `pi` ships no HTTP client and reaching the Agent server is the agent's own +shell tool plus `curl`. It declares no `ENTRYPOINT`, since the process this container starts is +the listener and the agent is what the listener starts. + +**There is no healthcheck on `agent`, deliberately.** With `fork`, every connection starts a +`pi`, so a probe on an interval would boot and discard one for ever. A plain `depends_on` is +enough: an instance that is not listening is an ordinary failed Run carrying the address, not a +boot failure, and a Gateway that refused to start over it would take every other Party's access +down with the agent's. + +**The agent's environment is the Operator's, whole.** The image, the model credential, the files +`pi` reads, the flags it is started with: every one of them is written on `agent` in +`compose.yml`, and the Gateway is given none of it. `ANTHROPIC_API_KEY` is set there and in no +other service. `AGENTS.md` and `settings.json` are mounted read-only out of this directory, +which is how the agent learns that the Agent server exists and which model to talk to, and +mounting them read-only is what keeps a Run from rewriting the instructions the next Run reads. + +**The Gateway is told three things about the instance and nothing else:** + +```yaml + AGENT_INSTANCE_HOST: agent + AGENT_INSTANCE_PORT: "4000" + AGENT_SESSIONS_DIR: /sessions +``` + +`AGENT_SERVER_HOST` and `AGENT_SERVER_PORT` beside them point the other way entirely: they are +where the Gateway's own Agent server listens for the agent's `curl`. Two addresses cross on the +same network and the names are long so that neither can be read as the other. + +`AGENT_SESSIONS_DIR` is **the path the Agent Instance sees**, and it is the same string as the +target of the `./state/sessions:/sessions` mount on `agent`. It is written twice because nothing +can check it once: one end is resolved by `pi` and the other by the Docker daemon, and neither +can see the other's filesystem. A Session named `user_abc` is `/sessions/user_abc.jsonl` inside that +container and `state/sessions/user_abc.jsonl` here, which is where to read a transcript. + ## Look around - `main.ts` is the whole deployment: the Runtime, six components, one Handler, the prompt that Handler renders, and the seeding block that creates both people in one transaction. -- `AGENTS.md` is mounted read-only into the agent's Workspace, and it is where the agent is told - that a commitment is published as a Decision and then messaged to both parties. Publishing - notifies nobody, so without that second step a Decision sits in a log nobody is watching. It - is also what keeps the terminal client a client of two routes and nothing else. +- `AGENTS.md` is mounted read-only into the Agent Instance's Workspace, and it is where the + agent is told that a commitment is published as a Decision and then messaged to both parties. + Publishing notifies nobody, so without that second step a Decision sits in a log nobody is + watching. It is also what keeps the terminal client a client of two routes and nothing else. - The Gateway describes its own HTTP API, and the Public server is published at . That is 8082 and not 8080, so this stack and the other examples can run at the same time. diff --git a/examples/02_decisions/compose.yml b/examples/02_decisions/compose.yml index 832a25f..64428f4 100644 --- a/examples/02_decisions/compose.yml +++ b/examples/02_decisions/compose.yml @@ -16,24 +16,19 @@ services: context: . dockerfile: Dockerfile environment: - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?copy .env.example to .env and put your model key in it} DATABASE_URL: *database-url ALICE_PASSWORD: ${ALICE_PASSWORD:?copy .env.example to .env} BOB_PASSWORD: ${BOB_PASSWORD:?copy .env.example to .env} SIGNING_KEY_FILE: /app/insecure-example-only-signing-key.pem PUBLIC_HOST: 0.0.0.0 PUBLIC_PORT: "8082" - AGENT_HOST: 0.0.0.0 - AGENT_PORT: "7411" - AGENT_SERVER_URL: http://gateway:7411 - AGENT_IMAGE: concorde-decisions-agent:0.83.0 - AGENT_NETWORK: concorde_decisions_agent - RUNTIME_DIR_HOST: ${PWD} + AGENT_SERVER_HOST: 0.0.0.0 + AGENT_SERVER_PORT: "7411" + AGENT_INSTANCE_HOST: agent + AGENT_INSTANCE_PORT: "4000" + AGENT_SESSIONS_DIR: /sessions volumes: - - /var/run/docker.sock:/var/run/docker.sock - ./insecure-example-only-signing-key.pem:/app/insecure-example-only-signing-key.pem:ro - - ./state/workspace:/app/state/workspace - - ./state/agent:/app/state/agent ports: - "127.0.0.1:8082:8082" networks: [db, agent, public] @@ -42,10 +37,31 @@ services: condition: service_healthy migrate: condition: service_completed_successfully - agent-image: - condition: service_completed_successfully + agent: + condition: service_started stop_grace_period: 300s + agent: + build: + context: . + dockerfile: Dockerfile.agent + command: + - socat + - TCP-LISTEN:4000,reuseaddr,fork + - EXEC:pi --mode rpc --no-approve + environment: + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?copy .env.example to .env and put your model key in it} + AGENT_SERVER_URL: http://gateway:7411 + PI_OFFLINE: "1" + PI_CODING_AGENT_DIR: /home/agent/.pi/agent + volumes: + - ./state/workspace:/workspace + - ./state/agent:/home/agent/.pi/agent + - ./state/sessions:/sessions + - ./AGENTS.md:/workspace/AGENTS.md:ro + - ./settings.json:/home/agent/.pi/agent/settings.json:ro + networks: [agent] + migrate: build: context: . @@ -74,15 +90,6 @@ services: timeout: 3s retries: 20 - agent-image: - build: - context: . - dockerfile: Dockerfile.agent - image: concorde-decisions-agent:0.83.0 - command: ["--version"] - restart: "no" - networks: [agent] - tui-alice: <<: *tui environment: diff --git a/examples/02_decisions/main.ts b/examples/02_decisions/main.ts index 1efea86..5e42f1f 100644 --- a/examples/02_decisions/main.ts +++ b/examples/02_decisions/main.ts @@ -24,28 +24,19 @@ const people = [ const tokenTtl = 30 * 24 * 60 * 60 * 1000; const runtime = createPiRuntime({ - image: process.env.AGENT_IMAGE!, - env: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!, - AGENT_SERVER_URL: process.env.AGENT_SERVER_URL!, - }, - networks: [process.env.AGENT_NETWORK!], - mounts: { - runtimeDir: process.env.RUNTIME_DIR_HOST!, - entries: [ - { agentPath: "/workspace", path: "state/workspace" }, - { agentPath: "/home/agent/.pi/agent", path: "state/agent" }, - { agentPath: "/workspace/AGENTS.md", path: "AGENTS.md", readOnly: true }, - { agentPath: "/home/agent/.pi/agent/settings.json", path: "settings.json", readOnly: true }, - ], - }, + host: process.env.AGENT_INSTANCE_HOST!, + port: Number(process.env.AGENT_INSTANCE_PORT), + sessionsDir: process.env.AGENT_SESSIONS_DIR!, }); const gateway = createGateway({ databaseUrl: process.env.DATABASE_URL!, runtime, publicListen: { host: process.env.PUBLIC_HOST!, port: Number(process.env.PUBLIC_PORT) }, - agentListen: { host: process.env.AGENT_HOST!, port: Number(process.env.AGENT_PORT) }, + agentListen: { + host: process.env.AGENT_SERVER_HOST!, + port: Number(process.env.AGENT_SERVER_PORT), + }, extend: ({ db, agentServer, publicServer, worker }) => { const users = createUsers({ db, agentServer, publicServer }); const passwordAuth = createPasswordAuth({ db, users, publicServer, tokenTtl }); diff --git a/examples/03_nostr/.env.example b/examples/03_nostr/.env.example index ad5d90e..e258f6f 100644 --- a/examples/03_nostr/.env.example +++ b/examples/03_nostr/.env.example @@ -1,4 +1,6 @@ # Copy this file to .env and fill in the one blank at the bottom. +# The model credential, and the Agent Instance's alone: it is set on `agent` in `compose.yml` +# and the Gateway is never given it. ANTHROPIC_API_KEY= # --------------------------------------------------------------------------------------------- diff --git a/examples/03_nostr/Dockerfile b/examples/03_nostr/Dockerfile index da9bef0..29d0336 100644 --- a/examples/03_nostr/Dockerfile +++ b/examples/03_nostr/Dockerfile @@ -1,7 +1,5 @@ FROM node:24-alpine -RUN apk add --no-cache docker-cli - WORKDIR /app COPY package.json ./ diff --git a/examples/03_nostr/Dockerfile.agent b/examples/03_nostr/Dockerfile.agent index 0031278..26211d5 100644 --- a/examples/03_nostr/Dockerfile.agent +++ b/examples/03_nostr/Dockerfile.agent @@ -1,10 +1,8 @@ FROM node:24-alpine -RUN apk add --no-cache curl +RUN apk add --no-cache curl socat -RUN npm install -g @earendil-works/pi-coding-agent@0.83.0 +RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 WORKDIR /workspace ENV PI_CODING_AGENT_DIR=/home/agent/.pi/agent - -ENTRYPOINT ["pi"] diff --git a/examples/03_nostr/README.md b/examples/03_nostr/README.md index f2bdf15..73e09aa 100644 --- a/examples/03_nostr/README.md +++ b/examples/03_nostr/README.md @@ -46,6 +46,79 @@ Run either with no argument to listen and say nothing. Ctrl-C stops listening. docker compose down -v ``` +## The two services + +This deployment is two containers that matter, and the Gateway is one of them. `gateway` serves +the people and decides what the agent is asked; `agent` is the **Agent Instance**, which the +Operator runs and the Gateway merely connects to. Nothing here mounts a container runtime +socket and no host path is written anywhere, so the Gateway cannot start a container and has no +name for a directory the Docker daemon would resolve. That is the whole arrangement: the +Gateway holds a TCP address and nothing else about the agent. + +What `agent` starts is a listener rather than the agent: + +```yaml + command: + - socat + - TCP-LISTEN:4000,reuseaddr,fork + - EXEC:pi --mode rpc --no-approve +``` + +`fork` means one `pi` process per TCP connection, and the Gateway opens one connection per Run +and closes it when the agent has settled. So a Run is still a process of its own, started for it +and gone with it; what carries over from one Run to the next is this container and the +directories mounted into it, which is the Operator's arrangement and nobody else's. Four things +in those three lines are load-bearing: + +- **No commas may appear in the `EXEC:` argument.** Comma is `socat`'s own option separator, so + a comma anywhere in it is read as an option rather than as part of the command, and what runs + is quietly not what was written. The command is a list and not a string for the neighbouring + reason: no shell is involved, and `socat` splits `EXEC:` on spaces itself. +- **`--no-approve` is the Operator's to pass**, and this line is the only place it now appears. + It is the flag that stops a Run arranging for the next one to load configuration out of the + writable Workspace, and the framework cannot fasten it to a command line it does not write. +- **`PI_OFFLINE` was a framework default** back when the framework started the container. It is + an environment variable in this file now, and there is nowhere else left for it to be. +- **`socat`'s `stderr` option must never be added.** `EXEC` wires stdin and stdout to the + socket, and `pi` writes its diagnostics to `socat`'s stderr; merging the two puts non-JSON + into the record stream and fails every Run. + +`Dockerfile.agent` is that container's image: `pi` at a pinned version, `socat` to listen with, +and `curl`, because `pi` ships no HTTP client and reaching the Agent server is the agent's own +shell tool plus `curl`. It declares no `ENTRYPOINT`, since the process this container starts is +the listener and the agent is what the listener starts. + +**There is no healthcheck on `agent`, deliberately.** With `fork`, every connection starts a +`pi`, so a probe on an interval would boot and discard one for ever. A plain `depends_on` is +enough: an instance that is not listening is an ordinary failed Run carrying the address, not a +boot failure, and a Gateway that refused to start over it would take every other Party's access +down with the agent's. + +**The agent's environment is the Operator's, whole.** The image, the model credential, the files +`pi` reads, the flags it is started with: every one of them is written on `agent` in +`compose.yml`, and the Gateway is given none of it. `ANTHROPIC_API_KEY` is set there and in no +other service. `AGENTS.md` and `settings.json` are mounted read-only out of this directory, +which is how the agent learns that the Agent server exists and which model to talk to, and +mounting them read-only is what keeps a Run from rewriting the instructions the next Run reads. + +**The Gateway is told three things about the instance and nothing else:** + +```yaml + AGENT_INSTANCE_HOST: agent + AGENT_INSTANCE_PORT: "4000" + AGENT_SESSIONS_DIR: /sessions +``` + +`AGENT_SERVER_HOST` and `AGENT_SERVER_PORT` beside them point the other way entirely: they are +where the Gateway's own Agent server listens for the agent's `curl`. Two addresses cross on the +same network and the names are long so that neither can be read as the other. + +`AGENT_SESSIONS_DIR` is **the path the Agent Instance sees**, and it is the same string as the +target of the `./state/sessions:/sessions` mount on `agent`. It is written twice because nothing +can check it once: one end is resolved by `pi` and the other by the Docker daemon, and neither +can see the other's filesystem. A Session named `user_abc` is `/sessions/user_abc.jsonl` inside that +container and `state/sessions/user_abc.jsonl` here, which is where to read a transcript. + ## Look around - `main.ts` is the whole deployment: the Runtime, three components, one Handler, the prompt that @@ -61,8 +134,8 @@ docker compose down -v - The relay is published at `ws://127.0.0.1:7777`, so your own tooling can reach it. Nothing in this stack needs that: every container reaches it by service name. - The Gateway describes its own HTTP API at . -- `AGENTS.md` is mounted read-only into the agent's Workspace and tells it that a Message it - sends travels as an encrypted direct message to somebody's Nostr client. +- `AGENTS.md` is mounted read-only into the Agent Instance's Workspace and tells it that a + Message it sends travels as an encrypted direct message to somebody's Nostr client. ## The keys are worthless diff --git a/examples/03_nostr/compose.yml b/examples/03_nostr/compose.yml index f0a9021..d02412d 100644 --- a/examples/03_nostr/compose.yml +++ b/examples/03_nostr/compose.yml @@ -10,7 +10,6 @@ services: context: . dockerfile: Dockerfile environment: - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?copy .env.example to .env and put your model key in it} DATABASE_URL: *database-url RELAY_URL: *relay-url NOSTR_AGENT_SECRET_KEY: ${NOSTR_AGENT_SECRET_KEY:?copy .env.example to .env} @@ -18,16 +17,11 @@ services: BOB_PUBKEY: ${BOB_PUBKEY:?copy .env.example to .env} PUBLIC_HOST: 0.0.0.0 PUBLIC_PORT: "8083" - AGENT_HOST: 0.0.0.0 - AGENT_PORT: "7411" - AGENT_SERVER_URL: http://gateway:7411 - AGENT_IMAGE: concorde-nostr-agent:0.83.0 - AGENT_NETWORK: concorde_nostr_agent - RUNTIME_DIR_HOST: ${PWD} - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./state/workspace:/app/state/workspace - - ./state/agent:/app/state/agent + AGENT_SERVER_HOST: 0.0.0.0 + AGENT_SERVER_PORT: "7411" + AGENT_INSTANCE_HOST: agent + AGENT_INSTANCE_PORT: "4000" + AGENT_SESSIONS_DIR: /sessions ports: - "127.0.0.1:8083:8083" networks: [db, agent, nostr] @@ -38,10 +32,31 @@ services: condition: service_healthy migrate: condition: service_completed_successfully - agent-image: - condition: service_completed_successfully + agent: + condition: service_started stop_grace_period: 300s + agent: + build: + context: . + dockerfile: Dockerfile.agent + command: + - socat + - TCP-LISTEN:4000,reuseaddr,fork + - EXEC:pi --mode rpc --no-approve + environment: + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?copy .env.example to .env and put your model key in it} + AGENT_SERVER_URL: http://gateway:7411 + PI_OFFLINE: "1" + PI_CODING_AGENT_DIR: /home/agent/.pi/agent + volumes: + - ./state/workspace:/workspace + - ./state/agent:/home/agent/.pi/agent + - ./state/sessions:/sessions + - ./AGENTS.md:/workspace/AGENTS.md:ro + - ./settings.json:/home/agent/.pi/agent/settings.json:ro + networks: [agent] + migrate: build: context: . @@ -83,15 +98,6 @@ services: timeout: 3s retries: 20 - agent-image: - build: - context: . - dockerfile: Dockerfile.agent - image: concorde-nostr-agent:0.83.0 - command: ["--version"] - restart: "no" - networks: [agent] - nak-alice: build: context: . diff --git a/examples/03_nostr/main.ts b/examples/03_nostr/main.ts index 3df0cc4..2ed09cc 100644 --- a/examples/03_nostr/main.ts +++ b/examples/03_nostr/main.ts @@ -19,28 +19,19 @@ const people = [ ]; const runtime = createPiRuntime({ - image: process.env.AGENT_IMAGE!, - env: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!, - AGENT_SERVER_URL: process.env.AGENT_SERVER_URL!, - }, - networks: [process.env.AGENT_NETWORK!], - mounts: { - runtimeDir: process.env.RUNTIME_DIR_HOST!, - entries: [ - { agentPath: "/workspace", path: "state/workspace" }, - { agentPath: "/home/agent/.pi/agent", path: "state/agent" }, - { agentPath: "/workspace/AGENTS.md", path: "AGENTS.md", readOnly: true }, - { agentPath: "/home/agent/.pi/agent/settings.json", path: "settings.json", readOnly: true }, - ], - }, + host: process.env.AGENT_INSTANCE_HOST!, + port: Number(process.env.AGENT_INSTANCE_PORT), + sessionsDir: process.env.AGENT_SESSIONS_DIR!, }); const gateway = createGateway({ databaseUrl: process.env.DATABASE_URL!, runtime, publicListen: { host: process.env.PUBLIC_HOST!, port: Number(process.env.PUBLIC_PORT) }, - agentListen: { host: process.env.AGENT_HOST!, port: Number(process.env.AGENT_PORT) }, + agentListen: { + host: process.env.AGENT_SERVER_HOST!, + port: Number(process.env.AGENT_SERVER_PORT), + }, extend: ({ db, agentServer, worker }) => { const users = createUsers({ db, agentServer }); const messenger = createMessenger({ db, users, worker, agentServer }); diff --git a/package.json b/package.json index 8d00792..5f72415 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,6 @@ "types": "./dist/db/index.d.ts", "default": "./dist/db/index.js" }, - "./agent-container": { - "types": "./dist/agent-container/index.d.ts", - "default": "./dist/agent-container/index.js" - }, "./signals": { "types": "./dist/signals/index.d.ts", "default": "./dist/signals/index.js" @@ -121,7 +117,7 @@ "lint": "biome check .", "format": "biome check --write .", "test": "node --test \"src/**/*.test.ts\"", - "test:container": "CONCORDE_CONTAINER_TESTS=1 node --test \"src/pi/container.test.ts\"", + "test:container": "CONCORDE_CONTAINER_TESTS=1 node --test \"src/pi/agent-instance.test.ts\"", "check": "npm run typecheck && npm run build && npm run lint && npm test", "check:package": "node scripts/check-package.ts", "extract:routes": "node scripts/reference/extract-routes.ts", diff --git a/scripts/check-package.ts b/scripts/check-package.ts index 2a6f85c..e98ba1f 100644 --- a/scripts/check-package.ts +++ b/scripts/check-package.ts @@ -3,9 +3,9 @@ * * - `dist` mirrors `src`, so nothing ships whose source is gone. * - the tarball installs into a fresh project. - * - **all twenty-three** entries resolve there, both to the type checker and to Node at - * runtime, and twenty-three is the whole map. Fifteen are the subpath a Developer imports a part - * from: `/gateway`, `/logging`, `/db`, `/agent-container`, `/signals`, `/pi`, `/users`, + * - **all twenty-two** entries resolve there, both to the type checker and to Node at + * runtime, and twenty-two is the whole map. Fourteen are the subpath a Developer imports a part + * from: `/gateway`, `/logging`, `/db`, `/signals`, `/pi`, `/users`, * `/password-auth`, `/nostr-auth`, `/messenger`, `/http-channel`, `/nostr-channel`, * `/signatures`, `/decisions` and `/scheduler`. The other **eight are `/schema`**, one per * component that owns tables, because `drizzle-kit`'s config takes file paths and an export @@ -13,14 +13,14 @@ * one of the two, and the runtime step below is what proves the component subpath carries none * of them. `/messenger` carries the log and `/http-channel` reaches a person over HTTP, which is * the split; `/nostr-channel` is the other Channel and the one Channel that owns tables. - * - **two of the fifteen are Auths**, so the whole of what a deployment accepts is which of them + * - **two of the fourteen are Auths**, so the whole of what a deployment accepts is which of them * it constructs. `main.ts` below constructs Password Auth and Nostr Auth and writes an Auth of * the consumer's own beside them, and all three register themselves with the Public server. * The second of them registers **no route**, so its whole surface here is one construction, * one method and the two tables on its specifier. * - **there is no `.` in that map, and the last step below reads Node saying so.** The assembly - * is on `/gateway`, the logging seam on `/logging`, the Db on `/db` and the container plumbing - * on `/agent-container`, and a bare `@shutter-network/concorde` fails with + * is on `/gateway`, the logging seam on `/logging` and the Db on `/db`, and a bare + * `@shutter-network/concorde` fails with * `ERR_PACKAGE_PATH_NOT_EXPORTED`. What that buys is that nothing lands on the root by * accident: a re-export written there resolves to nowhere, so adding a root export is a * deliberate edit to `exports` and not a slip. @@ -69,12 +69,12 @@ import { fileURLToPath } from "node:url"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); /** - * The consumer's imports, spelled once: the type checker and Node see the same twenty-three. + * The consumer's imports, spelled once: the type checker and Node see the same twenty-two. * - * **Twenty-three lines, one per entry point, and a component's tables are on a line of their + * **Twenty-two lines, one per entry point, and a component's tables are on a line of their * own.** That is the whole of the split: `drizzle-kit`'s config takes file paths, so the tables * need an export entry of their own, and having one they are on it and on nothing else. No line - * names the bare package, because there is no `.` export left: the four infrastructure specifiers + * names the bare package, because there is no `.` export left: the three infrastructure specifiers * at the top carry what the root used to, and the last step in this script is what proves the root * itself resolves to nothing. * @@ -88,10 +88,10 @@ const repoRoot = fileURLToPath(new URL("..", import.meta.url)); * apart. **An alias added to a line below removes the check for that name.** Reach for a rename in * the package instead. * - * The four infrastructure lines own no tables. `/gateway` carries the two assembly constructors - * and the server adapter, `/logging` the default Logger, `/db` the one call that opens a pool, and - * `/agent-container` what `docker run` takes: nothing under it has heard of an Agent - * Implementation, so a second one needs all of it unchanged. + * The three infrastructure lines own no tables. `/gateway` carries the two assembly constructors + * and the server adapter, `/logging` the default Logger, and `/db` the one call that opens a pool. + * The fourth of them is gone with container-per-Run: the Gateway starts no agent, so there is no + * subpath carrying what `docker run` takes and no module here that knows what a container is. * * The HTTP Channel's line carries a constructor and nothing beside it, and it has no `/schema` line * at all, because it owns no tables: the log is the Messenger's, whichever medium a Message @@ -119,13 +119,12 @@ const repoRoot = fileURLToPath(new URL("..", import.meta.url)); * component's vocabulary and belongs beside it. */ const consumerImports = [ - 'import { createAgentContainerRuntime, mountArguments } from "@shutter-network/concorde/agent-container";', 'import { openDb } from "@shutter-network/concorde/db";', 'import { createBareGateway, createGateway, NoAuthRegisteredError, serverComponent } from "@shutter-network/concorde/gateway";', 'import { defaultLogger } from "@shutter-network/concorde/logging";', 'import { createSignalWorker, runStates, signalStates, templateHandler } from "@shutter-network/concorde/signals";', 'import { runs, signals, signalsSchema, signalsTables } from "@shutter-network/concorde/signals/schema";', - 'import { createPiRuntime, interpretPiOutput, piRun } from "@shutter-network/concorde/pi";', + 'import { createPiRuntime } from "@shutter-network/concorde/pi";', 'import { createUsers } from "@shutter-network/concorde/users";', 'import { users, usersSchema, usersTables } from "@shutter-network/concorde/users/schema";', 'import { createPasswordAuth } from "@shutter-network/concorde/password-auth";', @@ -231,26 +230,21 @@ try { "dist/logging/logging.d.ts", "dist/pi/index.js", "dist/pi/index.d.ts", - // The `pi` Agent Implementation's own modules, which are now two. `dist/pi/` - // mirroring `src/pi/` is what makes the subpath resolve to the same relative imports - // in the repository and in the package, and the fixtures beside them must not come - // along. + // The `pi` Agent Implementation's own modules, which are now four and none of which + // spawns anything. `dist/pi/` mirroring `src/pi/` is what makes the subpath resolve to + // the same relative imports in the repository and in the package, and the fixtures + // beside them must not come along. "dist/pi/runtime.js", "dist/pi/runtime.d.ts", + // The RPC channel, which is the whole of the client: `node:net`, one connection per + // Run, and no runtime dependency behind it. + "dist/pi/rpc.js", + "dist/pi/rpc.d.ts", + // The one module that turns bytes into records, and the one place the LF-only framing + // rule lives. + "dist/pi/framing.js", + "dist/pi/framing.d.ts", "dist/pi/output.js", - // The Agent Container and its Runtime, which belong to no Agent Implementation: they - // ship under their own directory and are reachable on `/agent-container`, because - // nothing in them knows about one and the next one needs them unchanged. - // `process.js` is here rather than under `dist/pi/` for the same reason, - // and it moved rather than being rewritten. - "dist/agent-container/index.js", - "dist/agent-container/index.d.ts", - "dist/agent-container/agent-container.js", - "dist/agent-container/agent-container.d.ts", - "dist/agent-container/mount-table.js", - "dist/agent-container/mount-table.d.ts", - "dist/agent-container/process.js", - "dist/agent-container/process.d.ts", // `dist` mirrors `src`, so `src/db/db.ts` becomes `dist/db/db.js`. Nothing is // resolved from `import.meta.url` any more — that trick existed only to reach a // shipped migration folder, and there is none. @@ -441,6 +435,14 @@ try { "dist/container/agent-container.js", "dist/container/mount-table.js", "dist/container/process.js", + // And the whole of the subpath that carried container-per-Run. The Gateway starts no + // agent, so nothing ships that knows how: an Operator runs the Agent Instance, and an + // Operator who wants a container per Run writes a Runtime, which is one method. + "dist/agent-container/index.js", + "dist/agent-container/index.d.ts", + "dist/agent-container/agent-container.js", + "dist/agent-container/mount-table.js", + "dist/agent-container/process.js", ]) { assert.ok(!entries.has(gone), `the tarball should no longer ship ${gone}`); } @@ -597,18 +599,6 @@ try { // `skipLibCheck: false` here, so an export it does not mention is an export // nothing checks: a declaration that resolved to `any`, or went missing // altogether, would type-check in this project without it. - // The Agent Container's own types, on the specifier that carries what `docker run` takes. - // Nothing under it has heard of an Agent Implementation, so `/pi` names none of these and - // an author of a second Implementation reaches for exactly this set. - "import type {", - " AgentContainer,", - " AgentContainerRuntime,", - " AgentContainerRuntimeSpec,", - " ComposedCommand,", - " Mount,", - " MountTable,", - " RunPlan,", - '} from "@shutter-network/concorde/agent-container";', // The Db's own types: the handle, the transaction it hands a callback, the `LISTEN` // registration and the listener it calls back. `pg` is nowhere among them, which is what // keeps the pool out of the public API. @@ -668,11 +658,13 @@ try { " SignalWorkerOptions,", " TemplateHandlerOptions,", '} from "@shutter-network/concorde/signals";', - // The `pi` subpath exports **no type at all**, which is the shape one function - // leaves it in: there is no configuration to name, and everything the Runtime it returns - // is made of — the Agent Container, the Run plan, the composed command line — comes - // from `/agent-container`, because none of it is `pi`-shaped. The three values it - // does export are in `consumerImports` above. + // The `pi` subpath exports **one type and one function**, and nothing else is reachable + // through it. There is no Agent Container, no Mount Table and no command line to name, + // because the Gateway starts nothing: what an Operator declares is where the Agent + // Instance is and where it keeps Sessions. + "import type {", + " PiInstance,", + '} from "@shutter-network/concorde/pi";', // The Users component's own types, from its own subpath, for the same reason: // a deployment with no identity in it imports nothing from there. There is no // `IssuedToken` and no `ScryptParameters` among them any more: a credential is an Auth's, @@ -1378,108 +1370,23 @@ try { "export const assembledPublic: FastifyInstance = assembled.components.publicServer.fastify;", "export const assembledAgent: FastifyInstance = assembled.components.agentServer.fastify;", "", - "// What a `pi` deployment declares, which is an Agent Container and nothing else.", - "// There is no configuration type on the `/pi` subpath any more: no model, no", - "// provider and no container path, because the agent reads all of those out of a", - "// `settings.json` the Operator mounts and a `Dockerfile` they build.", - "// The Mount Table comes from `/agent-container`, not from `/pi`: it knows nothing", - "// about an Agent Implementation, and an entry may name a directory or a single", - "// file and may be read-only — which is how the `AGENTS.md` below, and the", - "// `settings.json` beside it, are protected from the agent that reads them", - "//.", - 'const workspace: Mount = { agentPath: "/workspace", path: "workspace" };', - "const mounts: MountTable = {", - " entries: [", - " workspace,", - ' { agentPath: "/home/agent/.pi/agent", path: "agent" },', - ' { agentPath: "/workspace/AGENTS.md", path: "AGENTS.md", readOnly: true },', - ' { agentPath: "/home/agent/.pi/agent/settings.json", path: "settings.json", readOnly: true },', - " ],", - " // The one namespace the table has: the host's path to the Runtime Directory, which", - " // is what the daemon resolves a bind source in. Every entry above is written", - " // relative to it, and a leading `/` on one is refused.", - ' runtimeDir: "/srv/concorde",', - "};", - "// One exported function and no resolved layer beside it: what a consumer holds is", - "// the `--mount` argument list itself. Type-annotated, so a declaration that resolved", - "// to `any` or went missing fails here.", - "export const piMountArguments: readonly string[] = mountArguments(mounts);", - "", - "// The Agent Container and the generic Runtime built from it, from `/agent-container`", - "// rather than from `/pi`, because nothing in either has heard of an Agent", - "// Implementation and the next one needs both unchanged. Only `image` is", - "// required; everything else here is a field an Operator may leave out. What an Agent", - "// Implementation adds is the one function below, whose outcome reader is produced", - "// per Run so it can name the Session in a failure.", - "const container: AgentContainer = {", - ' image: "concorde/agent:latest",', - " mounts,", - ' entrypoint: ["agent"],', - ' networks: ["concorde-agent", "concorde-models"],', - ' env: { ANTHROPIC_API_KEY: "sk-not-a-key" },', - ' extraArgs: ["--memory", "2g"],', - ' containerCommand: ["docker"],', + "// What a `pi` deployment declares, which is where the Agent Instance is and where it", + "// keeps its Sessions. Three values and no fourth: the Gateway starts nothing, so", + "// there is no image, no mount, no model, no provider and no credential to name here.", + "// Everything the agent reads on disk and everything it is started with belongs to the", + "// Operator's own compose file, on the other side of this address.", + "const instance: PiInstance = {", + ' host: "agent",', + " port: 4000,", + " // As the Agent Instance sees it. Absolute, and refused at construction if it is not:", + " // the Gateway never opens this directory and could not check what is in it.", + ' sessionsDir: "/sessions",', " logger: log,", "};", - "function agentRun(asked: RunPrompt): RunPlan {", - " return {", - ' args: ["--session-id", asked.session],', - " stdin: asked.text,", - " async outcome(stdout: AsyncIterable): Promise {", - " for await (const chunk of stdout) void chunk;", - " return { ok: true };", - " },", - " };", - "}", - "const containerSpec: AgentContainerRuntimeSpec = { container, run: agentRun };", - "export const containerRuntime: AgentContainerRuntime =", - " createAgentContainerRuntime(containerSpec);", - "// A Runtime like any other, so it goes straight into the Signal Worker's option —", - "// and one that can also show its command line without starting anything, which is", - "// what makes an author's argument tests pure.", - "export const asRuntime: Runtime = containerRuntime;", - "export const composed: ComposedCommand = containerRuntime.commandFor({", - ' session: "user_42",', - ' text: "what happened?",', - "});", - "// The `pi` Runtime itself, which is what an Operator actually passes to the Signal", - "// Worker: one call taking one value, with no second call to remember and no type of", - "// its own to hold one. It contributes two defaults to the container — the entry", - "// point and `PI_OFFLINE` — and `piRun`, and nothing else.", - "export const pi: AgentContainerRuntime = createPiRuntime({", - ' image: "concorde/pi:latest",', - " mounts,", - ' networks: ["concorde-agent"],', - ' env: { ANTHROPIC_API_KEY: "sk-not-a-key" },', - ' extraArgs: ["--memory", "2g"],', - " logger: log,", - "});", - "// Annotated as a Runtime because that is the seam the Signal Worker is given, and a", - "// `pi` Runtime is one like any other.", - "export const piAsRuntime: Runtime = pi;", - "export const piCommand: ComposedCommand = pi.commandFor({", - ' session: "user_42",', - ' text: "what happened?",', - "});", - "", - "// The two pure functions the subpath ships beside it, which are the whole of what", - "// `pi` adds to a container. An Operator could spawn the container themselves out of", - "// these, and an author of a second Agent Implementation writes the equivalent of the", - "// first one and nothing else — which is what this pair is exported to demonstrate.", - "export const piByHand: Runtime = {", - " async run(prompt: RunPrompt): Promise {", - " const plan: RunPlan = piRun(prompt);", - " const stdout: AsyncIterable = (async function* () {", - ' yield new TextEncoder().encode(plan.args.join(" ") + plan.stdin);', - " })();", - " return plan.outcome(stdout);", - " },", - "};", - "// The reader on its own, which takes the Session so a failure can name it.", - "export const piOutcome: Promise = interpretPiOutput(", - " (async function* () {})(),", - ' "user_42",', - ");", + "// Annotated as a Runtime because that is the seam the Signal Worker is given, and the", + "// `pi` Runtime is one like any other. Nothing connects here: an Agent Instance that is", + "// not listening is a failed Run and never a boot failure.", + "export const pi: Runtime = createPiRuntime(instance);", "", "// A Producer of the Operator's own, told when something arrives on a channel", "// it shares with whoever notifies it. The connection is the Db's, so `pg`", @@ -1709,38 +1616,28 @@ try { // our own `node_modules` would hide a missing entry in every other check. // // The `/pi` subpath, actually run rather than only resolved: `createPiRuntime` - // reaches across to `../agent-container/index.ts` for the generic half and down to - // `./output.ts` for the reader, so this is what proves a relative `.ts` import - // *inside and out of* the subpath survives being compiled and installed — the - // thing the deleted placeholder used to stand for. - // The Mount Table, constructed and resolved from `/agent-container` the way an - // Operator meets it: this is what proves `--mount type=bind` arguments come out - // of an installed package rather than only out of this repository. - "const mounts = { runtimeDir: '/srv/concorde', entries: [", - " { agentPath: '/workspace', path: 'workspace' },", - " { agentPath: '/srv/concorde/agent', path: 'agent' },", - " { agentPath: '/workspace/AGENTS.md', path: 'AGENTS.md', readOnly: true },", - "] };", - "const mountArgs = mountArguments(mounts);", - // And the generic Runtime, constructed and asked for a command line from - // `/agent-container`. `commandFor` is pure, so this proves the whole of the argument - // assembly runs out of an installed package with no Docker anywhere near it — - // the image, the mounts, the user, the networks, the entry point and the agent's - // own arguments, in that order. - "const generic = createAgentContainerRuntime({", - " container: { image: 'concorde/agent:latest', mounts, networks: ['concorde-agent'], entrypoint: ['agent'], env: { ANTHROPIC_API_KEY: 'sk-not-a-key' } },", - " run: (asked) => ({ args: ['--session-id', asked.session], stdin: asked.text, outcome: async () => ({ ok: true }) }),", - "});", - "const composed = generic.commandFor({ session: 'user_42', text: 'what happened?' });", - // And the `pi` Runtime itself, constructed the way an Operator constructs it: - // an image and what the container sees, with no model, no provider and no - // container path anywhere. It refuses a container it cannot work with at - // construction, so this also proves that check runs from the installed package. - "const pi = createPiRuntime({ image: 'concorde/pi:latest', mounts, networks: ['concorde-agent'], env: { ANTHROPIC_API_KEY: 'sk-not-a-key' } });", - "const piCommand = pi.commandFor({ session: 'user_42', text: 'what happened?' });", - // The one function `pi` adds, on its own, which is what an author of a second - // Agent Implementation writes the equivalent of. - "const plan = piRun({ session: 'user_42', text: 'what happened?' });", + // reaches down to `./rpc.ts`, `./framing.ts` and `./output.ts`, so this is what + // proves a relative `.ts` import inside the subpath survives being compiled and + // installed — the thing the deleted placeholder used to stand for. + // + // Three values and no image anywhere: the Gateway starts nothing now, so what an + // Operator declares is where the Agent Instance is and where it keeps Sessions. The + // logger is silenced inline because stdout is this step's assertion channel. + "const hush = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} };", + "const pi = createPiRuntime({ host: '127.0.0.1', port: 1, sessionsDir: '/sessions', logger: hush });", + // A `sessionsDir` that is relative is refused at construction, which is the one thing + // this subpath can refuse on an Operator's behalf — so this proves that check runs + // from the installed package and not only out of this repository. + "let refusedDir = 'a relative sessionsDir was accepted';", + "try { createPiRuntime({ host: 'agent', port: 4000, sessionsDir: 'sessions', logger: hush }); } catch (error) { refusedDir = String(error.message).split(',')[0]; }", + // And two Runs, which is what makes this an import *and* a call. The first reaches an + // address nothing is listening on, so it exercises the `node:net` client out of the + // installed package and proves an unreachable Agent Instance is a failed Run carrying + // the address rather than a throw. The second never leaves the process: a Session name + // outside `pi`'s grammar fails that Run alone, and the grammar is carried here now + // because a Session is addressed by path and `pi` will open any path it is handed. + "const unreachable = await pi.run({ session: 'user_42', text: 'what happened?' });", + "const badName = await pi.run({ session: '../escape', text: 'what happened?' });", // Users, constructed as an Operator constructs it. `openDb` // connects lazily, so this reaches the database not at all: what it proves is // that the subpath resolves at runtime and that construction is free of side @@ -1867,14 +1764,6 @@ try { "byHand.register(async (f) => { f.get('/healthz', async () => ({ ok: true })); });", "await byHand.ready();", "const byHandDocument = (await byHand.inject({ method: 'GET', url: '/docs/json' })).json();", - "const encoder = new TextEncoder();", - "const settled = await plan.outcome((async function* () {", - " yield encoder.encode(JSON.stringify({ type: 'message_end', message: { role: 'assistant', stopReason: 'stop' } }) + '\\n');", - " yield encoder.encode(JSON.stringify({ type: 'agent_settled' }) + '\\n');", - "})());", - // And the reader on its own, on a stream that says nothing, because naming the - // Session in the failure is what the per-Run reader is for. - "const silent = await interpretPiOutput((async function* () {})(), 'user_7');", // The Scheduler's calendar arithmetic, run from the installed package's own dependency: the // next 09:00 UTC strictly after noon on 2030-06-01 is the following day, and the zone checks // hold — which is what proves `cron-parser` and `luxon` are declared and resolve here. @@ -1932,11 +1821,10 @@ try { // `drizzle.config.ts` hands `drizzle-kit` as its `schema`. Nothing else here // asks that question, every other check being an import. "const resolvesToFiles = owners.every((owner) => { const url = import.meta.resolve('@shutter-network/concorde/' + owner + '/schema'); return url.startsWith('file:') && url.endsWith('/dist/' + owner + '/schema/index.js'); });", - // Nothing writes anything: there is no call between composing and interpreting, - // because the module that used to hold one is gone from the package, and the - // composed command line names no file for the agent to read either — the - // Operator's `AGENTS.md` above is a mount and `pi` discovers it. - "const built = [typeof openDb, typeof templateHandler, piCommand.command + ' ' + piCommand.args.slice(-6).join(' '), plan.args.join(' '), String(settled.ok), mountArgs[1], composed.command + ' ' + composed.args.slice(-5).join(' '), composed.redactedArgs.join(' ').includes('sk-not-a-key') ? 'leaked' : 'redacted', piCommand.redactedArgs.join(' ').includes('sk-not-a-key') ? 'leaked' : 'redacted', String(['--model', '--provider', '--workdir', '--session-dir', '--append-system-prompt'].some((flag) => piCommand.args.includes(flag))), silent.error.split(' ').slice(0, 2).join(' '), String(Object.keys(pi).sort()), usersSchema.schemaName, String(Object.keys(directory).sort()), 'password auth ' + passwordAuth.scheme + ' ' + String(Object.keys(passwordAuth).sort()) + ' in ' + passwordAuthSchema.schemaName, 'nostr auth ' + nostrAuth.scheme + ' ' + String(Object.keys(nostrAuth).sort()) + ' in ' + nostrAuthSchema.schemaName, messengerSchema.schemaName, String(Object.keys(messenger).sort()), 'channel ' + httpChannel.name + ' ' + String(Object.keys(httpChannel).sort()), 'channel ' + nostrChannel.name + ' ' + String(Object.keys(nostrChannel).sort()) + ' as ' + nostrChannel.publicKey + ' in ' + nostrChannelSchema.schemaName, messageReceivedKind, decisionsSchema.schemaName, String(Object.keys(signatures).sort()), String(Object.keys(decisionsComponent).sort()), jws.split('.').length + ' segments, ' + Buffer.from(jwsSignature, 'base64url').length + ' signature bytes, verified ' + checked + ', private member ' + Object.hasOwn(keySet.keys[0], 'd'), String(Object.keys(assembled.components)), description.info.title + ' describes ' + Object.keys(description.paths).length + ' paths', 'by hand ' + Object.keys(byHandDocument.paths).join(','), 'cron ' + cronNext + ' zone ' + zoneKnown, 'scheduler ' + String(Object.keys(scheduler).sort()) + ' fires ' + scheduleFiredKind + ' in ' + schedulerSchema.schemaName, 'tables ' + collectedTables.sort().join(' ') + ' in ' + schemaNames.join(' ') + ', wrappers seen ' + wrappersSeen.length + ', wrappers present ' + wrappersPresent.length, 'schemas ' + distinctSchemas + ', on a component subpath ' + stillOnTheComponent.length + ', resolving to files ' + resolvesToFiles];", + // Nothing writes a file anywhere and nothing starts a process: the framework has + // no container runtime to reach for any more, and the one thing it opens is a + // socket to an address that refuses it. + "const built = [typeof openDb, typeof templateHandler, refusedDir, unreachable.ok ? 'it reached something' : unreachable.error.split(':').slice(0, 2).join(':'), badName.ok ? 'it took the name' : badName.error.split(':')[0], String(Object.keys(pi).sort()), usersSchema.schemaName, String(Object.keys(directory).sort()), 'password auth ' + passwordAuth.scheme + ' ' + String(Object.keys(passwordAuth).sort()) + ' in ' + passwordAuthSchema.schemaName, 'nostr auth ' + nostrAuth.scheme + ' ' + String(Object.keys(nostrAuth).sort()) + ' in ' + nostrAuthSchema.schemaName, messengerSchema.schemaName, String(Object.keys(messenger).sort()), 'channel ' + httpChannel.name + ' ' + String(Object.keys(httpChannel).sort()), 'channel ' + nostrChannel.name + ' ' + String(Object.keys(nostrChannel).sort()) + ' as ' + nostrChannel.publicKey + ' in ' + nostrChannelSchema.schemaName, messageReceivedKind, decisionsSchema.schemaName, String(Object.keys(signatures).sort()), String(Object.keys(decisionsComponent).sort()), jws.split('.').length + ' segments, ' + Buffer.from(jwsSignature, 'base64url').length + ' signature bytes, verified ' + checked + ', private member ' + Object.hasOwn(keySet.keys[0], 'd'), String(Object.keys(assembled.components)), description.info.title + ' describes ' + Object.keys(description.paths).length + ' paths', 'by hand ' + Object.keys(byHandDocument.paths).join(','), 'cron ' + cronNext + ' zone ' + zoneKnown, 'scheduler ' + String(Object.keys(scheduler).sort()) + ' fires ' + scheduleFiredKind + ' in ' + schedulerSchema.schemaName, 'tables ' + collectedTables.sort().join(' ') + ' in ' + schemaNames.join(' ') + ', wrappers seen ' + wrappersSeen.length + ', wrappers present ' + wrappersPresent.length, 'schemas ' + distinctSchemas + ', on a component subpath ' + stillOnTheComponent.length + ', resolving to files ' + resolvesToFiles];", "process.stdout.write(built.join(':'));", ].join("\n"), ], @@ -1944,12 +1832,12 @@ try { ); assert.equal( imported, - "function:function:docker concorde/pi:latest --mode json --session-id user_42 --no-approve:--mode json --session-id user_42 --no-approve:true:type=bind,source=/srv/concorde/workspace,target=/workspace:docker --entrypoint agent concorde/agent:latest --session-id user_42:redacted:redacted:false:Session user_7:commandFor,run:concorde_users:agentRoutes,create,get,list,setAttributes,start,stop:password auth Bearer authenticate,issueToken,revoke,scheme,setPassword,start,stop in concorde_password_auth:nostr auth Nostr authenticate,recordPublicKey,scheme,start,stop in concorde_nostr_auth:concorde_messenger:history,register,send,start,stop:channel http name,send,start,stop:channel nostr drain,name,publicKey,recordPublicKey,send,start,stop as 1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f in concorde_nostr_channel:message.received:concorde_decisions:sign,start,stop:history,publish,start,stop:3 segments, 64 signature bytes, verified true, private member false:db,agentServer,publicServer,users,passwordAuth,signatures,decisions,messenger,httpChannel,ownLoop,worker:Concorde Gateway: Agent server describes 10 paths:by hand /healthz:cron 2030-06-02T09:00:00.000Z zone true:scheduler cancel,list,schedule,start,stop,tick fires concorde_schedule_fired in concorde_scheduler:tables concorde_decisions.decisions concorde_messenger.messages concorde_nostr_auth.admitted concorde_nostr_auth.grants concorde_nostr_channel.outbox concorde_nostr_channel.pubkeys concorde_nostr_channel.received concorde_password_auth.passwords concorde_password_auth.tokens concorde_scheduler.schedules concorde_signals.runs concorde_signals.signals concorde_users.users in concorde_decisions concorde_messenger concorde_nostr_auth concorde_nostr_channel concorde_password_auth concorde_scheduler concorde_signals concorde_users, wrappers seen 0, wrappers present 8:schemas 8 of 8 distinct, on a component subpath 0, resolving to files true", - "all twenty-three entries should resolve at runtime and none of them is the bare package, the Signal Worker's constructor and the template Handler both arriving off `/signals`, the template Handler should load handlebars, the Mount Table should emit a bind mount, the Agent Container Runtime should compose a whole command line from `/agent-container` without starting anything — the entry point before the image and the agent's own arguments after it — and hide every environment value in the loggable copy, the pi Runtime should construct from an image and its mounts alone and compose a line carrying its own three flags and no model, provider or container path, its one function should produce that plan and read an outcome from it, its reader should name the Session in a failure, and Users should construct into its own schema with its read plugin and its four operations — the two writes the agent's surface has no route for included, and no credential of any kind among them — and Password Auth should construct off the eighth subpath into a schema of its own from the Users component and a Public server, register its four routes and itself as an Auth with that server in its own constructor, and answer with the scheme a challenge names, the one member the server walks and its three trusted-code methods and no route plugin, and Nostr Auth should construct off its own subpath into a schema of its own, register itself with that same server and **no route anywhere**, and answer with the scheme a challenge names, the one member the server walks and the one trusted-code method that grants a public key, and the Messenger should construct into a schema of its own from all four of its required arguments and answer with an object carrying exactly its three trusted-code methods, because every other capability it has is a route it registered itself, and the HTTP Channel should construct off the ninth subpath, register itself with that Messenger and answer with a name fixed by its type and the three methods a Channel is and no trusted-code method at all, and the Nostr Channel should construct off the tenth from 32 raw bytes and a Relay address with no server anywhere, register itself with a second Messenger because one Channel per Messenger is refused at registration, derive the agent's public key from those bytes inside the installed package, and answer with the one trusted-code method that records a public key, the drain that is the half of a send a transaction cannot hold, and no route plugin beside them, and all of them should carry the `start` and `stop` that do nothing and put them in the Gateway's record, and Signatures should construct with no Db anywhere, sign in process, and serve a key set with no private member in it that `node:crypto` checks the artifact against, and Decisions should construct into a schema of its own from the Signatures it holds and answer with an object carrying exactly its own two trusted-code methods, a publish that takes the caller's transaction and a read that takes none, and one `createGateway` call should assemble the infrastructure and the five parts built in `extend` from an installed package — which is also the only proof that the value import of fastify the two servers need survives installation — in the order the framework keyed them, with the Worker last and the consumer's own Components ahead of it, and that assembly's Agent server should answer a description of its own ten paths, generated by two plugins that reached this project only because the framework declares them and that a consumer can also register by hand, and `cron-parser` and its `luxon` dependency should resolve here — reached only because the framework declares them for the Scheduler — and compute the next occurrence and validate a zone, and the Scheduler itself should construct from the installed `/scheduler` subpath and carry its management surface and its Component lifecycle, filing its table under a schema of its own, and each of the eight `/schema` subpaths, which is what an Operator lists in their own barrel and where the tables are, should hand `drizzle-kit`'s own per-module collection rule its own tables and its own schema, thirteen tables and eight schemas between them — the HTTP Channel absent because that Channel owns no log and no tables, and the Nostr Channel present because the three things only it can know are its own —, and none of the `Tables` wrappers, because a table reachable only through a wrapper object is dropped in silence and generates an empty migration, while all eight wrappers should nevertheless resolve on their own specifiers, and those eight schema objects should be eight distinct values, and the eight **component** subpaths should carry no table and no schema object at all, because a component's tables are on exactly one specifier, and every one of the eight should resolve to a file inside the installed package, that path being the only thing `drizzle-kit`'s config takes and the whole reason the entries exist", + "function:function:the pi Runtime's sessionsDir must be absolute:Session user_42 could not reach the Agent Instance at 127.0.0.1:1:Session ../escape is not a name pi will accept:run:concorde_users:agentRoutes,create,get,list,setAttributes,start,stop:password auth Bearer authenticate,issueToken,revoke,scheme,setPassword,start,stop in concorde_password_auth:nostr auth Nostr authenticate,recordPublicKey,scheme,start,stop in concorde_nostr_auth:concorde_messenger:history,register,send,start,stop:channel http name,send,start,stop:channel nostr drain,name,publicKey,recordPublicKey,send,start,stop as 1b84c5567b126440995d3ed5aaba0565d71e1834604819ff9c17f5e9d5dd078f in concorde_nostr_channel:message.received:concorde_decisions:sign,start,stop:history,publish,start,stop:3 segments, 64 signature bytes, verified true, private member false:db,agentServer,publicServer,users,passwordAuth,signatures,decisions,messenger,httpChannel,ownLoop,worker:Concorde Gateway: Agent server describes 10 paths:by hand /healthz:cron 2030-06-02T09:00:00.000Z zone true:scheduler cancel,list,schedule,start,stop,tick fires concorde_schedule_fired in concorde_scheduler:tables concorde_decisions.decisions concorde_messenger.messages concorde_nostr_auth.admitted concorde_nostr_auth.grants concorde_nostr_channel.outbox concorde_nostr_channel.pubkeys concorde_nostr_channel.received concorde_password_auth.passwords concorde_password_auth.tokens concorde_scheduler.schedules concorde_signals.runs concorde_signals.signals concorde_users.users in concorde_decisions concorde_messenger concorde_nostr_auth concorde_nostr_channel concorde_password_auth concorde_scheduler concorde_signals concorde_users, wrappers seen 0, wrappers present 8:schemas 8 of 8 distinct, on a component subpath 0, resolving to files true", + "all twenty-two entries should resolve at runtime and none of them is the bare package, the Signal Worker's constructor and the template Handler both arriving off `/signals`, the template Handler should load handlebars, the pi Runtime should refuse a relative sessionsDir where the Operator wrote it rather than at the first Run, answer an Agent Instance that is not listening with a failed Run carrying the address — over `node:net`, out of the installed package, with no dependency behind it — and refuse a Session name outside `pi`'s grammar without reaching the network at all, carrying the one method a Runtime is and no second one, and Users should construct into its own schema with its read plugin and its four operations — the two writes the agent's surface has no route for included, and no credential of any kind among them — and Password Auth should construct off the eighth subpath into a schema of its own from the Users component and a Public server, register its four routes and itself as an Auth with that server in its own constructor, and answer with the scheme a challenge names, the one member the server walks and its three trusted-code methods and no route plugin, and Nostr Auth should construct off its own subpath into a schema of its own, register itself with that same server and **no route anywhere**, and answer with the scheme a challenge names, the one member the server walks and the one trusted-code method that grants a public key, and the Messenger should construct into a schema of its own from all four of its required arguments and answer with an object carrying exactly its three trusted-code methods, because every other capability it has is a route it registered itself, and the HTTP Channel should construct off the ninth subpath, register itself with that Messenger and answer with a name fixed by its type and the three methods a Channel is and no trusted-code method at all, and the Nostr Channel should construct off the tenth from 32 raw bytes and a Relay address with no server anywhere, register itself with a second Messenger because one Channel per Messenger is refused at registration, derive the agent's public key from those bytes inside the installed package, and answer with the one trusted-code method that records a public key, the drain that is the half of a send a transaction cannot hold, and no route plugin beside them, and all of them should carry the `start` and `stop` that do nothing and put them in the Gateway's record, and Signatures should construct with no Db anywhere, sign in process, and serve a key set with no private member in it that `node:crypto` checks the artifact against, and Decisions should construct into a schema of its own from the Signatures it holds and answer with an object carrying exactly its own two trusted-code methods, a publish that takes the caller's transaction and a read that takes none, and one `createGateway` call should assemble the infrastructure and the five parts built in `extend` from an installed package — which is also the only proof that the value import of fastify the two servers need survives installation — in the order the framework keyed them, with the Worker last and the consumer's own Components ahead of it, and that assembly's Agent server should answer a description of its own ten paths, generated by two plugins that reached this project only because the framework declares them and that a consumer can also register by hand, and `cron-parser` and its `luxon` dependency should resolve here — reached only because the framework declares them for the Scheduler — and compute the next occurrence and validate a zone, and the Scheduler itself should construct from the installed `/scheduler` subpath and carry its management surface and its Component lifecycle, filing its table under a schema of its own, and each of the eight `/schema` subpaths, which is what an Operator lists in their own barrel and where the tables are, should hand `drizzle-kit`'s own per-module collection rule its own tables and its own schema, thirteen tables and eight schemas between them — the HTTP Channel absent because that Channel owns no log and no tables, and the Nostr Channel present because the three things only it can know are its own —, and none of the `Tables` wrappers, because a table reachable only through a wrapper object is dropped in silence and generates an empty migration, while all eight wrappers should nevertheless resolve on their own specifiers, and those eight schema objects should be eight distinct values, and the eight **component** subpaths should carry no table and no schema object at all, because a component's tables are on exactly one specifier, and every one of the eight should resolve to a file inside the installed package, that path being the only thing `drizzle-kit`'s config takes and the whole reason the entries exist", ); // And the claim nothing above can see, because everything above imports a subpath: **the bare - // specifier resolves to nothing.** `exports` has twenty-three entries and no `.`, so Node refuses + // specifier resolves to nothing.** `exports` has twenty-two entries and no `.`, so Node refuses // `import "@shutter-network/concorde"` before it reads a byte of any module, and names the // refusal `ERR_PACKAGE_PATH_NOT_EXPORTED`. The code is read rather than the exit status, because // a module that threw on load would also exit non-zero and would prove the opposite of this. @@ -1987,7 +1875,7 @@ try { // that failed to resolve from the installed tree fails here. No arguments at all proves the // refusal path answers on stderr with the exit code a shell script can branch on. // - // Nothing here asserts that the export map still has twenty-three entries and no twenty-fourth + // Nothing here asserts that the export map still has twenty-two entries and no twenty-third // for this command. A `bin` is not importable, so the export map is untouched, and `check:docs` already // fails a subpath that no generator documents. step("checking the bin runs from the installed package"); diff --git a/scripts/reference/pages.ts b/scripts/reference/pages.ts index 8e36813..de8ab9a 100644 --- a/scripts/reference/pages.ts +++ b/scripts/reference/pages.ts @@ -17,7 +17,7 @@ * authored pages as well now. Written once here because both renderers need the identical value * and a wrong one is quiet: VitePress reports a dead link written in a page and never one written * in a sidebar, so a link that reaches nothing survives the build and is found by a reader. - * `typedoc.jsonc`'s `docsRoot` states the same fact to TypeDoc, which computes its own fifteen + * `typedoc.jsonc`'s `docsRoot` states the same fact to TypeDoc, which computes its own fourteen * links from it. */ export const referenceBase = "/reference"; diff --git a/site/.vitepress/config.ts b/site/.vitepress/config.ts index f59a668..e0a94b7 100644 --- a/site/.vitepress/config.ts +++ b/site/.vitepress/config.ts @@ -50,7 +50,7 @@ export default defineConfig({ // The authored pages first and the generated sections after them, which is the order somebody // adopting the framework meets them in. Every entry below `API reference` is generated: the - // fifteen entry-point pages TypeDoc writes, then the table and route sections the renderer + // fourteen entry-point pages TypeDoc writes, then the table and route sections the renderer // writes. sidebar: [ { diff --git a/site/architecture.md b/site/architecture.md index 5b6e3f1..7f38ed0 100644 --- a/site/architecture.md +++ b/site/architecture.md @@ -11,15 +11,18 @@ A shared agent is one deployable application, assembled from parts: infrastructure the framework builds, the Db, the Signal Worker and the two servers, and the components a deployment builds by hand: the Messenger with a Nostr Channel and an HTTP Channel above it, and Users with Nostr Auth and Password Auth below it. Outside it are the Agent -Implementation, a person's client, and a Nostr Relay.](/architecture.svg) +Instance, a person's client, and a Nostr Relay.](/architecture.svg) The dashed boundary is the Gateway, and it is the only path between a person and the agent. What is drawn inside it in blue is the infrastructure `createGateway` builds for every deployment. What is drawn in green is components, which a deployment builds by hand and picks for itself: these four are the messaging and identity half, and **Signatures, Decisions and the Scheduler are not drawn**, so read the green boxes as examples of a component rather than as the whole set. What is -drawn outside is what the Gateway does not own: the Agent Implementation in its container, the -person's own client, and a Relay. +drawn outside is what the Gateway does not own: the **Agent Instance**, which the Operator runs +and the Gateway merely connects to, the person's own client, and a Relay. The agent is drawn +outside the boundary because it *is* outside it, and that is the one thing about the picture worth +arguing over: the Gateway holds a TCP address for the agent and nothing else about it, so the box +is the Operator's in the same way the Relay and the client are somebody's. The wiring the picture is most exact about is Users and the Messenger, and it is worth reading twice. Each Channel hands what it received to the one Messenger, and each Auth answers with a @@ -50,8 +53,9 @@ This is the path that one message takes through the same parts: v Runtime | + | one connection, opened for this Run v - Agent Implementation in a container + Agent Instance the Operator's, not the Gateway's | | calls back over HTTP v @@ -60,8 +64,9 @@ This is the path that one message takes through the same parts: Those parts sit in three rings, from the inside out: -1. **The Agent Implementation** runs the model. It is `pi` by default, driven by a Runtime, and - it runs in a container. +1. **The Agent Implementation** runs the model. It is `pi` by default, driven by a Runtime. It is + not started by anything here: the Operator runs it, and the innermost ring is therefore the one + ring the Gateway does not own. 2. **The Signal Worker** owns the Signal queue, the dispatch to Handlers, and the Runs. It holds no identity and knows nothing about messaging. 3. **Producers** are trusted parts that emit Signals into the Worker. The Messenger and the @@ -79,7 +84,7 @@ One message produces one pass through this loop: 3. The **Signal Worker** takes the oldest pending Signal. 4. It dispatches on the Signal's `kind` to exactly one **Signal Handler**. 5. The Handler returns zero or more **Prompts**, each naming a **Session**. -6. For each Prompt the **Runtime** starts a **Run**. +6. For each Prompt the **Runtime** performs a **Run** against the Agent Instance. 7. During the Run the agent calls the Agent server. It reads Messages, sends Messages, and reads Users. 8. An outbound Message is written to the log and handed to the Channel. @@ -153,6 +158,14 @@ that only your own code can call. Reaching the Agent server port is access to every route on it. Keeping that port unreachable is the deployment's responsibility. +**Two unauthenticated privileged addresses sit on the agent network, and they point opposite +ways.** The Agent server is the inbound one: the agent reaches it and is asked for nothing. The +Agent Instance's RPC is the outbound one: the Gateway reaches that and is asked for nothing there +either, `pi`'s RPC carrying no authentication of any kind and a command set that includes `bash`. +Neither is a new trust. The network is the boundary for both, it is the same boundary the Agent +server has always rested on, and what changed is that the same network now carries traffic in both +directions. + A person reaches Users, their Auth's own routes, a Channel, Decisions, and two of the three Signature routes. Over HTTP with Password Auth, those Auth routes are a login, a logout, and a change of their **own** password. People never see a Signal, and they never reach `POST /sign`. @@ -177,28 +190,63 @@ The Runtime is held by the Signal Worker and is never started. It is not a Compo It is called one Run at a time, never concurrently. No implementation needs locking. -`createPiRuntime` runs `pi` in a container. The Prompt goes on standard input, never in the -argument list. Nothing about the model, the provider, or the session directory comes from the -framework: those come from a mounted settings file and from the image. - -### The Agent Container - -The container plumbing is separate from `pi`, on its own subpath, because a second Agent -Implementation needs it unchanged. It is what `docker run` takes, and what to do with the result. +### The Agent Instance -A **Mount Table** declares what the container reaches on disk. It has one required -`runtimeDir`, which is a path on the **host**, and every entry is written relative to it. The -table verifies nothing about the filesystem, and it refuses four things that cannot mean what -they say: +`createPiRuntime` starts nothing, and what it takes says so: -- An `agentPath` that is not absolute. -- A leading slash on an entry's `path`. -- A `.` or `..` segment in a resolved path. -- Two entries that resolve to one target. +```ts +const runtime = createPiRuntime({ + host: "agent", + port: 4000, + sessionsDir: "/sessions", +}); +``` -Every Run is `--rm` and `--interactive`, which holds stdin open and gives the container no TTY. -It runs as the Gateway process's own user and group, where the platform reports them. Env values -are redacted in the loggable copy of the command. +Three values and no fourth. There is no image, no model, no provider, no credential, no flag and +no path on this host, because the Gateway does not run the agent. An Operator runs an **Agent +Instance**, which is `pi --mode rpc` behind a listener in a container of their own, and everything +`pi` reads on disk and everything it is started with belongs to that container. The Gateway +therefore holds no container runtime socket, names no host path, and carries no part of the +agent's environment. + +The separation that buys is stronger than the one it replaced, and it is worth being clear about +why. Driving `pi` in process through its TypeScript SDK was always refused, because `pi`'s shell +tool hands its child the whole of `process.env`: an in-process agent would hold the Gateway's +`DATABASE_URL` and could write every table directly, going round the Agent server. Running one +container per Run answered that from inside the framework. Handing it to the Operator answers it +from outside, and the agent's process now never shared an address space, a filesystem or an +environment with the Gateway to begin with. + +What the framework gave up is container-per-Run, and the isolation that bought did not disappear: +it moved into the Operator's compose file, where a container has always been better expressed. An +Operator who wants a fresh container for every Run writes a Runtime, which is one method. + +**One Run is one connection**, opened when a Prompt exists and closed when the agent has settled. +Over it go four commands, strictly in sequence and never pipelined: `switch_session` to the file +this Session lives in, `get_state` to confirm the instance went where it was asked, `prompt`, and +then the stream is read until the agent settles. The second command looks redundant and is not: +`switch_session` is create-or-resume and answers the same success either way, so reading the +instance's own state back is the only thing that tells "opened the Session I named" apart from +"did something else and reported no error". A Prompt delivered into whichever Session the previous +connection happened to leave open is the failure that step exists to make impossible. + +`sessionsDir` is a path in the **Agent Instance's** filesystem and never in the Gateway's. +`/.jsonl` is the file one Session lives in. The Gateway never opens it, +creates nothing in it, and does not need to be able to reach it: the two ends agree because the +Operator wrote the same string in the compose file and in the entry point. Nothing can check that +agreement, since neither end can see the other's filesystem, so a `sessionsDir` that is missing or +relative is refused when the Runtime is built, which is the one part of it that can be checked +where the Operator wrote it. + +A Session name is checked against `pi`'s own grammar before the path is joined, so a Signal +Handler's string can neither climb out of `sessionsDir` nor reach the agent unchecked. A bad name +fails that one Run and names the Session, because a Signal that produced several Prompts must not +lose the rest of them to one. + +**An Agent Instance that is not listening is a failed Run, never a boot failure.** Nothing is +connected or probed when the Gateway starts. That is the same rule a Relay gets: a remote thing +the Operator runs is an outage, and a Gateway that refused to start over it would take every other +party's access down with the agent's. ## Signals, Runs, and Handlers @@ -402,9 +450,12 @@ Each of these is a deliberate decision, not an omission. Read the whole list bef whom. - **Resistance to prompt injection.** This risk is accepted. Guidance to Handler authors is the only mitigation. -- **Confinement of the Agent Implementation.** The deployment confines it. -- **An unreachable Agent server.** There is no authentication on it. The bind address your entry - point states is the whole of the protection. +- **Confinement of the Agent Implementation.** The deployment runs the Agent Instance, so the + deployment confines it. The framework names no image, no mount and no flag. +- **An unreachable Agent server, and an unreachable agent.** There is no authentication on the + Agent server, and none on the Agent Instance's RPC port either. The bind address your entry + point states and the network you put the agent on are the whole of the protection, in both + directions. - **Rate limiting.** The login route is unthrottled and no lockout exists. Rate limiting belongs at your edge, where it survives a second Gateway process. - **Account recovery.** There is no email, no reset flow, and no security questions. A forgotten diff --git a/site/guide.md b/site/guide.md index 1e4c50f..17fda0b 100644 --- a/site/guide.md +++ b/site/guide.md @@ -5,7 +5,8 @@ sends a message, and the agent answers. You build four components of your own: Users, Password Auth, the Messenger, and the HTTP Channel. `createGateway` builds the infrastructure under them. The whole deployment runs as a Docker -Compose stack with PostgreSQL. +Compose stack: PostgreSQL, the Gateway, and the agent, which you run yourself and the Gateway +connects to. Where a step builds something the [Architecture](./architecture) page explains, it links to that section. Read this guide first and that page second. @@ -14,8 +15,9 @@ section. Read this guide first and that page second. You need three things: -- **Docker**, with Compose. The Gateway starts the agent in a container, so it holds the host's - Docker socket. +- **Docker**, with Compose. Every part of this deployment is a service in one stack, the agent + included. Nothing here reaches the Docker daemon: the Gateway starts no container and is given + no socket. - **Node.js 24 or later**, for the type check. The stack itself runs in containers. - **An API key for a model provider.** This guide uses Anthropic. @@ -87,10 +89,14 @@ Then install: npm install ``` -## Step 2: Build the Runtime +## Step 2: Point the Runtime at the agent -The **Runtime** is what a Prompt is handed to. It starts the agent, waits for it, and answers -with an outcome. This deployment runs `pi` in a container. +The **Runtime** is what a Prompt is handed to. For each one it opens a connection to the agent, +prompts it, waits until the agent has settled, and answers with an outcome. + +**It does not start the agent.** You do. The agent is a second service in the same Compose stack, +which step 9 writes, and it is yours from the image down: yours to build, yours to give a model +key to, yours to pass flags to. The Gateway is told where it is and nothing else about it. Start `main.ts`: @@ -98,21 +104,9 @@ Start `main.ts`: import { createPiRuntime } from "@shutter-network/concorde/pi"; const runtime = createPiRuntime({ - image: process.env.AGENT_IMAGE!, - env: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY!, - AGENT_SERVER_URL: process.env.AGENT_SERVER_URL!, - }, - networks: [process.env.AGENT_NETWORK!], - mounts: { - runtimeDir: process.env.RUNTIME_DIR_HOST!, - entries: [ - { agentPath: "/workspace", path: "state/workspace" }, - { agentPath: "/home/agent/.pi/agent", path: "state/agent" }, - { agentPath: "/workspace/AGENTS.md", path: "AGENTS.md", readOnly: true }, - { agentPath: "/home/agent/.pi/agent/settings.json", path: "settings.json", readOnly: true }, - ], - }, + host: process.env.AGENT_INSTANCE_HOST!, + port: Number(process.env.AGENT_INSTANCE_PORT), + sessionsDir: process.env.AGENT_SESSIONS_DIR!, }); ``` @@ -121,19 +115,30 @@ Three facts about this block matter. **The package has no root export.** Every import names a subpath, such as `@shutter-network/concorde/pi`. An import from `"@shutter-network/concorde"` resolves to nothing. -**Only the environment you name here reaches the agent.** None of the Gateway's own environment -is passed through. +**Three values are the whole of the agent's configuration here, and there is no fourth.** No +image, no model, no provider, no credential, no flag. Every one of those is written on the agent +service in step 9, and none of them is ever given to the Gateway, the model key included. -**`runtimeDir` is a path on the host, not in the Gateway container.** The Docker daemon resolves -a bind source on the host. Each entry's `path` is written relative to `runtimeDir`. +**`sessionsDir` is a path the agent sees**, and neither a path on your host nor one inside the +Gateway container. The Gateway never opens it and does not need to be able to reach it. A Session +named `user_abc` is the file `/user_abc.jsonl` in the agent's own filesystem, which +is where you read a transcript. You write this string twice, here and as a mount target in step 9, +and nothing can check that the two agree: one end is resolved by `pi` and the other by the Docker +daemon, and neither can see the other's filesystem. -::: warning A leading slash on an entry is refused -Write `path: "state/workspace"`, never `path: "/state/workspace"`. The framework joins the entry -onto `runtimeDir`, so a leading slash resolves against the root a second time. Construction fails -with a message that names the entry. +::: warning sessionsDir must be absolute, and is refused when the Gateway is built +Not at the first message. A relative path would be resolved against a working directory nothing in +this process can see, so it is refused in the file where you wrote it. ::: -See [Architecture: the Runtime](./architecture#the-runtime-and-the-agent-implementation). +::: tip An agent that is not listening is a failed message, never a failed boot +`createPiRuntime` connects to nothing and probes nothing. If the Agent Instance is not up, the +Gateway still starts and everybody can still log in and read their log; each Run fails with the +address in its message. A Gateway that refused to start over it would take every person's access +down along with the agent's. +::: + +See [Architecture: the Agent Instance](./architecture#the-agent-instance). ## Step 3: Call createGateway @@ -149,7 +154,10 @@ const gateway = createGateway({ databaseUrl: process.env.DATABASE_URL!, runtime, publicListen: { host: process.env.PUBLIC_HOST!, port: Number(process.env.PUBLIC_PORT) }, - agentListen: { host: process.env.AGENT_HOST!, port: Number(process.env.AGENT_PORT) }, + agentListen: { + host: process.env.AGENT_SERVER_HOST!, + port: Number(process.env.AGENT_SERVER_PORT), + }, extend: ({ db, agentServer, publicServer, worker }) => { // step 4 fills this in return {}; @@ -171,6 +179,15 @@ There are two servers, and the difference between them is the whole trust bounda Reaching the Agent server port is full read and write access to every route on it. Bind it where only the agent can reach it. In this stack it is published to nobody, and the agent reaches it by service name on a private Docker network. + +The agent's own RPC port is the same arrangement pointing the other way, and it takes no +credential either. The private network is what protects both. +::: + +::: warning Two addresses cross on that network, so both names are long +`AGENT_SERVER_HOST` and `AGENT_SERVER_PORT` here are where **this** Gateway listens for the agent. +`AGENT_INSTANCE_HOST` and `AGENT_INSTANCE_PORT` in step 2 are where the **agent** listens for this +Gateway. A short `AGENT_HOST` could be read as either one. ::: `createGateway` connects to nothing and listens on nothing. That happens in step 6, at @@ -386,13 +403,14 @@ See [Architecture: data ownership](./architecture#data-ownership). ## Step 8: Write the container files -The Gateway image runs your entry point and holds the Docker CLI. Write `Dockerfile`: +Two images, one for each of the two services that matter, and neither holds anything of the +other's. + +The Gateway image runs your entry point. Write `Dockerfile`: ```dockerfile FROM node:24-alpine -RUN apk add --no-cache docker-cli - WORKDIR /app COPY package.json ./ @@ -403,24 +421,30 @@ COPY main.ts drizzle.config.ts schema.ts ./ CMD ["node", "main.ts"] ``` -The agent image is separate. Write `Dockerfile.agent`: +No Docker CLI in it, and step 9 gives it no socket to talk to one with. The Gateway starts no +container. + +The agent image is the other one. Write `Dockerfile.agent`: ```dockerfile FROM node:24-alpine -RUN apk add --no-cache curl +RUN apk add --no-cache curl socat -RUN npm install -g @earendil-works/pi-coding-agent@0.83.0 +RUN npm install -g @earendil-works/pi-coding-agent@0.85.1 WORKDIR /workspace ENV PI_CODING_AGENT_DIR=/home/agent/.pi/agent - -ENTRYPOINT ["pi"] ``` -`curl` is there because the agent reads the Agent server's own API document with it. +`socat` is the listener the Gateway connects to, and step 9 is where it is started. `curl` is +there because `pi` ships no HTTP client, and reaching the Agent server is the agent's own shell +tool plus `curl`. + +**There is no `ENTRYPOINT`.** The process this container starts is the listener, and the agent is +what the listener starts, once per connection. -Write `settings.json`, which step 2 mounts read-only into the agent: +Write `settings.json`, which step 9 mounts read-only into the agent: ```json { @@ -439,8 +463,8 @@ state ## Step 9: Write the Compose stack -The stack has five services: PostgreSQL, a one-shot migration, the agent image build, the -Gateway, and a terminal client held behind a profile. Write `compose.yml`: +Five services: the Gateway, the **Agent Instance**, PostgreSQL, a one-shot migration, and a +terminal client held behind a profile. Write `compose.yml`: ```yaml name: my-shared-agent @@ -453,21 +477,15 @@ services: context: . dockerfile: Dockerfile environment: - ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?put your model key in .env} DATABASE_URL: *database-url USER_PASSWORD: ${USER_PASSWORD:?copy .env.example to .env} PUBLIC_HOST: 0.0.0.0 PUBLIC_PORT: "8081" - AGENT_HOST: 0.0.0.0 - AGENT_PORT: "7411" - AGENT_SERVER_URL: http://gateway:7411 - AGENT_IMAGE: my-shared-agent-agent:0.83.0 - AGENT_NETWORK: my_shared_agent_agent - RUNTIME_DIR_HOST: ${PWD} - volumes: - - /var/run/docker.sock:/var/run/docker.sock - - ./state/workspace:/app/state/workspace - - ./state/agent:/app/state/agent + AGENT_SERVER_HOST: 0.0.0.0 + AGENT_SERVER_PORT: "7411" + AGENT_INSTANCE_HOST: agent + AGENT_INSTANCE_PORT: "4000" + AGENT_SESSIONS_DIR: /sessions ports: - "127.0.0.1:8081:8081" networks: [db, agent, public] @@ -476,10 +494,31 @@ services: condition: service_healthy migrate: condition: service_completed_successfully - agent-image: - condition: service_completed_successfully + agent: + condition: service_started stop_grace_period: 300s + agent: + build: + context: . + dockerfile: Dockerfile.agent + command: + - socat + - TCP-LISTEN:4000,reuseaddr,fork + - EXEC:pi --mode rpc --no-approve + environment: + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:?put your model key in .env} + AGENT_SERVER_URL: http://gateway:7411 + PI_OFFLINE: "1" + PI_CODING_AGENT_DIR: /home/agent/.pi/agent + volumes: + - ./state/workspace:/workspace + - ./state/agent:/home/agent/.pi/agent + - ./state/sessions:/sessions + - ./AGENTS.md:/workspace/AGENTS.md:ro + - ./settings.json:/home/agent/.pi/agent/settings.json:ro + networks: [agent] + migrate: build: context: . @@ -508,15 +547,6 @@ services: timeout: 3s retries: 20 - agent-image: - build: - context: . - dockerfile: Dockerfile.agent - image: my-shared-agent-agent:0.83.0 - command: ["--version"] - restart: "no" - networks: [agent] - tui: build: context: . @@ -540,16 +570,45 @@ volumes: db: ``` -Four details in this file are load-bearing. +**What `agent` starts is a listener, not the agent.** `socat` accepts on 4000 and starts one `pi` +per connection, and the Gateway opens one connection per Run and closes it when the agent has +settled. So a Run is still a process of its own, started for it and gone with it. What carries +over from one Run to the next is this container and the directories mounted into it, which is your +arrangement and nobody else's. + +::: danger No commas in the `EXEC:` argument +A comma is `socat`'s own option separator, so a comma anywhere in the `EXEC:` argument is read as +an option rather than as part of the command, and what runs is quietly not what you wrote. For +the same reason, never add `socat`'s `stderr` option: `EXEC` wires stdin and stdout to the +socket, `pi` writes its diagnostics to stderr, and merging the two puts non-JSON into the record +stream and fails every Run. +::: + +`--no-approve` is yours to pass, and this line is the only place it appears. It is the flag that +stops a Run arranging for the next one to load configuration out of the writable Workspace, and no +framework can fasten a flag to a command line it does not write. `PI_OFFLINE` is the same kind of +thing: an environment variable on this service, because there is nowhere else left for it to be. + +Six further details in this file are load-bearing. -**`RUNTIME_DIR_HOST: ${PWD}`.** The Gateway cannot in general reach that directory itself. It -hands the path to the Docker daemon, which resolves it on the host. If you bring the stack up -from another directory, the agent's mounts resolve against a tree nobody is looking at. +**The model key is on `agent` and on no other service.** So is the image, so are the flags, and so +are the files `pi` reads. The Gateway is given a host, a port and a directory name, and nothing +else about the agent at all. + +**`AGENT_SESSIONS_DIR` and the `/sessions` mount target are the same string, written twice.** +Nothing can check that they agree, because one end is resolved by `pi` and the other by the Docker +daemon. A Session named `user_abc` is then `/sessions/user_abc.jsonl` in the agent's container and +`state/sessions/user_abc.jsonl` here, which is where you read a transcript. + +**There is no healthcheck on `agent`, deliberately.** With `fork`, every connection starts a `pi`, +so a probe on an interval would boot and discard one for ever. A plain `depends_on` is enough, +because an instance that is not listening is an ordinary failed Run and not a boot failure. **The Gateway waits on the migration** with `condition: service_completed_successfully`. The schema exists before the first query. -**The Agent server port is never published.** Only `8081` is, and only to `127.0.0.1`. +**The Agent server port is never published.** Neither is the agent's. Only `8081` is, and only to +`127.0.0.1`. **`stop_grace_period: 300s`** gives a Run in flight time to finish before Docker kills the Gateway. @@ -557,6 +616,12 @@ Gateway. The `tui` service is a line-oriented terminal client. It ships with the framework as a `bin`, so it needs no separate image. +::: warning Write `AGENTS.md` before you bring the stack up +Step 10 writes it, and `compose.yml` mounts it. Docker creates a missing bind source as an empty +**directory**, so a first `docker compose up` with no `AGENTS.md` beside `compose.yml` leaves you +with a directory of that name and an agent that was told nothing. +::: + Write `.env.example` last: ``` @@ -564,6 +629,8 @@ ANTHROPIC_API_KEY= USER_PASSWORD=correct horse battery staple ``` +The model key in it is the agent's, and reaches only the `agent` service. + ::: warning A password in the environment is a demo affordance This deployment reads a password from the environment so that `docker compose up` is the whole setup. A real deployment sets a password out of band and holds none here. diff --git a/site/index.md b/site/index.md index f637959..9a84483 100644 --- a/site/index.md +++ b/site/index.md @@ -20,11 +20,12 @@ of this site is written for that reader. ![The parts of a shared agent. A dashed boundary encloses the Gateway, holding the Db, the Signal Worker, the Agent server and the Public server, together with the Messenger and its two Channels -and Users with its two Auths. Outside it are the Agent Implementation, a person's client, and a -Nostr Relay.](/architecture.svg) +and Users with its two Auths. Outside it are the Agent Instance, a person's client, and a Nostr +Relay.](/architecture.svg) -Everything inside the dashed boundary is the Gateway. The [Architecture](./architecture#the-shape) -page reads the picture part by part. +Everything inside the dashed boundary is the Gateway. The agent is drawn outside it because the +Operator runs it and the Gateway only connects to it. The +[Architecture](./architecture#the-shape) page reads the picture part by part. ## Where to start @@ -56,7 +57,7 @@ The framework does not do these things, and each omission is deliberate: - **Confidentiality between parties.** The agent reads every Message and decides what to send to whom. - **Resistance to prompt injection.** This risk is accepted, not solved. -- **Confinement of the Agent Implementation.** Your deployment confines it. +- **Confinement of the Agent Implementation.** You run it, so you confine it. - **Rate limiting.** The login route is unthrottled. The [Architecture](./architecture#what-you-must-provide-yourself) page states the full list. Read diff --git a/site/public/architecture.svg b/site/public/architecture.svg index ce76214..8126ba1 100644 --- a/site/public/architecture.svg +++ b/site/public/architecture.svg @@ -1,4 +1,4 @@ Agent HTTPServerPublic HTTPServerAgentSignal WorkerPromptCallUsersNostrAuthPasswordAuthMessengerHTTPChannelNostrChannelDBNostrRelaySignalsMessagesUserGateway \ No newline at end of file + @font-face { font-family: Excalifont; src: url(data:font/woff2;base64,d09GMgABAAAAABhwAA4AAAAAKYgAABgaAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGhYbiQYcNAZgAIEMEQgKv0CvGwtMAAE2AiQDgRQEIAWDGAcgGyQgo6K0Mm4i+6sDOxg+PiO80zTVq4b3N2etYa06rEwMQ0+cIWJJzd+5tNsjjjziRGqQ5iznVQGvQ9EWGcUrjhUxXRGfqv3GlBebmcLz1KG9/ZuAJd5I0kEy0VqEA3zjB4CtWTJdNcMV1TUcY01Hcvef4BDGoFSn5Eed9vZqrU+SoUCJk5TwALjJ0dT/5z/szdpbbU5Ssh0q4KUHgF+2+h/Av//hXP+1d27X0ke0R6RY/c97FuUTPrGJncmUtkuFpQfMSWvM+fzv5+p/iOZJRDwRMt4JkVLmb/f/zdPbx9KsYWalUEkuST00VgIh0QqHnEYmdTZpswLe3iXAFszy6h3z8gACAA0wGCRFGejG1hQqcdDJLDEDuO26Wxqs/NRSWS+/l7Y1MSitcxv1boeWJkACTpS3AUtAawbXHvlJAeSl1nAJgYEXweQN3GNm3b/8jrCdXviUHh21rYUJBlfYKkPTRH19D1h2oMS/rlhiQoL64GAPTPVLKjJg9lILBwNLUkZzGO5/8m/Iv8od/2I9eAHl/lxkLgM70rQR7kWfyTm98Fq4gquh0YCXMQOTaUBlKhgBgWFk7F1JLh4aIcoZXDEmD1ZZFgkZbq8nUrAmIgCwDxsCRAEmeVB8BDIMbhkHXxLq8gQSGi42Oh4KjL4viEPTgAn5glEGCRnFgMqXAwAAwESlwUQjS4EtjRwHJAkRkp4XB6gTUh/2TEaZAUPKpOQAIIEWAy2F+AZyQrY0zi3JoA+IdNeOPwEzq5aODgDLLQAA9AhHFCGViwAm4GlAkvDQRRd3w4KgEDGySuaQKVulWk6duv8HfHt0LJJuk7SUparVa/mKB+467phlSxYtGDdm1NAgCHihfyAyEsiJnQsOozyJtCWpSi917TwyjQDpdvRght9aPtYpnfq4i7ZewLUbvNKKKgojxYNTUzKaQ3kKYXgMQ9nKMrh5JMoTDa5efszwQX6CJYU8/DEiiBqqqFXCVSVVAtYXSYIU4PlzgO8gxHC4tl0OE9/c3dX4IhnoVVmX38OSA60lJiCSoWmaGA2AmclrxgKWXQfgH9mqtqpVWn1sf4SAwZABmHOiIBKSRA3S1lMkz/1RDAs8SXgn0WcFAZz1S5BEtLlNzTKOP8Rx1qDFZsHA5m0GE2xWa1r9VH22qWWSfEzSYgJizMB8aFKGId0vjywXxpKpPTH8SyTHl5KOTHNdnvyyKENaJJ+mCiqIN407jjRHKopUwh6PqEYEYi+oGWugaYhY8pSV+IBxHrrny0ZDXj9VW+h5iJ9fvaXKAlEbXiLaIe9QTVqpZMWG6iOJyzE2/7zpGh+cGoYZB34cdjI28fPPtVzpFSXxViV5eTyTAmI3lg1Nx5UZlEbA8kjYqbPCTnE802l+I1dCzy/E68rE2Z3Xs2gvOAieG99BsxWfm+XTeamqJCF9Twt1bIUhRoQcUwsfBhqwtQZ/bYmXqBlKYLhgNlG6r7U77TGqfLtJq7RK+apo84rIN/U0684DsJLCTCQ/pfanKSWaC7evNzvIZGBtzBCcpRIDXHZLAIYBIxCRZGOFeImqEpUTT3K2GHQnlgpqY4d8piEBsOWCYUEy5mmDM8Q3ueZPVQX0BnFdQROXPzBMnE7fVX6TyhCXsY4xxu68YohvMNbyQg2HqUhpUfRnoVU8CWlMk1lHaLq4HrfjMka3zd1Ltebv8kVO+arW0dM3MmPADHig7YDBRCZKq4a3kcp7gjVnCRynLOsG5JfEXBtc6xQS0te8V0hiTCGKkan6+3JNB4YzC6xvZmonBgDHlZuDaBgOrDxMuU5Vu1qlVGwyPhGRuSQtIgOgBMCUovxPkKXT/SR3Jp7m+ZQmVyflp1ZLH+ykj2ROErNYNl1BE2Ncb3Oekxm1XcjFu0vNKq1Oaf63VA/vJhJ/icgQ43UBnUbknyHaY2DVcRynyuWyFF7WFVZ2AP7VkpAl0WvS+SxmF8/JmOvrqpeQTOYaOa/XPyJbiJrVZ9VvlL59o799i64jDNMiklQQQMRAx5b7JMaGk20Bmzj4RkOKE55QvlpUFckm7wEWJo4speX3XKlVpTIMdcnvIv+QRdfAYECy0Cf2pIKAJyA5GGIB43bLyACcY05MZbu1Qsg1zin5PqUqxnUZtkpltZKzrIy+SMVIUnISFmqilfqcU3Lqq7wiekfms7gOsDwvMzYAZXB7G4B8lKsosaqFBd4RiqrD9l+laPAT7K4rLi4jrdJwbvlKoN3xtVGwAsFyEWnjw9Odnbuff16RGrfpbUIyhQrDwcCEPJzVJrC117KTgbQiSa7S77nFLXjfPa1uBPkoySnLUycq0gZ/dYToOpJ5DE0oxxhr0TaKi4BFU4qxG6l4BZ0VpH683/b4oiN93+9rA93B3tyOZJJ5NZ/3km1TQrI9YLLmYHq/KS/Nxm3uVi2jJY/8t+lEElfBwGDOm/BHw7ttsbfwmGSnsbuNdyfW+WT6pJ51XdkBEVgkJOoxzPweYCUXu5fiWK9hXWAzM3mRU8RLNDsv87OxUr1fu0+r/iL1uejr+AS73e/ZrMNkSxl112PsAkgivVbYiPVjR4IXSNJcIZKyi9Y2P5NrNcW7eXeLnY0kcfmZI7TiC7NXnqcXUsV6PvFEz7RkGiM0w0ePUPpK8y8/Ewe1zI3/jiSJGmqoD5DE+KaekjTd5dTn2k5DnF9KOjr8yDKSpxgwSRB1PjlYywCAkW19fPO9+S8tnxfjwXKlzmSNYGtg2AdzIj2WqFkafa8xGM6AJIaUyL2iTRt8JVE7Se9jZdu2aaIQjmHA4q5cUySzq3G3sZmh93gI0VeSVDT/WQptE1bCeAxL8GeU65PhVTFa3Z9EhZXdZcxKjuCj9DLX6ZldbTv8uit8xQBOTDS+XzREPcW92e5UJqpBuFQAJgd9M+pYJ/m+SBXx0zDhKqXSURbHiit8MKqPkbuqeCtih3e2eY+PZJaZ+AMKTcYENy6XFQTIz6jKE+07Z6jjm073tjdQl1PxoNXk2rg+m53MO+WyiYKAJGq1irUiehLUoBhKP5RoJwOiiCjRLijQgLnI3CQLVUU6GpI49yEmO+mfbJ9TriqRN7FUXIGVBNrcpNRs0sE0heumYowPrCy4AEF+7/d0o6OminT57jKS8Skz0IC94ONdyXmn8jyrQbKGgAigBgsyOGUB13F8H4wXe6lMeFLkbtf2/5Xiez3DSwTs/ExyvMCYoOnK3YdyOJf+x7MLs+6hxcn44fSR9Qv7y57AoCuJiBRmMi+GAKvSTQVdwzFabPFL9WO4OK/m6xRiNAWWr5FPd7e4vx9+QlInnyQJqZF1PBIeeZWrDB9V9f3VM+K9sRcEAA5ejLPGYLM7xeG5ubmc2qloWeR3eCQCsUf69anVIv1+/kLVHkz2FIzx0/RTdfLL2bbu0OwQE6XOV4UNYqgTgPJP6KfIVG42YVj7fhH25kQUEeVZlWTROJyoYb0k3orHRRJt1UtNSvsoozuVhkJzEEMTyf5c7zcGCwui09PjXhuDYD5ur2cgLyzSfzuUud7pUIvpnAOdXiiGN123jpHSK3vTbvrU75zupxSiIYFvq2++hnYA8C4s5EUQSPUxb2VFvcU90YpW0a70rerYEtx2RNTcpFV/VxZfkKYLp8KjCirPB++4RuN0guyQ9tpe+Pd8IJ2JrhfImGiP3/V550YmidxSwSuucq2BJO7JkGnm3Rcpqjb9ze7vJd+2aZf8f6HeyffgCMAgYCNgGI7sIDaZayh97/M2G+2sB2VeJjTHOEGwkEel3cdDDlVUrGdtkHId54uyP7Wv/LAs0GT4yiuARkGDnTt4hwFRy5R64RAEl7ktZMCcmSfHyyEqtPGRKjSJbcNIGdxFf6zFP+JOkb718p4KVRDIDvoqNtxQ5TqXAgPQaxONOGHCtOJ2Z+Kfx2IE5CndyAlGHxWZCsxoU4U3/wIF4ZDuzDxlCMRC4Vu6BMgR0kLvzuNavD82ZODG0bYCnRdLwdZzDmkyFh9wGvWYg84qGjnL95UshM8yVPG6Oy2IhLzYlbVI48GrQHrtn0fCs9NUp9Jc48fzze35Ud/i28hUK3wVzIVixvqePaDPXMavzZ+lz9N5VLeKcLiDhcL3xaYvTcN9hv6eAh9zqzGwWQA6v8dUkzG58CybUvDi9SdT3oJkR5iuyWM2QNLwOQiXYc7sdu1KHZHgAmzit6SD4you7IJ3OJrTdu/GMXb7t7xqihmmCEknLORTEJAmCh0+uyJvulNXfzGIJigYE1IzzWlmqfZg8VXLnXNXS07f03lXsKcwZZ3ml73LKVVxIRxzJoGlqu3Z5ePymzwI46AjkC4OykP1XFuTXUFOPwIylaX9b9meSVrlBI980N4wKMlUpmlMq67vqLNui0mV4c3GRzzK6mSYaDvybbzfzA1uxrJC1KpNN7LDJ/GbbBJ9xElQjJZjOrBX5nXECakmTMwkZmUgGbOMouEUdq3CZif5fgU7skaRHXKkxKbHemR/myk8Eq79vzW+RsYfhdX4uzi6WJKCuJxgai6UBvch3DWsT64NLSQG2SL2gowQ/QCHpOeDr0MeQxGrMDi/1iGLqHAVbO6HEDh7sDuUwiCb3JUp3LZdIvCn9ez9v+yOz5pN3NKFgURWGyCUyAij0FTc1Joj0Q0KjoLIzCx3R8xKiRkcEv+BXAop4Y+c170yOUGaTuIbCwzc2UfvU4mLlgln4CEyedwDpGWTd3b+fDxH4BQAtGXmStYa036yFgI7RxH1EcsFKGaJBomB7sNeTop3yYO7YFLw7PH+p7wN9nqb4/NoyGIP47fRppfy7yXyOxRDF7SVKPS/53Ftxzg5cfxppMnP78ZOg/kWBEsidUiHTjkYu7h41j2F6eyhx9Nk/0paU8fBk84LDueRFZhduhtUcQsDIT0lz+4+LfERnO57sFujCXej781ckokvwRu5ZbLp/uYi/u4EkG3+cv8Pl4rBetnUUJfEaCEQ0fH4hJkw2DmB08q+r/14skM4FlCBOTVmO1s2K3g1qrsbLr0DaG5lMMUdDIFIF8NvPKrJdLQmmr9xxad/zRef0/BF//+ir5RvCh7qdyMgTMfhvo3KoZGb0LUwlhAuEnH575HNpZR20nbYchgySnjM1kzda1RZGrq3KgFzSB3YSGleYKYl/KoHc8vur88mhFCv6bGReM64OIcl1riQA0GiqhEYS2mqBK5wK5wsWb8pX8oKuquYr4Y2fVBoITv52Yz56SZ1ZqNPbwzz8tIdzQ4V1IscuDN5hAW0zPqmPz63bJyWis0YBxdADm6NatuQsL1JHhT8+OIE79YZ4fHJY4s+6mIaK8FOV0NoJZSMcdE/ghgFKoeRlQINa/YaGBXxmTGe38qU89OFBYQWqxyLrHCyMCfFCt21o/64WexNghaynCudtLU0Lqa9r8WeEdngfxKFvGgV4jJccph1GgThHieSXTyOcfNK8EmNiNypQJSDQie97vvUoebrv9wkExSHt32cvOnpX0rJk9m0QU1dVVW3lNNXS4fahXCKH5cN9yELlGjzRKUrIzOoJMB3evvUj9UTGYZdHcK9YB5VHjfZxvWMqEUrBYW1Xv6fyozGsK+fGA3hro4ws6hkk00+RmVO3589dflMF87kv45K2dWJqg7Kmyfxli+PT8lSFtBhKBTCwb2fQj69LZzkO7Oqb5qPq9XdrLFKEyLI9///ZMguSzX2TlCUJqPjVRp74tRXf/0FTuD3dS6KqXVXUjL5fpCf6WBAhWSmlZlCsGsXaf4uyJ8mTBZK8l2mMlLQ7wDzL9tgPikL2yjQ+RiaM/SF7MvQkEor0kWE2dHQ2Tm7nWitG6BQKyxKexC5AEofime481/R+XU32PMpHm8w5ngP5hTEMUL2xMu6yDvIIdE4J85PDFPnZrH6e37vz2BbPjxbFiBLIR8z38r8i50HCuh9bqnvylnXv+HmpumjBMU+T7T94qqz8fL+PuTcx8hzc5/OfOsbdhjWR695H/I9CdpVj7WEES5dhWAdR/3HtW1f8dRYgpwyuieZdtfuG4cQSDeBqwQVyRxWERxz4E2a+/ww4JDT6mXeSTJtaXC2h7vW7vU8nOA1vnJtamJabCf7w9Y494qFn7MDYufyV8IWckZlKPDJOiWrqotLTXR5V97XnBqvQFNzEQE/tOHWH4z1ZYmjuSP7rxta5BLevXEOQWoKqlcapXnPOrHWjzqKJ5z+IHRaM5/3V0j1SsDr4Joj8hoXa12TS4Wcw8ZZnyUIuDaKnFTEQVPhaYPU0eOjrJTwnJrPyREWgFFVVKxzn8nx8129WWKMjrZtqjrEvi4T6XnF3qSOpcj6cTLgrlmS4l3L6BRVLL4R8AS8xSTFBf8WIdPw5LCyPsxWQ6ddogonSACC0oNDVXACB8mHBYwfSi6m0c8s2QGBgTPMbTj6Pp4oNec500IFArGV552z3CPHD4rgmJrB9OP3vnqKKOtGlXLZjrS52gkhPdJuKJKdzR7v8vcaQPd0JxxEDuHm+VRx+j9+4+DzgSRJMAQHK+vAever5aYpOtWjdTQ2Xo3g5i8n2hwnR4MX/bEleTbPE1vPDjMx9NfgDp/SConfAQgm4XCh+lKAqX/8L6Tel0PqJjzDkQbITJmm2ifD4BS8Vp4vhVZZwUiusP8WNNSFTlCov7vB1H8DugUe3vBwDLFQ6IH0g8RcG4SYRNtX03v6QQ9YUyJbBr+PEtBm5heHL/l+P0d54CMvopUbm373yvY59j5SLpR9zHOxgtZ5ImOgaTgpcAhcTlanG7vYS1OWhia+nA3fiM+mtsJqMCgxtHgy2A1TXDlV1RxdgWKbVSjBSWJdRHgaNyi5eaXxiLeNOtcTS0LILMMb0ck6f0YEZNh1UZo1WHpNK15dN6N+l5clk5OKHIfcnolqwA3Ur/ao7TtaW+dXu03nuyDePiG+P9S8Nb5xqMfHSqdKlOCbJbBpzFTdimpIkhEU4H36EDtjppHk40+FKI1vdtWDJS1O57Ws39g+Fsp6Eqhyg9IKyMbhb/7gmujE7HYvlfUnbdirpjjJtbSwpSgZHicm6qIqZi0cNO9ojDvfZjVnbJN/oiYwlw9l9IDp0tkMIw/bKaEJQyV0aPUK4dop+aOKeRjZbaqVR9tgGTqRwpVPAi0C+pTI6LV3z8YyEBsigI1AZ+JelGtCnnuu2amlevw5O7wjcFmSPtNNF24uO+eONz4ukKVjsBh5q95t7LDy08ZiqW/2tH+/fGHs59e7M9/mROiL7hHsZvGwYYCnOZsvP7dPTu1V8KxZhhNXyJ47y67fMURyc2h9muoDNYI1QT6Is0quvrZTd3btZjCy9LOZtVNYaGZqKCnb3X5VhqneO3brxpg6a4dY0Hbetr6zN1O1Jb0RSY5m0TBwjYYTUbqJT4ZXuqTBcb3d3qI1u8aFh26Y6QKZqJl0POgTV6IeaUFEQbBMHhWc9l+Z70my5xF7lYfdV/PPVgJBS1YzxMH+Cg0aS7e8+8HJBbcI9smDf2gKXR3bO4WeI4EEEgOZ4yMIQQyu9ePHH1j2/v4PiUv2iqVul/IvPErHF+wUoX2JTOKORtdlzDAVTom4qVvHrhAbVDqcAGcYiIh6Xn+3Zv7YwaHR0BIYV9DZ/hJ29giig51R6DnUjM31M2Qn6Fqq7ikQm0GcgPtzPE8HVS5Rp++KfAAtz3xbqp2xzdIcZpfo48z/3uV3uqdF1YBbraK5cjGtKHLalthZ566YI8Wu5Rfd8xt0pRSrcIQv+/MIC+7/BilhBFj+71l453L2j8iUatQYMzV9iIuHJKknrjnsFRwld7qM3hG3HjIdRY7ru1GANkDzZxzFsEMrZswrCSFPAADudvsZAQDg3qrnvQNj3os+aywBQAL70PwDYWlyHoTtR+v/AfgLeS1jKjMA+BmAP1HTTlhpgF5pA8sx/vmFly7uOUSXO+xpQq65oFwTVDlioqWIv/DyhSiFlHHlHn/+iRDuA1b4At0FYZkvJgwQmRyquIooS6jjzTdKUGSH1eiAnr0CwxGSwli5ywAHPAWE8QBoAQch7x8Ia4N8kDQ4Fw/hORoPY9gRj/AzLh4lUxaPiSMDPjkrACZdypVqUKvKYE3aBEpTqVr7jZKlWmSpfEPrQ9WkrIhaEBWyaJbH7m5ONUp5k4QihOA7JSFzR1CGnoaoZWhLN0pnkSzaV8tJ2w9hIObU/QC1LKEmJvgyoCQWQrVGmGVlum0Lv6xCixI7Bf3ThAbsLvIvtJ6gkkglOiaoYNkRkNfALxgAAA==); }Agent HTTPServerPublic HTTPServerAgentInstanceSignal WorkerPromptCallUsersNostrAuthPasswordAuthMessengerHTTPChannelNostrChannelDBNostrRelaySignalsMessagesUserGateway \ No newline at end of file diff --git a/site/typedoc.jsonc b/site/typedoc.jsonc index d9750a9..109e4d1 100644 --- a/site/typedoc.jsonc +++ b/site/typedoc.jsonc @@ -15,7 +15,7 @@ "./expanded-object-methods.mjs" ], - // Fifteen of the export map's twenty-three entries, in the order `package.json` lists them. + // Fourteen of the export map's twenty-two entries, in the order `package.json` lists them. // The other eight are the `/schema` subpaths, and they are deliberately absent: a component's // tables are documented by the table pages `scripts/reference/render.ts` writes, and a TypeDoc // page for one would print `PgTableWithColumns<{}>` for every table, `excludeExternals` having @@ -29,7 +29,6 @@ "../src/gateway/index.ts", "../src/logging/index.ts", "../src/db/index.ts", - "../src/agent-container/index.ts", "../src/signals/index.ts", "../src/pi/index.ts", "../src/users/index.ts", @@ -59,14 +58,14 @@ // They were both `./reference` while the reference was the whole site. It is not: `../site` // holds authored pages now, VitePress takes `site/` as its root, and a sidebar pointing at // `/gateway` would reach nothing. Setting these equal again silently unroots every one of the - // fifteen TypeDoc links, and the build still passes: VitePress reports a dead link only for a + // fourteen TypeDoc links, and the build still passes: VitePress reports a dead link only for a // link written in a page, never for one in a sidebar. "out": "./reference", "docsRoot": ".", // One file per entry point rather than one per exported symbol, so the whole public - // surface is fifteen pages a reader can hold. `flattenOutputFiles` is what keeps those - // fifteen from nesting inside a directory named after the module they are reached through. + // surface is fourteen pages a reader can hold. `flattenOutputFiles` is what keeps those + // fourteen from nesting inside a directory named after the module they are reached through. "outputFileStrategy": "modules", "flattenOutputFiles": true, @@ -98,7 +97,7 @@ // TypeDoc's default grouping by kind is kept and no custom group is declared: a component's // tables land under Variables and its constructor under Functions, which separates them // without hiding either. The page header and breadcrumb are dropped because the title is - // already the specifier and there are only fifteen pages to be lost among. + // already the specifier and there are only fourteen pages to be lost among. "hidePageHeader": true, "hideBreadcrumbs": true, @@ -115,7 +114,7 @@ // The block above an object type prints its members rather than the word `object`. It is the // first thing on a page and it is what a reader takes the shape from, and most of this public // API is object literals, so collapsed it left the shape to be assembled by hand out of the - // sections below on all fifteen pages. + // sections below on all fourteen pages. // // **It cannot be set without the theme on the next line.** The plugin renders each member from // `getDeclarationType`, which answers a method with the return type of its first signature, so diff --git a/src/agent-container/agent-container.test.ts b/src/agent-container/agent-container.test.ts deleted file mode 100644 index 786eeb4..0000000 --- a/src/agent-container/agent-container.test.ts +++ /dev/null @@ -1,840 +0,0 @@ -/** - * What comes out of an Agent Container: the command line, and what a Run does with it. - * - * The subject is `commandFor` on a **constructed Runtime**, deliberately rather than an - * internal assembler. That is the seam an author actually has, and it is the only one - * that can observe an Agent Implementation's own defaults: a test composing arguments - * from the parts would have to restate those defaults in order to check them, which the - * prototype demonstrated by producing a command line with no entry point in it and - * nothing able to notice. - * - * There is no `pi` here and no second Agent Implementation either. The stand-in below is - * an agent shaped unlike `pi` on purpose — its Prompt goes on argv rather than stdin in - * one case, and its output is plain text — because if the generic half is right then - * neither difference needs anything added to it. - * - * Everything about composition is pure: no Docker, no credentials, no network, no - * filesystem. The Runs that do start a process start the stub container runtime, which - * is a Node script. What none of it can prove is that mounts resolve or that user ids - * match; nothing but a real container can, and that is `src/pi/container.test.ts`. - * - * Assertions are on the composed argv rather than on a rendered string, and several are - * on flag *pairs*, because that is the property a mistake breaks: the process being - * started is the container runtime and not the agent, so a flag on the wrong side of the - * image name reaches the wrong program. - */ - -import assert from "node:assert/strict"; -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { after, describe, it } from "node:test"; -import type { LogFields, Logger } from "../logging/logging.ts"; -// The two ends of a Run are the Signal Worker's vocabulary and come off its own subpath, -// which is what a `RunPlan` is written against. -import type { RunOutcome, RunPrompt } from "../signals/index.ts"; -import { - type FakeContainerScript, - fakeContainerCommand, - fakeContainerReport, -} from "../test-support/fake-container.ts"; -// From the package root, which is where an Operator meets the container half: nothing in -// any of it knows about an Agent Implementation. -import { - type AgentContainer, - type ComposedCommand, - createAgentContainerRuntime, - type Mount, - type RunPlan, -} from "./index.ts"; - -/** The three entries a deployment typically declares, and every test's default. */ -const entries: readonly Mount[] = [ - { agentPath: "/workspace", path: "workspace" }, - { agentPath: "/home/agent/.pi/agent", path: "agent" }, - { agentPath: "/sessions", path: "sessions" }, -]; - -/** The host's runtime directory those entries are written against, and every test's default. */ -const runtimeDir = "/srv/concorde"; - -/** The least container the Runtime accepts, plus the mounts most tests want. */ -const minimal: AgentContainer = { image: "concorde/agent:latest", mounts: { entries, runtimeDir } }; - -const prompt: RunPrompt = { session: "user_42", text: "what happened?" }; - -/** - * A stand-in Agent Implementation: the Prompt on stdin, and a Session named in failure. - * - * The reader closes over the Session, which is the whole reason `run` produces one per - * Run rather than being handed a reader once at construction. It takes the Session as it - * finds it and resolves nothing: a `RunPrompt`'s Session is a string, because the Signal - * Worker answered a Handler's request for a fresh one long before here. - */ -function agentRun({ session, text }: RunPrompt): RunPlan { - return { - args: ["--session-id", session, "--answer"], - stdin: text, - outcome: async (stdout) => { - let said = ""; - for await (const chunk of stdout) said += Buffer.from(chunk).toString("utf8"); - return said.includes("answered") - ? { ok: true } - : { ok: false, error: `Session ${session} produced no answer.` }; - }, - }; -} - -/** The command line one Prompt composes, without starting anything. */ -function commandFor( - container: Partial = {}, - given: RunPrompt = prompt, -): ComposedCommand { - return createAgentContainerRuntime({ - container: { ...minimal, ...container }, - run: agentRun, - }).commandFor(given); -} - -/** - * This process's `uid:gid`, which is what the Runtime always sets `--user` to. - * - * Asserted rather than assumed, because these two are absent on Windows and the - * assertion is the honest statement of what the container topology needs. - */ -function ownUser(): string { - assert.ok( - typeof process.getuid === "function" && typeof process.getgid === "function", - "the Gateway's bind mounts need a uid and a gid, which this platform does not have", - ); - return `${process.getuid()}:${process.getgid()}`; -} - -/** The argument after `flag`, asserting the flag appears exactly once. */ -function argumentAfter(composed: ComposedCommand, flag: string): string { - const occurrences = composed.args.filter((arg) => arg === flag); - assert.equal(occurrences.length, 1, `${flag} should appear once in ${composed.args.join(" ")}`); - const value = composed.args[composed.args.indexOf(flag) + 1]; - assert.ok(value !== undefined, `${flag} should be followed by a value`); - return value; -} - -/** Every value given to a flag that may repeat, in order. */ -function valuesOf(args: readonly string[], flag: string): string[] { - return args.flatMap((arg, at) => (arg === flag ? [args[at + 1] ?? ""] : [])); -} - -/** The arguments before the image name, which belong to the container runtime. */ -function containerArgsOf(composed: ComposedCommand, image = minimal.image): string[] { - const at = composed.args.indexOf(image); - assert.notEqual(at, -1, "the image should appear in the arguments"); - return composed.args.slice(0, at); -} - -/** The arguments after the image name, which belong to the agent. */ -function agentArgsOf(composed: ComposedCommand, image = minimal.image): string[] { - return composed.args.slice(composed.args.indexOf(image) + 1); -} - -describe("the least an Operator can declare", () => { - it("is an image, and everything else has a default or is absent", () => { - const composed = createAgentContainerRuntime({ - container: { image: "concorde/agent:latest" }, - run: agentRun, - }).commandFor(prompt); - - assert.equal(composed.command, "docker"); - assert.deepEqual(composed.args, [ - "run", - "--rm", - "--interactive", - "--user", - ownUser(), - "concorde/agent:latest", - "--session-id", - "user_42", - "--answer", - ]); - }); - - it("takes no Mount Table at all, which is a deployment and not a mistake", () => { - // An image that bakes in its own configuration and keeps no state between Runs - // mounts nothing. An empty table says the same thing and is no longer refused: the - // rule that forbade it was deleted rather than moved. It has to name a - // Runtime Directory nothing then reads, which is why the absent table is the one the - // Runtime itself falls back on. - for (const container of [ - { image: "concorde/agent" }, - { image: "concorde/agent", mounts: { entries: [], runtimeDir } }, - ]) { - const composed = createAgentContainerRuntime({ container, run: agentRun }).commandFor(prompt); - assert.ok(!composed.args.includes("--mount")); - } - }); -}); - -describe("what the container runtime is told", () => { - it("is docker unless the Operator named something else", () => { - assert.equal(commandFor().command, "docker"); - assert.equal(containerArgsOf(commandFor())[0], "run"); - - const podman = commandFor({ containerCommand: ["podman"] }); - assert.equal(podman.command, "podman"); - assert.equal(podman.args[0], "run"); - - // A list with nothing in it takes the default too, rather than being refused. `pi`'s - // own configuration used to refuse it at resolution, on the argument that everything - // holding a resolved configuration should have a command to run; there is no - // resolution step left to refuse it in, and "no container runtime named" and "the - // default container runtime" are the same statement. - assert.equal(commandFor({ containerCommand: [] }).command, "docker"); - }); - - it("takes a container runtime that needs arguments of its own", () => { - const composed = commandFor({ containerCommand: ["sudo", "docker"] }); - - assert.equal(composed.command, "sudo"); - assert.deepEqual(composed.args.slice(0, 2), ["docker", "run"]); - }); - - it("removes the container and keeps stdin open, but gives it no TTY", () => { - const args = containerArgsOf(commandFor()); - - assert.ok(args.includes("--rm"), "one fresh container per Run leaves nothing behind"); - assert.ok(args.includes("--interactive"), "the Prompt is written to stdin"); - // A TTY would make an agent believe it is being used interactively. - assert.ok(!args.includes("--tty") && !args.includes("-t"), "there must be no TTY"); - }); - - it("runs as this process's own user, so bind-mounted files are readable both ways", () => { - // Not configuration, and there is no field that says otherwise. Without it every - // file the agent writes into a bind mount is owned by uid 0, and a Signal Handler - // can read it and delete it but cannot modify it in place. - assert.equal(argumentAfter(commandFor(), "--user"), ownUser()); - }); - - it("joins every network it was given, and none where none was named", () => { - // Plural with no default: the runtime's own default is the shared bridge the agent - // should not be on, and no network at all breaks every Run. - assert.deepEqual( - valuesOf(commandFor({ networks: ["concorde-agent", "concorde-models"] }).args, "--network"), - ["concorde-agent", "concorde-models"], - ); - assert.ok(!commandFor().args.includes("--network")); - assert.ok(!commandFor({ networks: [] }).args.includes("--network")); - }); - - it("passes one flag per environment variable, and none where none was named", () => { - const composed = commandFor({ env: { ANTHROPIC_API_KEY: "sk-test", HTTPS_PROXY: "" } }); - - assert.deepEqual(valuesOf(composed.args, "--env"), [ - "ANTHROPIC_API_KEY=sk-test", - "HTTPS_PROXY=", - ]); - assert.ok(!commandFor().args.includes("--env")); - }); - - it("composes the whole line in one order, whatever the container declares", () => { - // The single claim the individual assertions around this one break down: the - // confinement flags, the mounts, the user, the networks, the environment, the entry - // point, then the flags the framework does not model, then the image, then the - // agent's own. Written out because the order is the part a change silently breaks. - const composed = commandFor({ - mounts: { entries: [{ agentPath: "/workspace", path: "workspace" }], runtimeDir }, - networks: ["concorde-agent", "concorde-models"], - env: { ANTHROPIC_API_KEY: "sk-test", HTTPS_PROXY: "" }, - entrypoint: ["agent"], - extraArgs: ["--memory", "2g"], - }); - - assert.deepEqual(composed.args, [ - "run", - "--rm", - "--interactive", - "--mount", - "type=bind,source=/srv/concorde/workspace,target=/workspace", - "--user", - ownUser(), - "--network", - "concorde-agent", - "--network", - "concorde-models", - "--env", - "ANTHROPIC_API_KEY=sk-test", - "--env", - "HTTPS_PROXY=", - "--entrypoint", - "agent", - "--memory", - "2g", - "concorde/agent:latest", - "--session-id", - "user_42", - "--answer", - ]); - }); - - it("puts nothing of the container runtime's after the image, and nothing of the agent's before it", () => { - const composed = commandFor({ - networks: ["concorde-agent"], - env: { A: "1" }, - extraArgs: ["--memory", "2g"], - entrypoint: ["agent"], - }); - - for (const flag of [ - "run", - "--rm", - "--mount", - "--env", - "--network", - "--memory", - "--entrypoint", - ]) { - assert.ok(containerArgsOf(composed).includes(flag), `${flag} is the container runtime's`); - assert.ok(!agentArgsOf(composed).includes(flag), `${flag} must not reach the agent`); - } - for (const flag of ["--session-id", "--answer"]) { - assert.ok(agentArgsOf(composed).includes(flag), `${flag} is the agent's`); - assert.ok(!containerArgsOf(composed).includes(flag), `${flag} must not reach docker`); - } - }); -}); - -describe("the Mount Table on an Agent Container", () => { - it("emits one bind mount per entry, in the order they were declared", () => { - assert.deepEqual(valuesOf(commandFor().args, "--mount"), [ - "type=bind,source=/srv/concorde/workspace,target=/workspace", - "type=bind,source=/srv/concorde/agent,target=/home/agent/.pi/agent", - "type=bind,source=/srv/concorde/sessions,target=/sessions", - ]); - }); - - it("never emits -v, which is what makes the daemon refuse a source that is not there", () => { - // The load-bearing subtraction, and the reason a startup mount check - // could go: `-v` invents a missing directory source as `root`, and invents a - // *directory* even where a file was meant. `--mount` refuses both, naming the path. - const composed = commandFor({ extraArgs: ["--memory", "2g"] }); - - assert.ok(!composed.args.includes("-v")); - assert.ok(!composed.args.includes("--volume")); - }); - - it("marks an entry read-only where it was declared so, and no other", () => { - // A read-only *file* nested inside a read-write *directory*: the container runtime - // sorts bind mounts by destination depth, so the file is unwritable while every - // sibling operation in the directory around it still succeeds. - const composed = commandFor({ - mounts: { - entries: [ - ...entries, - { - agentPath: "/workspace/AGENTS.md", - path: "AGENTS.md", - readOnly: true, - }, - ], - runtimeDir, - }, - }); - - const mounts = valuesOf(composed.args, "--mount"); - assert.deepEqual(mounts.slice(-1), [ - "type=bind,source=/srv/concorde/AGENTS.md,target=/workspace/AGENTS.md,readonly", - ]); - assert.equal(mounts.filter((value) => value.includes("readonly")).length, 1); - }); - - it("quotes a value containing a comma, which is what --mount splits its fields on", () => { - const composed = commandFor({ - mounts: { entries: [{ agentPath: "/work,space", path: "a,b" }], runtimeDir: "/srv" }, - }); - - assert.deepEqual(valuesOf(composed.args, "--mount"), [ - 'type=bind,"source=/srv/a,b","target=/work,space"', - ]); - }); - - it("resolves every entry against the runtime directory, which is the daemon's own", () => { - // The daemon resolves a bind source on the *host*, so the runtime directory is the - // host's path to the shared tree, however this process reaches it. An entry naming - // the directory itself resolves to it whole, and one below it appends. - const composed = commandFor({ - mounts: { - entries: [ - { agentPath: "/state", path: "" }, - { agentPath: "/workspace", path: "workspace" }, - ], - runtimeDir: "/host/gateway/state", - }, - }); - - assert.deepEqual(valuesOf(composed.args, "--mount"), [ - "type=bind,source=/host/gateway/state,target=/state", - "type=bind,source=/host/gateway/state/workspace,target=/workspace", - ]); - }); - - it("treats a trailing slash on the runtimeDir as the same directory", () => { - // An Operator writing the root with a trailing separator is not making a different - // statement, so every entry composes the same way. - const composed = commandFor({ - mounts: { entries, runtimeDir: "/srv/concorde/" }, - }); - - assert.deepEqual(valuesOf(composed.args, "--mount"), [ - "type=bind,source=/srv/concorde/workspace,target=/workspace", - "type=bind,source=/srv/concorde/agent,target=/home/agent/.pi/agent", - "type=bind,source=/srv/concorde/sessions,target=/sessions", - ]); - }); - - it('takes "/" as a runtime directory, which is the escape for a tree on two host mounts', () => { - // One directory cannot span two host mounts, so a deployment whose shared tree does - // names the host root and writes the rest of each path into the entry. The general - // rule with an ordinary value, in place of a special case. - const composed = commandFor({ - mounts: { entries: [{ agentPath: "/thing", path: "mnt/b/thing" }], runtimeDir: "/" }, - }); - - assert.deepEqual(valuesOf(composed.args, "--mount"), [ - "type=bind,source=/mnt/b/thing,target=/thing", - ]); - }); -}); - -describe("the entry point", () => { - it("is the image's own unless the container named one", () => { - assert.ok(!commandFor().args.includes("--entrypoint")); - }); - - it("goes to the container runtime, and anything after it goes after the image", () => { - // `--entrypoint` takes exactly one word; the rest is the container's *command* and - // sits between the image and the agent's own arguments. - const composed = commandFor({ entrypoint: ["sh", "-c", "exec agent"] }); - - assert.equal(argumentAfter(composed, "--entrypoint"), "sh"); - assert.deepEqual(agentArgsOf(composed), [ - "-c", - "exec agent", - "--session-id", - "user_42", - "--answer", - ]); - }); - - it("is an Agent Implementation's default, and an Operator's own wins over it", () => { - // The whole extension mechanism: defaults spread beneath the Operator's own. There - // is no registration, no base to extend and no lifecycle to implement. - const withDefaults = (container: AgentContainer) => - createAgentContainerRuntime({ - container: { - entrypoint: ["agent"], - ...container, - env: { AGENT_OFFLINE: "1", ...container.env }, - }, - run: agentRun, - }); - - const asShipped = withDefaults(minimal).commandFor(prompt); - assert.equal(argumentAfter(asShipped, "--entrypoint"), "agent"); - assert.deepEqual(valuesOf(asShipped.args, "--env"), ["AGENT_OFFLINE=1"]); - - const overridden = withDefaults({ - ...minimal, - entrypoint: ["/usr/local/bin/agent"], - env: { AGENT_OFFLINE: "0" }, - }).commandFor(prompt); - assert.equal(argumentAfter(overridden, "--entrypoint"), "/usr/local/bin/agent"); - assert.deepEqual(valuesOf(overridden.args, "--env"), ["AGENT_OFFLINE=0"]); - }); -}); - -describe("the flags the framework does not model", () => { - it("go last, so they also override the ones it composed", () => { - const extra = ["--memory", "2g", "--user", "0:0", "--cap-drop", "ALL"]; - const composed = commandFor({ extraArgs: extra }); - - assert.deepEqual(containerArgsOf(composed).slice(-extra.length), extra); - // Both `--user` values are there and the Operator's is the later one, which is the - // one the container runtime keeps — verified on Docker 29.4.0, and the documented - // way to countermand a user that is otherwise not configuration at all. - assert.deepEqual(valuesOf(composed.args, "--user"), [ownUser(), "0:0"]); - }); - - it("reach the container runtime and never the agent", () => { - // Half an escape hatch, recorded as a gap rather than a decision: there is still no - // way to pass the agent itself a flag the framework does not model. - const composed = commandFor({ extraArgs: ["--memory", "2g"] }); - - assert.ok(!agentArgsOf(composed).includes("--memory")); - }); -}); - -describe("the loggable copy of the command line", () => { - it("replaces every environment value, with no exceptions list", () => { - // A list of what is safe to log would have to be right about every provider's key - // name forever, and it would have to name an Agent Implementation's own variables - // inside a module that must not know them. - const composed = commandFor({ - env: { ANTHROPIC_API_KEY: "sk-a-real-key", AGENT_DIR: "/home/agent/.pi/agent" }, - extraArgs: ["--env", "OPENAI_API_KEY=another-real-key", "-e", "AWS_SECRET=third"], - }); - - assert.ok(!composed.redactedArgs.join(" ").includes("sk-a-real-key")); - assert.ok(!composed.redactedArgs.join(" ").includes("another-real-key")); - assert.ok(!composed.redactedArgs.join(" ").includes("third")); - assert.deepEqual(valuesOf(composed.redactedArgs, "--env"), [ - "ANTHROPIC_API_KEY=…", - // Not a secret, and hidden anyway: that is what "no exceptions" costs, and the - // Operator can read this one back out of their own compose file. - "AGENT_DIR=…", - "OPENAI_API_KEY=…", - ]); - assert.deepEqual(valuesOf(composed.redactedArgs, "-e"), ["AWS_SECRET=…"]); - }); - - it("leaves a variable set to nothing visibly empty", () => { - // There is nothing in it to hide, and "set to empty" and "set to something" are - // worth telling apart in a log. - const composed = commandFor({ env: { HTTPS_PROXY: "", HTTP_PROXY: "http://proxy:3128" } }); - - assert.deepEqual(valuesOf(composed.redactedArgs, "--env"), ["HTTPS_PROXY=", "HTTP_PROXY=…"]); - }); - - it("changes nothing else, so the two arrays stay comparable", () => { - const composed = commandFor({ env: { A: "1" }, extraArgs: ["--memory", "2g"] }); - - assert.equal(composed.redactedArgs.length, composed.args.length); - assert.deepEqual( - composed.redactedArgs.filter((arg) => !arg.includes("=")), - composed.args.filter((arg) => !arg.includes("=")), - ); - }); -}); - -describe("a container that cannot work", () => { - it("is refused at construction rather than at the first Run", () => { - // A Run that fails is never retried, so a deployment refused only at its - // first Signal is one whose every Signal becomes a permanently failed Run. These two - // and the tests after them are the whole of what is decidable from the value alone. - assert.throws( - () => createAgentContainerRuntime({ container: { image: "" }, run: agentRun }), - /no image/, - ); - assert.throws( - () => - createAgentContainerRuntime({ - container: { - image: "concorde/agent", - mounts: { entries: [{ agentPath: "workspace", path: "workspace" }], runtimeDir }, - }, - run: agentRun, - }), - /agentPath "workspace".*absolute/s, - ); - }); - - it("refuses a leading '/' on an entry's path, naming the entry and the runtime directory", () => { - // The old absolute form written into the new field: it does not fail, it resolves under the - // runtime directory a second time. `/srv/concorde/state` against a runtimeDir of - // `/srv/concorde` is a plausible-looking `/srv/concorde/srv/concorde/state` and a daemon - // refusal at the first Run, which is a permanently dead Signal, nothing being retried. - assert.throws( - () => - createAgentContainerRuntime({ - container: { - image: "concorde/agent", - mounts: { - entries: [ - { agentPath: "/workspace", path: "workspace" }, - { agentPath: "/state", path: "/srv/concorde/state" }, - ], - runtimeDir: "/srv/concorde", - }, - }, - run: agentRun, - }), - (error: Error) => { - assert.match(error.message, /"\/srv\/concorde\/state"/); - assert.match(error.message, /"\/srv\/concorde"/); - return true; - }, - ); - }); - - it("refuses a '.' or '..' segment in an agentPath, an entry's path, or the runtimeDir", () => { - // Every one is decidable from the value with no I/O. A '..' segment is - // what makes an entry's path escape the runtime directory it is written against, and - // joining the two would resolve it away silently. The table is refused where it was - // written, and the Operator is told to normalize the path rather than having it - // normalized on their behalf. - const refuses = (mounts: NonNullable, offending: string) => - assert.throws( - () => - createAgentContainerRuntime({ - container: { image: "concorde/agent", mounts }, - run: agentRun, - }), - (error: Error) => { - assert.ok(error.message.includes(offending), error.message); - assert.match(error.message, /normaliz/i); - return true; - }, - ); - - refuses({ entries: [{ agentPath: "/work/../etc", path: "a" }], runtimeDir }, "/work/../etc"); - refuses({ entries: [{ agentPath: "/work/./here", path: "a" }], runtimeDir }, "/work/./here"); - refuses({ entries: [{ agentPath: "/ok", path: "a/../etc" }], runtimeDir }, "a/../etc"); - refuses( - { entries: [{ agentPath: "/state", path: "state" }], runtimeDir: "/srv/../concorde" }, - "/srv/../concorde", - ); - }); - - it("allows double slashes and dotted filenames, which are not dot segments", () => { - // A double slash cannot escape the runtime directory and the daemon collapses one - // anyway, so an empty segment is legal; and a filename that merely contains dots is - // not a dot segment. - assert.doesNotThrow(() => - createAgentContainerRuntime({ - container: { - image: "concorde/agent", - mounts: { - entries: [ - { agentPath: "/work//nested", path: "a//b" }, - { agentPath: "/cfg/..hidden", path: "my.file" }, - ], - runtimeDir: "/srv", - }, - }, - run: agentRun, - }), - ); - }); - - it("refuses two entries that name the same agentPath, even differing only by a trailing slash", () => { - // Two sources at one target is the daemon's refusal at the first Run today, which - // is a permanently dead Signal — and it needs no I/O to see. - // The comparison grants the same trailing-slash tolerance the prefix matching does. - const refuses = (entries: readonly Mount[]) => - assert.throws( - () => - createAgentContainerRuntime({ - container: { image: "concorde/agent", mounts: { entries, runtimeDir } }, - run: agentRun, - }), - (error: Error) => { - assert.match(error.message, /"\/workspace"/); - return true; - }, - ); - - refuses([ - { agentPath: "/workspace", path: "a" }, - { agentPath: "/workspace", path: "b" }, - ]); - refuses([ - { agentPath: "/workspace", path: "a" }, - { agentPath: "/workspace/", path: "b" }, - ]); - }); - - it("lets no entry resolve outside the runtime directory it is written against", () => { - // The two ways to leave it, and both are refused rather than resolved: a '..' segment, - // which `path.posix.join` would quietly collapse into a host path above the root, and - // a leading '/', which would land under it twice. - const refuses = (entry: Mount, reason: RegExp) => - assert.throws( - () => - createAgentContainerRuntime({ - container: { image: "concorde/agent", mounts: { entries: [entry], runtimeDir } }, - run: agentRun, - }), - (error: Error) => { - assert.ok(error.message.includes(entry.path), error.message); - assert.match(error.message, reason); - return true; - }, - ); - - refuses({ agentPath: "/secrets", path: "../secrets" }, /normaliz/i); - refuses({ agentPath: "/secrets", path: "/srv/secrets" }, /relative/); - }); - - it("touches no filesystem doing it, so none of these paths need exist", () => { - // Resolution is pure. Whether a source is really there is the daemon's answer at the - // first Run and deliberately nobody else's. - assert.doesNotThrow(() => - createAgentContainerRuntime({ - container: { - image: "concorde/agent", - mounts: { - entries: [{ agentPath: "/nowhere", path: "definitely/not/here" }], - runtimeDir: "/nor/is/this", - }, - }, - run: agentRun, - }), - ); - }); -}); - -describe("what the agent's function is asked", () => { - it("is asked exactly once per Run, so an impure one cannot disagree with itself", async () => { - // Its result is used for both the command line and the outcome reader, which is the - // whole reason one function replaced two. - let asked = 0; - const runtime = createAgentContainerRuntime({ - container: { ...minimal, containerCommand: fakeContainerCommand({ stdout: "answered" }) }, - run: (given) => { - asked += 1; - return agentRun(given); - }, - }); - - assert.equal(asked, 0, "construction must not ask"); - assert.deepEqual(await runtime.run(prompt), { ok: true }); - assert.equal(asked, 1); - - runtime.commandFor(prompt); - assert.equal(asked, 2, "and commandFor asks once of its own"); - }); - - it("may put the Prompt on argv and write nothing to stdin", async () => { - // The shape `pi` cannot use, and the point of the seam: an agent taking its Prompt - // as an argument needs nothing added to the framework. - const composed = createAgentContainerRuntime({ - container: minimal, - run: (given) => ({ - args: ["--prompt", given.text], - stdin: "", - outcome: async (): Promise => ({ ok: true }), - }), - }).commandFor(prompt); - - assert.equal(composed.stdin, ""); - assert.deepEqual(agentArgsOf(composed), ["--prompt", "what happened?"]); - }); - - it("keeps whatever it asked for on stdin out of the command line", () => { - const composed = commandFor({}, { session: "user_42", text: "read @notes.md" }); - - assert.equal(composed.stdin, "read @notes.md"); - assert.ok(!composed.args.includes("read @notes.md")); - }); -}); - -describe("a Run", () => { - const temporary: string[] = []; - after(async () => { - await Promise.all(temporary.map((dir) => rm(dir, { recursive: true, force: true }))); - }); - - /** A Runtime whose container runtime is the stub running `script`. */ - async function runtimeOn( - script: Omit = {}, - container: Partial = {}, - ) { - const root = await mkdtemp(path.join(tmpdir(), "concorde-container-")); - temporary.push(root); - const reportTo = path.join(root, "report.json"); - const lines: { level: string; fields: LogFields; message: string }[] = []; - const at = (level: string) => (fields: LogFields, message: string) => { - lines.push({ level, fields, message }); - }; - const logger: Logger = { - debug: at("debug"), - info: at("info"), - warn: at("warn"), - error: at("error"), - }; - - return { - runtime: createAgentContainerRuntime({ - container: { - ...minimal, - ...container, - containerCommand: fakeContainerCommand({ ...script, reportTo }), - logger, - }, - run: agentRun, - }), - lines, - report: () => fakeContainerReport(reportTo), - }; - } - - it("starts the container with exactly the command line commandFor shows, defaults included", async () => { - // The claim `commandFor` exists to make: what a test sees and what a Run does are - // the same argv, so an argument test never has to start a container. - const started = await runtimeOn( - { stdout: "answered" }, - { entrypoint: ["agent"], env: { AGENT_OFFLINE: "1" }, networks: ["concorde-agent"] }, - ); - - const shown = started.runtime.commandFor(prompt); - await started.runtime.run(prompt); - - // The stub is the container runtime, so what it was handed is `args` minus the - // stub's own leading arguments, which `command` and the rest of `containerCommand` - // account for. - const handed = started.report().args; - assert.deepEqual(handed, shown.args.slice(shown.args.length - handed.length)); - assert.ok(handed.includes("--entrypoint"), "the Agent Implementation's own default is in it"); - assert.equal(started.report().stdin, "what happened?", "and the Prompt reached stdin"); - }); - - it("reads its outcome from the stream and not from the exit code", async () => { - const succeeded = await runtimeOn({ stdout: "answered", exitCode: 3 }); - - assert.deepEqual(await succeeded.runtime.run(prompt), { ok: true }); - // Said out loud, because it is a combination that should not occur. - assert.ok(succeeded.lines.some((line) => line.level === "warn")); - }); - - it("appends the exit status and stderr to a failure the stream decided on", async () => { - // The Run's `error` column is the only place an Operator looks, so the diagnosis has - // to be in the message rather than only in a log line. - const failed = await runtimeOn({ stdout: "", stderr: "Unable to find image\n", exitCode: 125 }); - - const outcome = await failed.runtime.run(prompt); - assert.equal( - outcome.ok === false && outcome.error, - "Session user_42 produced no answer. The container exited with code 125. Its stderr said: Unable to find image", - ); - }); - - it("names the Session in that failure, which is what the per-Run reader buys", async () => { - const failed = await runtimeOn({ stdout: "" }); - - const outcome = await failed.runtime.run({ session: "user_99", text: "hi" }); - assert.match(outcome.ok === false ? outcome.error : "", /Session user_99/); - }); - - it("logs the command line with the environment's values taken out", async () => { - const started = await runtimeOn({ stdout: "answered" }, { env: { KEY: "sk-a-real-key" } }); - - await started.runtime.run(prompt); - - const line = started.lines.find((it) => it.message === "starting the agent's container"); - assert.ok(line !== undefined, "the composed command line should be logged"); - assert.equal(line.level, "debug"); - const logged = JSON.stringify(line.fields); - assert.ok(!logged.includes("sk-a-real-key"), "an API key must not reach a log line"); - assert.match(logged, /KEY=…/); - assert.match(logged, /--mount/); - }); - - it("says so, readably, when the container runtime is not there at all", async () => { - const missing = createAgentContainerRuntime({ - container: { ...minimal, containerCommand: ["concorde-not-a-container-runtime"] }, - run: agentRun, - }); - - await assert.rejects( - missing.run(prompt), - /concorde-not-a-container-runtime.*could not be started/s, - ); - }); -}); diff --git a/src/agent-container/agent-container.ts b/src/agent-container/agent-container.ts deleted file mode 100644 index 4a5a7e2..0000000 --- a/src/agent-container/agent-container.ts +++ /dev/null @@ -1,326 +0,0 @@ -/** - * Nothing in this file may learn what an Agent Implementation is, and the same holds for every - * other file in this directory. Every field below is one `docker run` takes, and the whole bet of - * this directory is that the next agent program needs all of them unchanged and contributes only a `run` - * function. `src/pi/` is the other half and imports from here. Nothing here may import back, and an - * import of `../pi/` is the thing to refuse in review; no lint rule enforces it. - * - * `composeArgv` is the one place argument order is decided, and it is called twice for two - * different reasons: once at construction for its throwing alone, with the result dropped, and - * once per Run for the command line. Keeping a composed result in a closure beside one computed - * per Run is how the two come to disagree, and composing is pure and a handful of string checks, - * so the duplicate work is not worth removing. - * - * Environment values are redacted with no exceptions list. Such a list would have to be right - * about every provider's key name forever, and it would have to name `pi`'s own variables inside a - * module that must not know them. - */ - -import { defaultLogger, type Logger } from "../logging/logging.ts"; -import type { RunOutcome, RunPrompt, Runtime } from "../signals/runtime.ts"; -import { type MountTable, mountArguments } from "./mount-table.ts"; -import { runContainer } from "./process.ts"; - -/** - * The container one Run happens in, as an Operator declares it. Inert: it creates nothing, checks - * no path and starts nothing. - * - * Everything but `image` is a default worth overriding, or a fact about a deployment that most - * deployments do not have. - * - * The container is always run with `--rm`, with stdin open and no TTY, and as this process's own - * uid and gid. None of the three is configurable: a TTY makes an agent decide it is being used - * interactively, and a container running as root leaves files in a bind mount that a Signal - * Handler can read and delete but cannot change. - */ -export type AgentContainer = { - /** - * The container image, handed to the container runtime as written, so a tag or a digest pins what - * runs. - */ - readonly image: string; - /** - * What the container can reach on disk. Absent means nothing at all. - * - * That is a real deployment: an image that bakes in its own configuration and keeps no state - * mounts nothing. What it costs is silent, because nothing written survives the container. Every - * Run is then a first Run, whatever Session it names, and no log line says so. - */ - readonly mounts?: MountTable; - /** - * What to run inside the image, in place of its own `ENTRYPOINT`. - * - * The first word becomes `--entrypoint`, which takes exactly one. Anything after it is the - * container's command and lands after the image name, ahead of what the agent's own function - * contributes. - */ - readonly entrypoint?: readonly string[]; - /** - * The container networks to join, one `--network` each. - * - * Plural, a container being able to join several. There is no default and no good one: the - * container runtime's own is the shared bridge, and no network at all breaks every Run, the - * agent needing both its model and the Agent server. - */ - readonly networks?: readonly string[]; - /** - * Environment variables for the agent's container, such as a provider API key or a proxy. - * - * Only what is named here reaches the agent, and none of the Gateway's own environment does, - * which is most of why the agent runs in a container at all. Every **value** is hidden in the - * loggable copy of the command line, with no exception for a name that looks harmless. - */ - readonly env?: Readonly>; - /** - * Container flags the framework does not model, spliced in last so that one here overrides one - * the framework set. - * - * The one escape hatch, and how to countermand `--user`, a later `--user` winning. It reaches - * the container runtime only: there is still no way to pass the agent itself an unmodelled flag. - */ - readonly extraArgs?: readonly string[]; - /** How the container runtime is invoked. Defaults to `["docker"]`, and `["podman"]` works. */ - readonly containerCommand?: readonly string[]; - /** - * Where this Runtime logs its two `debug` lines per Run, the composed command line and how the - * container ended. Defaults to a `pino` instance on stdout, which drops both. - */ - readonly logger?: Logger; -}; - -/** How to perform one Run: the agent's arguments, its stdin, and how to read what comes back. */ -export type RunPlan = { - /** The agent's own arguments, placed after the image name. */ - readonly args: readonly string[]; - /** Written to the container's stdin, which is then closed. */ - readonly stdin: string; - /** - * Reads the container's stdout into an outcome, and decides whether the Run succeeded. - * - * Raw bytes rather than text, so a multi-byte character split across two chunks is this - * function's to reassemble. Report a bad stream as a failed Run rather than throwing: a throw - * kills the container and propagates, where a failure is recorded against the Run with the exit - * status and stderr appended to the message. - * - * The stream is what decides. A reader that answers success is believed even if the container - * then exits non-zero, which is logged as the contradiction it is. - */ - outcome(stdout: AsyncIterable): Promise; -}; - -/** - * What one containerised agent is: the box an Operator declares, and the one function that drives - * an agent inside it. - * - * The two are separate fields rather than one flat object, so a field written in the wrong half is - * a type error rather than a container flag nothing reads. - */ -export type AgentContainerRuntimeSpec = { - readonly container: AgentContainer; - /** - * The whole of what an Agent Implementation adds. Called once per Run, and its result drives both - * the command line and the reading of stdout. - * - * One function and not two, because `outcome` comes out of it per Run and can therefore close - * over which Run this is and name the Session when it fails. - * - * `prompt.session` is always a string here. A Signal Handler may ask for a fresh Session, and - * the Signal Worker has already settled that and named it before anything reaches this. - */ - run(prompt: RunPrompt): RunPlan; -}; - -/** One Run's command line, and what to feed it. */ -export type ComposedCommand = { - /** The program: the container runtime. */ - readonly command: string; - /** Its arguments: the container's flags, then the image, then the agent's own. */ - readonly args: readonly string[]; - /** - * The same arguments with every environment **value** replaced. Log this and never `args`. - * - * Redacted here because this is the one place that knows which argument is a value and which is - * a flag. A variable set to nothing stays visibly empty, there being nothing in it to hide. - */ - readonly redactedArgs: readonly string[]; - /** The Prompt, or whatever else the agent's own function asked to have written to stdin. */ - readonly stdin: string; -}; - -/** - * A Runtime, plus one pure method the seam itself has no use for. - * - * `commandFor` composes the command line for a Prompt without starting anything, which is the only - * way to see this Runtime's own defaults applied to a declaration. - */ -export type AgentContainerRuntime = Runtime & { - commandFor(prompt: RunPrompt): ComposedCommand; -}; - -/** - * Builds a Runtime that runs the agent as one fresh container per Run, discarding the container - * afterwards. - * - * A command line is composed once here and thrown away, so that a declaration which cannot work is - * refused where the Operator wrote it. That is worth a startup failure because the alternative is - * a Run that fails at the first Signal and is never retried. - * - * @throws If the image is empty, or if the Mount Table cannot mean what it says. - */ -export function createAgentContainerRuntime( - spec: AgentContainerRuntimeSpec, -): AgentContainerRuntime { - const log = spec.container.logger ?? defaultLogger(); - // Called for its throwing, and the result deliberately dropped; see the file header. What this - // call buys is the *when*. - composeArgv(spec.container, []); - - const compose = (plan: RunPlan): ComposedCommand => ({ - ...composeArgv(spec.container, plan.args), - stdin: plan.stdin, - }); - - return { - commandFor: (prompt) => compose(spec.run(prompt)), - - async run(prompt: RunPrompt): Promise { - // Asked once, and its answer used for both the command line and the reader. - const plan = spec.run(prompt); - const invocation = compose(plan); - - // No Run id on this line or the one below it. The Signal Worker is serial globally, so its - // own "Run started" and "Run finished" lines bracket these two. The Run a container line - // belongs to is the one immediately above it. The Session is on the Worker's lines and in - // every failure message, and a transcript is found by it. - log.debug( - // `redactedArgs`, never `args`. This line exists so a mount or a network problem can be - // diagnosed without the framework's source. The command line carries whatever `env` - // holds, which is where a provider API key goes. - { command: invocation.command, args: invocation.redactedArgs }, - "starting the agent's container", - ); - - const result = await runContainer(invocation, plan.outcome); - // One line carrying the two things nothing else records. The exit status is not the - // outcome, and stderr is where a first Run of a named Session often warns. - const ended = { exitCode: result.exitCode, signal: result.signal }; - log.debug( - result.stderr === "" ? ended : { ...ended, stderr: result.stderr }, - "the agent's container is gone", - ); - - if (result.value.ok) { - if (result.exitCode !== 0) { - // Not a failure: the stream said the agent answered, and the stream is what decides. - // Said out loud because it is a combination that should not occur. - log.warn( - ended, - "the agent's container reported a successful Run and then exited non-zero", - ); - } - return result.value; - } - - return { ok: false, error: [result.value.error, ...diagnosis(result)].join(" ") }; - }, - }; -} - -/** - * The whole command line for one Run, and a copy safe to log. The only place argument order is - * decided. - * - * The process being started is the container runtime and not the agent, so everything before the - * image name belongs to the runtime and everything after it to the agent. A flag put on the wrong - * side reaches the wrong program and is not refused by anything. - */ -function composeArgv( - container: AgentContainer, - agentArgs: readonly string[], -): Omit { - if (container.image === "") { - throw new Error("the agent's container has no image, so there is nothing to run"); - } - - const [command = "docker", ...runtimeArgs] = container.containerCommand ?? ["docker"]; - const args: string[] = [ - ...runtimeArgs, - "run", - // One fresh container per Run, and nothing kept afterwards but what the mounts hold. - "--rm", - // Keeps stdin open so the Prompt can be written to it. Deliberately without `--tty`: - // a TTY makes an agent decide it is being used interactively. - "--interactive", - ]; - - // `--mount type=bind` per entry and never `-v`. That is what makes the daemon refuse a missing - // source, rather than invent it as a `root`-owned directory. An absent table contributes - // nothing at all, and is not stood in for by an empty one: a Mount Table names a Runtime - // Directory, and no deployment is made to name a directory it has no entries under. - if (container.mounts !== undefined) args.push(...mountArguments(container.mounts)); - - const user = ownUser(); - if (user !== undefined) args.push("--user", user); - - for (const network of container.networks ?? []) args.push("--network", network); - for (const [name, value] of Object.entries(container.env ?? {})) { - args.push("--env", `${name}=${value}`); - } - - // `--entrypoint` takes one word. Anything after it is the container's *command* and - // goes after the image, ahead of the agent's own arguments. - const [program, ...rest] = container.entrypoint ?? []; - if (program !== undefined) args.push("--entrypoint", program); - - // Last among the runtime's own flags, so these override the ones composed above. - args.push(...(container.extraArgs ?? [])); - args.push(container.image, ...rest, ...agentArgs); - - return { command, args, redactedArgs: redact(args) }; -} - -/** Hides every environment value: the names survive a log line and the values do not. */ -function redact(args: readonly string[]): readonly string[] { - return args.map((arg, at) => { - const flag = args[at - 1]; - if (flag !== "--env" && flag !== "-e") return arg; - const [name, value] = arg.split(/=(.*)/s); - if (name === undefined) return arg; - // A variable set to nothing stays visibly empty. There is nothing in it to hide, and the two - // cases are worth telling apart in a log. - return `${name}=${value === undefined || value === "" ? "" : "…"}`; - }); -} - -/** - * What to add to a failure the stream already decided on. The exit code and stderr are diagnosis - * and never a verdict, so they only ever reach a message that already says the Run failed, and the - * Run's `error` column is where an Operator reads it. - */ -function diagnosis(result: { - readonly exitCode: number | null; - readonly signal: NodeJS.Signals | null; - readonly stderr: string; -}): string[] { - const notes: string[] = []; - if (result.signal !== null) { - notes.push(`The container was killed by ${result.signal}.`); - } else if (result.exitCode !== 0) { - notes.push(`The container exited with code ${result.exitCode}.`); - } - if (result.stderr !== "") notes.push(`Its stderr said: ${result.stderr.trim()}`); - return notes; -} - -/** - * This process's `uid:gid`, or nothing on a platform that has no such thing. Not configuration, and - * the reason is filesystem ownership: without `--user` the agent's files in a bind mount are owned by uid 0, - * and a Signal Handler running as the Gateway's uid can then read and delete such a file but never - * change it in place. `extraArgs` is the documented countermand. - */ -function ownUser(): string | undefined { - if (typeof process.getuid !== "function" || typeof process.getgid !== "function") { - return undefined; - } - return `${process.getuid()}:${process.getgid()}`; -} diff --git a/src/agent-container/index.ts b/src/agent-container/index.ts deleted file mode 100644 index 7e657f4..0000000 --- a/src/agent-container/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * One Run in one fresh container, generic over which agent program runs in it. An Agent Container - * is the declaration of that container: the image, what it reaches on disk, the networks, the - * environment and the flags the framework does not model. It is inert, and creates nothing until a - * Run starts. - * - * {@link createAgentContainerRuntime} is the entry point. It takes an - * {@link AgentContainerRuntimeSpec}, which is an {@link AgentContainer} beside one function that - * answers each Run with a {@link RunPlan}: what to put after the image, what to write on stdin, and - * how to read stdout. {@link AgentContainerRuntime} comes back, a Runtime the Signal Worker - * accepts, and `commandFor` on it composes one Run's {@link ComposedCommand} and starts nothing. - * {@link MountTable} is the disk half, one {@link Mount} per directory or file, and - * {@link mountArguments} turns a table into container arguments on its own. - * - * Reach for this to drive an agent program this package does not adapt. For `pi`, - * `@shutter-network/concorde/pi` supplies that one function and two defaults, and takes an - * {@link AgentContainer} written exactly as it is written here. Nothing on this subpath names an - * agent program or reads a value one of them defines, so what the agent finds in its image and on - * its command line stays the author's to decide. - * - * Nothing here reads the filesystem. {@link createAgentContainerRuntime} composes a command line - * once, at construction, so a declaration that cannot mean anything is refused where the Operator - * wrote it. Whether a path exists is the container runtime's answer, and it arrives at the first - * Run as a Run that failed and will not be retried. This subpath has no Component and no route, it - * does not use the Db, and it exports no schema. - * - * @example - * A Runtime for an agent program of your own: it takes the Prompt on stdin and prints what it said - * on stdout. - * ```ts - * import { createAgentContainerRuntime } from "@shutter-network/concorde/agent-container"; - * import { createGateway } from "@shutter-network/concorde/gateway"; - * - * const runtime = createAgentContainerRuntime({ - * container: { - * image: "my-own-agent:1", - * networks: ["concorde_default"], - * // Only what is named here reaches the agent. None of the Gateway's own environment does. - * env: { MY_AGENT_KEY: process.env.MY_AGENT_KEY ?? "" }, - * mounts: { - * // The host's path to the shared tree, and every entry written under it. - * runtimeDir: "/srv/concorde", - * entries: [ - * { agentPath: "/workspace", path: "workspace" }, - * { agentPath: "/workspace/AGENTS.md", path: "AGENTS.md", readOnly: true }, - * ], - * }, - * }, - * // Called once per Run, and its result drives both the command line and the reading of stdout. - * run: (prompt) => ({ - * args: ["--session", prompt.session], - * stdin: prompt.text, - * outcome: async (stdout) => { - * const chunks: Uint8Array[] = []; - * for await (const chunk of stdout) chunks.push(chunk); - * const said = Buffer.concat(chunks).toString("utf8").trim(); - * // A bad stream is a failed Run and never a throw, which would kill the container. - * return said === "" ? { ok: false, error: "the agent said nothing" } : { ok: true }; - * }, - * }), - * }); - * - * // The whole command line, with the defaults applied and every environment value hidden. - * console.log(runtime.commandFor({ session: "notes", text: "say hello" }).redactedArgs); - * - * const gateway = createGateway({ - * databaseUrl: process.env.DATABASE_URL ?? "", - * runtime, - * // Not loopback: the agent reaches this server from a container of its own. - * agentListen: { host: "0.0.0.0", port: 8081 }, - * publicListen: { host: "0.0.0.0", port: 8080 }, - * handlers: () => ({}), - * }); - * - * await gateway.start(); - * ``` - * - * @module - */ - -export type { - AgentContainer, - AgentContainerRuntime, - AgentContainerRuntimeSpec, - ComposedCommand, - RunPlan, -} from "./agent-container.ts"; -export { createAgentContainerRuntime } from "./agent-container.ts"; -export type { Mount, MountTable } from "./mount-table.ts"; -export { mountArguments } from "./mount-table.ts"; diff --git a/src/agent-container/mount-table.ts b/src/agent-container/mount-table.ts deleted file mode 100644 index 12ccef5..0000000 --- a/src/agent-container/mount-table.ts +++ /dev/null @@ -1,200 +0,0 @@ -/** - * A value and a pure function, and keeping it that way is the whole decision. It creates - * nothing, writes nothing and stats nothing, so a pre-flight check on any of these paths does not - * belong here however cheap it looks. Everything refused below is decidable from the value alone, - * and that is the criterion the list of refusals is built on. - * - * Every mount is emitted as `--mount type=bind` and never `-v`. `-v` invents a missing source as a - * `root`-owned directory, even where a file was meant, where `--mount` refuses and names the path. - * That is the check this module is allowed not to write. - * - * Resolution is one `path.posix.join` of the Runtime Directory and an entry's path, and that is why - * the two ways out of that directory are refused rather than resolved: a `..` segment - * joins away into a path above the root with nothing left to see it, and a leading `/` lands under - * the root a second time. The second refusal is what catches the shape this replaced, where an - * entry named an absolute path of its own; do not soften it into normalization, because the wrong - * path it produces is a plausible one and the daemon refuses it at the first Run, not here. - */ - -import path from "node:path"; - -/** - * One entry: a directory or a single file the agent's container can reach. - * - * Nothing here says which of the two it is, and nothing needs to. There are two paths because there - * are two namespaces: `agentPath` is the agent's own container, and `path` is the host's, written - * against the table's {@link MountTable.runtimeDir}. - */ -export type Mount = { - /** - * The mount point the agent sees. Absolute, and POSIX whatever platform this is. - * - * Two entries naming one `agentPath` are refused, a trailing slash making no difference. - */ - readonly agentPath: string; - /** - * Where the same thing sits inside the Runtime Directory, **relative** to it. - * - * A leading `/` is refused, because an absolute path here would resolve under that directory a - * second time rather than fail. The empty string is the Runtime Directory itself. - */ - readonly path: string; - /** - * Whether the agent can write it. Defaults to `false`. - * - * A read-only **file** nested inside a read-write **directory** works, the container runtime - * sorting bind mounts by destination depth: the file is unwritable and unlinkable while every - * operation on its siblings still succeeds. That is how a file the agent must not change becomes - * one it cannot. - */ - readonly readOnly?: boolean; -}; - -/** - * The whole of what the agent's container can reach on disk. - * - * Everything else about the container belongs to the `AgentContainer` that carries this: the image, - * the entry point, the networks and the environment. - */ -export type MountTable = { - /** - * The entries, in whatever order suits the reader. - * - * Declaration order is preserved in the arguments and means nothing to the outcome. The daemon - * sorts bind mounts by destination depth, so a nested entry nests under its parent however the - * two were written. - * - * An empty list is a deployment too and is not refused. Nothing the agent writes then outlives - * the container, so every Run is a first Run. - */ - readonly entries: readonly Mount[]; - /** - * The **host's** path to the Runtime Directory every entry is written against. - * - * This is the one namespace the table has: the container runtime's daemon resolves a bind source - * on the host, so this is the string it is handed, unread. Where the Gateway process itself - * reaches that directory is not stated here and, for a Gateway in a container, is not in general - * reachable at all, so anything the Gateway reads for itself comes from its own image or from a - * path it holds separately. Nothing discovers this value. - * - * `"/"` is how a shared tree spanning more than one host mount is expressed: an entry then reads - * `mnt/b/thing` and resolves to `/mnt/b/thing`. It is an ordinary value of the same rule and not - * a special case. A trailing separator makes no difference. - */ - readonly runtimeDir: string; -}; - -/** - * Turns a Mount Table into one `--mount` and its value per entry, in declaration order, or refuses - * the table. - * - * Pure and total. It joins each entry's path onto `runtimeDir`, and it refuses a relative - * `agentPath`, a leading `/` on an entry's path, a `.` or `..` segment in any path it resolves, and - * two entries naming one target. - * - * It performs no I/O, so it cannot say whether any of these paths exists. That answer comes from - * the daemon at the first Run, as a Run that failed and will not be retried, which is why - * `createAgentContainerRuntime` calls this at construction: the refusals it can make, it makes - * where the Operator wrote the table. - * - * @throws On any of those four. - */ -export function mountArguments(table: MountTable): readonly string[] { - refuseDotSegment("Mount Table's runtimeDir", table.runtimeDir); - const resolved = table.entries.map((entry) => resolveEntry(entry, table.runtimeDir)); - refuseDuplicateAgentPath(resolved); - return resolved.flatMap((entry) => ["--mount", mountArgument(entry)]); -} - -/** An entry with its defaults settled and its bind source resolved. Internal to this module. */ -type ResolvedEntry = { - readonly agentPath: string; - /** The bind source the daemon is given: the entry's path joined onto the Runtime Directory. */ - readonly hostPath: string; - readonly readOnly: boolean; -}; - -function resolveEntry(entry: Mount, runtimeDir: string): ResolvedEntry { - // A path inside the container. The container runtime requires it to be absolute, and it is - // POSIX whatever this platform is. - if (!entry.agentPath.startsWith("/")) { - throw new Error( - `the mount's agentPath ${JSON.stringify(entry.agentPath)} is not absolute; it is a path inside the agent's container, which the container runtime requires to be absolute`, - ); - } - refuseDotSegment("mount's agentPath", entry.agentPath); - if (entry.path.startsWith("/")) { - throw new Error( - `the mount's path ${JSON.stringify(entry.path)} begins with "/", and every entry's path is relative to the runtimeDir ${JSON.stringify(runtimeDir)}; joining the two resolves it under that directory a second time rather than failing, so write it relative`, - ); - } - refuseDotSegment("mount's path", entry.path); - return { - agentPath: entry.agentPath, - // POSIX whatever this platform is: the daemon this string reaches resolves it as one. - hostPath: path.posix.join(runtimeDir, entry.path), - readOnly: entry.readOnly ?? false, - }; -} - -/** - * One `--mount` value: `type=bind,source=…,target=…`, and `readonly` where declared. - * - * The source is the entry's *host* path. The daemon resolves it on the host. - */ -function mountArgument(entry: ResolvedEntry): string { - const fields = ["type=bind", field("source", entry.hostPath), field("target", entry.agentPath)]; - if (entry.readOnly) fields.push("readonly"); - return fields.join(","); -} - -/** - * One `key=value` of a `--mount` argument, quoted if it has to be. - * - * `--mount` parses its value as CSV. A comma inside a path would end the field and turn the - * rest into an unknown option. CSV quoting is what the parser accepts. - */ -function field(name: string, value: string): string { - const pair = `${name}=${value}`; - if (!pair.includes(",") && !pair.includes('"')) return pair; - return `"${pair.replaceAll('"', '""')}"`; -} - -/** - * Refuses a `.` or `..` **segment** in a path the framework resolves; the file header says why. - * - * A segment is what sits between two slashes, and only a whole `.` or `..` is one. A filename that - * merely contains dots, `my.file` or `..hidden`, is not, and neither is the empty segment a doubled - * slash leaves. - */ -function refuseDotSegment(label: string, value: string): void { - if (value.split("/").some((segment) => segment === "." || segment === "..")) { - throw new Error( - `the ${label} ${JSON.stringify(value)} has a "." or ".." segment; write it as a normalized path, because resolution joins the runtime directory and an entry's path and a ".." would resolve away silently, out of the one directory this table describes`, - ); - } -} - -/** - * Refuses two entries that resolve to one target, comparing `agentPath`s with one trailing slash - * trimmed. It moves here from the daemon's side of the line only because it needs no I/O; an - * image-internal symlink aliasing two distinct targets is undecidable from the value and stays the - * daemon's business. - */ -function refuseDuplicateAgentPath(entries: readonly ResolvedEntry[]): void { - const seen = new Set(); - for (const { agentPath } of entries) { - const target = withoutTrailingSlash(agentPath); - if (seen.has(target)) { - throw new Error( - `two entries name the same agentPath ${JSON.stringify(target)}; a Mount Table cannot mount two sources at one target, and a trailing slash does not make them different directories`, - ); - } - seen.add(target); - } -} - -/** A path with one trailing separator removed. */ -function withoutTrailingSlash(value: string): string { - return value.endsWith("/") ? value.slice(0, -1) : value; -} diff --git a/src/agent-container/process.ts b/src/agent-container/process.ts deleted file mode 100644 index dc1c82e..0000000 --- a/src/agent-container/process.ts +++ /dev/null @@ -1,128 +0,0 @@ -/** - * The one place in the framework that spawns anything, and it spawns one thing: a Run. Four - * properties are load-bearing here, and undoing any of them buys a hang rather than a failure, - * because there are no timeouts anywhere in this framework. - * - * - **stdout is read to the end**, even after the answer is known. A process whose stdout stops - * being read blocks as soon as the pipe fills. - * - **stderr is drained from before stdout is read**, for the same reason, and never awaited - * after. A chatty container would otherwise block on a full pipe while stdout is being read. - * - **a write to stdin can fail** with `EPIPE`, when the container exits before reading the - * Prompt. Unhandled, an `error` event on a stream takes the Gateway's process down, so the - * handler is attached before anything is written. - * - **the process is waited for**, so that a Run is finished only when the container is gone. - * - * Both `spawn` outcomes are awaited before anything else, because a later stream error cannot - * answer whether the container runtime is installed at all. - */ - -import { spawn } from "node:child_process"; -import type { Readable } from "node:stream"; - -/** Enough of a composed command to start one: a `ComposedCommand` satisfies it. */ -export type ContainerCommand = { - readonly command: string; - readonly args: readonly string[]; - /** Written to the process's stdin, which is then closed. */ - readonly stdin: string; -}; - -/** What a finished container left behind. */ -export type ContainerResult = { - /** Whatever the reader made of stdout, and the only thing that decides a Run. */ - readonly value: T; - /** - * The exit status, or `null` when a signal ended it. Reported and never interpreted: an agent in - * machine-readable mode can exit 0 on a model error, so an exit code cannot say whether a Run - * succeeded. It is worth adding to a failure that was decided elsewhere. - */ - readonly exitCode: number | null; - /** The signal that ended it, if one did. */ - readonly signal: NodeJS.Signals | null; - /** What it wrote to stderr, truncated. Diagnosis for a failure, never a verdict. */ - readonly stderr: string; -}; - -/** - * How much stderr is kept, and it is the beginning rather than the end. The container runtime's own - * refusals come first, an image it cannot find or a flag it does not know, and a wall of the - * agent's progress output would push them out. Bounded at all because this reaches a Run's `error` - * column. - */ -const stderrLimit = 4000; - -/** - * Runs one container to completion and hands its stdout to `read`, which is given raw bytes rather - * than text. - * - * There is no timeout, here or anywhere: a Run that never returns halts the Gateway. - * - * @throws If the container runtime cannot be started, or if `read` threw. A reader is expected to - * report a bad stream as a failed Run instead, so a throw is a stream failure, and the container - * is killed before it propagates. - */ -export async function runContainer( - invocation: ContainerCommand, - read: (stdout: AsyncIterable) => Promise, -): Promise> { - const child = spawn(invocation.command, [...invocation.args], { - stdio: ["pipe", "pipe", "pipe"], - }); - - // Node emits exactly one of these two events; see the file header for why both are awaited. - const failedToStart = await new Promise((settled) => { - child.once("spawn", () => settled(undefined)); - child.once("error", (error) => settled(error)); - }); - if (failedToStart !== undefined) { - throw new Error( - `the container runtime ${JSON.stringify(invocation.command)} could not be started: ${failedToStart.message}. It is the command the agent's container is run with: check that it is installed and on this process's PATH, or set containerCommand.`, - { cause: failedToStart }, - ); - } - - // Before anything is written. A container that exits immediately cannot then make this an - // unhandled error event on the way past. - child.stdin.on("error", () => { - // Deliberately nothing. A broken pipe means the container is already gone. What it did or - // did not do is in the stream that is still being read. - }); - child.stdin.end(invocation.stdin, "utf8"); - - const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((done) => { - child.once("close", (code, signal) => done({ code, signal })); - }); - // Started here and awaited at the end, so stderr drains while stdout is being read. - const stderr = collect(child.stderr); - - let value: T; - try { - value = await read(child.stdout); - } catch (error) { - // Whatever it was, the container must not outlive the call that started it: `docker run` - // forwards the signal and `--rm` cleans up. - child.kill(); - throw error; - } - const { code, signal } = await exited; - return { value, exitCode: code, signal, stderr: await stderr }; -} - -/** Reads a stream as text, keeping at most `stderrLimit` characters of it. */ -async function collect(stream: Readable): Promise { - let text = ""; - let truncated = false; - stream.setEncoding("utf8"); - for await (const chunk of stream) { - if (text.length >= stderrLimit) { - truncated = true; - continue; - } - text += String(chunk); - } - if (text.length > stderrLimit) { - truncated = true; - text = text.slice(0, stderrLimit); - } - return truncated ? `${text}… (truncated)` : text; -} diff --git a/src/decisions/index.ts b/src/decisions/index.ts index 200f8f1..eea0e3a 100644 --- a/src/decisions/index.ts +++ b/src/decisions/index.ts @@ -33,7 +33,7 @@ * * const gateway = createGateway({ * databaseUrl: process.env.DATABASE_URL ?? "", - * runtime: createPiRuntime({ image: "my-agent:1" }), + * runtime: createPiRuntime({ host: "agent", port: 4000, sessionsDir: "/sessions" }), * // Not loopback: the agent reaches this server from a container of its own. * agentListen: { host: "0.0.0.0", port: 8081 }, * publicListen: { host: "0.0.0.0", port: 8080 }, diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index 36fb9d5..97f6f21 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -77,9 +77,9 @@ export type GatewayOptions = { /** * What a Prompt is handed to, and what an outcome comes back from. * - * `createPiRuntime` on `@shutter-network/concorde/pi` returns one for `pi`, and - * `createAgentContainerRuntime` on `@shutter-network/concorde/agent-container` builds one for any - * other agent program. + * `createPiRuntime` on `@shutter-network/concorde/pi` returns one that performs each Run against + * an Agent Instance the Operator runs. Any other agent program is a `Runtime` of the Operator's + * own, which is one method. */ readonly runtime: Runtime; /** diff --git a/src/gateway/index.ts b/src/gateway/index.ts index 6112f5c..a2aa6cb 100644 --- a/src/gateway/index.ts +++ b/src/gateway/index.ts @@ -38,7 +38,7 @@ * * const gateway = createGateway({ * databaseUrl: process.env.DATABASE_URL ?? "", - * runtime: createPiRuntime({ image: "my-agent:1" }), + * runtime: createPiRuntime({ host: "agent", port: 4000, sessionsDir: "/sessions" }), * // Not loopback: the agent reaches this server from a container of its own. * agentListen: { host: "0.0.0.0", port: 8081 }, * publicListen: { host: "0.0.0.0", port: 8080 }, diff --git a/src/http-channel/index.ts b/src/http-channel/index.ts index 3739565..5a5fdc4 100644 --- a/src/http-channel/index.ts +++ b/src/http-channel/index.ts @@ -34,7 +34,7 @@ * * const gateway = createGateway({ * databaseUrl: process.env.DATABASE_URL ?? "", - * runtime: createPiRuntime({ image: "my-agent:1" }), + * runtime: createPiRuntime({ host: "agent", port: 4000, sessionsDir: "/sessions" }), * // Not loopback: the agent reaches this server from a container of its own. * agentListen: { host: "0.0.0.0", port: 8081 }, * publicListen: { host: "0.0.0.0", port: 8080 }, diff --git a/src/logging/index.ts b/src/logging/index.ts index ecf3037..f14e399 100644 --- a/src/logging/index.ts +++ b/src/logging/index.ts @@ -31,7 +31,7 @@ * * const gateway = createGateway({ * databaseUrl: process.env.DATABASE_URL ?? "", - * runtime: createPiRuntime({ image: "my-agent:1" }), + * runtime: createPiRuntime({ host: "agent", port: 4000, sessionsDir: "/sessions" }), * // Not loopback: the agent reaches this server from a container of its own. * agentListen: { host: "0.0.0.0", port: 8081 }, * publicListen: { host: "0.0.0.0", port: 8080 }, diff --git a/src/messenger/index.ts b/src/messenger/index.ts index f40e848..dea2904 100644 --- a/src/messenger/index.ts +++ b/src/messenger/index.ts @@ -42,7 +42,7 @@ * * const gateway = createGateway({ * databaseUrl: process.env.DATABASE_URL ?? "", - * runtime: createPiRuntime({ image: "my-agent:1" }), + * runtime: createPiRuntime({ host: "agent", port: 4000, sessionsDir: "/sessions" }), * // Not loopback: the agent reaches this server from a container of its own. * agentListen: { host: "0.0.0.0", port: 8081 }, * publicListen: { host: "0.0.0.0", port: 8080 }, diff --git a/src/nostr-auth/index.ts b/src/nostr-auth/index.ts index ac362eb..2b7f4ce 100644 --- a/src/nostr-auth/index.ts +++ b/src/nostr-auth/index.ts @@ -38,7 +38,7 @@ * * const gateway = createGateway({ * databaseUrl: process.env.DATABASE_URL ?? "", - * runtime: createPiRuntime({ image: "my-agent:1" }), + * runtime: createPiRuntime({ host: "agent", port: 4000, sessionsDir: "/sessions" }), * // Not loopback: the agent reaches this server from a container of its own. * agentListen: { host: "0.0.0.0", port: 8081 }, * publicListen: { host: "0.0.0.0", port: 8080 }, diff --git a/src/nostr-channel/index.ts b/src/nostr-channel/index.ts index 6a859ee..13a52d8 100644 --- a/src/nostr-channel/index.ts +++ b/src/nostr-channel/index.ts @@ -49,7 +49,7 @@ * * const gateway = createGateway({ * databaseUrl: process.env.DATABASE_URL ?? "", - * runtime: createPiRuntime({ image: "my-agent:1" }), + * runtime: createPiRuntime({ host: "agent", port: 4000, sessionsDir: "/sessions" }), * // Not loopback: the agent reaches this server from a container of its own. * agentListen: { host: "0.0.0.0", port: 8081 }, * publicListen: { host: "0.0.0.0", port: 8080 }, diff --git a/src/password-auth/index.ts b/src/password-auth/index.ts index e2e4daf..be4ba1f 100644 --- a/src/password-auth/index.ts +++ b/src/password-auth/index.ts @@ -34,7 +34,7 @@ * * const gateway = createGateway({ * databaseUrl: process.env.DATABASE_URL ?? "", - * runtime: createPiRuntime({ image: "my-agent:1" }), + * runtime: createPiRuntime({ host: "agent", port: 4000, sessionsDir: "/sessions" }), * // Not loopback: the agent reaches this server from a container of its own. * agentListen: { host: "0.0.0.0", port: 8081 }, * publicListen: { host: "0.0.0.0", port: 8080 }, diff --git a/src/pi/agent-instance.test.ts b/src/pi/agent-instance.test.ts new file mode 100644 index 0000000..bb9bef2 --- /dev/null +++ b/src/pi/agent-instance.test.ts @@ -0,0 +1,566 @@ +/** + * A real `pi` behind a real listener, driven by a real Signal Worker: the one opt-in + * end-to-end test. + * + * One test path, deliberately. It is slow and it needs Docker and the network, so it earns + * its place by proving the things nothing faster can — and, since the Gateway stopped + * starting the agent, every one of them is a claim about **`pi`** rather than about us: + * + * - **`switch_session` is create-or-resume.** A path that does not exist becomes a fresh + * Session kept at that path. This is **undocumented behaviour** and the whole design + * rests on it: without it a Signal Handler could only ever prompt into whatever Session + * the instance happened to be in. A fake scripted to do it would pin our reading of the + * protocol and nothing else, which is why this file exists at all. + * - **`get_state` answers with the path it was switched to**, which is the only thing that + * tells "it created the Session I named" apart from "it did something and said it went + * fine". Every Run makes that check, so a `pi` that stopped agreeing fails here loudly + * rather than delivering Prompts into the wrong Session quietly. + * - **a named Session resumes across two Runs and two connections**, which is a claim + * about a transcript on disk being found and parsed by a second `pi` process. + * - **the Agent Instance is reachable over one TCP connection per Run**, through `socat` + * with `fork`, which is the arrangement every example ships. + * - **a model error settles like an answer.** The agent reports it inside an assistant + * message and nothing else says a word, which is why an outcome is read from + * `stopReason` and never from anything else. + * - **`pi` discovers an `AGENTS.md` the Operator placed in the Workspace**, with no flag + * from the framework and nothing of the framework's in the file, and the agent + * **reaches the Agent server** at the address that file names — over HTTP from inside + * its container, with `curl` from its own shell tool and no credential. + * + * What is real here and what is not, exactly: the container, the `pi` binary in it, the + * listener, the socket, the Session files, the files the Operator placed, the JSON lines in + * both directions, the Agent server, the Signal Worker, and PostgreSQL. **Only the model is + * stubbed** — a scripted OpenAI-compatible server on this host, which is what makes the + * test deterministic and what makes it need no provider credentials. The consequence, + * stated rather than hidden: this proves the framework's half of a Run end to end, and says + * nothing about whether a real model would choose to call the Agent server unprompted. + * + * This test is also the Operator, and doing that job is most of what it sets up: it creates + * the three directories, writes `models.json` and `settings.json` into the agent's own + * directory, writes the `AGENTS.md` that carries the Agent server's address, and starts the + * container. **The framework writes none of it and has never read any of it.** It is also + * the Operator in the one way that matters most here: `--no-approve` is passed on the + * container's command line, where it now belongs, and `PI_OFFLINE` is an environment + * variable of the Operator's rather than a default of ours. + * + * Note where the scripted model still learns things for itself: the address it tells the + * agent to `curl` is read **out of the system prompt it was given**, which is where `pi` + * puts a context file it discovered. So a Run whose `AGENTS.md` did not reach the container + * has no URL to find and fails here rather than passing quietly. + * + * Skipped unless `CONCORDE_CONTAINER_TESTS` is set — see `../test-support/docker.ts` for why. + */ + +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { connect } from "node:net"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { after, before, describe, it, type TestContext } from "node:test"; +import { promisify } from "node:util"; +import { eq } from "drizzle-orm"; +import Fastify from "fastify"; +import { openDb } from "../db/index.ts"; +import { createBareGateway, serverComponent } from "../gateway/components.ts"; +import type { SignalHandler } from "../signals/handlers.ts"; +import * as signalsSchema from "../signals/schema/index.ts"; +import { runs } from "../signals/schema/index.ts"; +import { createSignalWorker } from "../signals/worker.ts"; +import { applySchema } from "../test-support/apply-schema.ts"; +import { createTestDatabase, type TestDatabase } from "../test-support/database.ts"; +import { + addHostToGateway, + buildPiImage, + hostFromContainer, + reservePort, + skipContainerTests, +} from "../test-support/docker.ts"; +import { + assistantMessages, + type MockModel, + type ModelReply, + type ModelRequest, + startMockModel, +} from "../test-support/mock-model.ts"; +import { waitUntil } from "../test-support/wait.ts"; +import { createPiRuntime } from "./runtime.ts"; + +const run = promisify(execFile); +const skip = await skipContainerTests(); + +/** What a Signal carries: the Session to run in, and what to say. */ +type Ask = { readonly session: string | null; readonly text: string }; + +const asking: SignalHandler = { + handle: (signal) => [{ session: signal.payload.session, text: signal.payload.text }], +}; + +/** The file a Signal Handler leaves in the Workspace for the agent to read. */ +const handlerNote = "handler-note.txt"; +/** The file the agent writes in the Workspace for the Gateway to read. */ +const agentNote = "agent-note.txt"; +/** + * What `pi` looks for in its working directory and its ancestors, and the name an + * Operator's instructions file therefore takes inside the container. + * + * The framework knows nothing about it: no flag names it, and this constant exists in a + * test rather than in `src/pi/` because `pi`'s own discovery is the whole mechanism. + */ +const agentsFileName = "AGENTS.md"; + +/** + * The three paths inside the Agent Instance, which are the Operator's to choose and the + * Operator's to keep consistent with what the Gateway is told. + * + * Only the third is named twice — once in the container's mounts and once in the Runtime's + * `sessionsDir` — and nothing checks that the two agree, because nothing can: one is + * resolved by the container runtime's daemon and the other by `pi`. + */ +const insideWorkspace = "/workspace"; +const insideAgentDir = "/home/agent/.pi/agent"; +const insideSessions = "/sessions"; + +let image: string; +/** + * The throwaway database, and the Db that will drop it — which is deliberately **not** the + * Gateway's. The Gateway's Db is a Component, so the record stops it, and a pool cannot be + * ended twice. + */ +let database: TestDatabase; + +before(async () => { + if (skip !== false) return; + image = await buildPiImage(); + database = await createTestDatabase("pi_agent_instance"); +}); + +after(async () => { + if (skip !== false) return; + await database.drop(); +}); + +/** Everything one test needs standing up around the Runtime. */ +type Rig = { + readonly model: MockModel; + /** Where the Operator's own instructions file told the agent to reach the Gateway. */ + readonly agentServerUrl: string; + readonly workspace: string; + /** The Session files the Agent Instance has written, sorted, as this host sees them. */ + sessions(): Promise; + /** Emits a Signal and resolves when its Runs have finished. */ + ask(payload: Ask): Promise; + /** + * Every Run recorded for a Signal. + * + * The id comes back because a fresh Session is named after it, so it is what a test + * checks the name against rather than predicting one. + */ + runsOf( + signalId: string, + ): Promise<{ id: string; session: string | null; state: string; error: string | null }[]>; +}; + +/** What the Operator creates on their own disk and mounts into the Agent Instance. */ +type Paths = { + readonly workspace: string; + readonly agentDir: string; + readonly sessions: string; +}; + +/** Three fresh directories under a temporary root, cleaned up with the test. */ +async function temporaryPaths(t: TestContext): Promise { + const root = await mkdtemp(path.join(tmpdir(), "concorde-agent-")); + t.after(() => rm(root, { recursive: true, force: true })); + const paths = { + workspace: path.join(root, "workspace"), + agentDir: path.join(root, "agent"), + sessions: path.join(root, "sessions"), + }; + await Promise.all(Object.values(paths).map((directory) => mkdir(directory, { recursive: true }))); + return paths; +} + +/** The scripted model, as `models.json` describes a provider to `pi`. */ +function mockProvider(baseUrl: string): Record { + return { + providers: { + mock: { + baseUrl, + api: "openai-completions", + apiKey: "not-a-real-key", + compat: { supportsDeveloperRole: false, supportsReasoningEffort: false }, + models: [{ id: "mock-model", name: "Mock", contextWindow: 128_000, maxTokens: 4096 }], + }, + }, + }; +} + +/** + * The two files an Operator places in the agent's own directory, and the one they place in + * the Workspace. + * + * The framework writes none of them and reads none of them. `settings.json` is where the + * model and the provider live: `pi` falls back to `defaultModel` and `defaultProvider` when + * no flag names either, and no flag does, so this file is the whole of how a Run knows what + * to talk to. + */ +async function placeTheOperatorsFiles( + paths: Paths, + modelBaseUrl: string, + agentServerUrl: string, +): Promise { + await writeFile( + path.join(paths.agentDir, "models.json"), + `${JSON.stringify(mockProvider(modelBaseUrl), null, 2)}\n`, + "utf8", + ); + await writeFile( + path.join(paths.agentDir, "settings.json"), + `${JSON.stringify({ defaultModel: "mock-model", defaultProvider: "mock" }, null, 2)}\n`, + "utf8", + ); + await writeFile( + path.join(paths.workspace, agentsFileName), + [ + "# You are the shared agent of a test", + "", + "The Gateway exposes an HTTP API to you and to nothing else, at", + `\`${agentServerUrl}\`. Reach it with \`curl\` from your shell tool. It takes no`, + 'credential. `GET /signals?limit=` answers `{ "signals": [...] }`, newest first.', + "", + ].join("\n"), + "utf8", + ); +} + +/** + * The Agent Instance itself, started the way an example's compose file starts it. + * + * One `socat` listening, one `pi` per connection, and `--no-approve` on the command line + * where it now belongs: it is the flag that stops a Run arranging for the next one to load + * configuration out of the writable Workspace, and the framework can no longer fasten it. + * No healthcheck, for the reason an example has none — with `fork`, a probe on an interval + * boots and discards a `pi` continuously. + */ +async function startAgentInstance(t: TestContext, paths: Paths, port: number): Promise { + const name = `concorde-agent-${process.pid}-${port}`; + const { stdout } = await run("docker", [ + "run", + "--detach", + "--rm", + "--name", + name, + // Published on loopback only: this is the Operator's own network in a compose file, and + // `pi`'s RPC has no authentication of any kind. + "--publish", + `127.0.0.1:${port}:4000`, + // So the agent can reach the Agent server on this host, which is what its `AGENTS.md` + // tells it to do. + addHostToGateway, + // The id the files below belong to, since the image knows nothing about any user and + // what the agent writes has to be readable and removable by this process afterwards. + "--user", + `${process.getuid?.() ?? 0}:${process.getgid?.() ?? 0}`, + // The agent's environment, whole, and none of it the framework's. `PI_OFFLINE` was a + // default the Runtime contributed when the Runtime started containers; it is a line in + // the Operator's compose file now. + "--env", + "PI_OFFLINE=1", + "--mount", + `type=bind,source=${paths.workspace},target=${insideWorkspace}`, + "--mount", + `type=bind,source=${paths.agentDir},target=${insideAgentDir}`, + "--mount", + `type=bind,source=${paths.sessions},target=${insideSessions}`, + image, + // No commas anywhere in what follows: comma is `socat`'s own option separator. And no + // `stderr` option either, which would merge `pi`'s diagnostics into the record stream. + "socat", + "TCP-LISTEN:4000,reuseaddr,fork", + "EXEC:pi --mode rpc --no-approve", + ]); + t.after(async () => { + await run("docker", ["rm", "--force", name]).catch(() => undefined); + }); + assert.ok(stdout.trim().length > 0, "docker run should report a container id"); + + // The framework never waits for the Agent Instance, so this wait is the test's: an + // unreachable instance is an ordinary failed Run, and a suite whose container had not + // finished starting would be asserting that rather than what it meant to. + await waitUntil("the Agent Instance is accepting connections", () => reachable(port), 60_000); +} + +/** Whether something is listening, asked the way the Runtime asks it. */ +async function reachable(port: number): Promise { + return new Promise((answered) => { + const socket = connect({ host: "127.0.0.1", port }); + socket.once("connect", () => { + socket.destroy(); + answered(true); + }); + socket.once("error", () => { + socket.destroy(); + answered(false); + }); + }); +} + +/** + * Stands up a whole Gateway around one `pi` Runtime and hands it to `body`. + * + * One end-to-end path, so this is used once: a Signal Worker, a real database, the Agent + * server with the Worker's routes on it, the scripted model, and an Agent Instance in a + * container. Constructed by hand through `createBareGateway`, because what this file needs + * is a subset of the infrastructure and none of the parts `createGateway` hands the Operator + * through `extend`. Nothing here is about the assembly; the subject is a real `pi`. + */ +async function withGateway( + t: TestContext, + reply: (request: ModelRequest, at: number) => ModelReply, + body: (rig: Rig) => Promise, +): Promise { + const paths = await temporaryPaths(t); + + // Both ports before anything listens: the agent is told where the Agent server is in a + // file written now, and the Gateway is told where the Agent Instance is before the + // container exists. + const gatewayPort = await reservePort(); + const instancePort = await reservePort(); + const agentServerUrl = `http://${hostFromContainer}:${gatewayPort}`; + const model = await startMockModel(reply); + + await placeTheOperatorsFiles(paths, model.baseUrl, agentServerUrl); + await startAgentInstance(t, paths, instancePort); + + // A bare Fastify instance in a Component, as an Operator's entry point constructs it. + // Bound beyond loopback on purpose — under a plain Linux daemon a container cannot reach + // a loopback-bound server at all, and this test has to pass on both. + const agentServer = serverComponent(Fastify(), { port: gatewayPort, host: "0.0.0.0" }); + const runtime = createPiRuntime({ + host: "127.0.0.1", + port: instancePort, + // The same string the container's third mount targets, and nothing checks that they + // agree: one is resolved by the daemon and the other by `pi`. + sessionsDir: insideSessions, + }); + + const db = openDb(database.url); + const worker = createSignalWorker({ db, runtime, handlers: { ask: asking }, agentServer }); + await applySchema(db, signalsSchema); + + const handle = db.handle({ runs }); + const rig: Rig = { + model, + agentServerUrl, + workspace: paths.workspace, + async sessions() { + return (await readdir(paths.sessions)).sort(); + }, + async ask(payload) { + const id = await db.tx((tx) => worker.emit(tx, { kind: "ask", payload })); + await waitUntil( + `the Signal ${id} has been processed`, + async () => { + const [row] = await handle.select().from(runs).where(eq(runs.signalId, id)); + return row !== undefined && row.state !== "pending" && row.state !== "running"; + }, + // Two model round trips and a `pi` start, on whatever machine this is. The + // framework itself has no timeouts; this one is the test's, so a wedged Run fails + // the suite rather than hanging it. + 180_000, + ); + return id; + }, + async runsOf(signalId) { + const rows = await handle.select().from(runs).where(eq(runs.signalId, signalId)); + return rows.map((row) => ({ + id: row.id, + session: row.session, + state: row.state, + error: row.error, + })); + }, + }; + + const gateway = createBareGateway({ db, agentServer, worker }); + await gateway.start(); + try { + await body(rig); + } finally { + await gateway.stop(); + await model.close(); + } +} + +/** + * A model that reads the Signals, touches the Workspace, and then answers. + * + * Every one of those is a real tool call: `pi` runs `curl` and `cat` and `printf` in the + * container, against the real Agent server and the real bind mount. Which turn it is comes + * from the conversation the model was handed, so one function scripts every Run. + */ +function readsAndWrites(request: ModelRequest): ModelReply { + switch (assistantMessages(request)) { + case 0: + return { bash: `curl -s "${agentServerIn(request)}/signals?limit=5"` }; + case 1: + return { + bash: `cat ${insideWorkspace}/${handlerNote} && printf '%s' 'written by the agent' > ${insideWorkspace}/${agentNote}`, + }; + default: + return { say: "I read the Signals and left a note." }; + } +} + +/** + * The Agent server's address, as the agent was told it. + * + * Read out of the system prompt rather than passed in from the test, because that is the + * only channel the real thing has: `pi` ships no HTTP client, so the Operator's own + * `AGENTS.md` plus `curl` *is* the binding. `pi` discovered that file in its working + * directory and put it here with no flag from us, and a Run where that failed makes this + * throw rather than quietly passing. + */ +function agentServerIn(request: ModelRequest): string { + const found = request.system.match(new RegExp(`http://${hostFromContainer}:\\d+`)); + assert.ok( + found !== null, + `the agent was never told where the Gateway is; its system prompt ends: ${request.system.slice(-400)}`, + ); + return found[0]; +} + +/** The Prompt whose Run the model refuses, so it fails inside the Agent Implementation. */ +const doomed = "This Prompt cannot work."; + +/** The whole test's model, in one function: the doomed Prompt is refused, the rest work. */ +function scripted(request: ModelRequest): ModelReply { + if (request.texts.some((text) => text.includes(doomed))) { + return { refuse: { status: 400, message: "this deployment has no model" } }; + } + return readsAndWrites(request); +} + +describe("a real pi behind a real listener", { skip }, () => { + it("creates a Session at the path it is given, resumes it, and shares the Workspace", async (t) => { + await withGateway(t, scripted, async (rig) => { + // Nothing has run yet, and nothing of the framework's is in either directory: no + // startup step wrote there and none ever will. + assert.deepEqual(await rig.sessions(), []); + + await writeFile(path.join(rig.workspace, handlerNote), "written by a Signal Handler", "utf8"); + + const first = await rig.ask({ session: "user_42", text: "This is the first Prompt." }); + const second = await rig.ask({ session: "user_42", text: "This is the second Prompt." }); + const fresh = await rig.ask({ session: null, text: "This is a one-off Prompt." }); + + // Each Signal produced an actual agent Run, recorded with its true outcome. The model + // it talked to is the one `settings.json` made the default, since no flag named it. + for (const [label, signalId] of [ + ["the first", first], + ["the second", second], + ] as const) { + const rows = await rig.runsOf(signalId); + assert.deepEqual( + rows.map(({ session, state, error }) => ({ session, state, error })), + [{ session: "user_42", state: "done", error: null }], + `${label} Signal should have one Run, done`, + ); + } + + // The Handler that asked for a fresh Session, which is the Worker's to name. + const [freshRun] = await rig.runsOf(fresh); + assert.ok(freshRun !== undefined, "the fresh Signal should have one Run"); + assert.deepEqual( + { session: freshRun.session, state: freshRun.state, error: freshRun.error }, + { session: `run_${freshRun.id}`, state: "done", error: null }, + ); + + // **The claim the whole design rests on.** `switch_session` was given a path that did + // not exist and `pi` made a Session there — at exactly that path, one file per + // Session, in the directory the Operator mounted and the Gateway names. Nothing is + // nested, nothing is named after a working directory, and the framework created no + // file: the two Runs of `user_42` produced one Session, and the fresh one is findable + // on disk from the Run's own row. + assert.deepEqual(await rig.sessions(), [`run_${freshRun.id}.jsonl`, "user_42.jsonl"].sort()); + + // And the other half of it: the second Run's `pi` found the Session file the first + // one left, parsed it, and sent its messages to the model. Two connections, two + // processes, one conversation. + const resumed = rig.model.requests.find( + (request) => + request.texts.includes("This is the second Prompt.") && + request.texts.includes("This is the first Prompt."), + ); + assert.ok(resumed !== undefined, "the second Run should carry the first Run's conversation"); + + // The agent read prior Signals over the Agent server, from inside its container, and + // got real records back — in every Run, found by its own Prompt. + const toolResults = rig.model.requests.flatMap((request) => request.texts); + for (const asked of [ + "This is the first Prompt.", + "This is the second Prompt.", + "This is a one-off Prompt.", + ]) { + assert.ok( + rig.model.requests.some( + (request) => + request.texts.includes(asked) && + request.texts.some((text) => text.includes('"signals"')), + ), + `the Run of ${JSON.stringify(asked)} should have read the Signals over HTTP`, + ); + } + assert.ok( + toolResults.some( + (text) => text.includes('"signals"') && text.includes("This is the first Prompt."), + ), + "a Run should have read a prior Signal's payload back", + ); + + // The Operator's `AGENTS.md` reached the agent, with the address they wrote in it, and + // `pi` found it in its working directory with no flag from the framework. + const system = rig.model.requests[0]?.system ?? ""; + const placed = await readFile(path.join(rig.workspace, agentsFileName), "utf8"); + assert.ok( + system.includes(placed.trim()), + `the file the Operator placed should be in the system prompt verbatim: ${system.slice(-600)}`, + ); + assert.ok(system.includes(rig.agentServerUrl)); + + // The Workspace both ways: the agent read the Handler's file, and what the agent wrote + // is a file this process can read and then edit. + assert.ok( + toolResults.some((text) => text.includes("written by a Signal Handler")), + "the agent should have read the file a Signal Handler left it", + ); + const written = path.join(rig.workspace, agentNote); + assert.equal(await readFile(written, "utf8"), "written by the agent"); + + // And the Run whose model refuses it: recorded failed, with the provider's own words, + // out of an agent that settled and said nothing else about it. + const [failed] = await rig.runsOf(await rig.ask({ session: "user_7", text: doomed })); + assert.equal(failed?.state, "failed"); + assert.match(failed?.error ?? "", /this deployment has no model/); + assert.match(failed?.error ?? "", /stopReason/); + + // And a Session name outside `pi`'s grammar, which the framework now refuses itself + // because a Session is addressed by path and `pi` would open any path it is handed. + // That Run alone fails, it names the Session, and nothing reached the Agent Instance: + // the directory holds exactly what the Runs that really happened put there. + const rejectedName = "../escape"; + const [rejected] = await rig.runsOf( + await rig.ask({ session: rejectedName, text: "This name is not one pi accepts." }), + ); + assert.equal(rejected?.state, "failed"); + assert.equal(rejected?.session, rejectedName); + assert.match(rejected?.error ?? "", /is not a name pi will accept/); + assert.deepEqual( + await rig.sessions(), + [`run_${freshRun.id}.jsonl`, "user_42.jsonl", "user_7.jsonl"].sort(), + "a Session name the framework refused should have left nothing behind", + ); + }); + }); +}); diff --git a/src/pi/container.test.ts b/src/pi/container.test.ts deleted file mode 100644 index 7f258d2..0000000 --- a/src/pi/container.test.ts +++ /dev/null @@ -1,741 +0,0 @@ -/** - * `pi` in a real container, driven by a real Signal Worker: the one opt-in end-to-end test. - * - * One test path, deliberately. It is slow and it needs Docker and the network, so it - * earns its place by proving the things nothing faster can: - * - * - the **mounts resolve** — the agent sees the Workspace the Gateway writes into, and - * the Gateway sees what the agent wrote back - * - the **user ids match**, so a file the agent created is one a Signal Handler can - * read and edit - * - a **named Session resumes** across two Runs, which is a claim about a transcript on - * disk being found and parsed by a second container — under the **mounted agent - * directory**, in a directory the Gateway never created and never named. No - * `--session-dir` is passed at all: `pi` resolves where its transcripts go, and a - * Session survives because the agent directory is mounted and for no other reason. - * Nothing says so if it is not, which is the one accepted failure that does not even - * fail — the agent merely forgets - * - `pi` **discovers an `AGENTS.md` the Operator placed in the Workspace**, with no flag - * from the framework and nothing of the framework's in the file, and the agent - * **reaches the Agent server** at the address that file names — over HTTP from inside - * its container, with `curl` from its own shell tool and no credential. - * This is the whole replacement for the instructions file the framework used to write - * before every Run, end to end - * - a **Session name `pi` refuses** fails that Run and no other, with `pi`'s own - * message in the Run's `error` — the framework carries no copy of that grammar and - * checks nothing, so this is the only place the claim can be tested at all - * - a **mount source that is not there is refused by the daemon**, naming the path, and - * nothing is invented on the Operator's disk. This is what the deleted startup mount - * check was replaced by, so it is the one claim about mounting with nothing else - * behind it (`--mount type=bind`, never `-v`) - * - a **read-only file entry nested inside a read-write directory entry** is genuinely - * unwritable from inside the container while the directory around it still writes, - * which is what lets a file the agent must not change be one it cannot change - * - * What is real here and what is not, exactly: the container, the `pi` binary in it, the - * mounts, the files the Operator placed in them, the Prompt on a pipe, the JSONL that - * comes back, the Agent server, the Signal Worker, and PostgreSQL. **Only the model is - * stubbed** — a scripted OpenAI-compatible server on this host, which is what makes the - * test deterministic and what makes it need no provider credentials. The consequence, stated - * rather than hidden: this proves the framework's half of a Run end to end, and says - * nothing about whether a real model would choose to call the Agent server unprompted. - * - * This test is also the Operator, and doing that job is most of what it sets up: it - * creates the two directories, writes `models.json` into the agent's own directory to - * describe the scripted model, and writes the two files it mounts read-only — the - * `settings.json` that makes that model the default, and the `AGENTS.md` that carries the - * Agent server's address. The framework writes none of it, has never read any of it, and - * no longer passes a `--model` flag either — the default in that `settings.json` is the - * whole of how a Run knows what to talk to, and a `settings.json` the agent cannot write - * is what an Operator should reach for, since `pi` takes a lock beside it even to read it. - * - * Note where the scripted model still learns things for itself: the address it tells the - * agent to `curl` is read **out of the system prompt it was given**, which is where `pi` - * puts a context file it discovered. So a Run whose `AGENTS.md` did not reach the - * container has no URL to find and fails here rather than passing quietly. - * - * Skipped unless `CONCORDE_CONTAINER_TESTS` is set — see `../test-support/docker.ts` for why. - */ - -import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { after, before, describe, it, type TestContext } from "node:test"; -import { eq } from "drizzle-orm"; -import Fastify from "fastify"; -import type { Mount } from "../agent-container/index.ts"; -import { openDb } from "../db/index.ts"; -import { createBareGateway, serverComponent } from "../gateway/components.ts"; -import type { SignalHandler } from "../signals/handlers.ts"; -import type { Runtime } from "../signals/runtime.ts"; -import * as signalsSchema from "../signals/schema/index.ts"; -import { runs } from "../signals/schema/index.ts"; -import { createSignalWorker } from "../signals/worker.ts"; -import { applySchema } from "../test-support/apply-schema.ts"; -import { createTestDatabase, type TestDatabase } from "../test-support/database.ts"; -import { - addHostToGateway, - buildPiImage, - hostFromContainer, - reservePort, - skipContainerTests, -} from "../test-support/docker.ts"; -import { - assistantMessages, - type MockModel, - type ModelReply, - type ModelRequest, - startMockModel, -} from "../test-support/mock-model.ts"; -import { waitUntil } from "../test-support/wait.ts"; -import { createPiRuntime } from "./runtime.ts"; - -const skip = await skipContainerTests(); - -/** What a Signal carries: the Session to run in, and what to say. */ -type Ask = { readonly session: string | null; readonly text: string }; - -const asking: SignalHandler = { - handle: (signal) => [{ session: signal.payload.session, text: signal.payload.text }], -}; - -/** The file a Signal Handler leaves in the Workspace for the agent to read. */ -const handlerNote = "handler-note.txt"; -/** The file the agent writes in the Workspace for the Gateway to read. */ -const agentNote = "agent-note.txt"; -/** - * What `pi` looks for in its working directory and its ancestors, and the name an - * Operator's instructions file therefore takes inside the container. - * - * The framework knows nothing about it: no flag names it, and this constant exists in a - * test rather than in `src/pi/` because `pi`'s own discovery is the whole mechanism. - */ -const agentsFileName = "AGENTS.md"; -/** - * Where `pi` puts its transcripts, relative to the agent directory it was given. - * - * The framework does not know this and does not say it: no `--session-dir` is passed, so - * `pi` falls back to `/sessions` and resolves the rest itself. Observed in - * pi@0.83.0: it puts one directory per *working directory* under there, named after the - * path — `--workspace--` for `/workspace` — and one `.jsonl` per Session inside it. So - * the flat directory whose cost is recorded above is flat per Workspace, and since an - * image declares one `WORKDIR` that is one directory for the whole deployment. - * - * Written down here, in a test, because a test is the only thing entitled to know it. - */ -const sessionsUnderAgentDir = "sessions"; - -let image: string; -/** - * The throwaway database, and the Db that will drop it — which is deliberately **not** - * the Gateway's. - * - * The Gateway's Db is a Component, so the record stops it, and a pool cannot be ended - * twice. This one is opened by `createTestDatabase`, is never queried on, and exists to - * hand the database back at the end. - */ -let database: TestDatabase; - -before(async () => { - if (skip !== false) return; - image = await buildPiImage(); - database = await createTestDatabase("pi_container"); -}); - -after(async () => { - if (skip !== false) return; - await database.drop(); -}); - -/** Everything one test needs standing up around the Runtime. */ -type Rig = { - readonly runtime: Runtime; - readonly model: MockModel; - /** Where the Operator's own instructions file told the agent to reach the Gateway. */ - readonly agentServerUrl: string; - readonly workspace: string; - readonly agentDir: string; - readonly agentsFile: string; - /** The transcripts `pi` has written under the agent directory, sorted. */ - transcripts(): Promise; - /** Emits a Signal and resolves when its Runs have finished. */ - ask(payload: Ask): Promise; - /** - * Every Run recorded for a Signal. - * - * The id comes back because a fresh Session is named after it, so it is what a test - * checks the name against rather than predicting one. - */ - runsOf( - signalId: string, - ): Promise<{ id: string; session: string | null; state: string; error: string | null }[]>; -}; - -/** Where a test wants the two mounts pointed, given where they really are. */ -type Paths = { - /** - * The Runtime Directory: what a Mount Table's `runtimeDir` is given, and what every entry's - * path below is written relative to. - * - * This process and the daemon are the same host here, so the one namespace the table has is - * also the one this file creates directories in. A containerised Gateway is what makes those - * two part company, and nothing on this side would notice. - */ - readonly root: string; - readonly workspace: string; - readonly agentDir: string; - /** - * The Operator's instructions file, on this side and **outside the Workspace**. - * - * Outside it because that is the arrangement worth demonstrating: a file under version - * control somewhere else, mounted into a Workspace the agent otherwise writes. - */ - readonly agentsFile: string; - /** The Operator's `settings.json`, likewise on this side and outside the mount it lands in. */ - readonly settingsFile: string; -}; - -/** Two fresh directory names under a temporary root, cleaned up with the test. */ -async function temporaryPaths(t: TestContext): Promise { - const root = await mkdtemp(path.join(tmpdir(), "concorde-container-")); - t.after(() => rm(root, { recursive: true, force: true })); - return { - root, - workspace: path.join(root, "workspace"), - agentDir: path.join(root, "agent"), - agentsFile: path.join(root, agentsFileName), - settingsFile: path.join(root, "settings.json"), - }; -} - -/** - * The two directories an Operator creates, since the framework creates none. - * - * Two rather than three: there is no Session root any more, so the deployment creates one - * fewer directory and names no Session path anywhere. - */ -function directoriesOf(paths: Paths): readonly string[] { - return [paths.workspace, paths.agentDir]; -} - -/** Every transcript `pi` has written under an agent directory, sorted; none is fine. */ -async function transcriptsIn(agentDir: string): Promise { - const root = path.join(agentDir, sessionsUnderAgentDir); - const found = await readdir(root, { recursive: true }).catch(() => []); - return found.filter((entry) => entry.endsWith(".jsonl")).sort(); -} - -/** The scripted model, as `models.json` describes a provider to `pi`. */ -function mockProvider(baseUrl: string): Record { - return { - providers: { - mock: { - baseUrl, - api: "openai-completions", - apiKey: "not-a-real-key", - compat: { supportsDeveloperRole: false, supportsReasoningEffort: false }, - models: [{ id: "mock-model", name: "Mock", contextWindow: 128_000, maxTokens: 4096 }], - }, - }, - }; -} - -/** - * `models.json`, placed by the Operator in the directory they mount as the agent's. - * - * The framework used to write this file before every Run out of an opaque field of its - * configuration. It carries no such field now, and `pi` reads the file itself out of - * `PI_CODING_AGENT_DIR`. - */ -async function placeModels(paths: Paths, baseUrl: string): Promise { - const file = path.join(paths.agentDir, "models.json"); - await writeFile(file, `${JSON.stringify(mockProvider(baseUrl), null, 2)}\n`, "utf8"); -} - -/** - * `settings.json`, which is where the model and the provider live now. - * - * They used to be two fields of the Runtime's configuration and two flags on the command - * line. Verified in pi@0.83.0: `settings.json` carries `defaultModel` and - * `defaultProvider`, and `pi` falls back to them when no flag names either — so this file - * is the whole of how the Run below knows what to talk to, and a Run that reaches the - * scripted model at all is the proof that it works. - * - * Written **outside** the agent directory and mounted read-only into it, which is what - * every example does and is the arrangement worth proving: `pi` takes a lock beside - * this file even to read it, so a Run against a settings file it cannot write is a claim - * that only a real container settles. - */ -async function placeSettings(paths: Paths): Promise { - const settings = { defaultModel: "mock-model", defaultProvider: "mock" }; - await writeFile(paths.settingsFile, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); -} - -/** That file as an entry: read-only, inside the agent directory, from outside it. */ -function settingsEntry(paths: Paths): Mount { - return { - agentPath: "/home/agent/.pi/agent/settings.json", - path: path.relative(paths.root, paths.settingsFile), - readOnly: true, - }; -} - -/** - * The instructions file, as an Operator writes one: their own words, then the address. - * - * Nothing of the framework's is in it, and nothing of the framework's produced it — an - * Operator writes this text themselves, as each example's `AGENTS.md` does, and a copy of it - * can go stale when the Signal Worker's routes change. - */ -async function placeInstructions(paths: Paths, agentServerUrl: string): Promise { - await writeFile( - paths.agentsFile, - [ - "# You are the shared agent of a test", - "", - "The Gateway exposes an HTTP API to you and to nothing else, at", - `\`${agentServerUrl}\`. Reach it with \`curl\` from your shell tool. It takes no`, - 'credential. `GET /signals?limit=` answers `{ "signals": [...] }`, newest first.', - "", - ].join("\n"), - "utf8", - ); -} - -/** - * The Runtime under test, however a case wants its mounts pointed. - * - * An image and what the container sees, and nothing else: no model, no provider, and no - * path inside the container. The two the image declares — its `WORKDIR` and its - * `PI_CODING_AGENT_DIR` — are in `../test-support/pi-image/Dockerfile`, and the two mount - * targets below have to agree with them by hand, because nothing checks that they do. - */ -function runtimeOn(paths: Paths, extraEntries: readonly Mount[] = []): Runtime { - return createPiRuntime({ - image, - // The extra entries go last only for readability: the container runtime sorts bind - // mounts by destination depth itself, which is what makes a nested entry work at all. - mounts: { - entries: [ - { agentPath: "/workspace", path: path.relative(paths.root, paths.workspace) }, - { agentPath: "/home/agent/.pi/agent", path: path.relative(paths.root, paths.agentDir) }, - ...extraEntries, - ], - runtimeDir: paths.root, - }, - extraArgs: [addHostToGateway], - }); -} - -/** - * The instructions file as an entry: read-only, inside the Workspace, from outside it. - * - * The Workspace is writable by the agent, so an instructions file simply placed in it is - * one a successful injection can rewrite for the next Run. `readOnly` is what makes the - * property structural, and a single-file entry is what leaves the directory around it - * writable. - */ -function instructionsEntry(paths: Paths): Mount { - return { - agentPath: `/workspace/${agentsFileName}`, - path: path.relative(paths.root, paths.agentsFile), - readOnly: true, - }; -} - -/** - * Stands up a whole Gateway around one `pi` Runtime and hands it to `body`. - * - * One end-to-end path, so this is used once: a Signal Worker, a real database, the - * Agent server with the Worker's routes on it, and the scripted model. Construct and start, - * as every entry point does it — but by hand through `createBareGateway`, - * because what this file needs is a subset of the infrastructure and none of the four parts - * `createGateway` hands the Operator through `extend`. Nothing here is about the - * assembly; the subject is a real container running a real `pi`. - * - * What it does **not** prove is the ordering: it stops nothing mid-Run, so that the - * Agent server must outlive the Signal Worker is not asserted here. - * `gateway.test.ts` is where it is, with a fake Runtime parked in flight while the - * Gateway shuts down around it. - */ -async function withGateway( - t: TestContext, - reply: (request: ModelRequest, at: number) => ModelReply, - body: (rig: Rig) => Promise, -): Promise { - const paths = await temporaryPaths(t); - // Both, as an Operator's entry point does it, because the framework creates no directory - // anywhere and the daemon refuses a bind source that is not there rather than - // inventing one — which is the case the test below this one walks into. - await Promise.all(directoriesOf(paths).map((directory) => mkdir(directory, { recursive: true }))); - - // The port before the server, because the agent is told where the Agent server is in a - // file written now, and it has to name the port the container will connect to. - const port = await reservePort(); - const agentServerUrl = `http://${hostFromContainer}:${port}`; - // A bare Fastify instance in a Component, as an Operator's entry point constructs it: - // the framework ships no server and defaults no address, so both are stated here. - // Bound beyond loopback on purpose — under a plain Linux daemon a container cannot - // reach a loopback-bound server at all, and this test has to pass on both. Nothing - // warns about it and nothing inspects what was bound. - const agentServer = serverComponent(Fastify(), { port, host: "0.0.0.0" }); - const model = await startMockModel(reply); - - // The Operator's three files, every one of them something the framework used to write - // or to carry and now knows nothing about: how to reach the model, which model to use, - // and how to reach the Gateway. - await placeModels(paths, model.baseUrl); - await placeSettings(paths); - await placeInstructions(paths, agentServerUrl); - - const runtime = runtimeOn(paths, [instructionsEntry(paths), settingsEntry(paths)]); - - // The Gateway's own Db, on the same database. The Worker's tables are pushed here rather - // than by constructing it, because the framework applies no DDL of its own; - // handing the Worker the server is what registers its routes, so nothing here calls - // `register`. One push per database, and this function runs once. - const db = openDb(database.url); - const worker = createSignalWorker({ db, runtime, handlers: { ask: asking }, agentServer }); - await applySchema(db, signalsSchema); - - const handle = db.handle({ runs }); - const rig: Rig = { - runtime, - model, - agentServerUrl, - ...paths, - transcripts: () => transcriptsIn(paths.agentDir), - async ask(payload) { - const id = await db.tx((tx) => worker.emit(tx, { kind: "ask", payload })); - await waitUntil( - `the Signal ${id} has been processed`, - async () => { - const [row] = await handle.select().from(runs).where(eq(runs.signalId, id)); - return row !== undefined && row.state !== "pending" && row.state !== "running"; - }, - // A container start, an image lookup and two model round trips, on whatever - // machine this is. The framework itself has no timeouts; this one is - // the test's, so a wedged Run fails the suite rather than hanging it. - 180_000, - ); - return id; - }, - async runsOf(signalId) { - const rows = await handle.select().from(runs).where(eq(runs.signalId, signalId)); - return rows.map((row) => ({ - id: row.id, - session: row.session, - state: row.state, - error: row.error, - })); - }, - }; - - // An example deployment's order, minus the three parts this test has no use for: the - // Db first so it stops last, the Agent server before the Worker so it closes after the - // drain, and `start` in one call that binds the port the agent was already told about. - const gateway = createBareGateway({ db, agentServer, worker }); - await gateway.start(); - try { - await body(rig); - } finally { - await gateway.stop(); - await model.close(); - } -} - -/** - * A model that reads the Signals, touches the Workspace, and then answers. - * - * Every one of those is a real tool call: `pi` runs `curl` and `cat` and `printf` in the - * container, against the real Agent server and the real bind mount. Which turn it is - * comes from the conversation the model was handed, so one function scripts every Run. - */ -function readsAndWrites(request: ModelRequest): ModelReply { - switch (assistantMessages(request)) { - case 0: - return { bash: `curl -s "${agentServerIn(request)}/signals?limit=5"` }; - case 1: - return { - bash: `cat /workspace/${handlerNote} && printf '%s' 'written by the agent' > /workspace/${agentNote}`, - }; - default: - return { say: "I read the Signals and left a note." }; - } -} - -/** - * The Agent server's address, as the agent was told it. - * - * Read out of the system prompt rather than passed in from the test, because that is the - * only channel the real thing has: `pi` ships no HTTP client, so the Operator's own - * `AGENTS.md` plus `curl` *is* the binding. `pi` discovered that file in its - * working directory and put it here with no flag from us, and a Run where that failed - * makes this throw rather than quietly passing. - */ -function agentServerIn(request: ModelRequest): string { - const found = request.system.match(new RegExp(`http://${hostFromContainer}:\\d+`)); - assert.ok( - found !== null, - `the agent was never told where the Gateway is; its system prompt ends: ${request.system.slice(-400)}`, - ); - return found[0]; -} - -/** The Prompt whose Run the model refuses, so it fails inside the Agent Implementation. */ -const doomed = "This Prompt cannot work."; - -/** - * The whole test's model, in one function. - * - * One script rather than one per case, because there is one end-to-end path: the Run of - * the doomed Prompt is refused and every other Run reads and writes. - */ -function scripted(request: ModelRequest): ModelReply { - if (request.texts.some((text) => text.includes(doomed))) { - return { refuse: { status: 400, message: "this deployment has no model" } }; - } - return readsAndWrites(request); -} - -describe("pi in a real container", { skip }, () => { - it("runs the agent, which reads prior Signals and shares the Workspace, and resumes a Session", async (t) => { - await withGateway(t, scripted, async (rig) => { - // Nothing has run yet, and nothing of the framework's is in the directory the - // Operator's Handlers share: no startup step wrote there and none ever will. - assert.deepEqual(await readdir(rig.workspace), []); - - await writeFile(path.join(rig.workspace, handlerNote), "written by a Signal Handler", "utf8"); - - const first = await rig.ask({ session: "user_42", text: "This is the first Prompt." }); - const second = await rig.ask({ session: "user_42", text: "This is the second Prompt." }); - const fresh = await rig.ask({ session: null, text: "This is a one-off Prompt." }); - - // A Signal produced an actual agent Run, recorded with its true outcome. The model - // it talked to is the one `settings.json` made the default, since no flag named it. - for (const [label, signalId] of [ - ["the first", first], - ["the second", second], - ] as const) { - const rows = await rig.runsOf(signalId); - assert.deepEqual( - rows.map(({ session, state, error }) => ({ session, state, error })), - [{ session: "user_42", state: "done", error: null }], - `${label} Signal should have one Run, done`, - ); - } - - // The Handler that asked for a fresh Session, which is the Worker's to name. The - // row says `run_` rather than `null`, so the Session an Operator has to - // go looking for is on the Run they are already looking at. - const [freshRun] = await rig.runsOf(fresh); - assert.ok(freshRun !== undefined, "the fresh Signal should have one Run"); - assert.deepEqual( - { session: freshRun.session, state: freshRun.state, error: freshRun.error }, - { session: `run_${freshRun.id}`, state: "done", error: null }, - ); - - // The agent read prior Signals over the Agent server, from inside its container, - // and got real records back — in every Run, found by its own Prompt. - const toolResults = rig.model.requests.flatMap((request) => request.texts); - for (const prompt of [ - "This is the first Prompt.", - "This is the second Prompt.", - "This is a one-off Prompt.", - ]) { - assert.ok( - rig.model.requests.some( - (request) => - request.texts.includes(prompt) && - request.texts.some((text) => text.includes('"signals"')), - ), - `the Run of ${JSON.stringify(prompt)} should have read the Signals over HTTP`, - ); - } - const readSignals = toolResults.filter((text) => text.includes('"signals"')); - assert.ok( - readSignals.some((text) => text.includes("This is the first Prompt.")), - `a Run should have read a prior Signal's payload back: ${readSignals[0]}`, - ); - - // The Operator's `AGENTS.md` reached the agent, with the address they wrote in it, - // and `pi` found it in its working directory with no flag from the framework — - // which is the whole of what replaced the file the framework used to write. - const system = rig.model.requests[0]?.system ?? ""; - const placed = await readFile(rig.agentsFile, "utf8"); - assert.ok( - system.includes(placed.trim()), - `the file the Operator placed should be in the system prompt verbatim: ${system.slice(-600)}`, - ); - assert.ok( - system.includes(rig.agentServerUrl), - "the address the agent was told should be the one the Operator wrote", - ); - - // The Session resumed: the second Run's container found the Session file the first - // one left, parsed it, and sent its messages to the model. - const resumed = rig.model.requests.find( - (request) => - request.texts.includes("This is the second Prompt.") && - request.texts.includes("This is the first Prompt."), - ); - assert.ok(resumed !== undefined, "the second Run should carry the first Run's conversation"); - - // And where the transcripts went, which is what the Session root was traded - // for: **under the mounted agent directory**, in a directory `pi` chose and - // created inside its own container. The framework passed no `--session-dir`, named - // no path, and created nothing; the agent directory is the Operator's, and mounting - // it is the whole of why any of this survived the `--rm`. - // - // One directory for the Workspace and one transcript per Session inside it, and - // every Run parses all of them: that is the cost of the framework holding no - // filesystem knowledge, and since the image declares one `WORKDIR` it is one - // directory for the whole deployment, growing without bound with nothing the - // Operator can do about it. - const transcripts = await rig.transcripts(); - assert.equal(transcripts.length, 2, `one transcript per Session: ${transcripts.join(", ")}`); - assert.ok( - transcripts.some((file) => file.includes("user_42")), - `the named Session should be one of them: ${transcripts.join(", ")}`, - ); - // And the other is the fresh one, findable on disk from the Run's own row. This is - // what naming a fresh Session buys and the reason it is not left ephemeral: the - // transcript of a Run nobody chose a Session for is still one an Operator can open, - // starting from the Run they were reading. - assert.ok( - transcripts.some((file) => file.includes(`run_${freshRun.id}`)), - `the fresh Session's transcript should be named after its Run: ${transcripts.join(", ")}`, - ); - - // The Workspace both ways: the agent read the Handler's file, and what the agent - // wrote is a file this process can read and then edit. - assert.ok( - toolResults.some((text) => text.includes("written by a Signal Handler")), - "the agent should have read the file a Signal Handler left it", - ); - const written = path.join(rig.workspace, agentNote); - assert.equal(await readFile(written, "utf8"), "written by the agent"); - await writeFile(written, "and edited by the Gateway", "utf8"); - assert.equal(await readFile(written, "utf8"), "and edited by the Gateway"); - - // And the Run whose model refuses it: recorded failed, with the provider's own - // words, out of a process that exited 0 (the first JSONL trap, through a real - // container this time rather than a captured stream). - const [failed] = await rig.runsOf(await rig.ask({ session: "user_7", text: doomed })); - assert.equal(failed?.state, "failed"); - assert.match(failed?.error ?? "", /this deployment has no model/); - assert.match(failed?.error ?? "", /stopReason/); - - // And a Session name `pi` will not have: nothing in the framework inspected it, - // so this is `pi`'s own refusal, reaching the Operator through the Run's `error` - // with the name they wrote in the Run's `session` beside it. The framework used - // to carry a transcription of that grammar and fail the whole Signal before any - // Run existed; this is the diagnostic that replaced it, and it cannot go stale - // when `pi` changes its mind. - const rejectedName = "user:42"; - const [rejected] = await rig.runsOf( - await rig.ask({ session: rejectedName, text: "This name is not one pi accepts." }), - ); - assert.equal(rejected?.state, "failed"); - assert.equal(rejected?.session, rejectedName); - // The clause that describes *this* refusal, not merely one of `pi`'s. - assert.match(rejected?.error ?? "", /Session id must .*only alphanumeric characters/); - // `pi` exits 1 without writing a line of JSONL, so the Runtime's own half of the - // message is there too — the exit code, which is what says the process refused - // rather than the model. - assert.match(rejected?.error ?? "", /exited with code 1/); - // It created nothing on the way out: the agent directory still holds one transcript - // per Session that actually ran. - assert.deepEqual( - (await rig.transcripts()).filter((entry) => entry.includes(":")), - [], - "a Session pi refused should have left no transcript behind", - ); - }); - }); - - it("is refused by the daemon when a mount source is not there, which names the path", async (t) => { - // The claim the deleted startup mount check was traded for, and the only place it can - // be made: `--mount type=bind` refuses a missing source, where `-v` would invent it - // as a `root`-owned directory and let the Run succeed against an empty Workspace. - // No Signal Worker and no Agent server here — the container never starts. - const paths = await temporaryPaths(t); - // Everything but the Workspace, which is the typo this is about. - await mkdir(paths.agentDir, { recursive: true }); - const runtime = runtimeOn(paths); - - const outcome = await runtime.run({ session: "user_42", text: "This will not start." }); - - assert.equal(outcome.ok, false); - const error = outcome.ok ? "" : outcome.error; - assert.match(error, /bind source path does not exist/); - assert.ok( - error.includes(paths.workspace), - `the daemon's refusal should name the path it could not find: ${error}`, - ); - // And nothing was created behind the Operator's back, which is the other half of - // what `-v` did and the reason a wrong path used to be silent. - await assert.rejects(() => stat(paths.workspace), /ENOENT/); - }); - - it("gives the agent a read-only file inside a directory it can still write", async (t) => { - // What replaces rewriting the agent's configuration before every Run: a file the - // agent must not change becomes one it *cannot* change, by construction and for free. - // The nesting is the part worth proving — a read-only directory would - // hold the same property and break every sibling operation `pi` needs. - const paths = await temporaryPaths(t); - await Promise.all( - directoriesOf(paths).map((directory) => mkdir(directory, { recursive: true })), - ); - const guarded = paths.agentsFile; - await writeFile(guarded, "the Operator's own words", "utf8"); - - const model = await startMockModel((request) => - assistantMessages(request) === 0 - ? { - bash: [ - `cat /workspace/${agentsFileName}`, - `(printf 'overwritten by the agent' > /workspace/${agentsFileName}) 2>&1 || true`, - `printf 'written by the agent' > /workspace/${agentNote}`, - // The lock directory `pi` needs beside a settings file even to read one: - // a sibling of the read-only file, in the read-write directory around it. - `mkdir /workspace/${agentsFileName}.lock && echo made-the-lock-directory`, - ].join("; "), - } - : { say: "I could read it and I could not write it." }, - ); - try { - await placeModels(paths, model.baseUrl); - await placeSettings(paths); - const runtime = runtimeOn(paths, [instructionsEntry(paths), settingsEntry(paths)]); - - const outcome = await runtime.run({ session: "readonly", text: "Try to write it." }); - assert.deepEqual(outcome, { ok: true }, "being denied a write must not fail the Run"); - - const seen = model.requests.flatMap((request) => request.texts); - assert.ok( - seen.some((text) => text.includes("the Operator's own words")), - `the agent should have read the Gateway's content: ${seen.join(" | ")}`, - ); - assert.ok( - seen.some((text) => /Read-only file system/i.test(text)), - `the agent's write should have been denied by the kernel: ${seen.join(" | ")}`, - ); - assert.ok( - seen.some((text) => text.includes("made-the-lock-directory")), - `a sibling of the read-only file should still be creatable: ${seen.join(" | ")}`, - ); - // The two claims as this process sees them: the guarded file is untouched, and the - // directory around it took the agent's writes. - assert.equal(await readFile(guarded, "utf8"), "the Operator's own words"); - assert.equal( - await readFile(path.join(paths.workspace, agentNote), "utf8"), - "written by the agent", - ); - await stat(path.join(paths.workspace, `${agentsFileName}.lock`)); - } finally { - await model.close(); - } - }); -}); diff --git a/src/pi/framing.test.ts b/src/pi/framing.test.ts new file mode 100644 index 0000000..898f817 --- /dev/null +++ b/src/pi/framing.test.ts @@ -0,0 +1,140 @@ +/** + * The one module that turns bytes into records, tested as itself. + * + * `./output.test.ts` exercises the same code through the outcome reader, over captured + * streams, and that is where the traps about what `pi` emits live. What is left here is + * the framing rule on its own: what counts as a line, what a line that is not a record + * becomes, and what happens to the tail of a stream that stopped in the middle of one. + * + * The rule is `pi`'s, written for clients in its `docs/rpc.md`: split on LF and on nothing + * else, and accept an optional `\r`. `node:readline` is not protocol-compliant for it, and + * the case that proves so is in `./output.test.ts` because it needs a record with the + * agent's own text in it. + */ + +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { type Framed, framedRecords } from "./framing.ts"; + +/** + * The two characters `node:readline` splits on and JSON does not. Written as escapes rather + * than literally, because a literal one looks like nothing at all in a source file and would + * be lost to the next person who touched the line — or to the formatter. + */ +const lineSeparator = "\u2028"; +const paragraphSeparator = "\u2029"; + +/** `text` in chunks of `size` bytes, the way a socket delivers it. */ +function chunks(text: string, size = 4096): AsyncIterable { + const bytes = Buffer.from(text, "utf8"); + return (async function* () { + for (let at = 0; at < bytes.length; at += size) yield bytes.subarray(at, at + size); + })(); +} + +/** Everything `text` frames into. */ +async function framingOf(text: string, size = 4096): Promise { + const found: Framed[] = []; + for await (const framed of framedRecords(chunks(text, size))) found.push(framed); + return found; +} + +describe("what counts as a line", () => { + it("is LF, however many records a chunk holds and however few", async () => { + const text = '{"type":"a"}\n{"type":"b"}\n{"type":"c"}\n'; + + for (const size of [1, 2, 5, 13, 4096]) { + assert.deepEqual( + (await framingOf(text, size)).map((framed) => + framed.kind === "record" ? framed.record.type : framed.kind, + ), + ["a", "b", "c"], + `chunks of ${size} bytes`, + ); + } + }); + + it("tolerates a CR before it, which is what pi asks a client to accept", async () => { + assert.deepEqual(await framingOf('{"type":"a"}\r\n'), [ + { kind: "record", record: { type: "a" } }, + ]); + }); + + it("is not U+2028 or U+2029, which is the whole reason this module exists", async () => { + // Both are legal inside a JSON string and `JSON.stringify` leaves them literal, so a + // reader that split on them would tear this one record into three malformed halves. + const record = { + type: "message_end", + text: `before${lineSeparator}between${paragraphSeparator}after`, + }; + + assert.deepEqual(await framingOf(`${JSON.stringify(record)}\n`), [{ kind: "record", record }]); + }); + + it("is not a blank line, which every stream ending in an LF leaves behind", async () => { + assert.deepEqual(await framingOf('\n\n{"type":"a"}\n\n'), [ + { kind: "record", record: { type: "a" } }, + ]); + }); + + it("survives a multi-byte character split across two chunks", async () => { + // U+2028 is three bytes and the emoji is four, so a one-byte chunk size cuts both. A + // per-chunk `toString()` would produce U+FFFD and a record that no longer parses. + const record = { type: "message_end", text: `\u{1f642} ok${lineSeparator}still ok` }; + + assert.deepEqual(await framingOf(`${JSON.stringify(record)}\n`, 1), [ + { kind: "record", record }, + ]); + }); +}); + +describe("a line that is not a record", () => { + it("is yielded rather than thrown, with which of the three it is", async () => { + // A throw here would be a Run that fails with a stack trace instead of a sentence, and + // the three are told apart because they send an Operator to different places. + assert.deepEqual(await framingOf("EADDRINUSE: something else is on this port\n"), [ + { + kind: "unreadable", + line: "EADDRINUSE: something else is on this port", + why: "it is not JSON", + }, + ]); + assert.deepEqual(await framingOf("[1,2,3]\n"), [ + { kind: "unreadable", line: "[1,2,3]", why: "it is JSON but not an object" }, + ]); + assert.deepEqual(await framingOf('{"notAType":true}\n'), [ + { kind: "unreadable", line: '{"notAType":true}', why: "it has no type field" }, + ]); + }); + + it("does not stop the ones after it, which is the caller's to decide about", async () => { + const framed = await framingOf('nonsense\n{"type":"agent_settled"}\n'); + + assert.deepEqual( + framed.map((one) => one.kind), + ["unreadable", "record"], + ); + }); +}); + +describe("a stream that ended inside a record", () => { + it("says so, rather than reporting the half it got or nothing at all", async () => { + // Its own kind and not a bad line: the stream **ended**, which is a connection that + // went away rather than something wrong with what was written. + const framed = await framingOf('{"type":"a"}\n{"type":"b"'); + + assert.deepEqual(framed, [ + { kind: "record", record: { type: "a" } }, + { kind: "truncated", line: '{"type":"b"' }, + ]); + }); + + it("says nothing when the stream merely ended, however it was chunked", async () => { + for (const size of [1, 3, 4096]) { + assert.deepEqual(await framingOf('{"type":"a"}\n', size), [ + { kind: "record", record: { type: "a" } }, + ]); + } + assert.deepEqual(await framingOf(""), []); + }); +}); diff --git a/src/pi/framing.ts b/src/pi/framing.ts new file mode 100644 index 0000000..938411a --- /dev/null +++ b/src/pi/framing.ts @@ -0,0 +1,99 @@ +/** + * The one place a byte ever becomes a record, and the reason it is one place. + * + * Framing is strictly LF, and **nothing in this package may reach for `node:readline`**. That + * splits on U+2028 and U+2029 as well; both are legal inside a JSON string and `JSON.stringify` + * emits them literally, so a record carrying either arrives as two malformed halves. `pi`'s own + * `docs/rpc.md` says this about writing a client, in those words, and it is the kind of rule that + * survives only by having a single module to live in: a second reader written next year would be + * written with `createInterface`, because that is what a line looks like in Node. + * + * A trailing `\r` is stripped, which is the other half of the same page: the protocol is LF-framed + * and a client should accept `\r\n` anyway. Nothing observed has written one, and accepting it + * costs a line. + * + * Reading is total. A line that is not a record is **yielded** as one that could not be read rather + * than thrown, because the caller is deciding the outcome of a Run and a throw there is a Run that + * fails with a stack trace instead of a sentence. The three unreadable shapes are told apart for + * the same reason: `it is not JSON` and `it has no type field` send an Operator to different + * places, one of them being something else on the Agent Instance's stdout. + */ + +/** + * A record as the RPC channel writes it: a `type` and whatever else that type carries. + * + * Nothing here knows which types exist. `switch_session` responses, `agent_settled` and an + * extension's UI request are all this shape, and what each one means is the caller's to decide. + */ +export type PiRecord = { readonly type: string } & Readonly>; + +/** + * One line of the channel, read. + * + * `truncated` is not `unreadable` with a different message: it says the stream **ended** inside a + * record, which is a dropped connection rather than something wrong with what was written. The two + * end a Run with different sentences and an Operator looks in different places for each. + */ +export type Framed = + | { readonly kind: "record"; readonly record: PiRecord } + | { readonly kind: "unreadable"; readonly line: string; readonly why: string } + | { readonly kind: "truncated"; readonly line: string }; + +/** + * Cuts a stream of bytes into records, on LF and on nothing else. + * + * The source is raw chunks rather than decoded text, a chunk boundary falling wherever the + * operating system puts it, including inside a multi-byte character: `stream: true` is what makes + * such a character survive, where a per-chunk `toString()` would produce U+FFFD and a record that + * no longer parses. + * + * Lazy, and that is load-bearing rather than tidy. A Run is over at `agent_settled`, and the caller + * stops pulling there; a reader that drained to the end of the stream instead would wait for the + * Agent Instance to close a connection it has no reason to close. + */ +export async function* framedRecords(source: AsyncIterable): AsyncGenerator { + const decoder = new TextDecoder("utf-8"); + let pending = ""; + + for await (const chunk of source) { + pending += decoder.decode(chunk, { stream: true }); + for (;;) { + const end = pending.indexOf("\n"); + if (end === -1) break; + const line = pending.slice(0, end); + pending = pending.slice(end + 1); + const framed = readLine(line); + if (framed !== undefined) yield framed; + } + } + // Whatever the decoder was holding back, which can only be the tail of a character that never + // arrived whole. It cannot contain an LF, and the check below is what reports it. + pending += decoder.decode(); + + // A trailing LF leaves an empty remainder behind, and every well-formed stream ends with one. + if (pending.trim() !== "") yield { kind: "truncated", line: pending }; +} + +/** One framed line as a record, or why it is not one, or nothing when it is blank. */ +function readLine(line: string): Framed | undefined { + // The protocol is LF-framed and a client accepts `\r\n`; see the file header. + const text = line.endsWith("\r") ? line.slice(0, -1) : line; + // Nothing observed writes a blank line, but a reader that failed on one would fail on a stream + // that merely ended politely. + if (text.trim() === "") return undefined; + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + return { kind: "unreadable", line: text, why: "it is not JSON" }; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return { kind: "unreadable", line: text, why: "it is JSON but not an object" }; + } + const fields = parsed as Record; + if (typeof fields.type !== "string") { + return { kind: "unreadable", line: text, why: "it has no type field" }; + } + return { kind: "record", record: fields as PiRecord }; +} diff --git a/src/pi/index.ts b/src/pi/index.ts index 8686a2f..0c7087e 100644 --- a/src/pi/index.ts +++ b/src/pi/index.ts @@ -1,30 +1,31 @@ /** - * The `pi` Agent Implementation drives the `pi` coding agent as the Signal Worker's Runtime. An - * Agent Implementation is the interchangeable agent program a Run happens in, and `pi` is the one - * this package adapts. + * The `pi` Agent Implementation, driven as an **Agent Instance** the Operator runs. * - * {@link createPiRuntime} is the whole of it for an Operator: hand it an Agent Container, and pass - * what comes back as the Signal Worker's `runtime`. {@link piRun} and {@link interpretPiOutput} are - * pure functions, exported to be called from a test and to be read. `piRun` holds everything - * specific to `pi` and nothing else does, so it is the entire size of the job for an author writing - * a second Agent Implementation. + * An Agent Implementation is the interchangeable agent program a Run happens in, and `pi` is the + * one this package adapts. The Gateway does not start it: an Operator runs `pi --mode rpc` behind a + * listener, in a container of their own, and {@link createPiRuntime} builds the Runtime the + * Signal Worker performs each Run through — one connection per Run, opened when a Prompt exists and + * closed when the agent has settled. * - * Nothing about a container is here. The Agent Container, the Mount Table, the argument assembly, - * the confinement flags, the process handling and the diagnosis appended to a failure are all on - * `@shutter-network/concorde/agent-container`, generic over which agent runs, so a second Agent - * Implementation takes them unchanged. Read that subpath for what an Agent Container declares: - * `createPiRuntime` takes one written exactly as it is written there. + * {@link PiInstance} is the whole of the configuration and it is three values: where the instance + * is, and where it keeps Sessions. There is no model, no provider, no image, no mount and no + * credential here, because none of that is the Gateway's any more. What `pi` reads on disk and what + * it is started with belong to the Operator's own compose file, and this subpath cannot refuse a + * deployment that got them wrong: that deployment is a Gateway which starts, serves, and then fails + * its Runs with whatever the agent says. * - * Nothing `pi`-shaped is here either, and there is no configuration type at all. The model and the - * provider are `defaultModel` and `defaultProvider` in a `settings.json` the Operator mounts. The - * working directory and the agent's own directory are `WORKDIR` and `PI_CODING_AGENT_DIR` in an - * image the Operator builds, no `pi` image being published. The Session directory is `pi`'s own to - * resolve. Nothing here writes a file or names a path, and so nothing here can refuse a deployment - * that is missing one: that deployment is a Gateway which starts, serves, and then fails its first - * Run permanently. + * Two things this subpath does refuse, and both are the Operator's mistake rather than the agent's. + * A `sessionsDir` that is relative or missing is refused at construction, where the Operator wrote + * it. A Session name outside `pi`'s own grammar fails that one Run, naming the Session: a Session is + * addressed by path over RPC and `pi` will open any path it is handed, so the grammar is carried + * here and a Handler's string can neither escape the directory nor reach the agent unchecked. + * + * An unreachable Agent Instance is a failed Run and never a boot failure, on the **Relay** + * precedent. There is no startup probe, and adding one would turn an outage in a part the Operator + * runs into a Gateway that will not start for any Party. * * @example - * A Gateway whose Runtime is `pi`, in a container the Operator declared. + * A Gateway whose Runtime is an Agent Instance on the agent network. * ```ts * import { readFileSync } from "node:fs"; * import { createGateway } from "@shutter-network/concorde/gateway"; @@ -32,22 +33,15 @@ * import { templateHandler } from "@shutter-network/concorde/signals"; * * const runtime = createPiRuntime({ - * image: "my-agent:1", - * networks: ["concorde_default"], - * // Only what is named here reaches the agent. None of the Gateway's own environment does. - * env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY ?? "" }, - * mounts: { - * runtimeDir: "/srv/concorde", - * entries: [ - * { agentPath: "/workspace", path: "workspace" }, - * { agentPath: "/workspace/AGENTS.md", path: "AGENTS.md", readOnly: true }, - * ], - * }, + * // The service the Operator's compose file runs `pi --mode rpc` in, and the port its + * // listener accepts on. Nothing of the agent's environment is named here. + * host: "agent", + * port: 4000, + * // As the Agent Instance sees it, not as this process does: the Gateway never opens it. + * // `/.jsonl` is the file one Session lives in. + * sessionsDir: "/sessions", * }); * - * // The command line, without starting a container: the one way to see the defaults applied. - * console.log(runtime.commandFor({ session: "notes", text: "say hello" }).redactedArgs); - * * const gateway = createGateway({ * databaseUrl: process.env.DATABASE_URL ?? "", * runtime, @@ -69,5 +63,5 @@ * @module */ -export { interpretPiOutput } from "./output.ts"; -export { createPiRuntime, piRun } from "./runtime.ts"; +export type { PiInstance } from "./runtime.ts"; +export { createPiRuntime } from "./runtime.ts"; diff --git a/src/pi/output.test.ts b/src/pi/output.test.ts index fc307d1..a184763 100644 --- a/src/pi/output.test.ts +++ b/src/pi/output.test.ts @@ -1,5 +1,5 @@ /** - * The Agent Implementation's JSONL output, and the three traps in reading it. + * The Agent Implementation's event stream, and the three traps in reading it. * * The fixtures under `./fixtures/` are **real** `pi --mode json` output, captured * from `@earendil-works/pi-coding-agent` 0.83.0 driven against a local @@ -10,9 +10,15 @@ * fixture invented from the documentation would pin our reading of the documentation * rather than the behaviour — `docs/json.md` does not even list `agent_settled`. * - * Every case here is a pure function over one of those streams. No Docker, no - * credentials, no network: that is the whole reason this ticket is separate from the - * one that starts a container. + * They were captured from `--mode json`, where the same events go to stdout, rather than + * over the RPC channel that carries them now. That is deliberate and it is what a fixture + * is for: the records are the agent's, the channel is only how they arrive, and a capture + * is worth having precisely because nobody can talk it into saying something convenient. + * + * Every case here is the framing and the reader together, over one of those streams, and + * nothing else: no socket, no Docker, no credentials, no network. The bytes go in as + * chunks the way a pipe or a socket delivers them, and what comes out is the outcome of a + * Run. */ import assert from "node:assert/strict"; @@ -21,7 +27,8 @@ import { createInterface } from "node:readline"; import { Readable } from "node:stream"; import { describe, it } from "node:test"; import type { RunOutcome } from "../signals/runtime.ts"; -import { interpretPiOutput } from "./output.ts"; +import { type Framed, framedRecords } from "./framing.ts"; +import { readOutcome } from "./output.ts"; /** * The two characters `node:readline` splits on and JSON does not. Written as escapes @@ -96,7 +103,7 @@ const session = "user_42"; /** What a Run of that Session would report for this output. */ async function outcomeOf(text: string, chunkSize = 4096): Promise { - return interpretPiOutput(chunks(text, chunkSize), session); + return readOutcome(framedRecords(chunks(text, chunkSize)), session); } /** The failure message, with an assertion that there was a failure at all. */ @@ -133,15 +140,43 @@ describe("a settled run", () => { assert.deepEqual(await outcomeOf(asStream(records)), { ok: true }); }); + + it("returns at the settle rather than reading to the end of the stream", async () => { + // Not a nicety. The records are the channel's, the connection stays open until the + // Runtime closes it, and the Agent Instance has no reason to hang up: a reader that + // drained to EOF would wait for a peer that is waiting for the next command. + const records = await recordsOf("settled-ok"); + const settleAt = records.findIndex((record) => record.type === "agent_settled"); + assert.notEqual(settleAt, -1); + // Three more records after it, which a reader that drained would go on to pull. + const trailing = [{ type: "turn_start" }, { type: "turn_end" }, { type: "agent_start" }]; + + let pulled = 0; + const framed = framedRecords(chunks(asStream([...records, ...trailing]), 4096)); + const counted: AsyncIterator = { + next() { + pulled += 1; + return framed.next(); + }, + }; + + assert.deepEqual(await readOutcome(counted, session), { ok: true }); + assert.equal( + pulled, + settleAt + 1, + "the reader should have pulled the settle and stopped there", + ); + }); }); /** - * **Trap 1.** `--mode json` exits 0 on model and API errors — only `mode: "text"` - * sets a non-zero exit, and it does so by inspecting the last assistant message - * itself. So the outcome is read from that message's `stopReason` and never from the - * exit code, and this function is not given one to be tempted by. + * **Trap 1.** A model error is announced nowhere but inside an assistant message. It + * was `--mode json` exiting 0 on model and API errors that made this a trap worth a + * name; over the RPC channel there is no exit status to be tempted by at all, since + * the agent's process is the Operator's and outlives the Run. Either way the outcome + * is read from the last assistant message's `stopReason` and from nothing else. */ -describe("a model error while the process exits zero", () => { +describe("a model error the agent reports as an ordinary settle", () => { it("is a failed Run carrying the error", async () => { const error = await failureOf(await fixture("model-error-exit-zero")); @@ -330,8 +365,10 @@ describe("output that cannot be read", () => { assert.match(await failureOf(asStream(records)), /type/); }); - it("fails the Run when there was no output at all", async () => { - assert.match(await failureOf(""), /no output/); + it("fails the Run when the agent said nothing at all", async () => { + // A Prompt accepted and then a connection that ended, which is the shape of an Agent + // Instance that went away between the acknowledgement and the first event. + assert.match(await failureOf(""), /said nothing at all/); }); it("fails the Run when it settled without the agent ever answering", async () => { @@ -358,7 +395,7 @@ describe("output that cannot be read", () => { // that did not carry it would have to be written deliberately. assert.equal( await failureOf(""), - `Session ${session} produced no output at all, so nothing says whether the Run happened`, + `Session ${session} said nothing at all after the Prompt was accepted, so nothing says whether the Run happened`, ); }); }); diff --git a/src/pi/output.ts b/src/pi/output.ts index bd12671..154071c 100644 --- a/src/pi/output.ts +++ b/src/pi/output.ts @@ -1,20 +1,21 @@ /** - * The highest-risk logic in the `pi` adapter, which is why it is a module of its own with no - * process in it. Three properties of `pi --mode json` each produce a plausible wrong answer rather - * than an error, so getting a Run to work once catches none of them and only a test over a crafted + * The highest-risk logic in the `pi` adapter, which is why it is a module of its own with no socket + * in it. Three properties of `pi`'s event stream each produce a plausible wrong answer rather than + * an error, so getting a Run to work once catches none of them and only a test over a crafted * stream does. * - * Two of the three are the worthless exit code and the terminal record, and both are rendered on the - * function, having consequences a caller acts on. The third is here, because it has none until - * somebody undoes it: framing is strictly LF, and nothing here may reach for `node:readline`. That - * splits on U+2028 and U+2029 as well. Both are legal inside a JSON string and `JSON.stringify` - * emits them literally, so one record would arrive as two malformed halves. + * Two of the three are rendered on the function below, having consequences a caller acts on: the + * terminal record is not the obvious one, and an error the model returned is announced nowhere but + * inside an assistant message. The third is framing, and it moved to `./framing.ts` when the + * RPC channel arrived, because a response to a command and an event of a Run are the same bytes + * read the same way. Nothing here sees a byte. * - * `agent_settled` is missing from `pi`'s own `docs/json.md`, which is stale. Read the code rather - * than that page before changing which record ends a Run. + * `agent_settled` is missing from `pi`'s own `docs/json.md`, which is stale; `docs/rpc.md` has it. + * Read the code rather than either page before changing which record ends a Run. */ import type { RunOutcome } from "../signals/runtime.ts"; +import type { Framed } from "./framing.ts"; /** * The stop reasons that mean the agent finished answering. Anything else is a failed Run. @@ -37,127 +38,111 @@ type Answer = { readonly errorMessage: string | undefined; }; -/** What one interpretation carries across the stream. Mutable, and never shared between two. */ -type Reading = { - /** Bytes decoded but not yet terminated by an LF. */ - pending: string; - /** How many records were read, for a message about a stream that stopped early. */ - records: number; - /** The first line that could not be read as a record, and why, if there was one. */ - unreadable: { readonly line: string; readonly why: string } | undefined; - /** The last assistant message seen so far. */ - answer: Answer | undefined; - /** The answer as it stood at the settle, which is the one the outcome is read from. */ - settledAnswer: Answer | undefined; - /** Whether `agent_settled` has been seen. */ - settled: boolean; -}; - /** - * Reads one Run's `pi --mode json` output and reports how the Run ended. + * Reads the events of one Run and reports how it ended, **stopping at the settle**. * - * No exit code is read and none is taken, because `--mode json` exits 0 on a model error and on an - * API error. What decides the outcome is the stop reason on the last assistant message before the - * agent settled. An `agent_end` record is not that settle: it fires per low-level agent run, and a - * retry or a compaction can follow it and continue the same Run, so a stream ending after one is a - * Run that did not finish. + * No exit status is read and there is none to read: the Agent Instance is a process the Operator + * runs and the Gateway only ever holds a connection to it. That is the same trade the old + * container-per-Run reader made for a different reason — `--mode json` exits 0 on a model error — + * and it is now structural rather than a choice. What decides the outcome is the stop reason on the + * last assistant message before the agent settled. An `agent_end` record is not that settle: it + * fires per low-level agent run, and a retry or a compaction can follow it and continue the same + * Run, so a stream ending after one is a Run that did not finish. * - * The `source` is the container's stdout as raw chunks rather than as decoded text, a chunk boundary - * falling wherever the operating system puts it, including inside a multi-byte character. + * An `AsyncIterator` and not an `AsyncIterable`, which is the whole point of this signature. The + * records are the channel's, shared with the commands that were sent before the Prompt, and this + * reader must take exactly what it needs and leave the iterator alone: `for await` would call + * `return()` on it at the settle and close the connection from underneath the caller, which is the + * caller's to do and to log. It also means the reader never drains to EOF. Nothing closes the + * connection but us, so draining would be waiting for a peer with no reason to hang up. * * Bad output never throws. A stream that stopped early, ended mid-record, or carried a line that is * not a record is a failed Run with a reason, and never a success inferred from the records that did * parse. Every reason names the `session`, because a Run's `error` column is the only thing an - * Operator has to go on, and `Session user_42 produced no output at all` says where to look. - * - * The whole source is consumed even once the outcome is known, a subprocess whose stdout stops being - * read blocking as soon as the pipe fills, which would turn a finished Run into a hang. There is no - * timeout here or anywhere else, so a stream that never ends never returns. + * Operator has to go on, and `Session user_42 said nothing at all` says where to look. */ -export async function interpretPiOutput( - source: AsyncIterable, +export async function readOutcome( + records: AsyncIterator, session: string, ): Promise { - const reading: Reading = { - pending: "", - records: 0, - unreadable: undefined, - answer: undefined, - settledAnswer: undefined, - settled: false, - }; - // `stream: true` is what makes a character split across two chunks survive. A per-chunk - // `toString()` would produce U+FFFD and a record that no longer parses. - const decoder = new TextDecoder("utf-8"); - - for await (const chunk of source) { - frameLines(reading, decoder.decode(chunk, { stream: true })); - } - frameLines(reading, decoder.decode()); - - return outcomeOf(reading, session); -} + // Every failure below is this Session's, so it says so once here rather than seven times. + const failed = (why: string): RunOutcome => ({ ok: false, error: `Session ${session} ${why}` }); + /** How many records were read, for a message about a stream that stopped early. */ + let read = 0; + /** The last assistant message seen so far, which at the settle is the one that decides. */ + let answer: Answer | undefined; -/** Cuts `text` into lines on LF and on nothing else, for the reason the file header gives. */ -function frameLines(reading: Reading, text: string): void { - reading.pending += text; for (;;) { - const end = reading.pending.indexOf("\n"); - if (end === -1) return; - const line = reading.pending.slice(0, end); - reading.pending = reading.pending.slice(end + 1); - readRecord(reading, line); + const step = await records.next(); + if (step.done === true) { + // The dropped connection, and the one failure mode a Runtime over a socket has that a + // Runtime over a pipe did not: the Agent Instance is somebody else's process on somebody + // else's schedule, and it can go away in the middle of a Run. + return read === 0 + ? failed( + "said nothing at all after the Prompt was accepted, so nothing says whether the Run happened", + ) + : failed( + `ended after ${read} records without an agent_settled record, so the Run did not finish. An agent_end is not the end: it can be followed by a retry or a compaction`, + ); + } + const framed = step.value; + if (framed.kind === "unreadable") { + // Reported the moment it is seen, and a settle after it cannot rescue it. The half that was + // lost might have been the half that mattered, and "the rest of it parsed" is not evidence + // of anything. + return failed( + `wrote a line that could not be read as a record (${framed.why}), so its output cannot be trusted: ${excerpt(framed.line)}`, + ); + } + if (framed.kind === "truncated") { + return failed( + `ended mid-record after ${read} records, so the Run did not finish: ${excerpt(framed.line)}`, + ); + } + + read += 1; + const record = framed.record; + switch (record.type) { + case "message_end": + case "turn_end": + answer = answerIn(record.message) ?? answer; + break; + case "agent_end": + // `agent_end` carries the whole message list. Read for the answer, never as the end of the + // Run: a retry or a compaction can follow it and continue the same Run. + if (Array.isArray(record.messages)) { + const found = record.messages.map(answerIn).findLast((one) => one !== undefined); + if (found !== undefined) answer = found; + } + break; + case "agent_settled": + return settlement(failed, answer, read); + default: + break; + } } } -/** Reads one framed line as a record, or notes why it could not be read as one. */ -function readRecord(reading: Reading, line: string): void { - // `pi` writes no blank lines, but a trailing LF leaves one behind here, and a reader that failed - // on it would fail on every well-formed stream. - if (line.trim() === "") return; - - let record: unknown; - try { - record = JSON.parse(line); - } catch { - reading.unreadable ??= { line, why: "it is not JSON" }; - return; - } - if (typeof record !== "object" || record === null || Array.isArray(record)) { - reading.unreadable ??= { line, why: "it is JSON but not an object" }; - return; - } - const fields = record as Record; - if (typeof fields.type !== "string") { - reading.unreadable ??= { line, why: "it has no type field" }; - return; +/** What the settle means, given the answer as it stood when it arrived. */ +function settlement( + failed: (why: string) => RunOutcome, + answer: Answer | undefined, + read: number, +): RunOutcome { + if (answer === undefined) { + return failed( + `settled after ${read} records with no assistant message, so there is nothing that says the Run succeeded`, + ); } - - reading.records += 1; - // Past the settle the Run's outcome is already decided, so nothing is read from what follows. - // Still counted and still framed, so the stream keeps draining. - if (reading.settled) return; - - switch (fields.type) { - case "message_end": - case "turn_end": - reading.answer = answerIn(fields.message) ?? reading.answer; - return; - case "agent_end": - // `agent_end` carries the whole message list. Read for the answer, never as the end of the - // Run: a retry or a compaction can follow it and continue the same Run. - if (Array.isArray(fields.messages)) { - const answer = fields.messages.map(answerIn).findLast((found) => found !== undefined); - if (answer !== undefined) reading.answer = answer; - } - return; - case "agent_settled": - reading.settled = true; - reading.settledAnswer = reading.answer; - return; - default: - return; + if (!answeredStopReasons.has(answer.stopReason)) { + // The stop reason is named because nothing else says anything: the agent settled, the + // connection is healthy, and this string is all the Operator gets. + return failed( + `settled with stopReason ${JSON.stringify(answer.stopReason)} and reported no failure of its own: ${answer.errorMessage ?? `the agent's last message was not an answer (${answer.stopReason})`}`, + ); } + return { ok: true }; } /** The answer a message holds, if that message is one of the agent's own. */ @@ -171,53 +156,6 @@ function answerIn(message: unknown): Answer | undefined { }; } -/** - * The outcome, with the reasons in the order they take precedence. - * - * A stream that could not be read whole is reported as such, and that holds even where the records - * which did parse settled successfully. The half that was lost might have been the half that - * mattered, and "some of it parsed" is not evidence of anything. - */ -function outcomeOf(reading: Reading, session: string): RunOutcome { - // Every failure below is this Session's, so it says so once here rather than six times. The Run's - // `error` column is the only thing an Operator has to go on. - const failed = (why: string): RunOutcome => ({ ok: false, error: `Session ${session} ${why}` }); - - if (reading.unreadable !== undefined) { - const { line, why } = reading.unreadable; - return failed( - `wrote a line that could not be read as a record (${why}), so its output cannot be trusted: ${excerpt(line)}`, - ); - } - if (reading.pending.trim() !== "") { - return failed( - `ended mid-record after ${reading.records} records, so the Run did not finish: ${excerpt(reading.pending)}`, - ); - } - if (reading.records === 0) { - return failed("produced no output at all, so nothing says whether the Run happened"); - } - if (!reading.settled) { - return failed( - `ended after ${reading.records} records without an agent_settled record, so the Run did not finish. An agent_end is not the end: it can be followed by a retry or a compaction`, - ); - } - const answer = reading.settledAnswer; - if (answer === undefined) { - return failed( - `settled after ${reading.records} records with no assistant message, so there is nothing that says the Run succeeded`, - ); - } - if (!answeredStopReasons.has(answer.stopReason)) { - // The stop reason is named because the exit code was zero and this string is all the Operator - // gets. - return failed( - `settled with stopReason ${JSON.stringify(answer.stopReason)} and exited successfully anyway: ${answer.errorMessage ?? `the agent's last message was not an answer (${answer.stopReason})`}`, - ); - } - return { ok: true }; -} - /** Enough of a line to recognise it by, without putting a whole Session into a log. */ function excerpt(line: string): string { const trimmed = line.trim(); diff --git a/src/pi/rpc.ts b/src/pi/rpc.ts new file mode 100644 index 0000000..39a5353 --- /dev/null +++ b/src/pi/rpc.ts @@ -0,0 +1,203 @@ +/** + * The RPC channel: one TCP connection to an Agent Instance, opened for one Run and closed at the + * end of it. + * + * Always written as the **RPC channel** and never as an unqualified Channel, the way a PostgreSQL + * notification channel is: a Channel in this framework is what reaches one person over one medium, + * and this reaches the agent. + * + * `node:net` and nothing else, which is the whole of the client. The protocol is JSON lines in both + * directions, so a library would buy the two things this file already has — framing, which is + * `./framing.ts`, and correlation, which is eleven lines below — and cost the package a runtime + * dependency that every consumer installs. `dependencies` is unchanged by the Agent Instance, and + * that is a claim worth keeping true. + * + * **Commands are never pipelined.** One is written, its response is awaited, and only then is the + * next written. That is not a limitation of the code below, which correlates by `id` and would + * survive interleaving; it is what makes the correlation checkable at all. A response whose `id` is + * not the outstanding one therefore means the assumption underneath the whole file is false, and it + * fails the Run rather than being matched up, because reading another command's answer as this + * command's is how a Run comes to be switched into a Session nobody asked for. + * + * Events are skipped while a response is outstanding, and that is safe for the one sequence this + * channel is driven through. `switch_session` and `get_state` produce no events at all, and the + * `prompt` response is written when the Prompt is **accepted**, before the agent has run: nothing + * skipped there can be an assistant message or the settle. A second command sent while the agent is + * streaming would break that, which is one more reason there is not one. + * + * Nothing here knows what a Session is, what a Prompt is, or what ends a Run. It writes commands, + * answers with responses, and hands the rest of the stream to whoever asks. + */ + +import { createConnection } from "node:net"; +import { type Framed, framedRecords } from "./framing.ts"; + +/** What one command was answered with. */ +export type Answered = { + /** The command the Agent Instance says this answers, which is checked against the one sent. */ + readonly command: string; + readonly success: boolean; + /** Present when `success` is false, and written for a person by the Agent Instance. */ + readonly error: string | undefined; + /** + * Whatever the response carried, uninterpreted. + * + * `switch_session` answers `{ cancelled: boolean }` and `get_state` a dozen fields including + * `sessionFile`, and which of them matter is the Runtime's business rather than this file's. + */ + readonly data: Readonly> | undefined; +}; + +/** One connection to an Agent Instance, for the length of one Run. */ +export type RpcChannel = { + /** + * Writes one command and answers with its response. + * + * @throws If the connection ended before the response arrived, or if what came back was another + * command's answer. Both mean the Run cannot be trusted to have happened, and the caller turns + * the message into a failed Run. + */ + send(command: string, fields?: Readonly>): Promise; + /** + * Everything that has not been read as a response, as an iterator the outcome reader pulls. + * + * An iterator and not an iterable: whoever reads the events of a Run takes what it needs and + * leaves the stream open, because closing it is this channel's job and `close` below is where it + * happens. + */ + readonly records: AsyncIterator; + /** + * Why the connection went away, if it did so by failing rather than by being closed. + * + * Read after a Run has already failed, to say `read ECONNRESET` beside `the stream ended`. A + * dropped connection reaches the reader as nothing at all — the records simply stop — so without + * this the Agent Instance dying mid-Run and the Agent Instance hanging up politely produce the + * same sentence. + */ + dropped(): string | undefined; + /** Closes the connection. Called for every Run, whatever the outcome, and never twice. */ + close(): void; +}; + +/** + * Opens one connection to an Agent Instance. + * + * @throws If the Agent Instance cannot be reached, with the address in the message. That is a failed + * Run and never a boot failure: nothing calls this until a Prompt exists, which is the **Relay** + * precedent — a remote thing the Operator runs is an outage and not a configuration error, and a + * startup probe would only turn a Gateway that serves every other Party into one that will not + * start. + */ +export async function openRpcChannel(host: string, port: number): Promise { + const socket = createConnection({ host, port }); + // The commands are tiny and strictly sequential, so Nagle's algorithm has nothing to coalesce and + // everything to delay: a 40-millisecond wait on each of three commands, three times per Run. + socket.setNoDelay(true); + // A Run has no timeout anywhere by design, and the Signal Worker is serial, so an Agent Instance + // that dies without closing its end wedges every Party's queue until somebody restarts the + // Gateway. Nothing else would notice: a peer that has stopped existing is indistinguishable from + // a peer that is thinking, which is the failure a local child process could not have because its + // death arrived as an exit. Thirty seconds is the idle time before the *first* probe, and the + // interval and count after it belong to the operating system, so this bounds the wait at roughly + // ten minutes rather than at thirty seconds. Where a NAT or a firewall sits in the path it is + // also what keeps the flow from being collected in the first place, which is the better half of + // the bargain and the immediate one. + socket.setKeepAlive(true, 30_000); + + // Node emits exactly one of these two, and both are awaited because a later stream error cannot + // answer whether there was anything listening in the first place. + const failedToConnect = await new Promise((settled) => { + socket.once("connect", () => settled(undefined)); + socket.once("error", (error) => settled(error)); + }); + if (failedToConnect !== undefined) { + socket.destroy(); + throw new Error( + `could not reach the Agent Instance at ${host}:${port}: ${failedToConnect.message}. That is the address an Operator gave createPiRuntime, and the Agent Instance is theirs to run`, + { cause: failedToConnect }, + ); + } + + let dropped: string | undefined; + // Attached before a byte is written. An `error` event with no listener takes the whole Gateway's + // process down, and a write to a socket the Agent Instance has already closed is exactly how one + // arrives. + socket.on("error", (error) => { + dropped ??= error.message; + }); + + const records = framedRecords( + (async function* () { + try { + for await (const chunk of socket) yield chunk as Uint8Array; + } catch (error) { + // A dropped connection is the end of the stream and never a throw. The Run it was carrying + // fails on the records that are missing, which is a sentence about the Run rather than + // about a socket. + dropped ??= error instanceof Error ? error.message : String(error); + } + })(), + ); + + let sent = 0; + return { + records, + dropped: () => dropped, + close: () => socket.destroy(), + + async send(command, fields) { + // Per connection, and a connection is per Run. Nothing is correlated across Runs and nothing + // needs to be. + sent += 1; + const id = String(sent); + socket.write(`${JSON.stringify({ id, type: command, ...fields })}\n`); + + for (;;) { + const step = await records.next(); + if (step.done === true) { + throw new Error( + `the Agent Instance at ${host}:${port} ended the connection without answering the ${command} command${dropped === undefined ? "" : `: ${dropped}`}`, + ); + } + const framed = step.value; + if (framed.kind !== "record") { + throw new Error( + `the Agent Instance at ${host}:${port} wrote something that is not a record while the ${command} command was outstanding, so nothing it says can be trusted: ${JSON.stringify(framed.line.slice(0, 200))}`, + ); + } + // An event of some earlier work, or of this Prompt being accepted; see the file header for + // why passing over it is safe and for the one thing that would make it unsafe. + if (framed.record.type !== "response") continue; + + const record = framed.record; + if (record.id !== id) { + throw new Error( + `the Agent Instance at ${host}:${port} answered request ${JSON.stringify(record.id)} while ${JSON.stringify(id)} was the only one outstanding, so its answers cannot be matched to the commands they are for`, + ); + } + const answered = readResponse(record); + if (answered.command !== command) { + throw new Error( + `the Agent Instance at ${host}:${port} answered request ${id} as the ${JSON.stringify(answered.command)} command when it was the ${JSON.stringify(command)} command`, + ); + } + return answered; + } + }, + }; +} + +/** One `response` record, in the shape a caller branches on. */ +function readResponse(record: Readonly>): Answered { + const data = record.data; + return { + command: typeof record.command === "string" ? record.command : "", + // `=== true` rather than truthiness: a response with no `success` at all is not a success. + success: record.success === true, + error: typeof record.error === "string" ? record.error : undefined, + data: + typeof data === "object" && data !== null && !Array.isArray(data) + ? (data as Record) + : undefined, + }; +} diff --git a/src/pi/runtime.test.ts b/src/pi/runtime.test.ts index bd3281b..a8f3ad9 100644 --- a/src/pi/runtime.test.ts +++ b/src/pi/runtime.test.ts @@ -1,249 +1,410 @@ /** - * What `pi` adds to a container, which is three flags, a Prompt on stdin and a reader. + * One Run against an Agent Instance, over a real socket to a fake one. * - * The subject is `commandFor` on a constructed Runtime, so what is asserted is what a Run - * would really start, an Agent Implementation's own defaults included. Everything generic - * — the confinement flags, the mounts, the user, the networks, the redaction, the order — - * is `src/container/agent-container.test.ts` and is deliberately not restated here: this - * file is only the `pi`-shaped half, which is now most of what there is to say about `pi`. + * The fake is `../test-support/agent-instance.ts`: a TCP server speaking `pi`'s RPC framing, driven + * by a script. That is the whole seam now — the Gateway starts nothing, so everything between "a + * Prompt exists" and "a Run is recorded" is a connection, three commands and a stream of events, and + * all of it is exercised here with no Docker, no image, no model and no network beyond loopback. * - * No Docker, no credentials, no network, no filesystem. `piRun` is a **pure and total** - * function of its Prompt, with no case left over: the Session arrives already named, - * because the Signal Worker answered a Handler's request for a fresh one before any of - * this ran, and that is asserted in `src/signals/worker.test.ts` where it - * happens. What none of this can prove is that the mounts resolve, that the image - * declares the two things `pi` needs of it, or that a Session resumes: nothing but a real - * container can, and that is `./container.test.ts`. + * What this file cannot prove is the one thing the design rests on: that `pi`'s `switch_session` + * creates a Session at a path that does not exist and resumes one that does. A fake that was + * scripted to do it would only pin our reading of the protocol. `./agent-instance.test.ts` drives + * the real program and is where that claim lives. * - * Assertions are on the composed argv rather than on a rendered string, and several are - * on flag *pairs*, because that is the property a mistake breaks: `pi` is not the process - * being started, `docker` is, and a flag on the wrong side of the image name reaches the - * wrong program. + * The assertions are on the **sequence** as much as on the outcome, because that is the property a + * mistake breaks. A Run that prompts after a switch it did not check is a Prompt delivered into + * whatever Session the instance was already in, and the outcome of such a Run is a perfectly + * ordinary success. */ import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import type { AgentContainer, ComposedCommand } from "../agent-container/index.ts"; -import type { RunPrompt } from "../signals/runtime.ts"; -import { createPiRuntime, piRun } from "./runtime.ts"; - -/** The least container a `pi` deployment declares, plus what one really mounts. */ -const minimal: AgentContainer = { - image: "concorde/pi:latest", - mounts: { - entries: [ - { agentPath: "/workspace", path: "workspace" }, - { agentPath: "/home/agent/.pi/agent", path: "agent" }, - ], - runtimeDir: "/srv/concorde", - }, -}; +import { createServer } from "node:net"; +import { after, describe, it } from "node:test"; +import type { RunOutcome, RunPrompt } from "../signals/runtime.ts"; +import { + dropsTheConnection, + type FakeInstance, + type Received, + type Reply, + resetsTheConnection, + scriptedInstance, + startFakeInstance, + type Written, +} from "../test-support/agent-instance.ts"; +import { createPiRuntime } from "./runtime.ts"; + +/** Where Sessions live as the Agent Instance sees them, which is the only path anything names. */ +const sessionsDir = "/sessions"; const prompt: RunPrompt = { session: "user_42", text: "what happened?" }; -/** The command line one Prompt composes, without starting anything. */ -function commandFor( - container: Partial = {}, - given: RunPrompt = prompt, -): ComposedCommand { - return createPiRuntime({ ...minimal, ...container }).commandFor(given); +/** Every fake started by a case, closed when the file is done. */ +const running: FakeInstance[] = []; + +after(async () => { + await Promise.all(running.map((instance) => instance.close())); +}); + +/** A fake on loopback, and the Runtime pointed at it. */ +async function instanceOf(reply: Reply): Promise { + const instance = await startFakeInstance(reply); + running.push(instance); + return instance; } -/** The argument after `flag`, asserting the flag appears exactly once. */ -function argumentAfter(composed: ComposedCommand, flag: string): string { - const occurrences = composed.args.filter((arg) => arg === flag); - assert.equal(occurrences.length, 1, `${flag} should appear once in ${composed.args.join(" ")}`); - const value = composed.args[composed.args.indexOf(flag) + 1]; - assert.ok(value !== undefined, `${flag} should be followed by a value`); - return value; +/** What the Runtime makes of one Run against `reply`, and what that fake saw. */ +async function runAgainst( + reply: Reply, + given: RunPrompt = prompt, +): Promise<{ outcome: RunOutcome; instance: FakeInstance }> { + const instance = await instanceOf(reply); + const runtime = createPiRuntime({ + host: instance.host, + port: instance.port, + sessionsDir, + logger: silent, + }); + return { outcome: await runtime.run(given), instance }; } -/** Every value given to a flag that may repeat, in order. */ -function valuesOf(composed: ComposedCommand, flag: string): string[] { - return composed.args.flatMap((arg, at) => (arg === flag ? [composed.args[at + 1] ?? ""] : [])); +/** The failure of a Run that must have failed. */ +function failure(outcome: RunOutcome): string { + assert.equal(outcome.ok, false, `this Run should have failed; it was ${JSON.stringify(outcome)}`); + return outcome.ok ? "" : outcome.error; } -/** The arguments after the image name, which are the only ones that reach `pi`. */ -function agentArgsOf(composed: ComposedCommand): string[] { - const at = composed.args.indexOf(minimal.image); - assert.notEqual(at, -1, "the image should appear in the arguments"); - return composed.args.slice(at + 1); +/** The commands a fake was sent, by type, which is what "in that order" is asserted on. */ +function commandTypes(instance: FakeInstance): string[] { + return instance.received.map((command) => command.type); } -describe("what the agent is told", () => { - it("is three flags and nothing else, after the image and after the entry point", () => { - // Written out whole, because the whole of it is now short enough to read: the flags - // `pi` needs, and no value the Operator did not state somewhere else. - assert.deepEqual(agentArgsOf(commandFor()), [ - "--mode", - "json", - "--session-id", - "user_42", - "--no-approve", - ]); +/** Nothing on the console: a Run logs at debug, and these cases run by the dozen. */ +const silent = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +describe("the shape of one Run", () => { + it("switches, verifies, prompts and reads to the settle, in that order", async () => { + const { outcome, instance } = await runAgainst(scriptedInstance()); + + assert.deepEqual(outcome, { ok: true }); + // Three commands and no fourth. Nothing asks the Agent Instance what model it holds, what + // extensions it has or where its agent directory is: everything about it is the Operator's. + assert.deepEqual(commandTypes(instance), ["switch_session", "get_state", "prompt"]); }); - it("asks for the machine-readable event stream", () => { - assert.equal(argumentAfter(commandFor(), "--mode"), "json"); + it("addresses the Session as one file under the directory the Operator named", async () => { + const { instance } = await runAgainst(scriptedInstance()); + + assert.equal(instance.received[0]?.sessionPath, "/sessions/user_42.jsonl"); }); - it("names no model and no provider, because the mounted settings.json carries both", () => { - // Verified against pi@0.83.0: `settings.json` holds `defaultModel` and - // `defaultProvider`, and `pi` falls back to them when no flag is given. The cost is - // that nothing refuses a deployment with no usable model any more — it is a Gateway - // that starts, serves, and fails its first Run permanently. - for (const flag of ["--model", "--provider"]) { - assert.ok(!commandFor().args.includes(flag), `${flag} should not be passed`); + it("writes the Prompt as the message, byte for byte, whatever it starts with", async () => { + // There is no argv any more, so the two treatments that made stdin the only safe channel are + // gone with it: `pi` reads a leading `@word` on a command line as a file to include and refuses + // an argument starting with `-`. A JSON string is a JSON string. + for (const text of ["@file.md and more", "--help", "-p", " leading space", "it's &"]) { + const { instance } = await runAgainst(scriptedInstance(), { session: "user_42", text }); + assert.equal(instance.received[2]?.message, text); } }); - it("names no directory, and sets no variable saying where one is", () => { - // All three container paths are gone. The image declares the first two — `WORKDIR` - // and `ENV PI_CODING_AGENT_DIR` — and `pi` resolves the third under the second. A - // path the framework does not carry is a path it cannot get wrong. - const composed = commandFor(); + it("correlates every response by id rather than by arrival", async () => { + const { instance } = await runAgainst(scriptedInstance()); - for (const flag of ["--workdir", "-w", "--session-dir"]) { - assert.ok(!composed.args.includes(flag), `${flag} should not be passed`); - } + // Distinct, and per connection: a connection is per Run, so nothing is correlated across Runs. assert.deepEqual( - valuesOf(composed, "--env").filter((value) => value.startsWith("PI_CODING_AGENT_DIR=")), - [], - "the agent's directory is the image's to declare", + instance.received.map((command) => command.id), + ["1", "2", "3"], ); }); - it("resolves the Session with the flag that creates it if missing", () => { - const composed = commandFor(); + it("fails the Run when an answer carries another request's id, rather than matching it up", async () => { + // The whole correlation claim, made testable: commands are never pipelined, so a response + // arriving under some other id means the assumption underneath the channel is false. Reading it + // as this command's answer is how a Run comes to be switched into a Session nobody asked for. + const { outcome, instance } = await runAgainst((command) => [ + { type: "response", id: "99", command: command.type, success: true, data: {} }, + ]); - assert.equal(argumentAfter(composed, "--session-id"), "user_42"); - // `--session` resolves only an existing Session and exits 1 otherwise, which would - // fail every first Run of a named Session. - for (const flag of ["--session", "--no-session", "--continue", "--resume"]) { - assert.ok(!composed.args.includes(flag), `${flag} should not be passed`); - } + assert.match(failure(outcome), /"99".*"1"|"1".*"99"/s); + assert.deepEqual(commandTypes(instance), ["switch_session"]); }); - it("is whatever the Handler wrote, including a name pi will not accept", () => { - // Nothing here holds a copy of `pi`'s session-id grammar. `pi` checks `--session-id` - // itself and exits 1 with its own message, which reaches the Operator through the - // failed Run's `error` beside the name in its `session` — a diagnostic that cannot go - // stale, unlike a transcribed pattern. Nothing is joined onto a - // path either, here or anywhere, so a name that climbs is a name and not a traversal. - for (const session of ["../escape", "user:42", "a/b", ""]) { - assert.equal(argumentAfter(commandFor({}, { session, text: "hi" }), "--session-id"), session); - } - }); + it("fails the Run when an answer carries the right id under another command's name", async () => { + // The second correlation guard, and the one an id alone cannot make: an instance that numbers + // its answers correctly and labels them wrongly is still an instance whose answers cannot be + // read. `switch_session` and `get_state` differ in exactly the field the next step branches on, + // so taking one for the other is a Prompt sent into an unverified Session. + const { outcome, instance } = await runAgainst((command) => [ + { type: "response", id: command.id, command: "get_state", success: true, data: {} }, + ]); - it("names no file of the framework's, because the framework writes none", () => { - // What used to be here was `--append-system-prompt /…`, pointing - // at a file rewritten before every Run. The Operator places an `AGENTS.md` in the - // Workspace instead and `pi` finds it in its own working directory, so there is no - // flag to pass and nothing to know. - const composed = commandFor({ extraArgs: ["--memory", "2g"] }); + assert.match(failure(outcome), /"get_state".*"switch_session"|"switch_session".*"get_state"/s); + assert.deepEqual(commandTypes(instance), ["switch_session"]); + }); - for (const flag of ["--append-system-prompt", "--system-prompt", "--prompt-file"]) { - assert.ok(!composed.args.includes(flag), `${flag} should not be passed`); + it("closes the connection, whether the Run succeeded or failed", async () => { + // With `socat ...,fork` every connection is a `pi` process, so one left open is one left + // running. The Operator's listener started it and nothing else will reap it. + for (const reply of [scriptedInstance(), refusing("prompt", "already streaming")]) { + const { instance } = await runAgainst(reply); + assert.equal(instance.connections(), 1); + await waitFor(() => instance.ended() === 1, "the connection should have been closed"); } - // Nor by any other spelling: every argument is a flag, an image, a Session name, a - // mount the Operator declared, or something else they wrote themselves. - assert.ok( - !composed.args.some((arg) => arg.endsWith(".md")), - `no argument should name a Markdown file: ${composed.args.join(" ")}`, - ); }); - it("ignores project-local configuration in the Workspace", () => { - // The Workspace is writable by the agent and `trust.json` persists between Runs, so - // without this one Run could arrange for the next to load its settings out of the - // Workspace — a reconfiguration that survives the Run that managed it. - assert.ok(agentArgsOf(commandFor()).includes("--no-approve")); - assert.ok(!commandFor().args.includes("--approve")); + it("opens one connection per Run and holds none between them", async () => { + const instance = await instanceOf(scriptedInstance()); + const runtime = createPiRuntime({ + host: instance.host, + port: instance.port, + sessionsDir, + logger: silent, + }); + + await runtime.run(prompt); + await runtime.run({ session: "user_7", text: "and then?" }); + + assert.equal(instance.connections(), 2); + assert.deepEqual(commandTypes(instance), [ + "switch_session", + "get_state", + "prompt", + "switch_session", + "get_state", + "prompt", + ]); }); }); -describe("the defaults pi contributes to the container", () => { - it("runs pi, so an image whose entry point is something else still works", () => { - assert.equal(argumentAfter(commandFor(), "--entrypoint"), "pi"); +describe("the switch, which is the step everything else assumes", () => { + it("fails the Run and never prompts when the Agent Instance refuses it", async () => { + const { outcome, instance } = await runAgainst(refusing("switch_session", "no such directory")); + + assert.match(failure(outcome), /\/sessions\/user_42\.jsonl.*no such directory/s); + assert.deepEqual(commandTypes(instance), ["switch_session"]); }); - it("keeps pi from reaching pi.dev, because a Run should not depend on it", () => { - assert.deepEqual(valuesOf(commandFor(), "--env"), ["PI_OFFLINE=1"]); + it("fails the Run and never prompts when an extension cancelled it", async () => { + // `success: true` with `cancelled: true`, which is the shape that would otherwise pass every + // check: the command worked, and the switch did not happen. The agent is in some other Session, + // and a Prompt sent now goes to it. + const { outcome, instance } = await runAgainst( + answering("switch_session", { cancelled: true }), + ); + + assert.match(failure(outcome), /cancelled/); + assert.match(failure(outcome), /Session user_42/); + assert.deepEqual(commandTypes(instance), ["switch_session"]); }); - it("loses both to an Operator who states them, because they are defaults and not rules", () => { - // The whole extension mechanism: two values spread beneath the Operator's own. A - // Gateway has no use for `pi`'s startup version check, and an Operator who asks for - // it anyway gets it. - const own = commandFor({ - entrypoint: ["/opt/pi/bin/pi"], - env: { PI_OFFLINE: "0", ANTHROPIC_API_KEY: "sk-test" }, - }); + it("is verified with get_state, and a disagreement fails the Run naming both paths", async () => { + // Why the verification exists at all: `switch_session` is create-or-resume, which is + // undocumented behaviour of `pi`'s that the whole design rests on. Reading `sessionFile` back is + // what tells "it created the Session I named" apart from "it did something and reported + // success". + const { outcome, instance } = await runAgainst( + answering("get_state", { sessionFile: "/sessions/somebody-else.jsonl" }), + ); - assert.equal(argumentAfter(own, "--entrypoint"), "/opt/pi/bin/pi"); - assert.deepEqual(valuesOf(own, "--env"), ["PI_OFFLINE=0", "ANTHROPIC_API_KEY=sk-test"]); - }); - - it("leaves everything else about the container to the Operator", () => { - // `pi` contributes no field of its own at all, so the least a deployment can declare - // is an image — and what comes out is a container line with `pi` on the end of it. - const composed = createPiRuntime({ image: "concorde/pi:latest" }).commandFor(prompt); - - assert.equal(composed.command, "docker"); - assert.ok(!composed.args.includes("--mount")); - assert.ok(!composed.args.includes("--network")); - assert.deepEqual(composed.args.slice(-6), [ - "concorde/pi:latest", - "--mode", - "json", - "--session-id", - "user_42", - "--no-approve", - ]); + const error = failure(outcome); + assert.match(error, /\/sessions\/user_42\.jsonl/); + assert.match(error, /\/sessions\/somebody-else\.jsonl/); + assert.deepEqual(commandTypes(instance), ["switch_session", "get_state"]); + }); + + it("fails the Run when get_state names no file at all", async () => { + const { outcome } = await runAgainst(answering("get_state", { isStreaming: false })); + + assert.match(failure(outcome), /\/sessions\/user_42\.jsonl/); }); }); describe("the Prompt", () => { - it("is written to stdin rather than passed as an argument", () => { - const composed = commandFor({}, { session: "user_42", text: "read @notes.md" }); + it("fails the Run when the Agent Instance rejects it before accepting it", async () => { + // The only failure `prompt` reports as a response: everything that goes wrong after acceptance + // arrives in the event stream instead. + const { outcome, instance } = await runAgainst(refusing("prompt", "already streaming")); - assert.equal(composed.stdin, "read @notes.md"); - // `pi` reads a leading `@word` as a file to include and refuses an argument starting - // with `-`. Neither applies to piped stdin, and the whole Prompt is rendered text an - // Operator's template produced. - assert.ok(!composed.args.includes("read @notes.md")); + assert.match(failure(outcome), /already streaming/); + assert.deepEqual(commandTypes(instance), ["switch_session", "get_state", "prompt"]); }); - it("reaches stdin byte for byte, whatever it starts with", () => { - for (const text of ["@file.md and more", "--help", "-p", " leading space", "it's &"]) { - const composed = commandFor({}, { session: "user_42", text }); - assert.equal(composed.stdin, text); - assert.ok(!composed.args.includes(text), `${JSON.stringify(text)} must not reach argv`); + it("is refused when it is empty, without opening a connection at all", async () => { + for (const text of ["", " ", "\n\n"]) { + const { outcome, instance } = await runAgainst(scriptedInstance(), { + session: "user_42", + text, + }); + + assert.match(failure(outcome), /no text/); + assert.equal(instance.connections(), 0); } }); +}); - it("is refused when it is empty, rather than reaching the agent as nothing", () => { - for (const text of ["", " ", "\n\n"]) { +describe("a Session name outside pi's grammar", () => { + /** `pi`'s own `assertValidSessionId`, which this Runtime now carries a copy of. */ + const refused = ["../escape", "user:42", "a/b", "", ".", "..", "-leading", "trailing-", "a b"]; + const accepted = ["user_42", "a", "1", "run_01K9-x.y", "A.B_C-1"]; + + it("fails that Run alone, naming the Session, and reaches the Agent Instance not at all", async () => { + for (const session of refused) { + const { outcome, instance } = await runAgainst(scriptedInstance(), { + session, + text: "hello", + }); + + assert.match(failure(outcome), new RegExp(`^Session ${escaped(session)} is not a name pi`)); + // Nothing was sent, which is the point: `pi` will open any path it is handed over RPC, so a + // name that climbs would be a traversal rather than a refusal. + assert.equal(instance.connections(), 0); + } + }); + + it("lets through every name pi would take, so no deployment's Sessions are renamed", async () => { + for (const session of accepted) { + const { outcome, instance } = await runAgainst(scriptedInstance(), { + session, + text: "hello", + }); + + assert.deepEqual(outcome, { ok: true }, session); + assert.equal(instance.received[0]?.sessionPath, `/sessions/${session}.jsonl`); + } + }); +}); + +describe("the Agent Instance the Operator has to run", () => { + it("is not reached at construction, so an unreachable one is not a boot failure", async () => { + // The **Relay** precedent, and there is no startup probe: a remote thing the Operator runs is an + // outage, and a Gateway that would not start takes every other Party's access down with the + // agent's. + assert.doesNotThrow(() => + createPiRuntime({ host: "127.0.0.1", port: 1, sessionsDir, logger: silent }), + ); + }); + + it("is a failed Run carrying the address when nothing is listening", async () => { + const port = await unusedPort(); + const runtime = createPiRuntime({ host: "127.0.0.1", port, sessionsDir, logger: silent }); + + const error = failure(await runtime.run(prompt)); + assert.match(error, /^Session user_42 could not reach the Agent Instance at 127\.0\.0\.1:/); + assert.match(error, new RegExp(`127\\.0\\.0\\.1:${port}`)); + }); + + it("is a failed Run saying the stream ended when it hangs up mid-Run", async () => { + const { outcome } = await runAgainst( + scriptedInstance([{ type: "agent_start" }, dropsTheConnection]), + ); + + assert.match(failure(outcome), /without an agent_settled record/); + }); + + it("says so as well when the connection fails rather than ending", async () => { + // An RST rather than a FIN: the process died. The reader sees the same absence either way, so + // without the socket's own word the two produce the same sentence. + const { outcome } = await runAgainst( + scriptedInstance([{ type: "agent_start" }, resetsTheConnection]), + ); + + const error = failure(outcome); + assert.match(error, /without an agent_settled record/); + assert.match(error, /connection failed/); + }); + + it("is a failed Run when it goes away before answering a command", async () => { + const { outcome } = await runAgainst(() => [dropsTheConnection]); + + assert.match(failure(outcome), /without answering the switch_session command/); + }); +}); + +describe("the sessionsDir an Operator declares", () => { + it("is required, because every Run names a file under it", () => { + for (const missing of [undefined, "", " "]) { assert.throws( - () => commandFor({}, { session: "user_42", text }), - /no text/, - `${JSON.stringify(text)} should be refused`, + () => + createPiRuntime({ + host: "agent", + port: 4000, + sessionsDir: missing as unknown as string, + }), + /sessionsDir/, + JSON.stringify(missing), ); } }); -}); -describe("the outcome reader one Run gets", () => { - /** A stream that says nothing at all, which is the shortest failure there is. */ - const silence = (): AsyncIterable => (async function* () {})(); + it("must be absolute, refused where the Operator wrote it rather than at the first Run", () => { + // The check the container-per-Run design could not make: it named no path at all, so a + // deployment with the wrong one was a Gateway that started, served, and failed every Run. + for (const relative of ["sessions", "./sessions", "../sessions", "sessions/nested"]) { + assert.throws( + () => createPiRuntime({ host: "agent", port: 4000, sessionsDir: relative }), + /absolute/, + relative, + ); + } + }); - it("names that Run's Session in a failure, which is why it is made per Run", async () => { - // The Run's `error` column is the only thing an Operator has to go on, and a message - // that named nothing left them with no transcript to open. A reader supplied once at - // construction could not have said this. - const outcome = await piRun({ session: "user_99", text: "hi" }).outcome(silence()); + it("takes any absolute path, including one with a trailing slash", async () => { + const instance = await instanceOf(scriptedInstance()); + const runtime = createPiRuntime({ + host: instance.host, + port: instance.port, + sessionsDir: "/srv/agent/sessions/", + logger: silent, + }); - assert.equal(outcome.ok, false); - assert.match(outcome.ok ? "" : outcome.error, /^Session user_99 produced no output/); + assert.deepEqual(await runtime.run(prompt), { ok: true }); + assert.equal(instance.received[0]?.sessionPath, "/srv/agent/sessions/user_42.jsonl"); }); }); + +/** A fake that answers one command with `success: false` and drives the rest normally. */ +function refusing(command: string, why: string): Reply { + const healthy = scriptedInstance(); + return (received) => + received.type === command + ? [{ type: "response", id: received.id, command, success: false, error: why }] + : healthy(received); +} + +/** A fake that answers one command successfully but with `data` of the test's choosing. */ +function answering(command: string, data: Written): Reply { + const healthy = scriptedInstance(); + return (received: Received) => + received.type === command + ? [{ type: "response", id: received.id, command, success: true, data }] + : healthy(received); +} + +/** A regular expression's worth of a Session name, several of which are not literal. */ +function escaped(session: string): string { + return session.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&"); +} + +/** A TCP port nothing is listening on, taken and given back. */ +async function unusedPort(): Promise { + const socket = createServer(); + await new Promise((listening) => socket.listen(0, "127.0.0.1", listening)); + const address = socket.address(); + if (address === null || typeof address === "string") throw new Error("no port"); + await new Promise((closed) => socket.close(() => closed())); + return address.port; +} + +/** Waits for something the other end of a socket does, which is never synchronous with our side. */ +async function waitFor(done: () => boolean, why: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (done()) return; + await new Promise((later) => setTimeout(later, 5)); + } + assert.fail(why); +} diff --git a/src/pi/runtime.ts b/src/pi/runtime.ts index b212c39..00f8d28 100644 --- a/src/pi/runtime.ts +++ b/src/pi/runtime.ts @@ -1,102 +1,223 @@ /** - * A container rather than this process, which is the load-bearing decision underneath all of - * `src/pi/`. Driving `pi` in-process through its TypeScript SDK is real, and it was rejected on - * exposure: `pi`'s shell tool hands its child `{ ...process.env }`, so an in-process agent would - * hold the Gateway's `DATABASE_URL` and could write to every table directly, bypassing the Agent - * server. Only what the container's `env` names reaches the agent. + * The Gateway does not run the agent, which is the load-bearing decision underneath all of + * `src/pi/`. An Operator runs an **Agent Instance** — `pi --mode rpc` behind a listener, in a + * container of their own — and this Runtime opens one connection to it per Run. What that buys is + * that the Gateway holds no container runtime socket, names no host path, and carries no part of + * the agent's environment: the image, the model credential, the files the agent reads and the flags + * it is started with are all on the other side of a TCP address. * - * The split with `src/agent-container/` runs one way. Everything about running an agent as a - * container lives there and knows nothing about `pi`: the argument assembly, the confinement flags, - * the mounts, the networks, the environment, the spawning, stdin, stderr, the exit status and the - * diagnosis appended to a failure. This file imports from it and nothing there imports back, and an - * import of `../pi/` into that directory is the thing to refuse in review, because the whole point - * of the split is that a second Agent Implementation takes it unchanged. + * Driving `pi` in-process through its TypeScript SDK is the alternative, and it stays refused on + * the same ground it always was: `pi`'s shell tool hands its child `{ ...process.env }`, so an + * in-process agent would hold the Gateway's `DATABASE_URL` and could write every table directly, + * bypassing the Agent server. The separation is now the Operator's arrangement rather than the + * framework's, and it is a stronger one — the agent's process never shared an address space, a + * filesystem or an environment with the Gateway to begin with. + * + * Container-per-Run is gone with the Docker socket. The isolation it bought did not disappear; it + * moved into the Operator's compose file, where it was always better expressed. An Operator who + * wants a fresh container per Run writes a {@link Runtime}, which is one method. */ -import { - type AgentContainer, - type AgentContainerRuntime, - createAgentContainerRuntime, - type RunPlan, -} from "../agent-container/index.ts"; -import type { RunPrompt } from "../signals/runtime.ts"; -import { interpretPiOutput } from "./output.ts"; +import { posix } from "node:path"; +import { defaultLogger, type Logger } from "../logging/index.ts"; +import type { RunOutcome, RunPrompt, Runtime } from "../signals/runtime.ts"; +import { readOutcome } from "./output.ts"; +import { openRpcChannel, type RpcChannel } from "./rpc.ts"; /** - * Builds a Runtime that runs `pi` as one fresh container per Run, of the image the container names. - * - * Two defaults sit beneath the Operator's own, and a container stating either one gets what it - * asked for. `entrypoint` is `["pi"]`, so an image that starts something else, or a `pi` installed - * somewhere unusual, is a field rather than a workaround. `PI_OFFLINE` is set, because a Gateway has - * no use for `pi`'s version check and its update telemetry, and a Run must not depend on reaching - * `pi.dev`. + * Where the Agent Instance is and where its Sessions are kept. * - * @throws If the container names no image, or if its Mount Table cannot mean what it says. + * Three required values and no fourth. There is no model, no provider, no image, no flag and no + * credential, because the Gateway starts nothing: everything `pi` reads on disk or takes on its + * command line is the Operator's to place in the instance they run, and a field here would be a + * second place to say it. */ -export const createPiRuntime = (container: AgentContainer): AgentContainerRuntime => - createAgentContainerRuntime({ - container: { - entrypoint: ["pi"], - ...container, - env: { PI_OFFLINE: "1", ...container.env }, - }, - run: piRun, - }); +export type PiInstance = { + /** The host the Agent Instance accepts RPC on, as this process resolves it. */ + readonly host: string; + /** The port it accepts on. */ + readonly port: number; + /** + * The directory Sessions live in, **as the Agent Instance sees it**. + * + * Absolute, and refused in {@link createPiRuntime} rather than at the first Run. This is the check + * the container-per-Run design could not make and recorded its regret at not making: it named no + * path at all, so a deployment that had mounted the wrong thing was a Gateway which started, + * served, and then failed every Run permanently. A path can be checked for what it *is* even + * where it cannot be checked for what is *there*, and the Operator wrote it in the same file as + * this option, which is where a refusal belongs. + * + * It is not a path on this host and must not be read as one. The Gateway never opens it, creates + * nothing in it and does not need to be able to reach it; the two ends agree on it because the + * Operator wrote the same string in the compose file and here. + */ + readonly sessionsDir: string; + readonly logger?: Logger; +}; /** - * Plans one Run as `pi` needs it performed: three flags, the Prompt on stdin, and a reader for the - * JSONL that comes back. The flags are `--mode json`, `--session-id ` and `--no-approve`, - * and nothing else is passed. + * `pi`'s own Session id grammar, copied verbatim from `assertValidSessionId` in its + * `core/session-manager`. * - * The Prompt goes on stdin, never argv, and that is not a style choice. `pi` reads a leading `@word` - * on argv as a file to include, and refuses an argument starting with `-` as an unknown option. Both - * are ordinary Handlebars output. Piped stdin becomes the initial message with neither treatment - * applied. + * Copied, which the framework never had to do before: `pi` used to be handed `--session-id` and to + * refuse a bad one itself, so the framework carried no transcription that could go stale and the + * Operator got `pi`'s own message. A Session is addressed **by path** over RPC, and `pi` will open + * any path it is handed, so the grammar has to live somewhere and the only honest place is beside + * the code that joins the path. + * + * Traversal-safe by construction rather than by a second check: there is no `/` in it, and `.` and + * `..` are excluded because both ends must be alphanumeric. So a Signal Handler's Session name + * cannot climb out of `sessionsDir`, and nothing here needs to compare a resolved path against a + * prefix. + */ +const sessionNames = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/; + +/** + * Builds the Runtime that performs each Run against an Agent Instance. * - * Pure, and a total function of its Prompt. Nothing is started, nothing is written, and no Session - * name is invented: the Session is already a name by the time it arrives here, the Signal Worker - * having answered a Handler's request for a fresh one against the Run row it had just written. The - * reader is {@link interpretPiOutput}, closed over that Session, so a failure says which Session it - * was. + * Nothing is connected here and nothing is probed. An Agent Instance that is not listening is a + * failed Run carrying the address, on the **Relay** precedent: a remote thing the Operator runs is + * an outage, and a Gateway that refused to start would take every other Party's access down with + * the agent's. What is refused here is only what an Operator got wrong in the file in front of + * them. * - * @throws If the Prompt has no text. The agent drops an empty message rather than answering it, so - * the Run would settle having said nothing. + * @throws If `sessionsDir` is missing, empty or relative. */ -export function piRun(prompt: RunPrompt): RunPlan { - if (prompt.text.trim() === "") { +export function createPiRuntime(instance: PiInstance): Runtime { + const { host, port, sessionsDir } = instance; + const log = instance.logger ?? defaultLogger(); + + if (typeof sessionsDir !== "string" || sessionsDir.trim() === "") { throw new Error( - "the Prompt has no text, and the Agent Implementation drops an empty message rather than answering it, so the Run would settle having said nothing", + "the pi Runtime needs a sessionsDir: the directory Sessions are kept in, as the Agent Instance sees it. Every Run names a file under it, so there is no default that could be right", + ); + } + // `posix` and not the platform's `path`, deliberately: this is a path in the Agent Instance's + // filesystem, which is a container, and a Gateway that happened to be running on Windows would + // otherwise refuse `/sessions` and join with a backslash. + if (!posix.isAbsolute(sessionsDir)) { + throw new Error( + `the pi Runtime's sessionsDir must be absolute, and ${JSON.stringify(sessionsDir)} is not. It is resolved by the Agent Instance and never by this process, so a relative path would be read against a working directory nothing here can see`, ); } return { - args: [ - // The machine-readable event stream. Note it exits 0 on model and API errors, so the outcome - // is read from the stream and never from the exit code (see output.ts). - "--mode", - "json", - // `--session-id`, never `--session`: this one creates the Session if it is missing, which is - // the fresh-or-named behaviour a Prompt asks for. The other resolves only an existing Session - // and exits 1 otherwise. - "--session-id", - prompt.session, - // No `--model`, no `--provider`, no `--session-dir`, and no flag naming a file. The first two - // are `defaultModel` and `defaultProvider` in a `settings.json` the Operator mounts. The third - // is `pi`'s own to resolve, under the agent directory the image declares. The framework writes - // no file, so it has none to name. That last one would make things worse rather than merely - // being unnecessary: `--append-system-prompt` resolves a missing path to its own literal - // argument, and the Run then settles happily knowing nothing. - // - // Project-local `.pi` settings and extensions are ignored, and that is load-bearing rather - // than tidy. The Workspace is writable by the agent, so a saved trust decision in the - // persisted `trust.json` would let one Run arrange for the next one to load configuration out - // of the Workspace. Context files are not project-local configuration and are unaffected, - // which is what makes a read-only `AGENTS.md` both readable and unchangeable. - "--no-approve", - ], - stdin: prompt.text, - // Closed over the Session, which is the whole reason the reader is produced per Run: a failure - // says which Session it was, and that is what a Run's `error` column needs to be worth reading. - outcome: (stdout) => interpretPiOutput(stdout, prompt.session), + async run(prompt: RunPrompt): Promise { + // Every failure below is this Run's Session, which is the only thing an Operator has to find + // a transcript by. + const failed = (why: string): RunOutcome => ({ + ok: false, + error: `Session ${prompt.session} ${why}`, + }); + + if (!sessionNames.test(prompt.session)) { + // This Run and no other. A Handler that writes a bad name writes it for one Prompt, and a + // Signal that produced several must not lose the rest of them to it. + return failed( + `is not a name pi will accept: a Session name is one or more of A-Z, a-z, 0-9, '.', '_' and '-', beginning and ending with a letter or a digit`, + ); + } + if (prompt.text.trim() === "") { + // The agent drops an empty message rather than answering it, so the Run would settle having + // said nothing and be recorded as a success. A failed Run rather than a throw, because + // everything else that can go wrong here is one and the Signal Worker treats them alike. + return failed("was given a Prompt with no text, so the agent would answer nothing"); + } + + const sessionFile = posix.join(sessionsDir, `${prompt.session}.jsonl`); + + let rpc: RpcChannel; + try { + rpc = await openRpcChannel(host, port); + } catch (error) { + return failed(messageOf(error)); + } + + // No Run id on this line. The Signal Worker is serial globally, so its own "Run started" and + // "Run finished" lines bracket this one, and the Run a connection belongs to is the one + // immediately above it. + log.debug({ session: prompt.session, sessionFile, host, port }, "connected to the agent"); + + try { + return await performRun(rpc, prompt, sessionFile, failed); + } catch (error) { + return failed(messageOf(error)); + } finally { + // Whatever happened. A connection left open is a `pi` process the Operator's listener + // started and will not reap, and with `fork` there is one per Run. + rpc.close(); + log.debug({ session: prompt.session, dropped: rpc.dropped() }, "closed the connection"); + } + }, }; } + +/** + * The four steps of one Run, strictly in sequence. + * + * `switch_session` is **create-or-resume**: a path that does not exist becomes a fresh Session kept + * at that path, and a path that does is loaded. That behaviour is undocumented, the whole design + * rests on it, and `get_state` is here because of it — the one thing that can tell "created it" + * apart from "did something else and said it went fine". Reading `sessionFile` back and comparing it + * to what was asked for costs one round trip per Run and is the difference between a Session that + * continues and a Session that silently starts over, or worse, a Prompt delivered into the Session + * the previous connection happened to leave open. + */ +async function performRun( + rpc: RpcChannel, + prompt: RunPrompt, + sessionFile: string, + failed: (why: string) => RunOutcome, +): Promise { + const switched = await rpc.send("switch_session", { sessionPath: sessionFile }); + if (!switched.success) { + return failed( + `could not be opened at ${sessionFile}: ${switched.error ?? "the Agent Instance refused the switch and said why nowhere"}`, + ); + } + if (switched.data?.cancelled === true) { + // `success: true` with `cancelled: true`, which is an extension of the Operator's refusing the + // switch in a `session_before_switch` handler. Prompting anyway would deliver this Prompt into + // whichever Session the instance is in, which is the failure this whole sequence exists to make + // impossible. + return failed( + `was not opened at ${sessionFile}: an extension of the Agent Instance cancelled the switch, so the agent is in some other Session and this Prompt is not for it`, + ); + } + + const state = await rpc.send("get_state"); + if (!state.success) { + return failed( + `could not be confirmed: the Agent Instance refused to say what state it is in${state.error === undefined ? "" : `: ${state.error}`}`, + ); + } + const reached = state.data?.sessionFile; + if (reached !== sessionFile) { + // Both, because either one alone leaves the reader guessing which end was wrong. + return failed( + `was asked for at ${sessionFile} and the Agent Instance is in ${typeof reached === "string" ? reached : JSON.stringify(reached)}, so the Prompt would go to the wrong Session`, + ); + } + + const prompted = await rpc.send("prompt", { message: prompt.text }); + if (!prompted.success) { + // A refusal before acceptance, which is the only failure `prompt` reports this way: anything + // that goes wrong afterwards arrives in the event stream instead. + return failed( + `was refused the Prompt: ${prompted.error ?? "the Agent Instance rejected it and said why nowhere"}`, + ); + } + + // Accepted, not finished. What ends the Run is `agent_settled` in the stream that follows. + const outcome = await readOutcome(rpc.records, prompt.session); + const dropped = rpc.dropped(); + if (outcome.ok || dropped === undefined) return outcome; + // The reader saw records stop; only the socket knows whether that was a failure. Appended rather + // than replacing the reader's sentence, because which record was missing is the useful half. + return { ok: false, error: `${outcome.error}. The connection failed: ${dropped}` }; +} + +/** What a thrown value says, for a Run's `error` column, which nothing parses. */ +function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/scheduler/index.ts b/src/scheduler/index.ts index 938bccc..a13e60a 100644 --- a/src/scheduler/index.ts +++ b/src/scheduler/index.ts @@ -33,7 +33,7 @@ * * const gateway = createGateway({ * databaseUrl: process.env.DATABASE_URL ?? "", - * runtime: createPiRuntime({ image: "my-agent:1" }), + * runtime: createPiRuntime({ host: "agent", port: 4000, sessionsDir: "/sessions" }), * // Not loopback: the agent reaches this server from a container of its own. * agentListen: { host: "0.0.0.0", port: 8081 }, * publicListen: { host: "0.0.0.0", port: 8080 }, diff --git a/src/signatures/index.ts b/src/signatures/index.ts index dbdeafe..81960ae 100644 --- a/src/signatures/index.ts +++ b/src/signatures/index.ts @@ -29,7 +29,7 @@ * * const gateway = createGateway({ * databaseUrl: process.env.DATABASE_URL ?? "", - * runtime: createPiRuntime({ image: "my-agent:1" }), + * runtime: createPiRuntime({ host: "agent", port: 4000, sessionsDir: "/sessions" }), * // Not loopback: the agent reaches this server from a container of its own. * agentListen: { host: "0.0.0.0", port: 8081 }, * publicListen: { host: "0.0.0.0", port: 8080 }, diff --git a/src/signatures/keys.test.ts b/src/signatures/keys.test.ts index 56e8293..5b86099 100644 --- a/src/signatures/keys.test.ts +++ b/src/signatures/keys.test.ts @@ -27,8 +27,8 @@ * a self-consistent verifier accepts it happily while every other library rejects it. * * The refusals are asserted at the **constructor**, synchronously, on the precedent of - * `createUsers` refusing a `tokenTtl` and `createAgentContainerRuntime` refusing an empty - * image: an ambiguous key throws before any server exists, so there is no HTTP surface it + * `createUsers` refusing a `tokenTtl` and `createPiRuntime` refusing a relative + * `sessionsDir`: an ambiguous key throws before any server exists, so there is no HTTP surface it * could have. Each of them is checked for naming what to pass and not merely for throwing, * because a refusal that does not say `signingAlg` leaves an Operator exactly where they were. * diff --git a/src/signatures/signatures.ts b/src/signatures/signatures.ts index 3799fc0..0460e21 100644 --- a/src/signatures/signatures.ts +++ b/src/signatures/signatures.ts @@ -75,7 +75,7 @@ export type SignaturesOptions = { * Where `POST /sign` is registered. * * The key stays in this process, and the agent reaches it only over that route, so a compromised - * Agent Container mints nothing once the Gateway is stopped. + * Agent Instance mints nothing once the Gateway is stopped. * * Structural: anything carrying a Fastify instance satisfies it. */ diff --git a/src/test-support/agent-instance.ts b/src/test-support/agent-instance.ts new file mode 100644 index 0000000..019dd7c --- /dev/null +++ b/src/test-support/agent-instance.ts @@ -0,0 +1,182 @@ +/** + * A fake Agent Instance: a TCP server that speaks `pi`'s RPC framing and says whatever a script + * tells it to. + * + * It stands in for `pi --mode rpc` behind the Operator's listener, which is the only thing the + * framework can see of an Agent Implementation now. That makes it the right size of fake: the seam + * is a socket carrying JSON lines, so a fake on the other end of a real socket exercises everything + * between "a Prompt exists" and "a Run is recorded" — the connection, the framing, the correlation + * by `id`, the sequence, the reading of the events — with no Docker, no image, no model and no + * network beyond loopback. + * + * What it deliberately cannot prove is that `pi` behaves as it is scripted to here. Two claims in + * particular are `pi`'s and not ours: that `switch_session` creates a Session at a path that does + * not exist, and that `get_state` answers with the path it was switched to. Those need the real + * program, and `../pi/agent-instance.test.ts` is where they are made. + * + * Every connection is scripted from the beginning, which is what `socat ...,fork` does: each one is + * its own `pi` process, so nothing a script is asked twice depends on the connection before it. + */ + +import { createServer, type Server, type Socket } from "node:net"; + +/** One record written back to the client, as a plain object. */ +export type Written = Record; + +/** + * A place in a scripted stream where the Agent Instance goes away mid-Run. + * + * Two of them, because the two are different events on the client's socket and the Runtime is meant + * to say so: a FIN is a peer that hung up, where an RST is `read ECONNRESET` and a process that + * died. Compared by identity, so neither can be confused with a record a script meant to write. + */ +export const dropsTheConnection: Written = { theInstanceHangsUp: true }; +/** The same, by way of an RST: the connection fails rather than ending. */ +export const resetsTheConnection: Written = { theInstanceDies: true }; + +/** + * What the fake says when a command arrives. + * + * The whole command is handed over, `id` included, so a script can answer with the wrong `id` on + * purpose — which is how the claim that responses are correlated rather than counted gets tested at + * all. Answering with nothing at all is a script saying "write no response", which is what a + * dropped connection looks like from the client's side. + */ +export type Reply = (command: Received) => readonly Written[] | Promise; + +/** One command the fake was sent. */ +export type Received = { + readonly id: unknown; + readonly type: string; +} & Record; + +/** A running fake, and what it saw. */ +export type FakeInstance = { + readonly host: string; + readonly port: number; + /** Every command received, in order, across every connection. */ + readonly received: Received[]; + /** How many connections have been opened, which is one per Run. */ + connections(): number; + /** How many of them have ended, which is how "the Runtime closes it" is asserted. */ + ended(): number; + close(): Promise; +}; + +/** + * Starts a fake Agent Instance on loopback and an ephemeral port. + * + * `reply` is called per command and answers with the records to write. It may write more than one — + * a response and then a stream of events is exactly the shape of `prompt` — and the records are + * written as separate socket writes, so a client that assumed one record per chunk is caught. + */ +export async function startFakeInstance(reply: Reply): Promise { + const received: Received[] = []; + let connections = 0; + let ended = 0; + let latest: Socket | undefined; + + const server: Server = createServer((socket) => { + connections += 1; + latest = socket; + socket.on("close", () => { + ended += 1; + }); + // The fake is not the subject, so a client that hung up mid-write is not a test failure. + socket.on("error", () => {}); + + // The fake reads LF-framed lines the way the client does, and for the same reason: a command + // carrying a U+2028 inside its Prompt is one record and not two. + let pending = ""; + socket.on("data", (chunk) => { + pending += chunk.toString("utf8"); + for (;;) { + const end = pending.indexOf("\n"); + if (end === -1) return; + const line = pending.slice(0, end); + pending = pending.slice(end + 1); + if (line.trim() === "") continue; + void answer(socket, JSON.parse(line) as Received); + } + }); + }); + + async function answer(socket: Socket, command: Received): Promise { + received.push(command); + for (const record of await reply(command)) { + if (socket.destroyed) return; + // The two sentinels are places in a script rather than records; see their declarations. + if (record === dropsTheConnection) { + socket.destroy(); + return; + } + if (record === resetsTheConnection) { + socket.resetAndDestroy(); + return; + } + socket.write(`${JSON.stringify(record)}\n`); + } + } + + await new Promise((listening) => server.listen(0, "127.0.0.1", listening)); + const address = server.address(); + if (address === null || typeof address === "string") throw new Error("the fake took no port"); + + return { + host: "127.0.0.1", + port: address.port, + received, + connections: () => connections, + ended: () => ended, + close: () => + new Promise((closed) => { + latest?.destroy(); + server.close(() => closed()); + }), + }; +} + +/** + * The records a settled Run is made of: an assistant message that answered, and the settle. + * + * Written out here because every case that is not about the events themselves needs a stream that + * succeeds, and a test that spelled one out each time would be a test about JSON. + */ +export function settledRun(said = "I did it."): readonly Written[] { + return [ + { type: "agent_start" }, + { + type: "message_end", + message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: said }] }, + }, + { type: "agent_settled" }, + ]; +} + +/** + * A reply that drives the whole sequence: a switch that succeeds, a state naming `sessionFile`, and + * a Prompt accepted and then settled. + * + * `sessionFile` is a function of the switch that arrived rather than a constant, because agreeing + * with whatever was asked for is what a healthy Agent Instance does and what every case that is not + * about the mismatch needs. + */ +export function scriptedInstance(events: readonly Written[] = settledRun()): Reply { + let switchedTo: unknown; + return (command) => { + const response = (extra: Written = {}): Written[] => [ + { type: "response", id: command.id, command: command.type, success: true, ...extra }, + ]; + switch (command.type) { + case "switch_session": + switchedTo = command.sessionPath; + return response({ data: { cancelled: false } }); + case "get_state": + return response({ data: { sessionFile: switchedTo, isStreaming: false } }); + case "prompt": + return [...response(), ...events]; + default: + return response(); + } + }; +} diff --git a/src/test-support/docker.ts b/src/test-support/docker.ts index 0c2e127..547451c 100644 --- a/src/test-support/docker.ts +++ b/src/test-support/docker.ts @@ -1,16 +1,15 @@ /** - * What the one opt-in container test needs of the machine it runs on. + * What the one opt-in end-to-end test needs of the machine it runs on. * * That test is **opt-in and skipped**, which is a deliberate trade rather than * timidity. It needs a container runtime, an image built from the network, and about * ten seconds; `npm run check` is the inner loop and the command CI is measured by, so - * a test that slow does not belong in it by default. Everything else about running an - * agent in a container is a fast test — the composed command line in - * `../pi/runtime.test.ts`, the pure functions over captured output in - * `../pi/output.test.ts`, and the stub container runtime in - * `../container/agent-container.test.ts` — so what is being skipped is exactly the three - * things nothing else can prove: that mounts resolve, that user ids match, and that a - * Session resumes. + * a test that slow does not belong in it by default. Everything the framework does with + * an Agent Instance is a fast test against a fake one — `../pi/runtime.test.ts` over a + * real socket, `../pi/framing.test.ts` and `../pi/output.test.ts` over bytes — so what is + * being skipped is exactly the claims that are `pi`'s own rather than ours: that + * `switch_session` creates a Session at a path that does not exist, that it resumes one + * that does, and that `get_state` answers with the path it was switched to. * * `npm run test:container` sets the variable. CI runs it as its own step. */ @@ -49,8 +48,8 @@ export const hostFromContainer = "host.docker.internal"; */ export const addHostToGateway = `--add-host=${hostFromContainer}:host-gateway`; -/** The image the container test runs, built from `./pi-image/Dockerfile`. */ -export const piImageTag = "concorde-pi-test:0.83.0"; +/** The image the end-to-end test runs, built from `./pi-image/Dockerfile`. */ +export const piImageTag = "concorde-pi-test:0.85.1"; /** * Why the container tests are being skipped, or `false` when they are not. @@ -89,11 +88,12 @@ export async function buildPiImage(): Promise { /** * A TCP port nothing is listening on. * - * Needed because the agent is told where the Agent server is in a file written before the - * Run, and that file has to carry the port, while the port is only known after listening - * — so the port is chosen first and both values are built from it. The gap between - * closing this socket and the server taking the port is a race in principle and has never - * been one in practice. + * Needed twice over. The agent is told where the Agent server is in a file written before + * any Run, and that file has to carry the port, while the port is only known after + * listening — so the port is chosen first and both values are built from it. The Agent + * Instance needs one for the same reason: the container publishes its listener on a port + * the Gateway is given before the container exists. The gap between closing this socket + * and something taking the port is a race in principle and has never been one in practice. */ export async function reservePort(): Promise { const socket = createServer(); diff --git a/src/test-support/fake-auth.ts b/src/test-support/fake-auth.ts index f8fca59..9b283f2 100644 --- a/src/test-support/fake-auth.ts +++ b/src/test-support/fake-auth.ts @@ -4,7 +4,7 @@ * The aggregate's whole subject is the walk: the order the schemes are asked in, where it stops, * what the refusal looks like on the wire, and what happens with nothing registered. A real Auth * would drag scrypt or a NIP-98 signature through every one of those assertions and prove none of - * them better. It exists for the reason `fake-runtime.ts` and `fake-container.ts` do. + * them better. It exists for the reason `fake-runtime.ts` and `agent-instance.ts` do. * * It does not register itself, where a real Auth registers at the end of its own constructor. A * test that calls `registerAuth` by hand is a test whose registration order is a line the reader diff --git a/src/test-support/fake-container.ts b/src/test-support/fake-container.ts deleted file mode 100644 index 5dab516..0000000 --- a/src/test-support/fake-container.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * A stand-in for the container runtime, so the adapter's own plumbing is a fast test. - * - * `containerCommand` is public API — it exists so `podman` works — and pointing it at - * this script is what lets everything between "compose the invocation" and "read the - * outcome" be exercised with no Docker, no image and no credentials: that the Prompt - * reaches stdin and the stream is closed, that the argv arrives as composed, that the - * outcome comes from the stream and not the exit code, and that stderr is not a verdict. - * - * What it deliberately cannot prove is anything about a real container: that mounts - * resolve, that user ids match, that a Session resumes. Those need Docker, and they are - * what the one opt-in test in `container.test.ts` is for. - * - * Run as a program — `node fake-container.ts '