millforge 0.1.0__py3-none-any.whl
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.
- millforge/__init__.py +1174 -0
- millforge/_forge/LICENSE +21 -0
- millforge/_forge/PROVENANCE.json +295 -0
- millforge/_forge/UPDATE_POLICY.md +24 -0
- millforge/_forge/__init__.py +14 -0
- millforge/_forge/adapter.py +2232 -0
- millforge/_forge/base_runner.py +121 -0
- millforge/_forge/clients/__init__.py +10 -0
- millforge/_forge/clients/base.py +200 -0
- millforge/_forge/context/__init__.py +23 -0
- millforge/_forge/context/manager.py +178 -0
- millforge/_forge/context/strategies.py +335 -0
- millforge/_forge/core/__init__.py +16 -0
- millforge/_forge/core/inference.py +433 -0
- millforge/_forge/core/messages.py +119 -0
- millforge/_forge/core/runner.py +479 -0
- millforge/_forge/core/steps.py +108 -0
- millforge/_forge/core/workflow.py +400 -0
- millforge/_forge/errors.py +222 -0
- millforge/_forge/guardrails/__init__.py +21 -0
- millforge/_forge/guardrails/error_tracker.py +71 -0
- millforge/_forge/guardrails/guardrails.py +194 -0
- millforge/_forge/guardrails/nudge.py +47 -0
- millforge/_forge/guardrails/response_validator.py +119 -0
- millforge/_forge/guardrails/step_enforcer.py +183 -0
- millforge/_forge/prompts/__init__.py +16 -0
- millforge/_forge/prompts/nudges.py +95 -0
- millforge/_forge/prompts/templates.py +285 -0
- millforge/_version.py +3 -0
- millforge/artifacts.py +570 -0
- millforge/base/__init__.py +97 -0
- millforge/base/composition.py +402 -0
- millforge/base/context.py +285 -0
- millforge/base/harness.py +138 -0
- millforge/base/identity.py +465 -0
- millforge/base/options.py +34 -0
- millforge/base/platform.py +17 -0
- millforge/base/prompt.py +317 -0
- millforge/base/runner.py +546 -0
- millforge/compiled_plan.py +970 -0
- millforge/compiler/__init__.py +231 -0
- millforge/compiler/artifact_validation.py +257 -0
- millforge/compiler/canonicalization.py +169 -0
- millforge/compiler/capabilities.py +66 -0
- millforge/compiler/catalogs.py +500 -0
- millforge/compiler/diagnostics.py +491 -0
- millforge/compiler/graph.py +678 -0
- millforge/compiler/lowering.py +198 -0
- millforge/compiler/output.py +692 -0
- millforge/compiler/parsing.py +1424 -0
- millforge/compiler/requests.py +1180 -0
- millforge/compiler/schema_validation.py +272 -0
- millforge/compiler/semantic.py +490 -0
- millforge/compiler/service.py +448 -0
- millforge/compiler/source.py +375 -0
- millforge/compiler/validators.py +184 -0
- millforge/connectors/__init__.py +95 -0
- millforge/connectors/admission.py +801 -0
- millforge/connectors/broker.py +202 -0
- millforge/connectors/contracts.py +1159 -0
- millforge/connectors/diagnostics.py +189 -0
- millforge/connectors/fake.py +66 -0
- millforge/connectors/runtime.py +236 -0
- millforge/contracts.py +2860 -0
- millforge/custom_tools/__init__.py +67 -0
- millforge/custom_tools/compiler.py +724 -0
- millforge/custom_tools/contracts.py +1093 -0
- millforge/custom_tools/diagnostics.py +205 -0
- millforge/eval_artifacts.py +952 -0
- millforge/eval_boundary.py +2435 -0
- millforge/eval_fixtures/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/__init__.py +1 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.bug_diagnosis.traceback.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.direct_edit.import_sort.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.evidence_discipline.no_source_change.v1.json +51 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.false_closure.visible_green.v1.json +52 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.multi_file.api_contract.v1.json +54 -0
- millforge/eval_fixtures/default_pack/fixtures/fixture.08a.recovery.malformed_artifact.v1.json +54 -0
- millforge/eval_fixtures/default_pack/manifest.json +12 -0
- millforge/eval_modes.py +1282 -0
- millforge/eval_presets.py +1398 -0
- millforge/eval_reports.py +2517 -0
- millforge/eval_suite.py +2429 -0
- millforge/eval_trials.py +2632 -0
- millforge/eval_workflow.py +794 -0
- millforge/exceptions.py +122 -0
- millforge/model_backend.py +2098 -0
- millforge/protocols.py +340 -0
- millforge/py.typed +0 -0
- millforge/runtime.py +1791 -0
- millforge/testing/__init__.py +1089 -0
- millforge/tools/__init__.py +83 -0
- millforge/tools/builtin_runtime.py +1339 -0
- millforge/tools/builtins.py +773 -0
- millforge/tools/execution.py +1545 -0
- millforge/tools/path_policy.py +155 -0
- millforge/tools/pi_compat/PI_LICENSE +21 -0
- millforge/tools/pi_compat/PROVENANCE.json +55 -0
- millforge/tools/pi_compat/UPDATE_POLICY.md +36 -0
- millforge/tools/pi_compat/__init__.py +34 -0
- millforge/tools/pi_compat/contracts.py +49 -0
- millforge/tools/pi_compat/editing.py +390 -0
- millforge/tools/pi_compat/mutations.py +57 -0
- millforge/tools/pi_compat/operations.py +401 -0
- millforge/tools/pi_compat/paths.py +155 -0
- millforge/tools/pi_compat/process.py +1375 -0
- millforge/tools/pi_compat/search.py +738 -0
- millforge/tools/pi_compat/truncation.py +267 -0
- millforge/tools/pi_compat_catalog.py +396 -0
- millforge/tools/pi_compat_runtime.py +460 -0
- millforge/tools/registry.py +553 -0
- millforge/tools/results.py +533 -0
- millforge-0.1.0.dist-info/METADATA +844 -0
- millforge-0.1.0.dist-info/RECORD +116 -0
- millforge-0.1.0.dist-info/WHEEL +4 -0
- millforge-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Nudge message templates for the WorkflowRunner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def retry_nudge(raw_response: str) -> str:
|
|
9
|
+
"""Nudge for when the model returns text instead of a tool call.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
raw_response: The raw text the model produced (unused — kept for
|
|
13
|
+
signature compatibility).
|
|
14
|
+
"""
|
|
15
|
+
return (
|
|
16
|
+
"Your previous response was not a valid tool call. "
|
|
17
|
+
"You must respond with a tool call, not free text. "
|
|
18
|
+
"Please try again with a valid tool call."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def unknown_tool_nudge(tool_name: str, available_tools: list[str]) -> str:
|
|
23
|
+
"""Nudge for when the model calls a tool that doesn't exist.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
tool_name: The tool name the model tried to call.
|
|
27
|
+
available_tools: The list of valid tool names.
|
|
28
|
+
"""
|
|
29
|
+
tools_list = ", ".join(available_tools)
|
|
30
|
+
return (
|
|
31
|
+
f"Tool '{tool_name}' does not exist. "
|
|
32
|
+
f"Available tools: {tools_list}. "
|
|
33
|
+
"Call one of them."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def step_nudge(terminal_tool: str, pending_steps: list[str], tier: int = 1) -> str:
|
|
38
|
+
"""Escalating nudge for premature terminal tool attempts.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
terminal_tool: The name of the terminal tool the model tried to call.
|
|
42
|
+
pending_steps: The required steps that must be completed first.
|
|
43
|
+
tier: Escalation level (1=polite, 2=direct, 3=aggressive). Clamped to 1-3.
|
|
44
|
+
"""
|
|
45
|
+
tier = max(1, min(3, tier))
|
|
46
|
+
steps = ", ".join(pending_steps)
|
|
47
|
+
if tier == 1:
|
|
48
|
+
return (
|
|
49
|
+
f"You cannot call {terminal_tool} yet. "
|
|
50
|
+
f"You must first complete these required steps: {steps}. "
|
|
51
|
+
"Call one of them now."
|
|
52
|
+
)
|
|
53
|
+
if tier == 2:
|
|
54
|
+
return f"You must call one of these tools now: {steps}. Pick one."
|
|
55
|
+
return (
|
|
56
|
+
f"STOP. You MUST call one of: {steps}. "
|
|
57
|
+
f"Do NOT call {terminal_tool}. "
|
|
58
|
+
f"Your next response MUST be a tool call to one of: {steps}."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def tool_arg_validation_nudge(tool_name: str, args: Any) -> str:
|
|
63
|
+
"""Nudge for when a tool call's args are not a JSON object.
|
|
64
|
+
|
|
65
|
+
The model emitted a structurally valid tool call but with malformed
|
|
66
|
+
args content (e.g. an empty string, null, a list, or a primitive
|
|
67
|
+
instead of a JSON object). Same shape as calling a tool with a bad
|
|
68
|
+
path — the call exists, the inputs are wrong.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
tool_name: The tool the model tried to call.
|
|
72
|
+
args: The raw args value the model emitted (any type).
|
|
73
|
+
"""
|
|
74
|
+
return (
|
|
75
|
+
f"Tool call to '{tool_name}' had malformed arguments. "
|
|
76
|
+
f"Got args={args!r} (type: {type(args).__name__}). "
|
|
77
|
+
"Required: args must be a JSON object (dict). "
|
|
78
|
+
"Re-emit the tool call with args as an object — "
|
|
79
|
+
'{} for no-arg tools or {"key": value} otherwise.'
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def prerequisite_nudge(tool_name: str, missing_prereqs: list[str]) -> str:
|
|
84
|
+
"""Nudge for when a tool is called without its prerequisites.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
tool_name: The tool the model tried to call.
|
|
88
|
+
missing_prereqs: The prerequisite tool names that haven't been called.
|
|
89
|
+
"""
|
|
90
|
+
prereqs = ", ".join(missing_prereqs)
|
|
91
|
+
return (
|
|
92
|
+
f"You cannot call {tool_name} yet. "
|
|
93
|
+
f"You must first call: {prereqs}. "
|
|
94
|
+
"Call the prerequisite tool now."
|
|
95
|
+
)
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Tool prompt builders for the prompt-injected tool calling path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from millforge._forge.core.workflow import ToolCall, ToolSpec
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_tool_prompt(tools: list[ToolSpec]) -> str:
|
|
12
|
+
"""Build tool description block for prompt injection.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
tools: The list of tool specs to describe.
|
|
16
|
+
"""
|
|
17
|
+
lines = ["You have access to the following tools:", ""]
|
|
18
|
+
|
|
19
|
+
for tool in tools:
|
|
20
|
+
schema = tool.get_json_schema()
|
|
21
|
+
properties = schema.get("properties", {})
|
|
22
|
+
required = set(schema.get("required", []))
|
|
23
|
+
|
|
24
|
+
lines.append(f"## {tool.name}")
|
|
25
|
+
lines.append(f"Description: {tool.description}")
|
|
26
|
+
if properties:
|
|
27
|
+
lines.append("Parameters:")
|
|
28
|
+
for name, prop in properties.items():
|
|
29
|
+
req = " (required)" if name in required else " (optional)"
|
|
30
|
+
ptype = prop.get("type", "any")
|
|
31
|
+
desc = prop.get("description", "")
|
|
32
|
+
lines.append(f" - {name} ({ptype}{req}): {desc}")
|
|
33
|
+
if "enum" in prop:
|
|
34
|
+
lines.append(
|
|
35
|
+
f" Allowed values: {', '.join(str(v) for v in prop['enum'])}"
|
|
36
|
+
)
|
|
37
|
+
lines.append("")
|
|
38
|
+
|
|
39
|
+
lines.append(
|
|
40
|
+
"To call a tool, respond with ONLY a JSON object in this exact format:"
|
|
41
|
+
)
|
|
42
|
+
lines.append('{"tool": "<tool_name>", "args": {<arguments>}}')
|
|
43
|
+
lines.append("")
|
|
44
|
+
lines.append("Example:")
|
|
45
|
+
if tools:
|
|
46
|
+
example_tool = tools[0]
|
|
47
|
+
example_schema = example_tool.get_json_schema()
|
|
48
|
+
example_args = {
|
|
49
|
+
name: f"<{name}>" for name in example_schema.get("properties", {})
|
|
50
|
+
}
|
|
51
|
+
lines.append(json.dumps({"tool": example_tool.name, "args": example_args}))
|
|
52
|
+
lines.append("")
|
|
53
|
+
lines.append("Respond with ONLY the JSON tool call. Do not include any other text.")
|
|
54
|
+
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def extract_tool_call(text: str, available_tools: list[str]) -> list[ToolCall]:
|
|
59
|
+
"""Extract all ToolCalls from free-text model output.
|
|
60
|
+
|
|
61
|
+
Handles JSON wrapped in code fences or embedded in surrounding text.
|
|
62
|
+
Returns all valid tool calls found, or an empty list if none.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
text: The raw model output text.
|
|
66
|
+
available_tools: List of valid tool names to match against.
|
|
67
|
+
"""
|
|
68
|
+
# Strip code fences if present
|
|
69
|
+
cleaned = re.sub(r"```(?:json)?\s*\n?", "", text)
|
|
70
|
+
cleaned = re.sub(r"```", "", cleaned)
|
|
71
|
+
|
|
72
|
+
found: list[ToolCall] = []
|
|
73
|
+
# Try to find JSON objects by scanning for opening braces
|
|
74
|
+
i = 0
|
|
75
|
+
while i < len(cleaned):
|
|
76
|
+
if cleaned[i] == "{":
|
|
77
|
+
# Find matching closing brace
|
|
78
|
+
depth = 0
|
|
79
|
+
for j in range(i, len(cleaned)):
|
|
80
|
+
if cleaned[j] == "{":
|
|
81
|
+
depth += 1
|
|
82
|
+
elif cleaned[j] == "}":
|
|
83
|
+
depth -= 1
|
|
84
|
+
if depth == 0:
|
|
85
|
+
candidate = cleaned[i : j + 1]
|
|
86
|
+
result = _try_parse_tool_call(candidate, available_tools)
|
|
87
|
+
if result is not None:
|
|
88
|
+
found.append(result)
|
|
89
|
+
i = j + 1
|
|
90
|
+
break
|
|
91
|
+
else:
|
|
92
|
+
i += 1
|
|
93
|
+
else:
|
|
94
|
+
i += 1
|
|
95
|
+
return found
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _try_parse_tool_call(json_str: str, available_tools: list[str]) -> ToolCall | None:
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(json_str)
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
if not isinstance(data, dict):
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
# Forge style: {"tool": "...", "args": {...}}
|
|
108
|
+
# OpenAI style: {"name": "...", "arguments": {...}}
|
|
109
|
+
# Granite 4.0 emits OpenAI-style inside <tool_call> tags.
|
|
110
|
+
tool_name = data.get("tool") or data.get("name")
|
|
111
|
+
if tool_name not in available_tools:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
args = data.get("args")
|
|
115
|
+
if args is None:
|
|
116
|
+
args = data.get("arguments", {})
|
|
117
|
+
|
|
118
|
+
return ToolCall(tool=tool_name, args=args)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Pattern for native FC rehearsal syntax: tool_name[ARGS]{...}
|
|
122
|
+
# Reasoning models rehearse tool calls in thinking tokens using this format.
|
|
123
|
+
# Captures: tool name and the JSON args blob.
|
|
124
|
+
_REHEARSAL_RE = re.compile(r"(\w+)\[ARGS\](\{.*\})", re.DOTALL)
|
|
125
|
+
|
|
126
|
+
# Think tag patterns (same as llamafile._THINK_TAG_RE) — needed to strip
|
|
127
|
+
# thinking blocks before rescue parsing.
|
|
128
|
+
_THINK_TAG_RE = re.compile(r"\[THINK\].*?\[/THINK\]|<think>.*?</think>", re.DOTALL)
|
|
129
|
+
|
|
130
|
+
# Qwen Coder XML tool call format.
|
|
131
|
+
# <function=name>
|
|
132
|
+
# <parameter=key>value</parameter>
|
|
133
|
+
# <parameter=other>value</parameter>
|
|
134
|
+
# </function>
|
|
135
|
+
# Pattern adapted from Qwen's reference parser:
|
|
136
|
+
# https://huggingface.co/Qwen/Qwen3-Coder-480B-A35B-Instruct/blob/main/qwen3coder_tool_parser.py
|
|
137
|
+
_QWEN_FUNCTION_RE = re.compile(r"<function=([^>\s]+)>(.*?)</function>", re.DOTALL)
|
|
138
|
+
_QWEN_PARAMETER_RE = re.compile(
|
|
139
|
+
r"<parameter=([^>\s]+)>(.*?)(?:</parameter>|(?=<parameter=)|(?=</function>)|$)",
|
|
140
|
+
re.DOTALL,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# Mistral native bracket-tag tool call format:
|
|
144
|
+
# [TOOL_CALLS]<tool_name>{<json_args>}
|
|
145
|
+
# with optional whitespace/newline between the name and the opening brace.
|
|
146
|
+
# Emitted by Devstral-Small-2 and Mistral-Small-3.x family in prompt mode
|
|
147
|
+
# when the model falls back to its native serialization. Anchor matches
|
|
148
|
+
# only the [TOOL_CALLS]<name> prefix; the JSON args are extracted via
|
|
149
|
+
# brace-balance scan in _parse_mistral_bracket_tool_calls.
|
|
150
|
+
_MISTRAL_BRACKET_RE = re.compile(r"\[TOOL_CALLS\](\w+)\s*(?=\{)")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse_qwen_xml_tool_calls(text: str, available_tools: list[str]) -> list[ToolCall]:
|
|
154
|
+
"""Parse Qwen Coder XML-format tool calls from model output.
|
|
155
|
+
|
|
156
|
+
Handles the format emitted by Qwen3-Coder models (and occasionally other
|
|
157
|
+
models trained on similar data), with or without the outer <tool_call>
|
|
158
|
+
wrapper. Whitespace behavior matches Qwen's reference parser: one leading
|
|
159
|
+
and one trailing newline are stripped from each parameter value.
|
|
160
|
+
|
|
161
|
+
Type coercion is deferred to Pydantic — all parameter values are passed
|
|
162
|
+
as strings, and the tool's parameter model coerces at ToolCall construction.
|
|
163
|
+
"""
|
|
164
|
+
found: list[ToolCall] = []
|
|
165
|
+
for fn_match in _QWEN_FUNCTION_RE.finditer(text):
|
|
166
|
+
tool_name = fn_match.group(1).strip()
|
|
167
|
+
if tool_name not in available_tools:
|
|
168
|
+
continue
|
|
169
|
+
|
|
170
|
+
body = fn_match.group(2)
|
|
171
|
+
args: dict[str, str] = {}
|
|
172
|
+
for param_match in _QWEN_PARAMETER_RE.finditer(body):
|
|
173
|
+
key = param_match.group(1).strip()
|
|
174
|
+
value = param_match.group(2)
|
|
175
|
+
# Strip the first newline after the opening tag and the last
|
|
176
|
+
# newline before the closing tag — matches Qwen's parser.
|
|
177
|
+
if value.startswith("\n"):
|
|
178
|
+
value = value[1:]
|
|
179
|
+
if value.endswith("\n"):
|
|
180
|
+
value = value[:-1]
|
|
181
|
+
args[key] = value
|
|
182
|
+
|
|
183
|
+
found.append(ToolCall(tool=tool_name, args=args))
|
|
184
|
+
|
|
185
|
+
return found
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _parse_mistral_bracket_tool_calls(
|
|
189
|
+
text: str, available_tools: list[str]
|
|
190
|
+
) -> list[ToolCall]:
|
|
191
|
+
"""Parse Mistral native ``[TOOL_CALLS]<name>{<args>}`` tool-call format.
|
|
192
|
+
|
|
193
|
+
Devstral-Small-2 and Mistral-Small-3.x emit this shape in prompt mode when
|
|
194
|
+
they fall back to their training-data tool-call serialization. The args
|
|
195
|
+
are JSON; extracted via brace-balance scan to handle nested objects /
|
|
196
|
+
strings that contain literal braces.
|
|
197
|
+
|
|
198
|
+
Optional whitespace (including newlines) is permitted between the tool
|
|
199
|
+
name and the opening ``{``. Multiple bracket-tagged calls in one message
|
|
200
|
+
are returned as a list.
|
|
201
|
+
"""
|
|
202
|
+
found: list[ToolCall] = []
|
|
203
|
+
for m in _MISTRAL_BRACKET_RE.finditer(text):
|
|
204
|
+
tool_name = m.group(1)
|
|
205
|
+
if tool_name not in available_tools:
|
|
206
|
+
continue
|
|
207
|
+
# Brace-balance scan starting at the opening brace (lookahead-anchored).
|
|
208
|
+
i = m.end()
|
|
209
|
+
if i >= len(text) or text[i] != "{":
|
|
210
|
+
continue
|
|
211
|
+
depth = 0
|
|
212
|
+
in_string = False
|
|
213
|
+
escape = False
|
|
214
|
+
for j in range(i, len(text)):
|
|
215
|
+
ch = text[j]
|
|
216
|
+
if escape:
|
|
217
|
+
escape = False
|
|
218
|
+
continue
|
|
219
|
+
if ch == "\\":
|
|
220
|
+
escape = True
|
|
221
|
+
continue
|
|
222
|
+
if ch == '"':
|
|
223
|
+
in_string = not in_string
|
|
224
|
+
continue
|
|
225
|
+
if in_string:
|
|
226
|
+
continue
|
|
227
|
+
if ch == "{":
|
|
228
|
+
depth += 1
|
|
229
|
+
elif ch == "}":
|
|
230
|
+
depth -= 1
|
|
231
|
+
if depth == 0:
|
|
232
|
+
candidate = text[i : j + 1]
|
|
233
|
+
try:
|
|
234
|
+
args = json.loads(candidate)
|
|
235
|
+
except json.JSONDecodeError:
|
|
236
|
+
break
|
|
237
|
+
if isinstance(args, dict):
|
|
238
|
+
found.append(ToolCall(tool=tool_name, args=args))
|
|
239
|
+
break
|
|
240
|
+
return found
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def rescue_tool_call(text: str, available_tools: list[str]) -> list[ToolCall]:
|
|
244
|
+
"""Try to parse ToolCalls from a TextResponse that failed native FC.
|
|
245
|
+
|
|
246
|
+
Used by the runner to rescue valid tool calls that the model emitted as
|
|
247
|
+
free text instead of structured output. Returns an empty list if nothing
|
|
248
|
+
parseable is found — caller falls through to the normal retry nudge.
|
|
249
|
+
|
|
250
|
+
Parsing strategies (in order):
|
|
251
|
+
1. Prompt-injected JSON: {"tool": "name", "args": {...}}
|
|
252
|
+
2. Rehearsal syntax: tool_name[ARGS]{...}
|
|
253
|
+
3. Qwen Coder XML: <function=name><parameter=key>value</parameter></function>
|
|
254
|
+
4. Mistral bracket-tag: [TOOL_CALLS]<name>{<args>}
|
|
255
|
+
"""
|
|
256
|
+
# Strip think tags — the tool call may be after or outside thinking blocks
|
|
257
|
+
cleaned = _THINK_TAG_RE.sub("", text).strip()
|
|
258
|
+
if not cleaned:
|
|
259
|
+
return []
|
|
260
|
+
|
|
261
|
+
# Strategy 1: existing JSON extraction (handles code fences, embedded JSON)
|
|
262
|
+
found = extract_tool_call(cleaned, available_tools)
|
|
263
|
+
|
|
264
|
+
# Strategy 2: rehearsal syntax — tool_name[ARGS]{...}
|
|
265
|
+
# Only try if JSON extraction found nothing (avoid double-counting)
|
|
266
|
+
if not found:
|
|
267
|
+
for m in _REHEARSAL_RE.finditer(cleaned):
|
|
268
|
+
tool_name, args_str = m.group(1), m.group(2)
|
|
269
|
+
if tool_name in available_tools:
|
|
270
|
+
try:
|
|
271
|
+
args = json.loads(args_str)
|
|
272
|
+
if isinstance(args, dict):
|
|
273
|
+
found.append(ToolCall(tool=tool_name, args=args))
|
|
274
|
+
except json.JSONDecodeError:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
# Strategy 3: Qwen Coder XML — <function=name><parameter=key>value</parameter></function>
|
|
278
|
+
if not found:
|
|
279
|
+
found = _parse_qwen_xml_tool_calls(cleaned, available_tools)
|
|
280
|
+
|
|
281
|
+
# Strategy 4: Mistral bracket-tag — [TOOL_CALLS]<name>{<args>}
|
|
282
|
+
if not found:
|
|
283
|
+
found = _parse_mistral_bracket_tool_calls(cleaned, available_tools)
|
|
284
|
+
|
|
285
|
+
return found
|
millforge/_version.py
ADDED