@dudousxd/nestjs-agent 1.2.0 → 1.3.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/README.md CHANGED
@@ -130,6 +130,36 @@ nobody is streaming (including one that has already finished). In-process caller
130
130
  authorized elsewhere use `AgentService.subscribe(runId)`; anything reachable from a request must go
131
131
  through `AgentService.subscribeAs(actor, runId)`.
132
132
 
133
+ ### Who approves an action, and for how long
134
+
135
+ Every `action` tool call waits on the person chatting by default. `approvalPolicy` changes that per
136
+ call:
137
+
138
+ ```ts
139
+ AgentModule.forRoot({
140
+ // …
141
+ approvalPolicy: {
142
+ requirementFor: (tool, actor) =>
143
+ tool.name === 'issueRefund'
144
+ ? { required: true, approver: 'finance', ttlMs: 15 * 60_000 }
145
+ : { required: true, approver: 'requester' },
146
+ // Optional. Default: 'requester' = the thread's own actor, anything else = a role the decider holds.
147
+ // canDecide: (actor, { approver, requesterRef }) => gate.forUser(actor).allows('approve', approver),
148
+ },
149
+ });
150
+ ```
151
+
152
+ - `POST /agent/tool-call/approve` `{ toolCallId, remember?, via? }` and `…/reject` `{ toolCallId,
153
+ reason?, via? }` enforce the recorded approver (`403` otherwise) and refuse a request that has
154
+ lapsed (`410`). The decider is recorded as `executedByRef`, `via` as `decided_via` (`'web'` when
155
+ omitted; the console approvals port takes `decidedVia` too).
156
+ - `remember: true` approves later calls of the same tool in the same thread without asking.
157
+ - A `ttlMs` becomes the approval wait's timeout — `ctx.waitForSignal(…, { timeoutMs })` on the
158
+ durable runner, a timer inline. On expiry the call settles `expired`, the model is told nobody
159
+ approved it in time, and the stream carries `tool-output-denied` + `approval-settled`.
160
+ - The tool-call row gains `approver`, `expires_at`, `remember` and `decided_via`; a reloaded thread
161
+ carries them as `StoredMessage.approvals`.
162
+
133
163
  ### A durable turn's model call and tools run in a worker
134
164
 
135
165
  Under `durable: true` the turn's model call and each of its tool executions are **dispatched steps**
@@ -1,4 +1,4 @@
1
- import { ModelProvider, AgentStore, ToolRegistry, RolesPolicy, QuotaStore, TokenStreamSink, PromptBuilder, PromptContributor, ToolTransientRetrySetting, Retriever, AgentPricingStore, HistoryPolicy, InputProcessor, OutputProcessor, AgentIntake, SkillsConfig, MemoryConfig, ToolSpec, ToolHandler, ActorResolver, AgentHistoryWindow, SkillProvider, ScopeResolver, MemoryProvider, AgentRegistry } from '@dudousxd/nestjs-agent-core';
1
+ import { ModelProvider, AgentStore, ToolRegistry, RolesPolicy, ApprovalPolicy, QuotaStore, TokenStreamSink, PromptBuilder, PromptContributor, ToolTransientRetrySetting, Retriever, AgentPricingStore, HistoryPolicy, InputProcessor, OutputProcessor, AgentIntake, SkillsConfig, MemoryConfig, ToolSpec, ToolHandler, ActorResolver, AgentHistoryWindow, SkillProvider, ScopeResolver, MemoryProvider, AgentRegistry } from '@dudousxd/nestjs-agent-core';
2
2
  import { StandardSchemaV1 } from '@standard-schema/spec';
3
3
  import { Provider, FactoryProvider, Type, CanActivate, ModuleMetadata, InjectionToken, OptionalFactoryDependency } from '@nestjs/common';
4
4
 
