feat(copilot): add task decomposition for agent building

Add a decompose_goal tool that breaks user goals into sub-instructions
before building. Users see a plan checklist and can approve or modify
before the agent is created, improving transparency and control.

- Backend: DecomposeGoalTool, TaskDecompositionResponse model, system prompt update
- Frontend: DecomposeGoal component with StepItem checklist, approve/modify buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
anvyle
2026-04-08 14:33:49 +02:00
parent 261959104a
commit 5bb919e7b5
8 changed files with 496 additions and 0 deletions

View File

@@ -127,6 +127,17 @@ After building the file, reference it with `@@agptfile:` in other tools:
non-overlapping scope to avoid redundant searches.
### Agent Building Workflow — ALWAYS follow this
When the user asks to create an agent, ALWAYS follow this workflow:
1. Analyze the goal and break it into logical sub-instructions.
2. Call `decompose_goal` with the steps (each step = one logical task like
"add input block", "add AI summarizer", "wire blocks together").
3. Wait for user approval before proceeding.
4. After approval, call `create_agent` with the full agent JSON.
For simple goals (1-2 blocks), keep the decomposition brief (2-3 steps).
For complex goals, decompose into 4-8 steps max.
### Tool Discovery Priority
When the user asks to interact with a service or API, follow this order:

View File

@@ -17,6 +17,7 @@ from .connect_integration import ConnectIntegrationTool
from .continue_run_block import ContinueRunBlockTool
from .create_agent import CreateAgentTool
from .customize_agent import CustomizeAgentTool
from .decompose_goal import DecomposeGoalTool
from .edit_agent import EditAgentTool
from .feature_requests import CreateFeatureRequestTool, SearchFeatureRequestsTool
from .find_agent import FindAgentTool
@@ -59,6 +60,7 @@ TOOL_REGISTRY: dict[str, BaseTool] = {
"ask_question": AskQuestionTool(),
"create_agent": CreateAgentTool(),
"customize_agent": CustomizeAgentTool(),
"decompose_goal": DecomposeGoalTool(),
"edit_agent": EditAgentTool(),
"find_agent": FindAgentTool(),
"find_block": FindBlockTool(),

View File

@@ -0,0 +1,130 @@
"""DecomposeGoalTool - Breaks agent-building goals into sub-instructions."""
import logging
from typing import Any
from backend.copilot.model import ChatSession
from .base import BaseTool
from .models import (
DecompositionStepModel,
ErrorResponse,
TaskDecompositionResponse,
ToolResponseBase,
)
logger = logging.getLogger(__name__)
MAX_STEPS = 10
class DecomposeGoalTool(BaseTool):
"""Tool for decomposing an agent goal into sub-instructions."""
@property
def name(self) -> str:
return "decompose_goal"
@property
def description(self) -> str:
return (
"Break down an agent-building goal into logical sub-instructions. "
"Each step maps to one task (e.g. add a block, wire connections, "
"configure settings). ALWAYS call this before create_agent to show "
"the user your plan and get approval."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"goal": {
"type": "string",
"description": "The user's agent-building goal.",
},
"steps": {
"type": "array",
"items": {
"type": "object",
"properties": {
"description": {
"type": "string",
"description": "Human-readable step description.",
},
"action": {
"type": "string",
"description": (
"Action type: 'add_block', 'connect_blocks', "
"'configure', 'add_input', 'add_output'."
),
},
"block_name": {
"type": "string",
"description": "Block name if adding a block.",
},
},
"required": ["description", "action"],
},
"description": "List of sub-instructions for the plan.",
},
"require_approval": {
"type": "boolean",
"description": "Whether to ask user for approval (default: true).",
"default": True,
},
},
"required": ["goal", "steps"],
}
async def _execute(
self,
user_id: str | None,
session: ChatSession,
goal: str | None = None,
steps: list[dict[str, Any]] | None = None,
require_approval: bool = True,
**kwargs,
) -> ToolResponseBase:
session_id = session.session_id if session else None
if not goal:
return ErrorResponse(
message="Please provide a goal to decompose.",
error="missing_goal",
session_id=session_id,
)
if not steps:
return ErrorResponse(
message="Please provide at least one step in the plan.",
error="missing_steps",
session_id=session_id,
)
if len(steps) > MAX_STEPS:
return ErrorResponse(
message=f"Too many steps ({len(steps)}). Keep the plan to {MAX_STEPS} steps max.",
error="too_many_steps",
session_id=session_id,
)
decomposition_steps = [
DecompositionStepModel(
step_id=f"step_{i + 1}",
description=step.get("description", ""),
action=step.get("action", "add_block"),
block_name=step.get("block_name"),
status="pending",
)
for i, step in enumerate(steps)
]
return TaskDecompositionResponse(
message=f"Here's the plan to build your agent ({len(decomposition_steps)} steps):",
goal=goal,
steps=decomposition_steps,
step_count=len(decomposition_steps),
requires_approval=require_approval,
session_id=session_id,
)

