@visual-z/xeno 0.8.0 → 0.8.2
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 +41 -0
- package/docs/install.md +25 -0
- package/package.json +1 -1
- package/src/apps/cli.ts +4 -1
- package/src/apps/startup.ts +27 -2
- package/src/apps/ui/app.ts +69 -9
- package/src/core/activity-status.ts +10 -1
- package/src/core/command-contract.ts +79 -5
- package/src/core/completion-review.ts +1 -1
- package/src/core/environment.ts +7 -0
- package/src/core/goal-view.ts +64 -0
- package/src/core/history.ts +7 -1
- package/src/core/permissions.ts +4 -3
- package/src/core/prompt-policy.ts +9 -2
- package/src/core/regex-dialect.ts +25 -0
- package/src/core/response-recovery.ts +13 -1
- package/src/core/ripgrep-release.ts +64 -0
- package/src/core/stall.ts +63 -18
- package/src/core/stop-report.ts +21 -1
- package/src/core/tool-contract.ts +2 -0
- package/src/core/tool-failure.ts +17 -4
- package/src/core/update-check.ts +2 -1
- package/src/runtime/exec-sessions.ts +3 -1
- package/src/runtime/loop.ts +98 -12
- package/src/runtime/openai.ts +2 -2
- package/src/runtime/pi-provider.ts +3 -3
- package/src/runtime/provider.ts +33 -3
- package/src/runtime/retry.ts +9 -2
- package/src/runtime/tools.ts +46 -8
- package/src/runtime/usage.ts +28 -0
- package/src/store/command.ts +3 -1
- package/src/store/file-checkpoints.ts +6 -3
- package/src/store/search.ts +9 -5
- package/src/store/tool-binaries.ts +90 -0
- package/src/store/worktree.ts +55 -10
- package/src/tui/ui/duration.ts +16 -0
- package/src/tui/ui/layout.ts +4 -4
- package/src/tui/ui/screen.ts +18 -9
- package/src/tui/ui/status.ts +115 -21
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** N.12: a returned response is durable evidence, not necessarily a completed action. */
|
|
2
|
-
import type { GuidanceEvent, LogEvent } from "./events.ts";
|
|
2
|
+
import type { AssistantBlock, GuidanceEvent, LogEvent } from "./events.ts";
|
|
3
3
|
import { currentRequestEvents } from "./request-state.ts";
|
|
4
4
|
|
|
5
5
|
// One appended attempt is an operational assumption, not a measured optimum.
|
|
@@ -15,6 +15,18 @@ export function isCompleteResponse(reason: string | null | undefined): boolean {
|
|
|
15
15
|
return reason !== undefined && reason !== null && ["stop", "end_turn", "stop_sequence", "tool_calls", "tool_use"].includes(reason);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* A stop that asks for tool execution must carry a tool call. One that does not
|
|
20
|
+
* is a malformed provider response, not the model's answer: taken as a final
|
|
21
|
+
* reply it ends the request with outcome unknown and nothing on screen
|
|
22
|
+
* (session mv28chr8-oc3x, an Anthropic-compatible gateway returning
|
|
23
|
+
* `stop_reason: tool_use` with no content). Adapters reject it inside their
|
|
24
|
+
* retry so the loop only sees a coherent turn or a definite failure.
|
|
25
|
+
*/
|
|
26
|
+
export function isToolStopWithoutCall(stopReason: string | null | undefined, blocks: readonly AssistantBlock[]): boolean {
|
|
27
|
+
return (stopReason === "tool_use" || stopReason === "tool_calls") && !blocks.some(block => block.type === "tool_use");
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
export function isOutputLimit(reason: string | null | undefined): boolean {
|
|
19
31
|
return reason === "length" || reason === "max_tokens";
|
|
20
32
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one ripgrep build xeno will install, per platform.
|
|
3
|
+
*
|
|
4
|
+
* Models write `rg` in shell commands because the harnesses they were trained
|
|
5
|
+
* in ship it (Claude Code bundles it; Pi downloads it on first use). On a
|
|
6
|
+
* machine without it every such command exits 127 and costs a request. xeno
|
|
7
|
+
* installs it only when the user has none, and only this exact archive:
|
|
8
|
+
* running a binary fetched from the network needs proof that it is the one
|
|
9
|
+
* reviewed, which a pinned digest gives and "latest" (Pi's choice) does not.
|
|
10
|
+
*
|
|
11
|
+
* Digests are the upstream release's published `.sha256` files for 14.1.1;
|
|
12
|
+
* the aarch64-apple-darwin archive was also downloaded and hashed locally on
|
|
13
|
+
* 2026-10-11 and matched. Upgrading means changing this table and releasing.
|
|
14
|
+
*/
|
|
15
|
+
import { truthy } from "./update-check.ts";
|
|
16
|
+
|
|
17
|
+
export const RIPGREP_VERSION = "14.1.1";
|
|
18
|
+
|
|
19
|
+
export interface RipgrepAsset {
|
|
20
|
+
readonly url: string;
|
|
21
|
+
readonly sha256: string;
|
|
22
|
+
/** Archive member holding the executable; nothing else is extracted. */
|
|
23
|
+
readonly member: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DIGESTS: Readonly<Record<string, string>> = {
|
|
27
|
+
"aarch64-apple-darwin": "24ad76777745fbff131c8fbc466742b011f925bfa4fffa2ded6def23b5b937be",
|
|
28
|
+
"x86_64-apple-darwin": "fc87e78f7cb3fea12d69072e7ef3b21509754717b746368fd40d88963630e2b3",
|
|
29
|
+
// musl is static, so it runs on glibc and musl distributions alike.
|
|
30
|
+
"x86_64-unknown-linux-musl": "4cf9f2741e6c465ffdb7c26f38056a59e2a2544b51f7cc128ef28337eeae4d8e",
|
|
31
|
+
// 14.1.1 publishes no aarch64 musl build; this one needs glibc.
|
|
32
|
+
"aarch64-unknown-linux-gnu": "c827481c4ff4ea10c9dc7a4022c8de5db34a5737cb74484d62eb94a95841ab2f",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
function target(platform: string, arch: string): string | null {
|
|
36
|
+
if (platform === "darwin") return arch === "arm64" ? "aarch64-apple-darwin" : arch === "x64" ? "x86_64-apple-darwin" : null;
|
|
37
|
+
if (platform === "linux") return arch === "arm64" ? "aarch64-unknown-linux-gnu" : arch === "x64" ? "x86_64-unknown-linux-musl" : null;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Null for platforms without a pinned build: those keep the TypeScript search. */
|
|
42
|
+
export function ripgrepAsset(platform: string, arch: string): RipgrepAsset | null {
|
|
43
|
+
const triple = target(platform, arch);
|
|
44
|
+
if (triple === null) return null;
|
|
45
|
+
const name = `ripgrep-${RIPGREP_VERSION}-${triple}`;
|
|
46
|
+
return {
|
|
47
|
+
url: `https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/${name}.tar.gz`,
|
|
48
|
+
sha256: DIGESTS[triple]!,
|
|
49
|
+
member: `${name}/rg`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether xeno may fetch the binary on its own, in the background.
|
|
55
|
+
*
|
|
56
|
+
* The same occasions the update check stays off the network (core/update-check.ts):
|
|
57
|
+
* offline, CI, and a source checkout -- the last is also what keeps test
|
|
58
|
+
* subprocesses, which run the CLI from this repository, from downloading.
|
|
59
|
+
* XENO_NO_TOOL_INSTALL opts out explicitly; the user's own rg makes it moot.
|
|
60
|
+
*/
|
|
61
|
+
export function toolInstallAllowed(input: { env: Readonly<Record<string, string | undefined>>; offline: boolean; install: string }): boolean {
|
|
62
|
+
const on = (name: string) => truthy(input.env[name]);
|
|
63
|
+
return !input.offline && input.install !== "checkout" && !on("XENO_OFFLINE") && !on("XENO_NO_TOOL_INSTALL") && !on("CI") && !on("CONTINUOUS_INTEGRATION");
|
|
64
|
+
}
|
package/src/core/stall.ts
CHANGED
|
@@ -6,23 +6,60 @@ import { deriveProgress, type ProgressEvidence } from "./progress.ts";
|
|
|
6
6
|
import { isProcessReceipt } from "./process-receipt.ts";
|
|
7
7
|
import { canonicalValue } from "./continuation.ts";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
/**
|
|
10
|
+
* S.8: the watchdog is now an on/off switch -- 0 disables it, any other
|
|
11
|
+
* allowed value enables it. Sessions persist this field (S.3); a stored 12
|
|
12
|
+
* still means "on" and is never reread as "twelve repeats".
|
|
13
|
+
*/
|
|
11
14
|
export const DEFAULT_STALL_LIMIT = 12;
|
|
15
|
+
/**
|
|
16
|
+
* How many times a cycle must repeat. A hypothesis, not measured: OpenCode's
|
|
17
|
+
* doom loop, Cline and Roo use 3; OpenHands 4 counting the first, novel,
|
|
18
|
+
* observation; Gemini 5. Lower asks too often, higher pays for more repeats.
|
|
19
|
+
*/
|
|
20
|
+
export const CYCLE_REPEATS = 3;
|
|
21
|
+
/** Longest cycle recognised; Gemini CLI's bound, and the user's (S.8). */
|
|
22
|
+
export const MAX_CYCLE = 5;
|
|
23
|
+
|
|
24
|
+
export interface StallExchange { readonly signature: string; readonly progressed: boolean }
|
|
12
25
|
export interface StallState {
|
|
13
26
|
evidence: readonly ProgressEvidence[];
|
|
14
27
|
pending: readonly LogEvent[];
|
|
15
28
|
pendingNode: number;
|
|
16
|
-
|
|
17
|
-
|
|
29
|
+
/** The request's latest exchanges, oldest first, at most MAX_CYCLE × CYCLE_REPEATS. */
|
|
30
|
+
recent: readonly StallExchange[];
|
|
18
31
|
failures: number;
|
|
19
32
|
emptyWaits: number;
|
|
20
|
-
|
|
33
|
+
/** A cycle was already pointed out to the model; the next one goes to the user. */
|
|
34
|
+
nudged: boolean;
|
|
21
35
|
/** The previous exchange's tool calls, canonical; empty after real user input. */
|
|
22
36
|
previous: string;
|
|
37
|
+
/**
|
|
38
|
+
* A completion review just asked the model to re-read the request and
|
|
39
|
+
* verify. Its answer is the harness's own request, so that one exchange
|
|
40
|
+
* stays out of the cycle window (fix-review-induced-stall).
|
|
41
|
+
*/
|
|
42
|
+
reviewed: boolean;
|
|
23
43
|
}
|
|
24
44
|
export function emptyStallState(): StallState {
|
|
25
|
-
return { evidence: [], pending: [], pendingNode: 0,
|
|
45
|
+
return { evidence: [], pending: [], pendingNode: 0, recent: [], failures: 0, emptyWaits: 0, nudged: false, previous: "", reviewed: false };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The shortest period k <= MAX_CYCLE such that the last k × repeats exchanges
|
|
50
|
+
* are one k-call cycle repeated, none of which brought new evidence -- or null.
|
|
51
|
+
* Distinct calls that each tell nothing new are not a loop: that is reading
|
|
52
|
+
* and re-checking, which a cumulative allowance wrongly punished (S.8).
|
|
53
|
+
*/
|
|
54
|
+
export function repeatedCycle(recent: readonly StallExchange[], repeats = CYCLE_REPEATS): number | null {
|
|
55
|
+
for (let k = 1; k <= MAX_CYCLE; k++) {
|
|
56
|
+
const n = k * repeats;
|
|
57
|
+
if (recent.length < n) return null;
|
|
58
|
+
const window = recent.slice(-n);
|
|
59
|
+
if (window.some(item => item.progressed)) continue;
|
|
60
|
+
if (window.every((item, i) => i < k || item.signature === window[i - k]!.signature)) return k;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
26
63
|
}
|
|
27
64
|
|
|
28
65
|
/** Name and arguments of every call, order-independent: what the model asked for, not what came back. */
|
|
@@ -38,7 +75,7 @@ export function finishStallExchange(state: StallState): StallState {
|
|
|
38
75
|
const progress = deriveProgress(state.evidence, state.pending, state.pendingNode, checks);
|
|
39
76
|
const signature = exchangeSignature(assistant);
|
|
40
77
|
const waits = new Set(assistant.blocks.flatMap(block => block.type === "tool_use" && block.name === "write_stdin" ? [block.call_id] : []));
|
|
41
|
-
// S.6 item 2:
|
|
78
|
+
// S.6 item 2: asking
|
|
42
79
|
// for exactly what the previous exchange asked for is a rerun on an
|
|
43
80
|
// unchanged premise, so output that differs only by timing (a failing test's
|
|
44
81
|
// "[123ms]") is not news. Reads and edits are content-addressed and keep
|
|
@@ -51,9 +88,12 @@ export function finishStallExchange(state: StallState): StallState {
|
|
|
51
88
|
(event.effect?.kind !== "command" && !isProcessReceipt(event.data))), state.pendingNode, checks).progressed
|
|
52
89
|
: progress.progressed;
|
|
53
90
|
const results = state.pending.filter(event => event.t === "tool_result");
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
91
|
+
// Waiting on a process that is still running is not another attempt: its
|
|
92
|
+
// own timeout bounds it and the user can interrupt (S.8). Only exchanges
|
|
93
|
+
// that try something enter the cycle window.
|
|
94
|
+
const waiting = results.length > 0 && results.every(result => waits.has(result.call_id) && isProcessReceipt(result.data) && result.data.running === true);
|
|
95
|
+
const recent = state.reviewed || waiting ? state.recent : [...state.recent, { signature, progressed: rerun }].slice(-MAX_CYCLE * CYCLE_REPEATS);
|
|
96
|
+
return { ...state, pending: [], evidence: progress.evidence, previous: signature, reviewed: false, recent,
|
|
57
97
|
failures: state.failures + results.filter(result => !result.ok).length,
|
|
58
98
|
emptyWaits: state.emptyWaits + results.filter(result => waits.has(result.call_id) && isProcessReceipt(result.data) &&
|
|
59
99
|
result.data.running === true && result.data.cursor !== undefined && result.data.cursor.start === result.data.cursor.end).length };
|
|
@@ -63,11 +103,16 @@ export function finishStallExchange(state: StallState): StallState {
|
|
|
63
103
|
export function advanceStall(state: StallState, entries: readonly { node: number; event: LogEvent }[]): StallState {
|
|
64
104
|
let result = state;
|
|
65
105
|
for (const { node, event } of entries) {
|
|
66
|
-
|
|
106
|
+
const review = event.t === "guidance" && event.reason === "completion_review";
|
|
107
|
+
const nudge = event.t === "guidance" && event.reason === "stall";
|
|
108
|
+
if (event.t === "assistant" || event.t === "user" || event.t === "cut" || review || nudge) {
|
|
67
109
|
result = finishStallExchange(result);
|
|
68
|
-
if (
|
|
110
|
+
if (review) result = { ...result, reviewed: true };
|
|
111
|
+
// A nudge, or the user's "continue", starts a fresh window: only a new
|
|
112
|
+
// full cycle after it counts, never the one already reported.
|
|
113
|
+
if (nudge) result = { ...result, nudged: true, recent: [] };
|
|
114
|
+
if (event.t === "user") result = { ...result, pending: [], recent: [], failures: 0, emptyWaits: 0, nudged: false, previous: "", reviewed: false };
|
|
69
115
|
if (event.t === "assistant") result = { ...result, pending: [event], pendingNode: node };
|
|
70
|
-
if (event.t === "guidance") result = { ...result, warned: true };
|
|
71
116
|
} else if (result.pending.length > 0) result = { ...result, pending: [...result.pending, event] };
|
|
72
117
|
}
|
|
73
118
|
return result;
|
|
@@ -75,9 +120,9 @@ export function advanceStall(state: StallState, entries: readonly { node: number
|
|
|
75
120
|
export function stallDecision(state: StallState, limit: number) {
|
|
76
121
|
if (!Number.isSafeInteger(limit) || limit < 0 || limit === 1) throw new RangeError("stall limit must be zero or an integer of at least two");
|
|
77
122
|
const current = finishStallExchange(state);
|
|
78
|
-
|
|
79
|
-
// the
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
warn:
|
|
123
|
+
const cycle = limit > 0 ? repeatedCycle(current.recent) : null;
|
|
124
|
+
// S.8: the first cycle is pointed out to the model; a second goes to the
|
|
125
|
+
// user when one can answer, and otherwise ends the request.
|
|
126
|
+
return { cycle, repeats: CYCLE_REPEATS, failures: current.failures, emptyWaits: current.emptyWaits,
|
|
127
|
+
warn: cycle !== null && !current.nudged, stop: cycle !== null && current.nudged };
|
|
83
128
|
}
|
package/src/core/stop-report.ts
CHANGED
|
@@ -19,8 +19,28 @@ export function stopReport(events: readonly LogEvent[], requests: number, limit:
|
|
|
19
19
|
return [
|
|
20
20
|
`Stopped: ${exitLine(cause)} (${limit === Infinity ? requests : `${requests}/${limit}`} model requests).`,
|
|
21
21
|
`Recorded evidence: ${results.length} tool receipts; ${exits} successful command exits; ${failures.length} failed tool receipts. Task completion is not verified by this stop.`,
|
|
22
|
-
...(failures.length ? [`
|
|
22
|
+
...(failures.length ? [`Failed tools: ${failedTools(current, failures)}.`] : []),
|
|
23
|
+
...(finalReplyGiven(current) ? ["A final reply was given before this stop; read it above — this stop does not mark it wrong."] : []),
|
|
23
24
|
...(running.length ? [`Last observed running: ${running.slice(0, 3).join(", ")}${running.length > 3 ? ` (+${running.length - 3})` : ""}; inspect the process receipt before replaying a command.`] : []),
|
|
24
25
|
"Next: inspect /state or the recorded tool output, then provide a focused continuation; completed commands should not be replayed just to recover their output.",
|
|
25
26
|
].join("\n");
|
|
26
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The user reads this line; no command looks a receipt up by id, so ids told
|
|
31
|
+
* them nothing (fix-stop-report-readable). Tool names say where it went wrong.
|
|
32
|
+
*/
|
|
33
|
+
function failedTools(events: readonly LogEvent[], failures: readonly Extract<LogEvent, { t: "tool_result" }>[]): string {
|
|
34
|
+
const names = new Map<string, string>();
|
|
35
|
+
for (const event of events) if (event.t === "assistant") for (const block of event.blocks) if (block.type === "tool_use") names.set(block.call_id, block.name);
|
|
36
|
+
const counts = new Map<string, number>();
|
|
37
|
+
for (const failure of failures) { const name = names.get(failure.call_id) ?? "unknown"; counts.set(name, (counts.get(name) ?? 0) + 1); }
|
|
38
|
+
const ranked = [...counts].sort(([a, x], [b, y]) => y - x || (a < b ? -1 : a > b ? 1 : 0));
|
|
39
|
+
return ranked.slice(0, 3).map(([name, count]) => `${name} ×${count}`).join(", ") + (ranked.length > 3 ? ` (+${ranked.length - 3} more)` : "");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A stop after the model already answered must not read as "the task failed". */
|
|
43
|
+
function finalReplyGiven(events: readonly LogEvent[]): boolean {
|
|
44
|
+
return events.some(event => event.t === "assistant" && !event.blocks.some(block => block.type === "tool_use") &&
|
|
45
|
+
event.blocks.some(block => block.type === "text" && block.text.trim().length > 0));
|
|
46
|
+
}
|
|
@@ -86,6 +86,8 @@ const SYNONYMS: Readonly<Record<string, readonly string[]>> = {
|
|
|
86
86
|
cmd: ["command"], script: ["command"],
|
|
87
87
|
text: ["content"], contents: ["content"], old_str: ["old_string"], new_str: ["new_string"],
|
|
88
88
|
query: ["pattern"], regex: ["pattern"], timeout_ms: ["timeout"], offset: ["start"], limit: ["end", "limit"],
|
|
89
|
+
// Different units from max_output_tokens, so a hint rather than a silent conversion.
|
|
90
|
+
max_output_chars: ["max_output_tokens"], max_output_bytes: ["max_output_tokens"], max_tokens: ["max_output_tokens"],
|
|
89
91
|
};
|
|
90
92
|
|
|
91
93
|
/** The candidate nearest to `name`, when it is near enough to be the likely intent. */
|
package/src/core/tool-failure.ts
CHANGED
|
@@ -21,26 +21,39 @@ const guidance: Record<ToolDiagnostic["code"], string> = {
|
|
|
21
21
|
command_failed: "Use the actual exit and output to diagnose the command; a nonzero exit alone does not identify its cause.",
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The refusal for `.git` and `.xeno` paths. It is a fixed rule protecting the
|
|
26
|
+
* session's own evidence, not a user policy, so the generic denial ("a user
|
|
27
|
+
* policy change is required") would send the model to ask for a setting that
|
|
28
|
+
* does not exist -- which is what a real session did before this text.
|
|
29
|
+
*/
|
|
30
|
+
export const STATE_PATH_GUIDANCE = "This path is repository or xeno session state, which edit tools never change; no setting changes this. " +
|
|
31
|
+
"To work in an isolated checkout, use the worktree tool instead of creating one under .git or .xeno.";
|
|
32
|
+
const STATE_PATH_GUIDANCE_WITHOUT_WORKTREE = "This path is repository or xeno session state, which edit tools never change; no setting changes this. " +
|
|
33
|
+
"Edit files outside .git and .xeno.";
|
|
34
|
+
|
|
24
35
|
export function failureDiagnostic(code: ToolDiagnostic["code"], message: string, facts: FailureFacts,
|
|
25
|
-
extra: Pick<ToolDiagnostic, "recover" | "example"> = {}): ToolDiagnostic {
|
|
36
|
+
extra: Pick<ToolDiagnostic, "recover" | "example" | "next"> = {}): ToolDiagnostic {
|
|
26
37
|
const retry = code === "permission_denied" || code === "cancelled" ? "never"
|
|
27
38
|
: code === "unknown_effect" || facts.effects === "possible" ? "after_inspection"
|
|
28
39
|
: code === "transient" ? "backoff" : "after_change";
|
|
29
|
-
|
|
40
|
+
const { next = guidance[code], ...rest } = extra;
|
|
41
|
+
return { code, message, ...facts, retry, next, ...(code === "permission_denied" ? {} : rest) };
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
/** R10: only new advice is scoped; execution facts and historical records are never rewritten. */
|
|
33
45
|
export function scopeFailureGuidance(diagnostic: ToolDiagnostic, tools: readonly string[], recoverAllowed = true): ToolDiagnostic {
|
|
34
46
|
const { recover, ...facts } = diagnostic;
|
|
35
47
|
const next = diagnostic.execution !== undefined && diagnostic.code === "environment_missing" && !tools.includes("environment")
|
|
36
|
-
? { next: "Verify the working directory and required executable in the current executor using permitted observations. If the needed capability is unavailable, report that evidence gap before retrying." }
|
|
48
|
+
? { next: "Verify the working directory and required executable in the current executor using permitted observations. If the needed capability is unavailable, report that evidence gap before retrying." }
|
|
49
|
+
: diagnostic.next === STATE_PATH_GUIDANCE && !tools.includes("worktree") ? { next: STATE_PATH_GUIDANCE_WITHOUT_WORKTREE } : {};
|
|
37
50
|
return { ...facts, ...next, ...(recover && recoverAllowed && tools.includes(recover.tool) && diagnostic.code !== "permission_denied" ? { recover } : {}) };
|
|
38
51
|
}
|
|
39
52
|
|
|
40
53
|
/** Runtime and store throw only facts they own; the outer boundary must retain these facts. */
|
|
41
54
|
export class ToolFault extends Error {
|
|
42
55
|
readonly diagnostic: ToolDiagnostic;
|
|
43
|
-
constructor(code: ToolDiagnostic["code"], message: string, facts: FailureFacts, extra: Pick<ToolDiagnostic, "recover"> = {}) {
|
|
56
|
+
constructor(code: ToolDiagnostic["code"], message: string, facts: FailureFacts, extra: Pick<ToolDiagnostic, "recover" | "next"> = {}) {
|
|
44
57
|
super(message); this.name = "ToolFault";
|
|
45
58
|
this.diagnostic = failureDiagnostic(code, message, facts, extra);
|
|
46
59
|
}
|
package/src/core/update-check.ts
CHANGED
|
@@ -46,7 +46,8 @@ export interface UpdatePlan {
|
|
|
46
46
|
readonly notice?: string;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
/** Shared by every background network opt-out, so one variable means the same everywhere. */
|
|
50
|
+
export function truthy(value: string | undefined): boolean {
|
|
50
51
|
return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -23,6 +23,8 @@ import { join } from "node:path";
|
|
|
23
23
|
import { randomUUID } from "node:crypto";
|
|
24
24
|
import { commandArgv, commandEnvironment, COMMAND_TERMINATION_GRACE_MS } from "../core/command-contract.ts";
|
|
25
25
|
import { archiveObservation, type ArchivedObservation } from "../store/observations.ts";
|
|
26
|
+
import { configDir } from "../store/config.ts";
|
|
27
|
+
import { xenoBinDir } from "../store/tool-binaries.ts";
|
|
26
28
|
import { ToolFault, NOT_STARTED } from "../core/tool-failure.ts";
|
|
27
29
|
import { processReceipt, retainProcessReceipts, type ProcessReceipt, type ProcessSource } from "../core/process-receipt.ts";
|
|
28
30
|
import type { CommandArtifactObserver } from "./command-artifacts.ts";
|
|
@@ -170,7 +172,7 @@ export async function startSession(input: {
|
|
|
170
172
|
await mkdir(input.spoolDir, { recursive: true });
|
|
171
173
|
const spoolPath = join(input.spoolDir, `.${randomUUID()}.session`);
|
|
172
174
|
const spool = await open(spoolPath, "w+", 0o600);
|
|
173
|
-
const env = commandEnvironment(process.env);
|
|
175
|
+
const env = commandEnvironment(process.env, xenoBinDir(configDir()));
|
|
174
176
|
let resolveExit!: () => void;
|
|
175
177
|
const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
|
|
176
178
|
const session = {
|
package/src/runtime/loop.ts
CHANGED
|
@@ -42,8 +42,9 @@ import {
|
|
|
42
42
|
} from "../core/context-policy.ts";
|
|
43
43
|
import { retainedContext, selectRecentContext } from "../core/context-window.ts";
|
|
44
44
|
import { advanceStall, emptyStallState, stallDecision, DEFAULT_STALL_LIMIT, type StallState } from "../core/stall.ts";
|
|
45
|
+
import { questionRequest, validateQuestionReply } from "../core/question.ts";
|
|
45
46
|
import { DEFAULT_OUTPUT_RECOVERY_LIMIT, isCompleteResponse, incompleteToolText, knownUnexecutedTool, outputRecoveryNotice, validateOutputRecoveryLimit } from "../core/response-recovery.ts";
|
|
46
|
-
import { join, resolve } from "node:path";
|
|
47
|
+
import { join, relative, resolve } from "node:path";
|
|
47
48
|
import { randomUUID } from "node:crypto";
|
|
48
49
|
import { pendingToolCalls } from "../core/execution.ts";
|
|
49
50
|
import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
|
|
@@ -91,7 +92,7 @@ import { FailedResponseError, type Provider, type Turn, type StreamDelta } from
|
|
|
91
92
|
import { putBlob, getBlob } from "../store/blobs.ts";
|
|
92
93
|
import { hydrateProcessReceipts } from "../store/process-receipts.ts";
|
|
93
94
|
import { readNodeHistory } from "../store/restore.ts";
|
|
94
|
-
import { createWorktree, removeWorktree } from "../store/worktree.ts";
|
|
95
|
+
import { createWorktree, listWorktrees, mainWorktree, persistentWorktreeParent, removeWorktree } from "../store/worktree.ts";
|
|
95
96
|
import {
|
|
96
97
|
parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
|
|
97
98
|
BRANCH_TOOL_NAMES, WRITING_BRANCH_TOOL_NAMES,
|
|
@@ -113,6 +114,7 @@ import { changedPaths, invertFileChanges, sameVersion, type CheckpointEvent, typ
|
|
|
113
114
|
import type { GitCheckpoint } from "../store/git-checkpoints.ts";
|
|
114
115
|
import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
|
|
115
116
|
import { planCheckpointRestore } from "../store/checkpoint-history.ts";
|
|
117
|
+
import { configDir } from "../store/config.ts";
|
|
116
118
|
|
|
117
119
|
/** Ids and timestamps come from here so the loop stays the only impure part. */
|
|
118
120
|
export interface Clock {
|
|
@@ -380,6 +382,8 @@ export class Session {
|
|
|
380
382
|
private recovery: RecoveryState | null = null;
|
|
381
383
|
private processWake = new AbortController();
|
|
382
384
|
private pendingAdoption: number | null = null;
|
|
385
|
+
/** A worktree enter/exit moves the root only once its tool round is paired, like adoption. */
|
|
386
|
+
private pendingRoot: string | null = null;
|
|
383
387
|
private readonly allocatedEventIds = new Set<string>();
|
|
384
388
|
private recordTail: Promise<void> = Promise.resolve();
|
|
385
389
|
private readonly recordListeners = new Set<(item: CommittedItem) => void>();
|
|
@@ -1337,8 +1341,8 @@ export class Session {
|
|
|
1337
1341
|
return;
|
|
1338
1342
|
}
|
|
1339
1343
|
|
|
1340
|
-
if (yield* this.checkStall()) {
|
|
1341
|
-
yield { kind: "done", reason: await report("
|
|
1344
|
+
if (yield* this.checkStall(request, signal)) {
|
|
1345
|
+
yield { kind: "done", reason: await report("repeated tool cycle without new evidence", turn), outcome: executionOutcome("stalled") };
|
|
1342
1346
|
return;
|
|
1343
1347
|
}
|
|
1344
1348
|
if (phase === "finalize" && !supportsToolDisabling(this.provider.config.spec.api)) {
|
|
@@ -1633,6 +1637,7 @@ export class Session {
|
|
|
1633
1637
|
// granularity the research warns against.
|
|
1634
1638
|
const declarationNode = this.state.node;
|
|
1635
1639
|
if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
|
|
1640
|
+
if (this.pendingRoot !== null) await this.applyPendingRoot();
|
|
1636
1641
|
if (this.pendingAdoption !== null) await this.adoptPendingBranch();
|
|
1637
1642
|
|
|
1638
1643
|
// Between complete exchanges is the only safe place to cut.
|
|
@@ -1931,7 +1936,7 @@ export class Session {
|
|
|
1931
1936
|
const forkSkill = call.name === "read" && typeof call.input === "object" && call.input !== null ? this.config.skills?.find(skill =>
|
|
1932
1937
|
skill.context === "fork" && skill.filePath === resolve(ctx.root, String((call.input as Record<string, unknown>)["path"]))) : undefined;
|
|
1933
1938
|
const discovery = call.name === DISCOVERY_TOOL_NAME && ctx.extensions?.get(call.name) === DISCOVERY_TOOL;
|
|
1934
|
-
if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
|
|
1939
|
+
if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target", "worktree"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
|
|
1935
1940
|
const branchIndex = forkSkill || call.name === "branch" ? nextBranch() : 0;
|
|
1936
1941
|
return prepareNormalizedTool(ctx, call.name, call.input, async () => {
|
|
1937
1942
|
if (discovery) return async () => searchDeferredTools(ctx, call.input as Record<string, unknown>, this.allowedNames());
|
|
@@ -1950,6 +1955,7 @@ export class Session {
|
|
|
1950
1955
|
return { ...prepared.out, ...(prepared.annotation ? { annotation: prepared.annotation } : {}) };
|
|
1951
1956
|
};
|
|
1952
1957
|
if (call.name === "resolve") return () => this.runResolve(call.input);
|
|
1958
|
+
if (call.name === "worktree") return () => this.runWorktree(call.input, ctx.pathRoot);
|
|
1953
1959
|
if (forkSkill?.disableModelInvocation) return async () => ({ ok: false, content: "this skill requires explicit user invocation" });
|
|
1954
1960
|
if (forkSkill) await this.recordSkill(forkSkill, "fork", "model");
|
|
1955
1961
|
const task = this.state.events.findLast(event => event.t === "user");
|
|
@@ -2163,6 +2169,50 @@ export class Session {
|
|
|
2163
2169
|
};
|
|
2164
2170
|
}
|
|
2165
2171
|
|
|
2172
|
+
private async runWorktree(input: unknown, pathRoot?: string): Promise<ToolResult> {
|
|
2173
|
+
const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
|
|
2174
|
+
const root = this.config.tools.root;
|
|
2175
|
+
// A branch already runs in its own checkout and never owns the parent's root.
|
|
2176
|
+
if ((this.config.branchDepth ?? 0) > 0) return { content: "worktree is unavailable inside a branch; the branch already has its own checkout", ok: false };
|
|
2177
|
+
if (this.pendingRoot !== null) return { content: "one worktree switch per tool round; the previous one applies after this round", ok: false };
|
|
2178
|
+
const main = await mainWorktree(root);
|
|
2179
|
+
if (main === null) return { content: `${root} is not inside a git repository, so there is nothing to make a worktree of`, ok: false };
|
|
2180
|
+
const outside = (path: string) => pathRoot !== undefined && relative(pathRoot, path).startsWith("..");
|
|
2181
|
+
if (args["action"] === "exit") {
|
|
2182
|
+
if (main === root) return { content: `already in the main checkout ${main}; nothing to exit`, ok: false };
|
|
2183
|
+
if (outside(main)) return { content: `the main checkout ${main} is outside --path-root ${pathRoot}`, ok: false };
|
|
2184
|
+
const branch = (await listWorktrees(main)).find(wt => wt.path === root)?.branch ?? null;
|
|
2185
|
+
this.pendingRoot = main;
|
|
2186
|
+
return { ok: true, content: [`main ${main}`, `kept ${root}${branch ? ` on ${branch.replace(/^refs\/heads\//, "")}` : ""}`,
|
|
2187
|
+
"The session root moves to the main checkout after this tool round. Nothing was merged or deleted; " +
|
|
2188
|
+
`remove the worktree yourself with \`git worktree remove ${root}\` once its branch is merged or no longer needed.`].join("\n") };
|
|
2189
|
+
}
|
|
2190
|
+
if (args["action"] !== "enter") return { content: "worktree action must be enter or exit", ok: false };
|
|
2191
|
+
const name = typeof args["name"] === "string" ? args["name"] : "";
|
|
2192
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || name.includes("..")) {
|
|
2193
|
+
return { content: "worktree name must be 1-64 letters, digits, dot, dash or underscore, starting with a letter or digit", ok: false };
|
|
2194
|
+
}
|
|
2195
|
+
const parent = persistentWorktreeParent(configDir(), main);
|
|
2196
|
+
if (outside(parent)) return { content: `xeno's worktree directory ${parent} is outside --path-root ${pathRoot}`, ok: false };
|
|
2197
|
+
const created = await createWorktree(root, name, { location: { parent, leaf: name }, includeChanges: args["include_changes"] === true });
|
|
2198
|
+
if (!created.ok) return { content: `refused: ${created.error}`, ok: false };
|
|
2199
|
+
this.pendingRoot = created.path;
|
|
2200
|
+
return { ok: true, content: [`worktree ${created.path}`, `branch ${created.branch}`, `from ${created.snapshot.head}`,
|
|
2201
|
+
`The session root moves there after this tool round; relative paths, edits and commands then resolve in it. ` +
|
|
2202
|
+
`The working directory in the system prompt still names ${root}. ` +
|
|
2203
|
+
(args["include_changes"] === true ? "It starts from the current uncommitted files." : "It starts from HEAD; uncommitted files in the main checkout were not copied.")].join("\n"),
|
|
2204
|
+
data: { path: created.path, branch: created.branch, head: created.snapshot.head } };
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
private async applyPendingRoot(): Promise<void> {
|
|
2208
|
+
const root = this.pendingRoot;
|
|
2209
|
+
this.pendingRoot = null;
|
|
2210
|
+
if (root === null) return;
|
|
2211
|
+
this.config.tools.root = root;
|
|
2212
|
+
this.installWorkspace();
|
|
2213
|
+
await this.saveCursor();
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2166
2216
|
private async runResolve(input: unknown): Promise<ToolResult> {
|
|
2167
2217
|
const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
|
|
2168
2218
|
const node = Number(args["node"]);
|
|
@@ -2555,7 +2605,14 @@ export class Session {
|
|
|
2555
2605
|
return recoveryReview(this.recovery, tools);
|
|
2556
2606
|
}
|
|
2557
2607
|
|
|
2558
|
-
|
|
2608
|
+
/**
|
|
2609
|
+
* S.8: a repeated cycle is pointed out to the model once; the next one is
|
|
2610
|
+
* the user's call when a question client can answer -- continue, stop, or
|
|
2611
|
+
* new instructions -- and ends the request only without one (headless) or
|
|
2612
|
+
* when the user stops it. The answer is recorded as an event, never kept
|
|
2613
|
+
* as loop state: a continue is a `stall` guidance, instructions are steering.
|
|
2614
|
+
*/
|
|
2615
|
+
private async *checkStall(request: string, signal?: AbortSignal): AsyncGenerator<LoopEvent, boolean> {
|
|
2559
2616
|
const limit = this.config.stallLimit ?? DEFAULT_STALL_LIMIT;
|
|
2560
2617
|
if (limit === 0) return false;
|
|
2561
2618
|
if (this.stall === null) {
|
|
@@ -2564,13 +2621,42 @@ export class Session {
|
|
|
2564
2621
|
this.stall = advanceStall(emptyStallState(), history.nodes.flatMap(({ node, events }) => events.map((event) => ({ node, event }))));
|
|
2565
2622
|
}
|
|
2566
2623
|
const decision = stallDecision(this.stall, limit);
|
|
2567
|
-
if (decision.
|
|
2568
|
-
|
|
2569
|
-
|
|
2624
|
+
if (decision.cycle === null) return false;
|
|
2625
|
+
const pattern = decision.cycle === 1 ? "the same tool call" : `a cycle of ${decision.cycle} tool calls`;
|
|
2626
|
+
const facts = `${pattern} repeated ${decision.repeats} times without new evidence (${decision.failures} failed receipts; ${decision.emptyWaits} empty waits)`;
|
|
2627
|
+
if (decision.warn) {
|
|
2628
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
|
|
2629
|
+
text: `Harness: ${facts}. Change the method, run a meaningful check, or give the final result with unresolved limits. If the pattern repeats again, the user decides whether the request continues.` });
|
|
2630
|
+
return false;
|
|
2570
2631
|
}
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2632
|
+
const answer = await this.askAboutCycle(facts, signal);
|
|
2633
|
+
if (answer?.kind === "continue") {
|
|
2634
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
|
|
2635
|
+
text: `Harness: the user reviewed this (${facts}) and chose to continue. Do not repeat the same calls expecting a different result; change the method or finish with the result and its limits.` });
|
|
2636
|
+
return false;
|
|
2637
|
+
}
|
|
2638
|
+
if (answer?.kind === "steer") {
|
|
2639
|
+
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, text: answer.text });
|
|
2640
|
+
return false;
|
|
2641
|
+
}
|
|
2642
|
+
yield { kind: "stop_and_ask", reason: `${facts}; change the approach or provide new input` };
|
|
2643
|
+
return true;
|
|
2644
|
+
}
|
|
2645
|
+
|
|
2646
|
+
/** Undefined means nobody answered: no question client (headless), cancelled, or an invalid reply. */
|
|
2647
|
+
private async askAboutCycle(facts: string, signal?: AbortSignal): Promise<{ kind: "continue" } | { kind: "stop" } | { kind: "steer"; text: string } | undefined> {
|
|
2648
|
+
const slot = this.config.tools.questionSlot, adapter = slot?.current, turn = slot?.activeTurn;
|
|
2649
|
+
if (!adapter || !turn || signal?.aborted) return undefined;
|
|
2650
|
+
const request = questionRequest(turn, `stall-${this.clock.nextId()}`, [{ id: "stall", header: "Repeated tool calls",
|
|
2651
|
+
question: `The agent ${facts}. Continue, stop, or type instructions for it?`, multiple: false, custom: true,
|
|
2652
|
+
options: [{ id: "continue", label: "Continue", description: "Let the agent keep working; it is told to change its method." },
|
|
2653
|
+
{ id: "stop", label: "Stop", description: "End this request; the log and evidence stay." }] }]);
|
|
2654
|
+
try {
|
|
2655
|
+
const reply = validateQuestionReply(request, await adapter(structuredClone(request), signal ?? new AbortController().signal));
|
|
2656
|
+
const [answer] = reply.answers, text = answer?.text?.trim();
|
|
2657
|
+
if (text) return { kind: "steer", text };
|
|
2658
|
+
return answer?.choices[0] === "continue" ? { kind: "continue" } : answer?.choices[0] === "stop" ? { kind: "stop" } : undefined;
|
|
2659
|
+
} catch { return undefined; }
|
|
2574
2660
|
}
|
|
2575
2661
|
|
|
2576
2662
|
/** A steering signal releases only the wait; cancellation owns process termination. */
|
package/src/runtime/openai.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type { Message, ContentBlock, FrozenPrefix } from "../core/project.ts";
|
|
|
23
23
|
import { imageData } from "./images.ts";
|
|
24
24
|
import type { AssistantBlock } from "../core/events.ts";
|
|
25
25
|
import { assertNever } from "../core/exhaustive.ts";
|
|
26
|
-
import type
|
|
26
|
+
import { coherentTurn, type Provider, type ProviderConfig, type Turn, type ToolSchema, type OnDelta } from "./provider.ts";
|
|
27
27
|
import { DEFAULT_COMPAT, type Compat, type Effort, type ModelSpec } from "./model.ts";
|
|
28
28
|
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
29
29
|
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
@@ -502,7 +502,7 @@ export class OpenAICompletionsProvider implements Provider {
|
|
|
502
502
|
}
|
|
503
503
|
|
|
504
504
|
const assembled = await readCompletionsSse(response.body, onDelta);
|
|
505
|
-
return parseResponse(assembled, node, Date.now() - started);
|
|
505
|
+
return coherentTurn(parseResponse(assembled, node, Date.now() - started), assembled.usage !== undefined);
|
|
506
506
|
}
|
|
507
507
|
}
|
|
508
508
|
|
|
@@ -20,7 +20,7 @@ import { providerRequest } from "./provider-request.ts";
|
|
|
20
20
|
import { grokVersionHint } from "./oauth.ts";
|
|
21
21
|
import type { ResolveEffects } from "./auth.ts";
|
|
22
22
|
import { imageData } from "./images.ts";
|
|
23
|
-
import { FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
|
|
23
|
+
import { coherentTurn, FailedResponseError, type OnDelta, type Provider, type ProviderConfig, type ToolSchema, type Turn } from "./provider.ts";
|
|
24
24
|
import { DEFAULT_RETRY, withRetry, type RetryPolicy } from "./retry.ts";
|
|
25
25
|
import { HttpError, StreamError } from "./openai.ts";
|
|
26
26
|
import type { UsageSample } from "./usage.ts";
|
|
@@ -392,7 +392,7 @@ export class PiProvider implements Provider {
|
|
|
392
392
|
if (identityErrors.length) throw new ProviderIntegrityError(identityErrors, hasKnownUsage ? usage : undefined);
|
|
393
393
|
// The SDK has already classified native STOP/end_turn/toolUse/length values. The loop consumes
|
|
394
394
|
// xeno's closed completion vocabulary, never a raw native spelling that could skip tool execution.
|
|
395
|
-
return { blocks: piBlocks(completed, originalArguments.values), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
|
|
396
|
-
usage };
|
|
395
|
+
return coherentTurn({ blocks: piBlocks(completed, originalArguments.values), stopReason: completed.stopReason === "length" ? "max_tokens" : completed.stopReason === "toolUse" ? "tool_use" : "end_turn",
|
|
396
|
+
usage }, hasKnownUsage);
|
|
397
397
|
}
|
|
398
398
|
}
|