@tea-agent/loop-agent 0.33.4 → 0.33.6-beta.0

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 (27) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/executors/dag-pi-executor.js +34 -12
  3. package/dist/executors/shell-executor.js +26 -66
  4. package/dist/task/config-types.js +2 -2
  5. package/dist/worker/console/chat/chat-event-store.js +57 -18
  6. package/dist/worker/console/chat/routes.js +850 -170
  7. package/dist/worker/console/static/assets/{index-PzYzcuFG.js → index-CteJFFL2.js} +17 -17
  8. package/dist/worker/console/static/index.html +1 -1
  9. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +33 -8
  10. package/dist/worker/console/static-src/operator-chat/refs.js +3 -0
  11. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +3 -0
  12. package/dist/worker/console/static-src/operator-chat/useChatStream.js +80 -2
  13. package/dist/workflows/dag/backend-test-case-coverage-analysis.js +27 -2
  14. package/dist/workflows/dag/backend-test-markdown-workflow.js +17 -0
  15. package/dist/workflows/dag/backend-test-module-stem.js +26 -0
  16. package/dist/workflows/dag/backend-test-pytest-collection.js +44 -0
  17. package/dist/workflows/dag/backend-test-writer-completeness.js +165 -49
  18. package/dist/workflows/dag/dynamic-runtime/map.js +24 -8
  19. package/dist/workflows/dag/init-hybrid.js +44 -167
  20. package/dist/workflows/dag/types.js +7 -0
  21. package/docs/templates/backend-test-dag.json +10 -10
  22. package/docs/templates/frontend-test-dag.generate-cases.prompt.md +2 -2
  23. package/docs/templates/frontend-test-dag.json +9 -9
  24. package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -7
  25. package/harness.json +1 -1
  26. package/package.json +1 -1
  27. package/skills/playwright-cli/SKILL.md +2 -3
@@ -2,6 +2,7 @@ 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
+ import { BACKEND_TEST_MAX_MODULE_COUNT, isOpaqueHashBackendTestModuleStem, isPriorityOnlyBackendTestModuleStem, } from "./backend-test-module-stem.js";
5
6
  /**
6
7
  * Maps a backend-test generation writer task id to its writer-progress role.
7
8
  * Returns undefined for nodes that are not completeness-gated generators.
@@ -96,51 +97,89 @@ function hasMarkdownTable(section, headerNeedle) {
96
97
  * phantom missing modules when the model discusses forbidden filenames
97
98
  * ("do not create 1.md / 9.md") inside the README body.
98
99
  */
