@lazyingart/agintiflow 0.20.5 → 0.20.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,7 +52,7 @@ In the interactive CLI, `/provider`, `/route`, `/model`, `/spare`, and `/auxilia
52
52
 
53
53
  ```text
54
54
  venice/venice-uncensored-1-2
55
- venice/e2ee-venice-uncensored-24b-p
55
+ venice/venice-uncensored
56
56
  venice/gemma-4-uncensored
57
57
  ```
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lazyingart/agintiflow",
3
- "version": "0.20.5",
3
+ "version": "0.20.7",
4
4
  "type": "module",
5
5
  "description": "AgInTiFlow is a web-first coding agent and CLI with DeepSeek routing, sandboxed tools, model providers, canvas artifacts, and optional wrappers.",
6
6
  "license": "Apache-2.0",
@@ -53,7 +53,7 @@ Interactive equivalents:
53
53
  /auxiliary model grsai/nano-banana-2
54
54
  ```
55
55
 
56
- `/venice` opens a route/main selector for Venice text models. The selector includes `venice/venice-uncensored-1-2` (Venice 1.2), `venice/e2ee-venice-uncensored-24b-p` (Venice 1.1), and `venice/gemma-4-uncensored` (Gemma 4). In non-interactive shells, `/venice` keeps script compatibility by selecting `venice/venice-uncensored-1-2` for both roles. `/venice 1.2 gemma` sets route to Venice 1.2 and main to Gemma 4; `/venice off` restores `deepseek/deepseek-v4-flash` for route and `deepseek/deepseek-v4-pro` for main.
56
+ `/venice` opens a route/main selector for Venice text models. The selector includes `venice/venice-uncensored-1-2` (Venice 1.2), `venice/venice-uncensored` (Venice 1.1), and `venice/gemma-4-uncensored` (Gemma 4). In non-interactive shells, `/venice` keeps script compatibility by selecting `venice/venice-uncensored-1-2` for both roles. `/venice 1.2 gemma` sets route to Venice 1.2 and main to Gemma 4; `/venice off` restores `deepseek/deepseek-v4-flash` for route and `deepseek/deepseek-v4-pro` for main.
57
57
 
58
58
  The web UI should expose model names as dropdowns, not free-text fields. The left panel should stay focused on common daily controls, while model-role editing and less-used switches live in an Advanced settings modal. The terminal-like capability panels belong after the runtime log so the left control panel remains short.
59
59
 
@@ -74,7 +74,7 @@ Codex wrapper defaults stay separate from native OpenAI API settings: GPT-5.5 me
74
74
 
75
75
  | UI bucket | Concrete default | Notes |
76
76
  |---|---|---|
77
- | `venice-uncensored` | `venice-uncensored-1-2` | Venice-native text; `/venice` also exposes `e2ee-venice-uncensored-24b-p` as Venice 1.1 |
77
+ | `venice-uncensored` | `venice-uncensored-1-2` | Venice-native text; `/venice` also exposes `venice-uncensored` as Venice 1.1 |
78
78
  | `venice-qwen` | `qwen3-6-27b` | Qwen-family text/code |
79
79
  | `venice-gpt` | `openai-gpt-55` | OpenAI-family through Venice |
80
80
  | `venice-claude` | `claude-sonnet-4-6` | Claude-family through Venice |
@@ -52,7 +52,7 @@ Store `VENICE_API_KEY` only in ignored local files such as `.aginti/.env` or a s
52
52
  | `openai-gpt-52` | 256K | GPT-5.2 |
53
53
  | `venice-uncensored` | 32K | Venice Uncensored legacy / deprecated |
54
54
 
55
- AgInTiFlow exposes the primary Venice text choices through `/venice`: Venice 1.2 (`venice-uncensored-1-2`), Venice 1.1 (`e2ee-venice-uncensored-24b-p`), and Gemma 4 (`gemma-4-uncensored`). The command can select route and main independently, so a fast Venice route can be paired with a larger Gemma main model when useful. The older `venice-uncensored` ID is retained only as a legacy alias.
55
+ AgInTiFlow exposes the primary Venice text choices through `/venice`: Venice 1.2 (`venice-uncensored-1-2`), Venice 1.1 (`venice-uncensored`), and Gemma 4 (`gemma-4-uncensored`). The command can select route and main independently, so a fast Venice route can be paired with a larger Gemma main model when useful. The E2EE 1.1 ID (`e2ee-venice-uncensored-24b-p`) is documented for reference, but the selector uses the working non-E2EE 1.1 route because the E2EE route currently returns an upstream provider error in live tests.
56
56
 
