@nightowlsdev/core 0.3.0 → 0.4.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.
package/dist/index.js CHANGED
@@ -1,4 +1,16 @@
1
1
  // src/types.ts
2
+ function assertActorMayMutateDefinition(actor) {
3
+ if (actor.type === "agent") {
4
+ throw new AgentMutationForbidden(actor.agentSlug);
5
+ }
6
+ }
7
+ var AgentMutationForbidden = class extends Error {
8
+ code = "AGENT_MUTATION_FORBIDDEN";
9
+ constructor(agentSlug) {
10
+ super(`agent principal "${agentSlug}" may not mutate an agent definition (publish/rollback)`);
11
+ this.name = "AgentMutationForbidden";
12
+ }
13
+ };
2
14
  var SCRATCHPAD_MAX_ENTRY_CHARS = 4e3;
3
15
  var SCRATCHPAD_MAX_KEYS = 64;
4
16
 
@@ -13,11 +25,244 @@ function isEvent(e, type) {
13
25
  // src/define.ts
14
26
  import { z as z4 } from "zod";
15
27
  import { createTool as createTool4 } from "@mastra/core/tools";
28
+ import { createHookDispatcher } from "@nightowlsdev/hooks";
16
29
 
17
30
  // src/engine.ts
18
31
  import { Mastra } from "@mastra/core/mastra";
19
32
  import { InMemoryStore } from "@mastra/core/storage";
20
33
  import { RequestContext } from "@mastra/core/request-context";
34
+ import { HookDispatcher } from "@nightowlsdev/hooks";
35
+
36
+ // src/tool-gate.ts
37
+ var TOOL_GATE_KEY = "__nightowlsdev_toolGate";
38
+ var TOOL_EXECUTORS = /* @__PURE__ */ new WeakMap();
39
+ function setToolExecutor(handle, exec) {
40
+ TOOL_EXECUTORS.set(handle, exec);
41
+ }
42
+ function getToolExecutor(handle) {
43
+ return TOOL_EXECUTORS.get(handle);
44
+ }
45
+ var ToolBlockedError = class extends Error {
46
+ constructor(toolName, reason) {
47
+ super(`tool "${toolName}" blocked: ${reason}`);
48
+ this.toolName = toolName;
49
+ this.reason = reason;
50
+ this.name = "ToolBlockedError";
51
+ }
52
+ toolName;
53
+ reason;
54
+ blocked = true;
55
+ };
56
+ function approvalSuspendPayload(args) {
57
+ const prompt = args.reason?.trim() ? `Approve \`${args.toolName}\`? ${args.reason.trim()}` : `Approve running \`${args.toolName}\`?`;
58
+ return {
59
+ to: "user",
60
+ prompt,
61
+ field: { kind: "confirm", confirmLabel: "Approve", rejectLabel: "Reject" },
62
+ asker: args.asker,
63
+ kind: "approval",
64
+ toolName: args.toolName
65
+ };
66
+ }
67
+ function isApproved(answer) {
68
+ if (typeof answer === "boolean") return answer;
69
+ if (typeof answer === "string") {
70
+ return /^(y|yes|approve|approved|ok|true|confirm|confirmed)$/i.test(answer.trim());
71
+ }
72
+ return false;
73
+ }
74
+ function gateErrMessage(err) {
75
+ return err instanceof Error ? err.message : String(err);
76
+ }
77
+ async function executeToolWithGate(opts) {
78
+ let decision;
79
+ try {
80
+ decision = await opts.gate(opts.ev);
81
+ } catch (err) {
82
+ return { ok: false, error: gateErrMessage(err), reason: gateErrMessage(err) };
83
+ }
84
+ if (decision.action === "deny") return { ok: false, error: decision.reason, reason: decision.reason };
85
+ if (decision.action === "ask") return { ok: false, suspended: true, reason: decision.reason };
86
+ try {
87
+ return { ok: true, result: await opts.run() };
88
+ } catch (err) {
89
+ return { ok: false, error: gateErrMessage(err) };
90
+ }
91
+ }
92
+ function toolPreCallEvent(args) {
93
+ return {
94
+ runId: args.runId,
95
+ tenantId: args.tenantId,
96
+ agentSlug: args.agentSlug,
97
+ toolName: args.toolName,
98
+ origin: args.origin,
99
+ needsApproval: args.needsApproval,
100
+ args: args.args
101
+ };
102
+ }
103
+
104
+ // src/secrets.ts
105
+ var SECRET_RESOLVER_KEY = "__nightowlsdev_secretResolver";
106
+ function bindSecrets(resolver, ctx) {
107
+ return {
108
+ resolve: (ref) => resolver ? resolver.resolve(ref, ctx) : Promise.resolve(void 0)
109
+ };
110
+ }
111
+
112
+ // src/step-driver.ts
113
+ function initialWorkflowState(wf) {
114
+ return { workflow: wf.name, cursor: wf.start, outputs: {}, generationIndex: 0 };
115
+ }
116
+ function resolveRef(ref, state, input) {
117
+ if (ref === "input") return input.message;
118
+ if (ref.startsWith("steps.")) return state.outputs[ref.slice("steps.".length)];
119
+ return void 0;
120
+ }
121
+ function resolveValue(v, state, input) {
122
+ if (v && typeof v === "object" && "$ref" in v) {
123
+ const ref = String(v.$ref);
124
+ if (ref.startsWith("steps.")) {
125
+ const id = ref.slice("steps.".length);
126
+ if (!(id in state.outputs)) {
127
+ throw new Error(`workflow $ref "${ref}" references step "${id}" which has not run (skipped branch or forward reference)`);
128
+ }
129
+ }
130
+ return resolveRef(ref, state, input);
131
+ }
132
+ return v;
133
+ }
134
+ function resolveMap(o, state, input) {
135
+ if (!o) return void 0;
136
+ const out = {};
137
+ for (const [k, v] of Object.entries(o)) out[k] = resolveValue(v, state, input);
138
+ return out;
139
+ }
140
+ function agentMessage(step, resolvedInput) {
141
+ const base2 = step.instruction ?? "";
142
+ if (resolvedInput && Object.keys(resolvedInput).length) return `${base2}
143
+
144
+ Context:
145
+ ${JSON.stringify(resolvedInput)}`;
146
+ return base2;
147
+ }
148
+ var DEAD_END = /* @__PURE__ */ Symbol("dead-end");
149
+ function nextStep(step, state, input) {
150
+ if (step.next === void 0) return void 0;
151
+ if (typeof step.next === "string") return step.next;
152
+ for (const t of step.next) {
153
+ if (!t.when) return t.to;
154
+ const v = resolveRef(t.when.$ref, state, input);
155
+ if (t.when.exists !== void 0) {
156
+ if (v !== void 0 === t.when.exists) return t.to;
157
+ continue;
158
+ }
159
+ if (t.when.eq !== void 0) {
160
+ if (v === t.when.eq) return t.to;
161
+ continue;
162
+ }
163
+ return t.to;
164
+ }
165
+ return DEAD_END;
166
+ }
167
+ var StepDriver = class {
168
+ constructor(wf, deps) {
169
+ this.wf = wf;
170
+ this.deps = deps;
171
+ }
172
+ wf;
173
+ deps;
174
+ ts = 0;
175
+ base(ctx) {
176
+ return { runId: ctx.runId, agentSlug: ctx.agentSlug, ts: this.deps.nextTs ? this.deps.nextTs() : this.ts++ };
177
+ }
178
+ /**
179
+ * Drive the workflow from `state` (fresh or resumed). Yields the run's SwarmEvents. Returns a `DriveOutcome`
180
+ * so the engine can finalize. B2 scope: linear `agent`/`tool` steps + `$ref` wiring + per-step snapshot.
181
+ */
182
+ async *drive(state, ctx, input) {
183
+ const byId = new Map(this.wf.steps.map((s) => [s.id, s]));
184
+ let guard = 0;
185
+ let retryStep = "";
186
+ let retriesLeft = 0;
187
+ const budget = this.wf.steps.length * 8 + 8;
188
+ while (true) {
189
+ if (guard++ > budget) return { kind: "failed", stage: "workflow", message: "step budget exceeded" };
190
+ const step = byId.get(state.cursor);
191
+ if (!step) return { kind: "failed", stage: "workflow", message: `unknown step "${state.cursor}"` };
192
+ yield ev("swarm.status", this.base(ctx), { state: step.tool ? "tool" : "thinking", note: `step:${step.id}` });
193
+ let stepError;
194
+ if (step.agent !== void 0) {
195
+ try {
196
+ const msg = agentMessage(step, resolveMap(step.input, state, input));
197
+ const { text } = yield* this.deps.runAgentStep(step.agent, msg, state.generationIndex, ctx);
198
+ state.outputs[step.id] = text;
199
+ state.generationIndex += 1;
200
+ } catch (err) {
201
+ if (err && typeof err === "object" && "stage" in err) throw err;
202
+ stepError = err instanceof Error ? err.message : String(err);
203
+ }
204
+ } else if (step.tool !== void 0) {
205
+ let args;
206
+ try {
207
+ args = resolveMap(step.args, state, input) ?? {};
208
+ } catch (err) {
209
+ stepError = err instanceof Error ? err.message : String(err);
210
+ }
211
+ if (args !== void 0) {
212
+ const toolCallId = `${ctx.runId}:wf:${step.id}`;
213
+ yield ev("swarm.tool_call", this.base(ctx), { toolCallId, name: step.tool, args, needsApproval: false });
214
+ const r = await this.deps.runToolStep(step.tool, args, ctx);
215
+ yield ev("swarm.tool_result", this.base(ctx), { toolCallId, ok: r.ok, result: r.result, error: r.error });
216
+ if (r.ok) state.outputs[step.id] = r.result;
217
+ else if (r.suspended) {
218
+ const followupId = `${ctx.runId}:wf:${step.id}`;
219
+ yield ev("swarm.question", this.base(ctx), { followupId, toolCallId: followupId, to: "user", prompt: r.reason ?? `Approve "${step.tool}"?`, field: { kind: "confirm" } });
220
+ state.pending = { kind: "approval", stepId: step.id, followupId, toolCallId: followupId };
221
+ await this.deps.saveState(ctx.runId, state);
222
+ return { kind: "suspended", state };
223
+ } else stepError = r.error ?? r.reason ?? "blocked";
224
+ }
225
+ } else if (step.human !== void 0) {
226
+ if (!(step.id in state.outputs)) {
227
+ const followupId = `${ctx.runId}:wf:${step.id}`;
228
+ yield ev("swarm.question", this.base(ctx), { followupId, toolCallId: followupId, to: "user", prompt: step.human.prompt, field: step.human.field });
229
+ state.pending = { kind: "human", stepId: step.id, followupId, toolCallId: followupId };
230
+ await this.deps.saveState(ctx.runId, state);
231
+ return { kind: "suspended", state };
232
+ }
233
+ state.pending = void 0;
234
+ }
235
+ if (stepError !== void 0) {
236
+ const oe = step.onError ?? "fail";
237
+ if (oe === "fail") return { kind: "failed", stage: "workflow", message: `step "${step.id}" failed: ${stepError}` };
238
+ if (typeof oe === "object" && "to" in oe) {
239
+ state.cursor = oe.to;
240
+ retryStep = "";
241
+ await this.deps.saveState(ctx.runId, state);
242
+ continue;
243
+ }
244
+ if (typeof oe === "object" && "retry" in oe) {
245
+ if (retryStep !== step.id) {
246
+ retryStep = step.id;
247
+ retriesLeft = oe.retry;
248
+ }
249
+ if (retriesLeft > 0) {
250
+ retriesLeft -= 1;
251
+ await this.deps.saveState(ctx.runId, state);
252
+ continue;
253
+ }
254
+ return { kind: "failed", stage: "workflow", message: `step "${step.id}" failed after retries: ${stepError}` };
255
+ }
256
+ }
257
+ retryStep = "";
258
+ const next = nextStep(step, state, input);
259
+ if (next === DEAD_END) return { kind: "failed", stage: "workflow", message: `no transition from step "${step.id}"` };
260
+ state.cursor = next ?? state.cursor;
261
+ await this.deps.saveState(ctx.runId, state);
262
+ if (next === void 0) return { kind: "done" };
263
+ }
264
+ }
265
+ };
21
266
 
22
267
  // src/mastra-map.ts
23
268
  import { Agent } from "@mastra/core/agent";
@@ -41,6 +286,16 @@ function composeSystemPrompt(row) {
41
286
  { role: "system", content: persona }
42
287
  ];
43
288
  }
