Files
moeka-project/services/computer-use-mcp/src/strategy.ts
T

684 lines
24 KiB
TypeScript

/**
* Strategy layer — decision engine that sits between raw tool dispatch
* and the action executor.
*
* Responsibilities:
* 1. Prefer programmatic tools over vision when feasible.
* 2. Auto-focus the correct app before UI interactions.
* 3. Read terminal errors before deciding to retry / rollback.
* 4. Handle approval rejection by suggesting an alternative plan.
* 5. Detect and recover from common failure modes.
*/
import type { RunState } from './state'
import type {
ActionInvocation,
ForegroundContext,
} from './types'
import { appNamesMatch, findKnownAppMention } from './app-aliases'
// ---------------------------------------------------------------------------
// Advisory types
// ---------------------------------------------------------------------------
export type AdvisoryKind
= | 'focus_app_first'
| 'take_screenshot_first'
| 'use_terminal_instead'
| 'retry_after_error'
| 'read_error_first'
| 'abort_task'
| 'approval_rejected_replan'
| 'wait_and_retry'
| 'proceed'
// Surface-routing advisories
| 'use_accessibility_grounding'
| 'use_browser_surface'
| 'use_pty_surface'
| 'enumerate_displays_first'
/** Broad category for classifying advisories. */
export type AdvisoryCategory = 'prep' | 'reroute' | 'recovery' | 'informational'
/** Which surface/tool family the advisory points to. */
export type RecommendedSurface = 'display' | 'accessibility' | 'browser_dom' | 'browser_cdp' | 'pty' | 'terminal' | 'desktop' | 'none'
export interface StrategyAdvisory {
/** What the strategy layer recommends. */
kind: AdvisoryKind
/** Broad classification of this advisory. */
category: AdvisoryCategory
/** Which surface the advisory recommends (if any). */
recommendedSurface: RecommendedSurface
/** Human-readable explanation of why this advisory was emitted. */
reason: string
/**
* If the advisory recommends a preparatory action, this is the
* suggested action to execute first.
*/
suggestedAction?: ActionInvocation
/**
* When the advisory suggests calling a specific MCP tool directly
* (e.g. an accessibility or CDP tool outside the ActionInvocation union).
*/
suggestedToolName?: string
/**
* If the advisory recommends aborting, this is the accumulated
* evidence (error messages, exit codes, etc.).
*/
evidence?: string[]
}
// ---------------------------------------------------------------------------
// Central advisory maps
// ---------------------------------------------------------------------------
/** Maps each advisory kind to its classification category. */
export const ADVISORY_CATEGORY_MAP: Record<AdvisoryKind, AdvisoryCategory> = {
// Prep: actions that prepare the environment before the main action
enumerate_displays_first: 'prep',
focus_app_first: 'prep',
take_screenshot_first: 'prep',
// Reroute: the caller should switch to a different surface/tool
use_browser_surface: 'reroute',
use_accessibility_grounding: 'reroute',
use_terminal_instead: 'reroute',
use_pty_surface: 'reroute',
// Recovery: respond to a previous failure
retry_after_error: 'recovery',
read_error_first: 'recovery',
wait_and_retry: 'recovery',
abort_task: 'recovery',
approval_rejected_replan: 'recovery',
// Informational: no action needed, safe to proceed
proceed: 'informational',
}
/** Maps each advisory kind to the surface it recommends. */
export const ADVISORY_SURFACE_MAP: Record<AdvisoryKind, RecommendedSurface> = {
enumerate_displays_first: 'display',
focus_app_first: 'desktop',
take_screenshot_first: 'desktop',
use_browser_surface: 'browser_cdp',
use_accessibility_grounding: 'accessibility',
use_pty_surface: 'pty',
use_terminal_instead: 'terminal',
retry_after_error: 'none',
read_error_first: 'terminal',
wait_and_retry: 'none',
abort_task: 'none',
approval_rejected_replan: 'none',
proceed: 'none',
}
/**
* Workflow engine prep-tool policy for advisory kinds that recommend a
* specific MCP tool. Defines priority (lower = run first), retryability,
* and the outcome the engine should set on the step when the prep
* succeeds.
*/
export type PrepRetryability = 'transient' | 'permanent' | 'advisory_only'
export interface PrepToolPolicy {
/** Tool invocation priority — lower values run first. */
priority: number
/** Retry classification for the prep tool. */
retryability: PrepRetryability
/**
* What the engine should record on the step when prep succeeds:
* - 'prepared': continue to main action
* - 'reroute': stop the workflow and return reroute signal
*/
outcomeOnSuccess: 'prepared' | 'reroute'
}
export const PREP_TOOL_POLICY: Partial<Record<AdvisoryKind, PrepToolPolicy>> = {
enumerate_displays_first: {
priority: 10,
retryability: 'transient',
outcomeOnSuccess: 'prepared',
},
use_accessibility_grounding: {
priority: 20,
retryability: 'permanent',
outcomeOnSuccess: 'reroute',
},
use_browser_surface: {
priority: 20,
retryability: 'permanent',
outcomeOnSuccess: 'reroute',
},
use_pty_surface: {
priority: 20,
retryability: 'permanent',
outcomeOnSuccess: 'reroute',
},
}
/**
* Helper to construct a `StrategyAdvisory` with `category` and
* `recommendedSurface` populated from the central maps.
*/
function advisory(fields: Omit<StrategyAdvisory, 'category' | 'recommendedSurface'> & Partial<Pick<StrategyAdvisory, 'category' | 'recommendedSurface'>>): StrategyAdvisory {
return {
...fields,
category: fields.category ?? ADVISORY_CATEGORY_MAP[fields.kind],
recommendedSurface: fields.recommendedSurface ?? ADVISORY_SURFACE_MAP[fields.kind],
}
}
// ---------------------------------------------------------------------------
// Strategy evaluator
// ---------------------------------------------------------------------------
/**
* Evaluate the current run state and the proposed next action, returning
* zero or more advisories that the orchestration layer should follow.
*
* The caller can decide to:
* - Execute the suggested preparatory action before proceeding.
* - Skip the proposed action entirely (abort / replan).
* - Proceed as-is if the advisory is 'proceed'.
*/
export function evaluateStrategy(params: {
proposedAction: ActionInvocation
state: RunState
/** Foreground context from the most recent probe (may be fresher than state). */
freshContext?: ForegroundContext
}): StrategyAdvisory[] {
const advisories: StrategyAdvisory[] = []
const { proposedAction, state } = params
const ctx = params.freshContext ?? state.foregroundContext
// -----------------------------------------------------------------------
// Rule 1: If the last approval was rejected, recommend replanning.
// -----------------------------------------------------------------------
if (state.lastApprovalRejected) {
advisories.push(advisory({
kind: 'approval_rejected_replan',
reason: `The last action was rejected${state.lastRejectionReason ? `: ${state.lastRejectionReason}` : ''}. Consider an alternative approach.`,
evidence: state.lastRejectionReason ? [state.lastRejectionReason] : [],
}))
}
// -----------------------------------------------------------------------
// Rule 2: For UI-interaction actions, make sure the correct app is
// focused before sending clicks / keystrokes / text.
// -----------------------------------------------------------------------
const uiActions = new Set<string>(['click', 'type_text', 'press_keys', 'scroll'])
if (uiActions.has(proposedAction.kind) && ctx?.available) {
// If there is an active task whose current step targets a specific app,
// verify the foreground matches.
const targetApp = inferTargetApp(state)
if (targetApp && !isAppFocused(ctx, targetApp)) {
advisories.push(advisory({
kind: 'focus_app_first',
reason: `Expected "${targetApp}" in foreground but found "${ctx.appName || 'unknown'}". Will focus the correct app first.`,
suggestedAction: { kind: 'focus_app', input: { app: targetApp } },
}))
}
}
// -----------------------------------------------------------------------
// Rule 3: For mutation actions on remote targets, require a recent
// screenshot so the model is operating on up-to-date visuals.
// -----------------------------------------------------------------------
if (
isMutatingUiAction(proposedAction)
&& state.executionTarget?.mode === 'remote'
&& !state.lastScreenshot
) {
advisories.push(advisory({
kind: 'take_screenshot_first',
reason: 'No recent screenshot available for the remote desktop. Capture one before mutating.',
suggestedAction: { kind: 'screenshot', input: {} },
}))
}
// -----------------------------------------------------------------------
// Rule 4: If the last terminal command failed, advise reading the
// error output before blindly retrying.
// -----------------------------------------------------------------------
if (
proposedAction.kind === 'terminal_exec'
&& state.lastTerminalResult
&& state.lastTerminalResult.exitCode !== 0
) {
const hasUnreadError = state.lastTerminalResult.stderr.length > 0
if (hasUnreadError) {
advisories.push(advisory({
kind: 'read_error_first',
reason: `The previous command exited with code ${state.lastTerminalResult.exitCode}. Review the error output before running another command.`,
evidence: [
`exit_code=${state.lastTerminalResult.exitCode}`,
`stderr_preview=${state.lastTerminalResult.stderr.slice(0, 300)}`,
],
}))
}
}
// -----------------------------------------------------------------------
// Rule 5: If the active task has hit too many consecutive failures,
// recommend aborting.
// -----------------------------------------------------------------------
if (state.activeTask && state.activeTask.failureCount >= state.activeTask.maxConsecutiveFailures) {
advisories.push(advisory({
kind: 'abort_task',
reason: `Task "${state.activeTask.goal}" has accumulated ${state.activeTask.failureCount} failures (max ${state.activeTask.maxConsecutiveFailures}). Aborting to prevent damage.`,
evidence: state.activeTask.steps
.filter(s => s.outcome === 'failure')
.map(s => `Step ${s.index}: ${s.label}${s.outcomeReason || 'unknown error'}`),
}))
}
// -----------------------------------------------------------------------
// Rule 6: Prefer terminal commands over visual interactions when the
// information can be obtained programmatically.
// -----------------------------------------------------------------------
if (proposedAction.kind === 'screenshot' && canUseTerminalInstead(state)) {
advisories.push(advisory({
kind: 'use_terminal_instead',
reason: 'The information you need may be available via a terminal command, which is faster and more reliable than a screenshot.',
}))
}
// -----------------------------------------------------------------------
// Rule 7: If the runner is tainted, recommend a screenshot first.
// -----------------------------------------------------------------------
if (
isMutatingUiAction(proposedAction)
&& state.executionTarget?.tainted
) {
advisories.push(advisory({
kind: 'take_screenshot_first',
reason: 'The runner is tainted from a previous failure. Capture a fresh screenshot to restore it before proceeding.',
suggestedAction: { kind: 'screenshot', input: {} },
}))
}
// -----------------------------------------------------------------------
// Rule 8: Browser surface routing — when the foreground is a browser,
// prefer browser DOM / CDP tools over desktop-level UI actions.
// -----------------------------------------------------------------------
if (isMutatingUiAction(proposedAction) && ctx?.available && isBrowserApp(ctx.appName)) {
const browserSurface = selectBrowserSurface(state)
if (browserSurface) {
advisories.push(advisory({
kind: 'use_browser_surface',
reason: browserSurface.reason,
suggestedToolName: browserSurface.toolName,
recommendedSurface: browserSurface.surface,
}))
}
}
// -----------------------------------------------------------------------
// Rule 9: Accessibility grounding — on macOS, prefer the accessibility
// tree for structured UI data over raw screenshots for native apps.
// For browsers, Rule 8 already routes to DOM/CDP which is richer.
// -----------------------------------------------------------------------
if (
proposedAction.kind === 'screenshot'
&& ctx?.platform === 'darwin'
&& !isBrowserApp(ctx.appName)
) {
advisories.push(advisory({
kind: 'use_accessibility_grounding',
reason: 'macOS accessibility tree provides structured UI element data. Consider capturing it before or instead of a screenshot for element discovery.',
suggestedToolName: 'accessibility_snapshot',
}))
}
// -----------------------------------------------------------------------
// Rule 10: PTY surface for interactive TUI sessions — when the
// terminal is running a TUI program, terminal_exec won't work well.
// -----------------------------------------------------------------------
const ptySession = selectUsablePtySession(state)
if (
proposedAction.kind === 'terminal_exec'
&& ptySession
&& (
isLikelyTuiSession(ctx?.windowTitle ?? state.activeWindowTitle)
|| ptySession.boundWorkflowStepLabel === getCurrentTaskStepLabel(state)
)
) {
advisories.push(advisory({
kind: 'use_pty_surface',
reason: `Use tracked PTY session "${ptySession.id}" for direct TUI interaction instead of terminal_exec.`,
suggestedToolName: 'pty_read_screen',
}))
}
// -----------------------------------------------------------------------
// Rule 11: Multi-display awareness — if display configuration is
// unknown and the action involves spatial coordinates, enumerate first.
// -----------------------------------------------------------------------
if (
(proposedAction.kind === 'screenshot' || proposedAction.kind === 'click')
&& !state.displayInfo
) {
advisories.push(advisory({
kind: 'enumerate_displays_first',
reason: 'Display configuration is unknown. Enumerate displays to ensure correct coordinate targeting on multi-monitor setups.',
suggestedToolName: 'display_enumerate',
}))
}
// If no advisories were emitted, it is safe to proceed.
if (advisories.length === 0) {
advisories.push(advisory({
kind: 'proceed',
reason: 'No pre-conditions violated. Safe to execute.',
}))
}
return advisories
}
/**
* Produce a recovery plan after an action failure, based on the current
* run state and the error that occurred.
*/
export function buildRecoveryPlan(params: {
failedAction: ActionInvocation
errorMessage: string
state: RunState
}): StrategyAdvisory {
const { failedAction, errorMessage, state } = params
// Terminal failure with active TUI session -> suggest PTY surface.
const ptySession = selectUsablePtySession(state)
if (
failedAction.kind === 'terminal_exec'
&& ptySession
&& (
isLikelyTuiSession(state.activeWindowTitle)
|| ptySession.boundWorkflowStepLabel === getCurrentTaskStepLabel(state)
)
) {
return advisory({
kind: 'use_pty_surface',
reason: `Terminal command failed while PTY session "${ptySession.id}" is available: ${errorMessage}. Use PTY tools for direct terminal interaction.`,
suggestedToolName: 'pty_read_screen',
evidence: [errorMessage],
})
}
// Terminal failure -> suggest reading stderr and optionally retrying.
if (failedAction.kind === 'terminal_exec') {
if (errorMessage.includes('timeout')) {
return advisory({
kind: 'wait_and_retry',
reason: 'The command timed out. Consider increasing the timeout or splitting the work.',
evidence: [errorMessage],
})
}
return advisory({
kind: 'read_error_first',
reason: `Terminal command failed: ${errorMessage}. Inspect stderr/stdout before deciding next step.`,
evidence: [
errorMessage,
...(state.lastTerminalResult?.stderr ? [`stderr: ${state.lastTerminalResult.stderr.slice(0, 500)}`] : []),
],
})
}
// UI action failure on wrong app -> suggest focusing.
if (isMutatingUiAction(failedAction) && state.foregroundContext?.available) {
const targetApp = inferTargetApp(state)
if (targetApp && !isAppFocused(state.foregroundContext, targetApp)) {
return advisory({
kind: 'focus_app_first',
reason: `UI action failed because "${state.foregroundContext.appName}" is in front instead of "${targetApp}".`,
suggestedAction: { kind: 'focus_app', input: { app: targetApp } },
evidence: [errorMessage],
})
}
}
// UI action failure in a browser → suggest switching to browser surface.
if (isMutatingUiAction(failedAction) && state.foregroundContext?.available && isBrowserApp(state.foregroundContext.appName)) {
const browserSurface = selectBrowserSurface(state)
if (browserSurface) {
return advisory({
kind: 'use_browser_surface',
reason: `Desktop UI action failed in browser "${state.foregroundContext.appName}": ${errorMessage}. ${browserSurface.reason}`,
suggestedToolName: browserSurface.toolName,
recommendedSurface: browserSurface.surface,
evidence: [errorMessage],
})
}
}
// Observation failure on macOS → suggest accessibility tree as alternative.
if (
(failedAction.kind === 'screenshot' || failedAction.kind === 'observe_windows')
&& state.foregroundContext?.platform === 'darwin'
) {
return advisory({
kind: 'use_accessibility_grounding',
reason: `Observation failed: ${errorMessage}. Use the accessibility tree as an alternative structured UI data source.`,
suggestedToolName: 'accessibility_snapshot',
evidence: [errorMessage],
})
}
// Generic: suggest taking a screenshot to reassess.
return advisory({
kind: 'take_screenshot_first',
reason: `Action "${failedAction.kind}" failed: ${errorMessage}. Take a screenshot to reassess the current state.`,
suggestedAction: { kind: 'screenshot', input: {} },
evidence: [errorMessage],
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function isMutatingUiAction(action: ActionInvocation): boolean {
return ['click', 'type_text', 'press_keys', 'scroll', 'open_app', 'focus_app'].includes(action.kind)
}
function isAppFocused(ctx: ForegroundContext, targetApp: string): boolean {
if (!ctx.available || !ctx.appName)
return false
return appNamesMatch(ctx.appName, targetApp)
}
/**
* Attempt to infer which app the current task step expects to be in front.
* Returns undefined if no inference is possible.
*/
function inferTargetApp(state: RunState): string | undefined {
if (!state.activeTask)
return undefined
const step = state.activeTask.steps[state.activeTask.currentStepIndex]
if (!step)
return undefined
// If the step label mentions a known app, use that.
return findKnownAppMention(step.label)
}
function getCurrentTaskStepLabel(state: RunState): string | undefined {
if (!state.activeTask) {
return undefined
}
return state.activeTask.steps[state.activeTask.currentStepIndex]?.label
}
/** Get the stable stepId for the current task step. */
function getCurrentTaskStepId(state: RunState): string | undefined {
if (!state.activeTask) {
return undefined
}
return state.activeTask.steps[state.activeTask.currentStepIndex]?.stepId
}
function selectUsablePtySession(state: RunState) {
// Prefer stepId binding over stepLabel binding
const currentStepId = getCurrentTaskStepId(state)
if (currentStepId) {
const boundById = state.ptySessions.find(session => session.alive && session.boundStepId === currentStepId)
if (boundById) {
return boundById
}
}
// Fallback: legacy stepLabel binding
const currentStepLabel = getCurrentTaskStepLabel(state)
if (currentStepLabel) {
const bound = state.ptySessions.find(session => session.alive && session.boundWorkflowStepLabel === currentStepLabel)
if (bound) {
return bound
}
}
if (!state.activePtySessionId) {
return undefined
}
return state.ptySessions.find(session => session.alive && session.id === state.activePtySessionId)
}
/**
* Heuristic: can the proposed observation be satisfied via a terminal
* command instead of a screenshot?
*/
function canUseTerminalInstead(state: RunState): boolean {
// If the terminal is healthy (last command succeeded or no command yet),
// and we have an active task that is dev-oriented, prefer terminal.
if (!state.activeTask)
return false
const devWorkflows = ['dev_run_tests', 'dev_inspect_failure']
return devWorkflows.includes(state.activeTask.workflowId || '')
}
function selectBrowserSurface(state: RunState): {
surface: Extract<RecommendedSurface, 'browser_cdp' | 'browser_dom'>
toolName: 'browser_cdp_collect_elements' | 'browser_dom_read_page'
reason: string
} | undefined {
const availability = state.browserSurfaceAvailability
if (!availability) {
return {
surface: 'browser_cdp',
toolName: 'browser_cdp_collect_elements',
reason: 'Browser CDP is selected as the default browser surface when no live availability model is present.',
}
}
if (!availability.suitable || !availability.preferredSurface || !availability.selectedToolName) {
return undefined
}
return {
surface: availability.preferredSurface,
toolName: availability.selectedToolName,
reason: availability.reason,
}
}
// ---------------------------------------------------------------------------
// Surface detection helpers
// ---------------------------------------------------------------------------
const KNOWN_BROWSERS = new Set([
'google chrome',
'chrome',
'firefox',
'safari',
'arc',
'microsoft edge',
'edge',
'brave browser',
'brave',
'opera',
'vivaldi',
'chromium',
'orion',
])
const APP_SUFFIX_RE = /\.app$/u
/** Check if the foreground app is a known web browser. */
function isBrowserApp(appName: string | undefined): boolean {
if (!appName)
return false
return KNOWN_BROWSERS.has(appName.trim().toLowerCase().replace(APP_SUFFIX_RE, ''))
}
const KNOWN_TUI_PROGRAMS = [
'vim',
'nvim',
'neovim',
'vi',
'nano',
'emacs',
'htop',
'btop',
'top',
'less',
'more',
'man',
'tmux',
'screen',
'irssi',
'weechat',
'mutt',
'neomutt',
'mc',
'ranger',
'nnn',
'fzf',
'tig',
'lazygit',
'lazydocker',
]
/**
* Heuristic: does the window title suggest an interactive TUI program
* is running (vim, htop, tmux, etc.)?
*/
function isLikelyTuiSession(windowTitle: string | undefined): boolean {
if (!windowTitle)
return false
const lower = windowTitle.toLowerCase()
return KNOWN_TUI_PROGRAMS.some(prog => lower.includes(prog))
}
/**
* Summarize the strategy advisory list into a classified, user-friendly
* string for inclusion in MCP responses.
*
* Groups advisories by category (prep / reroute / recovery / informational)
* and includes the recommended surface when relevant.
*/
export function summarizeAdvisories(advisories: StrategyAdvisory[]): string {
if (advisories.length === 1 && advisories[0].kind === 'proceed') {
return ''
}
const meaningful = advisories.filter(a => a.kind !== 'proceed')
return meaningful
.map((a) => {
const surface = a.recommendedSurface !== 'none' ? ` → ${a.recommendedSurface}` : ''
return `[${a.category}/${a.kind}${surface}] ${a.reason}`
})
.join(' | ')
}