@zenera/server 0.0.0-stage → 1.2.1
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 +157 -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/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/trace.d.ts +84 -0
- package/dist/trace.js +389 -0
- package/package.json +56 -5
package/README.md
CHANGED
|
@@ -1,3 +1,158 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<img
|
|
3
|
+
src="https://raw.githubusercontent.com/zenera-ai/ZeneraNeo/main/docs/imgs/zenera-logo-with-media-queries.svg"
|
|
4
|
+
alt="Zenera"
|
|
5
|
+
width="24"
|
|
6
|
+
/>
|
|
7
|
+
<div>Powered By Zenera</div>
|
|
8
|
+
<br>
|
|
9
|
+
<img
|
|
10
|
+
src="https://raw.githubusercontent.com/zenera-ai/ZeneraNeo/main/docs/imgs/banner-server-with-media-queries.svg"
|
|
11
|
+
alt="ZENERA SERVER"
|
|
12
|
+
width="918"
|
|
13
|
+
/>
|
|
2
14
|
|
|
3
|
-
|
|
15
|
+
[](https://www.npmjs.com/package/@zenera/server)
|
|
16
|
+
[](https://github.com/zenera-ai/ZeneraNeo/blob/main/LICENSE)
|
|
17
|
+
[](https://nodejs.org)
|
|
18
|
+
|
|
19
|
+
[Quickstart](#quickstart) · [Secure setup](#secure-setup) · [Commands](#commands) · [Options](#common-options) · [HTTP API](#http-api)
|
|
20
|
+
|
|
21
|
+
</div>
|
|
22
|
+
|
|
23
|
+
# `@zenera/server`
|
|
24
|
+
|
|
25
|
+
Serve a ZeneraNeo project's chats over HTTP. Each user has private sessions and
|
|
26
|
+
memory; agent commands run in Podman sandboxes, or under bwrap in an agent container.
|
|
27
|
+
Requires Node.js 24+ and the [Zenera CLI](../cli/README.md).
|
|
28
|
+
|
|
29
|
+
## Quickstart
|
|
30
|
+
|
|
31
|
+
From a configured project:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
zen server start --insecure --user ann
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Open the printed HTTP `/chat` link to list sessions, send messages, follow active
|
|
38
|
+
runs, cancel them, or delete a session. `--user` selects the development JWT's
|
|
39
|
+
subject, not a server-wide user. The default is `local`.
|
|
40
|
+
|
|
41
|
+
**Insecure mode is for local testing only:** anyone who can reach the server can
|
|
42
|
+
impersonate any user. API requests still need a JWT, but signatures are not checked.
|
|
43
|
+
The test page is available only in this mode. Its token stays in memory; after a
|
|
44
|
+
reload, reopen the printed link or enter a development token again.
|
|
45
|
+
|
|
46
|
+
To run the server in the project's agent image:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
zen server start --container --insecure --user ann
|
|
50
|
+
zen server start --container --insecure --user ann --verbose # full build logs
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Podman builds and caches the image. Default build output shows a spinner, elapsed
|
|
54
|
+
time, and the latest line; warnings remain visible. Use `--rebuild` to rebuild.
|
|
55
|
+
Press Ctrl+C to stop gracefully and save active runs.
|
|
56
|
+
|
|
57
|
+
## Secure setup
|
|
58
|
+
|
|
59
|
+
A trusted caller, usually Zenera-app, authenticates users, checks project access,
|
|
60
|
+
and signs a short-lived JWT for each runtime request. The runtime holds **only
|
|
61
|
+
the public key**.
|
|
62
|
+
|
|
63
|
+
For a standalone caller, generate an Ed25519 pair outside the project directory:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
zen server keygen --out "$HOME/runtime-auth"
|
|
67
|
+
zen server start --jwt-public-key "$HOME/runtime-auth.public.pem"
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`keygen` creates `<prefix>.private.pem` (PKCS#8, owner-only `0600` permissions)
|
|
71
|
+
and `<prefix>.public.pem` (SPKI). The parent directory must already exist.
|
|
72
|
+
Existing files are never overwritten; a failed write removes newly created files.
|
|
73
|
+
Keep the private key with the trusted caller, out of source control and runtime
|
|
74
|
+
containers. Zenera-app already manages its own signing identity; it does not need
|
|
75
|
+
this command.
|
|
76
|
+
|
|
77
|
+
Generating keys does **not** sign requests. Secure callers send an
|
|
78
|
+
`Authorization: Bearer <jwt>` header containing an EdDSA-signed token with:
|
|
79
|
+
|
|
80
|
+
- `iss` / `aud`: defaults `zenera-app` / `zenera-runtime`.
|
|
81
|
+
- `sub`: the authenticated user; `prj`: this server's project ID.
|
|
82
|
+
- `req`: the exact `"<METHOD> <path-and-query>"` of the request.
|
|
83
|
+
- `iat` / `exp`: issue time and expiry; maximum age 2 minutes, 30 seconds clock tolerance.
|
|
84
|
+
|
|
85
|
+
Use HTTPS at the ingress or a protected internal connection. A token cannot
|
|
86
|
+
authorize a different endpoint, but can be replayed for the same request until
|
|
87
|
+
it expires. The server does not perform user login or project-membership checks.
|
|
88
|
+
|
|
89
|
+
## Commands
|
|
90
|
+
|
|
91
|
+
| Command | Use |
|
|
92
|
+
| ---------------------------------- | --------------------------------------------------------------- |
|
|
93
|
+
| `zen server start` | Start a project's runtime. |
|
|
94
|
+
| `zen server check` | Check whether it can start in the current environment. |
|
|
95
|
+
| `zen server status` | Inspect a running runtime; use `--token <jwt>` for secure mode. |
|
|
96
|
+
| `zen server keygen --out <prefix>` | Generate private/public signing keys. |
|
|
97
|
+
| `zen server token --user <id>` | Print an **unsigned** development JWT for insecure mode. |
|
|
98
|
+
|
|
99
|
+
Test an API without the browser:
|
|
100
|
+
|
|
101
|
+
```sh
|
|
102
|
+
TOKEN=$(zen server token --user ann)
|
|
103
|
+
curl http://127.0.0.1:3000/api/sessions \
|
|
104
|
+
-H "Authorization: Bearer $TOKEN"
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`token` also accepts `--email`, `--role`, `--name`, and `--thread`. It neither
|
|
108
|
+
creates an account nor signs a secure token.
|
|
109
|
+
|
|
110
|
+
## Common options
|
|
111
|
+
|
|
112
|
+
| Option | Use / default |
|
|
113
|
+
| --------------------------------- | ---------------------------------------------------------------------- |
|
|
114
|
+
| `--project <name-or-dir>` | Select a project; otherwise use the current project. |
|
|
115
|
+
| `--port <n>` / `--host <addr>` | `3000` / `127.0.0.1`. Insecure mode is loopback-only. |
|
|
116
|
+
| `--root <dir>` | Persistent user state; `~/.zenera/neo/servers/<project>` on the host. |
|
|
117
|
+
| `--memory-seed <dir>` | Initial memory for new users; defaults to project memory. |
|
|
118
|
+
| `--no-memory-seed` | Start new users with empty memory. |
|
|
119
|
+
| `--jwt-public-key <file>` | Public PEM for secure mode; alternatively `ZEN_SERVER_JWT_PUBLIC_KEY`. |
|
|
120
|
+
| `--jwt-issuer` / `--jwt-audience` | Override the expected signer / audience. |
|
|
121
|
+
| `--container` | Run the server in the project image; commands use bwrap. |
|
|
122
|
+
| `--verbose` / `--rebuild` | Full container build logs / rebuild images. |
|
|
123
|
+
| `--debug` | Enable run, trace, and memory inspection routes. |
|
|
124
|
+
|
|
125
|
+
Run `zen server --help` for all options. Sessions and memory live under
|
|
126
|
+
`<root>/users/<user>/`; memory is private per user and shared across their sessions.
|
|
127
|
+
New users receive the memory seed captured at startup. Existing user memory is
|
|
128
|
+
never replaced by a restart.
|
|
129
|
+
|
|
130
|
+
Model credentials come from `zen key`, the environment, and the project's `.env`.
|
|
131
|
+
Container mode forwards provider settings, including Vertex and Bifrost, and
|
|
132
|
+
credential files. The gateway must be reachable from the container; host
|
|
133
|
+
`localhost` is not container `localhost`.
|
|
134
|
+
Set `sandbox.keys: false` to withhold credentials from agent-written commands,
|
|
135
|
+
while allowing the engine to use them for model calls.
|
|
136
|
+
|
|
137
|
+
## HTTP API
|
|
138
|
+
|
|
139
|
+
Session routes operate only on the JWT subject's own sessions.
|
|
140
|
+
|
|
141
|
+
| Route | Use |
|
|
142
|
+
| ---------------------------------------------- | ---------------------------------------------- |
|
|
143
|
+
| `GET /healthz` | Public health probe. |
|
|
144
|
+
| `GET /chat` | Public test page, insecure mode only. |
|
|
145
|
+
| `GET /api/status` / `GET /api/activity` | Runtime status / active runs. |
|
|
146
|
+
| `GET /api/sessions` / `GET /api/sessions/:tid` | List sessions / load history. |
|
|
147
|
+
| `POST /api/sessions/:tid/runs` | Start a turn, streaming AG-UI events over SSE. |
|
|
148
|
+
| `GET /api/sessions/:tid/runs/:runId/events` | Follow or replay a run. |
|
|
149
|
+
| `POST /api/sessions/:tid/runs/:runId/cancel` | Cancel a run. |
|
|
150
|
+
| `DELETE /api/sessions/:tid` | Delete a session and its files. |
|
|
151
|
+
|
|
152
|
+
Runs continue after a client disconnects and can be followed again. Defaults:
|
|
153
|
+
4 concurrent runs total, 2 per user (`ZEN_RUNTIME_MAX_RUNS`,
|
|
154
|
+
`ZEN_RUNTIME_MAX_USER_RUNS`).
|
|
155
|
+
|
|
156
|
+
For deployment and sandbox details, see [agent-image architecture](../../docs/agent-image.md).
|
|
157
|
+
To rebuild the bundled test page, run `npm run build:chat` from ZeneraLite's root;
|
|
158
|
+
it automatically copies the page into a sibling ZeneraNeo checkout when present.
|
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
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { decodeJwt, importSPKI, jwtVerify } from 'jose';
|
|
2
|
+
import { settings } from "./config.js";
|
|
3
|
+
import { HttpError } from "./http.js";
|
|
4
|
+
const OPEN = new Set(['/healthz']);
|
|
5
|
+
let key;
|
|
6
|
+
function publicKey(pem) {
|
|
7
|
+
if (key?.pem !== pem)
|
|
8
|
+
key = { pem, key: importSPKI(pem, 'EdDSA') };
|
|
9
|
+
return key.key;
|
|
10
|
+
}
|
|
11
|
+
function bearer(req) {
|
|
12
|
+
const header = req.get('authorization') ?? '';
|
|
13
|
+
if (!header.startsWith('Bearer '))
|
|
14
|
+
throw new HttpError(401, 'no caller token');
|
|
15
|
+
return header.slice('Bearer '.length).trim();
|
|
16
|
+
}
|
|
17
|
+
export async function verifyCaller(req) {
|
|
18
|
+
const s = settings();
|
|
19
|
+
const token = bearer(req);
|
|
20
|
+
let payload;
|
|
21
|
+
if (s.auth.mode === 'insecure') {
|
|
22
|
+
try {
|
|
23
|
+
payload = decodeJwt(token);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
throw new HttpError(401, `not a token: ${err instanceof Error ? err.message : err}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
let verifyKey;
|
|
31
|
+
try {
|
|
32
|
+
verifyKey = await publicKey(s.auth.publicKey);
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
throw new HttpError(503, `the server's public key is not an Ed25519 key: ${err}`);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
({ payload } = await jwtVerify(token, verifyKey, {
|
|
39
|
+
algorithms: ['EdDSA'],
|
|
40
|
+
issuer: s.auth.issuer,
|
|
41
|
+
audience: s.auth.audience,
|
|
42
|
+
requiredClaims: ['sub', 'iat', 'exp'],
|
|
43
|
+
clockTolerance: 30,
|
|
44
|
+
maxTokenAge: '2m',
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
throw new HttpError(401, `invalid caller token: ${err instanceof Error ? err.message : err}`);
|
|
49
|
+
}
|
|
50
|
+
if (payload.prj !== s.projectId) {
|
|
51
|
+
throw new HttpError(403, 'the token is for another project');
|
|
52
|
+
}
|
|
53
|
+
// Bound to one request: a token seen in transit is no good for any other.
|
|
54
|
+
if (payload.req !== `${req.method} ${req.originalUrl}`) {
|
|
55
|
+
throw new HttpError(401, 'the token is for another request');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (typeof payload.sub !== 'string' || !payload.sub.trim()) {
|
|
59
|
+
throw new HttpError(401, 'the token names no user (sub)');
|
|
60
|
+
}
|
|
61
|
+
return payload;
|
|
62
|
+
}
|
|
63
|
+
export function requireCaller() {
|
|
64
|
+
return async (req, res, next) => {
|
|
65
|
+
if (OPEN.has(req.path)) {
|
|
66
|
+
next();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
res.locals.caller = await verifyCaller(req);
|
|
70
|
+
next();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
/** A token an `insecure` server takes: the claims, no signature. */
|
|
74
|
+
export function unsignedToken(claims) {
|
|
75
|
+
const part = (v) => Buffer.from(JSON.stringify(v)).toString('base64url');
|
|
76
|
+
const now = Math.floor(Date.now() / 1000);
|
|
77
|
+
return `${part({ alg: 'none', typ: 'JWT' })}.${part({ iat: now, ...claims })}.`;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=auth.js.map
|