import { ConfigService } from "@nestjs/config"; import { ClsService } from "nestjs-cls"; import { OperatorService } from "../../../agents/operator/services/operator.service"; import { ResponderService } from "../../../agents/responder/services/responder.service"; import { GraphCatalogService } from "../../../agents/graph/services/graph.catalog.service"; import { ScopeGuard } from "../../../agents/graph/services/scope.guard"; import { UserModulesRepository } from "../../../agents/graph/repositories/user-modules.repository"; import type { ToolCallRecord } from "../../../agents/graph/tools/tool.factory"; import { AssistantSeedContextProvider } from "../../../common/interfaces/seed.context.interface"; import { EntityServiceRegistry } from "../../../common/registries/entity.service.registry"; import { BaseConfigInterface } from "../../../config/interfaces/base.config.interface"; import { BlockNoteService } from "../../../core/blocknote/services/blocknote.service"; import { JsonApiDataInterface } from "../../../core/jsonapi/interfaces/jsonapi.data.interface"; import { JsonApiService } from "../../../core/jsonapi/services/jsonapi.service"; import { AbstractService } from "../../../core/neo4j/abstracts/abstract.service"; import { WebSocketService } from "../../../core/websocket/services/websocket.service"; import { AssistantAction } from "../../assistant-action/entities/assistant-action"; import { AssistantActionRepository } from "../../assistant-action/repositories/assistant-action.repository"; import { AssistantActionService } from "../../assistant-action/services/assistant-action.service"; import { AssistantMessage } from "../../assistant-message/entities/assistant-message"; import { AssistantMessageRepository } from "../../assistant-message/repositories/assistant-message.repository"; import { AssistantMessageService } from "../../assistant-message/services/assistant-message.service"; import { Assistant, AssistantDescriptor } from "../entities/assistant"; import { AssistantRepository } from "../repositories/assistant.repository"; import { AssistantMentionExtractor } from "./assistant-mention.extractor"; /** * Maximum number of prior messages (turns) passed to the LLM on each turn. * Keeps prompt size and cost bounded for long conversations. */ export declare const MAX_MESSAGES_TO_LLM = 20; /** * AssistantService * * Wraps the stateless ResponderService.run() in a stateful lifecycle. Extends * AbstractService so standard CRUD (find / findById / patch / delete) is * inherited and wired through the framework's JSON:API pipeline — only the * agent-turn methods below are bespoke: * - `createWithFirstMessage` — persists a brand-new assistant thread with the first turn. * - `appendMessage` — appends a user turn + agent turn to an existing assistant thread. * * Messages are stored as first-class `AssistantMessage` nodes linked via * `(Assistant)-[:HAS_MESSAGE]->(AssistantMessage)`. Per-turn `references` are * materialised as `(AssistantMessage)-[:REFERENCES]->(entity)` edges (see * AssistantMessageRepository.linkReferences). */ export declare class AssistantService extends AbstractService { private readonly userModuleIdsRepository; private readonly responder; private readonly assistantMessages; private readonly assistantMessageRepo; private readonly graphCatalog; private readonly entityServices; private readonly operator; private readonly assistantActions; private readonly assistantActionRepo; private readonly webSocketService; private readonly configService; private readonly mentions; private readonly blockNote; private readonly scopeGuard; private readonly seedContextProviders?; protected readonly descriptor: import("../../..").EntityDescriptor import("../../..").DataModelInterface; }; }; }>; private readonly assistantLogger; constructor(jsonApiService: JsonApiService, assistantRepository: AssistantRepository, clsService: ClsService, userModuleIdsRepository: UserModulesRepository, responder: ResponderService, assistantMessages: AssistantMessageService, assistantMessageRepo: AssistantMessageRepository, graphCatalog: GraphCatalogService, entityServices: EntityServiceRegistry, operator: OperatorService, assistantActions: AssistantActionService, assistantActionRepo: AssistantActionRepository, webSocketService: WebSocketService, configService: ConfigService, mentions: AssistantMentionExtractor, blockNote: BlockNoteService, scopeGuard: ScopeGuard, seedContextProviders?: AssistantSeedContextProvider[]); createWithFirstMessage(params: { companyId: string; userId: string; firstMessage: string; title?: string; howToMode?: boolean; limitToHowToId?: string; handbookMode?: boolean; limitToHandbookPageId?: string; boundContent?: { type: string; id: string; }; }): Promise<{ assistant: Assistant; userMessage: AssistantMessage; assistantMessage: AssistantMessage; toolCalls: ToolCallRecord[]; }>; appendMessage(params: { assistantId: string; companyId: string; userId: string; newMessage: string; howToMode?: boolean; limitToHowToId?: string; handbookMode?: boolean; limitToHandbookPageId?: string; }): Promise<{ userMessage: AssistantMessage; assistantMessage: AssistantMessage; toolCalls: ToolCallRecord[]; }>; /** * Operator variant of `createWithFirstMessage`: same persistence lifecycle * (assistant node, user message at position 0, assistant turn at position 1) * but the turn runs on the checkpointed OperatorService instead of the * stateless responder. A `pending_approval` outcome freezes the run and * materialises an AssistantAction + an `approval-request` assistant message. */ createWithFirstMessageOperator(params: { companyId: string; userId: string; firstMessage: string; title?: string; howToMode?: boolean; limitToHowToId?: string; boundContent?: { type: string; id: string; }; }): Promise<{ assistant: Assistant; userMessage: AssistantMessage; assistantMessage: AssistantMessage; toolCalls: ToolCallRecord[]; action?: AssistantAction; }>; /** * Operator variant of `appendMessage`: identical hydration, history trim and * persistence shape, but the turn runs on the checkpointed OperatorService. */ appendMessageOperator(params: { assistantId: string; companyId: string; userId: string; newMessage: string; howToMode?: boolean; limitToHowToId?: string; }): Promise<{ userMessage: AssistantMessage; assistantMessage: AssistantMessage; toolCalls: ToolCallRecord[]; action?: AssistantAction; }>; /** * Approve or deny a pending AssistantAction and resume the frozen operator * run. The status transition is guarded atomically in Cypher * (`resolveStatus`) BEFORE the resume so a double approve/deny loses with a * 409 and never re-executes the tool. The final assistant message is * appended at the current end of the thread (chatting may have continued) * and pushed over the websocket so any open chat updates live. */ resolveAction(params: { actionId: string; approved: boolean; }): Promise<{ assistantMessage: AssistantMessage; action: AssistantAction; }>; /** * List the threads bound to one resource (e.g. every assistant thread for a * campaign). Delegates to the inherited `findByRelated` over the `content` * relationship — the BOUND_TO traversal already exists, so there is no new * Cypher here and company/owner RBAC still comes from the repository. */ findByBoundContent(params: { boundType: string; boundId: string; query: any; }): Promise; /** * Build the run's UserContext once per turn. `scopeId`/`scopeType` are set * only when the thread is bound to a catalogued scope ROOT (a descriptor * whose compiled scope chain is empty, e.g. a campaign). A thread bound to * something further down a chain still gets content scope, but not a * transitive run scope — the guard has no root to confine the run to. */ private buildTurnContext; /** * Publishes the turn's scope to CLS, where RETRIEVAL reads it. * * This is the choke point on purpose: every entry point that starts a turn — * ask, append, regenerate, operator run, operator resume — builds its context * here, so a new one cannot be added that forgets to declare its scope. The * key is written on EVERY turn, `undefined` included: leaving a previous * turn's value in place would scope this turn to the wrong root, which is * worse than not scoping it at all. */ private publishScope; /** * Collects seed-context blocks from the app-registered providers for one * turn. Documentation turns — help mode and handbook mode alike — are never * seeded: a seed provider supplies the app's own domain context (the open * proceeding, the selected client), which is exactly the material a question * about the documentation is not asking about. A provider returning null * contributes nothing; a provider that throws is logged and skipped — the * turn must run (unseeded) no matter what a provider does. */ private collectSeedContexts; /** * Write the `BOUND_TO` edge that scopes a thread to a resource. * * Deliberately NOT part of the `createFromDTO` call above: `BOUND_TO` is * polymorphic, so its descriptor entry carries `assistantMeta` as a * placeholder model. The generic create path reads `rel.model.labelName` to * validate the target and to build the edge, so a Campaign id sent that way * is looked up as `(:Assistant { id: … })` and 400s with "One or more * related nodes do not exist." * * The label is resolved from the model registry — the same registry the * descriptor's own polymorphic discriminator uses on the read path — so an * unregistered type fails loudly here rather than writing a dangling edge. */ private attachBoundContent; private toContentScope; private toBoundContent; /** * Derive from a user message: the markdown to persist (mention links kept, so * the thread can re-render them), a plain-text form for the thread title, and * the validated entity mentions to pin into the turn's focus context. * * Plain-text input passes straight through, so apps without a rich composer * are unaffected. */ private resolveUserTurnInput; /** * Materialise REFERENCES edges from the USER message to the entities that * message named. The reference metadata is fixed: a mention is an explicit, * maximally relevant pointer, not a ranked retrieval hit. */ private linkPinnedMentions; /** * Drop records that fall outside the run's scope root, grouping by JSON:API * type so `ScopeGuard` can build one predicate per type. A no-op for an * unscoped run — `ScopeGuard.filter` returns everything in that case, so the * short-circuit only avoids a pointless round trip. */ private filterByScope; /** * Resolve the content scope from the assistant's BOUND_TO relationship if * loaded. */ private resolveContentScope; /** * Runs a single operator turn. Builds the exact message list the responder * path builds ([hydration system message?, ...trimmed priors, question]) * and invokes the checkpointed operator graph under `threadId`. */ private runOperatorTurn; /** * Persist the outcome of an operator turn (initial run or resume). * * - `completed` → assistant message + REFERENCES/CITES edges, exactly like * the responder path (the operator result carries no UnifiedTrace, so * `setTrace` is not called). * - `pending_approval` → an `approval-request` assistant message carrying * the human-readable summary, plus a `pending` AssistantAction linked to * it, expiring after `operator.approvalTtlDays` from app config (default 7). */ private persistOperatorOutcome; /** * Load the most recent N messages for an Assistant, ordered chronologically * (position ASC). Used to build the LLM prompt without pulling full history. */ private loadRecentMessages; /** * Runs a single agent turn. Builds a message list of: * [ optional reference-memory system message, ...prior messages, new user message ] * and invokes the unified responder. Returns the assistant reply metadata. */ private runAgentTurn; private roleToType; /** * Two-tier hydration: * - Focus: full records re-read for every {type,id} referenced by the most * recent assistant message. * - Background: id + label stubs for references from earlier messages. * Returns null when there is nothing to hydrate. On any unexpected error * the whole builder returns null so a single bad Neo4j hiccup does not * fail the chat turn. */ private buildHydrationMessage; private loadFocusRecords; /** * Strip a focus record's nested relationship objects down to {id, type, summary} * stubs. The hydration design rule (see bridge-entities spec) is that the focus * block carries the entity's own scalar fields plus id-stubs for relationships — * NOT a full one-hop expansion that includes the related node's own collections * (which are mapper-initialised to `[]` and look like authoritative empty data * to the LLM, causing it to skip the traverse and answer "empty" wrongly). */ private stripFocusRecord; private looksLikeRelatedRecord; private stubRelatedRecord; private loadBackgroundStubs; /** * Auto-generate a title from the first user message: trim to 60 chars on a * word boundary. Falls back to a hard 60-char cut if no suitable break exists. */ private autoTitle; } //# sourceMappingURL=assistant.service.d.ts.map