View File

@@ -36,6 +36,9 @@ class ResponseType(str, Enum):
AGENT_BUILDER_VALIDATION_RESULT = "agent_builder_validation_result"
AGENT_BUILDER_FIX_RESULT = "agent_builder_fix_result"
# Task decomposition (goal → sub-instructions)
TASK_DECOMPOSITION = "task_decomposition"
# Block
BLOCK_LIST = "block_list"
BLOCK_DETAILS = "block_details"
@@ -688,3 +691,35 @@ class AgentsMovedToFolderResponse(ToolResponseBase):
agent_names: list[str] = []
folder_id: str | None = None
count: int = 0
# Task decomposition models
class DecompositionStepModel(BaseModel):
"""A single step in a decomposed agent-building plan."""
step_id: str = Field(description="Unique step identifier, e.g. 'step_1'")
description: str = Field(description="Human-readable step description")
action: str = Field(
description="Action type: 'add_block', 'connect_blocks', 'configure', etc."
)
block_name: str | None = Field(
default=None, description="Block being added, if applicable"
)
status: str = Field(
default="pending",
description="Step status: pending, in_progress, completed, failed",
)
class TaskDecompositionResponse(ToolResponseBase):
"""Response for decompose_goal tool — shows the plan to the user."""
type: ResponseType = ResponseType.TASK_DECOMPOSITION
goal: str = Field(description="The original user goal")
steps: list[DecompositionStepModel]
step_count: int
requires_approval: bool = True

View File

