@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 (
|
|
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
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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]
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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,
|