@dudousxd/nestjs-agent-core 0.38.0 → 0.38.1

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
@@ -23,7 +23,7 @@ import type { ModelProvider, AgentStore, ToolSpec, RolesPolicy } from '@dudousxd
23
23
  - `ToolHandler.describe?({ actor, threadId?, agentName? })` — **a per-turn description.** Called when the turn's tool list is built, after every gate; what it returns replaces the spec's `description` / `inputSchema` in what the model sees (`registry.definitionsFor(actor, policy, allowList, { threadId, agentName })`). The registry still validates calls against the registered schema, so a tool whose input varies per turn registers a permissive one and validates in `execute`.
24
24
  - `@dudousxd/nestjs-agent-core/genui` (+ `/genui/builtins`) — **the generative-UI catalog**, an isomorphic entry (no server-only import; a spec holds that line) a browser imports too: `defineComponent` / `defineCatalog` (Standard Schema or JSON Schema props), validation, `catalogToModelText`, `componentToText` fallbacks, tree helpers, and `genuiTools(catalog, { mode, terminal, showTool, resolveCatalog, … })` — the tools that push components, with an optional per-request catalog resolver consulted on every call and every turn's description. NestJS apps use `AgentGenuiModule` (`@dudousxd/nestjs-agent/genui`) instead of calling `genuiTools` themselves.
25
25
  - `AgentDefinition` — a named agent (`systemPrompt` string | `PromptBuilder`, `tools`, `delegatesTo`, `personas`, …) for multi-agent setups. `delegatesTo` holds `AgentDelegation` entries: a bare target name for the delegation that waits, `{ agent, detached: true }` for one that does not.
26
- - `detachedStarted` / `detachedDelivered` / `settleUnsettledDelegation` / `AgentLoopHooks.startAgent` / `AgentRunInput.deliverTo` — **delegation that does not block the chat.** An `agent`-kind call whose spec says `detached` is STARTED rather than awaited (`hooks.startAgent`, which the durable runner maps to `ctx.startChild` and the inline one to a loop nobody awaits), and the turn ends with a `DetachedDelegationReceipt` as the call's result instead of an answer. The started run carries a `deliverTo` address — the delegating thread and the call that started it — and posts its answer there as a message of its own, stamped with its own `runId` and `agentName`, so a client renders "the research agent finished" rather than the assistant's next reply. Whether a call detaches is settled INSIDE `persist:toolcall` alongside its kind and target, never from a live registry lookup, so a replay reads the branch back rather than re-deciding it; the loop writes the same checkpoint names either way, and only the runner's own positions (`spawn:` versus the awaited child's `signal:child:`) differ. A detached run streams into its OWN sink and its `action` tools park on its OWN run, so its approval reaches the pending-approvals surface instead of an inline card in whatever turn happens to be open. `settleUnsettledDelegation` is the runner's half: a run that crashed or was stopped posts a message saying so, because "started" is the one state a reader can neither wait on nor act on. `AgentRunInput.parentRunId` / `RecordRunStartInput.parentRunId` record the edge, so a delegation is not a run row with nothing pointing at it.
26
+ - `detachedStarted` / `detachedDelivered` / `settleUnsettledDelegation` / `AgentLoopHooks.startAgent` / `AgentRunInput.deliverTo` — **delegation that does not block the chat.** An `agent`-kind call whose spec says `detached` is STARTED rather than awaited (`hooks.startAgent`, which the durable runner maps to a run of its own — started from a journaled `detach:<toolCallId>` step, NOT a `ctx.startChild`, so a Stop on the delegating turn does not cascade to it — and the inline one to a loop nobody awaits), and the turn ends with a `DetachedDelegationReceipt` as the call's result instead of an answer. The started run carries a `deliverTo` address — the delegating thread and the call that started it — and posts its answer there as a message of its own, stamped with its own `runId` and `agentName`, so a client renders "the research agent finished" rather than the assistant's next reply. Whether a call detaches is settled INSIDE `persist:toolcall` alongside its kind and target, never from a live registry lookup, so a replay reads the branch back rather than re-deciding it; the loop writes the same checkpoint names either way, and only the runner's own positions (`patch:agent:detached-unlinked` + `detach:` — or `spawn:` on a run journaled by `@dudousxd/nestjs-agent` 1.19.2 or earlier — versus the awaited child's `signal:child:`) differ. A detached run outlives a Stop on the turn that started it; it is stopped by its own `runId`, and the card follows it to `delivered`, `failed` or `cancelled`. A detached run streams into its OWN sink and its `action` tools park on its OWN run, so its approval reaches the pending-approvals surface instead of an inline card in whatever turn happens to be open. `settleUnsettledDelegation` is the runner's half: a run that crashed or was stopped posts a message saying so (once — a thread already holding a message from that run is left alone, so the body and the runner may both call it), because "started" is the one state a reader can neither wait on nor act on. `AgentRunInput.parentRunId` / `RecordRunStartInput.parentRunId` record the edge, so a delegation is not a run row with nothing pointing at it.
27
27
  - `RolesPolicy.can(actor, tool): boolean | Promise<boolean>` — the tool authorization seam
