@teamclaws/teamclaw 2026.3.25 → 2026.3.26-1

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.
@@ -1,6 +1,6 @@
1
1
  import type { OpenClawPluginApi, PluginLogger } from "../api.js";
2
2
  import { getRole } from "./roles.js";
3
- import type { RoleId, TaskExecutionEventInput } from "./types.js";
3
+ import type { RoleId, TaskAssignmentPayload, TaskExecutionEventInput } from "./types.js";
4
4
 
5
5
  const TEAMCLAW_ROLE_IDS_TEXT = [
6
6
  "pm",
@@ -18,6 +18,13 @@ const TEAMCLAW_ROLE_IDS_TEXT = [
18
18
  const SESSION_PROGRESS_POLL_INTERVAL_MS = 1000;
19
19
  const SESSION_PROGRESS_MESSAGE_LIMIT = 200;
20
20
  const MAX_SESSION_PROGRESS_MESSAGE_CHARS = 4000;
21
+ const RUN_WAIT_SLICE_MS = 30_000;
22
+ const RATE_LIMIT_STALL_PROBE_MS = 5 * 60 * 1000;
23
+ const RATE_LIMIT_PROBE_TIMEOUT_MS = 60_000;
24
+ const BACKGROUND_WORK_PROBE_MS = 60_000;
25
+ const BACKGROUND_WORK_PROBE_TIMEOUT_MS = 60_000;
26
+ const CHILD_SESSION_PROGRESS_POLL_INTERVAL_MS = 5_000;
27
+ const RATE_LIMIT_WAITING_SENTINEL = "TEAMCLAW_STILL_WAITING";
21
28
  const TOOL_CALL_BLOCK_TYPES = new Set(["tool_use", "toolcall", "tool_call"]);
22
29
  const TOOL_RESULT_BLOCK_TYPES = new Set(["tool_result", "tool_result_error"]);
23
30
 
@@ -26,21 +33,32 @@ type SessionProgressEntry = {
26
33
  message: string;
27
34
  phase: string;
28
35
  stream: string;
36
+ isRateLimit: boolean;
29
37
  };
30
38
 
31
39
  type SessionProgressSnapshot = {
32
40
  fingerprints: string[];
41
+ childSessionKeys: string[];
42
+ childFingerprints: Map<string, string[]>;
43
+ lastChildPollAt: number;
33
44
  lastAssistantMessage: string;
34
45
  latestMessages: unknown[];
35
46
  };
36
47
 
48
+ type AssistantTurnSnapshot = {
49
+ text: string;
50
+ toolCalls: string[];
51
+ yielded: boolean;
52
+ backgroundPending: boolean;
53
+ };
54
+
37
55
  export type RoleTaskExecutorDeps = {
38
56
  runtime: OpenClawPluginApi["runtime"];
39
57
  logger: PluginLogger;
40
58
  role: RoleId;
41
59
  taskTimeoutMs: number;
42
- getSessionKey: (taskId: string) => string;
43
- getIdempotencyKey?: (taskId: string) => string;
60
+ getSessionKey: (assignment: TaskAssignmentPayload) => string;
61
+ getIdempotencyKey?: (assignment: TaskAssignmentPayload) => string;
44
62
  reportExecutionEvent?: (taskId: string, event: TaskExecutionEventInput) => Promise<void> | void;
45
63
  };
46
64
 
@@ -51,8 +69,10 @@ export function createRoleTaskExecutor(deps: RoleTaskExecutorDeps) {
51
69
  ? roleDef.systemPrompt
52
70
  : `You are a ${role} in a virtual software team. Complete the assigned task.`;
53
71
 
54
- return async (taskDescription: string, taskId: string): Promise<string> => {
55
- const sessionKey = getSessionKey(taskId);
72
+ return async (taskDescription: string, assignment: TaskAssignmentPayload): Promise<string> => {
73
+ const taskId = assignment.taskId;
74
+ const sessionKey = getSessionKey(assignment);
75
+ const idempotencyKey = getIdempotencyKey?.(assignment);
56
76
  const taskMessage = buildTaskMessage(taskDescription, taskId, roleDef?.label ?? role);
57
77
  logger.info(`TeamClaw: executing task ${taskId} as ${role} via subagent`);
58
78
 
@@ -76,7 +96,7 @@ export function createRoleTaskExecutor(deps: RoleTaskExecutorDeps) {
76
96
  sessionKey,
77
97
  message: taskMessage,
78
98
  extraSystemPrompt: roleSystemPrompt,
79
- idempotencyKey: getIdempotencyKey?.(taskId),
99
+ idempotencyKey,
80
100
  });
81
101
 
82
102
  logger.info(`TeamClaw: subagent run started for task ${taskId}, runId=${runResult.runId}`);
@@ -92,9 +112,81 @@ export function createRoleTaskExecutor(deps: RoleTaskExecutorDeps) {
92
112
 
93
113
  const progressSnapshot: SessionProgressSnapshot = {
94
114
  fingerprints: [],
115
+ childSessionKeys: [],
116
+ childFingerprints: new Map(),
117
+ lastChildPollAt: 0,
95
118
  lastAssistantMessage: "",
96
119
  latestMessages: [],
97
120
  };
121
+ const deadline = Date.now() + taskTimeoutMs;
122
+ const rateLimitState: {
123
+ active: boolean;
124
+ visibleAt?: number;
125
+ nextProbeAt?: number;
126
+ probeCount: number;
127
+ } = {
128
+ active: false,
129
+ probeCount: 0,
130
+ };
131
+ const backgroundWaitState: {
132
+ active: boolean;
133
+ visibleAt?: number;
134
+ nextProbeAt?: number;
135
+ probeCount: number;
136
+ } = {
137
+ active: false,
138
+ probeCount: 0,
139
+ };
140
+
141
+ const markRateLimitWaiting = async (): Promise<void> => {
142
+ if (rateLimitState.active) {
143
+ return;
144
+ }
145
+ const now = Date.now();
146
+ rateLimitState.active = true;
147
+ rateLimitState.visibleAt = now;
148
+ rateLimitState.nextProbeAt = now + RATE_LIMIT_STALL_PROBE_MS;
149
+ await emitExecutionEvent({
150
+ type: "progress",
151
+ phase: "model_rate_limit_waiting",
152
+ source: "worker",
153
+ status: "running",
154
+ runId: runResult.runId,
155
+ sessionKey,
156
+ message: "Model rate limit reached. OpenClaw is retrying upstream; TeamClaw will keep waiting for the task to continue.",
157
+ });
158
+ };
159
+
160
+ const clearRateLimitWaiting = (): void => {
161
+ rateLimitState.active = false;
162
+ rateLimitState.visibleAt = undefined;
163
+ rateLimitState.nextProbeAt = undefined;
164
+ };
165
+
166
+ const markBackgroundWorkWaiting = async (): Promise<void> => {
167
+ if (backgroundWaitState.active) {
168
+ return;
169
+ }
170
+ const now = Date.now();
171
+ backgroundWaitState.active = true;
172
+ backgroundWaitState.visibleAt = now;
173
+ backgroundWaitState.nextProbeAt = now + BACKGROUND_WORK_PROBE_MS;
174
+ await emitExecutionEvent({
175
+ type: "progress",
176
+ phase: "background_work_waiting",
177
+ source: "worker",
178
+ status: "running",
179
+ runId: runResult.runId,
180
+ sessionKey,
181
+ message: "The worker ended its last turn while background work was still running. TeamClaw will keep checking until the real final deliverable is ready.",
182
+ });
183
+ };
184
+
185
+ const clearBackgroundWorkWaiting = (): void => {
186
+ backgroundWaitState.active = false;
187
+ backgroundWaitState.visibleAt = undefined;
188
+ backgroundWaitState.nextProbeAt = undefined;
189
+ };
98
190
 
99
191
  const syncSessionProgress = async (): Promise<void> => {
100
192
  const sessionMessages = await runtime.subagent.getSessionMessages({
@@ -106,8 +198,25 @@ export function createRoleTaskExecutor(deps: RoleTaskExecutorDeps) {
106
198
  const entries = buildSessionProgressEntries(progressSnapshot.latestMessages, taskMessage);
107
199
  const newEntries = getNewSessionProgressEntries(entries, progressSnapshot.fingerprints);
108
200
  progressSnapshot.fingerprints = entries.map((entry) => entry.fingerprint);
201
+ progressSnapshot.childSessionKeys = mergeChildSessionKeys(
202
+ progressSnapshot.childSessionKeys,
203
+ collectChildSessionKeys(progressSnapshot.latestMessages),
204
+ );
109
205
 
110
206
  for (const entry of newEntries) {
207
+ if (entry.isRateLimit) {
208
+ await markRateLimitWaiting();
209
+ continue;
210
+ }
211
+ if (rateLimitState.active && isStillWaitingResponse(entry.message)) {
212
+ continue;
213
+ }
214
+ if (rateLimitState.active && isInternalRetryPrompt(entry.message, entry.stream)) {
215
+ continue;
216
+ }
217
+ if (rateLimitState.active) {
218
+ clearRateLimitWaiting();
219
+ }
111
220
  if (entry.stream === "assistant") {
112
221
  progressSnapshot.lastAssistantMessage = entry.message;
113
222
  }
@@ -121,6 +230,146 @@ export function createRoleTaskExecutor(deps: RoleTaskExecutorDeps) {
121
230
  message: entry.message,
122
231
  });
123
232
  }
233
+
234
+ if (Date.now() - progressSnapshot.lastChildPollAt >= CHILD_SESSION_PROGRESS_POLL_INTERVAL_MS) {
235
+ progressSnapshot.lastChildPollAt = Date.now();
236
+ const childRateLimitDetected = await syncChildSessionRateLimits(runtime, progressSnapshot);
237
+ if (childRateLimitDetected) {
238
+ await markRateLimitWaiting();
239
+ }
240
+ }
241
+ };
242
+
243
+ const extractSessionAssistantTurn = async (): Promise<AssistantTurnSnapshot> => {
244
+ let turn = extractLastAssistantTurn(progressSnapshot.latestMessages);
245
+ if (!turn.text && !turn.backgroundPending) {
246
+ const sessionMessages = await runtime.subagent.getSessionMessages({
247
+ sessionKey,
248
+ limit: 100,
249
+ });
250
+ progressSnapshot.latestMessages = Array.isArray(sessionMessages.messages) ? sessionMessages.messages : [];
251
+ turn = extractLastAssistantTurn(sessionMessages.messages);
252
+ }
253
+ return turn;
254
+ };
255
+
256
+ const probeRateLimitedTaskCompletion = async (): Promise<string | null> => {
257
+ rateLimitState.probeCount += 1;
258
+ const now = Date.now();
259
+ rateLimitState.visibleAt = now;
260
+ rateLimitState.nextProbeAt = now + RATE_LIMIT_STALL_PROBE_MS;
261
+ await emitExecutionEvent({
262
+ type: "progress",
263
+ phase: "model_rate_limit_probe",
264
+ source: "worker",
265
+ status: "running",
266
+ runId: runResult.runId,
267
+ sessionKey,
268
+ message: `Model rate limit has delayed task progress for over ${formatDuration(RATE_LIMIT_STALL_PROBE_MS)}. Re-checking whether the current task has already completed.`,
269
+ });
270
+
271
+ const probeRun = await runtime.subagent.run({
272
+ sessionKey,
273
+ message: buildRateLimitProbeMessage(taskId, roleDef?.label ?? role),
274
+ extraSystemPrompt: roleSystemPrompt,
275
+ idempotencyKey: `${idempotencyKey ?? `teamclaw-${taskId}`}:rate-limit-probe:${rateLimitState.probeCount}`,
276
+ });
277
+ const probeWait = await runtime.subagent.waitForRun({
278
+ runId: probeRun.runId,
279
+ timeoutMs: RATE_LIMIT_PROBE_TIMEOUT_MS,
280
+ });
281
+
282
+ try {
283
+ await syncSessionProgress();
284
+ } catch (err) {
285
+ logger.debug?.(`TeamClaw: failed probe session sync for ${taskId}: ${String(err)}`);
286
+ }
287
+
288
+ if (probeWait.status !== "ok") {
289
+ return null;
290
+ }
291
+
292
+ const probeTurn = await extractSessionAssistantTurn();
293
+ if (!probeTurn.text || probeTurn.backgroundPending || isRateLimitMessage(probeTurn.text) || isStillWaitingResponse(probeTurn.text)) {
294
+ await emitExecutionEvent({
295
+ type: "progress",
296
+ phase: "model_rate_limit_still_waiting",
297
+ source: "worker",
298
+ status: "running",
299
+ runId: runResult.runId,
300
+ sessionKey,
301
+ message: "The task is still waiting on model availability. TeamClaw will continue waiting.",
302
+ });
303
+ return null;
304
+ }
305
+
306
+ clearRateLimitWaiting();
307
+ return probeTurn.text;
308
+ };
309
+
310
+ const probeBackgroundTaskCompletion = async (): Promise<AssistantTurnSnapshot | null> => {
311
+ backgroundWaitState.probeCount += 1;
312
+ const now = Date.now();
313
+ backgroundWaitState.visibleAt = now;
314
+ backgroundWaitState.nextProbeAt = now + BACKGROUND_WORK_PROBE_MS;
315
+ await emitExecutionEvent({
316
+ type: "progress",
317
+ phase: "background_work_probe",
318
+ source: "worker",
319
+ status: "running",
320
+ runId: runResult.runId,
321
+ sessionKey,
322
+ message: `Background work has been running for over ${formatDuration(BACKGROUND_WORK_PROBE_MS)}. Re-checking whether the original task is now complete.`,
323
+ });
324
+
325
+ const probeRun = await runtime.subagent.run({
326
+ sessionKey,
327
+ message: buildBackgroundWorkProbeMessage(taskId, roleDef?.label ?? role),
328
+ extraSystemPrompt: roleSystemPrompt,
329
+ idempotencyKey: `${idempotencyKey ?? `teamclaw-${taskId}`}:background-work-probe:${backgroundWaitState.probeCount}`,
330
+ });
331
+ const probeWait = await runtime.subagent.waitForRun({
332
+ runId: probeRun.runId,
333
+ timeoutMs: Math.min(
334
+ BACKGROUND_WORK_PROBE_TIMEOUT_MS,
335
+ Math.max(1_000, deadline - Date.now()),
336
+ ),
337
+ });
338
+
339
+ try {
340
+ await syncSessionProgress();
341
+ } catch (err) {
342
+ logger.debug?.(`TeamClaw: failed background probe session sync for ${taskId}: ${String(err)}`);
343
+ }
344
+
345
+ if (probeWait.status !== "ok") {
346
+ if (probeWait.status === "error" && isRateLimitMessage(probeWait.error || "")) {
347
+ await markRateLimitWaiting();
348
+ }
349
+ return null;
350
+ }
351
+
352
+ const probeTurn = await extractSessionAssistantTurn();
353
+ if (
354
+ !probeTurn.text ||
355
+ probeTurn.backgroundPending ||
356
+ isRateLimitMessage(probeTurn.text) ||
357
+ isStillWaitingResponse(probeTurn.text)
358
+ ) {
359
+ await emitExecutionEvent({
360
+ type: "progress",
361
+ phase: "background_work_still_waiting",
362
+ source: "worker",
363
+ status: "running",
364
+ runId: runResult.runId,
365
+ sessionKey,
366
+ message: "The task is still waiting on background work. TeamClaw will continue waiting.",
367
+ });
368
+ return null;
369
+ }
370
+
371
+ clearBackgroundWorkWaiting();
372
+ return probeTurn;
124
373
  };
125
374
 
126
375
  let keepPolling = true;
@@ -140,11 +389,40 @@ export function createRoleTaskExecutor(deps: RoleTaskExecutorDeps) {
140
389
  })();
141
390
 
142
391
  let waitResult;
392
+ let completionOverride: string | null = null;
143
393
  try {
144
- waitResult = await runtime.subagent.waitForRun({
145
- runId: runResult.runId,
146
- timeoutMs: taskTimeoutMs,
147
- });
394
+ while (true) {
395
+ const remainingMs = deadline - Date.now();
396
+ if (remainingMs <= 0) {
397
+ waitResult = { status: "timeout" as const };
398
+ break;
399
+ }
400
+
401
+ if (rateLimitState.active && (rateLimitState.nextProbeAt ?? Number.POSITIVE_INFINITY) <= Date.now()) {
402
+ completionOverride = await probeRateLimitedTaskCompletion();
403
+ if (completionOverride) {
404
+ waitResult = { status: "ok" as const };
405
+ break;
406
+ }
407
+ }
408
+
409
+ const sliceTimeoutMs = Math.max(1_000, Math.min(RUN_WAIT_SLICE_MS, remainingMs));
410
+ waitResult = await runtime.subagent.waitForRun({
411
+ runId: runResult.runId,
412
+ timeoutMs: sliceTimeoutMs,
413
+ });
414
+
415
+ if (waitResult.status === "ok") {
416
+ break;
417
+ }
418
+ if (waitResult.status === "error") {
419
+ if (isRateLimitMessage(waitResult.error || "")) {
420
+ await markRateLimitWaiting();
421
+ continue;
422
+ }
423
+ break;
424
+ }
425
+ }
148
426
  } finally {
149
427
  keepPolling = false;
150
428
  await pollSessionProgress;
@@ -157,25 +435,45 @@ export function createRoleTaskExecutor(deps: RoleTaskExecutorDeps) {
157
435
  }
158
436
 
159
437
  if (waitResult.status === "ok") {
160
- let result = extractLastAssistantText(progressSnapshot.latestMessages);
161
- if (!result) {
162
- const sessionMessages = await runtime.subagent.getSessionMessages({
163
- sessionKey,
164
- limit: 100,
165
- });
166
- result = extractLastAssistantText(sessionMessages.messages);
167
- }
168
- if (result && normalizeComparableText(result) !== normalizeComparableText(progressSnapshot.lastAssistantMessage)) {
169
- await emitExecutionEvent({
170
- type: "output",
171
- phase: "final_output",
172
- source: "subagent",
173
- message: result,
174
- });
438
+ let assistantTurn = completionOverride
439
+ ? buildAssistantTurnSnapshot(completionOverride)
440
+ : await extractSessionAssistantTurn();
441
+ while (isBackgroundWorkPendingTurn(assistantTurn)) {
442
+ await markBackgroundWorkWaiting();
443
+ const remainingMs = deadline - Date.now();
444
+ if (remainingMs <= 0) {
445
+ waitResult = { status: "timeout" as const };
446
+ break;
447
+ }
448
+ const nextProbeAt = backgroundWaitState.nextProbeAt ?? (Date.now() + BACKGROUND_WORK_PROBE_MS);
449
+ const delayMs = Math.max(1_000, Math.min(nextProbeAt - Date.now(), remainingMs));
450
+ await delay(delayMs);
451
+ const probeTurn = await probeBackgroundTaskCompletion();
452
+ if (probeTurn) {
453
+ assistantTurn = probeTurn;
454
+ break;
455
+ }
456
+ assistantTurn = await extractSessionAssistantTurn();
175
457
  }
458
+ if (waitResult.status === "ok") {
459
+ if (rateLimitState.active) {
460
+ clearRateLimitWaiting();
461
+ }
462
+ const result = assistantTurn.text;
463
+ if (result && normalizeComparableText(result) !== normalizeComparableText(progressSnapshot.lastAssistantMessage)) {
464
+ await emitExecutionEvent({
465
+ type: "output",
466
+ phase: "final_output",
467
+ source: "subagent",
468
+ message: result,
469
+ });
470
+ }
176
471
 
177
- logger.info(`TeamClaw: task ${taskId} completed successfully as ${role}`);
178
- return result;
472
+ clearBackgroundWorkWaiting();
473
+ logger.info(`TeamClaw: task ${taskId} completed successfully as ${role}`);
474
+ return result;
475
+ }
476
+ clearBackgroundWorkWaiting();
179
477
  }
180
478
 
181
479
  if (waitResult.status === "timeout") {
@@ -261,12 +559,73 @@ function buildSessionProgressEntries(messages: unknown[], taskMessage: string):
261
559
  message: rendered.message,
262
560
  phase: rendered.stream,
263
561
  stream: rendered.stream,
562
+ isRateLimit: isRateLimitMessage(rendered.message),
264
563
  });
265
564
  }
266
565
 
267
566
  return entries;
268
567
  }
269
568
 
569
+ function collectChildSessionKeys(messages: unknown[]): string[] {
570
+ const keys = new Set<string>();
571
+ for (const message of messages) {
572
+ if (!message || typeof message !== "object") {
573
+ continue;
574
+ }
575
+ const content = (message as { content?: unknown }).content;
576
+ const text = typeof content === "string"
577
+ ? content
578
+ : Array.isArray(content)
579
+ ? content
580
+ .map((entry) => (entry && typeof entry === "object" && typeof (entry as { text?: unknown }).text === "string")
581
+ ? (entry as { text: string }).text
582
+ : "")
583
+ .filter(Boolean)
584
+ .join("\n")
585
+ : "";
586
+ for (const match of text.matchAll(/"childSessionKey"\s*:\s*"([^"]+)"/g)) {
587
+ const childSessionKey = match[1]?.trim();
588
+ if (childSessionKey) {
589
+ keys.add(childSessionKey);
590
+ }
591
+ }
592
+ }
593
+ return Array.from(keys);
594
+ }
595
+
596
+ function mergeChildSessionKeys(existing: string[], discovered: string[]): string[] {
597
+ const keys = new Set(existing);
598
+ for (const childSessionKey of discovered) {
599
+ keys.add(childSessionKey);
600
+ }
601
+ return Array.from(keys);
602
+ }
603
+
604
+ async function syncChildSessionRateLimits(
605
+ runtime: OpenClawPluginApi["runtime"],
606
+ snapshot: SessionProgressSnapshot,
607
+ ): Promise<boolean> {
608
+ let detected = false;
609
+ for (const childSessionKey of snapshot.childSessionKeys) {
610
+ try {
611
+ const sessionMessages = await runtime.subagent.getSessionMessages({
612
+ sessionKey: childSessionKey,
613
+ limit: SESSION_PROGRESS_MESSAGE_LIMIT,
614
+ });
615
+ const entries = buildSessionProgressEntries(sessionMessages.messages, "");
616
+ const previousFingerprints = snapshot.childFingerprints.get(childSessionKey) ?? [];
617
+ const newEntries = getNewSessionProgressEntries(entries, previousFingerprints);
618
+ snapshot.childFingerprints.set(childSessionKey, entries.map((entry) => entry.fingerprint));
619
+ if (newEntries.some((entry) => entry.isRateLimit)) {
620
+ detected = true;
621
+ }
622
+ } catch (_err) {
623
+ // Child session updates are best-effort only.
624
+ }
625
+ }
626
+ return detected;
627
+ }
628
+
270
629
  function getNewSessionProgressEntries(
271
630
  entries: SessionProgressEntry[],
272
631
  previousFingerprints: string[],
@@ -442,11 +801,51 @@ function buildTaskMessage(taskDescription: string, taskId: string, roleLabel: st
442
801
  "- Do NOT mark the task completed or failed via progress tools. Return the final deliverable (or raise an error) and let TeamClaw close the task.",
443
802
  "- If critical information is missing and you cannot proceed safely, request clarification and wait instead of guessing.",
444
803
  "- If more work is needed, mention it briefly in your result or use a handoff/review tool on this same task.",
804
+ "- Before your final reply, submit a structured worker result contract with teamclaw_submit_result_contract so TeamClaw can route the next step without parsing prose.",
805
+ `- Do NOT use sessions_yield or end your turn while background work, coding agents, or process sessions are still running; if the task is not complete yet, reply with exactly ${RATE_LIMIT_WAITING_SENTINEL}.`,
806
+ "- Never return 'running in background' as the final result for a TeamClaw task. If you spawn a helper session, keep monitoring it and only return after you have the actual deliverable.",
807
+ "- Use structured fields on progress, review, handoff, and messaging tools whenever coordination is needed.",
445
808
  `- When naming a role, use exact TeamClaw role IDs: ${TEAMCLAW_ROLE_IDS_TEXT}.`,
446
809
  ].join("\n");
447
810
  }
448
811
 
449
- function extractLastAssistantText(messages: unknown[]): string {
812
+ function buildRateLimitProbeMessage(taskId: string, roleLabel: string): string {
813
+ return [
814
+ `This is a follow-up check for task ${taskId} (${roleLabel}).`,
815
+ "The earlier run appears to be delayed by upstream model rate limiting.",
816
+ "Do not restart the task from scratch.",
817
+ "If the original task is fully complete now, immediately submit the structured result contract and provide the final result for that original task.",
818
+ `If the original task is not complete yet, reply with exactly ${RATE_LIMIT_WAITING_SENTINEL}.`,
819
+ ].join("\n");
820
+ }
821
+
822
+ function buildBackgroundWorkProbeMessage(taskId: string, roleLabel: string): string {
823
+ return [
824
+ `This is a follow-up check for task ${taskId} (${roleLabel}).`,
825
+ "Your previous turn ended while background work was still running.",
826
+ "Do not restart the task from scratch.",
827
+ "Inspect the background coding or process session you previously started, continue from the existing workspace/session state, and only finalize once the original task deliverable is genuinely complete.",
828
+ "Do not call sessions_yield again unless you are still explicitly waiting on unfinished background work.",
829
+ "If the original task is fully complete now, immediately submit the structured result contract and provide the final result for that original task.",
830
+ `If the original task is not complete yet, reply with exactly ${RATE_LIMIT_WAITING_SENTINEL}.`,
831
+ ].join("\n");
832
+ }
833
+
834
+ function buildAssistantTurnSnapshot(text: string, toolCalls: string[] = []): AssistantTurnSnapshot {
835
+ const normalizedText = String(text || "").trim();
836
+ const normalizedToolCalls = toolCalls
837
+ .map((name) => String(name || "").trim().toLowerCase())
838
+ .filter(Boolean);
839
+ const yielded = normalizedToolCalls.includes("sessions_yield");
840
+ return {
841
+ text: normalizedText,
842
+ toolCalls: normalizedToolCalls,
843
+ yielded,
844
+ backgroundPending: yielded || isBackgroundWorkPendingMessage(normalizedText),
845
+ };
846
+ }
847
+
848
+ function extractLastAssistantTurn(messages: unknown[]): AssistantTurnSnapshot {
450
849
  const assistantMessages = messages.filter((message): message is { role?: unknown; content?: unknown } => {
451
850
  if (!message || typeof message !== "object") {
452
851
  return false;
@@ -456,11 +855,11 @@ function extractLastAssistantText(messages: unknown[]): string {
456
855
 
457
856
  const lastAssistant = assistantMessages[assistantMessages.length - 1];
458
857
  if (!lastAssistant) {
459
- return "";
858
+ return buildAssistantTurnSnapshot("");
460
859
  }
461
860
 
462
861
  if (typeof lastAssistant.content === "string") {
463
- return lastAssistant.content;
862
+ return buildAssistantTurnSnapshot(lastAssistant.content);
464
863
  }
465
864
 
466
865
  if (Array.isArray(lastAssistant.content)) {
@@ -470,10 +869,60 @@ function extractLastAssistantText(messages: unknown[]): string {
470
869
  })
471
870
  .map((block) => (typeof block.text === "string" ? block.text : ""))
472
871
  .filter(Boolean);
473
- if (textBlocks.length > 0) {
474
- return textBlocks.join("\n");
872
+ const toolCalls = lastAssistant.content
873
+ .filter((block): block is { type?: unknown; name?: unknown } => {
874
+ return !!block
875
+ && typeof block === "object"
876
+ && TOOL_CALL_BLOCK_TYPES.has(normalizeBlockType((block as { type?: unknown }).type));
877
+ })
878
+ .map((block) => (typeof block.name === "string" ? block.name : ""))
879
+ .filter(Boolean);
880
+ if (textBlocks.length > 0 || toolCalls.length > 0) {
881
+ return buildAssistantTurnSnapshot(textBlocks.join("\n"), toolCalls);
475
882
  }
476
883
  }
477
884
 
478
- return JSON.stringify(lastAssistant);
885
+ return buildAssistantTurnSnapshot(JSON.stringify(lastAssistant));
886
+ }
887
+
888
+ function isRateLimitMessage(value: string): boolean {
889
+ return /(rate[_ ]limit|too many requests|429\b|resource has been exhausted|tokens per day|quota|throttl)/i.test(
890
+ String(value || ""),
891
+ );
892
+ }
893
+
894
+ function isStillWaitingResponse(value: string): boolean {
895
+ const normalized = String(value || "").trim();
896
+ if (!normalized) {
897
+ return true;
898
+ }
899
+ if (normalized === RATE_LIMIT_WAITING_SENTINEL) {
900
+ return true;
901
+ }
902
+ return /(still waiting|continue waiting|not complete yet|尚未完成|继续等待|仍在等待)/i.test(normalized);
903
+ }
904
+
905
+ function isInternalRetryPrompt(value: string, stream?: string): boolean {
906
+ if (stream !== "user") {
907
+ return false;
908
+ }
909
+ const normalized = String(value || "").trim();
910
+ if (!normalized) {
911
+ return false;
912
+ }
913
+ return /continue where you left off\. the previous model attempt failed or timed out\./i.test(normalized);
914
+ }
915
+
916
+ function isBackgroundWorkPendingMessage(value: string): boolean {
917
+ const normalized = String(value || "").trim();
918
+ if (!normalized) {
919
+ return false;
920
+ }
921
+ return /(running in background|background session|command still running \(session|monitor progress and report back when complete|后台.*运行中|后台.*会在完成后汇报|后台.*完成后再汇报)/i.test(
922
+ normalized,
923
+ );
924
+ }
925
+
926
+ function isBackgroundWorkPendingTurn(turn: AssistantTurnSnapshot): boolean {
927
+ return turn.backgroundPending || isStillWaitingResponse(turn.text);
479
928
  }