57
57
  ## Image And Edit Models
58
58
 
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
7
7
  import {
8
8
  buildLaunchHeaderLines,
9
9
  buildPromptLayout,
10
+ buildPromptRenderSequence,
10
11
  canonicalSlashPromptBuffer,
11
12
  classifyEscapeAction,
12
13
  formatWorkspaceChange,
@@ -131,6 +132,16 @@ try {
131
132
  if (promptLayout.cursorRow < 0 || promptLayout.cursorColumn < 0) {
132
133
  throw new Error("terminal prompt layout returned an invalid cursor location");
133
134
  }
135
+ const promptCursorMoveBefore = buildPromptLayout("smooth typing", 13, 90, 24, { commandCwd: "/tmp/aginti-project" });
136
+ const promptCursorMoveAfter = buildPromptLayout("smooth typing", 4, 90, 24, { commandCwd: "/tmp/aginti-project" });
137
+ const cursorOnlySequence = buildPromptRenderSequence(promptCursorMoveAfter, {
138
+ lineCount: promptCursorMoveBefore.renderedRows.length,
139
+ cursorRow: promptCursorMoveBefore.cursorRow,
140
+ renderedRows: promptCursorMoveBefore.renderedRows,
141
+ });
142
+ if (cursorOnlySequence.includes("\x1b[2K") || cursorOnlySequence.includes("\x1b[?25l")) {
143
+ throw new Error("cursor-only prompt moves should not clear/redraw the input panel");
144
+ }
134
145
  const paddedPromptLayout = buildPromptLayout("hello", 5, 80, 24, { commandCwd: "/tmp/aginti-project" });
135
146
  const paddedRows = paddedPromptLayout.renderedRows.map((line) => line.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ""));
136
147
  if (
@@ -271,6 +282,7 @@ try {
271
282
  "workspace-patch-event-render",
272
283
  "large-launch-header",
273
284
  "prompt-layout",
285
+ "prompt-redraw-fast-path",
274
286
  "user-prompt-label",
275
287
  "escape-policy",
276
288
  "live-input-status-layout",
@@ -9,7 +9,7 @@ import {
9
9
  modelsForProviderGroup,
10
10
  selectModelRoute,
11
11
  } from "../src/model-routing.js";
12
- import { parseTextToolCalls } from "../src/model-client.js";
12
+ import { parseTextToolCalls, usesTextToolProtocol } from "../src/model-client.js";
13
13
 
14
14
  const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
15
15
 
@@ -115,6 +115,18 @@ const parsedTextToolCalls = parseTextToolCalls('[TOOL_CALLS]list_files[ARGS]call
115
115
  assert(parsedTextToolCalls.length === 1, "Venice text tool-call parser did not detect encoded tool call");
116
116
  assert(parsedTextToolCalls[0].function.name === "list_files", "Venice text tool-call parser returned wrong tool name");
117
117
  assert(parsedTextToolCalls[0].function.arguments.includes('"maxDepth":1'), "Venice text tool-call parser returned wrong arguments");
118
+ const looseTextToolCalls = parseTextToolCalls('[TOOL_CALLS]list_files[ARGS]{"path":"."}[TOOL_CALLS]inspect_project[ARGS]{"path":"."}');
119
+ assert(looseTextToolCalls.length === 2, "Venice loose text tool-call parser did not detect multiple calls");
120
+ assert(looseTextToolCalls[1].function.name === "inspect_project", "Venice loose text tool-call parser returned wrong second tool");
121
+ const nativeMarkerText = parseTextToolCalls('Done. <|tool_call>call:finish{result:<|"|>Done<|"|>}');
122
+ assert(nativeMarkerText.length === 0, "native marker text should not be treated as JSON text tool call");
123
+ const jsonBlockToolCalls = parseTextToolCalls('TOOL_CALLS:\n```json\n[{"name":"list_files","arguments":{"path":"/workspace"}}]\n```');
124
+ assert(jsonBlockToolCalls.length === 1, "Venice JSON text tool-call parser did not detect JSON block calls");
125
+ assert(jsonBlockToolCalls[0].function.arguments.includes("/workspace"), "Venice JSON text tool-call parser returned wrong arguments");
126
+ assert(usesTextToolProtocol({ provider: "venice", model: "gemma-4-uncensored" }), "Venice Gemma should use text tool protocol");
127
+ assert(usesTextToolProtocol({ provider: "venice", model: "e2ee-venice-uncensored-24b-p" }), "Venice 1.1 should use text tool protocol");
128
+ assert(usesTextToolProtocol({ provider: "venice", model: "venice-uncensored" }), "Venice legacy 1.1 should use text tool protocol");
129
+ assert(!usesTextToolProtocol({ provider: "venice", model: "venice-uncensored-1-2" }), "Venice 1.2 should keep native tool calls first");
118
130
 
119
131
  const output = await runCli(["models"]);
120
132
  assert(output.includes("/route") && output.includes("/spare") && output.includes("venice-gpt"), "aginti models output missing role details");
@@ -124,7 +136,7 @@ assert(interactiveOutput.includes("venice=on"), "/venice did not enable Venice r
124
136
  assert(interactiveOutput.includes("route=venice/venice-uncensored-1-2"), "/venice did not set Venice route role");
125
137
  assert(interactiveOutput.includes("main=venice/venice-uncensored-1-2"), "/venice did not set Venice main role");
126
138
  const interactiveGemmaOutput = await runInteractive("/venice 1.1 gemma\n");
127
- assert(interactiveGemmaOutput.includes("route=venice/e2ee-venice-uncensored-24b-p"), "/venice 1.1 did not set Venice 1.1 route role");
139
+ assert(interactiveGemmaOutput.includes("route=venice/venice-uncensored"), "/venice 1.1 did not set Venice 1.1 route role");
128
140
  assert(interactiveGemmaOutput.includes("main=venice/gemma-4-uncensored"), "/venice gemma did not set Gemma 4 main role");
129
141
  const interactiveOffOutput = await runInteractive("/venice off\n");
130
142
  assert(interactiveOffOutput.includes("venice=off"), "/venice off did not restore DeepSeek roles");
@@ -739,7 +739,7 @@ async function buildSnapshot(browserState, store, step, config) {
739
739
 
740
740
  async function injectQueuedUserMessages(store, state, observers) {
741
741
  const inbox = await store.drainInbox();
742
- if (inbox.length === 0) return;
742
+ if (inbox.length === 0) return 0;
743
743
 
744
744
  for (const item of inbox) {
745
745
  const content = String(item.content || "").trim();
@@ -762,6 +762,7 @@ async function injectQueuedUserMessages(store, state, observers) {
762
762
  priority: item.priority || "normal",
763
763
  });
764
764
  }
765
+ return inbox.length;
765
766
  }
766
767
 
767
768
  async function executeTool(browserState, toolCall, snapshot, config, store, observers, state) {
@@ -1374,6 +1375,16 @@ export async function runAgent(config) {
1374
1375
  });
1375
1376
 
1376
1377
  throwIfAborted(config);
1378
+ await store.appendEvent("model.requested", {
1379
+ step,
1380
+ provider: config.provider,
1381
+ model: config.model,
1382
+ });
1383
+ observers.event("model.requested", {
1384
+ step,
1385
+ provider: config.provider,
1386
+ model: config.model,
1387
+ });
1377
1388
  const response = await requestNextStep(client, config, state.messages);
1378
1389
  const assistantMessage = response.choices[0]?.message;
1379
1390
  if (!assistantMessage) {
@@ -1399,6 +1410,13 @@ export async function runAgent(config) {
1399
1410
  const toolCalls = assistantMessage.tool_calls || [];
1400
1411
 
1401
1412
  if (toolCalls.length === 0) {
1413
+ const queuedCount = await injectQueuedUserMessages(store, state, observers);
1414
+ if (queuedCount > 0) {
1415
+ state.stepsCompleted = step;
1416
+ state.updatedAt = new Date().toISOString();
1417
+ await store.saveState(state);
1418
+ continue;
1419
+ }
1402
1420
  const fallback = assistantMessage.content?.trim() || "No tool call returned.";
1403
1421
  appendChatEntry(state, "assistant", fallback);
1404
1422
  await store.appendEvent("session.finished", {
@@ -1419,6 +1437,7 @@ export async function runAgent(config) {
1419
1437
  };
1420
1438
  }
1421
1439
 
1440
+ let continueForQueuedInput = false;
1422
1441
  for (const toolCall of toolCalls) {
1423
1442
  throwIfAborted(config);
1424
1443
  const toolResult = await executeTool(browserState, toolCall, snapshot, config, store, observers, state);
@@ -1462,6 +1481,14 @@ export async function runAgent(config) {
1462
1481
  }
1463
1482
 
1464
1483
  if (toolResult.done) {
1484
+ const queuedCount = await injectQueuedUserMessages(store, state, observers);
1485
+ if (queuedCount > 0) {
1486
+ state.stepsCompleted = step;
1487
+ state.updatedAt = new Date().toISOString();
1488
+ await store.saveState(state);
1489
+ continueForQueuedInput = true;
1490
+ break;
1491
+ }
1465
1492
  state.stepsCompleted = step;
1466
1493
  state.updatedAt = new Date().toISOString();
1467
1494
  state.meta.lastUrl = browserState.page?.url() || state.meta.lastUrl;
@@ -1487,6 +1514,8 @@ export async function runAgent(config) {
1487
1514
  }
1488
1515
  }
1489
1516
 
1517
+ if (continueForQueuedInput) continue;
1518
+
1490
1519
  await injectQueuedUserMessages(store, state, observers);
1491
1520
 
1492
1521
  state.stepsCompleted = step;
@@ -832,24 +832,58 @@ function cursorLocation(layout, cursor) {
832
832
 
833
833
  function clearRenderedPrompt(previous) {
834
834
  if (!previous.lineCount) return;
835
+ let sequence = ansi.cursorHide;
835
836
  const below = previous.lineCount - 1 - previous.cursorRow;
836
- if (below > 0) output.write(`\x1b[${below}B`);
837
- output.write(`\r${ansi.clearLine}`);
837
+ if (below > 0) sequence += `\x1b[${below}B`;
838
+ sequence += `\r${ansi.clearLine}`;
838
839
  for (let index = 1; index < previous.lineCount; index += 1) {
839
- output.write(`\x1b[1A\r${ansi.clearLine}`);
840
+ sequence += `\x1b[1A\r${ansi.clearLine}`;
840
841
  }
842
+ sequence += ansi.cursorShow;
843
+ output.write(sequence);
844
+ }
845
+
846
+ function renderedRowsEqual(left = [], right = []) {
847
+ if (left.length !== right.length) return false;
848
+ return left.every((line, index) => line === right[index]);
849
+ }
850
+
851
+ export function buildPromptRenderSequence(layout, previous = { lineCount: 0, cursorRow: 0, renderedRows: [] }) {
852
+ const previousRows = Array.isArray(previous.renderedRows) ? previous.renderedRows : [];
853
+ const previousLineCount = Number(previous.lineCount) || 0;
854
+ const previousCursorRow = Number(previous.cursorRow) || 0;
855
+ const nextRows = layout.renderedRows || [];
856
+
857
+ if (previousLineCount > 0 && renderedRowsEqual(previousRows, nextRows)) {
858
+ const rowDelta = layout.cursorRow - previousCursorRow;
859
+ return `${rowDelta > 0 ? `\x1b[${rowDelta}B` : rowDelta < 0 ? `\x1b[${Math.abs(rowDelta)}A` : ""}\r\x1b[${
860
+ layout.cursorColumn + 1
861
+ }G`;
862
+ }
863
+
864
+ let sequence = ansi.cursorHide;
865
+ if (previousLineCount > 0) {
866
+ const below = Math.max(previousLineCount - 1 - previousCursorRow, 0);
867
+ if (below > 0) sequence += `\x1b[${below}B`;
868
+ sequence += "\r";
869
+ if (previousLineCount > 1) sequence += `\x1b[${previousLineCount - 1}A`;
870
+ }
871
+
872
+ const lineCount = Math.max(previousLineCount, nextRows.length);
873
+ for (let index = 0; index < lineCount; index += 1) {
874
+ sequence += `\r${ansi.clearLine}${nextRows[index] || ""}`;
875
+ if (index < lineCount - 1) sequence += "\n";
876
+ }
877
+
878
+ const cursorUp = Math.max(lineCount - 1 - layout.cursorRow, 0);
879
+ if (cursorUp > 0) sequence += `\x1b[${cursorUp}A`;
880
+ sequence += `\r\x1b[${layout.cursorColumn + 1}G${ansi.cursorShow}`;
881
+ return sequence;
841
882
  }
842
883
 
843
884
  function renderPromptBuffer(buffer, cursor, previous = { lineCount: 0, cursorRow: 0, renderedRows: [] }, options = {}) {
844
885
  const layout = buildPromptLayout(buffer, cursor, terminalWidth(), terminalHeight(), options);
845
- output.write(ansi.cursorHide);
846
- clearRenderedPrompt(previous);
847
- output.write(layout.renderedRows.join("\n"));
848
-
849
- const below = layout.renderedRows.length - 1 - layout.cursorRow;
850
- if (below > 0) output.write(`\x1b[${below}A`);
851
- output.write(`\r\x1b[${layout.cursorColumn + 1}G`);
852
- output.write(ansi.cursorShow);
886
+ output.write(buildPromptRenderSequence(layout, previous));
853
887
  return {
854
888
  lineCount: layout.renderedRows.length,
855
889
  cursorRow: layout.cursorRow,
@@ -1650,9 +1684,9 @@ function veniceTextModelChoices() {
1650
1684
  },
1651
1685
  {
1652
1686
  provider: "venice",
1653
- model: "e2ee-venice-uncensored-24b-p",
1687
+ model: "venice-uncensored",
1654
1688
  label: "Venice 1.1",
1655
- description: "E2EE Venice text model; 32K context",
1689
+ description: "legacy Venice text model; 32K context",
1656
1690
  },
1657
1691
  {
1658
1692
  provider: "venice",
@@ -1670,14 +1704,16 @@ function resolveVeniceTextModel(value = "") {
1670
1704
  }
1671
1705
  if (
1672
1706
  normalized === "1.1" ||
1673
- normalized === "venice-1.1" ||
1674
- normalized === "e2ee-venice-uncensored-24b-p"
1707
+ normalized === "venice-1.1"
1675
1708
  ) {
1676
- return "e2ee-venice-uncensored-24b-p";
1709
+ return "venice-uncensored";
1677
1710
  }
1678
1711
  if (normalized === "legacy" || normalized === "venice-uncensored") {
1679
1712
  return "venice-uncensored";
1680
1713
  }
1714
+ if (normalized === "e2ee" || normalized === "e2ee-venice-uncensored-24b-p") {
1715
+ return "e2ee-venice-uncensored-24b-p";
1716
+ }
1681
1717
  if (normalized === "gemma" || normalized === "gemma4" || normalized === "gemma-4" || normalized === "gemma-4-uncensored") {
1682
1718
  return "gemma-4-uncensored";
1683
1719
  }
@@ -1781,9 +1817,9 @@ function modelRoleChoices(role = "main") {
1781
1817
  },
1782
1818
  {
1783
1819
  provider: "venice",
1784
- model: "e2ee-venice-uncensored-24b-p",
1820
+ model: "venice-uncensored",
1785
1821
  label: "Venice 1.1",
1786
- description: "E2EE Venice text route; use /auth venice if missing",
1822
+ description: "legacy Venice text route; use /auth venice if missing",
1787
1823
  route: true,
1788
1824
  main: true,
1789
1825
  spare: true,
@@ -1930,8 +1966,11 @@ function clearSelector(lineCount) {
1930
1966
  }
1931
1967
  }
1932
1968
 
1969
+ function clearSelectorSequence(lineCount) {
1970
+ return Array.from({ length: Math.max(lineCount, 0) }, () => "\x1b[1A\r\x1b[2K").join("");
1971
+ }
1972
+
1933
1973
  function renderSelector({ title, subtitle, options, selectedIndex, lineCount = 0 }) {
1934
- if (lineCount > 0) clearSelector(lineCount);
1935
1974
  const width = Math.min(Math.max(terminalWidth() - 2, 60), 110);
1936
1975
  const bodyWidth = width - 4;
1937
1976
  const safeTitle = compactLine(title, bodyWidth);
@@ -1948,7 +1987,7 @@ function renderSelector({ title, subtitle, options, selectedIndex, lineCount = 0
1948
1987
  }),
1949
1988
  `╰${"─".repeat(width - 2)}╯`,
1950
1989
  ];
1951
- output.write(`${rows.join("\n")}\n`);
1990
+ output.write(`${lineCount > 0 ? clearSelectorSequence(lineCount) : ""}${rows.join("\n")}\n`);
1952
1991
  return rows.length;
1953
1992
  }
1954
1993
 
@@ -2639,6 +2678,7 @@ async function runPrompt(prompt, state, packageDir) {
2639
2678
  printSystemLine(`status=running workingOn=${state.activeGoal}`);
2640
2679
  }
2641
2680
  let result;
2681
+ let runError = null;
2642
2682
  let queuedAfterFinish = [];
2643
2683
  try {
2644
2684
  result = await runAgent({
@@ -2666,6 +2706,8 @@ async function runPrompt(prompt, state, packageDir) {
2666
2706
  onEvent: (type, data = {}) => {
2667
2707
  if (type === "plan.created") {
2668
2708
  printStatusEvent(state, "planned");
2709
+ } else if (type === "model.requested") {
2710
+ printStatusEvent(state, "model_wait", `${data.provider || "model"}/${data.model || ""}`);
2669
2711
  } else if (type === "tool.started") {
2670
2712
  printStatusEvent(state, "tool", data.toolName || "unknown");
2671
2713
  } else if (type === "tool.completed") {
@@ -2688,10 +2730,18 @@ async function runPrompt(prompt, state, packageDir) {
2688
2730
  }
2689
2731
  },
2690
2732
  });
2733
+ } catch (error) {
2734
+ runError = error;
2691
2735
  } finally {
2692
2736
  detachInterrupts();
2693
2737
  queuedAfterFinish = await liveInput.stop();
2694
2738
  }
2739
+ if (runError) {
2740
+ state.status = isAbortError(runError) ? "stopped" : "failed";
2741
+ state.activeGoal = "";
2742
+ printSystemLine(`status=${state.status} session=${state.sessionId}`);
2743
+ throw runError;
2744
+ }
2695
2745
  state.sessionId = result.sessionId || state.sessionId;
2696
2746
  state.status = result.stopped ? "stopped" : "idle";
2697
2747
  state.activeGoal = "";
@@ -73,16 +73,110 @@ function toolChoiceForProvider(config, messages = []) {
73
73
  return messages.some((message) => message.role === "tool") ? "auto" : "required";
74
74
  }
75
75
 
76
+ export function usesTextToolProtocol(config = {}) {
77
+ if (config.provider !== "venice") return false;
78
+ const model = String(config.model || "").toLowerCase();
79
+ return model === "gemma-4-uncensored" || model === "e2ee-venice-uncensored-24b-p" || model === "venice-uncensored";
80
+ }
81
+
82
+ function shouldRetryWithTextToolProtocol(error, config = {}) {
83
+ if (config.provider !== "venice") return false;
84
+ const message = [
85
+ error?.message,
86
+ error?.error?.message,
87
+ error?.response?.data?.error?.message,
88
+ error?.response?.data?.message,
89
+ ]
90
+ .filter(Boolean)
91
+ .join(" ");
92
+ return /invalid request parameters|tool_choice|parallel_tool_calls|tools/i.test(message);
93
+ }
94
+
95
+ function textToolProtocolPrompt(tools = []) {
96
+ const toolLines = tools.map((tool) => {
97
+ const fn = tool.function || {};
98
+ const properties = fn.parameters?.properties ? Object.keys(fn.parameters.properties).slice(0, 8) : [];
99
+ const required = Array.isArray(fn.parameters?.required) ? fn.parameters.required : [];
100
+ const args = properties.length > 0 ? ` args=${properties.join(",")}${required.length ? ` required=${required.join(",")}` : ""}` : "";
101
+ return `- ${fn.name}: ${String(fn.description || "").slice(0, 180)}${args}`;
102
+ });
103
+ return [
104
+ "This provider/model may not accept native OpenAI function-call parameters.",
105
+ "Use this text tool protocol when you need a tool:",
106
+ '[TOOL_CALLS]tool_name[ARGS]{"arg":"value"}',
107
+ 'A strict id form is also accepted: [TOOL_CALLS]tool_name[ARGS]call_short_id[ARGS]{"arg":"value"}',
108
+ 'A JSON block form is accepted too: TOOL_CALLS: ```json [{"name":"tool_name","arguments":{"arg":"value"}}] ```',
109
+ "Return only one or more TOOL_CALLS blocks when calling tools; do not wrap them in markdown.",
110
+ "If no tool is needed, answer normally.",
111
+ "Available text tools:",
112
+ ...toolLines,
113
+ ].join("\n");
114
+ }
115
+
116
+ function messagesWithTextToolProtocol(config, messages, tools) {
117
+ const prepared = prepareMessages(config, messages).map((message) => {
118
+ if (message.role === "tool") {
119
+ return {
120
+ role: "user",
121
+ content: `Tool result for ${message.tool_call_id || "previous tool"}:\n${message.content || ""}`,
122
+ };
123
+ }
124
+ if (message.role === "assistant" && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
125
+ return {
126
+ role: "assistant",
127
+ content:
128
+ message.content ||
129
+ `Requested tools: ${message.tool_calls
130
+ .map((call) => `${call.function?.name || "tool"}(${call.function?.arguments || "{}"})`)
131
+ .join("; ")}`,
132
+ };
133
+ }
134
+ return message;
135
+ });
136
+ const protocol = { role: "system", content: textToolProtocolPrompt(tools) };
137
+ if (prepared[0]?.role === "system") return [prepared[0], protocol, ...prepared.slice(1)];
138
+ return [protocol, ...prepared];
139
+ }
140
+
76
141
  export function parseTextToolCalls(content = "") {
77
142
  const text = String(content || "");
78
- if (!text.includes("[TOOL_CALLS]")) return [];
143
+ if (!text.includes("[TOOL_CALLS]") && !/TOOL_CALLS\s*:/i.test(text)) return [];
79
144
 
80
145
  const calls = [];
81
- const pattern = /\[TOOL_CALLS\]([A-Za-z0-9_-]+)\[ARGS\]([A-Za-z0-9_.:-]+)\[ARGS\]([\s\S]*?)(?=\[TOOL_CALLS\]|$)/g;
82
- for (const match of text.matchAll(pattern)) {
83
- const name = match[1]?.trim();
84
- const id = match[2]?.trim() || `text-tool-${calls.length + 1}`;
85
- const rawArgs = match[3]?.trim() || "{}";
146
+ const jsonBlock = text.match(/TOOL_CALLS\s*:\s*```(?:json)?\s*([\s\S]*?)```/i);
147
+ if (jsonBlock?.[1]) {
148
+ try {
149
+ const parsed = JSON.parse(jsonBlock[1].trim());
150
+ if (Array.isArray(parsed)) {
151
+ for (const item of parsed) {
152
+ const name = String(item?.name || item?.tool || "").trim();
153
+ if (!name) continue;
154
+ const args = item?.arguments && typeof item.arguments === "object" ? item.arguments : {};
155
+ calls.push({
156
+ id: String(item?.id || `text-tool-${calls.length + 1}`),
157
+ type: "function",
158
+ function: {
159
+ name,
160
+ arguments: JSON.stringify(args),
161
+ },
162
+ });
163
+ }
164
+ }
165
+ } catch {
166
+ // Fall through to bracket parser below.
167
+ }
168
+ }
169
+
170
+ for (const chunk of text.split("[TOOL_CALLS]").slice(1)) {
171
+ const match = chunk.match(/^([A-Za-z0-9_-]+)\[ARGS\]([\s\S]*?)$/);
172
+ const name = match?.[1]?.trim();
173
+ let rawArgs = match?.[2]?.trim() || "{}";
174
+ let id = `text-tool-${calls.length + 1}`;
175
+ const strictParts = rawArgs.split("[ARGS]");
176
+ if (strictParts.length >= 2 && !rawArgs.startsWith("{") && !rawArgs.startsWith("[")) {
177
+ id = strictParts.shift()?.trim() || id;
178
+ rawArgs = strictParts.join("[ARGS]").trim() || "{}";
179
+ }
86
180
  if (!name) continue;
87
181
  try {
88
182
  JSON.parse(rawArgs);
@@ -101,14 +195,39 @@ export function parseTextToolCalls(content = "") {
101
195
  return calls;
102
196
  }
103
197
 
198
+ function textBeforeToolCallMarker(content = "") {
199
+ return String(content || "")
200
+ .split("[TOOL_CALLS]")[0]
201
+ .split("TOOL_CALLS:")[0]
202
+ .split("<|tool_call>")[0]
203
+ .trim();
204
+ }
205
+
104
206
  function normalizeTextToolCallResponse(response) {
105
207
  const message = response?.choices?.[0]?.message;
106
208
  if (!message || Array.isArray(message.tool_calls) && message.tool_calls.length > 0) return response;
107
209
 
108
210
  const calls = parseTextToolCalls(message.content || "");
109
- if (calls.length === 0) return response;
211
+ if (calls.length === 0) {
212
+ const cleanedContent = textBeforeToolCallMarker(message.content || "");
213
+ if (!cleanedContent || cleanedContent === message.content) return response;
214
+ return {
215
+ ...response,
216
+ choices: response.choices.map((choice, index) =>
217
+ index === 0
218
+ ? {
219
+ ...choice,
220
+ message: {
221
+ ...message,
222
+ content: cleanedContent,
223
+ },
224
+ }
225
+ : choice
226
+ ),
227
+ };
228
+ }
110
229
 
111
- const content = String(message.content || "").split("[TOOL_CALLS]")[0].trim();
230
+ const content = textBeforeToolCallMarker(message.content || "");
112
231
  return {
113
232
  ...response,
114
233
  choices: response.choices.map((choice, index) =>
@@ -919,16 +1038,33 @@ export async function requestNextStep(client, config, messages) {
919
1038
  ]);
920
1039
  }
921
1040
 
922
- const response = await client.chat.completions.create(
923
- {
924
- model: config.model,
925
- temperature: 0,
926
- tool_choice: toolChoiceForProvider(config, messages),
927
- parallel_tool_calls: false,
928
- messages: prepareMessages(config, messages),
929
- tools,
930
- },
931
- requestOptions(config)
932
- );
1041
+ const textToolProtocol = usesTextToolProtocol(config);
1042
+ const nativePayload = {
1043
+ model: config.model,
1044
+ temperature: 0,
1045
+ tool_choice: toolChoiceForProvider(config, messages),
1046
+ parallel_tool_calls: false,
1047
+ messages: prepareMessages(config, messages),
1048
+ tools,
1049
+ };
1050
+ const textPayload = {
1051
+ model: config.model,
1052
+ temperature: 0,
1053
+ messages: messagesWithTextToolProtocol(config, messages, tools),
1054
+ };
1055
+
1056
+ let response;
1057
+ try {
1058
+ response = await client.chat.completions.create(
1059
+ textToolProtocol ? textPayload : nativePayload,
1060
+ requestOptions(config)
1061
+ );
1062
+ } catch (error) {
1063
+ if (!textToolProtocol && shouldRetryWithTextToolProtocol(error, config)) {
1064
+ response = await client.chat.completions.create(textPayload, requestOptions(config));
1065
+ } else {
1066
+ throw error;
1067
+ }
1068
+ }
933
1069
  return normalizeTextToolCallResponse(response);
934
1070
  }