@zenera/gym 1.1.40 → 1.1.42

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 (51) hide show
  1. package/README.md +169 -104
  2. package/dist/agents.d.ts +25 -0
  3. package/dist/agents.js +52 -0
  4. package/dist/bindings.d.ts +25 -0
  5. package/dist/bindings.js +81 -0
  6. package/dist/box.d.ts +39 -36
  7. package/dist/box.js +183 -217
  8. package/dist/cache.d.ts +24 -14
  9. package/dist/cache.js +102 -66
  10. package/dist/catalog.d.ts +83 -0
  11. package/dist/catalog.js +293 -0
  12. package/dist/command.d.ts +1 -0
  13. package/dist/command.js +235 -68
  14. package/dist/fake.d.ts +4 -0
  15. package/dist/fake.js +142 -0
  16. package/dist/generate.d.ts +28 -24
  17. package/dist/generate.js +417 -120
  18. package/dist/inputs.d.ts +12 -0
  19. package/dist/inputs.js +132 -0
  20. package/dist/knowledge.d.ts +50 -0
  21. package/dist/knowledge.js +199 -0
  22. package/dist/logger.d.ts +10 -8
  23. package/dist/logger.js +4 -3
  24. package/dist/paging.d.ts +2 -2
  25. package/dist/paging.js +2 -2
  26. package/dist/probe.d.ts +5 -5
  27. package/dist/probe.js +15 -15
  28. package/dist/prompt.d.ts +31 -18
  29. package/dist/prompt.js +160 -146
  30. package/dist/quiet.d.ts +2 -0
  31. package/dist/quiet.js +7 -0
  32. package/dist/router.js +5 -1
  33. package/dist/runtime.d.ts +160 -0
  34. package/dist/runtime.js +229 -0
  35. package/dist/schema.js +1 -2
  36. package/dist/server.js +98 -57
  37. package/dist/setup.d.ts +47 -6
  38. package/dist/setup.js +157 -21
  39. package/dist/spec.d.ts +4 -3
  40. package/dist/spec.js +73 -22
  41. package/dist/store.d.ts +78 -0
  42. package/dist/store.js +253 -0
  43. package/dist/synth.d.ts +42 -0
  44. package/dist/synth.js +589 -0
  45. package/dist/worker.d.ts +41 -0
  46. package/dist/worker.js +99 -0
  47. package/package.json +5 -3
  48. package/dist/envelope.d.ts +0 -13
  49. package/dist/envelope.js +0 -9
  50. package/dist/image.d.ts +0 -25
  51. package/dist/image.js +0 -81
package/README.md CHANGED
@@ -23,8 +23,10 @@
23
23
  # @zenera/gym
24
24
 
25
25
  **Simulated API environments for agent training and evaluation. Point Gym at
26
- a Swagger/OpenAPI spec and it serves it - the response bodies are written,
27
- once, by a model.**
26
+ OpenAPI/Swagger documents — and, if you like, a story about the world behind
27
+ them and the documentation that goes with it — and it serves the API:
28
+ stateful, consistent across endpoints and processes, and populated with
29
+ content that follows the story.**
28
30
 
29
31
  Gym currently provides the API simulation layer, not a training loop, task
30
32
  scoring, or rewards.
@@ -36,143 +38,206 @@ scoring, or rewards.
36
38
 
37
39
  ## What you get
38
40
 
39
- - **A mock server from a spec** - point it at one or more swagger/OpenAPI
40
- documents and every operation answers.
41
- - **Bodies written once** - a model writes a Python generator per operation,
42
- checked against the response schema; after that no request costs a token.
43
- - **Stable answers** - pin a `--seed` and the same request returns the same body.
44
- - **Lists that end** - paging tokens are recognised, walked and cut, so a client
45
- never loops forever.
41
+ - **A mock server from documents** - name OpenAPI/Swagger files, directories or
42
+ patterns, and every operation answers.
43
+ - **A world, not random bodies** - every answer comes from a shared world of
44
+ records. `GET /messages` lists what `GET /messages/{id}` returns, a `POST` is
45
+ visible to the next `GET`, and a `DELETE` deletes.
46
+ - **A story it follows** - describe the people, the projects and what is going
47
+ on, and the mailbox, the calendar and everything else read like that place.
48
+ - **Documentation it uses** - hand it a knowledge base, runbooks, an admin
49
+ guide; agents search it for realistic values and for what the API does.
50
+ - **Several APIs, one world** - mail on one port and calendar on another, given
51
+ the same story, share one world: the meeting in an email is the meeting on
52
+ the calendar.
53
+ - **Code written once** - an agent writes a TypeScript handler per operation,
54
+ judged before it is kept; after that a request is a function call, and the
55
+ only tokens spent are on content the world does not have yet.
56
+ - **Large APIs** - thousands of operations load in seconds; nothing is written
57
+ until it is called.
58
+ - **Lists that end** - paging is built in, judged, and cut at request time.
46
59
 
