@dudousxd/nestjs-agent 1.19.3 → 1.21.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
@@ -405,6 +405,57 @@ In anonymous mode the limits apply per browser (each is its own actor).
405
405
 
406
406
  ### Who approves an action, and for how long
407
407
 
408
+ Action handlers can implement an optional read-only `preflight(input, ctx, { phase })`. The
409
+ registry authorizes and validates the input first. `prepare` runs before an approval is requested;
410
+ `execute` rechecks current state inside the tool checkpoint immediately before its side effect.
411
+ Automatic and remembered approvals still run both checks; direct registry and MCP invocations
412
+ run the execution check too.
413
+
414
+ ```ts
415
+ class RemoveSessionsTool implements ToolHandler<{ accountId: string }, { removed: number }> {
416
+ constructor(private readonly sessions: SessionsService) {}
417
+
418
+ async preflight(input: { accountId: string }, ctx: AiToolCtx, { phase }: ToolPreflightOptions): Promise<ToolPreflightResult<{ removed: number }>> {
419
+ const count = await this.sessions.countForActor(input.accountId, ctx.actor);
420
+ if (count === 0) return { status: 'completed', output: { removed: 0 } };
421
+ if (await this.sessions.isLocked(input.accountId)) {
422
+ return { status: 'denied', reason: 'This account is locked.' };
423
+ }
424
+ return {
425
+ status: 'ready',
426
+ confirmation: { title: `Remove ${count} sessions?`, verb: 'Remove' },
427
+ };
428
+ }
429
+
430
+ async execute(input: { accountId: string }, ctx: AiToolCtx) {
431
+ return this.sessions.removeForActor(input.accountId, ctx.actor, ctx.idempotencyKey);
432
+ }
433
+ }
434
+ ```
435
+
436
+ `ready` may supply fully resolved `ToolConfirmation` strings (`title`, `verb`, optional `detail`)
437
+ that override the tool's presentation templates for this call, including after a history reload.
438
+ `denied` returns a domain refusal to the model, and `completed` returns an existing result. Both
439
+ skip approval and execution during preparation. A completed terminal action ends the turn.
440
+ A refusal after a person approves preserves that approval while reporting that the mutation did
441
+ not run. An exception becomes a failed tool result. Read tools ignore this hook.
442
+
443
+ Functional tools use the same handler contract:
444
+
445
+ ```ts
446
+ provideAgentTool({
447
+ spec: removeSessionsSpec,
448
+ handler: { preflight: checkSessions, execute: removeSessions },
449
+ });
450
+ ```
451
+
452
+ Preflight must not perform writes or emit UI. Its preparation result is journaled with the existing
453
+ call checkpoint (and, for remote execution, first with the model worker's turn); replay uses that
454
+ result without rerunning the check or changing its approval branch. The execution check is
455
+ journaled inside the existing tool checkpoint. Keep domain checks inside `execute` or an atomic
456
+ transaction as needed: a preflight check cannot eliminate concurrent changes between reads and
457
+ writes, and it does not replace idempotency.
458
+
408
459
  Every `action` tool call waits on the person chatting by default. `approvalPolicy` changes that per
409
460
  call:
410
461
 
@@ -1,9 +1,13 @@
1
- import { ModelProvider, AgentStore, ToolRegistry, RolesPolicy, ApprovalPolicy, QuotaStore, TokenStreamSink, PromptBuilder, PromptContributor, ToolTransientRetrySetting, Retriever, AgentPricingStore, HistoryPolicy, InputProcessor, OutputProcessor, AgentIntake, SkillsConfig, MemoryConfig, ToolSpec, ToolHandler, QuotaProvider, QuotaQuery, QuotaReport, ModelCatalog, EmptyRoles, ActorResolver, AgentHistoryWindow, SkillProvider, ScopeResolver, MemoryProvider, AgentRegistry, AttachmentStagingStore, ChatQueueStore, ChatQueueState, AgentRunner, AgentRunInput, AgentStreamEvent, QueuePauseReason, Actor } from '@dudousxd/nestjs-agent-core';
1
+ import { ResolveToolUiCatalog, ModelProvider, AgentStore, ToolRegistry, RolesPolicy, ApprovalPolicy, QuotaStore, TokenStreamSink, PromptBuilder, PromptContributor, Persona, ToolTransientRetrySetting, Retriever, AgentPricingStore, HistoryPolicy, InputProcessor, OutputProcessor, AgentIntake, SkillsConfig, MemoryConfig, ToolSpec, ToolHandler, QuotaProvider, QuotaQuery, QuotaReport, ActionApprovalMode, BackgroundActorResolver, ModelCatalog, EmptyRoles, ActorResolver, AgentHistoryWindow, SkillProvider, ScopeResolver, MemoryProvider, AgentRegistry, PersonaCatalogEntry, ActionProposal, Actor, AttachmentStagingStore, ChatQueueStore, ChatQueueState, AgentRunner, AgentRunInput, AgentStreamEvent, QueuePauseReason } from '@dudousxd/nestjs-agent-core';
2
+ import { Catalog } from '@dudousxd/nestjs-agent-core/genui';
2
3
  import { StandardSchemaV1 } from '@standard-schema/spec';
3
4
  import { Provider, FactoryProvider, Type, CanActivate, ModuleMetadata, InjectionToken, OptionalFactoryDependency } from '@nestjs/common';
5
+ import { GenuiCatalogResolver } from './genui/index.cjs';
4
6
 
5
7
  /** Everything `runAgentLoop` needs, minus the per-run `day` the runner stamps. */
6
8
  interface AgentDeps {
9
+ resolveUiCatalog?: ResolveToolUiCatalog;
10
+ actionApprovalMode?: 'blocking' | 'independent';
7
11
  model: ModelProvider;
8
12
  store: AgentStore;
9
13
  registry: ToolRegistry;
@@ -24,6 +28,8 @@ interface AgentDeps {
24
28
  maxAgentAppearances?: number;
25
29
  /** Agent-level tool allow-list. Undefined → all tools (after role filtering). */
26
30
  toolAllowList?: string[];
31
+ /** The agent's personas (`@Agent({ personas })`) — read by the loop only for a run that names one. */
32
+ personas?: Persona[];
27
33
  /** Per-tool execution timeout in ms (from module options). Undefined → no timeout. */
28
34
  toolTimeoutMs?: number;
29
35
  /**
@@ -238,6 +244,13 @@ interface AgentMemoryOptions {
238
244
  maxFactChars?: number;
239
245
  }
240
246
  interface AgentModuleOptions {
247
+ actionApprovalMode?: ActionApprovalMode;
248
+ backgroundActorResolver?: BackgroundActorResolver;
249
+ actionProposalWorker?: {
250
+ pollIntervalMs?: number;
251
+ leaseMs?: number;
252
+ maxConcurrency?: number;
253
+ };
241
254
  /**
242
255
  * The LLM provider — `aiSdkModel(openai('gpt-5-mini'))`, or `aiSdkModels({ … })` for a model
243
256
  * picker (its catalog is used when `models` is omitted). The only required option.
@@ -523,7 +536,9 @@ declare class AgentDepsFactory {
523
536
  private readonly pricingStore;
524
537
  private readonly skills;
525
538
  private readonly memory;
526
- constructor(options: AgentModuleOptions, model: ModelProvider, store: AgentStore, sink: TokenStreamSink, rolesPolicy: RolesPolicy, registry: ToolRegistry, agents: AgentRegistry, promptContributors: PromptContributor[], pricingStore: AgentPricingStore | undefined, skills: SkillsConfig | undefined, memory: MemoryConfig | undefined);
539
+ private readonly uiCatalog?;
540
+ private readonly uiCatalogResolver?;
541
+ constructor(options: AgentModuleOptions, model: ModelProvider, store: AgentStore, sink: TokenStreamSink, rolesPolicy: RolesPolicy, registry: ToolRegistry, agents: AgentRegistry, promptContributors: PromptContributor[], pricingStore: AgentPricingStore | undefined, skills: SkillsConfig | undefined, memory: MemoryConfig | undefined, uiCatalog?: Catalog | undefined, uiCatalogResolver?: GenuiCatalogResolver | undefined);
527
542
  /** The turn's memory seam, shared with the read-back endpoint. Undefined → memory is not configured. */
528
543
  memoryConfig(): MemoryConfig | undefined;
529
544
  /** The turn's skills seam, shared with the listing endpoint. Undefined → skills are not configured. */
@@ -534,7 +549,37 @@ declare class AgentDepsFactory {
534
549
  */
535
550
  defaultAgentName(): string;
536
551
  private effectiveTools;
552
+ /**
553
+ * The agent a name stands for: itself when it is a registered agent (or unknown — the bare
554
+ * assistant), else the agent whose persona took the name over (`Persona.aliases`) and that persona.
555
+ */
556
+ resolveAgent(name: string): {
557
+ agentName: string;
558
+ persona?: string;
559
+ };
560
+ /** `agentName`'s personas as a picker reads them. Empty when it declares none. */
561
+ personaCatalog(agentName: string): PersonaCatalogEntry[];
562
+ /** The persona `agentName` runs under when nothing names one. Undefined → none. */
563
+ defaultPersona(agentName: string): string | undefined;
564
+ /**
565
+ * The persona a turn of `agentName` runs under, most specific first: the one the send names
566
+ * (refused with `400 persona_not_found` when the agent does not declare it), else the thread's
567
+ * pinned one when this agent declares it, else the agent's default. Undefined → none.
568
+ */
569
+ resolvePersona(args: {
570
+ agentName: string;
571
+ requested?: string;
572
+ threadPersona?: string | null;
573
+ }): string | undefined;
574
+ forProposal(proposal: ActionProposal, _actor: Actor): AgentDeps;
537
575
  forAgent(agentName?: string): AgentDeps;
576
+ /**
577
+ * A persona baked into an agent's deps — how a run journaled under an agent name that a persona
578
+ * has since taken over (`Persona.aliases`) is served. That run names no persona of its own (it
579
+ * predates them, and its input cannot change), so the persona applies the way the old agent's own
580
+ * config did: as config, re-read on every replay, with no checkpoint of its own.
581
+ */
582
+ private withPersona;
538
583
  /**
539
584
  * The ceiling on how much of a thread this agent's turns carry, most specific wins: the agent's
540
585
  * own `@Agent({ history })`, else a module-wide custom `historyPolicy`, else the module-wide
@@ -591,13 +636,14 @@ declare class ChatQueueService {
591
636
  private readonly quotaProvider?;
592
637
  private readonly options?;
593
638
  private readonly staging?;
639
+ private readonly deps?;
594
640
  private readonly logger;
595
641
  /**
596
642
  * Runs this process claimed a thread for and has not handed to a runner yet. A holder in here is
597
643
  * alive whatever the runner says — it simply has not registered the run yet.
598
644
  */
599
645
  private readonly starting;
600
- constructor(store: AgentStore, sink: TokenStreamSink, quotaProvider?: QuotaProvider | undefined, options?: Pick<AgentModuleOptions, "quota"> | undefined, staging?: AttachmentStagingStore | undefined);
646
+ constructor(store: AgentStore, sink: TokenStreamSink, quotaProvider?: QuotaProvider | undefined, options?: Pick<AgentModuleOptions, "quota"> | undefined, staging?: AttachmentStagingStore | undefined, deps?: AgentDepsFactory | undefined);
601
647
  /** Whether the bound store can hold a queue. Without one, a busy thread is not queued. */
602
648
  get supported(): boolean;
603
649
  /** The bound store as a queue store, or a 501 naming what is missing. */
@@ -673,6 +719,13 @@ declare class ChatQueueService {
673
719
  * waited. An attachment the staging store no longer resolves keeps its stored url.
674
720
  */
675
721
  private inputFor;
722
+ /**
723
+ * The agent and persona a queued message starts as. A message carries the persona its send
724
+ * resolved; one that carries none — queued before personas existed, or by a host that sets none —
725
+ * resolves it now, exactly as a send would: an agent name a persona took over, then the thread's
726
+ * pinned persona, then the agent's default. Never refused here: nobody is waiting on a 400.
727
+ */
728
+ private targetFor;
676
729
  private freshAttachments;
677
730
  }
678
731
 
@@ -1,9 +1,13 @@
1
- import { ModelProvider, AgentStore, ToolRegistry, RolesPolicy, ApprovalPolicy, QuotaStore, TokenStreamSink, PromptBuilder, PromptContributor, ToolTransientRetrySetting, Retriever, AgentPricingStore, HistoryPolicy, InputProcessor, OutputProcessor, AgentIntake, SkillsConfig, MemoryConfig, ToolSpec, ToolHandler, QuotaProvider, QuotaQuery, QuotaReport, ModelCatalog, EmptyRoles, ActorResolver, AgentHistoryWindow, SkillProvider, ScopeResolver, MemoryProvider, AgentRegistry, AttachmentStagingStore, ChatQueueStore, ChatQueueState, AgentRunner, AgentRunInput, AgentStreamEvent, QueuePauseReason, Actor } from '@dudousxd/nestjs-agent-core';
1
+ import { ResolveToolUiCatalog, ModelProvider, AgentStore, ToolRegistry, RolesPolicy, ApprovalPolicy, QuotaStore, TokenStreamSink, PromptBuilder, PromptContributor, Persona, ToolTransientRetrySetting, Retriever, AgentPricingStore, HistoryPolicy, InputProcessor, OutputProcessor, AgentIntake, SkillsConfig, MemoryConfig, ToolSpec, ToolHandler, QuotaProvider, QuotaQuery, QuotaReport, ActionApprovalMode, BackgroundActorResolver, ModelCatalog, EmptyRoles, ActorResolver, AgentHistoryWindow, SkillProvider, ScopeResolver, MemoryProvider, AgentRegistry, PersonaCatalogEntry, ActionProposal, Actor, AttachmentStagingStore, ChatQueueStore, ChatQueueState, AgentRunner, AgentRunInput, AgentStreamEvent, QueuePauseReason } from '@dudousxd/nestjs-agent-core';
2
+ import { Catalog } from '@dudousxd/nestjs-agent-core/genui';
2
3
  import { StandardSchemaV1 } from '@standard-schema/spec';
3
4
  import { Provider, FactoryProvider, Type, CanActivate, ModuleMetadata, InjectionToken, OptionalFactoryDependency } from '@nestjs/common';
5
+ import { GenuiCatalogResolver } from './genui/index.js';
4
6
 
5
7
  /** Everything `runAgentLoop` needs, minus the per-run `day` the runner stamps. */
6
8
  interface AgentDeps {
9
+ resolveUiCatalog?: ResolveToolUiCatalog;
10
+ actionApprovalMode?: 'blocking' | 'independent';
7
11
  model: ModelProvider;
8
12
  store: AgentStore;
9
13
  registry: ToolRegistry;
@@ -24,6 +28,8 @@ interface AgentDeps {
24
28
  maxAgentAppearances?: number;
25
29
  /** Agent-level tool allow-list. Undefined → all tools (after role filtering). */
26
30
  toolAllowList?: string[];
31
+ /** The agent's personas (`@Agent({ personas })`) — read by the loop only for a run that names one. */
32
+ personas?: Persona[];
27
33
  /** Per-tool execution timeout in ms (from module options). Undefined → no timeout. */
28
34
  toolTimeoutMs?: number;
29
35
  /**
@@ -238,6 +244,13 @@ interface AgentMemoryOptions {
238
244
  maxFactChars?: number;
239
245
  }
240
246
  interface AgentModuleOptions {
247
+ actionApprovalMode?: ActionApprovalMode;
248
+ backgroundActorResolver?: BackgroundActorResolver;
249
+ actionProposalWorker?: {
250
+ pollIntervalMs?: number;
251
+ leaseMs?: number;
252
+ maxConcurrency?: number;
253
+ };
241
254
  /**
242
255
  * The LLM provider — `aiSdkModel(openai('gpt-5-mini'))`, or `aiSdkModels({ … })` for a model
243
256
  * picker (its catalog is used when `models` is omitted). The only required option.
@@ -523,7 +536,9 @@ declare class AgentDepsFactory {
523
536
  private readonly pricingStore;
524
537
  private readonly skills;
525
538
  private readonly memory;
526
- constructor(options: AgentModuleOptions, model: ModelProvider, store: AgentStore, sink: TokenStreamSink, rolesPolicy: RolesPolicy, registry: ToolRegistry, agents: AgentRegistry, promptContributors: PromptContributor[], pricingStore: AgentPricingStore | undefined, skills: SkillsConfig | undefined, memory: MemoryConfig | undefined);
539
+ private readonly uiCatalog?;
540
+ private readonly uiCatalogResolver?;
541
+ constructor(options: AgentModuleOptions, model: ModelProvider, store: AgentStore, sink: TokenStreamSink, rolesPolicy: RolesPolicy, registry: ToolRegistry, agents: AgentRegistry, promptContributors: PromptContributor[], pricingStore: AgentPricingStore | undefined, skills: SkillsConfig | undefined, memory: MemoryConfig | undefined, uiCatalog?: Catalog | undefined, uiCatalogResolver?: GenuiCatalogResolver | undefined);
527
542
  /** The turn's memory seam, shared with the read-back endpoint. Undefined → memory is not configured. */
528
543
  memoryConfig(): MemoryConfig | undefined;
529
544
  /** The turn's skills seam, shared with the listing endpoint. Undefined → skills are not configured. */
@@ -534,7 +549,37 @@ declare class AgentDepsFactory {
534
549
  */
535
550
  defaultAgentName(): string;
536
551
  private effectiveTools;
552
+ /**
553
+ * The agent a name stands for: itself when it is a registered agent (or unknown — the bare
554
+ * assistant), else the agent whose persona took the name over (`Persona.aliases`) and that persona.
555
+ */
556
+ resolveAgent(name: string): {
557
+ agentName: string;
558
+ persona?: string;
559
+ };
560
+ /** `agentName`'s personas as a picker reads them. Empty when it declares none. */
561
+ personaCatalog(agentName: string): PersonaCatalogEntry[];
562
+ /** The persona `agentName` runs under when nothing names one. Undefined → none. */
563
+ defaultPersona(agentName: string): string | undefined;
564
+ /**
565
+ * The persona a turn of `agentName` runs under, most specific first: the one the send names
566
+ * (refused with `400 persona_not_found` when the agent does not declare it), else the thread's
567
+ * pinned one when this agent declares it, else the agent's default. Undefined → none.
568
+ */
569
+ resolvePersona(args: {
570
+ agentName: string;
571
+ requested?: string;
572
+ threadPersona?: string | null;
573
+ }): string | undefined;
574
+ forProposal(proposal: ActionProposal, _actor: Actor): AgentDeps;
537
575
  forAgent(agentName?: string): AgentDeps;
576
+ /**
577
+ * A persona baked into an agent's deps — how a run journaled under an agent name that a persona
578
+ * has since taken over (`Persona.aliases`) is served. That run names no persona of its own (it
579
+ * predates them, and its input cannot change), so the persona applies the way the old agent's own
580
+ * config did: as config, re-read on every replay, with no checkpoint of its own.
581
+ */
582
+ private withPersona;
538
583
  /**
539
584
  * The ceiling on how much of a thread this agent's turns carry, most specific wins: the agent's
540
585
  * own `@Agent({ history })`, else a module-wide custom `historyPolicy`, else the module-wide
@@ -591,13 +636,14 @@ declare class ChatQueueService {
591
636
  private readonly quotaProvider?;
592
637
  private readonly options?;
593
638
  private readonly staging?;
639
+ private readonly deps?;
594
640
  private readonly logger;
595
641
  /**
596
642
  * Runs this process claimed a thread for and has not handed to a runner yet. A holder in here is
597
643
  * alive whatever the runner says — it simply has not registered the run yet.
598
644
  */
599
645
  private readonly starting;
600
- constructor(store: AgentStore, sink: TokenStreamSink, quotaProvider?: QuotaProvider | undefined, options?: Pick<AgentModuleOptions, "quota"> | undefined, staging?: AttachmentStagingStore | undefined);
646
+ constructor(store: AgentStore, sink: TokenStreamSink, quotaProvider?: QuotaProvider | undefined, options?: Pick<AgentModuleOptions, "quota"> | undefined, staging?: AttachmentStagingStore | undefined, deps?: AgentDepsFactory | undefined);
601
647
  /** Whether the bound store can hold a queue. Without one, a busy thread is not queued. */
602
648
  get supported(): boolean;
603
649
  /** The bound store as a queue store, or a 501 naming what is missing. */
@@ -673,6 +719,13 @@ declare class ChatQueueService {
673
719
  * waited. An attachment the staging store no longer resolves keeps its stored url.
674
720
  */
675
721
  private inputFor;
722
+ /**
723
+ * The agent and persona a queued message starts as. A message carries the persona its send
724
+ * resolved; one that carries none — queued before personas existed, or by a host that sets none —
725
+ * resolves it now, exactly as a send would: an agent name a persona took over, then the thread's
726
+ * pinned persona, then the agent's default. Never refused here: nobody is waiting on a 400.
727
+ */
728
+ private targetFor;
676
729
  private freshAttachments;
677
730
  }
678
731