@lunora/agent 1.0.0-alpha.9 → 1.0.0-alpha.90

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 (87) hide show
  1. package/dist/channels.d.mts +3 -11
  2. package/dist/channels.d.ts +3 -11
  3. package/dist/channels.mjs +1 -181
  4. package/dist/component.d.mts +2 -0
  5. package/dist/component.d.ts +2 -0
  6. package/dist/component.mjs +2 -418
  7. package/dist/inbound.d.mts +7 -5
  8. package/dist/inbound.d.ts +7 -5
  9. package/dist/inbound.mjs +1 -32
  10. package/dist/index.d.mts +49 -8
  11. package/dist/index.d.ts +49 -8
  12. package/dist/index.mjs +1 -18
  13. package/dist/naming.mjs +1 -8
  14. package/dist/packem_shared/AGENT_MODULE-M4D1EejI.mjs +1 -0
  15. package/dist/packem_shared/VoiceSessionDO-DJ0ka5vr.mjs +1 -0
  16. package/dist/packem_shared/adaptMcpResult-CVlq-_TO.mjs +2 -0
  17. package/dist/packem_shared/agent-loop-Cotwgugs.mjs +3 -0
  18. package/dist/packem_shared/agentAsTool-CgZb6ycK.mjs +1 -0
  19. package/dist/packem_shared/base64-5eyBfWO3.mjs +1 -0
  20. package/dist/packem_shared/braintrustTelemetry-CoYK-pq7.mjs +1 -0
  21. package/dist/packem_shared/branch-marker-boZ00zmk.mjs +1 -0
  22. package/dist/packem_shared/buildModelMessages-Y4tGo1T9.mjs +5 -0
  23. package/dist/packem_shared/codeTool-BQb-K3my.mjs +1 -0
  24. package/dist/packem_shared/collectAgenticMemoryTools-BeMWT2qt.mjs +1 -0
  25. package/dist/packem_shared/combineTelemetry-DgE9W9G8.mjs +1 -0
  26. package/dist/packem_shared/common-CJSjtsfv.mjs +1 -0
  27. package/dist/packem_shared/compileAgentWorkflow-8pHFPjec.mjs +1 -0
  28. package/dist/packem_shared/component-shared-G8ngerkU.mjs +1 -0
  29. package/dist/packem_shared/consoleTelemetry-BZY3Y-Ly.mjs +1 -0
  30. package/dist/packem_shared/createAgentContext-Ds2VaveB.mjs +1 -0
  31. package/dist/packem_shared/createAgentGenerate-BOILDUZj.mjs +3 -0
  32. package/dist/packem_shared/createDispatchRunner-BrbbxS2q-DBBqImoR.mjs +1 -0
  33. package/dist/packem_shared/defineAgent-DM0fNEY6.mjs +3 -0
  34. package/dist/packem_shared/defineSkill-DGGvDWNq.mjs +1 -0
  35. package/dist/packem_shared/fnv1a-BNN96GYb.mjs +1 -0
  36. package/dist/packem_shared/functionTool-D3CP4f8-.mjs +1 -0
  37. package/dist/packem_shared/normalizeEntityName-k3fjicAG.mjs +2 -0
  38. package/dist/packem_shared/otlpTelemetry-CywiFp-X.mjs +1 -0
  39. package/dist/packem_shared/positive-integer-ztHqqpBk.mjs +1 -0
  40. package/dist/packem_shared/runAgentLoop-Dmq-FWJk.mjs +1 -0
  41. package/dist/packem_shared/runVoiceTurn-DGD90HuL.mjs +1 -0
  42. package/dist/packem_shared/sandboxComponent-BtOkUYpY.mjs +5 -0
  43. package/dist/packem_shared/sentryTelemetry-CPqEbFPA.mjs +1 -0
  44. package/dist/packem_shared/tool-output-Cmk3JWMG.mjs +1 -0
  45. package/dist/packem_shared/{types.d-boAM2Yi1.d.mts → types.d-B8WZl1rU.d.mts} +255 -22
  46. package/dist/packem_shared/{types.d-boAM2Yi1.d.ts → types.d-B8WZl1rU.d.ts} +255 -22
  47. package/dist/packem_shared/voice-turn-B9dc3mvG.mjs +1 -0
  48. package/dist/reply.d.mts +40 -0
  49. package/dist/reply.d.ts +40 -0
  50. package/dist/reply.mjs +1 -0
  51. package/dist/sandbox.d.mts +14 -13
  52. package/dist/sandbox.d.ts +14 -13
  53. package/dist/sandbox.mjs +1 -113
  54. package/dist/skill-markdown.d.mts +36 -0
  55. package/dist/skill-markdown.d.ts +36 -0
  56. package/dist/skill-markdown.mjs +1 -0
  57. package/dist/telemetry/index.d.mts +55 -1
  58. package/dist/telemetry/index.d.ts +55 -1
  59. package/dist/telemetry/index.mjs +1 -5
  60. package/package.json +18 -9
  61. package/dist/packem_shared/AGENT_MODULE-Dnt_-AAT.mjs +0 -24
  62. package/dist/packem_shared/VoiceSessionDO-BdwlLaXC.mjs +0 -297
  63. package/dist/packem_shared/adaptMcpResult-wtNMvLoP.mjs +0 -65
  64. package/dist/packem_shared/agentAsTool-CUHlWsmt.mjs +0 -98
  65. package/dist/packem_shared/base64-BVwtgRJV.mjs +0 -18
  66. package/dist/packem_shared/braintrustTelemetry-TP7Kwuuj.mjs +0 -47
  67. package/dist/packem_shared/buildModelMessages-BWFigaoo.mjs +0 -69
  68. package/dist/packem_shared/codeTool-CjgJOC9t.mjs +0 -122
  69. package/dist/packem_shared/collectAgenticMemoryTools-QrzpV-WX.mjs +0 -97
  70. package/dist/packem_shared/combineTelemetry-DCyaaWAI.mjs +0 -43
  71. package/dist/packem_shared/common-DQXayow6.mjs +0 -89
  72. package/dist/packem_shared/compileAgentWorkflow-DAfyUuI5.mjs +0 -78
  73. package/dist/packem_shared/consoleTelemetry--3sWfu1R.mjs +0 -93
  74. package/dist/packem_shared/createAgentContext-4xJGXNR4.mjs +0 -50
  75. package/dist/packem_shared/createAgentGenerate-DO7Z96zX.mjs +0 -192
  76. package/dist/packem_shared/createDispatchRunner-DSbp_dph-ZHTtxy3f.mjs +0 -69
  77. package/dist/packem_shared/defineAgent-DAwAZC9P.mjs +0 -148
  78. package/dist/packem_shared/defineSkill-Ctf_S-rz.mjs +0 -22
  79. package/dist/packem_shared/functionTool-D6lCa2jB.mjs +0 -20
  80. package/dist/packem_shared/graph-component-Bbaxxymp.mjs +0 -217
  81. package/dist/packem_shared/memory-D4FPcBsX.mjs +0 -12
  82. package/dist/packem_shared/normalizeEntityName-BouctxLC.mjs +0 -3
  83. package/dist/packem_shared/otlpTelemetry-CKgmWVLg.mjs +0 -170
  84. package/dist/packem_shared/runAgentLoop-M8PKbtWT.mjs +0 -493
  85. package/dist/packem_shared/runVoiceTurn-LnqLvCRR.mjs +0 -211
  86. package/dist/packem_shared/sandboxComponent-DR3pTwBL.mjs +0 -194
  87. package/dist/packem_shared/sentryTelemetry-A4F5ndh9.mjs +0 -36
