@visual-z/xeno 0.6.0 → 0.7.0

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.
@@ -0,0 +1,26 @@
1
+ /** D8 / D11: terminal exit is a bounded projection of execution facts, never a replay. */
2
+ import type { LogEvent } from "./events.ts";
3
+ import { pendingToolCalls } from "./execution.ts";
4
+ import { modelOutcome } from "./outcome.ts";
5
+
6
+ export const DEFAULT_FULLSCREEN_EXIT_OUTPUT = "resume-hint";
7
+
8
+ /** Terminal controls and arbitrarily large reason text do not own the shell surface. */
9
+ export function exitLine(text: string, limit = 180): string {
10
+ return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/[\x00-\x1f\x7f-\x9f]/g, " ").replace(/\s+/g, " ").trim().slice(0, limit);
11
+ }
12
+
13
+ export function sessionExitStatus(events: readonly LogEvent[]): string {
14
+ const start = events.findLastIndex(event => event.t === "user");
15
+ const current = events.slice(Math.max(0, start));
16
+ const outcome = current.findLast(event => event.t === "outcome" || event.t === "user_bash");
17
+ if (outcome?.t === "outcome") return exitLine(`${outcome.outcome.status} · ${outcome.outcome.reason} · ${outcome.reason}`);
18
+ if (outcome?.t === "user_bash") return `${outcome.outcome.status} · ${outcome.outcome.reason} · command exit ${outcome.exit}`;
19
+ if (current.some(event => event.t === "interrupt")) return "cancelled · user interrupted";
20
+ const assistant = current.findLast(event => event.t === "assistant");
21
+ if (assistant?.t === "assistant" && !assistant.blocks.some(block => block.type === "tool_use") && pendingToolCalls(current).length === 0) {
22
+ const result = modelOutcome(assistant.stop_reason ?? null);
23
+ return result.reason === "model_end" ? "model finished · verification remains in session evidence" : `${result.status} · ${result.reason}`;
24
+ }
25
+ return start < 0 ? "session saved" : "unfinished · inspect recorded receipts before continuing";
26
+ }
@@ -99,7 +99,7 @@ export const DISPLAY_SETTINGS = [
99
99
  { id: "images.blockImages", label: "Block image transmission", fallback: "off" },
100
100
  { id: "markdown.mermaid", label: "Mermaid diagrams", fallback: "streaming" },
101
101
  { id: "tuiMode", label: "TUI mode", fallback: "fullscreen" },
102
- { id: "fullscreenExitOutput", label: "Fullscreen exit output", fallback: "transcript" },
102
+ { id: "fullscreenExitOutput", label: "Fullscreen exit output", fallback: "resume-hint" },
103
103
  { id: "fullscreenScrollbar", label: "Fullscreen scrollbar", fallback: "hidden" },
104
104
  { id: "fullscreenCopyOnSelect", label: "Copy on select", fallback: "on" },
105
105
  { id: "fullscreenWheelScrollLines", label: "Fullscreen wheel scroll lines", fallback: "auto" },
package/src/core/stall.ts CHANGED
@@ -3,6 +3,7 @@ import type { LogEvent } from "./events.ts";
3
3
  import { pendingToolCalls } from "./execution.ts";
4
4
  import { exchangeProgressChecks } from "./evidence.ts";
5
5
  import { deriveProgress, type ProgressEvidence } from "./progress.ts";
6
+ import { isProcessReceipt } from "./process-receipt.ts";
6
7
 
7
8
  // A conservative hypothesis: enough room to change method after a mid-budget
8
9
  // notice, while bounding repeated paid requests. Not a task-quality classifier.
@@ -12,18 +13,27 @@ export interface StallState {
12
13
  pending: readonly LogEvent[];
13
14
  pendingNode: number;
14
15
  consecutive: number;
16
+ unproductive: number;
17
+ failures: number;
18
+ emptyWaits: number;
15
19
  warned: boolean;
16
20
  }
17
21
  export function emptyStallState(): StallState {
18
- return { evidence: [], pending: [], pendingNode: 0, consecutive: 0, warned: false };
22
+ return { evidence: [], pending: [], pendingNode: 0, consecutive: 0, unproductive: 0, failures: 0, emptyWaits: 0, warned: false };
19
23
  }
20
24
  export function finishStallExchange(state: StallState): StallState {
21
25
  const assistant = state.pending[0];
22
26
  if (assistant?.t !== "assistant" || pendingToolCalls(state.pending).length > 0) return state;
23
27
  if (!assistant.blocks.some((block) => block.type === "tool_use")) return { ...state, pending: [] };
24
28
  const progress = deriveProgress(state.evidence, state.pending, state.pendingNode, exchangeProgressChecks(state.pending));
29
+ const results = state.pending.filter(event => event.t === "tool_result");
30
+ const waits = new Set(assistant.blocks.flatMap(block => block.type === "tool_use" && block.name === "write_stdin" ? [block.call_id] : []));
25
31
  return { ...state, pending: [], evidence: progress.evidence,
26
- consecutive: progress.progressed ? 0 : state.consecutive + 1, warned: progress.progressed ? false : state.warned };
32
+ consecutive: progress.progressed ? 0 : state.consecutive + 1,
33
+ unproductive: state.unproductive + (progress.progressed ? 0 : 1),
34
+ failures: state.failures + results.filter(result => !result.ok).length,
35
+ emptyWaits: state.emptyWaits + results.filter(result => waits.has(result.call_id) && isProcessReceipt(result.data) &&
36
+ result.data.running === true && result.data.cursor !== undefined && result.data.cursor.start === result.data.cursor.end).length };
27
37
  }
