@adonis-agora/agent 0.48.0 → 0.50.0

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/providers/agent_provider.d.ts.map +1 -1
  3. package/dist/providers/agent_provider.js +225 -46
  4. package/dist/providers/agent_provider.js.map +1 -1
  5. package/dist/providers/dashboard_provider.js +1 -1
  6. package/dist/providers/dashboard_provider.js.map +1 -1
  7. package/dist/src/agent-loop.d.ts +18 -0
  8. package/dist/src/agent-loop.d.ts.map +1 -1
  9. package/dist/src/agent-loop.js +52 -8
  10. package/dist/src/agent-loop.js.map +1 -1
  11. package/dist/src/agent-service.d.ts +129 -9
  12. package/dist/src/agent-service.d.ts.map +1 -1
  13. package/dist/src/agent-service.js +284 -8
  14. package/dist/src/agent-service.js.map +1 -1
  15. package/dist/src/chat-queue-service.d.ts +153 -0
  16. package/dist/src/chat-queue-service.d.ts.map +1 -0
  17. package/dist/src/chat-queue-service.js +360 -0
  18. package/dist/src/chat-queue-service.js.map +1 -0
  19. package/dist/src/client/chat-client.d.ts +52 -0
  20. package/dist/src/client/chat-client.d.ts.map +1 -1
  21. package/dist/src/client/chat-client.js +51 -2
  22. package/dist/src/client/chat-client.js.map +1 -1
  23. package/dist/src/client/index.d.ts +1 -1
  24. package/dist/src/client/index.d.ts.map +1 -1
  25. package/dist/src/client/index.js +1 -1
  26. package/dist/src/client/index.js.map +1 -1
  27. package/dist/src/client/sse.d.ts +5 -0
  28. package/dist/src/client/sse.d.ts.map +1 -1
  29. package/dist/src/client/sse.js +5 -1
  30. package/dist/src/client/sse.js.map +1 -1
  31. package/dist/src/define_config.d.ts +3 -1
  32. package/dist/src/define_config.d.ts.map +1 -1
  33. package/dist/src/define_config.js.map +1 -1
  34. package/dist/src/durable/agent-run-context.d.ts +6 -0
  35. package/dist/src/durable/agent-run-context.d.ts.map +1 -1
  36. package/dist/src/durable/agent-run-context.js.map +1 -1
  37. package/dist/src/durable/agent-run-workflow.d.ts.map +1 -1
  38. package/dist/src/durable/agent-run-workflow.js +94 -6
  39. package/dist/src/durable/agent-run-workflow.js.map +1 -1
  40. package/dist/src/durable/durable-agent-runner.d.ts +19 -3
  41. package/dist/src/durable/durable-agent-runner.d.ts.map +1 -1
  42. package/dist/src/durable/durable-agent-runner.js +62 -3
  43. package/dist/src/durable/durable-agent-runner.js.map +1 -1
  44. package/dist/src/elicitation.d.ts +12 -0
  45. package/dist/src/elicitation.d.ts.map +1 -1
  46. package/dist/src/elicitation.js +9 -1
  47. package/dist/src/elicitation.js.map +1 -1
  48. package/dist/src/index.d.ts +8 -5
  49. package/dist/src/index.d.ts.map +1 -1
  50. package/dist/src/index.js +7 -4
  51. package/dist/src/index.js.map +1 -1
  52. package/dist/src/react/use-agent-chat.d.ts.map +1 -1
  53. package/dist/src/react/use-agent-chat.js +15 -1
  54. package/dist/src/react/use-agent-chat.js.map +1 -1
  55. package/dist/src/runners/inline-agent-runner.d.ts +30 -6
  56. package/dist/src/runners/inline-agent-runner.d.ts.map +1 -1
  57. package/dist/src/runners/inline-agent-runner.js +117 -21
  58. package/dist/src/runners/inline-agent-runner.js.map +1 -1
  59. package/dist/src/spi/agent-runner.d.ts +20 -1
  60. package/dist/src/spi/agent-runner.d.ts.map +1 -1
  61. package/dist/src/spi/chat-queue.d.ts +133 -0
  62. package/dist/src/spi/chat-queue.d.ts.map +1 -0
  63. package/dist/src/spi/chat-queue.js +48 -0
  64. package/dist/src/spi/chat-queue.js.map +1 -0
  65. package/dist/src/spi/model-catalog.d.ts +13 -0
  66. package/dist/src/spi/model-catalog.d.ts.map +1 -1
  67. package/dist/src/spi/model-catalog.js.map +1 -1
  68. package/dist/src/spi/quota-provider.d.ts +30 -1
  69. package/dist/src/spi/quota-provider.d.ts.map +1 -1
  70. package/dist/src/spi/quota-provider.js +34 -1
  71. package/dist/src/spi/quota-provider.js.map +1 -1
  72. package/dist/src/stores/ledger-quota-provider.d.ts +10 -1
  73. package/dist/src/stores/ledger-quota-provider.d.ts.map +1 -1
  74. package/dist/src/stores/ledger-quota-provider.js +14 -3
  75. package/dist/src/stores/ledger-quota-provider.js.map +1 -1
  76. package/dist/src/stores/lucid-schema.d.ts +8 -6
  77. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  78. package/dist/src/stores/lucid-schema.js +34 -7
  79. package/dist/src/stores/lucid-schema.js.map +1 -1
  80. package/dist/src/stores/lucid.d.ts +23 -1
  81. package/dist/src/stores/lucid.d.ts.map +1 -1
  82. package/dist/src/stores/lucid.js +192 -0
  83. package/dist/src/stores/lucid.js.map +1 -1
  84. package/dist/src/stream-events.d.ts +17 -0
  85. package/dist/src/stream-events.d.ts.map +1 -1
  86. package/dist/src/testing/chat-queue-store-contract.d.ts +26 -0
  87. package/dist/src/testing/chat-queue-store-contract.d.ts.map +1 -0
  88. package/dist/src/testing/chat-queue-store-contract.js +162 -0
  89. package/dist/src/testing/chat-queue-store-contract.js.map +1 -0
  90. package/dist/src/testing/in-memory-store.d.ts +20 -1
  91. package/dist/src/testing/in-memory-store.d.ts.map +1 -1
  92. package/dist/src/testing/in-memory-store.js +125 -0
  93. package/dist/src/testing/in-memory-store.js.map +1 -1
  94. package/dist/src/testing/index.d.ts +1 -0
  95. package/dist/src/testing/index.d.ts.map +1 -1
  96. package/dist/src/testing/index.js +1 -0
  97. package/dist/src/testing/index.js.map +1 -1
  98. package/dist/src/tool-presentation.d.ts +5 -0
  99. package/dist/src/tool-presentation.d.ts.map +1 -1
  100. package/dist/src/tool-presentation.js +5 -1
  101. package/dist/src/tool-presentation.js.map +1 -1
  102. package/dist/src/types.d.ts +25 -1
  103. package/dist/src/types.d.ts.map +1 -1
  104. package/dist/stubs/database/migrations/create_agent_tables.stub +2 -2
  105. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # @adonis-agora/agent
