@a-dray/aglib 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +55 -136
  2. package/dist/agent.d.ts +12 -0
  3. package/dist/agent.js.map +1 -1
  4. package/dist/harness/adapters/acp/index.d.ts +27 -11
  5. package/dist/harness/adapters/acp/index.js +58 -24
  6. package/dist/harness/adapters/acp/index.js.map +1 -1
  7. package/dist/harness/adapters/native/compaction.d.ts +15 -0
  8. package/dist/harness/adapters/native/compaction.js +23 -8
  9. package/dist/harness/adapters/native/compaction.js.map +1 -1
  10. package/dist/harness/adapters/native/loop.d.ts +18 -1
  11. package/dist/harness/adapters/native/loop.js +0 -1
  12. package/dist/harness/adapters/native/loop.js.map +1 -1
  13. package/dist/harness/harness.d.ts +17 -6
  14. package/dist/harness/harness.js.map +1 -1
  15. package/dist/model/adapters/anthropic/index.js +21 -4
  16. package/dist/model/adapters/anthropic/index.js.map +1 -1
  17. package/dist/model/index.d.ts +0 -1
  18. package/dist/model/index.js +1 -1
  19. package/dist/model/index.js.map +1 -1
  20. package/dist/model/model.d.ts +8 -1
  21. package/dist/model/model.js +8 -1
  22. package/dist/model/model.js.map +1 -1
  23. package/dist/render.d.ts +100 -0
  24. package/dist/render.js +370 -0
  25. package/dist/render.js.map +1 -0
  26. package/dist/run.js +1 -0
  27. package/dist/run.js.map +1 -1
  28. package/dist/session/entry.d.ts +3 -2
  29. package/dist/session/entry.js.map +1 -1
  30. package/dist/session/messages.d.ts +9 -0
  31. package/dist/session/messages.js +5 -5
  32. package/dist/session/messages.js.map +1 -1
  33. package/dist/store/adapters/sqlite.js +25 -2
  34. package/dist/store/adapters/sqlite.js.map +1 -1
  35. package/dist/terminal.d.ts +23 -0
  36. package/dist/terminal.js +90 -0
  37. package/dist/terminal.js.map +1 -0
  38. package/dist/tools/execute.js +3 -1
  39. package/dist/tools/execute.js.map +1 -1
  40. package/dist/tools/tool.d.ts +8 -1
  41. package/dist/tools/tool.js.map +1 -1
  42. package/package.json +10 -1
package/README.md CHANGED
@@ -1,171 +1,90 @@
1
1
  # aglib
2
2
 
3
- A small TypeScript toolkit for building **your own agent harness**, inside an application you own.
3
+ A small TypeScript toolkit for building **production agents** the kind with a
4
+ durable log, a permission rule on every call, somewhere contained to run, and an
5
+ answer for what happens when the process dies mid-turn.
4
6
 
5
- You bring the product — users, tenancy, channels, UI, deployment, policy. aglib gives you a
6
- session log you can resume, a loop that runs against it, tools under your own permission rule,
7
- somewhere contained to run them, and four seams with adapters: model, store, sandbox, harness.
7
+ You bring the product — users, tenancy, channels, UI, deployment, policy. aglib
8
+ gives you a session log that *is* the state, a loop that runs against it, tools
9
+ under your own permission rule, somewhere contained to run them, and four seams
10
+ with adapters: **model, store, sandbox, harness**.
8
11
 
9
- > **Pre-release.** The API changes without deprecation aliases, so pin an exact version: `bun add aglib@npm:@a-dray/aglib@<version>`. Both recipes run. The unscoped name is a lookalike of an existing package; the alias keeps imports as `aglib`.
12
+ Whose loop does the reasoning is a decision here, not an assumption. Write your
13
+ own, compose a vendor's agent library, or drive one over a protocol — the log,
14
+ the rendering, the accounting and the handoff are the same in all three, and what
15
+ *differs* is declared rather than discovered.
10
16
 