47
60
  ## Install
48
61
 
49
- Node.js 24+ and [podman](https://podman.io). Install it alongside the CLI:
62
+ Node.js 24+. Install it alongside the CLI:
50
63
 
51
64
  ```sh
52
65
  npm i -g @zenera/cli @zenera/gym
53
66
  zen key add openai # the keyring `zen` already uses
54
67
  ```
55
68
 
56
- The first start builds one image; every start after that reuses it. `zen --help`
57
- lists `gym` whether or not it is installed, and says what to run if not.
69
+ No container engine is needed: handlers are TypeScript that Node runs as it is.
58
70
 
59
71
  ## Use
60
72
 
61
73
  ```sh
62
- zen gym serve api/openapi.yaml --port 8787
74
+ zen gym serve api/openapi.yaml
63
75
  curl -s localhost:8787/users/12324
64
- # { "user_id": 12324, "email": "brooke.hoffman@example.org", ... }
65
76
  ```
66
77
 
67
- More than one document at a time is fine, and the same request answers the same
68
- way every time when you pin a seed:
78
+ Three kinds of input, each a file, a directory or a quoted pattern, each
79
+ repeatable:
80
+
81
+ | Input | What it is | How the agents get it |
82
+ | ------------------- | ------------------------------------------------------------------------ | ------------------------------- |
83
+ | `spec…`, `--schema` | API documents (`.yaml`, `.yml`, `.json` declaring `swagger:`/`openapi:`) | a graph of operations and types |
84
+ | `--story` | the state of this world: who is who, what is happening | quoted whole, in every prompt |
85
+ | `--docs` | reference material: a knowledge base, guides, runbooks | searched when needed |
86
+
87
+ ```sh
88
+ zen gym serve --schema 'specs/*.yaml' --story world.md --docs kb/ --docs 'runbooks/*.md'
89
+ ```
90
+
91
+ A directory is walked, and only files that declare `swagger:` or `openapi:` are
92
+ taken as API documents — so pointing `--schema` at a folder of mixed JSON, or at
93
+ an index somebody built, finds the APIs in it. Quote patterns so the shell leaves
94
+ them to Gym.
95
+
96
+ Two APIs, two processes, one world:
69
97
 
70
98
  ```sh
71
- zen gym serve specs/*.yaml --seed 42
99
+ zen gym serve specs/mail.yaml --story world.md --docs kb/ --port 8787
100
+ zen gym serve specs/calendar.yaml --story world.md --docs kb/ --port 8788
72
101
  ```
73
102
 
74
- Warm it up before a demo or a test run, so no request pays for a model turn:
103
+ [`examples/mock-apis`](https://github.com/zenera-ai/ZeneraNeo/tree/main/examples/mock-apis)
104
+ has a story and a knowledge base for the mail and calendar specs beside it, and
105
+ another for an NSX network.
106
+
107
+ Everything is indexed before the server listens, so no request waits on an
108
+ index. Write every handler up front, before a demo or a test run:
75
109
 
76
110
  ```sh
77
- zen gym build api/openapi.yaml # write every generator now and exit
78
- zen gym cache ls # what has been generated
79
- zen gym cache clear # throw it away
111
+ zen gym build --schema 'specs/*.yaml' --story world.md --docs kb/
80
112
  ```
81
113
 
82
- A path key with a query string on it — `/x/{id}?action=retry`, which some
83
- vendors write when they need two `post`s under one path — is read for what
84
- it means rather than taken verbatim: the path is the part before the `?`, and
85
- each pair after it becomes a required query value that tells those operations
86
- apart. Call one without it and the 404 says which queries the path answers to.
87
-
88
- ## How a body is produced
89
-
90
- The first time an operation is called, Gym asks a model to write a
91
- **Python generator** for it - one file, taking a JSON input path and a JSON
92
- output path. That file is then run against sample requests it makes up, and
93
- judged twice: against the operation's response schema, and against the echo
94
- rule, which says that where a path or query parameter shares a name with a
95
- property in the response, the response has to carry the value that was asked
96
- for. `GET /users/12324` answering with somebody else's id validates perfectly
97
- and is still wrong.
98
-
99
- If it fails, the diagnostics go back to the model and it tries again, up to
100
- `--attempts` (five by default). A build that does not come good leaves a
101
- **report** in `<cache>/builds/` - one markdown file per build, every rejected
102
- attempt in it with its diagnostics and its whole source, and the `501` that the
103
- request gets links to it, in the log line and in the body. The next attempt
104
- overwrites `gen.py`, and a generator that merely hung leaves no traceback, so
105
- the code is the only evidence there is. If it passes, the generator is kept in
106
- this machine's shared cache under `~/.zenera/neo/cache/gym-generator/`, keyed
107
- by the operation's shape, and every later request is just
108
- `podman exec python3 gen.py in.json out.json` - no model, no tokens. The store
109
- is the machine's, so the same document served from another directory costs
110
- nothing the second time.
111
-
112
- Generators run in a container with **no network**, on an image baked once with
113
- `faker`, `exrex`, `jsonschema` and `python-dateutil`.
114
+ ## How an answer is produced
115
+
116
+ Two agents, and a split between them that is the whole design.
117
+
118
+ **The handler agent writes mechanics.** The first time an operation is called,
119
+ an agent writes its **handler**: one TypeScript module, `export default async
120
+ function handle(req, ctx)`. It is shown the operation as TypeScript types, the
121
+ operations it must agree with (found by tracing its record type through the API
122
+ graph, so they are found however far apart their paths are), where each type
123
+ already lives, and the documentation about the operation. It can look further:
124
+ `read_handler`, `trace_api`, `grep_api`, `list_api`, `describe_types`, the world's
125
+ collections and the docs.
126
+
127
+ A handler owns which type an operation is about, how the query filters it, what
128
+ a write changes and how the list pages — and never the content. It names records
129
+ by their schema type:
130
+
131
+ ```ts
132
+ export default async function handle(req, ctx) {
133
+ const all = await ctx.ensure('Message', { hint: req.query.q });
134
+ const q = (req.query.q ?? '').toLowerCase();
135
+ const hits = all.filter((m) => `${m.subject} ${m.snippet}`.toLowerCase().includes(q));
136
+ const page = ctx.page(hits, { token: req.query.pageToken, size: req.query.maxResults });
137
+ return {
138
+ messages: page.items.map(({ id, threadId }) => ({ id, threadId })),
139
+ nextPageToken: page.nextToken ?? undefined,
140
+ resultSizeEstimate: hits.length,
141
+ };
142
+ }
143
+ ```
144
+
145
+ **Where types live is decided once.** The first handler to use a type binds it to
146
+ a collection and an id field; every later handler is told the binding and held
147
+ to it. So `listMessages` and `getMessage` read the same records, whichever was
148
+ written first and whichever was rebuilt since.
149
+
150
+ **The world agent writes content.** When a handler finds the world short —
151
+ an inbox nobody has read yet, an id nobody has seen — `ctx.ensure` and
152
+ `ctx.lookup` ask, and an agent writes the records: from the story, from the
153
+ docs, and consistent with everything already in the world, checked against the
154
+ schema type before anything can read them. On a first read it makes what the
155
+ story says exists, however many that is. Records it refers to that do not exist
156
+ yet — a label, a transport zone — it makes too, in their own collections.
157
+ `me`, `primary` and the like resolve through `ctx.owner()`, the account the story
158
+ says the API belongs to.
159
+
160
+ Because content lives in the world and not in the code, one cached handler
161
+ serves every story.
162
+
163
+ **Nothing is kept until it is judged.** `test_handler` runs the module against
164
+ probe requests in a throwaway copy of the world (content there is sampled from
165
+ the schema, so judging costs no model calls) and checks every answer against the
166
+ response schema, the **echo rule** (`GET /users/12324` must be about user
167
+ 12324), the paging walk, and the bindings. The first test that passes ends the
168
+ build. A build that never passes leaves a report in `<cache>/builds/`, and every
169
+ agent run leaves an HTML report in `<cache>/reports/`.
170
+
171
+ Handlers run in a worker thread with an empty environment. A handler that
172
+ throws or times out is repaired, up to `--regen-limit` times per URL; a failure
173
+ of the world agent is never treated as a fault in the handler.
174
+
175
+ ## Worlds
176
+
177
+ A world is one SQLite file under `<cache>/worlds/`. With `--story` and no
178
+ `--world`, it is named after what the story says, so every process given the
179
+ same story shares it; editing the story starts a new one, and Gym says so.
180
+ `--world <name>` names one that survives story edits; `--world ./path.sqlite`
181
+ puts it anywhere. Without either, there is one default world.
182
+
183
+ ```sh
184
+ zen gym world ls # every world, what it holds, when it was used
185
+ zen gym world rm northwind # delete one
186
+ zen gym serve … --reset # start the one in use from empty
187
+ ```
188
+
189
+ Worlds are data, not cache: `zen gym cache clear` leaves them alone.
190
+
191
+ ## Docs and embeddings
192
+
193
+ `--docs` are indexed into a directory Gym owns, keyed by their content and by the
194
+ model that embeds them: a restart reuses the index, and an edit pays only for the
195
+ chunks that changed. By default search is by wording (a full-text index), and no
196
+ embedding model is called. `--embedding` adds search by meaning: `--embedding <ref>`
197
+ names the model, and `--embedding on` (or `default`) takes the configured one —
198
+ `ZENERA_GYM_EMBEDDING`, then `zen gym model --embedding <ref>`, then the first
199
+ provider your keys offer. Those settings only answer which model; without the
200
+ flag they turn nothing on. `--embedding off` (or `none`) is the default.
114
201
 
115
202
  ## Pages that end
116
203
 
117
- A list endpoint is the one place a mock can hang a real client. Given
118
- `?cursor=abc`, the honest-looking answer is a body that validates, echoes
119
- nothing it shouldn't, and hands back `abc` again - so the client asks for the
120
- same page forever.
121
-
122
- Gym reads the document for this. Where an operation has a paging
123
- parameter (`cursor`, `page`, `offset`, `page_token`, …) in query or request body
124
- and a response property that carries the next one (`next`, `next_cursor`, `has_more`, …),
125
- three things happen, all in the operation's own names:
126
-
127
- - the model is told to fabricate **between 1 and 10 pages** in total, to build the token
128
- out of the paging parameter rather than the seed, and to end the list - null,
129
- absent, or `has_more: false` (and empty items) where the schema leaves no other room;
130
- - the generator is then **walked**: Gym calls it with no cursor, follows
131
- the token it gets back, and rejects the file if the token repeats, cycles, or
132
- never runs out. The diagnostics say which, and the model gets another go;
133
- - at request time, pagination is tracked and bounded: a token identical to the one
134
- just sent is **cut** (nulled or dropped), cycles are stopped, and streams are capped
135
- to random 1-10 pages, returning empty results if a client loops past the end. The
136
- request line narrates each cut. Nothing is invented in its place; a generator written
137
- before this rule existed is still cached, and a cache is not rebuilt because a rule changed.
138
-
139
- Only paginated operations are affected. Their cache keys changed once, so they
140
- are written again on first use; everything else keeps the key it had.
141
- `GET /__gym/routes` reports the shape that was recognised, per operation.
142
-
143
- Plenty of documents describe the envelope and never write down the parameter
144
- that reads it back. The first two steps cannot help there - nothing static can
145
- see a parameter that is not declared - but the cut still applies: it takes the
146
- paging parameter from query parameters or request body, since a client only sends
147
- `cursor=X` because a body handed it X.
204
+ A list endpoint is the one place a mock can hang a real client. Where an
205
+ operation has a paging parameter (`cursor`, `page`, `offset`, `page_token`, …) and
206
+ a response property that carries the next one (`next`, `next_cursor`,
207
+ `has_more`, …), the handler is told to page with `ctx.page`, whose token always
208
+ advances and is absent on the last page; it is walked before it is kept; and at
209
+ request time a repeated or cycling token is cut, and streams are capped to a
210
+ random 1-10 pages. `GET /__gym/routes` reports the shape recognised.
211
+
212
+ A path key with a query string on it — `/x/{id}?action=retry` — is read for what
213
+ it means: the path is the part before the `?`, and each pair after it becomes a
214
+ required query value that tells those operations apart.
148
215
 
149
216
  ## Commands
150
217
 
151
218
  ```
152
- zen gym serve <spec...> Serve them. Generators are written on demand.
153
- zen gym build <spec...> Write every generator now and exit.
154
- zen gym cache ls | clear What has been generated, or throw it away.
155
- zen gym model [ref] Show or set the model it uses.
219
+ zen gym serve [spec...] Serve the APIs. Handlers are written on first use.
220
+ zen gym build [spec...] Index everything and write every handler, then exit.
221
+ zen gym cache ls | clear Handlers, bindings and indexes.
222
+ zen gym world ls | rm Worlds.
223
+ zen gym model [ref] Show or set the chat and embedding models.
156
224
  ```
157
225
 
226
+ Useful options: `--schema`, `--story`, `--docs`, `--world`, `--reset`,
227
+ `--embedding`, `--port`, `--host` (reachable only from this machine by default),
228
+ `--model`, `--seed`, `--rebuild`, `--attempts`, `--concurrency`, `--timeout`,
229
+ `--cache <dir>`, `--quiet`. `zen help gym` prints the full table.
230
+
158
231
  The model is looked for in this order: `--model`, `ZENERA_GYM_MODEL`, what
159
232
  `zen gym model <provider>:<id>` stored, then the best one your keys can buy.
160
- The stored one lives beside the keyring in `~/.zenera/neo/gym.json`, so it is
161
- remembered between runs and across projects; `zen gym model --clear` forgets
162
- it.
163
-
164
- Useful options: `--port`, `--host` (reachable only from this machine by
165
- default), `--model`, `--seed` (same request, same answer), `--rebuild`,
166
- `--attempts`, `--concurrency`, `--timeout`, `--cache <dir>` (the container's
167
- workspace), `--quiet`. `zen help gym` prints the full table.
168
-
169
- `GET /` is a contents page: every operation the mock is serving, grouped by the
170
- document it came from, with its parameters and the shape of its answer. Each
171
- one has a **Run** button, which opens a form — a box per parameter, required
172
- ones marked, a body prefilled with something the mock will accept — and shows
173
- the status, the time and the answer. `GET /__gym/routes` is the same thing as
174
- JSON; `GET /__gym/health` is a health check. A document that declares `/`
175
- itself keeps it.
233
+ Both agents call tools; OpenAI's reasoning models take tools over the Responses
234
+ API (`openai/responses:<id>`), which is what the default names.
235
+
236
+ `GET /` is a contents page: every operation, grouped by document, with a **Run**
237
+ button and a form per operation. `GET /__gym/routes` is the same as JSON;
238
+ `GET /__gym/health` is a health check.
239
+
240
+ See [DESIGN.md](DESIGN.md) for how the pieces fit together.
176
241
 
177
242
  ## Credentials
178
243
 
@@ -0,0 +1,25 @@
1
+ import { type AgentState, type AnyTool, type Model } from '@zenera/neo';
2
+ export interface AgentRunOptions {
3
+ model: Model;
4
+ name: string;
5
+ instructions: string;
6
+ tools: AnyTool[];
7
+ input: string;
8
+ /** the run is stopped after this many tool calls, finished or not */
9
+ maxToolCalls: number;
10
+ /** checked after every tool call; true ends the run there */
11
+ done?: () => boolean;
12
+ /** where the HTML report goes; no report when unset */
13
+ reportDir?: string;
14
+ /** a word for the report's file name */
15
+ label: string;
16
+ signal?: AbortSignal;
17
+ }
18
+ export interface AgentRun {
19
+ state: AgentState;
20
+ stopReason: 'final' | 'aborted' | 'failed';
21
+ toolCalls: number;
22
+ report?: string;
23
+ }
24
+ export declare function runAgent(opts: AgentRunOptions): Promise<AgentRun>;
25
+ //# sourceMappingURL=agents.d.ts.map
package/dist/agents.js ADDED
@@ -0,0 +1,52 @@
1
+ import { AgentRunner, buildRunReport, renderReportHtml, } from '@zenera/neo';
2
+ import { mkdirSync } from 'node:fs';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { join, resolve } from 'node:path';
5
+ export async function runAgent(opts) {
6
+ const runner = new AgentRunner({ model: opts.model, recordRequests: Boolean(opts.reportDir) });
7
+ const agent = runner.agent({
8
+ name: opts.name,
9
+ instructions: opts.instructions,
10
+ tools: opts.tools,
11
+ });
12
+ const stop = new AbortController();
13
+ const signal = opts.signal ? AbortSignal.any([opts.signal, stop.signal]) : stop.signal;
14
+ const stream = runner.run(agent, opts.input, { signal });
15
+ let toolCalls = 0;
16
+ for await (const event of stream) {
17
+ if (event.type === 'after_tool_call') {
18
+ toolCalls++;
19
+ if (opts.done?.() || toolCalls >= opts.maxToolCalls) {
20
+ stop.abort();
21
+ }
22
+ }
23
+ }
24
+ const result = await stream.final();
25
+ const report = opts.reportDir
26
+ ? await saveReport(runner, result.state, opts.reportDir, `${opts.name}-${opts.label}`)
27
+ : undefined;
28
+ if (result.stopReason === 'failed' && !opts.done?.()) {
29
+ throw Object.assign(new Error(result.state.error ?? `the ${opts.name} agent failed`), {
30
+ hint: report ? `report: ${report}` : undefined,
31
+ });
32
+ }
33
+ return { state: result.state, stopReason: result.stopReason, toolCalls, report };
34
+ }
35
+ /** A report is a convenience; failing to write one must not fail the run. */
36
+ async function saveReport(runner, state, dir, label) {
37
+ try {
38
+ mkdirSync(dir, { recursive: true });
39
+ const built = await buildRunReport(state, runner.services.payloads, {
40
+ title: label,
41
+ architecture: await runner.describe(),
42
+ });
43
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
44
+ const path = join(dir, `${stamp}-${label.replace(/[^A-Za-z0-9_.-]+/g, '_')}.html`);
45
+ await writeFile(path, renderReportHtml(built), 'utf8');
46
+ return resolve(path);
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ //# sourceMappingURL=agents.js.map
@@ -0,0 +1,25 @@
1
+ import { type Binding, type Bindings as Map } from './runtime.ts';
2
+ export declare const BINDINGS_KIND = "gym-bindings";
3
+ export interface BindingsOptions {
4
+ /** keep them somewhere other than the shared store */
5
+ cacheDir?: string;
6
+ /** keep nothing */
7
+ ephemeral?: boolean;
8
+ }
9
+ export declare class Bindings {
10
+ #private;
11
+ constructor(types: readonly string[], opts?: BindingsOptions);
12
+ /** Every binding, the owner account included. */
13
+ all(): Map;
14
+ of(type: string): Binding | undefined;
15
+ /** The type a collection holds, if anything is bound to it. */
16
+ typeOf(collection: string): string | undefined;
17
+ /**
18
+ * Records a binding. Nothing happens if it is already there; an error comes
19
+ * back if the type lives elsewhere or the collection holds another type.
20
+ */
21
+ bind(type: string, binding: Binding): string | undefined;
22
+ /** Why this binding cannot be made, or undefined if it can (or exists). */
23
+ conflict(type: string, binding: Binding): string | undefined;
24
+ }
25
+ //# sourceMappingURL=bindings.d.ts.map
@@ -0,0 +1,81 @@
1
+ import { Cache as Store, NO_CACHE } from '@zenera/cli/lib';
2
+ import { ACCOUNTS, OWNER_TYPE } from "./runtime.js";
3
+ import { digest } from "./store.js";
4
+ // ---------------------------------------------------------------------------
5
+ // Bindings: which collection each type lives in
6
+ //
7
+ // The one decision every handler of an API must agree on, so it is made once
8
+ // and written down rather than left to each handler's author to rediscover. A
9
+ // handler names types — `ctx.lookup('Message', id)` — and the first handler
10
+ // to use a type binds it to the collection it used; every later one is told
11
+ // the binding and held to it by the judge.
12
+ //
13
+ // Add-only. Changing where a type lives would strand every record already
14
+ // stored under the old name and every handler written against it, so the only
15
+ // way to rebind is to clear the cache (and, sensibly, the world).
16
+ //
17
+ // Kept beside the handlers, in the machine's cache, and keyed by the set of type
18
+ // names the documents declare — the same documents served from anywhere share
19
+ // one set of bindings, as they share their handlers.
20
+ // ---------------------------------------------------------------------------
21
+ export const BINDINGS_KIND = 'gym-bindings';
22
+ export class Bindings {
23
+ #store;
24
+ #key;
25
+ #map;
26
+ constructor(types, opts = {}) {
27
+ this.#store = opts.ephemeral
28
+ ? NO_CACHE
29
+ : new Store(BINDINGS_KIND, { dir: opts.cacheDir, mode: 0o600 });
30
+ this.#key = digest(JSON.stringify([...types].sort()));
31
+ this.#map = this.#load();
32
+ }
33
+ /** Every binding, the owner account included. */
34
+ all() {
35
+ return { ...this.#map, [OWNER_TYPE]: { collection: ACCOUNTS, idField: 'id' } };
36
+ }
37
+ of(type) {
38
+ return this.all()[type];
39
+ }
40
+ /** The type a collection holds, if anything is bound to it. */
41
+ typeOf(collection) {
42
+ return Object.entries(this.all()).find(([, b]) => b.collection === collection)?.[0];
43
+ }
44
+ /**
45
+ * Records a binding. Nothing happens if it is already there; an error comes
46
+ * back if the type lives elsewhere or the collection holds another type.
47
+ */
48
+ bind(type, binding) {
49
+ // Another process may have bound something since this one started.
50
+ this.#map = { ...this.#load(), ...this.#map };
51
+ const problem = this.conflict(type, binding);
52
+ if (problem || this.#map[type]) {
53
+ return problem;
54
+ }
55
+ this.#map[type] = binding;
56
+ this.#store.put(this.#key, this.#map);
57
+ return undefined;
58
+ }
59
+ /** Why this binding cannot be made, or undefined if it can (or exists). */
60
+ conflict(type, binding) {
61
+ const current = this.of(type);
62
+ if (current) {
63
+ if (current.collection !== binding.collection) {
64
+ return `\`${type}\` lives in \`${current.collection}\`, not \`${binding.collection}\``;
65
+ }
66
+ if (current.idField !== binding.idField) {
67
+ return `\`${type}\` records are keyed by \`${current.idField}\`, not \`${binding.idField}\``;
68
+ }
69
+ return undefined;
70
+ }
71
+ const holder = this.typeOf(binding.collection);
72
+ if (holder && holder !== type) {
73
+ return `\`${binding.collection}\` holds \`${holder}\` records, not \`${type}\``;
74
+ }
75
+ return undefined;
76
+ }
77
+ #load() {
78
+ return { ...(this.#store.get(this.#key) ?? {}) };
79
+ }
80
+ }
81
+ //# sourceMappingURL=bindings.js.map
package/dist/box.d.ts CHANGED
@@ -1,66 +1,69 @@
1
- import { type Runner, type Sandbox } from '@zenera/neo';
2
- export declare const GENERATORS = "generators";
3
- /** Every gym container's name starts with this; `cache clear` relies on it. */
4
- export declare const CONTAINER_PREFIX = "zn-gym-";
1
+ import type { Bindings, HandlerRequest, SynthesisRequest, Use } from './runtime.ts';
2
+ export declare const HANDLERS = "handlers";
3
+ /** Asked when a handler wants content the world does not have yet. */
4
+ export type SynthesizeHook = (request: SynthesisRequest, context: {
5
+ db: string;
6
+ probe: boolean;
7
+ input: HandlerRequest;
8
+ }) => Promise<Record<string, unknown>[]>;
5
9
  export interface BoxOptions {
6
- /** host directory mounted at /workspace; holds generators/ and io/ */
10
+ /** holds handlers/ */
7
11
  root: string;
8
- image: string;
9
- /** seconds one generator may take */
12
+ /** the live world every real request reads and writes */
13
+ db: string;
14
+ /** seconds one handler may run, not counting time spent waiting on an agent */
10
15
  timeout?: number;
11
- engine?: string;
12
- exec?: Runner;
13
- /** the process the container belongs to; defaults to this one */
14
- owner?: number;
16
+ synthesize?: SynthesizeHook;
17
+ /** where each type lives, as of now; asked on every run */
18
+ bindings?: () => Bindings;
19
+ }
20
+ export interface RunOptions {
21
+ /** another world to run against, e.g. a snapshot for judging */
22
+ db?: string;
23
+ /** the call is a judgement, not traffic: content is sampled, not written by an agent */
24
+ probe?: boolean;
25
+ /** bindings to run with instead of the current ones */
26
+ bindings?: Bindings;
15
27
  }
16
28
  /**
17
- * The container engine could not run the generator at all. Nothing is known
18
- * about the generator from this, so it must never be fed back to a model as a
19
- * fault in the code: rewriting a working file cannot bring a container back.
29
+ * The worker could not be started at all. This says nothing about the handler
30
+ * and must never be fed back to a model as a fault in its code.
20
31
  */
21
- export declare class SandboxUnavailable extends Error {
32
+ export declare class WorkerUnavailable extends Error {
22
33
  readonly hint: string;
23
- readonly container: string;
24
34
  readonly stderr?: string;
25
35
  constructor(message: string, opts: {
26
36
  hint: string;
27
- container: string;
28
37
  stderr?: string;
29
38
  });
30
39
  }
31
40
  export interface Outcome {
32
41
  ok: boolean;
33
- /** parsed `output.json`, when the run produced one */
42
+ /** the body the handler answered with */
34
43
  value?: unknown;
44
+ /** set when the handler answered with `ctx.fail` rather than a body */
45
+ status?: number;
35
46
  /** what went wrong, in the words the build loop feeds back to the model */
36
47
  fault?: string;
37
48
  stderr?: string;
49
+ /** the handler failed because the world agent did, not because of its code */
50
+ agentFault?: boolean;
51
+ /** the collections the run touched, and as which types */
52
+ used?: Use[];
38
53
  durationMs: number;
39
54
  }
40
55
  export declare class Box {
41
56
  #private;
42
57
  readonly root: string;
58
+ readonly db: string;
59
+ synthesize?: SynthesizeHook;
43
60
  constructor(opts: BoxOptions);
44
- get sandbox(): Sandbox;
45
- /**
46
- * Removes this process's container if a previous owner of the pid left one,
47
- * and every gym container whose owning process is gone. Belt to
48
- * `persist: false`'s braces: a hard kill never runs `dispose`, and the
49
- * leftover is exactly the stale-mount trap above. A container whose owner
50
- * is alive is another server's, and is never touched.
51
- */
52
- fresh(): Promise<void>;
53
- /** Host path of a generator's source file. */
61
+ /** Host path of a handler's source file. */
54
62
  sourceOf(key: string): string;
55
63
  write(key: string, source: string): Promise<void>;
56
- /**
57
- * One generator, one input, one output. The io directory is removed
58
- * afterwards whatever happened — a mock server left alone for a week must
59
- * not fill a disk with request envelopes.
60
- *
61
- * Throws `SandboxUnavailable` when the engine, not the generator, failed.
62
- */
63
- run(key: string, input: unknown): Promise<Outcome>;
64
+ run(key: string, input: HandlerRequest, opts?: RunOptions): Promise<Outcome>;
65
+ /** Closes the worker's handle on a borrowed world, so its file can be deleted. */
66
+ release(db: string): void;
64
67
  dispose(): Promise<void>;
65
68
  }
66
69
  //# sourceMappingURL=box.d.ts.map