diff --git a/__tests__/api/agent-server-adapter.test.ts b/__tests__/api/agent-server-adapter.test.ts
index 017f589dd9..e0169e25a8 100644
--- a/__tests__/api/agent-server-adapter.test.ts
+++ b/__tests__/api/agent-server-adapter.test.ts
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
+import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation";
import {
ACP_SERVER_TAG_KEY,
@@ -677,13 +678,12 @@ describe("buildStartConversationRequest", () => {
});
});
- describe("canvas_ui client tool injection", () => {
+ describe("client tool injection", () => {
it("sends canvas_ui as a client-defined JSON tool", () => {
const payload = buildStartConversationRequest({
settings: DEFAULT_SETTINGS,
});
- expect(payload.client_tools).toHaveLength(1);
expect(payload.client_tools[0]).toMatchObject({
name: CANVAS_UI_CLIENT_TOOL_NAME,
parameters: {
@@ -761,6 +761,7 @@ describe("buildStartConversationRequest", () => {
expect(payload.client_tools.map((tool) => tool.name)).toEqual([
CANVAS_UI_CLIENT_TOOL_NAME,
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
]);
});
@@ -773,6 +774,7 @@ describe("buildStartConversationRequest", () => {
expect(payload.conversation_id).toBe("legacy-conversation-id");
expect(payload.client_tools.map((tool) => tool.name)).toEqual([
CANVAS_UI_CLIENT_TOOL_NAME,
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
]);
});
diff --git a/__tests__/api/agent-server-conversation-service.test.ts b/__tests__/api/agent-server-conversation-service.test.ts
index f1d958c7a9..78b6457ba3 100644
--- a/__tests__/api/agent-server-conversation-service.test.ts
+++ b/__tests__/api/agent-server-conversation-service.test.ts
@@ -463,6 +463,40 @@ describe("AgentServerConversationService", () => {
expect(payload.workspace.working_dir).toBe("/Users/jane/projects/foo");
expect(payload.worktree).toBe(true);
});
+
+ it("links a local conversation to its parent", async () => {
+ mockGetSettings.mockResolvedValue({
+ agent_settings: { llm: { model: "gpt-4o" } },
+ conversation_settings: {},
+ });
+ mockGetSettingsForConversation.mockResolvedValue({
+ agentSettings: { llm: { model: "gpt-4o" } },
+ conversationSettings: {},
+ secretsEncrypted: true,
+ });
+ mockHttpPost.mockResolvedValue({
+ data: {
+ id: "ignored-server-id",
+ created_at: "2024-01-01",
+ updated_at: "2024-01-01",
+ },
+ });
+
+ await AgentServerConversationService.createConversation(
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ "/Users/jane/projects/foo",
+ "new_worktree",
+ "parent-conversation-id",
+ );
+
+ const [payloadCall] = mockHttpPost.mock.calls;
+ expect(payloadCall[1]).toMatchObject({
+ parent_conversation_id: "parent-conversation-id",
+ });
+ });
});
describe("downloadConversation local branch", () => {
diff --git a/__tests__/api/cloud-conversation-service.test.ts b/__tests__/api/cloud-conversation-service.test.ts
index 4248b23e0d..44ad6b2ce4 100644
--- a/__tests__/api/cloud-conversation-service.test.ts
+++ b/__tests__/api/cloud-conversation-service.test.ts
@@ -9,6 +9,7 @@ import { setStoredConversationMetadata } from "#/api/conversation-metadata-store
import {
batchGetCloudConversations,
createCloudAppConversation,
+ pickCloudBackendForLaunch,
searchCloudConversations,
} from "#/api/cloud/conversation-service.api";
import { AGENT_CANVAS_CLIENT_HEADERS } from "#/api/client-source";
@@ -64,6 +65,52 @@ describe("cloud conversation-service overlay", () => {
);
});
+ it("starts a Cloud conversation on a registered backend while a local one is active", async () => {
+ const localBackend: Backend = {
+ id: "local-1",
+ kind: "local",
+ host: "http://localhost:8000",
+ apiKey: "local-key",
+ name: "Local",
+ };
+ setRegisteredBackends([localBackend, cloudBackend]);
+ setActiveSelection({ backendId: localBackend.id, orgId: null });
+ mockCallCloudProxy.mockResolvedValueOnce({ id: "start-task" });
+
+ await createCloudAppConversation(
+ {
+ initial_message: null,
+ selected_repository: null,
+ selected_branch: null,
+ git_provider: null,
+ parent_conversation_id: null,
+ },
+ pickCloudBackendForLaunch()!,
+ );
+
+ expect(mockCallCloudProxy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ backend: cloudBackend,
+ path: "/api/v1/app-conversations",
+ headers: AGENT_CANVAS_CLIENT_HEADERS,
+ }),
+ );
+ });
+
+ it("has no cloud backend to launch on when only local backends are registered", () => {
+ const localBackend: Backend = {
+ id: "local-1",
+ kind: "local",
+ host: "http://localhost:8000",
+ apiKey: "local-key",
+ name: "Local",
+ };
+ setRegisteredBackends([localBackend]);
+ setActiveSelection({ backendId: localBackend.id, orgId: null });
+
+ expect(pickCloudBackendForLaunch()).toBeNull();
+ });
+
it("overlays locally-stored repo selection onto batchGetCloudConversations results when the server returns nulls", async () => {
setStoredConversationMetadata("conv-1", {
selected_repository: "octocat/hello-world",
diff --git a/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts b/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts
index 7e5b00b7bf..9920ec6f3b 100644
--- a/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts
+++ b/__tests__/components/conversation-events/chat/event-content-helpers/should-render-event.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { shouldRenderEvent } from "#/components/conversation-events/chat/event-content-helpers/should-render-event";
+import { CHILD_CONVERSATION_RESULT_PREFIX } from "#/constants/child-conversation";
import {
createPlanningFileEditorActionEvent,
createOtherActionEvent,
@@ -277,4 +278,14 @@ describe("shouldRenderEvent - /goal loop re-prompts", () => {
).toBe(true);
expect(shouldRenderEvent(makeUserMessage("hello"))).toBe(true);
});
+
+ it("hides the child-conversation launch result the frontend posts back", () => {
+ expect(
+ shouldRenderEvent(
+ makeUserMessage(
+ `${CHILD_CONVERSATION_RESULT_PREFIX}{"status":"launched","target":"local"}`,
+ ),
+ ),
+ ).toBe(false);
+ });
});
diff --git a/__tests__/components/conversation-events/get-event-content.test.tsx b/__tests__/components/conversation-events/get-event-content.test.tsx
index 44eefa10ae..44dc346359 100644
--- a/__tests__/components/conversation-events/get-event-content.test.tsx
+++ b/__tests__/components/conversation-events/get-event-content.test.tsx
@@ -4,6 +4,10 @@ import {
CANVAS_UI_CLIENT_ACTION_KIND,
CANVAS_UI_CLIENT_TOOL_NAME,
} from "#/constants/canvas-ui";
+import {
+ LAUNCH_CHILD_CONVERSATION_ACTION_KIND,
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+} from "#/constants/child-conversation";
import { getEventContent } from "#/components/conversation-events/chat";
import {
ActionEvent,
@@ -392,4 +396,59 @@ describe("getEventContent", () => {
"UI command 'open_tab' dispatched to the Agent Canvas frontend.",
);
});
+
+ // Without the explicit mapping the default branch would surface the SDK's
+ // generated discriminator ("CLIENTACTION_LAUNCH_CHILD_CONVERSATION") to users.
+ it("titles the launch-child-conversation tool call and its acknowledgement", () => {
+ const launchAction: ActionEvent = {
+ id: "action-launch",
+ timestamp: new Date().toISOString(),
+ source: "agent",
+ thought: [],
+ thinking_blocks: [],
+ action: {
+ kind: LAUNCH_CHILD_CONVERSATION_ACTION_KIND,
+ target: "local",
+ task: "Add a regression test for the parser",
+ },
+ tool_name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+ tool_call_id: "tool-launch",
+ tool_call: {
+ id: "tool-launch",
+ type: "function",
+ function: {
+ name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+ arguments: '{"target":"local","task":"Add a regression test"}',
+ },
+ },
+ llm_response_id: "response-launch",
+ security_risk: SecurityRisk.LOW,
+ summary: "",
+ };
+ const launchObservation: ObservationEvent = {
+ id: "obs-launch",
+ timestamp: new Date().toISOString(),
+ source: "environment",
+ tool_name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+ tool_call_id: "tool-launch",
+ action_id: "action-launch",
+ observation: {
+ kind: "ClientToolObservation",
+ content: [{ type: "text", text: "Tool call dispatched to client." }],
+ is_error: false,
+ },
+ };
+
+ render({getEventContent(launchAction).title});
+ expect(
+ screen.getByText("ACTION_MESSAGE$LAUNCH_CHILD_CONVERSATION"),
+ ).toBeInTheDocument();
+
+ render(
+ {getEventContent(launchObservation, launchAction).title},
+ );
+ expect(
+ screen.getByText("OBSERVATION_MESSAGE$LAUNCH_CHILD_CONVERSATION"),
+ ).toBeInTheDocument();
+ });
});
diff --git a/__tests__/services/child-conversation-launch.test.ts b/__tests__/services/child-conversation-launch.test.ts
new file mode 100644
index 0000000000..88d12c225f
--- /dev/null
+++ b/__tests__/services/child-conversation-launch.test.ts
@@ -0,0 +1,573 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import {
+ CHILD_CONVERSATION_RESULT_PREFIX,
+ LAUNCH_CHILD_CONVERSATION_ACTION_KIND,
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+} from "#/constants/child-conversation";
+import { setStoredConversationMetadata } from "#/api/conversation-metadata-store";
+import { useGoalStore } from "#/stores/goal-store";
+import type { LaunchChildConversationAction } from "#/types/agent-server/core";
+import { isLaunchChildConversationActionEvent } from "#/types/agent-server/type-guards";
+import {
+ handleLaunchChildConversationAction,
+ type LaunchChildConversationResult,
+} from "#/services/child-conversation-launch";
+
+const {
+ mockCreateConversation,
+ mockResolveWorkingDir,
+ mockSendMessage,
+ mockUpdateTitle,
+ mockCreateCloudAppConversation,
+ mockGetCloudStartTask,
+ mockPickCloudBackend,
+ mockGetCachedAgentServerVersion,
+} = vi.hoisted(() => ({
+ mockCreateConversation: vi.fn(),
+ mockResolveWorkingDir: vi.fn(),
+ mockSendMessage: vi.fn(),
+ mockUpdateTitle: vi.fn(),
+ mockCreateCloudAppConversation: vi.fn(),
+ mockGetCloudStartTask: vi.fn(),
+ mockPickCloudBackend: vi.fn(),
+ mockGetCachedAgentServerVersion: vi.fn(),
+}));
+
+vi.mock(
+ "#/api/conversation-service/agent-server-conversation-service.api",
+ () => ({
+ default: {
+ createConversation: mockCreateConversation,
+ resolveConversationWorkingDir: mockResolveWorkingDir,
+ sendMessage: mockSendMessage,
+ updateConversationTitle: mockUpdateTitle,
+ },
+ }),
+);
+
+vi.mock("#/api/cloud/conversation-service.api", () => ({
+ createCloudAppConversation: mockCreateCloudAppConversation,
+ getCloudAppConversationStartTask: mockGetCloudStartTask,
+ pickCloudBackendForLaunch: mockPickCloudBackend,
+}));
+
+vi.mock("#/api/agent-server-compatibility", () => ({
+ getCachedAgentServerVersion: mockGetCachedAgentServerVersion,
+ compareAgentServerVersions: (actual: string, required: string) => {
+ const parse = (v: string) => v.split(".").map(Number);
+ const [a, b] = [parse(actual), parse(required)];
+ for (let i = 0; i < 3; i += 1) {
+ if (a[i] > b[i]) return 1;
+ if (a[i] < b[i]) return -1;
+ }
+ return 0;
+ },
+}));
+
+vi.mock("#/utils/custom-toast-handlers", () => ({
+ displayErrorToast: vi.fn(),
+ displaySuccessToastWithLink: vi.fn(),
+}));
+
+const PARENT_ID = "parent-conversation-id";
+
+function action(
+ overrides: Partial,
+): LaunchChildConversationAction {
+ return {
+ kind: LAUNCH_CHILD_CONVERSATION_ACTION_KIND,
+ target: "local",
+ task: "Add a regression test for the parser",
+ ...overrides,
+ } as LaunchChildConversationAction;
+}
+
+/** Read back the JSON payload the service posted into the parent conversation. */
+function reportedResult(): LaunchChildConversationResult {
+ const [, message] = mockSendMessage.mock.calls.at(-1) as [
+ string,
+ { content: { text: string }[] },
+ ];
+ return JSON.parse(
+ message.content[0].text.slice(CHILD_CONVERSATION_RESULT_PREFIX.length),
+ );
+}
+
+let toolCall = 0;
+const nextToolCallId = () => {
+ toolCall += 1;
+ return `tool-call-${toolCall}`;
+};
+
+/** The repo fields the parent's metadata hands down to its children. */
+const PARENT_REPO_METADATA = {
+ selected_repository: "octocat/hello-world",
+ selected_branch: "main",
+ git_provider: null,
+};
+
+/**
+ * Give the parent a repository/workspace, which is what marks its workspace as
+ * able to host a worktree. Without stored metadata the parent is a scratch
+ * directory and the worktree is skipped.
+ */
+function seedWorktreeCapableParent() {
+ setStoredConversationMetadata(PARENT_ID, {
+ ...PARENT_REPO_METADATA,
+ selected_workspace: "/Users/jane/projects/foo",
+ });
+}
+
+describe("handleLaunchChildConversationAction", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ useGoalStore.setState({ statusByConversation: {} });
+ vi.clearAllMocks();
+ mockResolveWorkingDir.mockResolvedValue("/Users/jane/projects/foo");
+ mockCreateConversation.mockResolvedValue({
+ id: "start-task-id",
+ app_conversation_id: "child-id",
+ status: "READY",
+ });
+ mockUpdateTitle.mockResolvedValue(undefined);
+ mockSendMessage.mockResolvedValue(undefined);
+ mockGetCachedAgentServerVersion.mockReturnValue("1.37.1");
+ });
+
+ describe("parameter validation", () => {
+ // `enum` is advertised to the LLM but dropped when the agent-server builds
+ // the action model, so these values arrive intact and must be caught here.
+ it.each([
+ [
+ "an unknown target",
+ { target: "clould" },
+ '`target` must be exactly "local" or "cloud"',
+ ],
+ [
+ "an unknown isolation",
+ { isolation: "worktre" },
+ '`isolation` must be exactly "worktree" or "shared"',
+ ],
+ [
+ "a repository on a local target",
+ { target: "local", repository: "octocat/hello-world" },
+ "A local child always runs in this conversation's workspace",
+ ],
+ [
+ "an isolation on a cloud target",
+ { target: "cloud", isolation: "worktree" },
+ "Cloud children always run in their own isolated sandbox",
+ ],
+ [
+ "a branch without a repository",
+ { target: "cloud", branch: "main" },
+ 'Pass `repository` as "owner/repo" alongside `branch`',
+ ],
+ [
+ "an empty task",
+ { task: " " },
+ "`task` must be a self-contained brief",
+ ],
+ ])(
+ "rejects %s with corrective guidance and launches nothing",
+ async (_label, overrides, guidance) => {
+ await handleLaunchChildConversationAction(
+ action(overrides),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ const result = reportedResult();
+ expect(result.status).toBe("error");
+ expect(result.status === "error" && result.guidance).toContain(
+ guidance,
+ );
+ expect(mockCreateConversation).not.toHaveBeenCalled();
+ expect(mockCreateCloudAppConversation).not.toHaveBeenCalled();
+ },
+ );
+ });
+
+ describe("local target", () => {
+ // The agent server rejects a parent in a different workspace, so the child
+ // has to request the parent's own directory and isolate via the worktree.
+ it("launches into the parent's workspace as a linked, isolated child", async () => {
+ seedWorktreeCapableParent();
+
+ await handleLaunchChildConversationAction(
+ action({}),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(mockCreateConversation).toHaveBeenCalledWith(
+ "Add a regression test for the parser",
+ undefined,
+ undefined,
+ PARENT_REPO_METADATA,
+ "/Users/jane/projects/foo",
+ "new_worktree",
+ PARENT_ID,
+ );
+ });
+
+ it("reports the child's id, url and status back to the agent", async () => {
+ seedWorktreeCapableParent();
+
+ await handleLaunchChildConversationAction(
+ action({}),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(reportedResult()).toMatchObject({
+ status: "launched",
+ target: "local",
+ conversation_id: "child-id",
+ url: "http://localhost:3000/conversations/child-id",
+ initial_status: "READY",
+ workspace: "/Users/jane/projects/foo",
+ isolation: "worktree",
+ parent_link: true,
+ });
+ });
+
+ // Local start requests carry no title field — only `autotitle` — so an
+ // explicit title has to be applied as a follow-up rename.
+ it("renames the child when a title is given", async () => {
+ await handleLaunchChildConversationAction(
+ action({ title: "Parser tests" }),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(mockUpdateTitle).toHaveBeenCalledWith("child-id", "Parser tests");
+ });
+
+ it("runs a shared-isolation child in the parent's directory itself", async () => {
+ await handleLaunchChildConversationAction(
+ action({ isolation: "shared" }),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(mockCreateConversation).toHaveBeenCalledWith(
+ expect.anything(),
+ undefined,
+ undefined,
+ null,
+ "/Users/jane/projects/foo",
+ "local_repo",
+ PARENT_ID,
+ );
+ });
+
+ // `parent_conversation_id` landed in agent-server 1.37.1; older servers
+ // drop it silently, so the agent must not be told the link exists.
+ it("reports that an older agent server did not persist the parent link", async () => {
+ mockGetCachedAgentServerVersion.mockReturnValue("1.37.0");
+
+ await handleLaunchChildConversationAction(
+ action({}),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ const result = reportedResult();
+ expect(result).toMatchObject({ status: "launched", parent_link: false });
+ expect(result.status === "launched" && result.parent_link_note).toContain(
+ "1.37.1",
+ );
+ });
+
+ it("turns a failed launch into corrective guidance", async () => {
+ mockCreateConversation.mockRejectedValue(new Error("boom"));
+
+ await handleLaunchChildConversationAction(
+ action({}),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(reportedResult()).toMatchObject({
+ status: "error",
+ error: "boom",
+ });
+ });
+
+ // A conversation started without a repository runs in a scratch directory
+ // that is `git init`-ed but never committed to. `git worktree add` cannot
+ // branch from an unborn HEAD, and the agent-server raises that as a 500
+ // that used to take the whole launch down.
+ it("skips the worktree when the parent workspace cannot host one", async () => {
+ await handleLaunchChildConversationAction(
+ action({}),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(mockCreateConversation).toHaveBeenCalledTimes(1);
+ expect(mockCreateConversation).toHaveBeenCalledWith(
+ expect.anything(),
+ undefined,
+ undefined,
+ null,
+ "/Users/jane/projects/foo",
+ "local_repo",
+ PARENT_ID,
+ );
+
+ const result = reportedResult();
+ expect(result).toMatchObject({ status: "launched", isolation: "shared" });
+ expect(result.status === "launched" && result.isolation_note).toContain(
+ "no commits",
+ );
+ });
+
+ // The metadata check cannot see the workspace's git state, so a worktree
+ // that fails anyway must not lose the launch.
+ it("falls back to a shared child when the worktree cannot be created", async () => {
+ seedWorktreeCapableParent();
+ mockCreateConversation.mockRejectedValueOnce(
+ new Error("fatal: not a valid object name: 'HEAD'"),
+ );
+
+ await handleLaunchChildConversationAction(
+ action({}),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(mockCreateConversation).toHaveBeenCalledTimes(2);
+ expect(mockCreateConversation).toHaveBeenLastCalledWith(
+ expect.anything(),
+ undefined,
+ undefined,
+ PARENT_REPO_METADATA,
+ "/Users/jane/projects/foo",
+ "local_repo",
+ PARENT_ID,
+ );
+
+ const result = reportedResult();
+ expect(result).toMatchObject({
+ status: "launched",
+ conversation_id: "child-id",
+ isolation: "shared",
+ });
+ // The agent promised the user an isolated child, so it has to learn that
+ // this one shares the parent's directory after all.
+ expect(result.status === "launched" && result.isolation_note).toContain(
+ "not a valid object name",
+ );
+ });
+
+ // Both attempts failing means the worktree was not the problem, so the
+ // agent gets the original error rather than the fallback's.
+ it("reports the original failure when the shared fallback also fails", async () => {
+ seedWorktreeCapableParent();
+ mockCreateConversation
+ .mockRejectedValueOnce(new Error("worktree boom"))
+ .mockRejectedValueOnce(new Error("fallback boom"));
+
+ await handleLaunchChildConversationAction(
+ action({}),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(mockCreateConversation).toHaveBeenCalledTimes(2);
+ expect(reportedResult()).toMatchObject({
+ status: "error",
+ error: "worktree boom",
+ });
+ });
+
+ it("does not retry a child that never asked for a worktree", async () => {
+ mockCreateConversation.mockRejectedValue(new Error("boom"));
+
+ await handleLaunchChildConversationAction(
+ action({ isolation: "shared" }),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(mockCreateConversation).toHaveBeenCalledTimes(1);
+ expect(reportedResult()).toMatchObject({
+ status: "error",
+ error: "boom",
+ });
+ });
+ });
+
+ describe("cloud target", () => {
+ const cloudBackend = {
+ id: "cloud-1",
+ kind: "cloud" as const,
+ host: "https://app.all-hands.dev",
+ apiKey: "secret",
+ name: "Cloud",
+ };
+
+ beforeEach(() => {
+ mockPickCloudBackend.mockReturnValue(cloudBackend);
+ });
+
+ it("starts the child on the connected cloud backend without a dangling parent link", async () => {
+ mockCreateCloudAppConversation.mockResolvedValue({
+ id: "start-task-id",
+ app_conversation_id: "cloud-child-id",
+ status: "READY",
+ });
+
+ await handleLaunchChildConversationAction(
+ action({
+ target: "cloud",
+ repository: "octocat/hello-world",
+ branch: "main",
+ }),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(mockCreateCloudAppConversation).toHaveBeenCalledWith(
+ expect.objectContaining({
+ selected_repository: "octocat/hello-world",
+ selected_branch: "main",
+ // The parent lives on the local agent server, so Cloud has no
+ // conversation to link the child to — and a dangling id would hide
+ // the child from the Cloud conversation list.
+ parent_conversation_id: null,
+ }),
+ cloudBackend,
+ );
+ });
+
+ // Cloud provisions the sandbox asynchronously and only fills in
+ // `app_conversation_id` at READY, so reporting the first response would
+ // hand the agent a result with no conversation to open.
+ it("waits for the sandbox to expose the conversation id before reporting", async () => {
+ mockCreateCloudAppConversation.mockResolvedValue({
+ id: "start-task-id",
+ app_conversation_id: null,
+ status: "WORKING",
+ });
+ mockGetCloudStartTask.mockResolvedValue({
+ id: "start-task-id",
+ app_conversation_id: "cloud-child-id",
+ status: "READY",
+ });
+
+ await handleLaunchChildConversationAction(
+ action({ target: "cloud" }),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(reportedResult()).toMatchObject({
+ status: "launched",
+ target: "cloud",
+ conversation_id: "cloud-child-id",
+ url: "https://app.all-hands.dev/conversations/cloud-child-id",
+ initial_status: "READY",
+ parent_link: false,
+ });
+ });
+
+ it("guides the agent back to local when no cloud backend is connected", async () => {
+ mockPickCloudBackend.mockReturnValueOnce(null);
+
+ await handleLaunchChildConversationAction(
+ action({ target: "cloud" }),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ const result = reportedResult();
+ expect(result.status).toBe("error");
+ expect(result.status === "error" && result.guidance).toContain(
+ 'target="local"',
+ );
+ expect(mockCreateCloudAppConversation).not.toHaveBeenCalled();
+ });
+ });
+
+ // A replayed ActionEvent (socket reconnect, or a REST/WebSocket race after a
+ // reload) must not start a second — on Cloud, billable — conversation.
+ it("ignores a replayed tool call", async () => {
+ const toolCallId = nextToolCallId();
+
+ await handleLaunchChildConversationAction(
+ action({}),
+ PARENT_ID,
+ toolCallId,
+ );
+ await handleLaunchChildConversationAction(
+ action({}),
+ PARENT_ID,
+ toolCallId,
+ );
+
+ expect(mockCreateConversation).toHaveBeenCalledTimes(1);
+ expect(mockSendMessage).toHaveBeenCalledTimes(1);
+ });
+
+ // The agent server cancels an active /goal loop on any inbound message, so
+ // the result stays in the toast rather than ending the user's loop.
+ it("does not post the result while a goal loop is running", async () => {
+ useGoalStore.setState({
+ statusByConversation: {
+ [PARENT_ID]: {
+ active: true,
+ status: "running",
+ iteration: 1,
+ max_iterations: 5,
+ objective: "ship it",
+ verdict: null,
+ },
+ },
+ });
+
+ await handleLaunchChildConversationAction(
+ action({}),
+ PARENT_ID,
+ nextToolCallId(),
+ );
+
+ expect(mockCreateConversation).toHaveBeenCalledTimes(1);
+ expect(mockSendMessage).not.toHaveBeenCalled();
+ });
+});
+
+describe("isLaunchChildConversationActionEvent", () => {
+ const makeActionEvent = (toolName: string) =>
+ ({
+ id: "evt-1",
+ timestamp: "2026-07-26T00:00:00Z",
+ source: "agent",
+ action: {
+ kind: LAUNCH_CHILD_CONVERSATION_ACTION_KIND,
+ target: "local",
+ task: "do the thing",
+ },
+ tool_name: toolName,
+ tool_call_id: "call-1",
+ }) as never;
+
+ it("returns true for an ActionEvent from the launch tool", () => {
+ expect(
+ isLaunchChildConversationActionEvent(
+ makeActionEvent(LAUNCH_CHILD_CONVERSATION_TOOL_NAME),
+ ),
+ ).toBe(true);
+ });
+
+ it("returns false when tool_name belongs to a different tool", () => {
+ expect(
+ isLaunchChildConversationActionEvent(
+ makeActionEvent("canvas_ui_control"),
+ ),
+ ).toBe(false);
+ });
+});
diff --git a/__tests__/tools/launch-child-conversation-tool.test.ts b/__tests__/tools/launch-child-conversation-tool.test.ts
new file mode 100644
index 0000000000..74fcc0d52d
--- /dev/null
+++ b/__tests__/tools/launch-child-conversation-tool.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ LAUNCH_CHILD_CONVERSATION_ACTION_KIND,
+ LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL,
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+} from "#/api/launch-child-conversation-client-tool";
+
+describe("launch_child_conversation client tool", () => {
+ it("exports the semantic tool name and generated action kind", () => {
+ expect(LAUNCH_CHILD_CONVERSATION_TOOL_NAME).toBe(
+ "launch_child_conversation",
+ );
+ expect(LAUNCH_CHILD_CONVERSATION_ACTION_KIND).toBe(
+ "ClientAction_launch_child_conversation",
+ );
+ expect(LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL.name).toBe(
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+ );
+ });
+
+ it("pins the validated parameter schema", () => {
+ expect(LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL.parameters).toMatchObject({
+ type: "object",
+ // Misspelled parameter names must fail server-side (the SDK builds a
+ // pydantic model with extra="forbid") instead of launching a child with
+ // silently dropped arguments.
+ additionalProperties: false,
+ properties: {
+ target: { enum: ["local", "cloud"] },
+ isolation: { enum: ["worktree", "shared"] },
+ },
+ required: ["target", "task"],
+ });
+ });
+
+ it("is annotated as a non-idempotent, outward-reaching tool", () => {
+ expect(LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL.annotations).toEqual({
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: false,
+ openWorldHint: true,
+ });
+ });
+
+ it("tells the agent the result arrives as a follow-up message", () => {
+ expect(LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL.description).toContain(
+ "[child-conversation]",
+ );
+ });
+});
diff --git a/src/api/agent-server-adapter.test.ts b/src/api/agent-server-adapter.test.ts
index d78a9b62b7..bfa5b3920a 100644
--- a/src/api/agent-server-adapter.test.ts
+++ b/src/api/agent-server-adapter.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
+import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation";
import { DEFAULT_SETTINGS } from "#/services/settings";
import type { Settings } from "#/types/settings";
import { buildStartConversationRequest } from "./agent-server-adapter";
@@ -190,6 +191,7 @@ describe("buildStartConversationRequest — agentProfileId path", () => {
expect(payload.agent_settings).toBeUndefined();
expect(payload.client_tools.map((tool) => tool.name)).toEqual([
CANVAS_UI_CLIENT_TOOL_NAME,
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
]);
});
diff --git a/src/api/agent-server-adapter.ts b/src/api/agent-server-adapter.ts
index cdc6b97044..4ea06e28a4 100644
--- a/src/api/agent-server-adapter.ts
+++ b/src/api/agent-server-adapter.ts
@@ -38,6 +38,10 @@ import {
LEGACY_CANVAS_UI_TOOL_NAME,
type ClientToolSpec,
} from "./canvas-ui-client-tool";
+import {
+ LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL,
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+} from "./launch-child-conversation-client-tool";
export interface DirectConversationInfo {
id: string;
@@ -933,6 +937,7 @@ type StartConversationPayload = Record & {
worktree: boolean;
secrets_encrypted?: true;
conversation_id?: string;
+ parent_conversation_id?: string;
secrets?: Record;
tags?: Record;
client_tools: ClientToolSpec[];
@@ -945,6 +950,11 @@ export interface StartConversationOptions {
conversationInstructions?: string;
plugins?: PluginSpec[];
conversationId?: string;
+ // Links the new conversation to an existing one as its child. The
+ // agent-server requires the parent to exist and to share this
+ // conversation's requested `workspace.working_dir` (software-agent-sdk
+ // #4188, agent-server >= 1.37.1); older servers ignore the field.
+ parentConversationId?: string;
workingDir?: string;
worktree?: boolean;
encryptedAgentSettings?: Record;
@@ -1020,8 +1030,15 @@ export function buildStartConversationRequest(
? { agent_profile_id: options.agentProfileId }
: { agent_settings: agentSettings }),
workspace: conversationSettings.workspace,
+ // The agent-server caches each client tool's schema per tool *name* for the
+ // life of the process and rejects a re-registration with a different schema
+ // (`ClientToolSchemaConflictError`). Editing either schema below therefore
+ // requires restarting a long-running dev agent-server before new
+ // conversations can start.
client_tools:
- launchAgentKind === "openhands" ? [CANVAS_UI_CLIENT_TOOL] : [],
+ launchAgentKind === "openhands"
+ ? [CANVAS_UI_CLIENT_TOOL, LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL]
+ : [],
confirmation_policy:
getConversationConfirmationPolicy(conversationSettings),
max_iterations:
@@ -1060,6 +1077,10 @@ export function buildStartConversationRequest(
payload.conversation_id = options.conversationId;
}
+ if (options.parentConversationId) {
+ payload.parent_conversation_id = options.parentConversationId;
+ }
+
const securityAnalyzer =
getConversationSecurityAnalyzer(conversationSettings);
if (securityAnalyzer) {
@@ -1085,6 +1106,7 @@ export function buildStartConversationRequest(
};
delete toolModuleQualnames[LEGACY_CANVAS_UI_TOOL_NAME];
delete toolModuleQualnames[CANVAS_UI_CLIENT_TOOL_NAME];
+ delete toolModuleQualnames[LAUNCH_CHILD_CONVERSATION_TOOL_NAME];
if (Object.keys(toolModuleQualnames).length > 0) {
payload.tool_module_qualnames = toolModuleQualnames;
}
@@ -1150,6 +1172,7 @@ export async function buildStartConversationRequestWithEncryptedSettings(options
conversationInstructions?: string;
plugins?: PluginSpec[];
conversationId?: string;
+ parentConversationId?: string;
workingDir?: string;
worktree?: boolean;
agentProfileId?: string;
diff --git a/src/api/cloud/conversation-service.api.ts b/src/api/cloud/conversation-service.api.ts
index f5e5f29d65..74dfc53e32 100644
--- a/src/api/cloud/conversation-service.api.ts
+++ b/src/api/cloud/conversation-service.api.ts
@@ -1,4 +1,7 @@
-import { getActiveBackend } from "../backend-registry/active-store";
+import {
+ getActiveBackend,
+ getRegisteredBackends,
+} from "../backend-registry/active-store";
import type { Backend } from "../backend-registry/types";
import { getStoredConversationMetadata } from "../conversation-metadata-store";
import type {
@@ -51,6 +54,31 @@ function getActiveCloudBackend(): Backend {
return active;
}
+/**
+ * Resolve the cloud backend a Cloud conversation should be launched against
+ * when the active backend may not be a cloud one. Cloud conversations cannot
+ * register client tools, so the typed launch action always runs from a local
+ * parent and has to reach a registered-but-inactive cloud backend.
+ *
+ * Prefers the active backend when it is already cloud, then the first
+ * registered cloud backend that carries credentials. Returns `null` when the
+ * user has no cloud backend connected, so callers can give the agent
+ * corrective guidance instead of failing opaquely.
+ *
+ * Note: `createCloudClient` only sends `X-Org-Id` for the *active* backend, so
+ * a conversation launched against an inactive backend lands in the API key's
+ * own organization.
+ */
+export function pickCloudBackendForLaunch(): Backend | null {
+ const active = getActiveBackend().backend;
+ if (active.kind === "cloud") return active;
+ return (
+ getRegisteredBackends().find(
+ (backend) => backend.kind === "cloud" && !!backend.apiKey,
+ ) ?? null
+ );
+}
+
/**
* Search the cloud app-conversations list. Mirrors the local
* `AgentServerConversationService.searchConversations` interface but calls
@@ -120,8 +148,11 @@ export async function batchGetCloudConversations(
*/
export async function createCloudAppConversation(
request: AppConversationStartRequest,
+ // Defaults to the active backend. The typed launch action passes an explicit
+ // backend because it always runs from a *local* parent conversation.
+ backendOverride?: Backend,
): Promise {
- const backend = getActiveCloudBackend();
+ const backend = backendOverride ?? getActiveCloudBackend();
const data = await callCloudProxy({
backend,
method: "POST",
@@ -250,8 +281,9 @@ export async function readCloudConversationFile(
*/
export async function getCloudAppConversationStartTask(
taskId: string,
+ backendOverride?: Backend,
): Promise {
- const backend = getActiveCloudBackend();
+ const backend = backendOverride ?? getActiveCloudBackend();
const params = new URLSearchParams();
params.set("ids", taskId);
const data = await callCloudProxy<(AppConversationStartTask | null)[]>({
diff --git a/src/api/conversation-service/agent-server-conversation-service.api.ts b/src/api/conversation-service/agent-server-conversation-service.api.ts
index 9c86b87081..999da8f1a0 100644
--- a/src/api/conversation-service/agent-server-conversation-service.api.ts
+++ b/src/api/conversation-service/agent-server-conversation-service.api.ts
@@ -439,6 +439,11 @@ class AgentServerConversationService {
conversationInstructions,
plugins,
conversationId,
+ // The agent-server rejects a parent in a different workspace, so callers
+ // launching a child must pass the parent's own `working_dir` as
+ // `workingDirOverride` (see `resolveConversationWorkingDir`). Servers
+ // older than 1.37.1 ignore the field and create an unlinked conversation.
+ parentConversationId,
workingDir,
worktree: resolvedWorkspaceMode === "new_worktree",
agentProfileId,
diff --git a/src/api/launch-child-conversation-client-tool.ts b/src/api/launch-child-conversation-client-tool.ts
new file mode 100644
index 0000000000..17ca180111
--- /dev/null
+++ b/src/api/launch-child-conversation-client-tool.ts
@@ -0,0 +1,112 @@
+import {
+ CHILD_CONVERSATION_ISOLATIONS,
+ CHILD_CONVERSATION_RESULT_PREFIX,
+ CHILD_CONVERSATION_TARGETS,
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+} from "#/constants/child-conversation";
+import type { ClientToolSpec } from "./canvas-ui-client-tool";
+
+export {
+ LAUNCH_CHILD_CONVERSATION_ACTION_KIND,
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+} from "#/constants/child-conversation";
+
+const LAUNCH_CHILD_CONVERSATION_DESCRIPTION = `Start a NEW, independent conversation to work on a well-scoped task in parallel with this one, without the user having to run any CLI command. The new conversation is recorded as a child of this one.
+
+The child runs on its own. It does not block you, you do not see its output,
+and it cannot see this conversation's history — everything it needs must be in
+the task brief.
+
+Choosing target:
+
+* target="local" — runs on the same machine, in this conversation's workspace.
+ Fast, no repository clone, no sandbox provisioning. Use this by default when
+ the work is on code that is already checked out here.
+
+* target="cloud" — runs on OpenHands Cloud in its own isolated sandbox, from a
+ git repository. Use this when the work should not touch the user's machine or
+ when it needs a repository that is not checked out locally. Requires the user
+ to have an OpenHands Cloud backend connected in Agent Canvas; if none is
+ connected you will be told so and should fall back to target="local".
+
+Writing the task brief:
+
+* Put everything the child needs in "task": the goal, the relevant file paths,
+ the constraints, the expected deliverable, and how it should report back.
+* Keep each child's scope independent of its siblings so parallel children do
+ not fight over the same files.
+* One call per delegated task. Do NOT call this tool twice for the same task.
+
+Parameter rules (a call that breaks one of these launches nothing and comes
+back with corrective guidance):
+
+* "repository" and "branch" apply to target="cloud" only. A local child always
+ inherits this conversation's workspace, so do not pass them with
+ target="local".
+* "isolation" applies to target="local" only. Cloud sandboxes are always
+ isolated, so do not pass it with target="cloud".
+* isolation="worktree" (the default) gives the child its own git worktree and
+ branch, cut from the repository's default branch — it will NOT see this
+ conversation's uncommitted or committed work. isolation="shared" puts the
+ child in this conversation's exact directory; only choose it when the child
+ genuinely must see work in progress, and expect the two agents to conflict.
+
+What comes back: this tool is acknowledged immediately, before the conversation
+exists. The real outcome arrives moments later as a follow-up message starting
+with "${CHILD_CONVERSATION_RESULT_PREFIX.trim()}" that carries the child's
+conversation id, URL, target and initial status — or an error with guidance.
+Wait for it, then tell the user what you launched and give them the URL. If it
+reports an error, fix the parameters and call this tool again.`;
+
+export const LAUNCH_CHILD_CONVERSATION_CLIENT_TOOL: ClientToolSpec = {
+ name: LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+ description: LAUNCH_CHILD_CONVERSATION_DESCRIPTION,
+ parameters: {
+ type: "object",
+ additionalProperties: false,
+ properties: {
+ target: {
+ type: "string",
+ enum: [...CHILD_CONVERSATION_TARGETS],
+ description:
+ "Where the child runs. 'local' reuses this machine and this conversation's workspace; 'cloud' runs in an isolated OpenHands Cloud sandbox.",
+ },
+ task: {
+ type: "string",
+ description:
+ "Self-contained task brief, sent as the child's first message. The child cannot see this conversation, so state the goal, constraints, expected output and handoff criteria.",
+ },
+ title: {
+ type: "string",
+ description:
+ "Optional short title for the child conversation. Omit to let it title itself from the task.",
+ },
+ repository: {
+ type: "string",
+ description:
+ "target='cloud' only. Repository to clone into the sandbox, as 'owner/repo'. Defaults to this conversation's repository when it has one.",
+ },
+ branch: {
+ type: "string",
+ description:
+ "target='cloud' only, and only together with 'repository'. Defaults to the repository's default branch.",
+ },
+ isolation: {
+ type: "string",
+ enum: [...CHILD_CONVERSATION_ISOLATIONS],
+ description:
+ "target='local' only. 'worktree' (default) gives the child its own git worktree and branch; 'shared' runs it in this conversation's exact directory.",
+ },
+ },
+ required: ["target", "task"],
+ },
+ annotations: {
+ // Launching a conversation writes real state and is not repeatable, so the
+ // agent is asked to predict a security risk before every call.
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: false,
+ // The cloud target reaches OpenHands Cloud.
+ openWorldHint: true,
+ },
+};
diff --git a/src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts b/src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts
index 9e49321606..95d68e0e57 100644
--- a/src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts
+++ b/src/components/conversation-events/chat/event-content-helpers/get-action-event-title.ts
@@ -1,4 +1,5 @@
import { CANVAS_UI_CLIENT_ACTION_KIND } from "#/constants/canvas-ui";
+import { LAUNCH_CHILD_CONVERSATION_ACTION_KIND } from "#/constants/child-conversation";
import type { ActionEvent } from "#/types/agent-server/core";
export type EventTitleDescriptor =
@@ -131,6 +132,12 @@ export const getActionEventTitleDescriptor = (
case "CanvasUIAction":
case CANVAS_UI_CLIENT_ACTION_KIND:
return { kind: "text", text: "CANVASUI" };
+ case LAUNCH_CHILD_CONVERSATION_ACTION_KIND:
+ return {
+ kind: "translation",
+ key: "ACTION_MESSAGE$LAUNCH_CHILD_CONVERSATION",
+ values: {},
+ };
default:
return {
kind: "text",
diff --git a/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx b/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx
index e8dd3d840d..e05afb92ee 100644
--- a/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx
+++ b/src/components/conversation-events/chat/event-content-helpers/get-event-content.tsx
@@ -1,6 +1,7 @@
import { Trans } from "react-i18next";
import React from "react";
import { CANVAS_UI_CLIENT_TOOL_NAME } from "#/constants/canvas-ui";
+import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation";
import {
OpenHandsEvent,
ObservationEvent,
@@ -222,6 +223,10 @@ const getObservationEventTitle = (
observationKey = "OBSERVATION_MESSAGE$CANVAS_UI";
break;
}
+ if (event.tool_name === LAUNCH_CHILD_CONVERSATION_TOOL_NAME) {
+ observationKey = "OBSERVATION_MESSAGE$LAUNCH_CHILD_CONVERSATION";
+ break;
+ }
return observationType.replace("Observation", "").toUpperCase();
case "SwitchLLMObservation":
observationKey = event.observation.is_error
diff --git a/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts b/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts
index f3001db297..5e8005b361 100644
--- a/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts
+++ b/src/components/conversation-events/chat/event-content-helpers/should-render-event.ts
@@ -1,3 +1,4 @@
+import { CHILD_CONVERSATION_RESULT_PREFIX } from "#/constants/child-conversation";
import { MessageEvent, OpenHandsEvent } from "#/types/agent-server/core";
import {
isActionEvent,
@@ -24,18 +25,31 @@ const GOAL_REPROMPT_PREFIXES = [
"Resuming a goal that was paused or interrupted.",
];
-const isGoalLoopReprompt = (event: MessageEvent): boolean => {
- if (event.llm_message?.role !== "user") return false;
+const userMessageText = (event: MessageEvent): string | null => {
+ if (event.llm_message?.role !== "user") return null;
const content = event.llm_message.content;
- const text = Array.isArray(content)
+ return Array.isArray(content)
? content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n")
: "";
+};
+
+const isGoalLoopReprompt = (event: MessageEvent): boolean => {
+ const text = userMessageText(event);
+ if (text === null) return false;
return GOAL_REPROMPT_PREFIXES.some((prefix) => text.startsWith(prefix));
};
+// The frontend posts the outcome of a `launch_child_conversation` call back as
+// a `user` message because client tools have no result channel (see
+// `services/child-conversation-launch.ts`). It is a JSON payload addressed to
+// the agent, which reports the child's id and link in its own reply, so the
+// raw message does not belong in the chat.
+const isChildConversationResult = (event: MessageEvent): boolean =>
+ userMessageText(event)?.startsWith(CHILD_CONVERSATION_RESULT_PREFIX) ?? false;
+
export const shouldRenderEvent = (event: OpenHandsEvent) => {
if (isConversationStateUpdateEvent(event)) {
// A finished `/goal` loop renders inline so it settles into the
@@ -90,9 +104,11 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
// Render message events (user and assistant messages), except the goal loop's
// injected re-prompts — the judge feedback they carry is shown in the goal
- // banner, so otherwise they leak into the chat as fake user turns.
+ // banner, so otherwise they leak into the chat as fake user turns — and the
+ // child-conversation launch results, which are machine payloads the agent
+ // relays in its own reply.
if (isMessageEvent(event)) {
- return !isGoalLoopReprompt(event);
+ return !isGoalLoopReprompt(event) && !isChildConversationResult(event);
}
// Render agent error events
diff --git a/src/constants/child-conversation.ts b/src/constants/child-conversation.ts
new file mode 100644
index 0000000000..2cbb4eb062
--- /dev/null
+++ b/src/constants/child-conversation.ts
@@ -0,0 +1,37 @@
+export const LAUNCH_CHILD_CONVERSATION_TOOL_NAME = "launch_child_conversation";
+
+/**
+ * Action discriminator generated by the SDK for the client-defined launch tool
+ * (`ClientAction_`). Keep the SDK naming convention contained here
+ * instead of repeating its generated class name throughout the frontend.
+ */
+export const LAUNCH_CHILD_CONVERSATION_ACTION_KIND =
+ `ClientAction_${LAUNCH_CHILD_CONVERSATION_TOOL_NAME}` as const;
+
+/**
+ * Prefix of the machine-generated message the frontend posts back into the
+ * parent conversation after a launch. Client tools are acknowledged by the
+ * agent-server before the browser has done any work, so this message is the
+ * only way to hand the agent the child's id, URL and status — or corrective
+ * guidance when the call was malformed. The chat hides it (see
+ * `should-render-event.ts`) because the agent relays the same information in
+ * its own reply.
+ */
+export const CHILD_CONVERSATION_RESULT_PREFIX = "[child-conversation] ";
+
+export const CHILD_CONVERSATION_TARGETS = ["local", "cloud"] as const;
+export type ChildConversationTarget =
+ (typeof CHILD_CONVERSATION_TARGETS)[number];
+
+export const CHILD_CONVERSATION_ISOLATIONS = ["worktree", "shared"] as const;
+export type ChildConversationIsolation =
+ (typeof CHILD_CONVERSATION_ISOLATIONS)[number];
+
+/**
+ * `parent_conversation_id` on the local start-conversation request landed in
+ * software-agent-sdk#4188. Older agent-servers accept the request and drop the
+ * field (the pydantic model ignores extras), so the launch still succeeds — we
+ * just tell the agent the relationship was not persisted instead of letting it
+ * believe in a link that does not exist.
+ */
+export const MIN_AGENT_SERVER_VERSION_FOR_PARENT_LINK = "1.37.1";
diff --git a/src/contexts/conversation-websocket-context.tsx b/src/contexts/conversation-websocket-context.tsx
index f21aa89ccd..d1e984dd30 100644
--- a/src/contexts/conversation-websocket-context.tsx
+++ b/src/contexts/conversation-websocket-context.tsx
@@ -38,8 +38,10 @@ import {
isBrowserNavigateActionEvent,
isSwitchLLMObservationEvent,
isCanvasUIActionEvent,
+ isLaunchChildConversationActionEvent,
} from "#/types/agent-server/type-guards";
import { handleCanvasUIAction } from "#/services/canvas-ui";
+import { handleLaunchChildConversationAction } from "#/services/child-conversation-launch";
import { ConversationStateUpdateEventStats } from "#/types/agent-server/core/events/conversation-state-event";
import type {
ConversationErrorEvent,
@@ -644,6 +646,17 @@ export function ConversationWebSocketProvider({
if (isCanvasUIActionEvent(event)) {
handleCanvasUIAction(event.action, conversationId ?? null);
}
+
+ // Same client-tool pattern, but the work is a network call: launch
+ // the requested child conversation and post the outcome back so the
+ // agent learns the id the server-side acknowledgement can't carry.
+ if (conversationId && isLaunchChildConversationActionEvent(event)) {
+ void handleLaunchChildConversationAction(
+ event.action,
+ conversationId,
+ event.tool_call_id,
+ );
+ }
}
} catch (error) {
console.warn("Failed to parse WebSocket message as JSON:", error);
diff --git a/src/i18n/translation.json b/src/i18n/translation.json
index 6c7eb8f938..78b7004d0f 100644
--- a/src/i18n/translation.json
+++ b/src/i18n/translation.json
@@ -37364,5 +37364,107 @@
"de": "Nach Updates suchen",
"uk": "Перевірити наявність оновлень",
"ca": "Comprova si hi ha actualitzacions"
+ },
+ "CHILD_CONVERSATION$LAUNCHED_LOCAL": {
+ "en": "Launched a local child conversation",
+ "ja": "ローカルの子会話を開始しました",
+ "zh-CN": "已启动本地子会话",
+ "zh-TW": "已啟動本機子對話",
+ "ko-KR": "로컬 하위 대화를 시작했습니다",
+ "no": "Startet en lokal undersamtale",
+ "ar": "تم إطلاق محادثة فرعية محلية",
+ "de": "Lokale untergeordnete Unterhaltung gestartet",
+ "fr": "Conversation enfant locale lancée",
+ "it": "Conversazione figlia locale avviata",
+ "pt": "Conversa filha local iniciada",
+ "es": "Se inició una conversación hija local",
+ "tr": "Yerel alt sohbet başlatıldı",
+ "uk": "Запущено локальну дочірню розмову",
+ "ca": "S'ha iniciat una conversa filla local"
+ },
+ "CHILD_CONVERSATION$LAUNCHED_CLOUD": {
+ "en": "Launched a Cloud child conversation",
+ "ja": "クラウドの子会話を開始しました",
+ "zh-CN": "已启动云端子会话",
+ "zh-TW": "已啟動雲端子對話",
+ "ko-KR": "클라우드 하위 대화를 시작했습니다",
+ "no": "Startet en Cloud-undersamtale",
+ "ar": "تم إطلاق محادثة فرعية على السحابة",
+ "de": "Untergeordnete Cloud-Unterhaltung gestartet",
+ "fr": "Conversation enfant Cloud lancée",
+ "it": "Conversazione figlia Cloud avviata",
+ "pt": "Conversa filha na Cloud iniciada",
+ "es": "Se inició una conversación hija en la nube",
+ "tr": "Cloud alt sohbeti başlatıldı",
+ "uk": "Запущено хмарну дочірню розмову",
+ "ca": "S'ha iniciat una conversa filla al núvol"
+ },
+ "CHILD_CONVERSATION$OPEN": {
+ "en": "Open",
+ "ja": "開く",
+ "zh-CN": "打开",
+ "zh-TW": "開啟",
+ "ko-KR": "열기",
+ "no": "Åpne",
+ "ar": "فتح",
+ "de": "Öffnen",
+ "fr": "Ouvrir",
+ "it": "Apri",
+ "pt": "Abrir",
+ "es": "Abrir",
+ "tr": "Aç",
+ "uk": "Відкрити",
+ "ca": "Obre"
+ },
+ "CHILD_CONVERSATION$LAUNCH_FAILED": {
+ "en": "Could not launch the child conversation: {{error}}",
+ "ja": "子会話を開始できませんでした: {{error}}",
+ "zh-CN": "无法启动子会话:{{error}}",
+ "zh-TW": "無法啟動子對話:{{error}}",
+ "ko-KR": "하위 대화를 시작할 수 없습니다: {{error}}",
+ "no": "Kunne ikke starte undersamtalen: {{error}}",
+ "ar": "تعذّر إطلاق المحادثة الفرعية: {{error}}",
+ "de": "Untergeordnete Unterhaltung konnte nicht gestartet werden: {{error}}",
+ "fr": "Impossible de lancer la conversation enfant : {{error}}",
+ "it": "Impossibile avviare la conversazione figlia: {{error}}",
+ "pt": "Não foi possível iniciar a conversa filha: {{error}}",
+ "es": "No se pudo iniciar la conversación hija: {{error}}",
+ "tr": "Alt sohbet başlatılamadı: {{error}}",
+ "uk": "Не вдалося запустити дочірню розмову: {{error}}",
+ "ca": "No s'ha pogut iniciar la conversa filla: {{error}}"
+ },
+ "ACTION_MESSAGE$LAUNCH_CHILD_CONVERSATION": {
+ "en": "Launching a child conversation",
+ "ja": "子会話を開始しています",
+ "zh-CN": "正在启动子会话",
+ "zh-TW": "正在啟動子對話",
+ "ko-KR": "하위 대화를 시작하는 중",
+ "no": "Starter en undersamtale",
+ "ar": "جارٍ إطلاق محادثة فرعية",
+ "de": "Untergeordnete Unterhaltung wird gestartet",
+ "fr": "Lancement d'une conversation enfant",
+ "it": "Avvio di una conversazione figlia",
+ "pt": "Iniciando uma conversa filha",
+ "es": "Iniciando una conversación hija",
+ "tr": "Alt sohbet başlatılıyor",
+ "uk": "Запуск дочірньої розмови",
+ "ca": "S'està iniciant una conversa filla"
+ },
+ "OBSERVATION_MESSAGE$LAUNCH_CHILD_CONVERSATION": {
+ "en": "Requested a child conversation",
+ "ja": "子会話をリクエストしました",
+ "zh-CN": "已请求子会话",
+ "zh-TW": "已請求子對話",
+ "ko-KR": "하위 대화를 요청했습니다",
+ "no": "Ba om en undersamtale",
+ "ar": "تم طلب محادثة فرعية",
+ "de": "Untergeordnete Unterhaltung angefordert",
+ "fr": "Conversation enfant demandée",
+ "it": "Conversazione figlia richiesta",
+ "pt": "Conversa filha solicitada",
+ "es": "Se solicitó una conversación hija",
+ "tr": "Alt sohbet istendi",
+ "uk": "Запитано дочірню розмову",
+ "ca": "S'ha sol·licitat una conversa filla"
}
}
diff --git a/src/services/child-conversation-launch.ts b/src/services/child-conversation-launch.ts
new file mode 100644
index 0000000000..c4d0a06824
--- /dev/null
+++ b/src/services/child-conversation-launch.ts
@@ -0,0 +1,538 @@
+import i18n from "#/i18n";
+import { I18nKey } from "#/i18n/declaration";
+import {
+ compareAgentServerVersions,
+ getCachedAgentServerVersion,
+} from "#/api/agent-server-compatibility";
+import {
+ createCloudAppConversation,
+ getCloudAppConversationStartTask,
+ pickCloudBackendForLaunch,
+} from "#/api/cloud/conversation-service.api";
+import type { Backend } from "#/api/backend-registry/types";
+import {
+ getStoredConversationMetadata,
+ type ConversationMetadata,
+} from "#/api/conversation-metadata-store";
+import AgentServerConversationService from "#/api/conversation-service/agent-server-conversation-service.api";
+import type { AppConversationStartTask } from "#/api/conversation-service/agent-server-conversation-service.types";
+import {
+ CHILD_CONVERSATION_ISOLATIONS,
+ CHILD_CONVERSATION_RESULT_PREFIX,
+ CHILD_CONVERSATION_TARGETS,
+ LAUNCH_CHILD_CONVERSATION_TOOL_NAME,
+ MIN_AGENT_SERVER_VERSION_FOR_PARENT_LINK,
+ type ChildConversationIsolation,
+ type ChildConversationTarget,
+} from "#/constants/child-conversation";
+import { useGoalStore } from "#/stores/goal-store";
+import type { LaunchChildConversationAction } from "#/types/agent-server/core";
+import { buildAgentCanvasPath } from "#/utils/base-path";
+import {
+ displayErrorToast,
+ displaySuccessToastWithLink,
+} from "#/utils/custom-toast-handlers";
+
+/** Cadence and ceiling for waiting on a Cloud sandbox to expose its id. */
+const CLOUD_START_POLL_INTERVAL_MS = 3_000;
+const CLOUD_START_POLL_TIMEOUT_MS = 180_000;
+
+const LEDGER_STORAGE_KEY_PREFIX = "openhands-child-conversation-launches:";
+
+interface LaunchSuccess {
+ status: "launched";
+ target: ChildConversationTarget;
+ conversation_id: string | null;
+ url: string | null;
+ initial_status: string;
+ title: string | null;
+ /** Local only: the directory the child inherited from this conversation. */
+ workspace?: string;
+ /** Local only: which isolation mode was actually applied. */
+ isolation?: ChildConversationIsolation;
+ /**
+ * Present when the applied isolation is not the one that was asked for,
+ * explaining why and what it means for the child.
+ */
+ isolation_note?: string;
+ /** Cloud only: poll this if `conversation_id` is still null. */
+ start_task_id?: string;
+ /** Cloud only: which connected Cloud backend the child was launched on. */
+ backend?: string;
+ /** Whether the parent/child link was persisted server-side. */
+ parent_link: boolean;
+ /** Present when `parent_link` is false, explaining why. */
+ parent_link_note?: string;
+}
+
+interface LaunchFailure {
+ status: "error";
+ error: string;
+ guidance: string;
+}
+
+export type LaunchChildConversationResult = LaunchSuccess | LaunchFailure;
+
+interface ValidatedParams {
+ target: ChildConversationTarget;
+ task: string;
+ title: string | null;
+ repository: string | null;
+ branch: string | null;
+ isolation: ChildConversationIsolation;
+}
+
+const failure = (error: string, guidance: string): LaunchFailure => ({
+ status: "error",
+ error,
+ guidance,
+});
+
+const quoted = (values: readonly string[]) =>
+ values.map((value) => `"${value}"`).join(" or ");
+
+const blankToNull = (value: string | null | undefined) => {
+ const trimmed = value?.trim();
+ return trimmed ? trimmed : null;
+};
+
+/**
+ * Validate what the agent-server cannot.
+ *
+ * The tool's JSON Schema is turned into a pydantic model with `extra="forbid"`,
+ * so unknown/misspelled parameter names, missing required parameters and wrong
+ * types already fail server-side with a corrective message. `enum` is the gap:
+ * the SDK advertises it to the LLM but drops it when building the model, so a
+ * misspelled `target` reaches us intact and must be rejected here. The
+ * cross-target rules (cloud-only / local-only parameters) are ours to enforce
+ * too, since a schema cannot express them.
+ */
+function validateLaunchParams(
+ action: LaunchChildConversationAction,
+):
+ | { ok: true; params: ValidatedParams }
+ | { ok: false; failure: LaunchFailure } {
+ const target = action.target as ChildConversationTarget;
+ if (!CHILD_CONVERSATION_TARGETS.includes(target)) {
+ return {
+ ok: false,
+ failure: failure(
+ `Unknown target ${JSON.stringify(action.target)}.`,
+ `\`target\` must be exactly ${quoted(CHILD_CONVERSATION_TARGETS)}. Nothing was launched — call ${LAUNCH_CHILD_CONVERSATION_TOOL_NAME} again with a valid target.`,
+ ),
+ };
+ }
+
+ const task = blankToNull(action.task);
+ if (!task) {
+ return {
+ ok: false,
+ failure: failure(
+ "`task` is empty.",
+ "`task` must be a self-contained brief: the child conversation cannot see this one, so state the goal, constraints and expected output.",
+ ),
+ };
+ }
+
+ const repository = blankToNull(action.repository);
+ const branch = blankToNull(action.branch);
+ const isolation = blankToNull(
+ action.isolation,
+ ) as ChildConversationIsolation | null;
+
+ if (target === "local" && (repository || branch)) {
+ return {
+ ok: false,
+ failure: failure(
+ '`repository`/`branch` were passed with target="local".',
+ 'A local child always runs in this conversation\'s workspace. Drop `repository` and `branch`, or use target="cloud" to run against a repository in a Cloud sandbox.',
+ ),
+ };
+ }
+
+ if (target === "cloud" && isolation) {
+ return {
+ ok: false,
+ failure: failure(
+ '`isolation` was passed with target="cloud".',
+ 'Cloud children always run in their own isolated sandbox. Drop `isolation`, or use target="local" to choose between "worktree" and "shared".',
+ ),
+ };
+ }
+
+ if (branch && !repository) {
+ return {
+ ok: false,
+ failure: failure(
+ "`branch` was passed without `repository`.",
+ 'Pass `repository` as "owner/repo" alongside `branch`, or drop `branch` to use the repository\'s default branch.',
+ ),
+ };
+ }
+
+ if (isolation && !CHILD_CONVERSATION_ISOLATIONS.includes(isolation)) {
+ return {
+ ok: false,
+ failure: failure(
+ `Unknown isolation ${JSON.stringify(action.isolation)}.`,
+ `\`isolation\` must be exactly ${quoted(CHILD_CONVERSATION_ISOLATIONS)}. Nothing was launched — call ${LAUNCH_CHILD_CONVERSATION_TOOL_NAME} again with a valid isolation.`,
+ ),
+ };
+ }
+
+ return {
+ ok: true,
+ params: {
+ target,
+ task,
+ title: blankToNull(action.title),
+ repository,
+ branch,
+ isolation: isolation ?? "worktree",
+ },
+ };
+}
+
+/**
+ * Remember which tool calls have already been acted on.
+ *
+ * Unlike the Canvas UI tool, a launch is not idempotent: replaying its
+ * ActionEvent (a socket reconnect that falls back to `resend_mode: "all"`, or a
+ * REST/WebSocket race after a reload) would start a second — on Cloud, billable
+ * — conversation. Claim the tool call before any network work so a replay that
+ * arrives mid-flight is dropped too.
+ */
+function claimToolCall(parentConversationId: string, toolCallId: string) {
+ const key = `${LEDGER_STORAGE_KEY_PREFIX}${parentConversationId}`;
+ let handled: string[] = [];
+ try {
+ const raw = window.localStorage.getItem(key);
+ const parsed: unknown = raw ? JSON.parse(raw) : null;
+ if (Array.isArray(parsed)) {
+ handled = parsed.filter((id): id is string => typeof id === "string");
+ }
+ } catch {
+ // A corrupt ledger must not block the launch; start a fresh one.
+ }
+
+ if (handled.includes(toolCallId)) return false;
+
+ try {
+ window.localStorage.setItem(key, JSON.stringify([...handled, toolCallId]));
+ } catch {
+ // Storage full or unavailable — proceed, accepting replay risk over never
+ // launching at all.
+ }
+ return true;
+}
+
+function absoluteCanvasUrl(path: string) {
+ const canvasPath = buildAgentCanvasPath(path);
+ if (typeof window === "undefined") return canvasPath;
+ return new URL(canvasPath, window.location.origin).toString();
+}
+
+/**
+ * `parent_conversation_id` on the local start request landed in agent-server
+ * 1.37.1. Older servers ignore unknown fields, so the child is created without
+ * the link — report that rather than letting the agent assume a relationship
+ * that does not exist.
+ */
+function localParentLinkNote(): string | null {
+ const version = getCachedAgentServerVersion();
+ if (!version) return null;
+ const comparison = compareAgentServerVersions(
+ version,
+ MIN_AGENT_SERVER_VERSION_FOR_PARENT_LINK,
+ );
+ if (comparison === null || comparison >= 0) return null;
+ return `Agent server ${version} does not persist parent/child conversation links (needs ${MIN_AGENT_SERVER_VERSION_FOR_PARENT_LINK}); the child was created but is not linked to this conversation.`;
+}
+
+/**
+ * Whether the parent's workspace can host a git worktree.
+ *
+ * A conversation started without a repository or an attached workspace runs in
+ * a scratch directory that is `git init`-ed but never committed to. `git
+ * worktree add` cannot branch from an unborn HEAD, and the agent-server raises
+ * the resulting error straight out of its start-conversation handler as a 500,
+ * so asking for a worktree there fails the whole launch. Conversation metadata
+ * is only persisted when a repository or an explicit workspace was chosen (see
+ * `createConversation`), so its absence pinpoints exactly those scratch
+ * workspaces. Anything else is attempted and falls back on failure, because the
+ * frontend cannot inspect the workspace's git state directly.
+ */
+const parentSupportsWorktree = (
+ metadata: ConversationMetadata | null,
+): boolean => !!(metadata?.selected_repository || metadata?.selected_workspace);
+
+const SHARED_FALLBACK_CONSEQUENCE =
+ "The child was launched in this conversation's directory instead, so it can see work in progress here and the two agents may conflict over the same files.";
+
+async function launchLocalChild(
+ params: ValidatedParams,
+ parentConversationId: string,
+): Promise {
+ // The agent-server rejects a parent whose workspace differs from the child's,
+ // so the child requests the parent's own directory. `worktree` then carves an
+ // isolated worktree out of it, which is what keeps siblings from colliding.
+ const workspace =
+ await AgentServerConversationService.resolveConversationWorkingDir(
+ parentConversationId,
+ );
+ const parentMetadata = getStoredConversationMetadata(parentConversationId);
+
+ const createChild = (isolation: ChildConversationIsolation) =>
+ AgentServerConversationService.createConversation(
+ params.task,
+ undefined,
+ undefined,
+ parentMetadata
+ ? {
+ selected_repository: parentMetadata.selected_repository,
+ selected_branch: parentMetadata.selected_branch,
+ git_provider: parentMetadata.git_provider,
+ }
+ : null,
+ workspace,
+ isolation === "shared" ? "local_repo" : "new_worktree",
+ parentConversationId,
+ );
+
+ let isolation = params.isolation;
+ let isolationNote: string | null = null;
+
+ if (isolation === "worktree" && !parentSupportsWorktree(parentMetadata)) {
+ isolation = "shared";
+ isolationNote = `This conversation's workspace is a scratch directory with no commits, which git cannot cut a worktree from. ${SHARED_FALLBACK_CONSEQUENCE}`;
+ }
+
+ let startTask;
+ try {
+ startTask = await createChild(isolation);
+ } catch (worktreeError) {
+ if (isolation !== "worktree") throw worktreeError;
+ // Creating the worktree is the one part of a local launch that can fail on
+ // an otherwise healthy workspace, so retry without it rather than losing
+ // the launch. Anything else fails both attempts and is reported as-is.
+ try {
+ startTask = await createChild("shared");
+ } catch {
+ throw worktreeError;
+ }
+ isolation = "shared";
+ isolationNote = `Creating a git worktree in this conversation's workspace failed (${worktreeError instanceof Error ? worktreeError.message : String(worktreeError)}). ${SHARED_FALLBACK_CONSEQUENCE}`;
+ }
+
+ const conversationId = startTask.app_conversation_id ?? startTask.id;
+
+ if (params.title) {
+ // Local start requests carry no title — only `autotitle`. Best effort: a
+ // failed rename must not fail the launch.
+ await AgentServerConversationService.updateConversationTitle(
+ conversationId,
+ params.title,
+ ).catch(() => undefined);
+ }
+
+ const parentLinkNote = localParentLinkNote();
+ return {
+ status: "launched",
+ target: "local",
+ conversation_id: conversationId,
+ url: absoluteCanvasUrl(`/conversations/${conversationId}`),
+ initial_status: startTask.status,
+ title: params.title,
+ workspace,
+ isolation,
+ ...(isolationNote ? { isolation_note: isolationNote } : {}),
+ parent_link: !parentLinkNote,
+ ...(parentLinkNote ? { parent_link_note: parentLinkNote } : {}),
+ };
+}
+
+const delay = (ms: number) =>
+ new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+
+/**
+ * Wait for the Cloud start task to expose its conversation id.
+ *
+ * The Cloud API provisions a sandbox asynchronously and only fills in
+ * `app_conversation_id` once the task reaches READY, so without this the agent
+ * would get a result with no conversation to open. Bounded: on timeout the
+ * caller reports the still-provisioning task instead of hanging.
+ */
+async function waitForCloudConversationId(
+ task: AppConversationStartTask,
+ backend: Backend,
+): Promise {
+ const deadline = Date.now() + CLOUD_START_POLL_TIMEOUT_MS;
+ let latest = task;
+ while (!latest.app_conversation_id && latest.status !== "ERROR") {
+ if (Date.now() >= deadline) break;
+
+ await delay(CLOUD_START_POLL_INTERVAL_MS);
+
+ const next = await getCloudAppConversationStartTask(
+ latest.id,
+ backend,
+ ).catch(() => null);
+ if (!next) break;
+ latest = next;
+ }
+ return latest;
+}
+
+async function launchCloudChild(
+ params: ValidatedParams,
+ parentConversationId: string,
+): Promise {
+ const backend = pickCloudBackendForLaunch();
+ if (!backend) {
+ return failure(
+ "No OpenHands Cloud backend is connected in Agent Canvas.",
+ 'Ask the user to connect OpenHands Cloud from the backend picker, then call this tool again — or relaunch now with target="local".',
+ );
+ }
+
+ const parentMetadata = getStoredConversationMetadata(parentConversationId);
+ const startTask = await createCloudAppConversation(
+ {
+ initial_message: {
+ role: "user",
+ content: [{ type: "text", text: params.task }],
+ },
+ title: params.title,
+ // Fall back to this conversation's repository so a bare `target: "cloud"`
+ // call still lands the child on the code the user is working on. The
+ // provider only travels with the inherited repository — it may not apply
+ // to one the agent named itself.
+ selected_repository:
+ params.repository ?? parentMetadata?.selected_repository ?? null,
+ selected_branch: params.branch ?? null,
+ git_provider: params.repository
+ ? null
+ : (parentMetadata?.git_provider ?? null),
+ // The parent runs on the local agent-server, so its id means nothing to
+ // Cloud. Sending it would only hide the child from the Cloud
+ // conversation list, which filters out anything with a parent.
+ parent_conversation_id: null,
+ },
+ backend,
+ );
+
+ const settled = await waitForCloudConversationId(startTask, backend);
+ if (settled.status === "ERROR") {
+ return failure(
+ settled.detail || "The Cloud conversation failed to start.",
+ 'The Cloud sandbox could not be provisioned. Report this to the user; you can retry, or fall back to target="local".',
+ );
+ }
+
+ const conversationId = settled.app_conversation_id;
+ return {
+ status: "launched",
+ target: "cloud",
+ conversation_id: conversationId,
+ url: conversationId
+ ? `${backend.host.replace(/\/$/, "")}/conversations/${conversationId}`
+ : null,
+ initial_status: settled.status,
+ title: params.title,
+ start_task_id: settled.id,
+ backend: backend.name,
+ // Cloud children of a local parent carry no server-side link; see above.
+ parent_link: false,
+ parent_link_note:
+ "This conversation runs on the local agent server, so OpenHands Cloud has no parent to link the child to.",
+ };
+}
+
+/**
+ * Hand the outcome back to the agent and the user.
+ *
+ * Client tools are acknowledged by the agent-server before the browser does any
+ * work, so a message is the only way to give the agent the child's id or tell
+ * it how to fix a malformed call. `sendMessage` runs the agent, which is what
+ * makes it relay the result to the user in its next turn.
+ */
+async function reportLaunchResult(
+ parentConversationId: string,
+ result: LaunchChildConversationResult,
+) {
+ if (result.status === "error") {
+ displayErrorToast(
+ i18n.t(I18nKey.CHILD_CONVERSATION$LAUNCH_FAILED, {
+ error: result.error,
+ }),
+ );
+ } else if (result.url) {
+ displaySuccessToastWithLink(
+ i18n.t(
+ result.target === "cloud"
+ ? I18nKey.CHILD_CONVERSATION$LAUNCHED_CLOUD
+ : I18nKey.CHILD_CONVERSATION$LAUNCHED_LOCAL,
+ ),
+ i18n.t(I18nKey.CHILD_CONVERSATION$OPEN),
+ result.url,
+ );
+ }
+
+ // The agent-server cancels an active `/goal` loop on any inbound message, so
+ // a launch must not silently end one. The toast above still tells the user
+ // what happened.
+ const goalStatus =
+ useGoalStore.getState().statusByConversation[parentConversationId];
+ if (goalStatus?.active) return;
+
+ await AgentServerConversationService.sendMessage(parentConversationId, {
+ role: "user",
+ content: [
+ {
+ type: "text",
+ text: `${CHILD_CONVERSATION_RESULT_PREFIX}${JSON.stringify(result)}`,
+ },
+ ],
+ });
+}
+
+/**
+ * Execute a `launch_child_conversation` tool call.
+ *
+ * Never rejects: every failure is turned into corrective guidance for the
+ * agent, because the agent-server has already told it the call succeeded.
+ */
+export async function handleLaunchChildConversationAction(
+ action: LaunchChildConversationAction,
+ parentConversationId: string,
+ toolCallId: string,
+): Promise {
+ if (!claimToolCall(parentConversationId, toolCallId)) return;
+
+ const validation = validateLaunchParams(action);
+ let result: LaunchChildConversationResult;
+ if (!validation.ok) {
+ result = validation.failure;
+ } else {
+ try {
+ result =
+ validation.params.target === "cloud"
+ ? await launchCloudChild(validation.params, parentConversationId)
+ : await launchLocalChild(validation.params, parentConversationId);
+ } catch (error) {
+ result = failure(
+ error instanceof Error ? error.message : String(error),
+ "The launch request failed. Report the error to the user; retry only if the cause looks transient.",
+ );
+ }
+ }
+
+ await reportLaunchResult(parentConversationId, result).catch((error) => {
+ console.warn(
+ `[${LAUNCH_CHILD_CONVERSATION_TOOL_NAME}] Failed to report the launch result:`,
+ error,
+ );
+ });
+}
diff --git a/src/types/agent-server/core/base/action.ts b/src/types/agent-server/core/base/action.ts
index a316bf584c..60a502db03 100644
--- a/src/types/agent-server/core/base/action.ts
+++ b/src/types/agent-server/core/base/action.ts
@@ -1,4 +1,5 @@
import { CANVAS_UI_CLIENT_ACTION_KIND } from "#/constants/canvas-ui";
+import { LAUNCH_CHILD_CONVERSATION_ACTION_KIND } from "#/constants/child-conversation";
import { ActionBase } from "./base";
import { TaskItem } from "./common";
@@ -321,6 +322,24 @@ export interface CanvasUIAction extends ActionBase<
tab?: string | null;
}
+/**
+ * Request to launch a child conversation, emitted over the existing WebSocket
+ * and intercepted by handleLaunchChildConversationAction. The agent-server
+ * validates the parameter *names* and types against the tool schema but not
+ * `enum` values, so the fields stay loosely typed here and the dispatcher
+ * narrows them (returning corrective guidance on a mismatch).
+ */
+export interface LaunchChildConversationAction extends ActionBase<
+ typeof LAUNCH_CHILD_CONVERSATION_ACTION_KIND
+> {
+ target: string;
+ task: string;
+ title?: string | null;
+ repository?: string | null;
+ branch?: string | null;
+ isolation?: string | null;
+}
+
export type Action =
| MCPToolAction
| FinishAction
@@ -346,4 +365,5 @@ export type Action =
| InvokeSkillAction
| TaskAction
| SwitchLLMAction
- | CanvasUIAction;
+ | CanvasUIAction
+ | LaunchChildConversationAction;
diff --git a/src/types/agent-server/core/base/base.ts b/src/types/agent-server/core/base/base.ts
index 891e105851..31eea2470b 100644
--- a/src/types/agent-server/core/base/base.ts
+++ b/src/types/agent-server/core/base/base.ts
@@ -1,4 +1,5 @@
import { CANVAS_UI_CLIENT_ACTION_KIND } from "#/constants/canvas-ui";
+import { LAUNCH_CHILD_CONVERSATION_ACTION_KIND } from "#/constants/child-conversation";
type EventType =
| "MCPTool"
@@ -37,7 +38,8 @@ type ActionEventType =
| "GrepAction"
// The `task` tool delegating work to a spawned subagent.
| "TaskAction"
- | typeof CANVAS_UI_CLIENT_ACTION_KIND;
+ | typeof CANVAS_UI_CLIENT_ACTION_KIND
+ | typeof LAUNCH_CHILD_CONVERSATION_ACTION_KIND;
type ObservationEventType =
| `${ObservationOnlyType}Observation`
| `${EventType}Observation`
diff --git a/src/types/agent-server/type-guards.ts b/src/types/agent-server/type-guards.ts
index 542a9da400..65504665ba 100644
--- a/src/types/agent-server/type-guards.ts
+++ b/src/types/agent-server/type-guards.ts
@@ -2,6 +2,7 @@ import {
CANVAS_UI_CLIENT_TOOL_NAME,
LEGACY_CANVAS_UI_TOOL_NAME,
} from "#/constants/canvas-ui";
+import { LAUNCH_CHILD_CONVERSATION_TOOL_NAME } from "#/constants/child-conversation";
import {
OpenHandsEvent,
@@ -16,6 +17,7 @@ import {
BrowserNavigateAction,
SwitchLLMObservation,
CanvasUIAction,
+ LaunchChildConversationAction,
} from "./core";
import { AgentErrorEvent } from "./core/events/observation-event";
import { MessageEvent } from "./core/events/message-event";
@@ -184,6 +186,18 @@ export const isCanvasUIActionEvent = (
(event.tool_name === LEGACY_CANVAS_UI_TOOL_NAME ||
event.tool_name === CANVAS_UI_CLIENT_TOOL_NAME);
+/**
+ * Type guard for launch-child-conversation tool ActionEvents.
+ *
+ * Discriminates on tool_name, like `isCanvasUIActionEvent`, so the
+ * SDK-generated action kind stays contained in the constants module.
+ */
+export const isLaunchChildConversationActionEvent = (
+ event: OpenHandsEvent,
+): event is ActionEvent =>
+ isActionEvent(event) &&
+ event.tool_name === LAUNCH_CHILD_CONVERSATION_TOOL_NAME;
+
/**
* Type guard function to check if an event is a system prompt event
*/