From 519cec60de993abb3d8c537d4b6411ebd4794992 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Tue, 28 Apr 2026 17:33:00 +0200 Subject: [PATCH 1/7] feat: add escalation routing for out-of-bound actions --- container/src/approval-policy.ts | 43 ++++++++- container/src/index.ts | 14 +++ container/src/types.ts | 8 ++ src/agent/executor-types.ts | 2 + src/agents/agent-config-command.ts | 21 ++++- src/agents/agent-registry.ts | 12 +++ src/agents/agent-runtime-config.ts | 5 +- src/agents/agent-types.ts | 36 +++++++ src/audit/audit-events.ts | 23 +++++ src/config/runtime-config.ts | 6 ++ src/gateway/chat-approval.ts | 3 + src/gateway/fullauto-runtime.ts | 1 + src/gateway/gateway-chat-service.ts | 46 +++++++++ src/gateway/gateway-types.ts | 1 + src/gateway/gateway.ts | 120 +++++++++++++++++++----- src/gateway/text-channel-commands.ts | 2 +- src/infra/container-runner.ts | 5 + src/infra/host-runner.ts | 5 + src/memory/db.ts | 63 ++++++++++++- src/types/container.ts | 2 + src/types/execution.ts | 7 ++ tests/agent-registry.test.ts | 33 ++++++- tests/approval-policy.test.ts | 54 +++++++++++ tests/audit-events.test.ts | 22 +++++ tests/config-reload.integration.test.ts | 36 +++++++ tests/hybridai-skills-command.test.ts | 26 ++++- 26 files changed, 557 insertions(+), 39 deletions(-) diff --git a/container/src/approval-policy.ts b/container/src/approval-policy.ts index 89f426f4d..3c1ca0ffa 100644 --- a/container/src/approval-policy.ts +++ b/container/src/approval-policy.ts @@ -27,7 +27,7 @@ import { type StakesScore, } from './stakes-classifier.js'; import { normalizeText } from './text-normalization.js'; -import type { ChatMessage } from './types.js'; +import type { ChatMessage, EscalationTarget } from './types.js'; export type { NetworkPolicyAction, @@ -138,6 +138,7 @@ export interface ToolApprovalEvaluation { stakes: StakesLevel; stakesScore: StakesScore; escalationRoute: EscalationRoute; + escalationTarget?: EscalationTarget; decision: ApprovalDecision; actionKey: string; fingerprint: string; @@ -277,6 +278,22 @@ function escalationRouteForDecision( return 'none'; } +function normalizeEscalationTarget( + value: EscalationTarget | undefined, +): EscalationTarget | undefined { + const channel = normalizeText(value?.channel); + const recipient = normalizeText(value?.recipient); + return channel && recipient ? { channel, recipient } : undefined; +} + +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 +1292,7 @@ export class TrustedAgentApprovalRuntime { argsJson: string; latestUserPrompt: string; channelId?: string; + escalationTarget?: EscalationTarget; }): ToolApprovalEvaluation { this.reloadPolicyIfNeeded(); this.cleanupExpiredPending(); @@ -1319,15 +1337,17 @@ 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') { + if (stakes !== 'low') { + outOfBoundByAutonomy = true; baseTier = 'red'; - } else if (stakes === 'medium' && baseTier === 'green') { - baseTier = 'yellow'; } } @@ -1343,6 +1363,7 @@ export class TrustedAgentApprovalRuntime { stakes, stakesScore, escalationRoute: 'policy_denial', + ...(escalationTarget ? { escalationTarget } : {}), decision: 'denied', actionKey: classified.actionKey, fingerprint, @@ -1391,6 +1412,7 @@ export class TrustedAgentApprovalRuntime { decision = 'promoted'; } else if ( this.fullAutoEnabled && + !outOfBoundByAutonomy && !this.shouldNeverAutoApprove(params.toolName, classified.actionKey) ) { tier = 'yellow'; @@ -1404,6 +1426,7 @@ export class TrustedAgentApprovalRuntime { stakes, stakesScore, escalationRoute: 'policy_denial', + ...(escalationTarget ? { escalationTarget } : {}), decision: 'denied', actionKey: classified.actionKey, fingerprint, @@ -1434,6 +1457,7 @@ export class TrustedAgentApprovalRuntime { stakes, stakesScore, escalationRoute: 'approval_request', + ...(escalationTarget ? { escalationTarget } : {}), decision: 'required', actionKey: classified.actionKey, fingerprint, @@ -1453,6 +1477,7 @@ export class TrustedAgentApprovalRuntime { tier === 'yellow' && decision === 'auto' && this.fullAutoEnabled && + !outOfBoundByAutonomy && !this.shouldNeverAutoApprove(params.toolName, classified.actionKey) ) { decision = 'approved_fullauto'; @@ -1480,6 +1505,7 @@ export class TrustedAgentApprovalRuntime { stakes, stakesScore, escalationRoute: escalationRouteForDecision(decision, tier), + ...(escalationTarget ? { escalationTarget } : {}), decision, actionKey: classified.actionKey, fingerprint, @@ -1550,6 +1576,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 19c77fd11..317612087 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, @@ -939,6 +941,7 @@ async function processRequest( maxTokens?: number, effectiveUserPromptOverride?: string, ralphMaxIterationsOverride?: number | null, + escalationTarget?: EscalationTarget, ): Promise { const processStartedAt = Date.now(); await emitRuntimeEvent({ @@ -1372,6 +1375,7 @@ async function processRequest( argsJson: candidate.function.arguments, latestUserPrompt: effectiveUserPrompt, channelId, + escalationTarget, }); if ( candidateApproval.decision === 'required' || @@ -1460,6 +1464,7 @@ async function processRequest( argsJson: call.function.arguments, latestUserPrompt: effectiveUserPrompt, channelId, + escalationTarget, }); logToolCallStart(toolName, call.function.arguments, approval); @@ -1484,6 +1489,9 @@ async function processRequest( Number.isFinite(approval.expiresAtMs) ? approval.expiresAtMs : null, + ...(approval.escalationTarget + ? { escalationTarget: approval.escalationTarget } + : {}), }; emitApprovalProgress(pendingApproval); toolExecutions.push({ @@ -1500,6 +1508,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 +1554,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, @@ -1766,6 +1776,7 @@ async function main(): Promise { firstInput.maxTokens, firstPromptOverride, firstInput.ralphMaxIterations, + firstInput.escalationTarget, ); if ( firstMessagesForRequest !== firstInput.messages && @@ -1804,6 +1815,7 @@ async function main(): Promise { firstInput.maxTokens, firstPromptOverride, firstInput.ralphMaxIterations, + firstInput.escalationTarget, ); } } @@ -1929,6 +1941,7 @@ async function main(): Promise { input.maxTokens, promptOverride, input.ralphMaxIterations, + input.escalationTarget, ); if ( messagesForRequestWithSkillCache !== input.messages && @@ -1966,6 +1979,7 @@ async function main(): Promise { input.maxTokens, promptOverride, input.ralphMaxIterations, + input.escalationTarget, ); } diff --git a/container/src/types.ts b/container/src/types.ts index b600fa52b..9edfb8e2b 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,11 @@ export interface MediaContextItem { export type ToolExecutionStakesSignal = CanonicalStakesSignal; export type ToolExecutionStakesScore = CanonicalStakesScore; +export interface EscalationTarget { + channel: string; + recipient: string; +} + export interface ToolExecution { name: string; arguments: string; @@ -278,6 +284,7 @@ export interface ToolExecution { | 'implicit_notice' | 'approval_request' | 'policy_denial'; + escalationTarget?: EscalationTarget; approvalDecision?: | 'auto' | 'implicit' @@ -308,6 +315,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 0f92db48e..db3280f90 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 4f62d09ee..359672583 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 66e8b810d..c0f810166 100644 --- a/src/agents/agent-registry.ts +++ b/src/agents/agent-registry.ts @@ -28,8 +28,10 @@ import { type AgentsConfig, buildOptionalAgentPresentation, cloneAgentCv, + cloneAgentEscalationTarget, DEFAULT_AGENT_ID, normalizeAgentCv, + normalizeAgentEscalationTarget, } from './agent-types.js'; const LEGACY_WORKSPACE_DIRS = [ @@ -160,6 +162,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 +177,7 @@ function normalizeAgent(value: unknown): AgentConfig | null { ...(owner ? { owner } : {}), ...(role ? { role } : {}), ...(cv ? { cv } : {}), + ...(escalationTarget ? { escalationTarget } : {}), }; } @@ -219,6 +225,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 +298,9 @@ function applyDefaults(agent: AgentConfig): AgentConfig { ...(agent.owner ? { owner: agent.owner } : {}), ...(agent.role ? { role: agent.role } : {}), ...(agent.cv ? { cv: agent.cv } : {}), + ...(agent.escalationTarget + ? { escalationTarget: cloneAgentEscalationTarget(agent.escalationTarget) } + : {}), }; } @@ -344,6 +355,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 54a53089c..2bee3650f 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 87f5fd6db..c88506629 100644 --- a/src/agents/agent-types.ts +++ b/src/agents/agent-types.ts @@ -19,6 +19,11 @@ export interface AgentCv { asset?: string; } +export interface AgentEscalationTarget { + channel: string; + recipient: string; +} + export interface AgentConfig { id: string; name?: string; @@ -32,6 +37,7 @@ export interface AgentConfig { owner?: string; role?: string; cv?: AgentCv; + escalationTarget?: AgentEscalationTarget; } export interface AgentDefaultsConfig { @@ -93,6 +99,36 @@ export function cloneAgentCv(value: AgentCv | undefined): AgentCv | undefined { }; } +export function normalizeAgentEscalationTarget( + value: unknown, +): AgentEscalationTarget | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const raw = value as { + channel?: unknown; + recipient?: unknown; + }; + const channel = normalizeTrimmedString(raw.channel); + const recipient = normalizeTrimmedString(raw.recipient); + return channel && recipient ? { channel, recipient } : undefined; +} + +export function cloneAgentEscalationTarget( + value: AgentEscalationTarget | undefined, +): AgentEscalationTarget | undefined { + return value ? { ...value } : undefined; +} + +export function agentEscalationTargetEquals( + a?: AgentEscalationTarget, + b?: AgentEscalationTarget, +): boolean { + if (a === b) return true; + if (!a || !b) return false; + return a.channel === b.channel && a.recipient === b.recipient; +} + export function agentCvEquals(a?: AgentCv, b?: AgentCv): boolean { if (a === b) return true; if (!a || !b) return false; diff --git a/src/audit/audit-events.ts b/src/audit/audit-events.ts index 2869524cf..7fc1a48bf 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 7c6a16322..afa7eb47c 100644 --- a/src/config/runtime-config.ts +++ b/src/config/runtime-config.ts @@ -12,8 +12,10 @@ import { type AgentsConfig, buildOptionalAgentPresentation, cloneAgentCv, + cloneAgentEscalationTarget, DEFAULT_AGENT_ID, normalizeAgentCv, + normalizeAgentEscalationTarget, } from '../agents/agent-types.js'; import type { ChannelKind, @@ -2185,6 +2187,9 @@ function normalizeAgentConfig( const cv = Object.hasOwn(value, 'cv') ? normalizeAgentCv(value.cv) : cloneAgentCv(fallback?.cv); + const escalationTarget = Object.hasOwn(value, 'escalationTarget') + ? normalizeAgentEscalationTarget(value.escalationTarget) + : cloneAgentEscalationTarget(fallback?.escalationTarget); return { id, ...(name ? { name } : {}), @@ -2197,6 +2202,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 1bbe9af2d..31502a534 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 366739a46..cfb99841a 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 774253218..61469b84e 100644 --- a/src/gateway/gateway-chat-service.ts +++ b/src/gateway/gateway-chat-service.ts @@ -126,6 +126,46 @@ import { const MAX_HISTORY_MESSAGES = 40; +function getPendingApprovalEscalationChannel( + approval: PendingApproval | undefined, +): string { + return approval?.escalationTarget?.channel?.trim() || ''; +} + +function formatEscalationRouteNotice(approval: PendingApproval): string { + const target = approval.escalationTarget; + if (!target) return approval.prompt; + return [ + `Escalation for ${target.recipient} on ${target.channel}.`, + approval.prompt, + ].join('\n\n'); +} + +async function routeEscalationApproval(params: { + approval: PendingApproval | undefined; + currentChannelId: string; + onProactiveMessage: GatewayChatRequest['onProactiveMessage']; +}): Promise { + const targetChannel = getPendingApprovalEscalationChannel(params.approval); + if (!params.approval || !targetChannel) return; + if (targetChannel === params.currentChannelId) return; + try { + await params.onProactiveMessage?.({ + channelId: targetChannel, + text: formatEscalationRouteNotice(params.approval), + }); + } catch (error) { + logger.warn( + { + approvalId: params.approval.approvalId, + targetChannel, + error, + }, + 'Failed to route escalation approval notification', + ); + } +} + function readGatewayPromptModeDefault(): PromptMode | undefined { const raw = String(process.env[GATEWAY_SYSTEM_PROMPT_MODE_ENV] || '') .trim() @@ -1012,6 +1052,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 +1060,11 @@ async function handleGatewayMessageInner( media, ); const toolExecutions = output.toolExecutions || []; + await routeEscalationApproval({ + approval: output.pendingApproval, + currentChannelId: req.channelId, + onProactiveMessage: req.onProactiveMessage, + }); const observedSkillName = resolveObservedSkillName({ explicitSkillName, toolExecutions, diff --git a/src/gateway/gateway-types.ts b/src/gateway/gateway-types.ts index 9386c37a3..e7d031203 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 456d87ce2..291924a73 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -127,7 +127,7 @@ import { startScheduler, stopScheduler, } from '../scheduler/scheduler.js'; -import type { ArtifactMetadata } from '../types/execution.js'; +import type { ArtifactMetadata, EscalationTarget } from '../types/execution.js'; import { formatError } from '../utils/text-format.js'; import { buildApprovalConfirmationComponents } from './approval-confirmation.js'; import { @@ -293,6 +293,42 @@ const DISCORD_APPROVAL_PRESENTATION = createApprovalPresentation('buttons'); const SLACK_APPROVAL_PRESENTATION = createApprovalPresentation('buttons'); const TEAMS_APPROVAL_PRESENTATION = createApprovalPresentation('text'); +function normalizeEscalationTarget( + target: EscalationTarget | undefined, +): EscalationTarget | null { + const channel = target?.channel?.trim() || ''; + const recipient = target?.recipient?.trim() || ''; + return channel && recipient ? { channel, recipient } : null; +} + +function getApprovalRecipientUserId( + approval: { escalationTarget?: EscalationTarget }, + fallbackUserId: string, +): string { + return ( + normalizeEscalationTarget(approval.escalationTarget)?.recipient || + fallbackUserId + ); +} + +function approvalRoutesAwayFromChannel( + approval: { escalationTarget?: EscalationTarget }, + channelId: string, +): EscalationTarget | null { + const target = normalizeEscalationTarget(approval.escalationTarget); + if (!target || target.channel === channelId) return null; + return target; +} + +function formatRoutedApprovalNotice(approval: { + approvalId: string; + escalationTarget?: EscalationTarget; +}): string { + const target = normalizeEscalationTarget(approval.escalationTarget); + if (!target) return `Escalation pending. Approval ID: ${approval.approvalId}`; + return `Escalation routed to ${target.recipient} on ${target.channel}. Approval ID: ${approval.approvalId}`; +} + function scheduleNextMemoryConsolidationRun(): void { if (!isMemoryConsolidationEnabled()) { logger.info('Memory consolidation scheduler disabled'); @@ -1096,7 +1132,7 @@ async function startDiscordIntegration(): Promise { }, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -1148,28 +1184,43 @@ async function startDiscordIntegration(): Promise { result.memoryCitations, ); if (pendingApproval) { + const approvalUserId = getApprovalRecipientUserId( + pendingApproval, + userId, + ); + const routedTarget = approvalRoutesAwayFromChannel( + pendingApproval, + channelId, + ); const storedPrompt = getApprovalPromptText( pendingApproval, responseText, ); - const approvalPresentation = context.sendApprovalNotification - ? DISCORD_APPROVAL_PRESENTATION - : createApprovalPresentation('text'); + const approvalPresentation = + context.sendApprovalNotification && !routedTarget + ? DISCORD_APPROVAL_PRESENTATION + : createApprovalPresentation('text'); let cleanup: { disableButtons: () => Promise } | null = null; - if (context.sendApprovalNotification) { + if (context.sendApprovalNotification && !routedTarget) { cleanup = await context.sendApprovalNotification({ approval: pendingApproval, presentation: approvalPresentation, - userId, + userId: approvalUserId, }); + } else if (routedTarget) { + await context.stream.finalize( + formatRoutedApprovalNotice(pendingApproval), + ); } else { - await context.stream.finalize(`<@${userId}> ${storedPrompt}`); + await context.stream.finalize( + `<@${approvalUserId}> ${storedPrompt}`, + ); } await rememberPendingApproval({ sessionId: effectiveSessionId, approvalId: pendingApproval.approvalId, prompt: storedPrompt, - userId, + userId: approvalUserId, expiresAt: pendingApproval.expiresAt, presentation: approvalPresentation, disableButtons: cleanup?.disableButtons ?? null, @@ -1391,6 +1442,14 @@ async function startMSTeamsIntegration(): Promise { : ''; const pendingApproval = extractGatewayChatApprovalEvent(result); if (pendingApproval) { + const approvalUserId = getApprovalRecipientUserId( + pendingApproval, + userId, + ); + const routedTarget = approvalRoutesAwayFromChannel( + pendingApproval, + channelId, + ); const storedPrompt = getApprovalPromptText( pendingApproval, responseText, @@ -1404,10 +1463,16 @@ async function startMSTeamsIntegration(): Promise { sessionId: effectiveSessionId, approvalId: pendingApproval.approvalId, prompt: storedPrompt, - userId, + userId: approvalUserId, expiresAt: pendingApproval.expiresAt, presentation: TEAMS_APPROVAL_PRESENTATION, }); + if (routedTarget) { + await context.stream.finalize( + formatRoutedApprovalNotice(pendingApproval), + ); + return; + } 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]\`.`, ); @@ -1544,7 +1609,7 @@ async function startWhatsAppIntegration(): Promise { media, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -1695,7 +1760,7 @@ async function startEmailIntegration(): Promise { media, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -1864,7 +1929,7 @@ async function startTelegramIntegration(): Promise { media, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -2023,7 +2088,7 @@ async function startSignalIntegration(): Promise { content, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -2137,7 +2202,7 @@ async function startSlackIntegration(): Promise { reply: textReply, onProactiveMessage: async (message) => { await deliverProactiveMessage( - channelId, + message.channelId || channelId, message.text, 'delegate', message.artifacts, @@ -2192,20 +2257,31 @@ async function startSlackIntegration(): Promise { ) : ''; if (pendingApproval) { + const approvalUserId = getApprovalRecipientUserId( + pendingApproval, + userId, + ); + const routedTarget = approvalRoutesAwayFromChannel( + pendingApproval, + channelId, + ); const storedPrompt = getApprovalPromptText( pendingApproval, responseText, ); - const approvalPresentation = context.sendApprovalNotification - ? SLACK_APPROVAL_PRESENTATION - : createApprovalPresentation('text'); + const approvalPresentation = + context.sendApprovalNotification && !routedTarget + ? SLACK_APPROVAL_PRESENTATION + : createApprovalPresentation('text'); let cleanup: { disableButtons: () => Promise } | null = null; - if (context.sendApprovalNotification) { + if (context.sendApprovalNotification && !routedTarget) { cleanup = await context.sendApprovalNotification({ approval: pendingApproval, presentation: approvalPresentation, - userId, + userId: approvalUserId, }); + } else if (routedTarget) { + await reply(formatRoutedApprovalNotice(pendingApproval)); } else { await reply(storedPrompt); } @@ -2213,7 +2289,7 @@ async function startSlackIntegration(): Promise { sessionId: effectiveSessionId, approvalId: pendingApproval.approvalId, prompt: storedPrompt, - userId, + userId: approvalUserId, expiresAt: pendingApproval.expiresAt, presentation: approvalPresentation, disableButtons: cleanup?.disableButtons ?? null, @@ -2645,7 +2721,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 5e5b5503f..2e1c9a430 100644 --- a/src/gateway/text-channel-commands.ts +++ b/src/gateway/text-channel-commands.ts @@ -361,7 +361,7 @@ export async function handleTextChannelApprovalCommand(params: { sessionId: approvalSessionId, approvalId: pendingApproval.approvalId, prompt: getApprovalPromptText(pendingApproval, resultText), - userId, + userId: pendingApproval.escalationTarget?.recipient || userId, expiresAt: pendingApproval.expiresAt, }); return { diff --git a/src/infra/container-runner.ts b/src/infra/container-runner.ts index 6aad2b12a..648dca917 100644 --- a/src/infra/container-runner.ts +++ b/src/infra/container-runner.ts @@ -329,6 +329,9 @@ function parseApprovalProgress(line: string): PendingApproval | null { Number.isFinite(parsed.expiresAt) ? parsed.expiresAt : null, + ...(parsed.escalationTarget + ? { escalationTarget: parsed.escalationTarget } + : {}), }; } catch { return null; @@ -820,6 +823,7 @@ async function runContainerInner( media, audioTranscriptsPrepended, pluginTools, + escalationTarget, maxWallClockMs, inactivityTimeoutMs, } = params; @@ -926,6 +930,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 ef3a88cc8..129a27497 100644 --- a/src/infra/host-runner.ts +++ b/src/infra/host-runner.ts @@ -367,6 +367,9 @@ function parseApprovalProgress(line: string): PendingApproval | null { Number.isFinite(parsed.expiresAt) ? parsed.expiresAt : null, + ...(parsed.escalationTarget + ? { escalationTarget: parsed.escalationTarget } + : {}), }; } catch { return null; @@ -697,6 +700,7 @@ async function runHostProcessInner( media, audioTranscriptsPrepended, pluginTools, + escalationTarget, maxWallClockMs, inactivityTimeoutMs, } = params; @@ -809,6 +813,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 f67c660f7..adf3036bd 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 3144d6722..59926e2a5 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 3ae498bf7..60a83fee3 100644 --- a/src/types/execution.ts +++ b/src/types/execution.ts @@ -29,6 +29,11 @@ export interface PluginRuntimeToolDefinition { export type ToolExecutionStakesSignal = CanonicalStakesSignal; export type ToolExecutionStakesScore = CanonicalStakesScore; +export interface EscalationTarget { + channel: string; + recipient: string; +} + export interface ToolExecution { name: string; arguments: string; @@ -47,6 +52,7 @@ export interface ToolExecution { | 'implicit_notice' | 'approval_request' | 'policy_denial'; + escalationTarget?: EscalationTarget; approvalDecision?: | 'auto' | 'implicit' @@ -77,6 +83,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 43b3e61cc..39614e9b9 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 b79395c7d..8e1e56b2b 100644 --- a/tests/approval-policy.test.ts +++ b/tests/approval-policy.test.ts @@ -223,6 +223,60 @@ 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.decision).toBe('required'); + expect(evaluation.escalationRoute).toBe('approval_request'); + }); + + 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 3c4978c8d..9c3e1c28d 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 21a49eea2..877e060cd 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 17f1b8794..40eb9a365 100644 --- a/tests/hybridai-skills-command.test.ts +++ b/tests/hybridai-skills-command.test.ts @@ -1,7 +1,17 @@ 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 tempRoot = (process.env.TMPDIR || '/tmp').replace(/\/+$/, ''); + const homeDir = `${tempRoot}/hybridclaw-hybridai-skills-module-${Date.now()}-${Math.random().toString(36).slice(2)}`; + 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 +26,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'); From 962ec04bcaa6cdff9f02809e534800e030efc405 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Tue, 28 Apr 2026 20:32:49 +0200 Subject: [PATCH 2/7] fix: address escalation routing review feedback --- src/gateway/gateway-chat-service.ts | 12 +++++++++++- src/infra/container-runner.ts | 24 ++++++++++++++++++++---- src/infra/host-runner.ts | 24 ++++++++++++++++++++---- tests/hybridai-skills-command.test.ts | 22 ++++++++++++++++++++-- 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/src/gateway/gateway-chat-service.ts b/src/gateway/gateway-chat-service.ts index 61469b84e..b2c4a8bce 100644 --- a/src/gateway/gateway-chat-service.ts +++ b/src/gateway/gateway-chat-service.ts @@ -149,8 +149,18 @@ async function routeEscalationApproval(params: { const targetChannel = getPendingApprovalEscalationChannel(params.approval); if (!params.approval || !targetChannel) return; if (targetChannel === params.currentChannelId) return; + if (!params.onProactiveMessage) { + logger.warn( + { + approvalId: params.approval.approvalId, + targetChannel, + }, + 'Unable to route escalation approval notification because onProactiveMessage is unavailable', + ); + return; + } try { - await params.onProactiveMessage?.({ + await params.onProactiveMessage({ channelId: targetChannel, text: formatEscalationRouteNotice(params.approval), }); diff --git a/src/infra/container-runner.ts b/src/infra/container-runner.ts index 648dca917..be9df2772 100644 --- a/src/infra/container-runner.ts +++ b/src/infra/container-runner.ts @@ -305,7 +305,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 +318,9 @@ function parseApprovalProgress(line: string): PendingApproval | null { ) { return null; } + const escalationTarget = normalizeParsedEscalationTarget( + parsed.escalationTarget, + ); return { approvalId: parsed.approvalId, prompt: redactCredentialSecrets(parsed.prompt), @@ -329,15 +334,26 @@ function parseApprovalProgress(line: string): PendingApproval | null { Number.isFinite(parsed.expiresAt) ? parsed.expiresAt : null, - ...(parsed.escalationTarget - ? { escalationTarget: parsed.escalationTarget } - : {}), + ...(escalationTarget ? { escalationTarget } : {}), }; } catch { return null; } } +function normalizeParsedEscalationTarget( + value: unknown, +): PendingApproval['escalationTarget'] { + 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; +} + function emitApprovalProgress(entry: PoolEntry, line: string): boolean { const approval = parseApprovalProgress(line); if (!approval) return false; diff --git a/src/infra/host-runner.ts b/src/infra/host-runner.ts index 129a27497..2e5b73ecd 100644 --- a/src/infra/host-runner.ts +++ b/src/infra/host-runner.ts @@ -343,7 +343,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 +356,9 @@ function parseApprovalProgress(line: string): PendingApproval | null { ) { return null; } + const escalationTarget = normalizeParsedEscalationTarget( + parsed.escalationTarget, + ); return { approvalId: parsed.approvalId, prompt: redactCredentialSecrets(parsed.prompt), @@ -367,15 +372,26 @@ function parseApprovalProgress(line: string): PendingApproval | null { Number.isFinite(parsed.expiresAt) ? parsed.expiresAt : null, - ...(parsed.escalationTarget - ? { escalationTarget: parsed.escalationTarget } - : {}), + ...(escalationTarget ? { escalationTarget } : {}), }; } catch { return null; } } +function normalizeParsedEscalationTarget( + value: unknown, +): PendingApproval['escalationTarget'] { + 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; +} + function emitApprovalProgress(entry: PoolEntry, line: string): boolean { const approval = parseApprovalProgress(line); if (!approval) return false; diff --git a/tests/hybridai-skills-command.test.ts b/tests/hybridai-skills-command.test.ts index 40eb9a365..bdb24d875 100644 --- a/tests/hybridai-skills-command.test.ts +++ b/tests/hybridai-skills-command.test.ts @@ -6,8 +6,26 @@ 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 tempRoot = (process.env.TMPDIR || '/tmp').replace(/\/+$/, ''); - const homeDir = `${tempRoot}/hybridclaw-hybridai-skills-module-${Date.now()}-${Math.random().toString(36).slice(2)}`; + 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 }; From 6da54b1e985c8a6738fad8bedf6278aa350a33d6 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Tue, 28 Apr 2026 23:26:09 +0200 Subject: [PATCH 3/7] refactor: dedupe escalation routing helpers --- container/src/approval-policy.ts | 14 +- container/src/types.ts | 13 ++ src/agents/agent-types.ts | 45 ++---- src/gateway/gateway-chat-service.ts | 20 +-- src/gateway/gateway.ts | 218 +++++++++++++-------------- src/gateway/text-channel-commands.ts | 10 +- src/infra/container-runner.ts | 26 +--- src/infra/host-runner.ts | 23 +-- src/types/execution.ts | 28 ++++ 9 files changed, 194 insertions(+), 203 deletions(-) diff --git a/container/src/approval-policy.ts b/container/src/approval-policy.ts index 3c1ca0ffa..47e0e5556 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, EscalationTarget } from './types.js'; +import { + type ChatMessage, + type EscalationTarget, + normalizeEscalationTarget, +} from './types.js'; export type { NetworkPolicyAction, @@ -278,14 +282,6 @@ function escalationRouteForDecision( return 'none'; } -function normalizeEscalationTarget( - value: EscalationTarget | undefined, -): EscalationTarget | undefined { - const channel = normalizeText(value?.channel); - const recipient = normalizeText(value?.recipient); - return channel && recipient ? { channel, recipient } : undefined; -} - function formatStakesReasoning(score: StakesScore): string { const reasons = score.reasons.length > 0 diff --git a/container/src/types.ts b/container/src/types.ts index 9edfb8e2b..1424ae591 100644 --- a/container/src/types.ts +++ b/container/src/types.ts @@ -266,6 +266,19 @@ export interface EscalationTarget { 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; diff --git a/src/agents/agent-types.ts b/src/agents/agent-types.ts index c88506629..535aa94b8 100644 --- a/src/agents/agent-types.ts +++ b/src/agents/agent-types.ts @@ -1,8 +1,16 @@ +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 { + cloneEscalationTarget as cloneAgentEscalationTarget, + escalationTargetEquals as agentEscalationTargetEquals, + normalizeEscalationTarget as normalizeAgentEscalationTarget, +} from '../types/execution.js'; + export const DEFAULT_AGENT_ID = 'main'; export type AgentModelConfig = @@ -19,11 +27,6 @@ export interface AgentCv { asset?: string; } -export interface AgentEscalationTarget { - channel: string; - recipient: string; -} - export interface AgentConfig { id: string; name?: string; @@ -37,7 +40,7 @@ export interface AgentConfig { owner?: string; role?: string; cv?: AgentCv; - escalationTarget?: AgentEscalationTarget; + escalationTarget?: EscalationTarget; } export interface AgentDefaultsConfig { @@ -99,36 +102,6 @@ export function cloneAgentCv(value: AgentCv | undefined): AgentCv | undefined { }; } -export function normalizeAgentEscalationTarget( - value: unknown, -): AgentEscalationTarget | undefined { - if (!value || typeof value !== 'object' || Array.isArray(value)) { - return undefined; - } - const raw = value as { - channel?: unknown; - recipient?: unknown; - }; - const channel = normalizeTrimmedString(raw.channel); - const recipient = normalizeTrimmedString(raw.recipient); - return channel && recipient ? { channel, recipient } : undefined; -} - -export function cloneAgentEscalationTarget( - value: AgentEscalationTarget | undefined, -): AgentEscalationTarget | undefined { - return value ? { ...value } : undefined; -} - -export function agentEscalationTargetEquals( - a?: AgentEscalationTarget, - b?: AgentEscalationTarget, -): boolean { - if (a === b) return true; - if (!a || !b) return false; - return a.channel === b.channel && a.recipient === b.recipient; -} - export function agentCvEquals(a?: AgentCv, b?: AgentCv): boolean { if (a === b) return true; if (!a || !b) return false; diff --git a/src/gateway/gateway-chat-service.ts b/src/gateway/gateway-chat-service.ts index b2c4a8bce..98395d53b 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'; @@ -126,14 +130,8 @@ import { const MAX_HISTORY_MESSAGES = 40; -function getPendingApprovalEscalationChannel( - approval: PendingApproval | undefined, -): string { - return approval?.escalationTarget?.channel?.trim() || ''; -} - function formatEscalationRouteNotice(approval: PendingApproval): string { - const target = approval.escalationTarget; + const target = normalizeEscalationTarget(approval.escalationTarget); if (!target) return approval.prompt; return [ `Escalation for ${target.recipient} on ${target.channel}.`, @@ -146,8 +144,10 @@ async function routeEscalationApproval(params: { currentChannelId: string; onProactiveMessage: GatewayChatRequest['onProactiveMessage']; }): Promise { - const targetChannel = getPendingApprovalEscalationChannel(params.approval); - if (!params.approval || !targetChannel) return; + if (!params.approval) return; + const target = normalizeEscalationTarget(params.approval.escalationTarget); + if (!target) return; + const targetChannel = target.channel; if (targetChannel === params.currentChannelId) return; if (!params.onProactiveMessage) { logger.warn( diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index 291924a73..9170d36c0 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, EscalationTarget } 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,14 +302,6 @@ const DISCORD_APPROVAL_PRESENTATION = createApprovalPresentation('buttons'); const SLACK_APPROVAL_PRESENTATION = createApprovalPresentation('buttons'); const TEAMS_APPROVAL_PRESENTATION = createApprovalPresentation('text'); -function normalizeEscalationTarget( - target: EscalationTarget | undefined, -): EscalationTarget | null { - const channel = target?.channel?.trim() || ''; - const recipient = target?.recipient?.trim() || ''; - return channel && recipient ? { channel, recipient } : null; -} - function getApprovalRecipientUserId( approval: { escalationTarget?: EscalationTarget }, fallbackUserId: string, @@ -329,6 +330,78 @@ function formatRoutedApprovalNotice(approval: { 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 approvalUserId = getApprovalRecipientUserId( + params.pendingApproval, + params.userId, + ); + const routedTarget = approvalRoutesAwayFromChannel( + params.pendingApproval, + params.channelId, + ); + 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)); + } 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'); @@ -1184,46 +1257,17 @@ async function startDiscordIntegration(): Promise { result.memoryCitations, ); if (pendingApproval) { - const approvalUserId = getApprovalRecipientUserId( + const { cleanup } = await handlePendingApprovalRouting({ pendingApproval, + responseText, + sessionId: effectiveSessionId, userId, - ); - const routedTarget = approvalRoutesAwayFromChannel( - pendingApproval, channelId, - ); - const storedPrompt = getApprovalPromptText( - pendingApproval, - responseText, - ); - const approvalPresentation = - context.sendApprovalNotification && !routedTarget - ? DISCORD_APPROVAL_PRESENTATION - : createApprovalPresentation('text'); - let cleanup: { disableButtons: () => Promise } | null = null; - if (context.sendApprovalNotification && !routedTarget) { - cleanup = await context.sendApprovalNotification({ - approval: pendingApproval, - presentation: approvalPresentation, - userId: approvalUserId, - }); - } else if (routedTarget) { - await context.stream.finalize( - formatRoutedApprovalNotice(pendingApproval), - ); - } else { - await context.stream.finalize( + buttonPresentation: DISCORD_APPROVAL_PRESENTATION, + sendApprovalNotification: context.sendApprovalNotification, + sendText: (text) => context.stream.finalize(text), + formatTextPrompt: ({ approvalUserId, storedPrompt }) => `<@${approvalUserId}> ${storedPrompt}`, - ); - } - await rememberPendingApproval({ - sessionId: effectiveSessionId, - approvalId: pendingApproval.approvalId, - prompt: storedPrompt, - userId: approvalUserId, - expiresAt: pendingApproval.expiresAt, - presentation: approvalPresentation, - disableButtons: cleanup?.disableButtons ?? null, }); if (cleanup) { await context.stream.discard(); @@ -1442,40 +1486,23 @@ async function startMSTeamsIntegration(): Promise { : ''; const pendingApproval = extractGatewayChatApprovalEvent(result); if (pendingApproval) { - const approvalUserId = getApprovalRecipientUserId( - pendingApproval, - userId, - ); - const routedTarget = approvalRoutesAwayFromChannel( - pendingApproval, - channelId, - ); - 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: approvalUserId, - expiresAt: pendingApproval.expiresAt, - presentation: TEAMS_APPROVAL_PRESENTATION, + userId, + 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]\`.`; + }, }); - if (routedTarget) { - await context.stream.finalize( - formatRoutedApprovalNotice(pendingApproval), - ); - return; - } - 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; } @@ -2257,42 +2284,15 @@ async function startSlackIntegration(): Promise { ) : ''; if (pendingApproval) { - const approvalUserId = getApprovalRecipientUserId( - pendingApproval, - userId, - ); - const routedTarget = approvalRoutesAwayFromChannel( - pendingApproval, - channelId, - ); - const storedPrompt = getApprovalPromptText( + await handlePendingApprovalRouting({ pendingApproval, responseText, - ); - const approvalPresentation = - context.sendApprovalNotification && !routedTarget - ? SLACK_APPROVAL_PRESENTATION - : createApprovalPresentation('text'); - let cleanup: { disableButtons: () => Promise } | null = null; - if (context.sendApprovalNotification && !routedTarget) { - cleanup = await context.sendApprovalNotification({ - approval: pendingApproval, - presentation: approvalPresentation, - userId: approvalUserId, - }); - } else if (routedTarget) { - await reply(formatRoutedApprovalNotice(pendingApproval)); - } else { - await reply(storedPrompt); - } - await rememberPendingApproval({ sessionId: effectiveSessionId, - approvalId: pendingApproval.approvalId, - prompt: storedPrompt, - userId: approvalUserId, - expiresAt: pendingApproval.expiresAt, - presentation: approvalPresentation, - disableButtons: cleanup?.disableButtons ?? null, + userId, + channelId, + buttonPresentation: SLACK_APPROVAL_PRESENTATION, + sendApprovalNotification: context.sendApprovalNotification, + sendText: reply, }); return; } diff --git a/src/gateway/text-channel-commands.ts b/src/gateway/text-channel-commands.ts index 2e1c9a430..20897f089 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: pendingApproval.escalationTarget?.recipient || userId, + userId: escalationTarget?.recipient || userId, expiresAt: pendingApproval.expiresAt, }); return { diff --git a/src/infra/container-runner.ts b/src/infra/container-runner.ts index be9df2772..fd7eab1f3 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'; @@ -318,9 +319,7 @@ function parseApprovalProgress(line: string): PendingApproval | null { ) { return null; } - const escalationTarget = normalizeParsedEscalationTarget( - parsed.escalationTarget, - ); + const escalationTarget = normalizeEscalationTarget(parsed.escalationTarget); return { approvalId: parsed.approvalId, prompt: redactCredentialSecrets(parsed.prompt), @@ -341,19 +340,6 @@ function parseApprovalProgress(line: string): PendingApproval | null { } } -function normalizeParsedEscalationTarget( - value: unknown, -): PendingApproval['escalationTarget'] { - 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; -} - function emitApprovalProgress(entry: PoolEntry, line: string): boolean { const approval = parseApprovalProgress(line); if (!approval) return false; diff --git a/src/infra/host-runner.ts b/src/infra/host-runner.ts index 2e5b73ecd..88d43144c 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, @@ -356,9 +360,7 @@ function parseApprovalProgress(line: string): PendingApproval | null { ) { return null; } - const escalationTarget = normalizeParsedEscalationTarget( - parsed.escalationTarget, - ); + const escalationTarget = normalizeEscalationTarget(parsed.escalationTarget); return { approvalId: parsed.approvalId, prompt: redactCredentialSecrets(parsed.prompt), @@ -379,19 +381,6 @@ function parseApprovalProgress(line: string): PendingApproval | null { } } -function normalizeParsedEscalationTarget( - value: unknown, -): PendingApproval['escalationTarget'] { - 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; -} - function emitApprovalProgress(entry: PoolEntry, line: string): boolean { const approval = parseApprovalProgress(line); if (!approval) return false; diff --git a/src/types/execution.ts b/src/types/execution.ts index 60a83fee3..51d0bf509 100644 --- a/src/types/execution.ts +++ b/src/types/execution.ts @@ -34,6 +34,34 @@ export interface EscalationTarget { 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 cloneEscalationTarget( + value: EscalationTarget | undefined, +): EscalationTarget | undefined { + return value ? { ...value } : 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; From 7b9f720132bf8148996f8dcee8a2209df50e6422 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Tue, 28 Apr 2026 23:30:39 +0200 Subject: [PATCH 4/7] refactor: simplify escalation routing flow --- container/src/index.ts | 286 +++++++++++++++------------- src/agents/agent-registry.ts | 3 +- src/agents/agent-types.ts | 1 - src/config/runtime-config.ts | 5 +- src/gateway/gateway-chat-service.ts | 9 +- src/gateway/gateway.ts | 45 ++--- src/types/execution.ts | 6 - 7 files changed, 175 insertions(+), 180 deletions(-) diff --git a/container/src/index.ts b/container/src/index.ts index 317612087..a29a3d8e1 100644 --- a/container/src/index.ts +++ b/container/src/index.ts @@ -906,43 +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, - escalationTarget?: EscalationTarget, + 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', @@ -1752,32 +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, - firstInput.escalationTarget, - ); + 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' && @@ -1791,32 +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, - firstInput.escalationTarget, - ); + 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, + }); } } @@ -1917,32 +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, - input.escalationTarget, - ); + 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' && @@ -1955,32 +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, - input.escalationTarget, - ); + 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/src/agents/agent-registry.ts b/src/agents/agent-registry.ts index c0f810166..5f4594c77 100644 --- a/src/agents/agent-registry.ts +++ b/src/agents/agent-registry.ts @@ -28,7 +28,6 @@ import { type AgentsConfig, buildOptionalAgentPresentation, cloneAgentCv, - cloneAgentEscalationTarget, DEFAULT_AGENT_ID, normalizeAgentCv, normalizeAgentEscalationTarget, @@ -299,7 +298,7 @@ function applyDefaults(agent: AgentConfig): AgentConfig { ...(agent.role ? { role: agent.role } : {}), ...(agent.cv ? { cv: agent.cv } : {}), ...(agent.escalationTarget - ? { escalationTarget: cloneAgentEscalationTarget(agent.escalationTarget) } + ? { escalationTarget: { ...agent.escalationTarget } } : {}), }; } diff --git a/src/agents/agent-types.ts b/src/agents/agent-types.ts index 535aa94b8..7a5b35863 100644 --- a/src/agents/agent-types.ts +++ b/src/agents/agent-types.ts @@ -6,7 +6,6 @@ import { export type { EscalationTarget as AgentEscalationTarget } from '../types/execution.js'; export { - cloneEscalationTarget as cloneAgentEscalationTarget, escalationTargetEquals as agentEscalationTargetEquals, normalizeEscalationTarget as normalizeAgentEscalationTarget, } from '../types/execution.js'; diff --git a/src/config/runtime-config.ts b/src/config/runtime-config.ts index 8fc240dd7..91eddcd25 100644 --- a/src/config/runtime-config.ts +++ b/src/config/runtime-config.ts @@ -12,7 +12,6 @@ import { type AgentsConfig, buildOptionalAgentPresentation, cloneAgentCv, - cloneAgentEscalationTarget, DEFAULT_AGENT_ID, normalizeAgentCv, normalizeAgentEscalationTarget, @@ -2194,7 +2193,9 @@ function normalizeAgentConfig( : cloneAgentCv(fallback?.cv); const escalationTarget = Object.hasOwn(value, 'escalationTarget') ? normalizeAgentEscalationTarget(value.escalationTarget) - : cloneAgentEscalationTarget(fallback?.escalationTarget); + : fallback?.escalationTarget + ? { ...fallback.escalationTarget } + : undefined; return { id, ...(name ? { name } : {}), diff --git a/src/gateway/gateway-chat-service.ts b/src/gateway/gateway-chat-service.ts index 98395d53b..339702736 100644 --- a/src/gateway/gateway-chat-service.ts +++ b/src/gateway/gateway-chat-service.ts @@ -130,9 +130,10 @@ import { const MAX_HISTORY_MESSAGES = 40; -function formatEscalationRouteNotice(approval: PendingApproval): string { - const target = normalizeEscalationTarget(approval.escalationTarget); - if (!target) return approval.prompt; +function formatEscalationRouteNotice( + approval: PendingApproval, + target: NonNullable, +): string { return [ `Escalation for ${target.recipient} on ${target.channel}.`, approval.prompt, @@ -162,7 +163,7 @@ async function routeEscalationApproval(params: { try { await params.onProactiveMessage({ channelId: targetChannel, - text: formatEscalationRouteNotice(params.approval), + text: formatEscalationRouteNotice(params.approval, target), }); } catch (error) { logger.warn( diff --git a/src/gateway/gateway.ts b/src/gateway/gateway.ts index 9170d36c0..e273116ee 100644 --- a/src/gateway/gateway.ts +++ b/src/gateway/gateway.ts @@ -302,31 +302,10 @@ const DISCORD_APPROVAL_PRESENTATION = createApprovalPresentation('buttons'); const SLACK_APPROVAL_PRESENTATION = createApprovalPresentation('buttons'); const TEAMS_APPROVAL_PRESENTATION = createApprovalPresentation('text'); -function getApprovalRecipientUserId( - approval: { escalationTarget?: EscalationTarget }, - fallbackUserId: string, +function formatRoutedApprovalNotice( + approval: { approvalId: string }, + target: EscalationTarget, ): string { - return ( - normalizeEscalationTarget(approval.escalationTarget)?.recipient || - fallbackUserId - ); -} - -function approvalRoutesAwayFromChannel( - approval: { escalationTarget?: EscalationTarget }, - channelId: string, -): EscalationTarget | null { - const target = normalizeEscalationTarget(approval.escalationTarget); - if (!target || target.channel === channelId) return null; - return target; -} - -function formatRoutedApprovalNotice(approval: { - approvalId: string; - escalationTarget?: EscalationTarget; -}): string { - const target = normalizeEscalationTarget(approval.escalationTarget); - if (!target) return `Escalation pending. Approval ID: ${approval.approvalId}`; return `Escalation routed to ${target.recipient} on ${target.channel}. Approval ID: ${approval.approvalId}`; } @@ -352,14 +331,14 @@ async function handlePendingApprovalRouting(params: { storedPrompt: string; }) => string; }): Promise<{ cleanup: { disableButtons: () => Promise } | null }> { - const approvalUserId = getApprovalRecipientUserId( - params.pendingApproval, - params.userId, - ); - const routedTarget = approvalRoutesAwayFromChannel( - params.pendingApproval, - params.channelId, + 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, @@ -377,7 +356,9 @@ async function handlePendingApprovalRouting(params: { userId: approvalUserId, }); } else if (routedTarget) { - await params.sendText(formatRoutedApprovalNotice(params.pendingApproval)); + await params.sendText( + formatRoutedApprovalNotice(params.pendingApproval, routedTarget), + ); } else { await params.sendText( params.formatTextPrompt?.({ diff --git a/src/types/execution.ts b/src/types/execution.ts index 51d0bf509..602dd34f9 100644 --- a/src/types/execution.ts +++ b/src/types/execution.ts @@ -47,12 +47,6 @@ export function normalizeEscalationTarget( return channel && recipient ? { channel, recipient } : undefined; } -export function cloneEscalationTarget( - value: EscalationTarget | undefined, -): EscalationTarget | undefined { - return value ? { ...value } : undefined; -} - export function escalationTargetEquals( a?: EscalationTarget, b?: EscalationTarget, From 8cf335e205445948d4183b88ab96637144aad07d Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Tue, 28 Apr 2026 23:32:11 +0200 Subject: [PATCH 5/7] test: document medium-stakes escalation policy --- container/src/approval-policy.ts | 3 +++ tests/approval-policy.test.ts | 2 ++ 2 files changed, 5 insertions(+) diff --git a/container/src/approval-policy.ts b/container/src/approval-policy.ts index 47e0e5556..6cdf8a5f9 100644 --- a/container/src/approval-policy.ts +++ b/container/src/approval-policy.ts @@ -1341,6 +1341,9 @@ export class TrustedAgentApprovalRuntime { outOfBoundByAutonomy = true; baseTier = 'red'; } else if (autonomyLevel === 'low-stakes-autonomous') { + // 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'; diff --git a/tests/approval-policy.test.ts b/tests/approval-policy.test.ts index 8e1e56b2b..b0a380cac 100644 --- a/tests/approval-policy.test.ts +++ b/tests/approval-policy.test.ts @@ -241,8 +241,10 @@ autonomy: 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', () => { From a71c38addc5adc2e519e81dbd2df631902080743 Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Tue, 28 Apr 2026 23:34:53 +0200 Subject: [PATCH 6/7] fix: trace escalation approval routing --- src/gateway/gateway-chat-service.ts | 71 +++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/gateway/gateway-chat-service.ts b/src/gateway/gateway-chat-service.ts index 339702736..a4368e7a5 100644 --- a/src/gateway/gateway-chat-service.ts +++ b/src/gateway/gateway-chat-service.ts @@ -123,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, @@ -142,7 +143,10 @@ function formatEscalationRouteNotice( async function routeEscalationApproval(params: { approval: PendingApproval | undefined; + agentId: string; currentChannelId: string; + sessionId: string; + runId: string; onProactiveMessage: GatewayChatRequest['onProactiveMessage']; }): Promise { if (!params.approval) return; @@ -150,14 +154,52 @@ async function routeEscalationApproval(params: { 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 { @@ -165,15 +207,41 @@ async function routeEscalationApproval(params: { 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), + }, + }); } } @@ -1073,7 +1141,10 @@ async function handleGatewayMessageInner( 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({ From abcd0b5419a8f74cdba14a613c2aeab13fa24b7f Mon Sep 17 00:00:00 2001 From: Benedikt Koehler Date: Tue, 28 Apr 2026 23:36:06 +0200 Subject: [PATCH 7/7] refactor: simplify escalation route notice formatting --- src/gateway/gateway-chat-service.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/gateway/gateway-chat-service.ts b/src/gateway/gateway-chat-service.ts index a4368e7a5..000a958ee 100644 --- a/src/gateway/gateway-chat-service.ts +++ b/src/gateway/gateway-chat-service.ts @@ -135,10 +135,7 @@ function formatEscalationRouteNotice( approval: PendingApproval, target: NonNullable, ): string { - return [ - `Escalation for ${target.recipient} on ${target.channel}.`, - approval.prompt, - ].join('\n\n'); + return `Escalation for ${target.recipient} on ${target.channel}.\n\n${approval.prompt}`; } async function routeEscalationApproval(params: {