11
- ## The shape
12
-
13
- ```text
14
- src/
15
- json.ts result.ts content.ts agent.ts run.ts
16
-
17
- session/ entry.ts log.ts messages.ts the log — this IS the session's state
18
- tools/ tool.ts execute.ts declaring and running tools
19
-
20
- model/ model.ts adapters/{openai-compatible,anthropic,fake}/ conformance.ts
21
- store/ store.ts adapters/sqlite.ts conformance.ts
22
- sandbox/ sandbox.ts adapters/{local,docker} conformance.ts
23
- harness/ harness.ts adapters/{native,acp}/
24
- ```
25
-
26
- Four ports, each `<port>.ts` → `adapters/`. Read one and you can predict the rest. Our own loop is
27
- `adapters/native`, beside the others rather than above them: it is one implementation of the port,
28
- and the only thing special about it is that it holds every control point.
29
-
30
- **The log is the state, not a record of it.** The loop re-projects its context from committed
31
- entries every turn and appends results back. There is one representation of a conversation, so
32
- nothing can drift from it and no test has to prove two views agree.
33
-
34
- **One session hands work to another in a single write.** `append({ entries, enqueue })` commits what
35
- this session did and what another receives, together or not at all — so spawning a child, replying
36
- to a parent and messaging a peer are one operation, and a handoff cannot be half-done. A delivery
37
- names where in the recipient's loop it lands: `interrupt` ends the running turn, `turn` is folded into it before its next model call, `next` waits for the one after.
38
-
39
- **A killed worker's session is finishable.** `store.next()` answers what has been asked for;
40
- `store.interrupted()` answers what was being worked when a process stopped existing. Either hands back a
41
- claim, and a claim is what a run takes:
42
-
43
- ```ts
44
- runAgent({ agent, store, sessionId, input: "..." }); // a caller sends
45
- runAgent({ agent, store, claim }); // a worker runs what it was handed
46
- ```
17
+ > **Pre-release.** The API changes without deprecation aliases, so pin an exact version: `bun add aglib@npm:@a-dray/aglib@<version>`. The unscoped name is a lookalike of an existing package; the alias keeps imports as `aglib`.
47
18
 
48
- The claim carries the session, the position and the queue together, so a worker lines nothing up by
49
- hand. A claim with an empty queue is a resumption: the log is continued rather than begun again, and
50
- the tool call that already ran is read from it, never issued twice. A harness that cannot restart
51
- from history ends that run instead of continuing it, so a session nothing can finish is closed once
52
- rather than handed out for ever.
53
-
54
- **A model is a value, not a name.** A `Model` is a provider, a credential and a model id together;
55
- picking a different one is picking a different `Model`, and routing between them is a record lookup
56
- you write. Nothing here resolves a name, so nothing here owns a naming convention — names come *out*
57
- (`ModelResponse.model`, and what `price` reads) and never go in.
58
-
59
- **You are told when to look, and you still keep a heartbeat.** `store.watch()` is an optional
60
- interrupt line — which session moved, whether it has work owed, nothing else. A wake may be spurious
61
- or lost by design, so it removes latency rather than the need to ask; without one, a worker polls and
62
- that interval is the whole of a message's latency.
63
-
64
- **Where a port has more than one implementation, it has one executable contract.**
65
- `aglib/model/conformance`, `aglib/store/conformance` and `aglib/sandbox/conformance` are the cases an
66
- adapter must pass, as an inert list you run under your own test framework. Point one at a model, a
67
- store or a sandbox you wrote and find out whether it means what the interface says. A subject
68
- declares what it actually does — whether its output streams arrive apart, how far its change feed
69
- reaches — and the suite holds it to exactly that rather than assuming.
70
-
71
- ## Quickstart
19
+ ## Run one
72
20
 
73
21
  ```bash
74
22
  bun install --frozen-lockfile
75
23
  echo 'OPENROUTER_API_KEY=sk-or-...' > .env
76
- bun run recipe personal-agent "what did I decide about pricing?"
77
- ```
78
24
 
79
- ```ts
80
- import { runAgent, defineTool, textOf } from "aglib";
81
- import { createNativeHarness } from "aglib/harness";
82
- import { createOpenRouterModel } from "aglib/model/adapters/openai-compatible";
83
- import { createSqliteStore } from "aglib/store/adapters/sqlite";
84
- import { Database } from "bun:sqlite";
85
- import { z } from "zod";
86
-
87
- const bookkeeper = {
88
- id: "bookkeeper", version: "1",
89
- instructions: "Answer from the ledger.",
90
- harness: createNativeHarness({
91
- model: createOpenRouterModel({ apiKey: process.env.OPENROUTER_API_KEY!, model: "deepseek/deepseek-v4-flash" }),
92
- }),
93
- tools: [defineTool({
94
- name: "read_ledger",
95
- description: "Read the September ledger.",
96
- annotations: { readOnly: true },
97
- schema: z.object({}),
98
- execute: () => ({ content: "September closes at 1250 GBP." }),
99
- })],
100
- };
101
-
102
- const run = runAgent({
103
- agent: bookkeeper,
104
- store: createSqliteStore({ database: new Database("agent.db") }),
105
- input: "What is the September balance?",
106
- });
107
-
108
- for await (const update of run) if (update.type === "text.delta") process.stdout.write(update.text);
109
- const result = await run.result;
110
- if (result.status === "completed") console.log(textOf(result.output));
25
+ bun run recipe native-agent # a conversation, in your terminal
26
+ bun run recipe native-agent "what did I decide?" # …or one shot, for a pipe
111
27
  ```
