jeo-code 0.12.24 → 0.12.26
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 +18 -1
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/AGENTS.md +1 -1
- package/src/agent/engine.ts +76 -11
- package/src/agent/loop-guards.ts +25 -4
- package/src/agent/sandbox/hands-source.text +394 -394
- package/src/agent/workspace-snapshot.ts +81 -7
- package/src/ai/types.ts +8 -0
- package/src/commands/launch.ts +2 -1
- package/src/util/shared-global.ts +5 -7
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,21 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.12.26] - 2026-11-06
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **A read-only turn is no longer sent through three full-history retries by an unrelated writer** - the done gate counts a `bash` call as a file change when the workspace digest differs before and after it. In a shared directory (`/tmp`) a daemon heartbeat rewritten every 60 s during a 120 s `find /` flipped that digest, so a turn of `pwd`/`jeo-skill doctor` ended `UNVERIFIED` after three correction rounds (226k input tokens for `sleep 70`; the same prompt in an empty directory cost 32k). Three changes: (1) when the cwd *is* a shared scratch root (`$HOME`, `/`, a system temp dir) `bash` and `computer` calls are no longer snapshot-bracketed - the diff there cannot be attributed to one call and no project verifier exists - with one notice and `workspace: { mutation: false, skipped: "scratch" }` on the result; built-in `write`/`edit` and project directories nested inside a scratch root stay gated; (2) evidence that comes only from a shell/computer bracket ("shell-only": no built-in mutator, debug, monitor or job ran) earns one correction instead of three, counted apart from todo/hook nudges, and launch's `onBeforeDone` restatement of the gate (`MUTATION_NUDGE_PREFIX`) no longer disables that cap; (3) a passing verification now also resets the definite-mutation evidence, so a later unrelated write is judged as shell-only again. Re-run with an unrelated writer in a nested project dir: one correction, 47.8k tokens.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- **The done gate names the paths a shell call changed** - a snapshot mismatch is diffed per path (`snapshotChangedPaths`, `formatSnapshotChange`; the snapshot now keeps a relative-path to fingerprint map, digest arithmetic unchanged) and the unverified / stale-verification messages say `the host saw these paths change: modified heartbeat.json`, so the user and model can tell the command's writes from someone else's. The note no longer suggests that replying "read-only" can clear the gate - only a passing verification does - and `workspace snapshot inconclusive` notices and `done deferred` counters show the effective limit (`1/1`).
|
|
19
|
+
- **Transcripts record the workspace evidence** - each bash/computer tool result persists `workspace: { mutation, changed?, inconclusive?, skipped? }` in the session (never sent to a provider), so a rejected completion can be audited from the transcript; before, 1,221 sessions could not show which call latched the mutation flag.
|
|
20
|
+
- `formatRejectedCompletion` pluralizes (`after 1 correction.`).
|
|
21
|
+
|
|
22
|
+
## [0.12.25] - 2026-11-06
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- **`jeo update` no longer special-cases another tool** - the `shared global unchanged: N packages (...)` line used to list `jeopi-cli` first; it now lists every other package in plain ascending order, at most three shown.
|
|
26
|
+
|
|
12
27
|
## [0.12.24] - 2026-11-06
|
|
13
28
|
|
|
14
29
|
### Added
|
|
@@ -2757,7 +2772,9 @@ _Initial release._
|
|
|
2757
2772
|
### Added
|
|
2758
2773
|
- Initial jeo-code agent and CLI.
|
|
2759
2774
|
|
|
2760
|
-
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.
|
|
2775
|
+
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.26...HEAD
|
|
2776
|
+
[0.12.26]: https://github.com/akillness/jeo-code/releases/tag/v0.12.26
|
|
2777
|
+
[0.12.25]: https://github.com/akillness/jeo-code/releases/tag/v0.12.25
|
|
2761
2778
|
[0.12.24]: https://github.com/akillness/jeo-code/releases/tag/v0.12.24
|
|
2762
2779
|
[0.12.23]: https://github.com/akillness/jeo-code/releases/tag/v0.12.23
|
|
2763
2780
|
[0.12.22]: https://github.com/akillness/jeo-code/compare/v0.12.21...v0.12.22
|
package/README.ja.md
CHANGED
|
@@ -462,10 +462,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
462
462
|
|
|
463
463
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
464
464
|
- **[Unreleased]**
|
|
465
|
+
- **[0.12.26]** (2026-11-06)
|
|
466
|
+
- **[0.12.25]** (2026-11-06)
|
|
465
467
|
- **[0.12.24]** (2026-11-06)
|
|
466
468
|
- **[0.12.23]** (2026-11-06)
|
|
467
|
-
- **[0.12.22]** (2026-11-04)
|
|
468
|
-
- **[0.12.21]** (2026-11-04)
|
|
469
469
|
|
|
470
470
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
471
471
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -517,10 +517,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
517
517
|
|
|
518
518
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
519
519
|
- **[Unreleased]**
|
|
520
|
+
- **[0.12.26]** (2026-11-06)
|
|
521
|
+
- **[0.12.25]** (2026-11-06)
|
|
520
522
|
- **[0.12.24]** (2026-11-06)
|
|
521
523
|
- **[0.12.23]** (2026-11-06)
|
|
522
|
-
- **[0.12.22]** (2026-11-04)
|
|
523
|
-
- **[0.12.21]** (2026-11-04)
|
|
524
524
|
|
|
525
525
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
526
526
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -506,10 +506,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
506
506
|
|
|
507
507
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
508
508
|
- **[Unreleased]**
|
|
509
|
+
- **[0.12.26]** (2026-11-06)
|
|
510
|
+
- **[0.12.25]** (2026-11-06)
|
|
509
511
|
- **[0.12.24]** (2026-11-06)
|
|
510
512
|
- **[0.12.23]** (2026-11-06)
|
|
511
|
-
- **[0.12.22]** (2026-11-04)
|
|
512
|
-
- **[0.12.21]** (2026-11-04)
|
|
513
513
|
|
|
514
514
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
515
515
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -460,10 +460,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
460
460
|
|
|
461
461
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
462
462
|
- **[Unreleased]**
|
|
463
|
+
- **[0.12.26]** (2026-11-06)
|
|
464
|
+
- **[0.12.25]** (2026-11-06)
|
|
463
465
|
- **[0.12.24]** (2026-11-06)
|
|
464
466
|
- **[0.12.23]** (2026-11-06)
|
|
465
|
-
- **[0.12.22]** (2026-11-04)
|
|
466
|
-
- **[0.12.21]** (2026-11-04)
|
|
467
467
|
|
|
468
468
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
469
469
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/AGENTS.md
CHANGED
|
@@ -30,7 +30,7 @@ The core runtime loop, tool registry, session management, and state persistence
|
|
|
30
30
|
| `json.ts` | Brief description of purpose |
|
|
31
31
|
| `loop.ts` | The primary execution loop orchestrating model calls and tool execution |
|
|
32
32
|
| `loop-guards.ts` | Intermediate-judgment classification (gjc ultragoal-guard parity): named `GuardState` taxonomy, `GUARD_LIMITS` thresholds, and pure classifiers (`verifierCores`/`isVerificationSignal` — recognizes wrapped verifiers such as `cd x && bun test`, env/`timeout`/`uv run`/`rtk` prefixes and `2>&1`, never pipes or `;` — `repeatHint`, `classifyDoneGate` incl. `done_stale_verification`, and `formatRejectedCompletion`, which keeps the last candidate answer under an `UNVERIFIED` marker when corrections are exhausted) consumed by `engine.ts` |
|
|
33
|
-
| `workspace-snapshot.ts` | Host-side workspace fingerprint (`snapshotWorkspace`, `defaultWorkspaceSnapshotter`, `workspaceSnapshotChanged`): bounded lstat walk excluding `.git`/`.jeo`/`node_modules`, racy content hash for files modified near the bracket anchor; the engine brackets non-verifier `bash` and state-affecting `computer` calls with it so only a real workspace change counts as a mutation (snapshot error or cap counts as a mutation) |
|
|
33
|
+
| `workspace-snapshot.ts` | Host-side workspace fingerprint (`snapshotWorkspace`, `defaultWorkspaceSnapshotter`, `workspaceSnapshotChanged`): bounded lstat walk excluding `.git`/`.jeo`/`node_modules`, racy content hash for files modified near the bracket anchor; the engine brackets non-verifier `bash` and state-affecting `computer` calls with it so only a real workspace change counts as a mutation (snapshot error or cap counts as a mutation); an ok snapshot keeps a relative-path fingerprint map (`snapshotChangedPaths`/`formatSnapshotChange` name what changed) and `isSharedScratchRoot` marks `$HOME`, `/` and system temp dirs where `bash`/`computer` calls are not bracketed |
|
|
34
34
|
| `memory.ts` | OKF concept-bundle memory: session distill, query-aware budget injection with a **hybrid reranker ported from memsearch** — `priorityOrder` fuses two ranked channels via `reciprocalRankFusion` (RRF, k=60): lexical IDF relevance (`buildCorpusStats`/`scoreConcept`, the sparse/BM25 channel so rare discriminating terms steer retrieval) ⊕ graph proximity (`graphProximityOrder`, the local dense/semantic-neighbour channel — a hub linked from multiple query hits surfaces even with no keyword of its own); all embedding-free, deterministic, atop the failure-first tier + pinned-invariant reserved budget. Plus mid-loop `recordFailedAttempt` capture, shared `upsertConceptFile` writer, legacy MEMORY.md migration (`migrateLegacyMemory`) + `JEO_MEMORY_LEGACY` rollback toggle |
|
|
35
35
|
| `memory-okf.ts` | OKF v0.1 format layer: frontmatter parse/serialize, concept IDs, conformance validation |
|
|
36
36
|
| `memory-graph.ts` | Concept cross-link graph: build/expand (1-hop search), broken-link-tolerant lint, optional graphify detection |
|
package/src/agent/engine.ts
CHANGED
|
@@ -32,8 +32,11 @@ import { StepBudget, dynamicStepBudgetConfig, resolveStepBudgetConfig, hashSigna
|
|
|
32
32
|
|
|
33
33
|
import { historyTokens, trimToolResultsInPlace, stripReasoningArtifactsInPlace } from "./compaction";
|
|
34
34
|
import { jeoEnv } from "../util/env";
|
|
35
|
-
import { GUARD_LIMITS, isVerificationSignal, verificationProducedEvidence, repeatHint, classifyDoneGate, formatRejectedCompletion } from "./loop-guards";
|
|
36
|
-
import {
|
|
35
|
+
import { GUARD_LIMITS, MUTATION_NUDGE_PREFIX, isVerificationSignal, verificationProducedEvidence, repeatHint, classifyDoneGate, formatRejectedCompletion } from "./loop-guards";
|
|
36
|
+
import {
|
|
37
|
+
defaultWorkspaceSnapshotter, formatSnapshotChange, isSharedScratchRoot, snapshotChangedPaths, workspaceSnapshotChanged,
|
|
38
|
+
type WorkspaceSnapshot, type WorkspaceSnapshotter,
|
|
39
|
+
} from "./workspace-snapshot";
|
|
37
40
|
import { stripLeakedReasoningTags } from "../ai/think-tags";
|
|
38
41
|
|
|
39
42
|
const MID_TASK_PROSE_RE =
|
|
@@ -411,6 +414,9 @@ export interface AgentLoopOptions {
|
|
|
411
414
|
/** Host-side workspace fingerprint used to decide whether a bash/computer call mutated the
|
|
412
415
|
* workspace. Defaults to defaultWorkspaceSnapshotter; tests inject fakes. */
|
|
413
416
|
workspaceSnapshot?: WorkspaceSnapshotter;
|
|
417
|
+
/** Directories treated as shared scratch roots (default: `$HOME`, `/`, system temp dirs). Where the cwd IS one of
|
|
418
|
+
* these, bash/computer calls are not snapshot-bracketed. Tests inject a hermetic root instead of touching `$TMPDIR`. */
|
|
419
|
+
scratchRoots?: readonly string[];
|
|
414
420
|
signal?: AbortSignal;
|
|
415
421
|
events?: AgentLoopEvents;
|
|
416
422
|
/** Step-budget overrides (gjc-style retry flow). `{ maxExtensions: 0 }` restores the
|
|
@@ -575,9 +581,22 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
575
581
|
};
|
|
576
582
|
// bash/computer calls whose mutation status is decided by a workspace diff. Recognized
|
|
577
583
|
// verifiers are never mutations, and observe-only computer actions are read-only.
|
|
578
|
-
|
|
584
|
+
// A shared scratch root ($HOME, /, the system temp dir) is written by unrelated processes (daemon heartbeats,
|
|
585
|
+
// logs, other agents) and holds no project verifier, so a snapshot diff there cannot be attributed to one shell
|
|
586
|
+
// call: bash and computer calls are not bracketed (a built-in write/edit still counts as a definite mutation).
|
|
587
|
+
// A project directory nested inside a scratch root stays gated.
|
|
588
|
+
const scratchCwd = isSharedScratchRoot(cwd, opts.scratchRoots);
|
|
589
|
+
let scratchNoted = false;
|
|
590
|
+
const snapshotCandidate = (tool: string, args: Record<string, any> | undefined): boolean =>
|
|
579
591
|
(tool === "bash" && !isVerificationSignal(String(args?.command ?? args?.cmd ?? ""))) ||
|
|
580
592
|
(tool === "computer" && computerActionMayAffectState(args));
|
|
593
|
+
const snapshotGated = (tool: string, args: Record<string, any> | undefined): boolean =>
|
|
594
|
+
!scratchCwd && snapshotCandidate(tool, args);
|
|
595
|
+
// Mutation evidence that came ONLY from a shell/computer snapshot bracket, kept apart from definite mutators
|
|
596
|
+
// (write/edit/eval/…): an unrelated writer in the cwd is indistinguishable from the command's own writes, so a
|
|
597
|
+
// shell-only block gets one correction (naming the paths) instead of the full budget.
|
|
598
|
+
let definiteMutation = false;
|
|
599
|
+
const shellChanges: string[] = [];
|
|
581
600
|
const sandbox = currentSandboxExecution();
|
|
582
601
|
const parentTools: Record<string, true> = { task: true, subagent: true, irc: true, todo: true, sandbox_request: true };
|
|
583
602
|
const maxSteps = opts.maxSteps ?? 15;
|
|
@@ -703,6 +722,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
703
722
|
let verificationCycles = 0;
|
|
704
723
|
let completionGraceUsed = false;
|
|
705
724
|
let doneCorrections = 0;
|
|
725
|
+
// Corrections spent on a shell-only block, counted apart so an earlier todo/hook nudge cannot use up the one
|
|
726
|
+
// correction that names the changed paths.
|
|
727
|
+
let shellOnlyCorrections = 0;
|
|
706
728
|
// Most recent non-empty completion candidate, kept so an exhausted gate still shows it.
|
|
707
729
|
let lastCompletionCandidate = "";
|
|
708
730
|
// F1 (round 4): the run-command of the most recent post-turn hook FAILURE whose
|
|
@@ -1219,11 +1241,23 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1219
1241
|
};
|
|
1220
1242
|
const candidate = stripLeakedReasoningTags(String(toolCalls[0].arguments?.reason ?? ""));
|
|
1221
1243
|
if (candidate.trim()) lastCompletionCandidate = candidate;
|
|
1222
|
-
const doneGate = classifyDoneGate({
|
|
1244
|
+
const doneGate = classifyDoneGate({
|
|
1245
|
+
...evidence,
|
|
1246
|
+
pendingHookFailure,
|
|
1247
|
+
unmeasured: unmeasuredSnapshot ?? undefined,
|
|
1248
|
+
shellChanged: !definiteMutation && shellChanges.length ? shellChanges.slice(-3).join(" | ") : undefined,
|
|
1249
|
+
});
|
|
1223
1250
|
const nudge = await ev.onBeforeDone?.((toolCalls[0].arguments?.reason as string) ?? "", evidence);
|
|
1224
|
-
|
|
1251
|
+
// launch's onBeforeDone restates the mutation gate in its own words when the gate blocks; the engine's message
|
|
1252
|
+
// names the paths, so the restatement is dropped and does not count as an independent reason to keep going.
|
|
1253
|
+
const independentNudge = nudge && !(doneGate.block && nudge.startsWith(MUTATION_NUDGE_PREFIX)) ? nudge : "";
|
|
1254
|
+
const rejection = [doneGate.block ? doneGate.message : "", independentNudge].filter(Boolean).join("\n\n");
|
|
1225
1255
|
if (rejection) {
|
|
1226
|
-
|
|
1256
|
+
// Shell-only evidence (no built-in mutator succeeded) cannot tell the command's writes from an unrelated
|
|
1257
|
+
// process's, so it earns one correction naming the paths; every other block keeps the full budget.
|
|
1258
|
+
const shellOnlyBlock = doneGate.block && doneGate.state !== "done_hook_failing" && !definiteMutation && !independentNudge;
|
|
1259
|
+
const correctionLimit = shellOnlyBlock ? GUARD_LIMITS.MAX_SHELL_ONLY_DONE_CORRECTIONS : GUARD_LIMITS.MAX_DONE_CORRECTIONS;
|
|
1260
|
+
if (shellOnlyBlock ? shellOnlyCorrections >= correctionLimit : doneCorrections >= correctionLimit) {
|
|
1227
1261
|
return finish({
|
|
1228
1262
|
done: false,
|
|
1229
1263
|
steps: step,
|
|
@@ -1232,9 +1266,10 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1232
1266
|
});
|
|
1233
1267
|
}
|
|
1234
1268
|
doneCorrections++;
|
|
1269
|
+
if (shellOnlyBlock) shellOnlyCorrections++;
|
|
1235
1270
|
pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
|
|
1236
1271
|
history.push({ role: "user", content: rejection });
|
|
1237
|
-
ev.onNotice?.(`done deferred — completion correction ${doneCorrections}/${
|
|
1272
|
+
ev.onNotice?.(`done deferred — completion correction ${shellOnlyBlock ? shellOnlyCorrections : doneCorrections}/${correctionLimit}`);
|
|
1238
1273
|
step++;
|
|
1239
1274
|
continue;
|
|
1240
1275
|
}
|
|
@@ -1371,6 +1406,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1371
1406
|
let output: string;
|
|
1372
1407
|
let images: import("../ai/types").ImageAttachment[] | undefined;
|
|
1373
1408
|
let mutationMayHaveOccurred: boolean | undefined;
|
|
1409
|
+
let workspace: import("../ai/types").ToolResultRecord["workspace"];
|
|
1410
|
+
let bracketed = false;
|
|
1374
1411
|
if (tool === "done") {
|
|
1375
1412
|
success = false;
|
|
1376
1413
|
output = "Error: 'done' can only be called as the single tool invocation, not in a batch. Please send 'done' alone.";
|
|
@@ -1393,6 +1430,13 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1393
1430
|
success = false;
|
|
1394
1431
|
output = preHookResult.error + (preHookResult.output ? `\n${preHookResult.output}` : "");
|
|
1395
1432
|
} else {
|
|
1433
|
+
if (scratchCwd && snapshotCandidate(tool, args)) {
|
|
1434
|
+
workspace = { mutation: false, skipped: "scratch" };
|
|
1435
|
+
if (!scratchNoted) {
|
|
1436
|
+
scratchNoted = true;
|
|
1437
|
+
ev.onNotice?.(`cwd ${cwd} is a shared scratch directory: shell and computer calls here are not checked for file changes`);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1396
1440
|
// Taken after pre-tool hooks so hook writes are never attributed to the call.
|
|
1397
1441
|
const before = snapshotGated(tool, args) ? await takeSnapshot() : undefined;
|
|
1398
1442
|
try {
|
|
@@ -1423,7 +1467,16 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1423
1467
|
// bash/computer: the workspace diff is the sole mutation authority (overrides
|
|
1424
1468
|
// handler flags and the catch heuristic). Both sides share one racy anchor so
|
|
1425
1469
|
// an unchanged recently-modified file is hashed on both sides or neither.
|
|
1426
|
-
|
|
1470
|
+
const after = before.ok ? await takeSnapshot(before.startedAtMs) : undefined;
|
|
1471
|
+
mutationMayHaveOccurred = after ? workspaceSnapshotChanged(before, after) : true;
|
|
1472
|
+
bracketed = true;
|
|
1473
|
+
const failed = !before.ok ? before : after && !after.ok ? after : undefined;
|
|
1474
|
+
const change = after ? snapshotChangedPaths(before, after) : null;
|
|
1475
|
+
workspace = {
|
|
1476
|
+
mutation: mutationMayHaveOccurred,
|
|
1477
|
+
...(mutationMayHaveOccurred && change ? { changed: formatSnapshotChange(change) } : {}),
|
|
1478
|
+
...(failed && !failed.ok ? { inconclusive: `${failed.reason}: ${failed.detail}` } : {}),
|
|
1479
|
+
};
|
|
1427
1480
|
}
|
|
1428
1481
|
// A pre-tool hook's structured `additionalContext` rides along with the result
|
|
1429
1482
|
// so the model sees it next to the output it concerns.
|
|
@@ -1431,7 +1484,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1431
1484
|
}
|
|
1432
1485
|
}
|
|
1433
1486
|
}
|
|
1434
|
-
return { success, output, images, mutationMayHaveOccurred };
|
|
1487
|
+
return { success, output, images, mutationMayHaveOccurred, bracketed, workspace };
|
|
1435
1488
|
};
|
|
1436
1489
|
|
|
1437
1490
|
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
@@ -1474,7 +1527,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1474
1527
|
}
|
|
1475
1528
|
}
|
|
1476
1529
|
|
|
1477
|
-
const results: { success: boolean; output: string; executed: boolean; images?: import("../ai/types").ImageAttachment[]; mutationMayHaveOccurred?: boolean }[] = Array.from(
|
|
1530
|
+
const results: { success: boolean; output: string; executed: boolean; images?: import("../ai/types").ImageAttachment[]; mutationMayHaveOccurred?: boolean; bracketed?: boolean; workspace?: import("../ai/types").ToolResultRecord["workspace"] }[] = Array.from(
|
|
1478
1531
|
{ length: toolCalls.length },
|
|
1479
1532
|
() => ({ success: false, output: "", executed: false })
|
|
1480
1533
|
);
|
|
@@ -1563,6 +1616,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1563
1616
|
id: idFor(idx),
|
|
1564
1617
|
output: bodies[i],
|
|
1565
1618
|
isError: !results[idx].success,
|
|
1619
|
+
...(results[idx].workspace ? { workspace: results[idx].workspace } : {}),
|
|
1566
1620
|
}));
|
|
1567
1621
|
pushAssistantTurn(history, responseText, reasonBuf, artifactBuf, toolUse);
|
|
1568
1622
|
const resultMsg: Message = { role: "user", content: resultBlocks.join("\n\n"), toolResults };
|
|
@@ -1607,11 +1661,16 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1607
1661
|
if (results[i].success && verificationProducedEvidence(command, String(results[i].output ?? ""))) {
|
|
1608
1662
|
sawVerification = true;
|
|
1609
1663
|
lastVerificationSeq = ++mutVerSeq;
|
|
1610
|
-
|
|
1664
|
+
// A passing verification supersedes the evidence before it: what happens next is judged on its own.
|
|
1665
|
+
definiteMutation = false;
|
|
1666
|
+
shellChanges.length = 0;
|
|
1667
|
+
unmeasuredSnapshot = null;
|
|
1668
|
+
if (!pendingHookFailure) { doneCorrections = 0; shellOnlyCorrections = 0; }
|
|
1611
1669
|
} else {
|
|
1612
1670
|
sawVerification = false;
|
|
1613
1671
|
if (results[i].mutationMayHaveOccurred === true) {
|
|
1614
1672
|
sawMutation = true;
|
|
1673
|
+
definiteMutation = true; // a verifier is never snapshot-bracketed: this flag came from the handler itself
|
|
1615
1674
|
lastMutationSeq = ++mutVerSeq;
|
|
1616
1675
|
}
|
|
1617
1676
|
}
|
|
@@ -1619,6 +1678,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1619
1678
|
}
|
|
1620
1679
|
if (t === "todo" && results[i].success && doneCorrections > 0 && (!sawMutation || (sawVerification && lastMutationSeq < lastVerificationSeq)) && !pendingHookFailure) {
|
|
1621
1680
|
doneCorrections = 0;
|
|
1681
|
+
shellOnlyCorrections = 0;
|
|
1622
1682
|
}
|
|
1623
1683
|
|
|
1624
1684
|
const output = String(results[i].output ?? "").trim();
|
|
@@ -1638,19 +1698,24 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1638
1698
|
if (results[i].success || results[i].mutationMayHaveOccurred === true) {
|
|
1639
1699
|
sawMutation = true;
|
|
1640
1700
|
lastMutationSeq = ++mutVerSeq;
|
|
1701
|
+
if (results[i].bracketed !== true) definiteMutation = true;
|
|
1702
|
+
else if (results[i].workspace?.changed) shellChanges.push(results[i].workspace!.changed!);
|
|
1641
1703
|
}
|
|
1642
1704
|
} else if (t === "debug" && MUTATING_DEBUG_ACTIONS[String(toolCalls[i].arguments?.action ?? "")] === true) {
|
|
1643
1705
|
sawMutation = true;
|
|
1706
|
+
definiteMutation = true;
|
|
1644
1707
|
lastMutationSeq = ++mutVerSeq;
|
|
1645
1708
|
} else if (t === "monitor" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
|
|
1646
1709
|
// Starting a background monitor can mutate the workspace asynchronously;
|
|
1647
1710
|
// read-only monitor controls (list/tail/await/cancel) do not invalidate verification.
|
|
1648
1711
|
sawMutation = true;
|
|
1712
|
+
definiteMutation = true;
|
|
1649
1713
|
lastMutationSeq = ++mutVerSeq;
|
|
1650
1714
|
} else if (t === "job" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
|
|
1651
1715
|
// Starting a background shell may mutate the workspace asynchronously;
|
|
1652
1716
|
// read-only job controls (list/tail/await/cancel) do not invalidate verification.
|
|
1653
1717
|
sawMutation = true;
|
|
1718
|
+
definiteMutation = true;
|
|
1654
1719
|
lastMutationSeq = ++mutVerSeq;
|
|
1655
1720
|
}
|
|
1656
1721
|
}
|
package/src/agent/loop-guards.ts
CHANGED
|
@@ -36,6 +36,13 @@ export type GuardState =
|
|
|
36
36
|
* Kept in one frozen object so the limits are discoverable and testable instead of
|
|
37
37
|
* sprinkled as bare literals through the loop body.
|
|
38
38
|
*/
|
|
39
|
+
/**
|
|
40
|
+
* Prefix of the launch-level `onBeforeDone` nudge that merely restates the mutation-verification gate. The engine's
|
|
41
|
+
* own `classifyDoneGate` already blocks (and explains) that case, so the engine must not count the restatement as an
|
|
42
|
+
* independent block — otherwise the shell-only correction cap could never apply in the interactive loop.
|
|
43
|
+
*/
|
|
44
|
+
export const MUTATION_NUDGE_PREFIX = "Cannot finish: a workspace mutation from this session still lacks a successful, non-stale verification.";
|
|
45
|
+
|
|
39
46
|
export const GUARD_LIMITS = Object.freeze({
|
|
40
47
|
/** Identical step repeats tolerated before a consolidated stop (with corrections en route). */
|
|
41
48
|
MAX_REPEAT: 4,
|
|
@@ -43,6 +50,10 @@ export const GUARD_LIMITS = Object.freeze({
|
|
|
43
50
|
MAX_FAILURES: 5,
|
|
44
51
|
/** Rejected completion attempts that may request a correction before stopping incomplete. */
|
|
45
52
|
MAX_DONE_CORRECTIONS: 3,
|
|
53
|
+
/** Same, when the only mutation evidence is a shell/computer snapshot bracket (no built-in mutator
|
|
54
|
+
* succeeded). An unrelated process writing in the cwd looks identical to the command's own writes, so
|
|
55
|
+
* one correction states the paths and asks for verification; a second full-history retry only burns tokens. */
|
|
56
|
+
MAX_SHELL_ONLY_DONE_CORRECTIONS: 1,
|
|
46
57
|
/** Context-mutating refusal-ladder rungs (resend → reset → guidance strip) before
|
|
47
58
|
* recovery switches to unbounded backoff resends (gjc parity: never terminal). */
|
|
48
59
|
MAX_REFUSAL_RETRIES: 3,
|
|
@@ -185,7 +196,7 @@ export const UNVERIFIED_MARKER = "UNVERIFIED";
|
|
|
185
196
|
* model's last candidate answer, never presents it as accepted, and keeps the gate reason.
|
|
186
197
|
*/
|
|
187
198
|
export function formatRejectedCompletion(candidate: string, corrections: number, rejection: string): string {
|
|
188
|
-
const stop = `Stopped: completion rejected after ${corrections} corrections. ${rejection}`;
|
|
199
|
+
const stop = `Stopped: completion rejected after ${corrections} correction${corrections === 1 ? "" : "s"}. ${rejection}`;
|
|
189
200
|
const answer = candidate.trim();
|
|
190
201
|
if (!answer) return stop;
|
|
191
202
|
return `${UNVERIFIED_MARKER} - the completion gate did not accept this answer; treat it as unconfirmed.\n\n${answer}\n\n${stop}`;
|
|
@@ -239,6 +250,11 @@ export interface DoneGateInput {
|
|
|
239
250
|
* instead of claiming files were modified.
|
|
240
251
|
*/
|
|
241
252
|
unmeasured?: string;
|
|
253
|
+
/**
|
|
254
|
+
* Paths a shell call's before/after snapshot saw change (`modified a.json; added b.txt`), when the evidence
|
|
255
|
+
* came from a shell bracket. Lets the model and user judge whether the command or an unrelated process wrote them.
|
|
256
|
+
*/
|
|
257
|
+
shellChanged?: string;
|
|
242
258
|
}
|
|
243
259
|
|
|
244
260
|
/** Verdict from {@link classifyDoneGate}: whether to bounce `done`, and the message. */
|
|
@@ -263,10 +279,15 @@ export function classifyDoneGate(input: DoneGateInput): DoneGateVerdict {
|
|
|
263
279
|
const stale = input.sawVerification && input.verificationStale === true;
|
|
264
280
|
const block = input.sawMutation && (!input.sawVerification || stale || hookFailing);
|
|
265
281
|
if (!block) return { state: "done_ok", block: false, message: "" };
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
282
|
+
// A reply alone never clears this gate: only a passing verification does, or the budget running out.
|
|
283
|
+
const escape = "Only a passing verification command clears this check; if none applies, the answer is returned labelled UNVERIFIED once the correction budget is spent.";
|
|
284
|
+
const changedNote = input.shellChanged
|
|
285
|
+
? ` Note: around a shell/computer call the host saw these paths change: ${input.shellChanged}. ` +
|
|
286
|
+
`Something other than your commands may have written them (another process, or your own background job). ${escape}`
|
|
269
287
|
: "";
|
|
288
|
+
const unmeasuredNote = changedNote + (input.unmeasured
|
|
289
|
+
? ` Note: the host could not measure a shell/computer call's effect on the workspace (${input.unmeasured}), so it is counted as a possible file change. ${escape}`
|
|
290
|
+
: "");
|
|
270
291
|
if (hookFailing) {
|
|
271
292
|
return {
|
|
272
293
|
state: "done_hook_failing",
|