anyclaude-sdk 0.4.9 → 0.5.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.
package/dist/agent.d.ts CHANGED
@@ -49,6 +49,11 @@ export interface AgentOptions {
49
49
  content: string | ContentBlockParam[];
50
50
  is_error?: boolean;
51
51
  }>;
52
+ /** Validate tool arguments before executing; on malformed/incomplete JSON,
53
+ * return a corrective `is_error` tool_result (with the expected schema) so the
54
+ * model self-heals instead of running with garbage. Default `true`. The single
55
+ * biggest reliability win for weak/cheap models. See `anyclaude-sdk/llm` repair. */
56
+ repairToolCalls?: boolean;
52
57
  cwd?: string;
53
58
  sessionId?: string;
54
59
  abortController?: AbortController;
package/dist/agent.js CHANGED
@@ -24,6 +24,7 @@ import { defaultSystemPrompt, defaultSubagentPrompt } from './prompt.js';
24
24
  import { DEFAULT_MAX_RESULT_CHARS, maybePersistLargeResult } from './persist.js';
25
25
  import { computeCostUSD, contextWindowFor } from './util/pricing.js';
26
26
  import { estimateTokens, summarizeHistory } from './compact.js';
27
+ import { validateToolArguments } from './llm/repair.js';
27
28
  import { uuid } from './util/ids.js';
28
29
  /** Wrap a single text prompt into the async-iterable form runAgent expects. */
