diff --git a/container/src/approval-policy.ts b/container/src/approval-policy.ts index 89f426f4..6cdf8a5f 100644 --- a/container/src/approval-policy.ts +++ b/container/src/approval-policy.ts @@ -27,7 +27,11 @@ import { type StakesScore, } from './stakes-classifier.js'; import { normalizeText } from './text-normalization.js'; -import type { ChatMessage } from './types.js'; +import { + type ChatMessage, + type EscalationTarget, + normalizeEscalationTarget, +} from './types.js'; export type { NetworkPolicyAction, @@ -138,6 +142,7 @@ export interface ToolApprovalEvaluation { stakes: StakesLevel; stakesScore: StakesScore; escalationRoute: EscalationRoute; + escalationTarget?: EscalationTarget; decision: ApprovalDecision; actionKey: string; fingerprint: string; @@ -277,6 +282,14 @@ function escalationRouteForDecision( return 'none'; } +function formatStakesReasoning(score: StakesScore): string { + const reasons = + score.reasons.length > 0 + ? score.reasons.join('; ') + : 'no classifier reasons reported'; + return `${score.level} stakes via ${score.classifier} (score ${score.score}, confidence ${score.confidence}): ${reasons}`; +} + function parseJsonObject(raw: string): Record { try { const parsed = JSON.parse(raw) as unknown; @@ -1275,6 +1288,7 @@ export class TrustedAgentApprovalRuntime { argsJson: string; latestUserPrompt: string; channelId?: string; + escalationTarget?: EscalationTarget; }): ToolApprovalEvaluation { this.reloadPolicyIfNeeded(); this.cleanupExpiredPending(); @@ -1319,15 +1333,20 @@ export class TrustedAgentApprovalRuntime { this.stakesClassifier, ); const stakes = stakesScore.level; + const escalationTarget = normalizeEscalationTarget(params.escalationTarget); let baseTier: ApprovalTier = safetyTier; - if (autonomyLevel === 'confirm-each' && baseTier !== 'red') { + let outOfBoundByAutonomy = false; + if (autonomyLevel === 'confirm-each') { + outOfBoundByAutonomy = true; baseTier = 'red'; } else if (autonomyLevel === 'low-stakes-autonomous') { - if (stakes === 'high' && baseTier !== 'red') { + // Low-stakes autonomy permits only low-stakes actions to proceed without + // escalation. Medium and high stakes are out-of-bound and require a + // paused explicit approval path, rather than a yellow implicit notice. + if (stakes !== 'low') { + outOfBoundByAutonomy = true; baseTier = 'red'; - } else if (stakes === 'medium' && baseTier === 'green') { - baseTier = 'yellow'; } } @@ -1343,6 +1362,7 @@ export class TrustedAgentApprovalRuntime { stakes, stakesScore, escalationRoute: 'policy_denial', + ...(escalationTarget ? { escalationTarget } : {}), decision: 'denied', actionKey: classified.actionKey, fingerprint, @@ -1391,6 +1411,7 @@ export class TrustedAgentApprovalRuntime { decision = 'promoted'; } else if ( this.fullAutoEnabled && + !outOfBoundByAutonomy && !this.shouldNeverAutoApprove(params.toolName, classified.actionKey) ) { tier = 'yellow'; @@ -1404,6 +1425,7 @@ export class TrustedAgentApprovalRuntime { stakes, stakesScore, escalationRoute: 'policy_denial', + ...(escalationTarget ? { escalationTarget } : {}), decision: 'denied', actionKey: classified.actionKey, fingerprint, @@ -1434,6 +1456,7 @@ export class TrustedAgentApprovalRuntime { stakes, stakesScore, escalationRoute: 'approval_request', + ...(escalationTarget ? { escalationTarget } : {}), decision: 'required', actionKey: classified.actionKey, fingerprint, @@ -1453,6 +1476,7 @@ export class TrustedAgentApprovalRuntime { tier === 'yellow' && decision === 'auto' && this.fullAutoEnabled && + !outOfBoundByAutonomy && !this.shouldNeverAutoApprove(params.toolName, classified.actionKey) ) { decision = 'approved_fullauto'; @@ -1480,6 +1504,7 @@ export class TrustedAgentApprovalRuntime { stakes, stakesScore, escalationRoute: escalationRouteForDecision(decision, tier), + ...(escalationTarget ? { escalationTarget } : {}), decision, actionKey: classified.actionKey, fingerprint, @@ -1550,6 +1575,13 @@ export class TrustedAgentApprovalRuntime { ]; return [ `I need your approval before I ${evaluation.intent.toLowerCase()}.`, + `Proposed action: ${evaluation.commandPreview || evaluation.intent}`, + `Classifier reasoning: ${formatStakesReasoning(evaluation.stakesScore)}`, + ...(evaluation.escalationTarget + ? [ + `Escalation target: ${evaluation.escalationTarget.channel} / ${evaluation.escalationTarget.recipient}`, + ] + : []), `Why: ${evaluation.reason}`, `If you skip this, ${evaluation.consequenceIfDenied.charAt(0).toLowerCase()}${evaluation.consequenceIfDenied.slice(1)}`, requestLabel, diff --git a/container/src/index.ts b/container/src/index.ts index 19c77fd1..a29a3d8e 100644 --- a/container/src/index.ts +++ b/container/src/index.ts @@ -101,6 +101,7 @@ import { type ChatMessage, type ContainerInput, type ContainerOutput, + type EscalationTarget, type PendingApproval, TASK_MODEL_KEYS, type ToolCall, @@ -714,6 +715,7 @@ async function executePreparedToolCall( stakes: approval.stakes, stakesScore: approval.stakesScore, escalationRoute, + escalationTarget: approval.escalationTarget, approvalDecision, approvalActionKey: approval.actionKey, approvalReason: approval.reason, @@ -904,42 +906,62 @@ async function callHybridAIWithRetry(params: { /** * Process a single request: call API, run tool loop, write output. */ +interface ProcessRequestParams { + sessionId: string; + messages: ChatMessage[]; + apiKey: string; + baseUrl: string; + provider: ContainerInput['provider']; + providerMethod?: string; + isLocal?: boolean; + contextWindow?: number; + thinkingFormat?: 'qwen'; + model: string; + chatbotId: string; + enableRag: boolean; + requestHeaders?: Record; + tools: ToolDefinition[]; + taskModels?: ContainerInput['taskModels']; + contextGuard?: ContainerInput['contextGuard']; + channelId: string; + skipContainerSystemPrompt?: boolean; + streamTextDeltas?: boolean; + debugModelResponses?: boolean; + maxTokens?: number; + effectiveUserPromptOverride?: string; + ralphMaxIterationsOverride?: number | null; + escalationTarget?: EscalationTarget; +} + async function processRequest( - sessionId: string, - messages: ChatMessage[], - apiKey: string, - baseUrl: string, - provider: - | 'hybridai' - | 'openai-codex' - | 'anthropic' - | 'openrouter' - | 'mistral' - | 'huggingface' - | 'ollama' - | 'lmstudio' - | 'llamacpp' - | 'vllm' - | undefined, - providerMethod: string | undefined, - isLocal: boolean | undefined, - contextWindow: number | undefined, - thinkingFormat: 'qwen' | undefined, - model: string, - chatbotId: string, - enableRag: boolean, - requestHeaders: Record | undefined, - tools: ToolDefinition[], - taskModels: ContainerInput['taskModels'] | undefined, - contextGuard: ContainerInput['contextGuard'] | undefined, - channelId: string, - skipContainerSystemPrompt = false, - streamTextDeltas = false, - debugModelResponses = false, - maxTokens?: number, - effectiveUserPromptOverride?: string, - ralphMaxIterationsOverride?: number | null, + params: ProcessRequestParams, ): Promise { + const { + sessionId, + messages, + apiKey, + baseUrl, + provider, + providerMethod, + isLocal, + contextWindow, + thinkingFormat, + model, + chatbotId, + enableRag, + requestHeaders, + tools, + taskModels, + contextGuard, + channelId, + skipContainerSystemPrompt = false, + streamTextDeltas = false, + debugModelResponses = false, + maxTokens, + effectiveUserPromptOverride, + ralphMaxIterationsOverride, + escalationTarget, + } = params; const processStartedAt = Date.now(); await emitRuntimeEvent({ event: 'before_agent_start', @@ -1372,6 +1394,7 @@ async function processRequest( argsJson: candidate.function.arguments, latestUserPrompt: effectiveUserPrompt, channelId, + escalationTarget, }); if ( candidateApproval.decision === 'required' || @@ -1460,6 +1483,7 @@ async function processRequest( argsJson: call.function.arguments, latestUserPrompt: effectiveUserPrompt, channelId, + escalationTarget, }); logToolCallStart(toolName, call.function.arguments, approval); @@ -1484,6 +1508,9 @@ async function processRequest( Number.isFinite(approval.expiresAtMs) ? approval.expiresAtMs : null, + ...(approval.escalationTarget + ? { escalationTarget: approval.escalationTarget } + : {}), }; emitApprovalProgress(pendingApproval); toolExecutions.push({ @@ -1500,6 +1527,7 @@ async function processRequest( stakes: approval.stakes, stakesScore: approval.stakesScore, escalationRoute: approval.escalationRoute, + escalationTarget: approval.escalationTarget, approvalDecision: approval.decision, approvalActionKey: approval.actionKey, approvalIntent: approval.intent, @@ -1545,6 +1573,7 @@ async function processRequest( stakes: approval.stakes, stakesScore: approval.stakesScore, escalationRoute: approval.escalationRoute, + escalationTarget: approval.escalationTarget, approvalDecision: approval.decision, approvalActionKey: approval.actionKey, approvalIntent: approval.intent, @@ -1742,31 +1771,32 @@ async function main(): Promise { }; console.error('[approval] resolved user response without model run'); } else { - firstOutput = await processRequest( - firstInput.sessionId, - firstMessagesForRequest, - storedApiKey, - firstInput.baseUrl, - firstInput.provider, - firstInput.providerMethod, - firstInput.isLocal, - firstInput.contextWindow, - firstInput.thinkingFormat, - firstInput.model, - firstInput.chatbotId, - firstInput.enableRag, - storedRequestHeaders, - resolveTools(firstInput), - firstTaskModels, - firstInput.contextGuard, - firstInput.channelId, - firstInput.skipContainerSystemPrompt === true, - firstInput.streamTextDeltas === true, - firstInput.debugModelResponses === true, - firstInput.maxTokens, - firstPromptOverride, - firstInput.ralphMaxIterations, - ); + firstOutput = await processRequest({ + sessionId: firstInput.sessionId, + messages: firstMessagesForRequest, + apiKey: storedApiKey, + baseUrl: firstInput.baseUrl, + provider: firstInput.provider, + providerMethod: firstInput.providerMethod, + isLocal: firstInput.isLocal, + contextWindow: firstInput.contextWindow, + thinkingFormat: firstInput.thinkingFormat, + model: firstInput.model, + chatbotId: firstInput.chatbotId, + enableRag: firstInput.enableRag, + requestHeaders: storedRequestHeaders, + tools: resolveTools(firstInput), + taskModels: firstTaskModels, + contextGuard: firstInput.contextGuard, + channelId: firstInput.channelId, + skipContainerSystemPrompt: firstInput.skipContainerSystemPrompt === true, + streamTextDeltas: firstInput.streamTextDeltas === true, + debugModelResponses: firstInput.debugModelResponses === true, + maxTokens: firstInput.maxTokens, + effectiveUserPromptOverride: firstPromptOverride, + ralphMaxIterationsOverride: firstInput.ralphMaxIterations, + escalationTarget: firstInput.escalationTarget, + }); if ( firstMessagesForRequest !== firstInput.messages && firstOutput.status === 'error' && @@ -1780,31 +1810,33 @@ async function main(): Promise { : firstInput.messages; const firstRetryMessagesWithSkillCache = injectSkillCacheHint(firstRetryMessages); - firstOutput = await processRequest( - firstInput.sessionId, - firstRetryMessagesWithSkillCache, - storedApiKey, - firstInput.baseUrl, - firstInput.provider, - firstInput.providerMethod, - firstInput.isLocal, - firstInput.contextWindow, - firstInput.thinkingFormat, - firstInput.model, - firstInput.chatbotId, - firstInput.enableRag, - firstInput.requestHeaders, - resolveTools(firstInput), - firstTaskModels, - firstInput.contextGuard, - firstInput.channelId, - firstInput.skipContainerSystemPrompt === true, - firstInput.streamTextDeltas === true, - firstInput.debugModelResponses === true, - firstInput.maxTokens, - firstPromptOverride, - firstInput.ralphMaxIterations, - ); + firstOutput = await processRequest({ + sessionId: firstInput.sessionId, + messages: firstRetryMessagesWithSkillCache, + apiKey: storedApiKey, + baseUrl: firstInput.baseUrl, + provider: firstInput.provider, + providerMethod: firstInput.providerMethod, + isLocal: firstInput.isLocal, + contextWindow: firstInput.contextWindow, + thinkingFormat: firstInput.thinkingFormat, + model: firstInput.model, + chatbotId: firstInput.chatbotId, + enableRag: firstInput.enableRag, + requestHeaders: firstInput.requestHeaders, + tools: resolveTools(firstInput), + taskModels: firstTaskModels, + contextGuard: firstInput.contextGuard, + channelId: firstInput.channelId, + skipContainerSystemPrompt: + firstInput.skipContainerSystemPrompt === true, + streamTextDeltas: firstInput.streamTextDeltas === true, + debugModelResponses: firstInput.debugModelResponses === true, + maxTokens: firstInput.maxTokens, + effectiveUserPromptOverride: firstPromptOverride, + ralphMaxIterationsOverride: firstInput.ralphMaxIterations, + escalationTarget: firstInput.escalationTarget, + }); } } @@ -1905,31 +1937,32 @@ async function main(): Promise { continue; } - let output = await processRequest( - input.sessionId, - messagesForRequestWithSkillCache, + let output = await processRequest({ + sessionId: input.sessionId, + messages: messagesForRequestWithSkillCache, apiKey, - input.baseUrl, - input.provider, - input.providerMethod, - input.isLocal, - input.contextWindow, - input.thinkingFormat, - input.model, - input.chatbotId, - input.enableRag, + baseUrl: input.baseUrl, + provider: input.provider, + providerMethod: input.providerMethod, + isLocal: input.isLocal, + contextWindow: input.contextWindow, + thinkingFormat: input.thinkingFormat, + model: input.model, + chatbotId: input.chatbotId, + enableRag: input.enableRag, requestHeaders, - resolveTools(input), + tools: resolveTools(input), taskModels, - input.contextGuard, - input.channelId, - input.skipContainerSystemPrompt === true, - input.streamTextDeltas === true, - input.debugModelResponses === true, - input.maxTokens, - promptOverride, - input.ralphMaxIterations, - ); + contextGuard: input.contextGuard, + channelId: input.channelId, + skipContainerSystemPrompt: input.skipContainerSystemPrompt === true, + streamTextDeltas: input.streamTextDeltas === true, + debugModelResponses: input.debugModelResponses === true, + maxTokens: input.maxTokens, + effectiveUserPromptOverride: promptOverride, + ralphMaxIterationsOverride: input.ralphMaxIterations, + escalationTarget: input.escalationTarget, + }); if ( messagesForRequestWithSkillCache !== input.messages && output.status === 'error' && @@ -1942,31 +1975,32 @@ async function main(): Promise { ? replaceLatestUserPrompt(input.messages, promptOverride) : input.messages; const retryMessagesWithSkillCache = injectSkillCacheHint(retryMessages); - output = await processRequest( - input.sessionId, - retryMessagesWithSkillCache, + output = await processRequest({ + sessionId: input.sessionId, + messages: retryMessagesWithSkillCache, apiKey, - input.baseUrl, - input.provider, - input.providerMethod, - input.isLocal, - input.contextWindow, - input.thinkingFormat, - input.model, - input.chatbotId, - input.enableRag, + baseUrl: input.baseUrl, + provider: input.provider, + providerMethod: input.providerMethod, + isLocal: input.isLocal, + contextWindow: input.contextWindow, + thinkingFormat: input.thinkingFormat, + model: input.model, + chatbotId: input.chatbotId, + enableRag: input.enableRag, requestHeaders, - resolveTools(input), + tools: resolveTools(input), taskModels, - input.contextGuard, - input.channelId, - input.skipContainerSystemPrompt === true, - input.streamTextDeltas === true, - input.debugModelResponses === true, - input.maxTokens, - promptOverride, - input.ralphMaxIterations, - ); + contextGuard: input.contextGuard, + channelId: input.channelId, + skipContainerSystemPrompt: input.skipContainerSystemPrompt === true, + streamTextDeltas: input.streamTextDeltas === true, + debugModelResponses: input.debugModelResponses === true, + maxTokens: input.maxTokens, + effectiveUserPromptOverride: promptOverride, + ralphMaxIterationsOverride: input.ralphMaxIterations, + escalationTarget: input.escalationTarget, + }); } output.sideEffects = getPendingSideEffects(); diff --git a/container/src/types.ts b/container/src/types.ts index b600fa52..1424ae59 100644 --- a/container/src/types.ts +++ b/container/src/types.ts @@ -246,6 +246,7 @@ export interface ContainerInput { webSearch?: WebSearchConfig; persistBashState?: boolean; runtimeEnv?: Record; + escalationTarget?: EscalationTarget; } export interface MediaContextItem { @@ -260,6 +261,24 @@ export interface MediaContextItem { export type ToolExecutionStakesSignal = CanonicalStakesSignal; export type ToolExecutionStakesScore = CanonicalStakesScore; +export interface EscalationTarget { + channel: string; + recipient: string; +} + +export function normalizeEscalationTarget( + value: unknown, +): EscalationTarget | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const raw = value as { channel?: unknown; recipient?: unknown }; + const channel = typeof raw.channel === 'string' ? raw.channel.trim() : ''; + const recipient = + typeof raw.recipient === 'string' ? raw.recipient.trim() : ''; + return channel && recipient ? { channel, recipient } : undefined; +} + export interface ToolExecution { name: string; arguments: string; @@ -278,6 +297,7 @@ export interface ToolExecution { | 'implicit_notice' | 'approval_request' | 'policy_denial'; + escalationTarget?: EscalationTarget; approvalDecision?: | 'auto' | 'implicit' @@ -308,6 +328,7 @@ export interface PendingApproval { allowAgent: boolean; allowAll: boolean; expiresAt: number | null; + escalationTarget?: EscalationTarget; } export interface TokenUsageStats { diff --git a/src/agent/executor-types.ts b/src/agent/executor-types.ts index 0f92db48..db3280f9 100644 --- a/src/agent/executor-types.ts +++ b/src/agent/executor-types.ts @@ -1,6 +1,7 @@ import type { ChatMessage } from '../types/api.js'; import type { ContainerOutput, MediaContextItem } from '../types/container.js'; import type { + EscalationTarget, PendingApproval, PluginRuntimeToolDefinition, ToolProgressEvent, @@ -43,6 +44,7 @@ export interface ExecutorRequest { media?: MediaContextItem[]; audioTranscriptsPrepended?: boolean; pluginTools?: PluginRuntimeToolDefinition[]; + escalationTarget?: EscalationTarget; } export interface Executor { diff --git a/src/agents/agent-config-command.ts b/src/agents/agent-config-command.ts index 4f62d09e..35967258 100644 --- a/src/agents/agent-config-command.ts +++ b/src/agents/agent-config-command.ts @@ -11,7 +11,11 @@ import { upsertRegisteredAgent, } from './agent-registry.js'; import { activateAgentInRuntimeConfig } from './agent-runtime-config.js'; -import type { AgentConfig, AgentModelConfig } from './agent-types.js'; +import { + type AgentConfig, + type AgentModelConfig, + normalizeAgentEscalationTarget, +} from './agent-types.js'; const MARKDOWN_MAX_BYTES = 200_000; const IMAGE_ASSET_MAX_BYTES = 5_000_000; @@ -214,6 +218,21 @@ function applyAgentConfigFieldUpdates( throw new Error('`enableRag` must be a boolean or null.'); } } + if (Object.hasOwn(updates, 'escalationTarget')) { + if (updates.escalationTarget === null) { + delete next.escalationTarget; + } else { + const escalationTarget = normalizeAgentEscalationTarget( + updates.escalationTarget, + ); + if (!escalationTarget) { + throw new Error( + '`escalationTarget` must include non-empty string `channel` and `recipient` fields, or null.', + ); + } + next.escalationTarget = escalationTarget; + } + } return next; } diff --git a/src/agents/agent-registry.ts b/src/agents/agent-registry.ts index 66e8b810..5f4594c7 100644 --- a/src/agents/agent-registry.ts +++ b/src/agents/agent-registry.ts @@ -30,6 +30,7 @@ import { cloneAgentCv, DEFAULT_AGENT_ID, normalizeAgentCv, + normalizeAgentEscalationTarget, } from './agent-types.js'; const LEGACY_WORKSPACE_DIRS = [ @@ -160,6 +161,9 @@ function normalizeAgent(value: unknown): AgentConfig | null { const owner = normalizeString((value as { owner?: unknown }).owner); const role = normalizeString((value as { role?: unknown }).role); const cv = normalizeAgentCv((value as { cv?: unknown }).cv); + const escalationTarget = normalizeAgentEscalationTarget( + (value as { escalationTarget?: unknown }).escalationTarget, + ); return { id, ...(name ? { name } : {}), @@ -172,6 +176,7 @@ function normalizeAgent(value: unknown): AgentConfig | null { ...(owner ? { owner } : {}), ...(role ? { role } : {}), ...(cv ? { cv } : {}), + ...(escalationTarget ? { escalationTarget } : {}), }; } @@ -219,6 +224,8 @@ function fingerprintAgent(agent: AgentConfig): string { fingerprintString(agent.owner), fingerprintString(agent.role), fingerprintCv(agent.cv), + fingerprintString(agent.escalationTarget?.channel), + fingerprintString(agent.escalationTarget?.recipient), ].join('|'); } @@ -290,6 +297,9 @@ function applyDefaults(agent: AgentConfig): AgentConfig { ...(agent.owner ? { owner: agent.owner } : {}), ...(agent.role ? { role: agent.role } : {}), ...(agent.cv ? { cv: agent.cv } : {}), + ...(agent.escalationTarget + ? { escalationTarget: { ...agent.escalationTarget } } + : {}), }; } @@ -344,6 +354,7 @@ function syncConfiguredAgentsToDatabase(): void { owner: agent.owner, role: agent.role, cv: cloneAgentCv(agent.cv), + escalationTarget: agent.escalationTarget, }); } } diff --git a/src/agents/agent-runtime-config.ts b/src/agents/agent-runtime-config.ts index 54a53089..2bee3650 100644 --- a/src/agents/agent-runtime-config.ts +++ b/src/agents/agent-runtime-config.ts @@ -8,7 +8,7 @@ import type { AgentModelConfig, AgentsConfig, } from './agent-types.js'; -import { agentCvEquals } from './agent-types.js'; +import { agentCvEquals, agentEscalationTargetEquals } from './agent-types.js'; function sameStringArray(a?: string[], b?: string[]): boolean { if (a === b) return true; @@ -36,7 +36,8 @@ function sameAgentConfig(a: AgentConfig | undefined, b: AgentConfig): boolean { a.enableRag === b.enableRag && a.owner === b.owner && a.role === b.role && - agentCvEquals(a.cv, b.cv) + agentCvEquals(a.cv, b.cv) && + agentEscalationTargetEquals(a.escalationTarget, b.escalationTarget) ); } diff --git a/src/agents/agent-types.ts b/src/agents/agent-types.ts index 87f5fd6d..7a5b3586 100644 --- a/src/agents/agent-types.ts +++ b/src/agents/agent-types.ts @@ -1,8 +1,15 @@ +import type { EscalationTarget } from '../types/execution.js'; import { normalizeTrimmedString, normalizeTrimmedUniqueStringArray, } from '../utils/normalized-strings.js'; +export type { EscalationTarget as AgentEscalationTarget } from '../types/execution.js'; +export { + escalationTargetEquals as agentEscalationTargetEquals, + normalizeEscalationTarget as normalizeAgentEscalationTarget, +} from '../types/execution.js'; + export const DEFAULT_AGENT_ID = 'main'; export type AgentModelConfig = @@ -32,6 +39,7 @@ export interface AgentConfig { owner?: string; role?: string; cv?: AgentCv; + escalationTarget?: EscalationTarget; } export interface AgentDefaultsConfig { diff --git a/src/audit/audit-events.ts b/src/audit/audit-events.ts index 2869524c..7fc1a48b 100644 --- a/src/audit/audit-events.ts +++ b/src/audit/audit-events.ts @@ -155,6 +155,29 @@ export function emitToolExecutionAuditEvents(input: { }, }); + if (effectiveEscalationRoute !== 'none') { + recordAuditEvent({ + sessionId, + runId, + event: { + type: 'escalation.decision', + toolCallId, + action: execution.approvalActionKey || `tool:${execution.name}`, + proposedAction: + execution.approvalIntent || + execution.approvalActionKey || + `tool:${execution.name}`, + escalationRoute: effectiveEscalationRoute, + target: execution.escalationTarget || null, + stakes: execution.stakes || 'low', + classifier: execution.stakesScore?.classifier || null, + classifierReasoning: execution.stakesScore?.reasons || [], + approvalDecision: effectiveDecision, + reason: effectiveReason, + }, + }); + } + const isRedApprovalAction = execution.approvalTier === 'red' || execution.approvalBaseTier === 'red'; const decision = execution.approvalDecision; diff --git a/src/config/runtime-config.ts b/src/config/runtime-config.ts index a2d33762..91eddcd2 100644 --- a/src/config/runtime-config.ts +++ b/src/config/runtime-config.ts @@ -14,6 +14,7 @@ import { cloneAgentCv, DEFAULT_AGENT_ID, normalizeAgentCv, + normalizeAgentEscalationTarget, } from '../agents/agent-types.js'; import type { ChannelKind, @@ -2190,6 +2191,11 @@ function normalizeAgentConfig( const cv = Object.hasOwn(value, 'cv') ? normalizeAgentCv(value.cv) : cloneAgentCv(fallback?.cv); + const escalationTarget = Object.hasOwn(value, 'escalationTarget') + ? normalizeAgentEscalationTarget(value.escalationTarget) + : fallback?.escalationTarget + ? { ...fallback.escalationTarget } + : undefined; return { id, ...(name ? { name } : {}), @@ -2202,6 +2208,7 @@ function normalizeAgentConfig( ...(owner ? { owner } : {}), ...(role ? { role } : {}), ...(cv ? { cv } : {}), + ...(escalationTarget ? { escalationTarget } : {}), }; } diff --git a/src/gateway/chat-approval.ts b/src/gateway/chat-approval.ts index 1bbe9af2..31502a53 100644 --- a/src/gateway/chat-approval.ts +++ b/src/gateway/chat-approval.ts @@ -48,5 +48,8 @@ export function extractGatewayChatApprovalEvent( Number.isFinite(approval.expiresAt) ? approval.expiresAt : null, + ...(approval.escalationTarget + ? { escalationTarget: approval.escalationTarget } + : {}), }; } diff --git a/src/gateway/fullauto-runtime.ts b/src/gateway/fullauto-runtime.ts index 366739a4..cfb99841 100644 --- a/src/gateway/fullauto-runtime.ts +++ b/src/gateway/fullauto-runtime.ts @@ -16,6 +16,7 @@ const FULLAUTO_DEFAULT_USER_ID = 'fullauto-user'; const FULLAUTO_DEFAULT_USERNAME = 'fullauto'; export interface ProactiveMessagePayload { + channelId?: string; text: string; artifacts?: ArtifactMetadata[]; } diff --git a/src/gateway/gateway-chat-service.ts b/src/gateway/gateway-chat-service.ts index 77425321..000a958e 100644 --- a/src/gateway/gateway-chat-service.ts +++ b/src/gateway/gateway-chat-service.ts @@ -63,7 +63,11 @@ import { deriveSkillExecutionOutcome, recordSkillExecution, } from '../skills/skills-observation.js'; -import type { PendingApproval, ToolProgressEvent } from '../types/execution.js'; +import { + normalizeEscalationTarget, + type PendingApproval, + type ToolProgressEvent, +} from '../types/execution.js'; import type { CanonicalSessionContext } from '../types/session.js'; import { ensureBootstrapFiles } from '../workspace.js'; import { normalizeSilentMessageSendReply } from './chat-result.js'; @@ -119,6 +123,7 @@ import { } from './gateway-service.js'; import type { GatewayChatRequest, GatewayChatResult } from './gateway-types.js'; import { firstNumber } from './gateway-utils.js'; +import { isSupportedProactiveChannelId } from './proactive-delivery.js'; import { normalizeSessionShowMode, sessionShowModeShowsTools, @@ -126,6 +131,117 @@ import { const MAX_HISTORY_MESSAGES = 40; +function formatEscalationRouteNotice( + approval: PendingApproval, + target: NonNullable, +): string { + return `Escalation for ${target.recipient} on ${target.channel}.\n\n${approval.prompt}`; +} + +async function routeEscalationApproval(params: { + approval: PendingApproval | undefined; + agentId: string; + currentChannelId: string; + sessionId: string; + runId: string; + onProactiveMessage: GatewayChatRequest['onProactiveMessage']; +}): Promise { + if (!params.approval) return; + const target = normalizeEscalationTarget(params.approval.escalationTarget); + if (!target) return; + const targetChannel = target.channel; + if (targetChannel === params.currentChannelId) return; + const auditBase = { + type: 'escalation.route', + approvalId: params.approval.approvalId, + agentId: params.agentId, + currentChannelId: params.currentChannelId, + targetChannel, + targetRecipient: target.recipient, + }; + if (!isSupportedProactiveChannelId(targetChannel)) { + logger.warn( + { + approvalId: params.approval.approvalId, + sourceAgentId: params.agentId, + targetChannel, + }, + 'Blocked escalation approval route to unsupported proactive target', + ); + recordAuditEvent({ + sessionId: params.sessionId, + runId: params.runId, + event: { + ...auditBase, + result: 'blocked', + reason: 'unsupported_proactive_target', + }, + }); + return; + } + if (!params.onProactiveMessage) { + logger.warn( + { + approvalId: params.approval.approvalId, + sourceAgentId: params.agentId, + targetChannel, + }, + 'Unable to route escalation approval notification because onProactiveMessage is unavailable', + ); + recordAuditEvent({ + sessionId: params.sessionId, + runId: params.runId, + event: { + ...auditBase, + result: 'not_sent', + reason: 'missing_proactive_callback', + }, + }); + return; + } + try { + await params.onProactiveMessage({ + channelId: targetChannel, + text: formatEscalationRouteNotice(params.approval, target), + }); + logger.info( + { + approvalId: params.approval.approvalId, + sourceAgentId: params.agentId, + targetChannel, + }, + 'Routed escalation approval notification', + ); + recordAuditEvent({ + sessionId: params.sessionId, + runId: params.runId, + event: { + ...auditBase, + result: 'sent', + }, + }); + } catch (error) { + logger.warn( + { + approvalId: params.approval.approvalId, + sourceAgentId: params.agentId, + targetChannel, + error, + }, + 'Failed to route escalation approval notification', + ); + recordAuditEvent({ + sessionId: params.sessionId, + runId: params.runId, + event: { + ...auditBase, + result: 'failed', + reason: error instanceof Error ? error.message : String(error), + }, + }); + } +} + function readGatewayPromptModeDefault(): PromptMode | undefined { const raw = String(process.env[GATEWAY_SYSTEM_PROMPT_MODE_ENV] || '') .trim() @@ -1012,6 +1128,7 @@ async function handleGatewayMessageInner( media, audioTranscriptsPrepended: audioPrelude.transcripts.length > 0, pluginTools: pluginManager?.getToolDefinitions() ?? [], + escalationTarget: resolvedAgent.escalationTarget, }); agentStage = 'processing-agent-output'; const storedUserContent = buildStoredUserTurnContent( @@ -1019,6 +1136,14 @@ async function handleGatewayMessageInner( media, ); const toolExecutions = output.toolExecutions || []; + await routeEscalationApproval({ + approval: output.pendingApproval, + agentId: resolvedAgent.id, + currentChannelId: req.channelId, + sessionId: req.sessionId, + runId, + onProactiveMessage: req.onProactiveMessage, + }); const observedSkillName = resolveObservedSkillName({ explicitSkillName, toolExecutions, diff --git a/src/gateway/gateway-types.ts b/src/gateway/gateway-types.ts index 9386c37a..e7d03120 100644 --- a/src/gateway/gateway-types.ts +++ b/src/gateway/gateway-types.ts @@ -166,6 +166,7 @@ export interface GatewayChatRequest { onToolProgress?: (event: ToolProgressEvent) => void; onApprovalProgress?: (approval: PendingApproval) => void; onProactiveMessage?: (message: { + channelId?: string; text: string; artifacts?: ArtifactMetadata[]; }) => void | Promise; diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index 456d87ce..e273116e 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -127,10 +127,15 @@ import { startScheduler, stopScheduler, } from '../scheduler/scheduler.js'; -import type { ArtifactMetadata } from '../types/execution.js'; +import { + type ArtifactMetadata, + type EscalationTarget, + normalizeEscalationTarget, +} from '../types/execution.js'; import { formatError } from '../utils/text-format.js'; import { buildApprovalConfirmationComponents } from './approval-confirmation.js'; import { + type ApprovalPresentation, createApprovalPresentation, getApprovalPromptText, getApprovalVisibleText, @@ -159,7 +164,11 @@ import { handleGatewayCommand, resumeEnabledFullAutoSessions, } from './gateway-service.js'; -import type { GatewayChatRequest, GatewayChatResult } from './gateway-types.js'; +import type { + GatewayChatApprovalEvent, + GatewayChatRequest, + GatewayChatResult, +} from './gateway-types.js'; import { runManagedMediaCleanup } from './managed-media-cleanup.js'; import { getDreamTimezone, @@ -293,6 +302,87 @@ const DISCORD_APPROVAL_PRESENTATION = createApprovalPresentation('buttons'); const SLACK_APPROVAL_PRESENTATION = createApprovalPresentation('buttons'); const TEAMS_APPROVAL_PRESENTATION = createApprovalPresentation('text'); +function formatRoutedApprovalNotice( + approval: { approvalId: string }, + target: EscalationTarget, +): string { + return `Escalation routed to ${target.recipient} on ${target.channel}. Approval ID: ${approval.approvalId}`; +} + +type ApprovalNotificationSender = (params: { + approval: Pick; + presentation: ApprovalPresentation; + userId: string; +}) => Promise<{ disableButtons: () => Promise } | null>; + +async function handlePendingApprovalRouting(params: { + pendingApproval: GatewayChatApprovalEvent; + responseText: string; + sessionId: string; + userId: string; + channelId: string; + buttonPresentation: ApprovalPresentation; + sendApprovalNotification?: ApprovalNotificationSender; + sendText: (text: string) => Promise; + formatTextPrompt?: (input: { + approval: GatewayChatApprovalEvent; + approvalUserId: string; + responseText: string; + storedPrompt: string; + }) => string; +}): Promise<{ cleanup: { disableButtons: () => Promise } | null }> { + const escalationTarget = normalizeEscalationTarget( + params.pendingApproval.escalationTarget, + ); + const approvalUserId = escalationTarget?.recipient || params.userId; + const routedTarget = + escalationTarget && escalationTarget.channel !== params.channelId + ? escalationTarget + : null; + const storedPrompt = getApprovalPromptText( + params.pendingApproval, + params.responseText, + ); + const presentation = + params.sendApprovalNotification && !routedTarget + ? params.buttonPresentation + : createApprovalPresentation('text'); + let cleanup: { disableButtons: () => Promise } | null = null; + + if (params.sendApprovalNotification && !routedTarget) { + cleanup = await params.sendApprovalNotification({ + approval: params.pendingApproval, + presentation, + userId: approvalUserId, + }); + } else if (routedTarget) { + await params.sendText( + formatRoutedApprovalNotice(params.pendingApproval, routedTarget), + ); + } else { + await params.sendText( + params.formatTextPrompt?.({ + approval: params.pendingApproval, + approvalUserId, + responseText: params.responseText, + storedPrompt, + }) ?? storedPrompt, + ); + } + + await rememberPendingApproval({ + sessionId: params.sessionId, + approvalId: params.pendingApproval.approvalId, + prompt: storedPrompt, + userId: approvalUserId, + expiresAt: params.pendingApproval.expiresAt, + presentation, + disableButtons: cleanup?.disableButtons ?? null, + }); + + return { cleanup }; +} + function scheduleNextMemoryConsolidationRun(): void { if (!isMemoryConsolidationEnabled()) { logger.info('Memory consolidation scheduler disabled'); @@ -1096,7 +1186,7 @@ async function startDiscordIntegration(): Promise { }, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -1148,31 +1238,17 @@ async function startDiscordIntegration(): Promise { result.memoryCitations, ); if (pendingApproval) { - const storedPrompt = getApprovalPromptText( + const { cleanup } = await handlePendingApprovalRouting({ pendingApproval, responseText, - ); - const approvalPresentation = context.sendApprovalNotification - ? DISCORD_APPROVAL_PRESENTATION - : createApprovalPresentation('text'); - let cleanup: { disableButtons: () => Promise } | null = null; - if (context.sendApprovalNotification) { - cleanup = await context.sendApprovalNotification({ - approval: pendingApproval, - presentation: approvalPresentation, - userId, - }); - } else { - await context.stream.finalize(`<@${userId}> ${storedPrompt}`); - } - await rememberPendingApproval({ sessionId: effectiveSessionId, - approvalId: pendingApproval.approvalId, - prompt: storedPrompt, userId, - expiresAt: pendingApproval.expiresAt, - presentation: approvalPresentation, - disableButtons: cleanup?.disableButtons ?? null, + channelId, + buttonPresentation: DISCORD_APPROVAL_PRESENTATION, + sendApprovalNotification: context.sendApprovalNotification, + sendText: (text) => context.stream.finalize(text), + formatTextPrompt: ({ approvalUserId, storedPrompt }) => + `<@${approvalUserId}> ${storedPrompt}`, }); if (cleanup) { await context.stream.discard(); @@ -1391,26 +1467,23 @@ async function startMSTeamsIntegration(): Promise { : ''; const pendingApproval = extractGatewayChatApprovalEvent(result); if (pendingApproval) { - const storedPrompt = getApprovalPromptText( + await handlePendingApprovalRouting({ pendingApproval, responseText, - ); - const visiblePrompt = getApprovalVisibleText( - pendingApproval, - TEAMS_APPROVAL_PRESENTATION, - responseText, - ); - await rememberPendingApproval({ sessionId: effectiveSessionId, - approvalId: pendingApproval.approvalId, - prompt: storedPrompt, userId, - expiresAt: pendingApproval.expiresAt, - presentation: TEAMS_APPROVAL_PRESENTATION, + channelId, + buttonPresentation: TEAMS_APPROVAL_PRESENTATION, + sendText: (text) => context.stream.finalize(text), + formatTextPrompt: ({ approval, responseText }) => { + const visiblePrompt = getApprovalVisibleText( + approval, + TEAMS_APPROVAL_PRESENTATION, + responseText, + ); + return `${visiblePrompt}\n\nApproval required. Reply \`1\` to allow once, \`2\` to allow for this session, \`3\` to allow for this agent, \`4\` to allow for all, or \`5\` to deny. You can also use \`/approve view\` or \`/approve [1|2|3|4|5]\`.`; + }, }); - await context.stream.finalize( - `${visiblePrompt}\n\nApproval required. Reply \`1\` to allow once, \`2\` to allow for this session, \`3\` to allow for this agent, \`4\` to allow for all, or \`5\` to deny. You can also use \`/approve view\` or \`/approve [1|2|3|4|5]\`.`, - ); return; } @@ -1544,7 +1617,7 @@ async function startWhatsAppIntegration(): Promise { media, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -1695,7 +1768,7 @@ async function startEmailIntegration(): Promise { media, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -1864,7 +1937,7 @@ async function startTelegramIntegration(): Promise { media, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -2023,7 +2096,7 @@ async function startSignalIntegration(): Promise { content, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -2137,7 +2210,7 @@ async function startSlackIntegration(): Promise { reply: textReply, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -2192,31 +2265,15 @@ async function startSlackIntegration(): Promise { ) : ''; if (pendingApproval) { - const storedPrompt = getApprovalPromptText( + await handlePendingApprovalRouting({ pendingApproval, responseText, - ); - const approvalPresentation = context.sendApprovalNotification - ? SLACK_APPROVAL_PRESENTATION - : createApprovalPresentation('text'); - let cleanup: { disableButtons: () => Promise } | null = null; - if (context.sendApprovalNotification) { - cleanup = await context.sendApprovalNotification({ - approval: pendingApproval, - presentation: approvalPresentation, - userId, - }); - } else { - await reply(storedPrompt); - } - await rememberPendingApproval({ sessionId: effectiveSessionId, - approvalId: pendingApproval.approvalId, - prompt: storedPrompt, userId, - expiresAt: pendingApproval.expiresAt, - presentation: approvalPresentation, - disableButtons: cleanup?.disableButtons ?? null, + channelId, + buttonPresentation: SLACK_APPROVAL_PRESENTATION, + sendApprovalNotification: context.sendApprovalNotification, + sendText: reply, }); return; } @@ -2645,7 +2702,7 @@ async function startIMessageIntegration(): Promise { media, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, diff --git a/src/gateway/text-channel-commands.ts b/src/gateway/text-channel-commands.ts index 5e5b5503..20897f08 100644 --- a/src/gateway/text-channel-commands.ts +++ b/src/gateway/text-channel-commands.ts @@ -10,7 +10,10 @@ import { mapTuiSlashCommandToGatewayArgs, parseTuiSlashCommand, } from '../tui-slash-command.js'; -import type { ArtifactMetadata } from '../types/execution.js'; +import { + type ArtifactMetadata, + normalizeEscalationTarget, +} from '../types/execution.js'; import { formatError, formatInfo } from '../utils/text-format.js'; import { getApprovalPromptText } from './approval-presentation.js'; import { extractGatewayChatApprovalEvent } from './chat-approval.js'; @@ -357,11 +360,14 @@ export async function handleTextChannelApprovalCommand(params: { ); const pendingApproval = extractGatewayChatApprovalEvent(approvalResult); if (pendingApproval) { + const escalationTarget = normalizeEscalationTarget( + pendingApproval.escalationTarget, + ); await rememberPendingApproval({ sessionId: approvalSessionId, approvalId: pendingApproval.approvalId, prompt: getApprovalPromptText(pendingApproval, resultText), - userId, + userId: escalationTarget?.recipient || userId, expiresAt: pendingApproval.expiresAt, }); return { diff --git a/src/infra/container-runner.ts b/src/infra/container-runner.ts index 6aad2b12..fd7eab1f 100644 --- a/src/infra/container-runner.ts +++ b/src/infra/container-runner.ts @@ -61,10 +61,11 @@ import { resolveConfiguredAdditionalMounts } from '../security/mount-config.js'; import { validateAdditionalMounts } from '../security/mount-security.js'; import { redactCredentialSecrets } from '../security/redact.js'; import type { ContainerInput, ContainerOutput } from '../types/container.js'; -import type { - ArtifactMetadata, - PendingApproval, - ToolProgressEvent, +import { + type ArtifactMetadata, + normalizeEscalationTarget, + type PendingApproval, + type ToolProgressEvent, } from '../types/execution.js'; import type { ScheduledTaskInput } from '../types/scheduler.js'; import type { AdditionalMount } from '../types/security.js'; @@ -305,7 +306,9 @@ function parseApprovalProgress(line: string): PendingApproval | null { if (!match) return null; try { const raw = Buffer.from(match[1], 'base64').toString('utf-8'); - const parsed = JSON.parse(raw) as PendingApproval; + const parsed = JSON.parse(raw) as Partial & { + escalationTarget?: unknown; + }; if ( !parsed || typeof parsed !== 'object' || @@ -316,6 +319,7 @@ function parseApprovalProgress(line: string): PendingApproval | null { ) { return null; } + const escalationTarget = normalizeEscalationTarget(parsed.escalationTarget); return { approvalId: parsed.approvalId, prompt: redactCredentialSecrets(parsed.prompt), @@ -329,6 +333,7 @@ function parseApprovalProgress(line: string): PendingApproval | null { Number.isFinite(parsed.expiresAt) ? parsed.expiresAt : null, + ...(escalationTarget ? { escalationTarget } : {}), }; } catch { return null; @@ -820,6 +825,7 @@ async function runContainerInner( media, audioTranscriptsPrepended, pluginTools, + escalationTarget, maxWallClockMs, inactivityTimeoutMs, } = params; @@ -926,6 +932,7 @@ async function runContainerInner( tavilySearchDepth: WEB_SEARCH_TAVILY_SEARCH_DEPTH, }, persistBashState: CONTAINER_PERSIST_BASH_STATE, + escalationTarget, }; const workerSignature = computeWorkerSignature({ agentId, diff --git a/src/infra/host-runner.ts b/src/infra/host-runner.ts index ef3a88cc..88d43144 100644 --- a/src/infra/host-runner.ts +++ b/src/infra/host-runner.ts @@ -47,7 +47,11 @@ import { resolveTaskModelPolicies } from '../providers/task-routing.js'; import { resolveConfiguredAdditionalMounts } from '../security/mount-config.js'; import { redactCredentialSecrets } from '../security/redact.js'; import type { ContainerInput, ContainerOutput } from '../types/container.js'; -import type { PendingApproval, ToolProgressEvent } from '../types/execution.js'; +import { + normalizeEscalationTarget, + type PendingApproval, + type ToolProgressEvent, +} from '../types/execution.js'; import type { ScheduledTaskInput } from '../types/scheduler.js'; import { collectConfiguredDiscordChannelIds, @@ -343,7 +347,9 @@ function parseApprovalProgress(line: string): PendingApproval | null { if (!match) return null; try { const raw = Buffer.from(match[1], 'base64').toString('utf-8'); - const parsed = JSON.parse(raw) as PendingApproval; + const parsed = JSON.parse(raw) as Partial & { + escalationTarget?: unknown; + }; if ( !parsed || typeof parsed !== 'object' || @@ -354,6 +360,7 @@ function parseApprovalProgress(line: string): PendingApproval | null { ) { return null; } + const escalationTarget = normalizeEscalationTarget(parsed.escalationTarget); return { approvalId: parsed.approvalId, prompt: redactCredentialSecrets(parsed.prompt), @@ -367,6 +374,7 @@ function parseApprovalProgress(line: string): PendingApproval | null { Number.isFinite(parsed.expiresAt) ? parsed.expiresAt : null, + ...(escalationTarget ? { escalationTarget } : {}), }; } catch { return null; @@ -697,6 +705,7 @@ async function runHostProcessInner( media, audioTranscriptsPrepended, pluginTools, + escalationTarget, maxWallClockMs, inactivityTimeoutMs, } = params; @@ -809,6 +818,7 @@ async function runHostProcessInner( tavilySearchDepth: WEB_SEARCH_TAVILY_SEARCH_DEPTH, }, persistBashState: CONTAINER_PERSIST_BASH_STATE, + escalationTarget, }; const workerSignature = computeWorkerSignature({ agentId, diff --git a/src/memory/db.ts b/src/memory/db.ts index cdb65a04..3df87809 100644 --- a/src/memory/db.ts +++ b/src/memory/db.ts @@ -7,7 +7,11 @@ import type { AgentCv, AgentModelConfig, } from '../agents/agent-types.js'; -import { DEFAULT_AGENT_ID, normalizeAgentCv } from '../agents/agent-types.js'; +import { + DEFAULT_AGENT_ID, + normalizeAgentCv, + normalizeAgentEscalationTarget, +} from '../agents/agent-types.js'; import type { WireRecord } from '../audit/audit-trail.js'; import { DB_PATH } from '../config/config.js'; import { @@ -114,7 +118,7 @@ import { let db: Database.Database; let databaseInitialized = false; -export const DATABASE_SCHEMA_VERSION = 24; +export const DATABASE_SCHEMA_VERSION = 25; const STRUCTURED_AUDIT_SESSION_LIMIT = 10_000; const RECENT_CHAT_MESSAGE_SEARCH_TABLE = 'recent_chat_message_search'; const RECENT_CHAT_MESSAGE_SEARCH_INSERT_TRIGGER = @@ -159,6 +163,7 @@ type AgentRow = { owner: string | null; role: string | null; cv: string | null; + escalation_target: string | null; created_at: string; updated_at: string; }; @@ -2036,6 +2041,24 @@ function migrateV24( recordMigration(database, 24, 'Persist assistant message artifacts'); } +function migrateV25( + database: Database.Database, + opts?: InitDatabaseOptions, +): void { + addColumnIfMissing({ + database, + table: 'agents', + column: 'escalation_target', + ddl: 'escalation_target TEXT', + quiet: opts?.quiet === true, + }); + recordMigration( + database, + 25, + 'Persist per-agent escalation targets for approval routing', + ); +} + function runMigrations( database: Database.Database, opts?: InitDatabaseOptions, @@ -2091,6 +2114,7 @@ function runMigrations( if (currentVersion < 24 || messageArtifactsNeedMigration(database)) { migrateV24(database, opts); } + if (currentVersion < 25) migrateV25(database, opts); setSchemaVersion(database, DATABASE_SCHEMA_VERSION); if (!quiet && currentVersion < DATABASE_SCHEMA_VERSION) { @@ -2229,6 +2253,29 @@ function parseAgentCv(rawCv: string | null): AgentCv | undefined { } } +function serializeAgentEscalationTarget( + target: AgentConfig['escalationTarget'], +): string | null { + return target ? JSON.stringify(target) : null; +} + +function parseAgentEscalationTarget( + rawTarget: string | null, +): AgentConfig['escalationTarget'] { + const normalized = rawTarget?.trim() || ''; + if (!normalized) return undefined; + + try { + return normalizeAgentEscalationTarget(JSON.parse(normalized)); + } catch { + logger.warn( + { targetLength: normalized.length }, + 'Failed to parse persisted agent escalation target', + ); + return undefined; + } +} + function mapAgentRow(row: AgentRow): AgentConfig { const name = row.name?.trim() || ''; const displayName = row.display_name?.trim() || ''; @@ -2240,6 +2287,7 @@ function mapAgentRow(row: AgentRow): AgentConfig { const owner = row.owner?.trim() || ''; const role = row.role?.trim() || ''; const cv = parseAgentCv(row.cv); + const escalationTarget = parseAgentEscalationTarget(row.escalation_target); return { id: row.id, ...(name ? { name } : {}), @@ -2255,11 +2303,12 @@ function mapAgentRow(row: AgentRow): AgentConfig { ...(owner ? { owner } : {}), ...(role ? { role } : {}), ...(cv ? { cv } : {}), + ...(escalationTarget ? { escalationTarget } : {}), }; } const AGENT_SELECT_COLUMNS = - 'id, name, display_name, image_asset, model, skills, chatbot_id, enable_rag, workspace, owner, role, cv, created_at, updated_at'; + 'id, name, display_name, image_asset, model, skills, chatbot_id, enable_rag, workspace, owner, role, cv, escalation_target, created_at, updated_at'; export function getAgentById(agentId: string): AgentConfig | null { const normalizedAgentId = agentId.trim(); @@ -2300,6 +2349,9 @@ export function upsertAgent(agent: AgentConfig): AgentConfig { const normalizedOwner = agent.owner?.trim() || null; const normalizedRole = agent.role?.trim() || null; const normalizedCv = serializeAgentCv(agent.cv); + const normalizedEscalationTarget = serializeAgentEscalationTarget( + agent.escalationTarget, + ); const enableRag = typeof agent.enableRag === 'boolean' ? (agent.enableRag ? 1 : 0) : null; db.prepare( @@ -2316,9 +2368,10 @@ export function upsertAgent(agent: AgentConfig): AgentConfig { owner, role, cv, + escalation_target, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) ON CONFLICT(id) DO UPDATE SET name = excluded.name, display_name = excluded.display_name, @@ -2331,6 +2384,7 @@ export function upsertAgent(agent: AgentConfig): AgentConfig { owner = excluded.owner, role = excluded.role, cv = excluded.cv, + escalation_target = excluded.escalation_target, updated_at = datetime('now')`, ).run( normalizedId, @@ -2345,6 +2399,7 @@ export function upsertAgent(agent: AgentConfig): AgentConfig { normalizedOwner, normalizedRole, normalizedCv, + normalizedEscalationTarget, ); const storedAgent = getAgentById(normalizedId); if (!storedAgent) { diff --git a/src/types/container.ts b/src/types/container.ts index 3144d672..59926e2a 100644 --- a/src/types/container.ts +++ b/src/types/container.ts @@ -1,6 +1,7 @@ import type { ChatMessage } from './api.js'; import type { ArtifactMetadata, + EscalationTarget, PendingApproval, PluginRuntimeToolDefinition, ToolExecution, @@ -87,6 +88,7 @@ export interface ContainerInput { webSearch?: WebSearchConfig; persistBashState?: boolean; runtimeEnv?: Record; + escalationTarget?: EscalationTarget; } export interface ContainerOutput { diff --git a/src/types/execution.ts b/src/types/execution.ts index 3ae498bf..602dd34f 100644 --- a/src/types/execution.ts +++ b/src/types/execution.ts @@ -29,6 +29,33 @@ export interface PluginRuntimeToolDefinition { export type ToolExecutionStakesSignal = CanonicalStakesSignal; export type ToolExecutionStakesScore = CanonicalStakesScore; +export interface EscalationTarget { + channel: string; + recipient: string; +} + +export function normalizeEscalationTarget( + value: unknown, +): EscalationTarget | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const raw = value as { channel?: unknown; recipient?: unknown }; + const channel = typeof raw.channel === 'string' ? raw.channel.trim() : ''; + const recipient = + typeof raw.recipient === 'string' ? raw.recipient.trim() : ''; + return channel && recipient ? { channel, recipient } : undefined; +} + +export function escalationTargetEquals( + a?: EscalationTarget, + b?: EscalationTarget, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + return a.channel === b.channel && a.recipient === b.recipient; +} + export interface ToolExecution { name: string; arguments: string; @@ -47,6 +74,7 @@ export interface ToolExecution { | 'implicit_notice' | 'approval_request' | 'policy_denial'; + escalationTarget?: EscalationTarget; approvalDecision?: | 'auto' | 'implicit' @@ -77,6 +105,7 @@ export interface PendingApproval { allowAgent: boolean; allowAll: boolean; expiresAt: number | null; + escalationTarget?: EscalationTarget; } export interface ToolProgressEvent { diff --git a/tests/agent-registry.test.ts b/tests/agent-registry.test.ts index 43b3e61c..39614e9b 100644 --- a/tests/agent-registry.test.ts +++ b/tests/agent-registry.test.ts @@ -242,6 +242,10 @@ test('agent owner, role, and CV persist through runtime config and registry', as capabilities: [' research ', 'writing', 'research'], asset: 'agents/charly/CV.md', }, + escalationTarget: { + channel: ' slack:COPS ', + recipient: ' ops-lead ', + }, }, ]; }); @@ -257,6 +261,10 @@ test('agent owner, role, and CV persist through runtime config and registry', as capabilities: ['research', 'writing'], asset: 'agents/charly/CV.md', }); + expect(persistedConfig?.escalationTarget).toEqual({ + channel: 'slack:COPS', + recipient: 'ops-lead', + }); initAgentRegistry({ list: [ @@ -274,6 +282,10 @@ test('agent owner, role, and CV persist through runtime config and registry', as capabilities: ['research', 'writing'], asset: 'agents/charly/CV.md', }, + escalationTarget: { + channel: 'slack:COPS', + recipient: 'ops-lead', + }, }, ], }); @@ -286,6 +298,10 @@ test('agent owner, role, and CV persist through runtime config and registry', as capabilities: ['research', 'writing'], asset: 'agents/charly/CV.md', }); + expect(resolved.escalationTarget).toEqual({ + channel: 'slack:COPS', + recipient: 'ops-lead', + }); // Round-trip through SQLite confirms persistence. const stored = getAgentById('charly'); @@ -296,9 +312,13 @@ test('agent owner, role, and CV persist through runtime config and registry', as capabilities: ['research', 'writing'], asset: 'agents/charly/CV.md', }); + expect(stored?.escalationTarget).toEqual({ + channel: 'slack:COPS', + recipient: 'ops-lead', + }); }); -test('legacy agents without owner/role/cv load cleanly after migration v21', async () => { +test('legacy agents without owner/role/cv/escalation target load cleanly after migration v25', async () => { const homeDir = makeTempHome(); process.env.HOME = homeDir; vi.resetModules(); @@ -341,18 +361,24 @@ test('legacy agents without owner/role/cv load cleanly after migration v21', asy expect(columns.some((column) => column.name === 'owner')).toBe(true); expect(columns.some((column) => column.name === 'role')).toBe(true); expect(columns.some((column) => column.name === 'cv')).toBe(true); + expect(columns.some((column) => column.name === 'escalation_target')).toBe( + true, + ); const userVersion = migratedDb.pragma('user_version', { simple: true }); - expect(userVersion).toBeGreaterThanOrEqual(21); + expect(userVersion).toBeGreaterThanOrEqual(25); const charly = migratedDb - .prepare('SELECT id, name, owner, role, cv FROM agents WHERE id = ?') + .prepare( + 'SELECT id, name, owner, role, cv, escalation_target FROM agents WHERE id = ?', + ) .get('charly') as { id: string; name: string; owner: string | null; role: string | null; cv: string | null; + escalation_target: string | null; }; expect(charly).toMatchObject({ id: 'charly', @@ -360,6 +386,7 @@ test('legacy agents without owner/role/cv load cleanly after migration v21', asy owner: null, role: null, cv: null, + escalation_target: null, }); migratedDb.close(); diff --git a/tests/approval-policy.test.ts b/tests/approval-policy.test.ts index b79395c7..b0a380ca 100644 --- a/tests/approval-policy.test.ts +++ b/tests/approval-policy.test.ts @@ -223,6 +223,62 @@ autonomy: expect(evaluation.decision).toBe('required'); }); + test('low-stakes autonomy pauses medium-stakes actions for escalation', () => { + const policyPath = writeTempPolicy(` +autonomy: + default: low-stakes-autonomous +`); + const runtime = new TrustedAgentApprovalRuntime(policyPath); + + const evaluation = runtime.evaluateToolCall({ + toolName: 'write', + argsJson: JSON.stringify({ + path: 'docs/project-note.md', + content: 'Project note', + }), + latestUserPrompt: 'Create a project note', + }); + + expect(evaluation.stakes).toBe('medium'); + expect(evaluation.baseTier).toBe('red'); + expect(evaluation.tier).toBe('red'); + expect(evaluation.decision).toBe('required'); + expect(evaluation.escalationRoute).toBe('approval_request'); + expect(evaluation.escalationRoute).not.toBe('implicit_notice'); + }); + + test('out-of-bound escalation carries target and classifier reasoning', () => { + const policyPath = writeTempPolicy(` +autonomy: + default: low-stakes-autonomous +`); + const runtime = new TrustedAgentApprovalRuntime(policyPath); + + const evaluation = runtime.evaluateToolCall({ + toolName: 'message', + argsJson: JSON.stringify({ + action: 'send', + channel: 'customer-success', + text: 'Tell the customer their invoice was refunded.', + }), + latestUserPrompt: 'Send a refund update to the customer', + escalationTarget: { + channel: 'slack:COPS', + recipient: 'ops-lead', + }, + }); + const prompt = runtime.formatApprovalRequest(evaluation); + + expect(evaluation.escalationRoute).toBe('approval_request'); + expect(evaluation.escalationTarget).toEqual({ + channel: 'slack:COPS', + recipient: 'ops-lead', + }); + expect(prompt).toContain('Proposed action:'); + expect(prompt).toContain('Classifier reasoning: high stakes via'); + expect(prompt).toContain('Escalation target: slack:COPS / ops-lead'); + }); + test('pip install is classified as dependency installation', () => { const runtime = new TrustedAgentApprovalRuntime( '/tmp/hybridclaw-missing-policy.yaml', diff --git a/tests/audit-events.test.ts b/tests/audit-events.test.ts index 3c4978c8..9c3e1c28 100644 --- a/tests/audit-events.test.ts +++ b/tests/audit-events.test.ts @@ -106,8 +106,13 @@ test('emits approval request and response events for pending red actions', async autonomyLevel: 'full-autonomous', stakes: 'high', escalationRoute: 'approval_request', + escalationTarget: { + channel: 'slack:COPS', + recipient: 'ops-lead', + }, approvalDecision: 'required', approvalActionKey: 'bash:other', + approvalIntent: 'run shell command `open -a Music`', approvalReason: 'this command may change local state', approvalRequestId: 'approve123', }, @@ -119,10 +124,27 @@ test('emits approval request and response events for pending red actions', async 'tool.result', 'approval.response', 'approval.request', + 'escalation.decision', 'autonomy.decision', 'authorization.check', 'tool.call', ]); + const escalationEvent = events.find( + (event) => event.event_type === 'escalation.decision', + ); + expect(JSON.parse(escalationEvent?.payload || '{}')).toEqual( + expect.objectContaining({ + type: 'escalation.decision', + proposedAction: 'run shell command `open -a Music`', + escalationRoute: 'approval_request', + target: { + channel: 'slack:COPS', + recipient: 'ops-lead', + }, + classifier: null, + classifierReasoning: [], + }), + ); }); test('autonomy audit falls back to internally consistent approval metadata', async () => { diff --git a/tests/config-reload.integration.test.ts b/tests/config-reload.integration.test.ts index 21a49eea..877e060c 100644 --- a/tests/config-reload.integration.test.ts +++ b/tests/config-reload.integration.test.ts @@ -158,6 +158,42 @@ describe('config reload integration', () => { expect(cfg.container.persistBashState).toBe(false); }); + it('normalizes per-agent escalation targets', () => { + writeConfig({ + agents: { + defaultAgentId: 'writer', + list: [ + { + id: 'writer', + escalationTarget: { + channel: ' slack:COPS ', + recipient: ' ops-lead ', + }, + }, + { + id: 'ignored-target', + escalationTarget: { + channel: 'slack:COPS', + recipient: '', + }, + }, + ], + }, + }); + + const cfg = configMod.reloadRuntimeConfig('test'); + + const writer = cfg.agents.list?.find((agent) => agent.id === 'writer'); + const ignoredTarget = cfg.agents.list?.find( + (agent) => agent.id === 'ignored-target', + ); + expect(writer?.escalationTarget).toEqual({ + channel: 'slack:COPS', + recipient: 'ops-lead', + }); + expect(ignoredTarget?.escalationTarget).toBeUndefined(); + }); + it('normalizes per-agent skill autonomy rules', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); writeConfig({ diff --git a/tests/hybridai-skills-command.test.ts b/tests/hybridai-skills-command.test.ts index 17f1b879..bdb24d87 100644 --- a/tests/hybridai-skills-command.test.ts +++ b/tests/hybridai-skills-command.test.ts @@ -1,7 +1,35 @@ import fs from 'node:fs'; import path from 'node:path'; -import { afterEach, describe, expect, test, vi } from 'vitest'; +import { afterAll, afterEach, describe, expect, test, vi } from 'vitest'; + +const runtimeHome = vi.hoisted(() => { + const originalDataDir = process.env.HYBRIDCLAW_DATA_DIR; + const originalHome = process.env.HOME; + const getBuiltinModule = ( + process as typeof process & { + getBuiltinModule?: (id: string) => unknown; + } + ).getBuiltinModule; + const fsModule = getBuiltinModule?.('fs') as + | { mkdtempSync: (prefix: string) => string } + | undefined; + const osModule = getBuiltinModule?.('os') as + | { tmpdir: () => string } + | undefined; + const pathModule = getBuiltinModule?.('path') as + | { join: (...parts: string[]) => string } + | undefined; + if (!fsModule || !osModule || !pathModule) { + throw new Error('Unable to initialize temporary runtime home for tests.'); + } + const homeDir = fsModule.mkdtempSync( + pathModule.join(osModule.tmpdir(), 'hybridclaw-hybridai-skills-module-'), + ); + process.env.HYBRIDCLAW_DATA_DIR = homeDir; + process.env.HOME = homeDir; + return { homeDir, originalDataDir, originalHome }; +}); import { buildDefaultEvalProfile } from '../src/evals/eval-profile.js'; import { @@ -16,6 +44,20 @@ import { useTempDir } from './test-utils.ts'; const makeTempDir = useTempDir('hybridclaw-hybridai-skills-'); +afterAll(() => { + if (runtimeHome.originalDataDir === undefined) { + delete process.env.HYBRIDCLAW_DATA_DIR; + } else { + process.env.HYBRIDCLAW_DATA_DIR = runtimeHome.originalDataDir; + } + if (runtimeHome.originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = runtimeHome.originalHome; + } + fs.rmSync(runtimeHome.homeDir, { recursive: true, force: true }); +}); + function writeDoc(dir: string, filename: string, body: string): string { const target = path.join(dir, filename); fs.writeFileSync(target, body, 'utf8');