zames_pro 2.71.0 → 2.72.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/dist/agent-loop.js +253 -924
- package/dist/answer-parse.js +765 -0
- package/dist/browser-chats.js +161 -0
- package/dist/browser-dom.js +416 -0
- package/dist/browser-pure.js +249 -0
- package/dist/browser.js +75 -713
- package/dist/changelog.js +29 -0
- package/dist/cli-args.js +48 -0
- package/dist/compact.js +9 -0
- package/dist/config.js +6 -0
- package/dist/diagnostics.js +71 -0
- package/dist/hooks.js +30 -2
- package/dist/i18n/common.js +4 -0
- package/dist/index.js +49 -24
- package/dist/input.js +25 -1
- package/dist/run-task.js +31 -0
- package/dist/shell.js +102 -1
- package/dist/statusline.js +52 -0
- package/dist/subagent.js +40 -3
- package/dist/tools.js +81 -8
- package/package.json +10 -8
- package/scripts/changelog-draft.mts +38 -1
- package/scripts/{eslint-ts6.mjs → eslint-ts6.mts} +11 -4
- package/scripts/release.mts +36 -7
- package/scripts/{render-demo.mjs → render-demo.mts} +12 -11
- package/scripts/{self-smoke.mjs → self-smoke.mts} +41 -4
- /package/scripts/{clean-dist.mjs → clean-dist.mts} +0 -0
- /package/scripts/{coverage-gate.mjs → coverage-gate.mts} +0 -0
package/dist/agent-loop.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { buildSystemPrompt } from './system-prompt.js';
|
|
2
2
|
import { loadProjectContext } from './context.js';
|
|
3
3
|
import { getGitContext, formatGitContext } from './gitTools.js';
|
|
4
|
-
import { parseXmlToolCalls } from './xml-toolcall.js';
|
|
5
4
|
import { translate } from './i18n.js';
|
|
6
5
|
import { substituteAttachmentMarkers } from './commands.js';
|
|
7
|
-
import {
|
|
8
|
-
import { loadHooks, runPreToolUse, runPostToolUse, } from './hooks.js';
|
|
6
|
+
import { loadHooks, runPreToolUse, runPostToolUse, runLifecycleHooks, } from './hooks.js';
|
|
9
7
|
import { loadPermissions, decidePermission, } from './permissions.js';
|
|
10
|
-
|
|
8
|
+
import { shouldRunDiagnostics, runDiagnostics, } from './diagnostics.js';
|
|
9
|
+
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, diagnostics = null, onAskPermission = undefined, selfImprovement = false, onSubagent = null, }) {
|
|
11
10
|
// Resolve the hook config ONCE per task: a read per tool call would be
|
|
12
11
|
// wasteful, and a mid-task edit of hooks.json is not something to chase.
|
|
13
12
|
// `undefined` means "read .zames/hooks.json"; an explicit null disables
|
|
@@ -66,6 +65,14 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
66
65
|
if (freshChat) {
|
|
67
66
|
await browser.newChat();
|
|
68
67
|
transcript?.log('new_chat');
|
|
68
|
+
// SessionStart hook (N32): a fresh chat is the start of a session, so a
|
|
69
|
+
// hook can inject context or set up state. Best-effort, output is logged.
|
|
70
|
+
try {
|
|
71
|
+
const out = await runLifecycleHooks(hookConfig, 'SessionStart', workdir);
|
|
72
|
+
if (out)
|
|
73
|
+
transcript?.log('hook_session_start', { output: out });
|
|
74
|
+
}
|
|
75
|
+
catch { }
|
|
69
76
|
}
|
|
70
77
|
// Report the current chat id to the caller.
|
|
71
78
|
let lastReportedChatId = null;
|
|
@@ -270,6 +277,15 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
270
277
|
// 300s rate-limit wait or the finish loop) while the next iteration
|
|
271
278
|
// started a SECOND ask() against the same page — two sends / two Continue
|
|
272
279
|
// clicks.
|
|
280
|
+
// UserPromptSubmit hook (N32): runs right before the prompt is sent. Its
|
|
281
|
+
// stdout is appended to the outgoing message as extra context, mirroring
|
|
282
|
+
// Claude Code. Best-effort — a hook failure never blocks the send.
|
|
283
|
+
try {
|
|
284
|
+
const inject = await runLifecycleHooks(hookConfig, 'UserPromptSubmit', workdir, { prompt: message });
|
|
285
|
+
if (inject)
|
|
286
|
+
message = message + String.fromCharCode(10, 10) + inject;
|
|
287
|
+
}
|
|
288
|
+
catch { }
|
|
273
289
|
const askPromise = browser.ask(message, {
|
|
274
290
|
agent: !isFirst,
|
|
275
291
|
attachments: isFirst ? attachments : [],
|
|
@@ -704,134 +720,164 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
704
720
|
'repeat the identical call.';
|
|
705
721
|
continue;
|
|
706
722
|
}
|
|
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
|
-
prompt: rawPrompt,
|
|
733
|
-
});
|
|
734
|
-
const poll = setInterval(syncToolAbort, 100);
|
|
735
|
-
if (typeof poll.unref === 'function')
|
|
736
|
-
poll.unref();
|
|
737
|
-
const subStart = Date.now();
|
|
738
|
-
let subResult;
|
|
723
|
+
// N34: several READ-ONLY calls in one batch are independent, so run them
|
|
724
|
+
// concurrently instead of one-by-one (the model often emits 4-6 Reads in a
|
|
725
|
+
// single answer and each awaited round-trip added latency). The fast path
|
|
726
|
+
// is deliberately NARROW: it is taken only when EVERY call is a known
|
|
727
|
+
// read-only tool, no PreToolUse hook is configured (hooks can block/deny)
|
|
728
|
+
// and no call matches an `ask` permission rule (a concurrent operator
|
|
729
|
+
// prompt would race). Anything else keeps the strict sequential loop, so
|
|
730
|
+
// mutating side effects never reorder.
|
|
731
|
+
const PARALLEL_SAFE = new Set(['Read', 'Glob', 'Grep', 'LS', 'WebFetch']);
|
|
732
|
+
const readOnlyBatch = callsToRun.length > 1 &&
|
|
733
|
+
!(hookConfig.PreToolUse && hookConfig.PreToolUse.length) &&
|
|
734
|
+
callsToRun.every((c) => PARALLEL_SAFE.has(c.tool) &&
|
|
735
|
+
tools.some((t) => t.name === c.tool) &&
|
|
736
|
+
decidePermission(permissionPolicy, c.tool, c.args).action !== 'ask');
|
|
737
|
+
if (readOnlyBatch) {
|
|
738
|
+
for (const c of callsToRun) {
|
|
739
|
+
safeToolCall(c.tool, c.args);
|
|
740
|
+
transcript?.log('tool_call', { tool: c.tool, args: c.args });
|
|
741
|
+
}
|
|
742
|
+
// Results are pushed in the ORIGINAL order below, so the model sees the
|
|
743
|
+
// batch as before — only the waiting overlaps.
|
|
744
|
+
const settled = await Promise.all(callsToRun.map(async (c) => {
|
|
745
|
+
const tool = tools.find((t) => t.name === c.tool);
|
|
746
|
+
const toolStart = Date.now();
|
|
747
|
+
let result;
|
|
739
748
|
try {
|
|
740
|
-
|
|
741
|
-
prompt: rawPrompt,
|
|
742
|
-
description: rawDesc,
|
|
743
|
-
type: subType,
|
|
744
|
-
});
|
|
749
|
+
result = await tool.fn(c.args, { signal: toolAbort.signal });
|
|
745
750
|
}
|
|
746
751
|
catch (e) {
|
|
747
|
-
|
|
748
|
-
ok: false,
|
|
749
|
-
text: `Subagent error: ${e.message}`,
|
|
750
|
-
};
|
|
751
|
-
}
|
|
752
|
-
finally {
|
|
753
|
-
clearInterval(poll);
|
|
754
|
-
}
|
|
755
|
-
const subText = String(subResult.text || '').trim();
|
|
756
|
-
const subOut = subResult.ok
|
|
757
|
-
? subText || '(the subagent returned no report)'
|
|
758
|
-
: `Subagent failed: ${subText || 'unknown error'}`;
|
|
759
|
-
safeToolResult(subOut);
|
|
760
|
-
transcript?.log('subagent_done', {
|
|
761
|
-
type: subType,
|
|
762
|
-
ok: subResult.ok,
|
|
763
|
-
chars: subText.length,
|
|
764
|
-
durationMs: Date.now() - subStart,
|
|
765
|
-
});
|
|
766
|
-
results.push({ tool: 'Task', result: subOut });
|
|
767
|
-
// The subagent switched the chat underneath us and the callback has
|
|
768
|
-
// already restored the parent chat. Clear the stale-echo baseline:
|
|
769
|
-
// the capture left over from the subagent's chat would otherwise make
|
|
770
|
-
// the watchdog compare the parent's next answer against IT and fire.
|
|
771
|
-
lastRaw = '';
|
|
772
|
-
if (browser._abort || browser._stopped) {
|
|
773
|
-
transcript?.log('user_aborted');
|
|
774
|
-
return '(прервано пользователем)';
|
|
752
|
+
result = `Error: ${e.message}`;
|
|
775
753
|
}
|
|
776
|
-
|
|
777
|
-
}
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
const err = `Unknown tool: ${call.tool}`;
|
|
781
|
-
safeToolResult(err);
|
|
782
|
-
transcript?.log('tool_error', { tool: call.tool, error: err });
|
|
783
|
-
results.push({ tool: call.tool, result: err });
|
|
784
|
-
continue;
|
|
785
|
-
}
|
|
786
|
-
// The operator may have pressed Esc while we were parsing/among the
|
|
787
|
-
// previous calls — make sure the signal reflects it before we start.
|
|
788
|
-
syncToolAbort();
|
|
789
|
-
safeToolCall(call.tool, call.args);
|
|
790
|
-
transcript?.log('tool_call', { tool: call.tool, args: call.args });
|
|
791
|
-
// PreToolUse hooks run BEFORE the tool. A non-zero exit BLOCKS the call:
|
|
792
|
-
// its output becomes the tool result and the tool itself never runs.
|
|
793
|
-
// Best-effort by design — hooks must not be able to kill the loop.
|
|
794
|
-
const denial = await runPreToolUse(hookConfig, call.tool, call.args, workdir);
|
|
795
|
-
if (denial !== null) {
|
|
796
|
-
const blocked = `Blocked by PreToolUse hook: ${denial}`;
|
|
797
|
-
transcript?.log('hook_pre_deny', { tool: call.tool, reason: denial });
|
|
798
|
-
safeToolResult(blocked);
|
|
754
|
+
return { call: c, result, toolMs: Date.now() - toolStart };
|
|
755
|
+
}));
|
|
756
|
+
for (const { call: c, result, toolMs } of settled) {
|
|
757
|
+
safeToolResult(result);
|
|
799
758
|
transcript?.log('tool_result', {
|
|
800
|
-
tool:
|
|
801
|
-
result:
|
|
759
|
+
tool: c.tool,
|
|
760
|
+
result: String(result),
|
|
761
|
+
durationMs: toolMs,
|
|
802
762
|
});
|
|
803
|
-
results.push({ tool:
|
|
804
|
-
continue;
|
|
763
|
+
results.push({ tool: c.tool, result });
|
|
805
764
|
}
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
const blocked = `Blocked by permission policy: ${decision.reason}`;
|
|
812
|
-
transcript?.log('permission_deny', {
|
|
813
|
-
tool: call.tool,
|
|
814
|
-
reason: decision.reason,
|
|
815
|
-
});
|
|
816
|
-
safeToolResult(blocked);
|
|
817
|
-
transcript?.log('tool_result', {
|
|
818
|
-
tool: call.tool,
|
|
819
|
-
result: blocked,
|
|
820
|
-
});
|
|
821
|
-
results.push({ tool: call.tool, result: blocked });
|
|
822
|
-
continue;
|
|
765
|
+
// Same abort contract as the sequential loop: an Esc during the batch
|
|
766
|
+
// must stop before its results are fed to the model.
|
|
767
|
+
if (browser._abort || browser._stopped) {
|
|
768
|
+
transcript?.log('user_aborted');
|
|
769
|
+
return '(прервано пользователем)';
|
|
823
770
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
771
|
+
}
|
|
772
|
+
if (!readOnlyBatch)
|
|
773
|
+
for (const call of callsToRun) {
|
|
774
|
+
// `Task` is a SEAM, not an ordinary tool: it is handled by the caller's
|
|
775
|
+
// onSubagent callback (a separate chat with its own context). It is not
|
|
776
|
+
// in `tools` when subagents are disabled, so it is matched by name here
|
|
777
|
+
// BEFORE the unknown-tool branch. With no callback we answer honestly
|
|
778
|
+
// instead of pretending the tool exists.
|
|
779
|
+
if (call.tool === 'Task') {
|
|
780
|
+
const rawPrompt = typeof call.args.prompt === 'string' ? call.args.prompt.trim() : '';
|
|
781
|
+
const rawType = String(call.args.subagent_type ?? '').toLowerCase();
|
|
782
|
+
const subType = rawType === 'explore' ? 'explore' : 'general';
|
|
783
|
+
const rawDesc = typeof call.args.description === 'string'
|
|
784
|
+
? call.args.description
|
|
785
|
+
: '';
|
|
786
|
+
if (!onSubagent || !rawPrompt) {
|
|
787
|
+
const err = !onSubagent
|
|
788
|
+
? 'Subagents are not available in this run. Do the work yourself with the other tools.'
|
|
789
|
+
: 'Task requires a non-empty `prompt` argument.';
|
|
790
|
+
safeToolResult(err);
|
|
791
|
+
transcript?.log('tool_error', { tool: 'Task', error: err });
|
|
792
|
+
results.push({ tool: 'Task', result: err });
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
syncToolAbort();
|
|
796
|
+
safeToolCall('Task', call.args);
|
|
797
|
+
transcript?.log('subagent_start', {
|
|
798
|
+
type: subType,
|
|
799
|
+
description: rawDesc,
|
|
800
|
+
prompt: rawPrompt,
|
|
801
|
+
});
|
|
802
|
+
const poll = setInterval(syncToolAbort, 100);
|
|
803
|
+
if (typeof poll.unref === 'function')
|
|
804
|
+
poll.unref();
|
|
805
|
+
const subStart = Date.now();
|
|
806
|
+
let subResult;
|
|
807
|
+
try {
|
|
808
|
+
subResult = await onSubagent({
|
|
809
|
+
prompt: rawPrompt,
|
|
810
|
+
description: rawDesc,
|
|
811
|
+
type: subType,
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
catch (e) {
|
|
815
|
+
subResult = {
|
|
816
|
+
ok: false,
|
|
817
|
+
text: `Subagent error: ${e.message}`,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
finally {
|
|
821
|
+
clearInterval(poll);
|
|
822
|
+
}
|
|
823
|
+
const subText = String(subResult.text || '').trim();
|
|
824
|
+
const subOut = subResult.ok
|
|
825
|
+
? subText || '(the subagent returned no report)'
|
|
826
|
+
: `Subagent failed: ${subText || 'unknown error'}`;
|
|
827
|
+
safeToolResult(subOut);
|
|
828
|
+
transcript?.log('subagent_done', {
|
|
829
|
+
type: subType,
|
|
830
|
+
ok: subResult.ok,
|
|
831
|
+
chars: subText.length,
|
|
832
|
+
durationMs: Date.now() - subStart,
|
|
833
|
+
});
|
|
834
|
+
results.push({ tool: 'Task', result: subOut });
|
|
835
|
+
// The subagent switched the chat underneath us and the callback has
|
|
836
|
+
// already restored the parent chat. Clear the stale-echo baseline:
|
|
837
|
+
// the capture left over from the subagent's chat would otherwise make
|
|
838
|
+
// the watchdog compare the parent's next answer against IT and fire.
|
|
839
|
+
lastRaw = '';
|
|
840
|
+
if (browser._abort || browser._stopped) {
|
|
841
|
+
transcript?.log('user_aborted');
|
|
842
|
+
return '(прервано пользователем)';
|
|
843
|
+
}
|
|
844
|
+
continue;
|
|
828
845
|
}
|
|
829
|
-
|
|
830
|
-
|
|
846
|
+
const tool = tools.find((t) => t.name === call.tool);
|
|
847
|
+
if (!tool) {
|
|
848
|
+
const err = `Unknown tool: ${call.tool}`;
|
|
849
|
+
safeToolResult(err);
|
|
850
|
+
transcript?.log('tool_error', { tool: call.tool, error: err });
|
|
851
|
+
results.push({ tool: call.tool, result: err });
|
|
852
|
+
continue;
|
|
831
853
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
854
|
+
// The operator may have pressed Esc while we were parsing/among the
|
|
855
|
+
// previous calls — make sure the signal reflects it before we start.
|
|
856
|
+
syncToolAbort();
|
|
857
|
+
safeToolCall(call.tool, call.args);
|
|
858
|
+
transcript?.log('tool_call', { tool: call.tool, args: call.args });
|
|
859
|
+
// PreToolUse hooks run BEFORE the tool. A non-zero exit BLOCKS the call:
|
|
860
|
+
// its output becomes the tool result and the tool itself never runs.
|
|
861
|
+
// Best-effort by design — hooks must not be able to kill the loop.
|
|
862
|
+
const denial = await runPreToolUse(hookConfig, call.tool, call.args, workdir);
|
|
863
|
+
if (denial !== null) {
|
|
864
|
+
const blocked = `Blocked by PreToolUse hook: ${denial}`;
|
|
865
|
+
transcript?.log('hook_pre_deny', { tool: call.tool, reason: denial });
|
|
866
|
+
safeToolResult(blocked);
|
|
867
|
+
transcript?.log('tool_result', {
|
|
868
|
+
tool: call.tool,
|
|
869
|
+
result: blocked,
|
|
870
|
+
});
|
|
871
|
+
results.push({ tool: call.tool, result: blocked });
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
// Approval policy (C1): a `deny` rule blocks the call (like a PreToolUse
|
|
875
|
+
// denial); an `ask` rule prompts the operator. Hooks stay authoritative
|
|
876
|
+
// for programmatic guards — this is the human-in-the-loop layer.
|
|
877
|
+
const decision = decidePermission(permissionPolicy, call.tool, call.args);
|
|
878
|
+
if (decision.action === 'deny') {
|
|
879
|
+
const blocked = `Blocked by permission policy: ${decision.reason}`;
|
|
880
|
+
transcript?.log('permission_deny', {
|
|
835
881
|
tool: call.tool,
|
|
836
882
|
reason: decision.reason,
|
|
837
883
|
});
|
|
@@ -843,56 +889,91 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
843
889
|
results.push({ tool: call.tool, result: blocked });
|
|
844
890
|
continue;
|
|
845
891
|
}
|
|
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
|
-
|
|
892
|
+
if (decision.action === 'ask' && onAskPermission) {
|
|
893
|
+
let allowed;
|
|
894
|
+
try {
|
|
895
|
+
allowed = await onAskPermission({ ...decision, tool: call.tool });
|
|
896
|
+
}
|
|
897
|
+
catch {
|
|
898
|
+
allowed = false;
|
|
899
|
+
}
|
|
900
|
+
if (!allowed) {
|
|
901
|
+
const blocked = `Denied by operator: ${decision.reason}`;
|
|
902
|
+
transcript?.log('permission_denied', {
|
|
903
|
+
tool: call.tool,
|
|
904
|
+
reason: decision.reason,
|
|
905
|
+
});
|
|
906
|
+
safeToolResult(blocked);
|
|
907
|
+
transcript?.log('tool_result', {
|
|
908
|
+
tool: call.tool,
|
|
909
|
+
result: blocked,
|
|
910
|
+
});
|
|
911
|
+
results.push({ tool: call.tool, result: blocked });
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
let result;
|
|
916
|
+
// While the tool runs, poll for an Esc/Ctrl+C: the abort flag is a plain
|
|
917
|
+
// boolean set by stopGeneration(), so the only way to turn it into a
|
|
918
|
+
// child-process kill is to check it periodically. 100ms is cheap and
|
|
919
|
+
// makes Esc feel immediate.
|
|
920
|
+
const poll = setInterval(syncToolAbort, 100);
|
|
921
|
+
if (typeof poll.unref === 'function')
|
|
922
|
+
poll.unref();
|
|
923
|
+
// Time the tool (T-D3): the transcript carries durationMs so /cost can
|
|
924
|
+
// show where the time goes (frequent Read→Edit cycles vs slow Bash).
|
|
925
|
+
const toolStart = Date.now();
|
|
926
|
+
try {
|
|
927
|
+
result = await tool.fn(call.args, { signal: toolAbort.signal });
|
|
928
|
+
}
|
|
929
|
+
catch (e) {
|
|
930
|
+
result = `Error: ${e.message}`;
|
|
931
|
+
}
|
|
932
|
+
finally {
|
|
933
|
+
clearInterval(poll);
|
|
934
|
+
}
|
|
935
|
+
const toolMs = Date.now() - toolStart;
|
|
936
|
+
// PostToolUse hooks run AFTER the tool; their stdout is appended to the
|
|
937
|
+
// result (e.g. `prettier` output) before it is fed back to the model.
|
|
938
|
+
// Best-effort: a hook failure is ignored, the tool result still stands.
|
|
939
|
+
const post = await runPostToolUse(hookConfig, call.tool, call.args, String(result), workdir);
|
|
940
|
+
if (post) {
|
|
941
|
+
transcript?.log('hook_post_output', { tool: call.tool, output: post });
|
|
942
|
+
result = `${String(result)}
|
|
875
943
|
|
|
876
944
|
[PostToolUse hook]
|
|
877
945
|
${post}`;
|
|
946
|
+
}
|
|
947
|
+
// N38: after a file-mutating tool, run the project's own check and
|
|
948
|
+
// append its summary, so a type error reaches the model immediately
|
|
949
|
+
// instead of costing a whole round-trip. Best-effort and capped.
|
|
950
|
+
if (shouldRunDiagnostics(diagnostics, call.tool)) {
|
|
951
|
+
const diag = runDiagnostics(workdir, diagnostics);
|
|
952
|
+
if (diag) {
|
|
953
|
+
transcript?.log('diagnostics', { tool: call.tool, output: diag });
|
|
954
|
+
result = `${String(result)}
|
|
955
|
+
|
|
956
|
+
[diagnostics]
|
|
957
|
+
${diag}`;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
safeToolResult(result);
|
|
961
|
+
transcript?.log('tool_result', {
|
|
962
|
+
tool: call.tool,
|
|
963
|
+
result: String(result),
|
|
964
|
+
durationMs: toolMs,
|
|
965
|
+
});
|
|
966
|
+
results.push({ tool: call.tool, result });
|
|
967
|
+
// The operator pressed Esc/Ctrl+C while the tool was running. The tool
|
|
968
|
+
// itself has finished (we cannot kill an arbitrary child process from
|
|
969
|
+
// here), but we must NOT feed its result back to the model and must NOT
|
|
970
|
+
// run the remaining calls in this batch — that would keep the agent
|
|
971
|
+
// going after an explicit stop.
|
|
972
|
+
if (browser._abort || browser._stopped) {
|
|
973
|
+
transcript?.log('user_aborted');
|
|
974
|
+
return '(прервано пользователем)';
|
|
975
|
+
}
|
|
878
976
|
}
|
|
879
|
-
safeToolResult(result);
|
|
880
|
-
transcript?.log('tool_result', {
|
|
881
|
-
tool: call.tool,
|
|
882
|
-
result: String(result),
|
|
883
|
-
durationMs: toolMs,
|
|
884
|
-
});
|
|
885
|
-
results.push({ tool: call.tool, result });
|
|
886
|
-
// The operator pressed Esc/Ctrl+C while the tool was running. The tool
|
|
887
|
-
// itself has finished (we cannot kill an arbitrary child process from
|
|
888
|
-
// here), but we must NOT feed its result back to the model and must NOT
|
|
889
|
-
// run the remaining calls in this batch — that would keep the agent
|
|
890
|
-
// going after an explicit stop.
|
|
891
|
-
if (browser._abort || browser._stopped) {
|
|
892
|
-
transcript?.log('user_aborted');
|
|
893
|
-
return '(прервано пользователем)';
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
977
|
// A tool just ran — the next answer is expected to be a fresh tool call.
|
|
897
978
|
// Reset the watchdog so the next empty/repeated answer is nudged.
|
|
898
979
|
justRanTool = true;
|
|
@@ -1009,759 +1090,7 @@ ${post}`;
|
|
|
1009
1090
|
}
|
|
1010
1091
|
return 'Iteration limit reached.';
|
|
1011
1092
|
}
|
|
1012
|
-
//
|
|
1013
|
-
//
|
|
1014
|
-
//
|
|
1015
|
-
|
|
1016
|
-
if (text.length <= limit)
|
|
1017
|
-
return text;
|
|
1018
|
-
// Prefer a LINE boundary near the limit so the model does not read a line
|
|
1019
|
-
// cut in half (a half line is easy to misread as the real content). We look
|
|
1020
|
-
// for the last newline within the last 20% of the window; if none, fall back
|
|
1021
|
-
// to the hard char cut.
|
|
1022
|
-
let cut = limit;
|
|
1023
|
-
const NL = String.fromCharCode(10);
|
|
1024
|
-
const searchFrom = Math.floor(limit * 0.8);
|
|
1025
|
-
const nl = text.lastIndexOf(NL, limit);
|
|
1026
|
-
// Cut AFTER the newline (include it) so the kept part is a whole number of
|
|
1027
|
-
// lines — a cut that EXCLUDED the newline left a half line before the marker.
|
|
1028
|
-
if (nl >= searchFrom)
|
|
1029
|
-
cut = nl + 1;
|
|
1030
|
-
const omitted = text.length - cut;
|
|
1031
|
-
return text.slice(0, cut) + NL + `[...truncated ${omitted} chars]`;
|
|
1032
|
-
}
|
|
1033
|
-
// The answer looks like a tool call, but parseToolCall() did not recognize it.
|
|
1034
|
-
// Used as a safeguard against "the agent called a tool and stopped": in that
|
|
1035
|
-
// case runAgentLoop asks the model to resend the call instead of finishing the
|
|
1036
|
-
// task. We catch both explicit formats and "broken" call heads
|
|
1037
|
-
// (`<|tool": ...`, `**tool**:`, `tool": ...`), and truncated calls.
|
|
1038
|
-
export function responseLooksLikeToolCall(rawResponse) {
|
|
1039
|
-
const raw = rawResponse || '';
|
|
1040
|
-
return (
|
|
1041
|
-
// Explicit tool-call format markers: the JSON key "tool", XML/DSML tags,
|
|
1042
|
-
// function_call, etc.
|
|
1043
|
-
/("tool"\s*:|\btool_calls?\b|\binvoke\b|\bparameter\b|DSML|function_call)/i.test(raw) ||
|
|
1044
|
-
// "tool" without an opening quote/bracket, with a junk prefix
|
|
1045
|
-
// (`<|tool":`, `**tool**:`, `- tool:`): a call key, not prose.
|
|
1046
|
-
/(^|[^A-Za-z0-9_])(?:\*\*)?tool(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/.test(raw) ||
|
|
1047
|
-
// Keys in single quotes or unquoted: {'tool': 'Read', ...}.
|
|
1048
|
-
/[\{\[]\s*['"]?(tool|name|args)['"]?\s*:/.test(raw) ||
|
|
1049
|
-
// Truncated call: starts like a JSON object but is not closed, and has an
|
|
1050
|
-
// argument key (args/command/path/...). We require the opening bracket at
|
|
1051
|
-
// the start (after spaces/prefix) so we don't catch ordinary prose with
|
|
1052
|
-
// colons like "path: ...".
|
|
1053
|
-
(/^\s*[\[\{]/.test(raw) &&
|
|
1054
|
-
/["']?(?:tool|args|command|path|old_string|content|content_base64)["']?\s*:/.test(raw)) ||
|
|
1055
|
-
/<\s*\|?\s*(DSML|invoke|parameter)/i.test(raw) ||
|
|
1056
|
-
/^\s*\[?\s*\{[^}]*$/.test(raw.trim()));
|
|
1057
|
-
}
|
|
1058
|
-
// A respond message is only a real FINAL answer when it carries some meaning.
|
|
1059
|
-
// DeepSeek sometimes finishes with a placeholder — "...", "-", "ok", "done",
|
|
1060
|
-
// "готово" — which looks like a stop with no report. Such a respond must not
|
|
1061
|
-
// end the task silently: it is treated like an empty one (re-ask).
|
|
1062
|
-
function isMeaningfulRespond(msg) {
|
|
1063
|
-
const t = (msg || '').trim();
|
|
1064
|
-
if (!t)
|
|
1065
|
-
return false;
|
|
1066
|
-
// Only punctuation/dots/ellipses: "...", "---", "…", "?" — not a report.
|
|
1067
|
-
if (/^[.…–—_*?!/\s-]+$/.test(t))
|
|
1068
|
-
return false;
|
|
1069
|
-
// A bare acknowledgement with no content at all. NOTE: "ok"/"done"/
|
|
1070
|
-
// "готово" are NOT in this list: a short "готово" is a legitimate final
|
|
1071
|
-
// answer for a small task, and dropping it re-opened the loop.
|
|
1072
|
-
if (/^(na|null|undefined)[.!]*$/i.test(t))
|
|
1073
|
-
return false;
|
|
1074
|
-
return true;
|
|
1075
|
-
}
|
|
1076
|
-
// Normalize an answer for STALE comparison: collapse whitespace AND strip
|
|
1077
|
-
// markdown emphasis/code markers. DeepSeek echoes the previous turn with a
|
|
1078
|
-
// different emphasis (`**done**` vs `done`), which defeated an exact match and
|
|
1079
|
-
// made the loop re-run the same tool ("stopped after a tool call").
|
|
1080
|
-
function normForStale(s) {
|
|
1081
|
-
return normText(s)
|
|
1082
|
-
.replace(/[*_`#>]+/g, '')
|
|
1083
|
-
.replace(/[ \t]+/g, ' ')
|
|
1084
|
-
.trim();
|
|
1085
|
-
}
|
|
1086
|
-
// Text that promises a tool call in the future tense but contains no call
|
|
1087
|
-
// itself. DeepSeek regularly "hangs" like this: it writes
|
|
1088
|
-
// "Now update README to mention …", "Let me run the tests", "I'll check now"
|
|
1089
|
-
// and stops. Such answers must not be taken as final — otherwise the agent
|
|
1090
|
-
// stalls without doing the work. We keep the heuristic narrow (future tense /
|
|
1091
|
-
// intent) so we don't catch ordinary reports of completed work.
|
|
1092
|
-
function looksLikeUnfinishedWork(text) {
|
|
1093
|
-
const t = (text || '').trim();
|
|
1094
|
-
if (!t)
|
|
1095
|
-
return false;
|
|
1096
|
-
// Long answers (reports) are left alone — anything can be in there.
|
|
1097
|
-
if (t.length > 600)
|
|
1098
|
-
return false;
|
|
1099
|
-
// A final marker is already present — treat the answer as complete.
|
|
1100
|
-
if (/\b(done|finished|completed|готово|выполнено|завершено)\b/i.test(t)) {
|
|
1101
|
-
return false;
|
|
1102
|
-
}
|
|
1103
|
-
const en = /\b(now|next|then|let me|let's|i will|i'll|i am going to|i'm going to|going to|about to|will now|time to)\b[^.!?\n]{0,120}\b(update|write|edit|read|run|check|add|fix|create|remove|delete|apply|test|commit|push|install|open|search|look|verify|change|modify|implement|review)\b/i;
|
|
1104
|
-
const ru = /(^|[^а-яё])(проверю|обновлю|исправлю|добавлю|запущу|выполню|посмотрю|прочитаю|изменю|попробую|сделаю)([^а-яё]|$)/i;
|
|
1105
|
-
return en.test(t) || ru.test(t);
|
|
1106
|
-
}
|
|
1107
|
-
// ============ JSON parsing ============
|
|
1108
|
-
function repairRawControlChars(str) {
|
|
1109
|
-
let out = '';
|
|
1110
|
-
let inString = false;
|
|
1111
|
-
let escape = false;
|
|
1112
|
-
for (let i = 0; i < str.length; i++) {
|
|
1113
|
-
const c = str[i];
|
|
1114
|
-
if (escape) {
|
|
1115
|
-
out += c;
|
|
1116
|
-
escape = false;
|
|
1117
|
-
continue;
|
|
1118
|
-
}
|
|
1119
|
-
if (c === '\\') {
|
|
1120
|
-
out += c;
|
|
1121
|
-
escape = true;
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
if (c === '"') {
|
|
1125
|
-
inString = !inString;
|
|
1126
|
-
out += c;
|
|
1127
|
-
continue;
|
|
1128
|
-
}
|
|
1129
|
-
if (inString) {
|
|
1130
|
-
if (c === '\n') {
|
|
1131
|
-
out += '\\n';
|
|
1132
|
-
continue;
|
|
1133
|
-
}
|
|
1134
|
-
if (c === '\r') {
|
|
1135
|
-
out += '\\r';
|
|
1136
|
-
continue;
|
|
1137
|
-
}
|
|
1138
|
-
if (c === '\t') {
|
|
1139
|
-
out += '\\t';
|
|
1140
|
-
continue;
|
|
1141
|
-
}
|
|
1142
|
-
const code = c.charCodeAt(0);
|
|
1143
|
-
if (code < 0x20) {
|
|
1144
|
-
out += '\\u' + code.toString(16).padStart(4, '0');
|
|
1145
|
-
continue;
|
|
1146
|
-
}
|
|
1147
|
-
}
|
|
1148
|
-
out += c;
|
|
1149
|
-
}
|
|
1150
|
-
return out;
|
|
1151
|
-
}
|
|
1152
|
-
function tryParse(str) {
|
|
1153
|
-
try {
|
|
1154
|
-
const obj = JSON.parse(str);
|
|
1155
|
-
if (obj &&
|
|
1156
|
-
typeof obj.tool === 'string' &&
|
|
1157
|
-
obj.args &&
|
|
1158
|
-
typeof obj.args === 'object' &&
|
|
1159
|
-
!Array.isArray(obj.args)) {
|
|
1160
|
-
return obj;
|
|
1161
|
-
}
|
|
1162
|
-
return null;
|
|
1163
|
-
}
|
|
1164
|
-
catch {
|
|
1165
|
-
return null;
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
function tryParseArray(str) {
|
|
1169
|
-
try {
|
|
1170
|
-
const arr = JSON.parse(str);
|
|
1171
|
-
if (Array.isArray(arr) &&
|
|
1172
|
-
arr.length > 0 &&
|
|
1173
|
-
arr.every((o) => o &&
|
|
1174
|
-
typeof o.tool === 'string' &&
|
|
1175
|
-
o.args &&
|
|
1176
|
-
typeof o.args === 'object' &&
|
|
1177
|
-
!Array.isArray(o.args))) {
|
|
1178
|
-
return arr;
|
|
1179
|
-
}
|
|
1180
|
-
return null;
|
|
1181
|
-
}
|
|
1182
|
-
catch {
|
|
1183
|
-
return null;
|
|
1184
|
-
}
|
|
1185
|
-
}
|
|
1186
|
-
function parseArgsGreedy(str) {
|
|
1187
|
-
const result = {};
|
|
1188
|
-
let i = str.indexOf('{') + 1;
|
|
1189
|
-
if (i === 0)
|
|
1190
|
-
return null;
|
|
1191
|
-
const skipWs = () => {
|
|
1192
|
-
while (i < str.length && /[\s,]/.test(str[i]))
|
|
1193
|
-
i++;
|
|
1194
|
-
};
|
|
1195
|
-
while (i < str.length) {
|
|
1196
|
-
skipWs();
|
|
1197
|
-
if (i >= str.length || str[i] === '}')
|
|
1198
|
-
break;
|
|
1199
|
-
if (str[i] !== '"')
|
|
1200
|
-
return null;
|
|
1201
|
-
const keyEnd = str.indexOf('"', i + 1);
|
|
1202
|
-
if (keyEnd === -1)
|
|
1203
|
-
return null;
|
|
1204
|
-
const key = str.slice(i + 1, keyEnd);
|
|
1205
|
-
i = keyEnd + 1;
|
|
1206
|
-
while (i < str.length && /\s/.test(str[i]))
|
|
1207
|
-
i++;
|
|
1208
|
-
if (str[i] !== ':')
|
|
1209
|
-
return null;
|
|
1210
|
-
i++;
|
|
1211
|
-
while (i < str.length && /\s/.test(str[i]))
|
|
1212
|
-
i++;
|
|
1213
|
-
if (str[i] === '"') {
|
|
1214
|
-
let lastEnd = -1;
|
|
1215
|
-
let k = i + 1;
|
|
1216
|
-
while (k < str.length) {
|
|
1217
|
-
if (str.charCodeAt(k) === 92) {
|
|
1218
|
-
k += 2;
|
|
1219
|
-
continue;
|
|
1220
|
-
}
|
|
1221
|
-
if (str[k] === '"') {
|
|
1222
|
-
let t = k + 1;
|
|
1223
|
-
while (t < str.length && /\s/.test(str[t]))
|
|
1224
|
-
t++;
|
|
1225
|
-
if (t >= str.length || str[t] === ',' || str[t] === '}')
|
|
1226
|
-
lastEnd = k;
|
|
1227
|
-
}
|
|
1228
|
-
k++;
|
|
1229
|
-
}
|
|
1230
|
-
if (lastEnd === -1)
|
|
1231
|
-
return null;
|
|
1232
|
-
result[key] = unescapeValue(str.slice(i + 1, lastEnd));
|
|
1233
|
-
i = lastEnd + 1;
|
|
1234
|
-
continue;
|
|
1235
|
-
}
|
|
1236
|
-
if (str[i] === '{' || str[i] === '[') {
|
|
1237
|
-
const open = str[i];
|
|
1238
|
-
const close = open === '{' ? '}' : ']';
|
|
1239
|
-
const end = findMatching(str, i, open, close);
|
|
1240
|
-
if (end === -1)
|
|
1241
|
-
return null;
|
|
1242
|
-
let parsed = null;
|
|
1243
|
-
try {
|
|
1244
|
-
parsed = JSON.parse(str.slice(i, end + 1));
|
|
1245
|
-
}
|
|
1246
|
-
catch {
|
|
1247
|
-
if (open === '{')
|
|
1248
|
-
parsed = parseArgsPermissive(str.slice(i, end + 1));
|
|
1249
|
-
}
|
|
1250
|
-
if (parsed === null)
|
|
1251
|
-
return null;
|
|
1252
|
-
result[key] = parsed;
|
|
1253
|
-
i = end + 1;
|
|
1254
|
-
continue;
|
|
1255
|
-
}
|
|
1256
|
-
let j = i;
|
|
1257
|
-
while (j < str.length && !/[,}]/.test(str[j]))
|
|
1258
|
-
j++;
|
|
1259
|
-
const raw = str.slice(i, j).trim();
|
|
1260
|
-
if (raw === 'true')
|
|
1261
|
-
result[key] = true;
|
|
1262
|
-
else if (raw === 'false')
|
|
1263
|
-
result[key] = false;
|
|
1264
|
-
else if (raw === 'null')
|
|
1265
|
-
result[key] = null;
|
|
1266
|
-
else {
|
|
1267
|
-
const n = Number(raw);
|
|
1268
|
-
result[key] = Number.isNaN(n) ? raw : n;
|
|
1269
|
-
}
|
|
1270
|
-
i = j;
|
|
1271
|
-
}
|
|
1272
|
-
return result;
|
|
1273
|
-
}
|
|
1274
|
-
function parseArgsPermissive(str) {
|
|
1275
|
-
const result = {};
|
|
1276
|
-
let i = 1;
|
|
1277
|
-
while (i < str.length) {
|
|
1278
|
-
while (i < str.length && /[\s,]/.test(str[i]))
|
|
1279
|
-
i++;
|
|
1280
|
-
if (i >= str.length || str[i] === '}')
|
|
1281
|
-
break;
|
|
1282
|
-
if (str[i] !== '"')
|
|
1283
|
-
return null;
|
|
1284
|
-
const keyEnd = str.indexOf('"', i + 1);
|
|
1285
|
-
if (keyEnd === -1)
|
|
1286
|
-
return null;
|
|
1287
|
-
const key = str.slice(i + 1, keyEnd);
|
|
1288
|
-
i = keyEnd + 1;
|
|
1289
|
-
while (i < str.length && /\s/.test(str[i]))
|
|
1290
|
-
i++;
|
|
1291
|
-
if (str[i] !== ':')
|
|
1292
|
-
return null;
|
|
1293
|
-
i++;
|
|
1294
|
-
while (i < str.length && /\s/.test(str[i]))
|
|
1295
|
-
i++;
|
|
1296
|
-
if (str[i] === '"') {
|
|
1297
|
-
let j = i + 1;
|
|
1298
|
-
let value = '';
|
|
1299
|
-
while (j < str.length) {
|
|
1300
|
-
const c = str[j];
|
|
1301
|
-
if (c === '\\' && j + 1 < str.length) {
|
|
1302
|
-
value += c + str[j + 1];
|
|
1303
|
-
j += 2;
|
|
1304
|
-
continue;
|
|
1305
|
-
}
|
|
1306
|
-
if (c === '"') {
|
|
1307
|
-
let k = j + 1;
|
|
1308
|
-
while (k < str.length && /\s/.test(str[k]))
|
|
1309
|
-
k++;
|
|
1310
|
-
if (k >= str.length ||
|
|
1311
|
-
str[k] === ',' ||
|
|
1312
|
-
str[k] === '}' ||
|
|
1313
|
-
str[k] === ']') {
|
|
1314
|
-
break;
|
|
1315
|
-
}
|
|
1316
|
-
value += '"';
|
|
1317
|
-
j++;
|
|
1318
|
-
continue;
|
|
1319
|
-
}
|
|
1320
|
-
value += c;
|
|
1321
|
-
j++;
|
|
1322
|
-
}
|
|
1323
|
-
if (j >= str.length)
|
|
1324
|
-
return null;
|
|
1325
|
-
result[key] = unescapeValue(value);
|
|
1326
|
-
i = j + 1;
|
|
1327
|
-
}
|
|
1328
|
-
else if (str[i] === '{' || str[i] === '[') {
|
|
1329
|
-
const open = str[i];
|
|
1330
|
-
const close = open === '{' ? '}' : ']';
|
|
1331
|
-
const end = findMatching(str, i, open, close);
|
|
1332
|
-
if (end === -1)
|
|
1333
|
-
return null;
|
|
1334
|
-
const frag = str.slice(i, end + 1);
|
|
1335
|
-
let parsedFrag = null;
|
|
1336
|
-
try {
|
|
1337
|
-
parsedFrag = JSON.parse(frag);
|
|
1338
|
-
}
|
|
1339
|
-
catch {
|
|
1340
|
-
if (open === '{')
|
|
1341
|
-
parsedFrag = parseArgsPermissive(frag);
|
|
1342
|
-
}
|
|
1343
|
-
if (parsedFrag === null)
|
|
1344
|
-
return null;
|
|
1345
|
-
result[key] = parsedFrag;
|
|
1346
|
-
i = end + 1;
|
|
1347
|
-
}
|
|
1348
|
-
else {
|
|
1349
|
-
let j = i;
|
|
1350
|
-
while (j < str.length && !/[,}]/.test(str[j]))
|
|
1351
|
-
j++;
|
|
1352
|
-
const raw = str.slice(i, j).trim();
|
|
1353
|
-
if (raw === 'true')
|
|
1354
|
-
result[key] = true;
|
|
1355
|
-
else if (raw === 'false')
|
|
1356
|
-
result[key] = false;
|
|
1357
|
-
else if (raw === 'null')
|
|
1358
|
-
result[key] = null;
|
|
1359
|
-
else {
|
|
1360
|
-
const n = Number(raw);
|
|
1361
|
-
result[key] = Number.isNaN(n) ? raw : n;
|
|
1362
|
-
}
|
|
1363
|
-
i = j;
|
|
1364
|
-
}
|
|
1365
|
-
}
|
|
1366
|
-
return result;
|
|
1367
|
-
}
|
|
1368
|
-
function unescapeValue(s) {
|
|
1369
|
-
let out = '';
|
|
1370
|
-
let i = 0;
|
|
1371
|
-
while (i < s.length) {
|
|
1372
|
-
const c = s[i];
|
|
1373
|
-
if (c === '\\' && i + 1 < s.length) {
|
|
1374
|
-
const n = s[i + 1];
|
|
1375
|
-
if (n === 'n') {
|
|
1376
|
-
out += '\n';
|
|
1377
|
-
i += 2;
|
|
1378
|
-
continue;
|
|
1379
|
-
}
|
|
1380
|
-
if (n === 't') {
|
|
1381
|
-
out += '\t';
|
|
1382
|
-
i += 2;
|
|
1383
|
-
continue;
|
|
1384
|
-
}
|
|
1385
|
-
if (n === 'r') {
|
|
1386
|
-
out += '\r';
|
|
1387
|
-
i += 2;
|
|
1388
|
-
continue;
|
|
1389
|
-
}
|
|
1390
|
-
if (n === '"') {
|
|
1391
|
-
out += '"';
|
|
1392
|
-
i += 2;
|
|
1393
|
-
continue;
|
|
1394
|
-
}
|
|
1395
|
-
if (n === '\\') {
|
|
1396
|
-
out += '\\';
|
|
1397
|
-
i += 2;
|
|
1398
|
-
continue;
|
|
1399
|
-
}
|
|
1400
|
-
if (n === '/') {
|
|
1401
|
-
out += '/';
|
|
1402
|
-
i += 2;
|
|
1403
|
-
continue;
|
|
1404
|
-
}
|
|
1405
|
-
if (n === 'u' && i + 5 < s.length) {
|
|
1406
|
-
const hex = s.slice(i + 2, i + 6);
|
|
1407
|
-
if (/^[0-9a-fA-F]{4}$/.test(hex)) {
|
|
1408
|
-
out += String.fromCharCode(parseInt(hex, 16));
|
|
1409
|
-
i += 6;
|
|
1410
|
-
continue;
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
out += c + n;
|
|
1414
|
-
i += 2;
|
|
1415
|
-
continue;
|
|
1416
|
-
}
|
|
1417
|
-
out += c;
|
|
1418
|
-
i++;
|
|
1419
|
-
}
|
|
1420
|
-
return out;
|
|
1421
|
-
}
|
|
1422
|
-
function parseToolCallPermissive(text) {
|
|
1423
|
-
const toolMatch = text.match(/"tool"\s*:\s*"([A-Za-z_][A-Za-z0-9_]*)"/);
|
|
1424
|
-
if (!toolMatch)
|
|
1425
|
-
return null;
|
|
1426
|
-
const tool = toolMatch[1];
|
|
1427
|
-
// The model occasionally puts the argument keys INLINE with "tool" —
|
|
1428
|
-
// {"tool": "Bash", "command": "..."} — with no "args" wrapper at all.
|
|
1429
|
-
// The strict parser rejects it (no obj.args), and the permissive parser
|
|
1430
|
-
// used to bail out too (indexOf('"args"') === -1). Such a call was
|
|
1431
|
-
// reported as malformed and re-asked up to MAX_MALFORMED_RETRIES times;
|
|
1432
|
-
// after the budget ran out the run stopped with the model's text as the
|
|
1433
|
-
// final answer. Flatten the inline keys into args when "args" is absent.
|
|
1434
|
-
if (text.indexOf('"args"') === -1) {
|
|
1435
|
-
const braceIdx = text.indexOf('{');
|
|
1436
|
-
if (braceIdx === -1)
|
|
1437
|
-
return null;
|
|
1438
|
-
const endBrace = findMatching(text, braceIdx, '{', '}');
|
|
1439
|
-
const objText = endBrace === -1
|
|
1440
|
-
? text.slice(braceIdx)
|
|
1441
|
-
: text.slice(braceIdx, endBrace + 1);
|
|
1442
|
-
const inlineArgs = parseArgsPermissive(objText) || parseArgsGreedy(objText);
|
|
1443
|
-
if (!inlineArgs)
|
|
1444
|
-
return null;
|
|
1445
|
-
delete inlineArgs.tool;
|
|
1446
|
-
// The value may be a string ("command": "...") or a number/bool.
|
|
1447
|
-
return { tool, args: inlineArgs };
|
|
1448
|
-
}
|
|
1449
|
-
const argsIdx = text.indexOf('"args"');
|
|
1450
|
-
if (argsIdx === -1)
|
|
1451
|
-
return null;
|
|
1452
|
-
const openIdx = text.indexOf('{', argsIdx);
|
|
1453
|
-
if (openIdx === -1)
|
|
1454
|
-
return null;
|
|
1455
|
-
const endIdx = findMatching(text, openIdx, '{', '}');
|
|
1456
|
-
if (tool === 'Edit') {
|
|
1457
|
-
const balanced = endIdx === -1 ? null : text.slice(openIdx, endIdx + 1);
|
|
1458
|
-
const tailToEnd = text.slice(openIdx);
|
|
1459
|
-
for (const frag of [balanced, tailToEnd]) {
|
|
1460
|
-
if (!frag)
|
|
1461
|
-
continue;
|
|
1462
|
-
const e = parseEditArgs(frag);
|
|
1463
|
-
if (e)
|
|
1464
|
-
return { tool, args: e };
|
|
1465
|
-
}
|
|
1466
|
-
}
|
|
1467
|
-
const frags = [];
|
|
1468
|
-
if (endIdx !== -1)
|
|
1469
|
-
frags.push(text.slice(openIdx, endIdx + 1));
|
|
1470
|
-
frags.push(text.slice(openIdx));
|
|
1471
|
-
for (const argsStr of frags) {
|
|
1472
|
-
const args = parseArgsPermissive(argsStr);
|
|
1473
|
-
if (args)
|
|
1474
|
-
return { tool, args };
|
|
1475
|
-
const greedy = parseArgsGreedy(argsStr);
|
|
1476
|
-
if (greedy)
|
|
1477
|
-
return { tool, args: greedy };
|
|
1478
|
-
}
|
|
1479
|
-
return null;
|
|
1480
|
-
}
|
|
1481
|
-
function parseEditArgs(str) {
|
|
1482
|
-
const keyRe = (name) => new RegExp('"' + name + '"\\s*:\\s*"');
|
|
1483
|
-
const readValue = (name, nextNames) => {
|
|
1484
|
-
const m = keyRe(name).exec(str);
|
|
1485
|
-
if (!m)
|
|
1486
|
-
return null;
|
|
1487
|
-
const start = m.index + m[0].length;
|
|
1488
|
-
let end = str.length;
|
|
1489
|
-
for (const n of nextNames) {
|
|
1490
|
-
const mm = keyRe(n).exec(str.slice(start));
|
|
1491
|
-
if (mm) {
|
|
1492
|
-
const absIdx = start + mm.index;
|
|
1493
|
-
if (absIdx < end)
|
|
1494
|
-
end = absIdx;
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
let val = str.slice(start, end);
|
|
1498
|
-
val = val.replace(/"\s*[,}]?\s*$/, '');
|
|
1499
|
-
return val;
|
|
1500
|
-
};
|
|
1501
|
-
const path = readValue('path', ['old_string', 'new_string']);
|
|
1502
|
-
const oldStr = readValue('old_string', ['new_string']);
|
|
1503
|
-
const newStr = readValue('new_string', []);
|
|
1504
|
-
if (path === null || oldStr === null || newStr === null)
|
|
1505
|
-
return null;
|
|
1506
|
-
return { path: path, old_string: oldStr, new_string: newStr };
|
|
1507
|
-
}
|
|
1508
|
-
function extractJsonObjects(text) {
|
|
1509
|
-
const objects = [];
|
|
1510
|
-
let i = 0;
|
|
1511
|
-
while (i < text.length) {
|
|
1512
|
-
if (text[i] === '{') {
|
|
1513
|
-
const end = findMatching(text, i, '{', '}');
|
|
1514
|
-
if (end !== -1) {
|
|
1515
|
-
objects.push(text.slice(i, end + 1));
|
|
1516
|
-
i = end + 1;
|
|
1517
|
-
continue;
|
|
1518
|
-
}
|
|
1519
|
-
}
|
|
1520
|
-
if (text[i] === '[') {
|
|
1521
|
-
const end = findMatching(text, i, '[', ']');
|
|
1522
|
-
if (end !== -1) {
|
|
1523
|
-
objects.push(text.slice(i, end + 1));
|
|
1524
|
-
i = end + 1;
|
|
1525
|
-
continue;
|
|
1526
|
-
}
|
|
1527
|
-
}
|
|
1528
|
-
i++;
|
|
1529
|
-
}
|
|
1530
|
-
return objects;
|
|
1531
|
-
}
|
|
1532
|
-
function findMatching(text, openIdx, openCh, closeCh) {
|
|
1533
|
-
let depth = 0;
|
|
1534
|
-
let inString = false;
|
|
1535
|
-
let escape = false;
|
|
1536
|
-
for (let i = openIdx; i < text.length; i++) {
|
|
1537
|
-
const c = text[i];
|
|
1538
|
-
if (escape) {
|
|
1539
|
-
escape = false;
|
|
1540
|
-
continue;
|
|
1541
|
-
}
|
|
1542
|
-
if (c === '\\') {
|
|
1543
|
-
escape = true;
|
|
1544
|
-
continue;
|
|
1545
|
-
}
|
|
1546
|
-
if (c === '"') {
|
|
1547
|
-
inString = !inString;
|
|
1548
|
-
continue;
|
|
1549
|
-
}
|
|
1550
|
-
if (inString)
|
|
1551
|
-
continue;
|
|
1552
|
-
if (c === openCh)
|
|
1553
|
-
depth++;
|
|
1554
|
-
else if (c === closeCh) {
|
|
1555
|
-
depth--;
|
|
1556
|
-
if (depth === 0)
|
|
1557
|
-
return i;
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
return -1;
|
|
1561
|
-
}
|
|
1562
|
-
// The model sometimes returns a tool call with single-quoted keys/strings
|
|
1563
|
-
// ("{'tool': 'Read', 'args': {...}}") or unquoted keys
|
|
1564
|
-
// ("{tool: \"Read\", args: {...}}"). This is not valid JSON, and without
|
|
1565
|
-
// normalization such an answer is silently taken as final — the agent stalls
|
|
1566
|
-
// without calling a tool. We normalize it to double quotes.
|
|
1567
|
-
function normalizePseudoJson(str) {
|
|
1568
|
-
// Unquoted keys: {tool: ...} or , args: ... → "tool": / "args":
|
|
1569
|
-
let out = str.replace(/([\{\[]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:/g, '$1"$2":');
|
|
1570
|
-
out = out.replace(/,\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/g, ', "$1":');
|
|
1571
|
-
// Single quotes → double quotes. We don't touch the content of already
|
|
1572
|
-
// double-quoted strings in a row, and escape stray double quotes inside
|
|
1573
|
-
// single quotes.
|
|
1574
|
-
let res = '';
|
|
1575
|
-
let inDouble = false;
|
|
1576
|
-
let inSingle = false;
|
|
1577
|
-
for (let i = 0; i < out.length; i++) {
|
|
1578
|
-
const c = out[i];
|
|
1579
|
-
if (c === '\\' && (inDouble || inSingle)) {
|
|
1580
|
-
res += c;
|
|
1581
|
-
if (i + 1 < out.length) {
|
|
1582
|
-
res += out[i + 1];
|
|
1583
|
-
i++;
|
|
1584
|
-
}
|
|
1585
|
-
continue;
|
|
1586
|
-
}
|
|
1587
|
-
if (c === '"' && !inSingle) {
|
|
1588
|
-
inDouble = !inDouble;
|
|
1589
|
-
res += c;
|
|
1590
|
-
continue;
|
|
1591
|
-
}
|
|
1592
|
-
if (c === "'" && !inDouble) {
|
|
1593
|
-
if (!inSingle) {
|
|
1594
|
-
inSingle = true;
|
|
1595
|
-
res += '"';
|
|
1596
|
-
}
|
|
1597
|
-
else {
|
|
1598
|
-
inSingle = false;
|
|
1599
|
-
res += '"';
|
|
1600
|
-
}
|
|
1601
|
-
continue;
|
|
1602
|
-
}
|
|
1603
|
-
if (inSingle && c === '"') {
|
|
1604
|
-
res += '\\"';
|
|
1605
|
-
continue;
|
|
1606
|
-
}
|
|
1607
|
-
res += c;
|
|
1608
|
-
}
|
|
1609
|
-
return res;
|
|
1610
|
-
}
|
|
1611
|
-
// The model sometimes corrupts the head of a call: `<|tool": "Bash", "args": {...}`,
|
|
1612
|
-
// `tool": "Read", ...`, `**tool**: ...`, `- tool: ...`. Such answers have no
|
|
1613
|
-
// opening `{`, and the `tool` key lost its first quote. If such an answer is
|
|
1614
|
-
// taken as final, the agent silently stalls (a frequent "stop").
|
|
1615
|
-
// We repair it: trim the junk prefix up to the word tool, add `{` and
|
|
1616
|
-
// balance the key quotes.
|
|
1617
|
-
function repairToolCallPreamble(text) {
|
|
1618
|
-
const t = (text || '').trim();
|
|
1619
|
-
const m = t.match(/(?:^|[^A-Za-z0-9_])(?:\*\*)?(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/);
|
|
1620
|
-
if (!m || m.index === undefined)
|
|
1621
|
-
return null;
|
|
1622
|
-
// We look for the start from the first quote/bracket around the key, otherwise from the word tool.
|
|
1623
|
-
let start = m.index;
|
|
1624
|
-
const brace = t.indexOf('{', Math.max(0, start - 1));
|
|
1625
|
-
if (brace !== -1 && brace < start)
|
|
1626
|
-
start = brace;
|
|
1627
|
-
let frag = t.slice(start);
|
|
1628
|
-
// If the fragment does not start with `{` — we add it.
|
|
1629
|
-
if (!frag.startsWith('{')) {
|
|
1630
|
-
// The key may have lost its opening quote: tool": → "tool".
|
|
1631
|
-
// We trim the leading junk up to the word tool and normalize the key quotes.
|
|
1632
|
-
frag = frag.replace(/^[^A-Za-z0-9_]*/, '');
|
|
1633
|
-
frag = frag.replace(/^(?:\*\*)?(["'`\u2018\u2019\u201c\u201d]*)(tool)(?:\*\*)?["'`\u2018\u2019\u201c\u201d]*\s*:/, '"$2":');
|
|
1634
|
-
frag = '{' + frag;
|
|
1635
|
-
}
|
|
1636
|
-
return frag;
|
|
1637
|
-
}
|
|
1638
|
-
export function parseToolCall(text) {
|
|
1639
|
-
if (!text || typeof text !== 'string')
|
|
1640
|
-
return null;
|
|
1641
|
-
let cleaned = text.trim();
|
|
1642
|
-
cleaned = cleaned
|
|
1643
|
-
.replace(/^```(?:json)?\s*/i, '')
|
|
1644
|
-
.replace(/```$/i, '')
|
|
1645
|
-
.trim();
|
|
1646
|
-
const candidates = extractJsonObjects(cleaned);
|
|
1647
|
-
// We collect EVERY recognized call, not just the first one found. The model
|
|
1648
|
-
// often emits several separate {"tool": ...} objects in one answer instead of
|
|
1649
|
-
// a single JSON array. Returning only one of them used to drop the rest and
|
|
1650
|
-
// could leave the agent "stalled after a tool call" with pending work.
|
|
1651
|
-
const collected = [];
|
|
1652
|
-
const tryCollect = (parsed) => {
|
|
1653
|
-
if (!parsed)
|
|
1654
|
-
return false;
|
|
1655
|
-
if (Array.isArray(parsed))
|
|
1656
|
-
collected.push(...parsed);
|
|
1657
|
-
else
|
|
1658
|
-
collected.push(parsed);
|
|
1659
|
-
return true;
|
|
1660
|
-
};
|
|
1661
|
-
for (let i = 0; i < candidates.length; i++) {
|
|
1662
|
-
const raw = candidates[i];
|
|
1663
|
-
// A JSON array of calls is authoritative: if present, use all of it.
|
|
1664
|
-
const arrFirst = tryParseArray(raw);
|
|
1665
|
-
if (arrFirst)
|
|
1666
|
-
return arrFirst;
|
|
1667
|
-
const arrRepaired = tryParseArray(raw.replace(/\\(?!["\\/bfnrtu])/g, '\\\\'));
|
|
1668
|
-
if (arrRepaired)
|
|
1669
|
-
return arrRepaired;
|
|
1670
|
-
const first = tryParse(raw);
|
|
1671
|
-
if (first) {
|
|
1672
|
-
tryCollect(first);
|
|
1673
|
-
continue;
|
|
1674
|
-
}
|
|
1675
|
-
const repaired = raw.replace(/\\(?!["\\/bfnrtu])/g, '\\\\');
|
|
1676
|
-
const second = tryParse(repaired);
|
|
1677
|
-
if (second) {
|
|
1678
|
-
tryCollect(second);
|
|
1679
|
-
continue;
|
|
1680
|
-
}
|
|
1681
|
-
const ctrl = repairRawControlChars(raw);
|
|
1682
|
-
const third = tryParse(ctrl);
|
|
1683
|
-
if (third) {
|
|
1684
|
-
tryCollect(third);
|
|
1685
|
-
continue;
|
|
1686
|
-
}
|
|
1687
|
-
const ctrlArr = tryParseArray(ctrl);
|
|
1688
|
-
if (ctrlArr)
|
|
1689
|
-
return ctrlArr;
|
|
1690
|
-
}
|
|
1691
|
-
if (collected.length === 1)
|
|
1692
|
-
return collected[0];
|
|
1693
|
-
if (collected.length > 1)
|
|
1694
|
-
return collected;
|
|
1695
|
-
// Pseudo-JSON (single quotes / unquoted keys) — normalize and try to parse
|
|
1696
|
-
// as a regular call before going permissive.
|
|
1697
|
-
if (/['"]?tool['"]?\s*:/.test(cleaned)) {
|
|
1698
|
-
const norm = normalizePseudoJson(cleaned);
|
|
1699
|
-
if (norm !== cleaned) {
|
|
1700
|
-
for (const raw of extractJsonObjects(norm)) {
|
|
1701
|
-
const a = tryParseArray(raw);
|
|
1702
|
-
if (a)
|
|
1703
|
-
return a;
|
|
1704
|
-
const o = tryParse(raw);
|
|
1705
|
-
if (o)
|
|
1706
|
-
return o;
|
|
1707
|
-
}
|
|
1708
|
-
}
|
|
1709
|
-
}
|
|
1710
|
-
const permissive = parseToolCallPermissive(cleaned) ||
|
|
1711
|
-
parseToolCallPermissive(repairRawControlChars(cleaned)) ||
|
|
1712
|
-
parseToolCallPermissive(normalizePseudoJson(cleaned));
|
|
1713
|
-
if (permissive)
|
|
1714
|
-
return { ...permissive, _permissive: true };
|
|
1715
|
-
const toolIdx = cleaned.search(/["']?tool["']?\s:/);
|
|
1716
|
-
if (toolIdx > 0) {
|
|
1717
|
-
let tail = cleaned.slice(toolIdx);
|
|
1718
|
-
tail = tail.replace(/<[^>]*>.*$/s, '').trim();
|
|
1719
|
-
const tailPermissive = parseToolCallPermissive('{"' + tail) ||
|
|
1720
|
-
parseToolCallPermissive('{"' + repairRawControlChars(tail));
|
|
1721
|
-
if (tailPermissive)
|
|
1722
|
-
return { ...tailPermissive, _permissive: true };
|
|
1723
|
-
}
|
|
1724
|
-
const xmlCalls = parseXmlToolCalls(cleaned);
|
|
1725
|
-
if (xmlCalls)
|
|
1726
|
-
return Array.isArray(xmlCalls) ? xmlCalls : [xmlCalls];
|
|
1727
|
-
// Last attempt: "fix" a corrupted call head (`<|tool": ...`,
|
|
1728
|
-
// `tool": ...`, `**tool**: ...`, `- tool: ...`). We do this ONLY as a
|
|
1729
|
-
// fallback, after regular parsing — otherwise it's easy to corrupt valid
|
|
1730
|
-
// JSON (e.g. an array of calls starts with `[`, containing `{"tool":`).
|
|
1731
|
-
const preamble = repairToolCallPreamble(cleaned);
|
|
1732
|
-
if (preamble && preamble !== cleaned) {
|
|
1733
|
-
const reps = [
|
|
1734
|
-
preamble,
|
|
1735
|
-
repairRawControlChars(preamble),
|
|
1736
|
-
normalizePseudoJson(preamble),
|
|
1737
|
-
];
|
|
1738
|
-
for (const rep of reps) {
|
|
1739
|
-
for (const raw of extractJsonObjects(rep)) {
|
|
1740
|
-
const a = tryParseArray(raw);
|
|
1741
|
-
if (a)
|
|
1742
|
-
return a;
|
|
1743
|
-
const o = tryParse(raw);
|
|
1744
|
-
if (o)
|
|
1745
|
-
return o;
|
|
1746
|
-
}
|
|
1747
|
-
}
|
|
1748
|
-
const perm = parseToolCallPermissive(preamble);
|
|
1749
|
-
if (perm)
|
|
1750
|
-
return { ...perm, _permissive: true };
|
|
1751
|
-
}
|
|
1752
|
-
return null;
|
|
1753
|
-
}
|
|
1754
|
-
function extractPreToolText(text) {
|
|
1755
|
-
if (!text)
|
|
1756
|
-
return '';
|
|
1757
|
-
const patterns = ['{"tool"', '[{"tool"', '{"tool":', '[{"tool":'];
|
|
1758
|
-
let earliest = -1;
|
|
1759
|
-
for (const p of patterns) {
|
|
1760
|
-
const idx = text.indexOf(p);
|
|
1761
|
-
if (idx >= 0 && (earliest === -1 || idx < earliest))
|
|
1762
|
-
earliest = idx;
|
|
1763
|
-
}
|
|
1764
|
-
if (earliest === -1)
|
|
1765
|
-
return '';
|
|
1766
|
-
return text.slice(0, earliest).trim();
|
|
1767
|
-
}
|
|
1093
|
+
// The pure helpers (tool-call parser + answer heuristics) live in
|
|
1094
|
+
// answer-parse.ts (BACKLOG C3) and are imported here for runAgentLoop. They are
|
|
1095
|
+
// NOT re-exported: importers get them from answer-parse.js directly (C3b).
|
|
1096
|
+
import { parseToolCall, responseLooksLikeToolCall, truncateToolResult, normForStale, looksLikeUnfinishedWork, isMeaningfulRespond, extractPreToolText, } from './answer-parse.js';
|