@gajae-code/coding-agent 0.17.6 → 0.17.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/types/cli/public-command-errors.d.ts +2 -0
  3. package/dist/types/commands/sdk.d.ts +7 -1
  4. package/dist/types/config/model-profile-ownership.d.ts +74 -0
  5. package/dist/types/config/model-registry.d.ts +2 -1
  6. package/dist/types/config/settings-schema.d.ts +32 -0
  7. package/dist/types/sdk/broker/lifecycle.d.ts +4 -1
  8. package/dist/types/sdk/lifecycle/service.d.ts +2 -0
  9. package/dist/types/sdk/lifecycle-session.d.ts +5 -1
  10. package/dist/types/sdk/session-directory.d.ts +1 -0
  11. package/dist/types/sdk/session-list.d.ts +11 -0
  12. package/dist/types/sdk/startup-capability.d.ts +1 -1
  13. package/dist/types/session/agent-session.d.ts +6 -0
  14. package/dist/types/session/internal/managed-session-scope.d.ts +4 -0
  15. package/dist/types/session/session-manager.d.ts +6 -0
  16. package/dist/types/setup/model-onboarding-guidance.d.ts +18 -0
  17. package/dist/types/task/delegation-hint.d.ts +53 -0
  18. package/examples/README.md +3 -3
  19. package/examples/custom-tools/README.md +1 -1
  20. package/examples/hooks/README.md +1 -1
  21. package/package.json +7 -7
  22. package/scripts/generate-sdk-operation-inventory.ts +6 -0
  23. package/src/cli/config-cli.ts +2 -0
  24. package/src/cli/public-command-errors.ts +6 -0
  25. package/src/commands/sdk.ts +361 -117
  26. package/src/config/model-profile-ownership.ts +303 -0
  27. package/src/config/model-registry.ts +53 -14
  28. package/src/config/settings-schema.ts +81 -0
  29. package/src/internal-urls/docs-index.generated.ts +18 -17
  30. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  31. package/src/modes/controllers/selector-controller.ts +3 -29
  32. package/src/sdk/broker/broker.ts +120 -23
  33. package/src/sdk/broker/lifecycle.ts +89 -13
  34. package/src/sdk/cli/session-cli.ts +45 -9
  35. package/src/sdk/host/control/dispatch.ts +6 -0
  36. package/src/sdk/lifecycle/service.ts +186 -3
  37. package/src/sdk/lifecycle-session.ts +35 -8
  38. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  39. package/src/sdk/protocol/operation-registry.ts +5 -1
  40. package/src/sdk/session-directory.ts +1 -1
  41. package/src/sdk/session-list.ts +47 -0
  42. package/src/sdk/session.ts +290 -114
  43. package/src/sdk/startup-capability.ts +2 -1
  44. package/src/session/agent-session.ts +102 -4
  45. package/src/session/internal/managed-session-scope.ts +81 -17
  46. package/src/session/session-manager.ts +70 -11
  47. package/src/setup/model-onboarding-guidance.ts +26 -0
  48. package/src/task/delegation-hint.ts +578 -0
