@tea-agent/loop-agent 0.29.1 → 0.29.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.29.2] - 2026-08-07
6
+
7
+ ### 重点更新
8
+
9
+ - 优化后端测试生成 writer 的续写重试机制,直接内嵌具体目标路径以恢复输出,不再依赖读取受限路径
10
+ - 修复三处完整性门禁(Completeness Gate)误报,避免将正确的测试生成误判为残缺写集
11
+
12
+ ### 改进
13
+
14
+ - 后端测试生成 writer(N2 MD / N5 pytest)在输出截断或残缺写集后,续写重试直接内嵌具体目标路径清单,不再依赖读取 `.harness/**` 路径
15
+ - 即使遇到结果为空或输出失败的异常,也会评估完整性门禁,将可恢复的部分写入升格为不完整写集,避免重试空转
16
+
17
+ ### 修复
18
+
19
+ - 修复 README 正文提及的编号式文件名被误判为缺失模块的问题
20
+ - 修复覆盖率矩阵(Coverage Matrix)表头识别被覆盖率范围(Coverage Scope)数据行遮蔽的问题
21
+ - 修复合法多行 `def` 的 pytest 脚本被误判为截断的问题(真正截断现仍由括号配平计数准确捕获)
22
+
5
23
  ## [0.29.1] - 2026-08-07
6
24
 
7
25
  ### 重点更新
@@ -685,9 +685,7 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
685
685
  });
686
686
  }
687
687
  let completenessFailure;
688
- if (mapped.ok &&
689
- writeGuardOk &&
690
- !writerOutcomeViolation &&
688
+ if (writeGuardOk &&
691
689
  isBackendTestCompletenessRetryCandidate(input.task)) {
692
690
  try {
693
691
  const progress = input.task.id === "generate-backend-pytest-pi"
@@ -701,12 +699,25 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
701
699
  });
702
700
  if (progress.status !== "PASS") {
703
701
  const classified = classifyBackendTestWriterCompletenessFailure(progress);
704
- completenessFailure = {
705
- failureCategory: classified.recoverable
706
- ? INCOMPLETE_WRITE_SET_RETRY_CATEGORY
707
- : "invalid-output",
708
- detail: `backend-test completeness gate ${progress.status}: targets=${progress.targetPaths.join(",") || "(none)"}; issues=${progress.issues.map((issue) => issue.detail).join("; ") || "none"}`,
709
- };
702
+ // A recoverable partial write set (missing/broken files) always wins
703
+ // over empty-output / invalid-output / writer-empty-diff: it carries
704
+ // the concrete target paths the continuation attempt needs.
705
+ if (classified.recoverable) {
706
+ completenessFailure = {
707
+ failureCategory: INCOMPLETE_WRITE_SET_RETRY_CATEGORY,
708
+ detail: `backend-test completeness gate ${progress.status}: targets=${progress.targetPaths.join(",") || "(none)"}; issues=${progress.issues.map((issue) => issue.detail).join("; ") || "none"}`,
709
+ };
710
+ }
711
+ else if (mapped.ok && !writerOutcomeViolation) {
712
+ // Non-recoverable completeness issue only overrides a clean
713
+ // successful path; on an already-failed attempt, keep the
714
+ // executor's original category so retries reflect the real
715
+ // cause (empty-output / invalid-output / writer-empty-diff).
716
+ completenessFailure = {
717
+ failureCategory: "invalid-output",
718
+ detail: `backend-test completeness gate ${progress.status}: targets=${progress.targetPaths.join(",") || "(none)"}; issues=${progress.issues.map((issue) => issue.detail).join("; ") || "none"}`,
719
+ };
720
+ }
710
721
  }
711
722
  }
712
723
  catch (error) {
@@ -746,7 +757,21 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
746
757
  ? WRITER_EMPTY_DIFF_RETRY_CATEGORY
747
758
  : "invalid-output"
748
759
  : "write-guard"
749
- : mapped.failureCategory,
760
+ : // When the attempt already failed with a writer-style category
761
+ // (empty-output / invalid-output / writer-empty-diff) but the workspace
762
+ // shows a recoverable partial write set, prefer incomplete-write-set so
763
+ // the continuation is retryable and carries the embedded target paths.
764
+ // Provider/transport failures (quota, auth, network, timeout,
765
+ // rate-limit, unavailable) keep their original category so a provider
766
+ // outage is never masked as a recoverable local write problem.
767
+ writeGuardOk &&
768
+ completenessFailure?.failureCategory ===
769
+ INCOMPLETE_WRITE_SET_RETRY_CATEGORY &&
770
+ (mapped.failureCategory === "empty-output" ||
771
+ mapped.failureCategory === "invalid-output" ||
772
+ mapped.failureCategory === WRITER_EMPTY_DIFF_RETRY_CATEGORY)
773
+ ? INCOMPLETE_WRITE_SET_RETRY_CATEGORY
774
+ : mapped.failureCategory,
750
775
  durationMs: mapped.durationMs || Date.now() - started,
751
776
  };
