@dudousxd/nestjs-agent 1.14.0 → 1.16.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
@@ -302,6 +302,27 @@ as long as ids only increase — see *Numbering a stream you rebuild* in the pro
302
302
  `Last-Event-ID` header) skips what a reconnecting client already has; the React transport uses it to
303
303
  continue a message after a network drop. See docs/stream-protocol.md.
304
304
 
305
+ ### Sending while a turn is running (the message queue)
306
+
307
+ `POST /agent/chat` on a thread that already has a turn running no longer starts a second one: the
308
+ message is queued on the thread (persisted — it survives restarts) and the answer is `202 { queued:
309
+ true, messageId, position, queue }`. When the running turn completes, the next queued message
310
+ starts under its own id, and the settling turn's stream says so in a final `queue` frame
311
+ (`started: { messageId, runId }`). `mode: 'interrupt'` cancels the running turn and runs the message
312
+ next; `mode: 'queue'` always queues. A failed turn or a Stop pauses the queue (`POST
313
+ /agent/threads/:id/queue/resume` lifts it); an exhausted quota pauses it as the next message starts.
314
+ `POST /agent/queue/:messageId/interrupt` runs a message that is already waiting now — it moves to
315
+ the head as an interrupt and the running turn is cancelled for it, in one request.
316
+ `GET`/`DELETE /agent/threads/:id/queue`, `PATCH`/`DELETE /agent/queue/:messageId` list, clear,
317
+ edit, move and remove what is waiting.
318
+
319
+ Both runners and both SQL stores support it (`ChatQueueStore`; the stores add an
320
+ `agent_queued_message` table and an `agent_thread.queue_pause` column on boot). Admission is a
321
+ compare-and-set on the thread's active run, so exactly one turn runs per thread across pods, and a
322
+ durable drain is journaled — it never starts a queued message twice. `AgentService.chat()` stays a
323
+ start-or-refuse call for in-process callers (`409 run_active` on a busy thread); `send()` is the
324
+ queueing one. Policy and wire contract: *Message queue* in docs/stream-protocol.md.
325
+
305
326
  ### Message feedback
306
327
 
307
328
  `POST /agent/messages/:id/feedback` `{ value: 'up' | 'down' | null, comment? }` rates a message in
@@ -1,4 +1,4 @@
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, 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, QuotaProvider, QuotaQuery, QuotaReport, ModelCatalog, ActorResolver, AgentHistoryWindow, SkillProvider, ScopeResolver, MemoryProvider, AgentRegistry, AttachmentStagingStore, ChatQueueStore, ChatQueueState, AgentRunner, AgentRunInput, AgentStreamEvent, QueuePauseReason, Actor } 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
 
@@ -513,4 +513,133 @@ declare class AgentDepsFactory {
513
513
  private followUpsCount;
514
514
  }
515
515
 