@@ -23,7 +23,7 @@ interface AgentStepLike {
23
23
  /**
24
24
  * Durably hibernate until an external event of `type` arrives, then return
25
25
  * its payload. Used for human-in-the-loop approvals: a run pauses on
26
- * `approval:<toolCallId>` until a client resolves it. Like `do`, a resolved
26
+ * `approval:<toolCallId>` until a client resolves it. Like `do`, a resolved
27
27
  * wait is memoized — a replay returns the recorded decision without pausing
28
28
  * again. Signature mirrors `@lunora/workflow`'s `WorkflowStepLike`.
29
29
  */
@@ -42,13 +42,26 @@ interface AgentStepLike {
42
42
  * `idempotencyKey`.
43
43
  *
44
44
  * The `idempotencyKey` is the deterministic durable-step name
45
- * (`tool:&lt;name>:&lt;toolCallId>`). A COMPLETED tool step is never re-run on a
46
- * workflow replay (native step memoization) but a step that FAILS mid-body
47
- * is retried at-least-once, so a side-effecting tool (charge a card, send a
48
- * mail) must dedupe on this key itself.
45
+ * (`tool:<name>:<toolCallId>`, further suffixed per script step for a
46
+ * `codeTool`). A COMPLETED tool step is never re-run on a workflow replay
47
+ * (native step memoization) but a step that FAILS mid-body is retried
48
+ * at-least-once, so a side-effecting tool (charge a card, send a mail) must
49
+ * dedupe on this key itself. `functionTool` forwards it to the dispatched
50
+ * function as `args.idempotencyKey` (pinned after the model input, so it
51
+ * can't be overridden) — a function that wants to dedupe declares
52
+ * `idempotencyKey: v.optional(v.string())` in its own args and checks it; a
53
+ * function that ignores it is unaffected (an undeclared arg field is dropped,
54
+ * not rejected).
49
55
  * @experimental
50
56
  */
51
57
  interface AgentToolContext {
58
+ /**
59
+ * How many sub-agent delegations deep this run already is — 0 for a run a
60
+ * user started, one more per `agent.asTool` hop. The loop copies it off
61
+ * {@link AgentRunInput.depth}; `agent.asTool` reads it to refuse spawning a
62
+ * child past the delegation-depth bound (see `as-tool.ts`). Absent is 0.
63
+ */
64
+ depth?: number;
52
65
  /** The Worker environment bindings. */
53
66
  env: Record<string, unknown>;
54
67
  /**
@@ -67,6 +80,14 @@ interface AgentToolContext {
67
80
  getState: () => Promise<Record<string, unknown> | undefined>;
68
81
  /** Deterministic idempotency key — the tool's durable-step name. */
69
82
  idempotencyKey: string;
83
+ /**
84
+ * Verified owner of the thread this run belongs to (the loop copies it off
85
+ * {@link AgentRunInput.owner}); `undefined` on an anonymous/single-tenant
86
+ * thread. `agent.asTool` forwards it to the CHILD run, so a sub-agent thread
87
+ * under an owned parent inherits the same RLS scope instead of being created
88
+ * ownerless — which left it readable by anyone who knew its key.
89
+ */
90
+ owner?: string;
70
91
  /**
71
92
  * Emit an EPHEMERAL progress event for this tool call on the agent's live
72
93
  * channel — the same live-only sink the streamed token deltas ride. NOT
@@ -102,11 +123,45 @@ interface AgentToolContext {
102
123
  * skip the write when it is already present.
103
124
  */
104
125
  setState: (state: Record<string, unknown>) => Promise<void>;
126
+ /**
127
+ * The durable-step handle (`step.do`/`waitForEvent`). ALWAYS present:
128
+ * `agent-loop.ts` threads it into every tool's context unconditionally, and
129
+ * it is required rather than optional so a missing handle is a compile
130
+ * error instead of a silent durability downgrade. `codeTool` uses it to give
131
+ * each script step its OWN nested durable boundary — see `code-tool.ts` — so
132
+ * a failure at script step 3 retries only step 3, not steps 1–2's
133
+ * already-committed side effects. Cloudflare Workflows supports a `step.do`
134
+ * nested inside another `step.do`'s callback (the codeTool call's own
135
+ * enclosing step). Most tools never touch this directly; a test driving
136
+ * `execute`/`runToolScript` by hand passes a pass-through double.
137
+ */
138
+ step: AgentStepLike;
105
139
  /** The thread this tool call belongs to. */
106
140
  threadKey: string;
107
141
  /** The provider-issued tool-call id. */
108
142
  toolCallId: string;
109
143
  }
144
+ /**
145
+ * The view of {@link AgentToolContext} handed to a `needsApproval` gate
146
+ * function — every field except `setState`, `step`, and `reportProgress`.
147
+ *
148
+ * `setState` is dropped because a gate that mutates thread state is a side
149
+ * effect inside a decision predicate, and `reportProgress` because emitting a
150
+ * live event is the same thing in observable form — the decision is what the
151
+ * loop reports, not the deciding. `step` goes because the loop already runs the
152
+ * gate inside a durable step of its own, so a gate has no business opening
153
+ * another.
154
+ *
155
+ * `getState` and `run` stay: reads are legitimate gate inputs (gate on the
156
+ * caller's plan tier, on a spend total), and the gate resolves inside its own
157
+ * durable step, so they are replay-safe there. Note this makes the type a
158
+ * NARROWING, not a proof of purity — `run` takes any
159
+ * {@link AgentFunctionReference}, so a gate can still dispatch a mutation
160
+ * before approval. Nothing in the type system distinguishes a query reference
161
+ * from a mutation one; keeping the gate side-effect-free is the author's.
162
+ * @experimental
163
+ */
164
+ type AgentApprovalContext = Omit<AgentToolContext, "reportProgress" | "setState" | "step">;
110
165
  /**
111
166
  * An agent tool. Unlike a raw AI SDK tool, `execute` is NOT handed to the
112
167
  * model call — the loop runs it itself inside a named durable step so a
@@ -127,17 +182,25 @@ interface AgentToolDefinition<Input = unknown, Output = unknown> {
127
182
  * Gate the tool behind a human approval (mirrors the AI SDK's
128
183
  * `needsApproval`). When it resolves truthy the durable run PAUSES — the
129
184
  * thread moves to `"awaiting_input"` and the workflow hibernates on
130
- * `approval:&lt;toolCallId>` — until a client calls `agents:agentResolveApproval`.
185
+ * `approval:<toolCallId>` — until a client calls `agents:agentResolveApproval`.
131
186
  * On approve the tool runs exactly as normal; on reject it is skipped and a
132
187
  * tool result explaining the rejection is persisted so the next turn recovers.
133
188
  * A boolean gates statically; a function gates per input. Default: `false`
134
- * (unchanged behavior). Evaluated from replay-stable input OUTSIDE the
135
- * durable step (it re-runs on every replay), so it must be a PURE predicate:
136
- * deterministic (no `Date.now()`/`Math.random()`) and free of side effects —
137
- * never call {@link AgentToolContext.setState}/`getState` or a mutating `run`
138
- * here; state writes belong only in `execute`, inside the memoized step.
189
+ * (unchanged behavior).
190
+ *
191
+ * The boolean/`undefined` forms are compile-time constants re-derived
192
+ * identically on every replay — no durable step. The FUNCTION form runs
193
+ * inside its OWN durable step (`tool:approval-gate:<toolCallId>`, distinct
194
+ * from the tool's own step), so it now runs exactly once per call, not once
195
+ * per replay. It must still be otherwise pure: deterministic given its
196
+ * inputs (no `Date.now()`/`Math.random()`) and free of side effects — the
197
+ * context it receives is {@link AgentApprovalContext}, which has neither
198
+ * `setState` nor `reportProgress`; state writes and progress events belong
199
+ * only in `execute`, inside the tool's own memoized step. It still holds
200
+ * `run`, which the type cannot narrow to reads — see
201
+ * {@link AgentApprovalContext}.
139
202
  */
140
- needsApproval?: ((input: Input, context: AgentToolContext) => boolean | Promise<boolean>) | boolean;
203
+ needsApproval?: ((input: Input, context: AgentApprovalContext) => boolean | Promise<boolean>) | boolean;
141
204
  }
142
205
  /**
143
206
  * Author-supplied tool config (see {@link AgentToolDefinition}).
@@ -252,7 +315,7 @@ interface AgentMemoryOptions {
252
315
  * (`key: "default"`) from {@link AgentConfig.memory}, then one per skill that
253
316
  * carries `knowledge` (keyed by the skill's name). The key names the durable
254
317
  * step — the default source keeps the historic `"memory:retrieve"`, a skill
255
- * source uses `"memory:retrieve:&lt;key>"` — so replay stays deterministic.
318
+ * source uses `"memory:retrieve:<key>"` — so replay stays deterministic.
256
319
  * @experimental
257
320
  */
258
321
  interface AgentMemorySource extends AgentMemoryOptions {
@@ -308,7 +371,7 @@ interface SkillConfig {
308
371
  knowledge?: AgentMemoryOptions;
309
372
  /**
310
373
  * The skill's identifier — namespaces this skill's `knowledge` memory source
311
- * (the durable step `memory:retrieve:&lt;name>`). Must be identifier-shaped.
374
+ * (the durable step `memory:retrieve:<name>`). Must be identifier-shaped.
312
375
  */
313
376
  name: string;
314
377
  /**
@@ -318,6 +381,12 @@ interface SkillConfig {
318
381
  */
319
382
  tools?: Record<string, AnyAgentTool>;
320
383
  }
384
+ /**
385
+ * The code-side parts of a markdown-authored skill — everything `SKILL.md`
386
+ * cannot carry. `name` and `instructions` come from the file itself.
387
+ * @experimental
388
+ */
389
+ type SkillMarkdownExtras = Omit<SkillConfig, "instructions" | "name">;
321
390
  /**
322
391
  * A `defineSkill` result — config plus the brand the agent merge checks.
323
392
  * @experimental
@@ -358,11 +427,43 @@ interface AgentStepFinishInfo {
358
427
  }
359
428
  /**
360
429
  * Called after each LLM turn with that turn's text, tool calls, and usage. Runs
361
- * inside a named durable step (`agent:step-finish:&lt;turn>`) so it fires exactly
430
+ * inside a named durable step (`agent:step-finish:<turn>`) so it fires exactly
362
431
  * once per turn even across a workflow replay.
363
432
  * @experimental
364
433
  */
365
434
  type AgentOnStepFinish = (info: AgentStepFinishInfo) => Promise<void> | void;
435
+ /**
436
+ * What {@link AgentConfig.onReply} is called with when a triggered run reaches
437
+ * its final answer.
438
+ * @experimental
439
+ */
440
+ interface AgentReplyInfo {
441
+ /** The worker `env` — where an outbound credential (a bot token, a mailer binding) lives. */
442
+ env: Record<string, unknown>;
443
+ /** Where to reply, captured by the mapper that started this run. */
444
+ replyRef: AgentReplyRef;
445
+ /** The run's final answer. */
446
+ result: AgentRunResult;
447
+ /** The thread this run belongs to. */
448
+ threadKey: string;
449
+ }
450
+ /**
451
+ * Send a triggered run's answer back where it came from — the outbound half of
452
+ * an `onEmail` / `onInbound` trigger.
453
+ *
454
+ * Called once, automatically, when a run started with a `replyRef` produces a
455
+ * final answer; a run without one (an ordinary in-app `ctx.agents.<name>.run`)
456
+ * never calls it. It runs inside a named durable step, so a transient failure
457
+ * is retried and a workflow replay does not send the answer twice.
458
+ *
459
+ * Delivery is the app's call, because only the app has the credential: email
460
+ * has a home already (`replyToEmail(mailer, ref, body)` from
461
+ * `@lunora/agent/reply`), while Slack/GitHub/Discord bot tokens have no
462
+ * framework-owned store yet — read them from `info.env` and make the provider
463
+ * call.
464
+ * @experimental
465
+ */
466
+ type AgentOnReply = (info: AgentReplyInfo) => Promise<void> | void;
366
467
  /**
367
468
  * The input {@link AgentConfig.prepareStep} sees before a turn runs.
368
469
  * @experimental
@@ -404,6 +505,39 @@ type AgentPrepareStep = (input: AgentPrepareStepInput) => AgentPrepareStepResult
404
505
  * start a run from an inbound email.
405
506
  * @experimental
406
507
  */
508
+ /**
509
+ * Where a triggered run should send its answer back to — captured by the
510
+ * mapper, from fields the verified inbound payload already carries, and carried
511
+ * through the run so {@link AgentConfig.onReply} can answer in the same place
512
+ * the question was asked.
513
+ *
514
+ * A discriminated union rather than an opaque blob because each channel threads
515
+ * differently: email by RFC 5322 `In-Reply-To`/`References`, Slack by
516
+ * `thread_ts`, GitHub by issue number, Discord by channel (plus the triggering
517
+ * message for a true reply).
518
+ * @experimental
519
+ */
520
+ type AgentReplyRef = {
521
+ channel: "discord";
522
+ channelId: string;
523
+ messageId?: string;
524
+ } | {
525
+ channel: "email";
526
+ from: string;
527
+ messageId: string;
528
+ references?: string;
529
+ to: string[];
530
+ } | {
531
+ channel: "github";
532
+ commentId?: number;
533
+ issueNumber: number;
534
+ owner: string;
535
+ repo: string;
536
+ } | {
537
+ channel: "slack";
538
+ channelId: string;
539
+ threadTs: string;
540
+ };
407
541
  interface AgentEmailRun {
408
542
  /** The user message that starts (or continues) the thread — the model's prompt. */
409
543
  input: string;
@@ -414,6 +548,13 @@ interface AgentEmailRun {
414
548
  * `email.from`.
415
549
  */
416
550
  owner?: string;
551
+ /**
552
+ * Where to send the answer — see {@link AgentReplyRef}. Populate it from the
553
+ * event the mapper already has (`captureEmailReplyRef(email)` does it for
554
+ * email) and the run calls {@link AgentConfig.onReply} with it once the
555
+ * final answer is ready. Omit it and the run simply never replies.
556
+ */
557
+ replyRef?: AgentReplyRef;
417
558
  /** The thread key — reuse to continue a conversation (e.g. a ticket id parsed from the subject). */
418
559
  threadKey: string;
419
560
  /** Optional thread title, set on first creation. */
@@ -484,6 +625,22 @@ interface AgentInboundChannel {
484
625
  interface AgentConfig {
485
626
  /** Restrict the tools the model may call, by name. Default: all tools. */
486
627
  activeTools?: ReadonlyArray<string>;
628
+ /**
629
+ * How long a human-in-the-loop tool approval may stay pending before the
630
+ * run stops waiting — a Cloudflare Workflows duration: milliseconds, or a
631
+ * `"<n> <unit>"` string like `"3 days"` (the unit set is the host's, so a
632
+ * typo is a compile error). Default `"3 days"`.
633
+ *
634
+ * On timeout the call is treated as REJECTED (the run records why and
635
+ * continues down the normal rejection path), so a run whose approver never
636
+ * answers ends instead of hibernating forever.
637
+ *
638
+ * CLAMPED to one week. A longer wait would outlive the thread's
639
+ * abandoned-run horizon, letting a new run reclaim the thread while the
640
+ * approval is still pending — which is the exact failure this timeout
641
+ * exists to prevent, so it cannot be configured back into existence.
642
+ */
643
+ approvalTimeout?: `${number} ${"day" | "hour" | "minute" | "month" | "second" | "week" | "year"}${"s" | ""}` | number;
487
644
  /**
488
645
  * Automatic thread-history compaction. When the persisted history exceeds
489
646
  * `maxMessages`, the loop summarizes the older messages (all but the most
@@ -530,7 +687,7 @@ interface AgentConfig {
530
687
  model: AgentModelInput;
531
688
  /**
532
689
  * Optional override for the deployed workflow name (`wrangler.jsonc`
533
- * `workflows[].name`). Defaults to `agent-&lt;kebab-cased export name>`. Does
690
+ * `workflows[].name`). Defaults to `agent-<kebab-cased export name>`. Does
534
691
  * NOT change the binding name, which is always derived from the export
535
692
  * name (`support` → `AGENT_SUPPORT`).
536
693
  */
@@ -543,7 +700,16 @@ interface AgentConfig {
543
700
  *
544
701
  * - `"reject"` (default) — fail the new run fast with a `CONFLICT` error.
545
702
  * - `"replace"` — terminate the in-flight instance and take the thread over.
546
- * - `"queue"` — reserved for a future durable queue; currently degrades to `"reject"` (no queue exists yet), tracked as a follow-up.
703
+ * - `"queue"` — park the new run behind the one in flight (FIFO, up to five deep) and hibernate it until the thread is handed over.
704
+ *
705
+ * Each parked run is a live workflow instance waiting on an event, so the
706
+ * queue's depth cap is a real resource bound: past it, a start is rejected
707
+ * exactly as `"reject"` would. A `"replace"` arriving later supersedes the
708
+ * run in FLIGHT, not the queue — parked runs still take their turn after it.
709
+ *
710
+ * A dispatch with no instance id (the inbound-email / inbound-channel
711
+ * paths) cannot be parked, because nothing later can tell it apart from
712
+ * another such dispatch to wake it; `"queue"` rejects those.
547
713
  *
548
714
  * A workflow REPLAY re-enters the bootstrap under the SAME instance id and
549
715
  * is never a concurrent run (the guard compares the stored instance id).
@@ -563,6 +729,12 @@ interface AgentConfig {
563
729
  * signature over the raw body before calling `map`.
564
730
  */
565
731
  onInbound?: AgentInboundChannel;
732
+ /**
733
+ * Called with the final answer when the run was triggered from a channel
734
+ * that gave it a `replyRef` — see {@link AgentOnReply}. This is how an
735
+ * inbound-triggered agent answers where it was asked.
736
+ */
737
+ onReply?: AgentOnReply;
566
738
  /** Called after each LLM turn — see {@link AgentOnStepFinish}. */
567
739
  onStepFinish?: AgentOnStepFinish;
568
740
  /**
@@ -577,7 +749,7 @@ interface AgentConfig {
577
749
  * Opt this agent into being STARTED over the public RPC boundary — i.e. via
578
750
  * the auto-registered `agents:agentRun` mutation an HTTP-only client (e.g.
579
751
  * the `@lunora/mcp` server) calls. Default `false`: an agent is startable
580
- * only from server-side app code (`ctx.agents.&lt;name>.run(...)`), so declaring
752
+ * only from server-side app code (`ctx.agents.<name>.run(...)`), so declaring
581
753
  * an agent does NOT expose it to arbitrary RPC callers. Fail-closed — the run
582
754
  * mutation refuses an agent that has not opted in, regardless of any MCP-side
583
755
  * `allowAgents` configuration. A started thread is still owner-scoped to
@@ -649,8 +821,20 @@ interface AgentVoiceConfig {
649
821
  /**
650
822
  * Spoken on connect before the first user turn — a fixed greeting synthesized
651
823
  * through the TTS model. Omit for a silent-until-spoken-to session.
824
+ *
825
+ * Synthesized once per THREAD, not once per socket: the greeting's persisted
826
+ * row is keyed per thread, and a reconnect onto a thread that already exists
827
+ * gets the `ready` frame without paying for the same line again.
652
828
  */
653
829
  greeting?: string;
830
+ /**
831
+ * Cap on how many turns one voice socket may run before it is closed with
832
+ * code `4002`. Every turn is a full LLM generation plus sentence-by-sentence
833
+ * TTS — billed and persisted — on a hibernatable socket that can live for
834
+ * days, and the one-turn-in-flight guard throttles nothing. Defaults to 100;
835
+ * a client that hits the cap reconnects for a fresh budget.
836
+ */
837
+ maxTurns?: number;
654
838
  /**
655
839
  * TTS voice/speaker id forwarded to the TTS model (e.g. a Deepgram Aura voice
656
840
  * like `"aura-asteria-en"`). Model-specific; omitted when unset so the model
@@ -685,10 +869,10 @@ interface AgentSubToolInput {
685
869
  interface AgentAsToolOptions {
686
870
  /** What the sub-agent does — shown to the parent's model (it decides from it). */
687
871
  description: string;
688
- /** Cap on child-run status polls before giving up. Default 120. */
872
+ /** Cap on child-run status polls before giving up — a positive integer. Default 600 (with the 500 ms default interval, a five-minute budget). */
689
873
  maxPolls?: number;
690
874
  /**
691
- * The child agent's export name — selects its `AGENT_&lt;NAME>` Workflow
875
+ * The child agent's export name — selects its `AGENT_<NAME>` Workflow
692
876
  * binding (e.g. `"researcher"` → `AGENT_RESEARCHER`). The model-facing tool
693
877
  * name is the KEY assigned in the parent's `tools` map, not this.
694
878
  */
@@ -725,11 +909,39 @@ interface AgentDefinition extends AgentConfig {
725
909
  */
726
910
  memorySources?: ReadonlyArray<AgentMemorySource>;
727
911
  }
912
+ /**
913
+ * What `agentEnsureThread` reports back to the loop.
914
+ *
915
+ * A discriminated union rather than a bag of optional booleans: the four
916
+ * outcomes are mutually exclusive, and the data each carries only exists for its
917
+ * own case. `queued` has a position, `replaced` has the instance it took the
918
+ * thread from, and the other two have nothing — encoding that as five
919
+ * independent optional fields made every reader re-derive which combination was
920
+ * legal.
921
+ * @experimental
922
+ */
923
+ type EnsureThreadOutcome = {
924
+ outcome: "continued" | "created";
925
+ } | {
926
+ outcome: "queued";
927
+ position: number;
928
+ } | {
929
+ outcome: "replaced";
930
+ priorInstanceId: string;
931
+ };
728
932
  /**
729
933
  * Params of one agent run (the compiled workflow's payload).
730
934
  * @experimental
731
935
  */
732
936
  interface AgentRunInput {
937
+ /**
938
+ * Sub-agent delegation depth. A run a user starts omits it (0); each
939
+ * `agent.asTool` hop stamps its child one deeper, and the tool refuses to
940
+ * delegate past the bound — `maxTurns` bounds one level's turns, this bounds
941
+ * the TREE (every hop mints a distinct child `threadKey`, so the per-thread
942
+ * run-queue cap never applies across them).
943
+ */
944
+ depth?: number;
733
945
  /** The user message that starts (or continues) the thread. */
734
946
  input: string;
735
947
  /**
@@ -741,6 +953,8 @@ interface AgentRunInput {
741
953
  * the first run.
742
954
  */
743
955
  owner?: string;
956
+ /** Where a triggered run sends its answer — see {@link AgentReplyRef}. */
957
+ replyRef?: AgentReplyRef;
744
958
  /** The thread key — reuse to continue a conversation. */
745
959
  threadKey: string;
746
960
  /** Optional thread title, set on first creation. */
@@ -773,6 +987,17 @@ interface AgentRunResult {
773
987
  */
774
988
  interface AgentFunctionPaths {
775
989
  appendMessage: string;
990
+ /**
991
+ * The internal `agents:agentCompleteRun` mutation the loop dispatches at the
992
+ * end of a run: it writes the terminal status AND hands the thread to the
993
+ * next queued run in the same mutation.
994
+ */
995
+ completeRun: string;
996
+ /**
997
+ * The internal `agents:agentDeleteMessage` mutation the loop dispatches to
998
+ * retire the HITL approval marker once the decision has landed.
999
+ */
1000
+ deleteMessage: string;
776
1001
  ensureThread: string;
777
1002
  /** The internal `agents:agentEpisodeRecall` query the loop dispatches for an episodic-kind read. */
778
1003
  episodeRecall: string;
@@ -824,6 +1049,14 @@ interface AgentMessageRow {
824
1049
  interface AgentToolCall {
825
1050
  id: string;
826
1051
  input: unknown;
1052
+ /**
1053
+ * Why this call was REJECTED before it could run: the model's arguments
1054
+ * failed the tool's input schema, or did not parse as JSON. Present only on
1055
+ * a rejected call — the loop records it as a recoverable tool result and
1056
+ * never executes the tool, because the `input` above is the raw value the
1057
+ * provider sent, not a validated one.
1058
+ */
1059
+ invalid?: string;
827
1060
  name: string;
828
1061
  }
829
1062
  /**
@@ -1078,7 +1311,7 @@ interface AgentRunHandle {
1078
1311
  id: string;
1079
1312
  }
1080
1313
  /**
1081
- * The `ctx.agents.&lt;name>` producer handle.
1314
+ * The `ctx.agents.<name>` producer handle.
1082
1315
  * @experimental
1083
1316
  */
1084
1317
  interface AgentHandle {
@@ -1111,4 +1344,4 @@ interface AgentHandle {
1111
1344
  /** Read a run's workflow status by instance id. */
1112
1345
  status: (id: string) => Promise<unknown>;
1113
1346
  }
1114
- export { AgentDefinition as A, AgentGenerateOptions as B, AgentGenerateResult as C, AgentInstructionsContext as D, AgentLiveEvent as E, AgentMemoryOptions as F, AgentMemorySource as G, AgentMessageStatus as H, InboundChannelEvent as I, AgentOnStepFinish as J, AgentPrepareStep as K, AgentPrepareStepInput as L, AgentPrepareStepResult as M, AgentProgressEvent as N, AgentRunHandle as O, AgentStepFinishInfo as P, AgentStepInfo as Q, AgentThreadStatus as R, SkillDefinition as S, AgentTokenDelta as T, AgentToolCall as U, AgentUsage as V, AgentVoiceConfig as W, AgentWorkflowBindingLike as X, AgentWorkflowInstanceLike as Y, AgentToolContext as a, AgentToolDefinition as b, AgentCompact as c, AgentEpisodeExtract as d, AgentGraphExtract as e, AgentGenerate as f, AgentTokenSink as g, AgentRunInput as h, AgentFunctionPaths as i, AgentRunFunction as j, AgentStepLike as k, AgentStreamGenerate as l, AgentRunResult as m, AgentMessageRow as n, AgentConfig as o, AnyAgentTool as p, AgentAsToolOptions as q, AgentSubToolInput as r, AgentBindingSpec as s, AgentHandle as t, AgentToolConfig as u, AgentFunctionReference as v, AgentModelInput as w, SkillConfig as x, AgentEmailMapper as y, AgentEmailRun as z };
1347
+ export { AgentVoiceConfig as $, AgentDefinition as A, AgentApprovalContext as B, AgentEmailMapper as C, AgentEmailRun as D, AgentGenerateOptions as E, AgentGenerateResult as F, AgentInstructionsContext as G, AgentLiveEvent as H, InboundChannelEvent as I, AgentMemoryOptions as J, AgentMemorySource as K, AgentMessageStatus as L, AgentOnReply as M, AgentOnStepFinish as N, AgentPrepareStep as O, AgentPrepareStepInput as P, AgentPrepareStepResult as Q, AgentProgressEvent as R, SkillMarkdownExtras as S, AgentReplyInfo as T, AgentRunHandle as U, AgentStepFinishInfo as V, AgentStepInfo as W, AgentThreadStatus as X, AgentTokenDelta as Y, AgentToolCall as Z, AgentUsage as _, AgentReplyRef as a, AgentWorkflowBindingLike as a0, AgentWorkflowInstanceLike as a1, EnsureThreadOutcome as a2, AgentToolContext as b, AgentToolDefinition as c, SkillDefinition as d, AgentCompact as e, AgentEpisodeExtract as f, AgentGraphExtract as g, AgentGenerate as h, AgentTokenSink as i, AgentRunInput as j, AgentFunctionPaths as k, AgentRunFunction as l, AgentStepLike as m, AgentStreamGenerate as n, AgentRunResult as o, AgentMessageRow as p, AgentConfig as q, AnyAgentTool as r, AgentAsToolOptions as s, AgentSubToolInput as t, AgentBindingSpec as u, AgentHandle as v, AgentToolConfig as w, AgentFunctionReference as x, AgentModelInput as y, SkillConfig as z };
@@ -0,0 +1 @@
1
+ import{f as P,a as q}from"./base64-5eyBfWO3.mjs";import{c as z}from"./agent-loop-Cotwgugs.mjs";import{buildModelMessages as G}from"./buildModelMessages-Y4tGo1T9.mjs";import{toFunctionReference as T}from"./AGENT_MODULE-M4D1EejI.mjs";const D=new TextDecoder;new TextEncoder;const N="=",Q=t=>{if(t)try{const e=t[0]==="{"?t:D.decode(P(t)),n=JSON.parse(e);if(n&&typeof n=="object"&&!Array.isArray(n))return n}catch{}},ct=t=>{if(t){if(!t.startsWith(N))return t;try{return D.decode(P(t.slice(N.length)))}catch{return}}},dt=t=>{const e=Number(t);return Number.isFinite(e)&&e>0?e:void 0},ut=t=>typeof t=="number"&&Date.now()>=t,ft=t=>{try{t.send(JSON.stringify({code:"TOKEN_EXPIRED",error:{code:"TOKEN_EXPIRED",message:"authentication token expired"},message:"authentication token expired",type:"error"})),t.close?.(4001,"token_expired")}catch{}},Y=16e3,Z=1,tt=16,M=/[.!?]\s/u,et=/\s/u,nt=async function*(t){if(t instanceof Uint8Array){yield t;return}if(t instanceof ReadableStream){const e=t.getReader();try{for(;;){const{done:n,value:r}=await e.read();if(n)break;yield r}}finally{e.releaseLock()}return}yield*t},yt=(t,e=Y,n=Z,r=tt)=>{const i=n*r/8,p=e*i,m=new ArrayBuffer(44+t.byteLength),s=new DataView(m),u=(l,E)=>{for(let d=0;d<E.length;d+=1)s.setUint8(l+d,E.codePointAt(d)??0)};u(0,"RIFF"),s.setUint32(4,36+t.byteLength,!0),u(8,"WAVE"),u(12,"fmt "),s.setUint32(16,16,!0),s.setUint16(20,1,!0),s.setUint16(22,n,!0),s.setUint32(24,e,!0),s.setUint32(28,p,!0),s.setUint16(32,i,!0),s.setUint16(34,r,!0),u(36,"data"),s.setUint32(40,t.byteLength,!0);const a=new Uint8Array(m);return a.set(t,44),a},st=t=>{const e=[];let n=t,r=M.exec(n);for(;r;){let i=r.index+1;for(;i<n.length&&et.test(n[i]??"");)i+=1;const p=n.slice(0,i).trim();p.length>0&&e.push(p),n=n.slice(i),r=M.exec(n)}return{rest:n,sentences:e}},pt=async t=>{const{agent:e,compact:n,connectionId:r,env:i,exportName:p,owner:m,paths:s,pcm:u,run:a,send:l,sendAudio:E,signal:d,streamGenerate:C,synthesize:H,text:L,threadKey:o,transcribe:F,turn:U,waitForDrain:O}=t,h=()=>d.aborted,v=T(s.appendMessage),$=T(s.ensureThread),B=T(s.listMessages),b=T(s.patchThread);await a($,{agent:p,key:o,...e.initialState===void 0?{}:{initialState:e.initialState},...m===void 0?{}:{owner:m}});try{const f=(L??(u?await F(u):"")).trim();if(f.length===0)return await a(b,{key:o,status:"idle"}),{assistantText:"",interrupted:!1,userText:""};l({text:f,type:"user_transcript"}),await a(v,{content:f,messageKey:`voice:${r}:${String(U)}:user`,role:"user",threadKey:o}),await a(b,{key:o,status:"running"});const I=typeof e.instructions=="function"?e.instructions({env:i,input:f,threadKey:o}):e.instructions,K=await a(B,{key:o}),{history:j,summary:R}=await z({agent:e,compact:n,env:i},K),W=G({history:j,...I===void 0?{}:{instructions:I},...R===void 0?{}:{summary:R}});let g="",w="",S=Promise.resolve();const V=async c=>{if(h()||c.length===0)return;let x=!1;try{for await(const y of nt(await H(c,d))){if(h()||(await O?.(),h()))break;E(y),x=!0}}catch(y){l({message:y instanceof Error?y.message:String(y),type:"error"})}x&&(w=w.length>0?`${w} ${c}`:c)},_=c=>{S=S.then(async()=>V(c))},X=await C({messages:W,signal:d},c=>{if(h())return;l({text:c,type:"assistant_delta"}),g+=c;const{rest:x,sentences:y}=st(g);g=x;for(const J of y)_(J)});!h()&&g.trim().length>0&&_(g.trim()),g="",await S;const k=h(),A=k?w:X.text;return await a(v,{content:A,messageKey:`voice:${r}:${String(U)}:assistant`,role:"assistant",threadKey:o}),await a(b,{key:o,status:"idle"}),l(k?{type:"interrupted"}:{text:A,type:"assistant_done"}),{assistantText:A,interrupted:k,userText:f}}catch(f){throw await a(b,{key:o,status:"idle"}),f}},lt=t=>Q(t),ht=t=>{if(typeof t=="object"&&t!==null&&"text"in t){const{text:e}=t;return typeof e=="string"?e:""}return""},gt=t=>{if(t instanceof Uint8Array||t instanceof ReadableStream)return t;if(t instanceof Response&&t.body)return t.body;if(typeof t=="object"&&t!==null&&"audio"in t){const{audio:e}=t;if(typeof e=="string")return q(e);if(e instanceof ReadableStream||e instanceof Uint8Array)return e}return new Uint8Array(0)};export{dt as a,ft as b,yt as c,ct as d,ht as e,gt as f,ut as i,lt as p,pt as r,nt as t};
@@ -0,0 +1,40 @@
1
+ import { Mailer } from '@lunora/mail';
2
+ import { InboundEmail } from '@lunora/mail/inbound';
3
+ import { a as AgentReplyRef } from "./packem_shared/types.d-B8WZl1rU.mjs";
4
+ import 'ai';
5
+ /** The email arm of {@link AgentReplyRef}, narrowed for the helpers below. */
6
+ type EmailReplyRef = Extract<AgentReplyRef, {
7
+ channel: "email";
8
+ }>;
9
+ /**
10
+ * Capture a reply reference from an already-parsed inbound email — call it in
11
+ * an `onEmail` mapper and put the result on the run's `replyRef`.
12
+ *
13
+ * Returns `undefined` when the message carries no `Message-ID`: there is
14
+ * nothing to thread a reply against, and an unthreaded reply lands as a new
15
+ * conversation in the recipient's client, which is worse than not replying.
16
+ */
17
+ declare const captureEmailReplyRef: (email: InboundEmail) => EmailReplyRef | undefined;
18
+ /** RFC 5322 threading: append the replied-to id to any existing `References` chain. */
19
+ declare const buildReferences: (replyRef: EmailReplyRef) => string;
20
+ /** Subject + body of a reply. */
21
+ interface ReplyBody {
22
+ subject: string;
23
+ text: string;
24
+ }
25
+ /**
26
+ * Send a threaded reply through the app's own `@lunora/mail` mailer.
27
+ *
28
+ * The mailer is a parameter rather than something this module constructs: the
29
+ * app already configures one (API key or `send_email` binding), and passing it
30
+ * in keeps this module free of any runtime dependency on `@lunora/mail`.
31
+ *
32
+ * The reply goes to the original sender, from the mailer's configured default.
33
+ * An app running several aliases that wants the reply to come from whichever
34
+ * mailbox received the message passes `from: replyRef.to[0]` itself — a
35
+ * decision only the app can make.
36
+ */
37
+ declare const replyToEmail: (mailer: Mailer, replyRef: EmailReplyRef, body: ReplyBody) => Promise<{
38
+ id: string;
39
+ }>;
40
+ export { type EmailReplyRef, ReplyBody, buildReferences, captureEmailReplyRef, replyToEmail };
@@ -0,0 +1,40 @@
1
+ import { Mailer } from '@lunora/mail';
2
+ import { InboundEmail } from '@lunora/mail/inbound';
3
+ import { a as AgentReplyRef } from "./packem_shared/types.d-B8WZl1rU.js";
4
+ import 'ai';
5
+ /** The email arm of {@link AgentReplyRef}, narrowed for the helpers below. */
6
+ type EmailReplyRef = Extract<AgentReplyRef, {
7
+ channel: "email";
8
+ }>;
9
+ /**
10
+ * Capture a reply reference from an already-parsed inbound email — call it in
11
+ * an `onEmail` mapper and put the result on the run's `replyRef`.
12
+ *
13
+ * Returns `undefined` when the message carries no `Message-ID`: there is
14
+ * nothing to thread a reply against, and an unthreaded reply lands as a new
15
+ * conversation in the recipient's client, which is worse than not replying.
16
+ */
17
+ declare const captureEmailReplyRef: (email: InboundEmail) => EmailReplyRef | undefined;
18
+ /** RFC 5322 threading: append the replied-to id to any existing `References` chain. */
19
+ declare const buildReferences: (replyRef: EmailReplyRef) => string;
20
+ /** Subject + body of a reply. */
21
+ interface ReplyBody {
22
+ subject: string;
23
+ text: string;
24
+ }
25
+ /**
26
+ * Send a threaded reply through the app's own `@lunora/mail` mailer.
27
+ *
28
+ * The mailer is a parameter rather than something this module constructs: the
29
+ * app already configures one (API key or `send_email` binding), and passing it
30
+ * in keeps this module free of any runtime dependency on `@lunora/mail`.
31
+ *
32
+ * The reply goes to the original sender, from the mailer's configured default.
33
+ * An app running several aliases that wants the reply to come from whichever
34
+ * mailbox received the message passes `from: replyRef.to[0]` itself — a
35
+ * decision only the app can make.
36
+ */
37
+ declare const replyToEmail: (mailer: Mailer, replyRef: EmailReplyRef, body: ReplyBody) => Promise<{
38
+ id: string;
39
+ }>;
40
+ export { type EmailReplyRef, ReplyBody, buildReferences, captureEmailReplyRef, replyToEmail };
package/dist/reply.mjs ADDED
@@ -0,0 +1 @@
1
+ const t=e=>{if(e.messageId!==void 0)return{channel:"email",from:e.from,messageId:e.messageId,to:e.to,...e.references===void 0?{}:{references:e.references}}},n=e=>e.references===void 0?e.messageId:`${e.references} ${e.messageId}`,c=async(e,s,r)=>e.send({headers:{"In-Reply-To":s.messageId,References:n(s)},subject:r.subject,text:r.text,to:s.from});export{n as buildReferences,t as captureEmailReplyRef,c as replyToEmail};
@@ -1,4 +1,4 @@
1
- import { a as AgentToolContext, b as AgentToolDefinition } from "./packem_shared/types.d-boAM2Yi1.mjs";
1
+ import { b as AgentToolContext, c as AgentToolDefinition } from "./packem_shared/types.d-B8WZl1rU.mjs";
2
2
  import '@lunora/mail/inbound';
3
3
  import 'ai';
4
4
  /**
@@ -25,8 +25,8 @@ type BrowserToolInput = {
25
25
  /**
26
26
  * The model-provided input to a {@link containerTool} call — a discriminated
27
27
  * union on `op`. `fetch` sends an HTTP request to the container; `exec` asks it
28
- * to run a command (routed as a POST to `/exec`, since the container surface
29
- * exposes no first-class exec RPC — the container app must serve that route).
28
+ * to run a command (delegated to `ctx.containers.<name>.exec`, the first-class
29
+ * exec contract in `@lunora/container`; the container app serves its route).
30
30
  * @experimental
31
31
  */
32
32
  type ContainerToolInput = {
@@ -112,12 +112,13 @@ interface ContainerToolOptions {
112
112
  description?: string;
113
113
  /**
114
114
  * Gate a call behind a human approval. Defaults to gating any command
115
- * execution: an `exec`, AND a `fetch` whose path resolves to the privileged
116
- * `/exec` route (both reach the same command-execution path in the container,
117
- * so gating on the `op` name alone would let a `fetch` to `/exec` run a
118
- * command unattended). A plain `fetch` to any other route runs unattended.
119
- * Pass a boolean or your own predicate to change that. Evaluated from
120
- * replay-stable input, so keep it deterministic.
115
+ * execution an `exec` plus any `fetch` using a non-idempotent method
116
+ * (POST/PUT/PATCH/DELETE), so a prompt-injected model cannot reach some
117
+ * other mutating route unattended. A read-only `fetch` runs unattended. The
118
+ * exec route itself needs no special case: `@lunora/container` reserves
119
+ * `/__lunora/*` and refuses a `fetch` into it. Pass a boolean or your own
120
+ * predicate to change that. Evaluated from replay-stable input, so keep it
121
+ * deterministic.
121
122
  */
122
123
  needsApproval?: ((input: ContainerToolInput) => boolean) | boolean;
123
124
  }
@@ -149,19 +150,19 @@ interface ContainerToolOptions {
149
150
  declare const browserTool: (options?: BrowserToolOptions) => AgentToolDefinition<BrowserToolInput, string>;
150
151
  /**
151
152
  * A batteries-included agent tool that talks to a declared Cloudflare
152
- * Container. `name` is the `ctx.containers.&lt;name>` key (the `lunora/containers.ts`
153
+ * Container. `name` is the `ctx.containers.<name>` key (the `lunora/containers.ts`
153
154
  * export). One tool exposes `fetch` (HTTP request) and `exec` (run a command);
154
155
  * the model picks via `op`. The call dispatches to the auto-registered
155
156
  * `sandbox:invoke` action, which carries `ctx.containers`.
156
157
  *
157
158
  * By default a read-only (GET/HEAD/OPTIONS, or method-omitted) `fetch` runs
158
159
  * unattended while everything else is gated behind a human approval — an
159
- * `exec`; a `fetch` whose path resolves to the privileged `/exec` route (both
160
+ * `exec`; a `fetch` whose path resolves to the privileged exec route (both
160
161
  * reach the same command-execution path in the container, so gating on the
161
- * `op` name alone would let a `fetch` to `/exec` run a command unattended);
162
+ * `op` name alone would let a `fetch` to it run a command unattended);
162
163
  * and a `fetch` using a non-idempotent method (POST/PUT/PATCH/DELETE) to ANY
163
164
  * route, since a prompt-injected model could otherwise mutate container state
164
- * through some other privileged route just by avoiding the literal `/exec`
165
+ * through some other privileged route just by avoiding the literal exec
165
166
  * path. A `fetch` can still reach any *other read* route unattended, so scope
166
167
  * the container's GET routes accordingly. Pass `opts.needsApproval` to widen
167
168
  * or disable the gate.