752
777
  }
@@ -2,6 +2,17 @@ import { createHash } from "node:crypto";
2
2
  import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
+ /**
6
+ * Maps a backend-test generation writer task id to its writer-progress role.
7
+ * Returns undefined for nodes that are not completeness-gated generators.
8
+ */
9
+ export function backendTestWriterProgressRoleForTask(taskId) {
10
+ if (taskId === "generate-backend-md-cases-pi")
11
+ return "md-generate";
12
+ if (taskId === "generate-backend-pytest-pi")
13
+ return "pytest-generate";
14
+ return undefined;
15
+ }
5
16
  import { expectedBackendTestPytestScriptForMarkdownModule, normalizeBackendTestModuleStem, } from "./backend-test-markdown-workflow.js";
6
17
  export const BACKEND_TEST_WRITER_PROGRESS_SCHEMA_ID = "backend-test-writer-progress-v1";
7
18
  export const BACKEND_TEST_OUTPUT_LIMIT_RECOVERY_REPORT = "backend-test-output-limit-recovery.md";
@@ -42,7 +53,21 @@ function hasMarkdownTable(section, headerNeedle) {
42
53
  .replaceAll("\r\n", "\n")
43
54
  .replaceAll("\r", "\n")
44
55
  .split("\n");
45
- const headerIndex = lines.findIndex((line) => line.toLowerCase().includes(headerNeedle.toLowerCase()));
56
+ // A markdown table header row must itself be a pipe-delimited row (start
57
+ // and end with `|`) AND be immediately followed by a separator row
58
+ // (`|---|`). Find the first row that satisfies both and contains the
59
+ // needle, so a Coverage Scope data cell like `| Affected Rule Keys |`
60
+ // (which mentions the needle but is not a header) does not shadow the
61
+ // real Coverage Matrix header further down.
62
+ const headerIndex = lines.findIndex((line, index) => {
63
+ const trimmed = line.trim();
64
+ const next = (lines[index + 1] ?? "").trim();
65
+ return (trimmed.startsWith("|") &&
66
+ trimmed.endsWith("|") &&
67
+ /^\|[-: |]+$/.test(next) &&
68
+ /-{3,}/.test(next) &&
69
+ line.toLowerCase().includes(headerNeedle.toLowerCase()));
70
+ });
46
71
  if (headerIndex < 0)
47
72
  return false;
48
73
  const header = lines[headerIndex] ?? "";
@@ -51,19 +76,50 @@ function hasMarkdownTable(section, headerNeedle) {
51
76
  /-{3,}/.test(separator) &&
52
77
  separator.includes("|"));
53
78
  }
79
+ /**
80
+ * A README module stem must be a stable lowercase business stem such as
81
+ * `health` or `resource_notes`. Reject tokens that are purely numeric, look
82
+ * like Case IDs, or were clearly lifted from prose examples (e.g. `1`,
83
+ * `9`, `BE-RN-001`). This prevents the completeness gate from inventing
84
+ * phantom missing modules when the model discusses forbidden filenames
85
+ * ("do not create 1.md / 9.md") inside the README body.
86
+ */
87
+ function looksLikeValidModuleStem(raw) {
88
+ if (!raw)
89
+ return false;
90
+ const stem = normalizeBackendTestModuleStem(raw);
91
+ if (stem.toLowerCase() === "readme")
92
+ return false;
93
+ if (!/^[a-z][a-z0-9_]*$/.test(stem))
94
+ return false;
95
+ if (/^(?:be|tp|ac|req|br)[_-]/i.test(stem))
96
+ return false;
97
+ return true;
98
+ }
54
99
  function extractModuleStemsFromReadme(readme) {
55
100
  const stems = [];
56
- for (const match of readme.matchAll(/`?testcase\/md\/([A-Za-z0-9_.-]+)\.md`?/g)) {
57
- const stem = match[1];
58
- if (stem && stem.toLowerCase() !== "readme")
59
- stems.push(stem);
60
- }
61
- for (const match of readme.matchAll(/\|\s*`?([A-Za-z0-9_.-]+)`?\s*\|\s*`?testcase\/test_/g)) {
62
- if (match[1])
63
- stems.push(match[1]);
101
+ // Only trust testcase/md/<stem>.md mentions that appear inside markdown
102
+ // table rows (`| ... testcase/md/x.md ... |`) or as canonical relative
103
+ // links (`[label](./x.md)`). Free-form prose mentions such as a recovery
104
+ // note listing `testcase/md/1.md` must NOT be treated as authoritative
105
+ // module references, otherwise the gate invents phantom missing modules.
106
+ const tableRowLines = readme
107
+ .replaceAll("\r\n", "\n")
108
+ .replaceAll("\r", "\n")
109
+ .split("\n")
110
+ .filter((line) => line.includes("|"));
111
+ for (const line of tableRowLines) {
112
+ for (const match of line.matchAll(/`?testcase\/md\/([A-Za-z0-9_.-]+)\.md`?/g)) {
113
+ if (looksLikeValidModuleStem(match[1]))
114
+ stems.push(match[1]);
115
+ }
116
+ for (const match of line.matchAll(/\|\s*`?([A-Za-z0-9_.-]+)`?\s*\|\s*`?testcase\/test_/g)) {
117
+ if (looksLikeValidModuleStem(match[1]))
118
+ stems.push(match[1]);
119
+ }
64
120
  }