99
- function looksLikeValidModuleStem(raw) {
100
+ function moduleStemInvalidReason(raw) {
100
101
  if (!raw)
101
- return false;
102
+ return "invalid-syntax";
102
103
  const stem = normalizeBackendTestModuleStem(raw);
104
+ if (isPriorityOnlyBackendTestModuleStem(stem))
105
+ return "priority-only-module-stem";
106
+ if (isOpaqueHashBackendTestModuleStem(stem))
107
+ return "opaque-hash-module-stem";
103
108
  if (stem.toLowerCase() === "readme")
104
- return false;
109
+ return "reserved-module-stem";
105
110
  if (!/^[a-z][a-z0-9_]*$/.test(stem))
106
- return false;
111
+ return "invalid-syntax";
107
112
  if (/^(?:be|tp|ac|req|br)[_-]/i.test(stem))
108
- return false;
109
- return true;
113
+ return "case-like-module-stem";
114
+ return undefined;
110
115
  }
111
- /**
112
- * Exported for reuse by the manifest-shell node that materializes the
113
- * module list JSON consumed by the map_agent expansion. Kept deterministic
114
- * (same extractor as the completeness gate) so the shard set always matches
115
- * the README index the gate trusts.
116
- */
117
- export function extractModuleStemsFromReadme(readme) {
118
- const stems = [];
119
- // Only trust testcase/md/<stem>.md mentions that appear inside markdown
120
- // table rows (`| ... testcase/md/x.md ... |`) or as canonical relative
121
- // links (`[label](./x.md)`). Free-form prose mentions such as a recovery
122
- // note listing `testcase/md/1.md` must NOT be treated as authoritative
123
- // module references, otherwise the gate invents phantom missing modules.
124
- const tableRowLines = readme
116
+ function looksLikeValidModuleStem(raw) {
117
+ return moduleStemInvalidReason(raw) === undefined;
118
+ }
119
+ function extractCanonicalModuleIndexSection(readme) {
120
+ const lines = readme
125
121
  .replaceAll("\r\n", "\n")
126
122
  .replaceAll("\r", "\n")
127
- .split("\n")
128
- .filter((line) => line.includes("|"));
123
+ .split("\n");
124
+ const headings = lines.flatMap((line, index) => line.trim() === "## Module Index" ? [index] : []);
125
+ if (headings.length === 0) {
126
+ return { section: "", structureIssues: ["missing-module-index"] };
127
+ }
128
+ if (headings.length > 1) {
129
+ return { section: "", structureIssues: ["duplicate-module-index"] };
130
+ }
131
+ const start = headings[0] + 1;
132
+ const relativeEnd = lines.slice(start).findIndex((line) => /^##\s+\S/.test(line.trim()));
133
+ const end = relativeEnd < 0 ? lines.length : start + relativeEnd;
134
+ return { section: lines.slice(start, end).join("\n"), structureIssues: [] };
135
+ }
136
+ /** Inspect only candidates inside the unique exact `## Module Index` section. */
137
+ export function inspectModuleStemsFromReadme(readme) {
138
+ const extracted = extractCanonicalModuleIndexSection(readme);
139
+ const candidates = [];
140
+ const tableRowLines = extracted.section.split("\n").filter((line) => line.includes("|"));
129
141
  for (const line of tableRowLines) {
130
142
  for (const match of line.matchAll(/`?testcase\/md\/([A-Za-z0-9_.-]+)\.md`?/g)) {
131
- if (looksLikeValidModuleStem(match[1]))
132
- stems.push(match[1]);
143
+ candidates.push(match[1]);
133
144
  }
134
145
  for (const match of line.matchAll(/\|\s*`?([A-Za-z0-9_.-]+)`?\s*\|\s*`?testcase\/test_/g)) {
135
- if (looksLikeValidModuleStem(match[1]))
136
- stems.push(match[1]);
146
+ const raw = match[1];
147
+ if (looksLikeValidModuleStem(raw) || moduleStemInvalidReason(raw) !== "invalid-syntax") {
148
+ candidates.push(raw);
149
+ }
137
150
  }
138
151
  }
139
- for (const match of readme.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
140
- if (looksLikeValidModuleStem(match[1]))
141
- stems.push(match[1]);
152
+ for (const match of extracted.section.matchAll(/\[[^\]]+\]\(\.\/([A-Za-z0-9_.-]+)\.md\)/g)) {
153
+ candidates.push(match[1]);
154
+ }
155
+ const uniqueCandidates = orderedUnique(candidates);
156
+ const invalid = uniqueCandidates.flatMap((raw) => {
157
+ const reasonCode = moduleStemInvalidReason(raw);
158
+ return reasonCode
159
+ ? [{ raw, normalized: normalizeBackendTestModuleStem(raw), reasonCode }]
160
+ : [];
161
+ });
162
+ const validStems = orderedUnique(uniqueCandidates
163
+ .filter(looksLikeValidModuleStem)
164
+ .map((stem) => normalizeBackendTestModuleStem(stem)));
165
+ const structureIssues = [...extracted.structureIssues];
166
+ if (validStems.length > BACKEND_TEST_MAX_MODULE_COUNT) {
167
+ structureIssues.push(`excessive-module-count: ${validStems.length} > ${BACKEND_TEST_MAX_MODULE_COUNT}`);
142
168
  }
143
- return orderedUnique(stems.map((stem) => normalizeBackendTestModuleStem(stem)));
169
+ return {
170
+ candidates: uniqueCandidates,
171
+ validStems,
172
+ invalid,
173
+ structureIssues,
174
+ };
175
+ }
176
+ /**
177
+ * Exported for reuse by callers that only need the trusted shard set.
178
+ * Completeness gates must use inspectModuleStemsFromReadme so invalid
179
+ * authoritative candidates cannot be silently dropped.
180
+ */
181
+ export function extractModuleStemsFromReadme(readme) {
182
+ return inspectModuleStemsFromReadme(readme).validStems;
144
183
  }
145
184
  async function listMarkdownModules(workspaceRoot) {
146
185
  const root = path.join(workspaceRoot, "testcase", "md");
@@ -242,12 +281,80 @@ function moduleStructuralDetail(result) {
242
281
  return "module file is structurally complete";
243
282
  return `module file is empty or has structural issues: ${result.reasons.join("; ")}`;
244
283
  }
284
+ function pythonStructureOutsideStrings(source) {
285
+ const input = source.replace(/\r\n/g, "\n");
286
+ let output = "";
287
+ let index = 0;
288
+ let quote;
289
+ let triple = false;
290
+ let comment = false;
291
+ while (index < input.length) {
292
+ const char = input[index];
293
+ if (comment) {
294
+ if (char === "\n") {
295
+ comment = false;
296
+ output += "\n";
297
+ }
298
+ else
299
+ output += " ";
300
+ index += 1;
301
+ continue;
302
+ }
303
+ if (quote) {
304
+ if (char === "\\") {
305
+ output += " ";
306
+ if (index + 1 < input.length)
307
+ output += input[index + 1] === "\n" ? "\n" : " ";
308
+ index += 2;
309
+ continue;
310
+ }
311
+ if (triple && input.slice(index, index + 3) === quote.repeat(3)) {
312
+ output += " ";
313
+ index += 3;
314
+ quote = undefined;
315
+ triple = false;
316
+ continue;
317
+ }
318
+ if (!triple && char === quote) {
319
+ output += " ";
320
+ index += 1;
321
+ quote = undefined;
322
+ continue;
323
+ }
324
+ if (!triple && char === "\n")
325
+ return { text: output, stringsClosed: false };
326
+ output += char === "\n" ? "\n" : " ";
327
+ index += 1;
328
+ continue;
329
+ }
330
+ if (char === "#") {
331
+ comment = true;
332
+ output += " ";
333
+ index += 1;
334
+ continue;
335
+ }
336
+ if (char === "'" || char === '"') {
337
+ quote = char;
338
+ triple = input.slice(index, index + 3) === char.repeat(3);
339
+ output += triple ? " " : " ";
340
+ index += triple ? 3 : 1;
341
+ continue;
342
+ }
343
+ output += char;
344
+ index += 1;
345
+ }
346
+ return { text: output, stringsClosed: quote === undefined };
347
+ }
245
348
  function pythonParseable(source) {
246
- const text = source.replace(/\r\n/g, "\n");
247
- if (!text.trim())
349
+ const normalized = source.replace(/\r\n/g, "\n");
350
+ if (!normalized.trim())
248
351
  return false;
249
- if (/^\s*(?:def|class|import|from)\b/m.test(text) === false)
352
+ if (/^\s*(?:def|class|import|from)\b/m.test(normalized) === false)
250
353
  return false;
354
+ const structural = pythonStructureOutsideStrings(normalized);
355
+ if (!structural.stringsClosed)
356
+ return false;
357
+ const text = structural.text;
251
358
  const openParens = (text.match(/\(/g) ?? []).length;
252
359
  const closeParens = (text.match(/\)/g) ?? []).length;
253
360
  const openBrackets = (text.match(/\[/g) ?? []).length;
@@ -260,15 +367,6 @@ function pythonParseable(source) {
260
367
  return false;
261
368
  if (openBraces !== closeBraces)
262
369
  return false;
263
- if (/("""|''')[\s\S]*$/.test(text)) {
264
- const triples = text.match(/("""|''')/g) ?? [];
265
- if (triples.length % 2 !== 0)
266
- return false;
267
- }
268
- // The balanced-bracket counts above already catch a genuinely truncated
269
- // file (an unclosed def/call leaves unbalanced parens). The previous
270
- // per-line "def ... ( ... $" heuristic was a false-positive source for
271
- // legal multi-line definitions such as `def f(\n x,\n):` — removed.
272
370
  return true;
273
371
  }
274
372
  /**
@@ -318,12 +416,7 @@ function looksLikeValidPythonModule(source) {
318
416
  return false;
319
417
  if (/^\s*(?:def|class|import|from)\b/m.test(text) === false)
320
418
  return false;
321
- if (/("""|''')[\s\S]*$/.test(text)) {
322
- const triples = text.match(/("""|''')/g) ?? [];
323
- if (triples.length % 2 !== 0)
324
- return false;
325
- }
326
- return true;
419
+ return pythonStructureOutsideStrings(text).stringsClosed;
327
420
  }
328
421
  export function buildOutputLimitRecoveryPrompt(input) {
329
422
  return buildOutputLimitRecoverySection(input);
@@ -842,7 +935,30 @@ export async function assessBackendTestMdPlanCompleteness(workspaceRoot) {
842
935
  // The README must advertise a non-empty module index so the downstream
843
936
  // manifest shell has stems to shard; the indexed module files themselves
844
937
  // are produced by map_agent children and are NOT required here.
845
- const indexedStems = extractModuleStemsFromReadme(readme);
938
+ const stemInspection = inspectModuleStemsFromReadme(readme);
939
+ const indexedStems = stemInspection.validStems;
940
+ for (const structureIssue of stemInspection.structureIssues) {
941
+ if (!brokenPaths.includes("testcase/md/README.md")) {
942
+ brokenPaths.push("testcase/md/README.md");
943
+ }
944
+ issues.push({
945
+ code: "T5",
946
+ path: "testcase/md/README.md",
947
+ detail: structureIssue,
948
+ recoverable: true,
949
+ });
950
+ }
951
+ for (const invalid of stemInspection.invalid) {
952
+ if (!brokenPaths.includes("testcase/md/README.md")) {
953
+ brokenPaths.push("testcase/md/README.md");
954
+ }
955
+ issues.push({
956
+ code: "T5",
957
+ path: "testcase/md/README.md",
958
+ detail: `${invalid.reasonCode}: ${invalid.normalized}; use a stable lowercase business resource/domain stem`,
959
+ recoverable: true,
960
+ });
961
+ }
846
962
  if (indexedStems.length === 0) {
847
963
  if (!brokenPaths.includes("testcase/md/README.md")) {
848
964
  brokenPaths.push("testcase/md/README.md");
@@ -367,19 +367,26 @@ export async function executeDynamicMapExpansion(input) {
367
367
  }
368
368
  }
369
369
  const budgetBlockedIds = new Set(blockedChildren.map((entry) => entry.nodeId));
370
+ const failOnTokenBudgetExhaustion = input.expansion.failOnTokenBudgetExhaustion === true;
370
371
  const failedChildren = tolerateChildFailures
371
372
  ? []
372
373
  : childNodeIds.filter((nodeId) => {
373
374
  const record = input.state.nodes[nodeId];
374
375
  if (!record || record.status === "FINISHED")
375
376
  return false;
376
- if (budgetBlockedIds.has(nodeId) || isBudgetBlockedRecord(record))
377
- return false;
377
+ if (budgetBlockedIds.has(nodeId) || isBudgetBlockedRecord(record)) {
378
+ return failOnTokenBudgetExhaustion;
379
+ }
378
380
  return true;
379
381
  });
382
+ const finishedItemCount = childNodeIds.filter((nodeId) => input.state.nodes[nodeId]?.status === "FINISHED").length;
383
+ const blockedItemCount = budgetBlockedIds.size;
380
384
  const aggregate = {
381
385
  workflowNodeId: input.expansion.workflowNodeId,
382
386
  itemCount: items.length,
387
+ expectedItemCount: items.length,
388
+ finishedItemCount,
389
+ blockedItemCount,
383
390
  children: childNodeIds.map((nodeId, index) => ({
384
391
  nodeId,
385
392
  item: items[index],
@@ -399,21 +406,30 @@ export async function executeDynamicMapExpansion(input) {
399
406
  ?.reason,
400
407
  })),
401
408
  tokensUsed: totalTokensUsed,
409
+ maxTotalTokens: tokenBudget?.maxTotalTokens,
402
410
  caseOutcomeNotes: caseOutcomeNotes.length > 0 ? caseOutcomeNotes : undefined,
403
411
  };
412
+ const tokenBudgetFailure = failOnTokenBudgetExhaustion && blockedItemCount > 0;
404
413
  return {
405
- // With tolerateChildFailures: barrier always succeeds; product outcomes live in
406
- // case evidence + result materialization. Without it: real child failures fail-close.
407
- ok: failedChildren.length === 0,
414
+ // With tolerateChildFailures: barrier succeeds and product outcomes live in
415
+ // case evidence. Backend-test opts into token-budget fail-closed because a
416
+ // partial generated module set is never a valid downstream input.
417
+ ok: failedChildren.length === 0 && !tokenBudgetFailure,
408
418
  stdout: JSON.stringify(aggregate),
409
419
  stderr: caseOutcomeNotes.length > 0
410
420
  ? `map children recorded as case outcomes: ${caseOutcomeNotes
411
421
  .map((entry) => `${entry.nodeId}=${entry.status}:${entry.reason}`)
412
422
  .join(", ")}`
423
+ : tokenBudgetFailure
424
+ ? `dynamic map token budget exhausted: expected=${items.length} finished=${finishedItemCount} blocked=${blockedItemCount} tokensUsed=${totalTokensUsed} maxTotalTokens=${tokenBudget?.maxTotalTokens ?? "unbounded"}`
425
+ : failedChildren.length > 0
426
+ ? `dynamic map children failed: ${failedChildren.join(", ")}`
427
+ : "",
428
+ failureCategory: tokenBudgetFailure
429
+ ? "dynamic-expansion-token-budget-exhausted"
413
430
  : failedChildren.length > 0
414
- ? `dynamic map children failed: ${failedChildren.join(", ")}`
415
- : "",
416
- failureCategory: failedChildren.length > 0 ? "dynamic-expansion-child-failed" : "success",
431
+ ? "dynamic-expansion-child-failed"
432
+ : "success",
417
433
  durationMs: Date.now() - started,
418
434
  };
419
435
  }