112
28
 
113
- Drop `store` and the same agent runs entirely in memory. Durability is a composition choice,
114
- not a different program.
115
-
116
- A run answers with `usage` on every outcome — completed, cancelled or failed — summed from the entries it committed, so a harness reports no total of its own and a run that burned tokens and then failed says so.
117
-
118
- There is no price list here and no spend ceiling. The counts are the fact; the rates are yours, and so is what to do when a run gets expensive.
29
+ ## The shape
119
30
 
120
- ## Recipes
31
+ ```text
32
+ src/
33
+ json.ts result.ts content.ts agent.ts run.ts render.ts terminal.ts
121
34
 
122
- Two applications, chosen because between them they exercise every seam. They are the
123
- specification: anything with no call site in one of them should not exist.
35
+ session/ the log this IS the session's state
36
+ tools/ declaring and running tools
37
+ model/ store/ sandbox/ harness/ each: <port>.ts → adapters/
38
+ ```
124
39
 
125
- | Recipe | What it proves |
126
- | --- | --- |
127
- | [`recipes/personal-agent`](recipes/personal-agent/README.md) | The log, compaction, context lifetimes, tools, session search. Memory built entirely *on* the library, not in it. |
128
- | [`recipes/agent-service`](recipes/agent-service/README.md) | The queue, atomic cross-session handoff, a harness per session, a sandbox per session, live views. |
40
+ Four ports, each with adapters. Three ship an executable conformance suite;
41
+ `harness` does not, because what one must prove depends on what it declares. Our
42
+ own loop is `harness/adapters/native`, beside the others rather than above them.
129
43
 
130
- ## What it is not
44
+ **`src/` is the library** — everything published to npm, and nothing that knows
45
+ what your agent is for. **`recipes/` is where most of it earns its place** —
46
+ runnable programs that each answer one production question. An export needs a
47
+ consumer: a recipe that composes it, or a conformance suite run against an
48
+ implementation here. A surface with neither fails `bun run check`.
131
49
 
132
- Not a workflow engine, channel gateway, scheduler, memory product, prompt registry, deployment
133
- control plane, or finished agent. It does not make model output trustworthy, and a local
134
- sandbox is a host process, not a sandbox — ask for `isolation: "required"` and a provider that
135
- cannot deliver it fails rather than pretending. `adapters/docker` is the one that can: a container
136
- from the local daemon, no account and no vendor SDK. A hosted box is yours to adapt, and
137
- `agent-service` shows one.
50
+ ## Three recipes
138
51
 
139
- It also ships no price list. Rates go stale between releases and only one provider ever reported a
140
- cost, so the log carries token counts and the money is arithmetic over a table you pass in.
52
+ One per answer to "who does the reasoning". Each is the smallest thing that
53
+ still makes its point, and between them they exercise every seam.
141
54
 
142
- ## Four ways in
55
+ | Recipe | Whose loop | What it shows |
56
+ | --- | --- | --- |
57
+ | [`native-agent`](recipes/native-agent/README.md) | ours | The whole surface in one program — memory, skills, subagents, a sandbox you choose — and the one thing only our own loop offers: a message reaching a busy session mid-turn. |
58
+ | [`vendored-agent`](recipes/vendored-agent/README.md) | a vendor's, from its library | What a vendor library must expose for the log to stay the state. Pi's tools are values you re-point at your sandbox and its transcript is a field you assign, so an interrupted run continues — `recovery: "history"`. Its README says what the tiers below that get you. |
59
+ | [`coding-agent`](recipes/coding-agent/README.md) | a vendor's, over a protocol | The cheapest containment on offer: any ACP agent started *inside* your sandbox, its work arriving as your entries, for one row of argv. |
143
60
 
144
- aglib is for building the **outer** harness — the durable session, the tools, the policy, the
145
- orchestration — around whatever does the reasoning inside.
61
+ ## Where to read next
146
62
 
147
- | You want | You do |
63
+ | Question | Document |
148
64
  | --- | --- |
149
- | A loop you own end to end | Use `adapters/native` |
150
- | Your own reasoning strategy | Write a `Harness` and drop it in |
151
- | A vendor SDK, deeply integrated | Wrap it as a `Harness` — you keep its prompt, tools and settings, and choose which to override |
152
- | Somebody else's whole agent, cheaply | Use `adapters/acp` — any agent in the ACP registry, still inside your log, tools and sandbox |
65
+ | What is this, for whom, and why? | [`docs/PRODUCT.md`](docs/PRODUCT.md) |
66
+ | What is the outward surface? | [`docs/INTERFACE.md`](docs/INTERFACE.md) |
67
+ | How does it work, and who owns each part? | [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) |
68
+ | How is the source written? | [`docs/CODE.md`](docs/CODE.md) |
69
+ | How do I work in here? | [`AGENTS.md`](AGENTS.md) |
153
70
 
