Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@ import { createReadToolOutputTool } from "./read-tool-output";
import { createReadPromptTool } from "./prompts";
import { createReadResourceTool } from "./resources";
import { createSandboxTool, type VirtualClient } from "./sandbox";
import { createOpenInAgentTool } from "./open-in-agent";
import { createSubtaskTool } from "./subtask";
import { userAskTool } from "./user-ask";
import { proposePlanTool } from "./propose-plan";
import type { ModelsConfig } from "../types";
import { MeshProvider } from "@/ai-providers/types";
import type { MeshProvider } from "@/ai-providers/types";

export interface BuiltinToolParams {
/** Provider — null for Claude Code (subtask tool is omitted when null) */
provider: MeshProvider | null;
organization: OrganizationScope;
models: ModelsConfig;
userId: string;
toolApprovalLevel?: ToolApprovalLevel;
toolOutputMap: Map<string, string>;
passthroughClient: VirtualClient;
Expand All @@ -45,6 +47,7 @@ function buildAllTools(
provider,
organization,
models,
userId,
toolApprovalLevel = "auto",
toolOutputMap,
passthroughClient,
Expand Down Expand Up @@ -76,6 +79,15 @@ function buildAllTools(
passthroughClient,
toolOutputMap,
}),
open_in_agent: createOpenInAgentTool(
writer,
{
organization,
userId,
needsApproval: toolNeedsApproval(toolApprovalLevel, false) !== false,
},
ctx,
),
};
// subtask requires a provider (LLM calls) — skip when provider is null (Claude Code)
if (provider) {
Expand All @@ -99,6 +111,7 @@ function buildAllTools(
sandbox: ReturnType<typeof createSandboxTool>;
read_resource: ReturnType<typeof createReadResourceTool>;
read_prompt: ReturnType<typeof createReadPromptTool>;
open_in_agent: ReturnType<typeof createOpenInAgentTool>;
};
}

Expand Down
106 changes: 106 additions & 0 deletions apps/mesh/src/api/routes/decopilot/built-in-tools/open-in-agent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* open_in_agent Built-in Tool
*
* Validates the target agent and creates an empty thread (task).
* Returns immediately with a taskId — the frontend starts the actual
* agent run via the standard decopilot/stream endpoint.
*/

import type { MeshContext, OrganizationScope } from "@/core/mesh-context";
import type { UIMessageStreamWriter } from "ai";
import { tool, zodSchema } from "ai";
import { z } from "zod";

const OpenInAgentInputSchema = z.object({
agent_id: z
.string()
.min(1)
.max(128)
.describe("The ID of the agent (Virtual MCP) to open."),
context: z
.string()
.min(1)
.max(50_000)
.describe(
"The context/task to forward to the agent. Include all relevant information " +
"from the current conversation — the agent will start fresh with only this context.",
),
});

const description =
"Open a task in another agent's UI. Use this when the user @mentions an agent " +
"and wants to hand off work to that agent's specialized interface. " +
"The user will see a clickable card to navigate to the agent.\n\n" +
"Usage notes:\n" +
"- Include full context (conversation summary, tool results, relevant data) in the context field.\n" +
"- The agent starts fresh — it has no access to this conversation.\n" +
"- This is NOT subtask — the work runs in the agent's own UI, not inline.";

export interface OpenInAgentParams {
organization: OrganizationScope;
userId: string;
needsApproval?: boolean;
}

const ANNOTATIONS = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
} as const;