28
38
 
29
39
  /** Fold original events, never the retained projection where evidence can repeat. */
@@ -32,7 +42,7 @@ export function advanceStall(state: StallState, entries: readonly { node: number
32
42
  for (const { node, event } of entries) {
33
43
  if (event.t === "assistant" || event.t === "user" || event.t === "cut" || (event.t === "guidance" && event.reason === "stall")) {
34
44
  result = finishStallExchange(result);
35
- if (event.t === "user") result = { ...result, pending: [], consecutive: 0, warned: false };
45
+ if (event.t === "user") result = { ...result, pending: [], consecutive: 0, unproductive: 0, failures: 0, emptyWaits: 0, warned: false };
36
46
  if (event.t === "assistant") result = { ...result, pending: [event], pendingNode: node };
37
47
  if (event.t === "guidance") result = { ...result, warned: true };
38
48
  } else if (result.pending.length > 0) result = { ...result, pending: [...result.pending, event] };
@@ -42,7 +52,9 @@ export function advanceStall(state: StallState, entries: readonly { node: number
42
52
  export function stallDecision(state: StallState, limit: number) {
43
53
  if (!Number.isSafeInteger(limit) || limit < 0 || limit === 1) throw new RangeError("stall limit must be zero or an integer of at least two");
44
54
  const current = finishStallExchange(state);
45
- return { consecutive: current.consecutive,
46
- stop: limit > 0 && current.consecutive >= limit,
47
- warn: limit > 0 && current.consecutive >= Math.max(1, Math.floor(limit / 2)) && !current.warned };
55
+ // Reuse the existing conservative allowance. Novelty resets a streak, not
56
+ // the request's spent no-progress budget (research Q.26); failures are diagnostics.
57
+ return { consecutive: current.consecutive, unproductive: current.unproductive, failures: current.failures, emptyWaits: current.emptyWaits,
58
+ stop: limit > 0 && (current.consecutive >= limit || current.unproductive >= limit),
59
+ warn: limit > 0 && current.unproductive >= Math.max(1, Math.floor(limit / 2)) && !current.warned };
48
60
  }
@@ -0,0 +1,25 @@
1
+ /** D8 / D11: summarize termination mechanically; assistant claims never become receipts. */
2
+ import type { LogEvent } from "./events.ts";
3
+ import { isProcessReceipt, latestProcessReceipt } from "./process-receipt.ts";
4
+ import { exitLine } from "./session-exit.ts";
5
+
6
+ export function stopReport(events: readonly LogEvent[], requests: number, limit: number, cause: string): string {
7
+ const start = events.findLastIndex(event => event.t === "user" && event.request === undefined);
8
+ const current = events.slice(Math.max(0, start));
9
+ const results = current.filter(event => event.t === "tool_result");
10
+ const failures = results.filter(event => !event.ok);
11
+ const exits = results.filter(event => event.effect?.kind === "command" && event.effect.exit === 0).length;
12
+ const ids = new Set<number>();
13
+ for (const event of current) {
14
+ if (event.t === "tool_result" && isProcessReceipt(event.data)) ids.add(event.data.session_id);
15
+ if (event.t === "process_exit") ids.add(event.session);
16
+ }
17
+ const running = [...ids].filter(id => latestProcessReceipt([{ node: 0, events: current }], id)?.receipt.running === true);
18
+ return [
19
+ `Stopped: ${exitLine(cause)} (${requests}/${limit} model requests).`,
20
+ `Recorded evidence: ${results.length} tool receipts; ${exits} successful command exits; ${failures.length} failed tool receipts. Task completion is not verified by this stop.`,
21
+ ...(failures.length ? [`Inspect failed receipts: ${failures.slice(-3).map(event => exitLine(event.id, 80)).join(", ")}.`] : []),
22
+ ...(running.length ? [`Last observed running: ${running.slice(0, 3).join(", ")}${running.length > 3 ? ` (+${running.length - 3})` : ""}; inspect the process receipt before replaying a command.`] : []),
23
+ "Next: inspect /state or the recorded tool output, then provide a focused continuation; completed commands should not be replayed just to recover their output.",
24
+ ].join("\n");
25
+ }
@@ -30,7 +30,7 @@ import type { CommandArtifactReceipt } from "../core/command-artifacts.ts";
30
30
 
31
31
  /** Codex's default: long enough for most commands to finish in one call. */
32
32
  export const DEFAULT_YIELD_MS = 10_000;
33
- /** Bounds on one wait. A longer wait is a longer silence the model cannot act in. */
33
+ /** Bounds on a foreground poll or input send. Owned observation waits have no polling deadline. */
34
34
  export const MIN_YIELD_MS = 100;
35
35
  export const MAX_YIELD_MS = 30_000;
36
36
  /**
@@ -66,6 +66,7 @@ interface Session {
66
66
  spoolPath: string;
67
67
  queue: Promise<unknown>;
68
68
  operations: Promise<unknown>;
69
+ outputListeners: Set<() => void>;
69
70
  final?: TakenOutput;
70
71
  captureError?: string;
71
72
  written: number;
@@ -128,6 +129,7 @@ export function closedSession(id: number, owner?: string): ClosedSession | undef
128
129
  }
129
130
 
130
131
  function append(session: Session, chunk: Uint8Array): void {
132
+ if (chunk.length === 0) return;
131
133
  const bytes = chunk.slice();
132
134
  session.queue = session.queue.then(async () => {
133
135
  let offset = 0;
@@ -136,6 +138,7 @@ function append(session: Session, chunk: Uint8Array): void {
136
138
  if (bytesWritten < 1) throw new Error("process output spool made no write progress");
137
139
  offset += bytesWritten; session.written += bytesWritten;
138
140
  }
141
+ for (const notify of session.outputListeners) notify();
139
142
  }).catch(error => {
140
143
  session.captureError = `process output capture failed: ${String(error)}`;
141
144
  stopSession(session, "cancelled");
@@ -161,7 +164,7 @@ export async function startSession(input: {
161
164
  const exited = new Promise<void>((resolve) => { resolveExit = resolve; });
162
165
  const session = {
163
166
  id: nextId++, ...(input.owner === undefined ? {} : { owner: input.owner }), command: input.command, cwd: input.cwd, tty: input.tty, source: input.source, started: performance.now(),
164
- spool, spoolPath, queue: Promise.resolve(), operations: Promise.resolve(), written: 0, read: 0,
167
+ spool, spoolPath, queue: Promise.resolve(), operations: Promise.resolve(), outputListeners: new Set<() => void>(), written: 0, read: 0,
165
168
  exit: null, timeoutMs: input.timeoutMs, exited, drained: Promise.resolve(),
166
169
  ...(input.artifactObserver ? { artifactObserver: input.artifactObserver } : {}),
167
170
  } as Omit<Session, "pid" | "write" | "kill" | "timer"> as Session;
@@ -282,6 +285,38 @@ async function waitSession(session: Session, yieldMs: number, signal?: AbortSign
282
285
  await session.queue;
283
286
  }
284
287
 
288
+ export interface ObservationWait {
289
+ /** Steering releases this observation wait without cancelling the owned command. */
290
+ wake?: AbortSignal;
291
+ onWaiting?: (active: boolean) => void;
292
+ }
293
+
294
+ /** OMP's job owner pattern: no paid model turn merely to observe elapsed time. */
295
+ async function waitForObservation(session: Session, signal: AbortSignal | undefined, wait: ObservationWait): Promise<void> {
296
+ await session.queue;
297
+ let notify!: () => void;
298
+ const changed = new Promise<void>(resolve => { notify = resolve; });
299
+ session.outputListeners.add(notify);
300
+ signal?.addEventListener("abort", notify, { once: true });
301
+ wait.wake?.addEventListener("abort", notify, { once: true });
302
+ const pending = session.exit === null && session.read === session.written && !signal?.aborted && !wait.wake?.aborted;
303
+ try {
304
+ if (pending) {
305
+ wait.onWaiting?.(true);
306
+ await Promise.race([session.exited, changed]);
307
+ }
308
+ } finally {
309
+ session.outputListeners.delete(notify);
310
+ signal?.removeEventListener("abort", notify);
311
+ wait.wake?.removeEventListener("abort", notify);
312
+ if (pending) wait.onWaiting?.(false);
313
+ }
314
+ if (signal?.aborted) stopSession(session, "cancelled");
315
+ if (session.stopped !== undefined) await session.exited;
316
+ if (session.exit !== null) await drainExitedSession(session);
317
+ await session.queue;
318
+ }
319
+
285
320
  /**
286
321
  * Bytes printed since the previous call, archived in full. A session whose
287
322
  * exit has now been reported is closed.
@@ -325,7 +360,7 @@ async function takeOutput(session: Session, outputStore: string, previewBytes: n
325
360
 
326
361
  /** One lease covers stdin, waiting, archival and cursor commit; cancellation can interrupt a queued wait. */
327
362
  export async function pollSession(session: Session, outputStore: string, previewBytes: number, yieldMs: number,
328
- chars = "", signal?: AbortSignal): Promise<TakenOutput> {
363
+ chars = "", signal?: AbortSignal, wait?: ObservationWait): Promise<TakenOutput> {
329
364
  const cancel = () => stopSession(session, "cancelled");
330
365
  if (signal?.aborted) cancel(); else signal?.addEventListener("abort", cancel, { once: true });
331
366
  const result = session.operations.then(async () => {
@@ -337,7 +372,8 @@ export async function pollSession(session: Session, outputStore: string, preview
337
372
  if (session.exit !== null) throw new ToolFault("not_found", "process has exited; input was not sent; poll with empty chars", NOT_STARTED);
338
373
  session.write(chars);
339
374
  }
340
- await waitSession(session, yieldMs, signal);
375
+ if (chars.length === 0 && wait) await waitForObservation(session, signal, wait);
376
+ else await waitSession(session, yieldMs, signal);
341
377
  return takeOutput(session, outputStore, previewBytes);
342
378
  });
343
379
  session.operations = result.catch(() => {});
@@ -7,13 +7,17 @@ import { convertImageBytesToPng, convertToPng } from "../vendor/pi-rich/image-co
7
7
  import { putBlob, getBlobBytes } from "../store/blobs.ts";
8
8
  import { imageDescription, validImageRef, type ImageRef } from "../core/images.ts";
9
9
  import type { ContentBlock, Message } from "../core/project.ts";
10
+ import { imageDimensions, modelImageSide, MAX_IMAGE_SOURCE_BYTES, MAX_IMAGE_SOURCE_PIXELS } from "../core/image-limits.ts";
10
11
 
11
12
  // Admission limit, not a context threshold: avoid reading arbitrarily large local binaries into WASM.
12
- const MAX_IMAGE_SOURCE_BYTES = 64 * 1024 * 1024;
13
13
  export async function attachImageBytes(bytes: Uint8Array, name: string, blobDir: string, autoResize = true): Promise<ImageRef> {
14
14
  if (bytes.byteLength > MAX_IMAGE_SOURCE_BYTES) throw new Error("Image exceeds the 64 MiB attachment limit");
15
15
  let mime = detectSupportedImageMimeType(bytes);
16
16
  if (!mime) throw new Error("Unsupported or malformed image: " + name);
17
+ const dimensions = imageDimensions(bytes, mime);
18
+ if (!dimensions) throw new Error("Cannot read image dimensions: " + name);
19
+ if (dimensions.width * dimensions.height > MAX_IMAGE_SOURCE_PIXELS) throw new Error("Image exceeds the 25 megapixel decode limit: " + name);
20
+ const maxSide = modelImageSide(dimensions);
17
21
  const originalBytes = bytes;
18
22
  if (mime === "image/bmp") {
19
23
  const converted = await convertImageBytesToPng(bytes);
@@ -21,7 +25,7 @@ export async function attachImageBytes(bytes: Uint8Array, name: string, blobDir:
21
25
  bytes = converted; mime = "image/png";
22
26
  }
23
27
  // Pi's disabled auto-resize setting preserves the input; decoding still verifies its dimensions.
24
- const result = await resizeImage(bytes, mime, autoResize ? undefined : { maxWidth: Number.MAX_SAFE_INTEGER, maxHeight: Number.MAX_SAFE_INTEGER, maxBytes: Number.MAX_SAFE_INTEGER });
28
+ const result = await resizeImage(bytes, mime, autoResize ? { maxWidth: maxSide, maxHeight: maxSide } : { maxWidth: Number.MAX_SAFE_INTEGER, maxHeight: Number.MAX_SAFE_INTEGER, maxBytes: Number.MAX_SAFE_INTEGER });
25
29
  if (!result) throw new Error("Cannot decode or resize image: " + name);
26
30
  if (result.mimeType !== "image/png" && result.mimeType !== "image/jpeg" && result.mimeType !== "image/gif" && result.mimeType !== "image/webp") {
27
31
  throw new Error("Unsupported image encoding: " + result.mimeType);
@@ -43,7 +43,10 @@ import { randomUUID } from "node:crypto";
43
43
  import { pendingToolCalls } from "../core/execution.ts";
44
44
  import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
45
45
  import { DISCOVERY_TOOL, DISCOVERY_TOOL_NAME, currentDeferredTools, discoveryFingerprint, discoveryPermitted, searchDeferredTools } from "./tool-discovery.ts";
46
- import { executionOutcome, modelOutcome, type ExecutionOutcome } from "../core/outcome.ts";
46
+ import { executionOutcome, modelOutcome, type ExecutionOutcome, type ExecutionReason } from "../core/outcome.ts";
47
+ import { finalizationNotice, requestPhase } from "../core/request-budget.ts";
48
+ import { stopReport } from "../core/stop-report.ts";
49
+ import { supportsToolDisabling, type ProviderSendOptions } from "./provider-tool-policy.ts";
47
50
  import { commandCompletion } from "../core/command-contract.ts";
48
51
  import { ExecutionControl } from "./execution-control.ts";
49
52
  import { BranchJobs } from "./branch-jobs.ts";
@@ -153,7 +156,8 @@ export interface LoopEvent {
153
156
  outcome?: ExecutionOutcome;
154
157
  /** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
155
158
  request?: RequestState;
156
- progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string };
159
+ progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string }
160
+ | { kind: "waiting"; session: number; active: boolean };
157
161
  }
158
162
 
159
163
  /**
@@ -356,6 +360,7 @@ export class Session {
356
360
  private readonly hostPending = new Set<Promise<NativeCommandResult>>();
357
361
  private stall: StallState | null = null;
358
362
  private recovery: RecoveryState | null = null;
363
+ private processWake = new AbortController();
359
364
  private pendingAdoption: number | null = null;
360
365
  private readonly allocatedEventIds = new Set<string>();
361
366
  private recordTail: Promise<void> = Promise.resolve();
@@ -377,8 +382,10 @@ export class Session {
377
382
  stallDecision(emptyStallState(), config.stallLimit ?? DEFAULT_STALL_LIMIT);
378
383
  validateOutputRecoveryLimit(config.outputRecoveryLimit ?? DEFAULT_OUTPUT_RECOVERY_LIMIT);
379
384
  schedulingWindow([], 0, config.maxParallelTools ?? 3);
385
+ requestPhase(0, config.maxTurns);
380
386
  this.allocation = allocation;
381
- this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache() } };
387
+ this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache(),
388
+ processWait: { wake: () => this.processWake.signal } } };
382
389
  // A child or restored Session must never inherit another live invocation scope.
383
390
  delete this.config.tools.invoke;
384
391
  delete this.config.tools.invokeHost;
@@ -853,6 +860,9 @@ export class Session {
853
860
  pull?: (phase: Phase) => Promise<UserInput | null>,
854
861
  ): Promise<UserInput | null> {
855
862
  if (pull === undefined || this.isHalfRound()) return null;
863
+ // Rotate before pulling: an input queued during the async pull still wakes
864
+ // the next wait. A wake never cancels the process's lifetime signal.
865
+ this.processWake = new AbortController();
856
866
  const text = await pull("after_tools");
857
867
  if (text === null || typeof text === "string" && text.length === 0) return null;
858
868
  if (this.turnTools) this.config.tools = { ...this.turnTools };
@@ -1185,6 +1195,7 @@ export class Session {
1185
1195
  ): AsyncGenerator<LoopEvent> {
1186
1196
  if (this.cleanup || this.closing) throw new Error("session is draining or disposed");
1187
1197
  const owned = this.execution.begin(signal);
1198
+ this.processWake = new AbortController();
1188
1199
  const savedTools = this.config.tools;
1189
1200
  this.turnTools = savedTools;
1190
1201
  this.config.tools = { ...savedTools };
@@ -1271,6 +1282,7 @@ export class Session {
1271
1282
  let overflowRetried = false;
1272
1283
  const requestStarted = this.clock.now();
1273
1284
  for (let turn = 0; turn < this.config.maxTurns; turn++) {
1285
+ const phase = requestPhase(turn, this.config.maxTurns);
1274
1286
  completedThisTurn.length = 0;
1275
1287
  if (signal?.aborted) {
1276
1288
  yield* this.closeInterrupted(completedThisTurn);
@@ -1278,12 +1290,18 @@ export class Session {
1278
1290
  }
1279
1291
 
1280
1292
  if (yield* this.checkStall()) {
1281
- yield { kind: "done", reason: "stopped: consecutive tool exchanges without new evidence", outcome: executionOutcome("stalled") };
1293
+ yield { kind: "done", reason: await this.terminationReport("no-progress allowance exhausted", turn), outcome: executionOutcome("stalled") };
1294
+ return;
1295
+ }
1296
+ if (phase === "finalize" && !supportsToolDisabling(this.provider.config.spec.api)) {
1297
+ yield { kind: "done", reason: await this.terminationReport("work budget exhausted; protocol cannot disable tools while preserving frozen definitions", turn), outcome: executionOutcome("max_turns") };
1282
1298
  return;
1283
1299
  }
1284
1300
  yield { kind: "turn_start", node: this.state.node };
1285
1301
 
1286
- if (this.config.guidance !== false) {
1302
+ if (phase === "finalize") {
1303
+ await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "budget", sources: [], text: finalizationNotice() });
1304
+ } else if (this.config.guidance !== false) {
1287
1305
  const names = this.adviceNames();
1288
1306
  const guidance = (this.config.harnessReviews === false ? null : await this.recoveryGuidance(names)) ??
1289
1307
  continuationGuidance({ events: this.state.events, handoff: this.state.handoff,
@@ -1325,7 +1343,7 @@ export class Session {
1325
1343
 
1326
1344
  let result: Turn;
1327
1345
  try {
1328
- result = yield* this.awaitProvider(projected.messages, signal);
1346
+ result = yield* this.awaitProvider(projected.messages, signal, phase === "finalize" ? { toolUse: "disabled" } : undefined);
1329
1347
  } catch (error) {
1330
1348
  // D11: retain a typed response receipt once, without anchoring rejected content or trusting arbitrary error fields.
1331
1349
  if (error instanceof FailedResponseError && error.usage !== undefined) this.state.usage.push({ ...error.usage });
@@ -1333,6 +1351,11 @@ export class Session {
1333
1351
  yield* this.closeInterrupted(completedThisTurn);
1334
1352
  return;
1335
1353
  }
1354
+ if (phase === "finalize") {
1355
+ yield { kind: "discard" };
1356
+ yield { kind: "done", reason: await this.terminationReport("final response failed; no additional request was made", turn + 1), outcome: executionOutcome("provider_error") };
1357
+ return;
1358
+ }
1336
1359
  if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
1337
1360
  // The estimate said this fit and the provider says it does not: the
1338
1361
  // estimate is wrong for this content. The only projection that can
@@ -1423,6 +1446,16 @@ export class Session {
1423
1446
  (b): b is Extract<AssistantBlock, { type: "tool_use" }> => b.type === "tool_use",
1424
1447
  );
1425
1448
 
1449
+ // Provider policy is a wire instruction, not authority. Pair every
1450
+ // refused call, but never admit execution in the reserved final request.
1451
+ if (phase === "finalize" && (calls.length > 0 || !isCompleteResponse(result.stopReason) ||
1452
+ !result.blocks.some(block => block.type === "text" && block.text.trim()))) {
1453
+ for (const item of await this.answerUnpairedTools("not executed: tools are disabled in the final request", "not_started"))
1454
+ yield { kind: "tool_result", text: item.content, ok: false };
1455
+ yield { kind: "done", reason: await this.terminationReport("request budget exhausted; final response did not provide a complete text answer", turn + 1), outcome: executionOutcome("max_turns") };
1456
+ return;
1457
+ }
1458
+
1426
1459
  // N.12: syntactically valid arguments in an unfinished response are not
1427
1460
  // authorization to execute a partial batch. Pair without side effects.
1428
1461
  if (!isCompleteResponse(result.stopReason)) {
@@ -1563,7 +1596,7 @@ export class Session {
1563
1596
  }
1564
1597
  }
1565
1598
 
1566
- yield { kind: "done", reason: `hit maxTurns (${this.config.maxTurns})`, outcome: executionOutcome("max_turns") };
1599
+ yield { kind: "done", reason: await this.terminationReport("request budget exhausted", this.config.maxTurns), outcome: executionOutcome("max_turns") };
1567
1600
  } finally {
1568
1601
  // If the consumer aborts and stops pulling, the generator is closed
1569
1602
  // without running the yield* closeInterrupted path. Pairing still
@@ -1581,6 +1614,7 @@ export class Session {
1581
1614
  */
1582
1615
  private async answerUnpairedTools(
1583
1616
  content = "interrupted: the user cancelled before this tool completed",
1617
+ reason: ExecutionReason = "unknown",
1584
1618
  ): Promise<{ callId: string; content: string }[]> {
1585
1619
  const added: { callId: string; content: string }[] = [];
1586
1620
  for (const block of pendingToolCalls(this.state.events)) {
@@ -1595,7 +1629,7 @@ export class Session {
1595
1629
  ...(block.origin ? { origin: block.origin } : {}),
1596
1630
  ok: false,
1597
1631
  content: resultText,
1598
- outcome: executionOutcome("unknown"),
1632
+ outcome: executionOutcome(reason),
1599
1633
  });
1600
1634
  added.push({ callId: block.call_id, content: resultText });
1601
1635
  }
@@ -1644,6 +1678,7 @@ export class Session {
1644
1678
  private async *awaitProvider(
1645
1679
  messages: readonly Message[],
1646
1680
  signal?: AbortSignal,
1681
+ options?: ProviderSendOptions,
1647
1682
  ): AsyncGenerator<LoopEvent, Turn> {
1648
1683
  const hydrated = await hydrateImages(messages, this.config.blobDir ?? join(this.config.logDir, "blobs"),
1649
1684
  this.provider.config.spec.input?.includes("image") ?? false);
@@ -1665,7 +1700,7 @@ export class Session {
1665
1700
  default:
1666
1701
  return assertNever(delta, "onDelta");
1667
1702
  }
1668
- }));
1703
+ }, options));
1669
1704
  }
1670
1705
 
1671
1706
  /**
@@ -1814,6 +1849,7 @@ export class Session {
1814
1849
  if (native) context = nativeCommandContext(context);
1815
1850
  const decoder = new TextDecoder();
1816
1851
  context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
1852
+ context.onProcessWait = (session, active) => emit({ kind: "progress", progress: { kind: "waiting", session, active } });
1817
1853
  const broker = new ToolBroker<DeferredToolResult>({
1818
1854
  parallelLimit: this.config.maxParallelTools ?? 3,
1819
1855
  callId: () => `nested-${randomUUID()}`,
@@ -2720,14 +2756,23 @@ export class Session {
2720
2756
  }
2721
2757
  const decision = stallDecision(this.stall, limit);
2722
2758
  if (decision.stop) {
2723
- yield { kind: "stop_and_ask", reason: `${decision.consecutive} consecutive tool exchanges without new evidence or fresh verification; change the approach or provide new input` };
2759
+ yield { kind: "stop_and_ask", reason: `${decision.unproductive} total tool exchanges without new evidence (${decision.consecutive} consecutive; ${decision.failures} failed receipts; ${decision.emptyWaits} empty waits); change the approach or provide new input` };
2724
2760
  return true;
2725
2761
  }
2726
2762
  if (decision.warn) await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
2727
- text: `Harness: ${decision.consecutive} consecutive tool exchanges without new evidence or fresh verification. Change the method, run a meaningful check, or give the final result with unresolved limits. The harness will stop at ${limit} such exchanges. Notes and repeated observations do not count as progress.` });
2763
+ text: `Harness: ${decision.unproductive} total tool exchanges without new evidence or fresh verification (${decision.consecutive} consecutive; ${decision.failures} failed receipts; ${decision.emptyWaits} empty waits). Change the method, run a meaningful check, or give the final result with unresolved limits. The harness will stop at ${limit} total unproductive exchanges. Novel observations reset the streak, not the spent allowance. Notes and repeated observations do not count as progress.` });
2728
2764
  return false;
2729
2765
  }
2730
2766
 
2767
+ /** A steering signal releases only the wait; cancellation owns process termination. */
2768
+ wakeProcessWaits(): void { this.processWake.abort(); }
2769
+
2770
+ private async terminationReport(cause: string, requests: number): Promise<string> {
2771
+ const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
2772
+ if (!history.ok) throw new Error(history.error);
2773
+ return stopReport(history.nodes.flatMap(part => part.events), requests, this.config.maxTurns, cause);
2774
+ }
2775
+
2731
2776
  /** N.8: build and check the replacement before committing a durable boundary. */
2732
2777
  /**
2733
2778
  * `overflowed` is the provider's own verdict that the current projection
@@ -18,7 +18,7 @@ import { pollDeviceCode } from "./oauth-device.ts";
18
18
  import { parseDeviceAuthorization, parseOAuthTokens } from "../core/oauth-fields.ts";
19
19
 
20
20
  /** Matches package.json. Sent as User-Agent so the server can see who we are (D18). */
21
- export const XENO_VERSION = "0.6.0";
21
+ export const XENO_VERSION = "0.7.0";
22
22
 
23
23
  /**
24
24
  * Minutes subtracted from server expiry so a request issued at the edge
@@ -24,6 +24,7 @@ import { assertNever } from "../core/exhaustive.ts";
24
24
  import type { OnDelta, Provider, ProviderConfig, ToolSchema, Turn } from "./provider.ts";
25
25
  import { pushSse, HttpError, StreamError } from "./openai.ts";
26
26
  import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
27
+ import type { ProviderSendOptions } from "./provider-tool-policy.ts";
27
28
 
28
29
  /** What this adapter parks in a thinking block's raw: the reasoning item, whole. */
29
30
  interface ResponsesRaw { responses_item: Record<string, unknown> }
@@ -266,19 +267,20 @@ export class OpenAIResponsesProvider implements Provider {
266
267
  this.onRetry = options.onRetry;
267
268
  }
268
269
 
269
- send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
270
- return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta), this.retry, undefined,
270
+ send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, options?: ProviderSendOptions): Promise<Turn> {
271
+ return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta, options), this.retry, undefined,
271
272
  (attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs }); this.onRetry?.(attempt, delayMs, error); }, signal);
