@zenera/server 0.0.0-stage → 1.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.
- package/README.md +245 -2
- package/dist/agui.d.ts +29 -0
- package/dist/agui.js +333 -0
- package/dist/auth.d.ts +16 -0
- package/dist/auth.js +79 -0
- package/dist/config.d.ts +71 -0
- package/dist/config.js +44 -0
- package/dist/history.d.ts +8 -0
- package/dist/history.js +73 -0
- package/dist/http.d.ts +9 -0
- package/dist/http.js +26 -0
- package/dist/identity.d.ts +18 -0
- package/dist/identity.js +55 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +20 -0
- package/dist/layout.d.ts +15 -0
- package/dist/layout.js +47 -0
- package/dist/localFileCors.d.ts +10 -0
- package/dist/localFileCors.js +34 -0
- package/dist/memory.d.ts +81 -0
- package/dist/memory.js +471 -0
- package/dist/plugins.d.ts +5 -0
- package/dist/plugins.js +29 -0
- package/dist/projectEnv.d.ts +3 -0
- package/dist/projectEnv.js +37 -0
- package/dist/runLog.d.ts +27 -0
- package/dist/runLog.js +153 -0
- package/dist/runRecords.d.ts +38 -0
- package/dist/runRecords.js +154 -0
- package/dist/runs.d.ts +12 -0
- package/dist/runs.js +376 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.js +105 -0
- package/dist/sessions.d.ts +13 -0
- package/dist/sessions.js +61 -0
- package/dist/state.d.ts +24 -0
- package/dist/state.js +55 -0
- package/dist/status.d.ts +56 -0
- package/dist/status.js +127 -0
- package/dist/store.d.ts +52 -0
- package/dist/store.js +203 -0
- package/dist/token.d.ts +14 -0
- package/dist/token.js +30 -0
- package/dist/trace.d.ts +84 -0
- package/dist/trace.js +389 -0
- package/package.json +56 -5
package/README.md
CHANGED
|
@@ -1,3 +1,246 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zenera/server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A project's chats over HTTP, for every user its host vouches for: the server
|
|
4
|
+
the agent image runs. Each turn opens the project with
|
|
5
|
+
[`@zenera/engine`](../engine), streams its events as
|
|
6
|
+
[AG-UI](https://docs.ag-ui.com), and runs the agents' commands in a sandbox —
|
|
7
|
+
under bubblewrap inside the agent image, with podman anywhere else, as `zen run`.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
zen server start --insecure # try it here: tokens read, not checked
|
|
11
|
+
zen server token --user ann # a token such a server takes
|
|
12
|
+
zen server start --jwt-public-key app.pub.pem # behind zenera-app
|
|
13
|
+
zen server start --container --insecure # in the project's agent image, under bwrap
|
|
14
|
+
zen server check # could it start here?
|
|
15
|
+
zen server status # a running one's report
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
With `--insecure`, startup prints a ready-to-open HTTP link to the bundled
|
|
19
|
+
React chat page served at `/chat`. It includes the server address and an unsigned JWT in the
|
|
20
|
+
fragment (not a query parameter); the page removes the fragment and keeps the
|
|
21
|
+
token only in memory. The default browser user is `local`; use `--user ann` to
|
|
22
|
+
test another user's sessions. The same works with `--container`: the outer CLI
|
|
23
|
+
prints the host's published address and port. HTTP links preserve the fragment
|
|
24
|
+
when opened from a terminal on macOS, unlike file-document links.
|
|
25
|
+
The fragment ends with `&open=1` so terminal link detection preserves the unsigned
|
|
26
|
+
JWT's trailing dot.
|
|
27
|
+
The page route is public only in insecure mode; API requests still require a JWT.
|
|
28
|
+
Secure mode does not expose the page. It remains a single compiled HTML asset,
|
|
29
|
+
with no frontend dependencies or separate UI server needed at runtime.
|
|
30
|
+
|
|
31
|
+
Startup uses an aligned, colored summary for the project, HTTP address, auth,
|
|
32
|
+
sandbox, state directory, image and browser user. Container builds show one
|
|
33
|
+
updating, width-limited line with a spinner and elapsed build time by default,
|
|
34
|
+
even during quiet steps; piped logs retain only the final
|
|
35
|
+
progress line. Warnings and errors remain visible in both cases.
|
|
36
|
+
Use `zen server start --container --verbose` for every build line, grouped by
|
|
37
|
+
stage and colored by severity. This applies to both the sandbox base image and
|
|
38
|
+
the agent image. Colors honor `NO_COLOR` and normal terminal detection.
|
|
39
|
+
The image reports listener readiness without repeating the host's summary or
|
|
40
|
+
insecure warning; container startup and HTTP readiness are distinct messages.
|
|
41
|
+
|
|
42
|
+
The HTML contains its JavaScript, React components and CSS; it needs no frontend
|
|
43
|
+
server or CDN. It reuses ZeneraLite's chat messages, composer, tool cards and
|
|
44
|
+
AG-UI reducer. It lists sessions, loads history, streams turns, follows active
|
|
45
|
+
runs, cancels runs and deletes sessions. Connected-account actions still need
|
|
46
|
+
zenera-app. Reopen the printed link after a page reload, or paste a dev JWT into
|
|
47
|
+
the connection form: tokens are deliberately not saved in browser storage.
|
|
48
|
+
|
|
49
|
+
Local files send `Origin: null`. Only insecure servers allow that origin and
|
|
50
|
+
its GET/POST/DELETE preflights with Authorization and JSON headers; actual API
|
|
51
|
+
requests still require a token. Secure servers do not enable this CORS access.
|
|
52
|
+
A browser may ask you to allow local-network access.
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { createServer } from '@zenera/server';
|
|
56
|
+
|
|
57
|
+
const server = createServer({
|
|
58
|
+
projectId: 'p1',
|
|
59
|
+
projectDir: '/project',
|
|
60
|
+
root: '/state',
|
|
61
|
+
auth: {
|
|
62
|
+
mode: 'jwt',
|
|
63
|
+
publicKey: process.env.ZEN_SERVER_JWT_PUBLIC_KEY!,
|
|
64
|
+
issuer: 'zenera-app',
|
|
65
|
+
audience: 'zenera-runtime',
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
await server.listen({ port: 3000, host: '0.0.0.0' });
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Who is calling: a JWT per request
|
|
72
|
+
|
|
73
|
+
Every request but `/healthz` carries `Authorization: Bearer <jwt>`, signed by the
|
|
74
|
+
host (zenera-app) for that one request with its Ed25519 key. The server holds
|
|
75
|
+
only the public key (`--jwt-public-key <file>` or `$ZEN_SERVER_JWT_PUBLIC_KEY`),
|
|
76
|
+
so neither it nor a sandboxed command that reaches its port can make one.
|
|
77
|
+
|
|
78
|
+
| Claim | Checked |
|
|
79
|
+
| ----------------------- | --------------------------------------------------------------------------- |
|
|
80
|
+
| alg | `EdDSA` only |
|
|
81
|
+
| `iss` | `zenera-app` (`--jwt-issuer`) |
|
|
82
|
+
| `aud` | `zenera-runtime` (`--jwt-audience`) |
|
|
83
|
+
| `iat` | at most 2 minutes old, 30 s of clock skew |
|
|
84
|
+
| `prj` | this server's project id |
|
|
85
|
+
| `req` | `"<METHOD> <url>"` of this very request: a token seen in transit is no good |
|
|
86
|
+
| `sub` | the user, required |
|
|
87
|
+
| `thr` | when there, must be the thread in the URL |
|
|
88
|
+
| `role`, `email`, `name` | when there, handed to the run as the user's (`zenera` library) |
|
|
89
|
+
|
|
90
|
+
Logging people in and deciding who may use which project stay in front of the
|
|
91
|
+
server (zenera-app); the server only trusts what it signed.
|
|
92
|
+
|
|
93
|
+
**`--insecure`** is for trying a server on your machine: tokens are read, not
|
|
94
|
+
checked (any signature or none, any age, any project or request), but `sub` is
|
|
95
|
+
still required — `zen server token --user ann` prints one. It listens on
|
|
96
|
+
loopback only, and is refused inside the agent image unless whoever runs the
|
|
97
|
+
container says so (`ZEN_SERVER_INSECURE_LOCAL=1`, which `--container` sets while
|
|
98
|
+
publishing the port on loopback only).
|
|
99
|
+
|
|
100
|
+
## Root: every user in a folder of their own
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
<root>/server.json {version, projectId}: this server's
|
|
104
|
+
<root>/users/<user>/memory/ the user's graph, shared by their chats
|
|
105
|
+
<root>/users/<user>/sessions/<thread>/.data/ the session's state (its conversation)
|
|
106
|
+
<root>/users/<user>/sessions/<thread>/runs/ the engine's record of each run
|
|
107
|
+
<root>/users/<user>/sessions/<thread>/workspace/ what the agent reads and writes
|
|
108
|
+
<root>/users/<user>/sessions/<thread>/agui/ each run's AG-UI events
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
- `--root` / `$ZEN_SERVER_ROOT`, else `~/.zenera/neo/servers/<project>`; `/state`
|
|
112
|
+
in the agent image. `server.json` makes it one project's: a root of another
|
|
113
|
+
project, or of a newer layout, is refused.
|
|
114
|
+
- `<user>` is `sub` as it is when it is a safe path segment (a UUID lower-cased),
|
|
115
|
+
else `u-<sha256>`. A thread is one path segment (letters, digits, `_`, `-`).
|
|
116
|
+
- **Memory seed**: the first time a user needs a memory, it is copied from the
|
|
117
|
+
seed — the project's own (`memory:` in agents.yaml), `--memory-seed <dir>`, or
|
|
118
|
+
none (`--no-memory-seed`, everyone starts empty). From then on it is theirs:
|
|
119
|
+
their chats share it, other users never see it, and the seed is not read again.
|
|
120
|
+
The host CLI captures the initial seed at startup, including committed SQLite
|
|
121
|
+
WAL data and attached files. Later seed changes only affect the next server
|
|
122
|
+
start; existing user memories are never replaced.
|
|
123
|
+
With `--container`, this snapshot is mounted at `/seed:ro` in rollback-journal
|
|
124
|
+
mode, so SQLite can read it without creating WAL/SHM files on the read-only
|
|
125
|
+
project mount. The temporary snapshot is removed after the server stops
|
|
126
|
+
(also on failed startup); private user memories remain in the server root.
|
|
127
|
+
Snapshot file permissions allow image UID 10001 to read it even with rootful
|
|
128
|
+
podman; the private server root still controls host access.
|
|
129
|
+
If the container cannot be stopped, the CLI reports that error and retains
|
|
130
|
+
its snapshot rather than removing memory that the container may still need.
|
|
131
|
+
|
|
132
|
+
## Routes
|
|
133
|
+
|
|
134
|
+
| Route | What |
|
|
135
|
+
| ------------------------------------------------ | ------------------------------------------------------------ |
|
|
136
|
+
| `GET /healthz` | For probes; no token |
|
|
137
|
+
| `GET /api/status` | Versions, auth, sandbox (and why not), image identity, root |
|
|
138
|
+
| `GET /api/activity` | Whether a run is going, for idle stop |
|
|
139
|
+
| `GET /api/sessions` | The caller's sessions, newest activity first |
|
|
140
|
+
| `GET /api/sessions/:tid` | The chat so far, as AG-UI events |
|
|
141
|
+
| `POST /api/sessions/:tid/runs` | A turn (or AG-UI `resume` entries), streamed as SSE |
|
|
142
|
+
| `GET /api/sessions/:tid/runs/:runId/events` | Follow a run, or replay one that ended |
|
|
143
|
+
| `POST /api/sessions/:tid/runs/:runId/cancel` | Stop it |
|
|
144
|
+
| `DELETE /api/sessions/:tid` | The thread and its files |
|
|
145
|
+
| `/api/sessions/:tid/{runs,…/trace,memory}` debug | With `debug: true` only: recorded runs, traces, memory graph |
|
|
146
|
+
|
|
147
|
+
Every session route is the caller's own: `:tid` is looked up under their folder.
|
|
148
|
+
The list returns `{ sessions: [{ id, title, createdAt, updatedAt, running }] }`;
|
|
149
|
+
`running` is `{ runId }` or `null`, and titles come from the engine's session
|
|
150
|
+
metadata (falling back to the ID). A thread-scoped token lists only its thread.
|
|
151
|
+
Listing does not create sessions or memory. A new session is still created on
|
|
152
|
+
its first turn.
|
|
153
|
+
|
|
154
|
+
A run belongs to the server, not the request: it goes on when the caller leaves
|
|
155
|
+
and can be followed again. At most `maxRuns` (`ZEN_RUNTIME_MAX_RUNS`, 4) run at
|
|
156
|
+
once, `maxUserRuns` (`ZEN_RUNTIME_MAX_USER_RUNS`, 2) per user. An
|
|
157
|
+
`x-zenera-mcp-token` header on a run is handed to the run's MCP plugin
|
|
158
|
+
(`ZEN_PLUGINS`, `ZEN_MCP_BROKER_URL`).
|
|
159
|
+
|
|
160
|
+
## Sandbox
|
|
161
|
+
|
|
162
|
+
Chosen once, at start:
|
|
163
|
+
|
|
164
|
+
- **podman** — anywhere but the agent image, as `zen run`: a container per
|
|
165
|
+
session on this machine. `zen server start` readies podman as `zen run` does,
|
|
166
|
+
and a turn pulls or builds the image it needs.
|
|
167
|
+
- **bwrap** — inside the agent image, which ships `/opt/zenera/bin/bwrap` and is
|
|
168
|
+
itself the container: each command under bubblewrap. Commands see the image
|
|
169
|
+
(what the project's `sandbox/Dockerfile` installed, with its `ENV`), their own
|
|
170
|
+
workspace and home, and the `zenera` library; never the root (other chats,
|
|
171
|
+
other users, memories), the seed, the zen home, the project (its `.env`), the
|
|
172
|
+
server's install, `/state` or `/run/secrets`.
|
|
173
|
+
|
|
174
|
+
`--sandbox` or `$ZEN_SERVER_SANDBOX` overrides the choice; `createServer` takes
|
|
175
|
+
`sandbox: 'podman' | 'bwrap' | factory` and an optional `preflight`. A sandbox
|
|
176
|
+
plugin in `ZEN_PLUGINS` never replaces it.
|
|
177
|
+
|
|
178
|
+
The `zenera` library (Python and Node: `identity()`, `require_identity()`) is one
|
|
179
|
+
shared read-only directory mounted at `/opt/zenera/runtime` for every session —
|
|
180
|
+
built into the agent image, or written once per content under the zen home.
|
|
181
|
+
|
|
182
|
+
`zen server start` refuses to listen when commands have nowhere to run (podman
|
|
183
|
+
not answering, bwrap unable to isolate), when the image was built for another
|
|
184
|
+
project environment (`ZEN_IMAGE_BUILD` / `ZEN_IMAGE_BASE`), or when the root is
|
|
185
|
+
another project's.
|
|
186
|
+
|
|
187
|
+
## `--container`
|
|
188
|
+
|
|
189
|
+
`zen server start --container` runs the server in the project's agent image on
|
|
190
|
+
this machine, with podman:
|
|
191
|
+
|
|
192
|
+
1. The project's sandbox image is readied as for `zen run` (`sandbox/Dockerfile`).
|
|
193
|
+
2. The agent image is built on it: this zen's packages (`npm pack` of the ones
|
|
194
|
+
installed), Node in `/opt/zenera`, a static bwrap, the `zenera` library, the
|
|
195
|
+
sandbox image's `ENV` (so commands get it under bwrap). Tagged
|
|
196
|
+
`localhost/zenera-agent:<hash>`; built again only when something changed, or
|
|
197
|
+
with `--rebuild`.
|
|
198
|
+
3. `podman run`: the project read-only at `/project`, the root at `/state`, the
|
|
199
|
+
port published on `--host` (loopback by default), the auth flags and model
|
|
200
|
+
keys and provider settings passed through by environment name, credential files
|
|
201
|
+
mounted as private Podman secrets, logs followed;
|
|
202
|
+
Ctrl-C stops it.
|
|
203
|
+
|
|
204
|
+
The server runs as numeric UID/GID **10001**, with `HOME=/home/zenera`, not
|
|
205
|
+
as root or as the sandbox base image's `USER`. Commands inherit that UID unless
|
|
206
|
+
the project's sandbox config explicitly selects another user. Rootless podman
|
|
207
|
+
uses `--userns keep-id:uid=10001,gid=10001`: existing host-owned state stays
|
|
208
|
+
writable, without changing its ownership. With rootful podman, the host must
|
|
209
|
+
provide a state directory writable by UID/GID 10001; ownership is not changed
|
|
210
|
+
automatically. Project and seed mounts must also be readable by that user.
|
|
211
|
+
The container drops all capabilities and disables privilege escalation;
|
|
212
|
+
bwrap uses unprivileged user namespaces.
|
|
213
|
+
|
|
214
|
+
For Kubernetes, set `runAsNonRoot: true`, `runAsUser: 10001`,
|
|
215
|
+
`runAsGroup: 10001`, `fsGroup: 10001` for the state PVC, and
|
|
216
|
+
`allowPrivilegeEscalation: false` with all capabilities dropped. The node's
|
|
217
|
+
security policy must allow bwrap's unprivileged user namespaces; startup
|
|
218
|
+
reports a failed probe rather than running commands without isolation.
|
|
219
|
+
|
|
220
|
+
Model keys come from the keyring (`zen key …`) and the environment, as for any
|
|
221
|
+
run, including the project's `.env`; in the agent image, the environment.
|
|
222
|
+
Vertex project, location and quota project, and the Bifrost endpoint, are forwarded
|
|
223
|
+
alongside keys. Explicit `GOOGLE_APPLICATION_CREDENTIALS` takes precedence over
|
|
224
|
+
ambient gcloud application-default credentials.
|
|
225
|
+
|
|
226
|
+
File credentials use ephemeral Podman secrets readable only by UID 10001. The
|
|
227
|
+
server stages them in its private container home before opening engines, allowing
|
|
228
|
+
bwrap to bind container-owned files when the project's keys policy permits it.
|
|
229
|
+
They are not embedded in the image, argv, workspaces or persistent user state.
|
|
230
|
+
Secrets are removed after confirmed shutdown or failed startup; a failed stop
|
|
231
|
+
retains them and the memory seed until the container can be stopped. Failed secret
|
|
232
|
+
cleanup is reported with a manual removal command.
|
|
233
|
+
|
|
234
|
+
## Rebuilding the standalone page
|
|
235
|
+
|
|
236
|
+
The source and build live in ZeneraLite's `services/zenera-app`. Run there:
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
npm run build:chat -- --output /path/to/ZeneraNeo/packages/cli/assets/chat.html
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Without `--output`, the result is `dist/chat.html`. If a sibling `../ZeneraNeo`
|
|
243
|
+
checkout exists next to ZeneraLite, the build also copies the result into its
|
|
244
|
+
`packages/cli/assets/chat.html` automatically, including when `--output` is used.
|
|
245
|
+
Without that sibling, only the requested output is written. The CLI package includes
|
|
246
|
+
the compiled asset; ZeneraNeo needs no frontend build dependencies.
|
package/dist/agui.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type AGUIEvent, type Interrupt, type TokenUsage as AguiUsage, type RunAgentInput } from '@ag-ui/core';
|
|
2
|
+
import type { AgentEvent, AgentState, Payload, TokenUsage } from '@zenera/neo';
|
|
3
|
+
export declare const MAX_TOOL_OUTPUT = 8000;
|
|
4
|
+
export declare function clip(text: string, max?: number): string;
|
|
5
|
+
export type Resolve = (payload: Payload) => Promise<string>;
|
|
6
|
+
/**
|
|
7
|
+
* The calls a suspended run waits on, as AG-UI interrupts. The id is zenera-app's interrupt id
|
|
8
|
+
* when the tool got one (it resumes the run by it), else the call id; `toolCallId` ties it to the
|
|
9
|
+
* tool card; `metadata` is what the tool said about it (server, icon, ...), for that card.
|
|
10
|
+
*/
|
|
11
|
+
export declare function interruptsOf(state: AgentState): Interrupt[];
|
|
12
|
+
export interface TranslatorOptions {
|
|
13
|
+
threadId: string;
|
|
14
|
+
runId: string;
|
|
15
|
+
resolve: Resolve;
|
|
16
|
+
newId?: () => string;
|
|
17
|
+
}
|
|
18
|
+
export declare class AguiTranslator {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(opts: TranslatorOptions);
|
|
21
|
+
get done(): boolean;
|
|
22
|
+
/** The run's input goes with it: a replayed run then shows what it answered. */
|
|
23
|
+
started(input?: RunAgentInput): AGUIEvent[];
|
|
24
|
+
translate(ev: AgentEvent): Promise<AGUIEvent[]>;
|
|
25
|
+
/** The end of a run that stopped without a run_finished: an exception or a cancel. */
|
|
26
|
+
fail(err: unknown, code?: string): AGUIEvent[];
|
|
27
|
+
}
|
|
28
|
+
export declare function toAguiUsage(u: TokenUsage): AguiUsage;
|
|
29
|
+
//# sourceMappingURL=agui.d.ts.map
|
package/dist/agui.js
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// AG-UI for a @zenera/neo run. neo streams bare deltas plus a checkpoint per model call, tool
|
|
2
|
+
// call, hand-off and branch; AG-UI wants every text, reasoning and tool-call stream opened and
|
|
3
|
+
// closed around them. One translator per run keeps that bookkeeping, per branch.
|
|
4
|
+
// AguiTranslator - neo AgentEvent -> AG-UI events, in order (await each call before the next)
|
|
5
|
+
// clip - the cap on tool output sent to a browser
|
|
6
|
+
// interruptsOf - what a suspended run waits for, as AG-UI interrupts (RUN_FINISHED outcome)
|
|
7
|
+
import { EventType, } from '@ag-ui/core';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
export const MAX_TOOL_OUTPUT = 8000;
|
|
10
|
+
export function clip(text, max = MAX_TOOL_OUTPUT) {
|
|
11
|
+
return text.length > max
|
|
12
|
+
? `${text.slice(0, max)}\n… ${text.length - max} more characters`
|
|
13
|
+
: text;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The calls a suspended run waits on, as AG-UI interrupts. The id is zenera-app's interrupt id
|
|
17
|
+
* when the tool got one (it resumes the run by it), else the call id; `toolCallId` ties it to the
|
|
18
|
+
* tool card; `metadata` is what the tool said about it (server, icon, ...), for that card.
|
|
19
|
+
*/
|
|
20
|
+
export function interruptsOf(state) {
|
|
21
|
+
// `suspended` is newer than some @zenera/neo typings this compiles against.
|
|
22
|
+
const suspended = state.suspended ?? [];
|
|
23
|
+
return suspended.map((raw) => {
|
|
24
|
+
const s = raw;
|
|
25
|
+
const data = s.data && typeof s.data === 'object' ? s.data : undefined;
|
|
26
|
+
return {
|
|
27
|
+
id: typeof data?.interruptId === 'string' ? data.interruptId : s.callId,
|
|
28
|
+
reason: s.reason,
|
|
29
|
+
...(s.message ? { message: s.message } : {}),
|
|
30
|
+
toolCallId: s.callId,
|
|
31
|
+
...(s.responseSchema ? { responseSchema: s.responseSchema } : {}),
|
|
32
|
+
...(data ? { metadata: data } : {}),
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export class AguiTranslator {
|
|
37
|
+
#threadId;
|
|
38
|
+
#runId;
|
|
39
|
+
#resolve;
|
|
40
|
+
#newId;
|
|
41
|
+
#lanes = new Map();
|
|
42
|
+
// fork node id -> the tool call that asked for it
|
|
43
|
+
#forks = new Map();
|
|
44
|
+
#agent;
|
|
45
|
+
#done = false;
|
|
46
|
+
constructor(opts) {
|
|
47
|
+
this.#threadId = opts.threadId;
|
|
48
|
+
this.#runId = opts.runId;
|
|
49
|
+
this.#resolve = opts.resolve;
|
|
50
|
+
this.#newId = opts.newId ?? randomUUID;
|
|
51
|
+
}
|
|
52
|
+
get done() {
|
|
53
|
+
return this.#done;
|
|
54
|
+
}
|
|
55
|
+
/** The run's input goes with it: a replayed run then shows what it answered. */
|
|
56
|
+
started(input) {
|
|
57
|
+
return [
|
|
58
|
+
{
|
|
59
|
+
type: EventType.RUN_STARTED,
|
|
60
|
+
threadId: this.#threadId,
|
|
61
|
+
runId: this.#runId,
|
|
62
|
+
...(input ? { input } : {}),
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
async translate(ev) {
|
|
67
|
+
if (this.#done)
|
|
68
|
+
return [];
|
|
69
|
+
const out = [];
|
|
70
|
+
const lane = this.#lane(ev.branch?.runId);
|
|
71
|
+
const sub = lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {};
|
|
72
|
+
// A continued session sends no run_created, so the first trunk event opens the step.
|
|
73
|
+
if (!ev.branch && !this.#agent) {
|
|
74
|
+
this.#agent = ev.agent;
|
|
75
|
+
out.push({ type: EventType.STEP_STARTED, stepName: ev.agent });
|
|
76
|
+
}
|
|
77
|
+
switch (ev.type) {
|
|
78
|
+
case 'thinking_delta': {
|
|
79
|
+
if (!ev.delta)
|
|
80
|
+
break;
|
|
81
|
+
this.#endText(lane, out);
|
|
82
|
+
if (!lane.reasoning) {
|
|
83
|
+
lane.reasoning = this.#newId();
|
|
84
|
+
out.push({ type: EventType.REASONING_START, messageId: lane.reasoning, ...sub }, {
|
|
85
|
+
type: EventType.REASONING_MESSAGE_START,
|
|
86
|
+
messageId: lane.reasoning,
|
|
87
|
+
role: 'reasoning',
|
|
88
|
+
...sub,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
out.push({
|
|
92
|
+
type: EventType.REASONING_MESSAGE_CONTENT,
|
|
93
|
+
messageId: lane.reasoning,
|
|
94
|
+
delta: ev.delta,
|
|
95
|
+
...sub,
|
|
96
|
+
});
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
case 'text_delta':
|
|
100
|
+
if (!ev.delta)
|
|
101
|
+
break;
|
|
102
|
+
this.#endReasoning(lane, out);
|
|
103
|
+
this.#text(lane, ev.agent, ev.delta, out);
|
|
104
|
+
lane.streamedText = true;
|
|
105
|
+
break;
|
|
106
|
+
case 'tool_call_detected':
|
|
107
|
+
this.#endReasoning(lane, out);
|
|
108
|
+
this.#startTool(lane, ev.callId, ev.name, out);
|
|
109
|
+
break;
|
|
110
|
+
case 'tool_args_delta':
|
|
111
|
+
if (!lane.tools.has(ev.callId))
|
|
112
|
+
this.#startTool(lane, ev.callId, ev.name, out);
|
|
113
|
+
if (ev.delta) {
|
|
114
|
+
out.push({
|
|
115
|
+
type: EventType.TOOL_CALL_ARGS,
|
|
116
|
+
toolCallId: ev.callId,
|
|
117
|
+
delta: ev.delta,
|
|
118
|
+
...sub,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
break;
|
|
122
|
+
case 'before_llm_call':
|
|
123
|
+
lane.streamedText = false;
|
|
124
|
+
lane.tools.clear();
|
|
125
|
+
break;
|
|
126
|
+
case 'after_llm_call': {
|
|
127
|
+
this.#endReasoning(lane, out);
|
|
128
|
+
// A model that does not stream says everything here, at once.
|
|
129
|
+
if (!lane.streamedText) {
|
|
130
|
+
const text = await this.#resolve(ev.node.text);
|
|
131
|
+
if (text.trim())
|
|
132
|
+
this.#text(lane, ev.agent, text, out);
|
|
133
|
+
}
|
|
134
|
+
this.#endText(lane, out, { model: ev.node.model, usage: ev.node.usage });
|
|
135
|
+
for (const call of ev.node.toolCalls) {
|
|
136
|
+
if (!lane.tools.has(call.callId)) {
|
|
137
|
+
this.#startTool(lane, call.callId, call.name, out);
|
|
138
|
+
const args = await this.#resolve(call.args);
|
|
139
|
+
if (args) {
|
|
140
|
+
out.push({
|
|
141
|
+
type: EventType.TOOL_CALL_ARGS,
|
|
142
|
+
toolCallId: call.callId,
|
|
143
|
+
delta: args,
|
|
144
|
+
...sub,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
out.push({ type: EventType.TOOL_CALL_END, toolCallId: call.callId, ...sub });
|
|
149
|
+
}
|
|
150
|
+
lane.tools.clear();
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
case 'after_tool_call': {
|
|
154
|
+
const content = clip(await this.#resolve(ev.node.result));
|
|
155
|
+
out.push({
|
|
156
|
+
type: EventType.TOOL_CALL_RESULT,
|
|
157
|
+
messageId: this.#newId(),
|
|
158
|
+
toolCallId: ev.node.callId,
|
|
159
|
+
content,
|
|
160
|
+
role: 'tool',
|
|
161
|
+
...sub,
|
|
162
|
+
...(ev.node.isError ? { metadata: { isError: true } } : {}),
|
|
163
|
+
});
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case 'handoff':
|
|
167
|
+
this.#endReasoning(lane, out);
|
|
168
|
+
this.#endText(lane, out);
|
|
169
|
+
out.push({ type: EventType.STEP_FINISHED, stepName: ev.from, ...sub }, { type: EventType.STEP_STARTED, stepName: ev.to, ...sub });
|
|
170
|
+
if (!ev.branch)
|
|
171
|
+
this.#agent = ev.to;
|
|
172
|
+
break;
|
|
173
|
+
case 'before_fork':
|
|
174
|
+
this.#forks.set(ev.node.id, ev.node.callId);
|
|
175
|
+
break;
|
|
176
|
+
case 'branch_started': {
|
|
177
|
+
const parentToolCallId = this.#forks.get(ev.child.forkId);
|
|
178
|
+
out.push({
|
|
179
|
+
type: EventType.SUBAGENT_STARTED,
|
|
180
|
+
subagentRunId: ev.child.runId,
|
|
181
|
+
name: ev.child.name,
|
|
182
|
+
description: ev.childState.agentName,
|
|
183
|
+
...(lane.subagentRunId ? { parentSubagentRunId: lane.subagentRunId } : {}),
|
|
184
|
+
...(parentToolCallId ? { parentToolCallId } : {}),
|
|
185
|
+
});
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case 'branch_finished': {
|
|
189
|
+
const child = this.#lane(ev.child.runId);
|
|
190
|
+
this.#endReasoning(child, out);
|
|
191
|
+
this.#endText(child, out);
|
|
192
|
+
this.#lanes.delete(ev.child.runId);
|
|
193
|
+
if (ev.status === 'ok') {
|
|
194
|
+
out.push({ type: EventType.SUBAGENT_FINISHED, subagentRunId: ev.child.runId });
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
out.push({
|
|
198
|
+
type: EventType.SUBAGENT_ERROR,
|
|
199
|
+
subagentRunId: ev.child.runId,
|
|
200
|
+
message: ev.childState.error ?? `branch ${ev.child.name} ${ev.status}`,
|
|
201
|
+
code: ev.status,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
case 'run_finished':
|
|
207
|
+
// A branch's own end is reported by branch_finished on its parent.
|
|
208
|
+
if (!ev.branch)
|
|
209
|
+
out.push(...this.#finish(ev.result));
|
|
210
|
+
break;
|
|
211
|
+
default:
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
/** The end of a run that stopped without a run_finished: an exception or a cancel. */
|
|
217
|
+
fail(err, code = 'error') {
|
|
218
|
+
if (this.#done)
|
|
219
|
+
return [];
|
|
220
|
+
const out = this.#closeAll();
|
|
221
|
+
this.#done = true;
|
|
222
|
+
if (this.#agent)
|
|
223
|
+
out.push({ type: EventType.STEP_FINISHED, stepName: this.#agent });
|
|
224
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
225
|
+
out.push({ type: EventType.RUN_ERROR, message, code });
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
#finish(result) {
|
|
229
|
+
const out = this.#closeAll();
|
|
230
|
+
this.#done = true;
|
|
231
|
+
if (this.#agent)
|
|
232
|
+
out.push({ type: EventType.STEP_FINISHED, stepName: this.#agent });
|
|
233
|
+
const usage = [toAguiUsage(result.usage)];
|
|
234
|
+
if (result.stopReason === 'failed') {
|
|
235
|
+
out.push({
|
|
236
|
+
type: EventType.RUN_ERROR,
|
|
237
|
+
message: result.state.error ?? 'the run failed',
|
|
238
|
+
code: 'failed',
|
|
239
|
+
usage,
|
|
240
|
+
});
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
out.push({
|
|
244
|
+
type: EventType.RUN_FINISHED,
|
|
245
|
+
threadId: this.#threadId,
|
|
246
|
+
runId: this.#runId,
|
|
247
|
+
outcome: result.stopReason === 'aborted'
|
|
248
|
+
? { type: 'cancelled' }
|
|
249
|
+
: // 'suspended' is newer than some @zenera/neo typings this compiles against.
|
|
250
|
+
result.stopReason === 'suspended'
|
|
251
|
+
? { type: 'interrupt', interrupts: interruptsOf(result.state) }
|
|
252
|
+
: { type: 'success' },
|
|
253
|
+
result: { agent: result.agent },
|
|
254
|
+
usage,
|
|
255
|
+
});
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
#closeAll() {
|
|
259
|
+
const out = [];
|
|
260
|
+
for (const lane of this.#lanes.values()) {
|
|
261
|
+
this.#endReasoning(lane, out);
|
|
262
|
+
this.#endText(lane, out);
|
|
263
|
+
const sub = lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {};
|
|
264
|
+
for (const callId of lane.tools.keys()) {
|
|
265
|
+
out.push({ type: EventType.TOOL_CALL_END, toolCallId: callId, ...sub });
|
|
266
|
+
}
|
|
267
|
+
lane.tools.clear();
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
#lane(subagentRunId) {
|
|
272
|
+
const key = subagentRunId ?? '';
|
|
273
|
+
let lane = this.#lanes.get(key);
|
|
274
|
+
if (!lane) {
|
|
275
|
+
lane = { subagentRunId, streamedText: false, tools: new Map() };
|
|
276
|
+
this.#lanes.set(key, lane);
|
|
277
|
+
}
|
|
278
|
+
return lane;
|
|
279
|
+
}
|
|
280
|
+
#text(lane, agent, delta, out) {
|
|
281
|
+
const sub = lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {};
|
|
282
|
+
if (!lane.text) {
|
|
283
|
+
lane.text = this.#newId();
|
|
284
|
+
out.push({
|
|
285
|
+
type: EventType.TEXT_MESSAGE_START,
|
|
286
|
+
messageId: lane.text,
|
|
287
|
+
role: 'assistant',
|
|
288
|
+
name: agent,
|
|
289
|
+
...sub,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
out.push({ type: EventType.TEXT_MESSAGE_CONTENT, messageId: lane.text, delta, ...sub });
|
|
293
|
+
}
|
|
294
|
+
#endText(lane, out, call) {
|
|
295
|
+
if (!lane.text)
|
|
296
|
+
return;
|
|
297
|
+
const sub = lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {};
|
|
298
|
+
out.push({
|
|
299
|
+
type: EventType.TEXT_MESSAGE_END,
|
|
300
|
+
messageId: lane.text,
|
|
301
|
+
...sub,
|
|
302
|
+
...(call ? { metadata: { model: call.model, usage: toAguiUsage(call.usage) } } : {}),
|
|
303
|
+
});
|
|
304
|
+
lane.text = undefined;
|
|
305
|
+
}
|
|
306
|
+
#endReasoning(lane, out) {
|
|
307
|
+
if (!lane.reasoning)
|
|
308
|
+
return;
|
|
309
|
+
const sub = lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {};
|
|
310
|
+
out.push({ type: EventType.REASONING_MESSAGE_END, messageId: lane.reasoning, ...sub }, { type: EventType.REASONING_END, messageId: lane.reasoning, ...sub });
|
|
311
|
+
lane.reasoning = undefined;
|
|
312
|
+
}
|
|
313
|
+
#startTool(lane, callId, name, out) {
|
|
314
|
+
lane.tools.set(callId, { name });
|
|
315
|
+
out.push({
|
|
316
|
+
type: EventType.TOOL_CALL_START,
|
|
317
|
+
toolCallId: callId,
|
|
318
|
+
toolCallName: name,
|
|
319
|
+
...(lane.text ? { parentMessageId: lane.text } : {}),
|
|
320
|
+
...(lane.subagentRunId ? { subagentRunId: lane.subagentRunId } : {}),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
export function toAguiUsage(u) {
|
|
325
|
+
return {
|
|
326
|
+
inputTokens: u.inputTokens,
|
|
327
|
+
outputTokens: u.outputTokens,
|
|
328
|
+
totalTokens: u.inputTokens + u.outputTokens,
|
|
329
|
+
reasoningTokens: u.reasoningTokens,
|
|
330
|
+
cachedInputTokens: u.cachedInputTokens,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
//# sourceMappingURL=agui.js.map
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
import { type JWTPayload } from 'jose';
|
|
3
|
+
export interface CallerClaims extends JWTPayload {
|
|
4
|
+
sub: string;
|
|
5
|
+
prj?: string;
|
|
6
|
+
req?: string;
|
|
7
|
+
thr?: string;
|
|
8
|
+
role?: string;
|
|
9
|
+
email?: string;
|
|
10
|
+
name?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function verifyCaller(req: Request): Promise<CallerClaims>;
|
|
13
|
+
export declare function requireCaller(): (req: Request, res: Response, next: NextFunction) => Promise<void>;
|
|
14
|
+
/** A token an `insecure` server takes: the claims, no signature. */
|
|
15
|
+
export declare function unsignedToken(claims: Record<string, unknown>): string;
|
|
16
|
+
//# sourceMappingURL=auth.d.ts.map
|