feat: add a typed agent action for launching local or Cloud child conversations (#16380)

This commit is contained in:
Hiep Le
2026-08-07 18:58:12 +07:00
committed by GitHub
parent c3d7077271
commit e882651e29
22 changed files with 1718 additions and 13 deletions
+4 -2
View File
@@ -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,
]);
});
@@ -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", () => {
@@ -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",
@@ -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);
});
});
@@ -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(<span>{getEventContent(launchAction).title}</span>);
expect(
screen.getByText("ACTION_MESSAGE$LAUNCH_CHILD_CONVERSATION"),
).toBeInTheDocument();
render(
<span>{getEventContent(launchObservation, launchAction).title}</span>,
);
expect(
screen.getByText("OBSERVATION_MESSAGE$LAUNCH_CHILD_CONVERSATION"),
).toBeInTheDocument();
});
});
@@ -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>,
): 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);
});
});
@@ -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]",
);
});
});
+2
View File
@@ -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,
]);
});
+24 -1
View File
@@ -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<string, unknown> & {
worktree: boolean;
secrets_encrypted?: true;
conversation_id?: string;
parent_conversation_id?: string;
secrets?: Record<string, LookupSecret>;
tags?: Record<string, string>;
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<string, SettingsValue>;
@@ -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;
+35 -3
View File
@@ -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<AppConversationStartTask> {
const backend = getActiveCloudBackend();
const backend = backendOverride ?? getActiveCloudBackend();
const data = await callCloudProxy<AppConversationStartTask>({
backend,
method: "POST",
@@ -250,8 +281,9 @@ export async function readCloudConversationFile(
*/
export async function getCloudAppConversationStartTask(
taskId: string,
backendOverride?: Backend,
): Promise<AppConversationStartTask | null> {
const backend = getActiveCloudBackend();
const backend = backendOverride ?? getActiveCloudBackend();
const params = new URLSearchParams();
params.set("ids", taskId);
const data = await callCloudProxy<(AppConversationStartTask | null)[]>({
@@ -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,
@@ -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,
},
};
@@ -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",
@@ -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
@@ -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
+37
View File
@@ -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_<tool-name>`). 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";
@@ -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);
+102
View File
@@ -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"
}
}
+538
View File
@@ -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<LaunchChildConversationResult> {
// 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<AppConversationStartTask> {
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<LaunchChildConversationResult> {
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<void> {
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,
);
});
}
+21 -1
View File
@@ -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;
+3 -1
View File
@@ -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`
+14
View File
@@ -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<LaunchChildConversationAction> =>
isActionEvent(event) &&
event.tool_name === LAUNCH_CHILD_CONVERSATION_TOOL_NAME;
/**
* Type guard function to check if an event is a system prompt event
*/