289
+ function composePolicyPrompt(lines) {
290
+ if (!lines.length) return [];
291
+ return [
292
+ {
293
+ role: "system",
294
+ content: `Policy \u2014 follow these unless the user explicitly overrides:
295
+ ${lines.map((l) => `- ${l}`).join("\n")}`
296
+ }
297
+ ];
298
+ }
44
299
  function composeScratchpadPrompt(entries) {
45
300
  const render = (section) => {
46
301
  const rows = entries.filter((e) => e.section === section).map((e) => `- [${e.key}] (${e.author} \u2190 ${e.requestedBy}) ${e.content}`);
@@ -56,20 +311,100 @@ ${render("meta")}`
56
311
  return { role: "system", content };
57
312
  }
58
313
 
314
+ // src/tier.ts
315
+ var SENTINEL = "tier:";
316
+ function isTierSentinel(modelId) {
317
+ return typeof modelId === "string" && modelId.startsWith(SENTINEL);
318
+ }
319
+ function requestedTierFrom(modelId, cfg) {
320
+ const suffix = modelId.slice(SENTINEL.length).trim();
321
+ if (suffix === "swift" || suffix === "genius") return suffix;
322
+ return cfg.default ?? "swift";
323
+ }
324
+ function resolveTier(modelId, cfg, ctx) {
325
+ if (!isTierSentinel(modelId)) {
326
+ return { modelId, downgraded: false };
327
+ }
328
+ let requested = requestedTierFrom(modelId, cfg);
329
+ let escalated = false;
330
+ if (cfg.escalate) {
331
+ const bumped = cfg.escalate(ctx);
332
+ if (bumped === "genius" && requested !== "genius") {
333
+ requested = "genius";
334
+ escalated = true;
335
+ }
336
+ }
337
+ if (requested === "genius") {
338
+ const geniusAllowed = cfg.allowGenius === true && typeof cfg.tiers.genius === "string";
339
+ if (geniusAllowed) {
340
+ return { modelId: cfg.tiers.genius, tier: "genius", downgraded: false, ...escalated ? { escalated: true } : {} };
341
+ }
342
+ return { modelId: cfg.tiers.swift, tier: "swift", downgraded: true, requestedTier: "genius" };
343
+ }
344
+ return { modelId: cfg.tiers.swift, tier: "swift", downgraded: false };
345
+ }
346
+ function tierModelId(modelId, cfg, ctx) {
347
+ if (!cfg) return modelId;
348
+ return resolveTier(modelId, cfg, ctx).modelId;
349
+ }
350
+
59
351
  // src/mastra-map.ts
352
+ async function gateDelegation(rc, subSlug) {
353
+ const gate = rc.get(TOOL_GATE_KEY);
354
+ if (!gate) return;
355
+ const decision = await gate(
356
+ toolPreCallEvent({
357
+ runId: rc.get("runId") ?? "",
358
+ tenantId: rc.get("tenantId") ?? "default",
359
+ // The agent doing the delegating is the run owner / the parent in the path (the requestContext's agentSlug).
360
+ agentSlug: rc.get("agentSlug") ?? "",
361
+ toolName: `agent-${subSlug}`,
362
+ origin: "first-party",
363
+ // A delegation has no per-tool `needsApproval` flag; surface false so a "flag"-mode policy leaves it
364
+ // un-gated (today's behaviour) and only an "all-side-effecting" policy / an explicit hook can deny it.
365
+ needsApproval: false,
366
+ args: void 0
367
+ })
368
+ );
369
+ if (decision.action === "deny") {
370
+ throw new Error(`delegation to "${subSlug}" denied: ${decision.reason}`);
371
+ }
372
+ }
60
373
  var MAX_DELEGATION_DEPTH = 4;
374
+ var CONNECTOR_TOOLS_CACHE_KEY = "__nightowls_connector_tools";
61
375
  function memoryFor(args, row) {
62
376
  return args.resolveMemory ? args.resolveMemory(row) : args.memory;
63
377
  }
64
- function toolsFor(args, row) {
378
+ function toolsFor(args, row, connectorByName) {
65
379
  const out = { ...args.builtinTools ?? {} };
66
380
  for (const name of row.skillNames) {
67
- const skill = args.resolveSkill(name);
381
+ const skill = args.resolveSkill(name) ?? connectorByName?.[name];
68
382
  const mt = skill && __getMastraTool(skill);
69
383
  if (mt) out[name] = mt;
70
384
  }
71
385
  return out;
72
386
  }
387
+ async function connectorByNameFor(args, rc, agentSlug) {
388
+ if (!args.connectorTools) return {};
389
+ const cached = rc.get(CONNECTOR_TOOLS_CACHE_KEY);
390
+ if (cached) return cached;
391
+ const resolve = args.connectorTools;
392
+ const build = (async () => {
393
+ const ctx = {
394
+ tenantId: rc.get("tenantId") ?? "default",
395
+ userId: rc.get("userId") ?? "",
396
+ runId: rc.get("runId") ?? "",
397
+ agentSlug,
398
+ // informational — materialize is tenant-scoped; first caller's slug seeds the shared cache
399
+ threadId: rc.get("threadId") ?? ""
400
+ };
401
+ const out = {};
402
+ for (const t of await resolve(ctx)) out[t.name] = t;
403
+ return out;
404
+ })();
405
+ rc.set?.(CONNECTOR_TOOLS_CACHE_KEY, build);
406
+ return build;
407
+ }
73
408
  async function withScratchpad(args, base2, rc) {
74
409
  if (!args.loadScratchpad) return base2;
75
410
  const tenantId = rc.get("tenantId") ?? "default";
@@ -77,8 +412,13 @@ async function withScratchpad(args, base2, rc) {
77
412
  const entries = await args.loadScratchpad(container, tenantId);
78
413
  return [...base2, composeScratchpadPrompt(entries)];
79
414
  }
415
+ function withSoftPolicy(args, base2, slug) {
416
+ const soft = args.softPolicy?.(slug) ?? [];
417
+ return soft.length ? [...base2, ...composePolicyPrompt(soft)] : base2;
418
+ }
80
419
  async function modelFor(args, row, tenantId) {
81
- const id = await args.model.resolve(row.modelId, { tenantId });
420
+ const effective = tierModelId(row.modelId, args.tier, { tenantId, agentSlug: row.slug, pinnedModelId: row.modelId });
421
+ const id = await args.model.resolve(effective, { tenantId });
82
422
  return args.modelFactory(id, row.slug);
83
423
  }
84
424
  function buildSubAgent(args, row, depth, path) {
@@ -90,9 +430,12 @@ function buildSubAgent(args, row, depth, path) {
90
430
  // personality so the orchestrator's LLM knows WHAT this delegate is for (role is a coarse enum).
91
431
  description: row.personality || `Agent ${row.slug} (${row.role})`,
92
432
  ...memoryFor(args, row) ? { memory: memoryFor(args, row) } : {},
93
- instructions: async ({ requestContext }) => withScratchpad(args, composeSystemPrompt(row), requestContext),
433
+ instructions: async ({ requestContext }) => {
434
+ await gateDelegation(requestContext, row.slug);
435
+ return withScratchpad(args, withSoftPolicy(args, composeSystemPrompt(row), row.slug), requestContext);
436
+ },
94
437
  model: async ({ requestContext }) => await modelFor(args, row, requestContext.get("tenantId") ?? "default"),
95
- tools: toolsFor(args, row),
438
+ tools: (async ({ requestContext }) => toolsFor(args, row, await connectorByNameFor(args, requestContext, row.slug))),
96
439
  agents: async ({ requestContext }) => await buildSubAgentMap(
97
440
  args,
98
441
  row.delegateSlugs ?? [],
@@ -121,9 +464,16 @@ function buildMastraAgent(args) {
121
464
  // request). If Mastra rejects a dynamic `memory`, fall back to the static swarm Memory (root override is
122
465
  // then sub-agents-only — see the spec's accepted limitation).
123
466
  ...args.resolveMemory || args.memory ? { memory: (async ({ requestContext }) => memoryFor(args, await load(requestContext))) } : {},
124
- instructions: async ({ requestContext }) => withScratchpad(args, composeSystemPrompt(await load(requestContext)), requestContext),
467
+ instructions: async ({ requestContext }) => {
468
+ const row = await load(requestContext);
469
+ return withScratchpad(args, withSoftPolicy(args, composeSystemPrompt(row), row.slug), requestContext);
470
+ },
125
471
  model: async ({ requestContext }) => await modelFor(args, await load(requestContext), requestContext.get("tenantId") ?? "default"),
126
- tools: async ({ requestContext }) => ({ ...args.extraTools ?? {}, ...toolsFor(args, await load(requestContext)) }),
472
+ tools: (async ({ requestContext }) => {
473
+ const row = await load(requestContext);
474
+ const connectorByName = await connectorByNameFor(args, requestContext, row.slug);
475
+ return { ...args.extraTools ?? {}, ...toolsFor(args, row, connectorByName) };
476
+ }),
127
477
  // Delegation: the orchestrator's delegateSlugs become `agent-<slug>` tools (Mastra-native).
128
478
  agents: async ({ requestContext }) => {
129
479
  const row = await load(requestContext);
@@ -138,7 +488,7 @@ import { createTool } from "@mastra/core/tools";
138
488
  import { z } from "zod";
139
489
 
140
490
  // src/page-context.ts
141
- var PAGE_CONTEXT_KEY = "nightowls.pageContext";
491
+ var PAGE_CONTEXT_KEY = "__nightowlsdev_pageContext";
142
492
  function attachPageContext(rc, value) {
143
493
  rc.set(PAGE_CONTEXT_KEY, value ?? {});
144
494
  }
@@ -345,7 +695,7 @@ var InMemoryContainerFloor = class {
345
695
  s.held = who;
346
696
  if (s.timer) clearTimeout(s.timer);
347
697
  s.timer = setTimeout(() => {
348
- console.warn(`[nightowls] container floor force-released after ${this.maxHoldMs}ms: ${container} (held by ${who.label})`);
698
+ console.warn(`[@nightowlsdev/core] container floor force-released after ${this.maxHoldMs}ms: ${container} (held by ${who.label})`);
349
699
  this.release(container, s, who);
350
700
  }, this.maxHoldMs);
351
701
  if (typeof s.timer.unref === "function") s.timer.unref();
@@ -380,25 +730,84 @@ var PRICE_TABLE = {
380
730
  "openai/gpt-5.5": { inUsdPerMtok: 2.5, outUsdPerMtok: 10 },
381
731
  "openai/gpt-5.5-mini": { inUsdPerMtok: 0.3, outUsdPerMtok: 1.2 }
382
732
  };
383
- function priceUsage(prices, modelId, u) {
384
- const p = prices[modelId] ?? { inUsdPerMtok: 0, outUsdPerMtok: 0 };
385
- return u.inputTokens / 1e6 * p.inUsdPerMtok + u.outputTokens / 1e6 * p.outUsdPerMtok;
733
+ function priceUsage(prices, modelId, u, opts = {}) {
734
+ const p = prices[modelId];
735
+ if (!p) {
736
+ if (opts.failOnUnknownModel) {
737
+ throw new Error(
738
+ `[@nightowlsdev/core] no price entry for model '${modelId}' (failOnUnknownModel=true). Add it to PRICE_TABLE, the swarm cost.prices map, or a priceFeed.`
739
+ );
740
+ }
741
+ return 0;
742
+ }
743
+ const cacheReadRate = p.cacheReadUsdPerMtok ?? p.inUsdPerMtok;
744
+ const cacheWriteRate = p.cacheWriteUsdPerMtok ?? p.inUsdPerMtok;
745
+ const reasoningRate = p.reasoningUsdPerMtok ?? p.outUsdPerMtok;
746
+ const M = 1e6;
747
+ return (u.inputTokens ?? 0) / M * p.inUsdPerMtok + (u.outputTokens ?? 0) / M * p.outUsdPerMtok + (u.cacheReadTokens ?? 0) / M * cacheReadRate + (u.cacheWriteTokens ?? 0) / M * cacheWriteRate + (u.reasoningTokens ?? 0) / M * reasoningRate;
748
+ }
749
+ var OPTIONAL_USAGE_CLASSES = [
750
+ "cacheReadTokens",
751
+ "cacheWriteTokens",
752
+ "reasoningTokens",
753
+ "toolCalls",
754
+ "agentActivations"
755
+ ];
756
+ function sumBreakdowns(items) {
757
+ const total = { inputTokens: 0, outputTokens: 0 };
758
+ for (const b of items) {
759
+ total.inputTokens += b.inputTokens ?? 0;
760
+ total.outputTokens += b.outputTokens ?? 0;
761
+ for (const k of OPTIONAL_USAGE_CLASSES) {
762
+ const v = b[k];
763
+ if (v != null) total[k] = (total[k] ?? 0) + v;
764
+ }
765
+ }
766
+ return total;
767
+ }
768
+ function sumTurnUsage(items) {
769
+ const order = [];
770
+ const breakdownsBySlug = /* @__PURE__ */ new Map();
771
+ const usdBySlug = /* @__PURE__ */ new Map();
772
+ for (const it of items) {
773
+ if (!breakdownsBySlug.has(it.slug)) {
774
+ order.push(it.slug);
775
+ breakdownsBySlug.set(it.slug, []);
776
+ usdBySlug.set(it.slug, 0);
777
+ }
778
+ breakdownsBySlug.get(it.slug).push(it.breakdown);
779
+ usdBySlug.set(it.slug, usdBySlug.get(it.slug) + it.cost.usd);
780
+ }
781
+ const bySlug = order.map((slug) => {
782
+ const breakdown2 = sumBreakdowns(breakdownsBySlug.get(slug));
783
+ const usd2 = usdBySlug.get(slug);
784
+ return { slug, breakdown: breakdown2, cost: { usd: usd2, breakdown: breakdown2 } };
785
+ });
786
+ const breakdown = sumBreakdowns(items.map((it) => it.breakdown));
787
+ const usd = items.reduce((a, it) => a + it.cost.usd, 0);
788
+ return { breakdown, cost: { usd, breakdown }, bySlug };
386
789
  }
387
790
  var CostGovernor = class {
388
791
  constructor(opts) {
389
792
  this.opts = opts;
390
- this.prices = { ...PRICE_TABLE, ...opts.prices ?? {} };
793
+ this.prices = { ...PRICE_TABLE, ...opts.prices ?? {}, ...opts.priceFeed?.prices() ?? {} };
794
+ this.failOnUnknownModel = opts.failOnUnknownModel ?? false;
391
795
  }
392
796
  opts;
393
797
  steps = 0;
394
798
  usd = 0;
395
799
  prices;
800
+ failOnUnknownModel;
396
801
  step() {
397
802
  this.steps++;
398
803
  }
399
804
  /** Price a single usage WITHOUT accumulating it (for per-generation telemetry cost). */
400
805
  priceOf(modelId, u) {
401
- return priceUsage(this.prices, modelId, u);
806
+ return priceUsage(this.prices, modelId, u, { failOnUnknownModel: this.failOnUnknownModel });
807
+ }
808
+ /** Price a single usage WITHOUT accumulating it, returning the usd + the breakdown it was priced from. */
809
+ costOf(modelId, u) {
810
+ return { usd: this.priceOf(modelId, u), breakdown: u };
402
811
  }
403
812
  addUsage(modelId, u) {
404
813
  this.usd += this.priceOf(modelId, u);
@@ -406,6 +815,19 @@ var CostGovernor = class {
406
815
  costUsd() {
407
816
  return this.usd;
408
817
  }
818
+ /** The current USD cap (SP9-core: the cap-that-asks reads this to surface "spend / cap" + to compute the raise). */
819
+ get maxCostUsd() {
820
+ return this.opts.maxCostUsd;
821
+ }
822
+ /**
823
+ * SP9-core — RAISE the USD cap by `incrementUsd` (the budget an approved "Budget cap reached — continue?"
824
+ * grants). Mutates the governor's ceiling so a freshly-resumed generation isn't immediately re-blocked at the
825
+ * SAME cap; the run gets real additional headroom. Only the cap-that-asks resume path calls this; the default
826
+ * terminal-stop path never does, so today's behaviour is unchanged.
827
+ */
828
+ raiseCostCap(incrementUsd) {
829
+ this.opts.maxCostUsd += incrementUsd;
830
+ }
409
831
  shouldStop() {
410
832
  if (this.steps >= this.opts.maxSteps) return { stop: true, reason: "step cap reached" };
411
833
  if (this.usd >= this.opts.maxCostUsd) return { stop: true, reason: "USD cap reached" };
@@ -413,15 +835,17 @@ var CostGovernor = class {
413
835
  }
414
836
  };
415
837
  var DelegateBudgets = class {
416
- constructor(cfg, rootSlug, prices) {
838
+ constructor(cfg, rootSlug, pricing) {
417
839
  this.cfg = cfg;
418
840
  this.rootSlug = rootSlug;
419
- this.prices = { ...PRICE_TABLE, ...prices ?? {} };
841
+ this.prices = { ...PRICE_TABLE, ...pricing?.prices ?? {}, ...pricing?.priceFeed?.prices() ?? {} };
842
+ this.failOnUnknownModel = pricing?.failOnUnknownModel ?? false;
420
843
  }
421
844
  cfg;
422
845
  rootSlug;
423
846
  usd = /* @__PURE__ */ new Map();
424
847
  prices;
848
+ failOnUnknownModel;
425
849
  /** The USD cap for a delegate: its `bySlug` override if present, else the default. `undefined` → uncapped. */
426
850
  capFor(slug) {
427
851
  return this.cfg.bySlug?.[slug]?.maxCostUsd ?? this.cfg.maxCostUsd;
@@ -429,7 +853,10 @@ var DelegateBudgets = class {
429
853
  /** Accumulate one generation's usage against a delegate. No-op for the root orchestrator (not a delegate). */
430
854
  addUsage(slug, modelId, u) {
431
855
  if (slug === this.rootSlug) return;
432
- this.usd.set(slug, (this.usd.get(slug) ?? 0) + priceUsage(this.prices, modelId, u));
856
+ this.usd.set(
857
+ slug,
858
+ (this.usd.get(slug) ?? 0) + priceUsage(this.prices, modelId, u, { failOnUnknownModel: this.failOnUnknownModel })
859
+ );
433
860
  }
434
861
  /** The first delegate that has met or exceeded its USD cap, or null. */
435
862
  exceeded() {
@@ -457,7 +884,7 @@ function compositeTelemetry(exporters) {
457
884
  const results = await Promise.allSettled(exporters.map((e) => e.export(spans)));
458
885
  for (const r of results) {
459
886
  if (r.status === "rejected") {
460
- console.warn("[nightowls] telemetry exporter failed:", r.reason);
887
+ console.warn("[@nightowlsdev/core] telemetry exporter failed:", r.reason);
461
888
  }
462
889
  }
463
890
  }
@@ -504,10 +931,17 @@ var SpanCollector = class {
504
931
  */
505
932
  closeGeneration(usage, costUsd) {
506
933
  if (!this.gen) return;
934
+ const extra = {};
935
+ if (usage.cacheReadTokens != null) extra.cacheReadTokens = usage.cacheReadTokens;
936
+ if (usage.cacheWriteTokens != null) extra.cacheWriteTokens = usage.cacheWriteTokens;
937
+ if (usage.reasoningTokens != null) extra.reasoningTokens = usage.reasoningTokens;
938
+ if (usage.toolCalls != null) extra.toolCalls = usage.toolCalls;
939
+ if (usage.agentActivations != null) extra.agentActivations = usage.agentActivations;
507
940
  this.gen.attributes = {
508
941
  ...this.gen.attributes,
509
942
  inputTokens: usage.inputTokens,
510
943
  outputTokens: usage.outputTokens,
944
+ ...extra,
511
945
  costUsd: Math.max(0, costUsd)
512
946
  };
513
947
  this.gen.endedAt = this.now();
@@ -591,6 +1025,21 @@ var RowCache = class {
591
1025
 
592
1026
  // src/engine.ts
593
1027
  var AGENT_KEY = "swarm";
1028
+ var MAX_CONTINUE_NUDGES = 2;
1029
+ var CONTINUE_NUDGE = "[automated] Your previous turn ended without any message or tool call. If you have fully completed everything that was asked, give the final result now. Otherwise continue and finish every remaining step you intended \u2014 do not end your turn until the task is done.";
1030
+ function verifyNudge(missing) {
1031
+ const gap = (missing ?? "").trim();
1032
+ return gap ? `[automated] The task is NOT finished yet \u2014 still missing: ${gap}. Continue now and complete every remaining step. Do not end your turn until it is fully done.` : CONTINUE_NUDGE;
1033
+ }
1034
+ var VERIFY_TRANSCRIPT_CAP = 6e3;
1035
+ function appendTranscript(t, e) {
1036
+ let add = "";
1037
+ if (e.type === "swarm.message") add = e.data.delta ?? e.data.text ?? "";
1038
+ else if (e.type === "swarm.tool_call") add = `
1039
+ \xAB${e.agentSlug} \u2192 ${e.data.name}\xBB
1040
+ `;
1041
+ return add ? (t + add).slice(-VERIFY_TRANSCRIPT_CAP) : t;
1042
+ }
594
1043
  var SwarmEngine = class {
595
1044
  constructor(opts) {
596
1045
  this.opts = opts;
@@ -600,6 +1049,7 @@ var SwarmEngine = class {
600
1049
  const { memory, resolveMemory } = opts.memory ? buildMemoryResolver(opts.memory) : { memory: void 0, resolveMemory: void 0 };
601
1050
  this.memory = memory;
602
1051
  this.floor = opts.floor ?? containerFloor;
1052
+ this.hooks = opts.hooks ?? new HookDispatcher({}, opts.toolApproval ?? { mode: "flag" });
603
1053
  opts.storage.subscribeInvalidations?.((key) => this.rowCache.invalidate(key));
604
1054
  const agent = buildMastraAgent({
605
1055
  loadRow: (slug, tenantId) => this.loadRow(tenantId, slug),
@@ -607,6 +1057,8 @@ var SwarmEngine = class {
607
1057
  resolveSkill: (n) => opts.resolveSkill?.(n),
608
1058
  model: opts.model,
609
1059
  modelFactory: opts.modelFactory,
1060
+ // SP10: hand the cheap-model router to the per-agent model resolver. Undefined ⇒ no routing (today).
1061
+ tier: opts.tier,
610
1062
  builtinTools: {
611
1063
  [ASK_TOOL_NAME]: buildAskMastraTool(),
612
1064
  ...opts.scratchpad ? { scratchpad_write: buildScratchpadTool(opts.storage.scratchpad, typeof opts.scratchpad === "object" ? opts.scratchpad : void 0) } : {},
@@ -616,6 +1068,9 @@ var SwarmEngine = class {
616
1068
  // ONLY (never sub-agents) so the model can pull the host page's advisory RunInput.context.
617
1069
  ...opts.pageContext ? { extraTools: { get_page_context: buildPageContextTool() } } : {},
618
1070
  loadScratchpad: opts.scratchpad ? (c, t) => opts.storage.scratchpad.list(t, c) : void 0,
1071
+ softPolicy: opts.softPolicy,
1072
+ // PR2: per-request connector-tools resolver, granted to the orchestrator + sub-agents by skillNames.
1073
+ connectorTools: opts.connectorTools,
619
1074
  memory
620
1075
  });
621
1076
  this.mastra = new Mastra({
@@ -634,6 +1089,51 @@ var SwarmEngine = class {
634
1089
  // Typed `unknown` to keep the engine wall: no engine-vendor type escapes the public surface.
635
1090
  memory;
636
1091
  floor;
1092
+ // SP2: the decision-hook dispatcher. Always present — defaults to an allow-all dispatcher when the engine is
1093
+ // built without one (e.g. unit tests), so the preGeneration seam is uniform with no per-call null checks.
1094
+ hooks;
1095
+ /** SP1: the swarm's metering config, in the shape DelegateBudgets/priceUsage expect. CostGovernor reads the
1096
+ * same fields directly off `opts.cost`; this packs them for the per-delegate tracker so both caps price
1097
+ * tokens identically (built-in PRICE_TABLE ← static `prices` ← live `priceFeed`, with `failOnUnknownModel`). */
1098
+ pricingOpts() {
1099
+ return {
1100
+ prices: this.opts.cost.prices,
1101
+ priceFeed: this.opts.cost.priceFeed,
1102
+ failOnUnknownModel: this.opts.cost.failOnUnknownModel
1103
+ };
1104
+ }
1105
+ /** Fire the best-effort per-event observer (`EngineOpts.onEvent`). Awaited so an async observer (e.g. a
1106
+ * metering debit) completes in order, but FAIL-SAFE: a throw is swallowed (the host logs its own), never
1107
+ * breaking the run — same contract as the telemetry exporter. No-op when no observer is configured. */
1108
+ async notifyEvent(e, ctx) {
1109
+ if (!this.opts.onEvent) return;
1110
+ try {
1111
+ await this.opts.onEvent(e, ctx);
1112
+ } catch {
1113
+ }
1114
+ }
1115
+ /** Run the completion supervisor (`EngineOpts.verifyCompletion`), FAIL-OPEN: no verifier, or a throwing one,
1116
+ * yields `{ complete: true }` so a missing/broken judge never traps a run in a verify loop. */
1117
+ async safeVerify(request, transcript, ctx) {
1118
+ if (!this.opts.verifyCompletion) return { complete: true };
1119
+ try {
1120
+ return await this.opts.verifyCompletion({ request, transcript, ctx });
1121
+ } catch (err) {
1122
+ console.error(`[@nightowlsdev/core] verifyCompletion threw for run ${ctx.runId} \u2014 treating as complete:`, err);
1123
+ return { complete: true };
1124
+ }
1125
+ }
1126
+ /** Best-effort recall of the run's ORIGINAL request (first user message on the thread) for the completion
1127
+ * verifier on RESUME, where the engine doesn't hold the opening message. Empty on any failure / no verifier. */
1128
+ async recallRequest(ctx) {
1129
+ if (!this.opts.verifyCompletion) return "";
1130
+ try {
1131
+ const msgs = await this.history(ctx.threadId, ctx, { limit: 50 });
1132
+ return msgs.find((m) => m.role === "user")?.text ?? "";
1133
+ } catch {
1134
+ return "";
1135
+ }
1136
+ }
637
1137
  /** Cached agent-row load shared by the three dynamic agent fns AND run/resume. */
638
1138
  loadRow(tenantId, slug) {
639
1139
  return this.rowCache.get(`${tenantId}:${slug}`, async () => {
@@ -642,6 +1142,13 @@ var SwarmEngine = class {
642
1142
  return row;
643
1143
  });
644
1144
  }
1145
+ /** Resolve an agent's STORED modelId — which may be a tier sentinel (`"tier:"` / `"tier:swift"`) — to the
1146
+ * CONCRETE model id the generation actually runs on, so metering/pricing + the preGeneration event see the
1147
+ * real model, not the sentinel (which has no price → every tier-routed turn would meter at $0). Mirrors
1148
+ * mastra-map's modelFor routing; with no tier config it returns the id unchanged. (SP10 pricing follow-up.) */
1149
+ priceModelId(rawModelId, tenantId, agentSlug) {
1150
+ return tierModelId(rawModelId, this.opts.tier, { tenantId, agentSlug, pinnedModelId: rawModelId });
1151
+ }
645
1152
  agent() {
646
1153
  return this.mastra.getAgent(AGENT_KEY);
647
1154
  }
@@ -650,8 +1157,44 @@ var SwarmEngine = class {
650
1157
  for (const [k, v] of Object.entries(ctx)) {
651
1158
  if (v !== void 0) rc.set(k, v);
652
1159
  }
1160
+ rc.set(TOOL_GATE_KEY, this.toolGate);
1161
+ if (this.opts.secrets) rc.set(SECRET_RESOLVER_KEY, this.opts.secrets);
653
1162
  return rc;
654
1163
  }
1164
+ /**
1165
+ * SP5 — the action-approval gate handed to every gated tool via the RequestContext. Bound once (stable
1166
+ * reference). Delegates to the dispatcher's `preToolCall`, which is fail-closed (a throwing configured hook ⇒
1167
+ * deny) and applies the non-removable policy. The defineTool wrapper turns the returned `ToolDecision` into:
1168
+ * allow → run; deny → blocked result; ask → suspend-and-ask (the existing `swarm.question`/resume machinery).
1169
+ */
1170
+ toolGate = (ev2) => this.hooks.preToolCall(ev2);
1171
+ /**
1172
+ * SP5 truth-fix — resolve whether a tool WILL require approval, for the `swarm.tool_call` event's
1173
+ * `needsApproval` (the react reducer reads it to render an approval card). The mapChunk emit currently
1174
+ * hardcodes `false` (the truth-bug). This computes the truthful value from the SAME policy + per-tool flag the
1175
+ * gate uses: the tool's resolved `needsApproval` (its own flag, defaulting by origin) run through the
1176
+ * dispatcher's SYNC `policyDecision` — `ask` ⇒ true (it will gate), else false. The async `preToolCall` hook
1177
+ * can still escalate a specific call at execute time, but the policy-derived baseline is the truthful default
1178
+ * the UI needs without speculatively running the hook for every tool_call event.
1179
+ */
1180
+ gatesApproval(toolName) {
1181
+ const skill = this.opts.resolveSkill?.(toolName);
1182
+ const origin = skill?.origin ?? "first-party";
1183
+ const needsApproval = skill?.needsApproval ?? origin === "mcp";
1184
+ const decision = this.hooks.policyDecision({ runId: "", agentSlug: "", toolName, origin, needsApproval });
1185
+ return decision.action === "ask";
1186
+ }
1187
+ /**
1188
+ * SP2: the preGeneration DECISION seam. Awaited immediately before each model launch (run + resume). The
1189
+ * dispatcher is fail-closed (a throwing hook ⇒ deny), so this only ever sees a clean `allow`/`deny`; a `deny`
1190
+ * THROWS `ReserveDenied` so the model call below never happens and the run/resume catch-all maps it to a
1191
+ * terminal `run_failed` stage "reserve" (NOT the generic "exception"). Allow-all + zero-overhead when no
1192
+ * hooks are configured (the default dispatcher returns allow synchronously-ish without invoking anything).
1193
+ */
1194
+ async guardGeneration(ev2) {
1195
+ const decision = await this.hooks.preGeneration(ev2);
1196
+ if (decision.action === "deny") throw new ReserveDenied(decision.reason);
1197
+ }
655
1198
  /** Per-call Mastra memory ids + delegation, only when memory is configured (else stream is unchanged). */
656
1199
  memoryOpts(ctx) {
657
1200
  if (!this.opts.memory) return {};
@@ -861,6 +1404,13 @@ var SwarmEngine = class {
861
1404
  async activeRuns(container, ctx) {
862
1405
  return this.opts.storage.runs.listActive(ctx.tenantId, container);
863
1406
  }
1407
+ /** The full, globally-ordered event log for a thread's CONTAINER (all its runs + lane sub-threads) — lets a host
1408
+ * rebuild the RICH timeline (tool calls + delegation cards) on reload, since message history is text-only.
1409
+ * Returns [] when the store has no events table (`listForContainer` unset). */
1410
+ async threadEvents(threadId, ctx) {
1411
+ const container = threadId.split(":")[0] || threadId;
1412
+ return await this.opts.storage.events.listForContainer?.(ctx.tenantId, container) ?? [];
1413
+ }
864
1414
  /** The tenant's agent roster (slug, title-cased display name, role, delegate graph) as wall-safe
865
1415
  * AgentSummary[]. Sourced from the agent rows; no vendor type in the signature or result. Powers
866
1416
  * the multi-agent pile / @mention UI. */
@@ -875,7 +1425,9 @@ var SwarmEngine = class {
875
1425
  }));
876
1426
  }
877
1427
  async *run(input, ctx) {
878
- const modelId = (await this.loadRow(ctx.tenantId, ctx.agentSlug)).modelId ?? "unknown";
1428
+ const modelId = this.priceModelId((await this.loadRow(ctx.tenantId, ctx.agentSlug)).modelId ?? "unknown", ctx.tenantId, ctx.agentSlug);
1429
+ const workflowDef = input.workflow ? this.opts.workflows?.find((w) => w.name === input.workflow) : this.opts.agentWorkflows?.[ctx.agentSlug];
1430
+ if (input.workflow && !workflowDef) throw new Error(`unknown workflow: ${input.workflow}`);
879
1431
  await this.opts.storage.runs.create({
880
1432
  runId: ctx.runId,
881
1433
  tenantId: ctx.tenantId,
@@ -883,18 +1435,31 @@ var SwarmEngine = class {
883
1435
  threadId: ctx.threadId,
884
1436
  agentSlug: ctx.agentSlug
885
1437
  });
886
- const modelIdFor = (slug) => this.rowCache.peek(`${ctx.tenantId}:${slug}`)?.modelId ?? modelId;
1438
+ const modelIdFor = (slug) => {
1439
+ const raw = this.rowCache.peek(`${ctx.tenantId}:${slug}`)?.modelId;
1440
+ return raw ? this.priceModelId(raw, ctx.tenantId, slug) : modelId;
1441
+ };
1442
+ const gatesApproval = (name) => this.gatesApproval(name);
887
1443
  const gov = new CostGovernor(this.opts.cost);
888
- const delegateBudgets = this.opts.cost.perDelegate ? new DelegateBudgets(this.opts.cost.perDelegate, ctx.agentSlug) : null;
1444
+ const delegateBudgets = this.opts.cost.perDelegate ? new DelegateBudgets(this.opts.cost.perDelegate, ctx.agentSlug, this.pricingOpts()) : null;
889
1445
  const streamed = /* @__PURE__ */ new Set();
1446
+ const activity = /* @__PURE__ */ new Map();
1447
+ const turnUsage = [];
890
1448
  const rc = this.requestContext(ctx);
891
1449
  if (this.opts.pageContext) attachPageContext(rc, input.context);
892
1450
  const collector = this.opts.telemetry ? new SpanCollector(ctx.runId, () => Date.now(), "run", { agentSlug: ctx.agentSlug }) : null;
893
1451
  let ts = 0;
894
1452
  const emit = async (e) => {
895
1453
  e.seq = await this.opts.storage.events.append(e);
1454
+ await this.notifyEvent(e, ctx);
896
1455
  return e;
897
1456
  };
1457
+ let turnEmitted = false;
1458
+ const emitTurn = async () => {
1459
+ if (turnEmitted) return null;
1460
+ turnEmitted = true;
1461
+ return emit(turnUsageEvent(ctx, ts++, turnUsage, 0));
1462
+ };
898
1463
  const floorKey = ctx.threadId;
899
1464
  const me = { label: titleCase(ctx.agentSlug), runId: ctx.runId };
900
1465
  const floorAbort = new AbortController();
@@ -908,96 +1473,289 @@ var SwarmEngine = class {
908
1473
  if (floorAbort.signal.aborted) return;
909
1474
  }
910
1475
  yield await emit(ev("swarm.status", base(ctx, ts++), { state: "thinking" }));
1476
+ if (workflowDef) {
1477
+ const wfMessage = input.message.replace(/^(\[user:[^\]]*\]\s*)+/, "");
1478
+ await emit(ev("swarm.message", base(ctx, ts++), { role: "user", text: wfMessage }));
1479
+ yield* this.driveWorkflow(workflowDef, initialWorkflowState(workflowDef), ctx, { message: wfMessage, context: input.context }, {
1480
+ gov,
1481
+ modelIdFor,
1482
+ streamed,
1483
+ delegateBudgets,
1484
+ activity,
1485
+ gatesApproval,
1486
+ turnUsage,
1487
+ nextTs: () => ts++,
1488
+ emit,
1489
+ emitTurn
1490
+ });
1491
+ return;
1492
+ }
1493
+ const generationIndex = 0;
1494
+ await this.guardGeneration({ runId: ctx.runId, tenantId: ctx.tenantId, agentSlug: ctx.agentSlug, modelId, generationIndex, kind: "run" });
911
1495
  const userMessage = input.message.replace(/^(\[user:[^\]]*\]\s*)+/, "");
912
- const result = await this.agent().stream(userMessage, { runId: ctx.runId, requestContext: rc, ...this.memoryOpts(ctx) });
913
- for await (const part of result.fullStream) {
914
- if (part?.type === "step-finish") gov.step();
915
- if (part?.type === "tool-call-suspended") {
916
- const payload = part.payload ?? {};
917
- const toolCallId = payload.toolCallId ?? "";
918
- const followupId = `${ctx.runId}:${toolCallId}`;
919
- const sp = payload.suspendPayload ?? {};
920
- await recordSuspend(this.opts.storage, ctx, followupId, toolCallId);
921
- await this.opts.storage.runs.setStatus(ctx.runId, "suspended");
922
- await this.opts.storage.runs.saveSnapshot(ctx.runId, { pending: { toolCallId } });
923
- yield await emit(ev("swarm.status", base(ctx, ts++), { state: "waiting" }));
924
- yield await emit(
925
- ev("swarm.question", base(ctx, ts++), {
926
- followupId,
927
- toolCallId,
928
- to: sp.to ?? "user",
929
- from: sp.asker || ctx.agentSlug,
930
- // the agent that actually asked (a delegate), for UI attribution
931
- prompt: sp.prompt ?? "",
932
- field: sp.field
933
- })
934
- );
935
- return;
1496
+ await emit(ev("swarm.message", base(ctx, ts++), { role: "user", text: userMessage }));
1497
+ let turnMessage = userMessage;
1498
+ let continueNudges = 0;
1499
+ let transcript = "";
1500
+ let incompleteVerdict = null;
1501
+ for (; ; ) {
1502
+ const result = await this.agent().stream(turnMessage, { runId: ctx.runId, requestContext: rc, ...this.memoryOpts(ctx) });
1503
+ let sawStep = false;
1504
+ let lastOutputSlug;
1505
+ for await (const part of result.fullStream) {
1506
+ if (part?.type === "step-finish") {
1507
+ gov.step();
1508
+ sawStep = true;
1509
+ }
1510
+ if (part?.type === "tool-call-suspended") {
1511
+ const payload = part.payload ?? {};
1512
+ const toolCallId = payload.toolCallId ?? "";
1513
+ const followupId = `${ctx.runId}:${toolCallId}`;
1514
+ const sp = payload.suspendPayload ?? {};
1515
+ await recordSuspend(this.opts.storage, ctx, followupId, toolCallId);
1516
+ await this.opts.storage.runs.setStatus(ctx.runId, "suspended");
1517
+ await this.opts.storage.runs.saveSnapshot(ctx.runId, { pending: { toolCallId }, genIndex: generationIndex + 1 });
1518
+ {
1519
+ const t = await emitTurn();
1520
+ if (t) yield t;
1521
+ }
1522
+ yield await emit(ev("swarm.status", base(ctx, ts++), { state: "waiting" }));
1523
+ yield await emit(
1524
+ ev("swarm.question", base(ctx, ts++), {
1525
+ followupId,
1526
+ toolCallId,
1527
+ to: sp.to ?? "user",
1528
+ from: sp.asker || ctx.agentSlug,
1529
+ // the agent that actually asked (a delegate), for UI attribution
1530
+ prompt: sp.prompt ?? "",
1531
+ field: sp.field
1532
+ })
1533
+ );
1534
+ return;
1535
+ }
1536
+ if (part?.type === "error") {
1537
+ await this.opts.storage.runs.setStatus(ctx.runId, "failed");
1538
+ {
1539
+ const t = await emitTurn();
1540
+ if (t) yield t;
1541
+ }
1542
+ yield await emit(
1543
+ ev("swarm.run_failed", base(ctx, ts++), {
1544
+ stage: "stream",
1545
+ message: streamErrorMessage(part),
1546
+ retryable: false
1547
+ })
1548
+ );
1549
+ return;
1550
+ }
1551
+ for (const e of mapChunk(part, ctx, gov, modelIdFor, () => ts++, streamed, delegateBudgets, activity, gatesApproval, turnUsage)) {
1552
+ if (e.type === "swarm.message" || e.type === "swarm.tool_call") {
1553
+ lastOutputSlug = e.agentSlug;
1554
+ if (this.opts.verifyCompletion) transcript = appendTranscript(transcript, e);
1555
+ }
1556
+ yield await emit(e);
1557
+ }
1558
+ collectSpans(collector, part, modelId, gov);
1559
+ const overDelegate = delegateBudgets?.exceeded();
1560
+ const stop = gov.shouldStop();
1561
+ if (stop.stop || overDelegate) {
1562
+ if (this.opts.cost.onCapHit === "ask" && stop.stop && !overDelegate) {
1563
+ const followupId = `${ctx.runId}:${CAP_FOLLOWUP_SUFFIX}`;
1564
+ await recordSuspend(this.opts.storage, ctx, followupId, CAP_FOLLOWUP_SUFFIX);
1565
+ await this.opts.storage.runs.setStatus(ctx.runId, "suspended");
1566
+ await this.opts.storage.runs.saveSnapshot(ctx.runId, {
1567
+ capHit: { message: userMessage, spentUsd: gov.costUsd() },
1568
+ genIndex: generationIndex + 1
1569
+ });
1570
+ {
1571
+ const t = await emitTurn();
1572
+ if (t) yield t;
1573
+ }
1574
+ yield await emit(ev("swarm.status", base(ctx, ts++), { state: "waiting" }));
1575
+ yield await emit(ev("swarm.question", base(ctx, ts++), capQuestion(ctx, followupId, gov)));
1576
+ return;
1577
+ }
1578
+ await this.opts.storage.runs.setStatus(ctx.runId, "failed");
1579
+ {
1580
+ const t = await emitTurn();
1581
+ if (t) yield t;
1582
+ }
1583
+ yield await emit(
1584
+ ev("swarm.run_failed", base(ctx, ts++), {
1585
+ stage: "cost",
1586
+ message: overDelegate?.reason ?? stop.reason,
1587
+ retryable: false
1588
+ })
1589
+ );
1590
+ return;
1591
+ }
936
1592
  }
937
- if (part?.type === "error") {
938
- await this.opts.storage.runs.setStatus(ctx.runId, "failed");
939
- yield await emit(
940
- ev("swarm.run_failed", base(ctx, ts++), {
941
- stage: "stream",
942
- message: streamErrorMessage(part),
943
- retryable: false
944
- })
945
- );
946
- return;
947
- }
948
- for (const e of mapChunk(part, ctx, gov, modelIdFor, () => ts++, streamed, delegateBudgets)) yield await emit(e);
949
- collectSpans(collector, part, modelId, gov);
950
- const overDelegate = delegateBudgets?.exceeded();
951
- if (gov.shouldStop().stop || overDelegate) {
952
- await this.opts.storage.runs.setStatus(ctx.runId, "failed");
953
- yield await emit(
954
- ev("swarm.run_failed", base(ctx, ts++), {
955
- stage: "cost",
956
- message: overDelegate?.reason ?? gov.shouldStop().reason,
957
- retryable: false
958
- })
959
- );
960
- return;
1593
+ if (this.opts.verifyCompletion) {
1594
+ const verdict = await this.safeVerify(userMessage, transcript, ctx);
1595
+ if (!verdict.complete && continueNudges < MAX_CONTINUE_NUDGES) {
1596
+ continueNudges++;
1597
+ turnMessage = verifyNudge(verdict.missing);
1598
+ continue;
1599
+ }
1600
+ incompleteVerdict = verdict.complete ? null : verdict;
1601
+ } else if (sawStep && lastOutputSlug !== ctx.agentSlug && continueNudges < MAX_CONTINUE_NUDGES) {
1602
+ continueNudges++;
1603
+ turnMessage = CONTINUE_NUDGE;
1604
+ continue;
961
1605
  }
1606
+ break;
962
1607
  }
963
1608
  await this.mirrorDelegations(ctx);
964
1609
  await this.attributeRun(ctx);
965
- await this.opts.storage.runs.setStatus(ctx.runId, "done");
966
- yield await emit(ev("swarm.status", base(ctx, ts++), { state: "done" }));
1610
+ if (incompleteVerdict) {
1611
+ await this.opts.storage.runs.setStatus(ctx.runId, "failed");
1612
+ {
1613
+ const t = await emitTurn();
1614
+ if (t) yield t;
1615
+ }
1616
+ yield await emit(ev("swarm.run_failed", base(ctx, ts++), { stage: "incomplete", message: incompleteVerdict.missing ?? "The task was not completed.", retryable: true }));
1617
+ } else {
1618
+ await this.opts.storage.runs.setStatus(ctx.runId, "done");
1619
+ {
1620
+ const t = await emitTurn();
1621
+ if (t) yield t;
1622
+ }
1623
+ yield await emit(ev("swarm.status", base(ctx, ts++), { state: "done" }));
1624
+ }
967
1625
  } catch (err) {
968
- console.error(`[nightowls] run ${ctx.runId} threw:`, err);
1626
+ const stage = err instanceof ReserveDenied ? "reserve" : "exception";
1627
+ if (stage !== "reserve") console.error(`[@nightowlsdev/core] run ${ctx.runId} threw:`, err);
969
1628
  try {
970
1629
  await this.opts.storage.runs.setStatus(ctx.runId, "failed");
971
1630
  } catch {
972
1631
  }
973
- yield await emit(ev("swarm.run_failed", base(ctx, ts++), { stage: "exception", message: errMessage(err), retryable: false }));
1632
+ {
1633
+ const t = await emitTurn();
1634
+ if (t) yield t;
1635
+ }
1636
+ yield await emit(ev("swarm.run_failed", base(ctx, ts++), { stage, message: errMessage(err), retryable: false }));
974
1637
  } finally {
975
1638
  floorAbort.abort();
976
1639
  await releaseFloor?.();
977
1640
  await exportSpans(this.opts.telemetry, collector);
978
1641
  }
979
1642
  }
1643
+ /**
1644
+ * Phase B — drive a STRICT workflow IN PLACE OF the free-form continue-nudge loop. Shared by `run()` (fresh)
1645
+ * and `resume()` (re-entry after a human/approval suspend). An `agent` step reuses `this.agent().stream()`
1646
+ * with a per-step requestContext (agentSlug = the step's agent) so it inherits persona/tools/gate/model/cost;
1647
+ * a `tool` step runs `executeToolWithGate`; a `human`/approval pause suspends SP9-style. Reserve, usage, and
1648
+ * the terminal turn_usage flow through the caller's machinery (`m`). Handles the terminal status/setStatus.
1649
+ */
1650
+ async *driveWorkflow(wf, state, ctx, input, m) {
1651
+ const driver = new StepDriver(wf, {
1652
+ nextTs: m.nextTs,
1653
+ runAgentStep: (slug, message, genIndex) => this.streamWorkflowAgentStep(slug, message, genIndex, ctx, input, m),
1654
+ runToolStep: (toolName, args) => this.runWorkflowToolStep(toolName, args, ctx),
1655
+ saveState: (rid, st) => this.opts.storage.runs.saveSnapshot(rid, { workflow: st })
1656
+ });
1657
+ const it = driver.drive(state, ctx, input);
1658
+ let r = await it.next();
1659
+ while (!r.done) {
1660
+ yield await m.emit(r.value);
1661
+ r = await it.next();
1662
+ }
1663
+ const outcome = r.value;
1664
+ if (outcome.kind === "suspended") {
1665
+ const p = outcome.state.pending;
1666
+ await recordSuspend(this.opts.storage, ctx, p.followupId, p.toolCallId);
1667
+ await this.opts.storage.runs.setStatus(ctx.runId, "suspended");
1668
+ yield await m.emit(ev("swarm.status", base(ctx, m.nextTs()), { state: "waiting" }));
1669
+ {
1670
+ const t = await m.emitTurn();
1671
+ if (t) yield t;
1672
+ }
1673
+ return;
1674
+ }
1675
+ if (outcome.kind === "failed") {
1676
+ await this.opts.storage.runs.setStatus(ctx.runId, "failed");
1677
+ {
1678
+ const t = await m.emitTurn();
1679
+ if (t) yield t;
1680
+ }
1681
+ yield await m.emit(ev("swarm.run_failed", base(ctx, m.nextTs()), { stage: outcome.stage, message: outcome.message, retryable: true }));
1682
+ return;
1683
+ }
1684
+ await this.mirrorDelegations(ctx);
1685
+ await this.attributeRun(ctx);
1686
+ await this.opts.storage.runs.setStatus(ctx.runId, "done");
1687
+ {
1688
+ const t = await m.emitTurn();
1689
+ if (t) yield t;
1690
+ }
1691
+ yield await m.emit(ev("swarm.status", base(ctx, m.nextTs()), { state: "done" }));
1692
+ }
1693
+ /** A workflow `agent` step: stream `slug` with `message` (a per-step requestContext so it inherits the agent's
1694
+ * persona/tools/gate/model), reserving + metering through the caller's machinery, returning the final text. */
1695
+ async *streamWorkflowAgentStep(slug, message, genIndex, ctx, input, m) {
1696
+ await this.guardGeneration({ runId: ctx.runId, tenantId: ctx.tenantId, agentSlug: slug, modelId: m.modelIdFor(slug), generationIndex: genIndex, kind: "run" });
1697
+ const sctx = { ...ctx, agentSlug: slug };
1698
+ const stepRc = this.requestContext(sctx);
1699
+ if (this.opts.pageContext) attachPageContext(stepRc, input.context);
1700
+ const result = await this.agent().stream(message, { runId: ctx.runId, requestContext: stepRc, ...this.memoryOpts(sctx) });
1701
+ let text = "";
1702
+ for await (const part of result.fullStream) {
1703
+ if (part?.type === "step-finish") m.gov.step();
1704
+ for (const e of mapChunk(part, sctx, m.gov, m.modelIdFor, m.nextTs, m.streamed, m.delegateBudgets, m.activity, m.gatesApproval, m.turnUsage)) {
1705
+ if (e.type === "swarm.message") text += e.data.delta ?? e.data.text ?? "";
1706
+ yield e;
1707
+ }
1708
+ }
1709
+ return { text };
1710
+ }
1711
+ /** A workflow `tool` step: run the gate-free tool body through `executeToolWithGate` (the engine-owned gate). */
1712
+ async runWorkflowToolStep(toolName, args, ctx) {
1713
+ const skill = this.opts.resolveSkill?.(toolName);
1714
+ const exec = skill ? getToolExecutor(skill) : void 0;
1715
+ if (!skill || !exec) return { ok: false, error: `unknown tool "${toolName}"` };
1716
+ const toolCtx = { tenantId: ctx.tenantId, userId: ctx.userId, runId: ctx.runId, secrets: bindSecrets(this.opts.secrets, ctx) };
1717
+ return executeToolWithGate({
1718
+ ev: toolPreCallEvent({ runId: ctx.runId, tenantId: ctx.tenantId, agentSlug: ctx.agentSlug, toolName, origin: skill.origin ?? "first-party", needsApproval: this.gatesApproval(toolName), args }),
1719
+ gate: this.toolGate,
1720
+ run: () => exec(args, toolCtx)
1721
+ });
1722
+ }
980
1723
  async *resume(args, ctx) {
981
1724
  const snap = await this.opts.storage.runs.loadSnapshot(ctx.tenantId, args.runId);
982
1725
  if (!snap) throw new Error(`no suspended run: ${args.runId}`);
1726
+ const generationIndex = typeof snap.genIndex === "number" ? snap.genIndex : 1;
1727
+ const capHit = snap.capHit;
983
1728
  await this.opts.storage.markFollowupAnswered?.(args.followupId, ctx.tenantId);
984
1729
  await this.opts.storage.runs.setStatus(args.runId, "running");
985
- const modelId = (await this.loadRow(ctx.tenantId, ctx.agentSlug)).modelId ?? "unknown";
986
- const modelIdFor = (slug) => this.rowCache.peek(`${ctx.tenantId}:${slug}`)?.modelId ?? modelId;
1730
+ const modelId = this.priceModelId((await this.loadRow(ctx.tenantId, ctx.agentSlug)).modelId ?? "unknown", ctx.tenantId, ctx.agentSlug);
1731
+ const modelIdFor = (slug) => {
1732
+ const raw = this.rowCache.peek(`${ctx.tenantId}:${slug}`)?.modelId;
1733
+ return raw ? this.priceModelId(raw, ctx.tenantId, slug) : modelId;
1734
+ };
1735
+ const gatesApproval = (name) => this.gatesApproval(name);
987
1736
  const gov = new CostGovernor(this.opts.cost);
988
- const delegateBudgets = this.opts.cost.perDelegate ? new DelegateBudgets(this.opts.cost.perDelegate, ctx.agentSlug) : null;
1737
+ const delegateBudgets = this.opts.cost.perDelegate ? new DelegateBudgets(this.opts.cost.perDelegate, ctx.agentSlug, this.pricingOpts()) : null;
989
1738
  const streamed = /* @__PURE__ */ new Set();
1739
+ const activity = /* @__PURE__ */ new Map();
1740
+ const turnUsage = [];
990
1741
  const collector = this.opts.telemetry ? new SpanCollector(args.runId, () => Date.now(), "resume", { agentSlug: ctx.agentSlug }) : null;
991
1742
  let ts = 1e3;
992
1743
  const emit = async (e) => {
993
1744
  e.seq = await this.opts.storage.events.append(e);
1745
+ await this.notifyEvent(e, ctx);
994
1746
  return e;
995
1747
  };
1748
+ let turnEmitted = false;
996
1749
  const floorKey = ctx.threadId;
997
1750
  const me = { label: titleCase(ctx.agentSlug), runId: args.runId };
998
1751
  const floorAbort = new AbortController();
999
1752
  let releaseFloor = await this.floor.tryAcquire(floorKey, me);
1000
1753
  const rctx = { ...ctx, runId: args.runId };
1754
+ const emitTurn = async () => {
1755
+ if (turnEmitted) return null;
1756
+ turnEmitted = true;
1757
+ return emit(turnUsageEvent(rctx, ts++, turnUsage, generationIndex));
1758
+ };
1001
1759
  try {
1002
1760
  if (!releaseFloor) {
1003
1761
  const held = await this.floor.holder(floorKey);
@@ -1013,71 +1771,200 @@ var SwarmEngine = class {
1013
1771
  answer: args.answer
1014
1772
  })
1015
1773
  );
1016
- const rc = this.requestContext({ ...ctx, runId: args.runId });
1017
- if (this.opts.pageContext) attachPageContext(rc, args.context);
1018
- const result = await this.agent().resumeStream(
1019
- { answer: args.answer },
1020
- { runId: args.runId, toolCallId: args.toolCallId, requestContext: rc, ...this.memoryOpts({ ...ctx, runId: args.runId }) }
1021
- );
1022
- for await (const part of result.fullStream) {
1023
- if (part?.type === "step-finish") gov.step();
1024
- if (part?.type === "tool-call-suspended") {
1025
- const payload = part.payload ?? {};
1026
- const toolCallId = payload.toolCallId ?? "";
1027
- const followupId = `${args.runId}:${toolCallId}`;
1028
- const sp = payload.suspendPayload ?? {};
1029
- await recordSuspend(this.opts.storage, rctx, followupId, toolCallId);
1030
- await this.opts.storage.runs.setStatus(args.runId, "suspended");
1031
- await this.opts.storage.runs.saveSnapshot(args.runId, { pending: { toolCallId } });
1032
- yield await emit(ev("swarm.status", base(rctx, ts++), { state: "waiting" }));
1033
- yield await emit(
1034
- ev("swarm.question", base(rctx, ts++), {
1035
- followupId,
1036
- toolCallId,
1037
- to: sp.to ?? "user",
1038
- from: sp.asker || rctx.agentSlug,
1039
- prompt: sp.prompt ?? "",
1040
- field: sp.field
1041
- })
1042
- );
1043
- return;
1044
- }
1045
- if (part?.type === "error") {
1774
+ const wfState = snap.workflow;
1775
+ if (wfState) {
1776
+ const wf = this.opts.workflows?.find((w) => w.name === wfState.workflow) ?? this.opts.agentWorkflows?.[ctx.agentSlug];
1777
+ if (!wf) {
1046
1778
  await this.opts.storage.runs.setStatus(args.runId, "failed");
1047
- yield await emit(
1048
- ev("swarm.run_failed", base(rctx, ts++), {
1049
- stage: "stream",
1050
- message: streamErrorMessage(part),
1051
- retryable: false
1052
- })
1053
- );
1779
+ {
1780
+ const t = await emitTurn();
1781
+ if (t) yield t;
1782
+ }
1783
+ yield await emit(ev("swarm.run_failed", base(rctx, ts++), { stage: "workflow", message: `unknown workflow: ${wfState.workflow}`, retryable: false }));
1054
1784
  return;
1055
1785
  }
1056
- collectSpans(collector, part, modelId, gov);
1057
- for (const e of mapChunk(part, rctx, gov, modelIdFor, () => ts++, streamed, delegateBudgets)) yield await emit(e);
1058
- const overDelegate = delegateBudgets?.exceeded();
1059
- if (gov.shouldStop().stop || overDelegate) {
1060
- await this.opts.storage.runs.setStatus(args.runId, "failed");
1061
- yield await emit(
1062
- ev("swarm.run_failed", base(rctx, ts++), {
1063
- stage: "cost",
1064
- message: overDelegate?.reason ?? gov.shouldStop().reason,
1065
- retryable: false
1066
- })
1067
- );
1068
- return;
1786
+ if (wfState.pending) {
1787
+ wfState.outputs[wfState.pending.stepId] = args.answer;
1788
+ wfState.pending = void 0;
1789
+ }
1790
+ yield* this.driveWorkflow(wf, wfState, rctx, { message: "", context: args.context }, {
1791
+ gov,
1792
+ modelIdFor,
1793
+ streamed,
1794
+ delegateBudgets,
1795
+ activity,
1796
+ gatesApproval,
1797
+ turnUsage,
1798
+ nextTs: () => ts++,
1799
+ emit,
1800
+ emitTurn
1801
+ });
1802
+ return;
1803
+ }
1804
+ if (capHit && !isApproved(args.answer)) {
1805
+ await this.opts.storage.runs.setStatus(args.runId, "failed");
1806
+ {
1807
+ const t = await emitTurn();
1808
+ if (t) yield t;
1809
+ }
1810
+ yield await emit(
1811
+ ev("swarm.run_failed", base(rctx, ts++), {
1812
+ stage: "cost",
1813
+ message: "budget cap reached \u2014 continuation declined by the user",
1814
+ retryable: false
1815
+ })
1816
+ );
1817
+ return;
1818
+ }
1819
+ const rc = this.requestContext({ ...ctx, runId: args.runId });
1820
+ if (this.opts.pageContext) attachPageContext(rc, args.context);
1821
+ await this.guardGeneration({ runId: args.runId, tenantId: ctx.tenantId, agentSlug: ctx.agentSlug, modelId, generationIndex, kind: "resume" });
1822
+ let resumeNudges = 0;
1823
+ let firstPass = true;
1824
+ const request = await this.recallRequest(rctx);
1825
+ let nudgeMessage = CONTINUE_NUDGE;
1826
+ let transcript = "";
1827
+ let incompleteVerdict = null;
1828
+ for (; ; ) {
1829
+ const result = firstPass ? capHit ? await (async () => {
1830
+ gov.raiseCostCap(this.opts.cost.capIncrementUsd ?? this.opts.cost.maxCostUsd);
1831
+ return this.agent().stream(capHit.message, { runId: args.runId, requestContext: rc, ...this.memoryOpts({ ...ctx, runId: args.runId }) });
1832
+ })() : await this.agent().resumeStream(
1833
+ { answer: args.answer },
1834
+ { runId: args.runId, toolCallId: args.toolCallId, requestContext: rc, ...this.memoryOpts({ ...ctx, runId: args.runId }) }
1835
+ ) : await this.agent().stream(nudgeMessage, { runId: args.runId, requestContext: rc, ...this.memoryOpts({ ...ctx, runId: args.runId }) });
1836
+ firstPass = false;
1837
+ let sawStep = false;
1838
+ let lastOutputSlug;
1839
+ for await (const part of result.fullStream) {
1840
+ if (part?.type === "step-finish") {
1841
+ gov.step();
1842
+ sawStep = true;
1843
+ }
1844
+ if (part?.type === "tool-call-suspended") {
1845
+ const payload = part.payload ?? {};
1846
+ const toolCallId = payload.toolCallId ?? "";
1847
+ const followupId = `${args.runId}:${toolCallId}`;
1848
+ const sp = payload.suspendPayload ?? {};
1849
+ await recordSuspend(this.opts.storage, rctx, followupId, toolCallId);
1850
+ await this.opts.storage.runs.setStatus(args.runId, "suspended");
1851
+ await this.opts.storage.runs.saveSnapshot(args.runId, { pending: { toolCallId }, genIndex: generationIndex + 1 });
1852
+ {
1853
+ const t = await emitTurn();
1854
+ if (t) yield t;
1855
+ }
1856
+ yield await emit(ev("swarm.status", base(rctx, ts++), { state: "waiting" }));
1857
+ yield await emit(
1858
+ ev("swarm.question", base(rctx, ts++), {
1859
+ followupId,
1860
+ toolCallId,
1861
+ to: sp.to ?? "user",
1862
+ from: sp.asker || rctx.agentSlug,
1863
+ prompt: sp.prompt ?? "",
1864
+ field: sp.field
1865
+ })
1866
+ );
1867
+ return;
1868
+ }
1869
+ if (part?.type === "error") {
1870
+ await this.opts.storage.runs.setStatus(args.runId, "failed");
1871
+ {
1872
+ const t = await emitTurn();
1873
+ if (t) yield t;
1874
+ }
1875
+ yield await emit(
1876
+ ev("swarm.run_failed", base(rctx, ts++), {
1877
+ stage: "stream",
1878
+ message: streamErrorMessage(part),
1879
+ retryable: false
1880
+ })
1881
+ );
1882
+ return;
1883
+ }
1884
+ collectSpans(collector, part, modelId, gov);
1885
+ for (const e of mapChunk(part, rctx, gov, modelIdFor, () => ts++, streamed, delegateBudgets, activity, gatesApproval, turnUsage)) {
1886
+ if (e.type === "swarm.message" || e.type === "swarm.tool_call") {
1887
+ lastOutputSlug = e.agentSlug;
1888
+ if (this.opts.verifyCompletion) transcript = appendTranscript(transcript, e);
1889
+ }
1890
+ yield await emit(e);
1891
+ }
1892
+ const overDelegate = delegateBudgets?.exceeded();
1893
+ const stop = gov.shouldStop();
1894
+ if (stop.stop || overDelegate) {
1895
+ if (this.opts.cost.onCapHit === "ask" && stop.stop && !overDelegate && capHit) {
1896
+ const followupId = `${args.runId}:${CAP_FOLLOWUP_SUFFIX}`;
1897
+ await recordSuspend(this.opts.storage, rctx, followupId, CAP_FOLLOWUP_SUFFIX);
1898
+ await this.opts.storage.runs.setStatus(args.runId, "suspended");
1899
+ await this.opts.storage.runs.saveSnapshot(args.runId, {
1900
+ capHit: { message: capHit.message, spentUsd: gov.costUsd() },
1901
+ genIndex: generationIndex + 1
1902
+ });
1903
+ {
1904
+ const t = await emitTurn();
1905
+ if (t) yield t;
1906
+ }
1907
+ yield await emit(ev("swarm.status", base(rctx, ts++), { state: "waiting" }));
1908
+ yield await emit(ev("swarm.question", base(rctx, ts++), capQuestion(rctx, followupId, gov)));
1909
+ return;
1910
+ }
1911
+ await this.opts.storage.runs.setStatus(args.runId, "failed");
1912
+ {
1913
+ const t = await emitTurn();
1914
+ if (t) yield t;
1915
+ }
1916
+ yield await emit(
1917
+ ev("swarm.run_failed", base(rctx, ts++), {
1918
+ stage: "cost",
1919
+ message: overDelegate?.reason ?? stop.reason,
1920
+ retryable: false
1921
+ })
1922
+ );
1923
+ return;
1924
+ }
1069
1925
  }
1926
+ if (this.opts.verifyCompletion) {
1927
+ const verdict = await this.safeVerify(request, transcript, rctx);
1928
+ if (!verdict.complete && resumeNudges < MAX_CONTINUE_NUDGES) {
1929
+ resumeNudges++;
1930
+ nudgeMessage = verifyNudge(verdict.missing);
1931
+ continue;
1932
+ }
1933
+ incompleteVerdict = verdict.complete ? null : verdict;
1934
+ } else if (sawStep && lastOutputSlug !== rctx.agentSlug && resumeNudges < MAX_CONTINUE_NUDGES) {
1935
+ resumeNudges++;
1936
+ continue;
1937
+ }
1938
+ break;
1070
1939
  }
1071
1940
  await this.attributeRun(rctx);
1072
- await this.opts.storage.runs.setStatus(args.runId, "done");
1073
- yield await emit(ev("swarm.status", base(rctx, ts++), { state: "done" }));
1941
+ if (incompleteVerdict) {
1942
+ await this.opts.storage.runs.setStatus(args.runId, "failed");
1943
+ {
1944
+ const t = await emitTurn();
1945
+ if (t) yield t;
1946
+ }
1947
+ yield await emit(ev("swarm.run_failed", base(rctx, ts++), { stage: "incomplete", message: incompleteVerdict.missing ?? "The task was not completed.", retryable: true }));
1948
+ } else {
1949
+ await this.opts.storage.runs.setStatus(args.runId, "done");
1950
+ {
1951
+ const t = await emitTurn();
1952
+ if (t) yield t;
1953
+ }
1954
+ yield await emit(ev("swarm.status", base(rctx, ts++), { state: "done" }));
1955
+ }
1074
1956
  } catch (err) {
1075
- console.error(`[nightowls] resume ${args.runId} threw:`, err);
1957
+ const stage = err instanceof ReserveDenied ? "reserve" : "exception";
1958
+ if (stage !== "reserve") console.error(`[@nightowlsdev/core] resume ${args.runId} threw:`, err);
1076
1959
  try {
1077
1960
  await this.opts.storage.runs.setStatus(args.runId, "failed");
1078
1961
  } catch {
1079
1962
  }
1080
- yield await emit(ev("swarm.run_failed", base(rctx, ts++), { stage: "exception", message: errMessage(err), retryable: false }));
1963
+ {
1964
+ const t = await emitTurn();
1965
+ if (t) yield t;
1966
+ }
1967
+ yield await emit(ev("swarm.run_failed", base(rctx, ts++), { stage, message: errMessage(err), retryable: false }));
1081
1968
  } finally {
1082
1969
  floorAbort.abort();
1083
1970
  await releaseFloor?.();
@@ -1085,12 +1972,57 @@ var SwarmEngine = class {
1085
1972
  }
1086
1973
  }
1087
1974
  };
1975
+ var ReserveDenied = class extends Error {
1976
+ stage = "reserve";
1977
+ constructor(reason) {
1978
+ super(reason);
1979
+ this.name = "ReserveDenied";
1980
+ }
1981
+ };
1088
1982
  function errMessage(err) {
1089
1983
  return err instanceof Error ? err.message : String(err);
1090
1984
  }
1091
1985
  function base(ctx, ts) {
1092
1986
  return { runId: ctx.runId, agentSlug: ctx.agentSlug, ts };
1093
1987
  }
1988
+ var CAP_FOLLOWUP_SUFFIX = "cap";
1989
+ function capQuestion(ctx, followupId, gov) {
1990
+ const spent = gov.costUsd();
1991
+ const cap = gov.maxCostUsd;
1992
+ return {
1993
+ followupId,
1994
+ toolCallId: CAP_FOLLOWUP_SUFFIX,
1995
+ to: "user",
1996
+ from: ctx.agentSlug,
1997
+ prompt: `Budget cap reached \u2014 continue? This run has spent **$${spent.toFixed(2)}** of its $${cap.toFixed(2)} budget. Approve to grant more budget and keep going, or decline to stop the run here.`,
1998
+ field: {
1999
+ kind: "confirm",
2000
+ confirmLabel: "Continue",
2001
+ rejectLabel: "Stop"
2002
+ }
2003
+ };
2004
+ }
2005
+ function turnUsageEvent(ctx, ts, turnUsage, segmentIndex) {
2006
+ const total = sumTurnUsage(turnUsage);
2007
+ return ev("swarm.turn_usage", base(ctx, ts), {
2008
+ breakdown: total.breakdown,
2009
+ cost: total.cost,
2010
+ bySlug: total.bySlug,
2011
+ generations: turnUsage.length,
2012
+ segmentIndex
2013
+ });
2014
+ }
2015
+ function extractUsage(usage) {
2016
+ const u = usage ?? {};
2017
+ const cacheRead = u.cachedInputTokens ?? u.inputTokenDetails?.cacheReadTokens ?? u.raw?.inputTokenDetails?.cacheReadTokens;
2018
+ const cacheWrite = u.inputTokenDetails?.cacheWriteTokens ?? u.raw?.inputTokenDetails?.cacheWriteTokens;
2019
+ const reasoning = u.reasoningTokens ?? u.outputTokenDetails?.reasoningTokens ?? u.raw?.outputTokenDetails?.reasoningTokens;
2020
+ const b = { inputTokens: u.inputTokens ?? 0, outputTokens: u.outputTokens ?? 0 };
2021
+ if (cacheRead != null) b.cacheReadTokens = cacheRead;
2022
+ if (cacheWrite != null) b.cacheWriteTokens = cacheWrite;
2023
+ if (reasoning != null) b.reasoningTokens = reasoning;
2024
+ return b;
2025
+ }
1094
2026
  function titleCase(slug) {
1095
2027
  return slug.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
1096
2028
  }
@@ -1122,8 +2054,7 @@ function collectSpans(collector, part, modelId, gov) {
1122
2054
  break;
1123
2055
  case "step-finish": {
1124
2056
  const output = p.output;
1125
- const usage = output?.usage;
1126
- const u = { inputTokens: usage?.inputTokens ?? 0, outputTokens: usage?.outputTokens ?? 0 };
2057
+ const u = extractUsage(output?.usage);
1127
2058
  collector.openGeneration(modelId);
1128
2059
  collector.closeGeneration(u, gov.priceOf(modelId, u));
1129
2060
  break;
@@ -1144,14 +2075,19 @@ async function recordSuspend(storage, ctx, followupId, toolCallId) {
1144
2075
  if (!storage.recordSuspend && !warnedNoRecordSuspend) {
1145
2076
  warnedNoRecordSuspend = true;
1146
2077
  console.warn(
1147
- "[nightowls] storage adapter does not implement recordSuspend() \u2014 human-in-the-loop resume will be forbidden (the followup index is never written). Implement recordSuspend on your StorageAdapter."
2078
+ "[@nightowlsdev/core] storage adapter does not implement recordSuspend() \u2014 human-in-the-loop resume will be forbidden (the followup index is never written). Implement recordSuspend on your StorageAdapter."
1148
2079
  );
1149
2080
  }
1150
2081
  await storage.recordSuspend?.(ctx.runId, ctx.tenantId, followupId, toolCallId);
1151
2082
  }
1152
- function mapChunk(part, ctx, gov, modelIdFor, nextTs, streamed, delegateBudgets) {
2083
+ function mapChunk(part, ctx, gov, modelIdFor, nextTs, streamed, delegateBudgets, activity, gatesApproval, turnUsage) {
1153
2084
  const p = part.payload ?? {};
1154
2085
  const modelId = modelIdFor(ctx.agentSlug);
2086
+ const act = (slug) => {
2087
+ let a = activity.get(slug);
2088
+ if (!a) activity.set(slug, a = { toolCalls: 0, agentActivations: 0 });
2089
+ return a;
2090
+ };
1155
2091
  switch (part.type) {
1156
2092
  case "text-delta":
1157
2093
  return [ev("swarm.message", base(ctx, nextTs()), { role: "assistant", delta: p.text ?? "" })];
@@ -1161,17 +2097,21 @@ function mapChunk(part, ctx, gov, modelIdFor, nextTs, streamed, delegateBudgets)
1161
2097
  const to = name.slice("agent-".length);
1162
2098
  const a = typeof p.args === "string" ? safeParse(p.args) : p.args;
1163
2099
  const task = a?.prompt ?? "";
2100
+ act(ctx.agentSlug).agentActivations++;
1164
2101
  return [
1165
2102
  ev("swarm.handoff", base(ctx, nextTs()), { from: ctx.agentSlug, to, task }),
1166
2103
  ev("swarm.status", base(ctx, nextTs()), { state: "delegating", note: to })
1167
2104
  ];
1168
2105
  }
2106
+ act(ctx.agentSlug).toolCalls++;
1169
2107
  return [
1170
2108
  ev("swarm.tool_call", base(ctx, nextTs()), {
1171
2109
  toolCallId: p.toolCallId,
1172
2110
  name,
1173
2111
  args: p.args,
1174
- needsApproval: false
2112
+ // SP5 truth-fix: emit the RESOLVED needsApproval (policy + the tool's flag), not a hardcoded false, so
2113
+ // the UI reflects reality — a tool that will suspend-for-approval is shown as needing approval.
2114
+ needsApproval: gatesApproval(name)
1175
2115
  })
1176
2116
  ];
1177
2117
  }
@@ -1200,9 +2140,18 @@ function mapChunk(part, ctx, gov, modelIdFor, nextTs, streamed, delegateBudgets)
1200
2140
  const output = p.output;
1201
2141
  const usage = output?.usage;
1202
2142
  if (usage) {
1203
- const u = { inputTokens: usage.inputTokens ?? 0, outputTokens: usage.outputTokens ?? 0 };
2143
+ const counters = activity.get(ctx.agentSlug);
2144
+ const u = extractUsage(usage);
2145
+ if (counters && (counters.toolCalls || counters.agentActivations)) {
2146
+ u.toolCalls = counters.toolCalls;
2147
+ u.agentActivations = counters.agentActivations;
2148
+ }
2149
+ activity.delete(ctx.agentSlug);
1204
2150
  gov.addUsage(modelId, u);
1205
2151
  delegateBudgets?.addUsage(ctx.agentSlug, modelId, u);
2152
+ const cost = gov.costOf(modelId, u);
2153
+ turnUsage.push({ slug: ctx.agentSlug, breakdown: u, cost });
2154
+ return [ev("swarm.usage", base(ctx, nextTs()), { slug: ctx.agentSlug, modelId, breakdown: u, cost })];
1206
2155
  }
1207
2156
  return [];
1208
2157
  }
@@ -1213,7 +2162,7 @@ function mapChunk(part, ctx, gov, modelIdFor, nextTs, streamed, delegateBudgets)
1213
2162
  const inner = p.output;
1214
2163
  if (!inner || typeof inner.type !== "string") return [];
1215
2164
  if (inner.type === "text-delta") streamed.add(p.toolCallId);
1216
- return mapChunk(inner, { ...ctx, agentSlug: subSlug }, gov, modelIdFor, nextTs, streamed, delegateBudgets);
2165
+ return mapChunk(inner, { ...ctx, agentSlug: subSlug }, gov, modelIdFor, nextTs, streamed, delegateBudgets, activity, gatesApproval, turnUsage);
1217
2166
  }
1218
2167
  case "tool-error": {
1219
2168
  const name = p.toolName ?? "";
@@ -1242,8 +2191,125 @@ function allowListModelProvider(opts) {
1242
2191
  };
1243
2192
  }
1244
2193
 
2194
+ // src/rules.ts
2195
+ import {
2196
+ deny,
2197
+ ask,
2198
+ toolPolicyDecision
2199
+ } from "@nightowlsdev/hooks";
2200
+ var GLOB_CACHE = /* @__PURE__ */ new Map();
2201
+ function globRegex(pattern) {
2202
+ let re = GLOB_CACHE.get(pattern);
2203
+ if (!re) {
2204
+ re = new RegExp("^" + pattern.replace(/[.*+?^${}()|[\]\\]/g, (c) => c === "*" ? ".*" : "\\" + c) + "$");
2205
+ GLOB_CACHE.set(pattern, re);
2206
+ }
2207
+ return re;
2208
+ }
2209
+ function globMatch(pattern, value) {
2210
+ if (pattern === value) return true;
2211
+ if (!pattern.includes("*")) return false;
2212
+ return globRegex(pattern).test(value);
2213
+ }
2214
+ function matchField(field, value) {
2215
+ if (field === void 0) return true;
2216
+ const arr = Array.isArray(field) ? field : [field];
2217
+ return arr.some((p) => globMatch(p, value));
2218
+ }
2219
+ function ruleMatchesTool(rule, ev2) {
2220
+ if (rule.seam !== "tool") return false;
2221
+ if (rule.scopeAgent !== void 0 && rule.scopeAgent !== ev2.agentSlug) return false;
2222
+ const w = rule.when;
2223
+ if (!matchField(w.agent, ev2.agentSlug)) return false;
2224
+ if (!matchField(w.tool, ev2.toolName)) return false;
2225
+ if (w.origin !== void 0 && w.origin !== ev2.origin) return false;
2226
+ return true;
2227
+ }
2228
+ function ruleMatchesGeneration(rule, ev2) {
2229
+ if (rule.seam !== "generation") return false;
2230
+ if (rule.scopeAgent !== void 0 && rule.scopeAgent !== ev2.agentSlug) return false;
2231
+ const w = rule.when;
2232
+ if (!matchField(w.agent, ev2.agentSlug)) return false;
2233
+ if (!matchField(w.model, ev2.modelId)) return false;
2234
+ return true;
2235
+ }
2236
+ var TOOL_RANK = { deny: 2, ask: 1, allow: 0 };
2237
+ function mostRestrictiveTool(a, b) {
2238
+ return TOOL_RANK[b.action] > TOOL_RANK[a.action] ? b : a;
2239
+ }
2240
+ function errMessage2(err) {
2241
+ return err instanceof Error ? err.message : String(err);
2242
+ }
2243
+ function composeToolHooks(opts) {
2244
+ const rules = opts.rules.filter((r) => r.seam === "tool" && r.level === "enforce");
2245
+ return async (ev2) => {
2246
+ let decision = toolPolicyDecision(ev2, opts.policy);
2247
+ if (opts.host) {
2248
+ try {
2249
+ decision = mostRestrictiveTool(decision, await opts.host(ev2));
2250
+ } catch (err) {
2251
+ return deny(`preToolCall hook threw: ${errMessage2(err)}`);
2252
+ }
2253
+ }
2254
+ if (decision.action === "deny") return decision;
2255
+ for (const r of rules) {
2256
+ if (!ruleMatchesTool(r, ev2)) continue;
2257
+ const rd = r.action.do === "deny" ? deny(r.action.reason ?? r.statement) : ask(r.action.reason ?? r.statement);
2258
+ decision = mostRestrictiveTool(decision, rd);
2259
+ if (decision.action === "deny") return decision;
2260
+ }
2261
+ return decision;
2262
+ };
2263
+ }
2264
+ function composeGenerationHooks(opts) {
2265
+ const rules = opts.rules.filter((r) => r.seam === "generation" && r.level === "enforce");
2266
+ if (!rules.length && !opts.host) return void 0;
2267
+ return async (ev2) => {
2268
+ if (opts.host) {
2269
+ try {
2270
+ const d = await opts.host(ev2);
2271
+ if (d.action === "deny") return d;
2272
+ } catch (err) {
2273
+ return deny(`preGeneration hook threw: ${errMessage2(err)}`);
2274
+ }
2275
+ }
2276
+ for (const r of rules) {
2277
+ if (ruleMatchesGeneration(r, ev2)) return deny(r.action?.reason ?? r.statement);
2278
+ }
2279
+ return { action: "allow" };
2280
+ };
2281
+ }
2282
+ function softPolicyFor(slug, rules, workflows) {
2283
+ const out = [];
2284
+ for (const r of rules) {
2285
+ if (r.level !== "advise") continue;
2286
+ if (r.scopeAgent !== void 0 && r.scopeAgent !== slug) continue;
2287
+ if (!matchField(r.when.agent, slug)) continue;
2288
+ out.push(r.statement);
2289
+ }
2290
+ for (const w of workflows) {
2291
+ if (w.compliance !== "advisory" || !w.description) continue;
2292
+ if (w.scopeAgent !== void 0 && w.scopeAgent !== slug) continue;
2293
+ out.push(`Suggested procedure "${w.name}": ${w.description}`);
2294
+ }
2295
+ return out;
2296
+ }
2297
+
1245
2298
  // src/define.ts
1246
2299
  var MASTRA = /* @__PURE__ */ new WeakMap();
2300
+ var APPROVAL_SUSPEND_SCHEMA = z4.object({
2301
+ to: z4.string(),
2302
+ prompt: z4.string(),
2303
+ field: z4.object({
2304
+ kind: z4.enum(["confirm", "buttons", "select", "multiselect", "number", "text", "textarea"]),
2305
+ confirmLabel: z4.string().optional(),
2306
+ rejectLabel: z4.string().optional()
2307
+ }).optional(),
2308
+ asker: z4.string().optional(),
2309
+ kind: z4.literal("approval").optional(),
2310
+ toolName: z4.string().optional()
2311
+ });
2312
+ var APPROVAL_RESUME_SCHEMA = z4.object({ answer: z4.any() });
1247
2313
  function defineTool(spec) {
1248
2314
  const origin = spec.origin ?? "first-party";
1249
2315
  const needsApproval = spec.needsApproval ?? origin === "mcp";
@@ -1252,26 +2318,89 @@ function defineTool(spec) {
1252
2318
  description: spec.description ?? spec.name,
1253
2319
  inputSchema: spec.inputSchema,
1254
2320
  outputSchema: spec.outputSchema,
2321
+ // SP5: declare suspend/resume schemas so the action-approval gate can suspend-and-ask via
2322
+ // context.agent.suspend (Mastra gates `suspend` on a declared suspendSchema — same as the built-in `ask`).
2323
+ // The suspend payload is `ask`-shaped (+ approval metadata) so the engine emits the existing `swarm.question`;
2324
+ // resume carries the human's `{ answer }` (a confirm → boolean approve/reject).
2325
+ suspendSchema: APPROVAL_SUSPEND_SCHEMA,
2326
+ resumeSchema: APPROVAL_RESUME_SCHEMA,
1255
2327
  // Mastra 1.38 (per SPIKE-FINDINGS item 3): execute is `(inputData, context) => out`
1256
2328
  // with TWO positional args. `inputData` is the parsed input; tenant/user/run come
1257
- // off `context.requestContext`.
2329
+ // off `context.requestContext`. `context.agent` (when an agent drives the call) carries the
2330
+ // suspend/resumeData handles SP5's action-approval gate uses to suspend-and-ask the human.
2331
+ //
2332
+ // SP5 — the mandatory action-approval HITL gate (the enforcement POINT). Before the side effect runs we
2333
+ // resolve the effective ToolDecision via the per-run `ToolGate` the engine injected on the RequestContext
2334
+ // (policy + the resolved needsApproval + the preToolCall hook):
2335
+ // • allow → execute as today.
2336
+ // • deny → return a blocked tool-result; the side effect NEVER runs.
2337
+ // • ask → SUSPEND via context.agent.suspend(...) with an `ask`-SHAPED payload, so the engine's existing
2338
+ // `tool-call-suspended` handler emits the SAME `swarm.question`; on resume the tool re-executes
2339
+ // with context.agent.resumeData = { answer } → approve runs the side effect, reject blocks it.
2340
+ // We reuse the EXISTING suspend/resume + question/answer machinery — no parallel one.
1258
2341
  execute: async (inputData, context) => {
1259
2342
  const rc = context?.requestContext;
2343
+ const tenantId = rc?.get?.("tenantId") ?? "default";
2344
+ const userId = rc?.get?.("userId") ?? "";
2345
+ const runId = rc?.get?.("runId") ?? "";
2346
+ const resolver = rc?.get?.(SECRET_RESOLVER_KEY);
2347
+ const scopedCtx = {
2348
+ tenantId,
2349
+ userId,
2350
+ runId,
2351
+ agentSlug: rc?.get?.("agentSlug") ?? "",
2352
+ threadId: rc?.get?.("threadId") ?? "",
2353
+ ...(() => {
2354
+ const v = rc?.get?.("agentVersion");
2355
+ return typeof v === "number" ? { agentVersion: v } : {};
2356
+ })()
2357
+ };
1260
2358
  const ctx = {
1261
- tenantId: rc?.get?.("tenantId") ?? "default",
1262
- userId: rc?.get?.("userId") ?? "",
1263
- runId: rc?.get?.("runId") ?? ""
2359
+ tenantId,
2360
+ userId,
2361
+ runId,
2362
+ secrets: bindSecrets(resolver, scopedCtx)
1264
2363
  };
1265
- return spec.execute(inputData, ctx);
2364
+ const run = () => spec.execute(inputData, ctx);
2365
+ const agentCtx = context?.agent;
2366
+ if (agentCtx?.resumeData !== void 0 && agentCtx.resumeData !== null) {
2367
+ const answer = agentCtx.resumeData.answer;
2368
+ if (isApproved(answer)) return run();
2369
+ throw new ToolBlockedError(spec.name, "rejected by approver");
2370
+ }
2371
+ const gate = rc?.get?.(TOOL_GATE_KEY);
2372
+ if (!gate || typeof agentCtx?.suspend !== "function") return run();
2373
+ const agentSlug = deriveAsker(agentCtx, rc);
2374
+ const decision = await gate(
2375
+ toolPreCallEvent({
2376
+ runId: ctx.runId,
2377
+ tenantId: ctx.tenantId,
2378
+ agentSlug,
2379
+ toolName: spec.name,
2380
+ origin,
2381
+ needsApproval,
2382
+ args: inputData
2383
+ })
2384
+ );
2385
+ if (decision.action === "allow") return run();
2386
+ if (decision.action === "deny") throw new ToolBlockedError(spec.name, decision.reason);
2387
+ await agentCtx.suspend(approvalSuspendPayload({ toolName: spec.name, asker: agentSlug, reason: decision.reason }));
2388
+ throw new ToolBlockedError(spec.name, "awaiting approval");
1266
2389
  }
1267
2390
  });
1268
2391
  const handle = { name: spec.name, needsApproval, origin };
1269
2392
  MASTRA.set(handle, mastraTool);
2393
+ setToolExecutor(handle, (args, c) => spec.execute(args, c));
1270
2394
  return handle;
1271
2395
  }
1272
2396
  function defineSkill(tool) {
1273
2397
  return tool;
1274
2398
  }
2399
+ function deriveAsker(agentCtx, rc) {
2400
+ const agentId = agentCtx?.agentId ?? "";
2401
+ if (agentId.startsWith("swarm-sub-")) return agentId.slice("swarm-sub-".length);
2402
+ return rc?.get?.("agentSlug") ?? "";
2403
+ }
1275
2404
  function __getMastraTool(t) {
1276
2405
  return MASTRA.get(t);
1277
2406
  }
@@ -1282,6 +2411,10 @@ function defineAgent(spec) {
1282
2411
  // The concrete skill handles ride along on the def so defineSwarm can build
1283
2412
  // a per-swarm resolver. No module-level registry → no cross-swarm leakage.
1284
2413
  skills,
2414
+ // Per-agent policy rides on the def (engine-local), stamped with this agent's scope so defineSwarm can
2415
+ // collect + apply it without persisting to the versioned AgentVersion row (D3).
2416
+ ...spec.rules ? { rules: spec.rules.map((r) => ({ ...r, scopeAgent: spec.slug })) } : {},
2417
+ ...spec.workflow ? { workflow: { ...spec.workflow, scopeAgent: spec.slug } } : {},
1285
2418
  head: {
1286
2419
  slug: spec.slug,
1287
2420
  version: 1,
@@ -1295,11 +2428,217 @@ function defineAgent(spec) {
1295
2428
  }
1296
2429
  };
1297
2430
  }
2431
+ function defineRule(spec) {
2432
+ if (!spec.id) throw new Error("defineRule: `id` is required");
2433
+ if (!spec.statement) throw new Error(`defineRule(${spec.id}): \`statement\` is required`);
2434
+ const tools = spec.when.tool === void 0 ? [] : Array.isArray(spec.when.tool) ? spec.when.tool : [spec.when.tool];
2435
+ const seam = spec.on ?? (spec.when.model !== void 0 ? "generation" : "tool");
2436
+ if (spec.level === "enforce") {
2437
+ if (!spec.action) throw new Error(`defineRule(${spec.id}): enforce rules require an \`action\``);
2438
+ if (!spec.on && spec.when.tool === void 0 && spec.when.model === void 0) {
2439
+ throw new Error(`defineRule(${spec.id}): an enforce rule with an empty \`when\` must set \`on\` ("tool" | "generation")`);
2440
+ }
2441
+ if (spec.action.do === "ask") {
2442
+ if (seam !== "tool") throw new Error(`defineRule(${spec.id}): \`ask\` is tool-seam only (preGeneration cannot suspend)`);
2443
+ if (tools.some((t) => t.startsWith("agent-"))) {
2444
+ throw new Error(`defineRule(${spec.id}): \`ask\` cannot target a delegation (\`agent-*\`) \u2014 gateDelegation defers \`ask\`; use \`deny\``);
2445
+ }
2446
+ }
2447
+ }
2448
+ return { id: spec.id, statement: spec.statement, when: spec.when, level: spec.level, action: spec.action, seam };
2449
+ }
2450
+ function workflowRefTargets(step) {
2451
+ const out = [];
2452
+ const scan = (o) => {
2453
+ for (const v of Object.values(o ?? {})) {
2454
+ if (v && typeof v === "object" && "$ref" in v) out.push(String(v.$ref));
2455
+ }
2456
+ };
2457
+ scan(step.args);
2458
+ scan(step.input);
2459
+ if (Array.isArray(step.next)) {
2460
+ for (const t of step.next) if (t.when?.$ref) out.push(t.when.$ref);
2461
+ }
2462
+ return out;
2463
+ }
2464
+ function defineWorkflow(spec) {
2465
+ if (!spec.name) throw new Error("defineWorkflow: `name` is required");
2466
+ if (!spec.steps?.length) throw new Error(`defineWorkflow(${spec.name}): at least one step is required`);
2467
+ const ids = /* @__PURE__ */ new Set();
2468
+ for (const s of spec.steps) {
2469
+ if (ids.has(s.id)) throw new Error(`defineWorkflow(${spec.name}): duplicate step id "${s.id}"`);
2470
+ ids.add(s.id);
2471
+ const kinds = [s.agent !== void 0, s.tool !== void 0, s.human !== void 0].filter(Boolean).length;
2472
+ if (kinds !== 1) throw new Error(`defineWorkflow(${spec.name}): step "${s.id}" must have exactly one of agent/tool/human`);
2473
+ }
2474
+ const start = spec.start ?? spec.steps[0].id;
2475
+ if (!ids.has(start)) throw new Error(`defineWorkflow(${spec.name}): start "${start}" is not a known step`);
2476
+ const nextOf = (s) => {
2477
+ const outs = s.next === void 0 ? [] : typeof s.next === "string" ? [s.next] : s.next.map((t) => t.to);
2478
+ if (s.onError && typeof s.onError === "object" && "to" in s.onError) outs.push(s.onError.to);
2479
+ return outs;
2480
+ };
2481
+ for (const s of spec.steps) {
2482
+ for (const t of nextOf(s)) if (!ids.has(t)) throw new Error(`defineWorkflow(${spec.name}): step "${s.id}" \u2192 unknown step "${t}"`);
2483
+ for (const r of workflowRefTargets(s)) {
2484
+ if (r !== "input" && !(r.startsWith("steps.") && ids.has(r.slice("steps.".length)))) {
2485
+ throw new Error(`defineWorkflow(${spec.name}): step "${s.id}" has an invalid $ref "${r}"`);
2486
+ }
2487
+ }
2488
+ }
2489
+ const byId = new Map(spec.steps.map((s) => [s.id, s]));
2490
+ const seen = /* @__PURE__ */ new Set();
2491
+ const stack = /* @__PURE__ */ new Set();
2492
+ const visit = (id) => {
2493
+ if (stack.has(id)) throw new Error(`defineWorkflow(${spec.name}): cycle detected at step "${id}"`);
2494
+ if (seen.has(id)) return;
2495
+ seen.add(id);
2496
+ stack.add(id);
2497
+ for (const t of nextOf(byId.get(id))) visit(t);
2498
+ stack.delete(id);
2499
+ };
2500
+ visit(start);
2501
+ return { name: spec.name, compliance: spec.compliance, description: spec.description, steps: spec.steps, start };
2502
+ }
1298
2503
  function buildSkillResolver(agents) {
1299
2504
  const map = /* @__PURE__ */ new Map();
1300
2505
  for (const a of agents) for (const s of a.skills ?? []) map.set(s.name, s);
1301
2506
  return (name) => map.get(name);
1302
2507
  }
