@nylorun/runtime 0.4.0-beta → 0.5.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.
@@ -1,28 +1,30 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { Hono } from "hono";
3
2
  import { cors } from "hono/cors";
4
3
  import { HTTPException } from "hono/http-exception";
5
- import { agUiEvents, sse } from "./ag-ui.js";
6
- import { observedPayload } from "./digests.js";
7
- import { localJsonl, scrub } from "../adapters/journal.js";
8
- import { jsonlObserver } from "../adapters/observe.js";
9
- import { IMAGE_MEDIA_TYPES, MAX_IMAGE_BYTES, } from "../adapters/media.js";
10
- import { projectSecrets } from "../model/settings.js";
11
- import { piModel } from "../model/pi-model.js";
4
+ import { agUiEvents } from "./ag-ui.js";
5
+ import { EventDelivery } from "./delivery.js";
6
+ import { scrub } from "../redact.js";
7
+ import { memorySessions } from "../sessions/store.js";
8
+ import { SessionHost } from "../sessions/host.js";
9
+ import { IMAGE_MEDIA_TYPES, MAX_IMAGE_BYTES, decodeImageBase64, } from "../media.js";
10
+ import { defaultModel, processEnvironment, registerRuntimeLifecycle, } from "../model/defaults.js";
11
+ const randomUUID = () => crypto.randomUUID();
12
12
  const kServe = Symbol("serve");