516
- export { AgentDepsFactory as A, type FunctionalTool as F, LedgerQuotaProvider as L, type QuotaLimits as Q, type AgentSurface as a, type AgentModuleOptions as b, type AgentModuleAsyncOptions as c, type AgentSkillsOptions as d, AGENT_TOOL_BRAND as e, type AgentAttachmentsOptions as f, type AgentDeps as g, type AgentMemoryOptions as h, type QuotaWindowLimits as i, delegateToolName as j, provideAgentTools as k, provideAgentTool as p, utcDay as u };
516
+ /** How the run that held a thread ended. */
517
+ type QueueSettleOutcome = 'completed' | 'failed' | 'cancelled';
518
+ /** The next queued message a settling run handed the thread to — the caller starts it. */
519
+ interface QueuedTurn {
520
+ /** The run id the thread is already claimed for: the queued message's own id. */
521
+ runId: string;
522
+ input: AgentRunInput;
523
+ messageId: string;
524
+ }
525
+ /**
526
+ * What {@link ChatQueueService.plan} decided. Plain data, so a durable workflow can journal it in a
527
+ * `localStep` and start `next` itself (`ctx.startChild`) — replay-safe, and never twice.
528
+ */
529
+ interface QueuePlan {
530
+ next?: QueuedTurn;
531
+ /** The `queue` frame to write into the settling run's stream, before its terminal. */
532
+ frame?: AgentStreamEvent;
533
+ }
534
+ /** Starts a run under a pre-claimed id — a runner's own `start`, bound. */
535
+ type QueuedTurnStarter = (input: AgentRunInput, runId: string) => Promise<unknown>;
536
+ /**
537
+ * The thread-level message queue: messages a person sends while a turn is running wait here, and
538
+ * the next one starts as soon as the running turn settles.
539
+ *
540
+ * Everything that decides WHICH run holds a thread goes through the store's compare-and-set
541
+ * admission (`claimActiveStream` / `releaseActiveStream`), so two processes that both try to start
542
+ * the next turn — a settling run on a worker and a send on an API pod — cannot both win. The next
543
+ * run's id is the queued message's own id, which makes every step here idempotent: a retried claim
544
+ * for the same message succeeds again, and a durable start under the same id is a no-op.
545
+ *
546
+ * Policy, per outcome of the run that held the thread:
547
+ * - `completed` → the head starts.
548
+ * - `failed` → the queue pauses (`run_failed`), keeping every message.
549
+ * - `cancelled` → the queue pauses (`cancelled`), unless the head was queued by an interrupt — the
550
+ * cancel was made for it, so it starts.
551
+ * A paused queue starts nothing until it is resumed. A head whose actor is over quota pauses it
552
+ * (`quota_exceeded`) instead of starting.
553
+ */
554
+ declare class ChatQueueService {
555
+ private readonly store;
556
+ private readonly sink;
557
+ private readonly quotaProvider?;
558
+ private readonly options?;
559
+ private readonly staging?;
560
+ private readonly logger;
561
+ /**
562
+ * Runs this process claimed a thread for and has not handed to a runner yet. A holder in here is
563
+ * alive whatever the runner says — it simply has not registered the run yet.
564
+ */
565
+ private readonly starting;
566
+ constructor(store: AgentStore, sink: TokenStreamSink, quotaProvider?: QuotaProvider | undefined, options?: Pick<AgentModuleOptions, "quota"> | undefined, staging?: AttachmentStagingStore | undefined);
567
+ /** Whether the bound store can hold a queue. Without one, a busy thread is not queued. */
568
+ get supported(): boolean;
569
+ /** The bound store as a queue store, or a 501 naming what is missing. */
570
+ queueStore(): AgentStore & ChatQueueStore;
571
+ state(threadId: string): Promise<ChatQueueState>;
572
+ /**
573
+ * Whether `holder` is still running. A run this process is about to start is; otherwise the
574
+ * runner decides, and a runner that cannot tell says yes.
575
+ */
576
+ isAlive(holder: string, runner: AgentRunner): Promise<boolean>;
577
+ /**
578
+ * The run holding `threadId`, or `null` when it is free — a holder the runner reports dead counts
579
+ * as free, and is returned as `stale` so the caller can claim over it.
580
+ */
581
+ holder(threadId: string, runner: AgentRunner): Promise<{
582
+ live: string | null;
583
+ stale: string | null;
584
+ }>;
585
+ /**
586
+ * Decide what follows a TOP-LEVEL run that is settling on `threadId`, and move the thread there:
587
+ * hand it to the next queued message (claimed and popped here, started by the caller), or pause
588
+ * the queue, or release the thread. Call it BEFORE the run's terminal frame, so a client reading
589
+ * the stream learns what comes next from the `queue` frame, and so the thread is already free
590
+ * when the client sees the run end.
591
+ *
592
+ * A no-op returning `{}` on a store without a queue.
593
+ */
594
+ plan(args: {
595
+ threadId: string;
596
+ /** The run that is settling; `null` when the thread is idle (a kick). */
597
+ runId: string | null;
598
+ outcome: QueueSettleOutcome;
599
+ error?: string;
600
+ }): Promise<QueuePlan>;
601
+ /**
602
+ * {@link plan}, then start what it picked with `start`. What an in-process settle (the inline
603
+ * runner, a durable cancel issued outside a workflow) calls; answers the frame to write.
604
+ */
605
+ handoff(args: {
606
+ threadId: string;
607
+ runId: string;
608
+ outcome: QueueSettleOutcome;
609
+ error?: string;
610
+ }, start: QueuedTurnStarter): Promise<AgentStreamEvent | undefined>;
611
+ /**
612
+ * Start the head of an IDLE thread's queue — after a send queued behind a run that turned out to
613
+ * be gone, after a resume, after a stale holder. Does nothing while a live run holds the thread
614
+ * (its settle drains) or while the queue is paused. Answers the started run's id, if any.
615
+ */
616
+ kick(threadId: string, runner: AgentRunner): Promise<string | undefined>;
617
+ /**
618
+ * Start a turn the thread is already claimed for. On failure the message goes back to the head of
619
+ * the queue, the queue pauses (`start_failed`), and the claim is released — nothing is lost and
620
+ * nothing is left holding the thread.
621
+ */
622
+ launch(threadId: string, next: QueuedTurn, start: QueuedTurnStarter): Promise<boolean>;
623
+ /** Put a turn that never started back at the head of its queue, paused, and free the thread. */
624
+ restore(threadId: string, next: QueuedTurn, reason: QueuePauseReason, message: string): Promise<void>;
625
+ /** A `queue` frame with the thread's current state. */
626
+ pausedFrame(threadId: string): Promise<AgentStreamEvent>;
627
+ /**
628
+ * Tell whoever is watching the thread that its queue changed: a `queue` frame into the stream of
629
+ * the run holding it. Best-effort — a thread nobody is streaming has no one to tell, and the next
630
+ * read of the thread carries the queue anyway.
631
+ */
632
+ publish(threadId: string): Promise<ChatQueueState>;
633
+ private write;
634
+ /** Why `actor` may not start a turn now (the quota message), or `null`. */
635
+ quotaBlock(actor: Actor): Promise<string | null>;
636
+ /**
637
+ * The turn a queued message starts: everything it was queued with, today's date, and its
638
+ * attachments' urls minted afresh — the ones stored at enqueue time may have expired while it
639
+ * waited. An attachment the staging store no longer resolves keeps its stored url.
640
+ */
641
+ private inputFor;
642
+ private freshAttachments;
643
+ }
644
+
645
+ export { AgentDepsFactory as A, ChatQueueService as C, type FunctionalTool as F, LedgerQuotaProvider as L, type QueuePlan as Q, type AgentSurface as a, type AgentModuleOptions as b, type AgentModuleAsyncOptions as c, type AgentSkillsOptions as d, AGENT_TOOL_BRAND as e, type AgentAttachmentsOptions as f, type AgentDeps as g, type AgentMemoryOptions as h, type QueueSettleOutcome as i, type QueuedTurn as j, type QueuedTurnStarter as k, type QuotaLimits as l, type QuotaWindowLimits as m, delegateToolName as n, provideAgentTools as o, provideAgentTool as p, utcDay as u };
@@ -1,4 +1,4 @@
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, 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, QuotaProvider, QuotaQuery, QuotaReport, ModelCatalog, ActorResolver, AgentHistoryWindow, SkillProvider, ScopeResolver, MemoryProvider, AgentRegistry, AttachmentStagingStore, ChatQueueStore, ChatQueueState, AgentRunner, AgentRunInput, AgentStreamEvent, QueuePauseReason, Actor } 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
 