29
30
  async function* singleUserPrompt(text) {
@@ -203,6 +204,7 @@ export async function* runAgent(options) {
203
204
  : undefined;
204
205
  const messageQueue = options.messageQueue;
205
206
  const clientTools = new Set(options.clientTools ?? []);
207
+ const repairToolCalls = options.repairToolCalls !== false;
206
208
  // Teammates: a shared Mailbox + TaskBoard (reused from the parent when this
207
209
  // is a sub-agent) + team tools + coordinator prompt.
208
210
  const teamEnabled = options.team === true;
@@ -717,7 +719,19 @@ export async function* runAgent(options) {
717
719
  let content = '';
718
720
  let isError = false;
719
721
  let extraContext = '';
720
- if (!tool || !tool.run) {
722
+ // Repair: validate args against the tool schema before running a server
723
+ // tool; on malformed/incomplete JSON, return a corrective tool_result so
724
+ // the model retries with valid JSON instead of executing with garbage.
725
+ const repairCheck = repairToolCalls && tool && tool.run
726
+ ? validateToolArguments(tool.def, call.function.arguments)
727
+ : null;
728
+ if (repairCheck && repairCheck.ok)
729
+ input = repairCheck.input;
730
+ if (repairCheck && !repairCheck.ok) {
731
+ content = repairCheck.error;
732
+ isError = true;
733
+ }
734
+ else if (!tool || !tool.run) {
721
735
  // Unknown, or a run-less (client-delegated) tool that somehow reached
722
736
  // server execution — both are errors here (delegated tools are handled
723
737
  // above via clientTools).
@@ -0,0 +1,32 @@
1
+ import type { ToolCall } from '../types/index.js';
2
+ export interface ParsedToolCalls {
3
+ /** Tool calls recovered from the text (empty if none matched). */
4
+ calls: ToolCall[];
5
+ /** The text with tool-call markup removed (safe to show the user). */
6
+ cleanedText: string;
7
+ }
8
+ export interface ToolDialect {
9
+ /** Stable id, e.g. 'xml-function' | 'hermes' | 'json-fence'. */
10
+ name: string;
11
+ /** Cheap presence check — does this dialect's markup appear at all? */
12
+ test(text: string): boolean;
13
+ /** Extract calls + strip markup. `idBase` seeds generated call ids. */
14
+ parse(text: string, idBase?: number): ParsedToolCalls;
15
+ }
16
+ export declare const xmlFunctionDialect: ToolDialect;
17
+ export declare const hermesDialect: ToolDialect;
18
+ export declare const jsonFenceDialect: ToolDialect;
19
+ /** All built-in dialects, keyed by name. */
20
+ export declare const dialects: Record<string, ToolDialect>;
21
+ /** Default attempt order — xml-function first preserves original behavior. */
22
+ export declare const DEFAULT_DIALECTS: string[];
23
+ /**
24
+ * Try a list of dialects (by name) against text and return the first that
25
+ * yields tool calls. Falls back to `{ calls: [], cleanedText: text }`.
26
+ */
27
+ export declare function parseToolCalls(text: string, opts?: {
28
+ dialects?: string[];
29
+ idBase?: number;
30
+ }): ParsedToolCalls;
31
+ /** True if ANY of the given dialects (default: all) detects tool-call markup. */
32
+ export declare function hasToolCalls(text: string, order?: string[]): boolean;
@@ -0,0 +1,218 @@
1
+ /** Strip a single leading newline and trailing whitespace from a param value. */
2
+ function trimEdges(v) {
3
+ return v.replace(/^\r?\n/, '').replace(/\s+$/, '');
4
+ }
5
+ // ---------------------------------------------------------------------------
6
+ // xml-function — <function=name><parameter=key>value</parameter></function>
7
+ // (closing tags optional; wrapper <tool_call> optional). This is the original
8
+ // anyclaude inline format and stays first in the default order for back-compat.
9
+ // ---------------------------------------------------------------------------
10
+ const XML_FUNCTION_MARKER = /<function\s*=/;
11
+ export const xmlFunctionDialect = {
12
+ name: 'xml-function',
13
+ test: (text) => XML_FUNCTION_MARKER.test(text),
14
+ parse(text, idBase = 0) {
15
+ if (!text || !XML_FUNCTION_MARKER.test(text))
16
+ return { calls: [], cleanedText: text };
17
+ const calls = [];
18
+ const markerRe = /<function\s*=\s*([^>\s]+)\s*>/g;
19
+ const markers = [];
20
+ let m;
21
+ while ((m = markerRe.exec(text)) !== null) {
22
+ markers.push({ name: m[1], bodyStart: markerRe.lastIndex, markerStart: m.index });
23
+ }
24
+ for (let i = 0; i < markers.length; i++) {
25
+ const cur = markers[i];
26
+ const end = i + 1 < markers.length ? markers[i + 1].markerStart : text.length;
27
+ let body = text.slice(cur.bodyStart, end);
28
+ body = body.replace(/<\/function>[\s\S]*$/, '').replace(/<\/tool_call>[\s\S]*$/, '');
29
+ const args = {};
30
+ const parts = body.split(/<parameter\s*=/).slice(1);
31
+ for (const part of parts) {
32
+ const gt = part.indexOf('>');
33
+ if (gt < 0)
34
+ continue;
35
+ const key = part.slice(0, gt).trim();
36
+ let val = part.slice(gt + 1);
37
+ val = val
38
+ .replace(/<\/parameter>[\s\S]*$/, '')
39
+ .replace(/<\/function>[\s\S]*$/, '')
40
+ .replace(/<\/tool_call>[\s\S]*$/, '');
41
+ args[key] = trimEdges(val);
42
+ }
43
+ calls.push({
44
+ id: `call_inline_${idBase + i}`,
45
+ type: 'function',
46
+ function: { name: cur.name.trim(), arguments: JSON.stringify(args) },
47
+ });
48
+ }
49
+ const cut = text.search(/<tool_call>|<function\s*=/);
50
+ const cleanedText = cut >= 0 ? text.slice(0, cut).trim() : text;
51
+ return { calls, cleanedText };
52
+ },
53
+ };
54
+ // ---------------------------------------------------------------------------
55
+ // hermes — <tool_call>{"name": "...", "arguments": {...}}</tool_call>
56
+ // Accepts "arguments" | "parameters" | "args"; tolerates a missing closing tag.
57
+ // Used by Qwen, Hermes/NousResearch, and many Ollama-served models.
58
+ // ---------------------------------------------------------------------------
59
+ const HERMES_OPEN = /<tool_call>/i;
60
+ export const hermesDialect = {
61
+ name: 'hermes',
62
+ test: (text) => HERMES_OPEN.test(text) && text.includes('{'),
63
+ parse(text, idBase = 0) {
64
+ if (!HERMES_OPEN.test(text))
65
+ return { calls: [], cleanedText: text };
66
+ const calls = [];
67
+ const blockRe = /<tool_call>\s*([\s\S]*?)(?:<\/tool_call>|$)/gi;
68
+ let m;
69
+ let i = 0;
70
+ while ((m = blockRe.exec(text)) !== null) {
71
+ const obj = extractFirstJsonObject(m[1]);
72
+ if (!obj)
73
+ continue;
74
+ const call = jsonToToolCall(obj, idBase + i);
75
+ if (call) {
76
+ calls.push(call);
77
+ i++;
78
+ }
79
+ }
80
+ const cut = text.search(HERMES_OPEN);
81
+ const cleanedText = cut >= 0 ? text.slice(0, cut).trim() : text;
82
+ return { calls, cleanedText };
83
+ },
84
+ };
85
+ // ---------------------------------------------------------------------------
86
+ // json-fence — a fenced code block whose JSON looks like a tool call:
87
+ // ```json | ```tool_call | ```tool
88
+ // {"name": "...", "arguments": {...}} (also "tool"/"args"/"parameters")
89
+ // ```
90
+ // Conservative: only treats a block as a call when it has BOTH a name key
91
+ // (name|tool|function) AND an args key (arguments|args|parameters|input), so
92
+ // ordinary JSON the model prints for the user is not misread as a tool call.
93
+ // ---------------------------------------------------------------------------
94
+ const FENCE_RE = /```(?:json|tool_call|tool)?\s*\n?([\s\S]*?)```/gi;
95
+ export const jsonFenceDialect = {
96
+ name: 'json-fence',
97
+ test: (text) => /```/.test(text) && /"(name|tool|function)"\s*:/.test(text),
98
+ parse(text, idBase = 0) {
99
+ const calls = [];
100
+ let firstMatchIndex = -1;
101
+ let m;
102
+ let i = 0;
103
+ while ((m = FENCE_RE.exec(text)) !== null) {
104
+ const obj = extractFirstJsonObject(m[1]);
105
+ if (!obj)
106
+ continue;
107
+ const call = jsonToToolCall(obj, idBase + i);
108
+ if (call) {
109
+ if (firstMatchIndex < 0)
110
+ firstMatchIndex = m.index;
111
+ calls.push(call);
112
+ i++;
113
+ }
114
+ }
115
+ FENCE_RE.lastIndex = 0;
116
+ const cleanedText = firstMatchIndex >= 0 ? text.slice(0, firstMatchIndex).trim() : text;
117
+ return { calls, cleanedText };
118
+ },
119
+ };
120
+ /** All built-in dialects, keyed by name. */
121
+ export const dialects = {
122
+ 'xml-function': xmlFunctionDialect,
123
+ hermes: hermesDialect,
124
+ 'json-fence': jsonFenceDialect,
125
+ };
126
+ /** Default attempt order — xml-function first preserves original behavior. */
127
+ export const DEFAULT_DIALECTS = ['xml-function', 'hermes', 'json-fence'];
128
+ /**
129
+ * Try a list of dialects (by name) against text and return the first that
130
+ * yields tool calls. Falls back to `{ calls: [], cleanedText: text }`.
131
+ */
132
+ export function parseToolCalls(text, opts = {}) {
133
+ if (!text)
134
+ return { calls: [], cleanedText: text };
135
+ const order = opts.dialects ?? DEFAULT_DIALECTS;
136
+ for (const name of order) {
137
+ const d = dialects[name];
138
+ if (!d || !d.test(text))
139
+ continue;
140
+ const parsed = d.parse(text, opts.idBase ?? 0);
141
+ if (parsed.calls.length)
142
+ return parsed;
143
+ }
144
+ return { calls: [], cleanedText: text };
145
+ }
146
+ /** True if ANY of the given dialects (default: all) detects tool-call markup. */
147
+ export function hasToolCalls(text, order = DEFAULT_DIALECTS) {
148
+ return order.some((n) => dialects[n]?.test(text));
149
+ }
150
+ // ---------------------------------------------------------------------------
151
+ // helpers
152
+ // ---------------------------------------------------------------------------
153
+ /** Find and parse the first balanced top-level `{...}` JSON object in a string. */
154
+ function extractFirstJsonObject(s) {
155
+ const start = s.indexOf('{');
156
+ if (start < 0)
157
+ return null;
158
+ let depth = 0;
159
+ let inStr = false;
160
+ let esc = false;
161
+ for (let i = start; i < s.length; i++) {
162
+ const ch = s[i];
163
+ if (inStr) {
164
+ if (esc)
165
+ esc = false;
166
+ else if (ch === '\\')
167
+ esc = true;
168
+ else if (ch === '"')
169
+ inStr = false;
170
+ continue;
171
+ }
172
+ if (ch === '"')
173
+ inStr = true;
174
+ else if (ch === '{')
175
+ depth++;
176
+ else if (ch === '}') {
177
+ depth--;
178
+ if (depth === 0) {
179
+ const slice = s.slice(start, i + 1);
180
+ try {
181
+ const v = JSON.parse(slice);
182
+ return v && typeof v === 'object' ? v : null;
183
+ }
184
+ catch {
185
+ return null;
186
+ }
187
+ }
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+ /** Coerce a `{name|tool|function, arguments|args|parameters|input}` object into a ToolCall. */
193
+ function jsonToToolCall(obj, idx) {
194
+ // Some emitters wrap as { "tool_call": {...} } or { "function": {name, arguments} }.
195
+ if (obj.tool_call && typeof obj.tool_call === 'object') {
196
+ return jsonToToolCall(obj.tool_call, idx);
197
+ }
198
+ let name = obj.name ?? obj.tool ?? obj.function;
199
+ let rawArgs = obj.arguments ?? obj.args ?? obj.parameters ?? obj.input;
200
+ // Nested OpenAI shape: { function: { name, arguments } }
201
+ if (name && typeof name === 'object') {
202
+ const fn = name;
203
+ rawArgs = rawArgs ?? fn.arguments ?? fn.args;
204
+ name = fn.name;
205
+ }
206
+ if (typeof name !== 'string' || !name)
207
+ return null;
208
+ const argsStr = typeof rawArgs === 'string'
209
+ ? rawArgs
210
+ : rawArgs === undefined
211
+ ? '{}'
212
+ : JSON.stringify(rawArgs);
213
+ return {
214
+ id: `call_inline_${idx}`,
215
+ type: 'function',
216
+ function: { name: name.trim(), arguments: argsStr || '{}' },
217
+ };
218
+ }
@@ -2,4 +2,7 @@ export * from './openai.js';
2
2
  export * from './anthropic.js';
3
3
  export * from './responses.js';
4
4
  export { hasInlineToolCalls, parseInlineToolCalls } from './inlineTools.js';
5
+ export { parseToolCalls, hasToolCalls, dialects, DEFAULT_DIALECTS, xmlFunctionDialect, hermesDialect, jsonFenceDialect, type ToolDialect, type ParsedToolCalls, } from './dialects.js';
6
+ export { profileForModel, toolGuidancePrompt, builtinProfiles, genericProfile, type ModelProfile, } from './profiles.js';
7
+ export { validateToolArguments, schemaHint, type ArgValidation } from './repair.js';
5
8
  export type { LLMClient, ChatMsg, StreamResult, ToolCall, ToolDef, StopReason, Usage, ContentBlockParam, } from '../types/index.js';
package/dist/llm/index.js CHANGED
@@ -4,3 +4,12 @@ export * from './responses.js';
4
4
  // Inline tool-call parsing — recover tool calls a model emitted as TEXT
5
5
  // (e.g. weak models that narrate tool calls instead of using native function calls).
6
6
  export { hasInlineToolCalls, parseInlineToolCalls } from './inlineTools.js';
7
+ // Tool-call dialects — pluggable parsers for the inline formats cheap/open
8
+ // models use (xml-function, hermes, json-fence) when they skip native tool_calls.
9
+ export { parseToolCalls, hasToolCalls, dialects, DEFAULT_DIALECTS, xmlFunctionDialect, hermesDialect, jsonFenceDialect, } from './dialects.js';
10
+ // Model profiles — per-model quirks (dialects, tool_choice, parallel, temperature,
11
+ // guidance) for reliable tool use across the long tail of OpenAI-compatible endpoints.
12
+ export { profileForModel, toolGuidancePrompt, builtinProfiles, genericProfile, } from './profiles.js';
13
+ // Tool-call repair — validate args before executing and feed the model a
14
+ // corrective tool_result so it self-heals (the big reliability win for weak models).
15
+ export { validateToolArguments, schemaHint } from './repair.js';
@@ -1,9 +1,8 @@
1
1
  import type { ToolCall } from '../types/index.js';
2
2
  export declare function hasInlineToolCalls(text: string): boolean;
3
3
  /**
4
- * Extract inline tool calls from assistant text. Returns the parsed calls and
5
- * the text with the tool-call markup removed. If none are found, returns the
6
- * original text and an empty array.
4
+ * Extract inline tool calls from assistant text across all built-in dialects.
5
+ * Returns the parsed calls and the text with the tool-call markup removed.
7
6
  */
8
7
  export declare function parseInlineToolCalls(text: string): {
9
8
  calls: ToolCall[];
@@ -1,72 +1,11 @@
1
- // Fallback parser for models/proxies that emit tool calls as inline TEXT
2
- // instead of native function-calling blocks. Several relays and open models use
3
- // an "XML" tool-call format like:
4
- //
5
- // <tool_call>
6
- // <function=write_file>
7
- // <parameter=path>index.html</parameter>
8
- // <parameter=content>
9
- // <!DOCTYPE html> ...
10
- // </parameter>
11
- // </function>
12
- // </tool_call>
13
- //
14
- // Parameters may or may not have closing </parameter> tags, and the wrapper
15
- // <tool_call> may be absent. This parser is tolerant of all those variants and
16
- // also strips the markup out of the user-visible text.
17
- const FUNCTION_MARKER = /<function\s*=/;
1
+ import { hasToolCalls, parseToolCalls } from './dialects.js';
18
2
  export function hasInlineToolCalls(text) {
19
- return FUNCTION_MARKER.test(text);
3
+ return hasToolCalls(text);
20
4
  }
21
5
  /**
22
- * Extract inline tool calls from assistant text. Returns the parsed calls and
23
- * the text with the tool-call markup removed. If none are found, returns the
24
- * original text and an empty array.
6
+ * Extract inline tool calls from assistant text across all built-in dialects.
7
+ * Returns the parsed calls and the text with the tool-call markup removed.
25
8
  */
26
9
  export function parseInlineToolCalls(text) {
27
- if (!text || !FUNCTION_MARKER.test(text))
28
- return { calls: [], cleanedText: text };
29
- const calls = [];
30
- const markerRe = /<function\s*=\s*([^>\s]+)\s*>/g;
31
- const markers = [];
32
- let m;
33
- while ((m = markerRe.exec(text)) !== null) {
34
- markers.push({ name: m[1], bodyStart: markerRe.lastIndex, markerStart: m.index });
35
- }
36
- for (let i = 0; i < markers.length; i++) {
37
- const cur = markers[i];
38
- const end = i + 1 < markers.length ? markers[i + 1].markerStart : text.length;
39
- let body = text.slice(cur.bodyStart, end);
40
- // Trim at the function/tool_call closers if present.
41
- body = body.replace(/<\/function>[\s\S]*$/, '').replace(/<\/tool_call>[\s\S]*$/, '');
42
- const args = {};
43
- const parts = body.split(/<parameter\s*=/).slice(1);
44
- for (const part of parts) {
45
- const gt = part.indexOf('>');
46
- if (gt < 0)
47
- continue;
48
- const key = part.slice(0, gt).trim();
49
- let val = part.slice(gt + 1);
50
- // Value ends at its own closer (or the function/tool_call closer, or the
51
- // next parameter — already removed by the split).
52
- val = val
53
- .replace(/<\/parameter>[\s\S]*$/, '')
54
- .replace(/<\/function>[\s\S]*$/, '')
55
- .replace(/<\/tool_call>[\s\S]*$/, '');
56
- args[key] = trimEdges(val);
57
- }
58
- calls.push({
59
- id: `call_inline_${i}`,
60
- type: 'function',
61
- function: { name: cur.name.trim(), arguments: JSON.stringify(args) },
62
- });
63
- }
64
- // Everything from the first tool-call/function marker onward is markup.
65
- const cut = text.search(/<tool_call>|<function\s*=/);
66
- const cleanedText = cut >= 0 ? text.slice(0, cut).trim() : text;
67
- return { calls, cleanedText };
68
- }
69
- /** Strip a single leading newline and trailing whitespace from a param value. */
70
- function trimEdges(v) {
71
- return v.replace(/^\r?\n/, '').replace(/\s+$/, '');
10
+ return parseToolCalls(text);
72
11
  }
@@ -1,4 +1,5 @@
1
1
  import type { ChatMsg, ContentBlockParam, LLMClient } from '../types/index.js';
2
+ import { type ModelProfile } from './profiles.js';
2
3
  export interface OpenAIClientOptions {
3
4
  /** API key, or a function returning one per request (for round-robin key pools). */
4
5
  apiKey?: string | (() => string | undefined);
@@ -16,6 +17,20 @@ export interface OpenAIClientOptions {
16
17
  reasoningEffort?: string;
17
18
  /** Allow the model to batch multiple tool calls → sets `parallel_tool_calls` (when tools present). */
18
19
  parallelToolCalls?: boolean;
20
+ /**
21
+ * Per-model quirks for reliable tool use on cheap/open endpoints. Pass a
22
+ * `ModelProfile`, a built-in name ('qwen'|'deepseek'|'moonshot'|'zhipu'|
23
+ * 'mistral'|'llama'|'openai'|'anthropic'|'generic'), or omit to AUTO-DETECT
24
+ * from the model id. The profile supplies inline tool-call dialects + sane
25
+ * tool_choice / parallel / temperature defaults; explicit options above always win.
26
+ */
27
+ profile?: string | ModelProfile;
28
+ /**
29
+ * Inline tool-call dialects to attempt when the model emits tool calls as TEXT
30
+ * instead of native function-calls (e.g. ['hermes','json-fence','xml-function']).
31
+ * Overrides the profile's dialects. Set `[]` to disable inline recovery.
32
+ */
33
+ toolDialects?: string[];
19
34
  }
20
35
  /**
21
36
  * Creates an LLMClient backed by any OpenAI-compatible /chat/completions
@@ -1,4 +1,5 @@
1
- import { parseInlineToolCalls } from './inlineTools.js';
1
+ import { parseToolCalls } from './dialects.js';
2
+ import { profileForModel } from './profiles.js';
2
3
  /**
3
4
  * Creates an LLMClient backed by any OpenAI-compatible /chat/completions
4
5
  * endpoint (OpenAI, Groq, Together, OpenRouter, local llama.cpp, etc.).
@@ -12,14 +13,20 @@ export function createOpenAIClient(options = {}) {
12
13
  return {
13
14
  async streamChat(messages, opts) {
14
15
  const model = opts.model || defaultModel;
16
+ // Resolve a model profile (explicit > auto-detect from model id). It only
17
+ // fills gaps — any option set explicitly on the client always wins.
18
+ const profile = profileForModel(options.profile ?? model);
19
+ const dialects = options.toolDialects ?? profile.dialects;
20
+ const temperature = options.temperature ?? profile.temperature;
21
+ const parallel = options.parallelToolCalls ?? profile.parallelToolCalls;
15
22
  const body = {
16
23
  model,
17
24
  messages: messages.map(toOpenAIMessage),
18
25
  stream: true,
19
26
  stream_options: { include_usage: true },
20
27
  };
21
- if (options.temperature !== undefined)
22
- body.temperature = options.temperature;
28
+ if (temperature !== undefined)
29
+ body.temperature = temperature;
23
30
  if (options.maxTokens !== undefined)
24
31
  body.max_tokens = options.maxTokens;
25
32
  // Reasoning models (e.g. xAI grok-4.x): 'none' → 0 reasoning tokens (cheaper/faster).
@@ -27,9 +34,9 @@ export function createOpenAIClient(options = {}) {
27
34
  body.reasoning_effort = options.reasoningEffort;
28
35
  if (opts.tools?.length) {
29
36
  body.tools = opts.tools;
30
- body.tool_choice = 'auto';
31
- if (options.parallelToolCalls !== undefined)
32
- body.parallel_tool_calls = options.parallelToolCalls;
37
+ body.tool_choice = profile.toolChoice ?? 'auto';
38
+ if (parallel !== undefined)
39
+ body.parallel_tool_calls = parallel;
33
40
  }
34
41
  const apiKey = typeof options.apiKey === 'function' ? options.apiKey() : options.apiKey;
35
42
  const headers = {
@@ -102,10 +109,11 @@ export function createOpenAIClient(options = {}) {
102
109
  function: { name: t.name, arguments: t.args || '{}' },
103
110
  }));
104
111
  // Fallback: some endpoints emit tool calls as inline text rather than
105
- // native tool_calls. Parse them out and clean the visible text.
112
+ // native tool_calls. Parse them with the profile's dialects and clean the
113
+ // visible text. (Empty `dialects` — e.g. for native GPT/Claude — skips this.)
106
114
  let finalText = text;
107
- if (!toolCalls.length) {
108
- const inline = parseInlineToolCalls(text);
115
+ if (!toolCalls.length && (!dialects || dialects.length)) {
116
+ const inline = parseToolCalls(text, { dialects });
109
117
  if (inline.calls.length) {
110
118
  toolCalls.push(...inline.calls);
111
119
  finalText = inline.cleanedText;
@@ -0,0 +1,35 @@
1
+ import type { ToolDef } from '../types/index.js';
2
+ export interface ModelProfile {
3
+ /** Stable id, e.g. 'qwen' | 'deepseek' | 'openai' | 'generic'. */
4
+ name: string;
5
+ /** Match by model id (already lowercased before this is called). */
6
+ match: (model: string) => boolean;
7
+ /** Inline dialects to attempt (in order) when native tool_calls are absent. */
8
+ dialects?: string[];
9
+ /** tool_choice to send when tools are present. */
10
+ toolChoice?: 'auto' | 'required' | 'none';
11
+ /** parallel_tool_calls. Some models break or loop on parallel calls. */
12
+ parallelToolCalls?: boolean;
13
+ /** Suggested temperature for stable tool use (lower = more deterministic). */
14
+ temperature?: number;
15
+ /** Whether a short tool-use scaffolding prompt helps this family. */
16
+ injectToolGuidance?: boolean;
17
+ /** Human note surfaced in the compatibility matrix / docs. */
18
+ note?: string;
19
+ }
20
+ export declare const builtinProfiles: ModelProfile[];
21
+ /** Catch-all for unknown models: try everything, guide, keep parallel off. */
22
+ export declare const genericProfile: ModelProfile;
23
+ /**
24
+ * Resolve a profile for a model id. Pass a `ModelProfile` to use it verbatim, a
25
+ * string name to look up a built-in, or a model id to auto-detect. Unknown →
26
+ * `genericProfile`.
27
+ */
28
+ export declare function profileForModel(model?: string | ModelProfile): ModelProfile;
29
+ /**
30
+ * A short, model-agnostic tool-use scaffolding prompt for weak models that
31
+ * narrate tool calls instead of using native function-calling. Append it to the
32
+ * system prompt (e.g. via `query({ appendSystemPrompt })`) when a profile sets
33
+ * `injectToolGuidance`. Lists the available tools so the model knows the names.
34
+ */
35
+ export declare function toolGuidancePrompt(tools: ToolDef[]): string;
@@ -0,0 +1,123 @@
1
+ import { DEFAULT_DIALECTS } from './dialects.js';
2
+ const has = (...needles) => (model) => needles.some((n) => model.includes(n));
3
+ // Ordered most-specific → most-general; first match wins.
4
+ export const builtinProfiles = [
5
+ {
6
+ name: 'openai',
7
+ match: has('gpt-', 'gpt4', 'o1', 'o3', 'o4', 'chatgpt'),
8
+ dialects: [], // native tool_calls are reliable
9
+ toolChoice: 'auto',
10
+ parallelToolCalls: true,
11
+ note: 'Native tool_calls; no inline fallback needed.',
12
+ },
13
+ {
14
+ name: 'anthropic',
15
+ match: has('claude'),
16
+ dialects: [],
17
+ toolChoice: 'auto',
18
+ note: 'Native tool use; clean function-calling.',
19
+ },
20
+ {
21
+ name: 'qwen',
22
+ match: has('qwen', 'qwq'),
23
+ dialects: ['hermes', 'xml-function', 'json-fence'],
24
+ toolChoice: 'auto',
25
+ parallelToolCalls: false,
26
+ temperature: 0.3,
27
+ injectToolGuidance: true,
28
+ note: 'Hermes-style <tool_call>{json}</tool_call>; parallel calls unreliable.',
29
+ },
30
+ {
31
+ name: 'deepseek',
32
+ match: has('deepseek'),
33
+ dialects: ['json-fence', 'hermes', 'xml-function'],
34
+ toolChoice: 'auto',
35
+ parallelToolCalls: false,
36
+ temperature: 0.3,
37
+ injectToolGuidance: true,
38
+ note: 'Often emits tool calls in JSON code fences; keep parallel off.',
39
+ },
40
+ {
41
+ name: 'moonshot',
42
+ match: has('kimi', 'moonshot'),
43
+ dialects: ['hermes', 'json-fence'],
44
+ toolChoice: 'auto',
45
+ parallelToolCalls: false,
46
+ note: 'Kimi/Moonshot — Hermes-style; Anthropic-compatible endpoint also offered.',
47
+ },
48
+ {
49
+ name: 'zhipu',
50
+ match: has('glm', 'zhipu', 'chatglm'),
51
+ dialects: ['xml-function', 'hermes', 'json-fence'],
52
+ toolChoice: 'auto',
53
+ parallelToolCalls: false,
54
+ injectToolGuidance: true,
55
+ note: 'GLM/Zhipu — mixed dialects; sponsors claude-code-router as a cheap backend.',
56
+ },
57
+ {
58
+ name: 'mistral',
59
+ match: has('mistral', 'mixtral', 'codestral', 'devstral', 'magistral'),
60
+ dialects: ['json-fence', 'hermes', 'xml-function'],
61
+ toolChoice: 'auto',
62
+ parallelToolCalls: false,
63
+ temperature: 0.2,
64
+ injectToolGuidance: true,
65
+ note: 'Tool-calling historically fragile; low temperature + repair recommended.',
66
+ },
67
+ {
68
+ name: 'llama',
69
+ match: has('llama', 'codellama'),
70
+ dialects: ['json-fence', 'hermes', 'xml-function'],
71
+ toolChoice: 'auto',
72
+ parallelToolCalls: false,
73
+ temperature: 0.3,
74
+ injectToolGuidance: true,
75
+ note: 'Llama family (often via Ollama) — inline fallback + guidance help a lot.',
76
+ },
77
+ ];
78
+ /** Catch-all for unknown models: try everything, guide, keep parallel off. */
79
+ export const genericProfile = {
80
+ name: 'generic',
81
+ match: () => true,
82
+ dialects: DEFAULT_DIALECTS,
83
+ toolChoice: 'auto',
84
+ parallelToolCalls: false,
85
+ injectToolGuidance: true,
86
+ note: 'Unknown model — full inline fallback + guidance, parallel off, repair on.',
87
+ };
88
+ /**
89
+ * Resolve a profile for a model id. Pass a `ModelProfile` to use it verbatim, a
90
+ * string name to look up a built-in, or a model id to auto-detect. Unknown →
91
+ * `genericProfile`.
92
+ */
93
+ export function profileForModel(model) {
94
+ if (model && typeof model === 'object')
95
+ return model;
96
+ const id = (model ?? '').toLowerCase();
97
+ if (id) {
98
+ const byName = builtinProfiles.find((p) => p.name === id);
99
+ if (byName)
100
+ return byName;
101
+ const byMatch = builtinProfiles.find((p) => p.match(id));
102
+ if (byMatch)
103
+ return byMatch;
104
+ }
105
+ return genericProfile;
106
+ }
107
+ /**
108
+ * A short, model-agnostic tool-use scaffolding prompt for weak models that
109
+ * narrate tool calls instead of using native function-calling. Append it to the
110
+ * system prompt (e.g. via `query({ appendSystemPrompt })`) when a profile sets
111
+ * `injectToolGuidance`. Lists the available tools so the model knows the names.
112
+ */
113
+ export function toolGuidancePrompt(tools) {
114
+ const names = tools.map((t) => `- ${t.function.name}: ${t.function.description}`).join('\n');
115
+ return [
116
+ 'When you need to use a tool, prefer the native function-calling format.',
117
+ 'If you cannot, emit EXACTLY one tool call per turn as a single JSON object',
118
+ 'wrapped in <tool_call>…</tool_call> tags, with this shape:',
119
+ '<tool_call>{"name": "<tool_name>", "arguments": { /* params */ }}</tool_call>',
120
+ 'Do not wrap it in prose. Use only these tools:',
121
+ names,
122
+ ].join('\n');
123
+ }
@@ -0,0 +1,20 @@
1
+ import type { ToolDef } from '../types/index.js';
2
+ export interface ArgValidation {
3
+ /** True when the arguments parsed and satisfied required props / basic types. */
4
+ ok: boolean;
5
+ /** Parsed arguments (best-effort: `{}` when unparseable). */
6
+ input: Record<string, unknown>;
7
+ /** When `ok` is false, a concise, model-facing explanation + schema hint. */
8
+ error?: string;
9
+ }
10
+ /**
11
+ * Validate raw tool-call argument JSON against a tool definition.
12
+ *
13
+ * - Unparseable JSON → `ok:false` with the parse error and the expected schema.
14
+ * - Missing required properties → `ok:false` listing them.
15
+ * - Wrong primitive type on a provided property → `ok:false`.
16
+ * - No def (unknown tool / client tool) → parse-only; never blocks.
17
+ */
18
+ export declare function validateToolArguments(def: ToolDef | undefined, rawArgs: string): ArgValidation;
19
+ /** A compact one-line schema hint: `{ path: string (required), recursive?: boolean }`. */
20
+ export declare function schemaHint(def: ToolDef): string;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Validate raw tool-call argument JSON against a tool definition.
3
+ *
4
+ * - Unparseable JSON → `ok:false` with the parse error and the expected schema.
5
+ * - Missing required properties → `ok:false` listing them.
6
+ * - Wrong primitive type on a provided property → `ok:false`.
7
+ * - No def (unknown tool / client tool) → parse-only; never blocks.
8
+ */
9
+ export function validateToolArguments(def, rawArgs) {
10
+ const raw = rawArgs?.trim() ? rawArgs : '{}';
11
+ let parsed;
12
+ try {
13
+ parsed = JSON.parse(raw);
14
+ }
15
+ catch (e) {
16
+ const msg = e instanceof Error ? e.message : String(e);
17
+ return {
18
+ ok: false,
19
+ input: {},
20
+ error: def
21
+ ? `Arguments for "${def.function.name}" were not valid JSON (${msg}). Call the tool again with a single valid JSON object matching: ${schemaHint(def)}`
22
+ : `Tool arguments were not valid JSON (${msg}). Send a single valid JSON object.`,
23
+ };
24
+ }
25
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
26
+ return {
27
+ ok: false,
28
+ input: {},
29
+ error: def
30
+ ? `Arguments for "${def.function.name}" must be a JSON object. Expected: ${schemaHint(def)}`
31
+ : 'Tool arguments must be a JSON object.',
32
+ };
33
+ }
34
+ const input = parsed;
35
+ // No schema to check against (unknown / client-delegated tool) — accept.
36
+ if (!def)
37
+ return { ok: true, input };
38
+ const props = (def.function.parameters?.properties ?? {});
39
+ const required = def.function.parameters?.required ?? [];
40
+ const missing = required.filter((k) => input[k] === undefined || input[k] === null);
41
+ if (missing.length) {
42
+ return {
43
+ ok: false,
44
+ input,
45
+ error: `Missing required argument${missing.length > 1 ? 's' : ''} for "${def.function.name}": ${missing
46
+ .map((k) => `"${k}"`)
47
+ .join(', ')}. Call it again including ${missing.length > 1 ? 'them' : 'it'}. Expected: ${schemaHint(def)}`,
48
+ };
49
+ }
50
+ // Light primitive type check on provided props.
51
+ for (const [key, val] of Object.entries(input)) {
52
+ const want = props[key]?.type;
53
+ if (!want || val === null || val === undefined)
54
+ continue;
55
+ if (!matchesType(val, want)) {
56
+ return {
57
+ ok: false,
58
+ input,
59
+ error: `Argument "${key}" for "${def.function.name}" should be ${want}, got ${jsType(val)}. Call it again with the correct type. Expected: ${schemaHint(def)}`,
60
+ };
61
+ }
62
+ }
63
+ return { ok: true, input };
64
+ }
65
+ /** A compact one-line schema hint: `{ path: string (required), recursive?: boolean }`. */
66
+ export function schemaHint(def) {
67
+ const props = (def.function.parameters?.properties ?? {});
68
+ const required = new Set(def.function.parameters?.required ?? []);
69
+ const parts = Object.entries(props).map(([k, v]) => {
70
+ const req = required.has(k);
71
+ return `${k}${req ? '' : '?'}: ${v?.type ?? 'any'}${req ? ' (required)' : ''}`;
72
+ });
73
+ return `{ ${parts.join(', ')} }`;
74
+ }
75
+ function jsType(v) {
76
+ if (Array.isArray(v))
77
+ return 'array';
78
+ return typeof v;
79
+ }
80
+ function matchesType(v, want) {
81
+ switch (want) {
82
+ case 'string':
83
+ return typeof v === 'string';
84
+ case 'number':
85
+ case 'integer':
86
+ return typeof v === 'number';
87
+ case 'boolean':
88
+ return typeof v === 'boolean';
89
+ case 'array':
90
+ return Array.isArray(v);
91
+ case 'object':
92
+ return typeof v === 'object' && !Array.isArray(v) && v !== null;
93
+ default:
94
+ return true; // unknown/`any` — don't block
95
+ }
96
+ }
package/dist/loop.d.ts CHANGED
@@ -35,6 +35,13 @@ export interface RunToolLoopOptions {
35
35
  includePartialMessages?: boolean;
36
36
  /** Correlation id stamped on every emitted SDKMessage. */
37
37
  sessionId?: string;
38
+ /**
39
+ * Validate tool arguments before executing; on malformed/incomplete JSON,
40
+ * feed the model a corrective `is_error` tool_result (with the expected
41
+ * schema) instead of running the tool with garbage, so it self-heals.
42
+ * Default `true`. Set `false` to pass raw args straight through.
43
+ */
44
+ repairToolCalls?: boolean;
38
45
  }
39
46
  /**
40
47
  * Run the bare tool loop, yielding SDKMessages until the model stops or maxTurns.
package/dist/loop.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { toolByName, toolDefs } from './tools/index.js';
2
+ import { validateToolArguments } from './llm/repair.js';
2
3
  import { uuid } from './util/ids.js';
3
4
  const emptyUsage = () => ({ input_tokens: 0, output_tokens: 0 });
4
5
  function addUsage(t, b) {
@@ -78,6 +79,7 @@ export async function* runToolLoop(opts) {
78
79
  const { history, llm, model, ctx, signal, canUseTool, onClientTool } = opts;
79
80
  const tools = opts.tools;
80
81
  const clientTools = new Set(opts.clientTools ?? []);
82
+ const repair = opts.repairToolCalls !== false;
81
83
  const maxTurns = opts.maxTurns ?? 50;
82
84
  const sessionId = opts.sessionId ?? uuid();
83
85
  const emitPartial = !!opts.includePartialMessages;
@@ -187,50 +189,62 @@ export async function* runToolLoop(opts) {
187
189
  const tool = byName.get(name);
188
190
  let content = '';
189
191
  let isError = false;
190
- // Delegated tool (listed in clientTools, or has no `run`): execute on the
191
- // host via onClientTool instead of `ctx` never touches the server FS.
192
- const delegated = clientTools.has(name) || (tool != null && !tool.run);
193
- if (delegated) {
194
- if (!onClientTool) {
195
- content = `No client executor for "${name}" (delegated tool; pass onClientTool).`;
196
- isError = true;
197
- }
198
- else {
199
- try {
200
- const r = await onClientTool({ tool_use_id: call.id, name, input });
201
- content = (typeof r.content === 'string' ? r.content : JSON.stringify(r.content ?? ''));
202
- isError = !!r.is_error;
203
- }
204
- catch (err) {
205
- content = `Error (client) ${name}: ${err instanceof Error ? err.message : String(err)}`;
206
- isError = true;
207
- }
208
- }
209
- }
210
- else if (!tool) {
211
- content = `Error: unknown tool "${name}"`;
192
+ // Repair: validate args against the tool's schema before running. On a
193
+ // malformed/incomplete call, hand the model a corrective tool_result so
194
+ // it retries with valid JSON instead of executing with garbage.
195
+ const check = repair && tool ? validateToolArguments(tool.def, call.function.arguments) : null;
196
+ if (check && !check.ok) {
197
+ content = check.error;
212
198
  isError = true;
213
199
  }
214
200
  else {
215
- const decision = canUseTool
216
- ? await canUseTool(name, input, { signal, toolUseId: call.id })
217
- : { behavior: 'allow' };
218
- if (decision.behavior === 'deny') {
219
- content = `Permission denied: ${decision.message}`;
201
+ if (check)
202
+ input = check.input;
203
+ // Delegated tool (listed in clientTools, or has no `run`): execute on the
204
+ // host via onClientTool instead of `ctx` — never touches the server FS.
205
+ const delegated = clientTools.has(name) || (tool != null && !tool.run);
206
+ if (delegated) {
207
+ if (!onClientTool) {
208
+ content = `No client executor for "${name}" (delegated tool; pass onClientTool).`;
209
+ isError = true;
210
+ }
211
+ else {
212
+ try {
213
+ const r = await onClientTool({ tool_use_id: call.id, name, input });
214
+ content = (typeof r.content === 'string' ? r.content : JSON.stringify(r.content ?? ''));
215
+ isError = !!r.is_error;
216
+ }
217
+ catch (err) {
218
+ content = `Error (client) ${name}: ${err instanceof Error ? err.message : String(err)}`;
219
+ isError = true;
220
+ }
221
+ }
222
+ }
223
+ else if (!tool) {
224
+ content = `Error: unknown tool "${name}"`;
220
225
  isError = true;
221
226
  }
222
227
  else {
223
- if ('updatedInput' in decision && decision.updatedInput)
224
- input = decision.updatedInput;
225
- try {
226
- const r = await tool.run(input, ctx);
227
- content = r.content;
228
- isError = !!r.isError;
229
- }
230
- catch (err) {
231
- content = `Error executing ${name}: ${err instanceof Error ? err.message : String(err)}`;
228
+ const decision = canUseTool
229
+ ? await canUseTool(name, input, { signal, toolUseId: call.id })
230
+ : { behavior: 'allow' };
231
+ if (decision.behavior === 'deny') {
232
+ content = `Permission denied: ${decision.message}`;
232
233
  isError = true;
233
234
  }
235
+ else {
236
+ if ('updatedInput' in decision && decision.updatedInput)
237
+ input = decision.updatedInput;
238
+ try {
239
+ const r = await tool.run(input, ctx);
240
+ content = r.content;
241
+ isError = !!r.isError;
242
+ }
243
+ catch (err) {
244
+ content = `Error executing ${name}: ${err instanceof Error ? err.message : String(err)}`;
245
+ isError = true;
246
+ }
247
+ }
234
248
  }
235
249
  }
236
250
  const textOut = resultToText(content);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anyclaude-sdk",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "description": "Standalone, browser-compatible SDK providing Claude Code agent capabilities (tools, tool loop, multi-turn, MCP, sub-agents, sessions) against any OpenAI/Anthropic-compatible LLM endpoint. Runs in the browser (WebContainer), Node, and Bun — no backend required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",