diff --git a/services/computer-use-mcp/src/server/register-tools.ts b/services/computer-use-mcp/src/server/register-tools.ts index 5aa87884c..f9fa87716 100644 --- a/services/computer-use-mcp/src/server/register-tools.ts +++ b/services/computer-use-mcp/src/server/register-tools.ts @@ -13,6 +13,7 @@ import type { WorkflowSuspension } from '../workflows' import type { ExecuteAction } from './action-executor' import type { ComputerUseServerRuntime } from './runtime' +import { errorMessageFrom } from '@moeru/std' import { z } from 'zod' import { getUnsupportedBrowserDomActions, isBrowserDomActionSupported } from '../browser-dom/capabilities' @@ -37,6 +38,7 @@ import { import { refreshRuntimeRunState } from './refresh-run-state' import { executeChromeEnsure } from './register-chrome-session' import { createAcquirePtyCallback, executeApprovedPtyCreate } from './register-pty' +import { createToolLaneHygieneServer } from './tool-lane-hygiene' import { formatWorkflowStructuredContent } from './workflow-formatter' import { createWorkflowPrepToolExecutor } from './workflow-prep-tools' @@ -103,7 +105,8 @@ function buildBrowserDomUnavailableResponse(runtime: ComputerUseServerRuntime, u } export function registerComputerUseTools(params: RegisterComputerUseToolsOptions) { - const { server, runtime, executeAction, enableTestTools } = params + const { runtime, executeAction, enableTestTools } = params + const server = createToolLaneHygieneServer(params.server, runtime.stateManager) const executePrepTool = createWorkflowPrepToolExecutor(runtime) const acquirePty = createAcquirePtyCallback(runtime) @@ -445,15 +448,16 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions } } catch (error) { + const message = errorMessageFrom(error) ?? 'unknown error' return { isError: true, content: [ - textContent(`Browser agent failed: ${error instanceof Error ? error.message : String(error)}`), + textContent(`Browser agent failed: ${message}`), ], structuredContent: { status: 'error', browserAgent: launchContext, - error: error instanceof Error ? error.message : String(error), + error: message, }, } } @@ -877,10 +881,11 @@ export function registerComputerUseTools(params: RegisterComputerUseToolsOptions parsed = JSON.parse(optsJson) as unknown } catch (error) { + const message = errorMessageFrom(error) ?? 'unknown error' return { isError: true, content: [ - textContent(`browser_dom_trigger_event expected optsJson to be valid JSON: ${error instanceof Error ? error.message : String(error)}`), + textContent(`browser_dom_trigger_event expected optsJson to be valid JSON: ${message}`), ], structuredContent: { status: 'invalid_params', diff --git a/services/computer-use-mcp/src/server/tool-lane-hygiene.test.ts b/services/computer-use-mcp/src/server/tool-lane-hygiene.test.ts new file mode 100644 index 000000000..f0487260f --- /dev/null +++ b/services/computer-use-mcp/src/server/tool-lane-hygiene.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' + +import { + buildCrossLaneAdvisory, + inferToolLane, + shouldUpdateActiveLane, +} from './tool-lane-hygiene' + +describe('tool-lane-hygiene', () => { + describe('inferToolLane', () => { + it('looks up a tool lane from the descriptor registry', () => { + expect(inferToolLane('desktop_click')).toBe('desktop') + }) + }) + + describe('buildCrossLaneAdvisory', () => { + it('returns null when no active lane is established', () => { + const result = buildCrossLaneAdvisory({ + toolName: 'browser_dom_click', + toolLane: 'browser_dom', + inferredActiveLane: undefined, + }) + expect(result).toBeNull() + }) + + it('returns null when tool lane matches active lane', () => { + const result = buildCrossLaneAdvisory({ + toolName: 'coding_read_file', + toolLane: 'coding', + inferredActiveLane: 'coding', + }) + expect(result).toBeNull() + }) + + it('returns advisory when tool lane differs from active lane', () => { + const result = buildCrossLaneAdvisory({ + toolName: 'browser_dom_click', + toolLane: 'browser_dom', + inferredActiveLane: 'coding', + }) + expect(result).toContain('Advisory') + expect(result).toContain('coding') + expect(result).toContain('browser_dom') + expect(result).toContain('browser_dom_click') + }) + + it('does not trigger advisory when tool lane is workflow', () => { + const result = buildCrossLaneAdvisory({ + toolName: 'workflow_coding_loop', + toolLane: 'workflow', + inferredActiveLane: 'coding', + }) + expect(result).toBeNull() + }) + + it('does not trigger advisory when active lane is exempt', () => { + const result = buildCrossLaneAdvisory({ + toolName: 'browser_dom_click', + toolLane: 'browser_dom', + inferredActiveLane: 'workflow', + }) + expect(result).toBeNull() + }) + + it('triggers advisory for desktop to coding cross-lane usage', () => { + const result = buildCrossLaneAdvisory({ + toolName: 'coding_apply_patch', + toolLane: 'coding', + inferredActiveLane: 'desktop', + }) + expect(result).toContain('Advisory') + expect(result).toContain('desktop') + expect(result).toContain('coding') + }) + }) + + describe('shouldUpdateActiveLane', () => { + it('returns true for non-exempt lanes', () => { + expect(shouldUpdateActiveLane('coding')).toBe(true) + expect(shouldUpdateActiveLane('desktop')).toBe(true) + expect(shouldUpdateActiveLane('browser_dom')).toBe(true) + expect(shouldUpdateActiveLane('browser_cdp')).toBe(true) + expect(shouldUpdateActiveLane('pty')).toBe(true) + expect(shouldUpdateActiveLane('accessibility')).toBe(true) + expect(shouldUpdateActiveLane('vscode')).toBe(true) + }) + + it('returns false for exempt lanes', () => { + expect(shouldUpdateActiveLane('workflow')).toBe(false) + expect(shouldUpdateActiveLane('internal')).toBe(false) + expect(shouldUpdateActiveLane('task_memory')).toBe(false) + expect(shouldUpdateActiveLane('display')).toBe(false) + }) + }) +}) diff --git a/services/computer-use-mcp/src/server/tool-lane-hygiene.ts b/services/computer-use-mcp/src/server/tool-lane-hygiene.ts new file mode 100644 index 000000000..f8c36c7ea --- /dev/null +++ b/services/computer-use-mcp/src/server/tool-lane-hygiene.ts @@ -0,0 +1,129 @@ +/** + * Tool Lane Hygiene + * + * Advisory-only tracking for cross-lane MCP tool usage. A lane mismatch + * appends a nudge to the tool result, but never blocks execution. + */ + +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js' + +import type { ToolLane } from './tool-descriptors' + +import { textContent } from './content' +import { globalRegistry, initializeGlobalRegistry } from './tool-descriptors' + +const EXEMPT_LANES: ReadonlySet = new Set([ + 'workflow', + 'internal', + 'task_memory', + 'display', +]) + +export interface ToolLaneStateManager { + getState: () => Readonly<{ inferredActiveLane?: ToolLane }> + updateInferredLane: (lane: ToolLane) => void +} + +export function inferToolLane(toolName: string): ToolLane | undefined { + if (globalRegistry.size === 0) { + initializeGlobalRegistry() + } + + return globalRegistry.getOptional(toolName)?.lane +} + +export function buildCrossLaneAdvisory(params: { + toolName: string + toolLane: ToolLane + inferredActiveLane: ToolLane | undefined +}): string | null { + const { toolName, toolLane, inferredActiveLane } = params + + if (!inferredActiveLane) { + return null + } + + if (toolLane === inferredActiveLane) { + return null + } + + if (EXEMPT_LANES.has(toolLane) || EXEMPT_LANES.has(inferredActiveLane)) { + return null + } + + return ( + `Advisory: You are currently in the "${inferredActiveLane}" lane but called ` + + `"${toolName}" which belongs to the "${toolLane}" lane. ` + + 'Consider using a handoff if you need to switch execution surfaces.' + ) +} + +export function shouldUpdateActiveLane(lane: ToolLane): boolean { + return !EXEMPT_LANES.has(lane) +} + +export function createToolLaneHygieneServer( + server: McpServer, + stateManager: ToolLaneStateManager, +): McpServer { + return new Proxy(server, { + get(target, prop, receiver) { + if (prop !== 'tool') { + return Reflect.get(target, prop, receiver) + } + + return (name: string, ...rest: unknown[]) => { + const handlerIndex = findLastHandlerIndex(rest) + if (handlerIndex < 0) { + return (server.tool as any)(name, ...rest) + } + + const originalHandler = rest[handlerIndex] as (...args: any[]) => Promise | CallToolResult + const wrappedHandler = async (...args: any[]): Promise => { + const lane = inferToolLane(name) + let advisory: string | null = null + + if (lane) { + advisory = buildCrossLaneAdvisory({ + toolName: name, + toolLane: lane, + inferredActiveLane: stateManager.getState().inferredActiveLane, + }) + + if (shouldUpdateActiveLane(lane)) { + stateManager.updateInferredLane(lane) + } + } + + const result = await originalHandler(...args) + if (!advisory || !Array.isArray(result.content)) { + return result + } + + return { + ...result, + content: [ + ...result.content, + textContent(`\n\n${advisory}`), + ], + } + } + + const wrappedRest = [...rest] + wrappedRest[handlerIndex] = wrappedHandler + return (server.tool as any)(name, ...wrappedRest) + } + }, + }) +} + +function findLastHandlerIndex(args: unknown[]): number { + for (let index = args.length - 1; index >= 0; index -= 1) { + if (typeof args[index] === 'function') { + return index + } + } + + return -1 +} diff --git a/services/computer-use-mcp/src/state.ts b/services/computer-use-mcp/src/state.ts index 7a194f105..d5967edb6 100644 --- a/services/computer-use-mcp/src/state.ts +++ b/services/computer-use-mcp/src/state.ts @@ -10,6 +10,7 @@ */ import type { DesktopSession } from './desktop-session' +import type { ToolLane } from './server/tool-descriptors' import type { TaskMemory } from './task-memory/types' import type { BrowserSurfaceAvailability, @@ -199,6 +200,10 @@ export interface RunState { /** Agent's active desktop execution session. */ desktopSession?: DesktopSession + // --- Tool lane hygiene ------------------------------------------------ + /** Inferred active lane from the most recent non-exempt tool invocation. */ + inferredActiveLane?: ToolLane + // --- Meta ------------------------------------------------------------- /** ISO timestamp of the last state update. */ updatedAt: string @@ -490,6 +495,11 @@ export class RunStateManager { this.touch() } + updateInferredLane(lane: ToolLane): void { + this.state.inferredActiveLane = lane + this.touch() + } + clearTask() { this.state.activeTask = undefined this.touch() diff --git a/services/computer-use-mcp/validation/tool-lane-hygiene.md b/services/computer-use-mcp/validation/tool-lane-hygiene.md new file mode 100644 index 000000000..29daadbf5 --- /dev/null +++ b/services/computer-use-mcp/validation/tool-lane-hygiene.md @@ -0,0 +1,69 @@ +# Tool Lane Hygiene Validation + +Date: 2026-06-01 + +Scope: +- `services/computer-use-mcp/src/server/tool-lane-hygiene.ts` +- `services/computer-use-mcp/src/server/tool-lane-hygiene.test.ts` +- `services/computer-use-mcp/src/server/register-tools.ts` +- `services/computer-use-mcp/src/state.ts` + +Privacy note: +- Evidence is sanitized for a public repository. +- No local absolute paths, tokens, account identifiers, screenshots, or raw environment dumps are included. + +Commands run: + +```sh +pnpm install --ignore-scripts --frozen-lockfile +``` + +Result: passed. Lockfile stayed unchanged; lifecycle scripts were intentionally skipped for local verification setup. + +```sh +pnpm -F @proj-airi/computer-use-mcp exec vitest run src/server/tool-lane-hygiene.test.ts --config ./vitest.config.ts +``` + +Result: passed. 1 test file, 9 tests. + +```sh +pnpm -F @proj-airi/computer-use-mcp exec vitest run \ + src/server/tool-lane-hygiene.test.ts \ + src/server/register-tools-coordinate-contract.test.ts \ + src/server/register-tools-pty-approval.test.ts \ + --config ./vitest.config.ts +``` + +Result: passed. 3 test files, 15 tests. + +```sh +pnpm exec moeru-lint --fix \ + services/computer-use-mcp/validation/tool-lane-hygiene.md \ + services/computer-use-mcp/src/server/tool-lane-hygiene.ts \ + services/computer-use-mcp/src/server/tool-lane-hygiene.test.ts \ + services/computer-use-mcp/src/server/register-tools.ts \ + services/computer-use-mcp/src/state.ts +``` + +Result: passed with 0 warnings and 0 errors when run under Node 24. + +```sh +git diff --check +``` + +Result: passed. + +```sh +pnpm -F @proj-airi/computer-use-mcp typecheck +``` + +Result: failed on existing baseline files outside this change: +- `src/chrome-session-manager.ts` +- `src/chrome-session-manager.test.ts` +- `src/desktop-grounding.ts` + +Observed baseline error classes: +- `TS2339` and `TS2353` around `ChromeSessionInfo.ensureOutcome` +- `TS2451` / `TS2304` around duplicated `chromeWindowBounds` and missing `isChromeInFront` + +No typecheck errors were reported for the files changed by this patch.