mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-06 12:03:07 +08:00
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:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -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>,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 \
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user