154
- The last two are not equivalent, and `recipes/agent-service` publishes a table of exactly which
155
- control points each one gives you.
71
+ [`docs/REFERENCE.md`](docs/REFERENCE.md) is generated by `bun run docs`.
156
72
 
157
73
  ## Development
158
74
 
159
75
  ```bash
160
76
  bun run check # typecheck, tests, build, Node verification, recipes, docs, invariants
161
- bun run recipe personal-agent "..."
162
77
  ```
163
78
 
164
- `bun run check` is the green gate. A failing check is a decision, not an obstacle: fix the code,
165
- or change the check deliberately in the same commit with the reason in the message.
79
+ `bun run check` is the green gate. A failing check is a decision, not an
80
+ obstacle: fix the code, or change the check deliberately in the same commit
81
+ with the reason in the message.
166
82
 
167
- See [`AGENTS.md`](AGENTS.md) for the map, and [`docs/`](docs/) for the four documents that own
168
- the product.
83
+ Live tests cost money and are opt-in twice — the credential, and a flag:
84
+ `AGLIB_LIVE_MODEL=1` for the cases that reach a provider, `AGLIB_LIVE_ACP=1` for
85
+ the ones that start a vendor's agent over `npx`. The Docker cases need no flag:
86
+ they run where a daemon and the image are already there, and name themselves
87
+ when they skip.
169
88
 
170
89
  ## License
171
90
 
