zames_pro 2.71.0 → 2.72.0

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.
@@ -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 { normText } from './browser.js';
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
- 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, onAskPermission = undefined, selfImprovement = false, onSubagent = null, }) {
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
- for (const call of callsToRun) {
708
- // `Task` is a SEAM, not an ordinary tool: it is handled by the caller's
709
- // onSubagent callback (a separate chat with its own context). It is not
710
- // in `tools` when subagents are disabled, so it is matched by name here
711
- // BEFORE the unknown-tool branch. With no callback we answer honestly
712
- // instead of pretending the tool exists.
713
- if (call.tool === 'Task') {
714
- const rawPrompt = typeof call.args.prompt === 'string' ? call.args.prompt.trim() : '';
715
- const rawType = String(call.args.subagent_type ?? '').toLowerCase();
716
- const subType = rawType === 'explore' ? 'explore' : 'general';
717
- const rawDesc = typeof call.args.description === 'string' ? call.args.description : '';
718
- if (!onSubagent || !rawPrompt) {
719
- const err = !onSubagent
720
- ? 'Subagents are not available in this run. Do the work yourself with the other tools.'
721
- : 'Task requires a non-empty `prompt` argument.';
722
- safeToolResult(err);
723
- transcript?.log('tool_error', { tool: 'Task', error: err });
724
- results.push({ tool: 'Task', result: err });
725
- continue;
726
- }
727
- syncToolAbort();
728
- safeToolCall('Task', call.args);
729
- transcript?.log('subagent_start', {
730
- type: subType,
731
- description: rawDesc,
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
- subResult = await onSubagent({
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
- subResult = {
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
- continue;
777
- }
778
- const tool = tools.find((t) => t.name === call.tool);
779
- if (!tool) {
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: call.tool,
801
- result: blocked,
759
+ tool: c.tool,
760
+ result: String(result),
761
+ durationMs: toolMs,
802
762
  });
803
- results.push({ tool: call.tool, result: blocked });
804
- continue;
763
+ results.push({ tool: c.tool, result });
805
764
  }
806
- // Approval policy (C1): a `deny` rule blocks the call (like a PreToolUse
807
- // denial); an `ask` rule prompts the operator. Hooks stay authoritative
808
- // for programmatic guards — this is the human-in-the-loop layer.
809
- const decision = decidePermission(permissionPolicy, call.tool, call.args);
810
- if (decision.action === 'deny') {
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
- if (decision.action === 'ask' && onAskPermission) {
825
- let allowed;
826
- try {
827
- allowed = await onAskPermission({ ...decision, tool: call.tool });
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
- catch {
830
- allowed = false;
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
- if (!allowed) {
833
- const blocked = `Denied by operator: ${decision.reason}`;
834
- transcript?.log('permission_denied', {
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
- let result;
848
- // While the tool runs, poll for an Esc/Ctrl+C: the abort flag is a plain
849
- // boolean set by stopGeneration(), so the only way to turn it into a
850
- // child-process kill is to check it periodically. 100ms is cheap and
851
- // makes Esc feel immediate.
852
- const poll = setInterval(syncToolAbort, 100);
853
- if (typeof poll.unref === 'function')
854
- poll.unref();
855
- // Time the tool (T-D3): the transcript carries durationMs so /cost can
856
- // show where the time goes (frequent Read→Edit cycles vs slow Bash).
857
- const toolStart = Date.now();
858
- try {
859
- result = await tool.fn(call.args, { signal: toolAbort.signal });
860
- }
861
- catch (e) {
862
- result = `Error: ${e.message}`;
863
- }
864
- finally {
865
- clearInterval(poll);
866
- }
867
- const toolMs = Date.now() - toolStart;
868
- // PostToolUse hooks run AFTER the tool; their stdout is appended to the
869
- // result (e.g. `prettier` output) before it is fed back to the model.
870
- // Best-effort: a hook failure is ignored, the tool result still stands.
871
- const post = await runPostToolUse(hookConfig, call.tool, call.args, String(result), workdir);
872
- if (post) {
873
- transcript?.log('hook_post_output', { tool: call.tool, output: post });
874
- result = `${String(result)}
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,9 @@ ${post}`;
1009
1090
  }
1010
1091
  return 'Iteration limit reached.';
1011
1092
  }
1012
- // Cap a tool result for the model, but make the truncation EXPLICIT: a bare
1013
- // slice() silently hid the tail and the model had no idea it was looking at a
1014
- // partial output (it would act on a half-read file/log).
1015
- export function truncateToolResult(text, limit) {
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) moved to
1094
+ // answer-parse.ts (BACKLOG C3). They are imported for runAgentLoop and
1095
+ // RE-EXPORTED, so the public API of this module is unchanged (tests import
1096
+ // parseToolCall / responseLooksLikeToolCall / truncateToolResult from here).
1097
+ import { parseToolCall, responseLooksLikeToolCall, truncateToolResult, normForStale, looksLikeUnfinishedWork, isMeaningfulRespond, extractPreToolText, } from './answer-parse.js';
1098
+ export { parseToolCall, responseLooksLikeToolCall, truncateToolResult };