feat(computer-use-mcp): add tool lane hygiene advisory (#1919)
## Summary Adds advisory-only tool lane hygiene inside `services/computer-use-mcp`. This tracks the most recent non-exempt tool lane and appends a non-blocking advisory when a registered tool is called from a different active lane. The change is intentionally scoped to computer-use MCP server registration and run state. ## Boundary - Only touches `services/computer-use-mcp`. - Does not touch app, renderer, overlay, extension, or root project governance files. - Does not include local AI config, local worktree artifacts, screenshots, tokens, account identifiers, or raw environment dumps. ## Validation Sanitized evidence is committed in `services/computer-use-mcp/validation/tool-lane-hygiene.md`. Ran: - `pnpm install --ignore-scripts --frozen-lockfile` - `pnpm -F @proj-airi/computer-use-mcp exec vitest run src/server/tool-lane-hygiene.test.ts --config ./vitest.config.ts` - `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` - `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` - `git diff --check` `pnpm -F @proj-airi/computer-use-mcp typecheck` is currently blocked by existing baseline errors in `chrome-session-manager` and `desktop-grounding`, outside this patch. The validation note lists the error classes without local paths or raw logs.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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<ToolLane> = new Set<ToolLane>([
|
||||
'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> | CallToolResult
|
||||
const wrappedHandler = async (...args: any[]): Promise<CallToolResult> => {
|
||||
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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user