fix(computer-use): route enabled desktop control
This commit is contained in:
@@ -28,8 +28,8 @@ import { declareWorkerHostEntry, installWorkerInbox } from "@oh-my-pi/pi-utils/w
|
||||
import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias";
|
||||
import { extractProfileFlags } from "./cli/profile-bootstrap";
|
||||
import { DAEMON_BROKER_WORKER_ARG } from "./launch/protocol";
|
||||
import { startComputerWorker } from "./tools/computer/worker-entry";
|
||||
import { smokeTestComputerWorker } from "./tools/computer/supervisor";
|
||||
import { startComputerWorker } from "./tools/computer/worker-entry";
|
||||
|
||||
if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
|
||||
process.stderr.write(
|
||||
|
||||
@@ -79,6 +79,15 @@ Special URLs for internal resources; with most FS/bash tools they auto-resolve t
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
{{#has tools "computer"}}
|
||||
# Computer Use
|
||||
The `{{toolRefs.computer}}` tool is explicitly enabled and available in this session.
|
||||
- MUST use `{{toolRefs.computer}}` for requests to view or control host desktop applications.
|
||||
- NEVER claim Computer Use is unavailable while `{{toolRefs.computer}}` appears in the tool inventory.
|
||||
- While fulfilling host-desktop requests, NEVER substitute Browser, Bash, Eval, AppleScript, accessibility commands, or `screencapture` unless the user explicitly requests that mechanism or `{{toolRefs.computer}}` returns an error.
|
||||
- Inspect the fresh screenshot returned by every successful `{{toolRefs.computer}}` call before choosing the next action.
|
||||
{{/has}}
|
||||
|
||||
{{#if xdevTools.length}}
|
||||
# xd:// Tool Devices
|
||||
Additional tools are mounted as virtual devices, executed by writing a JSON args object as `content` to `xd://<tool>` via `{{toolRefs.write}}`.
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { MemoryBackendStartOptions } from "../memory-backend/types";
|
||||
import xdevMountNoticePrompt from "../prompts/system/xdev-mount-notice.md" with { type: "text" };
|
||||
import { usesCodexTaskPrompt } from "../task/prompt-policy";
|
||||
import { isMCPToolName, normalizeToolNames } from "../tools/builtin-names";
|
||||
import { computerExposureMode } from "../tools/computer/exposure";
|
||||
import { wrapToolWithMetaNotice } from "../tools/output-meta";
|
||||
import { ToolAbortError, ToolError } from "../tools/tool-errors";
|
||||
import { isMountableUnderXdev, type XdevRegistry } from "../tools/xdev";
|
||||
@@ -294,6 +295,16 @@ export class SessionTools {
|
||||
return usesCodexTaskPrompt(model) ? "task-policy:gpt-5.6" : "task-policy:default";
|
||||
}
|
||||
|
||||
#logComputerState(message: string, enabled: boolean): void {
|
||||
const model = this.#host.model();
|
||||
logger.debug(message, {
|
||||
enabled,
|
||||
active: this.getEnabledToolNames().includes("computer"),
|
||||
model: model ? formatModelString(model) : undefined,
|
||||
exposure: computerExposureMode(model),
|
||||
});
|
||||
}
|
||||
|
||||
/** Rebuilds model-dependent tool prompts after a model change. */
|
||||
async syncAfterModelChange(previousEditMode: EditMode): Promise<void> {
|
||||
const currentEditMode = this.resolveActiveEditMode();
|
||||
@@ -303,6 +314,20 @@ export class SessionTools {
|
||||
if (editModeChanged || modelChanged) {
|
||||
await this.refreshBaseSystemPrompt();
|
||||
}
|
||||
const computerExpected = this.#host.settings.get("computer.enabled");
|
||||
const computerActive = this.getEnabledToolNames().includes("computer");
|
||||
if (computerExpected && !computerActive) {
|
||||
const model = this.#host.model();
|
||||
const modelName = model ? formatModelString(model) : "the current model";
|
||||
logger.warn("Enabled computer tool missing after model change", { model: modelName });
|
||||
this.#host.emitNotice(
|
||||
"warning",
|
||||
`Computer use remains enabled, but the computer tool is unavailable to ${modelName}.`,
|
||||
"computer",
|
||||
);
|
||||
} else if (computerExpected) {
|
||||
this.#logComputerState("Computer tool retained after model change", true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Enabled MCP tools in their current presentation partition. */
|
||||
@@ -713,16 +738,24 @@ export class SessionTools {
|
||||
* tool (e.g. restricted child sessions have no factory).
|
||||
*/
|
||||
async setComputerToolEnabled(enabled: boolean): Promise<boolean> {
|
||||
const logState = (): void => this.#logComputerState("Computer tool state changed", enabled);
|
||||
const active = this.getEnabledToolNames();
|
||||
if (!enabled) {
|
||||
if (active.includes("computer")) {
|
||||
await this.applyActiveToolsByName(active.filter(name => name !== "computer"));
|
||||
}
|
||||
logState();
|
||||
return true;
|
||||
}
|
||||
if (!this.#toolRegistry.has("computer")) {
|
||||
const tool = await this.#createComputerTool?.();
|
||||
if (tool?.name !== "computer") return false;
|
||||
if (tool?.name !== "computer") {
|
||||
const model = this.#host.model();
|
||||
logger.warn("Computer tool could not be created", {
|
||||
model: model ? formatModelString(model) : undefined,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
const wrapped = this.#wrapRuntimeTool(tool);
|
||||
this.#toolRegistry.set(wrapped.name, wrapped);
|
||||
this.#builtInToolNames.add(wrapped.name);
|
||||
@@ -730,6 +763,7 @@ export class SessionTools {
|
||||
if (!active.includes("computer")) {
|
||||
await this.applyActiveToolsByName([...active, "computer"]);
|
||||
}
|
||||
logState();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,12 @@ import { APP_NAME, getProjectDir, setProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import { reset as resetCapabilities } from "../capability";
|
||||
import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
|
||||
import { CollabHost } from "../collab/host";
|
||||
import { expandRoleAlias, getModelMatchPreferences, resolveCliModel } from "../config/model-resolver";
|
||||
import {
|
||||
expandRoleAlias,
|
||||
formatModelString,
|
||||
getModelMatchPreferences,
|
||||
resolveCliModel,
|
||||
} from "../config/model-resolver";
|
||||
import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
|
||||
import type { SettingPath, SettingValue } from "../config/settings";
|
||||
import { settings } from "../config/settings";
|
||||
@@ -37,6 +42,7 @@ import type { SessionOAuthAccountList } from "../session/agent-session-types";
|
||||
import { COMPACT_MODES, parseCompactArgs } from "../session/compact-modes";
|
||||
import { resolveResumableSession } from "../session/session-listing";
|
||||
import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
|
||||
import { computerExposureMode } from "../tools/computer/exposure";
|
||||
import { expandTilde, resolveToCwd } from "../tools/path-utils";
|
||||
import { urlHyperlinkAlways } from "../tui";
|
||||
import {
|
||||
@@ -90,9 +96,23 @@ function formatFastModeStatus(session: AgentSession): string {
|
||||
return session.isFastModeEnabled() ? "on" : "off";
|
||||
}
|
||||
|
||||
/** `/computer status` label for the session-effective `computer.enabled` value. */
|
||||
/** Detailed, session-effective `/computer status` diagnostics. */
|
||||
function formatComputerUseStatus(session: AgentSession): string {
|
||||
return session.settings.get("computer.enabled") ? "on" : "off";
|
||||
const enabled = session.settings.get("computer.enabled");
|
||||
const active = session.getEnabledToolNames().includes("computer");
|
||||
const model = session.model;
|
||||
const modelName = model ? formatModelString(model) : "none";
|
||||
const exposure = !enabled || !active ? "not exposed" : computerExposureMode(model);
|
||||
const toolState = active ? "active" : enabled ? "unavailable" : "inactive";
|
||||
return [
|
||||
`Computer use: ${enabled ? "enabled" : "disabled"}`,
|
||||
`tool: ${toolState}`,
|
||||
`backend: ${session.settings.get("computer.backend")}`,
|
||||
`display: ${session.settings.get("computer.display")}`,
|
||||
`capture: ${session.settings.get("computer.maxWidth")}×${session.settings.get("computer.maxHeight")}`,
|
||||
`model: ${modelName}`,
|
||||
`exposure: ${exposure}`,
|
||||
].join(" · ");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,7 +127,9 @@ async function applyComputerUseToggle(session: AgentSession, enable: boolean): P
|
||||
return "Computer use is unavailable in this session.";
|
||||
}
|
||||
session.settings.override("computer.enabled", enable);
|
||||
return `Computer use ${enable ? "enabled" : "disabled"} for this session.`;
|
||||
return enable
|
||||
? `Computer use enabled for this session. ${formatComputerUseStatus(session)}`
|
||||
: "Computer use disabled for this session.";
|
||||
}
|
||||
|
||||
const AUTOCOMPLETE_DETAIL_LIMIT = 48;
|
||||
@@ -573,11 +595,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
{ name: "status", description: "Show computer use status" },
|
||||
],
|
||||
allowArgs: true,
|
||||
getTuiAutocompleteDescription: runtime => `Computer: ${formatComputerUseStatus(runtime.ctx.session)}`,
|
||||
getTuiAutocompleteDescription: runtime =>
|
||||
`Computer: ${runtime.ctx.session.settings.get("computer.enabled") ? "on" : "off"}`,
|
||||
handle: async (command, runtime) => {
|
||||
const arg = command.args.trim().toLowerCase();
|
||||
if (arg === "status") {
|
||||
await runtime.output(`Computer use is ${formatComputerUseStatus(runtime.session)}.`);
|
||||
await runtime.output(formatComputerUseStatus(runtime.session));
|
||||
return commandConsumed();
|
||||
}
|
||||
if (!arg || arg === "toggle" || arg === "on" || arg === "off") {
|
||||
@@ -590,7 +613,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
||||
handleTui: async (command, runtime) => {
|
||||
const arg = command.args.trim().toLowerCase();
|
||||
if (arg === "status") {
|
||||
runtime.ctx.showStatus(`Computer use is ${formatComputerUseStatus(runtime.ctx.session)}.`);
|
||||
runtime.ctx.showStatus(formatComputerUseStatus(runtime.ctx.session));
|
||||
runtime.ctx.editor.setText("");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Model } from "@oh-my-pi/pi-ai";
|
||||
|
||||
export type ComputerExposureMode = "native" | "function" | "unavailable";
|
||||
|
||||
/** Match the provider transport's effective Computer Use tool representation. */
|
||||
export function computerExposureMode(model: Model | undefined): ComputerExposureMode {
|
||||
if (!model) return "unavailable";
|
||||
return model.supportsComputerUse === true ? "native" : "function";
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DesktopAction, DesktopCapabilities, DesktopCapture, DesktopSessionOptions } from "@oh-my-pi/pi-natives";
|
||||
import { withTimeout, workerHostEntry } from "@oh-my-pi/pi-utils";
|
||||
import { logger, withTimeout, workerHostEntry } from "@oh-my-pi/pi-utils";
|
||||
import { ToolAbortError, ToolError } from "../tool-errors";
|
||||
import {
|
||||
COMPUTER_WORKER_ARG,
|
||||
@@ -138,11 +138,23 @@ export class ComputerSupervisor implements ComputerController {
|
||||
if (this.#startPromise) return this.#startPromise;
|
||||
const ready = Promise.withResolvers<void>();
|
||||
try {
|
||||
logger.debug("Starting native computer worker", {
|
||||
backend: this.options.backend,
|
||||
display: this.options.display,
|
||||
maxWidth: this.options.maxWidth,
|
||||
maxHeight: this.options.maxHeight,
|
||||
});
|
||||
const worker = this.createWorker();
|
||||
this.#worker = worker;
|
||||
this.#unsubscribeMessage = worker.onMessage(message => {
|
||||
if (message.type === "ready") {
|
||||
this.#capabilities = message.capabilities;
|
||||
logger.debug("Native computer worker ready", {
|
||||
backend: message.capabilities.backend,
|
||||
capturePermission: message.capabilities.capturePermission,
|
||||
inputPermission: message.capabilities.inputPermission,
|
||||
displayCount: message.capabilities.displayCount,
|
||||
});
|
||||
ready.resolve();
|
||||
return;
|
||||
}
|
||||
@@ -151,10 +163,22 @@ export class ComputerSupervisor implements ComputerController {
|
||||
const pending = this.#pending.get(message.id);
|
||||
this.#pending.delete(message.id);
|
||||
pending?.resolve(message.capture);
|
||||
logger.debug("Native computer capture completed", {
|
||||
requestId: message.id,
|
||||
backend: message.capture.backend,
|
||||
capturePermission: message.capture.capturePermission,
|
||||
inputPermission: message.capture.inputPermission,
|
||||
width: message.capture.width,
|
||||
height: message.capture.height,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === "error") {
|
||||
const error = workerError(message.error);
|
||||
logger.warn("Native computer worker request failed", {
|
||||
requestId: message.id,
|
||||
errorName: message.error.name,
|
||||
});
|
||||
if (message.id) {
|
||||
const pending = this.#pending.get(message.id);
|
||||
this.#pending.delete(message.id);
|
||||
@@ -184,6 +208,9 @@ export class ComputerSupervisor implements ComputerController {
|
||||
}
|
||||
|
||||
async #terminate(reason: unknown): Promise<void> {
|
||||
logger.debug("Terminating native computer worker", {
|
||||
reason: reason instanceof Error ? reason.name : typeof reason,
|
||||
});
|
||||
const worker = this.#worker;
|
||||
this.#worker = undefined;
|
||||
this.#startPromise = undefined;
|
||||
|
||||
@@ -28,6 +28,7 @@ describe("AgentSession.setComputerToolEnabled", () => {
|
||||
registryDir = path.join(os.tmpdir(), `pi-computer-toggle-${Snowflake.next()}`);
|
||||
fs.mkdirSync(registryDir, { recursive: true });
|
||||
authStorage = await AuthStorage.create(path.join(registryDir, "auth.db"));
|
||||
authStorage.setRuntimeApiKey("google", "test-key");
|
||||
modelRegistry = new ModelRegistry(authStorage);
|
||||
});
|
||||
|
||||
@@ -70,6 +71,14 @@ describe("AgentSession.setComputerToolEnabled", () => {
|
||||
|
||||
// Re-enable reuses the retained registry entry.
|
||||
expect(await session.setComputerToolEnabled(true)).toBe(true);
|
||||
session.settings.override("computer.enabled", true);
|
||||
expect(session.getEnabledToolNames()).toContain("computer");
|
||||
|
||||
const gemini = getBundledModel("google", "gemini-2.5-flash");
|
||||
if (!gemini) throw new Error("Expected bundled Google Gemini model to exist");
|
||||
await session.setModel(gemini);
|
||||
expect(session.model).toBe(gemini);
|
||||
expect(session.settings.get("computer.enabled")).toBe(true);
|
||||
expect(session.getEnabledToolNames()).toContain("computer");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,20 +2,50 @@ import { describe, expect, it, vi } from "bun:test";
|
||||
import { executeAcpBuiltinSlashCommand } from "@oh-my-pi/pi-coding-agent/slash-commands/acp-builtins";
|
||||
import type { SlashCommandRuntime } from "@oh-my-pi/pi-coding-agent/slash-commands/types";
|
||||
|
||||
function acpRuntime(options?: { enabled?: boolean; applyResult?: boolean }) {
|
||||
const store = { "computer.enabled": options?.enabled ?? false };
|
||||
function acpRuntime(options?: {
|
||||
enabled?: boolean;
|
||||
applyResult?: boolean;
|
||||
supportsComputerUse?: boolean;
|
||||
codex?: boolean;
|
||||
}) {
|
||||
const store = {
|
||||
"computer.enabled": options?.enabled ?? false,
|
||||
"computer.backend": "auto",
|
||||
"computer.display": "all",
|
||||
"computer.maxWidth": 1920,
|
||||
"computer.maxHeight": 1200,
|
||||
};
|
||||
const get = vi.fn((path: string) => store[path as keyof typeof store]);
|
||||
const override = vi.fn((path: string, value: boolean) => {
|
||||
store[path as keyof typeof store] = value;
|
||||
if (path === "computer.enabled") store[path] = value;
|
||||
});
|
||||
const set = vi.fn();
|
||||
const setComputerToolEnabled = vi.fn(async () => options?.applyResult ?? true);
|
||||
const getEnabledToolNames = vi.fn(() => (store["computer.enabled"] ? ["computer"] : []));
|
||||
const output = vi.fn();
|
||||
const model = options?.codex
|
||||
? {
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.6-sol",
|
||||
api: "openai-codex-responses",
|
||||
supportsComputerUse: options.supportsComputerUse ?? false,
|
||||
}
|
||||
: {
|
||||
provider: "google",
|
||||
id: "gemini-2.5-flash",
|
||||
api: "google-generative-ai",
|
||||
supportsComputerUse: options?.supportsComputerUse ?? false,
|
||||
};
|
||||
const runtime = {
|
||||
session: { settings: { get, override, set }, setComputerToolEnabled },
|
||||
session: {
|
||||
settings: { get, override, set },
|
||||
setComputerToolEnabled,
|
||||
getEnabledToolNames,
|
||||
model,
|
||||
},
|
||||
output,
|
||||
} as unknown as SlashCommandRuntime;
|
||||
return { get, override, set, setComputerToolEnabled, output, runtime };
|
||||
return { get, override, set, setComputerToolEnabled, getEnabledToolNames, output, runtime };
|
||||
}
|
||||
|
||||
describe("/computer slash command", () => {
|
||||
@@ -28,7 +58,9 @@ describe("/computer slash command", () => {
|
||||
expect(h.setComputerToolEnabled).toHaveBeenCalledWith(true);
|
||||
expect(h.override).toHaveBeenCalledWith("computer.enabled", true);
|
||||
expect(h.set).not.toHaveBeenCalled();
|
||||
expect(h.output).toHaveBeenCalledWith("Computer use enabled for this session.");
|
||||
expect(h.output).toHaveBeenCalledWith(
|
||||
"Computer use enabled for this session. Computer use: enabled · tool: active · backend: auto · display: all · capture: 1920×1200 · model: google/gemini-2.5-flash · exposure: function",
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles an enabled session off", async () => {
|
||||
@@ -60,7 +92,29 @@ describe("/computer slash command", () => {
|
||||
|
||||
expect(h.setComputerToolEnabled).not.toHaveBeenCalled();
|
||||
expect(h.override).not.toHaveBeenCalled();
|
||||
expect(h.output).toHaveBeenCalledWith("Computer use is on.");
|
||||
expect(h.output).toHaveBeenCalledWith(
|
||||
"Computer use: enabled · tool: active · backend: auto · display: all · capture: 1920×1200 · model: google/gemini-2.5-flash · exposure: function",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports subscription Codex computer exposure as a callable function", async () => {
|
||||
const h = acpRuntime({ enabled: true, codex: true });
|
||||
|
||||
await executeAcpBuiltinSlashCommand("/computer status", h.runtime);
|
||||
|
||||
expect(h.output).toHaveBeenCalledWith(
|
||||
"Computer use: enabled · tool: active · backend: auto · display: all · capture: 1920×1200 · model: openai-codex/gpt-5.6-sol · exposure: function",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports explicit Codex native opt-in without masking the override", async () => {
|
||||
const h = acpRuntime({ enabled: true, codex: true, supportsComputerUse: true });
|
||||
|
||||
await executeAcpBuiltinSlashCommand("/computer status", h.runtime);
|
||||
|
||||
expect(h.output).toHaveBeenCalledWith(
|
||||
"Computer use: enabled · tool: active · backend: auto · display: all · capture: 1920×1200 · model: openai-codex/gpt-5.6-sol · exposure: native",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the override untouched when the session cannot build the tool", async () => {
|
||||
|
||||
@@ -142,6 +142,32 @@ describe("system prompt tool inventory", () => {
|
||||
expect(text).not.toContain("Reads files from disk.");
|
||||
});
|
||||
|
||||
it("keeps enabled computer routing explicit in compact native-tool mode", async () => {
|
||||
const tools = new Map(TOOLS);
|
||||
tools.set("computer", {
|
||||
label: "Computer",
|
||||
description: "Controls the host desktop.",
|
||||
parameters: { type: "object", properties: {} },
|
||||
});
|
||||
const { systemPrompt } = await buildSystemPrompt({
|
||||
cwd: tempDir,
|
||||
contextFiles: [],
|
||||
skills: [],
|
||||
rules: [],
|
||||
toolNames: ["read", "computer"],
|
||||
tools,
|
||||
workspaceTree: { ...EMPTY_TREE, rootPath: tempDir },
|
||||
nativeTools: true,
|
||||
inlineToolDescriptors: false,
|
||||
});
|
||||
const text = systemPrompt.join("\n\n");
|
||||
expect(text).toContain("# Computer Use");
|
||||
expect(text).toContain("The `computer` tool is explicitly enabled and available");
|
||||
expect(text).toContain("MUST use `computer` for requests to view or control host desktop applications");
|
||||
expect(text).toContain("NEVER claim Computer Use is unavailable");
|
||||
expect(text).toContain("Inspect the fresh screenshot returned by every successful `computer` call");
|
||||
});
|
||||
|
||||
it("renders `# Tool:` sections (not a name list) when tools are not native", async () => {
|
||||
const text = await render({ nativeTools: false, inlineToolDescriptors: false });
|
||||
expect(text).toContain("# Tool: read");
|
||||
|
||||
Reference in New Issue
Block a user