2
2
 
3
+ ## 0.50.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#224](https://github.com/DavideCarvalho/adonis-agora-agent/pull/224) [`3ed6b2d`](https://github.com/DavideCarvalho/adonis-agora-agent/commit/3ed6b2dbcdceef26ea7c262edec5cacffb5d6c05) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Message queue — sending while a turn is still running, ported from `@dudousxd/nestjs-agent` (its `docs/stream-protocol.md`, *Message queue*), so `chat.queue` in `@dudousxd/nestjs-agent-react` works against an Adonis server.
8
+
9
+ - **A send on a busy thread is queued, not run concurrently (BREAKING behaviour).** `POST <path>/chat` on a thread whose turn is still running now answers `202 { queued: true, threadId, messageId, position, queue, runId?, interrupting? }` (JSON, no stream) and the message — stored server-side, per thread, FIFO — starts when that turn settles, under its own `messageId` as run id. `mode: 'auto' | 'queue' | 'interrupt'` (`interrupt: true` is the shorthand): `'queue'` always answers `202`, `'interrupt'` queues at the head and cancels the running turn. `regenerate` on a busy thread is `409 run_active`. Before, such a send started a second, concurrent turn on the same thread.
10
+ - **Drain policy.** Completed → the head starts; failed → the queue pauses (`run_failed`); Stop → pauses (`cancelled`); an interrupt's cancel → its message starts; the head's actor over quota → pauses (`quota_exceeded`). The decision is the settling stream's last event before its terminal: `{ kind: 'queue', queue, started?: { messageId, runId } }`. Every queue change while a turn holds the thread is a `queue` event too.
11
+ - **Routes.** `GET` / `DELETE <path>/threads/:id/queue`, `POST <path>/threads/:id/queue/resume`, `PATCH` / `DELETE <path>/queue/:messageId` (owner-scoped; `404` unknown, `403` another actor's, `410` already started or removed, `501` on a store without a queue). `GET <path>/threads/:id` carries `queue`.
12
+ - **One turn per thread.** Admission is a compare-and-set on the thread's active run. New SPI `ChatQueueStore` (`enqueueMessage`, `listQueue`, …, `claimActiveStream`, `releaseActiveStream`, probed by `isChatQueueStore`), implemented by the Lucid store (new table `agent_queued_message`, new column `agent_thread.queue_pause` — both from `createAgentTables`; with `autoCreateTables: false`, add a migration that calls it again) and the in-memory store. `CHAT_QUEUE_STORE_CONTRACT` (`./testing`) checks a store of your own. A store without it keeps the old behaviour.
13
+ - **`AgentService.send()`** queues; **`AgentService.chat()` stays start-or-refuse** and now throws `ChatQueueError` (`409 run_active`) on a busy thread. `ChatQueueService` (drain/kick/launch/publish) is exported; `getQueue`, `updateQueuedMessage`, `removeQueuedMessage`, `clearQueue`, `resumeQueue` on the service.
14
+ - **Runners.** `AgentRunner.start(input, { runId? })` and optional `isRunActive(runId)`. Inline: drains before the stream ends; a cancelled loop now actually stops at its next safe point (new `AgentLoopHooks.cancelled`, `RunCancelledError`) instead of running on in the background, and a parked wait is unwound. Durable: the handoff is journaled (`queue:completed` / `queue:failed` behind `ctx.patched('agent:chat-queue')`, then `ctx.startChild` under the message id — a run in flight at upgrade replays the history it has); `cancel` now frees the thread, hands it to the queue and ends the stream with a `cancelled` frame; a thread turn stopped from outside no longer writes its late answer.
15
+ - **Client.** `@adonis-agora/agent/client` `send` returns `{ queued }` for a `202` (`AgentChatQueued`), takes `mode`; `./react`'s hook follows a queued send that started at once.
16
+
17
+ ## 0.49.0
18
+
19
+ ### Minor Changes
20
+
21
+ - [#222](https://github.com/DavideCarvalho/adonis-agora-agent/pull/222) [`241bfdc`](https://github.com/DavideCarvalho/adonis-agora-agent/commit/241bfdc298373052435890daa4513597e046377d) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - The chat contract `@dudousxd/nestjs-agent` settled (its `docs/stream-protocol.md`), ported:
22
+
23
+ - **Refusals answer `{ message, code? }` (BREAKING for readers of `error`).** Every refused request on the agent routes (and the dashboard's asset route) answers its status with `{ message, code? }` instead of `{ error }` — `code` where there is a machine-readable reason (`unauthorized`, `model_not_allowed`, `thread_required`, `quota_exceeded`). `@adonis-agora/agent/client` now throws a refused send as `AgentChatHttpError` (`status`, `code`, `body`, and the server's `message` as its own message) instead of a bare `Error('Failed to start agent chat (HTTP n).')`.
24
+ - **A send's `model` is that turn's only** — never stored on the thread; `PATCH threads/:id { model }` is the only pin (documented and tested; the service already behaved this way).
25
+ - **Regenerate.** `POST chat { threadId, regenerate: true }` answers the thread's last user message again: `400 thread_required` without a thread, `message` ignored, no user message stored, the answer(s) after it dropped (a new `regenerate:truncate` durable step — normal turns journal exactly what they did), a new run. `AgentRunInput.regenerate` replaces the unused `isRegenerate`; `RegenerateNeedsThreadError` is exported.
26
+ - **Model lock.** `ModelCatalogView.locked: { model, reason? }` runs every turn of that agent on `locked.model` and refuses a send naming another (`400 model_not_allowed`); `GET agents` entries carry `lockedModel` (`AgentCatalogEntry`). `listAgents(actor)` is now async.
27
+ - **Quota soft limit and USD-only windows.** `QuotaWindow.usedTokens` is optional; `QuotaWindow.warnAt`, `QuotaReport.warning: { period, ratio, reason? }` (never while blocked), `quotaWarning(windows)`, `quotaUsedRatio(window)`; `quota: { limits, warnAt }` stamps it on the ledger windows (`LedgerQuotaProvider`'s new `options.warnAt`). `exhaustedWindow` reads a missing `usedTokens` as `0`.
28
+ - **Who answered a question.** `POST tool-call/answer|skip` take `via?` (1–64 characters, `'web'` by default; `400` otherwise); the settled outcome carries `answeredBy` / `answeredVia` (`ElicitationReply.answeredVia`, `ElicitationOutcome.answeredBy|answeredVia`), streamed as the call's `tool-output` and persisted with its result.
29
+ - **`GET tools?agent=*`** (`ALL_AGENTS`) answers the union across every agent, each tool once, under the same gates; an unknown name is still `404`.
30
+ - **Frame ids.** `SseEvent.id` (the client parser now keeps the `id:` line). Ids stay strictly increasing within a run; readers must tolerate gaps.
31
+
3
32
  ## 0.48.0
4
33
 
5
34
  ### Minor Changes
@@ -1 +1 @@
1
- {"version":3,"file":"agent_provider.d.ts","sourceRoot":"","sources":["../../providers/agent_provider.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAkJ/D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,OAAO,OAAO,aAAa;;IAMpB,SAAS,CAAC,GAAG,EAAE,kBAAkB;IAA7C,YAAsB,GAAG,EAAE,kBAAkB,EAAI;IAEjD,QAAQ,SAcP;IAEK,IAAI,kBAiJT;IAEK,QAAQ,kBASb;CA6mCF"}
1
+ {"version":3,"file":"agent_provider.d.ts","sourceRoot":"","sources":["../../providers/agent_provider.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAC;AAiL/D;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,MAAM,CAAC,OAAO,OAAO,aAAa;;IAMpB,SAAS,CAAC,GAAG,EAAE,kBAAkB;IAA7C,YAAsB,GAAG,EAAE,kBAAkB,EAAI;IAEjD,QAAQ,SAcP;IAEK,IAAI,kBA+JT;IAEK,QAAQ,kBASb;CAmyCF"}
@@ -1,7 +1,16 @@
1
1
  import { pathToFileURL } from 'node:url';
2
- import { AgentDepsFactory, AgentGenui, AgentRegistry, AgentService, AgentSseEncoder, AnonymousActorResolver, AttachmentRefusedError, DefaultToolAuthorizer, discoverTools, evaluateGovernanceGate, evaluateOwnership, governanceQueries as governanceQueriesFactories, HumanReplyMismatchError, InlineAgentRunner, InProcessTokenStreamSink, isQuotaProvider, LedgerQuotaProvider, lucidStoreConnection, ModelNotAllowedError, memoryForgetVerdict, offerMemories, parseStreamCursor, pricingStores, QuotaBlockedError, REQUESTER_APPROVER, registerDelegateTools, registerFunctionalTool, registerToolsFromBarrel, resolveActorResolver, ToolRegistry, toApprovalPolicy, toModelCatalog, withActorLabel, withActorLabels, } from '../src/index.js';
2
+ import { AgentDepsFactory, AgentGenui, AgentRegistry, AgentService, AgentSseEncoder, ALL_AGENTS, AnonymousActorResolver, AttachmentRefusedError, ChatQueueError, ChatQueueService, DefaultToolAuthorizer, discoverTools, evaluateGovernanceGate, evaluateOwnership, governanceQueries as governanceQueriesFactories, HumanReplyMismatchError, InlineAgentRunner, InProcessTokenStreamSink, isQuotaProvider, LedgerQuotaProvider, lucidStoreConnection, ModelNotAllowedError, memoryForgetVerdict, offerMemories, parseStreamCursor, pricingStores, QuotaBlockedError, REQUESTER_APPROVER, RegenerateNeedsThreadError, registerDelegateTools, registerFunctionalTool, registerToolsFromBarrel, resolveActorResolver, ToolRegistry, toApprovalPolicy, toModelCatalog, withActorLabel, withActorLabels, } from '../src/index.js';
3
3
  import { McpToolImporter } from '../src/mcp-client/index.js';
4
4
  import { setTelescopeGovernanceQueries } from '../src/telescope/governance-registry.js';
5
+ /** A send's `mode`, or `null` when it names none of the three. `interrupt: true` is `'interrupt'`. */
6
+ function sendMode(body) {
7
+ if (body.mode === undefined) {
8
+ return body.interrupt === true ? 'interrupt' : 'auto';
9
+ }
10
+ return body.mode === 'auto' || body.mode === 'queue' || body.mode === 'interrupt'
11
+ ? body.mode
12
+ : null;
13
+ }
5
14
  /** The catalog a model provider carries (`aiSdkModels(…).catalog`), if it carries one. */
6
15
  function catalogOf(model) {
7
16
  const catalog = model?.catalog;
@@ -174,7 +183,7 @@ export default class AgentProvider {
174
183
  ? undefined
175
184
  : isQuotaProvider(config.quota)
176
185
  ? config.quota
177
- : new LedgerQuotaProvider(store, undefined, config.quota.limits);
186
+ : new LedgerQuotaProvider(store, undefined, config.quota.limits, config.quota.warnAt !== undefined ? { warnAt: config.quota.warnAt } : {});
178
187
  const pricingStore = await this.#resolvePricing(config);
179
188
  // Governance read-model is resolved after pricing so the Lucid read-model prices its rollups
180
189
  // against the same live prices the loop's cost fold uses.
@@ -227,11 +236,20 @@ export default class AgentProvider {
227
236
  });
228
237
  // `durable: true` runs each turn as a replay-safe `@adonis-agora/durable` workflow; it degrades
229
238
  // gracefully to the in-process runner when the durable peer isn't installed/configured.
239
+ // The thread message queue: a send on a thread whose turn is still running waits here and
240
+ // starts when that turn settles. One instance, shared by the service (which queues) and the
241
+ // runner (which drains); inert on a store that is not a `ChatQueueStore`.
242
+ const queue = new ChatQueueService(store, sink, {
243
+ ...(quotaProvider !== undefined ? { quota: quotaProvider } : {}),
244
+ ...(attachmentStaging !== undefined ? { attachments: attachmentStaging } : {}),
245
+ personaFor: (agentName) => service.resolvePersona(agentName),
246
+ });
230
247
  const runner = config.durable === true
231
- ? ((await this.#resolveDurableRunner(factory, store)) ??
232
- new InlineAgentRunner(factory, store))
233
- : new InlineAgentRunner(factory, store);
248
+ ? ((await this.#resolveDurableRunner(factory, store, queue, sink)) ??
249
+ new InlineAgentRunner(factory, store, queue))
250
+ : new InlineAgentRunner(factory, store, queue);
234
251
  const service = new AgentService(runner, store, factory, {
252
+ queue,
235
253
  // No `models` → the catalog the model provider carries (`aiSdkModels`), else none.
236
254
  ...(config.models !== undefined
237
255
  ? { models: toModelCatalog(config.models) }
@@ -379,14 +397,14 @@ export default class AgentProvider {
379
397
  * unresolvable engine, or any wiring error logs a warning and degrades to the in-process runner
380
398
  * rather than breaking boot.
381
399
  */
382
- async #resolveDurableRunner(factory, store) {
400
+ async #resolveDurableRunner(factory, store, queue, sink) {
383
401
  try {
384
402
  const durable = await import('@adonis-agora/durable');
385
403
  const engine = await this.app.container.make(durable.WorkflowEngine);
386
404
  const { DurableAgentRunner, registerAgentWorkflow, setDurableAgentContext } = await import('../src/durable/index.js');
387
- setDurableAgentContext({ factory, store });
405
+ setDurableAgentContext({ factory, store, queue });
388
406
  registerAgentWorkflow(engine);
389
- return new DurableAgentRunner(engine, store);
407
+ return new DurableAgentRunner(engine, store, queue, sink);
390
408
  }
391
409
  catch (error) {
392
410
  const message = error instanceof Error ? error.message : String(error);
@@ -441,15 +459,29 @@ export default class AgentProvider {
441
459
  if (!(await this.#assertOwner(ctx, actor, owner, 'thread', governanceAuthorize)))
442
460
  return;
443
461
  }
462
+ const regenerate = body.regenerate === true;
463
+ if (regenerate && body.threadId === undefined) {
464
+ return ctx.response.badRequest({
465
+ message: 'regenerate requires an existing threadId',
466
+ code: 'thread_required',
467
+ });
468
+ }
444
469
  const refs = attachmentRefs(body.attachments);
445
470
  if (typeof refs === 'string') {
446
- return ctx.response.badRequest({ error: refs });
471
+ return ctx.response.badRequest({ message: refs });
472
+ }
473
+ const mode = sendMode(body);
474
+ if (mode === null) {
475
+ return ctx.response.badRequest({ message: "mode must be 'auto', 'queue' or 'interrupt'" });
447
476
  }
448
477
  let started;
449
478
  try {
450
- started = await service.chat({
479
+ started = await service.send({
451
480
  actor,
452
- message: body.message,
481
+ mode,
482
+ // A regenerate answers the stored user message; whatever `message` says is ignored.
483
+ message: regenerate ? '' : body.message,
484
+ ...(regenerate ? { regenerate: true } : {}),
453
485
  ...(typeof body.model === 'string' && body.model.length > 0 ? { model: body.model } : {}),
454
486
  ...(body.threadId !== undefined ? { threadId: body.threadId } : {}),
455
487
  ...(body.agent !== undefined ? { agentName: body.agent } : {}),
@@ -460,10 +492,16 @@ export default class AgentProvider {
460
492
  }
461
493
  catch (error) {
462
494
  if (error instanceof AttachmentRefusedError) {
463
- return ctx.response.status(error.status).json({ error: error.message });
495
+ return ctx.response.status(error.status).json({ message: error.message });
464
496
  }
465
497
  if (error instanceof ModelNotAllowedError) {
466
- return ctx.response.badRequest({ error: error.message });
498
+ return ctx.response.badRequest({ message: error.message, code: 'model_not_allowed' });
499
+ }
500
+ if (error instanceof RegenerateNeedsThreadError) {
501
+ return ctx.response.badRequest({ message: error.message, code: 'thread_required' });
502
+ }
503
+ if (error instanceof ChatQueueError) {
504
+ return this.#refuseQueue(ctx, error);
467
505
  }
468
506
  if (error instanceof QuotaBlockedError) {
469
507
  return ctx.response.status(429).json({
@@ -474,6 +512,11 @@ export default class AgentProvider {
474
512
  }
475
513
  throw error;
476
514
  }
515
+ if (started.queued === true) {
516
+ // The thread already has a turn running: the message waits in its queue (`202`, JSON — no
517
+ // stream). `runId` is there when it started straight away; attach to it with `GET …/stream`.
518
+ return ctx.response.status(202).json(started);
519
+ }
477
520
  await this.#pipe(ctx, service, started.runId, started.threadId);
478
521
  });
479
522
  // 2. GET /agent/chat/:runId/stream — re-attach SSE. Authenticated: the actor resolver reads the
@@ -490,7 +533,7 @@ export default class AgentProvider {
490
533
  // Nothing buffered under the run — it ended while the client was away, or its buffer went with
491
534
  // a restarted process. A 404 reads as "nothing to resume"; subscribing would wait forever.
492
535
  if (!(await service.hasStream(runId))) {
493
- return ctx.response.notFound({ error: 'Nothing is streaming under that run.' });
536
+ return ctx.response.notFound({ message: 'Nothing is streaming under that run.' });
494
537
  }
495
538
  // `?after=<seq>` (or the `Last-Event-ID` a browser EventSource sends on its own) skips the
496
539
  // events a reconnecting client already has; `after` wins when both are present.
@@ -524,7 +567,7 @@ export default class AgentProvider {
524
567
  const via = decisionVia(body.via);
525
568
  if (remember === null || via === null) {
526
569
  return ctx.response.badRequest({
527
- error: `remember must be a boolean and via a string of 1-${MAX_VIA_LENGTH} characters`,
570
+ message: `remember must be a boolean and via a string of 1-${MAX_VIA_LENGTH} characters`,
528
571
  });
529
572
  }
530
573
  const runId = await this.#runOfToolCall(ctx, service, body);
@@ -544,7 +587,7 @@ export default class AgentProvider {
544
587
  const via = decisionVia(body.via);
545
588
  if (via === null) {
546
589
  return ctx.response.badRequest({
547
- error: `via must be a string of 1-${MAX_VIA_LENGTH} characters`,
590
+ message: `via must be a string of 1-${MAX_VIA_LENGTH} characters`,
548
591
  });
549
592
  }
550
593
  const runId = await this.#runOfToolCall(ctx, service, body);
@@ -563,6 +606,12 @@ export default class AgentProvider {
563
606
  if (actor === null)
564
607
  return;
565
608
  const body = (ctx.request.body() ?? {});
609
+ const via = decisionVia(body.via);
610
+ if (via === null) {
611
+ return ctx.response.badRequest({
612
+ message: `via must be a string of 1-${MAX_VIA_LENGTH} characters`,
613
+ });
614
+ }
566
615
  const runId = await this.#runOfToolCall(ctx, service, body);
567
616
  if (runId === null)
568
617
  return;
@@ -573,7 +622,7 @@ export default class AgentProvider {
573
622
  // the person who typed it hears why — rather than signalled and quietly dropped by the loop.
574
623
  const problem = await service.answerProblem(body.toolCallId, body.answers ?? {});
575
624
  if (problem !== null) {
576
- return ctx.response.badRequest({ error: problem });
625
+ return ctx.response.badRequest({ message: problem });
577
626
  }
578
627
  try {
579
628
  await service.answer({
@@ -581,6 +630,7 @@ export default class AgentProvider {
581
630
  toolCallId: body.toolCallId,
582
631
  answers: body.answers ?? {},
583
632
  answeredByRef: actor.id,
633
+ answeredVia: via,
584
634
  });
585
635
  }
586
636
  catch (error) {
@@ -595,6 +645,12 @@ export default class AgentProvider {
595
645
  if (actor === null)
596
646
  return;
597
647
  const body = (ctx.request.body() ?? {});
648
+ const via = decisionVia(body.via);
649
+ if (via === null) {
650
+ return ctx.response.badRequest({
651
+ message: `via must be a string of 1-${MAX_VIA_LENGTH} characters`,
652
+ });
653
+ }
598
654
  const runId = await this.#runOfToolCall(ctx, service, body);
599
655
  if (runId === null)
600
656
  return;
@@ -606,6 +662,7 @@ export default class AgentProvider {
606
662
  runId,
607
663
  toolCallId: body.toolCallId,
608
664
  answeredByRef: actor.id,
665
+ answeredVia: via,
609
666
  });
610
667
  }
611
668
  catch (error) {
@@ -630,11 +687,11 @@ export default class AgentProvider {
630
687
  const threadId = await service.threadOfMessage(messageId);
631
688
  if (threadId === undefined) {
632
689
  return ctx.response.status(501).json({
633
- error: 'Message feedback requires an AgentStore that implements threadOfMessage() and setMessageFeedback().',
690
+ message: 'Message feedback requires an AgentStore that implements threadOfMessage() and setMessageFeedback().',
634
691
  });
635
692
  }
636
693
  if (threadId === null) {
637
- return ctx.response.notFound({ error: 'message not found' });
694
+ return ctx.response.notFound({ message: 'message not found' });
638
695
  }
639
696
  const owner = await service.threadOwner(threadId);
640
697
  if (!(await this.#assertOwner(ctx, actor, owner, 'thread', undefined)))
@@ -645,7 +702,7 @@ export default class AgentProvider {
645
702
  ...(body.comment !== undefined ? { comment: body.comment } : {}),
646
703
  });
647
704
  if (!result.ok) {
648
- return ctx.response.status(result.status).json({ error: result.error });
705
+ return ctx.response.status(result.status).json({ message: result.error });
649
706
  }
650
707
  return ctx.response.json({ feedback: result.feedback });
651
708
  });
@@ -663,16 +720,23 @@ export default class AgentProvider {
663
720
  const actor = await this.#resolveActor(ctx, actorResolver);
664
721
  if (actor === null)
665
722
  return;
666
- return ctx.response.json(service.listAgents());
723
+ return ctx.response.json(await service.listAgents(actor));
667
724
  });
668
725
  // 6b. GET /agent/tools?agent= — the tools this caller can reach through an agent, with how a chat
669
726
  // surface should talk about each (`presentation`). The same list the model is offered; nothing the
670
727
  // caller passes widens it — `agent` only narrows to that agent's allow-list, and an unknown name is
671
- // a 404 rather than the widest answer (no allow-list at all).
728
+ // a 404 rather than the widest answer (no allow-list at all). `?agent=*` is the union across every
729
+ // agent: each tool this caller reaches through any of them, once, under the same gates.
672
730
  router.get(p('tools'), async (ctx) => {
673
731
  const agent = ctx.request.qs().agent;
732
+ if (agent === ALL_AGENTS) {
733
+ const actor = await this.#resolveActor(ctx, actorResolver);
734
+ if (actor === null)
735
+ return;
736
+ return ctx.response.json(await service.toolCatalogForAllAgents(actor));
737
+ }
674
738
  if (agent !== undefined && agent !== defaultAgentName && agents.get(agent) === undefined) {
675
- return ctx.response.notFound({ error: `No agent named "${agent}"` });
739
+ return ctx.response.notFound({ message: `No agent named "${agent}"` });
676
740
  }
677
741
  const actor = await this.#resolveActor(ctx, resolveActorResolver(actorResolver, agents.get(agent ?? defaultAgentName)));
678
742
  if (actor === null)
@@ -712,20 +776,20 @@ export default class AgentProvider {
712
776
  return;
713
777
  const body = (ctx.request.body() ?? {});
714
778
  if (body.model !== undefined && body.model !== null && typeof body.model !== 'string') {
715
- return ctx.response.badRequest({ error: 'model must be a string or null' });
779
+ return ctx.response.badRequest({ message: 'model must be a string or null' });
716
780
  }
717
781
  if (body.model !== undefined) {
718
782
  try {
719
783
  const pinned = await service.setThreadModel(actor, threadId, body.model);
720
784
  if (!pinned) {
721
785
  return ctx.response.status(501).json({
722
- error: "Pinning a thread's model requires an AgentStore that implements updateThread().",
786
+ message: "Pinning a thread's model requires an AgentStore that implements updateThread().",
723
787
  });
724
788
  }
725
789
  }
726
790
  catch (error) {
727
791
  if (error instanceof ModelNotAllowedError) {
728
- return ctx.response.badRequest({ error: error.message });
792
+ return ctx.response.badRequest({ message: error.message });
729
793
  }
730
794
  throw error;
731
795
  }
@@ -733,12 +797,68 @@ export default class AgentProvider {
733
797
  if (body.title !== undefined) {
734
798
  const title = typeof body.title === 'string' ? body.title.trim() : '';
735
799
  if (title.length === 0 || title.length > 200) {
736
- return ctx.response.badRequest({ error: 'title must be a string of 1-200 characters' });
800
+ return ctx.response.badRequest({ message: 'title must be a string of 1-200 characters' });
737
801
  }
738
802
  await service.renameThread(threadId, title);
739
803
  }
740
804
  return ctx.response.json({ ok: true });
741
805
  });
806
+ // 8d. The thread message queue — messages sent while a turn was running, waiting to run after
807
+ // it. Owner-scoped like the thread routes; each answers the queue (`{ items, paused }`) and
808
+ // publishes it into the stream of the run holding the thread. `501` on a store without a queue.
809
+ router.get(p('threads/:id/queue'), async (ctx) => {
810
+ const threadId = await this.#ownedThread(ctx, service, actorResolver, governanceAuthorize);
811
+ if (threadId === null)
812
+ return;
813
+ return this.#queueAnswer(ctx, () => service.getQueue(threadId));
814
+ });
815
+ router.delete(p('threads/:id/queue'), async (ctx) => {
816
+ const threadId = await this.#ownedThread(ctx, service, actorResolver, governanceAuthorize);
817
+ if (threadId === null)
818
+ return;
819
+ return this.#queueAnswer(ctx, () => service.clearQueue(threadId));
820
+ });
821
+ router.post(p('threads/:id/queue/resume'), async (ctx) => {
822
+ const threadId = await this.#ownedThread(ctx, service, actorResolver, governanceAuthorize);
823
+ if (threadId === null)
824
+ return;
825
+ return this.#queueAnswer(ctx, () => service.resumeQueue(threadId));
826
+ });
827
+ router.patch(p('queue/:messageId'), async (ctx) => {
828
+ const actor = await this.#resolveActor(ctx, actorResolver);
829
+ if (actor === null)
830
+ return;
831
+ const messageId = String(ctx.params.messageId);
832
+ if (!(await this.#ownsQueuedMessage(ctx, service, actor, messageId, governanceAuthorize)))
833
+ return;
834
+ const body = (ctx.request.body() ?? {});
835
+ if (body.message !== undefined && typeof body.message !== 'string') {
836
+ return ctx.response.badRequest({ message: 'message must be a string' });
837
+ }
838
+ if (body.position !== undefined && typeof body.position !== 'number') {
839
+ return ctx.response.badRequest({ message: 'position must be a non-negative integer' });
840
+ }
841
+ const refs = body.attachments === undefined || body.attachments === null
842
+ ? body.attachments
843
+ : attachmentRefs(body.attachments);
844
+ if (typeof refs === 'string') {
845
+ return ctx.response.badRequest({ message: refs });
846
+ }
847
+ return this.#queueAnswer(ctx, () => service.updateQueuedMessage(actor, messageId, {
848
+ ...(typeof body.message === 'string' ? { message: body.message } : {}),
849
+ ...(refs !== undefined ? { attachments: refs } : {}),
850
+ ...(typeof body.position === 'number' ? { position: body.position } : {}),
851
+ }));
852
+ });
853
+ router.delete(p('queue/:messageId'), async (ctx) => {
854
+ const actor = await this.#resolveActor(ctx, actorResolver);
855
+ if (actor === null)
856
+ return;
857
+ const messageId = String(ctx.params.messageId);
858
+ if (!(await this.#ownsQueuedMessage(ctx, service, actor, messageId, governanceAuthorize)))
859
+ return;
860
+ return this.#queueAnswer(ctx, () => service.removeQueuedMessage(messageId));
861
+ });
742
862
  // 9. DELETE /agent/threads/:id. Authenticated + owner-scoped.
743
863
  router.delete(p('threads/:id'), async (ctx) => {
744
864
  const actor = await this.#resolveActor(ctx, actorResolver);
@@ -798,16 +918,16 @@ export default class AgentProvider {
798
918
  if (actor === null)
799
919
  return;
800
920
  if (memory === undefined) {
801
- return ctx.response.notFound({ error: 'No memory is configured in this deployment.' });
921
+ return ctx.response.notFound({ message: 'No memory is configured in this deployment.' });
802
922
  }
803
923
  const id = String(ctx.params.id);
804
924
  const entry = (await everyMemoryOf(actor)).entries.find((candidate) => candidate.id === id);
805
925
  if (entry === undefined) {
806
- return ctx.response.notFound({ error: `No memory with id "${id}".` });
926
+ return ctx.response.notFound({ message: `No memory with id "${id}".` });
807
927
  }
808
928
  const verdict = memoryForgetVerdict({ record: entry, actor });
809
929
  if (!verdict.allowed) {
810
- return ctx.response.forbidden({ error: verdict.reason });
930
+ return ctx.response.forbidden({ message: verdict.reason });
811
931
  }
812
932
  return ctx.response.json({
813
933
  forgotten: await memory.provider.forget({ id: entry.id, ctx: { actor, threadId: '' } }),
@@ -856,19 +976,19 @@ export default class AgentProvider {
856
976
  return;
857
977
  const file = ctx.request.file('file');
858
978
  if (file === null || file.tmpPath === undefined) {
859
- return ctx.response.status(400).json({ error: 'multipart field "file" is required' });
979
+ return ctx.response.status(400).json({ message: 'multipart field "file" is required' });
860
980
  }
861
981
  const contentType = file.headers?.['content-type']?.split(';')[0]?.trim() ?? `${file.type}/${file.subtype}`;
862
982
  if (!allowedContentTypes.includes(contentType)) {
863
983
  return ctx.response.status(415).json({
864
- error: `content type "${contentType}" is not allowed (allowed: ${allowedContentTypes.join(', ')})`,
984
+ message: `content type "${contentType}" is not allowed (allowed: ${allowedContentTypes.join(', ')})`,
865
985
  });
866
986
  }
867
987
  const sizeBytes = file.size;
868
988
  if (sizeBytes > maxBytes) {
869
989
  return ctx.response
870
990
  .status(413)
871
- .json({ error: `file exceeds the ${maxBytes}-byte limit` });
991
+ .json({ message: `file exceeds the ${maxBytes}-byte limit` });
872
992
  }
873
993
  const { readFile } = await import('node:fs/promises');
874
994
  const data = await readFile(file.tmpPath);
@@ -985,7 +1105,7 @@ export default class AgentProvider {
985
1105
  if (gov.threadDetail === undefined) {
986
1106
  return ctx.response
987
1107
  .status(501)
988
- .json({ error: 'this governance adapter does not support thread detail' });
1108
+ .json({ message: 'this governance adapter does not support thread detail' });
989
1109
  }
990
1110
  const detail = await withActorLabel(await gov.threadDetail(String(ctx.params.id)), actorDirectory);
991
1111
  if (detail === null)
@@ -1101,7 +1221,7 @@ export default class AgentProvider {
1101
1221
  typeof body.inputPricePer1m !== 'number' ||
1102
1222
  typeof body.outputPricePer1m !== 'number') {
1103
1223
  return ctx.response.status(400).json({
1104
- error: 'body must be {modelId: string, inputPricePer1m: number, outputPricePer1m: number, cacheWritePricePer1m?: number, cacheReadPricePer1m?: number}',
1224
+ message: 'body must be {modelId: string, inputPricePer1m: number, outputPricePer1m: number, cacheWritePricePer1m?: number, cacheReadPricePer1m?: number}',
1105
1225
  });
1106
1226
  }
1107
1227
  await pricing.upsertModelPrice({
@@ -1130,7 +1250,7 @@ export default class AgentProvider {
1130
1250
  try {
1131
1251
  const actor = await actorResolver.resolve(ctx);
1132
1252
  if (actor === null || actor === undefined) {
1133
- ctx.response.status(401).json({ error: 'unauthorized' });
1253
+ ctx.response.status(401).json({ message: 'unauthorized', code: 'unauthorized' });
1134
1254
  return null;
1135
1255
  }
1136
1256
  return actor;
@@ -1140,9 +1260,10 @@ export default class AgentProvider {
1140
1260
  // in production, every request here is untrusted by definition, so the detail stays server-side
1141
1261
  // (dev/test keeps it, matching `evaluateDashboardGate`/`evaluateGovernanceGate`'s `debug` knob).
1142
1262
  const debug = !this.app.inProduction;
1143
- ctx.response
1144
- .status(401)
1145
- .json({ error: debug && error instanceof Error ? error.message : 'unauthorized' });
1263
+ ctx.response.status(401).json({
1264
+ message: debug && error instanceof Error ? error.message : 'unauthorized',
1265
+ code: 'unauthorized',
1266
+ });
1146
1267
  return null;
1147
1268
  }
1148
1269
  }
@@ -1158,7 +1279,7 @@ export default class AgentProvider {
1158
1279
  return null;
1159
1280
  const verdict = await evaluateGovernanceGate(actor, ctx, governanceAuthorize, !this.app.inProduction);
1160
1281
  if (!verdict.ok) {
1161
- ctx.response.status(verdict.status).json({ error: verdict.error });
1282
+ ctx.response.status(verdict.status).json({ message: verdict.error });
1162
1283
  return null;
1163
1284
  }
1164
1285
  return actor;
@@ -1180,7 +1301,7 @@ export default class AgentProvider {
1180
1301
  const verdict = evaluateOwnership(actor.id, ownerRef, privileged);
1181
1302
  if (!verdict.ok) {
1182
1303
  const error = verdict.status === 404 ? `${kind} not found` : 'forbidden';
1183
- ctx.response.status(verdict.status).json({ error });
1304
+ ctx.response.status(verdict.status).json({ message: error });
1184
1305
  return false;
1185
1306
  }
1186
1307
  return true;
@@ -1201,7 +1322,7 @@ export default class AgentProvider {
1201
1322
  return body.runId;
1202
1323
  const runId = typeof body.toolCallId === 'string' ? await service.toolCallRun(body.toolCallId) : null;
1203
1324
  if (runId === null) {
1204
- ctx.response.notFound({ error: 'Unknown tool call.' });
1325
+ ctx.response.notFound({ message: 'Unknown tool call.' });
1205
1326
  return null;
1206
1327
  }
1207
1328
  return runId;
@@ -1225,11 +1346,11 @@ export default class AgentProvider {
1225
1346
  }
1226
1347
  else {
1227
1348
  if (owner === null) {
1228
- ctx.response.notFound({ error: 'Unknown run.' });
1349
+ ctx.response.notFound({ message: 'Unknown run.' });
1229
1350
  return false;
1230
1351
  }
1231
1352
  if (!(await service.mayDecide(actor, { toolCallId, approver, requesterRef: owner }))) {
1232
- ctx.response.forbidden({ error: `This approval is for ${approver}.` });
1353
+ ctx.response.forbidden({ message: `This approval is for ${approver}.` });
1233
1354
  return false;
1234
1355
  }
1235
1356
  }
@@ -1239,16 +1360,74 @@ export default class AgentProvider {
1239
1360
  approval.expiresAt !== null &&
1240
1361
  Date.parse(approval.expiresAt) <= Date.now()));
1241
1362
  if (lapsed) {
1242
- ctx.response.gone({ error: `The approval request for tool call ${toolCallId} has expired.` });
1363
+ ctx.response.gone({
1364
+ message: `The approval request for tool call ${toolCallId} has expired.`,
1365
+ });
1243
1366
  return false;
1244
1367
  }
1245
1368
  return true;
1246
1369
  }
1370
+ /** A refused queue request: the error's own status, as `{ message, code? }`. */
1371
+ #refuseQueue(ctx, error) {
1372
+ ctx.response
1373
+ .status(error.status)
1374
+ .json({ message: error.message, ...(error.code !== undefined ? { code: error.code } : {}) });
1375
+ }
1376
+ /** Answer a queue route: the service's result, or the refusal it threw. */
1377
+ async #queueAnswer(ctx, work) {
1378
+ try {
1379
+ ctx.response.json(await work());
1380
+ }
1381
+ catch (error) {
1382
+ if (error instanceof ChatQueueError) {
1383
+ return this.#refuseQueue(ctx, error);
1384
+ }
1385
+ if (error instanceof AttachmentRefusedError) {
1386
+ ctx.response.status(error.status).json({ message: error.message });
1387
+ return;
1388
+ }
1389
+ throw error;
1390
+ }
1391
+ }
1392
+ /** The `:id` thread once the caller is shown to own it; `null` after replying 401/403/404. */
1393
+ async #ownedThread(ctx, service, actorResolver, governanceAuthorize) {
1394
+ const actor = await this.#resolveActor(ctx, actorResolver);
1395
+ if (actor === null)
1396
+ return null;
1397
+ const threadId = String(ctx.params.id);
1398
+ const owner = await service.threadOwner(threadId);
1399
+ return (await this.#assertOwner(ctx, actor, owner, 'thread', governanceAuthorize))
1400
+ ? threadId
1401
+ : null;
1402
+ }
1403
+ /**
1404
+ * Does `actor` own the thread a queued message waits on? Replies `404` for a message that does
1405
+ * not exist (or a store without a queue answers `501`), `403` for another actor's.
1406
+ */
1407
+ async #ownsQueuedMessage(ctx, service, actor, messageId, governanceAuthorize) {
1408
+ let threadId;
1409
+ try {
1410
+ threadId = await service.queuedMessageThread(messageId);
1411
+ }
1412
+ catch (error) {
1413
+ if (error instanceof ChatQueueError) {
1414
+ this.#refuseQueue(ctx, error);
1415
+ return false;
1416
+ }
1417
+ throw error;
1418
+ }
1419
+ if (threadId === null) {
1420
+ ctx.response.notFound({ message: `queued message ${messageId} not found` });
1421
+ return false;
1422
+ }
1423
+ const owner = await service.threadOwner(threadId);
1424
+ return this.#assertOwner(ctx, actor, owner, 'thread', governanceAuthorize);
1425
+ }
1247
1426
  #conflictOnMismatch(ctx, error) {
1248
1427
  if (!(error instanceof HumanReplyMismatchError)) {
1249
1428
  throw error;
1250
1429
  }
1251
- ctx.response.conflict({ error: error.message });
1430
+ ctx.response.conflict({ message: error.message });
1252
1431
  }
1253
1432
  /**
1254
1433
  * Pipe the run's live token stream to the client as SSE: `event: meta` (runId/threadId), the run's