useagent 子会话控制深读
这页说明 useagent 如何创建延期 child session、继承资源、分页读取事件并把执行关系投影成可验证结构。
Gateway 工具先做 liveness 和能力判断
工具列表把用户预期写进 schema
child session 工具的 schema 不暴露 engine、repo、model 选择,只有 idempotencyKey、prompt、childRunId、cursor、limit 等受限字段。执行入口先检查 current live run 与 sessionCapabilities,再调用 create/list/events/gather。 backend/src/knowledge/gateway/child-session-tools.ts:20 backend/src/knowledge/gateway/child-session-tools.ts:155 backend/src/knowledge/gateway/child-session-tools.ts:178 backend/src/knowledge/gateway/child-session-tools.ts:316
backend/src/knowledge/gateway/child-session-tools.ts:155155export async function childSessionToolsEnabled(
156 claims: ToolTokenClaims,
157): Promise<boolean> {
158 const run = await getRunForOrg(claims.orgId, claims.runId);
159 if (!run || run.status !== "running") return false;
160 // Gateway child sessions are engine-independent (deferred serial thread turns through the
161 // product command lane), so no runtime-session-id requirement: any live run on a
162 // dispatch-ready engine/model gets them, ACP claude/codex included.
163 const capabilities = sessionCapabilities(run.engine, {
164 desktop: Boolean(run.sandboxId),
165 knowledgeTools: true,
166 });
167 return (
168 capabilities.gatewayChildSessions &&
169 persistedEngineModelReadyForDispatch(run.engine, run.model)
170 );
171}
172
173async function currentRun(claims: ToolTokenClaims) {
174 const run = await getRunForOrg(claims.orgId, claims.runId);
175 return run?.status === "running" ? run : null;
176}
177
178async function create(
179 claims: ToolTokenClaims,
180 args: Record<string, unknown>,
181): Promise<ToolCallResult> {
182 const run = await currentRun(claims);
183 if (!run || !(await childSessionToolsEnabled(claims))) {
184 return errorResult(
185 "Child sessions are not enabled for the current live run; they work on every engine but require an active live turn with a dispatch-ready engine and model.",
186 );
187 }
188 const idempotencyKey = cleanString(args.idempotencyKey);
189 const prompt = cleanString(args.prompt);
190 if (!idempotencyKey)
191 return errorResult("child_session_create requires idempotencyKey.");
192 if (!prompt) return errorResult("child_session_create requires prompt.");
193 if (prompt.length > MAX_PROMPT_CHARS) {
194 return errorResult(
195 `child_session_create prompt exceeds ${MAX_PROMPT_CHARS} characters.`,
196 );
197 }
198
199 const outcome = await createChildSession({
200 orgId: claims.orgId,
201 actorId: claims.userId || null,
202 parentRunId: run.id,
203 threadId: run.threadId,
204 prompt,
205 engine: run.engine,
206 model: run.model,
207 repos: run.repos,
208 memoryScope: run.memoryScope,
209 idempotencyKey,
210 });
211 if (outcome.status === "conflict") {
212 return errorResult(
213 "idempotencyKey was already used for different child session input.",
214 );
215 }
216 return textResult(
217 `${outcome.status === "created" ? "Created" : "Replayed"} child session ${outcome.child.id} (${outcome.child.status}).`,
218 { status: outcome.status, child: outcome.child },
219 );
220}standalone gateway 不能直接写 commands
当 `GATEWAY_DATABASE_URL` 存在时,gateway 只能读 runs,不能插入 run commands;源码用 `USEAGENT_API_ORIGIN` 把 child-session 操作转给 primary API,并用重新 mint 的短期 token 保持 tenant/run 绑定。 backend/src/knowledge/gateway/child-session-tools.ts:328 backend/src/knowledge/gateway/child-session-tools.ts:347 backend/src/knowledge/gateway/child-session-tools.ts:358 backend/src/knowledge/gateway/child-session-tools.ts:388
backend/src/knowledge/gateway/child-session-tools.ts:341341async function executeThroughPrimaryApi(
342 origin: string,
343 claims: ToolTokenClaims,
344 name: string,
345 args: Record<string, unknown>,
346): Promise<ToolCallResult> {
347 const remainingTtlMs = Math.max(1, Math.min(30_000, claims.exp - Date.now()));
348 const token = mintToolToken(
349 {
350 orgId: claims.orgId,
351 userId: claims.userId,
352 threadId: claims.threadId,
353 runId: claims.runId,
354 scope: claims.scope,
355 },
356 remainingTtlMs,
357 );
358 const response = await fetch(`${origin}/api/internal/child-sessions`, {
359 method: "POST",
360 headers: {
361 authorization: `Bearer ${token}`,
362 "content-type": "application/json",
363 },
364 body: JSON.stringify({ name, arguments: args }),
365 signal: AbortSignal.timeout(30_000),
366 });
367 const body = (await response.json().catch(() => null)) as
368 | { result?: ToolCallResult; error?: string }
369 | null;
370 if (!response.ok || !body?.result) {
371 return errorResult(
372 body?.error ?? `child-session control plane returned HTTP ${response.status}`,
373 { status: response.status },
374 );
375 }
376 return body.result;
377}
378
379/**
380 * The standalone gateway runs with a restricted database role that can read
381 * runs but never INSERT runs/run_commands/run_admissions - exactly what
382 * creating a child session does. Mirror the automation/approval seam: in
383 * gateway mode every child-session operation is delegated to the loopback
384 * primary API under a freshly minted, short-lived copy of the current live
385 * capability; the primary re-verifies liveness and tenant identity before
386 * executing. In-backend (non-gateway) mode keeps the direct path.
387 */
388export async function executeChildSessionTool(
389 claims: ToolTokenClaims,
390 name: string,
391 args: Record<string, unknown>,
392): Promise<ToolCallResult> {
393 const origin = primaryApiOrigin();
394 if (process.env.GATEWAY_DATABASE_URL && !origin) {
395 return errorResult(
396 "child-session control plane is not configured; ask the workspace operator to set USEAGENT_API_ORIGIN for the gateway, then retry",
397 );
398 }
399 return origin
400 ? executeThroughPrimaryApi(origin, claims, name, args)
401 : executeChildSessionToolLocal(claims, name, args);
402}Child run 通过 command lane 落库
幂等 key 包含 thread 与 parent
`childKey` 把 threadId、parentRunId 和 caller idempotencyKey 合成全局 key。`preflightRunCommandReplay` 和 `acceptRunCommand` 复用普通命令路径,所以 child session 与普通 run 共享一套 admission、run row 和生命周期事件。 backend/src/runs/child-sessions.ts:47 backend/src/runs/child-sessions.ts:51 backend/src/runs/child-sessions.ts:138 backend/src/runs/child-sessions.ts:192
backend/src/runs/child-sessions.ts:4747function childKeyPrefix(threadId: string): string {
48 return `${CHILD_SESSION_IDEMPOTENCY_PREFIX}:${threadId}:`;
49}
50
51function childKey(threadId: string, parentRunId: string, idempotencyKey: string): string {
52 return `${childKeyPrefix(threadId)}${parentRunId}:${idempotencyKey}`;
53}
54
55function boundedLimit(value: unknown, max: number, fallback: number): number {
56 if (typeof value !== "number" || !Number.isInteger(value)) return fallback;
57 return Math.min(max, Math.max(1, value));
58}
59
60function preview(text: string): string {
61 return text.length > 240 ? `${text.slice(0, 240)}...` : text;
62}
63
64function toSummary(row: {
65 readonly id: string;
66 readonly parentRunId: string | null;
67 readonly threadId: string;
68 readonly status: RunStatus;
69 readonly prompt: string;
70 readonly engine: EngineId;
71 readonly model: string;
72 readonly createdAt: Date;
73 readonly updatedAt: Date;
74}): ChildSessionSummary {
75 return {
76 id: row.id,
77 parentRunId: row.parentRunId,
78 threadId: row.threadId,
79 status: row.status,
80 promptPreview: preview(row.prompt),
81 engine: row.engine,
82 model: row.model,
83 createdAt: row.createdAt.toISOString(),
84 updatedAt: row.updatedAt.toISOString(),
85 eventRef: `skynet://runs/${row.id}/native-events`,
86 };
87}
88
89export function childSessionLimit(value: unknown, fallback = 10): number {
90 return boundedLimit(value, MAX_CHILD_LIMIT, fallback);
91}
92
93export function childSessionEventLimit(value: unknown, fallback = 25): number {
94 return boundedLimit(value, MAX_EVENT_LIMIT, fallback);
95}继承资源但不扩大权限
parent 不存在或不在同一 thread 会直接抛错;真正接受前只根据 parent.resolvedResources 或 legacyParentResources 做 intake。commandInput 中的 repos/resolvedResources 来自 intake,而不是 child prompt。 backend/src/runs/child-sessions.ts:133 backend/src/runs/child-sessions.ts:153 backend/src/runs/child-sessions.ts:157 backend/src/runs/child-sessions.ts:179
backend/src/runs/child-sessions.ts:115115 const intent: RunCommandIntent = {
116 prompt: input.prompt,
117 model: input.model,
118 engine: input.engine,
119 parentRunId: input.parentRunId,
120 // Child sessions inherit the parent's already-authorized resources. They
121 // cannot make an explicit repository selection of their own.
122 requestedRepos: [],
123 requestedResources: [],
124 attachmentIds: [],
125 memoryScope: input.memoryScope,
126 skillId: null,
127 skillVersion: null,
128 commandName: null,
129 commandProvider: null,
130 commandSessionId: null,
131 commandCatalogRevision: null,
132 };
133 const parent = await getRunForOrg(input.orgId, input.parentRunId);
134 if (!parent || parent.threadId !== input.threadId) {
135 throw new Error("child session parent is not available in this thread");
136 }
137 const internalOrigin = isInternalRunOrigin(parent.origin) ? parent.origin : null;
138 let accepted = internalOrigin
139 ? await preflightInternalRunCommandReplay({
140 orgId: input.orgId,
141 idempotencyKey,
142 intent,
143 origin: internalOrigin,
144 })
145 : await preflightRunCommandReplay({
146 orgId: input.orgId,
147 idempotencyKey,
148 intent,
149 });
150 if (accepted?.status === "conflict") return { status: "conflict" };
151
152 if (!accepted) {
153 const inheritedResources =
154 parent.resolvedResources.length > 0
155 ? parent.resolvedResources
156 : legacyParentResources(parent.repos, "api");
157 const intake = await resolveRunIntake(
158 {
159 source: "api",
160 // Child prompts are agent-authored delegation text, not direct user
161 // input, so they cannot discover or widen resource scope.
162 text: "",
163 inheritedResources,
164 },
165 { authorize: createRunResourceAuthorization(input.orgId) },
166 );
167 const commandInput = {
168 idempotencyKey,
169 orgId: input.orgId,
170 actorId: input.actorId,
171 intent,
172 run: {
173 id: runId,
174 prompt: input.prompt,
175 model: input.model,
176 engine: input.engine,
177 parentRunId: input.parentRunId,
178 threadId: input.threadId,
179 repos: [...intake.repos],
180 resolvedResources: intake.resources,
181 attachmentIds: [],
182 memoryScope: input.memoryScope,
183 skillId: null,
184 skillVersion: null,
185 skillContentHash: null,
186 commandName: null,
187 commandProvider: null,
188 commandSessionId: null,
189 commandCatalogRevision: null,
190 },
191 };
192 accepted = internalOrigin
193 ? await acceptInternalRunCommand({ ...commandInput, origin: internalOrigin })
194 : await acceptRunCommand(commandInput);
195 }
196 if (accepted.status === "conflict") return { status: "conflict" };
197 const child = await getChildSession(input.orgId, input.threadId, accepted.runId);
198 if (!child) throw new Error(`Accepted child session ${accepted.runId} was not readable`);
199 return { status: accepted.status, child };Child execution 可以被投影和评估
native events 被分页读出
`listChildSessionEvents` 先确认 child 属于当前 org/thread,再从 native frame storage 读 cursor 之后的事件,返回 nextCursor 和 `skynet://runs/.../native-events` 引用。gather 只聚合 count 和最近 event types,适合父线程快速汇总。 backend/src/runs/child-sessions.ts:264 backend/src/runs/child-sessions.ts:271 backend/src/runs/child-sessions.ts:275 backend/src/runs/child-sessions.ts:287
backend/src/runs/child-sessions.ts:264264export async function listChildSessionEvents(input: {
265 readonly orgId: string;
266 readonly threadId: string;
267 readonly childRunId: string;
268 readonly cursor?: unknown;
269 readonly limit?: unknown;
270}): Promise<ChildSessionEventPage | null> {
271 const child = await getChildSession(input.orgId, input.threadId, input.childRunId);
272 if (!child) return null;
273 const cursor = typeof input.cursor === "number" && Number.isInteger(input.cursor) ? input.cursor : -1;
274 const limit = childSessionEventLimit(input.limit);
275 const rows = (await getNativeFramesSince(input.childRunId, cursor)).slice(0, limit + 1);
276 const hasMore = rows.length > limit;
277 const events = rows.slice(0, limit);
278 const last = events.at(-1);
279 return {
280 childRunId: input.childRunId,
281 events,
282 nextCursor: hasMore && last ? last.seq : null,
283 eventRef: child.eventRef,
284 };
285}
286
287export async function gatherChildSessions(input: {
288 readonly orgId: string;
289 readonly threadId: string;
290 readonly limit?: unknown;
291}): Promise<ReadonlyArray<ChildSessionSummary & {
292 readonly eventCount: number;
293 readonly latestEventTypes: readonly string[];
294}>> {
295 const children = await listChildSessions(input);
296 if (children.length === 0) return [];
297 const rows = await db
298 .select({
299 runId: providerEvents.runId,
300 count: sql<number>`count(*)::int`,
301 latestTypes: sql<string[]>`(array_remove(array_agg(${providerEvents.eventType} order by ${providerEvents.seq} desc), null))[1:5]`,
302 })
303 .from(providerEvents)
304 .where(inArray(providerEvents.runId, children.map((child) => child.id)))
305 .groupBy(providerEvents.runId)
306 .orderBy(asc(providerEvents.runId));
307 const byRun = new Map(rows.map((row) => [row.runId, row]));
308 return children.map((child) => {
309 const row = byRun.get(child.id);
310 return {
311 ...child,
312 eventCount: row?.count ?? 0,
313 latestEventTypes: row?.latestTypes ?? [],
314 };
315 });
316}conformance evaluator 区分合成证明和产品能力
conformance 包里的 `SYNTHETIC_FIXTURE_NOTICE` 明确说合成 fixture 只证明 canonical semantics,不宣称 adapter parsing 或 live provider fidelity。`projectExecutions` 再把 canonical events 投影成 executionIds、childIds 和 delegationEdges。 packages/conformance/test/child-execution-evaluator.ts:11 packages/conformance/test/child-execution-evaluator.ts:36 packages/conformance/test/child-execution-evaluator.ts:230 packages/conformance/test/child-execution-evaluator.ts:238
packages/conformance/test/child-execution-evaluator.ts:238238export function createExecutionProjector(): ExecutionProjector {
239 const childIds = new Set<string>();
240 const delegationEdges = new Map<string, { parentId: string; childId: string }>();
241 const eventsByExecution = new Map<string, Map<string, CanonicalThreadEvent>>();
242
243 return {
244 ingest(event) {
245 if (event.kind === "child.started") {
246 childIds.add(event.childId);
247 const parentId = event.parentChildId
248 ?? event.identity.nativeParentSessionId
249 ?? (event.identity.nativeSessionId !== undefined
250 && event.identity.nativeSessionId !== event.childId
251 ? event.identity.nativeSessionId
252 : event.runId);
253 delegationEdges.set(`${parentId}:${event.childId}`, { parentId, childId: event.childId });
254 } else if (event.kind === "child.updated" || event.kind === "child.completed") {
255 childIds.add(event.childId);
256 }
257 const executionId = event.identity.nativeSessionId ?? event.runId;
258 const existing = eventsByExecution.get(executionId) ?? new Map<string, CanonicalThreadEvent>();
259 const previous = existing.get(event.eventId);
260 if (
261 previous === undefined ||
262 event.revision > previous.revision ||
263 (event.revision === previous.revision && event.deliverySeq > previous.deliverySeq)
264 ) {
265 existing.set(event.eventId, event);
266 }
267 eventsByExecution.set(executionId, existing);
268 },
269 snapshot() {
270 return {
271 executionIds: [...eventsByExecution.keys()],
272 childIds: [...childIds],
273 delegationEdges: [...delegationEdges.values()],
274 eventsByExecution: new Map(
275 [...eventsByExecution].map(([executionId, byId]) => [
276 executionId,
277 [...byId.values()].toSorted((a, b) => a.deliverySeq - b.deliverySeq),
278 ]),
279 ),
280 };
281 },
282 };
283}