@@ -513,4 +513,133 @@ declare class AgentDepsFactory {
513
513
  private followUpsCount;
514
514
  }
515
515
 
516
- export { AgentDepsFactory as A, type FunctionalTool as F, LedgerQuotaProvider as L, type QuotaLimits as Q, type AgentSurface as a, type AgentModuleOptions as b, type AgentModuleAsyncOptions as c, type AgentSkillsOptions as d, AGENT_TOOL_BRAND as e, type AgentAttachmentsOptions as f, type AgentDeps as g, type AgentMemoryOptions as h, type QuotaWindowLimits as i, delegateToolName as j, provideAgentTools as k, provideAgentTool as p, utcDay as u };
516
+ /** How the run that held a thread ended. */
517
+ type QueueSettleOutcome = 'completed' | 'failed' | 'cancelled';
518
+ /** The next queued message a settling run handed the thread to — the caller starts it. */
519
+ interface QueuedTurn {
520
+ /** The run id the thread is already claimed for: the queued message's own id. */
521
+ runId: string;
522
+ input: AgentRunInput;
523
+ messageId: string;
524
+ }
525
+ /**
526
+ * What {@link ChatQueueService.plan} decided. Plain data, so a durable workflow can journal it in a
527
+ * `localStep` and start `next` itself (`ctx.startChild`) — replay-safe, and never twice.
528
+ */
529
+ interface QueuePlan {
530
+ next?: QueuedTurn;
531
+ /** The `queue` frame to write into the settling run's stream, before its terminal. */
532
+ frame?: AgentStreamEvent;
533
+ }
534
+ /** Starts a run under a pre-claimed id — a runner's own `start`, bound. */
535
+ type QueuedTurnStarter = (input: AgentRunInput, runId: string) => Promise<unknown>;
536
+ /**
537
+ * The thread-level message queue: messages a person sends while a turn is running wait here, and
538
+ * the next one starts as soon as the running turn settles.
539
+ *
540
+ * Everything that decides WHICH run holds a thread goes through the store's compare-and-set
541
+ * admission (`claimActiveStream` / `releaseActiveStream`), so two processes that both try to start
542
+ * the next turn — a settling run on a worker and a send on an API pod — cannot both win. The next
543
+ * run's id is the queued message's own id, which makes every step here idempotent: a retried claim
544
+ * for the same message succeeds again, and a durable start under the same id is a no-op.
545
+ *
546
+ * Policy, per outcome of the run that held the thread:
547
+ * - `completed` → the head starts.
548
+ * - `failed` → the queue pauses (`run_failed`), keeping every message.
549
+ * - `cancelled` → the queue pauses (`cancelled`), unless the head was queued by an interrupt — the
550
+ * cancel was made for it, so it starts.
551
+ * A paused queue starts nothing until it is resumed. A head whose actor is over quota pauses it
552
+ * (`quota_exceeded`) instead of starting.
553
+ */
554
+ declare class ChatQueueService {
555
+ private readonly store;
556
+ private readonly sink;
557
+ private readonly quotaProvider?;
558
+ private readonly options?;
559
+ private readonly staging?;
560
+ private readonly logger;
561
+ /**
562
+ * Runs this process claimed a thread for and has not handed to a runner yet. A holder in here is
563
+ * alive whatever the runner says — it simply has not registered the run yet.
564
+ */
565
+ private readonly starting;
566
+ constructor(store: AgentStore, sink: TokenStreamSink, quotaProvider?: QuotaProvider | undefined, options?: Pick<AgentModuleOptions, "quota"> | undefined, staging?: AttachmentStagingStore | undefined);
567
+ /** Whether the bound store can hold a queue. Without one, a busy thread is not queued. */
568
+ get supported(): boolean;
569
+ /** The bound store as a queue store, or a 501 naming what is missing. */
570
+ queueStore(): AgentStore & ChatQueueStore;
571
+ state(threadId: string): Promise<ChatQueueState>;
572
+ /**
573
+ * Whether `holder` is still running. A run this process is about to start is; otherwise the
574
+ * runner decides, and a runner that cannot tell says yes.
575
+ */
576
+ isAlive(holder: string, runner: AgentRunner): Promise<boolean>;
577
+ /**
578
+ * The run holding `threadId`, or `null` when it is free — a holder the runner reports dead counts
579
+ * as free, and is returned as `stale` so the caller can claim over it.
580
+ */
581
+ holder(threadId: string, runner: AgentRunner): Promise<{
582
+ live: string | null;
583
+ stale: string | null;
584
+ }>;
585
+ /**
586
+ * Decide what follows a TOP-LEVEL run that is settling on `threadId`, and move the thread there:
587
+ * hand it to the next queued message (claimed and popped here, started by the caller), or pause
588
+ * the queue, or release the thread. Call it BEFORE the run's terminal frame, so a client reading
589
+ * the stream learns what comes next from the `queue` frame, and so the thread is already free
590
+ * when the client sees the run end.
591
+ *
592
+ * A no-op returning `{}` on a store without a queue.
593
+ */
594
+ plan(args: {
595
+ threadId: string;
596
+ /** The run that is settling; `null` when the thread is idle (a kick). */
597
+ runId: string | null;
598
+ outcome: QueueSettleOutcome;
599
+ error?: string;
600
+ }): Promise<QueuePlan>;
601
+ /**
602
+ * {@link plan}, then start what it picked with `start`. What an in-process settle (the inline
603
+ * runner, a durable cancel issued outside a workflow) calls; answers the frame to write.
604
+ */
605
+ handoff(args: {
606
+ threadId: string;
607
+ runId: string;
608
+ outcome: QueueSettleOutcome;
609
+ error?: string;
610
+ }, start: QueuedTurnStarter): Promise<AgentStreamEvent | undefined>;
611
+ /**
612
+ * Start the head of an IDLE thread's queue — after a send queued behind a run that turned out to
613
+ * be gone, after a resume, after a stale holder. Does nothing while a live run holds the thread
614
+ * (its settle drains) or while the queue is paused. Answers the started run's id, if any.
615
+ */
616
+ kick(threadId: string, runner: AgentRunner): Promise<string | undefined>;
617
+ /**
618
+ * Start a turn the thread is already claimed for. On failure the message goes back to the head of
619
+ * the queue, the queue pauses (`start_failed`), and the claim is released — nothing is lost and
620
+ * nothing is left holding the thread.
621
+ */
622
+ launch(threadId: string, next: QueuedTurn, start: QueuedTurnStarter): Promise<boolean>;
623
+ /** Put a turn that never started back at the head of its queue, paused, and free the thread. */
624
+ restore(threadId: string, next: QueuedTurn, reason: QueuePauseReason, message: string): Promise<void>;
625
+ /** A `queue` frame with the thread's current state. */
626
+ pausedFrame(threadId: string): Promise<AgentStreamEvent>;
627
+ /**
628
+ * Tell whoever is watching the thread that its queue changed: a `queue` frame into the stream of
629
+ * the run holding it. Best-effort — a thread nobody is streaming has no one to tell, and the next
630
+ * read of the thread carries the queue anyway.
631
+ */
632
+ publish(threadId: string): Promise<ChatQueueState>;
633
+ private write;
634
+ /** Why `actor` may not start a turn now (the quota message), or `null`. */
635
+ quotaBlock(actor: Actor): Promise<string | null>;
636
+ /**
637
+ * The turn a queued message starts: everything it was queued with, today's date, and its
638
+ * attachments' urls minted afresh — the ones stored at enqueue time may have expired while it
639
+ * waited. An attachment the staging store no longer resolves keeps its stored url.
640
+ */
641
+ private inputFor;
642
+ private freshAttachments;
643
+ }
644
+
645
+ export { AgentDepsFactory as A, ChatQueueService as C, type FunctionalTool as F, LedgerQuotaProvider as L, type QueuePlan as Q, type AgentSurface as a, type AgentModuleOptions as b, type AgentModuleAsyncOptions as c, type AgentSkillsOptions as d, AGENT_TOOL_BRAND as e, type AgentAttachmentsOptions as f, type AgentDeps as g, type AgentMemoryOptions as h, type QueueSettleOutcome as i, type QueuedTurn as j, type QueuedTurnStarter as k, type QuotaLimits as l, type QuotaWindowLimits as m, delegateToolName as n, provideAgentTools as o, provideAgentTool as p, utcDay as u };