272
273
  }
273
274
 
274
- private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
275
+ private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, options?: ProviderSendOptions): Promise<Turn> {
275
276
  const started = Date.now();
276
277
  const baseUrl = this.config.spec.baseUrl;
277
278
  if (baseUrl === undefined) throw new Error(`model ${this.config.spec.id} has no baseUrl to send to`);
278
279
  const init: RequestInit = {
279
280
  method: "POST",
280
281
  headers: { "content-type": "application/json", authorization: `Bearer ${this.apiKey}` },
281
- body: JSON.stringify({ ...buildResponsesRequest(this.config, frozen, messages), stream: true }),
282
+ body: JSON.stringify({ ...buildResponsesRequest(this.config, frozen, messages), stream: true,
283
+ ...(options?.toolUse === "disabled" ? { tool_choice: "none" } : {}) }),
282
284
  };
283
285
  if (signal !== undefined) init.signal = signal;
284
286
  const response = await this.fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, init);
@@ -26,6 +26,7 @@ import { assertNever } from "../core/exhaustive.ts";
26
26
  import type { Provider, ProviderConfig, Turn, ToolSchema, 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
+ import type { ProviderSendOptions } from "./provider-tool-policy.ts";
29
30
 
30
31
  /** One message as Chat Completions wants it. */
31
32
  export interface WireMessage {
@@ -48,6 +49,7 @@ export interface WireRequest {
48
49
  max_tokens?: number;
49
50
  max_completion_tokens?: number;
50
51
  tools?: unknown[];
52
+ tool_choice?: "none";
51
53
  thinking?: { type: "enabled" | "disabled" };
52
54
  reasoning_effort?: string;
53
55
  }
@@ -434,11 +436,12 @@ export class OpenAICompletionsProvider implements Provider {
434
436
  node: number,
435
437
  signal?: AbortSignal,
436
438
  onDelta?: OnDelta,
439
+ options?: ProviderSendOptions,
437
440
  ): Promise<Turn> {
438
441
  // The clock starts inside the retried request, so a reported duration is
439
442
  // one attempt rather than the sum of the ones that failed.
440
443
  return withRetry(
441
- () => this.attempt(frozen, messages, node, signal, onDelta),
444
+ () => this.attempt(frozen, messages, node, signal, onDelta, options),
442
445
  this.retry,
443
446
  undefined,
444
447
  (attempt, delayMs, error) => {
@@ -456,6 +459,7 @@ export class OpenAICompletionsProvider implements Provider {
456
459
  node: number,
457
460
  signal?: AbortSignal,
458
461
  onDelta?: OnDelta,
462
+ options?: ProviderSendOptions,
459
463
  ): Promise<Turn> {
460
464
  const started = Date.now();
461
465
  const baseUrl = this.config.spec.baseUrl;
@@ -468,6 +472,7 @@ export class OpenAICompletionsProvider implements Provider {
468
472
  stream: true;
469
473
  stream_options?: { include_usage: true };
470
474
  } = { ...buildRequest(this.config, frozen, messages), stream: true };
475
+ if (options?.toolUse === "disabled") payload.tool_choice = "none";
471
476
  // Without include_usage an OpenAI-dialect stream never reports usage, and
472
477
  // the loop would treat the resulting zeros as a real prompt size. Servers
473
478
  // that 400 on the field opt out via Compat.supportsUsageInStreaming.
@@ -23,6 +23,7 @@ import { FailedResponseError, type OnDelta, type Provider, type ProviderConfig,
23
23
  import { DEFAULT_RETRY, withRetry, type RetryPolicy } from "./retry.ts";
24
24
  import { HttpError, StreamError } from "./openai.ts";
25
25
  import type { UsageSample } from "./usage.ts";
26
+ import { disablePayloadTools, type ProviderSendOptions } from "./provider-tool-policy.ts";
26
27
 
27
28
  const APIS: Record<Api, ProviderStreams> = {
28
29
  "anthropic-messages": anthropicMessagesApi(), "openai-completions": openAICompletionsApi(),
@@ -305,14 +306,14 @@ export class PiProvider implements Provider {
305
306
  this.config = config; this.apiKey = apiKey; this.options = options;
306
307
  }
307
308
 
308
- send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
309
+ send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, policy?: ProviderSendOptions): Promise<Turn> {
309
310
  // Bedrock's Pi adapter leaves retry ownership with the AWS SDK; never multiply that loop.
310
311
  const retry = this.options.retry ?? (this.config.spec.api === "bedrock-converse-stream" ? { ...DEFAULT_RETRY, maxRetries: 0 } : DEFAULT_RETRY);
311
- return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta), retry, undefined,
312
+ return withRetry(() => this.attempt(frozen, messages, node, signal, onDelta, policy), retry, undefined,
312
313
  (attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs }); this.options.onRetry?.(attempt, delayMs, error); }, signal);
313
314
  }
314
315
 
315
- private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta): Promise<Turn> {
316
+ private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, policy?: ProviderSendOptions): Promise<Turn> {
316
317
  signal?.throwIfAborted();
317
318
  const started = Date.now();
318
319
  const request = providerRequest(this.config.spec, this.apiKey, this.options.effects, this.options.fetch, this.options.oauth);
@@ -342,7 +343,7 @@ export class PiProvider implements Provider {
342
343
  const next = mergePayload(payload, request.body);
343
344
  // Stateless Responses is required by D8: never replace the recomputable projection with server state.
344
345
  if (model.api.includes("responses")) { next["store"] = false; delete next["previous_response_id"]; }
345
- return next;
346
+ return policy?.toolUse === "disabled" ? disablePayloadTools(model.api, next) : next;
346
347
  } };
347
348
  let completed: AssistantMessage | undefined;
348
349
  // Pi emits fragments; xeno's progress contract replaces the displayed argument buffer.
@@ -0,0 +1,33 @@
1
+ /** D6: native request controls may change; frozen system and tool declarations may not. */
2
+ import { assertNever } from "../core/exhaustive.ts";
3
+ import type { Api } from "./model.ts";
4
+
5
+ export interface ProviderSendOptions { toolUse: "disabled" }
6
+
7
+ export function supportsToolDisabling(api: Api): boolean {
8
+ // Bedrock's SDK implements none by deleting toolConfig. It cannot preserve D6.
9
+ switch (api) {
10
+ case "anthropic-messages": case "openai-completions": case "openai-responses": case "azure-openai-responses": case "openai-codex-responses":
11
+ case "google-generative-ai": case "google-vertex": case "mistral-conversations": case "pi-messages": return true;
12
+ case "bedrock-converse-stream": return false;
13
+ default: return assertNever(api, "tool disabling capability");
14
+ }
15
+ }
16
+ const object = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
17
+
18
+ /** Called after provider body overrides, at the last native payload boundary. */
19
+ export function disablePayloadTools(api: Api, payload: Record<string, unknown>): Record<string, unknown> {
20
+ switch (api) {
21
+ case "anthropic-messages": return { ...payload, tool_choice: { type: "none" } };
22
+ case "openai-completions": case "openai-responses": case "azure-openai-responses": case "openai-codex-responses":
23
+ return { ...payload, tool_choice: "none" };
24
+ case "google-generative-ai": case "google-vertex": {
25
+ const config = object(payload["config"]), toolConfig = object(config["toolConfig"]);
26
+ return { ...payload, config: { ...config, toolConfig: { ...toolConfig, functionCallingConfig: { mode: "NONE" } } } };
27
+ }
28
+ case "mistral-conversations": return { ...payload, toolChoice: "none" };
29
+ case "pi-messages": return { ...payload, options: { ...object(payload["options"]), toolChoice: "none" } };
30
+ case "bedrock-converse-stream": throw new Error("Bedrock cannot disable tools while preserving frozen definitions");
31
+ default: return assertNever(api, "tool disabling protocol");
32
+ }
33
+ }