@tea-agent/loop-agent 0.27.1 → 0.28.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.
- package/CHANGELOG.md +24 -0
- package/dist/application/task-lifecycle/observe.js +5 -0
- package/dist/application/task-lifecycle/plan-transitions.js +7 -2
- package/dist/cli/program.js +1 -1
- package/dist/commands/client-recovery.js +439 -20
- package/dist/commands/init.js +42 -6
- package/dist/executors/dag-pi-executor.js +143 -38
- package/dist/executors/pi-playwright-cli-tool.js +955 -0
- package/dist/executors/pi-sdk-executor.js +56 -0
- package/dist/executors/playwright-cli-launcher.js +63 -0
- package/dist/executors/shell-executor.js +128 -0
- package/dist/shared/playwright-cli-command-policy.js +41 -0
- package/dist/worker/observability/read-model.js +66 -8
- package/dist/worker/observe/static/dag-model.js +85 -13
- package/dist/workflows/dag/dynamic-runtime/loop-until.js +4 -0
- package/dist/workflows/dag/dynamic-runtime/map.js +13 -13
- package/dist/workflows/dag/frontend-test-case-checklist.js +201 -8
- package/dist/workflows/dag/frontend-test-result-contract.js +52 -3
- package/dist/workflows/dag/init-hybrid.js +116 -30
- package/dist/workflows/dag/lifecycle.js +33 -2
- package/dist/workflows/dag/node-execution.js +11 -5
- package/dist/workflows/dag/output-protocol.js +25 -83
- package/dist/workflows/dag/report.js +9 -2
- package/dist/workflows/dag/rerun-run.js +62 -3
- package/dist/workflows/dag/run-store.js +6 -1
- package/dist/workflows/dag/runner.js +15 -3
- package/dist/workflows/dag/types.js +27 -0
- package/dist/workflows/dag/validate.js +121 -1
- package/docs/architecture/runtime-boundaries.md +13 -11
- package/docs/init-surface.manifest.json +6 -2
- package/docs/templates/README.md +9 -1
- package/docs/templates/frontend-test-dag.generate-cases.prompt.md +14 -7
- package/docs/templates/frontend-test-dag.json +55 -15
- package/docs/templates/frontend-test-dag.retrieve-context.prompt.md +7 -9
- package/docs/templates/frontend-test-dag.retrospect.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-cases.prompt.md +1 -1
- package/docs/templates/frontend-test-dag.review-execution.prompt.md +1 -1
- package/harness.json +1 -1
- package/package.json +1 -1
- package/skills/loop-agent/SKILL.md +1 -1
- package/skills/loop-agent/references/command-reference.md +18 -6
- package/skills/playwright-cli/SKILL.md +69 -402
- package/skills/playwright-cli/references/tracing.md +3 -137
- package/skills/playwright-cli/references/video-recording.md +3 -141
- package/skills/playwright-cli-case-generator/SKILL.md +53 -46
package/dist/commands/init.js
CHANGED
|
@@ -6,7 +6,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { isInitRuntimeActive, } from "../shared/runtime-activity.js";
|
|
8
8
|
import { loadHarnessManifest } from "../governance/harness.js";
|
|
9
|
-
import { OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH, applyPiRetryMerge, buildOpenCodeTransientRetryPluginSource, inspectPiRetryConfig, parseClientRecoveryMode, runClientRecovery, } from "./client-recovery.js";
|
|
9
|
+
import { OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH, OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH, PI_CONTEXT_OVERFLOW_EXTENSION_PATH, applyPiRetryMerge, buildOpenCodeContextOverflowCompactPluginSource, buildOpenCodeTransientRetryPluginSource, buildPiContextOverflowExtensionSource, inspectPiRetryConfig, parseClientRecoveryMode, runClientRecovery, } from "./client-recovery.js";
|
|
10
10
|
const MANAGED_BLOCK_START = "<!-- LOOP_AGENT_INIT_START -->";
|
|
11
11
|
const MANAGED_BLOCK_END = "<!-- LOOP_AGENT_INIT_END -->";
|
|
12
12
|
const GITIGNORE_BLOCK_START = "# LOOP_AGENT_INIT_START";
|
|
@@ -1243,6 +1243,8 @@ function inferInitSurfaceMode(relativePath) {
|
|
|
1243
1243
|
}
|
|
1244
1244
|
if (relativePath === "harness.json" ||
|
|
1245
1245
|
relativePath === OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH ||
|
|
1246
|
+
relativePath === OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH ||
|
|
1247
|
+
relativePath === PI_CONTEXT_OVERFLOW_EXTENSION_PATH ||
|
|
1246
1248
|
relativePath.startsWith("scripts/") ||
|
|
1247
1249
|
relativePath.startsWith(".harness/prompts/") ||
|
|
1248
1250
|
relativePath.startsWith("docs/README.md") ||
|
|
@@ -1293,6 +1295,12 @@ async function buildDesiredSurfaceContent(input) {
|
|
|
1293
1295
|
if (manifestPath === OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH) {
|
|
1294
1296
|
return { content: buildOpenCodeTransientRetryPluginSource() };
|
|
1295
1297
|
}
|
|
1298
|
+
if (manifestPath === OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH) {
|
|
1299
|
+
return { content: buildOpenCodeContextOverflowCompactPluginSource() };
|
|
1300
|
+
}
|
|
1301
|
+
if (manifestPath === PI_CONTEXT_OVERFLOW_EXTENSION_PATH) {
|
|
1302
|
+
return { content: buildPiContextOverflowExtensionSource() };
|
|
1303
|
+
}
|
|
1296
1304
|
if (manifestPath.startsWith("scripts/")) {
|
|
1297
1305
|
const scripts = buildInitScriptFiles(input.governanceRoot);
|
|
1298
1306
|
return { content: scripts[manifestPath] };
|
|
@@ -2465,11 +2473,25 @@ export async function initializeLoopAgentProject(options) {
|
|
|
2465
2473
|
repoRoot,
|
|
2466
2474
|
mode: clientRecoveryMode,
|
|
2467
2475
|
});
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2476
|
+
const clientRecoveryPaths = [
|
|
2477
|
+
OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH,
|
|
2478
|
+
OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH,
|
|
2479
|
+
PI_CONTEXT_OVERFLOW_EXTENSION_PATH,
|
|
2480
|
+
];
|
|
2481
|
+
if (clientRecoveryMode === "off") {
|
|
2482
|
+
for (const recoveryPath of clientRecoveryPaths)
|
|
2483
|
+
skipped.push(recoveryPath);
|
|
2484
|
+
}
|
|
2485
|
+
else if (clientRecovery) {
|
|
2486
|
+
const installResults = [
|
|
2487
|
+
clientRecovery.plugin,
|
|
2488
|
+
clientRecovery.overflowPlugin,
|
|
2489
|
+
clientRecovery.piExtension,
|
|
2490
|
+
];
|
|
2491
|
+
for (const [index, recoveryPath] of clientRecoveryPaths.entries()) {
|
|
2492
|
+
if (installResults[index]?.written)
|
|
2493
|
+
written.push(recoveryPath);
|
|
2494
|
+
}
|
|
2473
2495
|
}
|
|
2474
2496
|
await writeInitSurfaceState({
|
|
2475
2497
|
repoRoot,
|
|
@@ -2562,6 +2584,14 @@ function recommendedNextFor(report) {
|
|
|
2562
2584
|
next.push("No init update actions are needed.");
|
|
2563
2585
|
return next;
|
|
2564
2586
|
}
|
|
2587
|
+
const CLIENT_RECOVERY_SURFACE_PATHS = new Set([
|
|
2588
|
+
OPENCODE_TRANSIENT_RETRY_PLUGIN_PATH,
|
|
2589
|
+
OPENCODE_CONTEXT_OVERFLOW_COMPACT_PLUGIN_PATH,
|
|
2590
|
+
PI_CONTEXT_OVERFLOW_EXTENSION_PATH,
|
|
2591
|
+
]);
|
|
2592
|
+
function isClientRecoverySurfacePath(pathName) {
|
|
2593
|
+
return CLIENT_RECOVERY_SURFACE_PATHS.has(pathName);
|
|
2594
|
+
}
|
|
2565
2595
|
export async function checkInitUpdate(input) {
|
|
2566
2596
|
const repoRoot = path.resolve(input.repoRoot);
|
|
2567
2597
|
const { projectName, governanceRoot } = await resolveInitProjectContext({
|
|
@@ -2569,6 +2599,8 @@ export async function checkInitUpdate(input) {
|
|
|
2569
2599
|
projectName: input.projectName,
|
|
2570
2600
|
governanceRoot: input.governanceRoot,
|
|
2571
2601
|
});
|
|
2602
|
+
const clientRecoveryMode = input.clientRecovery ?? "auto";
|
|
2603
|
+
const skipClientRecoverySurfaces = clientRecoveryMode === "off";
|
|
2572
2604
|
const recordedState = await readExistingSurfaceState(repoRoot);
|
|
2573
2605
|
const assetRoot = await findPackageRoot();
|
|
2574
2606
|
const surfaceState = recordedState?.stateKind ?? "missing";
|
|
@@ -2608,6 +2640,10 @@ export async function checkInitUpdate(input) {
|
|
|
2608
2640
|
});
|
|
2609
2641
|
}
|
|
2610
2642
|
for (const [pathName, state] of Object.entries(currentState.files)) {
|
|
2643
|
+
// Mirror fresh init: client-recovery=off never reinstalls recovery surfaces.
|
|
2644
|
+
if (skipClientRecoverySurfaces && isClientRecoverySurfacePath(pathName)) {
|
|
2645
|
+
continue;
|
|
2646
|
+
}
|
|
2611
2647
|
if (state.relationship === "missing-from-target") {
|
|
2612
2648
|
if (retiredTargetPaths.has(pathName))
|
|
2613
2649
|
continue;
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { writeDagNodeJsonArtifact, writeTextArtifactFile, } from "../infrastructure/harness/artifact-store.js";
|
|
4
5
|
import { executePiStep, } from "./pi-executor.js";
|
|
5
6
|
import { buildPiWriterToolPolicyContext, createPiWriterCustomTools, } from "./pi-writer-tool-policy.js";
|
|
7
|
+
import { cleanupPlaywrightCliDefaultSession, createPlaywrightCliTool, PI_COMMAND_CAPABILITY_REGISTRY, resolveCaseIdFromWriteSet, resolveEvidenceDirFromWriteSet, } from "./pi-playwright-cli-tool.js";
|
|
8
|
+
import { dagCommandPolicyAllows, resolveDagCommandPolicy, } from "../workflows/dag/types.js";
|
|
6
9
|
import { redactPromptForLog, truncateOutput, } from "../shared/output-truncation.js";
|
|
7
10
|
import { GitStatusUnavailableError, pathsChangedDuringRun, readGitStatusPorcelain, recoverRootNulArtifact, snapshotGitStatusPathFingerprints, snapshotGitStatusPorcelain, validateShellWriteGuard, } from "./shell-write-guard.js";
|
|
8
11
|
import { redactSecrets, truncateUtf8Preview } from "../shared/preview.js";
|
|
@@ -122,12 +125,16 @@ export function resolveDagPiStepName(task) {
|
|
|
122
125
|
}
|
|
123
126
|
return resolvePiExecutorStep(resolveDagPiPersona(task));
|
|
124
127
|
}
|
|
125
|
-
/** Pi DAG nodes are read-only unless they explicitly opt into the bounded write tool profile. */
|
|
126
128
|
export function resolveDagPiToolNames(task) {
|
|
127
|
-
if (isDagPiWriteTask(task))
|
|
128
|
-
return [...
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
if (!isDagPiWriteTask(task))
|
|
130
|
+
return [...DAG_PI_READONLY_TOOLS];
|
|
131
|
+
const tools = [...DAG_PI_WRITE_TOOLS];
|
|
132
|
+
// Pi SDK activates custom tools only when they are in this explicit list.
|
|
133
|
+
// Do not add a command capability to ordinary writers or read-only nodes.
|
|
134
|
+
if (dagCommandPolicyAllows(task.commandPolicy, "playwright-cli")) {
|
|
135
|
+
tools.push("playwright_cli");
|
|
136
|
+
}
|
|
137
|
+
return tools;
|
|
131
138
|
}
|
|
132
139
|
export function buildDagPiUserMessage(task, persona, step) {
|
|
133
140
|
const role = task.role ?? "unspecified";
|
|
@@ -258,6 +265,36 @@ export async function writePiExecutorArtifacts(artifactsDir, input) {
|
|
|
258
265
|
await writeTextArtifactFile(summaryPath, buildPiResultSummaryMarkdown(input));
|
|
259
266
|
return { promptPath, summaryPath };
|
|
260
267
|
}
|
|
268
|
+
async function resolveControllerOwnedFrontendBaseUrl(runDir) {
|
|
269
|
+
const capabilityPath = path.join(runDir, "preflight-frontend-browser-tool-shell", "frontend-browser-capability.json");
|
|
270
|
+
let capability;
|
|
271
|
+
try {
|
|
272
|
+
capability = JSON.parse(await readFile(capabilityPath, "utf8"));
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
throw new Error("browser-command-capability-unavailable: missing controller-owned frontend browser capability artifact");
|
|
276
|
+
}
|
|
277
|
+
if (capability.status !== "ready" ||
|
|
278
|
+
typeof capability.baseUrl !== "string") {
|
|
279
|
+
throw new Error("browser-command-capability-unavailable: invalid controller-owned frontend browser capability artifact");
|
|
280
|
+
}
|
|
281
|
+
let parsed;
|
|
282
|
+
try {
|
|
283
|
+
parsed = new URL(capability.baseUrl);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
throw new Error("browser-command-capability-unavailable: invalid controller-owned frontend browser origin");
|
|
287
|
+
}
|
|
288
|
+
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
|
|
289
|
+
parsed.username ||
|
|
290
|
+
parsed.password ||
|
|
291
|
+
parsed.search ||
|
|
292
|
+
parsed.hash ||
|
|
293
|
+
/(?:^|\.)(?:www\.)?[^.]*(?:prod|production)/i.test(parsed.hostname)) {
|
|
294
|
+
throw new Error("browser-command-capability-unavailable: unsafe controller-owned frontend browser origin");
|
|
295
|
+
}
|
|
296
|
+
return parsed.toString();
|
|
297
|
+
}
|
|
261
298
|
const DEFAULT_DAG_PI_WRITE_GUARD_DEPENDENCIES = {
|
|
262
299
|
readGitStatusPorcelain,
|
|
263
300
|
recoverRootNulArtifact,
|
|
@@ -315,52 +352,120 @@ export async function executeDagPiNode(input, meta, piStepFn = executePiStep, wr
|
|
|
315
352
|
}
|
|
316
353
|
: undefined;
|
|
317
354
|
let writerToolPolicy;
|
|
318
|
-
|
|
355
|
+
const commandPolicy = resolveDagCommandPolicy(input.task.commandPolicy);
|
|
356
|
+
const allowsPlaywrightCli = dagCommandPolicyAllows(input.task.commandPolicy, "playwright-cli");
|
|
357
|
+
let playwrightToolContext;
|
|
358
|
+
if (allowsPlaywrightCli && !isWriteTask) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false, stdout: "", stderr: "pi command capability requires an explicit bounded write tool profile", failureCategory: "tool-policy", durationMs: Date.now() - started,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
if (isWriteTask || allowsPlaywrightCli) {
|
|
319
364
|
try {
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
365
|
+
const customTools = [];
|
|
366
|
+
if (isWriteTask) {
|
|
367
|
+
const policyContext = buildPiWriterToolPolicyContext({
|
|
368
|
+
repoRoot: input.cwd,
|
|
369
|
+
allowedPaths: input.task.allowedPaths,
|
|
370
|
+
writeSet: input.task.writeSet,
|
|
371
|
+
forbiddenPaths: input.task.forbiddenPaths,
|
|
372
|
+
writePolicy: input.task.writePolicy,
|
|
373
|
+
});
|
|
374
|
+
customTools.push(...(await createPiWriterCustomTools(policyContext)));
|
|
375
|
+
}
|
|
376
|
+
if (commandPolicy.mode === "capability-allowlist") {
|
|
377
|
+
for (const capability of commandPolicy.capabilities) {
|
|
378
|
+
const factory = PI_COMMAND_CAPABILITY_REGISTRY[capability];
|
|
379
|
+
if (!factory) {
|
|
380
|
+
throw new Error(`unknown command capability: ${capability}`);
|
|
381
|
+
}
|
|
382
|
+
if (capability === "playwright-cli") {
|
|
383
|
+
const caseId = resolveCaseIdFromWriteSet(input.task.writeSet) ??
|
|
384
|
+
input.task.id;
|
|
385
|
+
const evidenceDir = resolveEvidenceDirFromWriteSet(input.task.writeSet) ??
|
|
386
|
+
`testcase/frontend/evidence/${caseId}`;
|
|
387
|
+
playwrightToolContext = {
|
|
388
|
+
repoRoot: input.cwd,
|
|
389
|
+
runDir: meta.runDir,
|
|
390
|
+
nodeId: input.task.id,
|
|
391
|
+
caseId,
|
|
392
|
+
evidenceDir,
|
|
393
|
+
baseUrl: await resolveControllerOwnedFrontendBaseUrl(meta.runDir),
|
|
394
|
+
inputRoot: "testcase/frontend/fixtures",
|
|
395
|
+
};
|
|
396
|
+
// Cleanup must be confirmed before a new case; otherwise default-session
|
|
397
|
+
// isolation is unknown and no Pi invocation is permitted.
|
|
398
|
+
const preCleanup = await cleanupPlaywrightCliDefaultSession(playwrightToolContext, undefined, "pre-start");
|
|
399
|
+
if (!preCleanup.ok) {
|
|
400
|
+
throw new Error(`browser-command-capability-unavailable: pre-start cleanup failed (${preCleanup.errorClass ?? "unknown"})`);
|
|
401
|
+
}
|
|
402
|
+
customTools.push(await createPlaywrightCliTool(playwrightToolContext));
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
customTools.push(await factory(playwrightToolContext));
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
if (customTools.length > 0) {
|
|
410
|
+
writerToolPolicy = { requireSdk: true, customTools };
|
|
411
|
+
}
|
|
329
412
|
}
|
|
330
413
|
catch (error) {
|
|
331
414
|
return {
|
|
332
415
|
ok: false,
|
|
333
416
|
stdout: "",
|
|
334
|
-
stderr: `pi writer tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
417
|
+
stderr: `pi writer/command tool policy unavailable before Pi execution: ${error instanceof Error ? error.message : String(error)}`,
|
|
335
418
|
failureCategory: "tool-policy",
|
|
336
419
|
durationMs: Date.now() - started,
|
|
337
420
|
};
|
|
338
421
|
}
|
|
339
422
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
?
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
423
|
+
let result;
|
|
424
|
+
try {
|
|
425
|
+
result = await piStepFn({
|
|
426
|
+
attachedFiles: [],
|
|
427
|
+
modelConfig: resolveDagPiModelConfig(input.model, input.thinking ? { thinking: input.thinking } : undefined),
|
|
428
|
+
prompt: input.prompt,
|
|
429
|
+
repoRoot: input.cwd,
|
|
430
|
+
step,
|
|
431
|
+
toolNames: resolveDagPiToolNames(input.task),
|
|
432
|
+
userMessage: buildDagPiUserMessage(input.task, persona, step),
|
|
433
|
+
sessionEventsPath: path.join(meta.runDir, input.task.id, "session-events.jsonl"),
|
|
434
|
+
// Absolute max wall clock from adaptive liveness policy (defaults to 4h).
|
|
435
|
+
timeoutMs: input.timeoutMs,
|
|
436
|
+
stallTimeoutMs: input.stallTimeoutMs,
|
|
437
|
+
abortGraceMs: input.abortGraceMs,
|
|
438
|
+
...(writerToolPolicy ? { writerToolPolicy } : {}),
|
|
439
|
+
onActivity: bridgeActivity
|
|
440
|
+
? (activity) => {
|
|
441
|
+
if (activity.kind === "lease" ||
|
|
442
|
+
activity.kind === "synthetic-heartbeat") {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
bridgeActivity(activity.kind, activity.at);
|
|
359
446
|
}
|
|
360
|
-
|
|
447
|
+
: undefined,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
catch (error) {
|
|
451
|
+
if (playwrightToolContext) {
|
|
452
|
+
try {
|
|
453
|
+
await cleanupPlaywrightCliDefaultSession(playwrightToolContext, undefined, "post-execution");
|
|
361
454
|
}
|
|
362
|
-
|
|
363
|
-
|
|
455
|
+
catch {
|
|
456
|
+
// best-effort
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
throw error;
|
|
460
|
+
}
|
|
461
|
+
if (playwrightToolContext) {
|
|
462
|
+
try {
|
|
463
|
+
await cleanupPlaywrightCliDefaultSession(playwrightToolContext, undefined, "post-execution");
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// best-effort controller cleanup must not mask the primary result
|
|
467
|
+
}
|
|
468
|
+
}
|
|
364
469
|
const context = {
|
|
365
470
|
channel: "dag",
|
|
366
471
|
cwd: input.cwd,
|