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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
import { import {
ACP_SERVER_TAG_KEY, ACP_SERVER_TAG_KEY,
@@ -117,7 +118,6 @@ describe("buildStartConversationRequest", () => {
{ name: "terminal", params: {} }, { name: "terminal", params: {} },
{ name: "file_editor", params: {} }, { name: "file_editor", params: {} },
{ name: "task_tracker", params: {} }, { name: "task_tracker", params: {} },
{ name: "canvas_ui", params: {} },
{ name: "browser_tool_set", params: {} }, { name: "browser_tool_set", params: {} },
{ name: "task_tool_set", params: {} }, { name: "task_tool_set", params: {} },
]); ]);
@@ -412,11 +412,7 @@ describe("buildStartConversationRequest", () => {
}) as Record<string, unknown>; }) as Record<string, unknown>;
expect(payload.hook_config).toEqual({ on_start: [] }); 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({ expect(payload.tool_module_qualnames).toEqual({
canvas_ui: "canvas_ui_tool",
demo_tool: "pkg.tools.demo", demo_tool: "pkg.tools.demo",
}); });
expect(payload.agent_definitions).toEqual([ expect(payload.agent_definitions).toEqual([
@@ -595,40 +591,88 @@ describe("buildStartConversationRequest", () => {
}); });
}); });
describe("canvas_ui tool injection", () => { describe("canvas_ui client tool injection", () => {
it("registers canvas_ui_tool in tool_module_qualnames when the backend advertises canvas_ui", () => { it("sends canvas_ui as a client-defined JSON tool", () => {
const payload = buildStartConversationRequest({ const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS, 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( expect(
payload.agent_settings.tools.map((tool) => tool.name), payload.agent_settings?.tools?.map((tool) => tool.name) ?? [],
).not.toContain("canvas_ui"); ).not.toContain("canvas_ui");
expect(payload.tool_module_qualnames).toBeUndefined(); expect(payload.tool_module_qualnames).toBeUndefined();
}); });
it("drops a user-supplied canvas_ui module qualname when the backend does not advertise canvas_ui", () => { it("omits the client tool for an inline ACP agent", () => {
mockIsAgentServerToolAvailable.mockImplementation( const payload = buildStartConversationRequest({
(toolName: string) => toolName !== "canvas_ui", 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({ const payload = buildStartConversationRequest({
settings: { settings: {
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
@@ -636,6 +680,7 @@ describe("buildStartConversationRequest", () => {
...DEFAULT_SETTINGS.conversation_settings, ...DEFAULT_SETTINGS.conversation_settings,
tool_module_qualnames: { tool_module_qualnames: {
canvas_ui: "custom_canvas_ui_tool", canvas_ui: "custom_canvas_ui_tool",
[CANVAS_UI_CLIENT_TOOL_NAME]: "custom_canvas_ui_control_tool",
my_tool: "my_package.my_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({ const payload = buildStartConversationRequest({
settings: { settings: {
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
@@ -659,7 +704,6 @@ describe("buildStartConversationRequest", () => {
}) as { tool_module_qualnames: Record<string, string> }; }) as { tool_module_qualnames: Record<string, string> };
expect(payload.tool_module_qualnames).toEqual({ expect(payload.tool_module_qualnames).toEqual({
canvas_ui: "canvas_ui_tool",
my_tool: "my_package.my_tool", my_tool: "my_package.my_tool",
}); });
}); });
@@ -1,7 +1,15 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest"; 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 { 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 = { const terminalActionEvent: ActionEvent = {
id: "action-1", id: "action-1",
@@ -326,4 +334,62 @@ describe("getEventContent", () => {
"UI command 'open_tab' dispatched to the Agent Canvas frontend.", "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 // The active profile IS the well-known default → it's the enriched baseline
// (mirrors agent_settings), not a deliberate profile pick, so the launch // (mirrors agent_settings), not a deliberate profile pick, so the launch
// stays on the agent_settings path (no profile tail) even though its // stays on the agent_settings path (no profile tail) even though its
// llm_profile_ref resolves. Keeps <RUNTIME_SERVICES>/canvas_ui/project // llm_profile_ref resolves. Keeps <RUNTIME_SERVICES> and project skills,
// skills, which the profile-resolution path drops. // which the profile-resolution path drops.
listAgentProfilesMock.mockResolvedValue({ listAgentProfilesMock.mockResolvedValue({
profiles: [ profiles: [
{ {
@@ -461,6 +461,7 @@ describe("useCreateConversation", () => {
const call = createConversationSpy.mock.lastCall; const call = createConversationSpy.mock.lastCall;
expect(call?.[9]).toBe("profile-acp-default"); 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 () => { 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 { 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 { handleCanvasUIAction } from "#/services/canvas-ui";
import { useConversationStore } from "#/stores/conversation-store"; import { useConversationStore } from "#/stores/conversation-store";
import { useFilesTabStore } from "#/stores/files-tab-store"; import { useFilesTabStore } from "#/stores/files-tab-store";
@@ -98,14 +103,24 @@ describe("isCanvasUIActionEvent", () => {
timestamp: "2026-05-13T00:00:00Z", timestamp: "2026-05-13T00:00:00Z",
source: "agent", source: "agent",
action: { kind: "CanvasUIAction" }, action: { kind: "CanvasUIAction" },
tool_name: "canvas_ui", tool_name: LEGACY_CANVAS_UI_TOOL_NAME,
tool_call_id: "call-1", tool_call_id: "call-1",
...overrides, ...overrides,
}; };
} }
it("returns true for an ActionEvent whose tool_name is canvas_ui", () => { it.each([
expect(isCanvasUIActionEvent(makeActionEvent() as never)).toBe(true); ["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", () => { it("returns false when tool_name belongs to a different tool", () => {
@@ -122,7 +137,7 @@ describe("isCanvasUIActionEvent", () => {
timestamp: "2026-05-13T00:00:00Z", timestamp: "2026-05-13T00:00:00Z",
source: "environment", source: "environment",
observation: { kind: "ExecuteBashObservation" }, observation: { kind: "ExecuteBashObservation" },
tool_name: "canvas_ui", tool_name: LEGACY_CANVAS_UI_TOOL_NAME,
tool_call_id: "call-1", 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 { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", ".."); import {
const toolSource = readFileSync(resolve(repoRoot, "tools/canvas_ui_tool.py"), "utf8"); 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", () => { it("tells the agent to capture a browser screenshot before opening the browser tab", () => {
const captureInstruction = "browser_get_state(include_screenshot=true)"; const captureInstruction = "browser_get_state(include_screenshot=true)";
const openBrowserInstruction = 'command="open_tab", tab="browser"'; const openBrowserInstruction = 'command="open_tab", tab="browser"';
expect(toolSource).toContain(captureInstruction); expect(CANVAS_UI_CLIENT_TOOL.description).toContain(captureInstruction);
expect(toolSource).toContain(openBrowserInstruction); expect(CANVAS_UI_CLIENT_TOOL.description).toContain(openBrowserInstruction);
expect(toolSource.indexOf(captureInstruction)).toBeLessThan( expect(
toolSource.indexOf(openBrowserInstruction), 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). # 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 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 # Persisted conversations created before the client_tools migration still
# them via tool_module_qualnames. OH_EXTRA_PYTHON_PATH is set in entrypoint.sh. # 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 tools/ /opt/agent-canvas/tools/
# Copy generated defaults.env (from config/defaults.json via config-gen stage) # 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. # for locally-generated session keys.
export AUTOMATION_AGENT_SERVER_URL="${AUTOMATION_AGENT_SERVER_URL:-http://127.0.0.1:${AGENT_SERVER_PORT}}" 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 # Keep the legacy canvas_ui_tool module importable when the agent-server restores
# via tool_module_qualnames. Matches what scripts/dev-safe.mjs does with # conversations whose persisted metadata still references its module qualname.
# OH_EXTRA_PYTHON_PATH: config.canvasToolsDir.
export OH_EXTRA_PYTHON_PATH="${OH_EXTRA_PYTHON_PATH:-/opt/agent-canvas/tools}" export OH_EXTRA_PYTHON_PATH="${OH_EXTRA_PYTHON_PATH:-/opt/agent-canvas/tools}"
# Track child PIDs so we can clean up on exit. # 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 ```bash
# 1. Agent Server in a container (CORS allows localhost, so the browser talks # 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 # to it directly). The image pre-installs the ACP CLI wrappers. New
# canvas_ui tool is mounted so the agent-server can import the module Canvas # canvas_ui_control calls use client_tools; the Python mount keeps
# references in every start request. # pre-migration conversations loadable when persisted metadata imports
# canvas_ui_tool.
# Minimum image: 1.28.0-python (first compatible ACP provider/model protocol # Minimum image: 1.28.0-python (first compatible ACP provider/model protocol
# surface for current Canvas). Override SHA with a newer build. # surface for current Canvas). Override SHA with a newer build.
docker run -d --name oh-acp -p 8010:8000 -v acp-data:/workspace \ 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. # host:container — Canvas points VITE_BACKEND_BASE_URL at http://localhost:8010.
- "8010:8000" - "8010:8000"
environment: environment:
# Canvas's start request always references the bundled ``canvas_ui`` tool # New conversations define canvas_ui_control through client_tools. Keep the
# via ``tool_module_qualnames``; the agent-server imports that module from # old Python module importable so conversations persisted before that migration
# OH_EXTRA_PYTHON_PATH at conversation creation. Without the mount below + # can restore CanvasUIAction / CanvasUIObservation events after a restart.
# this var, conversation creation fails to import ``canvas_ui_tool``.
- OH_EXTRA_PYTHON_PATH=/canvas-tools - OH_EXTRA_PYTHON_PATH=/canvas-tools
# Optional cipher key. ACP conversations work without it (Canvas sends ACP # Optional cipher key. ACP conversations work without it (Canvas sends ACP
# provider credentials as loopback LookupSecrets resolved from the # provider credentials as loopback LookupSecrets resolved from the
@@ -66,8 +65,8 @@ services:
# Persist conversations AND the credential files the SDK materialises # Persist conversations AND the credential files the SDK materialises
# (Codex auth.json under CODEX_HOME, Gemini ADC/SA JSON) across restarts. # (Codex auth.json under CODEX_HOME, Gemini ADC/SA JSON) across restarts.
- acp-data:/workspace - acp-data:/workspace
# Canvas-specific Python tools (the canvas_ui tool) the agent-server loads # Legacy canvas_ui module used only when restoring pre-client_tools state.
# via OH_EXTRA_PYTHON_PATH. Path is relative to this compose file. # Path is relative to this compose file.
- ../../tools:/canvas-tools:ro - ../../tools:/canvas-tools:ro
restart: unless-stopped restart: unless-stopped
+5 -6
View File
@@ -636,10 +636,9 @@ function buildConfigFromPorts(ports, cwd, env) {
env.LOCAL_BACKEND_API_KEY || env.LOCAL_BACKEND_API_KEY ||
getOrCreatePersistedApiKeyFile(persistedKeyPath); getOrCreatePersistedApiKeyFile(persistedKeyPath);
// Host directory containing Agent-Canvas-specific Python tools (e.g. the // Host directory containing the legacy canvas_ui Python module. Persisted
// canvas_ui tool). Added to OH_EXTRA_PYTHON_PATH below so the agent-server // conversations created before the client_tools migration still reference
// can import the modules listed in `tool_module_qualnames`. Lives at // its module qualname, so the agent-server can import it when resuming them.
// <repo-root>/tools relative to this script.
const canvasToolsDir = fileURLToPath(new URL("../tools", import.meta.url)); const canvasToolsDir = fileURLToPath(new URL("../tools", import.meta.url));
return { return {
@@ -719,8 +718,8 @@ export function buildAgentServerEnv(config) {
// a follow-up change to the automation preset reads // a follow-up change to the automation preset reads
// OH_SESSION_API_KEYS_0 directly (which is already in env). // OH_SESSION_API_KEYS_0 directly (which is already in env).
AGENT_SERVER_URL: config.backendBaseUrl, AGENT_SERVER_URL: config.backendBaseUrl,
// Make the host tools/ directory importable so the agent-server can // Let the agent-server resolve canvas_ui_tool when old persisted metadata
// resolve modules listed in tool_module_qualnames (e.g. canvas_ui_tool). // requests that compatibility module during startup.
OH_EXTRA_PYTHON_PATH: config.canvasToolsDir, OH_EXTRA_PYTHON_PATH: config.canvasToolsDir,
}; };
} }
+5
View File
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
import { DEFAULT_SETTINGS } from "#/services/settings"; import { DEFAULT_SETTINGS } from "#/services/settings";
import type { Settings } from "#/types/settings"; import type { Settings } from "#/types/settings";
import { buildStartConversationRequest } from "./agent-server-adapter"; import { buildStartConversationRequest } from "./agent-server-adapter";
@@ -120,10 +121,14 @@ describe("buildStartConversationRequest — agentProfileId path", () => {
const payload = buildStartConversationRequest({ const payload = buildStartConversationRequest({
settings, settings,
agentProfileId: "profile-xyz", agentProfileId: "profile-xyz",
agentProfileKind: "openhands",
}); });
expect(payload.agent_profile_id).toBe("profile-xyz"); expect(payload.agent_profile_id).toBe("profile-xyz");
expect(payload.agent_settings).toBeUndefined(); 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", () => { 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 { SKILLS_CATALOG } from "@openhands/extensions/skills";
import { DEFAULT_SETTINGS } from "#/services/settings"; import { DEFAULT_SETTINGS } from "#/services/settings";
import { ExecutionStatus } from "#/types/agent-server/core"; import { ExecutionStatus } from "#/types/agent-server/core";
import { Settings, SettingsValue } from "#/types/settings"; import { AgentKind, Settings, SettingsValue } from "#/types/settings";
import { import {
getAcpPreferredDefaultModel, getAcpPreferredDefaultModel,
getAcpProvider, getAcpProvider,
@@ -28,6 +28,12 @@ import {
OPENAI_SUBSCRIPTION_VENDOR, OPENAI_SUBSCRIPTION_VENDOR,
isSubscriptionLlmConfig, isSubscriptionLlmConfig,
} from "#/constants/llm-subscription"; } 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 { export interface DirectConversationInfo {
id: string; id: string;
@@ -88,18 +94,7 @@ export interface DirectConversationInfo {
} | null; } | null;
} }
// Module qualname for the Canvas-UI tool. The agent-server imports this via const DEFAULT_TOOL_NAMES = ["terminal", "file_editor", "task_tracker"];
// 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 BROWSER_TOOL_SET_NAME = "browser_tool_set"; const BROWSER_TOOL_SET_NAME = "browser_tool_set";
const TASK_TOOL_SET_NAME = "task_tool_set"; const TASK_TOOL_SET_NAME = "task_tool_set";
@@ -518,10 +513,6 @@ function isToolRecord(
} }
function shouldIncludeTool(name: string, agentSettings: SettingsRecord) { function shouldIncludeTool(name: string, agentSettings: SettingsRecord) {
if (name === CANVAS_UI_TOOL_NAME) {
return isAgentServerToolAvailable(name);
}
if (name === BROWSER_TOOL_SET_NAME) { if (name === BROWSER_TOOL_SET_NAME) {
return browserToolsEnabled() && isAgentServerToolAvailable(name); return browserToolsEnabled() && isAgentServerToolAvailable(name);
} }
@@ -889,6 +880,7 @@ type StartConversationPayload = Record<string, unknown> & {
conversation_id?: string; conversation_id?: string;
secrets?: Record<string, LookupSecret>; secrets?: Record<string, LookupSecret>;
tags?: Record<string, string>; tags?: Record<string, string>;
client_tools: ClientToolSpec[];
tool_module_qualnames?: Record<string, string>; tool_module_qualnames?: Record<string, string>;
}; };
@@ -907,6 +899,7 @@ export interface StartConversationOptions {
// When set, the conversation launches from this AgentProfile (resolved // When set, the conversation launches from this AgentProfile (resolved
// server-side) instead of an inline ``agent_settings`` dump (#3727). // server-side) instead of an inline ``agent_settings`` dump (#3727).
agentProfileId?: string; agentProfileId?: string;
agentProfileKind?: AgentKind;
} }
export function buildStartConversationRequest( export function buildStartConversationRequest(
@@ -917,6 +910,11 @@ export function buildStartConversationRequest(
: options.settings; : options.settings;
const acpMode = isAcpAgent(sourceAgentSettings); const acpMode = isAcpAgent(sourceAgentSettings);
const launchAgentKind = options.agentProfileId
? options.agentProfileKind
: acpMode
? "acp"
: "openhands";
const agentSettings = buildConfiguredAgentSettings(sourceAgentSettings); const agentSettings = buildConfiguredAgentSettings(sourceAgentSettings);
const acpServerTag = acpMode const acpServerTag = acpMode
? getAcpServerTag(sourceAgentSettings) ? getAcpServerTag(sourceAgentSettings)
@@ -947,14 +945,15 @@ export function buildStartConversationRequest(
// server/SDK's responsibility to restore on the profile path — tracked in // server/SDK's responsibility to restore on the profile path — tracked in
// software-agent-sdk#3967 (profile resolution must attach the default // software-agent-sdk#3967 (profile resolution must attach the default
// toolset + public skills, else a profile-launched OpenHands agent has only // toolset + public skills, else a profile-launched OpenHands agent has only
// Finish/Think). The two genuinely canvas-only enrichments — the // Finish/Think). The dev ``RUNTIME_SERVICES`` system-message suffix remains
// ``canvas_ui`` tool and the dev ``RUNTIME_SERVICES`` system-message-suffix // agent-settings-only; the Canvas UI tool is a top-level client tool and
// (``buildAgentContext``) — have no server-side representation and are // therefore works on both inline-agent and profile launch paths.
// intentionally not carried on the profile path.
...(options.agentProfileId ...(options.agentProfileId
? { agent_profile_id: options.agentProfileId } ? { agent_profile_id: options.agentProfileId }
: { agent_settings: agentSettings }), : { agent_settings: agentSettings }),
workspace: conversationSettings.workspace, workspace: conversationSettings.workspace,
client_tools:
launchAgentKind === "openhands" ? [CANVAS_UI_CLIENT_TOOL] : [],
confirmation_policy: confirmation_policy:
getConversationConfirmationPolicy(conversationSettings), getConversationConfirmationPolicy(conversationSettings),
max_iterations: max_iterations:
@@ -1008,20 +1007,13 @@ export function buildStartConversationRequest(
payload.hook_config = conversationSettings.hook_config; payload.hook_config = conversationSettings.hook_config;
} }
const toolModuleQualnames: Record<string, string> = {}; const toolModuleQualnames = {
const canvasUiAvailable = isAgentServerToolAvailable(CANVAS_UI_TOOL_NAME); ...((conversationSettings.tool_module_qualnames as
if (canvasUiAvailable) {
toolModuleQualnames[CANVAS_UI_TOOL_NAME] = CANVAS_UI_TOOL_MODULE;
}
Object.assign(
toolModuleQualnames,
(conversationSettings.tool_module_qualnames as
| Record<string, string> | Record<string, string>
| undefined) ?? {}, | undefined) ?? {}),
); };
if (!canvasUiAvailable) { delete toolModuleQualnames[LEGACY_CANVAS_UI_TOOL_NAME];
delete toolModuleQualnames[CANVAS_UI_TOOL_NAME]; delete toolModuleQualnames[CANVAS_UI_CLIENT_TOOL_NAME];
}
if (Object.keys(toolModuleQualnames).length > 0) { if (Object.keys(toolModuleQualnames).length > 0) {
payload.tool_module_qualnames = toolModuleQualnames; payload.tool_module_qualnames = toolModuleQualnames;
} }
@@ -1090,6 +1082,7 @@ export async function buildStartConversationRequestWithEncryptedSettings(options
workingDir?: string; workingDir?: string;
worktree?: boolean; worktree?: boolean;
agentProfileId?: string; agentProfileId?: string;
agentProfileKind?: AgentKind;
}): Promise<Record<string, unknown>> { }): Promise<Record<string, unknown>> {
const { SecretsService } = await import("./secrets-service"); 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, VSCodeClient,
} from "@openhands/typescript-client/clients"; } from "@openhands/typescript-client/clients";
import { v4 as uuidv4 } from "uuid"; 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 type { ConversationRuntimeContext } from "#/api/conversation-file-upload.api";
import { buildHttpBaseUrl } from "#/utils/websocket-url"; import { buildHttpBaseUrl } from "#/utils/websocket-url";
import { import {
@@ -376,6 +376,7 @@ class AgentServerConversationService {
// cloud app-server (OpenHands #15060): local threads it through the // cloud app-server (OpenHands #15060): local threads it through the
// encrypted-settings builder; cloud sends it as a flat request field. // encrypted-settings builder; cloud sends it as a flat request field.
agentProfileId?: string, agentProfileId?: string,
agentProfileKind?: AgentKind,
): Promise<AppConversationStartTask> { ): Promise<AppConversationStartTask> {
if (getActiveBackend().backend.kind === "cloud") { if (getActiveBackend().backend.kind === "cloud") {
// Cloud path mirrors OpenHands' frontend: build a flat // Cloud path mirrors OpenHands' frontend: build a flat
@@ -429,6 +430,7 @@ class AgentServerConversationService {
workingDir, workingDir,
worktree: resolvedWorkspaceMode === "new_worktree", worktree: resolvedWorkspaceMode === "new_worktree",
agentProfileId, agentProfileId,
agentProfileKind,
}); });
const data = await new ConversationClient( const data = await new ConversationClient(
@@ -1,5 +1,9 @@
import { Trans } from "react-i18next"; import { Trans } from "react-i18next";
import React from "react"; import React from "react";
import {
CANVAS_UI_CLIENT_ACTION_KIND,
CANVAS_UI_CLIENT_TOOL_NAME,
} from "#/constants/canvas-ui";
import { import {
OpenHandsEvent, OpenHandsEvent,
ObservationEvent, ObservationEvent,
@@ -9,6 +13,7 @@ import {
isActionEvent, isActionEvent,
isObservationEvent, isObservationEvent,
isACPToolCallEvent, isACPToolCallEvent,
isCanvasUIActionEvent,
} from "#/types/agent-server/type-guards"; } from "#/types/agent-server/type-guards";
import { MonoComponent } from "../../../features/chat/mono-component"; import { MonoComponent } from "../../../features/chat/mono-component";
import { PathComponent } from "../../../features/chat/path-component"; import { PathComponent } from "../../../features/chat/path-component";
@@ -236,6 +241,9 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
case "BrowserCloseTabAction": case "BrowserCloseTabAction":
actionKey = "ACTION_MESSAGE$BROWSE"; actionKey = "ACTION_MESSAGE$BROWSE";
break; break;
case "CanvasUIAction":
case CANVAS_UI_CLIENT_ACTION_KIND:
return "CANVASUI";
default: default:
// For unknown actions, use the type name // For unknown actions, use the type name
return String(actionType).replace("Action", "").toUpperCase(); return String(actionType).replace("Action", "").toUpperCase();
@@ -324,6 +332,12 @@ const getObservationEventTitle = (
case "CanvasUIObservation": case "CanvasUIObservation":
observationKey = "OBSERVATION_MESSAGE$CANVAS_UI"; observationKey = "OBSERVATION_MESSAGE$CANVAS_UI";
break; 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": case "SwitchLLMObservation":
observationKey = event.observation.is_error observationKey = event.observation.is_error
? "MODEL$SWITCH_FAILED" ? "MODEL$SWITCH_FAILED"
@@ -376,6 +390,22 @@ const getObservationEventTitle = (
return observationType; 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 = ( export const getEventContent = (
event: OpenHandsEvent | SkillReadyEvent, event: OpenHandsEvent | SkillReadyEvent,
correspondingAction?: ActionEvent, correspondingAction?: ActionEvent,
@@ -409,6 +439,7 @@ export const getEventContent = (
); );
} else { } else {
details = details =
getCanvasUIClientObservationContent(event, correspondingAction) ??
resolveVisualizerBody(event, correspondingAction) ?? resolveVisualizerBody(event, correspondingAction) ??
getObservationContent(event); getObservationContent(event);
} }
@@ -1,3 +1,4 @@
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
import { ObservationEvent } from "#/types/agent-server/core"; import { ObservationEvent } from "#/types/agent-server/core";
import { getObservationResult } from "./get-observation-result"; import { getObservationResult } from "./get-observation-result";
import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared"; import { getDefaultEventContent, MAX_CONTENT_LENGTH } from "./shared";
@@ -417,6 +418,13 @@ export const getObservationContent = (event: ObservationEvent): string => {
event as ObservationEvent<CanvasUIObservation>, event as ObservationEvent<CanvasUIObservation>,
); );
case "ClientToolObservation":
return event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME
? getCanvasUIObservationContent(
event as ObservationEvent<CanvasUIObservation>,
)
: getDefaultEventContent(event);
case "SwitchLLMObservation": case "SwitchLLMObservation":
return getSwitchLLMObservationContent( return getSwitchLLMObservationContent(
event as ObservationEvent<SwitchLLMObservation>, 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); invalidateConversationQueries(queryClient, conversationId);
} }
// Handle canvas_ui custom-tool ActionEvents - drive the frontend // Handle canvas_ui ActionEvents from both the legacy Python tool and
// (navigate to a file, switch tabs, show a preview). The tool // the client-defined JSON tool. The server acknowledges immediately;
// executes server-side as a no-op; the actual UI change happens // the actual UI change happens here on the client.
// here on the client.
if (isCanvasUIActionEvent(event)) { if (isCanvasUIActionEvent(event)) {
handleCanvasUIAction(event.action, conversationId ?? null); 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 AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
import { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types"; import { PluginSpec } from "#/api/conversation-service/agent-server-conversation-service.types";
import { SuggestedTask } from "#/utils/types"; import { SuggestedTask } from "#/utils/types";
import { Provider } from "#/types/settings"; import { AgentKind, Provider } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking"; import { useTracking } from "#/hooks/use-tracking";
import { useLlmProfiles } from "#/hooks/query/use-llm-profiles"; import { useLlmProfiles } from "#/hooks/query/use-llm-profiles";
import { useAgentProfiles } from "#/hooks/query/use-agent-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 // deliberate profile pick — it mirrors global agent_settings. Launch it
// via agent_settings so the canvas-only enrichments the profile-resolution // via agent_settings so the canvas-only enrichments the profile-resolution
// path drops survive for the common home-launch: the <RUNTIME_SERVICES> // path drops survive for the common home-launch: the <RUNTIME_SERVICES>
// system-message suffix, the canvas_ui tool, and project-skill loading // system-message suffix and project-skill loading (buildAgentContext).
// (buildAgentContext). Named profiles are deliberate custom configs and // Named profiles are deliberate custom configs and still use the profile
// still use the profile path (accepting that enrichment boundary). // path (accepting that enrichment boundary).
// Trade-off: per-profile fields set on `default` itself don't apply on // Trade-off: per-profile fields set on `default` itself don't apply on
// home-launch — custom per-profile config belongs in a named profile. // home-launch — custom per-profile config belongs in a named profile.
// //
// Scoped to OpenHands: an ACP `default` must keep the profile path. // Scoped to OpenHands: an ACP `default` must keep the profile path.
// Activation is pointer-only, so global agent_settings is stale (often // Activation is pointer-only, so global agent_settings is stale (often
// still OpenHands) when an ACP profile is active — launching it via // still OpenHands) when an ACP profile is active — launching it via
// agent_settings would start the wrong agent. ACP also carries no // agent_settings would start the wrong agent. ACP carries no
// <RUNTIME_SERVICES>/canvas_ui enrichment, so there's nothing to preserve. // <RUNTIME_SERVICES> enrichment, so there's nothing to preserve.
// //
// Scoped to local: cloud never writes agent_settings, so it always // Scoped to local: cloud never writes agent_settings, so it always
// resolves `default` server-side via agent_profile_id (validated below). // 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 // Only extend the call with the profile tail when launching from a
// launching from a profile, so a plain create stays byte-identical to // profile, so a plain create stays byte-identical to the legacy
// the legacy agent_settings path (#3727). sandboxId is unused here. // agent_settings path (#3727). sandboxId is unused here.
// TODO(#1587): createConversation has grown to 10 positional params; // TODO(#1587): createConversation has grown to 11 positional params;
// refactor it to an options object so this position-skipping tail isn't // refactor it to an options object so this position-skipping tail isn't
// needed. // needed.
const profileArgs: [undefined, string] | [] = effectiveAgentProfileId const profileArgs: [undefined, string, AgentKind | undefined] | [] =
? [undefined, effectiveAgentProfileId] effectiveAgentProfileId
: []; ? [
undefined,
effectiveAgentProfileId,
resolvedAgentProfile?.agent_kind,
]
: [];
const conversation = const conversation =
await AgentServerConversationService.createConversation( 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 { ActionBase } from "./base";
import { TaskItem } from "./common"; 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 * Canvas UI action emitted over the existing WebSocket and intercepted by
* regular ActionEvent; intercepted client-side by handleCanvasUIAction. * handleCanvasUIAction. Legacy conversations use CanvasUIAction; new
* The Python definition lives in tools/canvas_ui_tool.py. * 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"; command: "navigate_to_file" | "open_tab" | "show_preview";
path?: string | null; path?: string | null;
tab?: 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 = type EventType =
| "MCPTool" | "MCPTool"
| "Finish" | "Finish"
@@ -22,9 +24,8 @@ type ActionOnlyType =
| "BrowserListTabs" | "BrowserListTabs"
| "BrowserSwitchTab" | "BrowserSwitchTab"
| "BrowserCloseTab" | "BrowserCloseTab"
// Frontend-injected custom tool. Not part of the upstream SDK Action // Legacy Python-defined Canvas tool kind. The client-tool kind is added
// union but emitted as a regular ActionEvent over the WebSocket. See // separately below because the SDK generates its full discriminator.
// tools/canvas_ui_tool.py and src/services/canvas-ui.ts.
| "CanvasUI"; | "CanvasUI";
type ObservationOnlyType = "Browser"; type ObservationOnlyType = "Browser";
@@ -35,7 +36,8 @@ type ActionEventType =
| "GlobAction" | "GlobAction"
| "GrepAction" | "GrepAction"
// The `task` tool delegating work to a spawned subagent. // The `task` tool delegating work to a spawned subagent.
| "TaskAction"; | "TaskAction"
| typeof CANVAS_UI_CLIENT_ACTION_KIND;
type ObservationEventType = type ObservationEventType =
| `${ObservationOnlyType}Observation` | `${ObservationOnlyType}Observation`
| `${EventType}Observation` | `${EventType}Observation`
@@ -44,8 +46,9 @@ type ObservationEventType =
| "GrepObservation" | "GrepObservation"
// Result of the `task` tool, which delegates work to a spawned subagent. // Result of the `task` tool, which delegates work to a spawned subagent.
| "TaskObservation" | "TaskObservation"
// Acknowledgement emitted after a `canvas_ui` command is dispatched. // Legacy and client-defined acknowledgements for Canvas UI dispatches.
| "CanvasUIObservation"; | "CanvasUIObservation"
| "ClientToolObservation";
export interface ActionBase<T extends ActionEventType = ActionEventType> { export interface ActionBase<T extends ActionEventType = ActionEventType> {
kind: T; kind: T;
@@ -325,7 +325,9 @@ export interface TaskObservation extends ObservationBase<"TaskObservation"> {
status: string; 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. * 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 { import {
OpenHandsEvent, OpenHandsEvent,
ObservationEvent, ObservationEvent,
@@ -167,17 +172,17 @@ export const isBrowserNavigateActionEvent = (
isActionEvent(event) && event.action.kind === "BrowserNavigateAction"; 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 * Discriminating on tool_name supports legacy CanvasUIAction events and the
* agent-server-adapter.ts). We discriminate on tool_name (which we control * SDK-generated action kind without leaking that generated name here.
* via register_tool("canvas_ui", ...)). The predicate narrows the event so
* the call site can read `event.action.command` etc. without further casts.
*/ */
export const isCanvasUIActionEvent = ( export const isCanvasUIActionEvent = (
event: OpenHandsEvent, event: OpenHandsEvent,
): event is ActionEvent<CanvasUIAction> => ): 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 * 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 ## Run it
```bash ```bash
# 1. Agent-server container with the canvas_ui tool mounted (as the dev stack does). # 1. Agent-server container. v1.28.0 adds the client_tools API used by Canvas.
# Minimum 1.25.0-python (software-agent-sdk#3510); override for a newer build. # The Python mount keeps pre-migration conversation state loadable.
docker run -d --name oh-acp -p 8010:8000 \ docker run -d --name oh-acp -p 8010:8000 \
-v oh-acp-data:/workspace \ -v oh-acp-data:/workspace \
-v "$(pwd)/tools:/canvas-tools:ro" -e OH_EXTRA_PYTHON_PATH=/canvas-tools \ -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). # 2. Run the e2e (all providers, or a subset).
npx vite-node -c tests/e2e/live-acp/vite-node.config.mts \ 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 * LookupSecrets Canvas emits resolve back from the store and authenticate the
* CLI end-to-end (including the SDK's acp_file_secrets materialisation). * 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 * Requires agent-server v1.28.0: it includes both software-agent-sdk#3510 for
* ACP credentials ride as loopback LookupSecrets, and only #3510 resolves them * off-loop LookupSecret resolution and the client_tools API used by
* off the event loop — an older image deadlocks ("Failed to start ACP server: * canvas_ui_control. Older images either deadlock resolving ACP credentials or
* timed out"). * omit the Canvas UI tool.
* *
* Excluded from `npm test` (lives under tests/). Run it by hand against a * Excluded from `npm test` (lives under tests/). Run it by hand against a
* running container: * running container:
* *
* docker run -d --name oh-acp -p 8010:8000 -v oh-acp-data:/workspace \ * 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 \ * -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 \ * npx vite-node -c tests/e2e/live-acp/vite-node.config.mts \
* tests/e2e/live-acp/acp-docker-e2e.mts -- codex claude gemini * tests/e2e/live-acp/acp-docker-e2e.mts -- codex claude gemini
* *
@@ -5,6 +5,7 @@ import {
type Locator, type Locator,
type Page, type Page,
} from "@playwright/test"; } from "@playwright/test";
import { CANVAS_UI_CLIENT_TOOL } from "../../../../src/api/canvas-ui-client-tool";
export const BACKEND_URL = export const BACKEND_URL =
process.env.LIVE_E2E_BACKEND_URL ?? "http://127.0.0.1:18100"; process.env.LIVE_E2E_BACKEND_URL ?? "http://127.0.0.1:18100";
@@ -46,7 +47,6 @@ const DEFAULT_AGENT_TOOLS = [
{ name: "terminal", params: {} }, { name: "terminal", params: {} },
{ name: "file_editor", params: {} }, { name: "file_editor", params: {} },
{ name: "task_tracker", params: {} }, { name: "task_tracker", params: {} },
{ name: "canvas_ui", params: {} },
]; ];
export const sessionApiKey = firstNonEmpty( export const sessionApiKey = firstNonEmpty(
process.env.LIVE_E2E_SESSION_API_KEY, process.env.LIVE_E2E_SESSION_API_KEY,
@@ -249,9 +249,7 @@ export async function createLiveConversation(
verification: buildLiveVerificationSettings(options), verification: buildLiveVerificationSettings(options),
tools: DEFAULT_AGENT_TOOLS, tools: DEFAULT_AGENT_TOOLS,
}, },
tool_module_qualnames: { client_tools: [CANVAS_UI_CLIENT_TOOL],
canvas_ui: "canvas_ui_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 New conversations define ``canvas_ui_control`` through the agent-server's
``/canvas-tools`` and loaded via ``tool_module_qualnames`` so the agent can ``client_tools`` JSON API. The distinct name avoids colliding with this
direct the frontend (navigate to a file, switch tabs, show a preview). 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 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 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 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 # Persisted pre-client_tools conversations import this module by qualname before
# tool_module_qualnames; this call wires the tool into the registry so it can # restoring their agent and events. Keep the registration until those records
# be referenced by name in conversation tool lists. # have a server-side migration path.
register_tool("canvas_ui", CanvasUITool) register_tool("canvas_ui", CanvasUITool)