@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.
- package/CHANGELOG.md +9 -0
- package/README.md +13 -2
- package/package.json +1 -1
- package/src/apps/session-hub.ts +12 -1
- package/src/apps/shell.ts +1 -1
- package/src/apps/ui/app.ts +68 -16
- package/src/apps/ui/image-cache.ts +71 -28
- package/src/core/activity-status.ts +28 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/progress.ts +9 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- package/src/runtime/exec-sessions.ts +40 -4
- package/src/runtime/images.ts +6 -2
- package/src/runtime/loop.ts +56 -11
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -1
- package/src/runtime/pi-provider.ts +5 -4
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +6 -1
- package/src/runtime/session-control.ts +1 -0
- package/src/runtime/tools.ts +7 -3
- package/src/store/blobs.ts +2 -1
- package/src/store/git-status.ts +15 -0
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +12 -8
- package/src/tui/ui/status.ts +32 -6
|
@@ -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
|
+
}
|
package/src/core/settings.ts
CHANGED
|
@@ -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: "
|
|
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,
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
|
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(() => {});
|
package/src/runtime/images.ts
CHANGED
|
@@ -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 ?
|
|
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);
|
package/src/runtime/loop.ts
CHANGED
|
@@ -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: "
|
|
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 (
|
|
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:
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
package/src/runtime/oauth.ts
CHANGED
|
@@ -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.
|
|
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);
|
package/src/runtime/openai.ts
CHANGED
|
@@ -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
|
+
}
|