@tea-agent/loop-agent 0.25.3 → 0.25.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.
Files changed (49) hide show
  1. package/AGENTS.md +6 -0
  2. package/CHANGELOG.md +55 -0
  3. package/dist/application/dag/args.js +21 -1
  4. package/dist/application/dag/run-dag.js +1 -0
  5. package/dist/cli/command-definitions.js +1 -1
  6. package/dist/cli/program.js +82 -63
  7. package/dist/commands/client-recovery.js +209 -62
  8. package/dist/commands/init.js +206 -82
  9. package/dist/commands/run-dag-progress.js +109 -0
  10. package/dist/commands/run-dag.js +16 -5
  11. package/dist/executors/dag-pi-executor.js +80 -15
  12. package/dist/executors/model-routing.js +1 -1
  13. package/dist/executors/shell-executor.js +159 -0
  14. package/dist/executors/shell-write-guard.js +21 -7
  15. package/dist/worker/console/repo-fingerprint.js +7 -1
  16. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +964 -0
  17. package/dist/workflows/dag/backend-test-case-manifest.js +39 -1
  18. package/dist/workflows/dag/backend-test-markdown-workflow.js +306 -30
  19. package/dist/workflows/dag/backend-test-result-contract.js +35 -9
  20. package/dist/workflows/dag/convergence/controller.js +134 -9
  21. package/dist/workflows/dag/frontend-test-case-checklist.js +71 -0
  22. package/dist/workflows/dag/frontend-test-html-report.js +77 -0
  23. package/dist/workflows/dag/frontend-test-l5-report.js +138 -0
  24. package/dist/workflows/dag/frontend-test-result-contract.js +44 -1
  25. package/dist/workflows/dag/init-hybrid.js +267 -80
  26. package/dist/workflows/dag/node-execution.js +64 -11
  27. package/dist/workflows/dag/prompt.js +118 -4
  28. package/dist/workflows/dag/retry-policy.js +5 -4
  29. package/dist/workflows/dag/scheduler.js +32 -5
  30. package/dist/workflows/dag/types.js +10 -3
  31. package/dist/workflows/dag/validate.js +6 -3
  32. package/docs/architecture/dag-execution.md +7 -4
  33. package/docs/architecture/runtime-boundaries.md +1 -1
  34. package/docs/templates/agent-dag.base.json +1 -1
  35. package/docs/templates/agent-dag.final-verification.json +1 -1
  36. package/docs/templates/agent-dag.schema.json +6 -0
  37. package/docs/templates/agent-dag.supervised-implementation.json +1 -1
  38. package/docs/templates/backend-test-dag.json +40 -13
  39. package/docs/templates/backend-test-dag.review-cases.prompt.md +1 -1
  40. package/docs/templates/frontend-test-dag.json +62 -5
  41. package/docs/templates/hybrid-dag.json +1 -1
  42. package/examples/decision-gate-agent-dag.json +1 -1
  43. package/examples/example-dag.json +1 -1
  44. package/examples/hybrid-loop-agent-dag.json +1 -1
  45. package/harness.json +3 -2
  46. package/package.json +1 -1
  47. package/skills/loop-agent/references/command-reference.md +2 -1
  48. package/skills/loop-agent/references/hybrid-dag.md +2 -2
  49. package/skills/loop-agent/references/model-routing.md +1 -1
@@ -1,7 +1,12 @@
1
- import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
- export const CLIENT_RECOVERY_MODES = ["auto", "project", "user", "off"];
4
+ export const CLIENT_RECOVERY_MODES = [
5
+ "auto",
6
+ "project",
7
+ "user",
8
+ "off",
9
+ ];
5
10
  export const OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH = ".opencode/plugins/loop-agent-transient-retry.js";
6
11
  export const PERMANENT_ERROR_INTERACTION = "plugin-ignore-permanent-error";
7
12
  export const BACKOFF_MS = [2000, 4000, 8000, 16000, 30000];
@@ -16,14 +21,26 @@ export const PI_RECOMMENDED_RETRY = {
16
21
  },
