jeo-code 0.12.25 → 0.12.27
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 +22 -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 +3 -2
- package/src/agent/engine.ts +140 -22
- package/src/agent/loop-guards.ts +391 -46
- package/src/agent/sandbox/docker.ts +15 -1
- package/src/agent/sandbox/hands-source.text +731 -731
- package/src/agent/snapshot-target.ts +188 -0
- package/src/agent/workspace-snapshot.ts +163 -24
- package/src/ai/types.ts +12 -0
- package/src/commands/launch.ts +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,26 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.12.27] - 2026-11-06
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **A stale managed container no longer blocks every sandbox launch** - `validateManagedRoots` inspects every labelled container, including exited ones, and canonicalized each bind source. A leaked container whose temp workspace had been deleted made `realpath` throw `ENOENT`, so every later launch failed with that opaque error. A vanished source (`ENOENT`, `ENOTDIR`) now resolves through its nearest existing ancestor (lexically for `ENOTDIR`) for the overlap check, which stays enforced; `EACCES`, `ELOOP` and invalid metadata still fail closed.
|
|
16
|
+
- **An unlistable directory or an unhashable fresh file no longer makes the whole workspace snapshot inconclusive** - a non-root directory that cannot be listed is recorded as `u:<rel>:<mode>:<ctimeNs>:<ino>` and its children skipped (a `chmod` or any entry change in it still flips the digest); a fresh file over the racy byte caps, unopenable or changed while read is recorded by its stat fingerprint plus `:x`. Both count in the new `degraded` field. An unreadable root and the entry and time caps still fail the snapshot. Residual risk, documented in the header: rewriting an existing child's content inside a write-only directory with no `chmod` and no entry change is not detected.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
- **The done gate recognizes far more real verification commands** - `parseVerifier` accepts `bash -c`, `time`, quoted arguments, `cd "x y" &&`, package-manager flags (`pnpm -r`, `--filter`, `--prefix`, `--cwd`, `yarn workspace`), `make ci|verify`, gradle, mvn, dotnet, rspec, phpunit, playwright and the silent linters ruff, mypy, flake8, golangci-lint, staticcheck, `prettier --check` and `cargo fmt --check`. Direct forms are trusted on tool success. Weak forms whose exit status is not the verifier's own (`| tail`, bare `| tee`, `|| true`, `; echo x`, `set -e;`, `cd x;`, a subshell) count only for test runners, and only with a positive pass summary, no failure marker anywhere in the output and the trailing `echo` text discounted. `make`, `mvn`, `gradle`, `rake` and `dotnet` accept only allow-listed targets, so `make ci deploy` is not a verifier. A direct run that reports zero tests (`0 pass`, `# pass 0`, `Tests run: 0`, `collected 0 items`) is no longer evidence.
|
|
20
|
+
- **A shell call from a shared scratch directory is bracketed on the project it works in** - from `$HOME`, `/` or a system temp dir, `cd <project> && sed -i ...`, or a `cwd`/`subdir` argument naming a project, is measured on the project root (the outermost directory with a `.git`, `package.json`, `Cargo.toml`, ... marker below any scratch root, so `cd app/pkg && echo x > ../src/a.ts` measures `app`) instead of being skipped, so a real unverified edit made that way blocks `done`. If that root cannot be measured BEFORE the call (over the entry or time cap), the call is skipped with one notice, as before, so a big or busy directory such as `/opt/homebrew` never blocks a read-only turn; if it is measurable before but not after, the call counts as a change. Unmeasured by design: a project over the 50k-entry cap (or one inflated earlier in the turn by a call that was not bracketed, such as a recognized `make build`), and a small project nested under a larger marker root. The transcript records `workspace.target` (relative to the cwd, `<outside cwd>` otherwise).
|
|
21
|
+
|
|
22
|
+
## [0.12.26] - 2026-11-06
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **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.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- **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`).
|
|
29
|
+
- **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.
|
|
30
|
+
- `formatRejectedCompletion` pluralizes (`after 1 correction.`).
|
|
31
|
+
|
|
12
32
|
## [0.12.25] - 2026-11-06
|
|
13
33
|
|
|
14
34
|
### Changed
|
|
@@ -2762,7 +2782,8 @@ _Initial release._
|
|
|
2762
2782
|
### Added
|
|
2763
2783
|
- Initial jeo-code agent and CLI.
|
|
2764
2784
|
|
|
2765
|
-
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.
|
|
2785
|
+
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.26...HEAD
|
|
2786
|
+
[0.12.26]: https://github.com/akillness/jeo-code/releases/tag/v0.12.26
|
|
2766
2787
|
[0.12.25]: https://github.com/akillness/jeo-code/releases/tag/v0.12.25
|
|
2767
2788
|
[0.12.24]: https://github.com/akillness/jeo-code/releases/tag/v0.12.24
|
|
2768
2789
|
[0.12.23]: https://github.com/akillness/jeo-code/releases/tag/v0.12.23
|
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.27]** (2026-11-06)
|
|
466
|
+
- **[0.12.26]** (2026-11-06)
|
|
465
467
|
- **[0.12.25]** (2026-11-06)
|
|
466
468
|
- **[0.12.24]** (2026-11-06)
|
|
467
|
-
- **[0.12.23]** (2026-11-06)
|
|
468
|
-
- **[0.12.22]** (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.27]** (2026-11-06)
|
|
521
|
+
- **[0.12.26]** (2026-11-06)
|
|
520
522
|
- **[0.12.25]** (2026-11-06)
|
|
521
523
|
- **[0.12.24]** (2026-11-06)
|
|
522
|
-
- **[0.12.23]** (2026-11-06)
|
|
523
|
-
- **[0.12.22]** (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.27]** (2026-11-06)
|
|
510
|
+
- **[0.12.26]** (2026-11-06)
|
|
509
511
|
- **[0.12.25]** (2026-11-06)
|
|
510
512
|
- **[0.12.24]** (2026-11-06)
|
|
511
|
-
- **[0.12.23]** (2026-11-06)
|
|
512
|
-
- **[0.12.22]** (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.27]** (2026-11-06)
|
|
464
|
+
- **[0.12.26]** (2026-11-06)
|
|
463
465
|
- **[0.12.25]** (2026-11-06)
|
|
464
466
|
- **[0.12.24]** (2026-11-06)
|
|
465
|
-
- **[0.12.23]** (2026-11-06)
|
|
466
|
-
- **[0.12.22]** (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
|
@@ -29,8 +29,9 @@ The core runtime loop, tool registry, session management, and state persistence
|
|
|
29
29
|
| `hooks.ts` | Command-hook runtime: `loadHooks` (user registry `~/.jeo/hooks.json` ⊕ `config.json`/project `.jeo/hooks.json`, with the `hooks.enabled=false` kill switch and project `enabled:false` opt-out; the registry is skipped in sandboxed sessions), `runPreToolHooks` (veto by exit code or structured `permissionDecision`, `additionalContext` passthrough), `runPostTurnHooksForBatch`, `runPostImplementationHooks`, and the per-turn `runPrePromptHooks` (context injected by `commands/launch.ts` into the prompt message) / `runTurnEndHooks` (final reply to ingesters); payloads follow the Claude hook protocol on the host (`hook_event_name`, `session_id`, `cwd`, `tool_name`/`tool_input`) and stay legacy-shaped in a sandbox |
|
|
30
30
|
| `json.ts` | Brief description of purpose |
|
|
31
31
|
| `loop.ts` | The primary execution loop orchestrating model calls and tool execution |
|
|
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`,
|
|
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) |
|
|
32
|
+
| `loop-guards.ts` | Intermediate-judgment classification (gjc ultragoal-guard parity): named `GuardState` taxonomy, `GUARD_LIMITS` thresholds, and pure classifiers (`verifierCores`/`isVerificationSignal` — `parseVerifier` recognizes wrapped verifiers such as `cd x && bun test`, `bash -c`, env/`timeout`/`time`/`uv run`/`rtk` prefixes and `2>&1`; direct forms are trusted on tool success, while weak forms whose exit status is not the verifier's own (`| tail`, bare `| tee`, `|| true`, `; echo x`, `set -e;`, `cd x;`, a subshell) count only for test runners and only with a positive pass summary, no failure marker anywhere in the output and the trailing `echo` text discounted; `make`/`mvn`/`gradle`/`rake` accept only allow-listed targets — `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); an unlistable non-root directory (`u:<rel>:<mode>:<ctimeNs>:<ino>`) or a racy file that cannot be hashed (stat fingerprint plus `:x`) degrades to a marker and is counted in `degraded` instead of failing the snapshot; 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 unless `snapshot-target.ts` finds a nested project |
|
|
34
|
+
| `snapshot-target.ts` | `snapshotTarget`/`describeSnapshotTarget`: for a `bash` call from a shared scratch cwd, resolves the project directory the call works in (`args.cwd`/`args.subdir`, then a leading literal `cd <path> &&`/`;`, up to 8 hops) and returns it only when it has a project marker (`.git`, `package.json`, `Cargo.toml`, …) below any scratch root; the engine brackets that directory and fails OPEN (skipped, one notice) when it cannot be measured, so a big or busy directory never blocks a turn |
|
|
34
35
|
| `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
36
|
| `memory-okf.ts` | OKF v0.1 format layer: frontmatter parse/serialize, concept IDs, conformance validation |
|
|
36
37
|
| `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,12 @@ 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";
|
|
40
|
+
import { describeSnapshotTarget, snapshotTarget, snapshotTargetLabel } from "./snapshot-target";
|
|
37
41
|
import { stripLeakedReasoningTags } from "../ai/think-tags";
|
|
38
42
|
|
|
39
43
|
const MID_TASK_PROSE_RE =
|
|
@@ -411,6 +415,9 @@ export interface AgentLoopOptions {
|
|
|
411
415
|
/** Host-side workspace fingerprint used to decide whether a bash/computer call mutated the
|
|
412
416
|
* workspace. Defaults to defaultWorkspaceSnapshotter; tests inject fakes. */
|
|
413
417
|
workspaceSnapshot?: WorkspaceSnapshotter;
|
|
418
|
+
/** Directories treated as shared scratch roots (default: `$HOME`, `/`, system temp dirs). Where the cwd IS one of
|
|
419
|
+
* these, bash/computer calls are not snapshot-bracketed. Tests inject a hermetic root instead of touching `$TMPDIR`. */
|
|
420
|
+
scratchRoots?: readonly string[];
|
|
414
421
|
signal?: AbortSignal;
|
|
415
422
|
events?: AgentLoopEvents;
|
|
416
423
|
/** Step-budget overrides (gjc-style retry flow). `{ maxExtensions: 0 }` restores the
|
|
@@ -546,9 +553,13 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
546
553
|
}
|
|
547
554
|
const tools = opts.tools ?? DEFAULT_TOOLS;
|
|
548
555
|
const snapshotter: WorkspaceSnapshotter = opts.workspaceSnapshot ?? defaultWorkspaceSnapshotter;
|
|
549
|
-
// Turn-scoped memo
|
|
550
|
-
//
|
|
551
|
-
|
|
556
|
+
// Turn-scoped memo, keyed per bracketed directory, so one oversized directory never poisons the measurement of the
|
|
557
|
+
// cwd or of another target. For the cwd any cap is remembered (gated calls count as mutations without re-walking a
|
|
558
|
+
// tree known to be too large). For a NESTED target only a cap seen on the BEFORE side is remembered: that directory
|
|
559
|
+
// was already unmeasurable before the call ran, so nothing in it is attributable. An AFTER-side cap is never
|
|
560
|
+
// remembered, because it may have been this very call's files that pushed the tree over the cap — a later small
|
|
561
|
+
// write in the same directory must still be judged on its own, not skipped by that memo.
|
|
562
|
+
const cappedDirs = new Set<string>();
|
|
552
563
|
// Why a bash/computer bracket could not be measured (cap or error), when one failed. The call still
|
|
553
564
|
// counts as a possible mutation, but the user and the done gate are told the real reason instead of
|
|
554
565
|
// only "files were modified" — a busy or oversized cwd (e.g. $HOME, /tmp) looks identical otherwise.
|
|
@@ -563,21 +574,54 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
563
574
|
}
|
|
564
575
|
return snap;
|
|
565
576
|
};
|
|
566
|
-
|
|
567
|
-
|
|
577
|
+
// A BEFORE-side failure on a NESTED target is returned as-is (not noted as unmeasured): the bracket then fails open
|
|
578
|
+
// for it, because before nested targeting those calls were skipped as scratch, and a read-only `cd ~/.cache && ls`
|
|
579
|
+
// must not start counting as a mutation just because that directory is big or busy. An AFTER-side failure is noted
|
|
580
|
+
// like the cwd's: the directory WAS measurable a moment ago, so files appeared during the call (fail-safe).
|
|
581
|
+
// Exception: a directory this turn already made unmeasurable (`grownDirs`, an after-side failure with a good before)
|
|
582
|
+
// never fails open again — it was this session's own writes that inflated it, so a later call there (even after a
|
|
583
|
+
// passing verification) must not be waved through just because the tree is now too big to measure.
|
|
584
|
+
const grownDirs = new Set<string>();
|
|
585
|
+
const takeSnapshot = async (dir: string, side: "before" | "after", racySinceMs?: number): Promise<WorkspaceSnapshot> => {
|
|
586
|
+
const nested = dir !== cwd;
|
|
587
|
+
const failOpen = nested && side === "before" && !grownDirs.has(dir);
|
|
588
|
+
const memoApplies = !nested || side === "before";
|
|
589
|
+
if (memoApplies && cappedDirs.has(dir)) {
|
|
590
|
+
const memo: WorkspaceSnapshot = { ok: false, reason: "cap", detail: "workspace exceeded the snapshot cap earlier this turn" };
|
|
591
|
+
return nested && !failOpen ? noteUnmeasured(memo) : memo;
|
|
592
|
+
}
|
|
568
593
|
try {
|
|
569
|
-
const snap = await snapshotter(
|
|
570
|
-
if (!snap.ok && snap.reason === "cap")
|
|
571
|
-
return noteUnmeasured(snap);
|
|
594
|
+
const snap = await snapshotter(dir, racySinceMs);
|
|
595
|
+
if (memoApplies && !snap.ok && snap.reason === "cap") cappedDirs.add(dir);
|
|
596
|
+
return failOpen ? snap : noteUnmeasured(snap);
|
|
572
597
|
} catch (err) {
|
|
573
|
-
|
|
598
|
+
const failure: WorkspaceSnapshot = { ok: false, reason: "error", detail: err instanceof Error ? err.message : String(err) };
|
|
599
|
+
return failOpen ? failure : noteUnmeasured(failure);
|
|
574
600
|
}
|
|
575
601
|
};
|
|
602
|
+
const unmeasurableNested = new Set<string>();
|
|
576
603
|
// bash/computer calls whose mutation status is decided by a workspace diff. Recognized
|
|
577
604
|
// verifiers are never mutations, and observe-only computer actions are read-only.
|
|
578
|
-
|
|
605
|
+
// A shared scratch root ($HOME, /, the system temp dir) is written by unrelated processes (daemon heartbeats,
|
|
606
|
+
// logs, other agents) and holds no project verifier, so a snapshot diff there cannot be attributed to one shell
|
|
607
|
+
// call: bash and computer calls are not bracketed (a built-in write/edit still counts as a definite mutation).
|
|
608
|
+
// The one exception is a bash call whose literal target (`cd ~/work/app && …`, a `cwd`/`subdir` argument) is a
|
|
609
|
+
// project (it, or an ancestor below the scratch root, holds a marker such as `.git`/`package.json`) nested under
|
|
610
|
+
// the scratch cwd: `snapshotTarget` names the outermost such project root and the bracket measures THAT directory,
|
|
611
|
+
// so an edit made through a scratch cwd (even from a subdirectory, `cd app/pkg && echo X > ../src/a.ts`) is not
|
|
612
|
+
// invisible. If that root was already unmeasurable BEFORE the call it is skipped like scratch (fail open); one that
|
|
613
|
+
// becomes unmeasurable DURING the call counts as a mutation. A project directory used as cwd stays gated as
|
|
614
|
+
// before, and there an unmeasurable bracket counts as a mutation.
|
|
615
|
+
const scratchCwd = isSharedScratchRoot(cwd, opts.scratchRoots);
|
|
616
|
+
let scratchNoted = false;
|
|
617
|
+
const snapshotCandidate = (tool: string, args: Record<string, any> | undefined): boolean =>
|
|
579
618
|
(tool === "bash" && !isVerificationSignal(String(args?.command ?? args?.cmd ?? ""))) ||
|
|
580
619
|
(tool === "computer" && computerActionMayAffectState(args));
|
|
620
|
+
// Mutation evidence that came ONLY from a shell/computer snapshot bracket, kept apart from definite mutators
|
|
621
|
+
// (write/edit/eval/…): an unrelated writer in the cwd is indistinguishable from the command's own writes, so a
|
|
622
|
+
// shell-only block gets one correction (naming the paths) instead of the full budget.
|
|
623
|
+
let definiteMutation = false;
|
|
624
|
+
const shellChanges: string[] = [];
|
|
581
625
|
const sandbox = currentSandboxExecution();
|
|
582
626
|
const parentTools: Record<string, true> = { task: true, subagent: true, irc: true, todo: true, sandbox_request: true };
|
|
583
627
|
const maxSteps = opts.maxSteps ?? 15;
|
|
@@ -703,6 +747,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
703
747
|
let verificationCycles = 0;
|
|
704
748
|
let completionGraceUsed = false;
|
|
705
749
|
let doneCorrections = 0;
|
|
750
|
+
// Corrections spent on a shell-only block, counted apart so an earlier todo/hook nudge cannot use up the one
|
|
751
|
+
// correction that names the changed paths.
|
|
752
|
+
let shellOnlyCorrections = 0;
|
|
706
753
|
// Most recent non-empty completion candidate, kept so an exhausted gate still shows it.
|
|
707
754
|
let lastCompletionCandidate = "";
|
|
708
755
|
// F1 (round 4): the run-command of the most recent post-turn hook FAILURE whose
|
|
@@ -1219,11 +1266,23 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1219
1266
|
};
|
|
1220
1267
|
const candidate = stripLeakedReasoningTags(String(toolCalls[0].arguments?.reason ?? ""));
|
|
1221
1268
|
if (candidate.trim()) lastCompletionCandidate = candidate;
|
|
1222
|
-
const doneGate = classifyDoneGate({
|
|
1269
|
+
const doneGate = classifyDoneGate({
|
|
1270
|
+
...evidence,
|
|
1271
|
+
pendingHookFailure,
|
|
1272
|
+
unmeasured: unmeasuredSnapshot ?? undefined,
|
|
1273
|
+
shellChanged: !definiteMutation && shellChanges.length ? shellChanges.slice(-3).join(" | ") : undefined,
|
|
1274
|
+
});
|
|
1223
1275
|
const nudge = await ev.onBeforeDone?.((toolCalls[0].arguments?.reason as string) ?? "", evidence);
|
|
1224
|
-
|
|
1276
|
+
// launch's onBeforeDone restates the mutation gate in its own words when the gate blocks; the engine's message
|
|
1277
|
+
// names the paths, so the restatement is dropped and does not count as an independent reason to keep going.
|
|
1278
|
+
const independentNudge = nudge && !(doneGate.block && nudge.startsWith(MUTATION_NUDGE_PREFIX)) ? nudge : "";
|
|
1279
|
+
const rejection = [doneGate.block ? doneGate.message : "", independentNudge].filter(Boolean).join("\n\n");
|
|
1225
1280
|
if (rejection) {
|
|
1226
|
-
|
|
1281
|
+
// Shell-only evidence (no built-in mutator succeeded) cannot tell the command's writes from an unrelated
|
|
1282
|
+
// process's, so it earns one correction naming the paths; every other block keeps the full budget.
|
|
1283
|
+
const shellOnlyBlock = doneGate.block && doneGate.state !== "done_hook_failing" && !definiteMutation && !independentNudge;
|
|
1284
|
+
const correctionLimit = shellOnlyBlock ? GUARD_LIMITS.MAX_SHELL_ONLY_DONE_CORRECTIONS : GUARD_LIMITS.MAX_DONE_CORRECTIONS;
|
|
1285
|
+
if (shellOnlyBlock ? shellOnlyCorrections >= correctionLimit : doneCorrections >= correctionLimit) {
|
|
1227
1286
|
return finish({
|
|
1228
1287
|
done: false,
|
|
1229
1288
|
steps: step,
|
|
@@ -1232,9 +1291,10 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1232
1291
|
});
|
|
1233
1292
|
}
|
|
1234
1293
|
doneCorrections++;
|
|
1294
|
+
if (shellOnlyBlock) shellOnlyCorrections++;
|
|
1235
1295
|
pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
|
|
1236
1296
|
history.push({ role: "user", content: rejection });
|
|
1237
|
-
ev.onNotice?.(`done deferred — completion correction ${doneCorrections}/${
|
|
1297
|
+
ev.onNotice?.(`done deferred — completion correction ${shellOnlyBlock ? shellOnlyCorrections : doneCorrections}/${correctionLimit}`);
|
|
1238
1298
|
step++;
|
|
1239
1299
|
continue;
|
|
1240
1300
|
}
|
|
@@ -1371,6 +1431,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1371
1431
|
let output: string;
|
|
1372
1432
|
let images: import("../ai/types").ImageAttachment[] | undefined;
|
|
1373
1433
|
let mutationMayHaveOccurred: boolean | undefined;
|
|
1434
|
+
let workspace: import("../ai/types").ToolResultRecord["workspace"];
|
|
1435
|
+
let bracketed = false;
|
|
1374
1436
|
if (tool === "done") {
|
|
1375
1437
|
success = false;
|
|
1376
1438
|
output = "Error: 'done' can only be called as the single tool invocation, not in a batch. Please send 'done' alone.";
|
|
@@ -1393,8 +1455,23 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1393
1455
|
success = false;
|
|
1394
1456
|
output = preHookResult.error + (preHookResult.output ? `\n${preHookResult.output}` : "");
|
|
1395
1457
|
} else {
|
|
1458
|
+
// Which directory this call's bracket measures. Undefined = skip: a scratch cwd with no resolvable
|
|
1459
|
+
// nested project target (`snapshotTarget` returns `cwd` itself exactly when it found none).
|
|
1460
|
+
let bracketDir: string | undefined;
|
|
1461
|
+
if (snapshotCandidate(tool, args)) {
|
|
1462
|
+
const dir = scratchCwd ? snapshotTarget(cwd, tool, args, opts.scratchRoots) : cwd;
|
|
1463
|
+
if (scratchCwd && dir === cwd) {
|
|
1464
|
+
workspace = { mutation: false, skipped: "scratch" };
|
|
1465
|
+
if (!scratchNoted) {
|
|
1466
|
+
scratchNoted = true;
|
|
1467
|
+
ev.onNotice?.(`cwd ${cwd} is a shared scratch directory: shell and computer calls here are not checked for file changes`);
|
|
1468
|
+
}
|
|
1469
|
+
} else {
|
|
1470
|
+
bracketDir = dir;
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1396
1473
|
// Taken after pre-tool hooks so hook writes are never attributed to the call.
|
|
1397
|
-
const before =
|
|
1474
|
+
const before = bracketDir !== undefined ? await takeSnapshot(bracketDir, "before") : undefined;
|
|
1398
1475
|
try {
|
|
1399
1476
|
const onProgress = ev.onToolProgress ? (partial: string) => ev.onToolProgress!(tool, partial) : undefined;
|
|
1400
1477
|
const sandboxedHandler = sandbox && !Object.hasOwn(parentTools, tool) && !SANDBOX_MEDIATED_TOOLS.has(handler)
|
|
@@ -1419,11 +1496,36 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1419
1496
|
((tool === "monitor" || tool === "job") && String(args?.action ?? "").trim().toLowerCase() === "start");
|
|
1420
1497
|
if (explicitSignal || knownMutatingCall) mutationMayHaveOccurred = true;
|
|
1421
1498
|
}
|
|
1422
|
-
if (before) {
|
|
1499
|
+
if (before && bracketDir !== undefined) {
|
|
1423
1500
|
// bash/computer: the workspace diff is the sole mutation authority (overrides
|
|
1424
1501
|
// handler flags and the catch heuristic). Both sides share one racy anchor so
|
|
1425
1502
|
// an unchanged recently-modified file is hashed on both sides or neither.
|
|
1426
|
-
|
|
1503
|
+
const after = before.ok ? await takeSnapshot(bracketDir, "after", before.startedAtMs) : undefined;
|
|
1504
|
+
const failed = !before.ok ? before : after && !after.ok ? after : undefined;
|
|
1505
|
+
const nested = bracketDir !== cwd;
|
|
1506
|
+
if (nested && after && !after.ok) grownDirs.add(bracketDir);
|
|
1507
|
+
if (nested && !before.ok && !grownDirs.has(bracketDir)) {
|
|
1508
|
+
// Fail open ONLY when the nested target was already unmeasurable BEFORE the call (cap, time cap,
|
|
1509
|
+
// error): nothing in it is attributable, and it behaves like today's scratch skip so a big read-only
|
|
1510
|
+
// directory cannot block a finished answer. A failing AFTER snapshot (before was ok) falls through
|
|
1511
|
+
// below and counts as a mutation: files appeared during the call.
|
|
1512
|
+
workspace = { mutation: false, skipped: "scratch", target: describeSnapshotTarget(cwd, bracketDir) };
|
|
1513
|
+
if (!unmeasurableNested.has(bracketDir)) {
|
|
1514
|
+
unmeasurableNested.add(bracketDir);
|
|
1515
|
+
ev.onNotice?.(`nested target ${snapshotTargetLabel(cwd, bracketDir)} could not be measured; treated as scratch`);
|
|
1516
|
+
}
|
|
1517
|
+
} else {
|
|
1518
|
+
mutationMayHaveOccurred = after ? workspaceSnapshotChanged(before, after) : true;
|
|
1519
|
+
bracketed = true;
|
|
1520
|
+
const change = after ? snapshotChangedPaths(before, after) : null;
|
|
1521
|
+
workspace = {
|
|
1522
|
+
mutation: mutationMayHaveOccurred,
|
|
1523
|
+
// Paths stay relative to the bracketed directory; `target` says which directory that was.
|
|
1524
|
+
...(mutationMayHaveOccurred && change ? { changed: formatSnapshotChange(change) } : {}),
|
|
1525
|
+
...(failed && !failed.ok ? { inconclusive: `${failed.reason}: ${failed.detail}` } : {}),
|
|
1526
|
+
...(nested ? { target: describeSnapshotTarget(cwd, bracketDir) } : {}),
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1427
1529
|
}
|
|
1428
1530
|
// A pre-tool hook's structured `additionalContext` rides along with the result
|
|
1429
1531
|
// so the model sees it next to the output it concerns.
|
|
@@ -1431,7 +1533,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1431
1533
|
}
|
|
1432
1534
|
}
|
|
1433
1535
|
}
|
|
1434
|
-
return { success, output, images, mutationMayHaveOccurred };
|
|
1536
|
+
return { success, output, images, mutationMayHaveOccurred, bracketed, workspace };
|
|
1435
1537
|
};
|
|
1436
1538
|
|
|
1437
1539
|
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
@@ -1474,7 +1576,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1474
1576
|
}
|
|
1475
1577
|
}
|
|
1476
1578
|
|
|
1477
|
-
const results: { success: boolean; output: string; executed: boolean; images?: import("../ai/types").ImageAttachment[]; mutationMayHaveOccurred?: boolean }[] = Array.from(
|
|
1579
|
+
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
1580
|
{ length: toolCalls.length },
|
|
1479
1581
|
() => ({ success: false, output: "", executed: false })
|
|
1480
1582
|
);
|
|
@@ -1563,6 +1665,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1563
1665
|
id: idFor(idx),
|
|
1564
1666
|
output: bodies[i],
|
|
1565
1667
|
isError: !results[idx].success,
|
|
1668
|
+
...(results[idx].workspace ? { workspace: results[idx].workspace } : {}),
|
|
1566
1669
|
}));
|
|
1567
1670
|
pushAssistantTurn(history, responseText, reasonBuf, artifactBuf, toolUse);
|
|
1568
1671
|
const resultMsg: Message = { role: "user", content: resultBlocks.join("\n\n"), toolResults };
|
|
@@ -1607,11 +1710,16 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1607
1710
|
if (results[i].success && verificationProducedEvidence(command, String(results[i].output ?? ""))) {
|
|
1608
1711
|
sawVerification = true;
|
|
1609
1712
|
lastVerificationSeq = ++mutVerSeq;
|
|
1610
|
-
|
|
1713
|
+
// A passing verification supersedes the evidence before it: what happens next is judged on its own.
|
|
1714
|
+
definiteMutation = false;
|
|
1715
|
+
shellChanges.length = 0;
|
|
1716
|
+
unmeasuredSnapshot = null;
|
|
1717
|
+
if (!pendingHookFailure) { doneCorrections = 0; shellOnlyCorrections = 0; }
|
|
1611
1718
|
} else {
|
|
1612
1719
|
sawVerification = false;
|
|
1613
1720
|
if (results[i].mutationMayHaveOccurred === true) {
|
|
1614
1721
|
sawMutation = true;
|
|
1722
|
+
definiteMutation = true; // a verifier is never snapshot-bracketed: this flag came from the handler itself
|
|
1615
1723
|
lastMutationSeq = ++mutVerSeq;
|
|
1616
1724
|
}
|
|
1617
1725
|
}
|
|
@@ -1619,6 +1727,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1619
1727
|
}
|
|
1620
1728
|
if (t === "todo" && results[i].success && doneCorrections > 0 && (!sawMutation || (sawVerification && lastMutationSeq < lastVerificationSeq)) && !pendingHookFailure) {
|
|
1621
1729
|
doneCorrections = 0;
|
|
1730
|
+
shellOnlyCorrections = 0;
|
|
1622
1731
|
}
|
|
1623
1732
|
|
|
1624
1733
|
const output = String(results[i].output ?? "").trim();
|
|
@@ -1638,19 +1747,28 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1638
1747
|
if (results[i].success || results[i].mutationMayHaveOccurred === true) {
|
|
1639
1748
|
sawMutation = true;
|
|
1640
1749
|
lastMutationSeq = ++mutVerSeq;
|
|
1750
|
+
if (results[i].bracketed !== true) definiteMutation = true;
|
|
1751
|
+
else if (results[i].workspace?.changed) {
|
|
1752
|
+
const w = results[i].workspace!;
|
|
1753
|
+
// A nested bracket's paths are relative to that directory, so the gate message names it.
|
|
1754
|
+
shellChanges.push(w.target ? `${w.changed} (in ${w.target})` : w.changed!);
|
|
1755
|
+
}
|
|
1641
1756
|
}
|
|
1642
1757
|
} else if (t === "debug" && MUTATING_DEBUG_ACTIONS[String(toolCalls[i].arguments?.action ?? "")] === true) {
|
|
1643
1758
|
sawMutation = true;
|
|
1759
|
+
definiteMutation = true;
|
|
1644
1760
|
lastMutationSeq = ++mutVerSeq;
|
|
1645
1761
|
} else if (t === "monitor" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
|
|
1646
1762
|
// Starting a background monitor can mutate the workspace asynchronously;
|
|
1647
1763
|
// read-only monitor controls (list/tail/await/cancel) do not invalidate verification.
|
|
1648
1764
|
sawMutation = true;
|
|
1765
|
+
definiteMutation = true;
|
|
1649
1766
|
lastMutationSeq = ++mutVerSeq;
|
|
1650
1767
|
} else if (t === "job" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
|
|
1651
1768
|
// Starting a background shell may mutate the workspace asynchronously;
|
|
1652
1769
|
// read-only job controls (list/tail/await/cancel) do not invalidate verification.
|
|
1653
1770
|
sawMutation = true;
|
|
1771
|
+
definiteMutation = true;
|
|
1654
1772
|
lastMutationSeq = ++mutVerSeq;
|
|
1655
1773
|
}
|
|
1656
1774
|
}
|