2508
+ function isConnectorLooking(name) {
2509
+ return name.includes(".");
2510
+ }
2511
+ function ruleToolRefs(rule) {
2512
+ if (rule.seam !== "tool") return [];
2513
+ const t = rule.when.tool;
2514
+ const names = t === void 0 ? [] : Array.isArray(t) ? t : [t];
2515
+ return names.filter((n) => !n.includes("*"));
2516
+ }
2517
+ var CRED_REF_KEYS = /* @__PURE__ */ new Set(["secretref", "credentialref", "connectionid", "owlconnections"]);
2518
+ var normKey = (k) => k.toLowerCase().replace(/[-_]/g, "");
2519
+ function assertNoCredRefs(obj, where) {
2520
+ const walk = (v) => {
2521
+ if (Array.isArray(v)) {
2522
+ v.forEach(walk);
2523
+ return;
2524
+ }
2525
+ if (v && typeof v === "object") {
2526
+ for (const [k, val] of Object.entries(v)) {
2527
+ if (CRED_REF_KEYS.has(normKey(k))) {
2528
+ throw new Error(`defineBundle: ${where} carries a credential-ref key "${k}" \u2014 a bundle may not embed credential/connection handles; declare the capability and resolve creds per-tenant at call time`);
2529
+ }
2530
+ walk(val);
2531
+ }
2532
+ }
2533
+ };
2534
+ walk(obj);
2535
+ }
2536
+ function defineBundle(spec) {
2537
+ if (!spec.slug) throw new Error("defineBundle: `slug` is required");
2538
+ if (!spec.agents?.length) throw new Error(`defineBundle(${spec.slug}): at least one agent is required`);
2539
+ const members = /* @__PURE__ */ new Set();
2540
+ const handles = /* @__PURE__ */ new Set();
2541
+ for (const a of spec.agents) {
2542
+ if (members.has(a.slug)) throw new Error(`defineBundle(${spec.slug}): duplicate agent slug "${a.slug}"`);
2543
+ members.add(a.slug);
2544
+ for (const s of a.skills ?? []) handles.add(s.name);
2545
+ }
2546
+ const requires = spec.requires ?? [];
2547
+ const requiredSlugs = new Set(requires.map((r) => r.slug));
2548
+ const connectorGrants = spec.connectorGrants ?? [];
2549
+ const grantedByMember = /* @__PURE__ */ new Map();
2550
+ const allGranted = /* @__PURE__ */ new Set();
2551
+ for (const g of connectorGrants) {
2552
+ if (!members.has(g.agentSlug)) {
2553
+ throw new Error(`defineBundle(${spec.slug}): connector grant targets unknown agent "${g.agentSlug}" (not a bundle member)`);
2554
+ }
2555
+ const set = grantedByMember.get(g.agentSlug) ?? /* @__PURE__ */ new Set();
2556
+ for (const action of g.actions) {
2557
+ const full = action.includes(".") ? action : `${g.provider}.${action}`;
2558
+ set.add(full);
2559
+ allGranted.add(full);
2560
+ }
2561
+ grantedByMember.set(g.agentSlug, set);
2562
+ }
2563
+ const agents = spec.agents.map((a) => {
2564
+ const granted = grantedByMember.get(a.slug);
2565
+ if (!granted || granted.size === 0) return a;
2566
+ const extra = [...granted].filter((n) => !a.head.skillNames.includes(n));
2567
+ return extra.length ? { ...a, head: { ...a.head, skillNames: [...a.head.skillNames, ...extra] } } : a;
2568
+ });
2569
+ const requireResolvable = (name, allowed, where) => {
2570
+ if (handles.has(name)) return;
2571
+ if (allowed?.has(name)) return;
2572
+ if (isConnectorLooking(name)) {
2573
+ throw new Error(`defineBundle(${spec.slug}): ${where} references connector action "${name}" with no first-party handle and no matching connector grant \u2014 add a \`connectorGrants\` entry, or attach a first-party handle`);
2574
+ }
2575
+ throw new Error(`defineBundle(${spec.slug}): ${where} references skill/tool "${name}" with no first-party handle in the bundle`);
2576
+ };
2577
+ for (const a of agents) {
2578
+ const granted = grantedByMember.get(a.slug);
2579
+ for (const name of a.head.skillNames) requireResolvable(name, granted, `agent "${a.slug}"`);
2580
+ for (const d of a.head.delegateSlugs) {
2581
+ if (!members.has(d) && !requiredSlugs.has(d)) {
2582
+ throw new Error(`defineBundle(${spec.slug}): agent "${a.slug}" delegates to "${d}", which is neither a bundle member nor a declared \`requires\` dependency`);
2583
+ }
2584
+ }
2585
+ }
2586
+ const allRules = [...spec.rules ?? [], ...spec.agents.flatMap((a) => a.rules ?? [])];
2587
+ for (const r of allRules) {
2588
+ for (const t of ruleToolRefs(r)) {
2589
+ if (t.startsWith("agent-")) continue;
2590
+ requireResolvable(t, allGranted, `rule "${r.id}"`);
2591
+ }
2592
+ }
2593
+ const allWorkflows = [...spec.workflows ?? [], ...spec.agents.flatMap((a) => a.workflow ? [a.workflow] : [])];
2594
+ for (const w of allWorkflows) {
2595
+ for (const step of w.steps) {
2596
+ if (step.tool !== void 0) requireResolvable(step.tool, allGranted, `workflow "${w.name}" step "${step.id}"`);
2597
+ assertNoCredRefs(step.args, `workflow "${w.name}" step "${step.id}" args`);
2598
+ assertNoCredRefs(step.input, `workflow "${w.name}" step "${step.id}" input`);
2599
+ }
2600
+ }
2601
+ return {
2602
+ slug: spec.slug,
2603
+ ...spec.title ? { title: spec.title } : {},
2604
+ agents,
2605
+ // members carry any granted connector action names in skillNames
2606
+ rules: spec.rules ?? [],
2607
+ // SWARM-scoped only (per-agent rules stay on the AgentDefs)
2608
+ workflows: spec.workflows ?? [],
2609
+ // SWARM-scoped only
2610
+ connectorGrants,
2611
+ requires
2612
+ };
2613
+ }
2614
+ function mergeBundle(cfg, bundle) {
2615
+ const existing = new Set(cfg.agents.map((a) => a.slug));
2616
+ for (const a of bundle.agents) {
2617
+ if (existing.has(a.slug)) {
2618
+ throw new Error(`mergeBundle(${bundle.slug}): agent "${a.slug}" already exists in the swarm config \u2014 bundle members must not shadow host agents`);
2619
+ }
2620
+ }
2621
+ return {
2622
+ ...cfg,
2623
+ agents: [...cfg.agents, ...bundle.agents],
2624
+ rules: [...cfg.rules ?? [], ...bundle.rules],
2625
+ workflows: [...cfg.workflows ?? [], ...bundle.workflows]
2626
+ };
2627
+ }
2628
+ function toBundleContent(def) {
2629
+ return {
2630
+ slug: def.slug,
2631
+ ...def.title ? { title: def.title } : {},
2632
+ agents: def.agents.map((a) => {
2633
+ const { version: _version, ...content } = a.head;
2634
+ return content;
2635
+ }),
2636
+ rules: def.rules,
2637
+ workflows: def.workflows,
2638
+ connectorGrants: def.connectorGrants,
2639
+ requires: def.requires
2640
+ };
2641
+ }
1303
2642
  var ASK_TOOL_NAME = "ask";
