diff --git a/assistant/src/daemon/lifecycle.ts b/assistant/src/daemon/lifecycle.ts index e4d94a61779..b2b51d07fdd 100644 --- a/assistant/src/daemon/lifecycle.ts +++ b/assistant/src/daemon/lifecycle.ts @@ -18,7 +18,7 @@ import { } from '../util/platform.js'; import { initializeDb } from '../memory/db.js'; import { rotateToolInvocations } from '../memory/tool-usage-store.js'; -import { initializeProviders } from '../providers/registry.js'; +import { initializeProviders, getFailoverProvider, listProviders } from '../providers/registry.js'; import { initializeTools } from '../tools/registry.js'; import { loadConfig } from '../config/loader.js'; import { ensurePromptFiles } from '../config/system-prompt.js'; @@ -46,6 +46,15 @@ import { telegramBotMessagingProvider } from '../messaging/providers/telegram-bo import { smsMessagingProvider } from '../messaging/providers/sms/adapter.js'; import { browserManager } from '../tools/browser/browser-manager.js'; import { RuntimeHttpServer } from '../runtime/http-server.js'; +import type { ApprovalCopyGenerator } from '../runtime/http-types.js'; +import { + buildGenerationPrompt, + includesRequiredKeywords, + getFallbackMessage, + APPROVAL_COPY_TIMEOUT_MS, + APPROVAL_COPY_MAX_TOKENS, + APPROVAL_COPY_SYSTEM_PROMPT, +} from '../runtime/approval-message-composer.js'; import { getHookManager } from '../hooks/manager.js'; import { installTemplates } from '../hooks/templates.js'; import { HeartbeatService } from '../workspace/heartbeat-service.js'; @@ -261,6 +270,49 @@ function loadDotEnv(): void { dotenvConfig({ path: join(getRootDir(), '.env'), quiet: true }); } +/** + * Create the daemon-owned approval copy generator that resolves providers + * and calls `provider.sendMessage` to generate approval copy text. + * This keeps all provider awareness in the daemon lifecycle, away from + * the runtime composer. + */ +function createApprovalCopyGenerator(): ApprovalCopyGenerator { + return async (context, options = {}) => { + const config = loadConfig(); + if (!listProviders().includes(config.provider)) { + return null; + } + const provider = getFailoverProvider(config.provider, config.providerOrder); + + const fallbackText = options.fallbackText?.trim() || getFallbackMessage(context); + const requiredKeywords = options.requiredKeywords?.map((kw) => kw.trim()).filter((kw) => kw.length > 0); + const prompt = buildGenerationPrompt(context, fallbackText, requiredKeywords); + + const response = await provider.sendMessage( + [{ role: 'user', content: [{ type: 'text', text: prompt }] }], + [], + APPROVAL_COPY_SYSTEM_PROMPT, + { + config: { + max_tokens: options.maxTokens ?? APPROVAL_COPY_MAX_TOKENS, + }, + signal: AbortSignal.timeout(options.timeoutMs ?? APPROVAL_COPY_TIMEOUT_MS), + }, + ); + + const block = response.content.find((entry) => entry.type === 'text'); + const text = block && 'text' in block ? block.text.trim() : ''; + if (!text) return null; + const cleaned = text + .replace(/^["'`]+/, '') + .replace(/["'`]+$/, '') + .trim(); + if (!cleaned) return null; + if (!includesRequiredKeywords(cleaned, requiredKeywords)) return null; + return cleaned; + }; +} + // Entry point for the daemon process itself export async function runDaemon(): Promise { loadDotEnv(); @@ -464,6 +516,7 @@ export async function runDaemon(): Promise { server.persistAndProcessMessage(conversationId, content, attachmentIds, options, sourceChannel), runOrchestrator: server.createRunOrchestrator(), interfacesDir: getInterfacesDir(), + approvalCopyGenerator: createApprovalCopyGenerator(), }); try { log.info({ port, hostname }, 'Daemon startup: starting runtime HTTP server'); diff --git a/assistant/src/runtime/approval-message-composer.ts b/assistant/src/runtime/approval-message-composer.ts index 5a49ec227b1..c4a54f9c901 100644 --- a/assistant/src/runtime/approval-message-composer.ts +++ b/assistant/src/runtime/approval-message-composer.ts @@ -3,22 +3,13 @@ * * Generates approval prompt text through a priority chain: * 1. Assistant preface (macOS parity — reuse existing assistant text) - * 2. Provider-generated rewrite of deterministic fallback text + * 2. Generator-produced rewrite of deterministic fallback text (when provided by daemon) * 3. Deterministic fallback templates (natural, scenario-specific messages) */ -import { getConfig } from '../config/loader.js'; -import { getFailoverProvider, listProviders } from '../providers/registry.js'; -import type { Provider } from '../providers/types.js'; import { getLogger } from '../util/logger.js'; +import type { ApprovalCopyGenerator } from './http-types.js'; const log = getLogger('approval-message-composer'); -const APPROVAL_COPY_TIMEOUT_MS = 4_000; -const APPROVAL_COPY_MAX_TOKENS = 180; -const APPROVAL_COPY_SYSTEM_PROMPT = - 'You are an assistant writing one user-facing message about permissions/approval state. ' - + 'Keep it concise, natural, and actionable. Preserve factual details exactly. ' - + 'Do not mention internal systems, scenario IDs, or policy engine details. ' - + 'Return plain text only.'; // --------------------------------------------------------------------------- // Types @@ -93,11 +84,13 @@ export function composeApprovalMessage(context: ApprovalMessageContext): string return getFallbackMessage(context); } -function escapeRegExp(input: string): string { +/** @internal Exported for use by the daemon-injected generator implementation. */ +export function escapeRegExp(input: string): string { return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } -function includesRequiredKeywords(text: string, requiredKeywords: string[] | undefined): boolean { +/** @internal Exported for use by the daemon-injected generator implementation. */ +export function includesRequiredKeywords(text: string, requiredKeywords: string[] | undefined): boolean { if (!requiredKeywords || requiredKeywords.length === 0) return true; return requiredKeywords.every((keyword) => { const re = new RegExp(`\\b${escapeRegExp(keyword)}\\b`, 'i'); @@ -105,7 +98,8 @@ function includesRequiredKeywords(text: string, requiredKeywords: string[] | und }); } -function buildGenerationPrompt( +/** @internal Exported for use by the daemon-injected generator implementation. */ +export function buildGenerationPrompt( context: ApprovalMessageContext, fallbackText: string, requiredKeywords: string[] | undefined, @@ -122,45 +116,27 @@ function buildGenerationPrompt( ].filter(Boolean).join('\n\n'); } -async function generateApprovalMessage( - provider: Provider, - context: ApprovalMessageContext, - fallbackText: string, - options: ComposeApprovalMessageGenerativeOptions, -): Promise { - const requiredKeywords = options.requiredKeywords?.map((kw) => kw.trim()).filter((kw) => kw.length > 0); - const prompt = buildGenerationPrompt(context, fallbackText, requiredKeywords); - const response = await provider.sendMessage( - [{ role: 'user', content: [{ type: 'text', text: prompt }] }], - [], - APPROVAL_COPY_SYSTEM_PROMPT, - { - config: { - max_tokens: options.maxTokens ?? APPROVAL_COPY_MAX_TOKENS, - }, - signal: AbortSignal.timeout(options.timeoutMs ?? APPROVAL_COPY_TIMEOUT_MS), - }, - ); - - const block = response.content.find((entry) => entry.type === 'text'); - const text = block && 'text' in block ? block.text.trim() : ''; - if (!text) return null; - const cleaned = text - .replace(/^["'`]+/, '') - .replace(/["'`]+$/, '') - .trim(); - if (!cleaned) return null; - if (!includesRequiredKeywords(cleaned, requiredKeywords)) return null; - return cleaned; -} +/** Constants for the generator implementation (moved to exports for daemon lifecycle). */ +export const APPROVAL_COPY_TIMEOUT_MS = 4_000; +export const APPROVAL_COPY_MAX_TOKENS = 180; +export const APPROVAL_COPY_SYSTEM_PROMPT = + 'You are an assistant writing one user-facing message about permissions/approval state. ' + + 'Keep it concise, natural, and actionable. Preserve factual details exactly. ' + + 'Do not mention internal systems, scenario IDs, or policy engine details. ' + + 'Return plain text only.'; /** - * Compose user-facing approval copy using the active provider when available, - * with deterministic fallback for reliability. + * Compose user-facing approval copy using the daemon-injected generator when + * available, with deterministic fallback for reliability. + * + * The generator parameter is the daemon-provided function that knows about + * providers. When absent (or in test env), only the deterministic fallback + * is used. */ export async function composeApprovalMessageGenerative( context: ApprovalMessageContext, options: ComposeApprovalMessageGenerativeOptions = {}, + generator?: ApprovalCopyGenerator, ): Promise { if (context.assistantPreface && context.assistantPreface.trim().length > 0) { return context.assistantPreface; @@ -172,16 +148,13 @@ export async function composeApprovalMessageGenerative( return fallbackText; } - try { - const config = getConfig(); - if (!listProviders().includes(config.provider)) { - return fallbackText; + if (generator) { + try { + const generated = await generator(context, options); + if (generated) return generated; + } catch (err) { + log.warn({ err, scenario: context.scenario }, 'Failed to generate approval copy, using fallback'); } - const provider = getFailoverProvider(config.provider, config.providerOrder); - const generated = await generateApprovalMessage(provider, context, fallbackText, options); - if (generated) return generated; - } catch (err) { - log.warn({ err, scenario: context.scenario }, 'Failed to generate approval copy, using fallback'); } return fallbackText; diff --git a/assistant/src/runtime/http-server.ts b/assistant/src/runtime/http-server.ts index 6285633bbe5..fb4bb79297f 100644 --- a/assistant/src/runtime/http-server.ts +++ b/assistant/src/runtime/http-server.ts @@ -87,12 +87,14 @@ export type { NonBlockingMessageProcessor, RuntimeHttpServerOptions, RuntimeAttachmentMetadata, + ApprovalCopyGenerator, } from './http-types.js'; import type { MessageProcessor, NonBlockingMessageProcessor, RuntimeHttpServerOptions, + ApprovalCopyGenerator, } from './http-types.js'; const log = getLogger('runtime-http'); @@ -384,6 +386,7 @@ export class RuntimeHttpServer { private processMessage?: MessageProcessor; private persistAndProcessMessage?: NonBlockingMessageProcessor; private runOrchestrator?: RunOrchestrator; + private approvalCopyGenerator?: ApprovalCopyGenerator; private interfacesDir: string | null; private suggestionCache = new Map(); private suggestionInFlight = new Map>(); @@ -397,6 +400,7 @@ export class RuntimeHttpServer { this.processMessage = options.processMessage; this.persistAndProcessMessage = options.persistAndProcessMessage; this.runOrchestrator = options.runOrchestrator; + this.approvalCopyGenerator = options.approvalCopyGenerator; this.interfacesDir = options.interfacesDir ?? null; } @@ -453,7 +457,7 @@ export class RuntimeHttpServer { // support is available. Guardian approvals can be created even when the // generic channel-approval UX flag is disabled. if (this.runOrchestrator) { - startGuardianExpirySweep(this.runOrchestrator, getGatewayBaseUrl(), this.bearerToken); + startGuardianExpirySweep(this.runOrchestrator, getGatewayBaseUrl(), this.bearerToken, this.approvalCopyGenerator); log.info('Guardian approval expiry sweep started'); } @@ -786,7 +790,7 @@ export class RuntimeHttpServer { if (endpoint === 'channels/inbound' && req.method === 'POST') { const gatewayOriginSecret = process.env.RUNTIME_GATEWAY_ORIGIN_SECRET || undefined; - return await handleChannelInbound(req, this.processMessage, this.bearerToken, this.runOrchestrator, assistantId, gatewayOriginSecret); + return await handleChannelInbound(req, this.processMessage, this.bearerToken, this.runOrchestrator, assistantId, gatewayOriginSecret, this.approvalCopyGenerator); } if (endpoint === 'channels/delivery-ack' && req.method === 'POST') { diff --git a/assistant/src/runtime/http-types.ts b/assistant/src/runtime/http-types.ts index 5ad6687237a..6c9d1153c88 100644 --- a/assistant/src/runtime/http-types.ts +++ b/assistant/src/runtime/http-types.ts @@ -3,6 +3,16 @@ */ import type { RunOrchestrator } from './run-orchestrator.js'; import type { GuardianRuntimeContext } from '../daemon/session-runtime-assembly.js'; +import type { ApprovalMessageContext, ComposeApprovalMessageGenerativeOptions } from './approval-message-composer.js'; + +/** + * Daemon-injected function that generates approval copy using a provider. + * Returns generated text or `null` on failure (caller falls back to deterministic text). + */ +export type ApprovalCopyGenerator = ( + context: ApprovalMessageContext, + options?: ComposeApprovalMessageGenerativeOptions, +) => Promise; export interface RuntimeMessageSessionOptions { transport?: { @@ -50,6 +60,8 @@ export interface RuntimeHttpServerOptions { runOrchestrator?: RunOrchestrator; /** Root directory for interface files on disk. */ interfacesDir?: string; + /** Daemon-injected generator for approval copy (provider-backed). */ + approvalCopyGenerator?: ApprovalCopyGenerator; } export interface RuntimeAttachmentMetadata { diff --git a/assistant/src/runtime/routes/channel-routes.ts b/assistant/src/runtime/routes/channel-routes.ts index ceab9c06cd0..7e10a4c507d 100644 --- a/assistant/src/runtime/routes/channel-routes.ts +++ b/assistant/src/runtime/routes/channel-routes.ts @@ -54,6 +54,7 @@ import type { RunOrchestrator } from '../run-orchestrator.js'; import type { MessageProcessor, RuntimeAttachmentMetadata, + ApprovalCopyGenerator, } from '../http-types.js'; import type { GuardianRuntimeContext } from '../../daemon/session-runtime-assembly.js'; import { composeApprovalMessageGenerative } from '../approval-message-composer.js'; @@ -158,6 +159,7 @@ interface DeliverGeneratedApprovalPromptParams { prompt: ChannelApprovalPrompt; uiMetadata: ApprovalUIMetadata; messageContext: ApprovalMessageContext; + approvalCopyGenerator?: ApprovalCopyGenerator; } /** @@ -176,6 +178,7 @@ async function deliverGeneratedApprovalPrompt(params: DeliverGeneratedApprovalPr prompt, uiMetadata, messageContext, + approvalCopyGenerator, } = params; const keywords = requiredDecisionKeywords(uiMetadata.actions); @@ -183,6 +186,7 @@ async function deliverGeneratedApprovalPrompt(params: DeliverGeneratedApprovalPr const richText = await composeApprovalMessageGenerative( { ...messageContext, channel: sourceChannel, richUi: true }, { fallbackText: prompt.promptText }, + approvalCopyGenerator, ); try { @@ -205,6 +209,7 @@ async function deliverGeneratedApprovalPrompt(params: DeliverGeneratedApprovalPr const plainTextFallback = await composeApprovalMessageGenerative( { ...messageContext, channel: sourceChannel, richUi: false }, { fallbackText: prompt.plainTextFallback, requiredKeywords: keywords }, + approvalCopyGenerator, ); // Embed the run reference so plain-text replies can disambiguate when @@ -230,6 +235,7 @@ async function deliverGeneratedApprovalPrompt(params: DeliverGeneratedApprovalPr const plainText = await composeApprovalMessageGenerative( { ...messageContext, channel: sourceChannel, richUi: false }, { fallbackText: prompt.plainTextFallback, requiredKeywords: keywords }, + approvalCopyGenerator, ); // Embed the run reference for disambiguation in multi-pending scenarios. @@ -335,6 +341,7 @@ export async function handleChannelInbound( runOrchestrator?: RunOrchestrator, assistantId: string = 'self', gatewayOriginSecret?: string, + approvalCopyGenerator?: ApprovalCopyGenerator, ): Promise { // Reject requests that lack valid gateway-origin proof. This ensures // channel inbound messages can only arrive via the gateway (which @@ -545,12 +552,12 @@ export async function handleChannelInbound( ? await composeApprovalMessageGenerative({ scenario: 'guardian_verify_success', channel: sourceChannel, - }) + }, {}, approvalCopyGenerator) : await composeApprovalMessageGenerative({ scenario: 'guardian_verify_failed', channel: sourceChannel, failureReason: stripVerificationFailurePrefix(verifyResult.reason), - }); + }, {}, approvalCopyGenerator); try { await deliverChannelReply(replyCallbackUrl, { @@ -773,6 +780,7 @@ export async function handleChannelInbound( orchestrator: runOrchestrator, guardianCtx, assistantId, + approvalCopyGenerator, }); if (approvalResult.handled) { @@ -854,6 +862,7 @@ export async function handleChannelInbound( metadataUxBrief, commandIntent, sourceLanguageCode, + approvalCopyGenerator, }); } else { // Fire-and-forget: process the message and deliver the reply in the background. @@ -1006,6 +1015,7 @@ interface ApprovalProcessingParams { metadataUxBrief?: string; commandIntent?: Record; sourceLanguageCode?: string; + approvalCopyGenerator?: ApprovalCopyGenerator; } /** @@ -1037,6 +1047,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v metadataUxBrief, commandIntent, sourceLanguageCode, + approvalCopyGenerator, } = params; const isNonGuardian = guardianCtx.actorRole === 'non-guardian'; @@ -1135,6 +1146,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v toolName: pending[0].toolName, requesterIdentifier: guardianCtx.requesterIdentifier ?? 'Unknown user', }, + approvalCopyGenerator, }); if (guardianNotified) { @@ -1160,7 +1172,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v scenario: 'guardian_request_forwarded', toolName: pending[0].toolName, channel: sourceChannel, - }); + }, {}, approvalCopyGenerator); await deliverChannelReply(replyCallbackUrl, { chatId: guardianCtx.requesterChatId ?? externalChatId, text: forwardedText, @@ -1188,6 +1200,7 @@ function processChannelMessageWithApprovals(params: ApprovalProcessingParams): v scenario: 'standard_prompt', toolName: pending[0].toolName, }, + approvalCopyGenerator, }); if (delivered) { hasPostDecisionDelivery = true; @@ -1326,6 +1339,7 @@ interface ApprovalInterceptionParams { orchestrator: RunOrchestrator; guardianCtx: GuardianContext; assistantId: string; + approvalCopyGenerator?: ApprovalCopyGenerator; } interface ApprovalInterceptionResult { @@ -1358,6 +1372,7 @@ async function handleApprovalInterception( orchestrator, guardianCtx, assistantId, + approvalCopyGenerator, } = params; // ── Guardian approval decision path ── @@ -1397,7 +1412,7 @@ async function handleApprovalInterception( scenario: 'guardian_disambiguation', pendingCount: allPending.length, channel: sourceChannel, - }); + }, {}, approvalCopyGenerator); await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: disambiguationText, @@ -1434,7 +1449,7 @@ async function handleApprovalInterception( const mismatchText = await composeApprovalMessageGenerative({ scenario: 'guardian_identity_mismatch', channel: sourceChannel, - }); + }, {}, approvalCopyGenerator); await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: mismatchText, @@ -1476,7 +1491,7 @@ async function handleApprovalInterception( decision: decision.action === 'reject' ? 'denied' : 'approved', toolName: guardianApproval.toolName, channel: sourceChannel, - }); + }, {}, approvalCopyGenerator); try { await deliverChannelReply(replyCallbackUrl, { chatId: guardianApproval.requesterChatId, @@ -1528,6 +1543,7 @@ async function handleApprovalInterception( channel: sourceChannel, toolName: pendingInfo[0].toolName, }, + approvalCopyGenerator, }); if (!delivered) { log.error( @@ -1590,7 +1606,7 @@ async function handleApprovalInterception( const pendingText = await composeApprovalMessageGenerative({ scenario: 'request_pending_guardian', channel: sourceChannel, - }); + }, {}, approvalCopyGenerator); await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: pendingText, @@ -1622,7 +1638,7 @@ async function handleApprovalInterception( scenario: 'guardian_expired_requester', toolName: pending[0].toolName, channel: sourceChannel, - }); + }, {}, approvalCopyGenerator); await deliverChannelReply(replyCallbackUrl, { chatId: externalChatId, text: expiredText, @@ -1704,6 +1720,7 @@ async function handleApprovalInterception( channel: sourceChannel, toolName: pending[0].toolName, }, + approvalCopyGenerator, }); if (!delivered) { log.error({ conversationId, externalChatId }, 'Failed to deliver approval reminder'); @@ -1879,6 +1896,7 @@ export function sweepExpiredGuardianApprovals( orchestrator: RunOrchestrator, gatewayBaseUrl: string, bearerToken?: string, + approvalCopyGenerator?: ApprovalCopyGenerator, ): void { const expired = getExpiredPendingApprovals(); for (const approval of expired) { @@ -1901,7 +1919,7 @@ export function sweepExpiredGuardianApprovals( scenario: 'guardian_expired_requester', toolName: approval.toolName, channel: approval.channel, - }); + }, {}, approvalCopyGenerator); await deliverChannelReply(deliverUrl, { chatId: approval.requesterChatId, text: requesterText, @@ -1918,7 +1936,7 @@ export function sweepExpiredGuardianApprovals( toolName: approval.toolName, requesterIdentifier: approval.requesterExternalUserId, channel: approval.channel, - }); + }, {}, approvalCopyGenerator); await deliverChannelReply(deliverUrl, { chatId: approval.guardianChatId, text: guardianText, @@ -1943,11 +1961,12 @@ export function startGuardianExpirySweep( orchestrator: RunOrchestrator, gatewayBaseUrl: string, bearerToken?: string, + approvalCopyGenerator?: ApprovalCopyGenerator, ): void { if (expirySweepTimer) return; expirySweepTimer = setInterval(() => { try { - sweepExpiredGuardianApprovals(orchestrator, gatewayBaseUrl, bearerToken); + sweepExpiredGuardianApprovals(orchestrator, gatewayBaseUrl, bearerToken, approvalCopyGenerator); } catch (err) { log.error({ err }, 'Guardian expiry sweep failed'); }