Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 54 additions & 1 deletion assistant/src/daemon/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<void> {
loadDotEnv();
Expand Down Expand Up @@ -464,6 +516,7 @@ export async function runDaemon(): Promise<void> {
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');
Expand Down
85 changes: 29 additions & 56 deletions assistant/src/runtime/approval-message-composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -93,19 +84,22 @@ 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');
return re.test(text);
});
}

function buildGenerationPrompt(
/** @internal Exported for use by the daemon-injected generator implementation. */
export function buildGenerationPrompt(
context: ApprovalMessageContext,
fallbackText: string,
requiredKeywords: string[] | undefined,
Expand All @@ -122,45 +116,27 @@ function buildGenerationPrompt(
].filter(Boolean).join('\n\n');
}

async function generateApprovalMessage(
provider: Provider,
context: ApprovalMessageContext,
fallbackText: string,
options: ComposeApprovalMessageGenerativeOptions,
): Promise<string | null> {
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<string> {
if (context.assistantPreface && context.assistantPreface.trim().length > 0) {
return context.assistantPreface;
Expand All @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions assistant/src/runtime/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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<string, string>();
private suggestionInFlight = new Map<string, Promise<string | null>>();
Expand All @@ -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;
}

Expand Down Expand Up @@ -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');
}

Expand Down Expand Up @@ -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') {
Expand Down
12 changes: 12 additions & 0 deletions assistant/src/runtime/http-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>;

export interface RuntimeMessageSessionOptions {
transport?: {
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading