@bf-meishijian/aip 0.0.4 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,8 @@ import { execFile } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { mkdir, mkdtemp, open, realpath, rm, stat } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
- import { basename, join, resolve } from "node:path";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
6
  import { promisify } from "node:util";
7
- import { DELIVERY_FILES, STAGE_DELIVERY_PROTOCOLS, stageEmployeeIds } from "./stage-delivery.js";
8
7
  import { createAgentSession, DefaultResourceLoader, SessionManager, } from "@earendil-works/pi-coding-agent";
9
8
  import { Type } from "typebox";
10
9
  import { collectDeliveryFiles, deliveryDirectory, initializeDeliveryFiles, } from "./delivery-files.js";
@@ -13,7 +12,9 @@ import { prepareMergeRequest, registerMergeRequest, } from "./merge-requests.js"
13
12
  import { paths } from "./paths.js";
14
13
  import { PlatformClientError } from "./platform-client.js";
15
14
  import { readJson, writeSecureJson } from "./secure-json.js";
15
+ import { DELIVERY_FILES, STAGE_DELIVERY_PROTOCOLS, stageEmployeeIds } from "./stage-delivery.js";
16
16
  const execFileAsync = promisify(execFile);
17
+ const EVENT_APPEND_MAX_ATTEMPTS = 8;
17
18
  export class CodingRunManager {
18
19
  pi;
19
20
  active = new Map();
@@ -66,14 +67,14 @@ export class CodingRunManager {
66
67
  active = undefined;
67
68
  }
68
69
  if (active) {
69
- if (images.length > 0 && !active.session.model?.input.includes("image"))
70
+ if (images.length > 0 && !active.supportsImages)
70
71
  throw new Error("MODEL_IMAGES_UNSUPPORTED");
72
+ active.observed.pendingSteerText = command.run.contentMarkdown ?? "";
71
73
  await active.emit("MESSAGE_QUEUE_UPDATED", { deliveryStatus: "QUEUED" });
72
74
  if (images.length > 0)
73
75
  await active.session.steer(command.run.contentMarkdown ?? "", images);
74
76
  else
75
77
  await active.session.steer(command.run.contentMarkdown ?? "");
76
- await active.emit("MESSAGE_QUEUE_UPDATED", { deliveryStatus: "READ" });
77
78
  }
78
79
  else {
79
80
  if (!command.run.runInput)
@@ -89,6 +90,7 @@ export class CodingRunManager {
89
90
  active.stopping = true;
90
91
  active.observed.stopping = true;
91
92
  await active.session.abort();
93
+ await active.emit.flush();
92
94
  for (const [activityKey, agentId] of active.observed.activeTasks) {
93
95
  await active.emit("SUBAGENT_STOPPED", {
94
96
  activityKey,
@@ -127,10 +129,12 @@ export class CodingRunManager {
127
129
  const local = paths();
128
130
  const runRoot = join(local.runWorktrees, runId);
129
131
  await mkdir(runRoot, { recursive: true, mode: 0o700 });
130
- await mkdir(local.piSessions, { recursive: true, mode: 0o700 });
131
- await mkdir(local.piAgent, { recursive: true, mode: 0o700 });
132
132
  const stateFile = join(local.runState, `${runId}.json`);
133
133
  const storedState = await readJson(stateFile);
134
+ if (storedState?.phaseSessions &&
135
+ Object.keys(storedState.phaseSessions).length &&
136
+ storedState.harness !== "pi")
137
+ throw new Error("PRIOR_HARNESS_SESSION_UNSUPPORTED:请新建执行,历史文件和工作目录已保留。");
134
138
  const cwd = await prepareProjects(local.repositoryCache, runRoot, runId, input.projects, Boolean(storedState?.prepared));
135
139
  for (const project of input.projects)
136
140
  project.devBranch ||= `aip/${runId}/${project.id.slice(0, 8)}`;
@@ -159,7 +163,7 @@ export class CodingRunManager {
159
163
  : undefined;
160
164
  const phaseSessions = storedState?.phaseSessions ?? {};
161
165
  const sessionPhase = input.executionPurpose === "RELEASE" ? "DEVELOPMENT" : input.phase;
162
- let sessionFileRef = phaseSessions[sessionPhase];
166
+ let sessionRef = phaseSessions[sessionPhase];
163
167
  let lastEventSequence = storedState?.lastEventSequence ?? 0;
164
168
  const observed = {
165
169
  pendingQuestion: storedState?.phase === input.phase ? (storedState.pendingQuestion ?? "") : "",
@@ -179,7 +183,8 @@ export class CodingRunManager {
179
183
  },
180
184
  prepared: true,
181
185
  phaseSessions,
182
- sessionFile: sessionFileRef,
186
+ sessionRef,
187
+ harness: "pi",
183
188
  lastEventSequence,
184
189
  pendingQuestion: observed.pendingQuestion,
185
190
  childSessions,
@@ -209,12 +214,12 @@ export class CodingRunManager {
209
214
  });
210
215
  return;
211
216
  }
212
- const terminal = terminalExtension(client, host, runId, turnId, runRoot, input, observed, redact, async (batch) => {
217
+ const terminal = terminalTools(client, host, runId, turnId, runRoot, input, observed, redact, async (batch) => {
213
218
  pendingDelivery = batch;
214
219
  await persistState();
215
220
  }, () => pendingDelivery, (question) => emit("QUESTION_UPDATED", { question: redact(question) }), () => emit.flush());
216
221
  if (!this.pi.resolveRuntimeModel)
217
- throw new Error("PI_RUNTIME_UNAVAILABLE");
222
+ throw new Error("MODEL_RUNTIME_UNAVAILABLE");
218
223
  const resolved = await this.pi.resolveRuntimeModel(input.providerId, input.modelId, {
219
224
  runId,
220
225
  turnId,
@@ -234,120 +239,106 @@ export class CodingRunManager {
234
239
  const childInstances = new Map();
235
240
  const childUpdates = new Map();
236
241
  const childTasks = new Map();
237
- const team = (api) => {
242
+ const memberExtension = (api) => {
243
+ if (!availableAgents.length)
244
+ return;
238
245
  api.registerTool({
239
246
  name: "subagent",
240
247
  label: "派发研发团队",
241
- description: `向实际绑定员工分派子任务,返回真实子会话结果。可用员工:${availableAgents.map((id) => `${id}:${input.teamEmployees?.[id]?.name ?? id};${input.teamEmployees?.[id]?.description ?? ""}`).join("\n")}。`,
248
+ description: `向实际绑定员工分派子任务,返回真实子会话结果。可用员工:${availableAgents
249
+ .map((id) => `${id}:${input.teamEmployees?.[id]?.name ?? id};${input.teamEmployees?.[id]?.description ?? ""}`)
250
+ .join("\n")}。`,
242
251
  parameters: Type.Object({
243
- agent: Type.Union(availableAgents.map((name) => Type.Literal(name))),
252
+ agent: availableAgents.length === 1
253
+ ? Type.Literal(availableAgents[0])
254
+ : Type.Union(availableAgents.map((name) => Type.Literal(name))),
244
255
  task: Type.String({ minLength: 1 }),
245
256
  }),
246
257
  async execute(callId, params, signal, onUpdate) {
247
- const member = params.agent;
258
+ const member = String(params.agent);
248
259
  if (!availableAgents.includes(member))
249
260
  throw new Error("DEVELOPMENT_TEAM_MEMBER_INVALID");
261
+ if (observed.stopping || signal?.aborted)
262
+ throw new Error("SUBAGENT_STOPPED");
263
+ observed.activeTasks.set(callId, member);
250
264
  childTasks.set(member, callId);
251
- const childBinding = { ...binding, member };
265
+ observed.memberResults.delete(member);
266
+ childUpdates.set(member, (text) => onUpdate?.({ content: [{ type: "text", text }], details: { agent: member } }));
267
+ const key = `${input.phase}:${member}`;
252
268
  let child = childInstances.get(member);
253
- observed.memberResults.delete(params.agent);
254
- childUpdates.set(member, (text) => onUpdate?.({ content: [{ type: "text", text }], details: { agent: params.agent } }));
255
269
  if (!child) {
256
- const childLive = liveEmployee(client, runId, childBinding, await client.employeeConfig(runId, { ...childBinding, purpose: "LOAD" }), runRoot, true, false, input.phase);
257
- const resultExtension = (pi) => pi.registerTool({
258
- name: "submit_member_result",
259
- label: "提交成员结果",
260
- description: "如实记录本次子任务的结果,未解决的问题必须 FAIL。仅向主管汇报,不确认最终交付。",
261
- parameters: Type.Object({
262
- status: Type.Union([Type.Literal("PASS"), Type.Literal("FAIL")]),
263
- summary: Type.String({ minLength: 1 }),
264
- }),
265
- async execute(_id, result) {
266
- const revisions = (await captureProjectRevisions(runRoot, input.projects, input.workItem.key, input.primaryProjectId)).filter((r) => Boolean(r));
267
- observed.memberResults.set(params.agent, {
268
- status: result.status,
269
- summary: result.summary,
270
- revisions,
271
- });
272
- return toolResult("结果已记录,向主管返回结论。");
273
- },
274
- });
275
- const childLoader = new DefaultResourceLoader({
276
- cwd,
277
- agentDir: local.piAgent,
278
- noExtensions: true,
279
- noSkills: true,
280
- noPromptTemplates: true,
281
- noThemes: true,
282
- noContextFiles: true,
283
- extensionFactories: [childLive.extension, resultExtension],
284
- systemPrompt: "当前为团队子成员会话,员工配置由平台下发。",
285
- });
286
- await childLoader.reload();
287
- const file = childSessions[`${input.phase}:${member}`];
288
- const childManager = file
289
- ? SessionManager.open(join(local.piSessions, file), local.piSessions, cwd)
290
- : SessionManager.create(cwd, local.piSessions);
291
- const created = await createAgentSession({
270
+ const childBinding = { ...binding, member };
271
+ const childLive = liveEmployee(client, runId, childBinding, await client.employeeConfig(runId, { ...childBinding, purpose: "LOAD" }), runRoot, true, false, input.phase, () => observed.memberResults.has(member));
272
+ const resultExtension = (pi) => {
273
+ pi.registerTool({
274
+ name: "submit_member_result",
275
+ label: "提交成员结果",
276
+ description: "如实记录本次子任务结果;未解决的问题必须 FAIL。仅向主管汇报,不确认最终交付。",
277
+ parameters: Type.Object({
278
+ status: Type.Union([Type.Literal("PASS"), Type.Literal("FAIL")]),
279
+ summary: Type.String({ minLength: 1 }),
280
+ }),
281
+ async execute(_id, result) {
282
+ const revisions = await captureProjectRevisions(runRoot, input.projects, input.workItem.key, input.primaryProjectId);
283
+ observed.memberResults.set(member, { ...result, revisions });
284
+ queueMicrotask(() => child?.agent.abort());
285
+ return toolResult("结果已记录,向主管返回结论。");
286
+ },
287
+ });
288
+ };
289
+ const opened = await openPiSession({
292
290
  cwd,
291
+ sessionDir: local.piSessions,
293
292
  agentDir: local.piAgent,
293
+ existingId: childSessions[key],
294
294
  modelRuntime: resolved.runtime,
295
295
  model: resolved.model,
296
296
  thinkingLevel: input.thinkingLevel,
297
- resourceLoader: childLoader,
298
- sessionManager: childManager,
297
+ extensions: [childLive.extension, resultExtension],
298
+ systemPrompt: "当前为团队子成员会话,员工配置由平台下发。",
299
+ runId,
299
300
  });
300
- child = created.session;
301
- childLive.attach(child);
301
+ childLive.attach(opened.session);
302
+ child = opened.session;
303
+ childSessions[key] = opened.sessionId;
302
304
  childInstances.set(member, child);
303
- const childFile = childManager.getSessionFile();
304
- if (!childFile)
305
- throw new Error("CHILD_SESSION_FILE_MISSING");
306
- childSessions[`${input.phase}:${member}`] = basename(childFile);
307
- await persistState();
308
- subscribeSession(child, async (kind, payload) => {
309
- if (kind === "MESSAGE_STARTED")
310
- return;
311
- if (kind === "TEXT_DELTA") {
305
+ subscribeSession(child, (kind, payload) => {
306
+ if (kind === "TEXT_DELTA")
312
307
  childUpdates.get(member)?.(String(payload.text ?? ""));
313
- return;
314
- }
315
- await emit(kind, {
308
+ return emit(kind, {
316
309
  ...payload,
317
- agentId: params.agent,
318
310
  phase: input.phase,
311
+ agentId: member,
319
312
  parentActivityKey: childTasks.get(member),
320
- sessionId: childSessions[`${input.phase}:${member}`],
313
+ sessionId: opened.sessionId,
321
314
  });
322
315
  }, observed, redact);
316
+ await persistState();
323
317
  }
324
318
  await emit("SUBAGENT_UPDATED", {
325
319
  phase: input.phase,
326
320
  task: params.task,
327
321
  activityKey: callId,
328
- agentId: params.agent,
329
- sessionFileRef: childSessions[`${input.phase}:${member}`],
322
+ agentId: member,
323
+ sessionRef: childSessions[key],
330
324
  summary: "独立子会话已就绪",
331
325
  });
332
326
  const abort = () => {
333
- void child?.abort();
327
+ child?.agent.abort();
334
328
  };
335
329
  signal?.addEventListener("abort", abort, { once: true });
336
330
  try {
337
- await child.prompt(`${initialPrompt(input, runRoot, false)}\n\n本次主管分派给 ${params.agent} 的任务:${params.task}\n只完成自身职责,最后调用 submit_member_result;主管负责其他成员协调和最终交付。`);
331
+ signal?.throwIfAborted();
332
+ await child.prompt(`${initialPrompt(input, runRoot, false)}\n\n本次主管分派给 ${member} 的任务:${params.task}\n只完成自身职责,最后调用 submit_member_result;主管负责其他成员协调和最终交付。`);
338
333
  await child.waitForIdle();
339
334
  if (signal?.aborted || observed.stopping)
340
335
  throw new Error("SUBAGENT_STOPPED");
341
- const result = observed.memberResults.get(params.agent);
336
+ const result = observed.memberResults.get(member);
342
337
  if (!result)
343
338
  throw new Error("SUBAGENT_RESULT_MISSING");
344
339
  return {
345
340
  content: [{ type: "text", text: JSON.stringify(result) }],
346
- details: {
347
- agent: params.agent,
348
- status: result.status,
349
- sessionFileRef: childSessions[`${input.phase}:${member}`],
350
- },
341
+ details: { agent: member, status: result.status },
351
342
  };
352
343
  }
353
344
  finally {
@@ -357,66 +348,49 @@ export class CodingRunManager {
357
348
  },
358
349
  });
359
350
  };
360
- const loader = new DefaultResourceLoader({
351
+ const opened = await openPiSession({
361
352
  cwd,
353
+ sessionDir: local.piSessions,
362
354
  agentDir: local.piAgent,
363
- extensionFactories: [...(availableAgents.length ? [team] : []), terminal, live.extension],
364
- noSkills: true,
365
- noExtensions: true,
366
- noPromptTemplates: true,
367
- noThemes: true,
368
- noContextFiles: true,
355
+ existingId: sessionRef,
356
+ modelRuntime: resolved.runtime,
357
+ model: resolved.model,
358
+ thinkingLevel: input.thinkingLevel,
359
+ extensions: [...(availableAgents.length ? [memberExtension] : []), terminal, live.extension],
369
360
  systemPrompt: input.team
370
361
  ? "当前为团队主负责人会话,员工配置由平台下发。"
371
362
  : "当前为工单主会话,员工配置由平台下发。",
363
+ runId,
372
364
  });
373
- await loader.reload();
374
- const existingSession = phaseSessions[sessionPhase]
375
- ? join(local.piSessions, phaseSessions[sessionPhase])
376
- : null;
377
- let sessionManager;
378
- if (existingSession) {
379
- sessionManager = SessionManager.open(existingSession, local.piSessions, cwd);
380
- }
381
- else {
382
- sessionManager = SessionManager.create(cwd, local.piSessions);
383
- const allocatedSessionFile = sessionManager.getSessionFile();
384
- if (!allocatedSessionFile)
385
- throw new Error("PI_SESSION_FILE_MISSING");
386
- const placeholder = await open(allocatedSessionFile, "wx", 0o600);
387
- await placeholder.close();
388
- sessionManager.setSessionFile(allocatedSessionFile);
389
- sessionManager.appendCustomEntry("aip-run-binding", {
390
- runId,
365
+ live.attach(opened.session);
366
+ const session = opened.session;
367
+ sessionRef = opened.sessionId;
368
+ subscribeSession(session, (kind, payload) => {
369
+ const activityKey = String(payload.activityKey ?? "");
370
+ if (kind === "SUBAGENT_STARTED")
371
+ observed.activeTasks.set(activityKey, String(payload.agentId));
372
+ if (["SUBAGENT_COMPLETED", "SUBAGENT_FAILED", "SUBAGENT_STOPPED"].includes(kind))
373
+ observed.activeTasks.delete(activityKey);
374
+ return emit(kind, {
375
+ ...payload,
376
+ phase: input.phase,
377
+ agentId: payload.agentId ?? "supervisor",
378
+ sessionId: sessionRef,
391
379
  });
392
- }
393
- const sessionFile = sessionManager.getSessionFile();
394
- if (!sessionFile)
395
- throw new Error("PI_SESSION_FILE_MISSING");
396
- if (!(await exists(sessionFile)))
397
- throw new Error("PI_SESSION_FILE_NOT_PERSISTED");
398
- const { session } = await createAgentSession({
399
- cwd,
400
- agentDir: local.piAgent,
401
- modelRuntime: resolved.runtime,
402
- model: resolved.model,
403
- thinkingLevel: input.thinkingLevel,
404
- resourceLoader: loader,
405
- sessionManager,
406
- });
407
- live.attach(session);
408
- const activeRun = { session, input, turnId, emit, stopping: false, observed };
380
+ }, observed, redact);
381
+ const activeRun = {
382
+ session,
383
+ supportsImages: resolved.model.input?.includes("image") === true,
384
+ input,
385
+ turnId,
386
+ emit,
387
+ stopping: false,
388
+ observed,
389
+ };
409
390
  this.active.set(runId, activeRun);
410
- subscribeSession(session, (kind, payload) => emit(kind, {
411
- ...payload,
412
- phase: input.phase,
413
- agentId: payload.agentId ?? "supervisor",
414
- sessionId: sessionFileRef,
415
- }), observed, redact);
416
- sessionFileRef = basename(sessionFile);
417
- phaseSessions[sessionPhase] = sessionFileRef;
391
+ phaseSessions[sessionPhase] = sessionRef;
418
392
  await persistState();
419
- await emit("RUN_STARTED", { sessionFileRef, phase: input.phase });
393
+ await emit("RUN_STARTED", { sessionRef, phase: input.phase });
420
394
  if (resumePrompt || images.length > 0) {
421
395
  await emit("MESSAGE_QUEUE_UPDATED", { deliveryStatus: "QUEUED" });
422
396
  await emit("MESSAGE_QUEUE_UPDATED", { deliveryStatus: "READ" });
@@ -432,13 +406,16 @@ export class CodingRunManager {
432
406
  })
433
407
  .finally(async () => {
434
408
  await emit.flush();
409
+ for (const child of childInstances.values())
410
+ child.dispose();
411
+ session.dispose();
435
412
  if (this.active.get(runId) === activeRun)
436
413
  this.active.delete(runId);
437
414
  });
438
415
  }
439
416
  async synthetic(client, host, runId, turnId, input) {
440
417
  const emit = eventEmitter(client, host, runId, turnId);
441
- await emit("RUN_STARTED", { sessionFileRef: `${runId}.jsonl` });
418
+ await emit("RUN_STARTED", { sessionRef: `synthetic:${runId}` });
442
419
  const member = stageEmployeeIds(input)[0];
443
420
  if (member)
444
421
  await emit("SUBAGENT_STARTED", {
@@ -484,29 +461,39 @@ function eventEmitter(client, host, runId, turnId, initialSequence = 0, persistS
484
461
  let sequence = initialSequence;
485
462
  let queue = Promise.resolve();
486
463
  const emit = (kind, payload) => {
487
- sequence += 1;
488
- const event = {
489
- eventId: crypto.randomUUID(),
490
- sequence,
491
- turnId,
492
- kind,
493
- payload: kind === "PREPARATION_COMPLETED" ? payload : redactPayload(payload, redact),
494
- createdAt: new Date().toISOString(),
495
- };
496
- queue = queue
497
- .then(() => client.appendCodingEvents(runId, {
498
- hostId: host.hostId,
499
- generation: host.generation,
500
- events: [event],
501
- }))
502
- .then(() => persistSequence?.(event.sequence))
503
- .then(() => undefined)
504
- .catch((error) => {
505
- if (error instanceof PlatformClientError &&
506
- error.status === 409 &&
507
- error.code === "CODING_EVENT_TURN_STALE")
508
- return;
509
- throw error;
464
+ queue = queue.then(async () => {
465
+ const event = {
466
+ eventId: crypto.randomUUID(),
467
+ sequence: sequence + 1,
468
+ turnId,
469
+ kind,
470
+ payload: kind === "PREPARATION_COMPLETED" ? payload : redactPayload(payload, redact),
471
+ createdAt: new Date().toISOString(),
472
+ };
473
+ for (let attempt = 1; attempt <= EVENT_APPEND_MAX_ATTEMPTS; attempt++) {
474
+ try {
475
+ await client.appendCodingEvents(runId, {
476
+ hostId: host.hostId,
477
+ generation: host.generation,
478
+ events: [event],
479
+ });
480
+ sequence = event.sequence;
481
+ await persistSequence?.(event.sequence);
482
+ return;
483
+ }
484
+ catch (error) {
485
+ if (error instanceof PlatformClientError &&
486
+ error.status === 409 &&
487
+ error.code === "CODING_EVENT_TURN_STALE")
488
+ return;
489
+ process.stderr.write(`[local-coding-host] 事件上报失败:${redact(error instanceof Error ? error.message : String(error))}\n`);
490
+ if (attempt === EVENT_APPEND_MAX_ATTEMPTS) {
491
+ process.stderr.write(`[local-coding-host] 事件上报放弃:${event.kind} sequence=${event.sequence} attempts=${attempt}\n`);
492
+ return;
493
+ }
494
+ await new Promise((resolve) => setTimeout(resolve, 0));
495
+ }
496
+ }
510
497
  });
511
498
  return queue;
512
499
  };
@@ -524,227 +511,55 @@ function redactPayloadValue(value, redact) {
524
511
  return redactPayload(value, redact);
525
512
  return value;
526
513
  }
527
- async function driveToTerminal(session, initial, observed, client, host, runId, turnId, emit, release, phase, images = []) {
528
- let prompt = initial;
529
- for (let attempt = 0; attempt <= 2; attempt += 1) {
530
- if (!session.model?.input.includes("image") &&
531
- session.messages?.some((message) => "content" in message &&
532
- Array.isArray(message.content) &&
533
- message.content.some((block) => block.type === "image")))
534
- throw new Error("MODEL_IMAGES_UNSUPPORTED");
535
- if (attempt === 0 && images.length > 0)
536
- await session.prompt(prompt, { images });
537
- else
538
- await session.prompt(prompt);
539
- await session.waitForIdle();
540
- if (observed.stopping || observed.terminalReached || observed.pendingQuestion)
541
- return;
542
- if (attempt < 2) {
543
- prompt = release
544
- ? `请调用 submit_pull_requests 完成提 PR;最近错误:${observed.lastDeliveryError ?? "尚未提交"}。`
545
- : `你尚未成功交付。自主补齐必需产物后调用 ${phase === "DEVELOPMENT" ? "complete_development" : "submit_delivery"}。最近提交结果:${observed.lastDeliveryError ?? "尚未提交"}。缺文件就在当前会话补齐,传输故障不要重写报告。无法继续时说明真实原因,需要沟通可记录待回应问题。`;
546
- continue;
547
- }
548
- await client.reportCodingTerminal(runId, {
549
- hostId: host.hostId,
550
- generation: host.generation,
551
- turnId,
552
- kind: "FAILED",
553
- summary: observed.lastDeliveryError ?? "Agent 多次结束但没有形成明确终态",
554
- knownIssues: "",
555
- failureCode: observed.lastDeliveryError?.split("\n")[0]?.slice(0, 200) ?? "PI_TERMINAL_TOOL_NOT_CALLED",
556
- projectFacts: [],
557
- testFacts: [],
558
- });
559
- observed.terminalReached = true;
560
- await emit("RUN_FAILED", { code: "PI_TERMINAL_TOOL_NOT_CALLED" });
514
+ async function openPiSession(options) {
515
+ await mkdir(options.sessionDir, { recursive: true, mode: 0o700 });
516
+ await mkdir(options.agentDir, { recursive: true, mode: 0o700 });
517
+ let sessionManager;
518
+ if (options.existingId) {
519
+ const listed = await SessionManager.list(options.cwd, options.sessionDir);
520
+ const match = listed.find((item) => item.id === options.existingId);
521
+ if (!match)
522
+ throw new Error("PI_SESSION_NOT_FOUND:请新建执行,历史文件已保留。");
523
+ sessionManager = SessionManager.open(match.path, options.sessionDir, options.cwd);
561
524
  }
562
- }
563
- function terminalExtension(client, host, runId, turnId, runRoot, input, observed, redact, persistBatch, pendingBatch, emitQuestion, flushEvents) {
564
- return (pi) => {
565
- pi.registerTool({
566
- name: input.executionPurpose === "RELEASE"
567
- ? "submit_pull_requests"
568
- : input.phase === "DEVELOPMENT"
569
- ? "complete_development"
570
- : "submit_delivery",
571
- label: "提交交付",
572
- description: input.executionPurpose === "RELEASE"
573
- ? "核实上线清单并提交分支后,使用 Runtime GitLab 身份创建或复用 MR 并登记工单。"
574
- : "读取并校验本阶段最终交付文件,上传成功后完成当前阶段。",
575
- parameters: input.executionPurpose === "RELEASE"
576
- ? Type.Object({
577
- checklistSummary: Type.String({
578
- description: "核实依据、无需修改或修改摘要;缺口未解决时不要提交",
579
- }),
580
- projects: Type.Array(Type.Object({
581
- project: Type.String({
582
- description: `项目名称,只能选择:${input.projects.map((p) => p.name).join("、")}`,
583
- }),
584
- title: Type.String({ description: "包含工单编号及本项目变更摘要的 MR 标题" }),
585
- description: Type.String({
586
- description: "任务目的、本项目改动、测试与验收结论、发布注意、依赖、风险回滚及清单和报告链接",
587
- }),
588
- })),
589
- })
590
- : Type.Object({}),
591
- async execute(_toolCallId, rawParams) {
592
- try {
593
- if (input.executionPurpose === "RELEASE") {
594
- const params = rawParams;
595
- if (!params?.checklistSummary?.trim())
596
- throw new Error("AIP_RELEASE_CHECKLIST_SUMMARY_REQUIRED");
597
- const files = await collectDeliveryFiles(runRoot, input, ["RELEASE_CHECKLIST"]);
598
- const checklist = {
599
- status: "VERIFIED",
600
- summary: params.checklistSummary,
601
- markdown: files[0].content,
602
- };
603
- await client.submitPullRequests(runId, {
604
- hostId: host.hostId,
605
- generation: host.generation,
606
- turnId,
607
- workItemId: input.workItem.id,
608
- projects: [],
609
- checklist,
610
- });
611
- const context = {
612
- runId,
613
- turnId,
614
- workItem: input.workItem,
615
- projects: input.projects,
616
- primaryProjectId: input.primaryProjectId,
617
- };
618
- for (const project of input.projects) {
619
- const explanation = params.projects?.find((item) => item.project === project.name);
620
- if (!explanation)
621
- throw new Error(`AIP_MR_DESCRIPTION_REQUIRED:${project.name}`);
622
- const pendingFile = join(runRoot, `mr-pending-${safeName(project.id)}.json`);
623
- const result = await prepareMergeRequest({
624
- project,
625
- cwd: join(runRoot, safeName(project.name)),
626
- title: explanation.title,
627
- description: explanation.description,
628
- pendingFile,
629
- });
630
- await registerMergeRequest(client, context, host, result, pendingFile);
631
- }
632
- await client.submitPullRequests(runId, {
633
- hostId: host.hostId,
634
- generation: host.generation,
635
- turnId,
636
- workItemId: input.workItem.id,
637
- projects: [],
638
- completeRelease: true,
639
- });
640
- observed.terminalReached = true;
641
- return toolResult("上线清单已核实,MR 已提交并登记,等待合并;平台负责查询最新状态。");
642
- }
643
- const existing = pendingBatch();
644
- if (existing && existing.turnId === turnId) {
645
- await flushEvents();
646
- await uploadBatch(client, runId, existing);
647
- await persistBatch(undefined);
648
- observed.terminalReached = true;
649
- return toolResult("交付已提交,等待经办人确认。");
650
- }
651
- const files = await collectDeliveryFiles(runRoot, input, input.phase === "DEVELOPMENT"
652
- ? Object.keys(DELIVERY_FILES).filter((slot) => STAGE_DELIVERY_PROTOCOLS.DEVELOPMENT.requiredFiles.includes(DELIVERY_FILES[slot]))
653
- : undefined);
654
- const projectFacts = await collectProjectFacts(runRoot, input.projects, input.workItem.key, input.primaryProjectId);
655
- const batch = {
656
- hostId: host.hostId,
657
- generation: host.generation,
658
- turnId,
659
- kind: input.phase === "DEVELOPMENT" ? "DEVELOPMENT_COMPLETED" : "DELIVERY",
660
- submissionId: crypto.randomUUID(),
661
- files,
662
- summary: files.find((file) => file.slot === "SUMMARY").content,
663
- knownIssues: "",
664
- projectFacts,
665
- testFacts: [],
666
- };
667
- await persistBatch(batch);
668
- await flushEvents();
669
- await uploadBatch(client, runId, batch);
670
- await persistBatch(undefined);
671
- observed.terminalReached = true;
672
- return toolResult("交付已提交,等待经办人确认。");
673
- }
674
- catch (error) {
675
- observed.lastDeliveryError =
676
- error instanceof Error ? error.message : "AIP_DELIVERY_FAILED";
677
- if (error instanceof PlatformClientError && error.status < 500 && error.status !== 429)
678
- await persistBatch(undefined);
679
- throw error;
680
- }
681
- },
682
- });
683
- pi.registerTool({
684
- name: "update_question",
685
- label: "更新待回应问题",
686
- description: "仅记录当前执行必须由人决定的业务问题,不把一般建议变为审批;真正解决后传空字符串清除。普通聊天继续使用 steer,不停止 Run。",
687
- parameters: Type.Object({ question: Type.String() }),
688
- async execute(_id, params) {
689
- // The caller binding supplies technical identity; the model only owns the question text.
690
- observed.pendingQuestion = params.question.trim();
691
- await emitQuestion(params.question);
692
- return toolResult(params.question);
693
- },
694
- });
695
- if (input.executionPurpose === "RELEASE")
696
- pi.registerTool({
697
- name: "request_code_changes",
698
- label: "返回开发修改",
699
- description: "用户明确要求修改应用代码时,将工单返回开发,再测试和人工确认;仅讨论或文档修改不要调用。",
700
- parameters: Type.Object({
701
- reason: Type.String({ description: "用户要求修改的内容及原因" }),
702
- }),
703
- async execute(_id, params) {
704
- await client.request(`/api/v1/coding-runs/${runId}/return-to-development`, {
705
- method: "POST",
706
- body: JSON.stringify({
707
- hostId: host.hostId,
708
- generation: host.generation,
709
- turnId,
710
- reason: params.reason,
711
- }),
712
- });
713
- observed.terminalReached = true;
714
- return toolResult("已返回开发,等待下一执行接手续改,完成后进入测试与人工确认。");
715
- },
716
- });
717
- pi.registerTool({
718
- name: "report_blocker",
719
- label: "报告阻塞",
720
- description: "仅在认证、权限、仓库、外部环境或输入冲突真实阻塞时报告。",
721
- parameters: Type.Object({
722
- category: Type.Union([
723
- Type.Literal("AUTHENTICATION"),
724
- Type.Literal("PERMISSION"),
725
- Type.Literal("REPOSITORY"),
726
- Type.Literal("EXTERNAL_ENVIRONMENT"),
727
- Type.Literal("INPUT_CONFLICT"),
728
- ]),
729
- summary: Type.String(),
730
- }),
731
- async execute(_id, params) {
732
- await client.reportCodingTerminal(runId, {
733
- hostId: host.hostId,
734
- generation: host.generation,
735
- turnId,
736
- kind: "BLOCKED",
737
- summary: redact(params.summary),
738
- knownIssues: "",
739
- blockerCategory: params.category,
740
- projectFacts: [],
741
- testFacts: [],
742
- });
743
- observed.terminalReached = true;
744
- return toolResult("阻塞已报告。");
745
- },
746
- });
747
- };
525
+ else {
526
+ sessionManager = SessionManager.create(options.cwd, options.sessionDir);
527
+ const allocated = sessionManager.getSessionFile();
528
+ if (!allocated)
529
+ throw new Error("PI_SESSION_FILE_MISSING");
530
+ await mkdir(dirname(allocated), { recursive: true, mode: 0o700 });
531
+ const placeholder = await open(allocated, "wx", 0o600);
532
+ await placeholder.close();
533
+ sessionManager.setSessionFile(allocated);
534
+ sessionManager.appendCustomEntry("aip-run-binding", { runId: options.runId });
535
+ }
536
+ const sessionId = sessionManager.getSessionId();
537
+ const sessionFile = sessionManager.getSessionFile();
538
+ if (!sessionFile || !(await exists(sessionFile)))
539
+ throw new Error("PI_SESSION_FILE_NOT_PERSISTED");
540
+ const loader = new DefaultResourceLoader({
541
+ cwd: options.cwd,
542
+ agentDir: options.agentDir,
543
+ extensionFactories: options.extensions,
544
+ noSkills: true,
545
+ noExtensions: true,
546
+ noPromptTemplates: true,
547
+ noThemes: true,
548
+ noContextFiles: true,
549
+ systemPrompt: options.systemPrompt,
550
+ });
551
+ await loader.reload();
552
+ const { session } = await createAgentSession({
553
+ cwd: options.cwd,
554
+ agentDir: options.agentDir,
555
+ modelRuntime: options.modelRuntime,
556
+ model: options.model,
557
+ thinkingLevel: options.thinkingLevel,
558
+ resourceLoader: loader,
559
+ sessionManager,
560
+ });
561
+ session.setAutoCompactionEnabled(true);
562
+ return { session, sessionId };
748
563
  }
749
564
  function subscribeSession(session, emit, observed, redact) {
750
565
  let messageId = crypto.randomUUID();
@@ -777,6 +592,22 @@ function subscribeSession(session, emit, observed, redact) {
777
592
  messageId = crypto.randomUUID();
778
593
  void emit("MESSAGE_STARTED", { messageId });
779
594
  }
595
+ else if (event.type === "message_start" && event.message.role === "user") {
596
+ const text = textFromMessage(event.message.content);
597
+ if (observed.pendingSteerText !== undefined &&
598
+ (!observed.pendingSteerText || text.includes(observed.pendingSteerText))) {
599
+ delete observed.pendingSteerText;
600
+ void emit("MESSAGE_QUEUE_UPDATED", { deliveryStatus: "READ" });
601
+ }
602
+ }
603
+ else if (event.type === "agent_end") {
604
+ observed.nativeWillRetry = event.willRetry === true;
605
+ }
606
+ else if (event.type === "auto_retry_end") {
607
+ observed.nativeWillRetry = false;
608
+ if (event.success === false && event.finalError)
609
+ observed.lastModelError = event.finalError;
610
+ }
780
611
  else if (event.type === "message_update" &&
781
612
  event.assistantMessageEvent.type === "text_delta" &&
782
613
  event.assistantMessageEvent.delta) {
@@ -794,7 +625,7 @@ function subscribeSession(session, emit, observed, redact) {
794
625
  void emit(subagent ? "SUBAGENT_STARTED" : "TOOL_STARTED", subagent
795
626
  ? {
796
627
  activityKey: event.toolCallId,
797
- agentId: String(event.args?.agent ?? observed.activeTasks.get(event.toolCallId) ?? ""),
628
+ agentId: String(event.args?.agent ?? ""),
798
629
  summary: String(event.args?.task ?? "团队子任务开始"),
799
630
  task: String(event.args?.task ?? ""),
800
631
  }
@@ -805,23 +636,20 @@ function subscribeSession(session, emit, observed, redact) {
805
636
  });
806
637
  }
807
638
  else if (event.type === "tool_execution_update") {
808
- pendingToolUpdates.set(event.toolCallId, event.toolName === "subagent"
809
- ? {
810
- kind: "SUBAGENT_UPDATED",
811
- payload: {
639
+ pendingToolUpdates.set(event.toolCallId, {
640
+ kind: event.toolName === "subagent" ? "SUBAGENT_UPDATED" : "TOOL_UPDATED",
641
+ payload: event.toolName === "subagent"
642
+ ? {
812
643
  activityKey: event.toolCallId,
813
644
  agentId: String(event.args?.agent ?? observed.activeTasks.get(event.toolCallId) ?? ""),
814
645
  currentAction: redact(textFromResult(event.partialResult)),
815
- },
816
- }
817
- : {
818
- kind: "TOOL_UPDATED",
819
- payload: {
646
+ }
647
+ : {
820
648
  activityKey: event.toolCallId,
821
649
  toolName: event.toolName,
822
650
  currentAction: redact(textFromResult(event.partialResult)),
823
651
  },
824
- });
652
+ });
825
653
  if (!toolUpdateTimers.has(event.toolCallId))
826
654
  toolUpdateTimers.set(event.toolCallId, setTimeout(() => flushToolUpdate(event.toolCallId), 200));
827
655
  }
@@ -851,14 +679,330 @@ function subscribeSession(session, emit, observed, redact) {
851
679
  summary: redact(textFromResult(event.result)),
852
680
  });
853
681
  }
682
+ else if (event.type === "bash_execution_update" && event.delta) {
683
+ const activityKey = event.id ?? "bash";
684
+ pendingToolUpdates.set(activityKey, {
685
+ kind: "TOOL_UPDATED",
686
+ payload: {
687
+ activityKey,
688
+ toolName: "bash",
689
+ currentAction: redact(event.delta),
690
+ },
691
+ });
692
+ if (!toolUpdateTimers.has(activityKey))
693
+ toolUpdateTimers.set(activityKey, setTimeout(() => flushToolUpdate(activityKey), 200));
694
+ }
695
+ else if (event.type === "compaction_start") {
696
+ flushText();
697
+ void emit("TOOL_STARTED", {
698
+ activityKey: `compaction:${event.reason}`,
699
+ toolName: "compaction",
700
+ summary: `上下文压缩(${event.reason})`,
701
+ });
702
+ }
703
+ else if (event.type === "compaction_end") {
704
+ void emit("TOOL_COMPLETED", {
705
+ activityKey: `compaction:${event.reason}`,
706
+ toolName: "compaction",
707
+ isError: event.aborted === true || Boolean(event.errorMessage),
708
+ summary: redact(event.result?.summary ??
709
+ event.errorMessage ??
710
+ (event.aborted ? "压缩已取消" : "压缩完成")),
711
+ });
712
+ }
854
713
  else if (event.type === "message_end" && event.message.role === "assistant") {
855
714
  flushText();
715
+ if ("stopReason" in event.message && event.message.stopReason)
716
+ observed.lastModelStopReason = String(event.message.stopReason);
717
+ if ("errorMessage" in event.message && event.message.errorMessage)
718
+ observed.lastModelError = String(event.message.errorMessage);
856
719
  const content = textFromMessage(event.message.content);
857
- if (content)
858
- void emit("MESSAGE_COMPLETED", { contentMarkdown: redact(content), messageId });
720
+ const usage = event.message.usage;
721
+ const payload = {
722
+ contentMarkdown: redact(content),
723
+ messageId,
724
+ };
725
+ if (usage) {
726
+ payload.usage = {
727
+ input: usage.input,
728
+ output: usage.output,
729
+ cacheRead: usage.cacheRead,
730
+ cacheWrite: usage.cacheWrite,
731
+ totalTokens: usage.totalTokens,
732
+ };
733
+ }
734
+ if (content.trim())
735
+ void emit("MESSAGE_COMPLETED", payload);
859
736
  }
860
737
  });
861
738
  }
739
+ function textFromMessage(content) {
740
+ if (typeof content === "string")
741
+ return content;
742
+ if (!Array.isArray(content))
743
+ return "";
744
+ return content
745
+ .map((part) => part && typeof part === "object" && "text" in part ? String(part.text ?? "") : "")
746
+ .join("");
747
+ }
748
+ function textFromResult(value) {
749
+ if (!value || typeof value !== "object")
750
+ return "";
751
+ return textFromMessage(value.content);
752
+ }
753
+ async function driveToTerminal(session, initial, observed, client, host, runId, turnId, emit, release, phase, images = []) {
754
+ let prompt = initial;
755
+ for (let attempt = 0; attempt <= 2; attempt += 1) {
756
+ if (!session.model?.input.includes("image") &&
757
+ session.messages?.some((message) => "content" in message &&
758
+ Array.isArray(message.content) &&
759
+ message.content.some((block) => block.type === "image")))
760
+ throw new Error("MODEL_IMAGES_UNSUPPORTED");
761
+ if (attempt === 0 && images.length > 0)
762
+ await session.prompt(prompt, { images });
763
+ else
764
+ await session.prompt(prompt);
765
+ await session.waitForIdle();
766
+ if (observed.stopping || observed.terminalReached || observed.pendingQuestion)
767
+ return;
768
+ if (observed.lastModelStopReason === "error" && observed.nativeWillRetry !== true) {
769
+ const code = observed.lastModelError?.split("\n")[0]?.slice(0, 200) || "MODEL_REQUEST_FAILED";
770
+ await client.reportCodingTerminal(runId, {
771
+ hostId: host.hostId,
772
+ generation: host.generation,
773
+ turnId,
774
+ kind: "FAILED",
775
+ summary: observed.lastModelError ?? code,
776
+ knownIssues: "",
777
+ failureCode: code,
778
+ projectFacts: [],
779
+ testFacts: [],
780
+ });
781
+ observed.terminalReached = true;
782
+ await emit("RUN_FAILED", { code });
783
+ return;
784
+ }
785
+ if (attempt < 2) {
786
+ prompt = release
787
+ ? `请调用 submit_pull_requests 完成提 PR;最近错误:${observed.lastDeliveryError ?? "尚未提交"}。`
788
+ : `你尚未成功交付。自主补齐必需产物后调用 ${phase === "DEVELOPMENT" ? "complete_development" : "submit_delivery"}。最近提交结果:${observed.lastDeliveryError ?? "尚未提交"}。缺文件就在当前会话补齐,传输故障不要重写报告。无法继续时说明真实原因,需要沟通可记录待回应问题。`;
789
+ continue;
790
+ }
791
+ await client.reportCodingTerminal(runId, {
792
+ hostId: host.hostId,
793
+ generation: host.generation,
794
+ turnId,
795
+ kind: "FAILED",
796
+ summary: observed.lastDeliveryError ?? "Agent 多次结束但没有形成明确终态",
797
+ knownIssues: "",
798
+ failureCode: observed.lastDeliveryError?.split("\n")[0]?.slice(0, 200) ?? "PI_TERMINAL_TOOL_NOT_CALLED",
799
+ projectFacts: [],
800
+ testFacts: [],
801
+ });
802
+ observed.terminalReached = true;
803
+ await emit("RUN_FAILED", { code: "PI_TERMINAL_TOOL_NOT_CALLED" });
804
+ }
805
+ }
806
+ function terminalTools(client, host, runId, turnId, runRoot, input, observed, redact, persistBatch, pendingBatch, emitQuestion, flushEvents) {
807
+ const definitions = [];
808
+ const add = (definition) => {
809
+ definitions.push(definition);
810
+ };
811
+ add({
812
+ name: input.executionPurpose === "RELEASE"
813
+ ? "submit_pull_requests"
814
+ : input.phase === "DEVELOPMENT"
815
+ ? "complete_development"
816
+ : "submit_delivery",
817
+ label: "提交交付",
818
+ description: input.executionPurpose === "RELEASE"
819
+ ? "核实上线清单并提交分支后,使用 Runtime GitLab 身份创建或复用 MR 并登记工单。"
820
+ : "读取并校验本阶段最终交付文件,上传成功后完成当前阶段。",
821
+ parameters: input.executionPurpose === "RELEASE"
822
+ ? Type.Object({
823
+ checklistSummary: Type.String({
824
+ description: "核实依据、无需修改或修改摘要;缺口未解决时不要提交",
825
+ }),
826
+ projects: Type.Array(Type.Object({
827
+ project: Type.String({
828
+ description: `项目名称,只能选择:${input.projects.map((p) => p.name).join("、")}`,
829
+ }),
830
+ title: Type.String({ description: "包含工单编号及本项目变更摘要的 MR 标题" }),
831
+ description: Type.String({
832
+ description: "任务目的、本项目改动、测试与验收结论、发布注意、依赖、风险回滚及清单和报告链接",
833
+ }),
834
+ })),
835
+ })
836
+ : Type.Object({}),
837
+ async execute(_toolCallId, rawParams) {
838
+ try {
839
+ if (input.executionPurpose === "RELEASE") {
840
+ const params = rawParams;
841
+ if (!params?.checklistSummary?.trim())
842
+ throw new Error("AIP_RELEASE_CHECKLIST_SUMMARY_REQUIRED");
843
+ const files = await collectDeliveryFiles(runRoot, input, ["RELEASE_CHECKLIST"]);
844
+ const checklist = {
845
+ status: "VERIFIED",
846
+ summary: params.checklistSummary,
847
+ markdown: files[0].content,
848
+ };
849
+ await client.submitPullRequests(runId, {
850
+ hostId: host.hostId,
851
+ generation: host.generation,
852
+ turnId,
853
+ workItemId: input.workItem.id,
854
+ projects: [],
855
+ checklist,
856
+ });
857
+ const context = {
858
+ runId,
859
+ turnId,
860
+ workItem: input.workItem,
861
+ projects: input.projects,
862
+ primaryProjectId: input.primaryProjectId,
863
+ };
864
+ for (const project of input.projects) {
865
+ const explanation = params.projects?.find((item) => item.project === project.name);
866
+ if (!explanation)
867
+ throw new Error(`AIP_MR_DESCRIPTION_REQUIRED:${project.name}`);
868
+ const pendingFile = join(runRoot, `mr-pending-${safeName(project.id)}.json`);
869
+ const result = await prepareMergeRequest({
870
+ project,
871
+ cwd: join(runRoot, safeName(project.name)),
872
+ title: explanation.title,
873
+ description: explanation.description,
874
+ pendingFile,
875
+ });
876
+ await registerMergeRequest(client, context, host, result, pendingFile);
877
+ }
878
+ await client.submitPullRequests(runId, {
879
+ hostId: host.hostId,
880
+ generation: host.generation,
881
+ turnId,
882
+ workItemId: input.workItem.id,
883
+ projects: [],
884
+ completeRelease: true,
885
+ });
886
+ observed.terminalReached = true;
887
+ return toolResult("上线清单已核实,MR 已提交并登记,等待合并;平台负责查询最新状态。");
888
+ }
889
+ const existing = pendingBatch();
890
+ if (existing && existing.turnId === turnId) {
891
+ await flushEvents();
892
+ await uploadBatch(client, runId, existing);
893
+ await persistBatch(undefined);
894
+ observed.terminalReached = true;
895
+ return toolResult("交付已提交,等待经办人确认。");
896
+ }
897
+ const files = await collectDeliveryFiles(runRoot, input, input.phase === "DEVELOPMENT"
898
+ ? Object.keys(DELIVERY_FILES).filter((slot) => STAGE_DELIVERY_PROTOCOLS.DEVELOPMENT.requiredFiles.includes(DELIVERY_FILES[slot]))
899
+ : undefined);
900
+ const projectFacts = await collectProjectFacts(runRoot, input.projects, input.workItem.key, input.primaryProjectId);
901
+ const batch = {
902
+ hostId: host.hostId,
903
+ generation: host.generation,
904
+ turnId,
905
+ kind: input.phase === "DEVELOPMENT" ? "DEVELOPMENT_COMPLETED" : "DELIVERY",
906
+ submissionId: crypto.randomUUID(),
907
+ files,
908
+ summary: files.find((file) => file.slot === "SUMMARY").content,
909
+ knownIssues: "",
910
+ projectFacts,
911
+ testFacts: [],
912
+ };
913
+ await persistBatch(batch);
914
+ await flushEvents();
915
+ await uploadBatch(client, runId, batch);
916
+ await persistBatch(undefined);
917
+ observed.terminalReached = true;
918
+ return toolResult("交付已提交,等待经办人确认。");
919
+ }
920
+ catch (error) {
921
+ observed.lastDeliveryError = error instanceof Error ? error.message : "AIP_DELIVERY_FAILED";
922
+ if (error instanceof PlatformClientError && error.status < 500 && error.status !== 429)
923
+ await persistBatch(undefined);
924
+ throw error;
925
+ }
926
+ },
927
+ });
928
+ add({
929
+ name: "update_question",
930
+ label: "更新待回应问题",
931
+ description: "仅记录当前执行必须由人决定的业务问题,不把一般建议变为审批;真正解决后传空字符串清除。普通聊天继续使用 steer,不停止 Run。",
932
+ parameters: Type.Object({ question: Type.String() }),
933
+ async execute(_id, params) {
934
+ // The caller binding supplies technical identity; the model only owns the question text.
935
+ observed.pendingQuestion = params.question.trim();
936
+ await emitQuestion(params.question);
937
+ return toolResult(params.question);
938
+ },
939
+ });
940
+ if (input.executionPurpose === "RELEASE")
941
+ add({
942
+ name: "request_code_changes",
943
+ label: "返回开发修改",
944
+ description: "用户明确要求修改应用代码时,将工单返回开发,再测试和人工确认;仅讨论或文档修改不要调用。",
945
+ parameters: Type.Object({
946
+ reason: Type.String({ description: "用户要求修改的内容及原因" }),
947
+ }),
948
+ async execute(_id, params) {
949
+ await client.request(`/api/v1/coding-runs/${runId}/return-to-development`, {
950
+ method: "POST",
951
+ body: JSON.stringify({
952
+ hostId: host.hostId,
953
+ generation: host.generation,
954
+ turnId,
955
+ reason: params.reason,
956
+ }),
957
+ });
958
+ observed.terminalReached = true;
959
+ return toolResult("已返回开发,等待下一执行接手续改,完成后进入测试与人工确认。");
960
+ },
961
+ });
962
+ add({
963
+ name: "report_blocker",
964
+ label: "报告阻塞",
965
+ description: "仅在认证、权限、仓库、外部环境或输入冲突真实阻塞时报告。",
966
+ parameters: Type.Object({
967
+ category: Type.Union([
968
+ Type.Literal("AUTHENTICATION"),
969
+ Type.Literal("PERMISSION"),
970
+ Type.Literal("REPOSITORY"),
971
+ Type.Literal("EXTERNAL_ENVIRONMENT"),
972
+ Type.Literal("INPUT_CONFLICT"),
973
+ ]),
974
+ summary: Type.String(),
975
+ }),
976
+ async execute(_id, params) {
977
+ await client.reportCodingTerminal(runId, {
978
+ hostId: host.hostId,
979
+ generation: host.generation,
980
+ turnId,
981
+ kind: "BLOCKED",
982
+ summary: redact(params.summary),
983
+ knownIssues: "",
984
+ blockerCategory: params.category,
985
+ projectFacts: [],
986
+ testFacts: [],
987
+ });
988
+ observed.terminalReached = true;
989
+ return toolResult("阻塞已报告。");
990
+ },
991
+ });
992
+ return (api) => {
993
+ for (const definition of definitions)
994
+ api.registerTool({
995
+ name: definition.name,
996
+ label: definition.label,
997
+ description: definition.description,
998
+ parameters: definition.parameters,
999
+ async execute(id, args, signal) {
1000
+ signal?.throwIfAborted();
1001
+ return definition.execute(id, args);
1002
+ },
1003
+ });
1004
+ };
1005
+ }
862
1006
  export async function prepareProjects(cacheRoot, runRoot, runId, projects, resume) {
863
1007
  await mkdir(cacheRoot, { recursive: true, mode: 0o700 });
864
1008
  for (const project of projects) {
@@ -995,24 +1139,6 @@ async function uploadBatch(client, runId, batch) {
995
1139
  }
996
1140
  }
997
1141
  }
998
- function textFromMessage(content) {
999
- if (typeof content === "string")
1000
- return content;
1001
- if (!Array.isArray(content))
1002
- return "";
1003
- return content
1004
- .filter((part) => Boolean(part &&
1005
- typeof part === "object" &&
1006
- part.type === "text" &&
1007
- typeof part.text === "string"))
1008
- .map((part) => part.text)
1009
- .join("\n");
1010
- }
1011
- function textFromResult(value) {
1012
- if (!value || typeof value !== "object")
1013
- return "";
1014
- return textFromMessage(value.content).slice(0, 4000);
1015
- }
1016
1142
  function toolResult(text) {
1017
1143
  return { content: [{ type: "text", text }], details: {} };
1018
1144
  }