package/dist/agent.d.ts CHANGED
@@ -15,6 +15,18 @@ export interface Agent {
15
15
  tools?: readonly Tool[];
16
16
  /** Per-call policy over parsed arguments. Absent means every call executes. */
17
17
  decide?: Decide;
18
+ /**
19
+ * Name each arrival's sender in the turn the model reads, as `[from kind id]`.
20
+ *
21
+ * Off by default, because two separate things were one: `from` is provenance
22
+ * the log holds whatever this says, and this is text in a prompt. An
23
+ * application that renders its own attribution into the input it delivers —
24
+ * most do, since only they can name a sender in words their agent knows —
25
+ * would otherwise have the model read two names for one sender, one of them a
26
+ * session id that means nothing to it. Turn it on where nothing else says who
27
+ * wrote.
28
+ */
29
+ attribution?: boolean;
18
30
  /** Runs once as an activation ends, whatever ended it; its deliveries commit with the final entries. */
19
31
  finished?(run: {
20
32
  sessionId: string;
package/dist/agent.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"agent.js","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"","sourcesContent":["import type { Content } from \"./content.js\";\nimport type { Harness } from \"./harness/harness.js\";\nimport type { Decide, Tool } from \"./tools/tool.js\";\nimport type { Delivery, Runnable, Store } from \"./store/store.js\";\nimport type { Update } from \"./harness/harness.js\";\nimport type { Failure } from \"./result.js\";\nimport type { From, Usage } from \"./session/entry.js\";\n\n/** An inert declaration. Nothing here opens a connection or holds state. */\nexport interface Agent {\n id: string;\n /** Bump when instructions, tools or configuration stop being compatible with an existing session. */\n version: string;\n instructions: Content;\n harness: Harness;\n tools?: readonly Tool[];\n /** Per-call policy over parsed arguments. Absent means every call executes. */\n decide?: Decide;\n /** Runs once as an activation ends, whatever ended it; its deliveries commit with the final entries. */\n finished?(run: {\n sessionId: string; runId: string;\n outcome: \"completed\" | \"failed\" | \"cancelled\";\n output: Content;\n }): readonly Delivery[] | Promise<readonly Delivery[]>;\n /**\n * Ceilings on one activation, over the facts this library holds: turns and\n * tool calls are on the log, and a deadline is the clock.\n *\n * There is no ceiling on money, and the asymmetry is the point. A spend limit\n * would have to read a rate table the library does not have and should not\n * carry, so it would take a function from the caller and then need defending\n * against it — a code for \"you declared a ceiling nothing can hold\", a rule\n * for a generation the function could not price, a check for a price that is\n * negative or not a number. All of that is the cost of enforcing something\n * from a fact we do not own, and enforcing it is policy besides: the two\n * applications that wanted one wanted it to stop at different moments.\n *\n * `RunResult.usage` is what a caller needs from here, and it is solid. What\n * that costs, and what to do about it, is priced by whoever holds the rates.\n */\n limits?: { maxTurns?: number; maxToolCalls?: number; deadline?: string };\n}\n\n/**\n * How an activation ended, and what it consumed on the way.\n *\n * `usage` is on every outcome, not only a completed one: a run that burned four\n * dollars of tokens and then failed burned them, and a caller that has to ask\n * the log to find that out has been handed a result missing the expensive half\n * of what happened.\n *\n * Summed from the `assistant` entries this activation committed, which is why a\n * harness does not report it — the log already holds every generation, and a\n * second total is a second answer. Money is not here: the counts are the fact,\n * and the rates that turn them into money belong to the deployment.\n */\nexport type RunResult =\n | { status: \"completed\"; output: Content; usage: Usage; seq: number }\n | { status: \"cancelled\"; usage: Usage; seq: number }\n | { status: \"failed\"; error: Failure; usage: Usage; seq: number };\n\nexport interface AgentRun extends AsyncIterable<Update> {\n readonly result: Promise<RunResult>;\n cancel(): void;\n}\n\n/**\n * One piece of input for an activation, and where it came from.\n *\n * Always the object form, because `Content` may itself be an array of parts and\n * a bare array therefore cannot say whether it is one multi-part message or\n * several messages. `RunAgentOptions.input` still takes plain content for the\n * common case; only the *many* form is required to name each arrival.\n */\nexport interface Arrival { input: Content; from?: From }\n\ninterface RunAgentBase {\n agent: Agent;\n /** Opaque application index key, used only when this run creates the session. */\n key?: string;\n context?: { run?: string; turn?: string };\n signal?: AbortSignal;\n}\n\n/**\n * An activation is working one of two things, and never both.\n *\n * A caller is **sending** — a person typed, a webhook fired, a test asked a\n * question. Or a worker is running **what the store handed it**, which carries\n * the session, the position to write from, and the deliveries to consume, all\n * as one value that arrived together and cannot be recombined wrongly.\n *\n * That second form replaced three fields a worker had to line up by hand:\n * `sessionId`, the position, and how many deliveries to take. Every one of them\n * was only ever a field of the claim, so each was a value another field already\n * determined — and the one that mattered, taking the deliveries, silently\n * left the input queued for ever when it was forgotten. Both applications\n * written on this wrote the same eight lines to get it right.\n */\nexport type RunAgentOptions = RunAgentBase & (\n | {\n /** One message as plain content, one named arrival, or several named arrivals. */\n input: Content | Arrival | readonly Arrival[];\n /**\n * Which session it goes to, as a UUID. A new one is opened when this is\n * omitted. An application's own naming for a conversation goes in `key`,\n * which is opaque to the library; a store is entitled to keep a session\n * id in a `uuid` column and refuse anything else.\n */\n sessionId?: string;\n /** Omit for an ephemeral run: the log lives in memory and nothing is persisted. */\n store?: Store;\n claim?: never;\n }\n | {\n /**\n * What `store.next()` or `store.interrupted()` handed this worker.\n *\n * Its deliveries become this activation's input and are consumed by the\n * write that commits them, in the same compare-and-swap that checks the\n * position — so a worker that loses the race loses its turn and never the\n * messages. A claim with an empty queue is a **resumption**: nothing opens\n * it, the loop continues the committed log, and the effect that already\n * happened is read there rather than asked for again. Only a harness\n * declaring `recovery: \"history\"` is given one.\n */\n claim: Runnable;\n /**\n * Required here, unlike a caller sending. A claim is a position in a log\n * and a queue to consume, and both of those are the store's — without one\n * the deliveries would be replayed into memory and left queued for ever.\n */\n store: Store;\n /**\n * Something the worker is adding of its own, ahead of what was waiting.\n *\n * One real use: orientation for a harness whose protocol has no system\n * prompt, which has nowhere else to go and must lead. Adding nothing is\n * the ordinary case.\n */\n input?: Content | Arrival | readonly Arrival[];\n sessionId?: never;\n }\n);\n"]}
1
+ {"version":3,"file":"agent.js","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"","sourcesContent":["import type { Content } from \"./content.js\";\nimport type { Harness } from \"./harness/harness.js\";\nimport type { Decide, Tool } from \"./tools/tool.js\";\nimport type { Delivery, Runnable, Store } from \"./store/store.js\";\nimport type { Update } from \"./harness/harness.js\";\nimport type { Failure } from \"./result.js\";\nimport type { From, Usage } from \"./session/entry.js\";\n\n/** An inert declaration. Nothing here opens a connection or holds state. */\nexport interface Agent {\n id: string;\n /** Bump when instructions, tools or configuration stop being compatible with an existing session. */\n version: string;\n instructions: Content;\n harness: Harness;\n tools?: readonly Tool[];\n /** Per-call policy over parsed arguments. Absent means every call executes. */\n decide?: Decide;\n /**\n * Name each arrival's sender in the turn the model reads, as `[from kind id]`.\n *\n * Off by default, because two separate things were one: `from` is provenance\n * the log holds whatever this says, and this is text in a prompt. An\n * application that renders its own attribution into the input it delivers —\n * most do, since only they can name a sender in words their agent knows —\n * would otherwise have the model read two names for one sender, one of them a\n * session id that means nothing to it. Turn it on where nothing else says who\n * wrote.\n */\n attribution?: boolean;\n /** Runs once as an activation ends, whatever ended it; its deliveries commit with the final entries. */\n finished?(run: {\n sessionId: string; runId: string;\n outcome: \"completed\" | \"failed\" | \"cancelled\";\n output: Content;\n }): readonly Delivery[] | Promise<readonly Delivery[]>;\n /**\n * Ceilings on one activation, over the facts this library holds: turns and\n * tool calls are on the log, and a deadline is the clock.\n *\n * There is no ceiling on money, and the asymmetry is the point. A spend limit\n * would have to read a rate table the library does not have and should not\n * carry, so it would take a function from the caller and then need defending\n * against it — a code for \"you declared a ceiling nothing can hold\", a rule\n * for a generation the function could not price, a check for a price that is\n * negative or not a number. All of that is the cost of enforcing something\n * from a fact we do not own, and enforcing it is policy besides: the two\n * applications that wanted one wanted it to stop at different moments.\n *\n * `RunResult.usage` is what a caller needs from here, and it is solid. What\n * that costs, and what to do about it, is priced by whoever holds the rates.\n */\n limits?: { maxTurns?: number; maxToolCalls?: number; deadline?: string };\n}\n\n/**\n * How an activation ended, and what it consumed on the way.\n *\n * `usage` is on every outcome, not only a completed one: a run that burned four\n * dollars of tokens and then failed burned them, and a caller that has to ask\n * the log to find that out has been handed a result missing the expensive half\n * of what happened.\n *\n * Summed from the `assistant` entries this activation committed, which is why a\n * harness does not report it — the log already holds every generation, and a\n * second total is a second answer. Money is not here: the counts are the fact,\n * and the rates that turn them into money belong to the deployment.\n */\nexport type RunResult =\n | { status: \"completed\"; output: Content; usage: Usage; seq: number }\n | { status: \"cancelled\"; usage: Usage; seq: number }\n | { status: \"failed\"; error: Failure; usage: Usage; seq: number };\n\nexport interface AgentRun extends AsyncIterable<Update> {\n readonly result: Promise<RunResult>;\n cancel(): void;\n}\n\n/**\n * One piece of input for an activation, and where it came from.\n *\n * Always the object form, because `Content` may itself be an array of parts and\n * a bare array therefore cannot say whether it is one multi-part message or\n * several messages. `RunAgentOptions.input` still takes plain content for the\n * common case; only the *many* form is required to name each arrival.\n */\nexport interface Arrival { input: Content; from?: From }\n\ninterface RunAgentBase {\n agent: Agent;\n /** Opaque application index key, used only when this run creates the session. */\n key?: string;\n context?: { run?: string; turn?: string };\n signal?: AbortSignal;\n}\n\n/**\n * An activation is working one of two things, and never both.\n *\n * A caller is **sending** — a person typed, a webhook fired, a test asked a\n * question. Or a worker is running **what the store handed it**, which carries\n * the session, the position to write from, and the deliveries to consume, all\n * as one value that arrived together and cannot be recombined wrongly.\n *\n * That second form replaced three fields a worker had to line up by hand:\n * `sessionId`, the position, and how many deliveries to take. Every one of them\n * was only ever a field of the claim, so each was a value another field already\n * determined — and the one that mattered, taking the deliveries, silently\n * left the input queued for ever when it was forgotten. Both applications\n * written on this wrote the same eight lines to get it right.\n */\nexport type RunAgentOptions = RunAgentBase & (\n | {\n /** One message as plain content, one named arrival, or several named arrivals. */\n input: Content | Arrival | readonly Arrival[];\n /**\n * Which session it goes to, as a UUID. A new one is opened when this is\n * omitted. An application's own naming for a conversation goes in `key`,\n * which is opaque to the library; a store is entitled to keep a session\n * id in a `uuid` column and refuse anything else.\n */\n sessionId?: string;\n /** Omit for an ephemeral run: the log lives in memory and nothing is persisted. */\n store?: Store;\n claim?: never;\n }\n | {\n /**\n * What `store.next()` or `store.interrupted()` handed this worker.\n *\n * Its deliveries become this activation's input and are consumed by the\n * write that commits them, in the same compare-and-swap that checks the\n * position — so a worker that loses the race loses its turn and never the\n * messages. A claim with an empty queue is a **resumption**: nothing opens\n * it, the loop continues the committed log, and the effect that already\n * happened is read there rather than asked for again. Only a harness\n * declaring `recovery: \"history\"` is given one.\n */\n claim: Runnable;\n /**\n * Required here, unlike a caller sending. A claim is a position in a log\n * and a queue to consume, and both of those are the store's — without one\n * the deliveries would be replayed into memory and left queued for ever.\n */\n store: Store;\n /**\n * Something the worker is adding of its own, ahead of what was waiting.\n *\n * One real use: orientation for a harness whose protocol has no system\n * prompt, which has nowhere else to go and must lead. Adding nothing is\n * the ordinary case.\n */\n input?: Content | Arrival | readonly Arrival[];\n sessionId?: never;\n }\n);\n"]}
@@ -1,6 +1,7 @@
1
1
  import type { Harness } from "../../harness.js";
