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
@@ -23,6 +23,7 @@ import {
|
||||
type CreateRunSpec,
|
||||
emptyUsage,
|
||||
type RunOutcome,
|
||||
type SubagentEventBody,
|
||||
} from "../runtime/port.ts";
|
||||
import { type ResolvedAgentSettings, resolveAgentSettings } from "../settings.ts";
|
||||
import { compactionFromSettings, type PiModelResolver, resolveModelFromSettings } from "./model.ts";
|
||||
@@ -199,8 +200,12 @@ class PiAgentRun implements AgentRunHandle {
|
||||
return { block: { reason: decision.reason } };
|
||||
}
|
||||
this.toolCalls += 1;
|
||||
if (this.toolCalls > this.settings.maxToolCalls) {
|
||||
const reason = `工具调用次数超出预算(${this.settings.maxToolCalls})`;
|
||||
const extCalls = this.spec.externalUsage?.().toolCalls ?? 0;
|
||||
if (this.toolCalls + extCalls > this.settings.maxToolCalls) {
|
||||
const reason =
|
||||
extCalls > 0
|
||||
? `工具调用次数超出预算(${this.settings.maxToolCalls},含子会话 ${extCalls} 次)`
|
||||
: `工具调用次数超出预算(${this.settings.maxToolCalls})`;
|
||||
this.exceed("tool_calls", reason);
|
||||
this.rejected.add(event.toolCallId);
|
||||
this.emit({
|
||||
@@ -216,6 +221,7 @@ class PiAgentRun implements AgentRunHandle {
|
||||
});
|
||||
hooks.on("before_request", () => {
|
||||
this.checkDeadline();
|
||||
this.checkCost();
|
||||
if (this.budget) this.scheduleAbort();
|
||||
return undefined;
|
||||
});
|
||||
@@ -262,12 +268,7 @@ class PiAgentRun implements AgentRunHandle {
|
||||
});
|
||||
events.on("usage", (e) => {
|
||||
this.usage = toAgentUsage(e.totals);
|
||||
const cost = this.settings.costBudget;
|
||||
if (cost?.maxTotalTokens !== undefined && this.usage.totalTokens > cost.maxTotalTokens) {
|
||||
this.exceed("tokens", `token 用量 ${this.usage.totalTokens} 超出预算 ${cost.maxTotalTokens}`);
|
||||
} else if (cost?.maxCostUsd !== undefined && this.usage.costUsd > cost.maxCostUsd) {
|
||||
this.exceed("cost", `费用 $${this.usage.costUsd.toFixed(4)} 超出预算 $${cost.maxCostUsd}`);
|
||||
}
|
||||
this.checkCost();
|
||||
});
|
||||
events.on("compaction_start", (e) => this.emit({ type: "compaction", phase: "start", reason: e.reason }));
|
||||
events.on("compaction_end", (e) =>
|
||||
@@ -302,6 +303,16 @@ class PiAgentRun implements AgentRunHandle {
|
||||
await this.lane.abort(BACKGROUND_CONTEXT).catch(() => undefined);
|
||||
}
|
||||
|
||||
appendEvent(body: SubagentEventBody): boolean {
|
||||
if (this.closed) return false;
|
||||
this.emit(body);
|
||||
return true;
|
||||
}
|
||||
|
||||
stats(): { toolCalls: number; usage: AgentUsageTotals } {
|
||||
return { toolCalls: this.toolCalls, usage: { ...this.usage } };
|
||||
}
|
||||
|
||||
async compact(customInstructions = DEFAULT_COMPACTION_INSTRUCTIONS): Promise<void> {
|
||||
const r = await this.lane.compact({ customInstructions }, BACKGROUND_CONTEXT);
|
||||
if (!r.ok && r.error._tag !== "NothingToCompact") throw new Error(`压缩失败: ${r.error._tag}`);
|
||||
@@ -398,6 +409,21 @@ class PiAgentRun implements AgentRunHandle {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** token / 费用预算:自身用量 + 子会话用量(externalUsage) */
|
||||
private checkCost(): void {
|
||||
const cost = this.settings.costBudget;
|
||||
if (!cost) return;
|
||||
const ext = this.spec.externalUsage?.();
|
||||
const tokens = this.usage.totalTokens + (ext?.totalTokens ?? 0);
|
||||
const usd = this.usage.costUsd + (ext?.costUsd ?? 0);
|
||||
const note = ext && (ext.totalTokens > 0 || ext.costUsd > 0) ? "(含子会话)" : "";
|
||||
if (cost.maxTotalTokens !== undefined && tokens > cost.maxTotalTokens) {
|
||||
this.exceed("tokens", `token 用量 ${tokens}${note} 超出预算 ${cost.maxTotalTokens}`);
|
||||
} else if (cost.maxCostUsd !== undefined && usd > cost.maxCostUsd) {
|
||||
this.exceed("cost", `费用 $${usd.toFixed(4)}${note} 超出预算 $${cost.maxCostUsd}`);
|
||||
}
|
||||
}
|
||||
|
||||
private checkDeadline(): void {
|
||||
if (this.startedAt !== undefined && Date.now() - this.startedAt > this.settings.maxDurationMs) {
|
||||
this.exceed("duration", `运行时长超出预算(${this.settings.maxDurationMs}ms)`);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AgentBudgetKind,
|
||||
AgentFailureReason,
|
||||
AgentRunEvent,
|
||||
AgentRunEventBody,
|
||||
AgentUsageTotals,
|
||||
CapabilityKind,
|
||||
} from "@umt/contracts";
|
||||
@@ -10,6 +11,11 @@ import type { AgentSettings } from "../settings.ts";
|
||||
// 事件、用量、失败原因的类型统一取自 @umt/contracts(AgentRunEvent / AgentUsageTotals / AgentFailureReason /
|
||||
// AgentBudgetKind);本文件只定义运行时端口。
|
||||
|
||||
export type SubagentEventBody = Extract<
|
||||
AgentRunEventBody,
|
||||
{ type: "subagent_spawned" | "subagent_event" | "subagent_completed" }
|
||||
>;
|
||||
|
||||
/** 调用能力时使用的业务身份;授权回调据此到 automation_grant / approval 查询 */
|
||||
export interface AgentActor {
|
||||
actorId: string;
|
||||
@@ -36,6 +42,17 @@ export interface CreateRunSpec {
|
||||
* (agent_run_event 中无记录时为 0),保证 (run_id, seq) 不冲突;默认 0。
|
||||
*/
|
||||
firstSeq?: number;
|
||||
/**
|
||||
* 子代理:本会话派生的子会话已用掉的量(工具调用 / token / 费用)。父会话总预算含子会话用量——
|
||||
* 运行时在每次工具调用与每轮请求前把它加到自己的用量上再比预算。由调用方缓存,须同步返回。
|
||||
*/
|
||||
externalUsage?: () => ExternalUsage;
|
||||
}
|
||||
|
||||
export interface ExternalUsage {
|
||||
toolCalls: number;
|
||||
totalTokens: number;
|
||||
costUsd: number;
|
||||
}
|
||||
|
||||
export interface RunOutcome {
|
||||
@@ -62,6 +79,13 @@ export interface AgentRunHandle {
|
||||
/** 崩溃/重启后继续未完成的操作 */
|
||||
resume(): Promise<RunOutcome>;
|
||||
abort(reason?: string): Promise<void>;
|
||||
/**
|
||||
* 往本会话事件流追加一条子代理事件(subagent_*),与运行时自身事件共用 seq 计数(单写者)。
|
||||
* 会话已关闭时返回 false,调用方自行落库。
|
||||
*/
|
||||
appendEvent(body: SubagentEventBody): boolean;
|
||||
/** 本会话自身已用量(不含子会话) */
|
||||
stats(): { toolCalls: number; usage: AgentUsageTotals };
|
||||
compact(customInstructions?: string): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export const AgentSettingsSchema = z.object({
|
||||
threshold: z.number().min(0.5).max(0.95).default(0.8),
|
||||
})
|
||||
.default({ mode: "threshold", threshold: 0.8 }),
|
||||
/** v0.1 未实现子代理;false 时忽略,true 也只记录(由 agent.delegate 能力后续实现) */
|
||||
/** 子代理开关(仅记录);派生由服务端 agent.spawn 能力实现,开关与并发/深度上限在服务端校验 */
|
||||
subagentsEnabled: z.boolean().default(false),
|
||||
maxToolCalls: z.number().int().min(1).default(40),
|
||||
maxDurationMs: z
|
||||
|
||||
@@ -16,6 +16,9 @@ export const AgentRunEventType = z.enum([
|
||||
"compaction",
|
||||
"run_completed",
|
||||
"run_failed",
|
||||
"subagent_spawned",
|
||||
"subagent_event",
|
||||
"subagent_completed",
|
||||
]);
|
||||
export type AgentRunEventType = z.infer<typeof AgentRunEventType>;
|
||||
|
||||
@@ -78,6 +81,36 @@ const RunFailed = z.object({
|
||||
budget: AgentBudgetKind.optional(),
|
||||
});
|
||||
|
||||
// 子代理(agent.spawn):子会话的事件镜像进父会话事件流,由父会话的运行进程按父 seq 写入(单写者,seq 不冲突)。
|
||||
const SubagentSpawned = z.object({
|
||||
type: z.literal("subagent_spawned"),
|
||||
childRunId: z.string(),
|
||||
goal: z.string(),
|
||||
mode: z.enum(["execute", "plan_only"]),
|
||||
depth: NonNegInt,
|
||||
capabilities: z.array(z.string()),
|
||||
/** 从父会话划拨给子会话的预算 */
|
||||
budget: z.record(z.string(), z.number()),
|
||||
});
|
||||
/** 子会话的一条事件(工具调用 / 开始 / 结束);childSeq 是子会话内的 seq,正文已脱敏截断 */
|
||||
const SubagentEvent = z.object({
|
||||
type: z.literal("subagent_event"),
|
||||
childRunId: z.string(),
|
||||
childSeq: NonNegInt,
|
||||
childType: z.string(),
|
||||
capabilityId: z.string().optional(),
|
||||
summary: z.string(),
|
||||
});
|
||||
const SubagentCompleted = z.object({
|
||||
type: z.literal("subagent_completed"),
|
||||
childRunId: z.string(),
|
||||
status: z.string(),
|
||||
/** 子会话汇报摘要(业务核查结论 + 未经核查的模型文本),已脱敏截断 */
|
||||
summary: z.string(),
|
||||
usage: AgentUsageTotals,
|
||||
toolCalls: NonNegInt,
|
||||
});
|
||||
|
||||
/** 事件主体(不含 runId/seq/at) */
|
||||
export const AgentRunEventBody = z.discriminatedUnion("type", [
|
||||
RunStarted,
|
||||
@@ -89,6 +122,9 @@ export const AgentRunEventBody = z.discriminatedUnion("type", [
|
||||
Compaction,
|
||||
RunCompleted,
|
||||
RunFailed,
|
||||
SubagentSpawned,
|
||||
SubagentEvent,
|
||||
SubagentCompleted,
|
||||
]);
|
||||
export type AgentRunEventBody = z.infer<typeof AgentRunEventBody>;
|
||||
|
||||
@@ -111,6 +147,9 @@ export const AgentRunEvent = z.discriminatedUnion("type", [
|
||||
Compaction.extend(EventMeta),
|
||||
RunCompleted.extend(EventMeta),
|
||||
RunFailed.extend(EventMeta),
|
||||
SubagentSpawned.extend(EventMeta),
|
||||
SubagentEvent.extend(EventMeta),
|
||||
SubagentCompleted.extend(EventMeta),
|
||||
]);
|
||||
export type AgentRunEvent = AgentRunEventBody & { runId: string; seq: number; at: string };
|
||||
|
||||
@@ -176,6 +215,21 @@ export function agentRunEventToBusEvent(e: AgentRunEvent): TypedEvent | null {
|
||||
else if (e.type === "tool_call_rejected") [capabilityId, summary] = [e.capabilityId ?? e.toolName, e.reason];
|
||||
return { type: "agent.tool_call", payload: { runId: e.runId, runSeq: e.seq, capabilityId, phase, summary } };
|
||||
}
|
||||
// 子会话事件:借 agent.tool_call(能力 agent.spawn)提示父会话页"有新事件",页面按 seq 补读
|
||||
if (e.type === "subagent_spawned" || e.type === "subagent_event" || e.type === "subagent_completed") {
|
||||
const phase =
|
||||
e.type === "subagent_completed" ? (e.status === "succeeded" ? "succeeded" : "failed") : ("started" as const);
|
||||
const summary =
|
||||
e.type === "subagent_spawned"
|
||||
? `派生子会话 ${e.childRunId}:${e.goal}`
|
||||
: e.type === "subagent_event"
|
||||
? `子会话 ${e.childRunId}:${e.childType} ${e.capabilityId ?? ""} ${e.summary}`.trim()
|
||||
: `子会话 ${e.childRunId} ${e.status}:${e.summary}`;
|
||||
return {
|
||||
type: "agent.tool_call",
|
||||
payload: { runId: e.runId, runSeq: e.seq, capabilityId: "agent.spawn", phase, summary: summary.slice(0, 600) },
|
||||
};
|
||||
}
|
||||
if (e.type === "text_delta") {
|
||||
return { type: "agent.text_delta", payload: { runId: e.runId, runSeq: e.seq, delta: e.delta } };
|
||||
}
|
||||
|
||||
@@ -68,6 +68,10 @@ export const AgentRun = EntityBase.extend({
|
||||
mode: AgentRunMode,
|
||||
/** 后台触发按根因去重(如 investigate:<decisionId>);用户委托为 null */
|
||||
dedupeKey: z.string().nullable(),
|
||||
/** 派生深度:用户/后台发起的会话为 0,agent.spawn 派生的子会话 = 父 + 1 */
|
||||
depth: NonNegInt,
|
||||
/** 父会话派生时给的理由 */
|
||||
spawnReason: z.string().nullable(),
|
||||
});
|
||||
export type AgentRun = z.infer<typeof AgentRun>;
|
||||
export const AgentRunCreate = AgentRun.pick({
|
||||
@@ -314,6 +318,8 @@ export const AgentRunListQuery = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
status: AgentRunStatus.optional(),
|
||||
trigger: AgentTrigger.optional(),
|
||||
/** 只列某个会话直接派生的子会话 */
|
||||
parentRunId: Id.optional(),
|
||||
});
|
||||
export type AgentRunListQuery = z.infer<typeof AgentRunListQuery>;
|
||||
|
||||
@@ -366,8 +372,42 @@ export const AgentRunMessage = EntityBase.extend({
|
||||
});
|
||||
export type AgentRunMessage = z.infer<typeof AgentRunMessage>;
|
||||
|
||||
/** 会话树节点(父会话 / 子会话,子会话页与父会话页互相跳转) */
|
||||
export const AgentRunNode = z.object({
|
||||
id: Id,
|
||||
parentRunId: Id.nullable(),
|
||||
goal: z.string(),
|
||||
status: AgentRunStatus,
|
||||
mode: AgentRunMode,
|
||||
depth: NonNegInt,
|
||||
spawnReason: z.string().nullable(),
|
||||
toolCallCount: NonNegInt,
|
||||
usage: AgentUsage,
|
||||
summary: z.string().nullable(),
|
||||
/** 能力白名单(继承信息展示) */
|
||||
capabilities: z.array(z.string()),
|
||||
budget: AgentBudget,
|
||||
createdAt: Timestamp,
|
||||
finishedAt: Timestamp.nullable(),
|
||||
});
|
||||
export type AgentRunNode = z.infer<typeof AgentRunNode>;
|
||||
|
||||
/** 本会话 + 全部子孙会话的用量合计(父会话总预算含子会话用量) */
|
||||
export const AgentFamilyUsage = z.object({
|
||||
runs: NonNegInt,
|
||||
toolCalls: NonNegInt,
|
||||
totalTokens: NonNegInt,
|
||||
costUsd: z.number().nonnegative(),
|
||||
});
|
||||
export type AgentFamilyUsage = z.infer<typeof AgentFamilyUsage>;
|
||||
|
||||
export const AgentRunDetail = z.object({
|
||||
run: AgentRun,
|
||||
/** 父会话(子会话才有) */
|
||||
parent: AgentRunNode.nullable().optional(),
|
||||
/** 全部子孙会话(扁平,按 parentRunId 组树) */
|
||||
children: z.array(AgentRunNode).optional(),
|
||||
familyUsage: AgentFamilyUsage.optional(),
|
||||
events: z.array(AgentRunEventRow),
|
||||
/** 下一页起点(传给 afterSeq);null 表示已到最新 */
|
||||
nextAfterSeq: z.number().int().nullable(),
|
||||
|
||||
@@ -186,6 +186,56 @@ export const InventoryQueryOutput = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
// ── 子代理(W7-B) ──
|
||||
|
||||
/** 子会话预算:缺省按父会话剩余预算的一半划拨;超过剩余预算直接拒绝 */
|
||||
export const SubagentBudget = z.object({
|
||||
maxToolCalls: z.number().int().positive().optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
maxCostUsd: z.number().positive().optional(),
|
||||
maxDurationSec: z.number().int().positive().optional(),
|
||||
});
|
||||
export const AgentSpawnInput = z.object({
|
||||
goal: z.string().trim().min(1).max(4000),
|
||||
/** 为什么要派生(展示在会话树里) */
|
||||
reason: z.string().max(500).optional(),
|
||||
/** 必须是父会话能力白名单的子集;缺省继承父会话白名单 */
|
||||
capabilityAllowlist: z.array(z.string()).min(1).max(50).optional(),
|
||||
budget: SubagentBudget.optional(),
|
||||
/** 不得高于父会话:plan_only 的父会话只能派生 plan_only 子会话;缺省同父会话 */
|
||||
mode: z.enum(["execute", "plan_only"]).optional(),
|
||||
/** 覆盖子会话使用的 Agent 上游;缺省同父会话 */
|
||||
upstreamId: Id.optional(),
|
||||
/** true:派生后等它结束再返回(等待不调用模型) */
|
||||
await: z.boolean().default(false),
|
||||
timeoutSec: z.number().int().min(1).max(3600).default(600),
|
||||
});
|
||||
export const SubagentResult = z.object({
|
||||
runId: Id,
|
||||
goal: z.string(),
|
||||
status: z.string(),
|
||||
/** 汇报摘要(业务核查结论 + 未经核查的模型文本),已脱敏截断;未结束时为 null */
|
||||
summary: z.string().nullable(),
|
||||
toolCalls: NonNegInt,
|
||||
usage: z.object({ totalTokens: NonNegInt, costUsd: z.number().nonnegative() }),
|
||||
pending: z.boolean(),
|
||||
});
|
||||
export const AgentSpawnOutput = z.object({
|
||||
runId: Id,
|
||||
depth: NonNegInt,
|
||||
mode: z.enum(["execute", "plan_only"]),
|
||||
capabilities: z.array(z.string()),
|
||||
budget: SubagentBudget,
|
||||
/** await=true 时的结果 */
|
||||
result: SubagentResult.nullable(),
|
||||
});
|
||||
export const AgentAwaitInput = z.object({
|
||||
/** 缺省等本会话派生的全部子会话 */
|
||||
runIds: z.array(Id).min(1).max(20).optional(),
|
||||
timeoutSec: z.number().int().min(1).max(3600).default(600),
|
||||
});
|
||||
export const AgentAwaitOutput = z.object({ results: z.array(SubagentResult), allDone: z.boolean() });
|
||||
|
||||
/** 能力 ID → 输入/输出 schema(server 注册处理器、agent 生成工具定义时共用) */
|
||||
export const CAPABILITY_IO = {
|
||||
"catalog.lookup": { input: CatalogLookupInput, output: CatalogLookupOutput },
|
||||
@@ -203,6 +253,8 @@ export const CAPABILITY_IO = {
|
||||
"policy.preview": { input: PolicyPreviewInput, output: PolicyPreviewOutput },
|
||||
"policy.apply": { input: PolicyApplyInput, output: PolicyApplyOutput },
|
||||
"inventory.query": { input: InventoryQueryInput, output: InventoryQueryOutput },
|
||||
"agent.spawn": { input: AgentSpawnInput, output: AgentSpawnOutput },
|
||||
"agent.await": { input: AgentAwaitInput, output: AgentAwaitOutput },
|
||||
} as const satisfies Record<KnownCapabilityId, { input: z.ZodType; output: z.ZodType }>;
|
||||
|
||||
type CapabilityIo = typeof CAPABILITY_IO;
|
||||
@@ -252,3 +304,9 @@ export type PolicyApplyInput = z.infer<typeof PolicyApplyInput>;
|
||||
export type PolicyApplyOutput = z.infer<typeof PolicyApplyOutput>;
|
||||
export type InventoryQueryInput = z.infer<typeof InventoryQueryInput>;
|
||||
export type InventoryQueryOutput = z.infer<typeof InventoryQueryOutput>;
|
||||
export type AgentSpawnInput = z.infer<typeof AgentSpawnInput>;
|
||||
export type AgentSpawnOutput = z.infer<typeof AgentSpawnOutput>;
|
||||
export type AgentAwaitInput = z.infer<typeof AgentAwaitInput>;
|
||||
export type AgentAwaitOutput = z.infer<typeof AgentAwaitOutput>;
|
||||
export type SubagentResult = z.infer<typeof SubagentResult>;
|
||||
export type SubagentBudget = z.infer<typeof SubagentBudget>;
|
||||
@@ -66,6 +66,8 @@ export const CAPABILITY_IDS = [
|
||||
"policy.preview",
|
||||
"policy.apply",
|
||||
"inventory.query",
|
||||
"agent.spawn",
|
||||
"agent.await",
|
||||
] as const;
|
||||
export const KnownCapabilityId = z.enum(CAPABILITY_IDS);
|
||||
export type KnownCapabilityId = z.infer<typeof KnownCapabilityId>;
|
||||
@@ -271,6 +273,30 @@ export const CAPABILITY_CATALOG: Record<KnownCapabilityId, CapabilityDescriptor>
|
||||
true,
|
||||
false,
|
||||
),
|
||||
// 子代理(设置 pi.subagents 开启后才提供给模型):类别 propose——只建有界子会话,不产生外部副作用,
|
||||
// "先给方案"的会话也能派生(子会话同样只给方案);授权不高于父会话,由服务端校验
|
||||
"agent.spawn": d(
|
||||
"agent.spawn",
|
||||
"派生子会话",
|
||||
"把一个有界子目标交给子会话处理:能力白名单须是本会话白名单的子集,预算从本会话剩余预算里划拨,模式不高于本会话;返回子会话 runId。await=true 时等它结束并返回汇报",
|
||||
"propose",
|
||||
"AgentSpawn",
|
||||
"organize.execute",
|
||||
"medium",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
"agent.await": d(
|
||||
"agent.await",
|
||||
"等待子会话",
|
||||
"等待本会话派生的子会话结束(等待期间不调用模型),返回各自状态、用量与汇报摘要;超时仍未结束的标为 pending",
|
||||
"read",
|
||||
"AgentAwait",
|
||||
"read",
|
||||
"low",
|
||||
true,
|
||||
false,
|
||||
),
|
||||
};
|
||||
|
||||
function d(
|
||||
|
||||
@@ -75,7 +75,17 @@ export const PiSettings = z.object({
|
||||
/** 达到上下文预算的该比例时触发压缩 */
|
||||
thresholdRatio: z.number().min(0.3).max(0.95),
|
||||
}),
|
||||
/** @deprecated 同 subagents.enabled(合并设置时两者保持一致);新代码读 subagents.enabled */
|
||||
subagentsEnabled: z.boolean(),
|
||||
/**
|
||||
* 子代理(agent.spawn,W7-B):关闭时不向模型提供派生能力、服务端拒绝派生;
|
||||
* maxConcurrent = 一个会话同时在跑的子会话上限,maxDepth = 派生层数上限(1 = 子会话不能再派生)。
|
||||
*/
|
||||
subagents: z.object({
|
||||
enabled: z.boolean(),
|
||||
maxConcurrent: z.number().int().min(1).max(8),
|
||||
maxDepth: z.number().int().min(1).max(3),
|
||||
}),
|
||||
/** @deprecated */
|
||||
apiKeySecretRef: Id.nullable(),
|
||||
});
|
||||
@@ -182,6 +192,7 @@ export const DEFAULT_SETTINGS: GlobalSettings = {
|
||||
contextBudget: 128_000,
|
||||
compaction: { enabled: true, thresholdRatio: 0.8 },
|
||||
subagentsEnabled: false,
|
||||
subagents: { enabled: false, maxConcurrent: 2, maxDepth: 1 },
|
||||
apiKeySecretRef: null,
|
||||
},
|
||||
budget: {
|
||||
@@ -220,6 +231,7 @@ export const GlobalSettingsUpdate = z.object({
|
||||
.partial()
|
||||
.extend({
|
||||
compaction: PiSettings.shape.compaction.partial().optional(),
|
||||
subagents: PiSettings.shape.subagents.partial().optional(),
|
||||
/** 只写:明文上游密钥,服务端存为 secret 并回填 apiKeySecretRef;传 null 表示清除 */
|
||||
apiKey: z.string().min(1).max(4096).nullable().optional(),
|
||||
})
|
||||
@@ -238,20 +250,38 @@ export type GlobalSettingsUpdate = z.infer<typeof GlobalSettingsUpdate>;
|
||||
|
||||
/** 合并部分更新(纯函数,web 乐观更新与 server 共用) */
|
||||
export function mergeSettings(current: GlobalSettings, patch: GlobalSettingsUpdate): GlobalSettings {
|
||||
const { apiKey: _apiKey, compaction, ...pi } = patch.pi ?? {};
|
||||
const { apiKey: _apiKey, compaction, subagents, ...pi } = patch.pi ?? {};
|
||||
const { tmdbApiKey: _tmdbApiKey, ...metadata } = patch.metadata ?? {};
|
||||
return {
|
||||
automation: { ...current.automation, ...patch.automation },
|
||||
cache: { ...current.cache, ...patch.cache },
|
||||
organize: { ...current.organize, ...patch.organize },
|
||||
retention: { ...current.retention, ...patch.retention },
|
||||
pi: { ...current.pi, ...pi, compaction: { ...current.pi.compaction, ...compaction } },
|
||||
pi: mergePi(current.pi, pi, compaction, subagents),
|
||||
budget: { ...current.budget, ...patch.budget },
|
||||
agent: { ...current.agent, ...patch.agent },
|
||||
metadata: { ...current.metadata, ...metadata },
|
||||
};
|
||||
}
|
||||
|
||||
/** 子代理开关:subagents.enabled 优先,旧字段 subagentsEnabled 作别名,合并后两者一致 */
|
||||
function mergePi(
|
||||
current: PiSettings,
|
||||
pi: Partial<Omit<PiSettings, "compaction" | "subagents">>,
|
||||
compaction: Partial<PiSettings["compaction"]> | undefined,
|
||||
subagents: Partial<PiSettings["subagents"]> | undefined,
|
||||
): PiSettings {
|
||||
const base = current.subagents ?? DEFAULT_SETTINGS.pi.subagents;
|
||||
const enabled = subagents?.enabled ?? pi.subagentsEnabled ?? base.enabled;
|
||||
return {
|
||||
...current,
|
||||
...pi,
|
||||
compaction: { ...current.compaction, ...compaction },
|
||||
subagents: { ...base, ...subagents, enabled },
|
||||
subagentsEnabled: enabled,
|
||||
};
|
||||
}
|
||||
|
||||
/** settings 表行 */
|
||||
export const SettingEntry = z.object({
|
||||
key: SettingsKey,
|
||||
|
||||
@@ -22,10 +22,28 @@ const samples: C.AgentRunEventBody[] = [
|
||||
{ type: "compaction", phase: "end", reason: "threshold", status: "completed" },
|
||||
{ type: "run_completed", finalText: "done", usage },
|
||||
{ type: "run_failed", reason: "budget", message: "超预算", usage, budget: "tokens" },
|
||||
{
|
||||
type: "subagent_spawned",
|
||||
childRunId: RUN,
|
||||
goal: "子目标",
|
||||
mode: "plan_only",
|
||||
depth: 1,
|
||||
capabilities: ["inventory.query"],
|
||||
budget: { maxToolCalls: 5 },
|
||||
},
|
||||
{
|
||||
type: "subagent_event",
|
||||
childRunId: RUN,
|
||||
childSeq: 3,
|
||||
childType: "tool_call_end",
|
||||
capabilityId: "inventory.query",
|
||||
summary: "{}",
|
||||
},
|
||||
{ type: "subagent_completed", childRunId: RUN, status: "succeeded", summary: "ok", usage, toolCalls: 1 },
|
||||
];
|
||||
|
||||
describe("agent 运行事件", () => {
|
||||
it("样例覆盖全部 9 种事件类型", () => {
|
||||
it("样例覆盖全部 12 种事件类型", () => {
|
||||
expect(samples.map((s) => s.type).sort()).toEqual([...C.AgentRunEventType.options].sort());
|
||||
});
|
||||
|
||||
@@ -60,6 +78,9 @@ describe("agent 运行事件", () => {
|
||||
null,
|
||||
"agent.run_status:succeeded",
|
||||
"agent.run_status:budget_exceeded",
|
||||
"agent.tool_call:started",
|
||||
"agent.tool_call:started",
|
||||
"agent.tool_call:succeeded",
|
||||
]);
|
||||
for (const b of bus) if (b) expect(C.parseEventPayload(b.type, b.payload), b.type).not.toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { Kysely } from "kysely";
|
||||
import { execDdl, oneOf } from "../src/migration-helpers.ts";
|
||||
|
||||
// W7-B Pi 子代理(agent.spawn):agent_run 已有 parent_run_id(0010),这里补派生深度与派生理由;
|
||||
// agent_run_event 增加 subagent_spawned / subagent_event / subagent_completed(子会话事件镜像进父会话事件流)。
|
||||
|
||||
const OLD_TYPES = [
|
||||
"run_started",
|
||||
"text_delta",
|
||||
"thinking_delta",
|
||||
"tool_call_start",
|
||||
"tool_call_end",
|
||||
"tool_call_rejected",
|
||||
"compaction",
|
||||
"run_completed",
|
||||
"run_failed",
|
||||
];
|
||||
const NEW_TYPES = [...OLD_TYPES, "subagent_spawned", "subagent_event", "subagent_completed"];
|
||||
|
||||
const swap = (values: readonly string[]) => `
|
||||
alter table agent_run_event drop constraint agent_run_event_type_check;
|
||||
alter table agent_run_event add constraint agent_run_event_type_check ${oneOf("type", values)};
|
||||
`;
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 迁移不依赖应用表类型
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
alter table agent_run add column depth integer not null default 0 check (depth >= 0);
|
||||
alter table agent_run add column spawn_reason text;
|
||||
create index agent_run_parent_idx on agent_run (parent_run_id, created_at) where parent_run_id is not null;
|
||||
${swap(NEW_TYPES)}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: 同上
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await execDdl(
|
||||
db,
|
||||
`
|
||||
delete from agent_run_event where type in ('subagent_spawned', 'subagent_event', 'subagent_completed');
|
||||
${swap(OLD_TYPES)}
|
||||
drop index if exists agent_run_parent_idx;
|
||||
alter table agent_run drop column spawn_reason;
|
||||
alter table agent_run drop column depth;
|
||||
`,
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,10 @@ export type AgentRunEventType =
|
||||
| "tool_call_rejected"
|
||||
| "compaction"
|
||||
| "run_completed"
|
||||
| "run_failed";
|
||||
| "run_failed"
|
||||
| "subagent_spawned"
|
||||
| "subagent_event"
|
||||
| "subagent_completed";
|
||||
export type AgentRunMessageStatus = "pending" | "consumed";
|
||||
export type ApprovalStatus = "pending" | "approved" | "rejected" | "revoked" | "expired" | "consumed";
|
||||
export type ArtifactKind = "evidence" | "plan" | "proposal" | "report" | "snapshot" | "log";
|
||||
@@ -344,6 +347,8 @@ export interface AgentRunTable {
|
||||
mode: Generated<AgentRunMode>;
|
||||
dedupe_key: string | null;
|
||||
upstream_id: string | null;
|
||||
depth: Generated<number>;
|
||||
spawn_reason: string | null;
|
||||
}
|
||||
|
||||
export interface AgentRunEventTable {
|
||||
|
||||
Reference in new issue
Block a user