Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Docker or the network:

- **`npm run check:package`** packs the tarball, installs it into a throwaway project, and proves
every `exports` subpath resolves there for Node *and* the type checker. It is also the only thing
anywhere that holds the thirteen table names apart, and the only proof that the root still
anywhere that holds the fourteen table names apart, and the only proof that the root still
resolves to nothing.
- **`npm run test:container`** is the single end-to-end test: a real container running real `pi`
against the real Agent server, with a scripted model on localhost, so it needs no model
Expand Down Expand Up @@ -114,7 +114,7 @@ Four ways that fails quietly, all of them a deployment's to get right:
schema module declares carries a component prefix, because **`export *` drops a name that resolves
to more than one binding**: eight components exporting a bare `schema` yield a barrel exporting
none, `schemaFilter` derives to one schema, and `push` prints `Changes applied` having created one
component's tables. Two names are in that class and no more; the thirteen table names and the four
component's tables. Two names are in that class and no more; the fourteen table names and the four
enum arrays are not prefixed.
- **The table names are guarded by `scripts/check-package.ts`, which imports all eight `/schema`
specifiers un-aliased into one module scope.** A duplicate is a `TS2300` and a `SyntaxError`
Expand Down
44 changes: 42 additions & 2 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ The interchangeable agent program at the centre of the architecture. `pi` is the
_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`, 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.
What the Signal Worker hands a Prompt to and gets an outcome back from, and the narrowest interface in the framework: one method. The outcome carries the Run's Usage on success and on failure, which is why a Runtime returns a failure rather than throwing: a call that throws has no Usage to save, and neither does one that never returns. 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 Instance**:
Expand Down Expand Up @@ -129,7 +129,13 @@ Operator-defined JSON a Signal Handler writes on a Prompt beside the Session and
_Avoid_: scope, claims, permissions, grant, ACL

**Post phase**:
The Signal Handler's second entry point, for cleanup. Runs once after all Runs arising from a Signal have finished, receives a flag saying whether one failed, and cannot produce Prompts.
The Signal Handler's cleanup step. It runs once after all Runs for a Signal finish. It also runs if `handle` throws an error. This step cannot produce Prompts.

It receives two values:

- `failed`: true if `handle` or any Run failed.
- `limited`: true if any Run exceeded its Usage limit.

_Avoid_: post handler, teardown, finalizer, callback

**Session**:
Expand All @@ -140,6 +146,40 @@ _Avoid_: chat, conversation, thread, context (as a name for a Session; a Prompt'
One execution of the agent: a single Prompt, in one Session, producing whatever the agent emits.
_Avoid_: turn, job, invocation, task

**Usage**:
The tokens a Run uses when it calls a model. Usage is also the name of the built-in Component that reads saved usage totals. This Component is always enabled.

The Runtime returns the usage of every model call that finished during a Run in the Run's outcome, whether the Run succeeded or failed. Retries count too, as do calls that shorten the Session's history by summarizing it. When the Run ends, the Signal Worker saves that usage as **Usage records** in the same transaction that marks the Run done or failed. Saved records are kept after a restart. Two things follow from saving at the end. A Run's usage is not in any total until the Run ends. If the process stops during a Run, that Run's usage is lost.

The Agent Implementation passes on the token counts reported by the model provider. Tokens do not have a single price: input, output, cache reads, and cache writes can each cost a different amount.

The Usage Component can add up records for one User and a chosen time period. It does not decide how much a User may use. The Operator writes that rule in a Signal Handler.

We use the name "Usage" because "Token" already means the credential a User uses to authenticate.
_Avoid_: Token Tracker, billing, metering, quota

**Usage record**:
A database row for one model call in a Run. It stores:

- The provider and model used.
- The input, output, cache read, and cache write token counts.
- The total of those four counts.
- The cost calculated by the Agent Implementation.

A record belongs to one Run and does not store a User. Its User is the User on its Run, which a Signal Handler sets on the Prompt as `userId`. Each record is saved once and never changed.

**Usage limit**:
The token limit for one Run. A Signal Handler sets it on the Prompt as `usageLimit`.

The Runtime adds up tokens as each model call finishes. If the total exceeds the limit, it stops the agent. The Run fails with `limited: true`, and its outcome still carries the usage of every call, including calls that finished after the stop. The Session keeps the history saved so far.

Usage is only known after a model call finishes. This means the Run can go over the limit by one call.

Every token counts equally toward this limit, including tokens read from the provider's cache. Each model call sends the agent's full context again. The provider reports most of that repeated context as cache reads. Cache reads therefore usually make up most of the token count, even though they cost much less.

The framework only enforces a limit for each Run. To enforce a User's budget across Runs, the Signal Handler sets `usageLimit` to the number of tokens the User has left. A Prompt with no `usageLimit` is never stopped for usage.
_Avoid_: budget, quota, max tokens

**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 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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ from the registry rather than this checkout.
| [Decisions](./examples/02_decisions/) | Two users and a shared record of signed decisions |
| [Nostr](./examples/03_nostr/) | Encrypted direct messages through a local relay |
| [Agent isolation](./examples/04_agent_isolation/) | Restricted agent networking and a model proxy that holds the credential |
| [Usage limits](./examples/05_usage_limits/) | Two users, each with a daily token limit |

## Deployment boundaries

Expand Down
3 changes: 3 additions & 0 deletions examples/05_usage_limits/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
state
.env
6 changes: 6 additions & 0 deletions examples/05_usage_limits/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
ANTHROPIC_API_KEY=

ALICE_PASSWORD=correct horse battery staple
BOB_PASSWORD=chased by a dog on a bicycle

DAILY_TOKEN_LIMIT=20000
26 changes: 26 additions & 0 deletions examples/05_usage_limits/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Shared agent instructions

You help one user through a gateway. Keep answers brief.

## Gateway API

Use `curl` in the shell to call the API at `$AGENT_SERVER_URL`. You do not need a credential.
Read the current API description before making a request:

```sh
curl -s "$AGENT_SERVER_URL/openapi.json"
```

The API description lists the request fields and error responses. If a request fails, read the
response and fix the request before trying again. Do not keep sending the same invalid request.

## Reply to the user

Send replies with `POST /messages` and body `{"userId":"...","text":"..."}`.
Copy `userId` from the Signal you are handling. Never invent one. Your final model response and
workspace files are not delivered to the user.

Read earlier messages with `GET /messages?user=<id>`. The log includes messages from both the
user and the agent. Use the returned cursors to request more pages. Write short, plain sentences
without headings, tables, or code blocks. The terminal client checks for messages once a second,
so the user may see your reply after your Run finishes.
10 changes: 10 additions & 0 deletions examples/05_usage_limits/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FROM node:24-alpine

WORKDIR /app

COPY package.json ./
RUN npm install --no-audit --no-fund

COPY main.ts drizzle.config.ts schema.ts ./

CMD ["node", "main.ts"]
8 changes: 8 additions & 0 deletions examples/05_usage_limits/Dockerfile.agent
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM node:24-alpine

RUN apk add --no-cache curl socat

RUN npm install -g @earendil-works/pi-coding-agent@0.85.1

WORKDIR /workspace
ENV PI_CODING_AGENT_DIR=/home/agent/.pi/agent
120 changes: 120 additions & 0 deletions examples/05_usage_limits/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Usage limits example

Alice and Bob talk to one agent over HTTP. Each user has a daily model token limit. When Alice
reaches her limit, the gateway replies to her next message without calling the model. Bob can
still get answers. This example defines the daily limit in `main.ts`. The framework records
Usage and stops each Run if it goes over its Usage limit.

## Requirements

This example requires Usage support in `@shutter-network/concorde`, so `package.json` specifies
`^0.2.0`. Until that release is published to npm, `npm install` and `docker compose up --build`
will fail. The example installs Concorde from the npm registry, not from this checkout.

## Run

You need Docker with Compose and an Anthropic API key. From this directory:

```sh
cp .env.example .env
```

Set `ANTHROPIC_API_KEY`, `ALICE_PASSWORD`, and `BOB_PASSWORD` in `.env`. `DAILY_TOKEN_LIMIT` is
the number of model tokens each user may use between midnight UTC and the next midnight UTC.
The value in `.env.example` is small so you can reach the limit with a few messages. Check the
model in `settings.json`.

```sh
docker compose up -d --build
docker compose logs -f gateway
```

The log lists each user's id and name. Open the two clients in separate terminals:

```sh
docker compose run --rm tui-alice <alice-id>
docker compose run --rm tui-bob <bob-id>
```

Send messages as Alice until the agent stops answering and she receives "Your daily usage limit
is reached. It resets at 00:00 UTC." Every further message from her that day gets the same
answer from the gateway, and the model is not called. Then send a message as Bob: his Usage is
counted separately, so he still gets answers.

The public API documentation is at <http://127.0.0.1:8085/docs>.

## How it works

`main.ts` uses a custom Signal Handler for `messageReceivedKind`. The handler checks Usage
before deciding whether to prompt the agent, so it does not use `templateHandler`.

- `handle` calls `usage.total` to count the sender's tokens since 00:00 UTC today. If the total
is at or above `DAILY_TOKEN_LIMIT`, it sends the limit message through the Messenger and
returns no Prompt, so no Run starts. Otherwise, it returns one Prompt with the sender's
`userId` and sets `usageLimit` to the number of tokens they have left for the day. The Run's
Usage is added to that user's total.
- `post` receives the Run's outcome. If `limited` is true, the Run exceeded its Usage limit
and the handler sends the limit message. For any other failure, it sends "I could not complete
that request. Please try again in a minute."

The prompt wraps the sender's text in a random delimiter from `fence` with `quote`, as the other
examples do, so the agent can tell the message apart from the instructions around it.

The Prompt's `userId` comes from the Message authenticated by the Gateway. It never comes from
text written by a model or sender. The Session name includes the user id, so later Runs continue
that user's Session. Stopping a Run at its Usage limit preserves the Session. The user's next
allowed message on the following day continues the same Session.

On the first start, trusted application code creates both users with their names and passwords
in one transaction. Restarts keep the saved users and passwords. Changing passwords in `.env`
does not change the saved passwords, even though the example prints the `.env` values again.

## Limit behavior and requirements

- **Usage can exceed the limit by one model response.** Usage is counted when each response
ends. The Run stops once its total exceeds `usageLimit`, but the provider still charges for
that response. A user's daily total can therefore exceed `DAILY_TOKEN_LIMIT`. The gateway
replies to their next message without calling the model.
- **A Run's Usage is saved when the Run ends.** The gateway handles one message at a time, so
the check in `handle` always sees the Usage of every earlier Run. If the gateway stops during a
Run, that Run's Usage is not saved.
- **The model server must report usage.** The counts are the ones the model provider returns
with each response, copied by `pi`. Some self-hosted and OpenAI-compatible servers report no
usage. Those responses are recorded as 0 tokens, so the limit never takes effect. If you
configure another provider in `models.json`, check that it reports usage before relying on
the limit. Responses interrupted mid-stream can also report fewer tokens than were used.
- **The counts are tokens, not money.** `DAILY_TOKEN_LIMIT` is compared with the total of input,
output, cache read, and cache write tokens. Providers price those categories differently, so
equal token totals can cost different amounts.
- **Cached tokens count toward the limit.** Every model call sends the agent's full context
again: the system prompt, `AGENTS.md`, the tool definitions, and the conversation so far.
The provider reuses most of this context from its cache and reports those tokens as cache reads.

A reply is usually a few hundred output tokens. But including the context, one message can use
4,000 to 10,000 tokens in total. This total grows as the conversation gets longer.

Cache reads usually cost about a tenth of the normal input price. Even so, each cached token
counts as one token toward `DAILY_TOKEN_LIMIT`. Account for these tokens when choosing a limit.
To give each token category a different weight, calculate the total in your Signal Handler
using the separate counts from `usage.total()`, rather than its `totalTokens` field.
- **Each user has a separate limit.** Runs without a `userId` do not count toward any user's
total. To set a hard spending cap for the whole deployment, enforce it at the model provider
or a model proxy that keeps the credential, as in
[the isolation example](../04_agent_isolation/).

## Files and storage

- `main.ts`: component setup, the daily limit handler, user creation, and shutdown.
- `AGENTS.md`: instructions made available inside the agent container.
- `settings.json`: model configuration.
- `compose.yml` and the Dockerfiles: gateway, pi RPC listener, database, migration, and two
clients.
- `schema.ts`: tables for the four components. The Signals schema already includes the Usage
table, so there is no extra schema to add.
- `state/workspace/` and `state/sessions/`: persistent agent files and session records.

Keep the agent server and RPC listener private. The [guide](../../site/guide.md) explains the
shared deployment setup.

Stop with `docker compose down`. Add `-v` to delete the database volume, which also deletes the
recorded Usage; local `state/` files remain.
Loading
Loading