mixdog 0.9.93 → 0.9.94
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/package.json +1 -1
- package/src/rules/agent/30-explorer.md +22 -16
- package/src/rules/shared/01-tool.md +23 -18
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +22 -6
- package/src/runtime/agent/orchestrator/context/collect.mjs +6 -2
- package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
- package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +100 -86
- package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +4 -1
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
- package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
- package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
- package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +543 -19
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +5 -2
- package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +4 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +51 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +3 -3
- package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
- package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
- package/src/runtime/shared/background-tasks.mjs +10 -3
- package/src/runtime/shared/child-spawn-gate.mjs +50 -26
- package/src/runtime/shared/task-notification-envelope.mjs +11 -2
- package/src/runtime/shared/tool-card-model.mjs +6 -2
- package/src/runtime/shared/tool-surface.mjs +7 -2
- package/src/session-runtime/provider-usage.mjs +26 -2
- package/src/standalone/explore-tool.mjs +1 -1
- package/src/tui/app/use-transcript-window.mjs +7 -1
- package/src/tui/components/Spinner.jsx +18 -9
- package/src/tui/dist/index.mjs +111 -25
- package/src/tui/engine/live-share.mjs +23 -3
- package/src/tui/engine/session-api.mjs +7 -0
- package/src/tui/engine/turn.mjs +77 -4
- package/src/tui/engine.mjs +14 -6
- package/src/tui/index.jsx +7 -0
|
@@ -14,7 +14,7 @@ import { markSessionToolCall, updateSessionStage } from './manager.mjs';
|
|
|
14
14
|
import { resolveToolSelfDeadlineMs } from '../agent-runtime/agent-progress-watchdog.mjs';
|
|
15
15
|
import { classifyResultKind } from './result-classification.mjs';
|
|
16
16
|
import { normalizeToolEnvelope } from './tool-envelope.mjs';
|
|
17
|
-
import {
|
|
17
|
+
import { maybeOffloadToolResultBatch } from './tool-result-offload.mjs';
|
|
18
18
|
import {
|
|
19
19
|
tryReadCached, setReadCached, invalidatePathForSession, markPostEdit,
|
|
20
20
|
consumePostEditMark, clearReadDedupSession, extractTouchedPathsFromPatch,
|
|
@@ -93,6 +93,16 @@ export async function processToolBatch(ctx) {
|
|
|
93
93
|
// between two tool results of the same multi-tool turn (which would put a
|
|
94
94
|
// user message between tool(A) and tool(B) and break provider pairing).
|
|
95
95
|
const _batchNewMessages = [];
|
|
96
|
+
// Finalize together after execution so the message-level output budget
|
|
97
|
+
// can select the largest non-Read results before cache/history/UI see
|
|
98
|
+
// any body.
|
|
99
|
+
const _batchCompleted = [];
|
|
100
|
+
const _batchToolResultByCallId = new Map();
|
|
101
|
+
const _batchToolResultsWithoutId = [];
|
|
102
|
+
const _stageToolResultMessage = (message) => {
|
|
103
|
+
if (message?.toolCallId) _batchToolResultByCallId.set(message.toolCallId, message);
|
|
104
|
+
else _batchToolResultsWithoutId.push(message);
|
|
105
|
+
};
|
|
96
106
|
// Ordered-mutation batch gate. Each apply_patch is an eager-dispatch
|
|
97
107
|
// barrier: side effects overlap within the current segment, but no
|
|
98
108
|
// later segment starts before the loop crosses the patch in model
|
|
@@ -110,7 +120,7 @@ export async function processToolBatch(ctx) {
|
|
|
110
120
|
if (_duplicateCallIds.has(call.id)) {
|
|
111
121
|
const _firstId = _dupFirstId.get(call.id);
|
|
112
122
|
const _stub = `[intra-turn-dedup] identical read-only \`${call.name}\` call was already executed in this same assistant turn as tool_use_id=${_firstId}. The first call's tool_result is in context immediately above; skipping re-execution to save tokens. If you needed a different slice of the file, narrow the next call (different path / offset / limit / pattern) so it has a distinct signature.`;
|
|
113
|
-
|
|
123
|
+
_stageToolResultMessage({
|
|
114
124
|
role: 'tool',
|
|
115
125
|
content: _stub,
|
|
116
126
|
toolCallId: call.id,
|
|
@@ -136,7 +146,7 @@ export async function processToolBatch(ctx) {
|
|
|
136
146
|
_prior.count += 1;
|
|
137
147
|
dedupStubTotal += 1;
|
|
138
148
|
const _stub = crossTurnDedupStub(call.name, _prior.firstIteration, dedupStubTotal >= 5);
|
|
139
|
-
|
|
149
|
+
_stageToolResultMessage({
|
|
140
150
|
role: 'tool',
|
|
141
151
|
content: _stub,
|
|
142
152
|
toolCallId: call.id,
|
|
@@ -175,7 +185,7 @@ export async function processToolBatch(ctx) {
|
|
|
175
185
|
{
|
|
176
186
|
const _rfg = sessionRef?._repeatFailGuard;
|
|
177
187
|
if (_rfg && _rfg.sig === _repeatFailSig && _rfg.count >= repeatFailLimit) {
|
|
178
|
-
|
|
188
|
+
_stageToolResultMessage({
|
|
179
189
|
role: 'tool',
|
|
180
190
|
content: `[repeat-failure-guard] This exact \`${call.name}\` call (identical arguments) has already failed ${_rfg.count} times in a row; not re-executing because the result will not change. Change approach: use different arguments, a different tool, or skip this step.`,
|
|
181
191
|
toolCallId: call.id,
|
|
@@ -200,7 +210,7 @@ export async function processToolBatch(ctx) {
|
|
|
200
210
|
// structural failure that drives retry/stop-hook behavior.
|
|
201
211
|
if (_orderedMutationFailed && _isOrderedGateSkippable(call.name) && !pending.has(call.id)) {
|
|
202
212
|
if (call?.id) restoreToolCallBodyForId(assistantTurnMsg, calls, call.id);
|
|
203
|
-
|
|
213
|
+
_stageToolResultMessage({
|
|
204
214
|
role: 'tool',
|
|
205
215
|
content: `[prerequisite-failed] an earlier apply_patch in this same tool batch (call index ${_orderedMutationFailed.index + 1}) failed, so this later \`${call.name}\` was skipped without starting. Re-issue it after resolving the patch failure.`,
|
|
206
216
|
toolCallId: call.id,
|
|
@@ -516,37 +526,62 @@ export async function processToolBatch(ctx) {
|
|
|
516
526
|
if (sessionId && _isShellTool(call.name)) {
|
|
517
527
|
clearScopedToolsForSession(sessionId);
|
|
518
528
|
}
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
let _nativeToolSearch = null;
|
|
529
|
+
_batchCompleted.push({
|
|
530
|
+
call,
|
|
531
|
+
result,
|
|
532
|
+
toolStartedAt,
|
|
533
|
+
toolEndedAt,
|
|
534
|
+
toolKind,
|
|
535
|
+
resultKind: _resultKind,
|
|
536
|
+
executeOk: _executeOk,
|
|
537
|
+
readCacheHit: _readCacheHit,
|
|
538
|
+
scopedCacheHit: _scopedCacheHit,
|
|
539
|
+
crossTurnSig: _ctSig,
|
|
540
|
+
mutationEpoch: epoch.mutation,
|
|
541
|
+
nativeToolSearch: null,
|
|
542
|
+
postError: null,
|
|
543
|
+
});
|
|
544
|
+
// Soft-cancel after each tool: if close landed during execution,
|
|
545
|
+
// discard the rest of the batch and skip the next provider.send.
|
|
546
|
+
throwIfAborted();
|
|
547
|
+
}
|
|
548
|
+
for (const completed of _batchCompleted) {
|
|
540
549
|
try {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
+
completed.nativeToolSearch = parseNativeToolSearchPayload(completed.call.name, completed.result);
|
|
551
|
+
if (completed.nativeToolSearch?.summary) completed.result = completed.nativeToolSearch.summary;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
completed.postError = error;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
let _offloadStates;
|
|
557
|
+
try {
|
|
558
|
+
_offloadStates = await maybeOffloadToolResultBatch(
|
|
559
|
+
sessionId,
|
|
560
|
+
_batchCompleted.map((completed) => ({
|
|
561
|
+
toolCallId: completed.call.id,
|
|
562
|
+
toolName: _stripMcpPrefix(completed.call.name),
|
|
563
|
+
result: completed.postError ? null : completed.result,
|
|
564
|
+
})),
|
|
565
|
+
);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
_offloadStates = _batchCompleted.map(() => ({ result: null, error }));
|
|
568
|
+
}
|
|
569
|
+
// Assistant-message pipeline: offload → compress → trace → cache →
|
|
570
|
+
// push. The cache and transcript therefore receive the same body.
|
|
571
|
+
for (let completedIndex = 0; completedIndex < _batchCompleted.length; completedIndex += 1) {
|
|
572
|
+
const completed = _batchCompleted[completedIndex];
|
|
573
|
+
const {
|
|
574
|
+
call, toolStartedAt, toolEndedAt, toolKind,
|
|
575
|
+
executeOk: _executeOk, resultKind: _resultKind,
|
|
576
|
+
readCacheHit: _readCacheHit, scopedCacheHit: _scopedCacheHit,
|
|
577
|
+
} = completed;
|
|
578
|
+
let _ctSig = completed.crossTurnSig;
|
|
579
|
+
let result = completed.result;
|
|
580
|
+
const _nativeToolSearch = completed.nativeToolSearch;
|
|
581
|
+
try {
|
|
582
|
+
if (completed.postError) throw completed.postError;
|
|
583
|
+
if (_offloadStates[completedIndex]?.error) throw _offloadStates[completedIndex].error;
|
|
584
|
+
result = _offloadStates[completedIndex]?.result;
|
|
550
585
|
result = compressToolResult(call.name, call.arguments, result, { sessionId, toolKind });
|
|
551
586
|
traceAgentTool({
|
|
552
587
|
sessionId,
|
|
@@ -561,44 +596,37 @@ export async function processToolBatch(ctx) {
|
|
|
561
596
|
resultText: result,
|
|
562
597
|
cwd,
|
|
563
598
|
});
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// tool_use will show the offload stub; LLM can still recover the full
|
|
570
|
-
// body via the disk path in that stub.
|
|
571
|
-
if (sessionId && _executeOk && _resultKind === 'normal') {
|
|
599
|
+
// Deferred writes that predate a later mutation are skipped;
|
|
600
|
+
// immediate writes used to be invalidated by that mutation.
|
|
601
|
+
const _outcomeMap = sessionRef?._scopedCacheOutcomeByCallId instanceof Map
|
|
602
|
+
? sessionRef._scopedCacheOutcomeByCallId : null;
|
|
603
|
+
if (sessionId && _executeOk && _resultKind === 'normal' && completed.mutationEpoch === epoch.mutation) {
|
|
572
604
|
if (_scopedCacheHit === null && _isScopedCacheableTool(call.name)) {
|
|
573
|
-
const _outcomeMap = sessionRef?._scopedCacheOutcomeByCallId instanceof Map
|
|
574
|
-
? sessionRef._scopedCacheOutcomeByCallId : null;
|
|
575
605
|
const _outcome = _outcomeMap?.get(call.id);
|
|
576
606
|
setScopedToolCached({
|
|
577
607
|
sessionId,
|
|
578
|
-
toolName:
|
|
608
|
+
toolName: _stripMcpPrefix(call.name),
|
|
579
609
|
args: call.arguments,
|
|
580
610
|
cwd,
|
|
581
611
|
content: result,
|
|
582
612
|
toolUseId: call.id,
|
|
583
613
|
complete: _outcome ? _outcome.complete : true,
|
|
584
614
|
});
|
|
585
|
-
_outcomeMap?.delete(call.id);
|
|
586
615
|
}
|
|
587
616
|
if (_readCacheHit === null && _isReadTool(call.name)) {
|
|
588
|
-
// Pass tool_use id so future cache-hits can reference the body's location in history.
|
|
589
617
|
setReadCached({ sessionId, args: call.arguments, cwd, content: result, toolUseId: call.id });
|
|
590
618
|
}
|
|
591
619
|
}
|
|
592
|
-
//
|
|
593
|
-
//
|
|
594
|
-
//
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
620
|
+
// A successful scoped lookup from before a later mutation is
|
|
621
|
+
// intentionally not cached, but its per-call completeness
|
|
622
|
+
// record still has to be reclaimed.
|
|
623
|
+
if (_scopedCacheHit === null && _isScopedCacheableTool(call.name)) {
|
|
624
|
+
_outcomeMap?.delete(call.id);
|
|
625
|
+
}
|
|
598
626
|
const _applyPatchUiDiff = _stripMcpPrefix(call.name) === 'apply_patch'
|
|
599
627
|
? takeApplyPatchUiDiff(call.id)
|
|
600
628
|
: null;
|
|
601
|
-
|
|
629
|
+
_stageToolResultMessage({
|
|
602
630
|
role: 'tool',
|
|
603
631
|
content: result,
|
|
604
632
|
toolCallId: call.id,
|
|
@@ -606,10 +634,6 @@ export async function processToolBatch(ctx) {
|
|
|
606
634
|
...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
|
|
607
635
|
...(_applyPatchUiDiff ? { uiDiff: _applyPatchUiDiff } : {}),
|
|
608
636
|
});
|
|
609
|
-
// Completion-first bookkeeping (Steps 1 & 2). Only successful
|
|
610
|
-
// executions count. Edit/progress = any executed tool whose def
|
|
611
|
-
// lacks readOnlyHint (apply_patch/bash/MCP-write/skill/...).
|
|
612
|
-
// Read-only successful calls seed the cross-turn dedup map.
|
|
613
637
|
if (_executeOk) {
|
|
614
638
|
const _isEager = isEagerDispatchable(call.name, tools);
|
|
615
639
|
if (isToolCallDedupEligible(call.name, tools)) {
|
|
@@ -621,30 +645,19 @@ export async function processToolBatch(ctx) {
|
|
|
621
645
|
crossTurnCalls.delete(_oldest);
|
|
622
646
|
}
|
|
623
647
|
}
|
|
624
|
-
} else if (!_isEager) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
// now return different content, so a post-edit
|
|
628
|
-
// verification read must NOT be stubbed as "unchanged".
|
|
629
|
-
if (isEditProgressTool(call.name, false)) {
|
|
630
|
-
crossTurnCalls.clear();
|
|
631
|
-
editCount += 1;
|
|
632
|
-
}
|
|
648
|
+
} else if (!_isEager && isEditProgressTool(call.name, false)) {
|
|
649
|
+
crossTurnCalls.clear();
|
|
650
|
+
editCount += 1;
|
|
633
651
|
}
|
|
634
652
|
}
|
|
635
653
|
} catch (postErr) {
|
|
636
|
-
// Reviewer fix: the exec itself succeeded — if it was a
|
|
637
|
-
// mutating edit-progress tool, the file changes are real even
|
|
638
|
-
// though post-processing failed, so the cross-turn dedup map
|
|
639
|
-
// must still be invalidated (otherwise a later verification
|
|
640
|
-
// read could be stubbed as "unchanged" against stale sigs).
|
|
641
654
|
if (_executeOk && !isEagerDispatchable(call.name, tools) && isEditProgressTool(call.name, false)) {
|
|
642
655
|
crossTurnCalls.clear();
|
|
643
656
|
editCount += 1;
|
|
644
657
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
658
|
+
if (sessionRef?._scopedCacheOutcomeByCallId instanceof Map && call?.id) {
|
|
659
|
+
sessionRef._scopedCacheOutcomeByCallId.delete(call.id);
|
|
660
|
+
}
|
|
648
661
|
if (call?.id) restoreToolCallBodyForId(assistantTurnMsg, calls, call.id);
|
|
649
662
|
const _postMsg = `Error: tool result post-processing failed for "${call.name}": ${postErr instanceof Error ? postErr.message : String(postErr)}`;
|
|
650
663
|
traceAgentToolFailure({
|
|
@@ -660,24 +673,25 @@ export async function processToolBatch(ctx) {
|
|
|
660
673
|
resultText: _postMsg,
|
|
661
674
|
resultKind: 'error',
|
|
662
675
|
});
|
|
663
|
-
|
|
664
|
-
// tool_use isn't orphaned. Cache writes are placed at the
|
|
665
|
-
// end of the try block (immediately before messages.push),
|
|
666
|
-
// so ANY throw in post-processing reaches this catch before
|
|
667
|
-
// the cache is written — stale/partial results are never
|
|
668
|
-
// cached. The next read on the same path/scope re-executes
|
|
669
|
-
// naturally.
|
|
670
|
-
pushToolResultMessage({
|
|
676
|
+
_stageToolResultMessage({
|
|
671
677
|
role: 'tool',
|
|
672
678
|
content: _postMsg,
|
|
673
679
|
toolCallId: call.id,
|
|
674
680
|
toolKind: 'error',
|
|
675
681
|
});
|
|
676
682
|
}
|
|
677
|
-
// Soft-cancel after each tool: if close landed during execution,
|
|
678
|
-
// discard the rest of the batch and skip the next provider.send.
|
|
679
683
|
throwIfAborted();
|
|
680
684
|
}
|
|
685
|
+
// Early dedup/guard skips and deferred execution results share this
|
|
686
|
+
// ordered flush so provider tool_result blocks always follow the
|
|
687
|
+
// assistant's original tool_use order.
|
|
688
|
+
for (const call of calls) {
|
|
689
|
+
const message = _batchToolResultByCallId.get(call?.id);
|
|
690
|
+
if (message) pushToolResultMessage(message);
|
|
691
|
+
}
|
|
692
|
+
for (const message of _batchToolResultsWithoutId) {
|
|
693
|
+
pushToolResultMessage(message);
|
|
694
|
+
}
|
|
681
695
|
// Flush the per-batch newMessages channel. All tool_results for this
|
|
682
696
|
// assistant turn are now pushed; appending the injected role:'user'
|
|
683
697
|
// messages here (AFTER the last tool_result, BEFORE the next provider
|
|
@@ -10,6 +10,7 @@ const TOOL_RESULT_PREVIEW_CHARS = 2_000;
|
|
|
10
10
|
const TOOL_RESULT_SHELL_THRESHOLD_CHARS = 30_000;
|
|
11
11
|
const TOOL_RESULT_SEARCH_THRESHOLD_CHARS = 50_000;
|
|
12
12
|
const TOOL_RESULT_GREP_THRESHOLD_CHARS = 20_000;
|
|
13
|
+
export const TOOL_RESULT_MESSAGE_MAX_CHARS = 200_000;
|
|
13
14
|
const TOOL_RESULT_OFFLOAD_PREFIX = '[tool output offloaded:';
|
|
14
15
|
const OFFLOAD_PRUNE_MIN_AGE_MS = 10 * 60 * 1000;
|
|
15
16
|
// clearOffloadSession only runs on a CLEAN session close, so every killed
|
|
@@ -86,6 +87,35 @@ function getOffloadThreshold(toolName) {
|
|
|
86
87
|
return INLINE_THRESHOLD_BY_TOOL.get(key) ?? TOOL_RESULT_OFFLOAD_THRESHOLD_CHARS;
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
const AGGREGATE_OFFLOAD_EXCLUDED_TOOLS = new Set([
|
|
91
|
+
'read',
|
|
92
|
+
'head',
|
|
93
|
+
'tail',
|
|
94
|
+
'diff',
|
|
95
|
+
'skill',
|
|
96
|
+
'skill_view',
|
|
97
|
+
'skills_list',
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
export function isAggregateOffloadEligible(toolName, result) {
|
|
101
|
+
if (typeof result !== 'string') return false;
|
|
102
|
+
const key = String(toolName || '').toLowerCase();
|
|
103
|
+
return !AGGREGATE_OFFLOAD_EXCLUDED_TOOLS.has(key);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function rankAggregateOffloadCandidates(entries) {
|
|
107
|
+
return entries
|
|
108
|
+
.map((entry, index) => ({
|
|
109
|
+
index,
|
|
110
|
+
length: typeof entry?.result === 'string' ? entry.result.length : 0,
|
|
111
|
+
eligible: isAggregateOffloadEligible(entry?.toolName, entry?.result)
|
|
112
|
+
&& !String(entry?.result || '').startsWith(TOOL_RESULT_OFFLOAD_PREFIX),
|
|
113
|
+
}))
|
|
114
|
+
.filter((entry) => entry.eligible)
|
|
115
|
+
.sort((a, b) => b.length - a.length || b.index - a.index)
|
|
116
|
+
.map((entry) => entry.index);
|
|
117
|
+
}
|
|
118
|
+
|
|
89
119
|
// Sanitize sessionId before using it as a path segment. A raw `..` or slash
|
|
90
120
|
// would let the sidecar dir — and clearOffloadSession's readdir+unlink — escape
|
|
91
121
|
// the tool-results root (arbitrary .txt deletion). Strip to [A-Za-z0-9_-];
|
|
@@ -139,15 +169,17 @@ function countLines(text) {
|
|
|
139
169
|
return lines;
|
|
140
170
|
}
|
|
141
171
|
|
|
142
|
-
export async function maybeOffloadToolResult(sessionId, toolCallId, toolName, result) {
|
|
172
|
+
export async function maybeOffloadToolResult(sessionId, toolCallId, toolName, result, options = {}) {
|
|
143
173
|
if (!sessionId || !toolCallId) return result;
|
|
144
174
|
if (typeof result !== 'string') return result;
|
|
145
|
-
if (result.
|
|
175
|
+
if (result.startsWith(TOOL_RESULT_OFFLOAD_PREFIX)) return result;
|
|
176
|
+
const force = options?.force === true;
|
|
177
|
+
if (!force && result.length <= getOffloadThreshold(toolName)) return result;
|
|
146
178
|
// Keep error surfaces inline so the model can self-correct without an
|
|
147
179
|
// extra read turn — but only up to the global default. A giant error
|
|
148
180
|
// (e.g. a megabyte of stack/diff/dump) still offloads so it can't blow up
|
|
149
181
|
// context; small errors (the overwhelming majority) stay inline.
|
|
150
|
-
if (classifyResultKind(result) === 'error'
|
|
182
|
+
if (!force && classifyResultKind(result) === 'error'
|
|
151
183
|
&& result.length <= TOOL_RESULT_OFFLOAD_THRESHOLD_CHARS) return result;
|
|
152
184
|
|
|
153
185
|
// Generate a safe filename — never trust toolCallId as a path component.
|
|
@@ -170,6 +202,69 @@ export async function maybeOffloadToolResult(sessionId, toolCallId, toolName, re
|
|
|
170
202
|
return `${header}\n\n${preview}${suffix}`;
|
|
171
203
|
}
|
|
172
204
|
|
|
205
|
+
// Apply per-tool persistence first, then enforce the Claude Code message-level
|
|
206
|
+
// budget across the remaining non-Read text results. Selection is
|
|
207
|
+
// largest-first; ties prefer the latest result in the assistant tool batch.
|
|
208
|
+
export async function maybeOffloadToolResultBatch(sessionId, entries, options = {}) {
|
|
209
|
+
const source = Array.isArray(entries) ? entries : [];
|
|
210
|
+
const maxChars = Number(options.maxAggregateChars) > 0
|
|
211
|
+
? Math.trunc(Number(options.maxAggregateChars))
|
|
212
|
+
: TOOL_RESULT_MESSAGE_MAX_CHARS;
|
|
213
|
+
const applyPerToolLimits = options.applyPerToolLimits !== false;
|
|
214
|
+
const offloadResult = typeof options.offloadResult === 'function'
|
|
215
|
+
? options.offloadResult
|
|
216
|
+
: maybeOffloadToolResult;
|
|
217
|
+
const states = source.map((entry) => ({ result: entry?.result, error: null }));
|
|
218
|
+
|
|
219
|
+
if (applyPerToolLimits) {
|
|
220
|
+
await Promise.all(source.map(async (entry, index) => {
|
|
221
|
+
try {
|
|
222
|
+
states[index].result = await offloadResult(
|
|
223
|
+
sessionId,
|
|
224
|
+
entry?.toolCallId,
|
|
225
|
+
entry?.toolName,
|
|
226
|
+
entry?.result,
|
|
227
|
+
{ force: false },
|
|
228
|
+
);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
states[index].error = error;
|
|
231
|
+
}
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const inlineChars = () => states.reduce((total, state, index) => (
|
|
236
|
+
state.error || !isAggregateOffloadEligible(source[index]?.toolName, state.result)
|
|
237
|
+
? total
|
|
238
|
+
: total + state.result.length
|
|
239
|
+
), 0);
|
|
240
|
+
let total = inlineChars();
|
|
241
|
+
const attempted = new Set();
|
|
242
|
+
while (total > maxChars) {
|
|
243
|
+
const ranked = rankAggregateOffloadCandidates(source.map((entry, index) => ({
|
|
244
|
+
toolName: entry?.toolName,
|
|
245
|
+
result: states[index].error || attempted.has(index) ? null : states[index].result,
|
|
246
|
+
})));
|
|
247
|
+
if (ranked.length === 0) break;
|
|
248
|
+
const index = ranked[0];
|
|
249
|
+
attempted.add(index);
|
|
250
|
+
const before = states[index].result;
|
|
251
|
+
try {
|
|
252
|
+
states[index].result = await offloadResult(
|
|
253
|
+
sessionId,
|
|
254
|
+
source[index]?.toolCallId,
|
|
255
|
+
source[index]?.toolName,
|
|
256
|
+
before,
|
|
257
|
+
{ force: true },
|
|
258
|
+
);
|
|
259
|
+
total += String(states[index].result || '').length - before.length;
|
|
260
|
+
} catch (error) {
|
|
261
|
+
states[index].error = error;
|
|
262
|
+
total -= before.length;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return states;
|
|
266
|
+
}
|
|
267
|
+
|
|
173
268
|
// Drop a session's offload sidecars on session close. Unlinks every
|
|
174
269
|
// tool-results/<sessionId>/*.txt entry, rmdirs the directory, and drops
|
|
175
270
|
// the sessionId entry from the module-level counter Map so a long-running
|
|
@@ -112,18 +112,18 @@ function smartMiddleTruncate(content) {
|
|
|
112
112
|
const head = fastLines.slice(0, SMART_BASH_HEAD_LINES).join('\n');
|
|
113
113
|
const tail = fastLines.slice(-SMART_BASH_TAIL_LINES).join('\n');
|
|
114
114
|
const middle = fastLines.length - SMART_BASH_HEAD_LINES - SMART_BASH_TAIL_LINES;
|
|
115
|
-
return `${head}\n\n... [
|
|
115
|
+
return `${head}\n\n... [${middle} lines omitted of ${fastLines.length} total — head and tail shown] ...\n\n${tail}`;
|
|
116
116
|
}
|
|
117
117
|
const lines = s.split('\n');
|
|
118
118
|
if (lines.length <= SMART_BASH_MAX_LINES) {
|
|
119
119
|
const head = s.slice(0, SMART_BASH_MAX_BYTES);
|
|
120
|
-
return `${head}\n\n... [
|
|
120
|
+
return `${head}\n\n... [output exceeded ${Math.round(SMART_BASH_MAX_BYTES / 1024)} KB on a single line] ...`;
|
|
121
121
|
}
|
|
122
122
|
const head = lines.slice(0, SMART_BASH_HEAD_LINES).join('\n');
|
|
123
123
|
const tail = lines.slice(-SMART_BASH_TAIL_LINES).join('\n');
|
|
124
124
|
const middle = lines.length - SMART_BASH_HEAD_LINES - SMART_BASH_TAIL_LINES;
|
|
125
125
|
const totalKb = Math.round(s.length / 1024);
|
|
126
|
-
return `${head}\n\n... [
|
|
126
|
+
return `${head}\n\n... [${middle} lines omitted of ${lines.length} total / ${totalKb} KB — head and tail shown] ...\n\n${tail}`;
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
function _prependDestructiveWarning(command, text) {
|
|
@@ -40,12 +40,12 @@ function pushClampNotice(a, msg) {
|
|
|
40
40
|
|
|
41
41
|
// ripgrep-flavored aliases: models trained on `rg` emit short flags (-A/-B/-C),
|
|
42
42
|
// long flags (--after-context / --before-context / --context), or snake/camel
|
|
43
|
-
// spellings. All fold onto the canonical -A/-B
|
|
43
|
+
// spellings. All fold onto the canonical -A/-B/context so a caller can write grep
|
|
44
44
|
// the way they would write ripgrep on the shell.
|
|
45
45
|
const GREP_CONTEXT_KEY_GROUPS = [
|
|
46
46
|
['-A', ['-A', 'A', 'after', 'after_context', 'afterContext', '--after-context', 'after-context', 'afterLines', 'after_lines']],
|
|
47
47
|
['-B', ['-B', 'B', 'before', 'before_context', 'beforeContext', '--before-context', 'before-context', 'beforeLines', 'before_lines']],
|
|
48
|
-
['
|
|
48
|
+
['context', ['context', '-C', 'C', 'context_lines', 'contextLines', '--context', 'contextN', 'around', 'surrounding']],
|
|
49
49
|
];
|
|
50
50
|
|
|
51
51
|
function grepContextKeyPresent(a, k) {
|
|
@@ -81,7 +81,7 @@ export function applyGrepContextLeadPolicy(args) {
|
|
|
81
81
|
if (!args || typeof args !== 'object' || Array.isArray(args)) return;
|
|
82
82
|
// Idempotent guard: the outer builtin guard (validateBuiltinArgs) and the
|
|
83
83
|
// executor (executeGrepTool) both call this on the SAME args object. The
|
|
84
|
-
// first pass folds aliases onto -A/-B
|
|
84
|
+
// first pass folds aliases onto -A/-B/context and clamps; a second pass would
|
|
85
85
|
// recompute the same folding for no effect. Skip once applied.
|
|
86
86
|
if (args._grepContextPolicyApplied) return;
|
|
87
87
|
for (const [canonical, keys] of GREP_CONTEXT_KEY_GROUPS) {
|
|
@@ -375,7 +375,10 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
375
375
|
// anywhere in the chain) used to hard-fail via foregroundLongCommandHint
|
|
376
376
|
// (~22 wasted turns/14d measured). Promote them to a background task —
|
|
377
377
|
// the exact remedy the deny message pointed at — same as sleep chains.
|
|
378
|
-
|
|
378
|
+
// Short (2-29s) leading sleep chains stay SYNC: a 5s settle before a
|
|
379
|
+
// verification probe must not detach produce→verify into an async
|
|
380
|
+
// notification round-trip — blocking a few seconds is strictly cheaper.
|
|
381
|
+
const _blockedSleep = detectBlockedSleepPattern(command, 30) || detectLongForegroundReason(command);
|
|
379
382
|
if (_blockedSleep) {
|
|
380
383
|
runInBackground = true;
|
|
381
384
|
autoAsyncReason = _blockedSleep;
|
|
@@ -115,7 +115,7 @@ export const BUILTIN_TOOLS = [
|
|
|
115
115
|
name: 'grep',
|
|
116
116
|
title: 'Mixdog Grep',
|
|
117
117
|
annotations: { title: 'Mixdog Grep', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
118
|
-
description: 'Content search by literal or regex over file/dir scopes.',
|
|
118
|
+
description: 'Content search by literal or regex over file/dir scopes; matches return copy-ready raw source spans.',
|
|
119
119
|
inputSchema: {
|
|
120
120
|
type: 'object',
|
|
121
121
|
properties: {
|
|
@@ -143,7 +143,7 @@ export const BUILTIN_TOOLS = [
|
|
|
143
143
|
output_mode: { type: 'string', enum: ['content_with_context', 'files_with_matches', 'count'], description: 'content_with_context (default); files_with_matches/count for existence.' },
|
|
144
144
|
head_limit: { type: 'number', minimum: 0, description: 'Max results. Defaults to 250; 0 = unlimited.' },
|
|
145
145
|
offset: { type: 'number', minimum: 0, description: 'Skip results for paging.' },
|
|
146
|
-
|
|
146
|
+
context: { type: 'number', minimum: 0, description: 'Minimum surrounding lines; runtime expands within its call budget. Omit for automatic context; 0 for matches only.' },
|
|
147
147
|
},
|
|
148
148
|
anyOf: [
|
|
149
149
|
{ required: ['pattern'] },
|
|
@@ -197,7 +197,7 @@ export const BUILTIN_TOOLS = [
|
|
|
197
197
|
description: 'Partial path/name words; query[] batches.',
|
|
198
198
|
},
|
|
199
199
|
path: { type: 'string', description: 'Base directory.' },
|
|
200
|
-
head_limit: { type: 'number', description: 'Max paths. Defaults to 25.' },
|
|
200
|
+
head_limit: { type: 'number', description: 'Max paths across the call. Defaults to 25.' },
|
|
201
201
|
},
|
|
202
202
|
required: ['query'],
|
|
203
203
|
additionalProperties: false,
|
|
@@ -207,7 +207,7 @@ export const BUILTIN_TOOLS = [
|
|
|
207
207
|
name: 'list',
|
|
208
208
|
title: 'Mixdog List Directory',
|
|
209
209
|
annotations: { title: 'Mixdog List Directory', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: true },
|
|
210
|
-
description: 'List directory entries; batch independent dirs as path[].',
|
|
210
|
+
description: 'List directory entries (path + type); batch independent dirs as path[].',
|
|
211
211
|
inputSchema: {
|
|
212
212
|
type: 'object',
|
|
213
213
|
properties: {
|
|
@@ -94,7 +94,13 @@ function fuzzyScore(query, str) {
|
|
|
94
94
|
// order but scattered mid-word, no contiguity/boundary structure) is treated
|
|
95
95
|
// as noise rather than a real hit. Contiguous substring / basename matches
|
|
96
96
|
// bypass the floor entirely — an exact hit must never be filtered.
|
|
97
|
-
|
|
97
|
+
// Measured separation for scattered junk vs real hits ("readme" probe):
|
|
98
|
+
// genuine matches score ≥8/char (boundary + contiguity structure), while
|
|
99
|
+
// incidental subsequences on unrelated paths land at 5–6/char — 6.5 splits
|
|
100
|
+
// them. The floor is tested against the RAW subsequence score (see
|
|
101
|
+
// fuzzyRank), never the +40 basename rank bonus, which otherwise vaults
|
|
102
|
+
// weak basename subsequences straight past any floor.
|
|
103
|
+
const SUBSEQUENCE_MIN_PER_CHAR = 6.5;
|
|
98
104
|
|
|
99
105
|
// Separator/case-insensitive normalization used for the "contiguous substring"
|
|
100
106
|
// strong-match test. Mirrors the query normalization in fuzzyScore so
|
|
@@ -163,7 +169,7 @@ export function fuzzyRank(query, items, limit = 0) {
|
|
|
163
169
|
const score = prepareFuzzyScore(query);
|
|
164
170
|
// Floor scales with query length: a scattered subsequence earns ~1 point
|
|
165
171
|
// per char, while any contiguous run (+5/char) or word-boundary hit
|
|
166
|
-
// (+8) pushes a genuine match well past
|
|
172
|
+
// (+8) pushes a genuine match well past 6.5/char.
|
|
167
173
|
const floor = normQuery.length * SUBSEQUENCE_MIN_PER_CHAR;
|
|
168
174
|
const scored = [];
|
|
169
175
|
// Preserve slice's legacy coercion behavior for non-integer public callers;
|
|
@@ -185,7 +191,10 @@ export function fuzzyRank(query, items, limit = 0) {
|
|
|
185
191
|
// Otherwise it is subsequence-only: keep it only if it clears the
|
|
186
192
|
// per-char floor. Weak scattered matches (the pgAdmin-style junk that
|
|
187
193
|
// merely contains the query chars in order) fall below it and drop out.
|
|
188
|
-
|
|
194
|
+
// Compare the RAW subsequence score — the +40 basename rank bonus is
|
|
195
|
+
// for ordering only and must not lift junk over the quality floor.
|
|
196
|
+
const rawSc = Math.max(pathScore ?? -Infinity, baseScore ?? -Infinity);
|
|
197
|
+
if (!strong && rawSc < floor) continue;
|
|
189
198
|
const entry = { item, score: sc };
|
|
190
199
|
if (bounded) retainTopRanked(scored, entry, ordinal, limit);
|
|
191
200
|
else scored.push(entry);
|
|
@@ -43,6 +43,28 @@ export function splitGrepCountPrefix(line) {
|
|
|
43
43
|
return { path: text.slice(0, idx), pathEnd: idx, count: Number(count) };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
export function parseGrepContextHeader(line) {
|
|
47
|
+
const match = /^# (.+):(\d+) \[lines (\d+)-(\d+)\]$/.exec(String(line || ''));
|
|
48
|
+
if (!match) return null;
|
|
49
|
+
const matchLine = Number(match[2]);
|
|
50
|
+
const startLine = Number(match[3]);
|
|
51
|
+
const endLine = Number(match[4]);
|
|
52
|
+
if (!Number.isSafeInteger(matchLine)
|
|
53
|
+
|| !Number.isSafeInteger(startLine)
|
|
54
|
+
|| !Number.isSafeInteger(endLine)
|
|
55
|
+
|| startLine < 1
|
|
56
|
+
|| endLine < startLine
|
|
57
|
+
|| matchLine < startLine
|
|
58
|
+
|| matchLine > endLine) return null;
|
|
59
|
+
return {
|
|
60
|
+
path: match[1],
|
|
61
|
+
matchLine,
|
|
62
|
+
startLine,
|
|
63
|
+
endLine,
|
|
64
|
+
sourceLineCount: endLine - startLine + 1,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
export function normalizeGrepLine(line, pathOnly = false, outputMode = 'content', filenameOmitted = false) {
|
|
47
69
|
if (process.platform !== 'win32') return line;
|
|
48
70
|
// files_with_matches mode: the WHOLE line is a path, so every separator is
|