@@ -8,6 +8,8 @@ interface AgentDeps {
8
8
  store: AgentStore;
9
9
  registry: ToolRegistry;
10
10
  rolesPolicy: RolesPolicy;
11
+ /** Who approves an action call, and for how long (from `forRoot({ approvalPolicy })`). Undefined → the requester, no expiry. */
12
+ approvalPolicy?: ApprovalPolicy;
11
13
  quota?: QuotaStore;
12
14
  sink: TokenStreamSink;
13
15
  /** Fallback accounting label; the provider's turn result overrides it when set. */
@@ -205,6 +207,13 @@ interface AgentModuleOptions {
205
207
  quotaLimitTokens?: number;
206
208
  /** Tool authorization gate. Defaults to role-in-`defaultRoles`. */
207
209
  rolesPolicy?: RolesPolicy;
210
+ /**
211
+ * Who has to approve an `action` tool call, and how long the request stays open. Defaults to the
212
+ * requester (the thread's own actor) with no expiry. A requirement naming another approver (a
213
+ * role) is enforced by `POST tool-call/approve|reject` through `canDecide` — by default, the
214
+ * deciding actor must hold that role. See `ApprovalPolicy`.
215
+ */
216
+ approvalPolicy?: ApprovalPolicy;
208
217
  /** Default roles a tool requires when its `roles` is omitted. Defaults to `['ADMIN']`. */
209
218
  defaultRoles?: string[];
210
219
  /**
@@ -1,4 +1,4 @@
1
- import { ModelProvider, AgentStore, ToolRegistry, RolesPolicy, QuotaStore, TokenStreamSink, PromptBuilder, PromptContributor, ToolTransientRetrySetting, Retriever, AgentPricingStore, HistoryPolicy, InputProcessor, OutputProcessor, AgentIntake, SkillsConfig, MemoryConfig, ToolSpec, ToolHandler, ActorResolver, AgentHistoryWindow, SkillProvider, ScopeResolver, MemoryProvider, AgentRegistry } from '@dudousxd/nestjs-agent-core';
1
+ import { ModelProvider, AgentStore, ToolRegistry, RolesPolicy, ApprovalPolicy, QuotaStore, TokenStreamSink, PromptBuilder, PromptContributor, ToolTransientRetrySetting, Retriever, AgentPricingStore, HistoryPolicy, InputProcessor, OutputProcessor, AgentIntake, SkillsConfig, MemoryConfig, ToolSpec, ToolHandler, ActorResolver, AgentHistoryWindow, SkillProvider, ScopeResolver, MemoryProvider, AgentRegistry } from '@dudousxd/nestjs-agent-core';
2
2
  import { StandardSchemaV1 } from '@standard-schema/spec';
3
3
  import { Provider, FactoryProvider, Type, CanActivate, ModuleMetadata, InjectionToken, OptionalFactoryDependency } from '@nestjs/common';
4
4
 
@@ -8,6 +8,8 @@ interface AgentDeps {
8
8
  store: AgentStore;
9
9
  registry: ToolRegistry;
10
10
  rolesPolicy: RolesPolicy;
11
+ /** Who approves an action call, and for how long (from `forRoot({ approvalPolicy })`). Undefined → the requester, no expiry. */
12
+ approvalPolicy?: ApprovalPolicy;
11
13
  quota?: QuotaStore;
12
14
  sink: TokenStreamSink;
13
15
  /** Fallback accounting label; the provider's turn result overrides it when set. */
@@ -205,6 +207,13 @@ interface AgentModuleOptions {
205
207
  quotaLimitTokens?: number;
206
208
  /** Tool authorization gate. Defaults to role-in-`defaultRoles`. */
207
209
  rolesPolicy?: RolesPolicy;
210
+ /**
211
+ * Who has to approve an `action` tool call, and how long the request stays open. Defaults to the
212
+ * requester (the thread's own actor) with no expiry. A requirement naming another approver (a
213
+ * role) is enforced by `POST tool-call/approve|reject` through `canDecide` — by default, the
214
+ * deciding actor must hold that role. See `ApprovalPolicy`.
215
+ */
216
+ approvalPolicy?: ApprovalPolicy;
208
217
  /** Default roles a tool requires when its `roles` is omitted. Defaults to `['ADMIN']`. */
209
218
  defaultRoles?: string[];
210
219
  /**
@@ -304,7 +304,21 @@ var AgentRunWorkflow = class _AgentRunWorkflow {
304
304
  runId: ctx.runId,
305
305
  // A child forwards into the top-level sink but must not end/fail it (the top-level run owns it).
306
306
  openSink: /* @__PURE__ */ __name(async () => input.sinkRunId !== void 0 ? childSinkWriter(await deps.sink.open(sinkRunId)) : deps.sink.open(ctx.runId), "openSink"),
307
- awaitApproval: /* @__PURE__ */ __name((call) => ctx.waitForSignal(`tool:${ctx.runId}:${call.id}`), "awaitApproval"),
307
+ // A policy's time to live becomes the signal wait's own timeout: the runtime journals the
308
+ // deadline on the first call (a position of its own, reached only for a call the journal says
309
+ // has a ttl) and wakes the run when it passes. The lapse comes back as a Decision rather than a
310
+ // throw, so the loop settles the call `expired` on its ordinary rejection checkpoint.
311
+ awaitApproval: /* @__PURE__ */ __name((call, _toolCtx, opts) => opts?.timeoutMs === void 0 ? ctx.waitForSignal(`tool:${ctx.runId}:${call.id}`) : ctx.waitForSignal(`tool:${ctx.runId}:${call.id}`, {
312
+ timeoutMs: opts.timeoutMs
313
+ }).catch((error) => {
314
+ if (isSignalTimeout(error)) {
315
+ return {
316
+ approved: false,
317
+ expired: true
318
+ };
319
+ }
320
+ throw error;
321
+ }), "awaitApproval"),
308
322
  // The same wait, on the same signal key, carrying a typed answer instead of a yes/no — so a
309
323
  // question set and an approval reach a parked run through one path, and the HTTP surface that
310
324
  // settles either one is the same `POST /agent/tool-call/*` family.
@@ -478,6 +492,10 @@ AgentRunWorkflow = _ts_decorate2([
478
492
  typeof RunGateway === "undefined" ? Object : RunGateway
479
493
  ])
480
494
  ], AgentRunWorkflow);
495
+ function isSignalTimeout(error) {
496
+ return error instanceof Error && error.name === "SignalTimeoutError";
497
+ }
498
+ __name(isSignalTimeout, "isSignalTimeout");
481
499
 
482
500
  // src/durable/durable-agent-runner.ts
483
501
  var import_node_crypto = require("crypto");
@@ -908,6 +926,9 @@ var AgentDepsFactory2 = class {
908
926
  store: this.store,
909
927
  sink: this.sink,
910
928
  rolesPolicy: this.rolesPolicy,
929
+ ...this.options.approvalPolicy !== void 0 ? {
930
+ approvalPolicy: this.options.approvalPolicy
931
+ } : {},
911
932
  registry: this.registry,
912
933
  promptContributors: this.promptContributors,
913
934
  systemPrompt: definition?.systemPrompt ?? "You are a helpful assistant.",
@@ -1191,18 +1212,35 @@ var AgentService = class {
1191
1212
  await this.assertOwnsActiveStream(actor, runId);
1192
1213
  return this.subscribe(runId);
1193
1214
  }
1194
- async approve(actor, toolCallId2) {
1195
- await this.assertOwnsToolCall(actor, toolCallId2);
1215
+ /**
1216
+ * Approve a parked action call as `actor`. Who may is the call's recorded approver's business
1217
+ * (see {@link assertMayDecide}); `remember` approves later calls of the same tool in the same
1218
+ * thread, and `via` names the surface the decision came through (`'web'`, `'slack'`, …) — both
1219
+ * persisted with the call.
1220
+ */
1221
+ async approve(actor, toolCallId2, opts = {}) {
1222
+ await this.assertMayDecide(actor, toolCallId2);
1196
1223
  return this.signalToolCall(toolCallId2, {
1197
- approved: true
1224
+ approved: true,
1225
+ executedByRef: actor.id,
1226
+ ...opts.remember === true ? {
1227
+ remember: true
1228
+ } : {},
1229
+ ...opts.via !== void 0 ? {
1230
+ decidedVia: opts.via
1231
+ } : {}
1198
1232
  });
1199
1233
  }
1200
- async reject(actor, toolCallId2, reason) {
1201
- await this.assertOwnsToolCall(actor, toolCallId2);
1234
+ async reject(actor, toolCallId2, reason, opts = {}) {
1235
+ await this.assertMayDecide(actor, toolCallId2);
1202
1236
  return this.signalToolCall(toolCallId2, {
1203
1237
  approved: false,
1238
+ executedByRef: actor.id,
1204
1239
  ...reason !== void 0 ? {
1205
1240
  reason
1241
+ } : {},
1242
+ ...opts.via !== void 0 ? {
1243
+ decidedVia: opts.via
1206
1244
  } : {}
1207
1245
  });
1208
1246
  }
@@ -1243,6 +1281,7 @@ var AgentService = class {
1243
1281
  * not the thread's own actor).
1244
1282
  */
1245
1283
  async signalToolCall(toolCallId2, reply) {
1284
+ await this.assertNotExpired(toolCallId2);
1246
1285
  const runId = await this.resolveRunForToolCall(toolCallId2);
1247
1286
  return this.runner.signal(runId, toolCallId2, reply);
1248
1287
  }
@@ -1481,6 +1520,47 @@ var AgentService = class {
1481
1520
  }
1482
1521
  }
1483
1522
  /**
1523
+ * Authorization seam for approve/reject. The call's recorded approver decides who may settle it:
1524
+ * the requester (the default, and every call recorded before approvers existed) is the thread's
1525
+ * own actor — the ownership check this always was; any other approver goes through the policy's
1526
+ * `canDecide`, which by default asks whether the actor holds that role.
1527
+ */
1528
+ async assertMayDecide(actor, toolCallId2) {
1529
+ const approval = await this.store.toolCallApproval?.(toolCallId2);
1530
+ const approver = approval?.approver ?? import_nestjs_agent_core8.REQUESTER_APPROVER;
1531
+ if (approver === import_nestjs_agent_core8.REQUESTER_APPROVER) {
1532
+ return this.assertOwnsToolCall(actor, toolCallId2);
1533
+ }
1534
+ const requesterRef = await this.store.ownerOfToolCall(toolCallId2);
1535
+ if (requesterRef === null) {
1536
+ throw new import_common6.NotFoundException(`tool call ${toolCallId2} not found`);
1537
+ }
1538
+ const allowed = await (0, import_nestjs_agent_core8.mayDecideApproval)(this.deps.forAgent().approvalPolicy, actor, {
1539
+ toolCallId: toolCallId2,
1540
+ approver,
1541
+ requesterRef
1542
+ });
1543
+ if (!allowed) {
1544
+ throw new import_common6.ForbiddenException(`this approval is for ${approver}`);
1545
+ }
1546
+ }
1547
+ /**
1548
+ * Refuse a decision on a request that has already lapsed — recorded `expired`, or past its
1549
+ * `expiresAt` with the run's own timer about to fire. Signalling it anyway would race the timeout:
1550
+ * a durable runtime buffers a signal nobody is waiting on, and a late approval must not be what a
1551
+ * later replay of the run reads back.
1552
+ */
1553
+ async assertNotExpired(toolCallId2) {
1554
+ const approval = await this.store.toolCallApproval?.(toolCallId2);
1555
+ if (approval === null || approval === void 0) {
1556
+ return;
1557
+ }
1558
+ const lapsed = approval.status === "expired" || approval.status === "pending_approval" && approval.expiresAt !== null && Date.parse(approval.expiresAt) <= Date.now();
1559
+ if (lapsed) {
1560
+ throw new import_common6.GoneException(`the approval request for tool call ${toolCallId2} has expired`);
1561
+ }
1562
+ }
1563
+ /**
1484
1564
  * Resolve the run awaiting `toolCallId`. Derived server-side from the tool call alone — the client
1485
1565
  * never knows or supplies a runId, and that run is the sub-agent's own child run when the pending
1486
1566
  * call belongs to a delegated agent. The store answers from the CALL'S OWN `runId` where it has
@@ -1558,6 +1638,12 @@ var AgentApprovalPortAdapter = class {
1558
1638
  approved: true,
1559
1639
  ...opts?.executedByRef !== void 0 ? {
1560
1640
  executedByRef: opts.executedByRef
1641
+ } : {},
1642
+ ...opts?.remember === true ? {
1643
+ remember: true
1644
+ } : {},
1645
+ ...opts?.decidedVia !== void 0 ? {
1646
+ decidedVia: opts.decidedVia
1561
1647
  } : {}
1562
1648
  });
1563
1649
  }
@@ -1569,6 +1655,9 @@ var AgentApprovalPortAdapter = class {
1569
1655
  } : {},
1570
1656
  ...opts?.reason !== void 0 ? {
1571
1657
  reason: opts.reason
1658
+ } : {},
1659
+ ...opts?.decidedVia !== void 0 ? {
1660
+ decidedVia: opts.decidedVia
1572
1661
  } : {}
1573
1662
  });
1574
1663
  }
@@ -2488,6 +2577,8 @@ var MAX_ANSWERED_QUESTIONS = 100;
2488
2577
  var MAX_VALUES_PER_QUESTION = 100;
2489
2578
  var MAX_ANSWER_VALUE_LENGTH = 4096;
2490
2579
  var MAX_REJECT_REASON_LENGTH = 4096;
2580
+ var MAX_VIA_LENGTH = 64;
2581
+ var DEFAULT_VIA = "web";
2491
2582
  function toolCallId(claimed) {
2492
2583
  if (typeof claimed !== "string" || claimed.length === 0) {
2493
2584
  throw new import_common15.BadRequestException("toolCallId must be a non-empty string");
@@ -2508,6 +2599,26 @@ function rejectReason(claimed) {
2508
2599
  return claimed;
2509
2600
  }
2510
2601
  __name(rejectReason, "rejectReason");
2602
+ function remember(claimed) {
2603
+ if (claimed === void 0) {
2604
+ return false;
2605
+ }
2606
+ if (typeof claimed !== "boolean") {
2607
+ throw new import_common15.BadRequestException("remember must be a boolean");
2608
+ }
2609
+ return claimed;
2610
+ }
2611
+ __name(remember, "remember");
2612
+ function via(claimed) {
2613
+ if (claimed === void 0) {
2614
+ return DEFAULT_VIA;
2615
+ }
2616
+ if (typeof claimed !== "string" || claimed.length === 0 || claimed.length > MAX_VIA_LENGTH) {
2617
+ throw new import_common15.BadRequestException(`via must be a string of 1-${MAX_VIA_LENGTH} characters`);
2618
+ }
2619
+ return claimed;
2620
+ }
2621
+ __name(via, "via");
2511
2622
  function answers(claimed) {
2512
2623
  if (claimed === void 0) {
2513
2624
  return void 0;
@@ -2552,14 +2663,19 @@ var ToolCallController = class {
2552
2663
  }
2553
2664
  async approve(req, body) {
2554
2665
  const actor = await this.actorResolver.resolve(req);
2555
- await this.agent.approve(actor, toolCallId(body.toolCallId));
2666
+ await this.agent.approve(actor, toolCallId(body.toolCallId), {
2667
+ remember: remember(body.remember),
2668
+ via: via(body.via)
2669
+ });
2556
2670
  return {
2557
2671
  ok: true
2558
2672
  };
2559
2673
  }
2560
2674
  async reject(req, body) {
2561
2675
  const actor = await this.actorResolver.resolve(req);
2562
- await this.agent.reject(actor, toolCallId(body.toolCallId), rejectReason(body.reason));
2676
+ await this.agent.reject(actor, toolCallId(body.toolCallId), rejectReason(body.reason), {
2677
+ via: via(body.via)
2678
+ });
2563
2679
  return {
2564
2680
  ok: true
2565
2681
  };
@@ -2625,7 +2741,7 @@ _ts_decorate15([
2625
2741
  _ts_metadata14("design:type", Function),
2626
2742
  _ts_metadata14("design:paramtypes", [
2627
2743
  typeof Request === "undefined" ? Object : Request,
2628
- typeof ApproveBody === "undefined" ? Object : ApproveBody
2744
+ Object
2629
2745
  ]),
2630
2746
  _ts_metadata14("design:returntype", Promise)
2631
2747
  ], ToolCallController.prototype, "skip", null);
@@ -3370,6 +3486,36 @@ var InlineAgentRunner = class _InlineAgentRunner {
3370
3486
  * shape it is delivering — the loop asked for one and only ever gets what the caller sent. A
3371
3487
  * cancel settles the wait too, by rejecting it.
3372
3488
  */
3489
+ /**
3490
+ * {@link park} for an approval, bounded by the policy's time to live when it has one: the timer
3491
+ * settles the wait as `expired` — the same Decision the durable runner builds from its signal
3492
+ * timeout — and a decision that arrives first disarms it.
3493
+ */
3494
+ parkApproval(runId, toolCallId2, timeoutMs) {
3495
+ const waiting = this.park(runId, toolCallId2);
3496
+ if (timeoutMs === void 0) {
3497
+ return waiting;
3498
+ }
3499
+ const key = `${runId}:${toolCallId2}`;
3500
+ let timer;
3501
+ const lapse = new Promise((resolve) => {
3502
+ timer = setTimeout(() => {
3503
+ const entry = this.pending.get(key);
3504
+ if (entry !== void 0) {
3505
+ this.pending.delete(key);
3506
+ resolve({
3507
+ approved: false,
3508
+ expired: true
3509
+ });
3510
+ }
3511
+ }, timeoutMs);
3512
+ timer.unref?.();
3513
+ });
3514
+ return Promise.race([
3515
+ waiting,
3516
+ lapse
3517
+ ]).finally(() => clearTimeout(timer));
3518
+ }
3373
3519
  park(runId, toolCallId2) {
3374
3520
  return new Promise((resolve, reject) => {
3375
3521
  this.pending.set(`${runId}:${toolCallId2}`, {
@@ -3383,7 +3529,7 @@ var InlineAgentRunner = class _InlineAgentRunner {
3383
3529
  return {
3384
3530
  runId,
3385
3531
  openSink: /* @__PURE__ */ __name(() => deps.sink.open(runId), "openSink"),
3386
- awaitApproval: /* @__PURE__ */ __name((call) => this.park(runId, call.id), "awaitApproval"),
3532
+ awaitApproval: /* @__PURE__ */ __name((call, _ctx, opts) => this.parkApproval(runId, call.id, opts?.timeoutMs), "awaitApproval"),
3387
3533
  awaitAnswers: /* @__PURE__ */ __name((request) => this.park(runId, request.id), "awaitAnswers"),
3388
3534
  step: /* @__PURE__ */ __name((_name, fn) => fn(), "step"),
3389
3535
  parallel: import_nestjs_agent_core22.settleAll,
@@ -3434,7 +3580,7 @@ var InlineAgentRunner = class _InlineAgentRunner {
3434
3580
  const hooks = {
3435
3581
  runId,
3436
3582
  openSink: /* @__PURE__ */ __name(() => deps.sink.open(runId), "openSink"),
3437
- awaitApproval: /* @__PURE__ */ __name((call) => this.park(runId, call.id), "awaitApproval"),
3583
+ awaitApproval: /* @__PURE__ */ __name((call, _ctx, opts) => this.parkApproval(runId, call.id, opts?.timeoutMs), "awaitApproval"),
3438
3584
  awaitAnswers: /* @__PURE__ */ __name((request) => this.park(runId, request.id), "awaitAnswers"),
3439
3585
  step: /* @__PURE__ */ __name((_name, fn) => fn(), "step"),
3440
3586
  parallel: import_nestjs_agent_core22.settleAll,
@@ -3538,7 +3684,7 @@ var InlineAgentRunner = class _InlineAgentRunner {
3538
3684
  runId,
3539
3685
  // Forward into the top-level stream; the top-level run owns end/fail on that shared sink.
3540
3686
  openSink: /* @__PURE__ */ __name(async () => childSinkWriter(await deps.sink.open(sinkRunId)), "openSink"),
3541
- awaitApproval: /* @__PURE__ */ __name((call) => this.park(runId, call.id), "awaitApproval"),
3687
+ awaitApproval: /* @__PURE__ */ __name((call, _ctx, opts) => this.parkApproval(runId, call.id, opts?.timeoutMs), "awaitApproval"),
3542
3688
  awaitAnswers: /* @__PURE__ */ __name((request) => this.park(runId, request.id), "awaitAnswers"),
3543
3689
  step: /* @__PURE__ */ __name((_name, fn) => fn(), "step"),
3544
3690
  parallel: import_nestjs_agent_core22.settleAll,