65
121
  for (const match of readme.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
66
- if (match[1] && match[1].toLowerCase() !== "readme")
122
+ if (looksLikeValidModuleStem(match[1]))
67
123
  stems.push(match[1]);
68
124
  }
69
125
  return orderedUnique(stems.map((stem) => normalizeBackendTestModuleStem(stem)));
@@ -118,13 +174,21 @@ function pythonParseable(source) {
118
174
  if (triples.length % 2 !== 0)
119
175
  return false;
120
176
  }
121
- if (/\bdef\s+\w+\s*\([^)]*$/m.test(text))
122
- return false;
123
- if (/\bpytest\.param\s*\([^)]*$/m.test(text))
124
- return false;
177
+ // The balanced-bracket counts above already catch a genuinely truncated
178
+ // file (an unclosed def/call leaves unbalanced parens). The previous
179
+ // per-line "def ... ( ... $" heuristic was a false-positive source for
180
+ // legal multi-line definitions such as `def f(\n x,\n):` — removed.
125
181
  return true;
126
182
  }
127
183
  export function buildOutputLimitRecoveryPrompt(input) {
184
+ return buildOutputLimitRecoverySection(input);
185
+ }
186
+ /**
187
+ * Build an inline `<retry_instruction>` block embedding the exact target
188
+ * paths. Used by {@link buildAttemptPrompt} so a continuation attempt does
189
+ * not depend on reading `.harness/**` (which is forbidden for Pi writers).
190
+ */
191
+ export function buildOutputLimitRecoverySection(input) {
128
192
  const paths = input.targetPaths.length > 0
129
193
  ? input.targetPaths.map((item) => ` - ${item}`).join("\n")
130
194
  : " - (none)";
@@ -144,6 +208,26 @@ export function buildOutputLimitRecoveryPrompt(input) {
144
208
  "</retry_instruction>",
145
209
  ].join("\n");
146
210
  }
211
+ /**
212
+ * Load the most recent writer-progress facts from the run dir for a given
213
+ * generation writer task id, so the next attempt's prompt can embed concrete
214
+ * target paths instead of asking the model to read a forbidden `.harness/**`
215
+ * path. Returns undefined when no progress facts exist yet (first retry of
216
+ * a session, or a non-completeness failure).
217
+ */
218
+ export async function loadBackendTestWriterProgressForRetry(runDir, taskId) {
219
+ const role = backendTestWriterProgressRoleForTask(taskId);
220
+ if (!role)
221
+ return undefined;
222
+ const factsPath = path.join(runDir, "contracts", `backend-test-writer-progress-${role}.json`);
223
+ try {
224
+ const raw = await readFile(factsPath, "utf8");
225
+ return progressSchema.parse(JSON.parse(raw));
226
+ }
227
+ catch {
228
+ return undefined;
229
+ }
230
+ }
147
231
  export async function assessBackendTestMdWriterCompleteness(workspaceRoot) {
148
232
  const issues = [];
149
233
  const expectedPaths = ["testcase/md/README.md"];
@@ -8,6 +8,7 @@ import { writeNodeRecord, writeNodeSkillArtifacts } from "./run-store.js";
8
8
  import { resolveContextPolicy } from "./context-policy.js";
9
9
  import { buildDagNodePromptEnvelope, formatConvergenceFeedbackBlock, } from "./prompt.js";
10
10
  import { persistLongNodeOutputArtifacts } from "./upstream-artifacts.js";
11
+ import { buildOutputLimitRecoverySection, loadBackendTestWriterProgressForRetry, } from "./backend-test-writer-completeness.js";
11
12
  import { computeBackoffDelayMs, isRetryablePiFailureCategory, isSafeReadOnlyPiRetryCandidate, isWriterEmptyDiffRetryCandidate, } from "./retry-policy.js";
12
13
  import { applyNodeActivity, evaluateNodeLiveness, resolveLivenessPolicy, } from "./liveness-policy.js";
13
14
  import { buildProtocolRetryInstruction, normalizeReviewVerdictAfterRetries, parseJsonReviewVerdict, validateOutputProtocol, } from "./output-protocol.js";
@@ -125,7 +126,7 @@ export function buildNodePrompt(spec, task, upstream, options) {
125
126
  convergenceFeedback: options?.convergenceFeedback,
126
127
  });