1304
2643
  var askFieldSchema = z4.object({
1305
2644
  kind: z4.enum(["confirm", "buttons", "select", "multiselect", "number", "text", "textarea"]),
@@ -1342,23 +2681,60 @@ function buildAskMastraTool() {
1342
2681
  function defineSwarm(cfg) {
1343
2682
  const seedable = cfg.storage;
1344
2683
  for (const a of cfg.agents) seedable.seedAgent?.(a.head);
2684
+ const rules = [...cfg.rules ?? [], ...cfg.agents.flatMap((a) => a.rules ?? [])];
2685
+ const workflows = [...cfg.workflows ?? [], ...cfg.agents.flatMap((a) => a.workflow ? [a.workflow] : [])];
2686
+ const namedWorkflows = workflows.filter((w) => w.compliance === "strict" && w.scopeAgent === void 0);
2687
+ const agentWorkflows = {};
2688
+ for (const w of workflows) if (w.compliance === "strict" && w.scopeAgent !== void 0) agentWorkflows[w.scopeAgent] = w;
2689
+ const hasSoft = rules.some((r) => r.level === "advise") || workflows.some((w) => w.compliance === "advisory" && !!w.description);
2690
+ const softPolicy = hasSoft ? (slug) => softPolicyFor(slug, rules, workflows) : void 0;
2691
+ const policy = cfg.toolApproval ?? { mode: "flag" };
2692
+ const enforceTool = rules.filter((r) => r.seam === "tool" && r.level === "enforce");
2693
+ const enforceGen = rules.filter((r) => r.seam === "generation" && r.level === "enforce");
2694
+ const composedHooks = { ...cfg.hooks };
2695
+ if (enforceTool.length) composedHooks.preToolCall = composeToolHooks({ rules: enforceTool, host: cfg.hooks?.preToolCall, policy });
2696
+ if (enforceGen.length) composedHooks.preGeneration = composeGenerationHooks({ rules: enforceGen, host: cfg.hooks?.preGeneration });
1345
2697
  const engine = new SwarmEngine({
2698
+ softPolicy,
2699
+ workflows: namedWorkflows,
2700
+ agentWorkflows,
1346
2701
  storage: cfg.storage,
1347
2702
  model: allowListModelProvider({ allow: cfg.models.allow }),
1348
2703
  modelFactory: cfg.modelFactory,
2704
+ // SP10: thread the optional cheap-model router onto the engine. The allow-list above still validates every
2705
+ // resolved model — incl. a routed tier model. Undefined ⇒ no routing (identical to today).
2706
+ tier: cfg.models.tier,
1349
2707
  cost: cfg.cost,
1350
2708
  // Per-swarm skill registry, built from the agents passed in. No global state.
1351
2709
  resolveSkill: buildSkillResolver(cfg.agents),
2710
+ // PR2: opt-in connector-tools resolver (built by the host via materializeConnectors). Undefined ⇒ no-op.
2711
+ connectorTools: cfg.connectorTools,
1352
2712
  telemetry: resolveTelemetry(cfg.telemetry),
1353
2713
  mastraStore: cfg.mastraStore,
1354
2714
  memory: cfg.memory,
1355
2715
  pageContext: cfg.pageContext,
1356
2716
  scratchpad: cfg.scratchpad,
1357
- recallLane: cfg.recallLane
2717
+ recallLane: cfg.recallLane,
2718
+ // SP2 + SP5: build the decision-hook dispatcher once per swarm, baking in the non-removable tool-approval
2719
+ // policy. Always present (allow-all hooks + `{ mode: "flag" }` policy when unconfigured, so the engine's
2720
+ // seams stay uniform — no null-checks on the hot path). The dispatcher combines policy + the per-tool flag +
2721
+ // the optional `preToolCall` hook into the effective ToolDecision.
2722
+ hooks: createHookDispatcher(composedHooks, cfg.toolApproval),
2723
+ // SP5: also pass the policy standalone so an engine built without a dispatcher (direct construction) still
2724
+ // enforces it; redundant here (the dispatcher already carries it) but keeps EngineOpts self-describing.
2725
+ toolApproval: cfg.toolApproval,
2726
+ // SP15: thread the optional SecretResolver onto the engine, which injects it per-run on the RequestContext.
2727
+ secrets: cfg.secrets,
2728
+ // SP3: best-effort per-event observer (metering debit/settle) — transport-agnostic, fired in run + resume.
2729
+ onEvent: cfg.onEvent,
2730
+ verifyCompletion: cfg.verifyCompletion
1358
2731
  });
1359
2732
  return { engine };
1360
2733
  }
1361
2734
 
2735
+ // src/index.ts
2736
+ import { HookDispatcher as HookDispatcher2, createHookDispatcher as createHookDispatcher2, defineHook, deny as deny2, ask as ask2, ALLOW, ALLOW_TOOL, DEFAULT_READ_ONLY_TOOLS } from "@nightowlsdev/hooks";
2737
+
1362
2738
  // src/storage/memory.ts
1363
2739
  var InMemoryStorage = class {
1364
2740
  evts = [];
@@ -1383,7 +2759,7 @@ var InMemoryStorage = class {
1383
2759
  this.suspends.set(`${tenantId}:${followupId}`, { runId, toolCallId });
1384
2760
  }
1385
2761
  markFollowupAnswered(followupId, tenantId) {
1386
- this.suspends.delete(`${tenantId}:${followupId}`);
2762
+ return this.suspends.delete(`${tenantId}:${followupId}`);
1387
2763
  }
1388
2764
  /** Test/host helper: read a run row (the RunStore interface is write-mostly). */
1389
2765
  getRun(runId) {
@@ -1500,17 +2876,51 @@ var InMemoryStorage = class {
1500
2876
  // src/auth.ts
1501
2877
  var customAuth = (fn) => ({ authenticate: fn });
1502
2878
 
2879
+ // src/rate-limit.ts
2880
+ function decideFixedWindow(prev, cfg, nowSec) {
2881
+ const windowValid = prev && nowSec - prev.windowStartSec < cfg.windowSec;
2882
+ const state = windowValid ? { count: prev.count + 1, windowStartSec: prev.windowStartSec } : { count: 1, windowStartSec: nowSec };
2883
+ const resetSec = Math.max(0, state.windowStartSec + cfg.windowSec - nowSec);
2884
+ const allow = state.count <= cfg.max;
2885
+ return { decision: { allow, remaining: Math.max(0, cfg.max - state.count), resetSec }, state };
2886
+ }
2887
+ function createInMemoryRateLimitStore() {
2888
+ const states = /* @__PURE__ */ new Map();
2889
+ let lastPruneSec = 0;
2890
+ return {
2891
+ async hit(key, cfg, nowSec) {
2892
+ if (nowSec > lastPruneSec) {
2893
+ for (const [k, s] of states) if (nowSec - s.windowStartSec >= cfg.windowSec) states.delete(k);
2894
+ lastPruneSec = nowSec;
2895
+ }
2896
+ const { decision, state } = decideFixedWindow(states.get(key) ?? null, cfg, nowSec);
2897
+ states.set(key, state);
2898
+ return decision;
2899
+ }
2900
+ };
2901
+ }
2902
+ function rateConfig(max, windowSec, fallbackMax) {
2903
+ const m = Number.isFinite(max) && max > 0 ? Math.floor(max) : fallbackMax;
2904
+ return { windowSec, max: m };
2905
+ }
2906
+
1503
2907
  // src/index.ts
1504
2908
  var VERSION = "0.0.0";
1505
2909
  export {
2910
+ ALLOW,
2911
+ ALLOW_TOOL,
1506
2912
  ASK_TOOL_NAME,
2913
+ AgentMutationForbidden,
1507
2914
  CapturingExporter,
1508
2915
  CostGovernor,
2916
+ DEFAULT_READ_ONLY_TOOLS,
1509
2917
  DelegateBudgets,
1510
2918
  GUARDRAILS,
2919
+ HookDispatcher2 as HookDispatcher,
1511
2920
  InMemoryContainerFloor,
1512
2921
  InMemoryStorage,
1513
2922
  PRICE_TABLE,
2923
+ ReserveDenied,
1514
2924
  RowCache,
1515
2925
  SCRATCHPAD_MAX_ENTRY_CHARS,
1516
2926
  SCRATCHPAD_MAX_KEYS,
@@ -1518,17 +2928,37 @@ export {
1518
2928
  SwarmEngine,
1519
2929
  VERSION,
1520
2930
  allowListModelProvider,
2931
+ ask2 as ask,
2932
+ assertActorMayMutateDefinition,
1521
2933
  buildSkillResolver,
2934
+ composePolicyPrompt,
1522
2935
  composeSystemPrompt,
1523
2936
  compositeTelemetry,
1524
2937
  containerFloor,
2938
+ createHookDispatcher2 as createHookDispatcher,
2939
+ createInMemoryRateLimitStore,
1525
2940
  customAuth,
1526
2941
  customTelemetry,
2942
+ decideFixedWindow,
1527
2943
  defineAgent,
2944
+ defineBundle,
2945
+ defineHook,
2946
+ defineRule,
1528
2947
  defineSkill,
1529
2948
  defineSwarm,
1530
2949
  defineTool,
2950
+ defineWorkflow,
2951
+ deny2 as deny,
1531
2952
  ev,
1532
2953
  isEvent,
1533
- resolveTelemetry
2954
+ isTierSentinel,
2955
+ mergeBundle,
2956
+ priceUsage,
2957
+ rateConfig,
2958
+ resolveTelemetry,
2959
+ resolveTier,
2960
+ sumBreakdowns,
2961
+ sumTurnUsage,
2962
+ tierModelId,
2963
+ toBundleContent
1534
2964
  };