@@ -0,0 +1,578 @@
1
+ import * as crypto from "node:crypto";
2
+ import * as http from "node:http";
3
+ import * as stream from "node:stream";
4
+ import { AUTOROUTING_TIERS, type AutoroutingTier } from "../config/autorouting-contract";
5
+ import { expandApplyPatchToEntries } from "../edit/modes/apply-patch";
6
+
7
+ export const KEV_SYSTEMONE_ENDPOINT = "http://127.0.0.1:8009/v1/systemone";
8
+ export const KEV_SYSTEMONE_MODEL = "kev-latest";
9
+ export const DELEGATION_HINT_TIMEOUT_MS = 750;
10
+
11
+ const TIERS = AUTOROUTING_TIERS;
12
+ const EDIT_TOOLS = new Set(["write", "edit", "apply_patch", "ast_edit"]);
13
+ const MAX_COUNTER = 10_000;
14
+ const MAX_TASKS = 32;
15
+ const MAX_PATCH_INPUT_CHARS = 64 * 1024;
16
+ const MAX_TRACKED_FILES = 128;
17
+ const MAX_TRACKED_PACKAGES = 32;
18
+ const MAX_RESPONSE_BYTES = 16 * 1024;
19
+ const MAX_HINT_SIGNATURES = 64;
20
+ const MIN_TURNS_BETWEEN_HINTS = 3;
21
+ const MIN_FILES_TO_CLASSIFY = 3;
22
+ const MIN_PACKAGES_TO_CLASSIFY = 2;
23
+ const MIN_EDITS_WITHOUT_VERIFICATION = 3;
24
+ const MIN_ACTIVE_PLAN_STEPS_TO_CLASSIFY = 3;
25
+ const SYSTEM_INSTRUCTIONS =
26
+ 'Classify only the aggregate numeric metrics provided. activePlanStepCount is the number of active peer tasks in the largest explicit plan phase; remainingContextRatio is the remaining fraction of the context window. Return exactly one JSON object with the keys "noul", "p_delegate", and "choice". "noul" is a boolean meaning whether the agent should delegate; "p_delegate" is a number from 0 to 1; "choice" must be one of the supplied tiers, or null when no tiers are available. Do not request or infer from raw paths, source text, assignments, prompts, or session content.';
27
+
28
+ type Tier = AutoroutingTier;
29
+
30
+ export interface DelegationHintMetrics {
31
+ toolCount: number;
32
+ editCount: number;
33
+ fileCount: number;
34
+ packageCount: number;
35
+ verificationCount: number;
36
+ consecutiveEdits: number;
37
+ /** Pending/in-progress peer tasks in the largest explicit plan phase. */
38
+ activePlanStepCount: number;
39
+ /** Remaining fraction of the context window, omitted when usage is unknown. */
40
+ remainingContextRatio?: number;
41
+ }
42
+
43
+ export interface KevDecision {
44
+ noul: boolean;
45
+ pDelegate: number;
46
+ choice: Tier | null;
47
+ }
48
+
49
+ export type KevDecisionFetcher = (
50
+ metrics: DelegationHintMetrics,
51
+ choices: readonly Tier[],
52
+ signal?: AbortSignal,
53
+ ) => Promise<KevDecision | undefined>;
54
+
55
+ export type KevFetchImplementation = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
56
+
57
+ export interface DelegationHintOptions {
58
+ getMode: () => unknown;
59
+ getAutoroutingEnabled: () => unknown;
60
+ getAutoroutingTiers: () => unknown;
61
+ notify: (message: string) => void;
62
+ fetcher?: KevFetchImplementation;
63
+ }
64
+
65
+ function boundedCount(value: number): number {
66
+ return Number.isFinite(value) ? Math.min(MAX_COUNTER, Math.max(0, Math.floor(value))) : 0;
67
+ }
68
+
69
+ function boundedRatio(value: number | undefined): number | undefined {
70
+ return typeof value === "number" && Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : undefined;
71
+ }
72
+
73
+ function normalizeMetrics(metrics: DelegationHintMetrics): DelegationHintMetrics {
74
+ const remainingContextRatio = boundedRatio(metrics.remainingContextRatio);
75
+ return {
76
+ toolCount: boundedCount(metrics.toolCount),
77
+ editCount: boundedCount(metrics.editCount),
78
+ fileCount: boundedCount(metrics.fileCount),
79
+ packageCount: boundedCount(metrics.packageCount),
80
+ verificationCount: boundedCount(metrics.verificationCount),
81
+ consecutiveEdits: boundedCount(metrics.consecutiveEdits),
82
+ activePlanStepCount: boundedCount(metrics.activePlanStepCount),
83
+ ...(remainingContextRatio === undefined ? {} : { remainingContextRatio }),
84
+ };
85
+ }
86
+
87
+ function isRecord(value: unknown): value is Record<string, unknown> {
88
+ return value !== null && typeof value === "object" && !Array.isArray(value);
89
+ }
90
+
91
+ function parseDecision(content: string, choices: readonly Tier[]): KevDecision | undefined {
92
+ let value: unknown;
93
+ try {
94
+ value = JSON.parse(content);
95
+ } catch {
96
+ return undefined;
97
+ }
98
+ if (!isRecord(value)) return undefined;
99
+ const keys = Object.keys(value).sort();
100
+ if (keys.length !== 3 || keys[0] !== "choice" || keys[1] !== "noul" || keys[2] !== "p_delegate") {
101
+ return undefined;
102
+ }
103
+ const choice = value.choice;
104
+ if (
105
+ typeof value.noul !== "boolean" ||
106
+ typeof value.p_delegate !== "number" ||
107
+ !Number.isFinite(value.p_delegate) ||
108
+ value.p_delegate < 0 ||
109
+ value.p_delegate > 1 ||
110
+ (choices.length === 0 ? choice !== null : typeof choice !== "string" || !choices.includes(choice as Tier))
111
+ ) {
112
+ return undefined;
113
+ }
114
+ return {
115
+ noul: value.noul,
116
+ pDelegate: value.p_delegate,
117
+ choice: typeof choice === "string" ? (choice as Tier) : null,
118
+ };
119
+ }
120
+
121
+ async function readBoundedBody(response: Response): Promise<string | undefined> {
122
+ const declaredLength = response.headers.get("content-length");
123
+ if (declaredLength !== null && Number(declaredLength) > MAX_RESPONSE_BYTES) {
124
+ await response.body?.cancel();
125
+ return undefined;
126
+ }
127
+ if (!response.body) return undefined;
128
+ const reader = response.body.getReader();
129
+ const chunks: Uint8Array[] = [];
130
+ let totalBytes = 0;
131
+ try {
132
+ while (true) {
133
+ const { done, value } = await reader.read();
134
+ if (done) break;
135
+ if (!value) continue;
136
+ totalBytes += value.byteLength;
137
+ if (totalBytes > MAX_RESPONSE_BYTES) {
138
+ await reader.cancel();
139
+ return undefined;
140
+ }
141
+ chunks.push(value);
142
+ }
143
+ } finally {
144
+ reader.releaseLock();
145
+ }
146
+ const bytes = new Uint8Array(totalBytes);
147
+ let offset = 0;
148
+ for (const chunk of chunks) {
149
+ bytes.set(chunk, offset);
150
+ offset += chunk.byteLength;
151
+ }
152
+ try {
153
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ function directKevRequest(input: string | URL | Request, init?: RequestInit): Promise<Response> {
160
+ const url = new URL(input instanceof Request ? input.url : input);
161
+ if (
162
+ !init ||
163
+ url.protocol !== "http:" ||
164
+ url.hostname !== "127.0.0.1" ||
165
+ url.port !== "8009" ||
166
+ url.pathname !== "/v1/systemone" ||
167
+ url.search !== "" ||
168
+ url.hash !== "" ||
169
+ url.username !== "" ||
170
+ url.password !== "" ||
171
+ init.method?.toUpperCase() !== "POST" ||
172
+ typeof init.body !== "string"
173
+ ) {
174
+ return Promise.reject(new TypeError("Kev decision requests must use the fixed loopback endpoint."));
175
+ }
176
+
177
+ const headers = new Headers(init.headers);
178
+ const requestHeaders: Record<string, string> = {};
179
+ headers.forEach((value, key) => {
180
+ requestHeaders[key] = value;
181
+ });
182
+ const deferred = Promise.withResolvers<Response>();
183
+ try {
184
+ const agent = new http.Agent({ proxyEnv: {} });
185
+ const request = http.request(
186
+ {
187
+ hostname: "127.0.0.1",
188
+ port: 8009,
189
+ path: "/v1/systemone",
190
+ method: "POST",
191
+ // Do not inherit Bun's process-wide HTTP_PROXY/NODE_USE_ENV_PROXY settings.
192
+ agent,
193
+ headers: requestHeaders,
194
+ signal: init.signal ?? undefined,
195
+ },
196
+ incoming => {
197
+ try {
198
+ const status = incoming.statusCode ?? 500;
199
+ if (status < 200 || status > 599) {
200
+ incoming.destroy();
201
+ deferred.resolve(new Response(null, { status: 500 }));
202
+ return;
203
+ }
204
+ const responseHeaders = new Headers();
205
+ for (const [key, value] of Object.entries(incoming.headers)) {
206
+ if (value !== undefined) responseHeaders.set(key, Array.isArray(value) ? value.join(", ") : value);
207
+ }
208
+ if (status >= 300 || [204, 205, 304].includes(status)) {
209
+ incoming.destroy();
210
+ deferred.resolve(new Response(null, { status, headers: responseHeaders }));
211
+ return;
212
+ }
213
+ const body = stream.Readable.toWeb(incoming) as ReadableStream<Uint8Array>;
214
+ deferred.resolve(new Response(body, { status, headers: responseHeaders }));
215
+ } catch (error) {
216
+ incoming.destroy();
217
+ deferred.reject(error);
218
+ }
219
+ },
220
+ );
221
+ request.on("error", error => deferred.reject(error));
222
+ request.end(init.body);
223
+ } catch (error) {
224
+ deferred.reject(error);
225
+ }
226
+ return deferred.promise;
227
+ }
228
+
229
+ /** Create a fixed-loopback, bounded Kev-compatible classifier client. */
230
+ export function createKevDecisionFetcher(
231
+ fetchImplementation: KevFetchImplementation = directKevRequest,
232
+ ): KevDecisionFetcher {
233
+ return async (metrics, choices, callerSignal) => {
234
+ if (choices.some(choice => !TIERS.includes(choice)) || new Set(choices).size !== choices.length) return undefined;
235
+ if (callerSignal?.aborted) return undefined;
236
+ const controller = new AbortController();
237
+ const abortFromCaller = () => controller.abort(callerSignal?.reason);
238
+ callerSignal?.addEventListener("abort", abortFromCaller, { once: true });
239
+ const timeout = setTimeout(
240
+ () => controller.abort(new Error("Kev decision hint timed out.")),
241
+ DELEGATION_HINT_TIMEOUT_MS,
242
+ );
243
+ const aborted = Promise.withResolvers<undefined>();
244
+ const onAbort = () => aborted.resolve(undefined);
245
+ controller.signal.addEventListener("abort", onAbort, { once: true });
246
+ try {
247
+ const requestBody = JSON.stringify({
248
+ model: KEV_SYSTEMONE_MODEL,
249
+ messages: [
250
+ { role: "system", content: SYSTEM_INSTRUCTIONS },
251
+ {
252
+ role: "user",
253
+ content: JSON.stringify({
254
+ noul: "should delegate",
255
+ choice: choices,
256
+ metrics: normalizeMetrics(metrics),
257
+ }),
258
+ },
259
+ ],
260
+ temperature: 0,
261
+ max_tokens: 64,
262
+ });
263
+ const request = (async (): Promise<KevDecision | undefined> => {
264
+ try {
265
+ const response = await fetchImplementation(KEV_SYSTEMONE_ENDPOINT, {
266
+ method: "POST",
267
+ headers: { "content-type": "application/json", accept: "application/json" },
268
+ body: requestBody,
269
+ redirect: "error",
270
+ signal: controller.signal,
271
+ });
272
+ if (!response.ok) {
273
+ await response.body?.cancel();
274
+ return undefined;
275
+ }
276
+ if (!response.headers.get("content-type")?.toLowerCase().includes("application/json")) {
277
+ await response.body?.cancel();
278
+ return undefined;
279
+ }
280
+ const responseText = await readBoundedBody(response);
281
+ if (responseText === undefined) return undefined;
282
+ const envelope: unknown = JSON.parse(responseText);
283
+ if (!isRecord(envelope) || !Array.isArray(envelope.choices) || envelope.choices.length !== 1) {
284
+ return undefined;
285
+ }
286
+ const firstChoice = envelope.choices[0];
287
+ if (
288
+ !isRecord(firstChoice) ||
289
+ !isRecord(firstChoice.message) ||
290
+ typeof firstChoice.message.content !== "string"
291
+ ) {
292
+ return undefined;
293
+ }
294
+ return parseDecision(firstChoice.message.content, choices);
295
+ } catch {
296
+ return undefined;
297
+ }
298
+ })();
299
+ return await Promise.race([request, aborted.promise]);
300
+ } catch {
301
+ return undefined;
302
+ } finally {
303
+ clearTimeout(timeout);
304
+ callerSignal?.removeEventListener("abort", abortFromCaller);
305
+ controller.signal.removeEventListener("abort", onAbort);
306
+ }
307
+ };
308
+ }
309
+
310
+ function stringsFromPathFields(args: Record<string, unknown>): string[] {
311
+ const paths: string[] = [];
312
+ for (const key of ["path", "file_path"]) {
313
+ if (typeof args[key] === "string") paths.push((args[key] as string).slice(0, 4096));
314
+ }
315
+ if (Array.isArray(args.paths)) {
316
+ for (const value of args.paths.slice(0, MAX_TASKS)) {
317
+ if (typeof value === "string") paths.push(value.slice(0, 4096));
318
+ }
319
+ }
320
+ if (Array.isArray(args.edits)) {
321
+ for (const value of args.edits.slice(0, MAX_TASKS)) {
322
+ if (!isRecord(value)) continue;
323
+ for (const key of ["path", "file_path", "rename"]) {
324
+ if (typeof value[key] === "string") paths.push((value[key] as string).slice(0, 4096));
325
+ }
326
+ }
327
+ }
328
+ const input = args.input;
329
+ if (
330
+ typeof input === "string" &&
331
+ input.length <= MAX_PATCH_INPUT_CHARS &&
332
+ input.trimStart().startsWith("*** Begin Patch")
333
+ ) {
334
+ try {
335
+ for (const entry of expandApplyPatchToEntries({ input }).slice(0, MAX_TRACKED_FILES)) {
336
+ paths.push(entry.path.slice(0, 4096));
337
+ if (entry.rename) paths.push(entry.rename.slice(0, 4096));
338
+ }
339
+ } catch {
340
+ // A malformed patch simply contributes no parsed file counts.
341
+ }
342
+ }
343
+ return paths;
344
+ }
345
+
346
+ function packageFromPath(path: string): string | undefined {
347
+ const segments = path.split(/[\\/]/u);
348
+ const packageIndex = segments.lastIndexOf("packages");
349
+ return packageIndex >= 0 ? segments[packageIndex + 1] : undefined;
350
+ }
351
+
352
+ function configuredTiers(value: unknown): Tier[] {
353
+ if (!isRecord(value)) return [];
354
+ return TIERS.filter(tier => {
355
+ const chain = value[tier];
356
+ return (
357
+ (typeof chain === "string" && chain.trim().length > 0) ||
358
+ (Array.isArray(chain) && chain.some(item => typeof item === "string" && item.trim().length > 0))
359
+ );
360
+ });
361
+ }
362
+
363
+ function isVerificationCommand(command: unknown): boolean {
364
+ return typeof command === "string" && /\b(test|check|lint|build|verify|typecheck)\b/iu.test(command);
365
+ }
366
+
367
+ function shouldClassify(metrics: DelegationHintMetrics, successfulTodoWrite: boolean): boolean {
368
+ return (
369
+ metrics.fileCount >= MIN_FILES_TO_CLASSIFY ||
370
+ metrics.packageCount >= MIN_PACKAGES_TO_CLASSIFY ||
371
+ metrics.consecutiveEdits >= MIN_EDITS_WITHOUT_VERIFICATION ||
372
+ (successfulTodoWrite && metrics.activePlanStepCount >= MIN_ACTIVE_PLAN_STEPS_TO_CLASSIFY)
373
+ );
374
+ }
375
+
376
+ function decisionHintMessage(decision: KevDecision, metrics: DelegationHintMetrics): string {
377
+ const suggestedTask = decision.choice ? `task(executor, tier=${decision.choice})` : "task(executor)";
378
+ const remainingContextRatio = metrics.remainingContextRatio?.toFixed(2) ?? "unknown";
379
+ return [
380
+ `Local delegation hint: consider ${suggestedTask} (advisory; it will not change model input or routing).`,
381
+ `p_delegate=${decision.pDelegate.toFixed(2)}; trigger: tools=${metrics.toolCount}, successful_edits=${metrics.editCount}, files=${metrics.fileCount}, packages=${metrics.packageCount}, verification_activity=${metrics.verificationCount}, consecutive_edits_without_verification=${metrics.consecutiveEdits}, active_plan_steps=${metrics.activePlanStepCount}, remaining_context_ratio=${remainingContextRatio}.`,
382
+ ].join(" ");
383
+ }
384
+
385
+ /** Ephemeral, bounded inference state. Notices stay out of prompts and session storage. */
386
+ export class DelegationHintController {
387
+ readonly #getMode: () => unknown;
388
+ readonly #getAutoroutingEnabled: () => unknown;
389
+ readonly #getAutoroutingTiers: () => unknown;
390
+ readonly #notify: (message: string) => void;
391
+ readonly #fetchDecision: KevDecisionFetcher;
392
+ readonly #files = new Set<string>();
393
+ readonly #packages = new Set<string>();
394
+ readonly #shownSignatures = new Set<string>();
395
+ #turnIndex = 0;
396
+ #inputGeneration = 0;
397
+ #toolCount = 0;
398
+ #editCount = 0;
399
+ #verificationCount = 0;
400
+ #consecutiveEdits = 0;
401
+ #activePlanStepCount = 0;
402
+ #lastHintTurn = Number.NEGATIVE_INFINITY;
403
+ #lastRequestTurn = Number.NEGATIVE_INFINITY;
404
+ #enabled = false;
405
+ #activeRequestController: AbortController | undefined;
406
+
407
+ constructor(options: DelegationHintOptions) {
408
+ this.#getMode = options.getMode;
409
+ this.#getAutoroutingEnabled = options.getAutoroutingEnabled;
410
+ this.#getAutoroutingTiers = options.getAutoroutingTiers;
411
+ this.#notify = options.notify;
412
+ this.#fetchDecision = createKevDecisionFetcher(options.fetcher);
413
+ this.#enabled = this.#getMode() === "hint";
414
+ }
415
+
416
+ setEnabled(enabled: boolean): void {
417
+ if (this.#enabled === enabled) return;
418
+ this.#enabled = enabled;
419
+ if (!enabled) this.onUserMessage();
420
+ }
421
+
422
+ /** Reset aggregate counters for the next assistant turn. */
423
+ onTurnStart(): void {
424
+ this.#turnIndex++;
425
+ this.#toolCount = 0;
426
+ this.#editCount = 0;
427
+ this.#verificationCount = 0;
428
+ this.#consecutiveEdits = 0;
429
+ this.#activePlanStepCount = 0;
430
+ this.#files.clear();
431
+ this.#packages.clear();
432
+ }
433
+
434
+ /** Prevent a pending classifier result from crossing into a new user prompt. */
435
+ onUserMessage(): void {
436
+ this.#inputGeneration++;
437
+ this.#activeRequestController?.abort();
438
+ this.#activeRequestController = undefined;
439
+ }
440
+
441
+ /** Synchronous callback; inference and its user-only notice never alter tool or model state. */
442
+ observeAfterToolCall(
443
+ input: {
444
+ toolName: string;
445
+ args: Record<string, unknown>;
446
+ isError: boolean;
447
+ resultError?: boolean;
448
+ getActivePlanStepCount?: () => number;
449
+ getRemainingContextRatio?: () => number | undefined;
450
+ },
451
+ signal?: AbortSignal,
452
+ ): void {
453
+ try {
454
+ this.setEnabled(this.#getMode() === "hint");
455
+ if (!this.#enabled) return;
456
+ this.#toolCount = boundedCount(this.#toolCount + 1);
457
+ if (input.toolName === "bash" && isVerificationCommand(input.args.command)) {
458
+ this.#verificationCount = boundedCount(this.#verificationCount + 1);
459
+ this.#consecutiveEdits = 0;
460
+ return;
461
+ }
462
+ if (EDIT_TOOLS.has(input.toolName)) this.#consecutiveEdits = boundedCount(this.#consecutiveEdits + 1);
463
+ const successfulEdit = EDIT_TOOLS.has(input.toolName) && !input.isError && input.resultError !== true;
464
+ const successfulTodoWrite = input.toolName === "todo_write" && !input.isError && input.resultError !== true;
465
+ if (!successfulEdit && !successfulTodoWrite) return;
466
+ this.#activePlanStepCount = boundedCount(input.getActivePlanStepCount?.() ?? 0);
467
+ if (successfulEdit) {
468
+ this.#editCount = boundedCount(this.#editCount + 1);
469
+ for (const path of stringsFromPathFields(input.args)) {
470
+ if (this.#files.size < MAX_TRACKED_FILES)
471
+ this.#files.add(crypto.createHash("sha256").update(path).digest("hex"));
472
+ const packageName = packageFromPath(path);
473
+ if (packageName && this.#packages.size < MAX_TRACKED_PACKAGES) {
474
+ this.#packages.add(crypto.createHash("sha256").update(packageName).digest("hex"));
475
+ }
476
+ }
477
+ }
478
+ const baseMetrics = this.#metrics();
479
+ if (
480
+ !shouldClassify(baseMetrics, successfulTodoWrite) ||
481
+ this.#turnIndex - this.#lastRequestTurn < MIN_TURNS_BETWEEN_HINTS ||
482
+ this.#shownSignatures.size >= MAX_HINT_SIGNATURES
483
+ ) {
484
+ return;
485
+ }
486
+ const remainingContextRatio = boundedRatio(input.getRemainingContextRatio?.());
487
+ const metrics = {
488
+ ...baseMetrics,
489
+ ...(remainingContextRatio === undefined ? {} : { remainingContextRatio }),
490
+ };
491
+ this.#lastRequestTurn = this.#turnIndex;
492
+ const requestTurn = this.#turnIndex;
493
+ const requestGeneration = this.#inputGeneration;
494
+ this.#activeRequestController?.abort();
495
+ const requestController = new AbortController();
496
+ this.#activeRequestController = requestController;
497
+ const requestSignal = signal ? AbortSignal.any([signal, requestController.signal]) : requestController.signal;
498
+ const choices = this.#getAutoroutingEnabled() === true ? configuredTiers(this.#getAutoroutingTiers()) : [];
499
+ setTimeout(() => {
500
+ if (
501
+ !this.#enabled ||
502
+ requestController.signal.aborted ||
503
+ requestGeneration !== this.#inputGeneration ||
504
+ this.#turnIndex - requestTurn > 1
505
+ ) {
506
+ if (this.#activeRequestController === requestController) this.#activeRequestController = undefined;
507
+ return;
508
+ }
509
+ void this.#classifyAndNotify(
510
+ metrics,
511
+ choices,
512
+ requestSignal,
513
+ requestTurn,
514
+ requestGeneration,
515
+ requestController,
516
+ );
517
+ }, 0);
518
+ } catch {
519
+ // Optional inference must not delay, replace, or fail an executed tool call.
520
+ }
521
+ }
522
+
523
+ #metrics(): DelegationHintMetrics {
524
+ return {
525
+ toolCount: this.#toolCount,
526
+ editCount: this.#editCount,
527
+ fileCount: this.#files.size,
528
+ packageCount: this.#packages.size,
529
+ verificationCount: this.#verificationCount,
530
+ consecutiveEdits: this.#consecutiveEdits,
531
+ activePlanStepCount: this.#activePlanStepCount,
532
+ };
533
+ }
534
+
535
+ async #classifyAndNotify(
536
+ metrics: DelegationHintMetrics,
537
+ choices: readonly Tier[],
538
+ signal: AbortSignal | undefined,
539
+ requestTurn: number,
540
+ requestGeneration: number,
541
+ requestController: AbortController,
542
+ ): Promise<void> {
543
+ try {
544
+ const decision = await this.#fetchDecision(metrics, choices, signal);
545
+ // No probability cutoff is applied without an in-domain evaluation; the notice remains advisory.
546
+ if (
547
+ !decision?.noul ||
548
+ !this.#enabled ||
549
+ requestController.signal.aborted ||
550
+ this.#getMode() !== "hint" ||
551
+ requestGeneration !== this.#inputGeneration ||
552
+ this.#turnIndex - requestTurn > 1 ||
553
+ this.#turnIndex - this.#lastHintTurn < MIN_TURNS_BETWEEN_HINTS
554
+ ) {
555
+ return;
556
+ }
557
+ const signature = JSON.stringify([
558
+ metrics.toolCount,
559
+ metrics.editCount,
560
+ metrics.fileCount,
561
+ metrics.packageCount,
562
+ metrics.verificationCount,
563
+ metrics.consecutiveEdits,
564
+ metrics.activePlanStepCount,
565
+ metrics.remainingContextRatio,
566
+ decision.choice,
567
+ ]);
568
+ if (this.#shownSignatures.has(signature) || this.#shownSignatures.size >= MAX_HINT_SIGNATURES) return;
569
+ this.#notify(decisionHintMessage(decision, metrics));
570
+ this.#lastHintTurn = this.#turnIndex;
571
+ this.#shownSignatures.add(signature);
572
+ } catch {
573
+ // Advisory inference and notification are isolated from tool execution.
574
+ } finally {
575
+ if (this.#activeRequestController === requestController) this.#activeRequestController = undefined;
576
+ }
577
+ }
578
+ }