export function createOpenInAgentTool(
writer: UIMessageStreamWriter,
params: OpenInAgentParams,
ctx: MeshContext,
) {
const { organization, userId, needsApproval } = params;

return tool({
description,
inputSchema: zodSchema(OpenInAgentInputSchema),
needsApproval,
execute: async ({ agent_id }, options) => {
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The tool still requires context but execute ignores it and creates an empty thread. That drops the supplied context, so the delegated agent can start without the intended task details. Either persist the context to the thread or remove/relax the context field and update the description to match.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/api/routes/decopilot/built-in-tools/open-in-agent.ts, line 63:

<comment>The tool still requires `context` but execute ignores it and creates an empty thread. That drops the supplied context, so the delegated agent can start without the intended task details. Either persist the context to the thread or remove/relax the `context` field and update the description to match.</comment>

<file context>
@@ -67,16 +54,15 @@ export function createOpenInAgentTool(
     inputSchema: zodSchema(OpenInAgentInputSchema),
     needsApproval,
-    execute: async ({ agent_id, context }, options) => {
+    execute: async ({ agent_id }, options) => {
       const startTime = performance.now();
       try {
</file context>
Fix with Cubic

const startTime = performance.now();
try {
const virtualMcp = await ctx.storage.virtualMcps.findById(
agent_id,
organization.id,
);

if (!virtualMcp || virtualMcp.organization_id !== organization.id) {
throw new Error("Agent not found");
}

if (virtualMcp.status !== "active") {
throw new Error("Agent is not active");
}

if (!userId) {
throw new Error("User ID is required to create a thread");
}

const taskId = crypto.randomUUID();
await ctx.storage.threads.create({
id: taskId,
created_by: userId,
virtual_mcp_id: agent_id,
});

return {
success: true,
agent_id: virtualMcp.id,
agent_title: virtualMcp.title,
task_id: taskId,
};
} finally {
const latencyMs = performance.now() - startTime;
writer.write({
type: "data-tool-metadata",
id: options.toolCallId,
data: { annotations: ANNOTATIONS, latencyMs },
});
}
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const mockParams: BuiltinToolParams = {
connectionId: "conn_test",
thinking: { id: "model_test" },
} as never,
userId: "user_test",
toolOutputMap: new Map(),
passthroughClient: {
listTools: () => Promise.resolve({ tools: [] }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ const mockParams: BuiltinToolParams = {
connectionId: "conn_test",
thinking: { id: "model_test" },
} as never,
userId: "user_test",
toolOutputMap: new Map(),
passthroughClient: {
listTools: () => Promise.resolve({ tools: [] }),
Expand Down
2 changes: 1 addition & 1 deletion apps/mesh/src/api/routes/decopilot/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export const DEFAULT_THREAD_TITLE = "New chat";

export const PARENT_STEP_LIMIT = 30;
export const SUBAGENT_STEP_LIMIT = 15;
export const SUBAGENT_EXCLUDED_TOOLS = ["user_ask", "subtask"];
export const SUBAGENT_EXCLUDED_TOOLS = ["user_ask", "subtask", "open_in_agent"];

/**
* Base platform prompt — shared by all agents (decopilot and custom).
Expand Down
1 change: 1 addition & 0 deletions apps/mesh/src/api/routes/decopilot/stream-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ async function streamCoreInner(
provider,
organization,
models: input.models,
userId: input.userId,
toolApprovalLevel: input.toolApprovalLevel,
toolOutputMap,
passthroughClient,
Expand Down
54 changes: 44 additions & 10 deletions apps/mesh/src/web/components/chat/derive-parts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,18 +198,52 @@ export function derivePartsFromTiptapDoc(
// Add label to inline text
inlineText += mentionName;

// Handle resource mentions (@) vs prompt mentions (/)
if (char === "@") {
// Resource mentions: metadata contains ReadResourceResult.contents directly
const contents = (node.attrs.metadata ||
[]) as ReadResourceResult["contents"];
parts.push(...resourcesToParts(contents, mentionName));
// @ mentions can be agents or resources — distinguish by metadata shape
const meta = node.attrs.metadata as
| Record<string, unknown>
| unknown[]
| null;
if (meta && !Array.isArray(meta) && "agentId" in meta) {
// Agent mention: instruct the AI to use open_in_agent tool
parts.push({
type: "text",
text:
`[OPEN IN AGENT: ${(meta as { title?: string }).title ?? node.attrs.name} (agent_id: ${(meta as { agentId: string }).agentId})]\n` +
`Use the open_in_agent tool to hand off this task to the agent above. ` +
`Include the full relevant context from this conversation in the context field.`,
});
} else if (Array.isArray(meta)) {
// Resource mention: metadata is ReadResourceResult.contents
parts.push(
...resourcesToParts(
meta as ReadResourceResult["contents"],
mentionName,
),
);
}
} else {
// Prompt mentions: metadata contains PromptMessage[]
const prompts = (node.attrs.metadata ||
node.attrs.prompts ||
[]) as PromptMessage[];
parts.push(...promptMessagesToParts(prompts, mentionName));
// Slash mentions: prompts or resources (both use "/")
// Distinguish by metadata shape: arrays with "role" = prompts, arrays with "uri" = resources
const metadata = node.attrs.metadata || node.attrs.prompts || [];
if (
Array.isArray(metadata) &&
metadata.length > 0 &&
"role" in metadata[0]
) {
// Prompt messages
parts.push(
...promptMessagesToParts(metadata as PromptMessage[], mentionName),
);
} else if (Array.isArray(metadata)) {
// Resource contents
parts.push(
...resourcesToParts(
metadata as ReadResourceResult["contents"],
mentionName,
),
);
}
}
} else if (node.type === "file" && node.attrs) {
const fileAttrs = node.attrs as unknown as FileAttrs;
Expand Down
9 changes: 9 additions & 0 deletions apps/mesh/src/web/components/chat/message/assistant.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { MessageStatsBar } from "../usage-stats.tsx";
import { MessageTextPart } from "./parts/text-part.tsx";
import {
GenericToolCallPart,
OpenInAgentPart,
ProposePlanPart,
SubtaskPart,
UserAskPart,
Expand Down Expand Up @@ -491,6 +492,14 @@ function MessagePart({
latency={getMeta(part.toolCallId)?.latencySeconds}
/>
);
case "tool-open_in_agent":
return (
<OpenInAgentPart
part={part}
annotations={getMeta(part.toolCallId)?.annotations}
latency={getMeta(part.toolCallId)?.latencySeconds}
/>
);
case "text":
return (
<MessageTextPart
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { GenericToolCallPart } from "./generic.tsx";
export { OpenInAgentPart } from "./open-in-agent.tsx";
export { UserAskPart } from "./user-ask.tsx";
export { SubtaskPart } from "./subtask.tsx";
export { ProposePlanPart } from "./propose-plan.tsx";
Loading
Loading