@@ -5,6 +5,7 @@ import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { AskQuestionTool } from "../../../tools/AskQuestion/AskQuestion";
import { ConnectIntegrationTool } from "../../../tools/ConnectIntegrationTool/ConnectIntegrationTool";
import { CreateAgentTool } from "../../../tools/CreateAgent/CreateAgent";
import { DecomposeGoalTool } from "../../../tools/DecomposeGoal/DecomposeGoal";
import { EditAgentTool } from "../../../tools/EditAgent/EditAgent";
import {
CreateFeatureRequestTool,
@@ -144,6 +145,8 @@ export function MessagePartRenderer({
case "tool-run_agent":
case "tool-schedule_agent":
return <RunAgentTool key={key} part={part as ToolUIPart} />;
case "tool-decompose_goal":
return <DecomposeGoalTool key={key} part={part as ToolUIPart} />;
case "tool-create_agent":
return <CreateAgentTool key={key} part={part as ToolUIPart} />;
case "tool-edit_agent":

View File

@@ -0,0 +1,124 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { CheckIcon, PencilSimpleIcon } from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import {
ContentGrid,
ContentHint,
ContentMessage,
} from "../../components/ToolAccordion/AccordionContent";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import { ToolErrorCard } from "../../components/ToolErrorCard/ToolErrorCard";
import { StepItem } from "./components/StepItem";
import {
AccordionIcon,
getAnimationText,
getDecomposeGoalOutput,
isDecompositionOutput,
isErrorOutput,
ToolIcon,
} from "./helpers";
interface Props {
part: ToolUIPart;
}
export function DecomposeGoalTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const output = getDecomposeGoalOutput(part);
const isError =
part.state === "output-error" || (!!output && isErrorOutput(output));
const isOperating = !output;
function handleApprove() {
onSend("Approved. Please build the agent.");
}
function handleModify() {
onSend("I'd like to modify the plan. Here are my changes: ");
}
return (
<div className="py-2">
{isOperating && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ToolIcon isStreaming={isStreaming} isError={isError} />
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
)}
{isError && output && isErrorOutput(output) && (
<ToolErrorCard
message={output.message ?? ""}
fallbackMessage="Failed to analyze the goal. Please try again."
actions={[
{
label: "Try again",
onClick: () => onSend("Please try decomposing the goal again."),
},
]}
/>
)}
{output && isDecompositionOutput(output) && (
<ToolAccordion
icon={<AccordionIcon />}
title={`Build Plan — ${output.step_count} steps`}
description={output.goal}
defaultExpanded
>
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
<div className="space-y-0.5 rounded-lg border border-slate-200 bg-white p-3">
{output.steps.map((step, i) => (
<StepItem
key={step.step_id}
index={i}
description={step.description}
action={step.action}
blockName={step.block_name}
status={step.status}
/>
))}
</div>
{output.requires_approval && (
<div className="flex items-center gap-2 pt-1">
<Button variant="primary" onClick={handleApprove}>
<span className="inline-flex items-center gap-1.5">
<CheckIcon size={14} weight="bold" />
Approve
</span>
</Button>
<Button variant="ghost" onClick={handleModify}>
<span className="inline-flex items-center gap-1.5">
<PencilSimpleIcon size={14} weight="bold" />
Modify
</span>
</Button>
</div>
)}
<ContentHint>
Review the plan above and approve to start building.
</ContentHint>
</ContentGrid>
</ToolAccordion>
)}
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { CubeIcon } from "@phosphor-icons/react";
import { StepStatusIcon } from "../helpers";
interface Props {
index: number;
description: string;
action: string;
blockName?: string | null;
status: string;
}
export function StepItem({ index, description, blockName, status }: Props) {
return (
<div className="flex items-start gap-3 py-1.5">
<div className="mt-0.5 flex shrink-0 items-center">
<StepStatusIcon status={status} />
</div>
<div className="min-w-0 flex-1">
<Text variant="body-medium" className="text-sm text-zinc-800">
{index + 1}. {description}
</Text>
{blockName && (
<div className="mt-0.5 flex items-center gap-1">
<CubeIcon size={12} className="text-neutral-400" />
<Text
variant="small"
className="font-mono text-xs text-neutral-500"
>
{blockName}
</Text>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,152 @@
"use client";
import {
CheckCircleIcon,
CircleDashedIcon,
ListChecksIcon,
SpinnerGapIcon,
WarningDiamondIcon,
XCircleIcon,
} from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { ScaleLoader } from "../../components/ScaleLoader/ScaleLoader";
interface DecompositionStep {
step_id: string;
description: string;
action: string;
block_name?: string | null;
status: string;
}
export interface TaskDecompositionOutput {
type: string;
message: string;
goal: string;
steps: DecompositionStep[];
step_count: number;
requires_approval: boolean;
}
export interface DecomposeErrorOutput {
type: string;
error?: string;
message?: string;
}
export type DecomposeGoalOutput =
| TaskDecompositionOutput
| DecomposeErrorOutput;
function parseOutput(output: unknown): DecomposeGoalOutput | null {
if (!output) return null;
if (typeof output === "string") {
const trimmed = output.trim();
if (!trimmed) return null;
try {
return parseOutput(JSON.parse(trimmed) as unknown);
} catch {
return null;
}
}
if (typeof output === "object") {
if ("steps" in output && "goal" in output) {
return output as TaskDecompositionOutput;
}
if ("error" in output) {
return output as DecomposeErrorOutput;
}
}
return null;
}
export function getDecomposeGoalOutput(
part: unknown,
): DecomposeGoalOutput | null {
if (!part || typeof part !== "object") return null;
return parseOutput((part as { output?: unknown }).output);
}
export function isDecompositionOutput(
output: DecomposeGoalOutput,
): output is TaskDecompositionOutput {
return "steps" in output && "goal" in output;
}
export function isErrorOutput(
output: DecomposeGoalOutput,
): output is DecomposeErrorOutput {
return "error" in output;
}
export function getAnimationText(part: {
state: ToolUIPart["state"];
output?: unknown;
}): string {
switch (part.state) {
case "input-streaming":
case "input-available":
return "Analyzing your goal...";
case "output-available": {
const output = parseOutput(part.output);
if (output && isDecompositionOutput(output))
return `Plan ready (${output.step_count} steps)`;
return "Analyzing your goal...";
}
case "output-error":
return "Error analyzing goal";
default:
return "Analyzing your goal...";
}
}
export function ToolIcon({
isStreaming,
isError,
}: {
isStreaming?: boolean;
isError?: boolean;
}) {
if (isError) {
return (
<WarningDiamondIcon size={14} weight="regular" className="text-red-500" />
);
}
if (isStreaming) {
return <ScaleLoader size={14} />;
}
return (
<ListChecksIcon size={14} weight="regular" className="text-neutral-400" />
);
}
export function AccordionIcon() {
return <ListChecksIcon size={32} weight="light" />;
}
export function StepStatusIcon({ status }: { status: string }) {
switch (status) {
case "completed":
return (
<CheckCircleIcon size={18} weight="fill" className="text-emerald-500" />
);
case "in_progress":
return (
<SpinnerGapIcon
size={18}
weight="bold"
className="animate-spin text-blue-500"
/>
);
case "failed":
return <XCircleIcon size={18} weight="fill" className="text-red-500" />;
default:
return (
<CircleDashedIcon
size={18}
weight="regular"
className="text-neutral-400"
/>
);
}
}