13
13
  export class Runtime {
14
- #config;
15
- #served = false;
16
- #closing;
17
- #shutdown;
18
- constructor(options = {}) {
19
- this.#config = options;
14
+ config;
15
+ host;
16
+ served = false;
17
+ closing;
18
+ constructor(config = {}) {
19
+ this.config = config;
20
+ this.host = new SessionHost(config.sessions ?? memorySessions());
21
+ registerRuntimeLifecycle(this.close);
20
22
  }
21
- close = () => (this.#closing ??= this.#shutdown?.() ?? Promise.resolve());
23
+ close = () => (this.closing ??= this.host.close());
22
24
  [kServe](options) {
23
- if (this.#served)
25
+ if (this.served)
24
26
  throw new Error("A Runtime may only be served once.");
25
- this.#served = true;
27
+ this.served = true;
26
28
  const agents = [...options.agents];
27
29
  const byId = new Map(agents.map((agent) => [agent.id, agent]));
28
30
  if (byId.size !== agents.length)
@@ -33,15 +35,9 @@ export class Runtime {
33
35
  if (agent.id !== agent.manifest.id || agent.name !== agent.manifest.name)
34
36
  throw new Error("Agent identity must match its manifest.");
35
37
  }
36
- const media = this.#config.media;
37
- const onModelCall = this.#config.onModelCall ?? piModel({ media });
38
- const journal = this.#config.durability ?? localJsonl();
39
- const configuredObserver = this.#config.observer;
40
- const redact = (value) => scrub(value, projectSecrets());
41
- const live = new Map();
42
- const routerOptions = options;
38
+ const { media } = this.config;
43
39
  const app = new Hono();
44
- if (process.env.NYLORUN_DEV === "1") {
40
+ if (processEnvironment().NYLORUN_DEV === "1")
45
41
  app.use("*", cors({
46
42
  origin: (origin) => {
47
43
  try {
@@ -56,340 +52,256 @@ export class Runtime {
56
52
  return undefined;
57
53
  }
58
54
  },
59
- allowMethods: [
60
- "GET",
61
- "HEAD",
62
- "POST",
63
- "PUT",
64
- "PATCH",
65
- "DELETE",
66
- "OPTIONS",
67
- ],
68
55
  }));
69
- }
70
- app.onError((error, context) => context.json({ error: String(redact(error.message)) }, error instanceof HTTPException ? error.status : 500));
56
+ app.onError((error, context) => context.json({
57
+ error: String(scrub(error.message, secretValues(environment(context)))),
58
+ }, error instanceof HTTPException ? error.status : 500));
59
+ // Application authorization/info resolution precedes all store and media access.
71
60
  app.use("*", async (context, next) => {
61
+ const actor = await options.getActor?.(context);
62
+ const info = options.getInfo
63
+ ? await options.getInfo(context)
64
+ : actor
65
+ ? { ...actor.context, userId: actor.id }
66
+ : undefined;
67
+ context.set("info", info);
68
+ if (options.getEnvironment)
69
+ context.set("modelEnvironment", await options.getEnvironment(context));
72
70
  await next();
73
71
  if (context.res.headers.get("content-type")?.includes("application/json")) {
74
72
  const value = await context.res.json();
75
- context.res = new Response(JSON.stringify(redact(value)), {
76
- status: context.res.status,
77
- headers: context.res.headers,
78
- });
73
+ context.res = new Response(JSON.stringify(scrub(value, secretValues(environment(context)))), { status: context.res.status, headers: context.res.headers });
79
74
  }
80
75
  });
81
- // Infer the public mount from this request URL and the local route path.
82
- // Do not use hono/route basePath: consumers often install a separate `hono`
83
- // copy, and that helper's match-result Symbol then misses the parent's match.
84
- const publicPath = (context, routePath, path) => `${normalizeBasePath(routerOptions.basePath ?? inferMountPath(context, routePath))}${path}`;
76
+ const environment = (context) => context.get("modelEnvironment") ??
77
+ this.config.environment ??
78
+ bindingsEnvironment(context) ??
79
+ processEnvironment();
80
+ const path = (context, route, suffix) => `${normalizeBasePath(options.basePath ?? inferMountPath(context, route))}${suffix}`;
81
+ const agentFor = (context) => {
82
+ const agent = byId.get(context.req.param("agentId"));
83
+ if (!agent)
84
+ throw new HTTPException(404, { message: "unknown agent" });
85
+ const session = context.req.param("session");
86
+ if (session !== undefined && !isSessionId(session))
87
+ throw new HTTPException(400, { message: "Invalid session identifier" });
88
+ return agent;
89
+ };
90
+ const submitOptions = (context, sessionId, onPreview) => {
91
+ // Leave undefined on Node so defaultModel can use the installed piModel
92
+ // factory. Hono's Node adapter puts stream bindings on context.env; those
93
+ // must not be treated as Workers-style provider bindings.
94
+ const explicitEnvironment = context.get("modelEnvironment") ??
95
+ this.config.environment ??
96
+ bindingsEnvironment(context);
97
+ return {
98
+ info: { ...(context.get("info") ?? {}), sessionId },
99
+ onEvent: (event) => {
100
+ try {
101
+ void Promise.resolve(this.config.observer?.({ type: event.type, ...event.payload })).catch(() => { });
102
+ }
103
+ catch {
104
+ /* Observation cannot acknowledge persistence. */
105
+ }
106
+ },
107
+ onModelCall: this.config.onModelCall ??
108
+ (this.config.createModel ?? defaultModel)({
109
+ environment: explicitEnvironment,
110
+ onPreview,
111
+ media,
112
+ }),
113
+ secrets: secretValues(environment(context)),
114
+ signal: context.req.raw.signal,
115
+ };
116
+ };
85
117
  app.get("/v1/agents", (context) => context.json({
86
118
  protocolVersion: 2,
87
119
  agents: agents.map((agent) => ({
88
120
  id: agent.id,
89
- manifestUrl: publicPath(context, "/v1/agents", `/${agent.id}/manifest.json`),
121
+ manifestUrl: path(context, "/v1/agents", `/${agent.id}/manifest.json`),
90
122
  })),
91
123
  }));
92
- app.get("/:agentId/manifest.json", (context) => {
93
- const agent = byId.get(context.req.param("agentId"));
94
- return agent === undefined
95
- ? context.json({ error: "unknown agent" }, 404)
96
- : context.json(manifest(agent, media !== undefined, (path) => publicPath(context, "/:agentId/manifest.json", path)));
97
- });
124
+ app.get("/:agentId/manifest.json", (context) => context.json(manifest(agentFor(context), media !== undefined, (suffix) => path(context, "/:agentId/manifest.json", suffix))));
98
125
  app.get("/:agentId/v1/media/:session/:assetId", async (context) => {
99
- const agent = requireAgent(context.req.param("agentId"));
100
- if (!agent)
101
- return context.json({ error: "unknown agent" }, 404);
126
+ const agent = agentFor(context);
102
127
  const asset = await media?.read(agent.id, context.req.param("session"), context.req.param("assetId"));
103
128
  if (!asset)
104
129
  return context.json({ error: "unknown media asset" }, 404);
105
- context.header("cache-control", "no-store");
106
130
  return context.body(asset.bytes, 200, {
107
131
  "content-type": asset.asset.mediaType,
132
+ "cache-control": "no-store",
108
133
  });
109
134
  });
110
- app.get("/:agentId/v1/sessions", async (context) => {
111
- const agent = requireAgent(context.req.param("agentId"));
112
- if (agent === undefined)
113
- return context.json({ error: "unknown agent" }, 404);
114
- const listed = await journal.list(agent.id);
115
- return context.json({
116
- sessions: listed.map((summary) => {
117
- const found = live.get(keyOf(agent.id, summary.session));
118
- if (found?.status === "running")
119
- return { ...summary, status: "running" };
120
- if (found?.status === "waiting")
121
- return { ...summary, status: "waiting" };
122
- return summary;
123
- }),
124
- });
125
- });
135
+ app.get("/:agentId/v1/sessions", async (context) => context.json({ sessions: await this.host.list(agentFor(context).id) }));
126
136
  app.get("/:agentId/v1/sessions/:session", async (context) => {
127
- const agent = requireAgent(context.req.param("agentId"));
128
- if (!agent)
129
- return context.json({ error: "unknown agent" }, 404);
130
- const key = keyOf(agent.id, context.req.param("session"));
131
- const found = live.get(key);
132
- const events = found?.events ??
133
- (await journal.events(agent.id, context.req.param("session")));
134
- if (!found && events.length === 0)
137
+ const document = await this.host.read(agentFor(context).id, context.req.param("session"));
138
+ if (!document)
135
139
  return context.json({ error: "unknown session" }, 404);
136
140
  return context.json({
137
- id: context.req.param("session"),
138
- state: found?.status ?? status(events),
139
- pending_interaction: pending(events),
141
+ id: document.id,
142
+ state: document.status,
143
+ pending_interaction: document.state?.plan?.calls.find((call) => call.status === "interaction")?.interaction,
140
144
  });
141
145
  });
142
146
  app.post("/:agentId/v1/sessions/:session", async (context) => {
143
- const agent = requireAgent(context.req.param("agentId"));
144
- if (!agent)
145
- return context.json({ error: "unknown agent" }, 404);
146
- const found = live.get(keyOf(agent.id, context.req.param("session")));
147
- if (!found)
148
- return context.json({ error: "session is no longer live" }, 409);
149
- const payload = await context.req
150
- .json()
151
- .catch(() => undefined);
152
- const interaction = payload?.interaction;
153
- if (!interaction || typeof interaction.id !== "string")
154
- return context.json({ error: "expected interaction" }, 400);
155
- const waiting = pending(found.events);
156
- if (found.status !== "waiting" ||
157
- !waiting ||
158
- waiting.id !== interaction.id)
159
- return context.json({ error: "interaction is no longer pending" }, 409);
160
- if (interaction.kind === "approval") {
161
- if (typeof interaction.approved !== "boolean")
162
- return context.json({ error: "expected approval interaction" }, 400);
163
- found.status = "running";
164
- await submit(found, {
147
+ const agent = agentFor(context), sessionId = context.req.param("session");
148
+ const payload = await context.req.json();
149
+ const interaction = payload.interaction;
150
+ let input;
151
+ if (payload.action === "cancel") {
152
+ await this.host.cancel(agent, sessionId);
153
+ return context.json({ session_id: sessionId, state: "cancelled" });
154
+ }
155
+ if (payload.action === "interrupt") {
156
+ const result = await this.host.interrupt(agent, sessionId, payload.input, submitOptions(context, sessionId));
157
+ return context.json({
158
+ session_id: sessionId,
159
+ state: result.status === "paused" ? "waiting" : result.status,
160
+ }, 202);
161
+ }
162
+ if (payload.settlement)
163
+ input = { kind: "settle", ...payload.settlement };
164
+ else if (interaction?.kind === "approval" &&
165
+ typeof interaction.id === "string" &&
166
+ typeof interaction.approved === "boolean")
167
+ input = {
165
168
  kind: "approve",
166
169
  interactionId: interaction.id,
167
170
  approved: interaction.approved,
168
- });
169
- return context.json({ session_id: context.req.param("session"), state: found.status }, 202);
170
- }
171
- if (interaction.kind === "respond") {
172
- if (!("value" in interaction))
173
- return context.json({ error: "expected respond interaction" }, 400);
174
- found.status = "running";
175
- await submit(found, {
171
+ };
172
+ else if (interaction?.kind === "respond" &&
173
+ typeof interaction.id === "string" &&
174
+ "value" in interaction)
175
+ input = {
176
176
  kind: "respond",
177
177
  interactionId: interaction.id,
178
178
  value: interaction.value,
179
- });
180
- return context.json({ session_id: context.req.param("session"), state: found.status }, 202);
181
- }
182
- return context.json({ error: "expected approval or respond interaction" }, 400);
179
+ };
180
+ else
181
+ return context.json({ error: "expected a correlated interaction or settlement" }, 400);
182
+ const document = await this.host.read(agent.id, sessionId);
183
+ if (!document || document.status !== "waiting")
184
+ return context.json({ error: "interaction is no longer pending" }, 409);
185
+ const result = await this.host.submit(agent, sessionId, input, submitOptions(context, sessionId));
186
+ return context.json({
187
+ session_id: sessionId,
188
+ state: result.status === "paused" ? "waiting" : result.status,
189
+ }, 202);
183
190
  });
184
191
  app.get("/:agentId/v1/sessions/:session/events", async (context) => {
185
- const agent = requireAgent(context.req.param("agentId"));
186
- if (!agent)
187
- return context.json({ error: "unknown agent" }, 404);
192
+ const document = await this.host.read(agentFor(context).id, context.req.param("session"));
188
193
  const after = Number(context.req.query("after") ?? "0");
189
- const session = context.req.param("session");
190
- const events = live.get(keyOf(agent.id, session))?.events ??
191
- (await journal.events(agent.id, session));
194
+ if (!Number.isSafeInteger(after) || after < 0)
195
+ return context.json({ error: "Invalid event cursor" }, 400);
196
+ const events = document?.events ?? [];
192
197
  return context.json({
193
198
  events: events.filter((event) => event.seq > after),
194
199
  next_cursor: events.at(-1)?.seq ?? after,
195
200
  });
196
201
  });
197
202
  app.get("/:agentId/v1/ag-ui/sessions/:session", async (context) => {
198
- const agent = requireAgent(context.req.param("agentId"));
199
- if (!agent)
200
- return context.json({ error: "unknown agent" }, 404);
201
- const found = live.get(keyOf(agent.id, context.req.param("session")));
203
+ const agent = agentFor(context);
202
204
  return context.json({
203
- messages: found?.messages ??
204
- messages(agent.id, await journal.events(agent.id, context.req.param("session"))),
205
+ messages: messages(agent.id, (await this.host.read(agent.id, context.req.param("session")))
206
+ ?.events ?? []),
205
207
  });
206
208
  });
207
209
  app.post("/:agentId/v1/ag-ui", async (context) => {
208
- const agent = requireAgent(context.req.param("agentId"));
209
- if (!agent)
210
- return context.json({ error: "unknown agent" }, 404);
211
- const payload = await context.req
212
- .json()
213
- .catch(() => undefined);
214
- const threadId = typeof payload?.threadId === "string" && payload.threadId
210
+ const agent = agentFor(context);
211
+ const payload = await context.req.json();
212
+ const threadId = typeof payload.threadId === "string" && payload.threadId
215
213
  ? payload.threadId
216
214
  : randomUUID();
217
215
  if (!isSessionId(threadId))
218
- return context.json({
219
- error: "threadId may only contain letters, digits, '.', '_' and '-'",
220
- }, 400);
221
- const runId = typeof payload?.runId === "string" && payload.runId
216
+ return context.json({ error: "Invalid threadId" }, 400);
217
+ const runId = typeof payload.runId === "string" && payload.runId
222
218
  ? payload.runId
223
219
  : randomUUID();
224
- let message;
225
- let actor;
226
- try {
227
- actor = await routerOptions.getActor?.(context);
228
- const metadata = await routerOptions.getRequestMetadata?.(context);
229
- message = await latestMessage(payload?.messages, media, agent.id, threadId, metadata);
230
- }
231
- catch (error) {
232
- return context.json({
233
- error: error instanceof Error
234
- ? error.message
235
- : "Invalid AG-UI user message",
236
- }, 400);
237
- }
238
- const found = await begin(agent, threadId, message, actor);
239
- const start = found.events.length;
240
- await submit(found, message.input);
241
- return sse(agUiEvents(found.events.slice(start), threadId, runId), context.res.headers);
242
- });
243
- function requireAgent(id) {
244
- return byId.get(id);
245
- }
246
- async function begin(agent, sessionId, message, actor) {
247
- const key = keyOf(agent.id, sessionId);
248
- const existing = live.get(key);
249
- if (existing) {
250
- existing.messages.push(message.chat);
251
- return existing;
252
- }
253
- const archived = await journal.events(agent.id, sessionId);
254
- const concurrent = live.get(key);
255
- if (concurrent) {
256
- concurrent.messages.push(message.chat);
257
- return concurrent;
258
- }
259
- if (archived.length)
260
- throw new HTTPException(409, {
261
- message: "This session is archived. Start a new conversation.",
262
- });
263
- let entry;
264
- const observer = configuredObserver ?? jsonlObserver({ agentId: agent.id, sessionId });
265
- const session = agent.run({
266
- id: sessionId,
267
- onModelCall,
268
- observer: (event) => {
269
- const image = generatedImageMessage(event, agent.id, sessionId);
270
- if (image)
271
- entry.messages.push(image);
272
- add(entry, agent.id, event.type, observedPayload(event));
273
- void Promise.resolve(observer(event)).catch(() => { });
220
+ const message = await latestMessage(payload.messages, media, agent.id, threadId, await options.getRequestMetadata?.(context));
221
+ const controller = new AbortController();
222
+ const abort = () => controller.abort(context.req.raw.signal.reason);
223
+ context.req.raw.signal.addEventListener("abort", abort, { once: true });
224
+ if (context.req.raw.signal.aborted)
225
+ abort();
226
+ const delivery = new EventDelivery(() => controller.abort(new Error("Delivery connection ended")), this.config.delivery, context.res.headers);
227
+ delivery.push({ type: "RUN_STARTED", threadId, runId });
228
+ const modelOptions = submitOptions(context, threadId, this.config.tokens
229
+ ? (preview) => delivery.push({
230
+ type: "CUSTOM",
231
+ name: "nylorun.preview",
232
+ value: { ...preview, runId },
233
+ }, preview.invocationId)
234
+ : undefined);
235
+ const task = this.host.submit(agent, threadId, message.input, {
236
+ ...modelOptions,
237
+ runId,
238
+ signal: controller.signal,
239
+ started: {
240
+ input_kind: "user-message",
241
+ message: message.chat,
242
+ ...(firstText(message.chat) === undefined
243
+ ? {}
244
+ : { input: firstText(message.chat) }),
274
245
  },
275
- ...(actor === undefined ? {} : { userId: actor.id }),
276
- ...(actor?.context === undefined ? {} : { context: actor.context }),
277
- });
278
- entry = {
279
- session,
280
- events: [],
281
- messages: [message.chat],
282
- status: "running",
283
- sequence: 0,
284
- writes: Promise.resolve(),
285
- };
286
- live.set(key, entry);
287
- return entry;
288
- }
289
- function add(entry, agentId, type, payload) {
290
- const event = {
291
- session: entry.session.id,
292
- seq: ++entry.sequence,
293
- ts: new Date().toISOString(),
294
- type,
295
- payload: redact(payload),
296
- };
297
- entry.events.push(event);
298
- entry.writes = entry.writes.then(() => journal.append(agentId, event));
299
- // The submit/close paths observe durability errors; avoid an unhandled rejection meanwhile.
300
- void entry.writes.catch(() => { });
301
- }
302
- async function submit(entry, input) {
303
- const agentId = [...live]
304
- .find(([, value]) => value === entry)[0]
305
- .split(":")[0];
306
- entry.status = "running";
307
- const inputEvent = typeof input === "string"
308
- ? { kind: "user-message", text: input }
309
- : "kind" in input
310
- ? input
311
- : { kind: "user-message", ...input };
312
- const message = chatFromInput(inputEvent, agentId, entry.session.id);
313
- add(entry, agentId, "session.run.started", {
314
- input_kind: inputEvent.kind,
315
- input: message ? firstText(message) : undefined,
316
- ...(message ? { message } : {}),
317
- ...("approved" in inputEvent ? { approved: inputEvent.approved } : {}),
318
- ...("value" in inputEvent ? { value: inputEvent.value } : {}),
319
- });
320
- const start = entry.events.length;
321
- try {
322
- const result = await entry.session.input(input).completed;
323
- for (const event of result.events) {
324
- if (event.type === "final" && event.output !== undefined) {
325
- entry.messages.push({
326
- id: randomUUID(),
327
- role: "assistant",
328
- content: finalContent(event.output),
329
- });
330
- add(entry, agentId, "final", { output: event.output });
331
- }
332
- else if (event.type === "interaction.required") {
333
- add(entry, agentId, event.type, { interaction: event.interaction });
334
- }
335
- }
336
- const observedFailure = entry.events
337
- .slice(start)
338
- .some((event) => event.type === "error" || event.type === "tripwire");
339
- const completionFailure = result.events.find((event) => event.type === "error" || event.type === "tripwire");
340
- const failed = observedFailure ||
341
- completionFailure !== undefined ||
342
- (result.status !== "completed" && result.status !== "waiting");
343
- if (!observedFailure && completionFailure) {
344
- add(entry, agentId, completionFailure.type, observedPayload(completionFailure));
345
- }
346
- else if (!observedFailure && failed) {
347
- add(entry, agentId, "error", {
348
- message: `Agent run ${result.status}.`,
246
+ onEvent: (event) => {
247
+ delivery.push({
248
+ type: "CUSTOM",
249
+ name: "nylorun.execution",
250
+ value: event,
349
251
  });
350
- }
351
- entry.status = failed
352
- ? "failed"
353
- : result.status === "waiting"
354
- ? "waiting"
355
- : "completed";
356
- await entry.writes;
357
- }
358
- catch (error) {
359
- entry.status = "failed";
360
- add(entry, agentId, "error", {
361
- message: error instanceof Error ? error.message : String(error),
252
+ for (const projected of agUiEvents([event], threadId, runId))
253
+ if (!["RUN_STARTED", "RUN_FINISHED"].includes(String(projected.type)))
254
+ delivery.push(projected);
255
+ modelOptions.onEvent(event);
256
+ },
257
+ });
258
+ void task
259
+ .then((result) => {
260
+ delivery.push({
261
+ type: "CUSTOM",
262
+ name: "nylorun.preview.settled",
263
+ value: { runId, status: result.status },
362
264
  });
363
- await entry.writes;
364
- throw error;
365
- }
366
- }
367
- this.#shutdown = async () => {
368
- const sessions = await Promise.allSettled([...live.values()].map(async (entry) => {
369
- try {
370
- await entry.session.stop();
371
- await entry.writes;
372
- }
373
- finally {
374
- }
375
- }));
376
- const resources = await Promise.allSettled(agents.map((agent) => agent.close?.()));
377
- const failure = [...sessions, ...resources].find((result) => result.status === "rejected");
378
- if (failure?.status === "rejected")
379
- throw failure.reason;
380
- };
265
+ if (result.status !== "failed")
266
+ delivery.push({ type: "RUN_FINISHED", threadId, runId });
267
+ }, (error) => {
268
+ delivery.push({
269
+ type: "RUN_ERROR",
270
+ message: String(scrub(error instanceof Error ? error.message : String(error), modelOptions.secrets)),
271
+ });
272
+ })
273
+ .finally(() => {
274
+ context.req.raw.signal.removeEventListener("abort", abort);
275
+ delivery.end();
276
+ });
277
+ return delivery.response;
278
+ });
381
279
  return app;
382
280
  }
383
281
  }
384
- /** Create the Hono protocol mount for a runtime. Applications may close it during graceful shutdown. */
385
- // `any` avoids leaking a second physical Hono installation through a peer boundary.
386
- // The returned value is the caller's Hono router at runtime.
387
282
  export function serveAgents(options) {
388
- if (!(options.runtime instanceof Runtime))
389
- throw new Error("serveAgents requires a Runtime.");
390
283
  const { runtime, ...rest } = options;
391
284
  return runtime[kServe](rest);
392
285
  }
286
+ /** Workers-style provider bindings on context.env; ignore Hono Node stream slots. */
287
+ function bindingsEnvironment(context) {
288
+ const env = context.env;
289
+ if (!env || typeof env !== "object")
290
+ return undefined;
291
+ const keys = Object.keys(env);
292
+ if (!keys.length)
293
+ return undefined;
294
+ if (keys.every((key) => key === "incoming" || key === "outgoing"))
295
+ return undefined;
296
+ return env;
297
+ }
298
+ function secretValues(environment) {
299
+ return Object.entries(environment).flatMap(([key, value]) => /key|token|secret|password|credential/i.test(key) &&
300
+ typeof value === "string" &&
301
+ value
302
+ ? [value]
303
+ : []);
304
+ }
393
305
  function manifest(agent, media, publicPath) {
394
306
  return {
395
307
  protocolVersion: 2,
@@ -415,7 +327,7 @@ function keyOf(agent, session) {
415
327
  }
416
328
  async function latestMessage(value, media, agentId, sessionId, metadata) {
417
329
  if (!Array.isArray(value))
418
- throw new Error("AG-UI requires a user message.");
330
+ throw new HTTPException(400, { message: "AG-UI requires a user message." });
419
331
  for (let i = value.length - 1; i >= 0; i -= 1) {
420
332
  const item = value[i];
421
333
  if (item?.role !== "user")
@@ -424,7 +336,9 @@ async function latestMessage(value, media, agentId, sessionId, metadata) {
424
336
  if (content)
425
337
  return content;
426
338
  }
427
- throw new Error("AG-UI requires a non-empty user message.");
339
+ throw new HTTPException(400, {
340
+ message: "AG-UI requires a non-empty user message.",
341
+ });
428
342
  }
429
343
  function pending(events) {
430
344
  for (let i = events.length - 1; i >= 0; i -= 1) {
@@ -484,22 +398,39 @@ async function incomingContent(value, media, agentId, sessionId, metadata) {
484
398
  continue;
485
399
  }
486
400
  if (part?.type !== "image")
487
- throw new Error("Only text and image inputs are supported.");
401
+ throw new HTTPException(400, {
402
+ message: "Only text and image inputs are supported.",
403
+ });
488
404
  if (++images > 1)
489
- throw new Error("Attach only one image per message.");
405
+ throw new HTTPException(400, {
406
+ message: "Attach only one image per message.",
407
+ });
490
408
  const source = part.source;
491
409
  if (!source ||
492
410
  source.type !== "data" ||
493
411
  typeof source.value !== "string" ||
494
412
  typeof source.mimeType !== "string")
495
- throw new Error("Image input must contain base64 data and a media type.");
413
+ throw new HTTPException(400, {
414
+ message: "Image input must contain base64 data and a media type.",
415
+ });
496
416
  if (!media)
497
- throw new Error("This runtime does not support image input.");
417
+ throw new HTTPException(400, {
418
+ message: "This runtime does not support image input.",
419
+ });
420
+ try {
421
+ decodeImageBase64(source.mimeType, source.value);
422
+ }
423
+ catch (cause) {
424
+ throw new HTTPException(400, {
425
+ message: cause instanceof Error ? cause.message : "Invalid image input",
426
+ cause,
427
+ });
428
+ }
498
429
  const asset = await media.saveInput(agentId, sessionId, source.mimeType, source.value);
499
430
  parts.push({
500
431
  type: "media",
501
432
  mediaType: asset.mediaType,
502
- reference: { agentId, assetId: asset.id },
433
+ reference: { agentId, sessionId, assetId: asset.id },
503
434
  });
504
435
  chat.push({
505
436
  type: "image",