useagentIndex
AI coworker · runtime orchestration

useagent 子会话控制深读

这页说明 useagent 如何创建延期 child session、继承资源、分页读取事件并把执行关系投影成可验证结构。

Created 2026-08-29TypeScriptAGPL-3.0-only270 stars when sampled
20
child limit
50
event page
4k
prompt chars
320
payload preview
01 / SOURCE READ

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

create 工具的 liveness 与能力门backend/src/knowledge/gateway/child-session-tools.ts:155
155export 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

gateway mode 下的 primary API 委托backend/src/knowledge/gateway/child-session-tools.ts:341
341async 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}
02 / SOURCE READ

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

key、summary 和分页上限backend/src/runs/child-sessions.ts:47
47function 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

child run 的 intent 与接受路径backend/src/runs/child-sessions.ts:115
115  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 };
03 / SOURCE READ

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

events/gather 的读取投影backend/src/runs/child-sessions.ts:264
264export 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

execution projector 的 child edge 规则packages/conformance/test/child-execution-evaluator.ts:238
238export 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}