@nylorun/runtime 0.1.1-beta → 0.2.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.
@@ -3,331 +3,373 @@ import { Hono } from "hono";
3
3
  import { HTTPException } from "hono/http-exception";
4
4
  import { agUiEvents, sse } from "./ag-ui.js";
5
5
  import { observedPayload } from "./digests.js";
6
- import { memoryHistory, scrub, } from "../adapters/journal.js";
6
+ import { localJsonl, scrub } from "../adapters/journal.js";
7
+ import { jsonlObserver } from "../adapters/observe.js";
7
8
  import { IMAGE_MEDIA_TYPES, MAX_IMAGE_BYTES, } from "../adapters/media.js";
8
9
  import { projectSecrets } from "../model/settings.js";
9
- export async function createRuntime(options) {
10
- const agents = [...options.agents];
11
- const byId = new Map(agents.map((agent) => [agent.id, agent]));
12
- if (byId.size !== agents.length)
13
- throw new Error("Agent IDs must be unique.");
14
- for (const agent of agents) {
15
- if (!/^[a-zA-Z0-9_-]+$/.test(agent.id))
16
- throw new Error(`Invalid agent ID: ${agent.id}`);
17
- if (agent.id !== agent.manifest.id || agent.name !== agent.manifest.name)
18
- throw new Error("Agent identity must match its manifest.");
10
+ import { piModel } from "../model/pi-model.js";
11
+ const kServe = Symbol("serve");
12
+ export class Runtime {
13
+ #config;
14
+ #served = false;
15
+ #closing;
16
+ #shutdown;
17
+ constructor(options = {}) {
18
+ this.#config = options;
19
19
  }
20
- const media = options.media;
21
- const journal = options.persistence ?? memoryHistory();
22
- const redact = (value) => scrub(value, projectSecrets());
23
- const live = new Map();
24
- const app = new Hono();
25
- app.onError((error, context) => context.json({ error: String(redact(error.message)) }, error instanceof HTTPException ? error.status : 500));
26
- app.use("*", async (context, next) => {
27
- await next();
28
- if (context.res.headers.get("content-type")?.includes("application/json")) {
29
- const value = await context.res.json();
30
- context.res = new Response(JSON.stringify(redact(value)), {
31
- status: context.res.status,
32
- headers: context.res.headers,
33
- });
34
- }
35
- });
36
- app.use("*", async (context, next) => {
37
- const origin = context.req.header("origin");
38
- if (origin && permitted(origin, options.origins ?? [])) {
39
- context.header("access-control-allow-origin", origin);
40
- context.header("access-control-allow-headers", "content-type");
41
- context.header("access-control-allow-methods", "GET, POST, OPTIONS");
42
- context.header("vary", "origin");
20
+ close = () => (this.#closing ??= this.#shutdown?.() ?? Promise.resolve());
21
+ [kServe](options) {
22
+ if (this.#served)
23
+ throw new Error("A Runtime may only be served once.");
24
+ this.#served = true;
25
+ const agents = [...options.agents];
26
+ const byId = new Map(agents.map((agent) => [agent.id, agent]));
27
+ if (byId.size !== agents.length)
28
+ throw new Error("Agent IDs must be unique.");
29
+ for (const agent of agents) {
30
+ if (!/^[a-zA-Z0-9_-]+$/.test(agent.id))
31
+ throw new Error(`Invalid agent ID: ${agent.id}`);
32
+ if (agent.id !== agent.manifest.id || agent.name !== agent.manifest.name)
33
+ throw new Error("Agent identity must match its manifest.");
43
34
  }
44
- if (context.req.method === "OPTIONS")
45
- return context.body(null, 204);
46
- await next();
47
- });
48
- app.get("/v1/agents", (context) => context.json({
49
- protocolVersion: 2,
50
- agents: agents.map((agent) => ({
51
- id: agent.id,
52
- manifestUrl: `/agents/${agent.id}/manifest.json`,
53
- })),
54
- }));
55
- app.get("/agents/:agentId/manifest.json", (context) => {
56
- const agent = byId.get(context.req.param("agentId"));
57
- return agent === undefined
58
- ? context.json({ error: "unknown agent" }, 404)
59
- : context.json(manifest(agent, media !== undefined));
60
- });
61
- app.get("/agents/:agentId/v1/media/:session/:assetId", async (context) => {
62
- const agent = requireAgent(context.req.param("agentId"));
63
- if (!agent)
64
- return context.json({ error: "unknown agent" }, 404);
65
- const asset = await media?.read(agent.id, context.req.param("session"), context.req.param("assetId"));
66
- if (!asset)
67
- return context.json({ error: "unknown media asset" }, 404);
68
- context.header("cache-control", "no-store");
69
- return context.body(asset.bytes, 200, {
70
- "content-type": asset.asset.mediaType,
35
+ const media = this.#config.media;
36
+ const onModelCall = this.#config.onModelCall ?? piModel({ media });
37
+ const journal = this.#config.durability ?? localJsonl();
38
+ const configuredObserver = this.#config.observer;
39
+ const redact = (value) => scrub(value, projectSecrets());
40
+ const live = new Map();
41
+ let publicPath = (path) => path;
42
+ const routerOptions = options;
43
+ const app = new Hono();
44
+ app.onError((error, context) => context.json({ error: String(redact(error.message)) }, error instanceof HTTPException ? error.status : 500));
45
+ app.use("*", async (context, next) => {
46
+ await next();
47
+ if (context.res.headers.get("content-type")?.includes("application/json")) {
48
+ const value = await context.res.json();
49
+ context.res = new Response(JSON.stringify(redact(value)), {
50
+ status: context.res.status,
51
+ headers: context.res.headers,
52
+ });
53
+ }
71
54
  });
72
- });
73
- app.get("/agents/:agentId/v1/sessions", async (context) => {
74
- const agent = requireAgent(context.req.param("agentId"));
75
- if (agent === undefined)
76
- return context.json({ error: "unknown agent" }, 404);
77
- const listed = await journal.list(agent.id);
78
- return context.json({
79
- sessions: listed.map((summary) => {
80
- const found = live.get(keyOf(agent.id, summary.session));
81
- if (found?.status === "running")
82
- return { ...summary, status: "running" };
83
- if (found?.status === "waiting")
84
- return { ...summary, status: "waiting" };
85
- return summary;
86
- }),
55
+ publicPath = (path) => `${normalizeBasePath(routerOptions.basePath)}${path}`;
56
+ app.get("/v1/agents", (context) => context.json({
57
+ protocolVersion: 2,
58
+ agents: agents.map((agent) => ({
59
+ id: agent.id,
60
+ manifestUrl: publicPath(`/agents/${agent.id}/manifest.json`),
61
+ })),
62
+ }));
63
+ app.get("/agents/:agentId/manifest.json", (context) => {
64
+ const agent = byId.get(context.req.param("agentId"));
65
+ return agent === undefined
66
+ ? context.json({ error: "unknown agent" }, 404)
67
+ : context.json(manifest(agent, media !== undefined, publicPath));
87
68
  });
88
- });
89
- app.get("/agents/:agentId/v1/sessions/:session", async (context) => {
90
- const agent = requireAgent(context.req.param("agentId"));
91
- if (!agent)
92
- return context.json({ error: "unknown agent" }, 404);
93
- const key = keyOf(agent.id, context.req.param("session"));
94
- const found = live.get(key);
95
- const events = found?.events ??
96
- (await journal.events(agent.id, context.req.param("session")));
97
- if (!found && events.length === 0)
98
- return context.json({ error: "unknown session" }, 404);
99
- return context.json({
100
- id: context.req.param("session"),
101
- state: found?.status ?? status(events),
102
- pending_interaction: pending(events),
69
+ app.get("/agents/:agentId/v1/media/:session/:assetId", async (context) => {
70
+ const agent = requireAgent(context.req.param("agentId"));
71
+ if (!agent)
72
+ return context.json({ error: "unknown agent" }, 404);
73
+ const asset = await media?.read(agent.id, context.req.param("session"), context.req.param("assetId"));
74
+ if (!asset)
75
+ return context.json({ error: "unknown media asset" }, 404);
76
+ context.header("cache-control", "no-store");
77
+ return context.body(asset.bytes, 200, {
78
+ "content-type": asset.asset.mediaType,
79
+ });
103
80
  });
104
- });
105
- app.post("/agents/:agentId/v1/sessions/:session", async (context) => {
106
- const agent = requireAgent(context.req.param("agentId"));
107
- if (!agent)
108
- return context.json({ error: "unknown agent" }, 404);
109
- const found = live.get(keyOf(agent.id, context.req.param("session")));
110
- if (!found)
111
- return context.json({ error: "session is no longer live" }, 409);
112
- const payload = await context.req
113
- .json()
114
- .catch(() => undefined);
115
- const interaction = payload?.interaction;
116
- if (!interaction || typeof interaction.id !== "string")
117
- return context.json({ error: "expected interaction" }, 400);
118
- const waiting = pending(found.events);
119
- if (found.status !== "waiting" || !waiting || waiting.id !== interaction.id)
120
- return context.json({ error: "interaction is no longer pending" }, 409);
121
- if (interaction.kind === "approval") {
122
- if (typeof interaction.approved !== "boolean")
123
- return context.json({ error: "expected approval interaction" }, 400);
124
- found.status = "running";
125
- await submit(found, {
126
- kind: "approve",
127
- interactionId: interaction.id,
128
- approved: interaction.approved,
81
+ app.get("/agents/:agentId/v1/sessions", async (context) => {
82
+ const agent = requireAgent(context.req.param("agentId"));
83
+ if (agent === undefined)
84
+ return context.json({ error: "unknown agent" }, 404);
85
+ const listed = await journal.list(agent.id);
86
+ return context.json({
87
+ sessions: listed.map((summary) => {
88
+ const found = live.get(keyOf(agent.id, summary.session));
89
+ if (found?.status === "running")
90
+ return { ...summary, status: "running" };
91
+ if (found?.status === "waiting")
92
+ return { ...summary, status: "waiting" };
93
+ return summary;
94
+ }),
129
95
  });
130
- return context.json({ session_id: context.req.param("session"), state: found.status }, 202);
131
- }
132
- if (interaction.kind === "respond") {
133
- if (!("value" in interaction))
134
- return context.json({ error: "expected respond interaction" }, 400);
135
- found.status = "running";
136
- await submit(found, {
137
- kind: "respond",
138
- interactionId: interaction.id,
139
- value: interaction.value,
96
+ });
97
+ app.get("/agents/:agentId/v1/sessions/:session", async (context) => {
98
+ const agent = requireAgent(context.req.param("agentId"));
99
+ if (!agent)
100
+ return context.json({ error: "unknown agent" }, 404);
101
+ const key = keyOf(agent.id, context.req.param("session"));
102
+ const found = live.get(key);
103
+ const events = found?.events ??
104
+ (await journal.events(agent.id, context.req.param("session")));
105
+ if (!found && events.length === 0)
106
+ return context.json({ error: "unknown session" }, 404);
107
+ return context.json({
108
+ id: context.req.param("session"),
109
+ state: found?.status ?? status(events),
110
+ pending_interaction: pending(events),
140
111
  });
141
- return context.json({ session_id: context.req.param("session"), state: found.status }, 202);
142
- }
143
- return context.json({ error: "expected approval or respond interaction" }, 400);
144
- });
145
- app.get("/agents/:agentId/v1/sessions/:session/events", async (context) => {
146
- const agent = requireAgent(context.req.param("agentId"));
147
- if (!agent)
148
- return context.json({ error: "unknown agent" }, 404);
149
- const after = Number(context.req.query("after") ?? "0");
150
- const session = context.req.param("session");
151
- const events = live.get(keyOf(agent.id, session))?.events ??
152
- (await journal.events(agent.id, session));
153
- return context.json({
154
- events: events.filter((event) => event.seq > after),
155
- next_cursor: events.at(-1)?.seq ?? after,
156
112
  });
157
- });
158
- app.get("/agents/:agentId/v1/ag-ui/sessions/:session", async (context) => {
159
- const agent = requireAgent(context.req.param("agentId"));
160
- if (!agent)
161
- return context.json({ error: "unknown agent" }, 404);
162
- const found = live.get(keyOf(agent.id, context.req.param("session")));
163
- return context.json({
164
- messages: found?.messages ??
165
- messages(agent.id, await journal.events(agent.id, context.req.param("session"))),
113
+ app.post("/agents/:agentId/v1/sessions/:session", async (context) => {
114
+ const agent = requireAgent(context.req.param("agentId"));
115
+ if (!agent)
116
+ return context.json({ error: "unknown agent" }, 404);
117
+ const found = live.get(keyOf(agent.id, context.req.param("session")));
118
+ if (!found)
119
+ return context.json({ error: "session is no longer live" }, 409);
120
+ const payload = await context.req
121
+ .json()
122
+ .catch(() => undefined);
123
+ const interaction = payload?.interaction;
124
+ if (!interaction || typeof interaction.id !== "string")
125
+ return context.json({ error: "expected interaction" }, 400);
126
+ const waiting = pending(found.events);
127
+ if (found.status !== "waiting" ||
128
+ !waiting ||
129
+ waiting.id !== interaction.id)
130
+ return context.json({ error: "interaction is no longer pending" }, 409);
131
+ if (interaction.kind === "approval") {
132
+ if (typeof interaction.approved !== "boolean")
133
+ return context.json({ error: "expected approval interaction" }, 400);
134
+ found.status = "running";
135
+ await submit(found, {
136
+ kind: "approve",
137
+ interactionId: interaction.id,
138
+ approved: interaction.approved,
139
+ });
140
+ return context.json({ session_id: context.req.param("session"), state: found.status }, 202);
141
+ }
142
+ if (interaction.kind === "respond") {
143
+ if (!("value" in interaction))
144
+ return context.json({ error: "expected respond interaction" }, 400);
145
+ found.status = "running";
146
+ await submit(found, {
147
+ kind: "respond",
148
+ interactionId: interaction.id,
149
+ value: interaction.value,
150
+ });
151
+ return context.json({ session_id: context.req.param("session"), state: found.status }, 202);
152
+ }
153
+ return context.json({ error: "expected approval or respond interaction" }, 400);
166
154
  });
167
- });
168
- app.post("/agents/:agentId/v1/ag-ui", async (context) => {
169
- const agent = requireAgent(context.req.param("agentId"));
170
- if (!agent)
171
- return context.json({ error: "unknown agent" }, 404);
172
- const payload = await context.req
173
- .json()
174
- .catch(() => undefined);
175
- const threadId = typeof payload?.threadId === "string" && payload.threadId
176
- ? payload.threadId
177
- : randomUUID();
178
- if (!isSessionId(threadId))
179
- return context.json({ error: "threadId may only contain letters, digits, '.', '_' and '-'" }, 400);
180
- const runId = typeof payload?.runId === "string" && payload.runId
181
- ? payload.runId
182
- : randomUUID();
183
- let message;
184
- try {
185
- message = await latestMessage(payload?.messages, media, agent.id, threadId);
186
- }
187
- catch (error) {
155
+ app.get("/agents/:agentId/v1/sessions/:session/events", async (context) => {
156
+ const agent = requireAgent(context.req.param("agentId"));
157
+ if (!agent)
158
+ return context.json({ error: "unknown agent" }, 404);
159
+ const after = Number(context.req.query("after") ?? "0");
160
+ const session = context.req.param("session");
161
+ const events = live.get(keyOf(agent.id, session))?.events ??
162
+ (await journal.events(agent.id, session));
163
+ return context.json({
164
+ events: events.filter((event) => event.seq > after),
165
+ next_cursor: events.at(-1)?.seq ?? after,
166
+ });
167
+ });
168
+ app.get("/agents/:agentId/v1/ag-ui/sessions/:session", async (context) => {
169
+ const agent = requireAgent(context.req.param("agentId"));
170
+ if (!agent)
171
+ return context.json({ error: "unknown agent" }, 404);
172
+ const found = live.get(keyOf(agent.id, context.req.param("session")));
188
173
  return context.json({
189
- error: error instanceof Error
190
- ? error.message
191
- : "Invalid AG-UI user message",
192
- }, 400);
174
+ messages: found?.messages ??
175
+ messages(agent.id, await journal.events(agent.id, context.req.param("session"))),
176
+ });
177
+ });
178
+ app.post("/agents/:agentId/v1/ag-ui", async (context) => {
179
+ const agent = requireAgent(context.req.param("agentId"));
180
+ if (!agent)
181
+ return context.json({ error: "unknown agent" }, 404);
182
+ const payload = await context.req
183
+ .json()
184
+ .catch(() => undefined);
185
+ const threadId = typeof payload?.threadId === "string" && payload.threadId
186
+ ? payload.threadId
187
+ : randomUUID();
188
+ if (!isSessionId(threadId))
189
+ return context.json({
190
+ error: "threadId may only contain letters, digits, '.', '_' and '-'",
191
+ }, 400);
192
+ const runId = typeof payload?.runId === "string" && payload.runId
193
+ ? payload.runId
194
+ : randomUUID();
195
+ let message;
196
+ let actor;
197
+ try {
198
+ actor = await routerOptions.getActor?.(context);
199
+ const metadata = await routerOptions.getRequestMetadata?.(context);
200
+ message = await latestMessage(payload?.messages, media, agent.id, threadId, metadata);
201
+ }
202
+ catch (error) {
203
+ return context.json({
204
+ error: error instanceof Error
205
+ ? error.message
206
+ : "Invalid AG-UI user message",
207
+ }, 400);
208
+ }
209
+ const found = await begin(agent, threadId, message, actor);
210
+ const start = found.events.length;
211
+ await submit(found, message.input);
212
+ return sse(agUiEvents(found.events.slice(start), threadId, runId), context.res.headers);
213
+ });
214
+ function requireAgent(id) {
215
+ return byId.get(id);
193
216
  }
194
- const found = await begin(agent, threadId, message);
195
- const start = found.events.length;
196
- await submit(found, message.input);
197
- return sse(agUiEvents(found.events.slice(start), threadId, runId), context.res.headers);
198
- });
199
- function requireAgent(id) {
200
- return byId.get(id);
201
- }
202
- async function begin(agent, sessionId, message) {
203
- const key = keyOf(agent.id, sessionId);
204
- const existing = live.get(key);
205
- if (existing) {
206
- existing.messages.push(message.chat);
207
- return existing;
217
+ async function begin(agent, sessionId, message, actor) {
218
+ const key = keyOf(agent.id, sessionId);
219
+ const existing = live.get(key);
220
+ if (existing) {
221
+ existing.messages.push(message.chat);
222
+ return existing;
223
+ }
224
+ const archived = await journal.events(agent.id, sessionId);
225
+ const concurrent = live.get(key);
226
+ if (concurrent) {
227
+ concurrent.messages.push(message.chat);
228
+ return concurrent;
229
+ }
230
+ if (archived.length)
231
+ throw new HTTPException(409, {
232
+ message: "This session is archived. Start a new conversation.",
233
+ });
234
+ let entry;
235
+ const observer = configuredObserver ?? jsonlObserver({ agentId: agent.id, sessionId });
236
+ const session = agent.run({
237
+ id: sessionId,
238
+ onModelCall,
239
+ observer: (event) => {
240
+ const image = generatedImageMessage(event, agent.id, sessionId);
241
+ if (image)
242
+ entry.messages.push(image);
243
+ add(entry, agent.id, event.type, observedPayload(event));
244
+ void Promise.resolve(observer(event)).catch(() => { });
245
+ },
246
+ ...(actor === undefined ? {} : { userId: actor.id }),
247
+ ...(actor?.context === undefined ? {} : { context: actor.context }),
248
+ });
249
+ entry = {
250
+ session,
251
+ events: [],
252
+ messages: [message.chat],
253
+ status: "running",
254
+ sequence: 0,
255
+ writes: Promise.resolve(),
256
+ };
257
+ live.set(key, entry);
258
+ return entry;
208
259
  }
209
- const archived = await journal.events(agent.id, sessionId);
210
- const concurrent = live.get(key);
211
- if (concurrent) {
212
- concurrent.messages.push(message.chat);
213
- return concurrent;
260
+ function add(entry, agentId, type, payload) {
261
+ const event = {
262
+ session: entry.session.id,
263
+ seq: ++entry.sequence,
264
+ ts: new Date().toISOString(),
265
+ type,
266
+ payload: redact(payload),
267
+ };
268
+ entry.events.push(event);
269
+ entry.writes = entry.writes.then(() => journal.append(agentId, event));
270
+ // The submit/close paths observe durability errors; avoid an unhandled rejection meanwhile.
271
+ void entry.writes.catch(() => { });
214
272
  }
215
- if (archived.length)
216
- throw new HTTPException(409, {
217
- message: "This session is archived. Start a new conversation.",
273
+ async function submit(entry, input) {
274
+ const agentId = [...live]
275
+ .find(([, value]) => value === entry)[0]
276
+ .split(":")[0];
277
+ entry.status = "running";
278
+ const inputEvent = typeof input === "string"
279
+ ? { kind: "user-message", text: input }
280
+ : "kind" in input
281
+ ? input
282
+ : { kind: "user-message", ...input };
283
+ const message = chatFromInput(inputEvent, agentId, entry.session.id);
284
+ add(entry, agentId, "session.run.started", {
285
+ input_kind: inputEvent.kind,
286
+ input: message ? firstText(message) : undefined,
287
+ ...(message ? { message } : {}),
288
+ ...("approved" in inputEvent ? { approved: inputEvent.approved } : {}),
289
+ ...("value" in inputEvent ? { value: inputEvent.value } : {}),
218
290
  });
219
- const session = agent.run({ id: sessionId });
220
- const entry = {
221
- session,
222
- events: [],
223
- messages: [message.chat],
224
- status: "running",
225
- sequence: 0,
226
- writes: Promise.resolve(),
227
- unsubscribe: () => { },
228
- };
229
- live.set(key, entry);
230
- entry.unsubscribe = session.observe((event) => {
231
- const image = generatedImageMessage(event, agent.id, sessionId);
232
- if (image)
233
- entry.messages.push(image);
234
- add(entry, agent.id, event.type, observedPayload(event));
235
- });
236
- return entry;
237
- }
238
- function add(entry, agentId, type, payload) {
239
- const event = {
240
- session: entry.session.id,
241
- seq: ++entry.sequence,
242
- ts: new Date().toISOString(),
243
- type,
244
- payload: redact(payload),
245
- };
246
- entry.events.push(event);
247
- entry.writes = entry.writes.then(() => journal.append(agentId, event));
248
- // The submit/close paths observe persistence errors; avoid an unhandled rejection meanwhile.
249
- void entry.writes.catch(() => { });
250
- }
251
- async function submit(entry, input) {
252
- const agentId = [...live]
253
- .find(([, value]) => value === entry)[0]
254
- .split(":")[0];
255
- entry.status = "running";
256
- const inputEvent = typeof input === "string"
257
- ? { kind: "user-message", text: input }
258
- : "kind" in input
259
- ? input
260
- : { kind: "user-message", ...input };
261
- const message = chatFromInput(inputEvent, agentId, entry.session.id);
262
- add(entry, agentId, "session.run.started", {
263
- input_kind: inputEvent.kind,
264
- input: message ? firstText(message) : undefined,
265
- ...(message ? { message } : {}),
266
- ...("approved" in inputEvent ? { approved: inputEvent.approved } : {}),
267
- ...("value" in inputEvent ? { value: inputEvent.value } : {}),
268
- });
269
- try {
270
- const result = await entry.session.input(input).completed;
271
- for (const event of result.events) {
272
- if (event.type === "final" && event.output !== undefined) {
273
- entry.messages.push({
274
- id: randomUUID(),
275
- role: "assistant",
276
- content: finalContent(event.output),
277
- });
278
- add(entry, agentId, "final", { output: event.output });
291
+ const start = entry.events.length;
292
+ try {
293
+ const result = await entry.session.input(input).completed;
294
+ for (const event of result.events) {
295
+ if (event.type === "final" && event.output !== undefined) {
296
+ entry.messages.push({
297
+ id: randomUUID(),
298
+ role: "assistant",
299
+ content: finalContent(event.output),
300
+ });
301
+ add(entry, agentId, "final", { output: event.output });
302
+ }
303
+ else if (event.type === "interaction.required") {
304
+ add(entry, agentId, event.type, { interaction: event.interaction });
305
+ }
306
+ }
307
+ const observedFailure = entry.events
308
+ .slice(start)
309
+ .some((event) => event.type === "error" || event.type === "tripwire");
310
+ const completionFailure = result.events.find((event) => event.type === "error" || event.type === "tripwire");
311
+ const failed = observedFailure ||
312
+ completionFailure !== undefined ||
313
+ (result.status !== "completed" && result.status !== "waiting");
314
+ if (!observedFailure && completionFailure) {
315
+ add(entry, agentId, completionFailure.type, observedPayload(completionFailure));
279
316
  }
280
- else if (event.type === "interaction.required") {
281
- add(entry, agentId, event.type, { interaction: event.interaction });
317
+ else if (!observedFailure && failed) {
318
+ add(entry, agentId, "error", {
319
+ message: `Agent run ${result.status}.`,
320
+ });
282
321
  }
322
+ entry.status = failed
323
+ ? "failed"
324
+ : result.status === "waiting"
325
+ ? "waiting"
326
+ : "completed";
327
+ await entry.writes;
328
+ }
329
+ catch (error) {
330
+ entry.status = "failed";
331
+ add(entry, agentId, "error", {
332
+ message: error instanceof Error ? error.message : String(error),
333
+ });
334
+ await entry.writes;
335
+ throw error;
283
336
  }
284
- entry.status =
285
- result.status === "waiting"
286
- ? "waiting"
287
- : result.status === "completed"
288
- ? "completed"
289
- : "failed";
290
- await entry.writes;
291
- }
292
- catch (error) {
293
- entry.status = "failed";
294
- add(entry, agentId, "error", {
295
- message: error instanceof Error ? error.message : String(error),
296
- });
297
- await entry.writes;
298
- throw error;
299
337
  }
300
- }
301
- let closing;
302
- return Object.freeze({
303
- app,
304
- hasSession: (agentId, sessionId) => live.has(keyOf(agentId, sessionId)),
305
- close: () => (closing ??= (async () => {
338
+ this.#shutdown = async () => {
306
339
  const sessions = await Promise.allSettled([...live.values()].map(async (entry) => {
307
340
  try {
308
341
  await entry.session.stop();
309
342
  await entry.writes;
310
343
  }
311
344
  finally {
312
- entry.unsubscribe();
313
345
  }
314
346
  }));
315
347
  const resources = await Promise.allSettled(agents.map((agent) => agent.close?.()));
316
348
  const failure = [...sessions, ...resources].find((result) => result.status === "rejected");
317
349
  if (failure?.status === "rejected")
318
350
  throw failure.reason;
319
- })()),
320
- });
351
+ };
352
+ return app;
353
+ }
354
+ }
355
+ /** Create the Hono protocol mount for a runtime. Applications may close it during graceful shutdown. */
356
+ // `any` avoids leaking a second physical Hono installation through a peer boundary.
357
+ // The returned value is the caller's Hono router at runtime.
358
+ export function serveAgents(options) {
359
+ if (!(options.runtime instanceof Runtime))
360
+ throw new Error("serveAgents requires a Runtime.");
361
+ const { runtime, ...rest } = options;
362
+ return runtime[kServe](rest);
321
363
  }
322
- function manifest(agent, media) {
364
+ function manifest(agent, media, publicPath) {
323
365
  return {
324
366
  protocolVersion: 2,
325
367
  id: agent.manifest.id,
326
368
  name: agent.manifest.name,
327
369
  manifest: agent.manifest,
328
370
  endpoints: {
329
- agUi: `/agents/${agent.id}/v1/ag-ui`,
330
- sessions: `/agents/${agent.id}/v1/sessions`,
371
+ agUi: publicPath(`/agents/${agent.id}/v1/ag-ui`),
372
+ sessions: publicPath(`/agents/${agent.id}/v1/sessions`),
331
373
  },
332
374
  ...(media
333
375
  ? {
@@ -342,14 +384,14 @@ function manifest(agent, media) {
342
384
  function keyOf(agent, session) {
343
385
  return `${agent}:${session}`;
344
386
  }
345
- async function latestMessage(value, media, agentId, sessionId) {
387
+ async function latestMessage(value, media, agentId, sessionId, metadata) {
346
388
  if (!Array.isArray(value))
347
389
  throw new Error("AG-UI requires a user message.");
348
390
  for (let i = value.length - 1; i >= 0; i -= 1) {
349
391
  const item = value[i];
350
392
  if (item?.role !== "user")
351
393
  continue;
352
- const content = await incomingContent(item.content, media, agentId, sessionId);
394
+ const content = await incomingContent(item.content, media, agentId, sessionId, metadata);
353
395
  if (content)
354
396
  return content;
355
397
  }
@@ -392,7 +434,7 @@ function messages(agentId, events) {
392
434
  ? [generatedImageFromEvent(event, agentId)]
393
435
  : []);
394
436
  }
395
- async function incomingContent(value, media, agentId, sessionId) {
437
+ async function incomingContent(value, media, agentId, sessionId, metadata) {
396
438
  const parts = [];
397
439
  const chat = [];
398
440
  if (typeof value === "string") {
@@ -442,7 +484,10 @@ async function incomingContent(value, media, agentId, sessionId) {
442
484
  if (parts.length === 0)
443
485
  return undefined;
444
486
  return Object.freeze({
445
- input: { content: Object.freeze(parts) },
487
+ input: {
488
+ content: Object.freeze(parts),
489
+ ...(metadata === undefined ? {} : { metadata }),
490
+ },
446
491
  chat: { id: randomUUID(), role: "user", content: Object.freeze(chat) },
447
492
  });
448
493
  }
@@ -548,42 +593,12 @@ function isSessionId(value) {
548
593
  return value !== "." && value !== ".." && /^[a-zA-Z0-9._-]+$/u.test(value);
549
594
  }
550
595
  function mediaUrl(agentId, sessionId, assetId) {
551
- return `/agents/${encodeURIComponent(agentId)}/v1/media/${encodeURIComponent(sessionId)}/${encodeURIComponent(assetId)}`;
552
- }
553
- /** Host headers a loopback bind answers to, on the port actually in use. */
554
- export function loopbackHosts(port) {
555
- return [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
596
+ return `media/${encodeURIComponent(sessionId)}/${encodeURIComponent(assetId)}`;
556
597
  }
557
- /**
558
- * Decide whether a request's Host header is served. Entries are exact
559
- * `host:port` values or bare host names that accept any port; `*` accepts all.
560
- */
561
- export function allowedHost(header, allowed) {
562
- if (allowed.includes("*"))
563
- return true;
564
- if (!header)
565
- return false;
566
- let hostname;
567
- try {
568
- hostname = new URL(`http://${header}`).hostname;
569
- }
570
- catch {
571
- return false;
572
- }
573
- const bare = hostname.replace(/^\[(.*)\]$/u, "$1");
574
- return allowed.some((entry) => entry.toLowerCase() === header.toLowerCase() ||
575
- entry.toLowerCase() === hostname ||
576
- entry.toLowerCase() === bare);
577
- }
578
- function permitted(origin, configured) {
579
- try {
580
- const url = new URL(origin);
581
- return (configured.includes(origin) ||
582
- url.hostname === "localhost" ||
583
- url.hostname.endsWith(".localhost") ||
584
- url.hostname === "127.0.0.1");
585
- }
586
- catch {
587
- return false;
588
- }
598
+ function normalizeBasePath(value) {
599
+ if (!value || value === "/")
600
+ return "";
601
+ if (!value.startsWith("/") || value.endsWith("/"))
602
+ throw new Error("basePath must start with / and must not end with /.");
603
+ return value;
589
604
  }