feat(agent): Pi subagents via agent.spawn / agent.await
- agent_run gains depth / spawn_reason (0029); agent_run_event types
subagent_spawned / subagent_event / subagent_completed
- agent.spawn (propose, medium, replay never): allowlist ⊆ parent, mode not
above parent, budget carved from parent's remainder, idempotent per run+input;
settings.pi.subagents {enabled, maxConcurrent, maxDepth} (subagentsEnabled kept as alias)
- child runs claim a concurrency slot under an advisory lock (waitForFact while
queued); agent.await polls business tables without calling the model
- parent abort cascades to all descendants; parent runtime budget counts
descendant usage; child events mirrored into the parent stream by parent seq
- authorize caps every child call by all ancestors' allowlists/kinds
- session page shows parent link, inherited info and the subagent tree
- e2e E11; runbook section on subagents
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
daa85a8bb4
commit
459991421e
27 files changed
+1720
-32
No files matched your search
@@ -34,6 +34,7 @@ import {
|
||||
RunStatusBadge,
|
||||
runPlanIds,
|
||||
} from "./agent-conversation.tsx";
|
||||
import { SubagentTree } from "./agent-subagent-tree.tsx";
|
||||
import { useAgentStream } from "./use-agent-stream.ts";
|
||||
|
||||
const TRIGGER_LABEL = { user: "用户发起", event: "事件触发", schedule: "定时", subagent: "子代理" } as const;
|
||||
@@ -181,11 +182,22 @@ export function AgentRunPanel({
|
||||
const audit = useRunInvocationAudit(runId, toolCount > 0, toolCount);
|
||||
const approvals = useRunApprovals(runPlanIds(live.items, run.data?.report ?? null));
|
||||
// run 状态变化(queued → running → 终态)经 SSE 推送:到达即刷新快照,不等 3 秒轮询
|
||||
// 子会话:本会话收到子会话提示(agent.tool_call / agent.spawn)或任一子会话 / 父会话状态变化时刷新会话树
|
||||
const related = new Set([
|
||||
runId,
|
||||
...(run.data?.children ?? []).map((c) => c.id),
|
||||
...(run.data?.parent ? [run.data.parent.id] : []),
|
||||
]);
|
||||
useSse(
|
||||
[EventType.agentRunStatus],
|
||||
[EventType.agentRunStatus, EventType.agentToolCall],
|
||||
(e) => {
|
||||
if ((e.payload as { runId?: string } | null)?.runId === runId)
|
||||
void qc.invalidateQueries({ queryKey: agentKeys.run(runId) });
|
||||
const p = e.payload as { runId?: string; capabilityId?: string } | null;
|
||||
if (!p?.runId) return;
|
||||
const hit =
|
||||
e.type === EventType.agentRunStatus
|
||||
? related.has(p.runId)
|
||||
: p.runId === runId && p.capabilityId === "agent.spawn";
|
||||
if (hit) void qc.invalidateQueries({ queryKey: agentKeys.run(runId) });
|
||||
},
|
||||
!agentMockEnabled(),
|
||||
);
|
||||
@@ -245,6 +257,8 @@ export function AgentRunPanel({
|
||||
<BudgetBar run={r} live={live} />
|
||||
</div>
|
||||
|
||||
<SubagentTree snapshot={run.data} />
|
||||
|
||||
{mode === "plan_only" ? <PlanSteps live={live} active={streaming} /> : null}
|
||||
|
||||
{streaming && sseStatus === "reconnecting" ? (
|
||||
|
||||
@@ -104,6 +104,7 @@ export function AgentSessionList({
|
||||
{(r.mode ?? r.scope.mode) === "plan_only" ? (
|
||||
<span className="text-meta text-muted-foreground">只要方案</span>
|
||||
) : null}
|
||||
{r.parentRunId ? <span className="text-meta text-muted-foreground">子会话</span> : null}
|
||||
<time
|
||||
dateTime={r.createdAt}
|
||||
className="ml-auto text-meta whitespace-nowrap text-muted-foreground"
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import type { AgentFamilyUsage, AgentRunNode } from "@umt/contracts";
|
||||
import { ChevronRight, CornerLeftUp, GitBranch } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { agentMockEnabled, type RunSnapshot } from "~/api/agent.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { RunStatusBadge } from "./agent-conversation.tsx";
|
||||
import { splitContextGoal } from "./agent-store.ts";
|
||||
|
||||
// 子代理(agent.spawn):会话页显示父会话链接 + 继承信息,以及子会话树(可展开、可跳转)与会话树合计用量。
|
||||
// 未派生过子会话、也不是子会话时不渲染(不显示空的多 Agent 看板)。
|
||||
|
||||
const tokens = (n: number | undefined) => (n === undefined ? "—" : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
||||
|
||||
export function usageLine(u: { toolCalls: number; totalTokens?: number; costUsd?: number }) {
|
||||
const parts = [`${u.toolCalls} 次调用`, `${tokens(u.totalTokens)} token`];
|
||||
if (u.costUsd) parts.push(`$${u.costUsd.toFixed(3)}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
const href = (id: string) => `/console/agent/${id}${agentMockEnabled() ? "?mock=1" : ""}`;
|
||||
|
||||
function budgetText(b: Record<string, number | undefined>) {
|
||||
const parts: string[] = [];
|
||||
if (b.maxToolCalls !== undefined) parts.push(`${b.maxToolCalls} 次调用`);
|
||||
if (b.maxTokens !== undefined) parts.push(`${tokens(b.maxTokens)} token`);
|
||||
if (b.maxDurationSec !== undefined) parts.push(`${b.maxDurationSec}s`);
|
||||
if (b.maxCostUsd !== undefined) parts.push(`$${b.maxCostUsd}`);
|
||||
return parts.length ? parts.join(" · ") : "按设置缺省";
|
||||
}
|
||||
|
||||
function Node({ node, byParent, depth }: { node: AgentRunNode; byParent: Map<string, AgentRunNode[]>; depth: number }) {
|
||||
const kids = byParent.get(node.id) ?? [];
|
||||
const [open, setOpen] = useState(true);
|
||||
return (
|
||||
<li>
|
||||
<div className="flex min-w-0 items-start gap-1.5 rounded-lg px-1.5 py-1 hover:bg-foreground/[0.04]">
|
||||
{kids.length ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-label={open ? "收起子会话" : "展开子会话"}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="mt-0.5 cursor-pointer rounded-tight text-muted-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronRight className={cn("size-4 transition-transform duration-micro", open && "rotate-90")} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
to={href(node.id)}
|
||||
className="line-clamp-2 rounded-tight text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{splitContextGoal(node.goal).text}
|
||||
</Link>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-1.5">
|
||||
<RunStatusBadge status={node.status} />
|
||||
{node.mode === "plan_only" ? <span className="text-meta text-muted-foreground">只要方案</span> : null}
|
||||
<span className="text-meta text-muted-foreground tabular-nums">
|
||||
{usageLine({
|
||||
toolCalls: node.toolCallCount,
|
||||
totalTokens: node.usage.totalTokens,
|
||||
costUsd: node.usage.costUsd,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{node.spawnReason ? (
|
||||
<p className="mt-0.5 truncate text-meta text-muted-foreground">理由:{node.spawnReason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{kids.length && open ? (
|
||||
<ul className={cn("ml-3 border-l border-border/70 pl-2", depth > 2 && "ml-1")}>
|
||||
{kids.map((k) => (
|
||||
<Node key={k.id} node={k} byParent={byParent} depth={depth + 1} />
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** 子会话树 + 父会话链接;两者都没有时返回 null */
|
||||
export function SubagentTree({ snapshot }: { snapshot: RunSnapshot }) {
|
||||
const { run, parent, children = [], familyUsage } = snapshot;
|
||||
if (!parent && children.length === 0) return null;
|
||||
const byParent = new Map<string, AgentRunNode[]>();
|
||||
for (const c of children) {
|
||||
const k = c.parentRunId ?? "";
|
||||
byParent.set(k, [...(byParent.get(k) ?? []), c]);
|
||||
}
|
||||
const direct = byParent.get(run.id) ?? [];
|
||||
const caps = ((run.scope as { capabilities?: string[] }).capabilities ?? []).length;
|
||||
return (
|
||||
<section aria-label="子会话" className="space-y-2 rounded-xl border border-border/70 p-3">
|
||||
{parent ? (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<CornerLeftUp aria-hidden className="size-3.5" />
|
||||
父会话
|
||||
</p>
|
||||
<Link
|
||||
to={href(parent.id)}
|
||||
className="line-clamp-2 rounded-tight text-caption break-words text-foreground outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{splitContextGoal(parent.goal).text}
|
||||
</Link>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
第 {run.depth} 层子会话 · 继承父会话的发起人与授权上限 · 能力 {caps} 项(⊆ 父会话)· 划拨预算{" "}
|
||||
{budgetText(run.budget as Record<string, number | undefined>)}
|
||||
{run.spawnReason ? ` · 理由:${run.spawnReason}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{direct.length ? (
|
||||
<div className="space-y-1">
|
||||
<p className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<GitBranch aria-hidden className="size-3.5" />
|
||||
子会话 {children.length} 个{familyUsage ? <FamilyUsage usage={familyUsage} /> : null}
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{direct.map((c) => (
|
||||
<Node key={c.id} node={c} byParent={byParent} depth={1} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FamilyUsage({ usage }: { usage: AgentFamilyUsage }) {
|
||||
return <span className="ml-auto tabular-nums">会话树合计 {usageLine(usage)}</span>;
|
||||
}
|
||||
@@ -153,6 +153,8 @@ function baseRun(id: string, goal: string, mode: AgentMode, createdAt: number, s
|
||||
createdBy: "admin",
|
||||
mode,
|
||||
dedupeKey: null,
|
||||
depth: 0,
|
||||
spawnReason: null,
|
||||
} satisfies AgentRunView;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user