@visual-z/xeno 0.8.1 → 0.8.3
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 +66 -0
- package/docs/install.md +25 -0
- package/package.json +1 -1
- package/src/apps/cli.ts +4 -1
- package/src/apps/shell.ts +13 -3
- package/src/apps/startup.ts +31 -6
- package/src/apps/ui/app.ts +32 -14
- package/src/apps/ui/project.ts +1 -1
- package/src/apps/ui/standalone.ts +4 -2
- package/src/apps/ui/surface.ts +2 -0
- package/src/core/activity-status.ts +27 -3
- package/src/core/command-contract.ts +121 -5
- package/src/core/completion-review.ts +1 -1
- package/src/core/derive.ts +9 -1
- package/src/core/environment.ts +7 -0
- package/src/core/event-validation.ts +19 -3
- package/src/core/events.ts +38 -2
- package/src/core/evidence.ts +149 -7
- package/src/core/export.ts +2 -0
- package/src/core/external-effects.ts +488 -0
- package/src/core/handoff.ts +24 -3
- package/src/core/history-query.ts +1 -1
- package/src/core/history.ts +131 -21
- package/src/core/observation-view.ts +24 -0
- package/src/core/path-suggestions.ts +59 -0
- package/src/core/permissions.ts +64 -3
- package/src/core/prompt-policy.ts +11 -2
- package/src/core/regex-dialect.ts +25 -0
- package/src/core/ripgrep-release.ts +64 -0
- package/src/core/secrets.ts +188 -0
- package/src/core/stall.ts +63 -18
- package/src/core/stop-report.ts +46 -4
- package/src/core/tool-contract.ts +2 -0
- package/src/core/tool-failure.ts +25 -4
- package/src/core/transcript.ts +3 -0
- package/src/core/update-check.ts +2 -1
- package/src/core/work-state.ts +7 -2
- package/src/core/working-set.ts +34 -6
- package/src/runtime/connection-reuse.ts +40 -0
- package/src/runtime/exec-sessions.ts +69 -19
- package/src/runtime/history-tools.ts +12 -2
- package/src/runtime/loop.ts +196 -26
- package/src/runtime/open-image.ts +42 -0
- package/src/runtime/openai-responses.ts +15 -7
- package/src/runtime/openai.ts +15 -7
- package/src/runtime/permission-resources.ts +11 -4
- package/src/runtime/pi-provider.ts +34 -9
- package/src/runtime/provider.ts +19 -8
- package/src/runtime/retry.ts +152 -9
- package/src/runtime/secrets.ts +79 -0
- package/src/runtime/session-control.ts +9 -1
- package/src/runtime/stream-idle.ts +80 -0
- package/src/runtime/tools.ts +139 -31
- package/src/runtime/turn-effects.ts +31 -0
- package/src/store/command.ts +3 -1
- package/src/store/context-files.ts +5 -1
- package/src/store/file-checkpoints.ts +6 -3
- package/src/store/log.ts +20 -0
- package/src/store/replay.ts +4 -3
- package/src/store/search.ts +9 -5
- package/src/store/secrets.ts +44 -0
- package/src/store/sessions.ts +49 -6
- package/src/store/tool-binaries.ts +90 -0
- package/src/store/worktree.ts +55 -10
- package/src/tui/ui/image-size.ts +6 -2
- package/src/tui/ui/items.ts +3 -1
- package/src/tui/ui/pictures.ts +14 -1
- package/src/tui/ui/screen.ts +6 -3
- package/src/tui/ui/status.ts +16 -0
package/src/runtime/loop.ts
CHANGED
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
* request.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent } from "../core/events.ts";
|
|
21
|
+
import type { LogEvent, AssistantBlock, NodeRef, CutReason, ToolInvocationEvent, FailureCause } from "../core/events.ts";
|
|
22
22
|
import { userInputData, submissionImages, type UserInput, type ImageRef } from "../core/images.ts";
|
|
23
23
|
import { hydrateImages } from "./images.ts";
|
|
24
24
|
import type { Handoff } from "../core/handoff.ts";
|
|
25
25
|
import { boundHandoff, DEFAULT_HANDOFF_TOKENS } from "../core/history.ts";
|
|
26
|
+
import { CURRENT_WORKING_SET_VERSION } from "../core/working-set.ts";
|
|
26
27
|
import { boundaryPreparation, continuationGuidance } from "../core/continuation.ts";
|
|
27
28
|
import type { FrozenPrefix, Message, ProjectInput } from "../core/project.ts";
|
|
28
29
|
import { projectContext, toolCallIdentityViolations } from "../core/project.ts";
|
|
@@ -42,8 +43,9 @@ import {
|
|
|
42
43
|
} from "../core/context-policy.ts";
|
|
43
44
|
import { retainedContext, selectRecentContext } from "../core/context-window.ts";
|
|
44
45
|
import { advanceStall, emptyStallState, stallDecision, DEFAULT_STALL_LIMIT, type StallState } from "../core/stall.ts";
|
|
46
|
+
import { questionRequest, validateQuestionReply } from "../core/question.ts";
|
|
45
47
|
import { DEFAULT_OUTPUT_RECOVERY_LIMIT, isCompleteResponse, incompleteToolText, knownUnexecutedTool, outputRecoveryNotice, validateOutputRecoveryLimit } from "../core/response-recovery.ts";
|
|
46
|
-
import { join, resolve } from "node:path";
|
|
48
|
+
import { join, relative, resolve } from "node:path";
|
|
47
49
|
import { randomUUID } from "node:crypto";
|
|
48
50
|
import { pendingToolCalls } from "../core/execution.ts";
|
|
49
51
|
import { declaredToolNames, recordedDeclarations } from "../core/tool-discovery.ts";
|
|
@@ -75,7 +77,11 @@ import { skillTools, type Skill } from "../core/skills.ts";
|
|
|
75
77
|
import type { WorkItem } from "../core/work-state.ts";
|
|
76
78
|
import { parseAcceptanceContract, type AcceptanceContract, type AcceptanceCriterion, type AcceptanceState } from "../core/acceptance.ts";
|
|
77
79
|
import { deadlineNotice } from "../core/deadline.ts";
|
|
78
|
-
import { statusOf } from "./retry.ts";
|
|
80
|
+
import { describeFailure, failureCause, statusOf } from "./retry.ts";
|
|
81
|
+
import { XENO_VERSION } from "./version.ts";
|
|
82
|
+
import { SECRET_GUIDANCE, SecretKeeper } from "./secrets.ts";
|
|
83
|
+
import { secretEnvironmentValues } from "../core/secrets.ts";
|
|
84
|
+
import { copyVault } from "../store/secrets.ts";
|
|
79
85
|
import { assertNever } from "../core/exhaustive.ts";
|
|
80
86
|
import { isLogEvent } from "../core/event-validation.ts";
|
|
81
87
|
import {
|
|
@@ -91,7 +97,7 @@ import { FailedResponseError, type Provider, type Turn, type StreamDelta } from
|
|
|
91
97
|
import { putBlob, getBlob } from "../store/blobs.ts";
|
|
92
98
|
import { hydrateProcessReceipts } from "../store/process-receipts.ts";
|
|
93
99
|
import { readNodeHistory } from "../store/restore.ts";
|
|
94
|
-
import { createWorktree, removeWorktree } from "../store/worktree.ts";
|
|
100
|
+
import { createWorktree, listWorktrees, mainWorktree, persistentWorktreeParent, removeWorktree } from "../store/worktree.ts";
|
|
95
101
|
import {
|
|
96
102
|
parseNote, matchGate, branchTools, finishToolResult, prepareNormalizedTool, presentObservation, toolDefinition, modelToolSchema, adviceTools,
|
|
97
103
|
BRANCH_TOOL_NAMES, WRITING_BRANCH_TOOL_NAMES,
|
|
@@ -113,6 +119,7 @@ import { changedPaths, invertFileChanges, sameVersion, type CheckpointEvent, typ
|
|
|
113
119
|
import type { GitCheckpoint } from "../store/git-checkpoints.ts";
|
|
114
120
|
import { readEditableFile, preflightChanges, applyFileChanges } from "../store/file-checkpoints.ts";
|
|
115
121
|
import { planCheckpointRestore } from "../store/checkpoint-history.ts";
|
|
122
|
+
import { configDir } from "../store/config.ts";
|
|
116
123
|
|
|
117
124
|
/** Ids and timestamps come from here so the loop stays the only impure part. */
|
|
118
125
|
export interface Clock {
|
|
@@ -175,6 +182,8 @@ export interface LoopEvent {
|
|
|
175
182
|
/** branch_start: wall-clock milliseconds, for asserting the stagger. */
|
|
176
183
|
at?: number;
|
|
177
184
|
outcome?: ExecutionOutcome;
|
|
185
|
+
/** done: credential-free cause of a failed request, recorded on its outcome. */
|
|
186
|
+
failure?: FailureCause;
|
|
178
187
|
/** Q23: task evidence is separate from the execution outcome; rebuilt from original receipts. */
|
|
179
188
|
request?: RequestState;
|
|
180
189
|
progress?: Extract<StreamDelta, { kind: "tool" | "retry" }> | { kind: "output"; text: string; name: string }
|
|
@@ -212,6 +221,11 @@ export const DEFAULT_BRANCH_LIMITS: BranchLimits = {
|
|
|
212
221
|
};
|
|
213
222
|
|
|
214
223
|
export interface SessionConfig {
|
|
224
|
+
/**
|
|
225
|
+
* Where secret-named environment values are read from (S.9). Defaults to
|
|
226
|
+
* process.env; a test supplies its own so it never depends on the machine.
|
|
227
|
+
*/
|
|
228
|
+
secretEnvironment?: Readonly<Record<string, string | undefined>>;
|
|
215
229
|
/** Runtime-only output contract, explicitly selected by the current caller. */
|
|
216
230
|
validateOutput?: (text: string) => { ok: boolean; error?: string };
|
|
217
231
|
/** Persist a selector, never persisted profile code or authorization (D6/D13). */
|
|
@@ -379,7 +393,15 @@ export class Session {
|
|
|
379
393
|
private stall: StallState | null = null;
|
|
380
394
|
private recovery: RecoveryState | null = null;
|
|
381
395
|
private processWake = new AbortController();
|
|
396
|
+
/**
|
|
397
|
+
* The exception the last provider request ended with, compared by identity
|
|
398
|
+
* in send()'s catch. Wrapping the error instead would break every check that
|
|
399
|
+
* reads it (FailedResponseError, statusOf, context overflow).
|
|
400
|
+
*/
|
|
401
|
+
private providerFailure: { error: unknown; retries: number } | undefined;
|
|
382
402
|
private pendingAdoption: number | null = null;
|
|
403
|
+
/** A worktree enter/exit moves the root only once its tool round is paired, like adoption. */
|
|
404
|
+
private pendingRoot: string | null = null;
|
|
383
405
|
private readonly allocatedEventIds = new Set<string>();
|
|
384
406
|
private recordTail: Promise<void> = Promise.resolve();
|
|
385
407
|
private readonly recordListeners = new Set<(item: CommittedItem) => void>();
|
|
@@ -393,6 +415,8 @@ export class Session {
|
|
|
393
415
|
readonly clock: Clock;
|
|
394
416
|
state: SessionState;
|
|
395
417
|
private readonly allocation: { next: number };
|
|
418
|
+
/** S.9: the vault behind placeholders. A branch is handed its parent's, like `allocation`. */
|
|
419
|
+
private secrets: SecretKeeper;
|
|
396
420
|
|
|
397
421
|
constructor(config: SessionConfig, provider: Provider, clock: Clock = systemClock(), allocation = { next: 1 }) {
|
|
398
422
|
if (config.acceptanceContract) parseAcceptanceContract(config.acceptanceContract);
|
|
@@ -402,6 +426,7 @@ export class Session {
|
|
|
402
426
|
schedulingWindow([], 0, config.maxParallelTools ?? 3);
|
|
403
427
|
validateRequestBudget(config.maxTurns, config.stallLimit ?? DEFAULT_STALL_LIMIT);
|
|
404
428
|
this.allocation = allocation;
|
|
429
|
+
this.secrets = new SecretKeeper(config.logDir, secretEnvironmentValues(config.secretEnvironment ?? process.env));
|
|
405
430
|
this.config = { ...config, ...(config.acceptanceContract ? { acceptanceContract: parseAcceptanceContract(config.acceptanceContract) } : {}), tools: { ...config.tools, execOwner: this.execOwner, historyCache: new HistoryCache(),
|
|
406
431
|
processWait: { wake: () => this.processWake.signal } } };
|
|
407
432
|
// A child or restored Session must never inherit another live invocation scope.
|
|
@@ -415,6 +440,8 @@ export class Session {
|
|
|
415
440
|
// Branch capability sets are owned by branchTools; a parent's optional catalog is not a grant.
|
|
416
441
|
if ((config.branchDepth ?? 0) > 0) delete this.config.tools.deferredTools;
|
|
417
442
|
this.config.tools.outputStore ??= join(config.logDir, "artifacts");
|
|
443
|
+
// Read at call time, so a branch that is handed its parent's keeper expands the parent's values.
|
|
444
|
+
this.config.tools.expandSecrets = input => this.secrets.expand(input);
|
|
418
445
|
this.config.tools.historyEvents = async () => {
|
|
419
446
|
const history = await readNodeHistory(this.config.logDir, this.state.node, undefined, "context");
|
|
420
447
|
if (!history.ok) throw Error(history.error);
|
|
@@ -494,6 +521,7 @@ export class Session {
|
|
|
494
521
|
async start(): Promise<void> {
|
|
495
522
|
this.resetDerivedToolState();
|
|
496
523
|
const dir = this.config.logDir;
|
|
524
|
+
await this.secrets.load();
|
|
497
525
|
const model = this.provider.config.spec.id;
|
|
498
526
|
const existing = await listNodes(dir);
|
|
499
527
|
const recordedCursor = await readCursor(dir);
|
|
@@ -603,6 +631,7 @@ export class Session {
|
|
|
603
631
|
*/
|
|
604
632
|
async fork(destLogDir: string, untilEvent?: string, position: "at" | "before" = "at"): Promise<void> {
|
|
605
633
|
await copyAncestryTo(this.config.logDir, destLogDir, this.clock.now(), untilEvent, position);
|
|
634
|
+
await copyVault(this.config.logDir, destLogDir);
|
|
606
635
|
if (untilEvent !== undefined) {
|
|
607
636
|
const cursor = await readCursor(destLogDir);
|
|
608
637
|
if (!cursor) throw new Error("missing fork cursor");
|
|
@@ -745,8 +774,18 @@ export class Session {
|
|
|
745
774
|
* must see that a person changed models, not that the node filled up.
|
|
746
775
|
* boundary.ts already produces this reason when checkpointRequested is
|
|
747
776
|
* set; switchModel is the other caller that wants it.
|
|
777
|
+
*
|
|
778
|
+
* An empty projection is the exception (fix-switch-model-empty-segment).
|
|
779
|
+
* With no events there is no old model's block to keep away from the new
|
|
780
|
+
* one, and the frozen prefix does not change here, so the cursor alone
|
|
781
|
+
* records the switch. Cutting anyway sealed an empty segment and opened
|
|
782
|
+
* the next from an empty handoff, which the model read as earlier work to
|
|
783
|
+
* resume. The check is the projection, not the node file: an inherit-basis
|
|
784
|
+
* node can be empty on disk while still projecting ancestor thinking.
|
|
785
|
+
*
|
|
786
|
+
* Returns whether a segment was cut, so the caller can say so truthfully.
|
|
748
787
|
*/
|
|
749
|
-
async switchModel(next: Provider): Promise<
|
|
788
|
+
async switchModel(next: Provider): Promise<boolean> {
|
|
750
789
|
if (this.running || this.isHalfRound()) {
|
|
751
790
|
throw new Error(
|
|
752
791
|
"cannot switch models mid-turn: a tool_use is still unpaired. " +
|
|
@@ -754,9 +793,12 @@ export class Session {
|
|
|
754
793
|
);
|
|
755
794
|
}
|
|
756
795
|
|
|
757
|
-
|
|
796
|
+
const cut = this.state.events.length > 0;
|
|
797
|
+
if (cut) await this.performCut("model_checkpoint", next.config.spec.id, undefined, next.config.spec.provider, next.config.effort);
|
|
798
|
+
else this.state.anchor = null;
|
|
758
799
|
this.provider = next;
|
|
759
800
|
await this.saveCursor();
|
|
801
|
+
return cut;
|
|
760
802
|
}
|
|
761
803
|
|
|
762
804
|
/** D6: changing the frozen prefix is an explicit checkpoint, never in-place prompt surgery. */
|
|
@@ -886,6 +928,7 @@ export class Session {
|
|
|
886
928
|
updated_at: this.clock.now(),
|
|
887
929
|
workspace: this.config.tools.root,
|
|
888
930
|
context_ref: contextRef ?? await this.persistContext(),
|
|
931
|
+
xeno_version: XENO_VERSION,
|
|
889
932
|
});
|
|
890
933
|
}
|
|
891
934
|
|
|
@@ -898,6 +941,10 @@ export class Session {
|
|
|
898
941
|
// D8: runtime callers may retain mutable results. The in-memory projection
|
|
899
942
|
// must own the same JSON value that was appended, independent of later edits.
|
|
900
943
|
event = JSON.parse(JSON.stringify(event)) as LogEvent;
|
|
944
|
+
// S.9: credentials become placeholders before anything is written, so no
|
|
945
|
+
// projection, handoff, export or history page can carry the value.
|
|
946
|
+
const redacted = await this.secrets.redact(event, this.lastAssistantText());
|
|
947
|
+
event = redacted.event;
|
|
901
948
|
const saved = this.recordTail.then(async () => {
|
|
902
949
|
await appendEvent(this.config.logDir, this.state.node, event);
|
|
903
950
|
this.state.events.push(event);
|
|
@@ -909,6 +956,9 @@ export class Session {
|
|
|
909
956
|
});
|
|
910
957
|
this.recordTail = saved.catch(() => {});
|
|
911
958
|
await saved;
|
|
959
|
+
if (redacted.added > 0 && !this.state.events.some(item => item.t === "guidance" && item.reason === "secret")) {
|
|
960
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "secret", text: SECRET_GUIDANCE, sources: [event.id] });
|
|
961
|
+
}
|
|
912
962
|
if (event.t === "tool_result" && event.ok && event.effect?.kind === "read") {
|
|
913
963
|
const path = resolve(this.config.tools.root, event.effect.path);
|
|
914
964
|
const skill = this.config.skills?.find(item => item.filePath === path);
|
|
@@ -921,6 +971,19 @@ export class Session {
|
|
|
921
971
|
}
|
|
922
972
|
}
|
|
923
973
|
|
|
974
|
+
/** The text the user is answering, for recognising a solicited credential. */
|
|
975
|
+
private lastAssistantText(): string | undefined {
|
|
976
|
+
for (let i = this.state.events.length - 1; i >= 0; i--) {
|
|
977
|
+
const event = this.state.events[i]!;
|
|
978
|
+
if (event.t === "user") return undefined;
|
|
979
|
+
if (event.t === "assistant") {
|
|
980
|
+
const text = event.blocks.flatMap(block => block.type === "text" ? [block.text] : []).join("\n");
|
|
981
|
+
if (text.trim()) return text;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return undefined;
|
|
985
|
+
}
|
|
986
|
+
|
|
924
987
|
private allowedNames(): string[] {
|
|
925
988
|
return this.config.frozen.tools.flatMap(tool => tool !== null && typeof tool === "object" && "name" in tool && typeof tool.name === "string" &&
|
|
926
989
|
(this.config.tools.allow === undefined || this.config.tools.allow.includes(tool.name)) ? [tool.name] : []);
|
|
@@ -996,7 +1059,7 @@ export class Session {
|
|
|
996
1059
|
|
|
997
1060
|
private async boundCarried(full: Handoff, tools = this.adviceNames()): Promise<Handoff> {
|
|
998
1061
|
return carriedHandoff(this.config.logDir, this.state.node, full, { handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
999
|
-
...(this.config.workingSet ? { working_set_version:
|
|
1062
|
+
...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}), guidance_tools: tools });
|
|
1000
1063
|
}
|
|
1001
1064
|
|
|
1002
1065
|
async acceptanceState(): Promise<AcceptanceState | undefined> {
|
|
@@ -1252,6 +1315,7 @@ export class Session {
|
|
|
1252
1315
|
if (item.value.outcome && item.value.outcome.reason !== "model_end") await this.record({
|
|
1253
1316
|
t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
1254
1317
|
reason: item.value.reason ?? item.value.outcome.reason, outcome: item.value.outcome,
|
|
1318
|
+
...(item.value.failure ? { failure: item.value.failure } : {}),
|
|
1255
1319
|
});
|
|
1256
1320
|
const request = await this.requestState();
|
|
1257
1321
|
if (request) item.value.request = request;
|
|
@@ -1266,9 +1330,14 @@ export class Session {
|
|
|
1266
1330
|
yield { kind: "done", reason: "stopped: request ended without a terminal outcome", outcome, ...(request ? { request } : {}) };
|
|
1267
1331
|
}
|
|
1268
1332
|
} catch (error) {
|
|
1269
|
-
// Raw
|
|
1333
|
+
// Raw exceptions can contain credentials; persist the category, never the message.
|
|
1334
|
+
// Only the provider request's own exception is a provider error: this catch
|
|
1335
|
+
// also sees failures in history reads and in the harness itself.
|
|
1336
|
+
const provider = this.providerFailure?.error === error ? this.providerFailure : undefined;
|
|
1337
|
+
const failure = failureCause(error, provider);
|
|
1270
1338
|
await this.record({ t: "outcome", id: this.clock.nextId(), at: this.clock.now(),
|
|
1271
|
-
reason:
|
|
1339
|
+
reason: `request failed before completion (${describeFailure(failure)}); no complete response was committed`,
|
|
1340
|
+
outcome: executionOutcome(provider ? "provider_error" : "execution_error"), failure });
|
|
1272
1341
|
throw error;
|
|
1273
1342
|
} finally {
|
|
1274
1343
|
// Closing a consumer is not a user cancellation. Preserve the existing
|
|
@@ -1337,8 +1406,8 @@ export class Session {
|
|
|
1337
1406
|
return;
|
|
1338
1407
|
}
|
|
1339
1408
|
|
|
1340
|
-
if (yield* this.checkStall()) {
|
|
1341
|
-
yield { kind: "done", reason: await report("
|
|
1409
|
+
if (yield* this.checkStall(request, signal)) {
|
|
1410
|
+
yield { kind: "done", reason: await report("repeated tool cycle without new evidence", turn), outcome: executionOutcome("stalled") };
|
|
1342
1411
|
return;
|
|
1343
1412
|
}
|
|
1344
1413
|
if (phase === "finalize" && !supportsToolDisabling(this.provider.config.spec.api)) {
|
|
@@ -1400,7 +1469,9 @@ export class Session {
|
|
|
1400
1469
|
}
|
|
1401
1470
|
if (phase === "finalize") {
|
|
1402
1471
|
yield { kind: "discard" };
|
|
1403
|
-
|
|
1472
|
+
const failure = failureCause(error, this.providerFailure?.error === error ? this.providerFailure : { retries: 0 });
|
|
1473
|
+
yield { kind: "done", reason: await report(`final response failed (${describeFailure(failure)}); no additional request was made`, turn + 1),
|
|
1474
|
+
outcome: executionOutcome("provider_error"), failure };
|
|
1404
1475
|
return;
|
|
1405
1476
|
}
|
|
1406
1477
|
if (!(error instanceof Error) || !isContextOverflow(error.message, statusOf(error))) throw error;
|
|
@@ -1633,6 +1704,7 @@ export class Session {
|
|
|
1633
1704
|
// granularity the research warns against.
|
|
1634
1705
|
const declarationNode = this.state.node;
|
|
1635
1706
|
if (!signal?.aborted && await this.applyDiscoveredTools()) yield { kind: "cut", node: declarationNode, reason: "model_checkpoint" };
|
|
1707
|
+
if (this.pendingRoot !== null) await this.applyPendingRoot();
|
|
1636
1708
|
if (this.pendingAdoption !== null) await this.adoptPendingBranch();
|
|
1637
1709
|
|
|
1638
1710
|
// Between complete exchanges is the only safe place to cut.
|
|
@@ -1732,8 +1804,9 @@ export class Session {
|
|
|
1732
1804
|
): AsyncGenerator<LoopEvent, Turn> {
|
|
1733
1805
|
const hydrated = await hydrateImages(messages, this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1734
1806
|
this.provider.config.spec.input?.includes("image") ?? false);
|
|
1735
|
-
|
|
1736
|
-
|
|
1807
|
+
let retries = 0;
|
|
1808
|
+
this.providerFailure = undefined;
|
|
1809
|
+
const progress = (emit: (event: LoopEvent) => void) => (delta: StreamDelta): void => {
|
|
1737
1810
|
switch (delta.kind) {
|
|
1738
1811
|
case "text":
|
|
1739
1812
|
emit({ kind: "text_delta", text: delta.text });
|
|
@@ -1744,13 +1817,25 @@ export class Session {
|
|
|
1744
1817
|
case "discard":
|
|
1745
1818
|
emit({ kind: "discard" });
|
|
1746
1819
|
break;
|
|
1747
|
-
case "
|
|
1820
|
+
case "retry":
|
|
1821
|
+
retries = Math.max(retries, delta.attempt);
|
|
1822
|
+
emit({ kind: "progress", progress: delta });
|
|
1823
|
+
break;
|
|
1824
|
+
case "tool":
|
|
1748
1825
|
emit({ kind: "progress", progress: delta });
|
|
1749
1826
|
break;
|
|
1750
1827
|
default:
|
|
1751
1828
|
return assertNever(delta, "onDelta");
|
|
1752
1829
|
}
|
|
1753
|
-
}
|
|
1830
|
+
};
|
|
1831
|
+
try {
|
|
1832
|
+
return yield* withProgress<Turn, LoopEvent>(emit => this.provider.send(
|
|
1833
|
+
this.config.frozen, hydrated, this.state.node, signal, progress(emit), options));
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
// The outcome needs to know this exception came from the request, and after how many retries.
|
|
1836
|
+
this.providerFailure = { error, retries };
|
|
1837
|
+
throw error;
|
|
1838
|
+
}
|
|
1754
1839
|
}
|
|
1755
1840
|
|
|
1756
1841
|
/**
|
|
@@ -1824,7 +1909,10 @@ export class Session {
|
|
|
1824
1909
|
let context: ToolContext = { ...this.config.tools, ...(this.readOnlyRequest ? { workflowMode: "plan" as const, planHint: READ_ONLY_HINT } : {}),
|
|
1825
1910
|
imageStore: this.config.blobDir ?? join(this.config.logDir, "blobs"),
|
|
1826
1911
|
advice: this.allowedNames(),
|
|
1827
|
-
imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {})
|
|
1912
|
+
imageAutoResize: this.config.imageOptions?.autoResize ?? true, ...(signal ? { signal } : {}),
|
|
1913
|
+
// feat-approval-events: the broker has already recorded this call's
|
|
1914
|
+
// invocation, and its result comes after execution, so the order is fixed.
|
|
1915
|
+
onApproval: approval => this.record({ t: "approval", id: this.clock.nextId(), at: this.clock.now(), ...approval }) };
|
|
1828
1916
|
if (native) context = nativeCommandContext(context);
|
|
1829
1917
|
const decoder = new TextDecoder();
|
|
1830
1918
|
context.onOutput = chunk => emit({ kind: "progress", progress: { kind: "output", name: call.name, text: decoder.decode(chunk, { stream: true }) } });
|
|
@@ -1931,7 +2019,7 @@ export class Session {
|
|
|
1931
2019
|
const forkSkill = call.name === "read" && typeof call.input === "object" && call.input !== null ? this.config.skills?.find(skill =>
|
|
1932
2020
|
skill.context === "fork" && skill.filePath === resolve(ctx.root, String((call.input as Record<string, unknown>)["path"]))) : undefined;
|
|
1933
2021
|
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);
|
|
2022
|
+
if (!forkSkill && !discovery && !["note", "branch", "resolve", "history", "target", "worktree"].includes(call.name)) return prepareNormalizedTool(ctx, call.name, call.input);
|
|
1935
2023
|
const branchIndex = forkSkill || call.name === "branch" ? nextBranch() : 0;
|
|
1936
2024
|
return prepareNormalizedTool(ctx, call.name, call.input, async () => {
|
|
1937
2025
|
if (discovery) return async () => searchDeferredTools(ctx, call.input as Record<string, unknown>, this.allowedNames());
|
|
@@ -1950,6 +2038,7 @@ export class Session {
|
|
|
1950
2038
|
return { ...prepared.out, ...(prepared.annotation ? { annotation: prepared.annotation } : {}) };
|
|
1951
2039
|
};
|
|
1952
2040
|
if (call.name === "resolve") return () => this.runResolve(call.input);
|
|
2041
|
+
if (call.name === "worktree") return () => this.runWorktree(call.input, ctx.pathRoot);
|
|
1953
2042
|
if (forkSkill?.disableModelInvocation) return async () => ({ ok: false, content: "this skill requires explicit user invocation" });
|
|
1954
2043
|
if (forkSkill) await this.recordSkill(forkSkill, "fork", "model");
|
|
1955
2044
|
const task = this.state.events.findLast(event => event.t === "user");
|
|
@@ -2086,6 +2175,7 @@ export class Session {
|
|
|
2086
2175
|
);
|
|
2087
2176
|
branch.state.node = node;
|
|
2088
2177
|
branch.state.nextNode = this.state.nextNode;
|
|
2178
|
+
branch.secrets = this.secrets;
|
|
2089
2179
|
if (skill) await branch.recordSkill(skill, "fork", skillOrigin);
|
|
2090
2180
|
this.children.set(node, branch);
|
|
2091
2181
|
yield { kind: "branch_start", node, reason: purpose, at: Date.now() };
|
|
@@ -2163,6 +2253,50 @@ export class Session {
|
|
|
2163
2253
|
};
|
|
2164
2254
|
}
|
|
2165
2255
|
|
|
2256
|
+
private async runWorktree(input: unknown, pathRoot?: string): Promise<ToolResult> {
|
|
2257
|
+
const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
|
|
2258
|
+
const root = this.config.tools.root;
|
|
2259
|
+
// A branch already runs in its own checkout and never owns the parent's root.
|
|
2260
|
+
if ((this.config.branchDepth ?? 0) > 0) return { content: "worktree is unavailable inside a branch; the branch already has its own checkout", ok: false };
|
|
2261
|
+
if (this.pendingRoot !== null) return { content: "one worktree switch per tool round; the previous one applies after this round", ok: false };
|
|
2262
|
+
const main = await mainWorktree(root);
|
|
2263
|
+
if (main === null) return { content: `${root} is not inside a git repository, so there is nothing to make a worktree of`, ok: false };
|
|
2264
|
+
const outside = (path: string) => pathRoot !== undefined && relative(pathRoot, path).startsWith("..");
|
|
2265
|
+
if (args["action"] === "exit") {
|
|
2266
|
+
if (main === root) return { content: `already in the main checkout ${main}; nothing to exit`, ok: false };
|
|
2267
|
+
if (outside(main)) return { content: `the main checkout ${main} is outside --path-root ${pathRoot}`, ok: false };
|
|
2268
|
+
const branch = (await listWorktrees(main)).find(wt => wt.path === root)?.branch ?? null;
|
|
2269
|
+
this.pendingRoot = main;
|
|
2270
|
+
return { ok: true, content: [`main ${main}`, `kept ${root}${branch ? ` on ${branch.replace(/^refs\/heads\//, "")}` : ""}`,
|
|
2271
|
+
"The session root moves to the main checkout after this tool round. Nothing was merged or deleted; " +
|
|
2272
|
+
`remove the worktree yourself with \`git worktree remove ${root}\` once its branch is merged or no longer needed.`].join("\n") };
|
|
2273
|
+
}
|
|
2274
|
+
if (args["action"] !== "enter") return { content: "worktree action must be enter or exit", ok: false };
|
|
2275
|
+
const name = typeof args["name"] === "string" ? args["name"] : "";
|
|
2276
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(name) || name.includes("..")) {
|
|
2277
|
+
return { content: "worktree name must be 1-64 letters, digits, dot, dash or underscore, starting with a letter or digit", ok: false };
|
|
2278
|
+
}
|
|
2279
|
+
const parent = persistentWorktreeParent(configDir(), main);
|
|
2280
|
+
if (outside(parent)) return { content: `xeno's worktree directory ${parent} is outside --path-root ${pathRoot}`, ok: false };
|
|
2281
|
+
const created = await createWorktree(root, name, { location: { parent, leaf: name }, includeChanges: args["include_changes"] === true });
|
|
2282
|
+
if (!created.ok) return { content: `refused: ${created.error}`, ok: false };
|
|
2283
|
+
this.pendingRoot = created.path;
|
|
2284
|
+
return { ok: true, content: [`worktree ${created.path}`, `branch ${created.branch}`, `from ${created.snapshot.head}`,
|
|
2285
|
+
`The session root moves there after this tool round; relative paths, edits and commands then resolve in it. ` +
|
|
2286
|
+
`The working directory in the system prompt still names ${root}. ` +
|
|
2287
|
+
(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"),
|
|
2288
|
+
data: { path: created.path, branch: created.branch, head: created.snapshot.head } };
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
private async applyPendingRoot(): Promise<void> {
|
|
2292
|
+
const root = this.pendingRoot;
|
|
2293
|
+
this.pendingRoot = null;
|
|
2294
|
+
if (root === null) return;
|
|
2295
|
+
this.config.tools.root = root;
|
|
2296
|
+
this.installWorkspace();
|
|
2297
|
+
await this.saveCursor();
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2166
2300
|
private async runResolve(input: unknown): Promise<ToolResult> {
|
|
2167
2301
|
const args = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
|
|
2168
2302
|
const node = Number(args["node"]);
|
|
@@ -2555,7 +2689,14 @@ export class Session {
|
|
|
2555
2689
|
return recoveryReview(this.recovery, tools);
|
|
2556
2690
|
}
|
|
2557
2691
|
|
|
2558
|
-
|
|
2692
|
+
/**
|
|
2693
|
+
* S.8: a repeated cycle is pointed out to the model once; the next one is
|
|
2694
|
+
* the user's call when a question client can answer -- continue, stop, or
|
|
2695
|
+
* new instructions -- and ends the request only without one (headless) or
|
|
2696
|
+
* when the user stops it. The answer is recorded as an event, never kept
|
|
2697
|
+
* as loop state: a continue is a `stall` guidance, instructions are steering.
|
|
2698
|
+
*/
|
|
2699
|
+
private async *checkStall(request: string, signal?: AbortSignal): AsyncGenerator<LoopEvent, boolean> {
|
|
2559
2700
|
const limit = this.config.stallLimit ?? DEFAULT_STALL_LIMIT;
|
|
2560
2701
|
if (limit === 0) return false;
|
|
2561
2702
|
if (this.stall === null) {
|
|
@@ -2564,13 +2705,42 @@ export class Session {
|
|
|
2564
2705
|
this.stall = advanceStall(emptyStallState(), history.nodes.flatMap(({ node, events }) => events.map((event) => ({ node, event }))));
|
|
2565
2706
|
}
|
|
2566
2707
|
const decision = stallDecision(this.stall, limit);
|
|
2567
|
-
if (decision.
|
|
2568
|
-
|
|
2569
|
-
|
|
2708
|
+
if (decision.cycle === null) return false;
|
|
2709
|
+
const pattern = decision.cycle === 1 ? "the same tool call" : `a cycle of ${decision.cycle} tool calls`;
|
|
2710
|
+
const facts = `${pattern} repeated ${decision.repeats} times without new evidence (${decision.failures} failed receipts; ${decision.emptyWaits} empty waits)`;
|
|
2711
|
+
if (decision.warn) {
|
|
2712
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
|
|
2713
|
+
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.` });
|
|
2714
|
+
return false;
|
|
2570
2715
|
}
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2716
|
+
const answer = await this.askAboutCycle(facts, signal);
|
|
2717
|
+
if (answer?.kind === "continue") {
|
|
2718
|
+
await this.record({ t: "guidance", id: this.clock.nextId(), at: this.clock.now(), reason: "stall", sources: [],
|
|
2719
|
+
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.` });
|
|
2720
|
+
return false;
|
|
2721
|
+
}
|
|
2722
|
+
if (answer?.kind === "steer") {
|
|
2723
|
+
await this.record({ t: "user", id: this.clock.nextId(), at: this.clock.now(), request, text: answer.text });
|
|
2724
|
+
return false;
|
|
2725
|
+
}
|
|
2726
|
+
yield { kind: "stop_and_ask", reason: `${facts}; change the approach or provide new input` };
|
|
2727
|
+
return true;
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
/** Undefined means nobody answered: no question client (headless), cancelled, or an invalid reply. */
|
|
2731
|
+
private async askAboutCycle(facts: string, signal?: AbortSignal): Promise<{ kind: "continue" } | { kind: "stop" } | { kind: "steer"; text: string } | undefined> {
|
|
2732
|
+
const slot = this.config.tools.questionSlot, adapter = slot?.current, turn = slot?.activeTurn;
|
|
2733
|
+
if (!adapter || !turn || signal?.aborted) return undefined;
|
|
2734
|
+
const request = questionRequest(turn, `stall-${this.clock.nextId()}`, [{ id: "stall", header: "Repeated tool calls",
|
|
2735
|
+
question: `The agent ${facts}. Continue, stop, or type instructions for it?`, multiple: false, custom: true,
|
|
2736
|
+
options: [{ id: "continue", label: "Continue", description: "Let the agent keep working; it is told to change its method." },
|
|
2737
|
+
{ id: "stop", label: "Stop", description: "End this request; the log and evidence stay." }] }]);
|
|
2738
|
+
try {
|
|
2739
|
+
const reply = validateQuestionReply(request, await adapter(structuredClone(request), signal ?? new AbortController().signal));
|
|
2740
|
+
const [answer] = reply.answers, text = answer?.text?.trim();
|
|
2741
|
+
if (text) return { kind: "steer", text };
|
|
2742
|
+
return answer?.choices[0] === "continue" ? { kind: "continue" } : answer?.choices[0] === "stop" ? { kind: "stop" } : undefined;
|
|
2743
|
+
} catch { return undefined; }
|
|
2574
2744
|
}
|
|
2575
2745
|
|
|
2576
2746
|
/** A steering signal releases only the wait; cancellation owns process termination. */
|
|
@@ -2795,7 +2965,7 @@ export class Session {
|
|
|
2795
2965
|
next_thinking: nextThinking,
|
|
2796
2966
|
...(nextContextRef === undefined ? {} : { next_context_ref: nextContextRef }),
|
|
2797
2967
|
handoff_tokens: DEFAULT_HANDOFF_TOKENS,
|
|
2798
|
-
...(this.config.workingSet ? { working_set_version:
|
|
2968
|
+
...(this.config.workingSet ? { working_set_version: CURRENT_WORKING_SET_VERSION } : {}),
|
|
2799
2969
|
guidance_tools: guidanceTools,
|
|
2800
2970
|
...(retainedFrom === undefined ? {} : { retained_from: retainedFrom }),
|
|
2801
2971
|
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Open a stored image in the system viewer (docs/tasks/feat-tui-image-full-size.md).
|
|
3
|
+
*
|
|
4
|
+
* Blobs are content-addressed and have no extension, and viewers recognise a
|
|
5
|
+
* file by its extension, so the verified bytes are copied once to
|
|
6
|
+
* `<tmpdir>/xeno-images/<sha256>.<ext>` and that file is opened. The name is
|
|
7
|
+
* the hash: opening the same picture again reuses the file, and a file under
|
|
8
|
+
* that name can only hold those bytes because we never write it twice.
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { getBlobBytes } from "../store/blobs.ts";
|
|
14
|
+
import { MAX_IMAGE_SOURCE_BYTES } from "../core/image-limits.ts";
|
|
15
|
+
import { detectSupportedImageMimeType } from "../vendor/pi-rich/mime.ts";
|
|
16
|
+
|
|
17
|
+
const EXTENSIONS: Readonly<Record<string, string>> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "image/bmp": "bmp" };
|
|
18
|
+
|
|
19
|
+
/** The platform's "open with the default application" command. */
|
|
20
|
+
function launchViewer(path: string): void {
|
|
21
|
+
Bun.spawn([process.platform === "darwin" ? "open" : "xdg-open", path], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Opens the blob; returns why it could not, or null once the viewer was started. */
|
|
25
|
+
export async function openImageFile(blobDir: string, sha256: string,
|
|
26
|
+
options: { dir?: string; launch?: (path: string) => void } = {}): Promise<string | null> {
|
|
27
|
+
let bytes: Uint8Array | null;
|
|
28
|
+
try { bytes = await getBlobBytes(blobDir, sha256, MAX_IMAGE_SOURCE_BYTES); } catch (error) { return `Cannot open image: ${error instanceof Error ? error.message : String(error)}`; }
|
|
29
|
+
if (bytes === null) return "Cannot open image: it is no longer in the session's blob store";
|
|
30
|
+
const mime = detectSupportedImageMimeType(bytes);
|
|
31
|
+
const extension = mime === null ? undefined : EXTENSIONS[mime];
|
|
32
|
+
if (extension === undefined) return "Cannot open image: unrecognised image format";
|
|
33
|
+
const dir = options.dir ?? join(tmpdir(), "xeno-images");
|
|
34
|
+
const path = join(dir, `${sha256}.${extension}`);
|
|
35
|
+
try {
|
|
36
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
37
|
+
// `wx`: an existing file is a previous copy of these same bytes.
|
|
38
|
+
await writeFile(path, bytes, { flag: "wx", mode: 0o600 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== "EEXIST") throw error; });
|
|
39
|
+
(options.launch ?? launchViewer)(path);
|
|
40
|
+
} catch (error) { return `Cannot open image: ${error instanceof Error ? error.message : String(error)}`; }
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
@@ -23,7 +23,10 @@ import { imageData } from "./images.ts";
|
|
|
23
23
|
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
|
-
import { withRetry, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
26
|
+
import { withRetry, retryLabel, DEFAULT_RETRY, type RetryPolicy } from "./retry.ts";
|
|
27
|
+
import { ConnectionReuse } from "./connection-reuse.ts";
|
|
28
|
+
import { watchIdle, type IdleWatch } from "./stream-idle.ts";
|
|
29
|
+
|
|
27
30
|
import type { ProviderSendOptions } from "./provider-tool-policy.ts";
|
|
28
31
|
|
|
29
32
|
/** What this adapter parks in a thinking block's raw: the reasoning item, whole. */
|
|
@@ -254,25 +257,30 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
254
257
|
readonly config: ProviderConfig;
|
|
255
258
|
private readonly apiKey: string;
|
|
256
259
|
private readonly fetchImpl: typeof fetch;
|
|
260
|
+
private readonly connections = new ConnectionReuse();
|
|
257
261
|
private readonly retry: RetryPolicy;
|
|
258
262
|
private readonly onRetry: ((attempt: number, delayMs: number, error: unknown) => void) | undefined;
|
|
263
|
+
private readonly streamIdleMs: number | undefined;
|
|
259
264
|
|
|
260
265
|
constructor(config: ProviderConfig, apiKey: string, options: {
|
|
261
266
|
fetchImpl?: typeof fetch; retry?: RetryPolicy; onRetry?: (attempt: number, delayMs: number, error: unknown) => void;
|
|
267
|
+
/** Silence after which one attempt is abandoned; tests shorten it (stream-idle.ts). */
|
|
268
|
+
streamIdleMs?: number;
|
|
262
269
|
} = {}) {
|
|
263
270
|
this.config = config;
|
|
264
271
|
this.apiKey = apiKey;
|
|
265
272
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
266
273
|
this.retry = options.retry ?? DEFAULT_RETRY;
|
|
267
274
|
this.onRetry = options.onRetry;
|
|
275
|
+
this.streamIdleMs = options.streamIdleMs;
|
|
268
276
|
}
|
|
269
277
|
|
|
270
278
|
send(frozen: FrozenPrefix, messages: readonly Message[], node: number, signal?: AbortSignal, onDelta?: OnDelta, options?: ProviderSendOptions): Promise<Turn> {
|
|
271
|
-
return withRetry(() => this.attempt(frozen, messages, node,
|
|
272
|
-
(attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs }); this.onRetry?.(attempt, delayMs, error); }, signal);
|
|
279
|
+
return withRetry(() => watchIdle(signal, this.streamIdleMs, idle => this.attempt(frozen, messages, node, idle, onDelta, options)).catch((error: unknown) => { this.connections.note(error); throw error; }), this.retry, undefined,
|
|
280
|
+
(attempt, delayMs, error) => { onDelta?.({ kind: "discard" }); onDelta?.({ kind: "retry", attempt, delayMs, max: this.retry.maxRetries, reason: retryLabel(error) }); this.onRetry?.(attempt, delayMs, error); }, signal);
|
|
273
281
|
}
|
|
274
282
|
|
|
275
|
-
private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number,
|
|
283
|
+
private async attempt(frozen: FrozenPrefix, messages: readonly Message[], node: number, idle: IdleWatch, onDelta?: OnDelta, options?: ProviderSendOptions): Promise<Turn> {
|
|
276
284
|
const started = Date.now();
|
|
277
285
|
const baseUrl = this.config.spec.baseUrl;
|
|
278
286
|
if (baseUrl === undefined) throw new Error(`model ${this.config.spec.id} has no baseUrl to send to`);
|
|
@@ -282,8 +290,8 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
282
290
|
body: JSON.stringify({ ...buildResponsesRequest(this.config, frozen, messages), stream: true,
|
|
283
291
|
...(options?.toolUse === "disabled" ? { tool_choice: "none" } : {}) }),
|
|
284
292
|
};
|
|
285
|
-
|
|
286
|
-
const response = await this.fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, init);
|
|
293
|
+
init.signal = idle.signal;
|
|
294
|
+
const response = await this.fetchImpl(`${baseUrl.replace(/\/$/, "")}/responses`, this.connections.init(init));
|
|
287
295
|
if (!response.ok) {
|
|
288
296
|
const text = await response.text();
|
|
289
297
|
let detail = text.slice(0, 200);
|
|
@@ -291,6 +299,6 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
291
299
|
throw new HttpError(response.status, response.headers, `HTTP ${response.status}: ${detail}`);
|
|
292
300
|
}
|
|
293
301
|
if (response.body === null) throw new Error(`${baseUrl} returned a streaming 200 with no body`);
|
|
294
|
-
return readResponsesSse(response.body, node, started, onDelta);
|
|
302
|
+
return readResponsesSse(idle.body(response.body), node, started, onDelta);
|
|
295
303
|
}
|
|
296
304
|
}
|