28
28
  - `HistoryPolicy` — the ceiling on how much of a thread rides into a turn. `select(messages, ctx)` must be pure; the loop runs it INSIDE `load:thread`, so the ceiling bounds the journal as well as the prompt — the checkpoint holds the selected messages rather than the whole `ThreadDetail`, and a run recorded before that keeps the old payload via `ctx.patched('agent:selected-history')`. The optional `summarize(dropped, ctx)` may call a model and runs in its own `history:summarize` checkpoint (which is also the only case where the dropped messages are journaled at all). `windowHistory`, `summarizeWithModel` and `estimateMessageTokens` are the built-ins. A policy MAY also declare `maxMessages` — the most messages `select` can ever keep — which the loop passes to `ThreadTurnReader` below as the store's read bound. Declaring it is a promise that `select` keeps at most that many, and that they are the NEWEST ones; a ceiling expressed only in tokens declares nothing, since one message can be four tokens or forty thousand and no row count follows from a budget.
29
29
  - `ThreadTurnReader.loadThreadForTurn({ threadId, messageLimit })` — **the read a turn actually needs.** `getThread` materializes a thread's whole transcript (every message row, every attachment, every tool output it ever recorded) to build a prompt bounded to its last few messages: on a 50-turn thread whose turns each ran a 50 KB tool that is ~2.6 MB per load, over 99% of it tool output. This read is bounded by the database (`order by created_at desc, id desc limit ?`, reversed for the prompt) and projected to the columns a model turn reads — `usage`, `follow_ups` and `run_id` stay in the table. The loop probes for it STRUCTURALLY, the same way `AgentService` probes `defaultAgentForThread`: a store that does not implement it keeps working through the full read, with no config change and no warning. `messageLimit` comes from `HistoryPolicy.maxMessages`, and is omitted — meaning read everything — for a policy that summarizes (`summarize` is handed what `select` DROPPED, and a read bounded to what it keeps drops nothing) or whose ceiling is only a token budget. Which branch ran is invisible to the journal by construction: both produce the same three answers, so the payload `load:thread` records is identical either way and a deployment's choice of store can never decide a run's checkpoints. `hasAssistantMessage` is answered over the WHOLE thread, never the page — it decides a `thread-start` intake, and a window holding only the user's last questions belongs to a conversation that has still been answered.
