Skip to content
Closed
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
19 changes: 9 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,9 @@ order to create and drop others: no two test files share a database. See
Three further commands are each their own CI step, kept out of the inner loop because each needs
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
resolves to nothing.
- **`npm run check:package`** builds the package tarball and installs it into a temporary project.
It checks that Node and the type checker can load every `exports` subpath. It is also the only
check that verifies all fourteen table names are unique and the package root cannot be imported.
- **`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
credentials. About ten seconds. `CONCORDE_CONTAINER_TESTS` opts in.
Expand Down Expand Up @@ -110,12 +109,12 @@ Four ways that fails quietly, all of them a deployment's to get right:
types and yields no table and no schema object at all. The exception is eight names and no table
among them — the four enum arrays and the unions derived from them, which a reader of a record has
to be able to name. That overlap is permanent.
- **The schema object is `<component>Schema` and the wrapper is `<component>Tables`.** A name every
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
enum arrays are not prefixed.
- **Name the schema object `<component>Schema` and the table wrapper `<component>Tables`.**
The component prefix keeps these names unique when one module re-exports all schemas with
`export *`. If several components export the same name, such as `schema`, that name is left out
of the combined exports. Then `schemaFilter` selects only one schema, and `push` prints
`Changes applied` even though it created only that component's tables. Only the schema object
and table wrapper need these prefixes. The fourteen table names and four enum arrays do not.
- **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`
naming both import lines. **An alias added to one of those lines removes the check for that
Expand Down
42 changes: 41 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
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 reports usage after each model call. Retries count too, as do calls that shorten the Session's history by summarizing it. The Signal Worker saves each report as a **Usage record** for that Run as soon as it arrives. Saved records are kept after a restart. If saving fails, the Run stops so it does not keep using the model without recording usage.

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 a chosen time period. It does not decide how much a User may use. The Operator writes that rule in a Signal Handler, including the budget, the time period it covers, and what to tell the User.

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.

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`. 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.
_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
114 changes: 114 additions & 0 deletions examples/05_usage_limits/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# 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'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.
- **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.
112 changes: 112 additions & 0 deletions examples/05_usage_limits/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: concorde-example-05-usage-limits

x-database-url: &database-url postgres://concorde:concorde@postgres:5432/concorde

x-tui: &tui
build:
context: .
dockerfile: Dockerfile
profiles: [cli]
entrypoint: ["npx", "concorde-chat-tui"]
networks: [public]

services:
gateway:
build:
context: .
dockerfile: Dockerfile
environment:
DATABASE_URL: *database-url
ALICE_PASSWORD: ${ALICE_PASSWORD:?copy .env.example to .env}
BOB_PASSWORD: ${BOB_PASSWORD:?copy .env.example to .env}
DAILY_TOKEN_LIMIT: ${DAILY_TOKEN_LIMIT:?copy .env.example to .env}
PUBLIC_HOST: 0.0.0.0
PUBLIC_PORT: "8085"
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:8085:8085"
networks: [db, agent, public]
depends_on:
postgres:
condition: service_healthy
migrate:
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: .
dockerfile: Dockerfile
command: ["npx", "drizzle-kit", "push", "--force"]
environment:
DATABASE_URL: *database-url
restart: "no"
networks: [db]
depends_on:
postgres:
condition: service_healthy

postgres:
image: postgres:17
environment:
POSTGRES_USER: concorde
POSTGRES_PASSWORD: concorde
POSTGRES_DB: concorde
volumes:
- db:/var/lib/postgresql/data
networks: [db]
healthcheck:
test: ["CMD", "pg_isready", "-U", "concorde", "-d", "concorde"]
interval: 3s
timeout: 3s
retries: 20

tui-alice:
<<: *tui
environment:
CONCORDE_GATEWAY_URL: http://gateway:8085
CONCORDE_PASSWORD: ${ALICE_PASSWORD:?copy .env.example to .env}

tui-bob:
<<: *tui
environment:
CONCORDE_GATEWAY_URL: http://gateway:8085
CONCORDE_PASSWORD: ${BOB_PASSWORD:?copy .env.example to .env}

networks:
db:
name: concorde_usage_limits_db
agent:
name: concorde_usage_limits_agent
public:
name: concorde_usage_limits_public

volumes:
db:
Loading
Loading