@nylorun/agents 0.6.0-beta → 0.8.0-beta
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/CHANGELOG.md +56 -0
- package/README.md +111 -6
- package/dist/ag-ui/handler.d.ts +42 -0
- package/dist/ag-ui/handler.js +397 -0
- package/dist/ag-ui/history.d.ts +7 -0
- package/dist/ag-ui/history.js +60 -0
- package/dist/ag-ui/index.d.ts +7 -0
- package/dist/ag-ui/index.js +6 -0
- package/dist/ag-ui/node.d.ts +5 -0
- package/dist/ag-ui/node.js +51 -0
- package/dist/ag-ui/sse.d.ts +9 -0
- package/dist/ag-ui/sse.js +7 -0
- package/dist/ag-ui/translate.d.ts +38 -0
- package/dist/ag-ui/translate.js +222 -0
- package/dist/client.d.ts +18 -1
- package/dist/client.js +31 -1
- package/dist/connection.js +3 -1
- package/dist/http.d.ts +10 -1
- package/dist/http.js +24 -4
- package/dist/index.d.ts +4 -3
- package/dist/index.js +2 -1
- package/dist/sandbox/sandbox.d.ts +1 -2
- package/dist/sandbox/sandbox.js +1 -2
- package/package.json +8 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# @nylorun/agents
|
|
2
2
|
|
|
3
|
+
## 0.8.0-beta
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a322696: **`@nylorun/agents/ag-ui`: serve agents to AG-UI clients from your own server.** `createAgUiHandler({ basePath, agents, subject })` returns a web-standard `fetch` handler (plus `run`, `history`, `reattach` and `cancel`), and `toNodeListener` adapts it to `node:http` and Express.
|
|
8
|
+
|
|
9
|
+
- One session per person, agent and thread; the AG-UI message id is the idempotency key, so a retried run replays the same turn.
|
|
10
|
+
- Approvals become AG-UI interrupts and resume through `runAgent({ resume })`.
|
|
11
|
+
- History returns a plain AG-UI `Message[]` with the ids the live run used; reattach continues a run from `Last-Event-ID`.
|
|
12
|
+
- Needs a Runtime with `transcript-events`. Adds the `@ag-ui/core` dependency (`~1.0.0`), loaded only by this subpath.
|
|
13
|
+
|
|
14
|
+
`AgentsClient.hostFeatures()` returns the Runtime's protocol features, including optional ones.
|
|
15
|
+
|
|
16
|
+
- 844bff3: **Act for a person: `Nylorun-Subject` and `Nylorun-Scopes`.** An app server that holds the Tenant key can name the person each request is for, and the Runtime enforces it (optional Host feature `subject-headers`).
|
|
17
|
+
|
|
18
|
+
- `client.as(subject, { scopes })` in `@nylorun/agents` sends both headers on every call, event streams included. Scopes: `agents:read`, `agents:write`, `sessions:own`, `vaults:own`, `tenant:settings`; default `["sessions:own"]`.
|
|
19
|
+
- The Runtime limits a subject to the routes its scopes allow (`403 scope_required`) and to its own sessions and vaults: another owner's session, vault or sandbox is the same `404` as a missing one, including `PUT` on its session id (was `409`). Reset, config seed, executors, actions and the sandbox tool routes are open to no subject. Only application keys may send the headers.
|
|
20
|
+
- The AG-UI handler calls the Runtime as each person and requires `subject-headers`; new optional `scopes` option. The host's `session()` parameters can no longer replace a session's id, agent or owner.
|
|
21
|
+
- Core exports `SUBJECT_HEADER`, `SCOPES_HEADER`, `SUBJECT_SCOPES` and `parseSubjectHeaders`. Postgres Tenant schemas migrate to version 2 (an indexed session owner column).
|
|
22
|
+
|
|
23
|
+
Requests without `Nylorun-Subject` are unchanged.
|
|
24
|
+
|
|
25
|
+
### Patch Changes
|
|
26
|
+
|
|
27
|
+
- 42272f8: The AG-UI handler answers `400` (`invalid_request`) when the Runtime refuses what the client sent, such as a `Last-Event-ID` it cannot read, instead of `502`.
|
|
28
|
+
- Pin core to the tested release.
|
|
29
|
+
- Updated dependencies [a322696]
|
|
30
|
+
- Updated dependencies [844bff3]
|
|
31
|
+
- Updated dependencies [a322696]
|
|
32
|
+
- @nylorun/core@0.7.0-beta
|
|
33
|
+
|
|
34
|
+
## 0.7.0-beta
|
|
35
|
+
|
|
36
|
+
### Minor Changes
|
|
37
|
+
|
|
38
|
+
- bf1c2da: **Tenants can register a Studio principal.** `CreateTenantRequest` gains optional `studioCredentialHash` behind the new protocol feature `studio-principal`; the Runtime stores it as application principal `studio`, and idempotent create compares it too. `@nylorun/admin` exports `deriveStudioToken(adminKey, tenantId)` (HMAC-SHA256 over `nylorun/studio/v1`, NUL, Tenant id) and `createTenant` sends the hash of that key, so Studio can reach any Tenant's API with a key derived from the admin key. Clients require the new feature, so upgrade the Runtime with them.
|
|
39
|
+
|
|
40
|
+
### Patch Changes
|
|
41
|
+
|
|
42
|
+
- ba1b239: **`nylorun` sets up the local stack; `@nylorun/cli` becomes the Runtime client `nylo` (breaking beta).**
|
|
43
|
+
|
|
44
|
+
- New unscoped package **`nylorun`**: `npx nylorun up` sets up the Docker stack on the first run and starts it after that, from any directory; `npx nylorun down` stops it (volumes are kept). `up` and `down` are aliases of `start` and `stop`. It also owns `status`, `logs`, `studio`, `reset` and `doctor`, pins the Runtime and Studio images (`package.json` `nylorun.runtime` / `nylorun.studio`), depends on `@nylorun/core` only, and never creates Tenants.
|
|
45
|
+
- **`@nylorun/cli`** is the Runtime client with the command **`nylo`**: `nylo tenant create [name]` (creates the Tenant; in a project, writes the Project link and seeds the model provider and sandbox from `.env`), `nylo tenant current|list|use|status|reset|delete`, `nylo configure`, `nylo env` (was `nylorun status --env`) and `nylo doctor sandbox`. It no longer provides the `nylorun` command or the stack commands.
|
|
46
|
+
- **Removed:** `nylorun dev` and `nylorun dev --ephemeral`. Link once with `nylo tenant create`, then run the project's own `npm run dev`. Moved commands exit 2 and name their replacement.
|
|
47
|
+
- **Starter:** the generated project depends on `@nylorun/agents` only (no Nylorun devDependency); `dev` is `tsx watch --env-file-if-exists=.env src/main.ts`. `npm create @nylorun/agent` installs the project and prints the next steps (`npx nylorun up`, `npx @nylorun/cli tenant create`, `npm run dev`) instead of starting development; `--no-open` is accepted and ignored.
|
|
48
|
+
- `connectAgents` names those steps when it finds no Runtime connection. Runtime repair hints, model errors and core's sandbox message name `nylo` and `nylorun up`.
|
|
49
|
+
|
|
50
|
+
- bf1c2da: **The microsandbox backend is removed; the Runtime runs sandbox tools on the virtual backend only.** The optional `microsandbox` dependency is gone. `sandbox.backend` and `NYLORUN_SANDBOX` accept `auto` or `virtual`, and `auto` selects `virtual`. A Tenant that stored `microsandbox` reads it as `auto`. `nylorun doctor sandbox` and the `nylorun dev` banner report only the virtual shell.
|
|
51
|
+
- Pin core to the tested release.
|
|
52
|
+
- Updated dependencies [bf1c2da]
|
|
53
|
+
- Updated dependencies [ba1b239]
|
|
54
|
+
- Updated dependencies [bf1c2da]
|
|
55
|
+
- Updated dependencies [bf1c2da]
|
|
56
|
+
- Updated dependencies [bf1c2da]
|
|
57
|
+
- @nylorun/core@0.6.0-beta
|
|
58
|
+
|
|
3
59
|
## 0.6.0-beta
|
|
4
60
|
|
|
5
61
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -19,8 +19,10 @@ await connectAgents({ agents }).ready;
|
|
|
19
19
|
Application mode saves definitions, registers **derived** executor credentials
|
|
20
20
|
(HMAC of the application key + Tenant + agent id), and connects. Restarts and
|
|
21
21
|
replicas re-register the same hashes; tokens are never stored in the Project.
|
|
22
|
-
The same entry runs under `
|
|
23
|
-
(`npm start`).
|
|
22
|
+
The same entry runs under the project's `npm run dev` (`tsx watch`) and as
|
|
23
|
+
`node dist/src/main.js` (`npm start`). It finds the Runtime through the three
|
|
24
|
+
`NYLORUN_*` variables or the Project link that `npx @nylorun/cli tenant create`
|
|
25
|
+
writes; with neither, it fails with `connection_missing` and names those steps. See [MIGRATION.md](../MIGRATION.md#runtime-clients-and-admin-api-breaking-beta)
|
|
24
26
|
for upgrading from `nylorun serve`.
|
|
25
27
|
|
|
26
28
|
## Connection resolution
|
|
@@ -84,7 +86,7 @@ await connection.close();
|
|
|
84
86
|
```
|
|
85
87
|
|
|
86
88
|
```sh
|
|
87
|
-
eval "$(npx nylorun
|
|
89
|
+
eval "$(npx @nylorun/cli env)"
|
|
88
90
|
# → NYLORUN_RUNTIME_URL, NYLORUN_SERVER_KEY, NYLORUN_TENANT
|
|
89
91
|
```
|
|
90
92
|
|
|
@@ -143,7 +145,7 @@ const analyst = Agent({
|
|
|
143
145
|
}).use(sandbox());
|
|
144
146
|
```
|
|
145
147
|
|
|
146
|
-
The model gets `bash`, `read`, `write`, `edit`, `grep` and `glob` on a Linux machine with a persistent `/workspace`. These tools run in the Runtime, not in your process, so sandbox-only agents need no connected executor. The agent declares what it needs; the Runtime decides where it runs (
|
|
148
|
+
The model gets `bash`, `read`, `write`, `edit`, `grep` and `glob` on a Linux machine with a persistent `/workspace`. These tools run in the Runtime, not in your process, so sandbox-only agents need no connected executor. The agent declares what it needs; the Runtime decides where it runs (today an emulated shell in the Runtime process). Every option is optional plain data:
|
|
147
149
|
|
|
148
150
|
```ts
|
|
149
151
|
.use(sandbox({
|
|
@@ -154,7 +156,7 @@ The model gets `bash`, `read`, `write`, `edit`, `grep` and `glob` on a Linux mac
|
|
|
154
156
|
}))
|
|
155
157
|
```
|
|
156
158
|
|
|
157
|
-
The `dev` preset allows package registries and code hosts. Private networks, loopback, the host and cloud metadata endpoints are always blocked. Options from the full design that are not in this version (`setup`, `files`, `secrets`, `mount`, `onStart`, …) throw a `SandboxError` that says so.
|
|
159
|
+
The `dev` preset allows package registries and code hosts. Private networks, loopback, the host and cloud metadata endpoints are always blocked. Options from the full design that are not in this version (`setup`, `files`, `secrets`, `mount`, `onStart`, …) throw a `SandboxError` that says so.
|
|
158
160
|
|
|
159
161
|
Put an agent in another agent's `tools` to let the model delegate to it:
|
|
160
162
|
|
|
@@ -284,4 +286,107 @@ After registration, `connectAgents` opens authenticated fetch SSE before discove
|
|
|
284
286
|
|
|
285
287
|
Tools receive state, info, identity, resume, signal, approval/response helpers and memoized `step`. Step outcomes survive a persisted wait result; they do not establish exactly-once external effects after an unacknowledged crash. `sleep` and `waitFor` currently return inspectable deferred outcomes; automatic timer/event wakeups remain runtime implementation work. Remote `onModelCall` convenience and progress-event transport are not supplied in this pass. Arbitrary middleware closures are rejected for durable definitions; use `before`/`after` hooks. The executor runs every capability registered at a hook point in one action. Agent definitions have no `.run()`; explicit local execution is available through `@nylorun/harness/run`.
|
|
286
288
|
|
|
287
|
-
|
|
289
|
+
## AG-UI
|
|
290
|
+
|
|
291
|
+
`@nylorun/agents/ag-ui` serves the Tenant's agents to any
|
|
292
|
+
[AG-UI](https://docs.ag-ui.com) client (`@ag-ui/client`'s `HttpAgent`,
|
|
293
|
+
CopilotKit, a desktop renderer) from your own server. Your server signs people
|
|
294
|
+
in and names the person each request is for; the handler maps each AG-UI thread
|
|
295
|
+
to one session per person, agent and thread, and streams it as AG-UI events.
|
|
296
|
+
|
|
297
|
+
```ts
|
|
298
|
+
import { createServer } from "node:http";
|
|
299
|
+
import { createAgUiHandler, toNodeListener } from "@nylorun/agents/ag-ui";
|
|
300
|
+
import support from "./agents/support.js";
|
|
301
|
+
|
|
302
|
+
const agui = createAgUiHandler({
|
|
303
|
+
basePath: "/api/agui",
|
|
304
|
+
agents: [support], // nothing else is reachable
|
|
305
|
+
subject: async (request) => (await getSignedInUser(request))?.id, // undefined → 401
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
createServer(toNodeListener(agui)).listen(3000);
|
|
309
|
+
// Next.js, Hono, Bun, Deno, Workers: export or mount `agui.fetch` directly.
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
| Method and path under `basePath` | Operation |
|
|
313
|
+
| --- | --- |
|
|
314
|
+
| `POST /{agentId}` | Run: an AG-UI `RunAgentInput` in, server-sent events out |
|
|
315
|
+
| `GET /{agentId}/threads/{threadId}/messages` | History as a plain AG-UI `Message[]`, usable as `HttpAgent`'s `initialMessages` |
|
|
316
|
+
| `GET /{agentId}/threads/{threadId}/events` | Reattach: the rest of a run after a dropped connection, from `Last-Event-ID` (or `?cursor=`); `204` when nothing is left |
|
|
317
|
+
| `POST /{agentId}/threads/{threadId}/cancel` | Cancel the running turn |
|
|
318
|
+
|
|
319
|
+
`run`, `history`, `reattach` and `cancel` are also on the handler for your own
|
|
320
|
+
routing. The default `client` is `createClient()`; pass `client` to use another
|
|
321
|
+
one, and `session(subject, agentId)` to add the person's `vaultIds` or
|
|
322
|
+
`credentialSelections` (keep `info` stable: it is part of the session's
|
|
323
|
+
identity).
|
|
324
|
+
|
|
325
|
+
- Each AG-UI message id is the command's idempotency key: a retried run replays
|
|
326
|
+
the same turn instead of starting a second one.
|
|
327
|
+
- An approval ends the run with an AG-UI interrupt (`reason: "tool_approval"`,
|
|
328
|
+
`toolCallId` the model's call id). Resume with
|
|
329
|
+
`runAgent({ resume: [{ interruptId, status: "resolved", payload: { approved: true } }] })`.
|
|
330
|
+
Other interactions answer with `payload` as the response.
|
|
331
|
+
- Every event that ends a group carries the Runtime cursor as its SSE `id`.
|
|
332
|
+
`HttpAgent` does not reconnect by itself; call the reattach route with the
|
|
333
|
+
last id you received.
|
|
334
|
+
- Closing the connection stops reading; it never cancels the turn.
|
|
335
|
+
- A busy or paused thread ends the run with `RUN_ERROR` code `session_busy`.
|
|
336
|
+
- The handler calls the Runtime as each person (`client.as(subject, { scopes })`,
|
|
337
|
+
below), so the Runtime itself keeps one person out of another's threads.
|
|
338
|
+
`scopes` defaults to `["sessions:own"]`; add `"vaults:own"` when `session()`
|
|
339
|
+
attaches the person's vaults. A `subject` the Runtime cannot name (see below)
|
|
340
|
+
answers `500` (`subject_invalid`).
|
|
341
|
+
- The handler needs a Runtime with the optional features `transcript-events`
|
|
342
|
+
and `subject-headers` and answers `502` (`runtime_feature_missing`) without
|
|
343
|
+
them.
|
|
344
|
+
|
|
345
|
+
A complete web backend with a test is in
|
|
346
|
+
[examples/src/ag-ui](../examples/README.md#an-agent-in-your-web-app-ag-ui).
|
|
347
|
+
|
|
348
|
+
Limitations: assistant text arrives once per model step (no token streaming);
|
|
349
|
+
no reasoning, state, activity or subagent events, and an agent used as a tool
|
|
350
|
+
shows only its result; frontend tools in `RunAgentInput.tools` are rejected
|
|
351
|
+
(`400`); one text part per user message; earlier messages cannot be edited or
|
|
352
|
+
regenerated. Browsers always go through your server: never let one call the
|
|
353
|
+
Runtime directly.
|
|
354
|
+
|
|
355
|
+
## Acting for a person (app servers)
|
|
356
|
+
|
|
357
|
+
A server that signs people in and calls the Runtime for them (an "app server")
|
|
358
|
+
keeps the Tenant key to itself and names the person on each call:
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
import { createClient } from "@nylorun/agents";
|
|
362
|
+
|
|
363
|
+
const app = createClient(); // the Tenant key, on the server only
|
|
364
|
+
|
|
365
|
+
// Per request, after your own sign-in:
|
|
366
|
+
const person = app.as(`app:${user.id}`, { scopes: ["sessions:own", "vaults:own"] });
|
|
367
|
+
await person.createSession({ agentId: "support", ownerUserId: `app:${user.id}` });
|
|
368
|
+
await person.listSessions(); // only this person's sessions
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
`as()` sends `Nylorun-Subject` and `Nylorun-Scopes` on every call, event
|
|
372
|
+
streams included, and the Runtime (optional feature `subject-headers`)
|
|
373
|
+
enforces both: another person's sessions and vaults answer the same `404` as
|
|
374
|
+
missing ones, and a route outside the scopes answers `403` (`scope_required`).
|
|
375
|
+
Nothing is minted, cached or refreshed, and the copy shares the client's
|
|
376
|
+
compatibility check, so calling `as()` per request is cheap.
|
|
377
|
+
|
|
378
|
+
| Scope | Allows |
|
|
379
|
+
| --- | --- |
|
|
380
|
+
| `sessions:own` | The person's own sessions: create, list, read, stream, message, approve, respond, cancel |
|
|
381
|
+
| `vaults:own` | The person's own vaults and credentials |
|
|
382
|
+
| `agents:read` | Listing the Tenant's agents |
|
|
383
|
+
| `agents:write` | Saving agents; listing agents, models and providers |
|
|
384
|
+
| `tenant:settings` | The Tenant's status, model provider and sandbox settings |
|
|
385
|
+
|
|
386
|
+
No scope reaches Tenant reset, config seed, executors, actions or the sandbox
|
|
387
|
+
tool routes; call those without `as()`. A subject is 1–200 visible ASCII
|
|
388
|
+
characters (spaces only inside) and `host` is reserved. Your server must drop
|
|
389
|
+
any `Nylorun-*` header its own clients send, and only an application key can act
|
|
390
|
+
for a subject: an executor key that tries is `403`.
|
|
391
|
+
|
|
392
|
+
The SDK depends only on core within the Nylorun packages; installing it does not install harness. `/ag-ui` adds `@ag-ui/core`; nothing else imports it. Use `/define`, `/client`, `/executor` or `/ag-ui` for focused imports, or the root for convenience. Studio uses `/client`. See [the adopted host contract](../harness/HOST_CONTRACT.md).
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { BuiltWorkflow } from "@nylorun/core/define";
|
|
2
|
+
import { type SubjectScope } from "@nylorun/core/contracts";
|
|
3
|
+
import { type AgentSource, type AgentsClient } from "../client.js";
|
|
4
|
+
/** Per-session parameters the host may add, such as the person's vault for MCP servers. */
|
|
5
|
+
export interface AgUiSessionOptions {
|
|
6
|
+
/** Part of the session's identity: keep it stable for a subject and agent, or runs get 409. */
|
|
7
|
+
info?: Record<string, unknown>;
|
|
8
|
+
vaultIds?: readonly string[];
|
|
9
|
+
credentialSelections?: Parameters<AgentsClient["createSession"]>[0]["credentialSelections"];
|
|
10
|
+
}
|
|
11
|
+
export interface AgUiHandlerOptions {
|
|
12
|
+
/** Agents this endpoint may run: built agents, workflows or their ids. Nothing else is reachable. */
|
|
13
|
+
agents: readonly (AgentSource | BuiltWorkflow | string)[];
|
|
14
|
+
/** The signed-in person the request is for. `undefined` answers 401; there is no anonymous mode. */
|
|
15
|
+
subject(request: Request): string | undefined | Promise<string | undefined>;
|
|
16
|
+
/** Where `fetch` is mounted, e.g. `/api/agui`. Default `/`. */
|
|
17
|
+
basePath?: string;
|
|
18
|
+
/** Application client. Default: `createClient()`, from the environment or the Project link. */
|
|
19
|
+
client?: AgentsClient | Promise<AgentsClient>;
|
|
20
|
+
/**
|
|
21
|
+
* What each subject may do through this endpoint. Default `["sessions:own"]`; add
|
|
22
|
+
* `vaults:own` if `session()` attaches the person's vaults.
|
|
23
|
+
*/
|
|
24
|
+
scopes?: readonly SubjectScope[];
|
|
25
|
+
/** Optional per-session parameters, e.g. the person's vault for connected accounts. */
|
|
26
|
+
session?(subject: string, agentId: string): AgUiSessionOptions | Promise<AgUiSessionOptions>;
|
|
27
|
+
}
|
|
28
|
+
export interface AgUiHandler {
|
|
29
|
+
/** One entry point that routes by method and path under `basePath`. Bound. */
|
|
30
|
+
readonly fetch: (request: Request) => Promise<Response>;
|
|
31
|
+
/** `POST`: an AG-UI `RunAgentInput` in, the run's events out as server-sent events. */
|
|
32
|
+
run(request: Request, agentId: string): Promise<Response>;
|
|
33
|
+
/** `GET`: the thread's messages as a plain AG-UI `Message[]`. */
|
|
34
|
+
history(request: Request, agentId: string, threadId: string): Promise<Response>;
|
|
35
|
+
/** `GET`: the rest of a run after a dropped connection, from `Last-Event-ID` or `?cursor=`. */
|
|
36
|
+
reattach(request: Request, agentId: string, threadId: string): Promise<Response>;
|
|
37
|
+
/** `POST`: cancels the thread's running turn. */
|
|
38
|
+
cancel(request: Request, agentId: string, threadId: string): Promise<Response>;
|
|
39
|
+
}
|
|
40
|
+
/** A session per subject, agent and thread; one subject can never name another's session. */
|
|
41
|
+
export declare function sessionIdFor(subject: string, agentId: string, threadId: string): string;
|
|
42
|
+
export declare function createAgUiHandler(options: AgUiHandlerOptions): AgUiHandler;
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An AG-UI endpoint over the Tenant's agents, as web-standard `Request` → `Response`
|
|
3
|
+
* handlers. The host authenticates people (`subject`); the handler maps each AG-UI thread to
|
|
4
|
+
* one session per subject, agent and thread, and streams the session's transcript events as
|
|
5
|
+
* AG-UI events. It never trusts a session id from the client, and it calls the Runtime as the
|
|
6
|
+
* subject (`client.as`), so the Runtime itself keeps one subject out of another's sessions.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
9
|
+
import { EventType } from "@ag-ui/core";
|
|
10
|
+
import { RunAgentInputSchema } from "@ag-ui/core/schemas";
|
|
11
|
+
import { parseSubjectHeaders, } from "@nylorun/core/contracts";
|
|
12
|
+
import { createClient, } from "../client.js";
|
|
13
|
+
import { IncompatibleRuntimeError, RuntimeError } from "../http.js";
|
|
14
|
+
import { messagesFromEvents } from "./history.js";
|
|
15
|
+
import { SSE_CONTENT_TYPE, SSE_HEARTBEAT, sseFrame } from "./sse.js";
|
|
16
|
+
import { RunTranslator } from "./translate.js";
|
|
17
|
+
const HEARTBEAT_MS = 15_000;
|
|
18
|
+
/** Host features the handler needs: the chat transcript, and acting for each subject. */
|
|
19
|
+
const REQUIRED_FEATURES = ["transcript-events", "subject-headers"];
|
|
20
|
+
const DEFAULT_SCOPES = ["sessions:own"];
|
|
21
|
+
const TERMINAL = new Set([
|
|
22
|
+
"turn.completed",
|
|
23
|
+
"turn.paused",
|
|
24
|
+
"turn.failed",
|
|
25
|
+
"turn.cancelled",
|
|
26
|
+
]);
|
|
27
|
+
class Problem extends Error {
|
|
28
|
+
status;
|
|
29
|
+
code;
|
|
30
|
+
constructor(status, message, code) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.status = status;
|
|
33
|
+
this.code = code;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function problem(status, message, code) {
|
|
37
|
+
return Response.json({ error: message, ...(code ? { code } : {}) }, { status });
|
|
38
|
+
}
|
|
39
|
+
function problemOf(error) {
|
|
40
|
+
if (error instanceof Problem)
|
|
41
|
+
return error;
|
|
42
|
+
if (error instanceof RuntimeError) {
|
|
43
|
+
if (error.status === 404)
|
|
44
|
+
return new Problem(404, "Not found");
|
|
45
|
+
// The Runtime refused what the client sent (e.g. a cursor it cannot read).
|
|
46
|
+
if (error.status === 400)
|
|
47
|
+
return new Problem(400, "The request was rejected", "invalid_request");
|
|
48
|
+
if (error.status === 409)
|
|
49
|
+
return new Problem(409, "The agent is busy or waiting for an answer to an open interrupt", "session_busy");
|
|
50
|
+
return new Problem(502, "The agent service failed", "runtime_error");
|
|
51
|
+
}
|
|
52
|
+
if (error instanceof IncompatibleRuntimeError)
|
|
53
|
+
return new Problem(502, error.message, "runtime_incompatible");
|
|
54
|
+
return new Problem(500, "Internal error");
|
|
55
|
+
}
|
|
56
|
+
/** A session per subject, agent and thread; one subject can never name another's session. */
|
|
57
|
+
export function sessionIdFor(subject, agentId, threadId) {
|
|
58
|
+
return createHash("sha256")
|
|
59
|
+
.update(`${subject}\u0000${agentId}\u0000${threadId}`)
|
|
60
|
+
.digest("hex")
|
|
61
|
+
.slice(0, 32);
|
|
62
|
+
}
|
|
63
|
+
/** The trailing user message: the Runtime keeps the transcript, AG-UI resends all of it. */
|
|
64
|
+
function lastUserMessage(messages) {
|
|
65
|
+
const last = messages.at(-1);
|
|
66
|
+
if (!last || last.role !== "user")
|
|
67
|
+
throw new Problem(400, "The run's last message must be a user message");
|
|
68
|
+
const text = typeof last.content === "string"
|
|
69
|
+
? last.content
|
|
70
|
+
: last.content
|
|
71
|
+
.filter((part) => part.type === "text")
|
|
72
|
+
.map((part) => part.text)
|
|
73
|
+
.join("\n");
|
|
74
|
+
if (!text.trim())
|
|
75
|
+
throw new Problem(400, "The user message has no text");
|
|
76
|
+
return { id: last.id, text };
|
|
77
|
+
}
|
|
78
|
+
function normalizeBase(basePath = "/") {
|
|
79
|
+
const trimmed = basePath.replace(/\/+$/, "");
|
|
80
|
+
return trimmed === "" ? "" : trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
81
|
+
}
|
|
82
|
+
export function createAgUiHandler(options) {
|
|
83
|
+
const allowed = new Set(options.agents.map((agent) => (typeof agent === "string" ? agent : agent.id)));
|
|
84
|
+
const base = normalizeBase(options.basePath);
|
|
85
|
+
const scopes = options.scopes ?? DEFAULT_SCOPES;
|
|
86
|
+
const checked = parseSubjectHeaders("probe", scopes.join(" "));
|
|
87
|
+
if (!checked.ok)
|
|
88
|
+
throw new TypeError(`createAgUiHandler: ${checked.message}`);
|
|
89
|
+
let ready;
|
|
90
|
+
/** The client, once the Runtime is known to have every required feature. Retries after a failure. */
|
|
91
|
+
function connected() {
|
|
92
|
+
ready ??= (async () => {
|
|
93
|
+
const client = await (options.client ?? createClient());
|
|
94
|
+
const features = await client.hostFeatures();
|
|
95
|
+
const missing = REQUIRED_FEATURES.filter((f) => !features.includes(f));
|
|
96
|
+
if (missing.length > 0)
|
|
97
|
+
throw new Problem(502, `The Runtime does not support ${missing.join(", ")}; upgrade the stack (nylorun up).`, "runtime_feature_missing");
|
|
98
|
+
return client;
|
|
99
|
+
})();
|
|
100
|
+
ready.catch(() => {
|
|
101
|
+
ready = undefined;
|
|
102
|
+
});
|
|
103
|
+
return ready;
|
|
104
|
+
}
|
|
105
|
+
/** The client acting for `subject`: the Runtime limits it to the subject's own sessions. */
|
|
106
|
+
async function clientFor(subject) {
|
|
107
|
+
const client = await connected();
|
|
108
|
+
try {
|
|
109
|
+
return client.as(subject, { scopes });
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
// The host's `subject` returned something the Runtime cannot name (see `as`).
|
|
113
|
+
throw new Problem(500, error.message, "subject_invalid");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function subjectOf(request) {
|
|
117
|
+
const subject = await options.subject(request);
|
|
118
|
+
if (!subject)
|
|
119
|
+
throw new Problem(401, "Sign in required");
|
|
120
|
+
return subject;
|
|
121
|
+
}
|
|
122
|
+
function assertAgent(agentId) {
|
|
123
|
+
if (!allowed.has(agentId))
|
|
124
|
+
throw new Problem(404, "Not found");
|
|
125
|
+
}
|
|
126
|
+
async function sessionFor(request, agentId, threadId) {
|
|
127
|
+
assertAgent(agentId);
|
|
128
|
+
const subject = await subjectOf(request);
|
|
129
|
+
const client = await clientFor(subject);
|
|
130
|
+
return {
|
|
131
|
+
client,
|
|
132
|
+
subject,
|
|
133
|
+
session: client.session(sessionIdFor(subject, agentId, threadId)),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
async function submit(session, input) {
|
|
137
|
+
if (input.resume?.length) {
|
|
138
|
+
let cursor = null;
|
|
139
|
+
// The interaction's kind is on the pending view, not in its id.
|
|
140
|
+
const pending = await session.pending();
|
|
141
|
+
const waits = Array.isArray(pending) ? pending : [];
|
|
142
|
+
for (const entry of input.resume) {
|
|
143
|
+
const wait = waits.find((w) => w?.interaction?.id === entry.interruptId);
|
|
144
|
+
const key = `${input.runId}:${entry.interruptId}`;
|
|
145
|
+
const payload = entry.payload;
|
|
146
|
+
const accepted = (wait?.interaction?.kind ?? "approval") === "approval"
|
|
147
|
+
? await session.approve(entry.interruptId, entry.status === "resolved" &&
|
|
148
|
+
(payload === true ||
|
|
149
|
+
(typeof payload === "object" && payload?.approved === true)), { idempotencyKey: key })
|
|
150
|
+
: entry.status === "cancelled"
|
|
151
|
+
? await session.cancel({ idempotencyKey: key, reason: "interrupt cancelled" })
|
|
152
|
+
: await session.respond(entry.interruptId, (entry.payload ?? null), {
|
|
153
|
+
idempotencyKey: key,
|
|
154
|
+
});
|
|
155
|
+
cursor ??= accepted.cursor;
|
|
156
|
+
}
|
|
157
|
+
return cursor;
|
|
158
|
+
}
|
|
159
|
+
const message = lastUserMessage(input.messages);
|
|
160
|
+
// The AG-UI message id is the idempotency key: a retried run replays the same turn,
|
|
161
|
+
// and history gives the message back its client id.
|
|
162
|
+
const accepted = await session.input(message.text, {
|
|
163
|
+
idempotencyKey: message.id,
|
|
164
|
+
});
|
|
165
|
+
return accepted.cursor;
|
|
166
|
+
}
|
|
167
|
+
/** Streams translated events until the run ends. Closing the response stops the read only. */
|
|
168
|
+
function stream(request, translator, source, preface = []) {
|
|
169
|
+
const reading = new AbortController();
|
|
170
|
+
request.signal.addEventListener("abort", () => reading.abort(), { once: true });
|
|
171
|
+
const bytes = new TextEncoder();
|
|
172
|
+
const body = new ReadableStream({
|
|
173
|
+
async start(controller) {
|
|
174
|
+
const write = (text) => {
|
|
175
|
+
try {
|
|
176
|
+
controller.enqueue(bytes.encode(text));
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
reading.abort();
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const heartbeat = setInterval(() => write(SSE_HEARTBEAT), HEARTBEAT_MS);
|
|
183
|
+
try {
|
|
184
|
+
write(sseFrame(translator.started()));
|
|
185
|
+
for (const event of preface)
|
|
186
|
+
write(sseFrame(event));
|
|
187
|
+
if (source)
|
|
188
|
+
for await (const event of source(reading.signal)) {
|
|
189
|
+
const step = translator.translate(event);
|
|
190
|
+
step.events.forEach((out, index) => write(sseFrame(out, index === step.events.length - 1 ? event.cursor : undefined)));
|
|
191
|
+
if (step.finished || reading.signal.aborted)
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (!reading.signal.aborted) {
|
|
197
|
+
const failure = problemOf(error);
|
|
198
|
+
write(sseFrame({
|
|
199
|
+
type: EventType.RUN_ERROR,
|
|
200
|
+
message: failure.message,
|
|
201
|
+
...(failure.code ? { code: failure.code } : {}),
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
clearInterval(heartbeat);
|
|
207
|
+
reading.abort();
|
|
208
|
+
try {
|
|
209
|
+
controller.close();
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
/* the client is gone */
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
cancel() {
|
|
217
|
+
reading.abort();
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
return new Response(body, {
|
|
221
|
+
headers: {
|
|
222
|
+
"content-type": SSE_CONTENT_TYPE,
|
|
223
|
+
"cache-control": "no-cache, no-transform",
|
|
224
|
+
"x-accel-buffering": "no",
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
async function run(request, agentId) {
|
|
229
|
+
try {
|
|
230
|
+
assertAgent(agentId);
|
|
231
|
+
const subject = await subjectOf(request);
|
|
232
|
+
const parsed = RunAgentInputSchema.safeParse(await request.json().catch(() => undefined));
|
|
233
|
+
if (!parsed.success)
|
|
234
|
+
throw new Problem(400, "Invalid RunAgentInput");
|
|
235
|
+
const input = parsed.data;
|
|
236
|
+
// Nylorun tools run in the Runtime or the app's executor; the browser runs none.
|
|
237
|
+
if (input.tools?.length)
|
|
238
|
+
throw new Problem(400, "Frontend tools are not supported");
|
|
239
|
+
const client = await clientFor(subject);
|
|
240
|
+
const extra = (await options.session?.(subject, agentId)) ?? {};
|
|
241
|
+
const translator = new RunTranslator(input.threadId, input.runId);
|
|
242
|
+
let session;
|
|
243
|
+
try {
|
|
244
|
+
// The host's parameters first: they never replace the session's identity.
|
|
245
|
+
session = await client.createSession({
|
|
246
|
+
...extra,
|
|
247
|
+
id: sessionIdFor(subject, agentId, input.threadId),
|
|
248
|
+
agentId,
|
|
249
|
+
ownerUserId: subject,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (error instanceof RuntimeError && error.status === 409)
|
|
254
|
+
throw new Problem(409, "The thread's session was opened with other parameters", "session_conflict");
|
|
255
|
+
throw error;
|
|
256
|
+
}
|
|
257
|
+
let cursor;
|
|
258
|
+
try {
|
|
259
|
+
cursor = await submit(session, input);
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
const failure = problemOf(error);
|
|
263
|
+
// A busy or paused session is a run that could not start, not a transport failure.
|
|
264
|
+
if (failure.status !== 409)
|
|
265
|
+
throw failure;
|
|
266
|
+
return stream(request, translator, undefined, [
|
|
267
|
+
{
|
|
268
|
+
type: EventType.RUN_ERROR,
|
|
269
|
+
message: failure.message,
|
|
270
|
+
code: failure.code,
|
|
271
|
+
},
|
|
272
|
+
]);
|
|
273
|
+
}
|
|
274
|
+
return stream(request, translator, (signal) => session.observe({ ...(cursor ? { cursor } : {}), signal }));
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
const failure = problemOf(error);
|
|
278
|
+
return problem(failure.status, failure.message, failure.code);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
async function history(request, agentId, threadId) {
|
|
282
|
+
try {
|
|
283
|
+
const { session } = await sessionFor(request, agentId, threadId);
|
|
284
|
+
const items = [];
|
|
285
|
+
let cursor;
|
|
286
|
+
try {
|
|
287
|
+
for (;;) {
|
|
288
|
+
const page = await session.history({ ...(cursor ? { cursor } : {}) });
|
|
289
|
+
items.push(...page.items);
|
|
290
|
+
if (!page.cursor || page.cursor === cursor || page.items.length === 0)
|
|
291
|
+
break;
|
|
292
|
+
cursor = page.cursor;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
// A thread that never ran has no session yet.
|
|
297
|
+
if (error instanceof RuntimeError && error.status === 404)
|
|
298
|
+
return Response.json([]);
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
return Response.json(messagesFromEvents(items));
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
const failure = problemOf(error);
|
|
305
|
+
return problem(failure.status, failure.message, failure.code);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function reattach(request, agentId, threadId) {
|
|
309
|
+
try {
|
|
310
|
+
const { session } = await sessionFor(request, agentId, threadId);
|
|
311
|
+
const url = new URL(request.url);
|
|
312
|
+
const cursor = request.headers.get("last-event-id") ?? url.searchParams.get("cursor");
|
|
313
|
+
if (!cursor)
|
|
314
|
+
throw new Problem(400, "Last-Event-ID or ?cursor= is required");
|
|
315
|
+
const view = await session.inspect();
|
|
316
|
+
const runId = url.searchParams.get("runId") ??
|
|
317
|
+
view.activeTurnId ??
|
|
318
|
+
(typeof view.lastTurnId === "string" ? view.lastTurnId : randomUUID());
|
|
319
|
+
const translator = new RunTranslator(threadId, runId, { reattached: true });
|
|
320
|
+
if (view.activeTurnId)
|
|
321
|
+
return stream(request, translator, (signal) => session.observe({ cursor, signal }));
|
|
322
|
+
// No turn is running: send what the client missed, if the run ended after the cursor.
|
|
323
|
+
const missed = [];
|
|
324
|
+
let from = cursor;
|
|
325
|
+
for (;;) {
|
|
326
|
+
const page = await session.history({ cursor: from });
|
|
327
|
+
missed.push(...page.items);
|
|
328
|
+
if (!page.cursor || page.cursor === from || page.items.length === 0)
|
|
329
|
+
break;
|
|
330
|
+
from = page.cursor;
|
|
331
|
+
}
|
|
332
|
+
if (!missed.some((event) => TERMINAL.has(event.type)))
|
|
333
|
+
return new Response(null, { status: 204 });
|
|
334
|
+
return stream(request, translator, () => iterate(missed));
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
const failure = problemOf(error);
|
|
338
|
+
return problem(failure.status, failure.message, failure.code);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async function cancel(request, agentId, threadId) {
|
|
342
|
+
try {
|
|
343
|
+
const { session } = await sessionFor(request, agentId, threadId);
|
|
344
|
+
await session.cancel({ idempotencyKey: `cancel:${randomUUID()}` });
|
|
345
|
+
return new Response(null, { status: 204 });
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
const failure = problemOf(error);
|
|
349
|
+
return problem(failure.status, failure.message, failure.code);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function route(request) {
|
|
353
|
+
const path = new URL(request.url).pathname;
|
|
354
|
+
if (path !== base && !path.startsWith(`${base}/`))
|
|
355
|
+
return problem(404, "Not found");
|
|
356
|
+
let segments;
|
|
357
|
+
try {
|
|
358
|
+
segments = path
|
|
359
|
+
.slice(base.length)
|
|
360
|
+
.split("/")
|
|
361
|
+
.slice(1)
|
|
362
|
+
.map((segment) => decodeURIComponent(segment));
|
|
363
|
+
}
|
|
364
|
+
catch {
|
|
365
|
+
return problem(404, "Not found");
|
|
366
|
+
}
|
|
367
|
+
if (segments.some((segment) => segment === ""))
|
|
368
|
+
return problem(404, "Not found");
|
|
369
|
+
const [agentId, threads, threadId, action] = segments;
|
|
370
|
+
const method = request.method.toUpperCase();
|
|
371
|
+
const operation = (allow, handle) => {
|
|
372
|
+
if (!agentId || !allowed.has(agentId))
|
|
373
|
+
return problem(404, "Not found");
|
|
374
|
+
if (method !== allow)
|
|
375
|
+
return new Response(JSON.stringify({ error: "Method not allowed" }), {
|
|
376
|
+
status: 405,
|
|
377
|
+
headers: { allow, "content-type": "application/json" },
|
|
378
|
+
});
|
|
379
|
+
return handle();
|
|
380
|
+
};
|
|
381
|
+
if (segments.length === 1)
|
|
382
|
+
return operation("POST", () => run(request, agentId));
|
|
383
|
+
if (segments.length === 4 && threads === "threads") {
|
|
384
|
+
if (action === "messages")
|
|
385
|
+
return operation("GET", () => history(request, agentId, threadId));
|
|
386
|
+
if (action === "events")
|
|
387
|
+
return operation("GET", () => reattach(request, agentId, threadId));
|
|
388
|
+
if (action === "cancel")
|
|
389
|
+
return operation("POST", () => cancel(request, agentId, threadId));
|
|
390
|
+
}
|
|
391
|
+
return problem(404, "Not found");
|
|
392
|
+
}
|
|
393
|
+
return { fetch: route, run, history, reattach, cancel };
|
|
394
|
+
}
|
|
395
|
+
async function* iterate(items) {
|
|
396
|
+
yield* items;
|
|
397
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An AG-UI transcript rebuilt from a session's log, with the ids a live run gave each
|
|
3
|
+
* message (see `translate.ts`), usable as `HttpAgent`'s `initialMessages`.
|
|
4
|
+
*/
|
|
5
|
+
import type { Message } from "@ag-ui/core";
|
|
6
|
+
import { type LiveEvent } from "@nylorun/core/contracts";
|
|
7
|
+
export declare function messagesFromEvents(items: readonly LiveEvent[]): Message[];
|