@@ -34,7 +34,7 @@ import type { ModelProvider, AgentStore, ToolSpec, RolesPolicy } from '@dudousxd
34
34
  - `Skill` / `SkillProvider` / `ScopeResolver` / `offerSkills` — authored procedures the model pulls in when a task calls for one, instead of every instruction living in the system prompt. A skill is not an agent: an `@Agent` is WHO answers, a skill is HOW one task is done, and any agent may load one. Scoping is by an opaque TOKEN (`actor:u1`, `tenant:berlin`, `global`, or a host's own `depot:north`), and which tokens apply is a host-supplied `ScopeResolver` returning them most-specific-first — so precedence falls out of the order and a new axis is a resolver change, not a schema change. `defaultScopeResolver` covers the tokens derivable from `Actor` alone (actor / tenant / global); `actorScope`, `tenantScope` and `GLOBAL_SCOPE` mint them. This package owns NO skill table: the host owns the rows behind `SkillProvider` (`list(scopes, ctx)` for the catalog, `load(name, scope, ctx)` for one body), so a consumer can relate its own `Sector` entity against the token values in its own read model without writing migrations into a schema the boot-time heal also edits. `staticSkillProvider` and `compositeSkillProvider` are the built-ins. `resolveSkillCatalog` is the pure precedence pass: most specific wins, and the loser's scope is recorded on `shadows` rather than discarded, so the model can say "your setting differs from the org default" instead of choosing silently. What enters the SYSTEM prompt is the catalog only — one line per skill, bounded by `maxSkills` (`DEFAULT_MAX_SKILLS`) — while a BODY arrives as a `skill` tool result on the transcript, where the `HistoryPolicy` ceiling already governs it. The loop spends ONE checkpoint on all of it (`skills:catalog`) holding the whole offer, and serves each load inside the ordinary `tool:<callId>` checkpoint, so both the scopes that applied and the body that entered the prompt are facts the journal holds rather than answers a replaying process's provider would give afresh. `loadSkill` refuses any name the turn's own catalog does not carry, which makes the journaled catalog the authorization boundary as well as the menu. `skillWriteVerdict` is the write rule: your own scope is yours, a wider one needs an elevated HUMAN author, and nothing but a human may ever write above its own scope — an agent that could write a `tenant:` skill is an agent whose prompt anyone in the tenant can edit by talking to it.
35
35
  - `MemoryRecord` / `MemoryProvider` / `offerMemories` / `writeMemory` — what the assistant concluded about a person or an organisation, carried across turns and threads. Scoped by the SAME opaque tokens and the same `ScopeResolver` skills use, so a deployment has one answer to "which scopes does this actor have". A memory is a keyed fact: `{ key, text, scope, origin, updatedAt }`, and the key is what makes a conflict mechanically detectable — two memories sharing a key at different scopes are one question answered twice, and `resolveMemoryDigest` lets the narrower win. Where it does, the entry's `overrides` carries the beaten **text** and its **author**, not merely its scope (a skill's `shadows`): the model is following one procedure either way, but a memory is a VALUE, and an agent that knew only that a wider one existed could tell the user nothing except which it picked. **Not retrieval:** a passage is a document someone authored and can fix at its source, a memory is the agent's own inference about someone who never saw it written — hence `MemoryOrigin` on every record, a block that tells the model these are its own fallible notes, and `forget` being REQUIRED on the provider while `write` is optional. Every provider method and every multi-argument export here takes ONE named object (`ListMemoriesInput`, `StoreMemoryInput`, `ResolveMemoryDigestInput`, …): `key`, `text` and `scope` are all strings, and transposed positional arguments would compile clean and write a fact whose key is its value. `memoryWriteVerdict` carries the same four rules as `skillWriteVerdict`; rule three (nothing but a human may write above its own scope, whatever elevation a host grants) is enforced by SHAPE as well as by check, since `rememberToolDefinition()` takes no scope parameter. `memoryForgetVerdict` is narrower still and takes no `elevated` flag: deleting what the assistant believes about YOU needs nobody's permission. What enters the system prompt is one line per memory bounded by `maxMemories` (`DEFAULT_MAX_MEMORIES`), each capped at `maxFactChars` (`DEFAULT_MAX_FACT_CHARS`) when it is WRITTEN — so the block's ceiling is the product of two numbers an operator set, and there is no body/catalog split because a fact that cannot be stated in a line is a document. **The prompt budget is bounded; the store is not.** Once the applicable set outgrows the block, WHICH memories it carries is a decision, and making it by scope starves the widest scopes first — one person's twentieth note would end every chance their organisation's facts had, leaving only a non-zero `omitted` behind. So a provider MAY implement `search({ scopes, query, limit, ctx })` and the block is filled by relevance to the turn instead; omit it and every turn is served by `list`, selecting narrowest-then-newest as before. Scope remains a hard FILTER that gates before ranking (a record returned outside `scopes` is dropped, so a host's filter bug costs throughput rather than privacy), and `search` must return every record sharing a returned key or precedence inverts. `MemoryRecord.pinned` is the categorical always-on marker — present whatever the turn is about, spending the same budget, never set by the agent (`StoreMemoryInput` has no such field, and the `remember` tool has no such parameter), with `MemoryDigest.pinnedOmitted` naming the one omission that is a misconfiguration rather than a budget. `buildMemoryBlock` frames entries by `origin.author`: what the agent CONCLUDED is hedged ("your own notes … prefer what the user says now"), what a person STATED is not, because telling a model to prefer the user over an organisation's published policy hands any user an override of it by assertion. A `partial` block says so, so the model does not read an absence as evidence. The loop spends ONE checkpoint (`memory:digest`) holding the whole digest — the search included, since a ranking is the most re-derivable decision here — which is both what the block is rendered from and what a later `remember` call is authorized against; the write itself happens inside the ordinary `tool:<callId>` checkpoint, which is what makes it idempotent under replay. The query is the user's own turn text and nothing else: the only thing available before the first model call, already a journaled input to the run, and it fails at a turn with no topic — which is what `pinned` is for.