127
128
  }
128
- function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory, previousProtocolReason) {
129
+ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths) {
129
130
  if (attemptNumber <= 1)
130
131
  return basePrompt;
131
132
  if (previousFailureCategory === "protocol-invalid" &&
@@ -138,6 +139,22 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
138
139
  ].join("\n");
139
140
  }
140
141
  if (previousFailureCategory === "writer-empty-diff") {
142
+ const maxAttempts = task.retryPolicy?.maxAttempts ?? 3;
143
+ // When a completeness progress exists for this writer, fold the concrete
144
+ // target paths into the empty-diff retry so the model does not guess and
145
+ // does not need to read a forbidden `.harness/**` evidence file.
146
+ if (recoveryTargetPaths && recoveryTargetPaths.length > 0) {
147
+ return [
148
+ basePrompt,
149
+ "",
150
+ buildOutputLimitRecoverySection({
151
+ attempt: attemptNumber,
152
+ maxAttempts,
153
+ reason: "T4",
154
+ targetPaths: recoveryTargetPaths,
155
+ }),
156
+ ].join("\n");
157
+ }
141
158
  return [
142
159
  basePrompt,
143
160
  "",
@@ -150,6 +167,22 @@ function buildAttemptPrompt(task, basePrompt, attemptNumber, previousFailureCate
150
167
  }
151
168
  if (previousFailureCategory === "incomplete-write-set") {
152
169
  const maxAttempts = task.retryPolicy?.maxAttempts ?? 3;
170
+ // Embed concrete missing/broken paths from the run-owned progress facts
171
+ // so the continuation attempt is fully self-contained and never reads
172
+ // a forbidden `.harness/**` evidence file. When the loader finds no
173
+ // progress facts yet (rare), fall back to the path-pointing contract.
174
+ if (recoveryTargetPaths) {
175
+ return [
176
+ basePrompt,
177
+ "",
178
+ buildOutputLimitRecoverySection({
179
+ attempt: attemptNumber,
180
+ maxAttempts,
181
+ reason: "T3_or_T5",
182
+ targetPaths: recoveryTargetPaths,
183
+ }),
184
+ ].join("\n");
185
+ }
153
186
  return [
154
187
  basePrompt,
155
188
  "",
@@ -597,12 +630,18 @@ export async function executeDagNode(input) {
597
630
  tasksById,
598
631
  state,
599
632
  });
633
+ // For backend-test generation writers, load the most recent
634
+ // completeness progress from the run dir so the next attempt's
635
+ // prompt embeds concrete target paths. Undefined for non-writers
636
+ // or when no progress facts exist yet (no-op).
637
+ const recoveryProgress = await loadBackendTestWriterProgressForRetry(runDir, task.id);
638
+ const recoveryTargetPaths = recoveryProgress?.targetPaths;
600
639
  result = await executeNode({
601
640
  task,
602
641
  cwd,
603
642
  model,
604
643
  ...(thinking ? { thinking } : {}),
605
- prompt: buildAttemptPrompt(task, prompt, attemptNumber, previousFailureCategory, previousProtocolReason),
644
+ prompt: buildAttemptPrompt(task, prompt, attemptNumber, previousFailureCategory, previousProtocolReason, recoveryTargetPaths),
606
645
  attempt: attemptNumber,
607
646
  reportActivity,
608
647
  timeoutMs: livenessPolicy.absoluteMaxWallClockMs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tea-agent/loop-agent",
3
- "version": "0.29.1",
3
+ "version": "0.29.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "loop-agent": "bin/loop-agent.js",