diff --git a/src/commands/ai/should-respond/server/AIShouldRespondServerCommand.ts b/src/commands/ai/should-respond/server/AIShouldRespondServerCommand.ts index b0b410d0f..38519f81a 100644 --- a/src/commands/ai/should-respond/server/AIShouldRespondServerCommand.ts +++ b/src/commands/ai/should-respond/server/AIShouldRespondServerCommand.ts @@ -1,16 +1,26 @@ /** * AI Should-Respond Server Command * - * Uses AIProviderDaemon with proper RAG context (message array, not flattened string) + * Thin TS shim — delegates to the Rust cognition/should-respond IPC + * (cognition/should_respond.rs). Rust owns the gating prompt, model + * call, and parser; this command maps the public params shape into + * the IPC request and forwards the typed decision back. + * + * Prior to continuum#1420 this command carried a parallel + * reimplementation of gating with a stale prompt + JSON-repair retry + * loop — that drifted from the canonical Rust path used by + * AIDecisionService.evaluateGating. The delegation removes both + * paths' divergence risk. */ import { AIShouldRespondCommand } from '../shared/AIShouldRespondCommand'; import type { JTAGContext } from '../../../../system/core/types/JTAGTypes'; import type { ICommandDaemon } from '../../../../daemons/command-daemon/shared/CommandBase'; import type { AIShouldRespondParams, AIShouldRespondResult } from '../shared/AIShouldRespondTypes'; -import { AIProviderDaemon } from '../../../../daemons/ai-provider-daemon/shared/AIProviderDaemon'; -import type { TextGenerationRequest } from '../../../../daemons/ai-provider-daemon/shared/AIProviderTypesV2'; -import { LOCAL_MODELS } from '../../../../system/shared/Constants'; +import { RustCoreIPCClient } from '../../../../workers/continuum-core/bindings/RustCoreIPC'; +import type { + AIDecisionContext as RustAIDecisionContext, +} from '../../../../shared/generated'; export class AIShouldRespondServerCommand extends AIShouldRespondCommand { constructor(context: JTAGContext, subpath: string, commander: ICommandDaemon) { @@ -19,111 +29,75 @@ export class AIShouldRespondServerCommand extends AIShouldRespondCommand { async execute(params: AIShouldRespondParams): Promise { try { - // Validate ragContext for LLM strategy if (!params.ragContext) { throw new Error('ragContext is required for LLM strategy'); } - // Build gating instruction - const gatingInstruction = this.buildGatingInstruction(params); - - // Mark the trigger message in conversation history with >>> arrows <<< - const markedHistory = params.ragContext.conversationHistory.map(msg => { - const isTrigger = msg.content === params.triggerMessage.content && - msg.name === params.triggerMessage.senderName; - - if (isTrigger) { - return { - ...msg, - content: `>>> ${msg.content} <<<` - }; - } - return msg; + // Build the Rust IPC context from the public params shape. + // The Rust side (cognition/should_respond.rs::AIDecisionContext) + // structurally matches the TS RAGContext fields we forward; + // the cast mirrors what AIDecisionService.evaluateGating does + // for the same surface. + const context = { + personaId: params.personaId, + personaName: params.personaName, + roomId: params.contextId, + triggerMessage: { + // Rust requires a stable id on the trigger. Params don't + // carry one (callers identify the message by content + + // sender timestamp); synthesize a deterministic-looking + // id from the timestamp so repeat calls don't multiply + // observability noise. + id: `trigger-${params.triggerMessage.timestamp}`, + senderName: params.triggerMessage.senderName, + content: { text: params.triggerMessage.content }, + }, + ragContext: params.ragContext, + systemPrompt: params.ragContext.identity?.systemPrompt, + } as unknown as RustAIDecisionContext; + + const client = await RustCoreIPCClient.getInstanceAsync(); + const decision = await client.cognitionShouldRespond({ + context, + model: params.model, }); - // Build proper messages array: system + conversation history (with marked trigger) + gating instruction - const request: TextGenerationRequest = { - messages: [ - { role: 'system', content: 'You are a conversation coordinator. Respond ONLY with JSON.' }, - ...markedHistory, // Conversation with trigger message marked - { role: 'user', content: gatingInstruction } - ], - model: params.model ?? LOCAL_MODELS.DEFAULT, - temperature: 0.3, - maxTokens: 200, - provider: 'local' - }; - - const response = await AIProviderDaemon.generateText(request); - - if (!response.text) { - throw new Error(response.error ?? 'AI generation failed'); - } - - // Try to parse JSON - if it fails, use a better model to fix it - let parsed = this.parseGatingResponse(response.text); - - // If parsing failed (confidence = 0.0 means parse error), retry with better model to fix JSON - if (parsed.confidence === 0.0 && parsed.reason === 'Failed to parse AI response') { - console.warn(`⚠️ Gating JSON parse failed with ${request.model}, retrying with local Qwen to fix malformed JSON`); - - const fixRequest: TextGenerationRequest = { - messages: [ - { role: 'system', content: 'You are a JSON repair tool. Fix malformed JSON and return valid JSON only.' }, - { role: 'user', content: `This JSON is malformed:\n\n${response.text}\n\nFix it and return ONLY valid JSON with this exact structure:\n{\n "shouldRespond": true/false,\n "confidence": 0.0-1.0,\n "reason": "string",\n "factors": {\n "mentioned": true/false,\n "questionAsked": true/false,\n "domainRelevant": true/false,\n "recentlySpoke": true/false,\n "othersAnswered": true/false\n }\n}` } - ], - model: LOCAL_MODELS.DEFAULT, - temperature: 0.1, // Low temp for structured output - maxTokens: 200, - provider: 'local' - }; - - const fixedResponse = await AIProviderDaemon.generateText(fixRequest); - if (fixedResponse.text) { - parsed = this.parseGatingResponse(fixedResponse.text); - if (parsed.confidence !== 0.0) { - console.log(`✅ JSON repair succeeded with local Qwen`); - } else { - throw new Error(`JSON repair failed even with local Qwen. Original: ${response.text.slice(0, 200)}`); - } - } else { - throw new Error(`JSON repair request failed: ${fixedResponse.error}`); - } - } - - const confidence = parsed.confidence ?? 0.5; - - // Build debug output if verbose mode enabled + // Verbose debug surface: TS keeps message count + preview + // (derivable from params without Rust round-trip). Dropped: + // `promptSent` + `aiResponse` (Rust owns prompt assembly + + // sees the raw response; operator inspects Rust logs at + // `cognition::should_respond` for that detail). let debugOutput: AIShouldRespondResult['debug'] = undefined; if (params.verbose) { const conversationText = params.ragContext.conversationHistory .map(msg => `${msg.role}: ${msg.content}`) .join('\n'); - debugOutput = { ragContext: { messageCount: params.ragContext.conversationHistory.length, - conversationPreview: conversationText.substring(0, 500) + (conversationText.length > 500 ? '...' : '') + conversationPreview: + conversationText.substring(0, 500) + + (conversationText.length > 500 ? '...' : ''), }, - promptSent: gatingInstruction, - aiResponse: response.text + promptSent: '(Rust-owned — see cognition::should_respond logs)', + aiResponse: '(Rust-owned — see cognition::should_respond logs)', }; } return { context: params.context, sessionId: params.sessionId, - shouldRespond: parsed.shouldRespond ?? false, - confidence, - reason: parsed.reason ?? 'No reason provided', - factors: parsed.factors ?? { + shouldRespond: decision.shouldRespond, + confidence: decision.confidence, + reason: decision.reason, + factors: decision.factors ?? { mentioned: false, questionAsked: false, domainRelevant: false, recentlySpoke: false, - othersAnswered: false + othersAnswered: false, }, - debug: debugOutput + debug: debugOutput, }; } catch (error) { console.error('❌ AI Should-Respond: Command failed:', error); @@ -139,8 +113,8 @@ export class AIShouldRespondServerCommand extends AIShouldRespondCommand { questionAsked: false, domainRelevant: false, recentlySpoke: false, - othersAnswered: false - } + othersAnswered: false, + }, }; } } diff --git a/src/eslint-baseline.txt b/src/eslint-baseline.txt index 48ea2a198..555672baa 100644 --- a/src/eslint-baseline.txt +++ b/src/eslint-baseline.txt @@ -1 +1 @@ -5435 +5433