mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 13:38:55 +08:00
feat: add a typed agent action for launching local or Cloud child conversations (#16380)
This commit is contained in:
@@ -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",
|
||||
|
||||
+11
@@ -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]",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
+7
@@ -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
|
||||
|
||||
+21
-5
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user