36
36
  - `AgentStore.setMessageToolResults(messageId, results)` — a message's tool CALLS are known when it is appended and their outputs are not, so the loop settles them afterwards with one write of the turn's complete result list (its synthetic `retrieve` / `structured_output` calls included). Both halves live on the message because that is where a thread reader pairs them; a call whose output only ever reaches the `agent_tool_call` table renders as a tool still running. Required, not optional — a store that silently declines it breaks a client with nothing logged.
37
- - `AttachmentStagingStore.list(input)` / `AgentStore.referencedMediaIds(actorRef, mediaIds)` — the two halves of attachment housekeeping, split the way ownership is. `stage()` writes bytes before any message exists, so an upload the user never sent leaves media nothing points at; the HOST can enumerate that media (it stored it) but cannot see a transcript, and this library sees every transcript but never holds bytes. `list` returns `StagedAttachment` metadata (`mediaId`, `name`, `contentType`, `sizeBytes`, `createdAt` — no `url`, since `resolve` mints those per turn precisely so they can be short-lived); `referencedMediaIds` answers which of a set of ids a message that still exists carries, scoped to one actor. The answer is DERIVED from the surviving message rows on every call, never latched: `truncateFrom` deletes messages — regenerating a turn does exactly that — so a reference disappears, and a flag set at send time would pin the bytes for ever. Both are optional; a caller that cannot get an answer must collect nothing rather than read silence as "unreferenced". `AgentService.collectableAttachments` composes them, and deletes nothing.
37
+ - `AttachmentStagingStore.list(input)` / `AgentStore.referencedMediaIds(actorRef, mediaIds)` — the two halves of attachment housekeeping, split the way ownership is. `stage()` writes bytes before any message exists, so an upload the user never sent leaves media nothing points at; the HOST can enumerate that media (it stored it) but cannot see a transcript, and this library sees every transcript but never holds bytes. `list` returns `StagedAttachment` metadata (`mediaId`, `name`, `contentType`, `sizeBytes`, `createdAt` — no `url`, since `resolve` mints those per turn precisely so they can be short-lived); `referencedMediaIds` answers which of a set of ids a message that still exists — or one still waiting in a thread's queue — carries, scoped to one actor. The answer is DERIVED from the surviving message rows on every call, never latched: `truncateFrom` deletes messages — regenerating a turn does exactly that — so a reference disappears, and a flag set at send time would pin the bytes for ever. Both are optional; a caller that cannot get an answer must collect nothing rather than read silence as "unreferenced". `AgentService.collectableAttachments` composes them, and deletes nothing.
38
38
  - `agentFailureCode(error)` — the stream error code for a failed run (`cancelled`, `quota_exceeded`, `output_rejected`, `structured_output_invalid`, `replay_diverged` for the durable runtime refusing a checkpoint position, `model_no_output` for a model call that produced nothing, else `run_failed`), so a control doing its job never reads as the model breaking. `streamFailure(error)` is the `{ code, message }` a runner closes the stream with: for a crash the message is `RUN_FAILED_MESSAGE` in production and the raw text elsewhere (`exposeStreamErrorDetails` decides it outright) — the error itself belongs in the log and on the run row.
