jeo-code 0.12.18 → 0.12.21
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 +28 -1
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/package.json +1 -1
- package/src/agent/AGENTS.md +2 -1
- package/src/agent/engine.ts +55 -11
- package/src/agent/loop-guards.ts +101 -21
- package/src/agent/sandbox/hands-source.text +590 -590
- package/src/agent/task-tool.ts +4 -1
- package/src/agent/workspace-snapshot.ts +209 -0
- package/src/cli/runner.ts +1 -1
- package/src/commands/launch.ts +32 -13
- package/src/commands/messenger.ts +27 -7
- package/src/commands/update.ts +30 -1
- package/src/messenger/discord-setup.ts +42 -72
- package/src/messenger/gateway.ts +20 -8
- package/src/messenger/launcher.ts +68 -0
- package/src/messenger/runtime.ts +255 -0
- package/src/messenger/service.ts +40 -2
- package/src/messenger/session-pool.ts +50 -4
- package/src/messenger/types.ts +8 -0
- package/src/util/slack.ts +40 -0
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,30 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
11
|
|
|
12
|
+
## [0.12.21] - 2026-11-04
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **Completion gate no longer blocks read-only turns** - a `bash` or `computer` call now counts as a workspace mutation only when a host-side snapshot of the session workspace taken right before and after the call differs (`.git`, `.jeo` and `node_modules` excluded; content changes to an already-modified file are detected). Read-only commands such as `date`, `ls -a`, `osascript` or opening a browser tab complete without a test run. A failed or over-cap snapshot still counts as a mutation, and `write`, `edit`, mutating `debug` actions and `job`/`monitor` starts are unchanged.
|
|
16
|
+
- **Wrapped verification commands are recognized** - `cd <dir> && <verifier>`, `VAR=value` and `env -u NAME` prefixes, `timeout N`, `2>&1`, `uv run` / `poetry run` / `pipenv run`, absolute or relative paths to a verifier binary, and the `rtk` / `rtk test` wrappers now count as verification. Pipes, `;`, `||`, other redirections, command substitution and the existing mutating or non-executing flag exclusions are still rejected.
|
|
17
|
+
- **Unverified state no longer leaks across turns** - an earlier turn's unverified mutation blocks completion of later turns only while a `/goal` is active; ordinary turns and messenger thread follow-ups are judged on their own evidence.
|
|
18
|
+
- **Exhausted completions keep the answer** - when the gate rejects `done` after the maximum corrections, the result stays incomplete but shows the model's last answer under an `UNVERIFIED` marker with the gate reason, instead of replacing it. Messenger workers send that marked answer with a failure reaction (new `JEO_MESSENGER_UNVERIFIED` worker line) instead of the generic "The request could not be completed", and keep serving follow-ups. Route fallback, failover hints and the task tool's rate-limit reroute ignore rejected completions.
|
|
19
|
+
|
|
20
|
+
## [0.12.20] - 2026-11-04
|
|
21
|
+
|
|
22
|
+
The GitHub-only 0.12.19 release was not published to npm (its publish run failed a welcome-banner test); its changes, listed under [0.12.19], are included in this release.
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **Welcome banner test** — the resize test measured padded rows in UTF-16 units, so a wide (CJK or emoji) character in the latest changelog item made the publish gate fail even though the banner rendered correctly; it now uses the renderer's display-width measure.
|
|
26
|
+
|
|
27
|
+
## [0.12.19] - 2026-11-04
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- **Managed messenger runtime** — a Discord messenger profile no longer runs whatever `jeo` entry happened to execute setup. When `jeo` is a package install, `jeo messenger setup` pins a private copy of the published package under `<profile>/runtime/<version>` (jeopi's `runtime-<version>` layout) and points the launcher at it; `jeo update` provisions the new version there, rewrites the launcher, restarts the installed service and prunes runtimes older than the predecessor kept for rollback. `jeo messenger update [--config <file>]` performs the same refresh on demand, and `jeo update --json` reports `messengersRefreshed` when any profile is registered. Setup run from a source checkout or compiled binary keeps an external launcher that updates leave alone, and says so. Re-running setup without `--service` now restarts an already installed service so the new launcher takes effect.
|
|
31
|
+
- **Messenger operator diagnostics** — `jeo messenger run` now logs each failed request to stderr with its stage (`prepare`, `reply`, `deliver`), an 8-character conversation id and the cause, plus bounded worker diagnostics (non-protocol worker stderr lines and abnormal exits). Users still receive only the generic failure text; message content and platform tokens never reach the log. Previously a worker that could not start (for example after the jeo checkout embedded in `run-discord.sh` was moved) produced only a `Failed` status in Discord and nothing in the service log.
|
|
32
|
+
|
|
33
|
+
### Verified (live Discord, 2026-11-04)
|
|
34
|
+
- A request-channel mention of the jeo-code bot failed with `The request could not be completed` while the launchd service ran from a deleted checkout (`error: Module not found ".../src/cli.ts"` reproduced locally). Re-running `jeo messenger setup --config ~/.jeo/discord/messenger.json --service --no-dm` from the live checkout regenerated the launcher, restarted `dev.jeo.messenger.discord`, and a follow-up mention returned the exact requested token in a new request thread with the hourglass-then-check reactions.
|
|
35
|
+
|
|
12
36
|
## [0.12.18] - 2026-11-04
|
|
13
37
|
|
|
14
38
|
### Changed
|
|
@@ -2700,7 +2724,10 @@ _Initial release._
|
|
|
2700
2724
|
### Added
|
|
2701
2725
|
- Initial jeo-code agent and CLI.
|
|
2702
2726
|
|
|
2703
|
-
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.
|
|
2727
|
+
[Unreleased]: https://github.com/akillness/jeo-code/compare/v0.12.21...HEAD
|
|
2728
|
+
[0.12.21]: https://github.com/akillness/jeo-code/releases/tag/v0.12.21
|
|
2729
|
+
[0.12.20]: https://github.com/akillness/jeo-code/releases/tag/v0.12.20
|
|
2730
|
+
[0.12.19]: https://github.com/akillness/jeo-code/releases/tag/v0.12.19
|
|
2704
2731
|
[0.12.18]: https://github.com/akillness/jeo-code/releases/tag/v0.12.18
|
|
2705
2732
|
[0.12.17]: https://github.com/akillness/jeo-code/releases/tag/v0.12.17
|
|
2706
2733
|
[0.12.16]: https://github.com/akillness/jeo-code/releases/tag/v0.12.16
|
package/README.ja.md
CHANGED
|
@@ -447,10 +447,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
447
447
|
|
|
448
448
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
449
449
|
- **[Unreleased]**
|
|
450
|
+
- **[0.12.21]** (2026-11-04)
|
|
451
|
+
- **[0.12.20]** (2026-11-04)
|
|
452
|
+
- **[0.12.19]** (2026-11-04)
|
|
450
453
|
- **[0.12.18]** (2026-11-04)
|
|
451
|
-
- **[0.12.17]** (2026-11-04)
|
|
452
|
-
- **[0.12.16]** (2026-11-04)
|
|
453
|
-
- **[0.12.15]** (2026-11-03)
|
|
454
454
|
|
|
455
455
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
456
456
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -502,10 +502,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
502
502
|
|
|
503
503
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
504
504
|
- **[Unreleased]**
|
|
505
|
+
- **[0.12.21]** (2026-11-04)
|
|
506
|
+
- **[0.12.20]** (2026-11-04)
|
|
507
|
+
- **[0.12.19]** (2026-11-04)
|
|
505
508
|
- **[0.12.18]** (2026-11-04)
|
|
506
|
-
- **[0.12.17]** (2026-11-04)
|
|
507
|
-
- **[0.12.16]** (2026-11-04)
|
|
508
|
-
- **[0.12.15]** (2026-11-03)
|
|
509
509
|
|
|
510
510
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
511
511
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -491,10 +491,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
491
491
|
|
|
492
492
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
493
493
|
- **[Unreleased]**
|
|
494
|
+
- **[0.12.21]** (2026-11-04)
|
|
495
|
+
- **[0.12.20]** (2026-11-04)
|
|
496
|
+
- **[0.12.19]** (2026-11-04)
|
|
494
497
|
- **[0.12.18]** (2026-11-04)
|
|
495
|
-
- **[0.12.17]** (2026-11-04)
|
|
496
|
-
- **[0.12.16]** (2026-11-04)
|
|
497
|
-
- **[0.12.15]** (2026-11-03)
|
|
498
498
|
|
|
499
499
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
500
500
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -445,10 +445,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
445
445
|
|
|
446
446
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
447
447
|
- **[Unreleased]**
|
|
448
|
+
- **[0.12.21]** (2026-11-04)
|
|
449
|
+
- **[0.12.20]** (2026-11-04)
|
|
450
|
+
- **[0.12.19]** (2026-11-04)
|
|
448
451
|
- **[0.12.18]** (2026-11-04)
|
|
449
|
-
- **[0.12.17]** (2026-11-04)
|
|
450
|
-
- **[0.12.16]** (2026-11-04)
|
|
451
|
-
- **[0.12.15]** (2026-11-03)
|
|
452
452
|
|
|
453
453
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
454
454
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/AGENTS.md
CHANGED
|
@@ -29,7 +29,8 @@ The core runtime loop, tool registry, session management, and state persistence
|
|
|
29
29
|
| `hooks.ts` | Brief description of purpose |
|
|
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 (`isVerificationSignal`, `repeatHint`, `classifyDoneGate`
|
|
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
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 |
|
|
34
35
|
| `memory-okf.ts` | OKF v0.1 format layer: frontmatter parse/serialize, concept IDs, conformance validation |
|
|
35
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,7 +32,8 @@ 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 } from "./loop-guards";
|
|
35
|
+
import { GUARD_LIMITS, isVerificationSignal, verificationProducedEvidence, repeatHint, classifyDoneGate, formatRejectedCompletion } from "./loop-guards";
|
|
36
|
+
import { defaultWorkspaceSnapshotter, workspaceSnapshotChanged, type WorkspaceSnapshot, type WorkspaceSnapshotter } from "./workspace-snapshot";
|
|
36
37
|
import { stripLeakedReasoningTags } from "../ai/think-tags";
|
|
37
38
|
|
|
38
39
|
const MID_TASK_PROSE_RE =
|
|
@@ -407,6 +408,9 @@ export interface AgentLoopOptions {
|
|
|
407
408
|
* just the max-token ceiling. When unset the model-manager falls back to the global config. */
|
|
408
409
|
reasoningEffort?: import("../ai/types").CallOptions["reasoningEffort"];
|
|
409
410
|
tools?: Record<string, ToolHandler>;
|
|
411
|
+
/** Host-side workspace fingerprint used to decide whether a bash/computer call mutated the
|
|
412
|
+
* workspace. Defaults to defaultWorkspaceSnapshotter; tests inject fakes. */
|
|
413
|
+
workspaceSnapshot?: WorkspaceSnapshotter;
|
|
410
414
|
signal?: AbortSignal;
|
|
411
415
|
events?: AgentLoopEvents;
|
|
412
416
|
/** Step-budget overrides (gjc-style retry flow). `{ maxExtensions: 0 }` restores the
|
|
@@ -461,6 +465,9 @@ export interface AgentLoopResult {
|
|
|
461
465
|
* recover from (repeated/cycled/consecutively-failing calls). Lets the caller
|
|
462
466
|
* capture the stall into failure memory; `undefined` for done/budget/cancelled. */
|
|
463
467
|
stopClass?: "consecutive_failure" | "cycle" | "repeat";
|
|
468
|
+
/** True when the turn ended because completion was rejected MAX_DONE_CORRECTIONS times; doneReason then
|
|
469
|
+
* carries the last candidate answer under the UNVERIFIED marker. Never set together with done:true. */
|
|
470
|
+
completionRejected?: boolean;
|
|
464
471
|
}
|
|
465
472
|
|
|
466
473
|
|
|
@@ -538,6 +545,25 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
538
545
|
history.unshift({ role: "system", content: opts.systemPrompt });
|
|
539
546
|
}
|
|
540
547
|
const tools = opts.tools ?? DEFAULT_TOOLS;
|
|
548
|
+
const snapshotter: WorkspaceSnapshotter = opts.workspaceSnapshot ?? defaultWorkspaceSnapshotter;
|
|
549
|
+
// Turn-scoped memo: once the workspace is over the snapshot cap, gated calls count as
|
|
550
|
+
// mutations without re-walking a tree that is known to be too large.
|
|
551
|
+
let snapshotCapped = false;
|
|
552
|
+
const takeSnapshot = async (racySinceMs?: number): Promise<WorkspaceSnapshot> => {
|
|
553
|
+
if (snapshotCapped) return { ok: false, reason: "cap", detail: "workspace exceeded the snapshot cap earlier this turn" };
|
|
554
|
+
try {
|
|
555
|
+
const snap = await snapshotter(cwd, racySinceMs);
|
|
556
|
+
if (!snap.ok && snap.reason === "cap") snapshotCapped = true;
|
|
557
|
+
return snap;
|
|
558
|
+
} catch (err) {
|
|
559
|
+
return { ok: false, reason: "error", detail: err instanceof Error ? err.message : String(err) };
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
// bash/computer calls whose mutation status is decided by a workspace diff. Recognized
|
|
563
|
+
// verifiers are never mutations, and observe-only computer actions are read-only.
|
|
564
|
+
const snapshotGated = (tool: string, args: Record<string, any> | undefined): boolean =>
|
|
565
|
+
(tool === "bash" && !isVerificationSignal(String(args?.command ?? args?.cmd ?? ""))) ||
|
|
566
|
+
(tool === "computer" && computerActionMayAffectState(args));
|
|
541
567
|
const sandbox = currentSandboxExecution();
|
|
542
568
|
const parentTools: Record<string, true> = { task: true, subagent: true, irc: true, todo: true, sandbox_request: true };
|
|
543
569
|
const maxSteps = opts.maxSteps ?? 15;
|
|
@@ -663,6 +689,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
663
689
|
let verificationCycles = 0;
|
|
664
690
|
let completionGraceUsed = false;
|
|
665
691
|
let doneCorrections = 0;
|
|
692
|
+
// Most recent non-empty completion candidate, kept so an exhausted gate still shows it.
|
|
693
|
+
let lastCompletionCandidate = "";
|
|
666
694
|
// F1 (round 4): the run-command of the most recent post-turn hook FAILURE whose
|
|
667
695
|
// diagnostics the model saw but has not yet resolved (a later clean hook run
|
|
668
696
|
// clears it). The done guard treats this as "verification missing" — the hook
|
|
@@ -1070,6 +1098,7 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1070
1098
|
(hasTools && looksLikeMidTaskProse(trimmed));
|
|
1071
1099
|
if (trimmed && (parseFailures > MAX_PARSE_BOUNCES || (!trimmed.includes("{") && !midTurnInProgress))) {
|
|
1072
1100
|
const candidateReason = stripLeakedReasoningTags(trimmed) || trimmed;
|
|
1101
|
+
if (candidateReason.trim()) lastCompletionCandidate = candidateReason;
|
|
1073
1102
|
if (parseFailures <= MAX_PARSE_BOUNCES && ev.onBeforeDone) {
|
|
1074
1103
|
const preCheck = await ev.onBeforeDone(candidateReason, {
|
|
1075
1104
|
sawMutation,
|
|
@@ -1078,7 +1107,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1078
1107
|
});
|
|
1079
1108
|
if (preCheck) {
|
|
1080
1109
|
if (doneCorrections >= GUARD_LIMITS.MAX_DONE_CORRECTIONS) {
|
|
1081
|
-
return finish({
|
|
1110
|
+
return finish({
|
|
1111
|
+
done: false,
|
|
1112
|
+
steps: step,
|
|
1113
|
+
completionRejected: true,
|
|
1114
|
+
doneReason: formatRejectedCompletion(lastCompletionCandidate, doneCorrections, preCheck),
|
|
1115
|
+
});
|
|
1082
1116
|
}
|
|
1083
1117
|
doneCorrections++;
|
|
1084
1118
|
ev.onNotice?.(`done deferred — completion correction ${doneCorrections}/${GUARD_LIMITS.MAX_DONE_CORRECTIONS}`);
|
|
@@ -1169,12 +1203,19 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1169
1203
|
sawVerification,
|
|
1170
1204
|
verificationStale: sawMutation && lastMutationSeq > lastVerificationSeq,
|
|
1171
1205
|
};
|
|
1206
|
+
const candidate = stripLeakedReasoningTags(String(toolCalls[0].arguments?.reason ?? ""));
|
|
1207
|
+
if (candidate.trim()) lastCompletionCandidate = candidate;
|
|
1172
1208
|
const doneGate = classifyDoneGate({ ...evidence, pendingHookFailure });
|
|
1173
1209
|
const nudge = await ev.onBeforeDone?.((toolCalls[0].arguments?.reason as string) ?? "", evidence);
|
|
1174
1210
|
const rejection = [doneGate.block ? doneGate.message : "", nudge].filter(Boolean).join("\n\n");
|
|
1175
1211
|
if (rejection) {
|
|
1176
1212
|
if (doneCorrections >= GUARD_LIMITS.MAX_DONE_CORRECTIONS) {
|
|
1177
|
-
return finish({
|
|
1213
|
+
return finish({
|
|
1214
|
+
done: false,
|
|
1215
|
+
steps: step,
|
|
1216
|
+
completionRejected: true,
|
|
1217
|
+
doneReason: formatRejectedCompletion(lastCompletionCandidate, doneCorrections, rejection),
|
|
1218
|
+
});
|
|
1178
1219
|
}
|
|
1179
1220
|
doneCorrections++;
|
|
1180
1221
|
pushAssistantTurn(history, responseText, reasonBuf, artifactBuf);
|
|
@@ -1338,6 +1379,8 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1338
1379
|
success = false;
|
|
1339
1380
|
output = preHookResult.error + (preHookResult.output ? `\n${preHookResult.output}` : "");
|
|
1340
1381
|
} else {
|
|
1382
|
+
// Taken after pre-tool hooks so hook writes are never attributed to the call.
|
|
1383
|
+
const before = snapshotGated(tool, args) ? await takeSnapshot() : undefined;
|
|
1341
1384
|
try {
|
|
1342
1385
|
const onProgress = ev.onToolProgress ? (partial: string) => ev.onToolProgress!(tool, partial) : undefined;
|
|
1343
1386
|
const sandboxedHandler = sandbox && !Object.hasOwn(parentTools, tool) && !SANDBOX_MEDIATED_TOOLS.has(handler)
|
|
@@ -1362,6 +1405,12 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1362
1405
|
((tool === "monitor" || tool === "job") && String(args?.action ?? "").trim().toLowerCase() === "start");
|
|
1363
1406
|
if (explicitSignal || knownMutatingCall) mutationMayHaveOccurred = true;
|
|
1364
1407
|
}
|
|
1408
|
+
if (before) {
|
|
1409
|
+
// bash/computer: the workspace diff is the sole mutation authority (overrides
|
|
1410
|
+
// handler flags and the catch heuristic). Both sides share one racy anchor so
|
|
1411
|
+
// an unchanged recently-modified file is hashed on both sides or neither.
|
|
1412
|
+
mutationMayHaveOccurred = before.ok ? workspaceSnapshotChanged(before, await takeSnapshot(before.startedAtMs)) : true;
|
|
1413
|
+
}
|
|
1365
1414
|
}
|
|
1366
1415
|
}
|
|
1367
1416
|
}
|
|
@@ -1528,6 +1577,9 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1528
1577
|
// mutation evidence before it, but anything after that verification remains
|
|
1529
1578
|
// stale. Verification commands are shell calls, so do not let a wrapper's
|
|
1530
1579
|
// mutationMayHaveOccurred flag turn the verifier itself into a later mutation.
|
|
1580
|
+
// Non-verifier bash and state-changing computer calls carry an explicit
|
|
1581
|
+
// mutationMayHaveOccurred from the workspace snapshot bracket in executeTool,
|
|
1582
|
+
// which flows through the structured-mutation branch below.
|
|
1531
1583
|
for (let i = 0; i < toolCalls.length; i++) {
|
|
1532
1584
|
if (!results[i].executed) continue;
|
|
1533
1585
|
const t = toolCalls[i].tool;
|
|
@@ -1573,10 +1625,6 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1573
1625
|
} else if (t === "debug" && MUTATING_DEBUG_ACTIONS[String(toolCalls[i].arguments?.action ?? "")] === true) {
|
|
1574
1626
|
sawMutation = true;
|
|
1575
1627
|
lastMutationSeq = ++mutVerSeq;
|
|
1576
|
-
} else if (t === "computer" && computerActionMayAffectState(toolCalls[i].arguments)) {
|
|
1577
|
-
// Failed UI actions may have partially changed external state.
|
|
1578
|
-
sawMutation = true;
|
|
1579
|
-
lastMutationSeq = ++mutVerSeq;
|
|
1580
1628
|
} else if (t === "monitor" && String(toolCalls[i].arguments?.action ?? "").trim().toLowerCase() === "start") {
|
|
1581
1629
|
// Starting a background monitor can mutate the workspace asynchronously;
|
|
1582
1630
|
// read-only monitor controls (list/tail/await/cancel) do not invalidate verification.
|
|
@@ -1587,10 +1635,6 @@ export async function runAgentLoop(history: Message[], opts: AgentLoopOptions):
|
|
|
1587
1635
|
// read-only job controls (list/tail/await/cancel) do not invalidate verification.
|
|
1588
1636
|
sawMutation = true;
|
|
1589
1637
|
lastMutationSeq = ++mutVerSeq;
|
|
1590
|
-
} else if (t === "bash") {
|
|
1591
|
-
// Even simple commands can run shell startup hooks or inherited functions.
|
|
1592
|
-
sawMutation = true;
|
|
1593
|
-
lastMutationSeq = ++mutVerSeq;
|
|
1594
1638
|
}
|
|
1595
1639
|
}
|
|
1596
1640
|
await ev.onEvidence?.({
|
package/src/agent/loop-guards.ts
CHANGED
|
@@ -77,40 +77,120 @@ const NONEXECUTING_VERIFY_FLAG_RE = /(?:^|\s)(?:--help|-h|--version|-version|--l
|
|
|
77
77
|
const MUTATING_VERIFY_FLAG_RE = /(?:^|\s)(?:--fix|--write|--update|-u|--update[-_]snapshots?|--updateSnapshots?|--snapshot[-_]updates?)(?=$|\s|=)/i;
|
|
78
78
|
const MUTATING_VERIFY_SCRIPT_RE = /^(?:bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?[\w-]+:[^\s]*(?:fix|update|write|deploy|publish)[^\s]*/i;
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
80
|
+
// ── Verifier command grammar ──────────────────────────────────────────────────
|
|
81
|
+
// command := segment ( "&&" segment )* after removing every standalone `2>&1` token
|
|
82
|
+
// segment[0] := cdSeg | verifierSeg cdSeg only as the first segment, and only when >=1 segment follows
|
|
83
|
+
// segment[i] := verifierSeg (i >= 1)
|
|
84
|
+
// verifierSeg:= prefix* core prefixes: `NAME=VALUE`, `env [-u NAME]... [NAME=VALUE]...`,
|
|
85
|
+
// `timeout N[s|m]`, `uv|poetry|pipenv run`, `rtk [test]`
|
|
86
|
+
// core := first `/`-bearing token replaced by its basename, then VERIFY_SIGNAL_RE with no
|
|
87
|
+
// non-executing / mutating flag, no mutating script, and no short no-op form.
|
|
88
|
+
// Only plain tokens are accepted: `$`, backticks, quotes and parentheses never match the token classes
|
|
89
|
+
// and are rejected by the global metacharacter check. Every prefix propagates the verifier's exit code.
|
|
90
|
+
const STDERR_MERGE_RE = /(^|\s)2>&1(?=\s|$)/g;
|
|
91
|
+
const ASSIGN_PREFIX_RE = /^([A-Za-z_][A-Za-z0-9_]*)=[A-Za-z0-9_./:@+,=%-]*\s+/;
|
|
92
|
+
const ENV_PREFIX_RE = /^env(?:\s+-u\s+[A-Za-z_][A-Za-z0-9_]*)*(?:\s+([A-Za-z_][A-Za-z0-9_]*)=[A-Za-z0-9_./:@+,=%-]*)*\s+/;
|
|
93
|
+
const TIMEOUT_PREFIX_RE = /^timeout\s+\d+[sm]?\s+/;
|
|
94
|
+
const RUNNER_PREFIX_RE = /^(?:uv|poetry|pipenv)\s+run\s+/;
|
|
95
|
+
const RTK_PREFIX_RE = /^rtk\s+(?:test\s+)?/;
|
|
96
|
+
const CD_SEGMENT_RE = /^cd\s+[A-Za-z0-9_./:@+,=~-]+$/;
|
|
97
|
+
const BINARY_PATH_RE = /^([A-Za-z0-9_.@+-]*\/[A-Za-z0-9_./@+-]*)(?=\s|$)/;
|
|
98
|
+
/** Env names that are the env-form of an update/snapshot flag (parity with MUTATING_VERIFY_FLAG_RE). */
|
|
99
|
+
const MUTATING_VERIFY_ENV_RE = /UPDATE|SNAPSHOT|GOLDEN/i;
|
|
100
|
+
const ENV_ASSIGNMENT_NAME_RE = /([A-Za-z_][A-Za-z0-9_]*)=/g;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Normalized verifier invocations (cores) of a command, or null when the command is not a
|
|
104
|
+
* pure recognized verification. Wrapper prefixes and a leading `cd <path> &&` are stripped,
|
|
105
|
+
* so flag/script exclusions are judged on the verifier itself, never on the wrappers.
|
|
106
|
+
*/
|
|
107
|
+
export function verifierCores(cmd: string): string[] | null {
|
|
108
|
+
const command = cmd.trim().replace(STDERR_MERGE_RE, "$1").trim();
|
|
109
|
+
if (!command) return null;
|
|
110
|
+
// Pipes, `;`, `||`, redirections, substitution, grouping and multi-line scripts.
|
|
111
|
+
if (/[;&|<>`$(){}\n\r]/.test(command.replace(/&&/g, " "))) return null;
|
|
112
|
+
const segments = command.split("&&").map(s => s.trim());
|
|
113
|
+
if (segments.some(s => !s)) return null;
|
|
114
|
+
const start = segments.length > 1 && CD_SEGMENT_RE.test(segments[0]) ? 1 : 0;
|
|
115
|
+
const cores: string[] = [];
|
|
116
|
+
for (const segment of segments.slice(start)) {
|
|
117
|
+
const core = verifierCore(segment);
|
|
118
|
+
if (!core) return null;
|
|
119
|
+
cores.push(core);
|
|
120
|
+
}
|
|
121
|
+
return cores.length ? cores : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function verifierCore(segment: string): string | null {
|
|
125
|
+
let rest = segment;
|
|
126
|
+
for (;;) { // terminates: every successful match strictly shortens `rest`
|
|
127
|
+
const assign = ASSIGN_PREFIX_RE.exec(rest);
|
|
128
|
+
if (assign) {
|
|
129
|
+
if (MUTATING_VERIFY_ENV_RE.test(assign[1])) return null;
|
|
130
|
+
rest = rest.slice(assign[0].length);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const env = ENV_PREFIX_RE.exec(rest);
|
|
134
|
+
if (env) {
|
|
135
|
+
if ([...env[0].matchAll(ENV_ASSIGNMENT_NAME_RE)].some(m => MUTATING_VERIFY_ENV_RE.test(m[1]))) return null;
|
|
136
|
+
rest = rest.slice(env[0].length);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const other = TIMEOUT_PREFIX_RE.exec(rest) ?? RUNNER_PREFIX_RE.exec(rest) ?? RTK_PREFIX_RE.exec(rest);
|
|
140
|
+
if (other) {
|
|
141
|
+
rest = rest.slice(other[0].length);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
const bin = BINARY_PATH_RE.exec(rest);
|
|
147
|
+
if (bin) rest = bin[1].slice(bin[1].lastIndexOf("/") + 1) + rest.slice(bin[1].length);
|
|
148
|
+
const shortNoOp = /^(?:(?:npx|bunx|bun|npm|pnpm|yarn)\s+(?:(?:run|exec|x|dlx)\s+)?)?tsc\b.*(?:^|\s)-[vV](?=\s|$)/.test(rest)
|
|
149
|
+
|| /^(?:make|just|go)\s+.*(?:^|\s)-n(?=\s|$)/.test(rest);
|
|
150
|
+
return !shortNoOp && VERIFY_SIGNAL_RE.test(rest) && !NONEXECUTING_VERIFY_FLAG_RE.test(rest)
|
|
151
|
+
&& !MUTATING_VERIFY_FLAG_RE.test(rest) && !MUTATING_VERIFY_SCRIPT_RE.test(rest) ? rest : null;
|
|
86
152
|
}
|
|
87
153
|
|
|
88
154
|
/**
|
|
89
|
-
* True only when the command itself is a recognized verifier invocation (or a
|
|
90
|
-
*
|
|
91
|
-
* is never evidence: arbitrary commands can
|
|
155
|
+
* True only when the command itself is a recognized verifier invocation (or a pure `&&`
|
|
156
|
+
* chain of them, optionally led by `cd <path> &&` and wrapped in exit-code-propagating
|
|
157
|
+
* prefixes — see {@link verifierCores}). Output is never evidence: arbitrary commands can
|
|
158
|
+
* print words such as "tests passed".
|
|
92
159
|
*/
|
|
93
160
|
export function isVerificationSignal(cmd: string): boolean {
|
|
94
|
-
|
|
95
|
-
if (!command) return false;
|
|
96
|
-
const withoutAndChain = command.replace(/&&/g, " ");
|
|
97
|
-
if (/[;&|<>`$(){}\n\r]/.test(withoutAndChain)) return false;
|
|
98
|
-
const segments = command.split("&&").map(s => s.trim());
|
|
99
|
-
return segments.length > 0 && segments.every(isSingleVerificationSegment);
|
|
161
|
+
return verifierCores(cmd) !== null;
|
|
100
162
|
}
|
|
101
163
|
|
|
102
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Require test-run evidence for Go and Cargo's recognized text output. Judged on the
|
|
166
|
+
* verifier cores, so a wrapped `timeout 60 go test ./...` or `cd x && cargo test` still
|
|
167
|
+
* needs real test evidence.
|
|
168
|
+
*/
|
|
103
169
|
export function verificationProducedEvidence(cmd: string, output: string): boolean {
|
|
104
|
-
|
|
170
|
+
const cores = verifierCores(cmd) ?? [cmd.trim()];
|
|
171
|
+
if (cores.some(c => /^go\s+test(?:\s|$)/.test(c))) {
|
|
105
172
|
const summaries = output.split(/\r?\n/).filter(line => /^(?:ok|\?)\s+/.test(line));
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
if (/^cargo\s+test(?:\s|$)/.test(cmd.trim())) {
|
|
109
|
-
return [...output.matchAll(/test result: ok\.\s+(\d+) passed/g)].some(match => Number(match[1]) > 0);
|
|
173
|
+
if (!summaries.some(line => !/\[no tests to run\]|\[no test files\]/i.test(line))) return false;
|
|
110
174
|
}
|
|
175
|
+
if (cores.some(c => /^cargo\s+test(?:\s|$)/.test(c))
|
|
176
|
+
&& ![...output.matchAll(/test result: ok\.\s+(\d+) passed/g)].some(match => Number(match[1]) > 0)) return false;
|
|
111
177
|
return true;
|
|
112
178
|
}
|
|
113
179
|
|
|
180
|
+
/** Marker that prefixes a candidate answer the completion gate never accepted. */
|
|
181
|
+
export const UNVERIFIED_MARKER = "UNVERIFIED";
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Result text when completion was rejected GUARD_LIMITS.MAX_DONE_CORRECTIONS times. Keeps the
|
|
185
|
+
* model's last candidate answer, never presents it as accepted, and keeps the gate reason.
|
|
186
|
+
*/
|
|
187
|
+
export function formatRejectedCompletion(candidate: string, corrections: number, rejection: string): string {
|
|
188
|
+
const stop = `Stopped: completion rejected after ${corrections} corrections. ${rejection}`;
|
|
189
|
+
const answer = candidate.trim();
|
|
190
|
+
if (!answer) return stop;
|
|
191
|
+
return `${UNVERIFIED_MARKER} - the completion gate did not accept this answer; treat it as unconfirmed.\n\n${answer}\n\n${stop}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
114
194
|
/**
|
|
115
195
|
* Result-aware repeat nudge: tells the model WHY repeating the call won't help and what
|
|
116
196
|
* to try instead, tailored to the repeated tool and its last actual result. When the
|