@nylorun/agents 0.7.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 CHANGED
@@ -1,5 +1,36 @@
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
+
3
34
  ## 0.7.0-beta
4
35
 
5
36
  ### Minor Changes
package/README.md CHANGED
@@ -286,4 +286,107 @@ After registration, `connectAgents` opens authenticated fetch SSE before discove
286
286
 
287
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`.
288
288
 
289
- The SDK depends only on core within the Nylorun packages; installing it does not install harness. Use `/define`, `/client`, or `/executor` for focused imports, or the root for convenience. Studio uses `/client`. See [the adopted host contract](../harness/HOST_CONTRACT.md).
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[];
@@ -0,0 +1,60 @@
1
+ import { parseTranscriptEvent } from "@nylorun/core/contracts";
2
+ import { outputText, toolResultOf } from "./translate.js";
3
+ export function messagesFromEvents(items) {
4
+ const messages = [];
5
+ const results = new Set();
6
+ let sawAssistant = false;
7
+ for (const raw of items) {
8
+ if (raw.type === "command.message") {
9
+ const p = (raw.payload ?? {});
10
+ messages.push({
11
+ // The handler sends the client's message id as the idempotency key.
12
+ id: p.idempotencyKey ?? raw.eventId,
13
+ role: "user",
14
+ content: typeof p.content === "string" ? p.content : JSON.stringify(p.data),
15
+ });
16
+ sawAssistant = false;
17
+ continue;
18
+ }
19
+ const event = parseTranscriptEvent(raw);
20
+ if (!event)
21
+ continue;
22
+ const result = toolResultOf(event);
23
+ if (result) {
24
+ if (results.has(result.callId))
25
+ continue;
26
+ results.add(result.callId);
27
+ messages.push({
28
+ id: `${result.callId}:result`,
29
+ role: "tool",
30
+ toolCallId: result.callId,
31
+ content: result.content,
32
+ });
33
+ continue;
34
+ }
35
+ if (event.type === "message.assistant" && !event.payload.agent) {
36
+ const p = event.payload;
37
+ sawAssistant = true;
38
+ const message = { id: p.invocationId, role: "assistant" };
39
+ if (p.text)
40
+ message.content = p.text;
41
+ if (p.toolCalls.length)
42
+ message.toolCalls = p.toolCalls.map((call) => ({
43
+ id: call.callId,
44
+ type: "function",
45
+ function: { name: call.name, arguments: JSON.stringify(call.input ?? {}) },
46
+ }));
47
+ messages.push(message);
48
+ }
49
+ else if (event.type === "turn.completed" && !sawAssistant) {
50
+ const output = event.payload.output;
51
+ if (output !== undefined && output !== null && outputText(output))
52
+ messages.push({
53
+ id: raw.eventId,
54
+ role: "assistant",
55
+ content: outputText(output),
56
+ });
57
+ }
58
+ }
59
+ return messages;
60
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * `@nylorun/agents/ag-ui`: serve the Tenant's agents to AG-UI clients from the app's own
3
+ * server. The only entry point that loads `@ag-ui/core`.
4
+ */
5
+ export { createAgUiHandler } from "./handler.js";
6
+ export type { AgUiHandler, AgUiHandlerOptions, AgUiSessionOptions, } from "./handler.js";
7
+ export { toNodeListener } from "./node.js";
@@ -0,0 +1,6 @@
1
+ /**
2
+ * `@nylorun/agents/ag-ui`: serve the Tenant's agents to AG-UI clients from the app's own
3
+ * server. The only entry point that loads `@ag-ui/core`.
4
+ */
5
+ export { createAgUiHandler } from "./handler.js";
6
+ export { toNodeListener } from "./node.js";
@@ -0,0 +1,5 @@
1
+ /** `node:http` and Express adapter for a fetch-style handler. */
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+ export declare function toNodeListener(handler: {
4
+ readonly fetch: (request: Request) => Promise<Response>;
5
+ }): (req: IncomingMessage, res: ServerResponse) => void;
@@ -0,0 +1,51 @@
1
+ import { Readable } from "node:stream";
2
+ export function toNodeListener(handler) {
3
+ return (req, res) => {
4
+ void (async () => {
5
+ // A closed connection aborts the request, which stops the read, never the turn.
6
+ const controller = new AbortController();
7
+ res.on("close", () => controller.abort());
8
+ const host = req.headers.host ?? "localhost";
9
+ const url = new URL(req.url ?? "/", `http://${host}`);
10
+ const headers = new Headers();
11
+ for (const [name, value] of Object.entries(req.headers)) {
12
+ if (value === undefined)
13
+ continue;
14
+ for (const item of Array.isArray(value) ? value : [value])
15
+ headers.append(name, item);
16
+ }
17
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
18
+ const request = new Request(url, {
19
+ method: req.method,
20
+ headers,
21
+ signal: controller.signal,
22
+ ...(hasBody
23
+ ? {
24
+ body: Readable.toWeb(req),
25
+ duplex: "half",
26
+ }
27
+ : {}),
28
+ });
29
+ let response;
30
+ try {
31
+ response = await handler.fetch(request);
32
+ }
33
+ catch {
34
+ response = new Response(JSON.stringify({ error: "Internal error" }), {
35
+ status: 500,
36
+ headers: { "content-type": "application/json" },
37
+ });
38
+ }
39
+ res.writeHead(response.status, Object.fromEntries(response.headers));
40
+ if (!response.body) {
41
+ res.end();
42
+ return;
43
+ }
44
+ res.flushHeaders();
45
+ const body = Readable.fromWeb(response.body);
46
+ body.on("error", () => res.destroy());
47
+ res.on("close", () => body.destroy());
48
+ body.pipe(res);
49
+ })();
50
+ };
51
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * AG-UI over server-sent events: one `data:` line of JSON per event. The last AG-UI event
3
+ * made from a Runtime event carries that event's cursor as `id:`, so a client that resumes
4
+ * from `Last-Event-ID` never lands inside a group.
5
+ */
6
+ import type { BaseEvent } from "@ag-ui/core";
7
+ export declare const SSE_CONTENT_TYPE = "text/event-stream";
8
+ export declare function sseFrame(event: BaseEvent, id?: string): string;
9
+ export declare const SSE_HEARTBEAT = ": keep-alive\n\n";
@@ -0,0 +1,7 @@
1
+ export const SSE_CONTENT_TYPE = "text/event-stream";
2
+ export function sseFrame(event, id) {
3
+ // Cursors are opaque base64; a line break would end the field early.
4
+ const idLine = id && !/[\r\n]/.test(id) ? `id: ${id}\n` : "";
5
+ return `${idLine}data: ${JSON.stringify(event)}\n\n`;
6
+ }
7
+ export const SSE_HEARTBEAT = ": keep-alive\n\n";
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Runtime transcript events → AG-UI 1.0 events for one run. Ids: an assistant message is its
3
+ * model call's `invocationId`, a tool call is the model's `callId`, a tool result is
4
+ * `<callId>:result`, so live runs and `messagesFromEvents` agree.
5
+ */
6
+ import { type BaseEvent } from "@ag-ui/core";
7
+ import { type LiveEvent, type TranscriptEvent } from "@nylorun/core/contracts";
8
+ /** The text a tool result shows; `undefined` while there is no result yet. */
9
+ export declare function toolResultText(result: unknown): string | undefined;
10
+ export declare function outputText(output: unknown): string;
11
+ /** One Runtime event's result for a run: AG-UI events, and whether the run ended. */
12
+ export interface Step {
13
+ readonly events: BaseEvent[];
14
+ readonly finished: boolean;
15
+ }
16
+ /** A tool result from any of the three places a call can end. */
17
+ export declare function toolResultOf(event: TranscriptEvent): {
18
+ callId: string;
19
+ content: string;
20
+ } | undefined;
21
+ export declare class RunTranslator {
22
+ private readonly threadId;
23
+ private readonly runId;
24
+ /** Tool invocation → the model's call id, for interrupts that name the invocation. */
25
+ private readonly callIds;
26
+ private readonly closed;
27
+ private sawAssistant;
28
+ /**
29
+ * `reattached`: the run continues from a cursor, so earlier assistant messages already
30
+ * reached the client and a workflow's output is not repeated as text.
31
+ */
32
+ constructor(threadId: string, runId: string, options?: {
33
+ reattached?: boolean;
34
+ });
35
+ translate(raw: LiveEvent): Step;
36
+ started(): BaseEvent;
37
+ private finished;
38
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Runtime transcript events → AG-UI 1.0 events for one run. Ids: an assistant message is its
3
+ * model call's `invocationId`, a tool call is the model's `callId`, a tool result is
4
+ * `<callId>:result`, so live runs and `messagesFromEvents` agree.
5
+ */
6
+ import { EventType } from "@ag-ui/core";
7
+ import { parseTranscriptEvent, } from "@nylorun/core/contracts";
8
+ /** The text a tool result shows; `undefined` while there is no result yet. */
9
+ export function toolResultText(result) {
10
+ const value = result;
11
+ switch (value?.kind) {
12
+ case "completed":
13
+ return outputText(value.output ?? null);
14
+ case "failed":
15
+ return JSON.stringify({ error: value.code, message: value.message });
16
+ case "denied":
17
+ return JSON.stringify({ denied: true, reason: value.reason });
18
+ default:
19
+ // interaction-required and deferred: the call is still open.
20
+ return undefined;
21
+ }
22
+ }
23
+ export function outputText(output) {
24
+ return typeof output === "string" ? output : JSON.stringify(output);
25
+ }
26
+ const none = { events: [], finished: false };
27
+ /** A tool result from any of the three places a call can end. */
28
+ export function toolResultOf(event) {
29
+ switch (event.type) {
30
+ case "action.completed": {
31
+ const p = event.payload;
32
+ if (p.kind !== "tool" || !p.callId || p.agent)
33
+ return undefined;
34
+ const content = toolResultText(p.result);
35
+ return content === undefined ? undefined : { callId: p.callId, content };
36
+ }
37
+ case "tool.completed": {
38
+ const p = event.payload;
39
+ if (p.agent)
40
+ return undefined;
41
+ return {
42
+ callId: p.callId,
43
+ content: p.error !== undefined
44
+ ? JSON.stringify({ error: p.error.code, message: p.error.message })
45
+ : outputText(p.output ?? null),
46
+ };
47
+ }
48
+ case "delegation.completed": {
49
+ // An agent used as a tool: its own work stays inside it; the parent sees the result.
50
+ const p = event.payload;
51
+ if (!p.callId)
52
+ return undefined;
53
+ const content = toolResultText(p.outcome);
54
+ return content === undefined ? undefined : { callId: p.callId, content };
55
+ }
56
+ default:
57
+ return undefined;
58
+ }
59
+ }
60
+ export class RunTranslator {
61
+ threadId;
62
+ runId;
63
+ /** Tool invocation → the model's call id, for interrupts that name the invocation. */
64
+ callIds = new Map();
65
+ closed = new Set();
66
+ sawAssistant;
67
+ /**
68
+ * `reattached`: the run continues from a cursor, so earlier assistant messages already
69
+ * reached the client and a workflow's output is not repeated as text.
70
+ */
71
+ constructor(threadId, runId, options = {}) {
72
+ this.threadId = threadId;
73
+ this.runId = runId;
74
+ this.sawAssistant = options.reattached ?? false;
75
+ }
76
+ translate(raw) {
77
+ const event = parseTranscriptEvent(raw);
78
+ if (!event)
79
+ return none;
80
+ if (event.type === "action.pending" || event.type === "action.completed") {
81
+ const { callId, invocationId } = event.payload;
82
+ if (callId && invocationId)
83
+ this.callIds.set(invocationId, callId);
84
+ }
85
+ const result = toolResultOf(event);
86
+ if (result) {
87
+ if (this.closed.has(result.callId))
88
+ return none;
89
+ this.closed.add(result.callId);
90
+ return {
91
+ events: [
92
+ {
93
+ type: EventType.TOOL_CALL_RESULT,
94
+ messageId: `${result.callId}:result`,
95
+ toolCallId: result.callId,
96
+ content: result.content,
97
+ role: "tool",
98
+ },
99
+ ],
100
+ finished: false,
101
+ };
102
+ }
103
+ switch (event.type) {
104
+ case "message.assistant": {
105
+ const p = event.payload;
106
+ if (p.agent)
107
+ return none;
108
+ this.sawAssistant = true;
109
+ return { events: assistantEvents(p), finished: false };
110
+ }
111
+ case "turn.completed": {
112
+ const p = event.payload;
113
+ // Workflows finish without a model step of their own: show their output.
114
+ const text = this.sawAssistant || p.output === undefined || p.output === null
115
+ ? []
116
+ : textEvents(raw.eventId, outputText(p.output));
117
+ return {
118
+ events: [...text, this.finished({ type: "success" }, p.output)],
119
+ finished: true,
120
+ };
121
+ }
122
+ case "turn.paused": {
123
+ const interrupts = event.payload.interactions.map((w) => {
124
+ const interaction = w.interaction;
125
+ const toolCallId = this.callIds.get(w.invocationId);
126
+ return {
127
+ id: interaction.id,
128
+ reason: interaction.kind === "approval" ? "tool_approval" : "input_required",
129
+ ...(typeof interaction.prompt === "string"
130
+ ? { message: interaction.prompt }
131
+ : {}),
132
+ ...(toolCallId ? { toolCallId } : {}),
133
+ ...(interaction.kind === "approval"
134
+ ? {
135
+ responseSchema: {
136
+ type: "object",
137
+ properties: { approved: { type: "boolean" } },
138
+ required: ["approved"],
139
+ },
140
+ }
141
+ : {}),
142
+ metadata: { nylorun: { kind: interaction.kind } },
143
+ };
144
+ });
145
+ return {
146
+ events: [this.finished({ type: "interrupt", interrupts })],
147
+ finished: true,
148
+ };
149
+ }
150
+ case "turn.cancelled":
151
+ return { events: [this.finished({ type: "cancelled" })], finished: true };
152
+ case "turn.failed": {
153
+ const p = event.payload;
154
+ return {
155
+ events: [
156
+ {
157
+ type: EventType.RUN_ERROR,
158
+ message: p.error?.message ?? p.message ?? "The agent failed",
159
+ code: p.error?.code ?? "turn.failed",
160
+ },
161
+ ],
162
+ finished: true,
163
+ };
164
+ }
165
+ case "effect.uncertain":
166
+ case "action.uncertain":
167
+ // The Runtime cannot tell whether a side effect happened; the run goes on.
168
+ return {
169
+ events: [
170
+ {
171
+ type: EventType.CUSTOM,
172
+ name: "nylorun.uncertain",
173
+ value: event.payload,
174
+ },
175
+ ],
176
+ finished: false,
177
+ };
178
+ default:
179
+ return none;
180
+ }
181
+ }
182
+ started() {
183
+ return {
184
+ type: EventType.RUN_STARTED,
185
+ threadId: this.threadId,
186
+ runId: this.runId,
187
+ };
188
+ }
189
+ finished(outcome, result) {
190
+ return {
191
+ type: EventType.RUN_FINISHED,
192
+ threadId: this.threadId,
193
+ runId: this.runId,
194
+ outcome,
195
+ ...(result === undefined ? {} : { result }),
196
+ };
197
+ }
198
+ }
199
+ function textEvents(messageId, text) {
200
+ if (!text)
201
+ return [];
202
+ return [
203
+ { type: EventType.TEXT_MESSAGE_START, messageId, role: "assistant" },
204
+ { type: EventType.TEXT_MESSAGE_CONTENT, messageId, delta: text },
205
+ { type: EventType.TEXT_MESSAGE_END, messageId },
206
+ ];
207
+ }
208
+ function assistantEvents(p) {
209
+ const events = textEvents(p.invocationId, p.text);
210
+ for (const call of p.toolCalls)
211
+ events.push({
212
+ type: EventType.TOOL_CALL_START,
213
+ toolCallId: call.callId,
214
+ toolCallName: call.name,
215
+ parentMessageId: p.invocationId,
216
+ }, {
217
+ type: EventType.TOOL_CALL_ARGS,
218
+ toolCallId: call.callId,
219
+ delta: JSON.stringify(call.input ?? {}),
220
+ }, { type: EventType.TOOL_CALL_END, toolCallId: call.callId });
221
+ return events;
222
+ }
package/dist/client.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { AgentManifest, BuiltAgent, BuiltWorkflow, JsonValue, SandboxToolName, WorkflowManifest } from "@nylorun/core/define";
2
- import { type CredentialInfo, type CredentialSelection, type SessionCommand, type VaultInfo } from "@nylorun/core/contracts";
2
+ import { type SubjectScope, type CredentialInfo, type CredentialSelection, type SessionCommand, type VaultInfo } from "@nylorun/core/contracts";
3
3
  import { Transport, type Destination } from "./http.js";
4
4
  /** Application-principal access to a session's sandbox built-ins (workflows.md §8). */
5
5
  export type SessionSandbox = {
@@ -22,9 +22,26 @@ export interface SessionView {
22
22
  waits?: unknown;
23
23
  [key: string]: unknown;
24
24
  }
25
+ export interface ActAsOptions {
26
+ /** What the subject may do; the Runtime grants nothing else. Default `["sessions:own"]`. */
27
+ scopes?: readonly SubjectScope[];
28
+ }
25
29
  export declare class AgentsClient {
26
30
  readonly transport: Transport;
31
+ /** The subject this client acts for (`as`), or `undefined` for the principal itself. */
32
+ readonly subject: string | undefined;
27
33
  constructor(destination?: Destination);
34
+ /**
35
+ * A client that acts for `subject`: every call sends `Nylorun-Subject` and
36
+ * `Nylorun-Scopes`, and the Runtime limits it to the scopes and to the subject's own sessions
37
+ * and vaults (Host feature `subject-headers`). For app servers, which authenticate the person
38
+ * themselves and hold the key; nothing is minted, cached or refreshed.
39
+ */
40
+ as(subject: string, options?: ActAsOptions): AgentsClient;
41
+ /** The Runtime's protocol features, including optional ones such as `transcript-events`. */
42
+ hostFeatures(options?: {
43
+ signal?: AbortSignal;
44
+ }): Promise<readonly string[]>;
28
45
  listAgents(options?: {
29
46
  signal?: AbortSignal;
30
47
  }): Promise<{
package/dist/client.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { SANDBOX_TOOL_NAMES, delegateOf, isBuiltWorkflow, isSandboxToolName, } from "@nylorun/core/define";
2
- import { LiveEventSchema, SessionItemsResponseSchema, } from "@nylorun/core/contracts";
2
+ import { SCOPES_HEADER, SUBJECT_HEADER } from "@nylorun/core/compatibility";
3
+ import { LiveEventSchema, SessionItemsResponseSchema, parseSubjectHeaders, } from "@nylorun/core/contracts";
3
4
  import { resolveConnection } from "./connection.js";
4
5
  import { Transport, id, segment } from "./http.js";
5
6
  import { observeSSE } from "./sse.js";
@@ -57,8 +58,37 @@ export function assertNoMiddlewareClosures(agent) {
57
58
  }
58
59
  export class AgentsClient {
59
60
  transport;
61
+ /** The subject this client acts for (`as`), or `undefined` for the principal itself. */
62
+ subject;
60
63
  constructor(destination = {}) {
61
64
  this.transport = new Transport(destination);
65
+ this.subject = undefined;
66
+ }
67
+ /**
68
+ * A client that acts for `subject`: every call sends `Nylorun-Subject` and
69
+ * `Nylorun-Scopes`, and the Runtime limits it to the scopes and to the subject's own sessions
70
+ * and vaults (Host feature `subject-headers`). For app servers, which authenticate the person
71
+ * themselves and hold the key; nothing is minted, cached or refreshed.
72
+ */
73
+ as(subject, options = {}) {
74
+ if (this.subject !== undefined)
75
+ throw new Error(`This client already acts for ${this.subject}`);
76
+ const scopes = [...(options.scopes ?? ["sessions:own"])].join(" ");
77
+ const parsed = parseSubjectHeaders(subject, scopes);
78
+ if (!parsed.ok)
79
+ throw new TypeError(parsed.message);
80
+ const client = Object.create(AgentsClient.prototype);
81
+ return Object.assign(client, {
82
+ transport: this.transport.withHeaders({
83
+ [SUBJECT_HEADER]: subject,
84
+ [SCOPES_HEADER]: [...parsed.scopes].join(" "),
85
+ }),
86
+ subject,
87
+ });
88
+ }
89
+ /** The Runtime's protocol features, including optional ones such as `transcript-events`. */
90
+ hostFeatures(options = {}) {
91
+ return this.transport.hostFeatures(options.signal);
62
92
  }
63
93
  listAgents(options = {}) {
64
94
  return this.transport.json("/v1/agents", "GET", undefined, options.signal);
package/dist/http.d.ts CHANGED
@@ -26,11 +26,20 @@ export declare class Transport {
26
26
  readonly key: string;
27
27
  readonly tenant: string;
28
28
  readonly fetcher: typeof fetch;
29
- private compatible;
29
+ /** Sent on every request, e.g. `Nylorun-Subject` and `Nylorun-Scopes` (`withHeaders`). */
30
+ readonly headers: Readonly<Record<string, string>>;
31
+ private readonly check;
30
32
  constructor(options?: Destination, role?: "server" | "executor");
33
+ /**
34
+ * The same destination with `headers` added to every request. The copy shares this
35
+ * transport's compatibility check, so a copy made per request does not repeat `/health`.
36
+ */
37
+ withHeaders(headers: Readonly<Record<string, string>>): Transport;
31
38
  /** Clears the cached Host compatibility result (used after a 426). */
32
39
  clearCompatibilityCache(): void;
33
40
  private ensureCompatible;
41
+ /** The Host's protocol features, including optional ones, from its `/health`. */
42
+ hostFeatures(signal?: AbortSignal): Promise<readonly string[]>;
34
43
  private authHeaders;
35
44
  request(path: string, init?: RequestInit, options?: {
36
45
  retried426?: boolean;
package/dist/http.js CHANGED
@@ -57,7 +57,9 @@ export class Transport {
57
57
  key;
58
58
  tenant;
59
59
  fetcher;
60
- compatible = false;
60
+ /** Sent on every request, e.g. `Nylorun-Subject` and `Nylorun-Scopes` (`withHeaders`). */
61
+ headers = {};
62
+ check = { compatible: false, features: [] };
61
63
  constructor(options = {}, role = "server") {
62
64
  const url = options.url ?? env("NYLORUN_RUNTIME_URL");
63
65
  const key = options.key ??
@@ -75,12 +77,22 @@ export class Transport {
75
77
  this.tenant = tenant;
76
78
  this.fetcher = options.fetch ?? globalThis.fetch;
77
79
  }
80
+ /**
81
+ * The same destination with `headers` added to every request. The copy shares this
82
+ * transport's compatibility check, so a copy made per request does not repeat `/health`.
83
+ */
84
+ withHeaders(headers) {
85
+ const copy = Object.create(Transport.prototype);
86
+ return Object.assign(copy, this, {
87
+ headers: Object.freeze({ ...this.headers, ...headers }),
88
+ });
89
+ }
78
90
  /** Clears the cached Host compatibility result (used after a 426). */
79
91
  clearCompatibilityCache() {
80
- this.compatible = false;
92
+ this.check.compatible = false;
81
93
  }
82
94
  async ensureCompatible(signal) {
83
- if (this.compatible)
95
+ if (this.check.compatible)
84
96
  return;
85
97
  const response = await this.fetcher(`${this.url}/health`, {
86
98
  method: "GET",
@@ -110,10 +122,18 @@ export class Transport {
110
122
  const result = checkCompatibility({ version: PROTOCOL_VERSION, required: [...PROTOCOL_FEATURES] }, protocol);
111
123
  if (!result.ok)
112
124
  throw new IncompatibleRuntimeError(result);
113
- this.compatible = true;
125
+ this.check.features = [...protocol.features];
126
+ this.check.compatible = true;
127
+ }
128
+ /** The Host's protocol features, including optional ones, from its `/health`. */
129
+ async hostFeatures(signal) {
130
+ await this.ensureCompatible(signal);
131
+ return this.check.features;
114
132
  }
115
133
  authHeaders(init = {}) {
116
134
  const headers = new Headers(init.headers);
135
+ for (const [name, value] of Object.entries(this.headers))
136
+ headers.set(name, value);
117
137
  headers.set("Authorization", `Bearer ${this.key}`);
118
138
  headers.set(TENANT_HEADER, this.tenant);
119
139
  headers.set(PROTOCOL_HEADER, String(PROTOCOL_VERSION));
package/dist/index.d.ts CHANGED
@@ -1,10 +1,11 @@
1
- export { hashManifest, PROTOCOL_VERSION, PROTOCOL_FEATURES, HOST_PROTOCOL, DEFINITION_SCHEMA_VERSION, TENANT_HEADER, PROTOCOL_HEADER, TENANT_ID_PATTERN, isTenantId, newTenantId, newPrincipalId, checkCompatibility, compareVersions, ERROR_CODES, } from "@nylorun/core/compatibility";
1
+ export { hashManifest, PROTOCOL_VERSION, PROTOCOL_FEATURES, HOST_PROTOCOL, DEFINITION_SCHEMA_VERSION, TENANT_HEADER, PROTOCOL_HEADER, SUBJECT_HEADER, SCOPES_HEADER, TENANT_ID_PATTERN, isTenantId, newTenantId, newPrincipalId, checkCompatibility, compareVersions, ERROR_CODES, } from "@nylorun/core/compatibility";
2
2
  export type { ProtocolFeature, ProtocolRange, Compatibility, ErrorCode, } from "@nylorun/core/compatibility";
3
3
  export { Agent, AgentBuilder, AgentBuildError, AgentLifecycleError, Loop, Chain, Switch, Parallel, Map, VerdictSchema, isVerdict, withInstructions, withoutTools, WorkflowBuildError, isBuiltWorkflow, capability, tool, defineSchema, ToolError, } from "@nylorun/core/define";
4
4
  export type { AgentOptions, AgentManifest, BuiltAgent, BuiltWorkflow, LoopOptions, LoopDecideArgs, LoopVerifyArgs, LoopDecision, ChainOptions, SwitchOptions, ParallelOptions, MapOptions, Verdict, WorkflowBinding, WorkflowManifest, ToolDefinition, ToolExecutionContext, ToolOutcome, Patch, Decision, TurnDecision, HookScope, BeforeHook, AfterHook, JsonValue, JsonObject, } from "@nylorun/core/define";
5
- export type { LiveEvent, SessionCommand, Action, ActionOutcome, CredentialInfo, CredentialSelection, VaultInfo, } from "@nylorun/core/contracts";
5
+ export type { LiveEvent, SessionCommand, Action, ActionOutcome, CredentialInfo, CredentialSelection, VaultInfo, SubjectScope, } from "@nylorun/core/contracts";
6
+ export { SUBJECT_SCOPES } from "@nylorun/core/contracts";
6
7
  export { AgentsClient, SessionClient, createClient } from "./client.js";
7
- export type { AgentSource, SessionView, CommandOptions, SessionSandbox, } from "./client.js";
8
+ export type { ActAsOptions, AgentSource, SessionView, CommandOptions, SessionSandbox, } from "./client.js";
8
9
  export { connectAgents } from "./executor.js";
9
10
  export type { ConnectOptions, AgentConnection } from "./executor.js";
10
11
  export type { ExecuteActionOptions, ExecutableDefinition, } from "./execute-action.js";
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Definition authoring and wire contracts are the only harness runtime imports.
2
- export { hashManifest, PROTOCOL_VERSION, PROTOCOL_FEATURES, HOST_PROTOCOL, DEFINITION_SCHEMA_VERSION, TENANT_HEADER, PROTOCOL_HEADER, TENANT_ID_PATTERN, isTenantId, newTenantId, newPrincipalId, checkCompatibility, compareVersions, ERROR_CODES, } from "@nylorun/core/compatibility";
2
+ export { hashManifest, PROTOCOL_VERSION, PROTOCOL_FEATURES, HOST_PROTOCOL, DEFINITION_SCHEMA_VERSION, TENANT_HEADER, PROTOCOL_HEADER, SUBJECT_HEADER, SCOPES_HEADER, TENANT_ID_PATTERN, isTenantId, newTenantId, newPrincipalId, checkCompatibility, compareVersions, ERROR_CODES, } from "@nylorun/core/compatibility";
3
3
  export { Agent, AgentBuilder, AgentBuildError, AgentLifecycleError, Loop, Chain, Switch, Parallel, Map, VerdictSchema, isVerdict, withInstructions, withoutTools, WorkflowBuildError, isBuiltWorkflow, capability, tool, defineSchema, ToolError, } from "@nylorun/core/define";
4
+ export { SUBJECT_SCOPES } from "@nylorun/core/contracts";
4
5
  export { AgentsClient, SessionClient, createClient } from "./client.js";
5
6
  export { connectAgents } from "./executor.js";
6
7
  export { resolveConnection, ConnectionError } from "./connection.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nylorun/agents",
3
- "version": "0.7.0-beta",
3
+ "version": "0.8.0-beta",
4
4
  "description": "Nylorun agent definitions, session client and connected executor",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -27,6 +27,10 @@
27
27
  "./executor": {
28
28
  "types": "./dist/executor.d.ts",
29
29
  "import": "./dist/executor.js"
30
+ },
31
+ "./ag-ui": {
32
+ "types": "./dist/ag-ui/index.d.ts",
33
+ "import": "./dist/ag-ui/index.js"
30
34
  }
31
35
  },
32
36
  "scripts": {
@@ -35,7 +39,8 @@
35
39
  "test": "vitest run --passWithNoTests"
36
40
  },
37
41
  "dependencies": {
38
- "@nylorun/core": "0.6.0-beta",
42
+ "@ag-ui/core": "~1.0.0",
43
+ "@nylorun/core": "0.7.0-beta",
39
44
  "gray-matter": "^4.0.3"
40
45
  },
41
46
  "peerDependencies": {
@@ -51,7 +56,7 @@
51
56
  },
52
57
  "repository": {
53
58
  "type": "git",
54
- "url": "git+https://github.com/nylorun/harness.git",
59
+ "url": "git+https://github.com/nylorun/agents.git",
55
60
  "directory": "agents"
56
61
  },
57
62
  "publishConfig": {