refactor: define canvas UI as an SDK client tool (#1797)

* refactor: define canvas UI as an SDK client tool

Send a JSON-defined canvas_ui_client tool on new, profile-based, and resumed conversation requests while retaining the legacy Python registration for persisted conversations. Normalize the new SDK event kinds to the existing Canvas UI rendering.

Co-authored-by: smolpaws <engel@enyst.org>

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: omit canvas client tool from ACP launches

* refactor: rename canvas client tool

Use the semantic canvas_ui_control name and contain the SDK-generated action discriminator behind exported constants.

Co-authored-by: Engel Nyst <engel.nyst@gmail.com>

---------

Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Debug Agent <157206163+simonrosenberg@users.noreply.github.com>
This commit is contained in:
🐾 smolpaws
2026-07-15 11:40:57 +02:00
committed by GitHub
parent d68bbc8d38
commit 2b7ceea667
27 changed files with 466 additions and 154 deletions
+75 -31
View File
@@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
import {
ACP_SERVER_TAG_KEY,
@@ -117,7 +118,6 @@ describe("buildStartConversationRequest", () => {
{ name: "terminal", params: {} },
{ name: "file_editor", params: {} },
{ name: "task_tracker", params: {} },
{ name: "canvas_ui", params: {} },
{ name: "browser_tool_set", params: {} },
{ name: "task_tool_set", params: {} },
]);
@@ -412,11 +412,7 @@ describe("buildStartConversationRequest", () => {
}) as Record<string, unknown>;
expect(payload.hook_config).toEqual({ on_start: [] });
// Canvas-UI tool is auto-injected; user-supplied entries are merged in
// alongside it. The dedicated canvas_ui describe block below pins the
// exact merge semantics.
expect(payload.tool_module_qualnames).toEqual({
canvas_ui: "canvas_ui_tool",
demo_tool: "pkg.tools.demo",
});
expect(payload.agent_definitions).toEqual([
@@ -595,40 +591,88 @@ describe("buildStartConversationRequest", () => {
});
});
describe("canvas_ui tool injection", () => {
it("registers canvas_ui_tool in tool_module_qualnames when the backend advertises canvas_ui", () => {
describe("canvas_ui client tool injection", () => {
it("sends canvas_ui as a client-defined JSON tool", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
}) as { tool_module_qualnames: Record<string, string> };
expect(payload.tool_module_qualnames).toMatchObject({
canvas_ui: "canvas_ui_tool",
});
});
it("omits canvas_ui and its module qualname when the backend does not advertise canvas_ui", () => {
mockIsAgentServerToolAvailable.mockImplementation(
(toolName: string) => toolName !== "canvas_ui",
);
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
}) as {
agent_settings: { tools: Array<{ name: string }> };
tool_module_qualnames?: Record<string, string>;
};
expect(payload.client_tools).toHaveLength(1);
expect(payload.client_tools[0]).toMatchObject({
name: CANVAS_UI_CLIENT_TOOL_NAME,
parameters: {
type: "object",
properties: {
command: {
enum: ["navigate_to_file", "open_tab", "show_preview"],
},
},
required: ["command"],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
});
expect(
payload.agent_settings.tools.map((tool) => tool.name),
payload.agent_settings?.tools?.map((tool) => tool.name) ?? [],
).not.toContain("canvas_ui");
expect(payload.tool_module_qualnames).toBeUndefined();
});
it("drops a user-supplied canvas_ui module qualname when the backend does not advertise canvas_ui", () => {
mockIsAgentServerToolAvailable.mockImplementation(
(toolName: string) => toolName !== "canvas_ui",
);
it("omits the client tool for an inline ACP agent", () => {
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
agent_settings: {
...DEFAULT_SETTINGS.agent_settings,
agent_kind: "acp",
acp_server: "custom",
acp_command: ["custom-acp"],
},
},
});
expect(payload.client_tools).toEqual([]);
});
it("omits the client tool for an ACP profile when global settings are stale", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
agentProfileId: "profile-acp",
agentProfileKind: "acp",
});
expect(payload.client_tools).toEqual([]);
});
it("sends the client tool for an OpenHands profile", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
agentProfileId: "profile-openhands",
agentProfileKind: "openhands",
});
expect(payload.client_tools.map((tool) => tool.name)).toEqual([
CANVAS_UI_CLIENT_TOOL_NAME,
]);
});
it("sends the client tool when resuming a conversation", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
conversationId: "legacy-conversation-id",
}) as { conversation_id: string; client_tools: Array<{ name: string }> };
expect(payload.conversation_id).toBe("legacy-conversation-id");
expect(payload.client_tools.map((tool) => tool.name)).toEqual([
CANVAS_UI_CLIENT_TOOL_NAME,
]);
});
it("drops conflicting user-supplied Canvas module qualnames", () => {
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
@@ -636,6 +680,7 @@ describe("buildStartConversationRequest", () => {
...DEFAULT_SETTINGS.conversation_settings,
tool_module_qualnames: {
canvas_ui: "custom_canvas_ui_tool",
[CANVAS_UI_CLIENT_TOOL_NAME]: "custom_canvas_ui_control_tool",
my_tool: "my_package.my_tool",
},
},
@@ -647,7 +692,7 @@ describe("buildStartConversationRequest", () => {
});
});
it("merges user-supplied tool_module_qualnames alongside canvas_ui_tool without dropping either side", () => {
it("preserves non-Canvas custom tool modules", () => {
const payload = buildStartConversationRequest({
settings: {
...DEFAULT_SETTINGS,
@@ -659,7 +704,6 @@ describe("buildStartConversationRequest", () => {
}) as { tool_module_qualnames: Record<string, string> };
expect(payload.tool_module_qualnames).toEqual({
canvas_ui: "canvas_ui_tool",
my_tool: "my_package.my_tool",
});
});
@@ -1,7 +1,15 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
CANVAS_UI_CLIENT_ACTION_KIND,
CANVAS_UI_CLIENT_TOOL_NAME,
} from "#/constants/canvas-ui";
import { getEventContent } from "#/components/conversation-events/chat";
import { ActionEvent, ObservationEvent, SecurityRisk } from "#/types/agent-server/core";
import {
ActionEvent,
ObservationEvent,
SecurityRisk,
} from "#/types/agent-server/core";
const terminalActionEvent: ActionEvent = {
id: "action-1",
@@ -326,4 +334,62 @@ describe("getEventContent", () => {
"UI command 'open_tab' dispatched to the Agent Canvas frontend.",
);
});
it("renders client-defined Canvas UI events like legacy events", () => {
const canvasUIAction: ActionEvent = {
id: "action-canvas-client",
timestamp: new Date().toISOString(),
source: "agent",
thought: [],
thinking_blocks: [],
action: {
kind: CANVAS_UI_CLIENT_ACTION_KIND,
command: "open_tab",
path: null,
tab: "files",
},
tool_name: CANVAS_UI_CLIENT_TOOL_NAME,
tool_call_id: "tool-canvas-client",
tool_call: {
id: "tool-canvas-client",
type: "function",
function: {
name: CANVAS_UI_CLIENT_TOOL_NAME,
arguments: '{"command":"open_tab","tab":"files"}',
},
},
llm_response_id: "response-canvas-client",
security_risk: SecurityRisk.LOW,
summary: "",
};
const canvasUIObservation: ObservationEvent = {
id: "obs-canvas-client",
timestamp: new Date().toISOString(),
source: "environment",
tool_name: CANVAS_UI_CLIENT_TOOL_NAME,
tool_call_id: "tool-canvas-client",
action_id: "action-canvas-client",
observation: {
kind: "ClientToolObservation",
content: [{ type: "text", text: "Tool call dispatched to client." }],
is_error: false,
},
};
const actionContent = getEventContent(canvasUIAction);
render(<span>{actionContent.title}</span>);
expect(screen.getByText("CANVASUI")).toBeInTheDocument();
const { title, details } = getEventContent(
canvasUIObservation,
canvasUIAction,
);
render(<span>{title}</span>);
expect(
screen.getByText("OBSERVATION_MESSAGE$CANVAS_UI"),
).toBeInTheDocument();
expect(details).toBe(
"UI command 'open_tab' dispatched to the Agent Canvas frontend.",
);
});
});
@@ -382,8 +382,8 @@ describe("useCreateConversation", () => {
// The active profile IS the well-known default → it's the enriched baseline
// (mirrors agent_settings), not a deliberate profile pick, so the launch
// stays on the agent_settings path (no profile tail) even though its
// llm_profile_ref resolves. Keeps <RUNTIME_SERVICES>/canvas_ui/project
// skills, which the profile-resolution path drops.
// llm_profile_ref resolves. Keeps <RUNTIME_SERVICES> and project skills,
// which the profile-resolution path drops.
listAgentProfilesMock.mockResolvedValue({
profiles: [
{
@@ -461,6 +461,7 @@ describe("useCreateConversation", () => {
const call = createConversationSpy.mock.lastCall;
expect(call?.[9]).toBe("profile-acp-default");
expect(call?.[10]).toBe("acp");
});
it("launches the seeded `default` profile from its resolved id on cloud (no agent_settings fallback exists there) (#1571)", async () => {
+19 -4
View File
@@ -1,5 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
CANVAS_UI_CLIENT_ACTION_KIND,
CANVAS_UI_CLIENT_TOOL_NAME,
LEGACY_CANVAS_UI_TOOL_NAME,
} from "#/constants/canvas-ui";
import { handleCanvasUIAction } from "#/services/canvas-ui";
import { useConversationStore } from "#/stores/conversation-store";
import { useFilesTabStore } from "#/stores/files-tab-store";
@@ -98,14 +103,24 @@ describe("isCanvasUIActionEvent", () => {
timestamp: "2026-05-13T00:00:00Z",
source: "agent",
action: { kind: "CanvasUIAction" },
tool_name: "canvas_ui",
tool_name: LEGACY_CANVAS_UI_TOOL_NAME,
tool_call_id: "call-1",
...overrides,
};
}
it("returns true for an ActionEvent whose tool_name is canvas_ui", () => {
expect(isCanvasUIActionEvent(makeActionEvent() as never)).toBe(true);
it.each([
["CanvasUIAction", LEGACY_CANVAS_UI_TOOL_NAME],
[CANVAS_UI_CLIENT_ACTION_KIND, CANVAS_UI_CLIENT_TOOL_NAME],
])("returns true for a %s ActionEvent from %s", (kind, toolName) => {
expect(
isCanvasUIActionEvent(
makeActionEvent({
action: { kind, command: "open_tab" },
tool_name: toolName,
}) as never,
),
).toBe(true);
});
it("returns false when tool_name belongs to a different tool", () => {
@@ -122,7 +137,7 @@ describe("isCanvasUIActionEvent", () => {
timestamp: "2026-05-13T00:00:00Z",
source: "environment",
observation: { kind: "ExecuteBashObservation" },
tool_name: "canvas_ui",
tool_name: LEGACY_CANVAS_UI_TOOL_NAME,
tool_call_id: "call-1",
};
+33 -7
View File
@@ -3,18 +3,44 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const toolSource = readFileSync(resolve(repoRoot, "tools/canvas_ui_tool.py"), "utf8");
import {
CANVAS_UI_CLIENT_ACTION_KIND,
CANVAS_UI_CLIENT_TOOL,
CANVAS_UI_CLIENT_TOOL_NAME,
LEGACY_CANVAS_UI_TOOL_NAME,
} from "#/api/canvas-ui-client-tool";
describe("canvas_ui browser guidance", () => {
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const legacyToolSource = readFileSync(
resolve(repoRoot, "tools/canvas_ui_tool.py"),
"utf8",
);
describe("canvas_ui client tool", () => {
it("tells the agent to capture a browser screenshot before opening the browser tab", () => {
const captureInstruction = "browser_get_state(include_screenshot=true)";
const openBrowserInstruction = 'command="open_tab", tab="browser"';
expect(toolSource).toContain(captureInstruction);
expect(toolSource).toContain(openBrowserInstruction);
expect(toolSource.indexOf(captureInstruction)).toBeLessThan(
toolSource.indexOf(openBrowserInstruction),
expect(CANVAS_UI_CLIENT_TOOL.description).toContain(captureInstruction);
expect(CANVAS_UI_CLIENT_TOOL.description).toContain(openBrowserInstruction);
expect(
CANVAS_UI_CLIENT_TOOL.description.indexOf(captureInstruction),
).toBeLessThan(
CANVAS_UI_CLIENT_TOOL.description.indexOf(openBrowserInstruction),
);
});
it("exports the semantic tool name and generated action kind", () => {
expect(CANVAS_UI_CLIENT_TOOL_NAME).toBe("canvas_ui_control");
expect(CANVAS_UI_CLIENT_ACTION_KIND).toBe("ClientAction_canvas_ui_control");
expect(CANVAS_UI_CLIENT_TOOL.name).toBe(CANVAS_UI_CLIENT_TOOL_NAME);
expect(CANVAS_UI_CLIENT_TOOL.name).not.toBe(LEGACY_CANVAS_UI_TOOL_NAME);
});
it("retains the Python registration shim for persisted conversations", () => {
expect(legacyToolSource).toContain("Legacy conversation compatibility");
expect(legacyToolSource).toContain(
'register_tool("canvas_ui", CanvasUITool)',
);
});
});
+3 -2
View File
@@ -126,8 +126,9 @@ COPY --from=frontend-build /build/node_modules/totalist /opt/agent-canvas/node_m
# emit the agent's <RUNTIME_SERVICES> block; same builder the dev stack uses).
COPY scripts/runtime-services-info.mjs /opt/agent-canvas/runtime-services-info.mjs
# Copy custom tools (e.g. canvas_ui_tool.py) so the agent-server can import
# them via tool_module_qualnames. OH_EXTRA_PYTHON_PATH is set in entrypoint.sh.
# Persisted conversations created before the client_tools migration still
# import canvas_ui_tool by qualname. Keep the compatibility module available;
# new conversations define canvas_ui_control through a JSON client tool.
COPY tools/ /opt/agent-canvas/tools/
# Copy generated defaults.env (from config/defaults.json via config-gen stage)
+2 -3
View File
@@ -124,9 +124,8 @@ export AGENT_SERVER_URL="${AGENT_SERVER_URL:-http://127.0.0.1:${AGENT_SERVER_POR
# for locally-generated session keys.
export AUTOMATION_AGENT_SERVER_URL="${AUTOMATION_AGENT_SERVER_URL:-http://127.0.0.1:${AGENT_SERVER_PORT}}"
# Make custom tools (e.g. canvas_ui_tool.py) importable by the agent-server
# via tool_module_qualnames. Matches what scripts/dev-safe.mjs does with
# OH_EXTRA_PYTHON_PATH: config.canvasToolsDir.
# Keep the legacy canvas_ui_tool module importable when the agent-server restores
# conversations whose persisted metadata still references its module qualname.
export OH_EXTRA_PYTHON_PATH="${OH_EXTRA_PYTHON_PATH:-/opt/agent-canvas/tools}"
# Track child PIDs so we can clean up on exit.
+4 -3
View File
@@ -144,9 +144,10 @@ point Canvas at it). In short:
```bash
# 1. Agent Server in a container (CORS allows localhost, so the browser talks
# to it directly). The image pre-installs the ACP CLI wrappers. The
# canvas_ui tool is mounted so the agent-server can import the module Canvas
# references in every start request.
# to it directly). The image pre-installs the ACP CLI wrappers. New
# canvas_ui_control calls use client_tools; the Python mount keeps
# pre-migration conversations loadable when persisted metadata imports
# canvas_ui_tool.
# Minimum image: 1.28.0-python (first compatible ACP provider/model protocol
# surface for current Canvas). Override SHA with a newer build.
docker run -d --name oh-acp -p 8010:8000 -v acp-data:/workspace \
+5 -6
View File
@@ -30,10 +30,9 @@ services:
# host:container — Canvas points VITE_BACKEND_BASE_URL at http://localhost:8010.
- "8010:8000"
environment:
# Canvas's start request always references the bundled ``canvas_ui`` tool
# via ``tool_module_qualnames``; the agent-server imports that module from
# OH_EXTRA_PYTHON_PATH at conversation creation. Without the mount below +
# this var, conversation creation fails to import ``canvas_ui_tool``.
# New conversations define canvas_ui_control through client_tools. Keep the
# old Python module importable so conversations persisted before that migration
# can restore CanvasUIAction / CanvasUIObservation events after a restart.
- OH_EXTRA_PYTHON_PATH=/canvas-tools
# Optional cipher key. ACP conversations work without it (Canvas sends ACP
# provider credentials as loopback LookupSecrets resolved from the
@@ -66,8 +65,8 @@ services:
# Persist conversations AND the credential files the SDK materialises
# (Codex auth.json under CODEX_HOME, Gemini ADC/SA JSON) across restarts.
- acp-data:/workspace
# Canvas-specific Python tools (the canvas_ui tool) the agent-server loads
# via OH_EXTRA_PYTHON_PATH. Path is relative to this compose file.
# Legacy canvas_ui module used only when restoring pre-client_tools state.
# Path is relative to this compose file.
- ../../tools:/canvas-tools:ro
restart: unless-stopped
+5 -6
View File
@@ -636,10 +636,9 @@ function buildConfigFromPorts(ports, cwd, env) {
env.LOCAL_BACKEND_API_KEY ||
getOrCreatePersistedApiKeyFile(persistedKeyPath);
// Host directory containing Agent-Canvas-specific Python tools (e.g. the
// canvas_ui tool). Added to OH_EXTRA_PYTHON_PATH below so the agent-server
// can import the modules listed in `tool_module_qualnames`. Lives at
// <repo-root>/tools relative to this script.
// Host directory containing the legacy canvas_ui Python module. Persisted
// conversations created before the client_tools migration still reference
// its module qualname, so the agent-server can import it when resuming them.
const canvasToolsDir = fileURLToPath(new URL("../tools", import.meta.url));
return {
@@ -719,8 +718,8 @@ export function buildAgentServerEnv(config) {
// a follow-up change to the automation preset reads
// OH_SESSION_API_KEYS_0 directly (which is already in env).
AGENT_SERVER_URL: config.backendBaseUrl,
// Make the host tools/ directory importable so the agent-server can
// resolve modules listed in tool_module_qualnames (e.g. canvas_ui_tool).
// Let the agent-server resolve canvas_ui_tool when old persisted metadata
// requests that compatibility module during startup.
OH_EXTRA_PYTHON_PATH: config.canvasToolsDir,
};
}
+5
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
import { DEFAULT_SETTINGS } from "#/services/settings";
import type { Settings } from "#/types/settings";
import { buildStartConversationRequest } from "./agent-server-adapter";
@@ -120,10 +121,14 @@ describe("buildStartConversationRequest — agentProfileId path", () => {
const payload = buildStartConversationRequest({
settings,
agentProfileId: "profile-xyz",
agentProfileKind: "openhands",
});
expect(payload.agent_profile_id).toBe("profile-xyz");
expect(payload.agent_settings).toBeUndefined();
expect(payload.client_tools.map((tool) => tool.name)).toEqual([
CANVAS_UI_CLIENT_TOOL_NAME,
]);
});
it("suppresses the ACP server tag when launching from a profile", () => {
+27 -34
View File
@@ -2,7 +2,7 @@ import { ACP_SETTINGS_KEYS } from "@openhands/typescript-client";
import { SKILLS_CATALOG } from "@openhands/extensions/skills";
import { DEFAULT_SETTINGS } from "#/services/settings";
import { ExecutionStatus } from "#/types/agent-server/core";
import { Settings, SettingsValue } from "#/types/settings";
import { AgentKind, Settings, SettingsValue } from "#/types/settings";
import {
getAcpPreferredDefaultModel,
getAcpProvider,
@@ -28,6 +28,12 @@ import {
OPENAI_SUBSCRIPTION_VENDOR,
isSubscriptionLlmConfig,
} from "#/constants/llm-subscription";
import {
CANVAS_UI_CLIENT_TOOL,
CANVAS_UI_CLIENT_TOOL_NAME,
LEGACY_CANVAS_UI_TOOL_NAME,
type ClientToolSpec,
} from "./canvas-ui-client-tool";
export interface DirectConversationInfo {
id: string;
@@ -88,18 +94,7 @@ export interface DirectConversationInfo {
} | null;
}
// Module qualname for the Canvas-UI tool. The agent-server imports this via
// tool_module_qualnames; the host directory is exposed via OH_EXTRA_PYTHON_PATH
// (see scripts/dev-safe.mjs).
const CANVAS_UI_TOOL_NAME = "canvas_ui";
const CANVAS_UI_TOOL_MODULE = "canvas_ui_tool";
const DEFAULT_TOOL_NAMES = [
"terminal",
"file_editor",
"task_tracker",
CANVAS_UI_TOOL_NAME,
];
const DEFAULT_TOOL_NAMES = ["terminal", "file_editor", "task_tracker"];
const BROWSER_TOOL_SET_NAME = "browser_tool_set";
const TASK_TOOL_SET_NAME = "task_tool_set";
@@ -518,10 +513,6 @@ function isToolRecord(
}
function shouldIncludeTool(name: string, agentSettings: SettingsRecord) {
if (name === CANVAS_UI_TOOL_NAME) {
return isAgentServerToolAvailable(name);
}
if (name === BROWSER_TOOL_SET_NAME) {
return browserToolsEnabled() && isAgentServerToolAvailable(name);
}
@@ -889,6 +880,7 @@ type StartConversationPayload = Record<string, unknown> & {
conversation_id?: string;
secrets?: Record<string, LookupSecret>;
tags?: Record<string, string>;
client_tools: ClientToolSpec[];
tool_module_qualnames?: Record<string, string>;
};
@@ -907,6 +899,7 @@ export interface StartConversationOptions {
// When set, the conversation launches from this AgentProfile (resolved
// server-side) instead of an inline ``agent_settings`` dump (#3727).
agentProfileId?: string;
agentProfileKind?: AgentKind;
}
export function buildStartConversationRequest(
@@ -917,6 +910,11 @@ export function buildStartConversationRequest(
: options.settings;
const acpMode = isAcpAgent(sourceAgentSettings);
const launchAgentKind = options.agentProfileId
? options.agentProfileKind
: acpMode
? "acp"
: "openhands";
const agentSettings = buildConfiguredAgentSettings(sourceAgentSettings);
const acpServerTag = acpMode
? getAcpServerTag(sourceAgentSettings)
@@ -947,14 +945,15 @@ export function buildStartConversationRequest(
// server/SDK's responsibility to restore on the profile path — tracked in
// software-agent-sdk#3967 (profile resolution must attach the default
// toolset + public skills, else a profile-launched OpenHands agent has only
// Finish/Think). The two genuinely canvas-only enrichments — the
// ``canvas_ui`` tool and the dev ``RUNTIME_SERVICES`` system-message-suffix
// (``buildAgentContext``) — have no server-side representation and are
// intentionally not carried on the profile path.
// Finish/Think). The dev ``RUNTIME_SERVICES`` system-message suffix remains
// agent-settings-only; the Canvas UI tool is a top-level client tool and
// therefore works on both inline-agent and profile launch paths.
...(options.agentProfileId
? { agent_profile_id: options.agentProfileId }
: { agent_settings: agentSettings }),
workspace: conversationSettings.workspace,
client_tools:
launchAgentKind === "openhands" ? [CANVAS_UI_CLIENT_TOOL] : [],
confirmation_policy:
getConversationConfirmationPolicy(conversationSettings),
max_iterations:
@@ -1008,20 +1007,13 @@ export function buildStartConversationRequest(
payload.hook_config = conversationSettings.hook_config;
}
const toolModuleQualnames: Record<string, string> = {};
const canvasUiAvailable = isAgentServerToolAvailable(CANVAS_UI_TOOL_NAME);
if (canvasUiAvailable) {
toolModuleQualnames[CANVAS_UI_TOOL_NAME] = CANVAS_UI_TOOL_MODULE;
}
Object.assign(
toolModuleQualnames,
(conversationSettings.tool_module_qualnames as
const toolModuleQualnames = {
...((conversationSettings.tool_module_qualnames as
| Record<string, string>
| undefined) ?? {},
);
if (!canvasUiAvailable) {
delete toolModuleQualnames[CANVAS_UI_TOOL_NAME];
}
| undefined) ?? {}),
};
delete toolModuleQualnames[LEGACY_CANVAS_UI_TOOL_NAME];
delete toolModuleQualnames[CANVAS_UI_CLIENT_TOOL_NAME];
if (Object.keys(toolModuleQualnames).length > 0) {
payload.tool_module_qualnames = toolModuleQualnames;
}
@@ -1090,6 +1082,7 @@ export async function buildStartConversationRequestWithEncryptedSettings(options
workingDir?: string;
worktree?: boolean;
agentProfileId?: string;
agentProfileKind?: AgentKind;
}): Promise<Record<string, unknown>> {
const { SecretsService } = await import("./secrets-service");
+91
View File
@@ -0,0 +1,91 @@
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
export {
CANVAS_UI_CLIENT_ACTION_KIND,
CANVAS_UI_CLIENT_TOOL_NAME,
LEGACY_CANVAS_UI_TOOL_NAME,
} from "#/constants/canvas-ui";
export interface ClientToolSpec {
name: string;
description: string;
parameters: Record<string, unknown>;
annotations?: {
title?: string | null;
readOnlyHint: boolean;
destructiveHint: boolean;
idempotentHint: boolean;
openWorldHint: boolean;
};
}
const CANVAS_UI_DESCRIPTION = `The user is interacting with you inside Agent Canvas — a web UI with a chat panel on the left and a tabbed right-side panel (files, terminal, browser, vscode, planner, tasklist). This tool lets you drive that right-side panel so the user sees what you just produced.
They will NOT see the files you wrote, the terminal output, or the browser
unless you call this tool to switch the right-side panel to the relevant
tab. Call this every time you finish work that produces something the user
should look at — don't rely on them noticing on their own.
When to call (pick the most specific option that matches your last action):
* You wrote or modified a single file (ANY language, ANY size — including
small scripts like a hello-world bash file) →
command="navigate_to_file", path=<workspace-relative path of that file>
* You generated an HTML page, image, SVG, PDF, markdown report, or other
previewable artifact →
command="show_preview", path=<that file>
* You finished editing multiple files in one logical step →
command="open_tab", tab="files"
(The Files tab automatically renders a diff view when the workspace has
uncommitted git changes, which covers the "highlight changes" case.)
* You ran a long-running terminal command, or one whose output the user
should inspect →
command="open_tab", tab="terminal"
* You browsed to a URL the user should see →
First call browser_get_state(include_screenshot=true) after your final
browser interaction so Agent Canvas has a screenshot to display, then call
command="open_tab", tab="browser"
(browser_navigate alone only updates the URL; without browser_get_state,
the Browser tab will open without a screenshot.)
Call this BEFORE writing your chat-message summary of the change, so the
artifact is visible while the user reads what you did. One canvas_ui_control
call per logical step is enough — don't repeat it for the same file or tab in
the same turn.`;
export const CANVAS_UI_CLIENT_TOOL: ClientToolSpec = {
name: CANVAS_UI_CLIENT_TOOL_NAME,
description: CANVAS_UI_DESCRIPTION,
parameters: {
type: "object",
additionalProperties: false,
properties: {
command: {
type: "string",
enum: ["navigate_to_file", "open_tab", "show_preview"],
description: "UI command to dispatch.",
},
path: {
type: "string",
description:
"Workspace-relative file path. Required for navigate_to_file and show_preview; ignored otherwise.",
},
tab: {
type: "string",
enum: ["files", "browser", "vscode", "terminal", "planner", "tasklist"],
description: "Tab to open. Required for open_tab; ignored otherwise.",
},
},
required: ["command"],
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
},
};
@@ -10,7 +10,7 @@ import {
VSCodeClient,
} from "@openhands/typescript-client/clients";
import { v4 as uuidv4 } from "uuid";
import { Provider } from "#/types/settings";
import { AgentKind, Provider } from "#/types/settings";
import type { ConversationRuntimeContext } from "#/api/conversation-file-upload.api";
import { buildHttpBaseUrl } from "#/utils/websocket-url";
import {
@@ -376,6 +376,7 @@ class AgentServerConversationService {
// cloud app-server (OpenHands #15060): local threads it through the
// encrypted-settings builder; cloud sends it as a flat request field.
agentProfileId?: string,
agentProfileKind?: AgentKind,
): Promise<AppConversationStartTask> {
if (getActiveBackend().backend.kind === "cloud") {
// Cloud path mirrors OpenHands' frontend: build a flat
@@ -429,6 +430,7 @@ class AgentServerConversationService {
workingDir,
worktree: resolvedWorkspaceMode === "new_worktree",
agentProfileId,
agentProfileKind,
});
const data = await new ConversationClient(
@@ -1,5 +1,9 @@
import { Trans } from "react-i18next";
import React from "react";
import {
CANVAS_UI_CLIENT_ACTION_KIND,
CANVAS_UI_CLIENT_TOOL_NAME,
} from "#/constants/canvas-ui";
import {
OpenHandsEvent,
ObservationEvent,
@@ -9,6 +13,7 @@ import {
isActionEvent,
isObservationEvent,
isACPToolCallEvent,
isCanvasUIActionEvent,
} from "#/types/agent-server/type-guards";
import { MonoComponent } from "../../../features/chat/mono-component";
import { PathComponent } from "../../../features/chat/path-component";
@@ -236,6 +241,9 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
case "BrowserCloseTabAction":
actionKey = "ACTION_MESSAGE$BROWSE";
break;
case "CanvasUIAction":
case CANVAS_UI_CLIENT_ACTION_KIND:
return "CANVASUI";
default:
// For unknown actions, use the type name
return String(actionType).replace("Action", "").toUpperCase();
@@ -324,6 +332,12 @@ const getObservationEventTitle = (
case "CanvasUIObservation":
observationKey = "OBSERVATION_MESSAGE$CANVAS_UI";
break;
case "ClientToolObservation":
if (event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME) {
observationKey = "OBSERVATION_MESSAGE$CANVAS_UI";
break;
}
return observationType.replace("Observation", "").toUpperCase();
case "SwitchLLMObservation":
observationKey = event.observation.is_error
? "MODEL$SWITCH_FAILED"
@@ -376,6 +390,22 @@ const getObservationEventTitle = (
return observationType;
};
const getCanvasUIClientObservationContent = (
event: ObservationEvent,
correspondingAction?: ActionEvent,
): string | null => {
if (
event.observation.kind !== "ClientToolObservation" ||
event.tool_name !== CANVAS_UI_CLIENT_TOOL_NAME ||
!correspondingAction ||
!isCanvasUIActionEvent(correspondingAction)
) {
return null;
}
return `UI command '${correspondingAction.action.command}' dispatched to the Agent Canvas frontend.`;
};
export const getEventContent = (
event: OpenHandsEvent | SkillReadyEvent,
correspondingAction?: ActionEvent,
@@ -409,6 +439,7 @@ export const getEventContent = (
);
} else {
details =
getCanvasUIClientObservationContent(event, correspondingAction) ??
resolveVisualizerBody(event, correspondingAction) ??
getObservationContent(event);
}
@@ -1,3 +1,4 @@
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
import { ObservationEvent } from "#/types/agent-server/core";
import { getObservationResult } from "./get-observation-result";
import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
@@ -417,6 +418,13 @@ export const getObservationContent = (event: ObservationEvent): string => {
event as ObservationEvent<CanvasUIObservation>,
);
case "ClientToolObservation":
return event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME
? getCanvasUIObservationContent(
event as ObservationEvent<CanvasUIObservation>,
)
: getDefaultEventContent(event);
case "SwitchLLMObservation":
return getSwitchLLMObservationContent(
event as ObservationEvent<SwitchLLMObservation>,
+10
View File
@@ -0,0 +1,10 @@
export const LEGACY_CANVAS_UI_TOOL_NAME = "canvas_ui";
export const CANVAS_UI_CLIENT_TOOL_NAME = "canvas_ui_control";
/**
* Action discriminator generated by the SDK for the client-defined Canvas UI
* tool (`ClientAction_<tool-name>`). Keep the SDK naming convention contained
* here instead of repeating its generated class name throughout the frontend.
*/
export const CANVAS_UI_CLIENT_ACTION_KIND =
`ClientAction_${CANVAS_UI_CLIENT_TOOL_NAME}` as const;
@@ -637,10 +637,9 @@ export function ConversationWebSocketProvider({
invalidateConversationQueries(queryClient, conversationId);
}
// Handle canvas_ui custom-tool ActionEvents - drive the frontend
// (navigate to a file, switch tabs, show a preview). The tool
// executes server-side as a no-op; the actual UI change happens
// here on the client.
// Handle canvas_ui ActionEvents from both the legacy Python tool and
// the client-defined JSON tool. The server acknowledges immediately;
// the actual UI change happens here on the client.
if (isCanvasUIActionEvent(event)) {
handleCanvasUIAction(event.action, conversationId ?? null);
}
+18 -13
View File
@@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
import { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types";
import { SuggestedTask } from "#/utils/types";
import { Provider } from "#/types/settings";
import { AgentKind, Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
import { useLlmProfiles } from "#/hooks/query/use-llm-profiles";
import { useAgentProfiles } from "#/hooks/query/use-agent-profiles";
@@ -149,17 +149,17 @@ export const useCreateConversation = () => {
// deliberate profile pick — it mirrors global agent_settings. Launch it
// via agent_settings so the canvas-only enrichments the profile-resolution
// path drops survive for the common home-launch: the <RUNTIME_SERVICES>
// system-message suffix, the canvas_ui tool, and project-skill loading
// (buildAgentContext). Named profiles are deliberate custom configs and
// still use the profile path (accepting that enrichment boundary).
// system-message suffix and project-skill loading (buildAgentContext).
// Named profiles are deliberate custom configs and still use the profile
// path (accepting that enrichment boundary).
// Trade-off: per-profile fields set on `default` itself don't apply on
// home-launch — custom per-profile config belongs in a named profile.
//
// Scoped to OpenHands: an ACP `default` must keep the profile path.
// Activation is pointer-only, so global agent_settings is stale (often
// still OpenHands) when an ACP profile is active — launching it via
// agent_settings would start the wrong agent. ACP also carries no
// <RUNTIME_SERVICES>/canvas_ui enrichment, so there's nothing to preserve.
// agent_settings would start the wrong agent. ACP carries no
// <RUNTIME_SERVICES> enrichment, so there's nothing to preserve.
//
// Scoped to local: cloud never writes agent_settings, so it always
// resolves `default` server-side via agent_profile_id (validated below).
@@ -198,15 +198,20 @@ export const useCreateConversation = () => {
}
}
// Only extend the call with the [sandboxId, agentProfileId] tail when
// launching from a profile, so a plain create stays byte-identical to
// the legacy agent_settings path (#3727). sandboxId is unused here.
// TODO(#1587): createConversation has grown to 10 positional params;
// Only extend the call with the profile tail when launching from a
// profile, so a plain create stays byte-identical to the legacy
// agent_settings path (#3727). sandboxId is unused here.
// TODO(#1587): createConversation has grown to 11 positional params;
// refactor it to an options object so this position-skipping tail isn't
// needed.
const profileArgs: [undefined, string] | [] = effectiveAgentProfileId
? [undefined, effectiveAgentProfileId]
: [];
const profileArgs: [undefined, string, AgentKind | undefined] | [] =
effectiveAgentProfileId
? [
undefined,
effectiveAgentProfileId,
resolvedAgentProfile?.agent_kind,
]
: [];
const conversation =
await AgentServerConversationService.createConversation(
+7 -4
View File
@@ -1,3 +1,4 @@
import { CANVAS_UI_CLIENT_ACTION_KIND } from "#/constants/canvas-ui";
import { ActionBase } from "./base";
import { TaskItem } from "./common";
@@ -308,11 +309,13 @@ export interface SwitchLLMAction extends ActionBase<"SwitchLLMAction"> {
}
/**
* Frontend-injected custom tool. Emitted over the existing WebSocket as a
* regular ActionEvent; intercepted client-side by handleCanvasUIAction.
* The Python definition lives in tools/canvas_ui_tool.py.
* Canvas UI action emitted over the existing WebSocket and intercepted by
* handleCanvasUIAction. Legacy conversations use CanvasUIAction; new
* conversations use the SDK-generated client action kind.
*/
export interface CanvasUIAction extends ActionBase<"CanvasUIAction"> {
export interface CanvasUIAction extends ActionBase<
"CanvasUIAction" | typeof CANVAS_UI_CLIENT_ACTION_KIND
> {
command: "navigate_to_file" | "open_tab" | "show_preview";
path?: string | null;
tab?: string | null;
+9 -6
View File
@@ -1,3 +1,5 @@
import { CANVAS_UI_CLIENT_ACTION_KIND } from "#/constants/canvas-ui";
type EventType =
| "MCPTool"
| "Finish"
@@ -22,9 +24,8 @@ type ActionOnlyType =
| "BrowserListTabs"
| "BrowserSwitchTab"
| "BrowserCloseTab"
// Frontend-injected custom tool. Not part of the upstream SDK Action
// union but emitted as a regular ActionEvent over the WebSocket. See
// tools/canvas_ui_tool.py and src/services/canvas-ui.ts.
// Legacy Python-defined Canvas tool kind. The client-tool kind is added
// separately below because the SDK generates its full discriminator.
| "CanvasUI";
type ObservationOnlyType = "Browser";
@@ -35,7 +36,8 @@ type ActionEventType =
| "GlobAction"
| "GrepAction"
// The `task` tool delegating work to a spawned subagent.
| "TaskAction";
| "TaskAction"
| typeof CANVAS_UI_CLIENT_ACTION_KIND;
type ObservationEventType =
| `${ObservationOnlyType}Observation`
| `${EventType}Observation`
@@ -44,8 +46,9 @@ type ObservationEventType =
| "GrepObservation"
// Result of the `task` tool, which delegates work to a spawned subagent.
| "TaskObservation"
// Acknowledgement emitted after a `canvas_ui` command is dispatched.
| "CanvasUIObservation";
// Legacy and client-defined acknowledgements for Canvas UI dispatches.
| "CanvasUIObservation"
| "ClientToolObservation";
export interface ActionBase<T extends ActionEventType = ActionEventType> {
kind: T;
@@ -325,7 +325,9 @@ export interface TaskObservation extends ObservationBase<"TaskObservation"> {
status: string;
}
export interface CanvasUIObservation extends ObservationBase<"CanvasUIObservation"> {
export interface CanvasUIObservation extends ObservationBase<
"CanvasUIObservation" | "ClientToolObservation"
> {
/**
* Acknowledgement text returned after the canvas UI command is dispatched.
*/
+11 -6
View File
@@ -1,3 +1,8 @@
import {
CANVAS_UI_CLIENT_TOOL_NAME,
LEGACY_CANVAS_UI_TOOL_NAME,
} from "#/constants/canvas-ui";
import {
OpenHandsEvent,
ObservationEvent,
@@ -167,17 +172,17 @@ export const isBrowserNavigateActionEvent = (
isActionEvent(event) && event.action.kind === "BrowserNavigateAction";
/**
* Type guard for the canvas_ui custom tool's ActionEvent.
* Type guard for Canvas UI tool ActionEvents.
*
* The tool is injected via tool_module_qualnames (see canvas_ui_tool.py and
* agent-server-adapter.ts). We discriminate on tool_name (which we control
* via register_tool("canvas_ui", ...)). The predicate narrows the event so
* the call site can read `event.action.command` etc. without further casts.
* Discriminating on tool_name supports legacy CanvasUIAction events and the
* SDK-generated action kind without leaking that generated name here.
*/
export const isCanvasUIActionEvent = (
event: OpenHandsEvent,
): event is ActionEvent<CanvasUIAction> =>
isActionEvent(event) && event.tool_name === "canvas_ui";
isActionEvent(event) &&
(event.tool_name === LEGACY_CANVAS_UI_TOOL_NAME ||
event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME);
/**
* Type guard function to check if an event is a system prompt event
+3 -3
View File
@@ -18,12 +18,12 @@ and needs a running container + real host credentials).
## Run it
```bash
# 1. Agent-server container with the canvas_ui tool mounted (as the dev stack does).
# Minimum 1.25.0-python (software-agent-sdk#3510); override for a newer build.
# 1. Agent-server container. v1.28.0 adds the client_tools API used by Canvas.
# The Python mount keeps pre-migration conversation state loadable.
docker run -d --name oh-acp -p 8010:8000 \
-v oh-acp-data:/workspace \
-v "$(pwd)/tools:/canvas-tools:ro" -e OH_EXTRA_PYTHON_PATH=/canvas-tools \
ghcr.io/openhands/agent-server:1.25.0-python
ghcr.io/openhands/agent-server:1.28.0-python
# 2. Run the e2e (all providers, or a subset).
npx vite-node -c tests/e2e/live-acp/vite-node.config.mts \
+5 -5
View File
@@ -10,17 +10,17 @@
* LookupSecrets Canvas emits resolve back from the store and authenticate the
* CLI end-to-end (including the SDK's acp_file_secrets materialisation).
*
* Requires an agent-server with software-agent-sdk#3510 (first in v1.25.0): the
* ACP credentials ride as loopback LookupSecrets, and only #3510 resolves them
* off the event loop — an older image deadlocks ("Failed to start ACP server:
* timed out").
* Requires agent-server v1.28.0: it includes both software-agent-sdk#3510 for
* off-loop LookupSecret resolution and the client_tools API used by
* canvas_ui_control. Older images either deadlock resolving ACP credentials or
* omit the Canvas UI tool.
*
* Excluded from `npm test` (lives under tests/). Run it by hand against a
* running container:
*
* docker run -d --name oh-acp -p 8010:8000 -v oh-acp-data:/workspace \
* -v "$(pwd)/tools:/canvas-tools:ro" -e OH_EXTRA_PYTHON_PATH=/canvas-tools \
* ghcr.io/openhands/agent-server:1.25.0-python
* ghcr.io/openhands/agent-server:1.28.0-python
* npx vite-node -c tests/e2e/live-acp/vite-node.config.mts \
* tests/e2e/live-acp/acp-docker-e2e.mts -- codex claude gemini
*
@@ -5,6 +5,7 @@ import {
type Locator,
type Page,
} from "@playwright/test";
import { CANVAS_UI_CLIENT_TOOL } from "../../../../src/api/canvas-ui-client-tool";
export const BACKEND_URL =
process.env.LIVE_E2E_BACKEND_URL ?? "http://127.0.0.1:18100";
@@ -46,7 +47,6 @@ const DEFAULT_AGENT_TOOLS = [
{ name: "terminal", params: {} },
{ name: "file_editor", params: {} },
{ name: "task_tracker", params: {} },
{ name: "canvas_ui", params: {} },
];
export const sessionApiKey = firstNonEmpty(
process.env.LIVE_E2E_SESSION_API_KEY,
@@ -249,9 +249,7 @@ export async function createLiveConversation(
verification: buildLiveVerificationSettings(options),
tools: DEFAULT_AGENT_TOOLS,
},
tool_module_qualnames: {
canvas_ui: "canvas_ui_tool",
},
client_tools: [CANVAS_UI_CLIENT_TOOL],
},
});
+14 -8
View File
@@ -1,12 +1,18 @@
"""Canvas UI control tool.
"""Legacy conversation compatibility for the former Python Canvas UI tool.
Shipped with Agent Canvas. Mounted into the agent-server container at
``/canvas-tools`` and loaded via ``tool_module_qualnames`` so the agent can
direct the frontend (navigate to a file, switch tabs, show a preview).
New conversations define ``canvas_ui_control`` through the agent-server's
``client_tools`` JSON API. The distinct name avoids colliding with this
process-global legacy registration. This module remains importable because
persisted conversations store
``tool_module_qualnames = {"canvas_ui": "canvas_ui_tool"}`` and may contain
``CanvasUIAction`` / ``CanvasUIObservation`` events. Agent-server imports the
module before restoring those conversations so the legacy event kinds and tool
registration remain resolvable.
The server-side executor is a no-op that returns an acknowledgment. The actual
UI effect happens client-side: the frontend watches the WebSocket stream for
``ActionEvent``s with ``tool_name == "canvas_ui"`` and dispatches the command.
legacy ``canvas_ui`` and current ``canvas_ui_control`` ActionEvents and dispatches
the command.
"""
from collections.abc import Sequence
@@ -127,7 +133,7 @@ class CanvasUITool(ToolDefinition[CanvasUIAction, CanvasUIObservation]):
]
# Auto-register at import time. The agent-server imports this module via
# tool_module_qualnames; this call wires the tool into the registry so it can
# be referenced by name in conversation tool lists.
# Persisted pre-client_tools conversations import this module by qualname before
# restoring their agent and events. Keep the registration until those records
# have a server-side migration path.
register_tool("canvas_ui", CanvasUITool)