39
39
  - `settleDanglingToolCalls(messages, outcomes?)` / `danglingToolCallIds(messages)` — give every tool call in a history a result. A turn that dies mid-step leaves an assistant message asking for tools and answered by nothing, which a provider refuses, so every later turn on the thread fails too. The loop settles them inside `load:thread` (no new position; a replay reads the recorded payload) from the calls' own rows where the store implements the optional `AgentStore.toolCallOutcomes` — a tool that DID run hands the model its real output, so it is not run again — and otherwise with `UNFINISHED_TOOL_CALL`. `AgentStore.failUnsettledToolCalls(runId, error)` (optional) is what a failing run calls so no approval card waits on it; `settleDeadRun(store, { runId, threadId?, failure? })` is the same for a caller with no journal left to write to.
40
40
  - `AiToolCtx.idempotencyKey` (`<runId>:<toolCallId>`) / `AiToolCtx.toolCallId` — the same for every execution of one call, so a tool re-run after a worker died between its side effect and its checkpoint (or by a transient retry) can be made to land on the first attempt. `toolCallContext(ctx, toolCallId)` builds it for a runner's own dispatched step.
@@ -1,4 +1,4 @@
1
- import { A as AgentStreamEvent } from '../stream-events-CgWqAI-1.cjs';
1
+ import { A as AgentStreamEvent } from '../stream-events-rpd3d6gh.cjs';
2
2
  import '@standard-schema/spec';
3
3
 