17
22
  };
18
23
  const PERMANENT_MESSAGE_PATTERNS = [
19
- { reason: "auth", pattern: /\b(401|unauthorized|invalid api key|authentication)\b/i },
20
- { reason: "permission", pattern: /\b(403|forbidden|permission denied|not allowed)\b/i },
21
- { reason: "quota", pattern: /\b(quota|rate.?limit|billing|insufficient.?credit)\b/i },
24
+ {
25
+ reason: "auth",
26
+ pattern: /\b(401|unauthorized|invalid api key|authentication)\b/i,
27
+ },
28
+ {
29
+ reason: "permission",
30
+ pattern: /\b(403|forbidden|permission denied|not allowed)\b/i,
31
+ },
32
+ {
33
+ reason: "quota",
34
+ pattern: /\b(quota|rate.?limit|billing|insufficient.?credit)\b/i,
35
+ },
22
36
  {
23
37
  reason: "context-overflow",
24
38
  pattern: /\b(context (length )?overflow|too many tokens|maximum context|context window)\b/i,
25
39
  },
26
- { reason: "cancelled", pattern: /\b(cancelled|canceled|aborted by user|user cancel)\b/i },
40
+ {
41
+ reason: "cancelled",
42
+ pattern: /\b(cancelled|canceled|aborted by user|user cancel)\b/i,
43
+ },
27
44
  {
28
45
  reason: "business-validation",
29
46
  pattern: /\b(business validation|invalid task|schema validation)\b/i,
@@ -33,7 +50,6 @@ const TRANSIENT_MESSAGE_PATTERNS = [
33
50
  /\bcode[:\s]*502\b/i,
34
51
  /\b502\b/,
35
52
  /\bLLMRequestError\b/i,
36
- /\bTypeValidationError\b/i,
37
53
  /\bnetwork fluctuation\b/i,
38
54
  /\btimeout\b/i,
39
55
  /\btransient provider failure\b/i,
@@ -90,13 +106,34 @@ function hasUnknownErrorShape(error) {
90
106
  }
91
107
  return false;
92
108
  }
109
+ /**
110
+ * TypeValidation bypass shape: bare TypeValidationError (zod invalid_union
111
+ * surfaced directly by the provider/SDK instead of wrapped as UnknownError).
112
+ * Bypass only fires when combined with a transient signal (see classifyTransientUnknownError).
113
+ */
114
+ function hasTypeValidationShape(error) {
115
+ const text = collectErrorText(error);
116
+ if (/\bTypeValidationError\b/i.test(text))
117
+ return true;
118
+ if (/Type validation failed/i.test(text))
119
+ return true;
120
+ if (error && typeof error === "object") {
121
+ const record = error;
122
+ if (typeof record.name === "string" &&
123
+ /TypeValidationError/i.test(record.name)) {
124
+ return true;
125
+ }
126
+ }
127
+ return false;
128
+ }
93
129
  function isStandardApiError(error) {
94
130
  if (!error || typeof error !== "object")
95
131
  return false;
96
132
  const record = error;
97
133
  if (typeof record.name === "string" && /^APIError$/i.test(record.name))
98
134
  return true;
99
- if (typeof record.name === "string" && /^(AuthError|PermissionError)$/i.test(record.name)) {
135
+ if (typeof record.name === "string" &&
136
+ /^(AuthError|PermissionError)$/i.test(record.name)) {
100
137
  return true;
101
138
  }
102
139
  return false;
@@ -106,7 +143,8 @@ function permanentReason(error) {
106
143
  const record = error;
107
144
  if (typeof record.name === "string" && /AuthError/i.test(record.name))
108
145
  return "auth";
109
- if (typeof record.name === "string" && /PermissionError/i.test(record.name)) {
146
+ if (typeof record.name === "string" &&
147
+ /PermissionError/i.test(record.name)) {
110
148
  return "permission";
111
149
  }
112
150
  return "api-error";
@@ -137,6 +175,12 @@ function looksTransient(error) {
137
175
  /**
138
176
  * Decide whether an OpenCode error should be resumed by the project plugin.
139
177
  * Only compensates transient UnknownError paths that built-in APIError retry misses.
178
+ *
179
+ * TypeValidation bypass: a bare TypeValidationError (provider/SDK surfaces
180
+ * zod invalid_union directly, without the UnknownError wrapper) is treated as
181
+ * a retryable transient-unknown-error ONLY when it also carries a transient
182
+ * signal (502 / LLMRequestError / timeout / etc.). A pure schema mismatch
183
+ * without a transient signal is still rejected (resumable: false).
140
184
  */
141
185
  export function classifyTransientUnknownError(error) {
142
186
  const permanent = permanentReason(error);
@@ -147,7 +191,9 @@ export function classifyTransientUnknownError(error) {
147
191
  interaction: PERMANENT_ERROR_INTERACTION,
148
192
  };
149
193
  }
150
- if (!hasUnknownErrorShape(error)) {
194
+ const isUnknownShape = hasUnknownErrorShape(error);
195
+ const isTypeValidationShape = hasTypeValidationShape(error);
196
+ if (!isUnknownShape && !isTypeValidationShape) {
151
197
  return {
152
198
  resumable: false,
153
199
  reason: "not-unknown-error",
@@ -164,7 +210,9 @@ export function classifyTransientUnknownError(error) {
164
210
  return { resumable: true, reason: "transient-unknown-error" };
165
211
  }
166
212
  export function nextBackoffMs(attempt) {
167
- if (!Number.isInteger(attempt) || attempt < 0 || attempt >= BACKOFF_MS.length) {
213
+ if (!Number.isInteger(attempt) ||
214
+ attempt < 0 ||
215
+ attempt >= BACKOFF_MS.length) {
168
216
  return undefined;
169
217
  }
170
218
  return BACKOFF_MS[attempt];
@@ -173,10 +221,15 @@ export function canResumeSession(input) {
173
221
  if (input.sessionStatus === "retry") {
174
222
  return { allowed: false, reason: "builtin-retry-active" };
175
223
  }
224
+ if (!input.sessionStatus) {
225
+ return { allowed: false, reason: "session-status-unknown" };
226
+ }
176
227
  if (input.locked) {
177
228
  return { allowed: false, reason: "session-locked" };
178
229
  }
179
- if (!input.idle || input.sessionStatus === "busy" || input.sessionStatus === "running") {
230
+ if (!input.idle ||
231
+ input.sessionStatus === "busy" ||
232
+ input.sessionStatus === "running") {
180
233
  return { allowed: false, reason: "session-busy" };
181
234
  }
182
235
  if (input.attempt >= MAX_SESSION_RETRIES) {
@@ -253,7 +306,12 @@ export function planPiRetryMerge(existing) {
253
306
  action: "write",
254
307
  reason: "missing-file",
255
308
  path: settingsPath,
256
- next: { retry: { ...PI_RECOMMENDED_RETRY, provider: { ...PI_RECOMMENDED_RETRY.provider } } },
309
+ next: {
310
+ retry: {
311
+ ...PI_RECOMMENDED_RETRY,
312
+ provider: { ...PI_RECOMMENDED_RETRY.provider },
313
+ },
314
+ },
257
315
  };
258
316
  }
259
317
  if (!isRecord(existing)) {
@@ -276,7 +334,8 @@ export function planPiRetryMerge(existing) {
276
334
  }
277
335
  const retry = isRecord(existing.retry) ? { ...existing.retry } : {};
278
336
  const provider = isRecord(retry.provider) ? { ...retry.provider } : {};
279
- let changed = !isRecord(existing.retry) || !isRecord(existing.retry.provider);
337
+ let changed = !isRecord(existing.retry) ||
338
+ !isRecord(existing.retry.provider);
280
339
  if (retry.enabled === undefined) {
281
340
  retry.enabled = PI_RECOMMENDED_RETRY.enabled;
282
341
  changed = true;
@@ -383,7 +442,8 @@ export async function applyPiRetryMerge(input) {
383
442
  const dir = path.dirname(settingsPath);
384
443
  const entries = await readdir(dir);
385
444
  await Promise.all(entries
386
- .filter((name) => name.startsWith(`.${path.basename(settingsPath)}.`) && name.endsWith(".tmp"))
445
+ .filter((name) => name.startsWith(`.${path.basename(settingsPath)}.`) &&
446
+ name.endsWith(".tmp"))
387
447
  .map((name) => rm(path.join(dir, name), { force: true })));
388
448
  }
389
449
  catch {
@@ -414,11 +474,13 @@ const MAX_RETRIES = ${MAX_SESSION_RETRIES};
414
474
 
415
475
  const sessionState = new Map();
416
476
  const sessionStatus = new Map();
477
+ const pendingErrors = new Map();
478
+ const recoveryWorkers = new Map();
417
479
 
418
480
  function getState(sessionId) {
419
481
  let state = sessionState.get(sessionId);
420
482
  if (!state) {
421
- state = { attempt: 0, locked: false };
483
+ state = { attempt: 0, locked: false, rerunRequested: false };
422
484
  sessionState.set(sessionId, state);
423
485
  }
424
486
  return state;
@@ -449,9 +511,13 @@ function isTransientUnknown(error) {
449
511
  if (isPermanent(error)) return false;
450
512
  const text = textOf(error);
451
513
  const hasUnknown = /UnknownError/i.test(text) || (error && /UnknownError/i.test(String(error.name || "")));
452
- if (!hasUnknown) return false;
514
+ const hasTypeValidation =
515
+ /\\bTypeValidationError\\b/i.test(text) ||
516
+ /Type validation failed/i.test(text) ||
517
+ (error && /TypeValidationError/i.test(String(error.name || "")));
518
+ if (!hasUnknown && !hasTypeValidation) return false;
453
519
  return (
454
- /\\b(code[:\\s]*502|502|LLMRequestError|TypeValidationError|Type validation failed|network fluctuation|timeout|transient provider failure|non-standard)\\b/i.test(
520
+ /\\b(code[:\\s]*502|502|LLMRequestError|network fluctuation|timeout|transient provider failure|non-standard)\\b/i.test(
455
521
  text,
456
522
  ) ||
457
523
  (error && (error.code === 502 || error.status === 502))
@@ -474,68 +540,121 @@ async function wait(ms) {
474
540
  }
475
541
 
476
542
  function statusType(status) {
477
- return status && typeof status.type === "string" ? status.type : undefined;
543
+ const type = status && typeof status.type === "string" ? status.type : undefined;
544
+ return type === "idle" || type === "retry" || type === "busy" ? type : undefined;
478
545
  }
479
546
 
480
547
  async function readSessionStatus(client, sessionId) {
481
548
  const cached = sessionStatus.get(sessionId);
482
- if (cached === "retry") return cached;
549
+ if (cached === "retry" || cached === "busy") {
550
+ return { ok: true, type: cached };
551
+ }
483
552
  try {
484
- const response = await client.session.status();
553
+ const response = await client.session.status({ throwOnError: true });
554
+ if (!response || typeof response !== "object" || response.error != null) {
555
+ return { ok: false };
556
+ }
485
557
  const current = statusType(response?.data?.[sessionId]);
486
- if (current) sessionStatus.set(sessionId, current);
487
- return current || cached || "idle";
558
+ if (!current) return { ok: false };
559
+ sessionStatus.set(sessionId, current);
560
+ return { ok: true, type: current };
488
561
  } catch {
489
- return cached || "idle";
562
+ return { ok: false };
490
563
  }
491
564
  }
492
565
 
566
+ function promptWasAccepted(response) {
567
+ return Boolean(
568
+ response &&
569
+ typeof response === "object" &&
570
+ response.error == null &&
571
+ Object.prototype.hasOwnProperty.call(response, "data") &&
572
+ response.data !== null &&
573
+ typeof response.data === "object",
574
+ );
575
+ }
576
+
493
577
  export default async function loopAgentTransientRetryPlugin({ client, $ }) {
494
578
  void $;
495
579
  void PLUGIN_PATH;
496
580
 
497
- async function maybeResume(sessionId, error) {
498
- if (!sessionId) return { interaction: PERMANENT_INTERACTION, reason: "missing-session" };
499
- if (!isTransientUnknown(error)) {
500
- return { interaction: PERMANENT_INTERACTION, reason: "plugin-ignore-permanent-error" };
501
- }
502
-
581
+ async function drainPendingRecovery(sessionId) {
503
582
  const state = getState(sessionId);
504
- if (state.locked) {
505
- return { interaction: PERMANENT_INTERACTION, reason: "session-locked" };
506
- }
583
+ const pending = pendingErrors.get(sessionId);
584
+ if (!pending) return;
507
585
  if (state.attempt >= MAX_RETRIES) {
508
- return { interaction: PERMANENT_INTERACTION, reason: "max-retries" };
586
+ pendingErrors.delete(sessionId);
587
+ return;
509
588
  }
510
589
 
511
- state.locked = true;
512
- try {
513
- let status = await readSessionStatus(client, sessionId);
514
- // Never race OpenCode built-in retry.
515
- if (status === "retry") {
516
- return { interaction: PERMANENT_INTERACTION, reason: "builtin-retry-active" };
517
- }
518
- if (status === "busy" || status === "running") {
519
- return { interaction: PERMANENT_INTERACTION, reason: "session-busy" };
520
- }
590
+ // A status event may be stale by the time this worker runs. Confirm idle via
591
+ // the SDK before and after backoff; every other outcome keeps the error pending.
592
+ const beforeDelay = await readSessionStatus(client, sessionId);
593
+ if (pendingErrors.get(sessionId) !== pending) return;
594
+ if (!beforeDelay.ok || beforeDelay.type !== "idle") return;
521
595
 
522
- const delay = BACKOFF_MS[state.attempt];
523
- const nextAttempt = state.attempt + 1;
524
- if (typeof delay === "number") await wait(delay);
525
- // Re-check idle after backoff.
526
- status = await readSessionStatus(client, sessionId);
527
- if (status === "retry" || status === "busy" || status === "running") {
528
- return { interaction: PERMANENT_INTERACTION, reason: "session-not-idle" };
529
- }
530
- const prompt = buildResumePrompt(sessionId, error, nextAttempt);
531
- state.attempt = nextAttempt;
532
- await client.session.promptAsync({
596
+ const delay = BACKOFF_MS[state.attempt];
597
+ if (typeof delay !== "number") {
598
+ pendingErrors.delete(sessionId);
599
+ return;
600
+ }
601
+ await wait(delay);
602
+ if (pendingErrors.get(sessionId) !== pending) return;
603
+
604
+ const beforePrompt = await readSessionStatus(client, sessionId);
605
+ if (pendingErrors.get(sessionId) !== pending) return;
606
+ if (!beforePrompt.ok || beforePrompt.type !== "idle") return;
607
+
608
+ const nextAttempt = state.attempt + 1;
609
+ const prompt = buildResumePrompt(sessionId, pending.error, nextAttempt);
610
+ let response;
611
+ try {
612
+ response = await client.session.promptAsync({
533
613
  path: { id: sessionId },
534
614
  body: { parts: [{ type: "text", text: prompt }] },
615
+ throwOnError: true,
535
616
  });
536
- return { resumed: true, attempt: state.attempt };
537
- } finally {
538
- state.locked = false;
617
+ } catch {
618
+ return;
619
+ }
620
+ if (!promptWasAccepted(response)) return;
621
+
622
+ state.attempt = nextAttempt;
623
+ if (pendingErrors.get(sessionId) === pending) {
624
+ pendingErrors.delete(sessionId);
625
+ }
626
+ }
627
+
628
+ function startRecoveryWorker(sessionId) {
629
+ const state = getState(sessionId);
630
+ if (recoveryWorkers.has(sessionId)) {
631
+ state.rerunRequested = true;
632
+ return;
633
+ }
634
+ state.locked = true;
635
+ state.rerunRequested = false;
636
+ const worker = drainPendingRecovery(sessionId)
637
+ .catch(() => {})
638
+ .finally(() => {
639
+ recoveryWorkers.delete(sessionId);
640
+ const rerun = state.rerunRequested;
641
+ state.rerunRequested = false;
642
+ state.locked = false;
643
+ if (rerun && pendingErrors.has(sessionId)) {
644
+ startRecoveryWorker(sessionId);
645
+ }
646
+ });
647
+ recoveryWorkers.set(sessionId, worker);
648
+ }
649
+
650
+ function clearSession(sessionId) {
651
+ pendingErrors.delete(sessionId);
652
+ sessionStatus.delete(sessionId);
653
+ const state = sessionState.get(sessionId);
654
+ if (state) {
655
+ state.attempt = 0;
656
+ state.rerunRequested = false;
657
+ if (!recoveryWorkers.has(sessionId)) state.locked = false;
539
658
  }
540
659
  }
541
660
 
@@ -544,11 +663,27 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
544
663
  if (!event || typeof event !== "object") return;
545
664
  const properties = event.properties || {};
546
665
  const sessionId =
547
- properties.sessionID || properties.sessionId || properties.id;
666
+ properties.sessionID || properties.sessionId || properties.id || properties.info?.id;
548
667
 
549
668
  if (event.type === "session.status") {
550
669
  const current = statusType(properties.status);
551
670
  if (sessionId && current) sessionStatus.set(sessionId, current);
671
+ if (sessionId && current === "idle" && pendingErrors.has(sessionId)) {
672
+ startRecoveryWorker(sessionId);
673
+ }
674
+ return;
675
+ }
676
+
677
+ if (event.type === "session.idle") {
678
+ if (sessionId) {
679
+ sessionStatus.set(sessionId, "idle");
680
+ if (pendingErrors.has(sessionId)) startRecoveryWorker(sessionId);
681
+ }
682
+ return;
683
+ }
684
+
685
+ if (event.type === "session.deleted") {
686
+ if (sessionId) clearSession(sessionId);
552
687
  return;
553
688
  }
554
689
 
@@ -557,16 +692,26 @@ export default async function loopAgentTransientRetryPlugin({ client, $ }) {
557
692
  if (
558
693
  info?.role === "assistant" &&
559
694
  info.sessionID &&
695
+ (info.status === undefined || info.status === "completed") &&
560
696
  info.time?.completed != null &&
561
697
  info.error == null
562
698
  ) {
563
- getState(info.sessionID).attempt = 0;
699
+ clearSession(info.sessionID);
564
700
  }
565
701
  return;
566
702
  }
567
703
 
568
704
  if (event.type === "session.error") {
569
- return maybeResume(sessionId, properties.error || properties);
705
+ if (!sessionId) return;
706
+ const error = properties.error || properties;
707
+ if (!isTransientUnknown(error)) {
708
+ pendingErrors.delete(sessionId);
709
+ getState(sessionId).rerunRequested = false;
710
+ return { interaction: PERMANENT_INTERACTION, reason: "plugin-ignore-permanent-error" };
711
+ }
712
+ pendingErrors.set(sessionId, { error });
713
+ startRecoveryWorker(sessionId);
714
+ return { pending: true };
570
715
  }
571
716
  },
572
717
  };
@@ -647,7 +792,9 @@ export async function runClientRecovery(input) {
647
792
  },
648
793
  };
649
794
  }
650
- const plugin = await installProjectOpenCodePlugin({ repoRoot: input.repoRoot });
795
+ const plugin = await installProjectOpenCodePlugin({
796
+ repoRoot: input.repoRoot,
797
+ });
651
798
  if (mode !== "user") {
652
799
  return { mode, plugin };
653
800
  }