-
Notifications
You must be signed in to change notification settings - Fork 2.9k
Expand file tree
/
Copy pathcoreToolScheduler.ts
More file actions
4908 lines (4610 loc) · 183 KB
/
Copy pathcoreToolScheduler.ts
File metadata and controls
4908 lines (4610 loc) · 183 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
ToolCallRequestInfo,
ToolCallResponseInfo,
ToolCallConfirmationDetails,
ToolResult,
ToolResultDisplay,
ToolRegistry,
EditorType,
Config,
ToolConfirmationPayload,
AnyDeclarativeTool,
AnyToolInvocation,
ChatRecordingService,
ToolArtifact,
} from '../index.js';
import { createDebugLogger } from '../utils/debugLogger.js';
import { compactToolResultDisplayForHistory } from '../utils/toolResultDisplayCompaction.js';
import {
generateToolUseId,
firePreToolUseHook,
firePostToolUseHook,
firePostToolUseFailureHook,
firePostToolBatchHook,
fireNotificationHook,
firePermissionRequestHook,
appendAdditionalContext,
} from './toolHookTriggers.js';
import { NotificationType } from '../hooks/types.js';
import type { PostToolBatchToolCall } from '../hooks/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import {
truncateLlmContent,
truncateToolOutput,
TOOL_OUTPUT_TRUNCATED_PREFIX,
} from '../utils/truncation.js';
import {
ToolConfirmationOutcome,
ApprovalMode,
logToolCall,
ToolErrorType,
ToolCallEvent,
InputFormat,
Kind,
} from '../index.js';
import type {
FunctionResponse,
FunctionResponsePart,
Part,
PartListUnion,
} from '@google/genai';
import { fileURLToPath } from 'node:url';
import { ToolNames, ToolNamesMigration } from '../tools/tool-names.js';
import {
collectAvailableSkillEntries,
renderAvailableSkillsBlock,
type AvailableSkillEntry,
} from '../tools/skill-utils.js';
import { escapeSystemReminderTags } from '../utils/xml.js';
import { unescapePath, PATH_ARG_KEYS } from '../utils/paths.js';
import type { MemoryPressureMonitor } from '../services/memoryPressureMonitor.js';
import { CONCURRENCY_SAFE_KINDS } from '../tools/tools.js';
import { isShellCommandReadOnly } from '../utils/shellReadOnlyChecker.js';
import { stripShellWrapper } from '../utils/shell-utils.js';
import { parsePositiveIntegerEnv } from '../utils/env.js';
import {
isAlreadyTruncated,
persistAndTruncateToolResult,
} from '../utils/truncation.js';
import {
injectPermissionRulesIfMissing,
persistPermissionOutcome,
} from './permission-helpers.js';
import {
evaluatePermissionFlow,
getEffectivePermissionForConfirmation,
needsConfirmation,
isPlanModeBlocked,
isAutoEditApproved,
} from './permissionFlow.js';
import {
applyAutoModeDecision,
evaluateAutoMode,
getAutoModePermissionDeniedReason,
shouldClassifyAllShellForAutoMode,
shouldForceAutoModeReviewForAllow,
shouldFirePermissionDeniedForAutoMode,
shouldRunAutoModeForCall,
} from '../permissions/autoMode.js';
import { MAX_TRANSCRIPT_MESSAGES } from '../permissions/classifier-transcript.js';
import {
formatDenialStateLog,
isApproveOutcome,
isDenialFallbackReason,
recordAllow,
recordFallbackApprove,
shouldFallback,
} from '../permissions/denialTracking.js';
import {
getResponseTextFromParts,
TOOL_SUCCEEDED_OUTPUT,
} from '../utils/generateContentResponseUtilities.js';
import type { ModifyContext } from '../tools/modifiable-tool.js';
import {
isModifiableDeclarativeTool,
modifyWithEditor,
} from '../tools/modifiable-tool.js';
import * as Diff from 'diff';
import levenshtein from 'fast-levenshtein';
import { ShellToolInvocation } from '../tools/shell.js';
import { IdeClient } from '../ide/ide-client.js';
import {
getPlanRequiredTeammatePreApprovalMessage,
isPlanRequiredTeammateAwaitingApproval,
isPlanRequiredTeammatePreApprovalAllowedTool,
shouldUsePlanOnlyReminderInSubagentContext,
} from '../agents/runtime/subagent-plan-tool-policy.js';
import { safeSetStatus } from '../telemetry/tracer.js';
import { SpanStatusCode, type Span } from '@opentelemetry/api';
import {
startToolSpan,
endToolSpan,
runInToolSpanContext,
startToolExecutionSpan,
endToolExecutionSpan,
startToolBlockedOnUserSpan,
endToolBlockedOnUserSpan,
startHookSpan,
endHookSpan,
addToolInputAttributes,
addToolResultAttributes,
truncateSpanError,
type ToolBlockedDecision,
type ToolBlockedSource,
type StartHookSpanOptions,
type HookSpanMetadata,
} from '../telemetry/index.js';
import { safeJsonStringify } from '../utils/safeJsonStringify.js';
import { acquireSleepInhibitor } from '../services/sleepInhibitor.js';
const debugLogger = createDebugLogger('TOOL_SCHEDULER');
function dedupeRequestsByCallId(
requests: ToolCallRequestInfo[],
): ToolCallRequestInfo[] {
const seenCallIds = new Set<string>();
const deduped: ToolCallRequestInfo[] = [];
for (const request of requests) {
if (request.callId) {
if (seenCallIds.has(request.callId)) {
debugLogger.debug(
`dedupeRequestsByCallId: dropping duplicate callId=${request.callId} name=${request.name}`,
);
continue;
}
seenCallIds.add(request.callId);
}
deduped.push(request);
}
return deduped;
}
// Gap between the persistence gate and per-tool truncation thresholds.
// Tools that self-truncate to ~25K add headers bringing output to ~25.4K;
// the headroom ensures the gate only fires for genuinely un-truncated output
// and must exceed the stub size (~2.3K) to avoid cascading re-persistence.
const GATE_HEADROOM = 3000;
// Tools that bound their own output and must bypass the persistence gate.
// read_file pages/truncates itself; read_mcp_resource caps text in
// formatMcpResourceContents and sets maxOutputChars=Infinity — but this gate
// runs first, so without the exemption a 28k–100k resource is spilled to a
// stub before that self-cap takes effect and the model never sees the body.
const GATE_EXEMPT_TOOLS = new Set<string>([
ToolNames.READ_FILE,
ToolNames.READ_MCP_RESOURCE,
]);
function extractTextFromPartListUnion(c: PartListUnion): string {
if (typeof c === 'string') return c;
if (Array.isArray(c)) {
const parts = toParts(c);
return parts.map((p) => p.text ?? '').join('\n');
}
if (c && typeof c === 'object') {
if ('text' in c) {
const text = (c as { text?: string }).text;
if (typeof text === 'string') return text;
}
if ('functionResponse' in c) {
const fr = (
c as {
functionResponse?: { response?: Record<string, unknown> };
}
).functionResponse;
const resp = fr?.response;
if (resp) {
if (typeof resp['output'] === 'string') return resp['output'];
if (typeof resp['content'] === 'string') return resp['content'];
}
}
}
return '';
}
const TOOL_FAILURE_KIND_ATTRIBUTE = 'tool.failure_kind';
const TOOL_FAILURE_KIND_PRE_HOOK_BLOCKED = 'pre_hook_blocked';
const TOOL_FAILURE_KIND_POST_HOOK_STOPPED = 'post_hook_stopped';
const TOOL_FAILURE_KIND_TOOL_ERROR = 'tool_error';
const TOOL_FAILURE_KIND_TOOL_EXCEPTION = 'tool_exception';
const TOOL_FAILURE_KIND_CANCELLED = 'cancelled';
// Approval-flow failure kinds — distinct from `pre_hook_blocked` (which
// only applies to actual PreToolUse hook denials in `_executeToolCallBody`)
// so dashboards can attribute denies to their real cause (#4321 review).
const TOOL_FAILURE_KIND_PERMISSION_DENIED = 'permission_denied';
const TOOL_FAILURE_KIND_PERMISSION_HOOK_DENIED = 'permission_hook_denied';
const TOOL_FAILURE_KIND_PLAN_MODE_BLOCKED = 'plan_mode_blocked';
const TOOL_FAILURE_KIND_NON_INTERACTIVE_DENIED = 'non_interactive_denied';
const TOOL_FAILURE_KIND_BACKGROUND_AGENT_DENIED = 'background_agent_denied';
const TOOL_SPAN_STATUS_PRE_HOOK_BLOCKED = 'Tool execution blocked by hook';
const TOOL_SPAN_STATUS_POST_HOOK_STOPPED = 'Tool execution stopped by hook';
const TOOL_SPAN_STATUS_PERMISSION_DENIED = 'Permission denied for tool';
const TOOL_SPAN_STATUS_PERMISSION_HOOK_DENIED =
'Permission denied by permission_request hook';
const TOOL_SPAN_STATUS_PLAN_MODE_BLOCKED =
'Plan mode blocked a non-read-only tool call';
const TOOL_SPAN_STATUS_NON_INTERACTIVE_DENIED =
'Non-interactive mode declined permission';
const TOOL_SPAN_STATUS_BACKGROUND_AGENT_DENIED =
'Background agent cannot prompt for confirmation';
const TOOL_SPAN_STATUS_TOOL_ERROR = 'Tool execution failed';
const TOOL_SPAN_STATUS_TOOL_EXCEPTION = 'Tool execution failed with exception';
const TOOL_SPAN_STATUS_TOOL_CANCELLED = 'Tool execution cancelled by user';
// Timeout-specific observability constants — distinguish timeouts from
// generic tool errors in OTel traces.
const TOOL_FAILURE_KIND_TIMEOUT = 'timeout';
const TOOL_SPAN_STATUS_TOOL_TIMEOUT = 'Tool execution timed out';
/**
* Builds the failure ToolResult surfaced when a tool call exceeds the
* execution timeout. Reported as a normal tool error so the model can adapt
* (narrow scope, retry, etc.) instead of the session hanging.
*/
function createToolTimeoutResult(timeoutMs: number): ToolResult {
const display =
timeoutMs >= 1000 ? `${Math.round(timeoutMs / 1000)}s` : `${timeoutMs}ms`;
const message =
`Tool execution timed out after ${display}. ` +
`The tool may be stuck or operating on too large a scope.`;
return {
llmContent: message,
returnDisplay: message,
error: { message, type: ToolErrorType.EXECUTION_TIMEOUT },
};
}
const TRUNCATION_PARAM_GUIDANCE =
'Note: Your previous response was truncated due to max_tokens limit, ' +
'which caused incomplete tool call parameters. ' +
'Please retry the tool call with complete parameters. ' +
'If the content is too large for a single response, ' +
'you MUST split it into smaller parts: ' +
'first write_file with a skeleton/partial content, ' +
'then use edit to add the remaining sections incrementally.';
const TRUNCATION_EDIT_REJECTION =
'Your previous response was truncated due to max_tokens limit, ' +
'which produced incomplete file content. ' +
'The tool call has been rejected to prevent writing ' +
'truncated content to the file. ' +
'You MUST split the content into smaller parts: ' +
'first write_file with a skeleton/partial content, ' +
'then use edit to add the remaining sections incrementally. ' +
'Do NOT retry with the same large content.';
function setToolSpanFailure(
span: Span,
failureKind: string,
message: string,
): void {
try {
span.setAttribute(TOOL_FAILURE_KIND_ATTRIBUTE, failureKind);
// Always write `success: false` so trace backends can filter tool
// failures with the same query they use for llm_request spans —
// mirrors the unconditional `success` attribute on llm_request.
span.setAttribute('success', false);
} catch {
// OTel errors must not block the failure status update.
}
// Bound the status message size at this single ingress point so every
// setToolSpanFailure caller is protected — multiple call sites pass
// raw error.message which can be unbounded (#4321 review-5 wenshao
// Suggestion). Static-constant callers see no change since their
// messages are well under 1024 chars.
safeSetStatus(span, {
code: SpanStatusCode.ERROR,
message: truncateSpanError(message),
});
}
function setToolSpanCancelled(span: Span): void {
try {
span.setAttribute(TOOL_FAILURE_KIND_ATTRIBUTE, TOOL_FAILURE_KIND_CANCELLED);
span.setAttribute('success', false);
} catch {
// OTel errors must not block the cancellation status update.
}
safeSetStatus(span, {
code: SpanStatusCode.UNSET,
});
}
async function safelyFirePostToolUseFailureHook(
messageBus: MessageBus | undefined,
toolUseId: string,
toolName: string,
toolInput: Record<string, unknown>,
errorMessage: string,
isInterrupt: boolean,
permissionMode?: string,
tool_call_id?: string,
): ReturnType<typeof firePostToolUseFailureHook> {
try {
return await firePostToolUseFailureHook(
messageBus,
toolUseId,
toolName,
toolInput,
errorMessage,
isInterrupt,
permissionMode,
undefined,
tool_call_id,
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
debugLogger.warn(
`PostToolUseFailure hook failed for ${toolName}: ${message}`,
);
return { hookError: message };
}
}
export type ValidatingToolCall = {
status: 'validating';
request: ToolCallRequestInfo;
tool: AnyDeclarativeTool;
invocation: AnyToolInvocation;
startTime?: number;
outcome?: ToolConfirmationOutcome;
};
export type ScheduledToolCall = {
status: 'scheduled';
request: ToolCallRequestInfo;
tool: AnyDeclarativeTool;
invocation: AnyToolInvocation;
startTime?: number;
outcome?: ToolConfirmationOutcome;
};
export type ErroredToolCall = {
status: 'error';
request: ToolCallRequestInfo;
response: ToolCallResponseInfo;
tool?: AnyDeclarativeTool;
durationMs?: number;
outcome?: ToolConfirmationOutcome;
};
export type SuccessfulToolCall = {
status: 'success';
request: ToolCallRequestInfo;
tool: AnyDeclarativeTool;
response: ToolCallResponseInfo;
invocation: AnyToolInvocation;
durationMs?: number;
outcome?: ToolConfirmationOutcome;
};
export type ExecutingToolCall = {
status: 'executing';
request: ToolCallRequestInfo;
tool: AnyDeclarativeTool;
invocation: AnyToolInvocation;
liveOutput?: ToolResultDisplay;
/** Timestamp when the tool was first scheduled (validating). */
startTime?: number;
/**
* Timestamp when the tool actually began executing (after any
* approval/scheduling wait). Use this for "how long has this been
* running" displays; prefer it over startTime to exclude approval time.
*/
executionStartTime?: number;
outcome?: ToolConfirmationOutcome;
pid?: number;
/**
* Set during a foreground shell-tool invocation: the AbortController
* the user/UI can fire (with `signal.reason = { kind: 'background' }`)
* to promote the running command to a background entry. Set right
* after `setPidCallback` fires (see ShellTool.execute), cleared
* implicitly when the tool transitions to a terminal status. Only
* meaningful for the shell tool's foreground path; absent on every
* other tool kind.
*/
promoteAbortController?: AbortController;
};
export type CancelledToolCall = {
status: 'cancelled';
request: ToolCallRequestInfo;
response: ToolCallResponseInfo;
tool: AnyDeclarativeTool;
invocation: AnyToolInvocation;
durationMs?: number;
outcome?: ToolConfirmationOutcome;
};
export type WaitingToolCall = {
status: 'awaiting_approval';
request: ToolCallRequestInfo;
tool: AnyDeclarativeTool;
invocation: AnyToolInvocation;
confirmationDetails: ToolCallConfirmationDetails;
startTime?: number;
outcome?: ToolConfirmationOutcome;
};
export type Status = ToolCall['status'];
export type ToolCall =
| ValidatingToolCall
| ScheduledToolCall
| ErroredToolCall
| SuccessfulToolCall
| ExecutingToolCall
| CancelledToolCall
| WaitingToolCall;
export type CompletedToolCall =
| SuccessfulToolCall
| CancelledToolCall
| ErroredToolCall;
/**
* Closed allowlist of tool names whose inputs name actual filesystem
* paths under the project root. Restricting `extractToolFilePaths` to
* this set prevents MCP tools (where `Record<string, unknown>` input
* conventions reuse `path` / `paths` for HTTP routes, JSON keys, search
* queries, etc.) from feeding non-filesystem strings into
* ConditionalRulesRegistry / SkillActivationRegistry — which would
* resolve them under projectRoot, normalize, and false-match against
* skill globs (e.g. `paths: ['**']` would activate on every MCP call).
*
* Custom FS tools added later need to opt in here. A future enhancement
* could replace this with a per-tool `pathFields?: string[]` annotation
* on tool declarations; the allowlist is the minimum-surface fix.
*/
const FS_PATH_TOOL_NAMES: ReadonlySet<string> = new Set<string>([
ToolNames.READ_FILE,
ToolNames.EDIT,
ToolNames.WRITE_FILE,
ToolNames.GREP,
ToolNames.ZVEC_GREP,
ToolNames.GLOB,
ToolNames.LS,
ToolNames.LSP,
ToolNames.NOTEBOOK_EDIT,
]);
function canonicalToolName(toolName: string): string {
return (ToolNamesMigration as Record<string, string>)[toolName] ?? toolName;
}
function isFilesystemPathTool(toolName: string): boolean {
return FS_PATH_TOOL_NAMES.has(canonicalToolName(toolName));
}
/**
* Trim trailing forward / back slashes from a path-shaped string without
* a regex. The regex form `s.replace(/[\\/]+$/, '')` is functionally
* equivalent but CodeQL #145 flags `+` on uncontrolled input as a
* polynomial ReDoS candidate; the loop is O(n) on the trailing
* separator run, no different from the regex engine, but quieter.
*/
function trimTrailingSlash(s: string): string {
let trimmed = s;
while (trimmed.endsWith('/') || trimmed.endsWith('\\')) {
trimmed = trimmed.slice(0, -1);
}
return trimmed;
}
/**
* Combine a search-root path and a path-shaped glob into the effective
* selector that the tool actually walks. Used by GLOB (`path` + `pattern`)
* and GREP (`path` + `glob`). Plain string concat (rather than
* `path.join`) so we don't (1) emit OS-specific backslashes on Windows
* and silently diverge from the forward-slash form the activation
* registry matches against, or (2) collapse `..` segments and lose
* information about which directory the call escaped from.
*/
function joinSearchRootAndGlob(
searchRoot: string | undefined,
globField: string,
): string {
if (!searchRoot || searchRoot.length === 0) return globField;
return `${trimTrailingSlash(searchRoot)}/${globField}`;
}
/**
* For LSP-shaped inputs, normalize `filePath`-style strings into project
* candidates. Accepts a plain absolute/relative path or a `file://` URI;
* silently drops other URI schemes (`http://`, `git://`, etc.) so an
* LSP call against a non-file resource cannot reach the activation
* registry as if it had touched a project file.
*/
function pushLspPathCandidate(out: string[], v: unknown): void {
if (typeof v !== 'string' || v.length === 0) return;
if (v.startsWith('file://')) {
try {
out.push(fileURLToPath(v));
} catch {
// Malformed file URI — drop silently rather than corrupt the
// activation pipeline.
}
return;
}
if (v.includes('://')) return; // non-file URI scheme: ignore
out.push(v);
}
/**
* Pull the filesystem path-bearing fields out of a tool's input.
* Per-tool dispatcher because the field name and shape differ:
*
* - read_file / edit / write_file → `file_path`
* - notebook_edit → `notebook_path`
* - list_directory → `path` (search root)
* - glob → `path` (search root, optional) + `pattern` (path-shaped
* selector); `<path>/<pattern>` is the effective glob walked
* - grep_search → `path` (search root, optional) + `glob` (path-shaped
* file filter); `pattern` is a regex on contents, NOT a path
* - lsp → `filePath` (URI-aware: `file://` accepted, others dropped)
* plus `callHierarchyItem.uri` for incomingCalls / outgoingCalls
*
* Used by ConditionalRulesRegistry / SkillActivationRegistry hooks to
* route every project-relative path the tool actually touched through
* the same activation pipeline. Returns `[]` for tool names outside
* `FS_PATH_TOOL_NAMES` — see that set's docstring for why this is gated.
*/
export function extractToolFilePaths(
toolName: string,
toolInput: unknown,
): string[] {
// Canonicalize legacy aliases (e.g. `replace` → `edit`,
// `search_file_content` → `grep_search`) before the allowlist check.
// The tool registry resolves these at execution time, so a tool call
// like `replace({ file_path: 'src/App.tsx' })` actually runs EditTool;
// gating only on the canonical name closes the alias-bypass hole.
const canonical = canonicalToolName(toolName);
if (!FS_PATH_TOOL_NAMES.has(canonical)) {
// Surface allowlist gaps at debug level when a non-FS tool's input
// *looks* path-shaped: we silently skip path activation for it, but
// the field naming suggests it might be a real FS tool that just
// hasn't been added to FS_PATH_TOOL_NAMES yet (or an MCP tool whose
// input convention legitimately reuses these field names — both are
// worth the debug breadcrumb when chasing "why didn't my path-gated
// skill activate?"). Cheap object-property reads, only fires when
// the user has DEBUG=tool-scheduler enabled, no production noise.
if (toolInput && typeof toolInput === 'object') {
const obj = toolInput as Record<string, unknown>;
if (
typeof obj['file_path'] === 'string' ||
typeof obj['filePath'] === 'string' ||
typeof obj['path'] === 'string' ||
Array.isArray(obj['paths'])
) {
debugLogger.debug(
`Tool "${toolName}" (canonical "${canonical}") has path-like input fields ` +
`but is not in FS_PATH_TOOL_NAMES — path-gated skills / conditional rules ` +
`will not see its paths. If this is a filesystem tool, add it to the allowlist.`,
);
}
}
return [];
}
if (!toolInput || typeof toolInput !== 'object') return [];
const obj = toolInput as Record<string, unknown>;
const out: string[] = [];
const push = (v: unknown): void => {
if (typeof v === 'string' && v.length > 0) out.push(v);
};
switch (canonical) {
case ToolNames.LSP: {
// `filePath` may be a plain path, a `file://` URI, or a non-file
// URI (`http://`, `git://`, etc.). Only the first two correspond
// to project files — everything else must be ignored, otherwise
// an LSP call on a non-file resource could activate path-gated
// skills without the model having touched the project.
pushLspPathCandidate(out, obj['filePath']);
// incomingCalls / outgoingCalls operate on `callHierarchyItem.uri`,
// not the top-level `filePath`. Without this, the model can follow
// a call hierarchy through a project file and never trigger
// activation for a skill scoped to that file.
const item = obj['callHierarchyItem'];
if (item && typeof item === 'object') {
pushLspPathCandidate(out, (item as Record<string, unknown>)['uri']);
}
return out;
}
case ToolNames.GLOB: {
const pathField = obj['path'];
const patternField = obj['pattern'];
// The standalone search-root candidate (so a broad skill keyed on
// `paths: ['src/**']` still activates from `glob({ path: 'src' })`).
push(pathField);
// `pattern` is the actual selector. Combine with `path` to form
// the effective walked glob.
if (typeof patternField === 'string' && patternField.length > 0) {
push(
joinSearchRootAndGlob(
typeof pathField === 'string' ? pathField : undefined,
patternField,
),
);
}
return out;
}
case ToolNames.GREP: {
const pathField = obj['path'];
const globField = obj['glob'];
push(pathField);
// `glob` is the path-shaped file filter (NOT `pattern`, which is a
// regex on contents). Combine with `path` for the effective
// filter selector.
if (typeof globField === 'string' && globField.length > 0) {
push(
joinSearchRootAndGlob(
typeof pathField === 'string' ? pathField : undefined,
globField,
),
);
}
return out;
}
case ToolNames.ZVEC_GREP: {
const pathField = obj['path'];
const pathsField = obj['paths'];
const globField = obj['glob'];
push(pathField);
if (Array.isArray(pathsField)) {
for (const item of pathsField) {
push(item);
}
}
if (typeof globField === 'string' && globField.length > 0) {
push(
joinSearchRootAndGlob(
typeof pathField === 'string' ? pathField : undefined,
globField,
),
);
}
return out;
}
case ToolNames.LS:
push(obj['path']);
return out;
case ToolNames.READ_FILE:
case ToolNames.EDIT:
case ToolNames.WRITE_FILE:
push(obj['file_path']);
return out;
case ToolNames.NOTEBOOK_EDIT:
push(obj['notebook_path']);
return out;
default:
push(obj['file_path']);
return out;
}
}
export type ConfirmHandler = (
toolCall: WaitingToolCall,
) => Promise<ToolConfirmationOutcome>;
export type OutputUpdateHandler = (
toolCallId: string,
outputChunk: ToolResultDisplay,
) => void;
export type AllToolCallsCompleteHandler = (
completedToolCalls: CompletedToolCall[],
) => Promise<void>;
export type ToolCallsUpdateHandler = (toolCalls: ToolCall[]) => void;
/**
* Formats tool output for a Gemini FunctionResponse.
*/
function createFunctionResponsePart(
callId: string,
toolName: string,
output: string,
mediaParts?: FunctionResponsePart[],
): Part {
const functionResponse: FunctionResponse = {
id: callId,
name: toolName,
response: { output },
...(mediaParts && mediaParts.length > 0 ? { parts: mediaParts } : {}),
};
return {
functionResponse,
};
}
export function convertToFunctionResponse(
toolName: string,
callId: string,
llmContent: PartListUnion,
): Part[] {
const contentToProcess =
Array.isArray(llmContent) && llmContent.length === 1
? llmContent[0]
: llmContent;
if (typeof contentToProcess === 'string') {
return [createFunctionResponsePart(callId, toolName, contentToProcess)];
}
if (Array.isArray(contentToProcess)) {
// Extract text and media from all parts so that EVERYTHING is inside
// the FunctionResponse.
const textParts: string[] = [];
const mediaParts: FunctionResponsePart[] = [];
for (const part of toParts(contentToProcess)) {
if (part.text !== undefined) {
textParts.push(part.text);
} else if (part.inlineData) {
mediaParts.push({ inlineData: part.inlineData });
} else if (part.fileData) {
mediaParts.push({ fileData: part.fileData });
}
// Other exotic part types (e.g. functionCall) are intentionally
// dropped here – they should not appear inside tool results.
}
const output =
textParts.length > 0 ? textParts.join('\n') : TOOL_SUCCEEDED_OUTPUT;
return [createFunctionResponsePart(callId, toolName, output, mediaParts)];
}
// After this point, contentToProcess is a single Part object.
if (contentToProcess.functionResponse) {
if (contentToProcess.functionResponse.response?.['content']) {
const stringifiedOutput =
getResponseTextFromParts(
contentToProcess.functionResponse.response['content'] as Part[],
) || '';
return [createFunctionResponsePart(callId, toolName, stringifiedOutput)];
}
// It's a functionResponse that we should pass through as is.
return [contentToProcess];
}
if (contentToProcess.inlineData || contentToProcess.fileData) {
const mediaParts: FunctionResponsePart[] = [];
if (contentToProcess.inlineData) {
mediaParts.push({ inlineData: contentToProcess.inlineData });
}
if (contentToProcess.fileData) {
mediaParts.push({ fileData: contentToProcess.fileData });
}
const functionResponse = createFunctionResponsePart(
callId,
toolName,
'',
mediaParts,
);
return [functionResponse];
}
if (contentToProcess.text !== undefined) {
return [
createFunctionResponsePart(callId, toolName, contentToProcess.text),
];
}
// Default case for other kinds of parts.
return [createFunctionResponsePart(callId, toolName, TOOL_SUCCEEDED_OUTPUT)];
}
function toParts(input: PartListUnion): Part[] {
const parts: Part[] = [];
for (const part of Array.isArray(input) ? input : [input]) {
if (typeof part === 'string') {
parts.push({ text: part });
} else if (part) {
parts.push(part);
}
}
return parts;
}
/**
* Per-message offload: when a batch of tool results collectively exceeds the
* budget, the largest results are spilled to disk and replaced with a small
* preview + recoverable pointer. This is the preview size used for that spill.
*/
const BATCH_OFFLOAD_PREVIEW_CHARS = 2000;
/** Total model-facing string output across a completed call's responseParts. */
function batchResponseOutputSize(call: CompletedToolCall): number {
if (call.status !== 'success') return 0;
let size = 0;
for (const part of call.response.responseParts) {
const output = part.functionResponse?.response?.['output'];
if (typeof output === 'string') size += output.length;
}
return size;
}
const VALIDATION_RETRY_LOOP_THRESHOLD = 3;
// NOTE: the `⚠` in this and TRUNCATION_RETRY_LOOP_DIRECTIVE below is part of an
// LLM-facing prompt directive (injected into the model prompt, not rendered in
// the TUI). The width-1 glyph rationale used elsewhere in this change does not
// apply here — these are not terminal strings to "fix" for column width.
/** Directive injected when a tool call repeatedly fails validation. */
const RETRY_LOOP_STOP_DIRECTIVE =
'\n\n⚠ RETRY LOOP DETECTED: This tool call has failed validation multiple times with the same error. ' +
'STOP retrying the same approach. Re-examine the tool schema and parameter requirements, then try a ' +
'fundamentally different approach. If you cannot resolve the validation error, explain the issue to the user ' +
'instead of retrying.';
/** Directive injected when a truncated file-modifying call repeats. */
const TRUNCATION_RETRY_LOOP_DIRECTIVE =
'\n\n⚠ RETRY LOOP DETECTED: The same truncated file write has been rejected multiple times. ' +
'STOP resending the same large content. Either split it into smaller write_file + incremental edit calls, ' +
'or explain to the user that the content is too large to write safely in one call.';
const createErrorResponse = (
request: ToolCallRequestInfo,
error: Error,
errorType: ToolErrorType | undefined,
artifacts?: ToolArtifact[],
): ToolCallResponseInfo => ({
callId: request.callId,
error,
responseParts: [
{
functionResponse: {
id: request.callId,
name: request.name,
response: { error: error.message },
},
},
],
resultDisplay: error.message,
errorType,
contentLength: error.message.length,
...(artifacts && artifacts.length > 0 ? { artifacts } : {}),
});
const createCancelledResponse = (
request: ToolCallRequestInfo,
reason: string,
artifacts?: ToolArtifact[],
): ToolCallResponseInfo => {
const errorMessage = `[Operation Cancelled] Reason: ${reason}`;
return {
callId: request.callId,
responseParts: [
{
functionResponse: {
id: request.callId,
name: request.name,
response: { error: errorMessage },
},
},
],
resultDisplay: undefined,
error: undefined,
errorType: undefined,
contentLength: errorMessage.length,
...(artifacts && artifacts.length > 0 ? { artifacts } : {}),
};
};
function isToolCallResponseInfo(value: unknown): value is ToolCallResponseInfo {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Partial<ToolCallResponseInfo>;
return (
typeof candidate.callId === 'string' &&
Array.isArray(candidate.responseParts)
);
}
function serializeToolResponse(
response: ToolCallResponseInfo,
): Record<string, unknown> {
// Keep this payload aligned with the persisted ToolCallResponseInfo fields
// hook authors need for batch-level auditing.
return {
response_parts: response.responseParts.map(summarizeBatchResponsePart),
result_display: response.resultDisplay,
error: response.error?.message,
error_type: response.errorType,
content_length: response.contentLength,
};
}
function summarizeBatchResponsePart(part: Part): Part {
const summarized = part.inlineData
? {
...part,
inlineData: {
mimeType: part.inlineData.mimeType,
data: '<binary omitted>',
},
}
: part;
if (!summarized.functionResponse?.parts) {
return summarized;
}
return {
...summarized,
functionResponse: {
...summarized.functionResponse,
parts: summarized.functionResponse.parts.map(summarizeBatchResponsePart),
},
};
}
function toPostToolBatchToolCall(
call: CompletedToolCall,
): PostToolBatchToolCall {
return {
tool_name: call.request.name,
tool_input: call.request.args,
tool_use_id: call.request.callId,
tool_call_id: call.request.callId,
// Note: tool_use_id here is also populated from call.request.callId, so
// tool_call_id duplicates the same value under a different name. The
// semantics of tool_use_id are inconsistent across hook events (synthetic
// in Pre/Post/Failure, API ID in PostToolBatch).
status: call.status,
tool_response: serializeToolResponse(call.response),
};
}
function appendContextToResponsePart(
part: Part,
additionalContext: string,
): Part {
if (!part.functionResponse) {
debugLogger.warn(
'appendContextToResponsePart: no functionResponse on part, additionalContext dropped',
);
return part;
}
const response = part.functionResponse.response ?? {};
const output = response['output'];
const error = response['error'];
const hasOutput = Object.prototype.hasOwnProperty.call(response, 'output');
const useOutputKey =
typeof output === 'string' || (hasOutput && typeof error !== 'string');
const key = useOutputKey ? 'output' : 'error';
const currentText = useOutputKey
? typeof output === 'string'
? output
: JSON.stringify(output)
: typeof error === 'string'
? error
: JSON.stringify(response);