4
4
  /**
@@ -1,4 +1,4 @@
1
- import { A as AgentStreamEvent } from '../stream-events-CgWqAI-1.js';
1
+ import { A as AgentStreamEvent } from '../stream-events-rpd3d6gh.js';
2
2
  import '@standard-schema/spec';
3
3
 
4
4
  /**
@@ -1,8 +1,8 @@
1
1
  import { a as Catalog, J as JsonSchema, G as GenuiValidation, b as GenuiIssue } from '../catalog-CrzetM3_.cjs';
2
2
  export { A as AjvLike, c as COMPONENT_NAME, d as CatalogOptions, C as ComponentDefinition, e as JsonSchemaValidator, P as PropsSchema, f as ajvValidator, g as builtinJsonSchemaValidator, h as defineCatalog, i as defineComponent, j as formatIssues, k as isStandardSchema, t as toJsonSchema, l as toSnakeCase, m as toolNameFor, v as validateProps, n as validatePropsSync } from '../catalog-CrzetM3_.cjs';
3
3
  import { StandardSchemaV1, StandardJSONSchemaV1 } from '@standard-schema/spec';
4
- import { T as ToolHandler } from '../tool-DuJ-_qXM.cjs';
5
- import { a as Actor, T as ToolSpec, b as ToolPresentation } from '../stream-events-CgWqAI-1.cjs';
4
+ import { T as ToolHandler } from '../tool-Dp8HA_f4.cjs';
5
+ import { a as Actor, T as ToolSpec, b as ToolPresentation } from '../stream-events-rpd3d6gh.cjs';
6
6
 
7
7
  /**
8
8
  * One node of a composed UI: a catalog component, its props, and — for components declared with
@@ -1,8 +1,8 @@
1
1
  import { a as Catalog, J as JsonSchema, G as GenuiValidation, b as GenuiIssue } from '../catalog-CrzetM3_.js';
2
2
  export { A as AjvLike, c as COMPONENT_NAME, d as CatalogOptions, C as ComponentDefinition, e as JsonSchemaValidator, P as PropsSchema, f as ajvValidator, g as builtinJsonSchemaValidator, h as defineCatalog, i as defineComponent, j as formatIssues, k as isStandardSchema, t as toJsonSchema, l as toSnakeCase, m as toolNameFor, v as validateProps, n as validatePropsSync } from '../catalog-CrzetM3_.js';
3
3
  import { StandardSchemaV1, StandardJSONSchemaV1 } from '@standard-schema/spec';
4
- import { T as ToolHandler } from '../tool-DklsS3JX.js';
5
- import { a as Actor, T as ToolSpec, b as ToolPresentation } from '../stream-events-CgWqAI-1.js';
4
+ import { T as ToolHandler } from '../tool-hhssZ_HW.js';
5
+ import { a as Actor, T as ToolSpec, b as ToolPresentation } from '../stream-events-rpd3d6gh.js';
6
6
 
7
7
  /**
8
8
  * One node of a composed UI: a catalog component, its props, and — for components declared with
@@ -1,6 +1,6 @@
1
- import { I as InputProcessor, O as OutputProcessor } from '../processors-C0snQzZJ.cjs';
2
- import { T as ToolHandler } from '../tool-DuJ-_qXM.cjs';
3
- import { a as Actor } from '../stream-events-CgWqAI-1.cjs';
1
+ import { I as InputProcessor, O as OutputProcessor } from '../processors-CAVevaYJ.cjs';
2
+ import { T as ToolHandler } from '../tool-Dp8HA_f4.cjs';
3
+ import { a as Actor } from '../stream-events-rpd3d6gh.cjs';
4
4
  import '@standard-schema/spec';
5
5
 
6
6
  /**
@@ -1,6 +1,6 @@
1
- import { I as InputProcessor, O as OutputProcessor } from '../processors-D5110pit.js';
2
- import { T as ToolHandler } from '../tool-DklsS3JX.js';
3
- import { a as Actor } from '../stream-events-CgWqAI-1.js';
1
+ import { I as InputProcessor, O as OutputProcessor } from '../processors-C-FP4nDy.js';
2
+ import { T as ToolHandler } from '../tool-hhssZ_HW.js';
3
+ import { a as Actor } from '../stream-events-rpd3d6gh.js';
4
4
  import '@standard-schema/spec';
5
5
 
6
6
  /**
package/dist/index.cjs CHANGED
@@ -2745,7 +2745,8 @@ function detachedUnsettled(args) {
2745
2745
  __name(detachedUnsettled, "detachedUnsettled");
2746
2746
  async function settleUnsettledDelegation(args) {
2747
2747
  const { store, delivery, agent, runId, status } = args;
2748
- if (await store.getThread(delivery.threadId) === null) {
2748
+ const thread = await store.getThread(delivery.threadId);
2749
+ if (thread === null || thread.messages.some((message) => message.runId === runId)) {
2749
2750
  return;
2750
2751
  }
2751
2752
  await store.appendMessage({
@@ -5675,7 +5676,8 @@ var InMemoryAgentStore = class {
5675
5676
  }
5676
5677
  }
5677
5678
  /**
5678
- * Of `mediaIds`, the ones a surviving message in one of this actor's threads still carries.
5679
+ * Of `mediaIds`, the ones a surviving message — or a message waiting in the queue — in one of
5680
+ * this actor's threads still carries.
5679
5681
  * Re-derived from the messages each call, so a media whose message was truncated away reads as
5680
5682
  * unreferenced again.
5681
5683
  */
@@ -5689,7 +5691,11 @@ var InMemoryAgentStore = class {
5689
5691
  if (thread.actorRef !== actorRef) {
5690
5692
  continue;
5691
5693
  }
5692
- for (const message of thread.messages) {
5694
+ const queued = this.queues.get(thread.id) ?? [];
5695
+ for (const message of [
5696
+ ...thread.messages,
5697
+ ...queued
5698
+ ]) {
5693
5699
  for (const attachment of message.attachments ?? []) {
5694
5700
  if (wanted.has(attachment.mediaId)) {
5695
5701
  found.add(attachment.mediaId);