@sayknow-cli/coding-agent 0.6.5 → 0.6.7
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 +29 -0
- package/dist/types/config/keybindings.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +79 -0
- package/dist/types/i18n/messages/en.d.ts +6 -29
- package/dist/types/modes/components/welcome.d.ts +34 -40
- package/dist/types/modes/interactive-mode.d.ts +7 -4
- package/dist/types/modes/types.d.ts +4 -0
- package/dist/types/sdk/broker/broker.d.ts +22 -0
- package/dist/types/sdk/broker/process-guard.d.ts +71 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/session/auth-storage-discovery.d.ts +15 -0
- package/dist/types/session/auto-fallback.d.ts +27 -0
- package/dist/types/session/fallback-chain-controller.d.ts +5 -0
- package/dist/types/session/response-language.d.ts +25 -0
- package/dist/types/setup/model-onboarding-guidance.d.ts +8 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/tools/debug.d.ts +2 -2
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/locate-core.d.ts +97 -0
- package/dist/types/tools/locate.d.ts +38 -0
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/src/cli/setup-cli.ts +7 -4
- package/src/commands/sdk.ts +3 -0
- package/src/commands/setup.ts +4 -1
- package/src/config/keybindings.ts +7 -0
- package/src/config/settings-schema.ts +82 -0
- package/src/decisions/typesafe-backend.ts +38 -4
- package/src/i18n/messages/de.settings.ts +26 -0
- package/src/i18n/messages/de.ts +4 -27
- package/src/i18n/messages/en.ts +6 -29
- package/src/i18n/messages/es.settings.ts +26 -0
- package/src/i18n/messages/es.ts +4 -27
- package/src/i18n/messages/fr.settings.ts +26 -0
- package/src/i18n/messages/fr.ts +4 -27
- package/src/i18n/messages/ja.settings.ts +26 -0
- package/src/i18n/messages/ja.ts +4 -27
- package/src/i18n/messages/ko.settings.ts +25 -0
- package/src/i18n/messages/ko.ts +5 -28
- package/src/i18n/messages/zh.settings.ts +23 -0
- package/src/i18n/messages/zh.ts +4 -27
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/modes/action-registry.ts +1 -0
- package/src/modes/components/welcome.ts +385 -387
- package/src/modes/controllers/input-controller.ts +15 -0
- package/src/modes/controllers/selector-controller.ts +13 -0
- package/src/modes/interactive-mode.ts +160 -98
- package/src/modes/types.ts +4 -0
- package/src/prompts/system/system-prompt.md +7 -2
- package/src/prompts/tools/locate.md +12 -0
- package/src/sdk/broker/broker.ts +75 -1
- package/src/sdk/broker/process-guard.ts +160 -0
- package/src/sdk/broker/transport.ts +15 -1
- package/src/sdk/bus/chat-daemon-runtime.ts +13 -1
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session.ts +4 -1
- package/src/session/agent-session.ts +118 -2
- package/src/session/auth-storage-discovery.ts +22 -7
- package/src/session/auto-fallback.ts +59 -0
- package/src/session/fallback-chain-controller.ts +5 -0
- package/src/session/response-language.ts +71 -0
- package/src/setup/model-onboarding-guidance.ts +29 -14
- package/src/setup/provider-onboarding.ts +5 -0
- package/src/slash-commands/builtin-registry.ts +112 -2
- package/src/tools/index.ts +3 -0
- package/src/tools/locate-core.ts +720 -0
- package/src/tools/locate.ts +197 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Guards for the SDK's long-lived background processes (`sdk broker-internal`,
|
|
5
|
+
* `sdk session-host-internal`). They are spawned detached and outlive whoever
|
|
6
|
+
* started them, so each one has to notice on its own when it has no reason, or no
|
|
7
|
+
* way, to keep running.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** How often the guards run. Cheap: one `stat`, plus a small JSON read for hosts. */
|
|
11
|
+
export const PROCESS_GUARD_INTERVAL_MS = 5_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The entry script of a process run from source (`bun …/src/cli.ts sdk …`), or
|
|
15
|
+
* undefined for a compiled binary, whose entry lives in Bun's virtual filesystem
|
|
16
|
+
* and cannot disappear from under it.
|
|
17
|
+
*/
|
|
18
|
+
export function sourceEntryPath(main: string | undefined = Bun.main): string | undefined {
|
|
19
|
+
if (!main) return undefined;
|
|
20
|
+
if (main.startsWith("/$bunfs/") || /^[A-Za-z]:[\\/]~BUN[\\/]/.test(main)) return undefined;
|
|
21
|
+
return main;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Errors that mean the file is gone, or the volume holding it is: unmounted, ejected, or failing. */
|
|
25
|
+
const SOURCE_GONE_CODES = new Set(["ENOENT", "ENOTDIR", "EIO", "ENXIO", "ENODEV"]);
|
|
26
|
+
|
|
27
|
+
/** Whether the source entry is still reachable. A compiled binary (no entry) always is. */
|
|
28
|
+
export function sourceEntryAvailable(
|
|
29
|
+
entry: string | undefined,
|
|
30
|
+
stat: (file: string) => unknown = file => fs.statSync(file),
|
|
31
|
+
): boolean {
|
|
32
|
+
if (!entry) return true;
|
|
33
|
+
try {
|
|
34
|
+
stat(entry);
|
|
35
|
+
return true;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return !SOURCE_GONE_CODES.has((error as NodeJS.ErrnoException).code ?? "");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* End this process at once when the source it runs from has vanished — typically
|
|
43
|
+
* an external drive with the checkout was unplugged. The native addon is mapped
|
|
44
|
+
* from that checkout and paged in lazily, so the next call into it faults on a
|
|
45
|
+
* page that can no longer be read, and Bun's fault handler then spins on the same
|
|
46
|
+
* unreadable mapping at 100% CPU forever. No cleanup is safe at that point (it
|
|
47
|
+
* would call into the same addon), so the process kills itself; readers already
|
|
48
|
+
* treat its discovery and markers as stale once the pid is gone.
|
|
49
|
+
*
|
|
50
|
+
* Call this before any native call in a periodic task. Returns true if it fired.
|
|
51
|
+
*/
|
|
52
|
+
export function exitIfSourceGone(
|
|
53
|
+
entry: string | undefined = sourceEntryPath(),
|
|
54
|
+
options: { stat?: (file: string) => unknown; kill?: () => void } = {},
|
|
55
|
+
): boolean {
|
|
56
|
+
if (sourceEntryAvailable(entry, options.stat)) return false;
|
|
57
|
+
(options.kill ?? (() => process.kill(process.pid, "SIGKILL")))();
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What a session host needs to recognise that its session is still its own. */
|
|
62
|
+
export interface SessionHostAuthority {
|
|
63
|
+
/** `<stateRoot>/sdk/<sessionId>.lifecycle.json`, written once by the broker when it spawned this host. */
|
|
64
|
+
markerPath: string;
|
|
65
|
+
pid: number;
|
|
66
|
+
effectMarker: string;
|
|
67
|
+
incarnation: string;
|
|
68
|
+
/** The host's worktree. */
|
|
69
|
+
cwd: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `held`: the marker still names this process and the worktree exists. `lost`: the
|
|
74
|
+
* marker or worktree is gone, or the marker names another process — the session was
|
|
75
|
+
* deleted, taken over, or its whole state root was removed (a finished test). `unknown`:
|
|
76
|
+
* something could not be read for another reason; that is not evidence either way.
|
|
77
|
+
*/
|
|
78
|
+
export type SessionHostAuthorityState = "held" | "lost" | "unknown";
|
|
79
|
+
|
|
80
|
+
const GONE_CODES = new Set(["ENOENT", "ENOTDIR"]);
|
|
81
|
+
|
|
82
|
+
export function checkSessionHostAuthority(
|
|
83
|
+
authority: SessionHostAuthority,
|
|
84
|
+
io: { readFile?: (file: string) => string; stat?: (file: string) => unknown } = {},
|
|
85
|
+
): SessionHostAuthorityState {
|
|
86
|
+
const readFile = io.readFile ?? (file => fs.readFileSync(file, "utf8"));
|
|
87
|
+
const stat = io.stat ?? (file => fs.statSync(file));
|
|
88
|
+
const code = (error: unknown) => (error as NodeJS.ErrnoException).code ?? "";
|
|
89
|
+
try {
|
|
90
|
+
stat(authority.cwd);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return GONE_CODES.has(code(error)) ? "lost" : "unknown";
|
|
93
|
+
}
|
|
94
|
+
let raw: string;
|
|
95
|
+
try {
|
|
96
|
+
raw = readFile(authority.markerPath);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
return GONE_CODES.has(code(error)) ? "lost" : "unknown";
|
|
99
|
+
}
|
|
100
|
+
let marker: { pid?: unknown; effectMarker?: unknown; incarnation?: unknown };
|
|
101
|
+
try {
|
|
102
|
+
marker = JSON.parse(raw) as typeof marker;
|
|
103
|
+
} catch {
|
|
104
|
+
// The broker publishes the marker by atomic rename, so a torn read is not
|
|
105
|
+
// expected; still, one unparsable read alone does not end a live session.
|
|
106
|
+
return "unknown";
|
|
107
|
+
}
|
|
108
|
+
return marker.pid === authority.pid &&
|
|
109
|
+
marker.effectMarker === authority.effectMarker &&
|
|
110
|
+
marker.incarnation === authority.incarnation
|
|
111
|
+
? "held"
|
|
112
|
+
: "lost";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Consecutive `lost` checks before a host gives up its session (≈15s at the default interval). */
|
|
116
|
+
export const SESSION_HOST_LOST_CHECKS = 3;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Watch a running session host. It stops (through `onLost`, the host's normal
|
|
120
|
+
* shutdown) once its ownership marker or worktree has been gone for
|
|
121
|
+
* {@link SESSION_HOST_LOST_CHECKS} checks in a row, and kills itself at once if the
|
|
122
|
+
* source checkout it runs from disappears. Before this, a host whose session state
|
|
123
|
+
* had been deleted kept running until someone killed it — for days, after tests.
|
|
124
|
+
* Returns a function that stops the watch.
|
|
125
|
+
*/
|
|
126
|
+
export function startSessionHostGuard(
|
|
127
|
+
options: SessionHostAuthority & {
|
|
128
|
+
onLost: () => void;
|
|
129
|
+
intervalMs?: number;
|
|
130
|
+
lostChecks?: number;
|
|
131
|
+
check?: (authority: SessionHostAuthority) => SessionHostAuthorityState;
|
|
132
|
+
sourceGuard?: () => boolean;
|
|
133
|
+
setInterval?: typeof setInterval;
|
|
134
|
+
clearInterval?: typeof clearInterval;
|
|
135
|
+
},
|
|
136
|
+
): () => void {
|
|
137
|
+
const testInterval = Number(process.env.SKC_SDK_TEST_HOST_GUARD_MS);
|
|
138
|
+
const intervalMs =
|
|
139
|
+
options.intervalMs ??
|
|
140
|
+
(Number.isFinite(testInterval) && testInterval > 0 ? testInterval : PROCESS_GUARD_INTERVAL_MS);
|
|
141
|
+
const lostChecks = options.lostChecks ?? SESSION_HOST_LOST_CHECKS;
|
|
142
|
+
const check = options.check ?? checkSessionHostAuthority;
|
|
143
|
+
const sourceGuard = options.sourceGuard ?? (() => exitIfSourceGone());
|
|
144
|
+
const set = options.setInterval ?? setInterval;
|
|
145
|
+
const clear = options.clearInterval ?? clearInterval;
|
|
146
|
+
let misses = 0;
|
|
147
|
+
let stopped = false;
|
|
148
|
+
const timer = set(() => {
|
|
149
|
+
if (stopped || sourceGuard()) return;
|
|
150
|
+
misses = check(options) === "lost" ? misses + 1 : 0;
|
|
151
|
+
if (misses < lostChecks) return;
|
|
152
|
+
stopped = true;
|
|
153
|
+
clear(timer);
|
|
154
|
+
options.onLost();
|
|
155
|
+
}, intervalMs);
|
|
156
|
+
return () => {
|
|
157
|
+
stopped = true;
|
|
158
|
+
clear(timer);
|
|
159
|
+
};
|
|
160
|
+
}
|
|
@@ -45,11 +45,16 @@ export class BrokerTransport {
|
|
|
45
45
|
readonly #requestedPort: number;
|
|
46
46
|
#server: Bun.Server<undefined> | null = null;
|
|
47
47
|
#port = 0;
|
|
48
|
+
#openConnections = 0;
|
|
48
49
|
constructor(broker: Broker, token: string, port = 0) {
|
|
49
50
|
this.#broker = broker;
|
|
50
51
|
this.#token = token;
|
|
51
52
|
this.#requestedPort = port;
|
|
52
53
|
}
|
|
54
|
+
/** Client WebSockets currently open. The broker does not idle out while any is. */
|
|
55
|
+
get openConnections(): number {
|
|
56
|
+
return this.#openConnections;
|
|
57
|
+
}
|
|
53
58
|
get port(): number {
|
|
54
59
|
if (!this.#server) throw new Error("Broker transport is not running");
|
|
55
60
|
return this.#port;
|
|
@@ -71,7 +76,15 @@ export class BrokerTransport {
|
|
|
71
76
|
},
|
|
72
77
|
websocket: {
|
|
73
78
|
maxPayloadLength: MAX_BROKER_JSON_FRAME_BYTES * 2,
|
|
74
|
-
open: socket =>
|
|
79
|
+
open: socket => {
|
|
80
|
+
this.#openConnections += 1;
|
|
81
|
+
this.#broker.noteActivity();
|
|
82
|
+
send(socket, { type: "broker_hello", protocolVersion: PROTOCOL_VERSION });
|
|
83
|
+
},
|
|
84
|
+
close: () => {
|
|
85
|
+
this.#openConnections = Math.max(0, this.#openConnections - 1);
|
|
86
|
+
this.#broker.noteActivity();
|
|
87
|
+
},
|
|
75
88
|
message: (socket, message) => void this.#handleMessage(socket, message),
|
|
76
89
|
},
|
|
77
90
|
});
|
|
@@ -82,6 +95,7 @@ export class BrokerTransport {
|
|
|
82
95
|
const server = this.#server;
|
|
83
96
|
this.#server = null;
|
|
84
97
|
if (server) await server.stop(true);
|
|
98
|
+
this.#openConnections = 0;
|
|
85
99
|
}
|
|
86
100
|
async #handleMessage(socket: ServerWebSocket<unknown>, raw: string | Buffer): Promise<void> {
|
|
87
101
|
if (Buffer.byteLength(raw) > MAX_BROKER_JSON_FRAME_BYTES) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { ensureBroker } from "../broker/ensure";
|
|
2
3
|
import { type IndexedSession, SessionIndex } from "../broker/session-index";
|
|
3
4
|
import { SdkClient, SdkClientError } from "../client/client";
|
|
4
5
|
import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
|
|
@@ -61,6 +62,8 @@ export interface ChatDaemonRuntimeDeps {
|
|
|
61
62
|
createClient?: (endpoint: SdkSessionEndpoint) => Promise<ChatDaemonSdkClient>;
|
|
62
63
|
createIndex?: (agentDir: string) => SessionIndex;
|
|
63
64
|
createBrokerClient?: (endpoint: { url: string; token: string }) => Promise<ChatDaemonSdkClient>;
|
|
65
|
+
/** Starts the agent broker when none is running (default: `ensureBroker`). */
|
|
66
|
+
ensureBroker?: (settings: { agentDir: string }) => Promise<unknown>;
|
|
64
67
|
onReconciled?: () => void;
|
|
65
68
|
setInterval?: typeof setInterval;
|
|
66
69
|
clearInterval?: typeof clearInterval;
|
|
@@ -480,7 +483,16 @@ export class ChatDaemonRuntime {
|
|
|
480
483
|
input: Record<string, unknown>,
|
|
481
484
|
idempotencyKey: string,
|
|
482
485
|
): Promise<Record<string, unknown>> {
|
|
483
|
-
|
|
486
|
+
let discovery = await readSdkBrokerDiscovery(this.input.agentDir);
|
|
487
|
+
if (!discovery) {
|
|
488
|
+
// A broker with nothing to serve stops on its own; start a fresh one for this command.
|
|
489
|
+
try {
|
|
490
|
+
await (this.deps.ensureBroker ?? ensureBroker)({ agentDir: this.input.agentDir });
|
|
491
|
+
} catch {
|
|
492
|
+
throw new ChatDeliveryError("pre_send");
|
|
493
|
+
}
|
|
494
|
+
discovery = await readSdkBrokerDiscovery(this.input.agentDir);
|
|
495
|
+
}
|
|
484
496
|
if (!discovery) throw new ChatDeliveryError("pre_send");
|
|
485
497
|
let client: ChatDaemonSdkClient;
|
|
486
498
|
try {
|
|
@@ -1771,6 +1771,17 @@
|
|
|
1771
1771
|
"packages/coding-agent/test/sdk-operation-inventory.test.ts"
|
|
1772
1772
|
]
|
|
1773
1773
|
},
|
|
1774
|
+
{
|
|
1775
|
+
"sourceId": "slash_command:fallback",
|
|
1776
|
+
"sourceFile": "packages/coding-agent/src/slash-commands/builtin-registry.ts",
|
|
1777
|
+
"sourceKind": "slash_command",
|
|
1778
|
+
"decision": "exclude",
|
|
1779
|
+
"rationale": "local fallback-chain configuration (fallback.models / fallback.auto settings); no SDK operation counterpart, and SDK clients observe switches via model_fallback_switched",
|
|
1780
|
+
"exclusionMetadata": {
|
|
1781
|
+
"adapterMappings": "not_applicable",
|
|
1782
|
+
"testIds": "not_applicable"
|
|
1783
|
+
}
|
|
1784
|
+
},
|
|
1774
1785
|
{
|
|
1775
1786
|
"sourceId": "slash_command:effort",
|
|
1776
1787
|
"sourceFile": "packages/coding-agent/src/slash-commands/builtin-registry.ts",
|
|
@@ -4419,6 +4430,17 @@
|
|
|
4419
4430
|
"packages/coding-agent/test/sdk-operation-inventory.test.ts"
|
|
4420
4431
|
]
|
|
4421
4432
|
},
|
|
4433
|
+
{
|
|
4434
|
+
"sourceId": "agent_session:getDefaultFallbackChain",
|
|
4435
|
+
"sourceFile": "packages/coding-agent/src/session/agent-session.ts",
|
|
4436
|
+
"sourceKind": "agent_session",
|
|
4437
|
+
"decision": "exclude",
|
|
4438
|
+
"rationale": "internal profile and fallback-chain state, not a user-facing SDK control seam",
|
|
4439
|
+
"exclusionMetadata": {
|
|
4440
|
+
"adapterMappings": "not_applicable",
|
|
4441
|
+
"testIds": "not_applicable"
|
|
4442
|
+
}
|
|
4443
|
+
},
|
|
4422
4444
|
{
|
|
4423
4445
|
"sourceId": "agent_session:abortRetry",
|
|
4424
4446
|
"sourceFile": "packages/coding-agent/src/session/agent-session.ts",
|
package/src/sdk/session.ts
CHANGED
|
@@ -110,7 +110,7 @@ import {
|
|
|
110
110
|
} from "../secrets";
|
|
111
111
|
import { AgentSession, type ForkContextSeed } from "../session/agent-session";
|
|
112
112
|
import type { AuthStorage } from "../session/auth-storage";
|
|
113
|
-
import { discoverAuthStorage } from "../session/auth-storage-discovery";
|
|
113
|
+
import { applyCredentialRankingModeSetting, discoverAuthStorage } from "../session/auth-storage-discovery";
|
|
114
114
|
import { type CustomMessage, convertToLlm } from "../session/messages";
|
|
115
115
|
import { createReadonlySessionManager, SessionManager } from "../session/session-manager";
|
|
116
116
|
import { formatNoModelsAvailableFallback } from "../setup/model-onboarding-guidance";
|
|
@@ -1057,6 +1057,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1057
1057
|
}
|
|
1058
1058
|
const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir }));
|
|
1059
1059
|
modelRegistry.applyConfiguredModelBindings(settings);
|
|
1060
|
+
// Multi-account order comes from settings (the env var still overrides it); apply it
|
|
1061
|
+
// before the first model-availability probe picks an account for this session.
|
|
1062
|
+
applyCredentialRankingModeSetting(authStorage, settings);
|
|
1060
1063
|
logger.time("initializeWithSettings", initializeWithSettings, settings);
|
|
1061
1064
|
const canRefreshModelsBeforeCredentialSelector =
|
|
1062
1065
|
!options.credentialSelector || runtimeCredentialSelectorInstalled || options.modelRegistry !== undefined;
|
|
@@ -366,6 +366,7 @@ import { buildNamedToolChoice, buildNamedToolChoiceResult } from "../utils/tool-
|
|
|
366
366
|
import { buildWorkflowIntentDiff, WORKFLOW_INTENT_DIFF_CUSTOM_TYPE } from "../workflow/workflow-intent-diff";
|
|
367
367
|
import { buildWorkspaceTree, type WorkspaceTree } from "../workspace-tree";
|
|
368
368
|
import type { AuthStorage } from "./auth-storage";
|
|
369
|
+
import { autoFallbackSelectors, selectorProvider } from "./auto-fallback";
|
|
369
370
|
import {
|
|
370
371
|
DefaultModelSelectionRecoveryError,
|
|
371
372
|
type DefaultModelSelectionResult,
|
|
@@ -378,6 +379,7 @@ import {
|
|
|
378
379
|
effectiveFallbackDelay,
|
|
379
380
|
FallbackChainController,
|
|
380
381
|
} from "./fallback-chain-controller";
|
|
382
|
+
import { buildResponseLanguageReminder, detectResponseLanguage } from "./response-language";
|
|
381
383
|
|
|
382
384
|
export { DefaultModelSelectionRecoveryError } from "./default-model-selection";
|
|
383
385
|
|
|
@@ -868,6 +870,14 @@ const KIMI_CODE_FIRST_EVENT_TIMEOUT_MESSAGES = {
|
|
|
868
870
|
]),
|
|
869
871
|
} as const;
|
|
870
872
|
|
|
873
|
+
/**
|
|
874
|
+
* Configured default-chain origins that mean "exactly this model": a subagent call
|
|
875
|
+
* composed by the task router, and an explicit `--model` at startup. These never get
|
|
876
|
+
* `fallback.models` or automatic entries appended. (A temporary pick installs its own
|
|
877
|
+
* runtime controller and never reaches the configured-chain path at all.)
|
|
878
|
+
*/
|
|
879
|
+
const EXACT_FALLBACK_CHAIN_ORIGINS: ReadonlySet<string> = new Set(["subagent", "startup-override"]);
|
|
880
|
+
|
|
871
881
|
const ALIBABA_TOKEN_PLAN_PROVIDER = "alibaba-token-plan";
|
|
872
882
|
const ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MESSAGES = {
|
|
873
883
|
"openai-responses": new Set([
|
|
@@ -7832,6 +7842,7 @@ export class AgentSession {
|
|
|
7832
7842
|
const eagerTodoPrelude =
|
|
7833
7843
|
!options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
|
|
7834
7844
|
const uiSkillPrelude = options?.synthetic ? undefined : this.#createUiSkillPrelude(expandedText);
|
|
7845
|
+
const languagePrelude = options?.synthetic ? undefined : this.#createResponseLanguagePrelude(expandedText);
|
|
7835
7846
|
|
|
7836
7847
|
const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
|
|
7837
7848
|
if (options?.images) {
|
|
@@ -7861,8 +7872,9 @@ export class AgentSession {
|
|
|
7861
7872
|
await this.#promptWithMessage(message, expandedText, {
|
|
7862
7873
|
...options,
|
|
7863
7874
|
prependMessages:
|
|
7864
|
-
eagerTodoPrelude || uiSkillPrelude
|
|
7875
|
+
eagerTodoPrelude || uiSkillPrelude || languagePrelude
|
|
7865
7876
|
? [
|
|
7877
|
+
...(languagePrelude ? [languagePrelude] : []),
|
|
7866
7878
|
...(uiSkillPrelude ? [uiSkillPrelude] : []),
|
|
7867
7879
|
...(eagerTodoPrelude ? [eagerTodoPrelude.message] : []),
|
|
7868
7880
|
]
|
|
@@ -12136,6 +12148,21 @@ export class AgentSession {
|
|
|
12136
12148
|
* routing and therefore costs nothing extra. The patterns stay in front of it: when
|
|
12137
12149
|
* they match, the model is never asked.
|
|
12138
12150
|
*/
|
|
12151
|
+
/**
|
|
12152
|
+
* Name the user's language at the start of the turn when their message is clearly in a
|
|
12153
|
+
* non-Latin script, so the final report after long English tool output stays in it.
|
|
12154
|
+
*/
|
|
12155
|
+
#createResponseLanguagePrelude(promptText: string): AgentMessage | undefined {
|
|
12156
|
+
const language = detectResponseLanguage(promptText);
|
|
12157
|
+
if (!language) return undefined;
|
|
12158
|
+
return {
|
|
12159
|
+
role: "developer",
|
|
12160
|
+
content: [{ type: "text", text: buildResponseLanguageReminder(language) }],
|
|
12161
|
+
attribution: "agent",
|
|
12162
|
+
timestamp: Date.now(),
|
|
12163
|
+
};
|
|
12164
|
+
}
|
|
12165
|
+
|
|
12139
12166
|
#createUiSkillPrelude(promptText: string): AgentMessage | undefined {
|
|
12140
12167
|
if (this.#planModeState?.enabled) return undefined;
|
|
12141
12168
|
const matched = buildUiSkillActivationContext(promptText);
|
|
@@ -13903,10 +13930,28 @@ export class AgentSession {
|
|
|
13903
13930
|
controller.seedResolution(activeIndex, [...controller.skips, ...resolution.skips]);
|
|
13904
13931
|
}
|
|
13905
13932
|
if (!resolution.model) throw new Error(this.#fallbackExhaustionError(controller));
|
|
13933
|
+
if ((controller.chain.appendedFrom ?? controller.chain.entries.length) < 2) {
|
|
13934
|
+
// A one-model configuration is a chain only through appended fallbacks. With its
|
|
13935
|
+
// model usable and the session not sitting on an appended fallback, leave the live
|
|
13936
|
+
// model alone exactly as before anything was appended: a context promotion or a
|
|
13937
|
+
// temporary scope stays, and an unchanged model is not re-set (which would close
|
|
13938
|
+
// provider sessions such as the Codex websocket on every prompt).
|
|
13939
|
+
if (activeIndex === resolutionStart && !this.#isOnAppendedFallback(controller)) return;
|
|
13940
|
+
if (this.model && modelsAreEqual(this.model, resolution.model)) return;
|
|
13941
|
+
}
|
|
13906
13942
|
this.#setModelAuthoritatively(resolution.model, "restore");
|
|
13907
13943
|
this.setThinkingLevel(resolution.explicitThinkingLevel ? resolution.thinkingLevel : this.thinkingLevel);
|
|
13908
13944
|
}
|
|
13909
13945
|
|
|
13946
|
+
/** True when the live model is one of the entries appended after the configured chain. */
|
|
13947
|
+
#isOnAppendedFallback(controller: FallbackChainController): boolean {
|
|
13948
|
+
const model = this.model;
|
|
13949
|
+
const appendedFrom = controller.chain.appendedFrom;
|
|
13950
|
+
if (!model || appendedFrom === undefined) return false;
|
|
13951
|
+
const key = `${model.provider}/${model.id}`;
|
|
13952
|
+
return controller.chain.entries.slice(appendedFrom).some(entry => entry === key || entry.startsWith(`${key}:`));
|
|
13953
|
+
}
|
|
13954
|
+
|
|
13910
13955
|
/**
|
|
13911
13956
|
* Materialize the default controller from the persisted configured-chain
|
|
13912
13957
|
* metadata. Consumers seed only resolution state; role/origin/identity stay
|
|
@@ -13925,11 +13970,12 @@ export class AgentSession {
|
|
|
13925
13970
|
if (materializeSettingsChain) {
|
|
13926
13971
|
this.setConfiguredModelChain("default", settingsEntries, "modelRoles");
|
|
13927
13972
|
}
|
|
13928
|
-
const
|
|
13973
|
+
const configured: ConfiguredFallbackChain = materializeSettingsChain
|
|
13929
13974
|
? { role: "default", entries: settingsEntries, origin: "modelRoles", explicitHead: true }
|
|
13930
13975
|
: configuredChain
|
|
13931
13976
|
? { ...configuredChain, entries: [...configuredChain.entries] }
|
|
13932
13977
|
: { role: "default", entries: settingsEntries, origin: "session", explicitHead: true };
|
|
13978
|
+
const chain = this.#withFallbackExtras(configured);
|
|
13933
13979
|
const existing = this.#defaultFallbackController;
|
|
13934
13980
|
if (
|
|
13935
13981
|
existing &&
|
|
@@ -13943,6 +13989,76 @@ export class AgentSession {
|
|
|
13943
13989
|
return this.#defaultFallbackController;
|
|
13944
13990
|
}
|
|
13945
13991
|
|
|
13992
|
+
/**
|
|
13993
|
+
* Append what runs after the configured chain when it is blocked: the user's
|
|
13994
|
+
* `fallback.models`, then (with `fallback.auto`) one model from each other
|
|
13995
|
+
* logged-in provider. Extras live only in the controller; the configured chain
|
|
13996
|
+
* the session persists is untouched. Temporary picks, subagent calls and an
|
|
13997
|
+
* explicit `--model` stay exactly what was asked for.
|
|
13998
|
+
*/
|
|
13999
|
+
#withFallbackExtras(chain: ConfiguredFallbackChain): ConfiguredFallbackChain {
|
|
14000
|
+
if (EXACT_FALLBACK_CHAIN_ORIGINS.has(chain.origin)) return chain;
|
|
14001
|
+
const entries = [...chain.entries];
|
|
14002
|
+
const seen = new Set(entries);
|
|
14003
|
+
const push = (selector: string): void => {
|
|
14004
|
+
const trimmed = selector.trim();
|
|
14005
|
+
if (!trimmed || seen.has(trimmed)) return;
|
|
14006
|
+
seen.add(trimmed);
|
|
14007
|
+
entries.push(trimmed);
|
|
14008
|
+
};
|
|
14009
|
+
for (const selector of this.settings.get("fallback.models")) push(selector);
|
|
14010
|
+
// Automatic picks stay out of an `enabledModels` allow-list (a deliberate scope) and
|
|
14011
|
+
// out of an explicit `retry.enabled: false` (the user asked for failures to surface).
|
|
14012
|
+
if (
|
|
14013
|
+
this.settings.get("fallback.auto") &&
|
|
14014
|
+
this.settings.get("retry.enabled") !== false &&
|
|
14015
|
+
this.settings.get("enabledModels").length === 0
|
|
14016
|
+
) {
|
|
14017
|
+
// Depends only on configured intent (never the live model), so the chain stays
|
|
14018
|
+
// stable while a fallback is active and when the primary comes back.
|
|
14019
|
+
const excludeProviders = new Set<string>();
|
|
14020
|
+
for (const entry of entries) {
|
|
14021
|
+
const provider = selectorProvider(entry);
|
|
14022
|
+
if (provider) excludeProviders.add(provider);
|
|
14023
|
+
}
|
|
14024
|
+
for (const selector of this.#autoFallbackTail(excludeProviders)) push(selector);
|
|
14025
|
+
}
|
|
14026
|
+
return entries.length === chain.entries.length
|
|
14027
|
+
? chain
|
|
14028
|
+
: { ...chain, entries, appendedFrom: chain.entries.length };
|
|
14029
|
+
}
|
|
14030
|
+
|
|
14031
|
+
#autoFallbackTail(excludeProviders: ReadonlySet<string>): string[] {
|
|
14032
|
+
try {
|
|
14033
|
+
const registry = this.#modelRegistry;
|
|
14034
|
+
return autoFallbackSelectors({
|
|
14035
|
+
available: registry.getAvailable(),
|
|
14036
|
+
hasCredentials: provider => registry.authStorage.hasAuth(provider),
|
|
14037
|
+
excludeProviders,
|
|
14038
|
+
usageOrder: this.settings.getStorage()?.getModelUsageOrder(),
|
|
14039
|
+
});
|
|
14040
|
+
} catch (error) {
|
|
14041
|
+
logger.debug("Automatic model fallback candidates unavailable", {
|
|
14042
|
+
error: error instanceof Error ? error.message : String(error),
|
|
14043
|
+
});
|
|
14044
|
+
return [];
|
|
14045
|
+
}
|
|
14046
|
+
}
|
|
14047
|
+
|
|
14048
|
+
/**
|
|
14049
|
+
* The default fallback chain the next prompt walks, automatic entries included,
|
|
14050
|
+
* and the position currently in use.
|
|
14051
|
+
*/
|
|
14052
|
+
getDefaultFallbackChain(): { entries: readonly string[]; activeIndex: number; appendedFrom: number } {
|
|
14053
|
+
const controller = this.#defaultFallbackChain();
|
|
14054
|
+
const entries = [...controller.chain.entries];
|
|
14055
|
+
return {
|
|
14056
|
+
entries,
|
|
14057
|
+
activeIndex: controller.activeIndex,
|
|
14058
|
+
appendedFrom: controller.chain.appendedFrom ?? entries.length,
|
|
14059
|
+
};
|
|
14060
|
+
}
|
|
14061
|
+
|
|
13946
14062
|
async #handleManagedAttemptOutcome(outcome: ManagedAttemptOutcome): Promise<ManagedAttemptDecision> {
|
|
13947
14063
|
if (outcome.type === "run_terminal") {
|
|
13948
14064
|
this.#defaultFallbackChain().resetAttemptBudget();
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { getAgentDbPath, getAgentDir } from "@sayknow-cli/utils";
|
|
11
11
|
import { resolveConfigValue } from "../config/resolve-config-value";
|
|
12
|
+
import type { Settings } from "../config/settings";
|
|
12
13
|
import { resolveAuthBrokerConfig } from "./auth-broker-config";
|
|
13
14
|
import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./auth-storage";
|
|
14
15
|
|
|
@@ -25,7 +26,7 @@ import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./auth
|
|
|
25
26
|
*/
|
|
26
27
|
export async function discoverAuthStorage(agentDir: string = getAgentDir()): Promise<AuthStorage> {
|
|
27
28
|
const brokerConfig = await resolveAuthBrokerConfig();
|
|
28
|
-
const credentialRankingMode =
|
|
29
|
+
const credentialRankingMode = credentialRankingModeFromEnv();
|
|
29
30
|
if (brokerConfig) {
|
|
30
31
|
const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
|
|
31
32
|
const initialResult = await client.fetchSnapshot();
|
|
@@ -70,14 +71,28 @@ export async function discoverAuthStorage(agentDir: string = getAgentDir()): Pro
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* {@link AuthStorage}'s
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* Per-machine multi-account ranking override from `SKC_CREDENTIAL_RANKING_MODE`.
|
|
75
|
+
* Unset/unknown → `undefined`, so the `auth.credentialRankingMode` setting (or
|
|
76
|
+
* {@link AuthStorage}'s `balanced` default) decides. `earliest-reset` switches to
|
|
77
|
+
* earliest-expiry-first selection so soon-to-reset tumbling-window quota is
|
|
78
|
+
* drained before it is lost.
|
|
78
79
|
*/
|
|
79
|
-
function
|
|
80
|
+
export function credentialRankingModeFromEnv(): "balanced" | "earliest-reset" | undefined {
|
|
80
81
|
const raw = process.env.SKC_CREDENTIAL_RANKING_MODE?.trim();
|
|
81
82
|
if (raw === "balanced" || raw === "earliest-reset") return raw;
|
|
82
83
|
return undefined;
|
|
83
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Apply the `auth.credentialRankingMode` setting to a credential store. The env
|
|
88
|
+
* var still wins, so a machine that pins a mode keeps it whatever config says.
|
|
89
|
+
* Returns the mode now in effect.
|
|
90
|
+
*/
|
|
91
|
+
export function applyCredentialRankingModeSetting(
|
|
92
|
+
storage: Pick<AuthStorage, "setCredentialRankingMode">,
|
|
93
|
+
settings: Pick<Settings, "get">,
|
|
94
|
+
): "balanced" | "earliest-reset" {
|
|
95
|
+
const mode = credentialRankingModeFromEnv() ?? settings.get("auth.credentialRankingMode");
|
|
96
|
+
storage.setCredentialRankingMode(mode);
|
|
97
|
+
return mode;
|
|
98
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Api, Model } from "@sayknow-cli/ai";
|
|
2
|
+
import { defaultModelPerProvider } from "../config/model-resolver";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How many other providers the automatic tail may add. Each entry can cost up to
|
|
6
|
+
* `fallback.maxAttempts` tries before the chain moves on, so the tail stays short.
|
|
7
|
+
*/
|
|
8
|
+
export const AUTO_FALLBACK_PROVIDER_LIMIT = 3;
|
|
9
|
+
|
|
10
|
+
/** Provider of a `provider/model[:level]` selector, or undefined for bare aliases. */
|
|
11
|
+
export function selectorProvider(selector: string): string | undefined {
|
|
12
|
+
const slash = selector.indexOf("/");
|
|
13
|
+
return slash > 0 ? selector.slice(0, slash) : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AutoFallbackInput {
|
|
17
|
+
/** Models whose provider has auth configured (`ModelRegistry.getAvailable()`). */
|
|
18
|
+
available: readonly Model<Api>[];
|
|
19
|
+
/** True when the provider has a stored credential or API key (keyless local providers do not count). */
|
|
20
|
+
hasCredentials: (provider: string) => boolean;
|
|
21
|
+
/** Providers already in the chain; the tail adds a different provider or nothing. */
|
|
22
|
+
excludeProviders: ReadonlySet<string>;
|
|
23
|
+
/** Most-recently-used `provider/id` keys, newest first. */
|
|
24
|
+
usageOrder?: readonly string[];
|
|
25
|
+
limit?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One model per other logged-in provider, for the automatic tail of the default
|
|
30
|
+
* fallback chain. Within a provider the model the user used most recently wins,
|
|
31
|
+
* then the provider's curated default; a provider with neither is skipped rather
|
|
32
|
+
* than guessed, because catalog order would land on an old model. Providers used
|
|
33
|
+
* recently come first, then the rest in curated-default order.
|
|
34
|
+
*/
|
|
35
|
+
export function autoFallbackSelectors(input: AutoFallbackInput): string[] {
|
|
36
|
+
const limit = input.limit ?? AUTO_FALLBACK_PROVIDER_LIMIT;
|
|
37
|
+
if (limit <= 0) return [];
|
|
38
|
+
const availableKeys = new Set(input.available.map(model => `${model.provider}/${model.id}`));
|
|
39
|
+
const picked = new Map<string, { selector: string; rank: number }>();
|
|
40
|
+
const eligible = (provider: string): boolean =>
|
|
41
|
+
!picked.has(provider) && !input.excludeProviders.has(provider) && input.hasCredentials(provider);
|
|
42
|
+
|
|
43
|
+
const usageOrder = input.usageOrder ?? [];
|
|
44
|
+
usageOrder.forEach((key, index) => {
|
|
45
|
+
const provider = selectorProvider(key);
|
|
46
|
+
if (!provider || !availableKeys.has(key) || !eligible(provider)) return;
|
|
47
|
+
picked.set(provider, { selector: key, rank: index });
|
|
48
|
+
});
|
|
49
|
+
Object.entries(defaultModelPerProvider).forEach(([provider, modelId], index) => {
|
|
50
|
+
const key = `${provider}/${modelId}`;
|
|
51
|
+
if (!availableKeys.has(key) || !eligible(provider)) return;
|
|
52
|
+
picked.set(provider, { selector: key, rank: usageOrder.length + index });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return [...picked.values()]
|
|
56
|
+
.sort((left, right) => left.rank - right.rank)
|
|
57
|
+
.slice(0, limit)
|
|
58
|
+
.map(entry => entry.selector);
|
|
59
|
+
}
|
|
@@ -7,6 +7,11 @@ export interface ConfiguredFallbackChain {
|
|
|
7
7
|
origin: string;
|
|
8
8
|
identity?: string;
|
|
9
9
|
explicitHead: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Index of the first entry appended at runtime after the configured intent
|
|
12
|
+
* (`fallback.models`, then automatic picks). Absent when nothing was appended.
|
|
13
|
+
*/
|
|
14
|
+
appendedFrom?: number;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
17
|
export interface FallbackFailure {
|