jeo-code 0.12.20 → 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 +10 -1
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- 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/commands/launch.ts +32 -13
- package/src/messenger/gateway.ts +9 -6
- package/src/messenger/session-pool.ts +16 -1
- package/src/messenger/types.ts +8 -0
package/src/agent/task-tool.ts
CHANGED
|
@@ -570,7 +570,10 @@ export async function runSubagentOnce(
|
|
|
570
570
|
// `routing.enabled` off = the model is FIXED: no failover reroute at all (the
|
|
571
571
|
// engine bail predicates above are withheld too, so a 429 rides its normal
|
|
572
572
|
// backoff ladder instead of bailing early with nowhere to go).
|
|
573
|
-
for (let attempt = 0; failoverEnabled && attempt < MAX_SUBAGENT_REROUTES; attempt++) {
|
|
573
|
+
for (let attempt = 0; failoverEnabled && attempt < MAX_SUBAGENT_REROUTES; attempt++) {
|
|
574
|
+
// An exhausted completion gate puts the model's own candidate answer in doneReason;
|
|
575
|
+
// its text (e.g. "HTTP 429") is not a provider failure and must never reroute.
|
|
576
|
+
const rateLimited = !result.done && !result.completionRejected && isRateLimitError(new Error(result.doneReason ?? ""));
|
|
574
577
|
const safetyBail = !result.done && !rateLimited && isSafetyFallbackBail(result.doneReason);
|
|
575
578
|
if (!rateLimited && !safetyBail) break;
|
|
576
579
|
const previousModel = activeModel;
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-side workspace fingerprint. The engine brackets bash/computer calls with two snapshots and
|
|
3
|
+
* counts the call as a mutation only when they differ (workspaceSnapshotChanged). Failures and caps
|
|
4
|
+
* are reported as `ok:false`, which always counts as a mutation (safe side).
|
|
5
|
+
*
|
|
6
|
+
* Entry strings folded into an order-independent digest:
|
|
7
|
+
* - directory: `d:<rel>` (presence only; directory mtimes are ignored so creating an excluded
|
|
8
|
+
* child such as `.jeo/` or `node_modules/` never counts)
|
|
9
|
+
* - file/link/other: `<f|l|o>:<rel>:<size>:<mtimeNs>:<ctimeNs>:<ino>` from lstat (never followed)
|
|
10
|
+
* - link: `:t<readlink target>`
|
|
11
|
+
* - regular file with mtime >= anchor - racyWindowMs: `:h<wyhash(content)>` (racy-clean guard
|
|
12
|
+
* against coarse timestamps; the after-snapshot reuses the before-snapshot's anchor)
|
|
13
|
+
* - FIFO/socket/device: stat only, never opened
|
|
14
|
+
*/
|
|
15
|
+
import * as fs from "node:fs/promises";
|
|
16
|
+
import { constants } from "node:fs";
|
|
17
|
+
import type { BigIntStats, Dirent } from "node:fs";
|
|
18
|
+
import type { FileHandle } from "node:fs/promises";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
|
|
21
|
+
export interface WorkspaceSnapshotLimits {
|
|
22
|
+
/** Max fingerprinted entries (files+dirs+links) before giving up (counts as a mutation). */
|
|
23
|
+
maxEntries: number;
|
|
24
|
+
/** Wall-clock budget per snapshot (ms). */
|
|
25
|
+
maxMs: number;
|
|
26
|
+
/** Files with mtime within this window of the snapshot start are content-hashed too. */
|
|
27
|
+
racyWindowMs: number;
|
|
28
|
+
/** Per-file and per-snapshot byte caps for racy content hashing. */
|
|
29
|
+
racyMaxFileBytes: number;
|
|
30
|
+
racyMaxTotalBytes: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const WORKSPACE_SNAPSHOT_LIMITS: Readonly<WorkspaceSnapshotLimits> = Object.freeze({
|
|
34
|
+
maxEntries: 50_000,
|
|
35
|
+
maxMs: 2_000,
|
|
36
|
+
racyWindowMs: 2_000,
|
|
37
|
+
racyMaxFileBytes: 1024 * 1024,
|
|
38
|
+
racyMaxTotalBytes: 8 * 1024 * 1024,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Directory NAMES skipped at any depth (A2): exactly these three. */
|
|
42
|
+
export const WORKSPACE_SNAPSHOT_EXCLUDED_DIRS: ReadonlySet<string> = new Set([".git", ".jeo", "node_modules"]);
|
|
43
|
+
|
|
44
|
+
export type WorkspaceSnapshot =
|
|
45
|
+
| { ok: true; digest: bigint; entries: number; elapsedMs: number; startedAtMs: number }
|
|
46
|
+
| { ok: false; reason: "error" | "cap"; detail: string };
|
|
47
|
+
|
|
48
|
+
/** `racySinceMs`: anchor for the racy content-hash predicate. The engine passes the BEFORE snapshot's
|
|
49
|
+
* `startedAtMs` to the AFTER snapshot so both sides hash exactly the same set of unchanged files. */
|
|
50
|
+
export type WorkspaceSnapshotter = (cwd: string, racySinceMs?: number) => Promise<WorkspaceSnapshot>;
|
|
51
|
+
|
|
52
|
+
type Failure = Extract<WorkspaceSnapshot, { ok: false }>;
|
|
53
|
+
|
|
54
|
+
/** Carries the first failure out of the concurrent walk. */
|
|
55
|
+
class SnapshotStop extends Error {
|
|
56
|
+
constructor(readonly result: Failure) { super(result.detail); }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const CONCURRENCY = 64;
|
|
60
|
+
const DIGEST_MASK = 0xffff_ffff_ffff_ffffn;
|
|
61
|
+
/** Never follow a link swapped in after lstat, and never block on a FIFO swapped in after lstat. */
|
|
62
|
+
const RACY_OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
|
|
63
|
+
|
|
64
|
+
const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error);
|
|
65
|
+
/** A child removed mid-walk: skipped, since a real deletion still changes the digest. */
|
|
66
|
+
const vanished = (error: unknown): boolean => {
|
|
67
|
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
|
68
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
69
|
+
};
|
|
70
|
+
const stop = (reason: Failure["reason"], detail: string): never => { throw new SnapshotStop({ ok: false, reason, detail }); };
|
|
71
|
+
|
|
72
|
+
async function chunked<T>(items: readonly T[], run: (item: T) => Promise<void>): Promise<void> {
|
|
73
|
+
for (let i = 0; i < items.length; i += CONCURRENCY) await Promise.all(items.slice(i, i + CONCURRENCY).map(run));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface Dir { rel: string; abs: string }
|
|
77
|
+
interface Leaf extends Dir { dirent: Dirent }
|
|
78
|
+
|
|
79
|
+
export async function snapshotWorkspace(
|
|
80
|
+
cwd: string,
|
|
81
|
+
limits: WorkspaceSnapshotLimits = WORKSPACE_SNAPSHOT_LIMITS,
|
|
82
|
+
racySinceMs?: number,
|
|
83
|
+
): Promise<WorkspaceSnapshot> {
|
|
84
|
+
const startedAtMs = Date.now();
|
|
85
|
+
const startedAt = performance.now();
|
|
86
|
+
const anchorMs = racySinceMs !== undefined && Number.isFinite(racySinceMs) ? racySinceMs : startedAtMs;
|
|
87
|
+
const racyThresholdNs = BigInt(Math.floor(anchorMs - limits.racyWindowMs)) * 1_000_000n;
|
|
88
|
+
let digest = 0n;
|
|
89
|
+
let entries = 0;
|
|
90
|
+
let racyBytes = 0;
|
|
91
|
+
|
|
92
|
+
const record = (entry: string): void => { digest = (digest + Bun.hash.wyhash(entry)) & DIGEST_MASK; };
|
|
93
|
+
const checkCaps = (): void => {
|
|
94
|
+
if (entries > limits.maxEntries) stop("cap", `workspace has more than ${limits.maxEntries} entries`);
|
|
95
|
+
if (performance.now() - startedAt > limits.maxMs) stop("cap", `workspace snapshot exceeded ${limits.maxMs} ms`);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Bounded read of a regular file's content; `null` when it vanished before it could be read. */
|
|
99
|
+
const racyHash = async (leaf: Leaf, stat: BigIntStats): Promise<bigint | null> => {
|
|
100
|
+
const size = Number(stat.size);
|
|
101
|
+
if (size > limits.racyMaxFileBytes) stop("error", `racy file too large: ${leaf.rel} (${size} bytes)`);
|
|
102
|
+
racyBytes += size;
|
|
103
|
+
if (racyBytes > limits.racyMaxTotalBytes) stop("error", `racy files too large: more than ${limits.racyMaxTotalBytes} bytes`);
|
|
104
|
+
let handle: FileHandle;
|
|
105
|
+
try { handle = await fs.open(leaf.abs, RACY_OPEN_FLAGS); }
|
|
106
|
+
catch (error) { if (vanished(error)) return null; return stop("error", `cannot open ${leaf.rel}: ${errorMessage(error)}`); }
|
|
107
|
+
try {
|
|
108
|
+
const opened = await handle.stat({ bigint: true });
|
|
109
|
+
if (!opened.isFile() || opened.ino !== stat.ino || opened.dev !== stat.dev) stop("error", `file changed during workspace snapshot: ${leaf.rel}`);
|
|
110
|
+
// One spare byte detects growth after lstat without reading past the cap.
|
|
111
|
+
const buffer = new Uint8Array(size + 1);
|
|
112
|
+
let read = 0;
|
|
113
|
+
for (;;) {
|
|
114
|
+
const { bytesRead } = await handle.read(buffer, read, buffer.length - read, read);
|
|
115
|
+
if (bytesRead === 0) break;
|
|
116
|
+
read += bytesRead;
|
|
117
|
+
if (read > size) stop("error", `file changed during workspace snapshot: ${leaf.rel}`);
|
|
118
|
+
}
|
|
119
|
+
return Bun.hash.wyhash(buffer.subarray(0, read));
|
|
120
|
+
} finally {
|
|
121
|
+
await handle.close().catch(() => {});
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const fingerprint = async (leaf: Leaf, queue: Dir[]): Promise<void> => {
|
|
126
|
+
let stat: BigIntStats;
|
|
127
|
+
try { stat = await fs.lstat(leaf.abs, { bigint: true }); }
|
|
128
|
+
catch (error) {
|
|
129
|
+
if (vanished(error)) { entries -= 1; return; }
|
|
130
|
+
return stop("error", `cannot stat ${leaf.rel}: ${errorMessage(error)}`);
|
|
131
|
+
}
|
|
132
|
+
if (stat.isDirectory()) {
|
|
133
|
+
// Unreported or raced dirent type: a real directory is never fingerprinted by metadata.
|
|
134
|
+
if (WORKSPACE_SNAPSHOT_EXCLUDED_DIRS.has(leaf.dirent.name)) { entries -= 1; return; }
|
|
135
|
+
record(`d:${leaf.rel}`);
|
|
136
|
+
queue.push(leaf);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const kind = stat.isSymbolicLink() ? "l" : stat.isFile() ? "f" : "o";
|
|
140
|
+
let entry = `${kind}:${leaf.rel}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}:${stat.ino}`;
|
|
141
|
+
if (kind === "l") {
|
|
142
|
+
try { entry += `:t${await fs.readlink(leaf.abs)}`; }
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (vanished(error)) { entries -= 1; return; }
|
|
145
|
+
return stop("error", `cannot read link ${leaf.rel}: ${errorMessage(error)}`);
|
|
146
|
+
}
|
|
147
|
+
} else if (kind === "f" && leaf.dirent.isFile() && stat.mtimeNs >= racyThresholdNs) {
|
|
148
|
+
const hash = await racyHash(leaf, stat);
|
|
149
|
+
if (hash === null) { entries -= 1; return; }
|
|
150
|
+
entry += `:h${hash}`;
|
|
151
|
+
}
|
|
152
|
+
record(entry);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const list = async (dir: Dir, next: Dir[], leaves: Leaf[]): Promise<void> => {
|
|
156
|
+
let dirents: Dirent[];
|
|
157
|
+
try { dirents = await fs.readdir(dir.abs, { withFileTypes: true }); }
|
|
158
|
+
catch (error) {
|
|
159
|
+
if (dir.rel !== "" && vanished(error)) return;
|
|
160
|
+
return stop("error", `cannot read directory ${dir.rel || "."}: ${errorMessage(error)}`);
|
|
161
|
+
}
|
|
162
|
+
for (const dirent of dirents) {
|
|
163
|
+
const rel = dir.rel ? `${dir.rel}/${dirent.name}` : dirent.name;
|
|
164
|
+
const child: Leaf = { rel, abs: path.join(dir.abs, dirent.name), dirent };
|
|
165
|
+
if (dirent.isDirectory()) {
|
|
166
|
+
if (WORKSPACE_SNAPSHOT_EXCLUDED_DIRS.has(dirent.name)) continue;
|
|
167
|
+
entries += 1;
|
|
168
|
+
record(`d:${rel}`);
|
|
169
|
+
next.push(child);
|
|
170
|
+
} else {
|
|
171
|
+
entries += 1;
|
|
172
|
+
leaves.push(child);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
checkCaps();
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
try {
|
|
179
|
+
let root: BigIntStats;
|
|
180
|
+
try { root = await fs.stat(cwd, { bigint: true }); }
|
|
181
|
+
catch (error) { return { ok: false, reason: "error", detail: `cannot stat workspace root: ${errorMessage(error)}` }; }
|
|
182
|
+
if (!root.isDirectory()) return { ok: false, reason: "error", detail: "workspace root is not a directory" };
|
|
183
|
+
|
|
184
|
+
let level: Dir[] = [{ rel: "", abs: cwd }];
|
|
185
|
+
while (level.length) {
|
|
186
|
+
const next: Dir[] = [];
|
|
187
|
+
for (let i = 0; i < level.length; i += CONCURRENCY) {
|
|
188
|
+
const leaves: Leaf[] = [];
|
|
189
|
+
await Promise.all(level.slice(i, i + CONCURRENCY).map(dir => list(dir, next, leaves)));
|
|
190
|
+
await chunked(leaves, leaf => fingerprint(leaf, next));
|
|
191
|
+
checkCaps();
|
|
192
|
+
}
|
|
193
|
+
level = next;
|
|
194
|
+
}
|
|
195
|
+
return { ok: true, digest, entries, elapsedMs: performance.now() - startedAt, startedAtMs };
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error instanceof SnapshotStop) return error.result;
|
|
198
|
+
return { ok: false, reason: "error", detail: errorMessage(error) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Production default: default limits, forwards the racy anchor. */
|
|
203
|
+
export const defaultWorkspaceSnapshotter: WorkspaceSnapshotter = (cwd, since) => snapshotWorkspace(cwd, WORKSPACE_SNAPSHOT_LIMITS, since);
|
|
204
|
+
|
|
205
|
+
/** Pure: true when either side failed, or digest/entry count differ. */
|
|
206
|
+
export function workspaceSnapshotChanged(before: WorkspaceSnapshot, after: WorkspaceSnapshot): boolean {
|
|
207
|
+
if (!before.ok || !after.ok) return true;
|
|
208
|
+
return before.digest !== after.digest || before.entries !== after.entries;
|
|
209
|
+
}
|
package/src/commands/launch.ts
CHANGED
|
@@ -1229,7 +1229,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1229
1229
|
// injected SKILL.md is NOT user-authored and the [skill] card already names it,
|
|
1230
1230
|
// gjc-style); a string → show that compact label instead of the raw input.
|
|
1231
1231
|
opts?: { userCard?: string | null; signal?: AbortSignal; trustedContext?: string },
|
|
1232
|
-
): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string }> => {
|
|
1232
|
+
): Promise<{ done: boolean; steps: number; reply: string; rendered: boolean; usage: string; unverified: boolean }> => {
|
|
1233
1233
|
opts?.signal?.throwIfAborted();
|
|
1234
1234
|
if (currentSandboxExecution() && sessionId !== sandboxTranscriptId) throw new Error("SESSION_MISMATCH: start a new launch for the selected transcript; sandbox grants cannot cross sessions");
|
|
1235
1235
|
let busyDraftHasPaste = false;
|
|
@@ -1370,7 +1370,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1370
1370
|
sessionNotifyEndpoint?.sendTurnStream(notifyRedact ? "[redacted]" : reply);
|
|
1371
1371
|
cliTui?.events().onStep?.(result.steps);
|
|
1372
1372
|
cliTui?.finish(reply);
|
|
1373
|
-
return { done: true, steps: result.steps, reply, rendered: !!cliTui, usage };
|
|
1373
|
+
return { done: true, steps: result.steps, reply, rendered: !!cliTui, usage, unverified: false };
|
|
1374
1374
|
} catch (error) {
|
|
1375
1375
|
if (oneShot) {
|
|
1376
1376
|
process.exitCode = error instanceof AgyCliError
|
|
@@ -1883,13 +1883,24 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1883
1883
|
// Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
|
|
1884
1884
|
// because nothing ever forced the model to update item statuses).
|
|
1885
1885
|
let turnTodos: { title: string; status: string }[] = [];
|
|
1886
|
+
// Evidence from earlier engine runs of THIS turn always counts; an earlier turn's
|
|
1887
|
+
// unverified mutation counts only while a goal is active (valid or corrupt goal file).
|
|
1888
|
+
let turnPendingMutation = false;
|
|
1889
|
+
try {
|
|
1890
|
+
turnPendingMutation = (await readGoalState(cwd)) !== null && pendingMutationVerification;
|
|
1891
|
+
} catch (error) {
|
|
1892
|
+
if (!(error instanceof GoalStateCorruptError)) throw error;
|
|
1893
|
+
turnPendingMutation = pendingMutationVerification;
|
|
1894
|
+
}
|
|
1886
1895
|
const onEvidence = async (
|
|
1887
1896
|
evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean },
|
|
1888
1897
|
): Promise<void> => {
|
|
1889
1898
|
if (evidence.sawVerification && !evidence.verificationStale) {
|
|
1890
1899
|
pendingMutationVerification = false;
|
|
1900
|
+
turnPendingMutation = false;
|
|
1891
1901
|
} else if (evidence.sawMutation) {
|
|
1892
1902
|
pendingMutationVerification = true;
|
|
1903
|
+
turnPendingMutation = true;
|
|
1893
1904
|
}
|
|
1894
1905
|
|
|
1895
1906
|
let goalState: GoalState | null;
|
|
@@ -1965,7 +1976,8 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
1965
1976
|
}
|
|
1966
1977
|
throw error;
|
|
1967
1978
|
}
|
|
1968
|
-
|
|
1979
|
+
const carriedPending = turnPendingMutation || (goalState !== null && pendingMutationVerification);
|
|
1980
|
+
if ((carriedPending || evidence.sawMutation) && !(evidence.sawVerification && !evidence.verificationStale)) {
|
|
1969
1981
|
return (
|
|
1970
1982
|
`Cannot finish: a workspace mutation from this session still lacks a successful, non-stale verification. ` +
|
|
1971
1983
|
`Run a relevant test/build/typecheck/lint after the last change (e.g. 'bun test', 'bun run typecheck', 'tsc --noEmit', 'npm test', 'pytest', 'cargo test', or 'go test ./...'), then call done again.`
|
|
@@ -2261,8 +2273,9 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
2261
2273
|
|
|
2262
2274
|
// A loop guard is a local safety boundary, not a provider failure. A
|
|
2263
2275
|
// fresh loop on an equivalent model loses the guard state and could
|
|
2264
|
-
// re-execute a previously skipped mutation.
|
|
2265
|
-
|
|
2276
|
+
// re-execute a previously skipped mutation. A rejected completion carries
|
|
2277
|
+
// the model's own answer in doneReason, which must not be read as a provider error.
|
|
2278
|
+
const routedFailureReason = !result.done && !result.stopClass && !result.completionRejected
|
|
2266
2279
|
? routeFailureReason(result.doneReason)
|
|
2267
2280
|
: null;
|
|
2268
2281
|
if (!routedFailureReason) break;
|
|
@@ -2361,7 +2374,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
2361
2374
|
: `(reached the ${result.steps}-step limit without signaling done)`);
|
|
2362
2375
|
// Routing off = the model is fixed and nothing fails over. When the turn died on
|
|
2363
2376
|
// an account-level limit, say so and name the one switch that re-enables failover.
|
|
2364
|
-
const failoverHint = !routingEnabled && !result.done && (() => {
|
|
2377
|
+
const failoverHint = !routingEnabled && !result.done && !result.completionRejected && (() => {
|
|
2365
2378
|
const failure = new Error(result.doneReason ?? "");
|
|
2366
2379
|
return isRateLimitError(failure) || isUsageLimitError(failure);
|
|
2367
2380
|
})()
|
|
@@ -2469,7 +2482,7 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
2469
2482
|
}
|
|
2470
2483
|
|
|
2471
2484
|
lastActiveModel = activeModel;
|
|
2472
|
-
return { done: result.done, steps: result.steps, reply, rendered: !!tui, usage };
|
|
2485
|
+
return { done: result.done, steps: result.steps, reply, rendered: !!tui, usage, unverified: result.completionRejected === true };
|
|
2473
2486
|
} finally {
|
|
2474
2487
|
preflightHarness?.dispose();
|
|
2475
2488
|
interactiveTurnActive = false;
|
|
@@ -2514,9 +2527,12 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
2514
2527
|
try {
|
|
2515
2528
|
const message = JSON.parse(line);
|
|
2516
2529
|
if (typeof message !== "string" || !message.trim()) throw new Error("Invalid messenger request");
|
|
2517
|
-
const { done, reply } = await runTurn(message, false);
|
|
2518
|
-
if (
|
|
2519
|
-
|
|
2530
|
+
const { done, reply, unverified } = await runTurn(message, false);
|
|
2531
|
+
if (done) console.log(`JEO_MESSENGER_RESULT ${JSON.stringify(reply)}`);
|
|
2532
|
+
// The turn ended in a controlled state with an UNVERIFIED-marked answer:
|
|
2533
|
+
// deliver it as a failure and keep serving this conversation's follow-ups.
|
|
2534
|
+
else if (unverified && reply.trim()) console.log(`JEO_MESSENGER_UNVERIFIED ${JSON.stringify(reply)}`);
|
|
2535
|
+
else throw new Error("Messenger agent turn did not complete");
|
|
2520
2536
|
} catch {
|
|
2521
2537
|
console.log("JEO_MESSENGER_ERROR");
|
|
2522
2538
|
break;
|
|
@@ -2752,10 +2768,13 @@ async function runLaunchSession(args: string[], registerEndpointCleanup?: (clean
|
|
|
2752
2768
|
return;
|
|
2753
2769
|
}
|
|
2754
2770
|
try {
|
|
2755
|
-
const { done, reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
|
|
2771
|
+
const { done, reply, rendered, usage, unverified } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
|
|
2756
2772
|
if (flags.literalPrompt) {
|
|
2757
|
-
if (
|
|
2758
|
-
|
|
2773
|
+
if (done) console.log(`JEO_MESSENGER_RESULT ${JSON.stringify(reply)}`);
|
|
2774
|
+
else if (unverified && reply.trim()) {
|
|
2775
|
+
console.log(`JEO_MESSENGER_UNVERIFIED ${JSON.stringify(reply)}`);
|
|
2776
|
+
process.exitCode = 1;
|
|
2777
|
+
} else throw new Error("Messenger agent turn did not complete");
|
|
2759
2778
|
} else if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
|
|
2760
2779
|
else if (usage) console.log(usage.trim());
|
|
2761
2780
|
} catch (err) {
|
package/src/messenger/gateway.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
MessengerMessage,
|
|
8
8
|
MessengerReply,
|
|
9
9
|
} from "./types";
|
|
10
|
-
import { MessengerFatalError } from "./types";
|
|
10
|
+
import { MessengerFatalError, MessengerUnverifiedReplyError } from "./types";
|
|
11
11
|
|
|
12
12
|
const MAX_MESSAGE_LENGTH = 32_000;
|
|
13
13
|
const MAX_QUEUED_MESSAGES = 100;
|
|
@@ -276,8 +276,9 @@ export interface MessengerGatewayOptions {
|
|
|
276
276
|
/** Authenticated human controls bypass model queues, including busy sessions. */
|
|
277
277
|
sandboxControl?: (message: MessengerMessage, signal: AbortSignal) => Promise<{ handled: boolean; output: string }>;
|
|
278
278
|
/**
|
|
279
|
-
* Operator log for a request that failed before or during delivery. Users
|
|
280
|
-
*
|
|
279
|
+
* Operator log for a request that failed before or during delivery. Users see the generic failure text, except
|
|
280
|
+
* an unverified completion, whose marked answer is delivered; this is the one place the cause is observable,
|
|
281
|
+
* and it receives the conversation, never message text.
|
|
281
282
|
*/
|
|
282
283
|
onRequestError?: (message: MessengerMessage, error: unknown, stage: "prepare" | "reply" | "deliver") => void;
|
|
283
284
|
}
|
|
@@ -372,9 +373,11 @@ export function createMessengerGateway(
|
|
|
372
373
|
}
|
|
373
374
|
if (signal.aborted) return;
|
|
374
375
|
reportError(message, error, "reply");
|
|
375
|
-
text = error instanceof
|
|
376
|
-
?
|
|
377
|
-
:
|
|
376
|
+
text = error instanceof MessengerUnverifiedReplyError
|
|
377
|
+
? error.reply
|
|
378
|
+
: error instanceof DiscordModelSelectionError
|
|
379
|
+
? "Discord could not verify a model. Ask the operator to configure an explicit Discord model, check provider credentials and live model discovery, then restart the messenger service."
|
|
380
|
+
: "The request could not be completed. Please try again.";
|
|
378
381
|
}
|
|
379
382
|
if (!signal.aborted && text.trim()) {
|
|
380
383
|
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MessengerActivityReporter } from "./types";
|
|
2
|
+
import { MessengerUnverifiedReplyError } from "./types";
|
|
2
3
|
|
|
3
4
|
interface WorkerProcess {
|
|
4
5
|
stdin: { write(data: string): unknown; flush(): unknown; end(): unknown };
|
|
@@ -15,6 +16,8 @@ interface Pending {
|
|
|
15
16
|
reject(error: Error): void;
|
|
16
17
|
report?: MessengerActivityReporter;
|
|
17
18
|
result?: string;
|
|
19
|
+
/** The result is an UNVERIFIED-marked answer: the turn rejects with it while the worker keeps serving. */
|
|
20
|
+
unverified?: boolean;
|
|
18
21
|
written?: boolean;
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -159,7 +162,8 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
159
162
|
worker.idleTimer = setTimeout(() => retire(key, worker), idleTimeoutMs);
|
|
160
163
|
worker.idleTimer.unref();
|
|
161
164
|
}
|
|
162
|
-
pending.
|
|
165
|
+
if (pending.unverified) pending.reject(new MessengerUnverifiedReplyError(pending.result));
|
|
166
|
+
else pending.resolve(pending.result);
|
|
163
167
|
}
|
|
164
168
|
|
|
165
169
|
async function readLines(stream: ReadableStream<Uint8Array>, handle: (line: string) => void) {
|
|
@@ -234,6 +238,17 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
234
238
|
const pending = worker.pending;
|
|
235
239
|
if (!pending) return;
|
|
236
240
|
if (line === "JEO_MESSENGER_ERROR") { retire(key, worker); return; }
|
|
241
|
+
// An exhausted completion gate is a controlled turn end: deliver the marked answer, keep the worker.
|
|
242
|
+
if (line.startsWith("JEO_MESSENGER_UNVERIFIED ")) {
|
|
243
|
+
try {
|
|
244
|
+
const text: unknown = JSON.parse(line.slice("JEO_MESSENGER_UNVERIFIED ".length));
|
|
245
|
+
if (typeof text !== "string" || !text.trim()) throw new Error("Invalid response");
|
|
246
|
+
pending.result = text;
|
|
247
|
+
pending.unverified = true;
|
|
248
|
+
finishTurn(key, worker, pending);
|
|
249
|
+
} catch { retire(key, worker); }
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
237
252
|
if (!line.startsWith("JEO_MESSENGER_RESULT ")) return;
|
|
238
253
|
try {
|
|
239
254
|
const text: unknown = JSON.parse(line.slice("JEO_MESSENGER_RESULT ".length));
|
package/src/messenger/types.ts
CHANGED
|
@@ -78,6 +78,14 @@ export class MessengerFatalError extends Error {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** The agent turn ended without verified completion; `reply` is the UNVERIFIED-marked answer for the user. */
|
|
82
|
+
export class MessengerUnverifiedReplyError extends Error {
|
|
83
|
+
constructor(readonly reply: string) {
|
|
84
|
+
super("Messenger agent turn ended without verified completion");
|
|
85
|
+
this.name = "MessengerUnverifiedReplyError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
82
90
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
83
91
|
}
|