2
2
  import type { Sandbox } from "../../../sandbox/sandbox.js";
3
3
  import type { Decide } from "../../../tools/tool.js";
4
+ import { type Failure, type Result } from "../../../result.js";
4
5
  /** An agent process: argv and the environment that selects its provider and model. */
5
6
  export interface AcpAgent {
6
7
  command: readonly string[];
@@ -51,21 +52,18 @@ export interface AcpHarnessOptions {
51
52
  sandbox: Sandbox;
52
53
  mcpServers?: readonly AcpMcpServer[];
53
54
  /**
54
- * What to set before prompting, by option id. Applied only where the agent
55
- * published that option and, for a select, that value otherwise the run
56
- * fails naming what it does offer, rather than quietly running something else.
55
+ * What to set before prompting, by option id the agent's own ids, from
56
+ * `onConfig`. The only configuration door, deliberately: an option this
57
+ * package named would be a guess at another product's vocabulary, and one
58
+ * that goes stale the first time an agent ships an axis nobody thought of.
59
+ *
60
+ * Applied only where the agent published that option and, for a select, that
61
+ * value — otherwise the run fails naming what it does offer, rather than
62
+ * quietly running something else.
57
63
  */
58
64
  select?: Readonly<Record<string, string | boolean>>;
59
- /**
60
- * Requested model, matched against whichever option the agent categorised as
61
- * its model selector. A convenience over `select` for the one option every
62
- * agent has, and it fails the same way.
63
- */
64
- model?: string;
65
65
  /** What the agent published. The caller persists it so a session can offer the agent's own choices. */
66
66
  onConfig?(options: readonly AcpConfigOption[]): void;
67
- /** Agent-defined mode. Choosing one that asks before acting is what routes its own tools through `decide`. */
68
- mode?: string;
69
67
  /**
70
68
  * Applied to the agent's own tools, per call, before they run.
71
69
  *
@@ -90,3 +88,21 @@ export interface AcpHarnessOptions {
90
88
  * agents can dispatch and message each other.
91
89
  */
92
90
  export declare function createAcpHarness(options: AcpHarnessOptions): Harness;
91
+ /**
92
+ * What an agent offers, before anyone has asked it anything.
93
+ *
94
+ * Over this protocol an agent publishes its options in the answer to
95
+ * `session/new`, so nothing knows what a model, a mode or a reasoning level is
96
+ * called until a session exists — and a session exists on the first prompt.
97
+ * That left a client with a menu it could not draw until after the choice it
98
+ * wanted to offer had already been made.
99
+ *
100
+ * So this opens one and asks. It spawns, initialises, opens a session, reads
101
+ * what came back and closes: a process and a handshake, no prompt, no
102
+ * generation, nothing billed. The session it opened is thrown away — the run
103
+ * opens its own, exactly as it did before.
104
+ */
105
+ export declare function acpOptions(input: {
106
+ agent: AcpAgent;
107
+ sandbox: Sandbox;
108
+ }): Promise<Result<readonly AcpConfigOption[], Failure>>;
@@ -2,6 +2,18 @@ import { textOf } from "../../../content.js";
2
2
  import { err, ok } from "../../../result.js";
3
3
  import { createRpc } from "./rpc.js";
4
4
  const PROTOCOL_VERSION = 1;
5
+ /**
6
+ * The handshake, and what it claims for us.
7
+ *
8
+ * Two callers open a connection — a turn, and `acpOptions` — and an agent
9
+ * decides what to offer from what the client says it can do. A second copy of
10
+ * this is a second answer to the same question, and the two would drift.
11
+ */
12
+ const initialize = (rpc) => rpc.request("initialize", {
13
+ protocolVersion: PROTOCOL_VERSION,
14
+ clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true },
15
+ clientInfo: { name: "aglib", version: "0" },
16
+ });
5
17
  /**
6
18
  * Runs a foreign coding agent over the Agent Client Protocol.
7
19
  *
@@ -17,13 +29,48 @@ export function createAcpHarness(options) {
17
29
  id: options.id,
18
30
  // Its tools are its own. We gate them and we record them; we do not claim
19
31
  // to have validated arguments we never had a schema for.
20
- toolUse: "harness",
21
32
  // The agent owns its context. Our entries describe what it did, and cannot
22
33
  // by themselves put it back mid-turn.
23
34
  recovery: "none",
24
35
  run: (context) => runTurn(options, context),
25
36
  };
26
37
  }
38
+ /**
39
+ * What an agent offers, before anyone has asked it anything.
40
+ *
41
+ * Over this protocol an agent publishes its options in the answer to
42
+ * `session/new`, so nothing knows what a model, a mode or a reasoning level is
43
+ * called until a session exists — and a session exists on the first prompt.
44
+ * That left a client with a menu it could not draw until after the choice it
45
+ * wanted to offer had already been made.
46
+ *
47
+ * So this opens one and asks. It spawns, initialises, opens a session, reads
48
+ * what came back and closes: a process and a handshake, no prompt, no
49
+ * generation, nothing billed. The session it opened is thrown away — the run
50
+ * opens its own, exactly as it did before.
51
+ */
52
+ export async function acpOptions(input) {
53
+ const started = await input.sandbox.spawn({
54
+ command: input.agent.command,
55
+ cwd: input.sandbox.root,
56
+ ...(input.agent.env ? { env: input.agent.env } : {}),
57
+ });
58
+ if (!started.ok)
59
+ return err(started.error);
60
+ const rpc = createRpc(started.value);
61
+ try {
62
+ const ready = await initialize(rpc);
63
+ if (!ready.ok)
64
+ return err(ready.error);
65
+ const opened = await rpc.request("session/new", { cwd: input.sandbox.root, mcpServers: [] });
66
+ if (!opened.ok)
67
+ return err(opened.error);
68
+ return ok(readOptions(opened.value).options);
69
+ }
70
+ finally {
71
+ rpc.close();
72
+ }
73
+ }
27
74
  async function runTurn(options, context) {
28
75
  const started = await options.sandbox.spawn({
29
76
  command: options.agent.command,
@@ -67,11 +114,7 @@ function createTurn(options, context, rpc) {
67
114
  serveTerminals();
68
115
  servePermission();
69
116
  rpc.onNotify("session/update", (params) => { void receive(params); });
70
- const ready = await rpc.request("initialize", {
71
- protocolVersion: PROTOCOL_VERSION,
72
- clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: true },
73
- clientInfo: { name: "aglib", version: "0" },
74
- });
117
+ const ready = await initialize(rpc);
75
118
  if (!ready.ok)
76
119
  return { status: "failed", error: ready.error };
77
120
  const capabilities = field(ready.value, "agentCapabilities");
@@ -143,24 +186,15 @@ function createTurn(options, context, rpc) {
143
186
  const { options: published, legacyModel } = readOptions(opened);
144
187
  if (published.length)
145
188
  options.onConfig?.(published);
146
- if (options.mode) {
147
- const set = await rpc.request("session/set_mode", { sessionId: id, modeId: options.mode });
148
- if (!set.ok)
149
- return set;
150
- }
151
- const wanted = { ...options.select };
152
- if (options.model) {
153
- const selector = published.find((option) => option.category === "model");
154
- if (!selector) {
155
- return err({
156
- code: "unsupported",
157
- message: `${options.id} publishes no model selector, so '${options.model}' cannot be chosen over the protocol. Point it at a model through its environment instead.`,
158
- retryable: false,
159
- });
160
- }
161
- wanted[selector.id] = options.model;
162
- }
163
- for (const [option, value] of Object.entries(wanted)) {
189
+ // One door, and `select` is it. There were two more — `mode`, which sent
190
+ // `session/set_mode`, and `model`, which found whichever option the agent
191
+ // had categorised as its selector. Both were conveniences naming an axis,
192
+ // and naming an axis is guessing at a vocabulary that is not ours: the
193
+ // agents measured here publish five categories between them, and no
194
+ // shorthand was ever going to cover the next one. `session/set_config_option`
195
+ // with `configId: "mode"` was checked against `claude-agent-acp` and does
196
+ // what `session/set_mode` did, answering with the new state as well.
197
+ for (const [option, value] of Object.entries(options.select ?? {})) {
164
198
  const published_ = published.find((candidate) => candidate.id === option);
165
199
  if (!published_) {
166
200
  return err({