local-coding-agent 1.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.
- local_coding_agent/__init__.py +314 -0
- local_coding_agent/__main__.py +5 -0
- local_coding_agent/acp_server/__init__.py +38 -0
- local_coding_agent/acp_server/_codec.py +95 -0
- local_coding_agent/acp_server/_protocol.py +13 -0
- local_coding_agent/acp_server/_server.py +663 -0
- local_coding_agent/acp_server/_session.py +24 -0
- local_coding_agent/acp_server/_tools.py +126 -0
- local_coding_agent/ast_compactor.py +130 -0
- local_coding_agent/atomizer.py +77 -0
- local_coding_agent/benchmark/__init__.py +57 -0
- local_coding_agent/benchmark/_cases.py +307 -0
- local_coding_agent/benchmark/_judge.py +175 -0
- local_coding_agent/benchmark/_oracles.py +157 -0
- local_coding_agent/benchmark/_runner.py +259 -0
- local_coding_agent/benchmark/_stats.py +22 -0
- local_coding_agent/benchmark/_summary.py +143 -0
- local_coding_agent/benchmark_oracle_worker.py +169 -0
- local_coding_agent/calibration.py +154 -0
- local_coding_agent/capability.py +450 -0
- local_coding_agent/chat_compaction.py +276 -0
- local_coding_agent/cli/__init__.py +12 -0
- local_coding_agent/cli/_dispatch.py +377 -0
- local_coding_agent/cli/_handlers.py +320 -0
- local_coding_agent/cli/_handlers2.py +725 -0
- local_coding_agent/cli/_input.py +61 -0
- local_coding_agent/cli/_main.py +159 -0
- local_coding_agent/cli/_parser.py +327 -0
- local_coding_agent/context_manager.py +155 -0
- local_coding_agent/controller/__init__.py +18 -0
- local_coding_agent/controller/_constants.py +108 -0
- local_coding_agent/controller/_controller.py +1008 -0
- local_coding_agent/controller/_post_apply.py +216 -0
- local_coding_agent/delegator.py +180 -0
- local_coding_agent/desktop/__init__.py +8 -0
- local_coding_agent/desktop/app.py +100 -0
- local_coding_agent/desktop/assets/lucide.min.js +12 -0
- local_coding_agent/desktop/assets/tailwind.css +1 -0
- local_coding_agent/desktop/client_js.py +1271 -0
- local_coding_agent/desktop/components.py +555 -0
- local_coding_agent/desktop/server/__init__.py +37 -0
- local_coding_agent/desktop/server/_handlers.py +2178 -0
- local_coding_agent/desktop/server/_models.py +204 -0
- local_coding_agent/desktop/server/_server.py +347 -0
- local_coding_agent/desktop/server/_telemetry.py +87 -0
- local_coding_agent/desktop/styles.py +76 -0
- local_coding_agent/desktop/ui.py +53 -0
- local_coding_agent/doctor.py +413 -0
- local_coding_agent/hooks/__init__.py +19 -0
- local_coding_agent/hooks/_adapters.py +458 -0
- local_coding_agent/hooks/_bridge.py +346 -0
- local_coding_agent/hooks/_decision.py +26 -0
- local_coding_agent/lsp/__init__.py +60 -0
- local_coding_agent/lsp/_client.py +322 -0
- local_coding_agent/lsp/_fallback.py +297 -0
- local_coding_agent/lsp/_manager.py +173 -0
- local_coding_agent/lsp/_mock.py +200 -0
- local_coding_agent/lsp/_types.py +398 -0
- local_coding_agent/mcp_config.py +398 -0
- local_coding_agent/mcp_server.py +377 -0
- local_coding_agent/memory.py +140 -0
- local_coding_agent/mode_router.py +200 -0
- local_coding_agent/model_scanner.py +525 -0
- local_coding_agent/monitor.py +635 -0
- local_coding_agent/observation_policy.py +184 -0
- local_coding_agent/ollama_adapter.py +736 -0
- local_coding_agent/plan_mode.py +653 -0
- local_coding_agent/prescriptions.py +278 -0
- local_coding_agent/profiles.py +239 -0
- local_coding_agent/repository_tools.py +782 -0
- local_coding_agent/ripgrep.py +326 -0
- local_coding_agent/semantic_linter.py +122 -0
- local_coding_agent/service.py +405 -0
- local_coding_agent/session_events.py +684 -0
- local_coding_agent/session_query.py +578 -0
- local_coding_agent/skill_config.py +426 -0
- local_coding_agent/smoke.py +268 -0
- local_coding_agent/speculative_racing.py +85 -0
- local_coding_agent/spill.py +258 -0
- local_coding_agent/stats.py +255 -0
- local_coding_agent/stdio.py +132 -0
- local_coding_agent/subagent.py +516 -0
- local_coding_agent/task.py +90 -0
- local_coding_agent/task_store.py +142 -0
- local_coding_agent/tasks.py +240 -0
- local_coding_agent/terminal/__init__.py +35 -0
- local_coding_agent/terminal/_manager.py +156 -0
- local_coding_agent/terminal/_process.py +148 -0
- local_coding_agent/terminal/_session.py +452 -0
- local_coding_agent/terminal/_tools.py +233 -0
- local_coding_agent/terminal/_types.py +60 -0
- local_coding_agent/tool_call_parser.py +178 -0
- local_coding_agent/validators.py +563 -0
- local_coding_agent/vram_fit.py +240 -0
- local_coding_agent/worker_pool.py +516 -0
- local_coding_agent-1.1.0.dist-info/METADATA +202 -0
- local_coding_agent-1.1.0.dist-info/RECORD +101 -0
- local_coding_agent-1.1.0.dist-info/WHEEL +5 -0
- local_coding_agent-1.1.0.dist-info/entry_points.txt +3 -0
- local_coding_agent-1.1.0.dist-info/licenses/LICENSE +21 -0
- local_coding_agent-1.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Model-facing terminal tool schemas and handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..terminal._manager import TerminalManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# ============================================================================
|
|
11
|
+
# Model-Facing Tool Schemas and Handlers
|
|
12
|
+
# ============================================================================
|
|
13
|
+
|
|
14
|
+
def get_terminal_tool_schemas() -> list[dict[str, Any]]:
|
|
15
|
+
"""Return JSON schemas for the 6 persistent terminal tools."""
|
|
16
|
+
return [
|
|
17
|
+
{
|
|
18
|
+
"name": "terminal_open",
|
|
19
|
+
"description": "Create a persistent interactive terminal session (e.g. bash, cmd, REPL) that survives across tool calls.",
|
|
20
|
+
"parameters": {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {
|
|
23
|
+
"session_id": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Unique identifier for this terminal session (e.g. 'main', 'build', 'gdb').",
|
|
26
|
+
},
|
|
27
|
+
"cwd": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Initial working directory. Defaults to workspace root.",
|
|
30
|
+
},
|
|
31
|
+
"shell": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Shell or interactive command to launch (e.g. 'bash', 'powershell', 'python -i'). Defaults to system shell.",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
"required": ["session_id"],
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"name": "terminal_send",
|
|
41
|
+
"description": "Send text or commands to a persistent terminal and receive the resulting output delta.",
|
|
42
|
+
"parameters": {
|
|
43
|
+
"type": "object",
|
|
44
|
+
"properties": {
|
|
45
|
+
"session_id": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Session ID returned by terminal_open.",
|
|
48
|
+
},
|
|
49
|
+
"text": {
|
|
50
|
+
"type": "string",
|
|
51
|
+
"description": "Input text or command line to send.",
|
|
52
|
+
},
|
|
53
|
+
"wait_ms": {
|
|
54
|
+
"type": "integer",
|
|
55
|
+
"description": "Milliseconds to wait for output after sending (default 500).",
|
|
56
|
+
"default": 500,
|
|
57
|
+
},
|
|
58
|
+
"submit": {
|
|
59
|
+
"type": "boolean",
|
|
60
|
+
"description": "Whether to append a newline (Enter) after text (default true).",
|
|
61
|
+
"default": True,
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
"required": ["session_id", "text"],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "terminal_read",
|
|
69
|
+
"description": "Read a bounded window from a terminal session's retained output buffer without sending input.",
|
|
70
|
+
"parameters": {
|
|
71
|
+
"type": "object",
|
|
72
|
+
"properties": {
|
|
73
|
+
"session_id": {
|
|
74
|
+
"type": "string",
|
|
75
|
+
"description": "Session ID to read from.",
|
|
76
|
+
},
|
|
77
|
+
"offset": {
|
|
78
|
+
"type": "integer",
|
|
79
|
+
"description": "Character offset to start reading from (default 0; negative offsets count from end).",
|
|
80
|
+
"default": 0,
|
|
81
|
+
},
|
|
82
|
+
"limit": {
|
|
83
|
+
"type": "integer",
|
|
84
|
+
"description": "Maximum number of characters to return (default 4096).",
|
|
85
|
+
"default": 4096,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
"required": ["session_id"],
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"name": "terminal_signal",
|
|
93
|
+
"description": "Deliver a control signal to the terminal (e.g. SIGINT/Ctrl+C, SIGTERM, SIGKILL).",
|
|
94
|
+
"parameters": {
|
|
95
|
+
"type": "object",
|
|
96
|
+
"properties": {
|
|
97
|
+
"session_id": {
|
|
98
|
+
"type": "string",
|
|
99
|
+
"description": "Target terminal session ID.",
|
|
100
|
+
},
|
|
101
|
+
"signal": {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"description": "Signal to deliver ('SIGINT', 'SIGTERM', 'SIGKILL', etc.).",
|
|
104
|
+
"enum": ["SIGINT", "SIGTERM", "SIGKILL", "CTRL_C", "SIGBREAK"],
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
"required": ["session_id", "signal"],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"name": "terminal_list",
|
|
112
|
+
"description": "List all active persistent terminal sessions and their current status.",
|
|
113
|
+
"parameters": {
|
|
114
|
+
"type": "object",
|
|
115
|
+
"properties": {},
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"name": "terminal_close",
|
|
120
|
+
"description": "Close a persistent terminal session and terminate its entire child process tree.",
|
|
121
|
+
"parameters": {
|
|
122
|
+
"type": "object",
|
|
123
|
+
"properties": {
|
|
124
|
+
"session_id": {
|
|
125
|
+
"type": "string",
|
|
126
|
+
"description": "Session ID to terminate and close.",
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
"required": ["session_id"],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def execute_terminal_tool(
|
|
136
|
+
manager: TerminalManager,
|
|
137
|
+
name: str,
|
|
138
|
+
arguments: dict[str, Any],
|
|
139
|
+
) -> dict[str, Any]:
|
|
140
|
+
"""Execute a model-facing terminal tool by name with arguments."""
|
|
141
|
+
if name == "terminal_open":
|
|
142
|
+
session_id = arguments.get("session_id")
|
|
143
|
+
if not session_id:
|
|
144
|
+
return {"ok": False, "error": "session_id is required"}
|
|
145
|
+
cwd = arguments.get("cwd")
|
|
146
|
+
shell = arguments.get("shell")
|
|
147
|
+
try:
|
|
148
|
+
session = manager.create_session(session_id, cwd=cwd, shell=shell)
|
|
149
|
+
return {
|
|
150
|
+
"ok": True,
|
|
151
|
+
"session_id": session.session_id,
|
|
152
|
+
"pid": session.pid,
|
|
153
|
+
"cwd": str(session.cwd),
|
|
154
|
+
"shell": session.shell_cmd,
|
|
155
|
+
"status": "running",
|
|
156
|
+
}
|
|
157
|
+
except Exception as e:
|
|
158
|
+
return {"ok": False, "error": str(e)}
|
|
159
|
+
|
|
160
|
+
elif name == "terminal_send":
|
|
161
|
+
session_id = arguments.get("session_id")
|
|
162
|
+
text = arguments.get("text")
|
|
163
|
+
if not session_id or text is None:
|
|
164
|
+
return {"ok": False, "error": "session_id and text are required"}
|
|
165
|
+
wait_ms = int(arguments.get("wait_ms", 500))
|
|
166
|
+
submit = bool(arguments.get("submit", True))
|
|
167
|
+
try:
|
|
168
|
+
output = manager.send_input(session_id, str(text), wait_ms=wait_ms, submit=submit)
|
|
169
|
+
session = manager.get_session(session_id)
|
|
170
|
+
return {
|
|
171
|
+
"ok": True,
|
|
172
|
+
"session_id": session_id,
|
|
173
|
+
"output": output,
|
|
174
|
+
"alive": session.is_alive(),
|
|
175
|
+
"exit_code": session.exit_code,
|
|
176
|
+
}
|
|
177
|
+
except Exception as e:
|
|
178
|
+
return {"ok": False, "error": str(e)}
|
|
179
|
+
|
|
180
|
+
elif name == "terminal_read":
|
|
181
|
+
session_id = arguments.get("session_id")
|
|
182
|
+
if not session_id:
|
|
183
|
+
return {"ok": False, "error": "session_id is required"}
|
|
184
|
+
offset = int(arguments.get("offset", 0))
|
|
185
|
+
limit = max(0, int(arguments.get("limit", 4096)))
|
|
186
|
+
try:
|
|
187
|
+
output = manager.read_buffer(session_id, offset=offset, limit=limit)
|
|
188
|
+
session = manager.get_session(session_id)
|
|
189
|
+
return {
|
|
190
|
+
"ok": True,
|
|
191
|
+
"session_id": session_id,
|
|
192
|
+
"output": output,
|
|
193
|
+
"total_buffer_bytes": len(session.buffer.encode("utf-8")),
|
|
194
|
+
"offset": offset,
|
|
195
|
+
"limit": limit,
|
|
196
|
+
}
|
|
197
|
+
except Exception as e:
|
|
198
|
+
return {"ok": False, "error": str(e)}
|
|
199
|
+
|
|
200
|
+
elif name == "terminal_signal":
|
|
201
|
+
session_id = arguments.get("session_id")
|
|
202
|
+
sig = arguments.get("signal")
|
|
203
|
+
if not session_id or not sig:
|
|
204
|
+
return {"ok": False, "error": "session_id and signal are required"}
|
|
205
|
+
try:
|
|
206
|
+
delivered = manager.send_signal(session_id, str(sig))
|
|
207
|
+
return {
|
|
208
|
+
"ok": delivered,
|
|
209
|
+
"session_id": session_id,
|
|
210
|
+
"signal": sig,
|
|
211
|
+
"delivered": delivered,
|
|
212
|
+
}
|
|
213
|
+
except Exception as e:
|
|
214
|
+
return {"ok": False, "error": str(e)}
|
|
215
|
+
|
|
216
|
+
elif name == "terminal_list":
|
|
217
|
+
try:
|
|
218
|
+
sessions = manager.list_sessions()
|
|
219
|
+
return {"ok": True, "sessions": sessions}
|
|
220
|
+
except Exception as e:
|
|
221
|
+
return {"ok": False, "error": str(e)}
|
|
222
|
+
|
|
223
|
+
elif name == "terminal_close":
|
|
224
|
+
session_id = arguments.get("session_id")
|
|
225
|
+
if not session_id:
|
|
226
|
+
return {"ok": False, "error": "session_id is required"}
|
|
227
|
+
try:
|
|
228
|
+
manager.close_session(session_id)
|
|
229
|
+
return {"ok": True, "session_id": session_id, "closed": True}
|
|
230
|
+
except Exception as e:
|
|
231
|
+
return {"ok": False, "error": str(e)}
|
|
232
|
+
|
|
233
|
+
return {"ok": False, "error": f"Unknown tool: {name}"}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Exceptions and data models for the terminal package."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# ============================================================================
|
|
10
|
+
# Exceptions
|
|
11
|
+
# ============================================================================
|
|
12
|
+
|
|
13
|
+
class TerminalError(RuntimeError):
|
|
14
|
+
"""Base exception for terminal failures."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TerminalSessionNotFoundError(TerminalError):
|
|
18
|
+
"""Raised when a requested terminal session does not exist."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class TerminalSessionExistsError(TerminalError):
|
|
22
|
+
"""Raised when creating a session with an ID that is already active."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TerminalProcessExitedError(TerminalError):
|
|
26
|
+
"""Raised when attempting to interact with a terminal process that has exited."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TerminalTimeoutError(TerminalError):
|
|
30
|
+
"""Raised when a terminal operation times out."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ============================================================================
|
|
34
|
+
# Data Models
|
|
35
|
+
# ============================================================================
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class TerminalSessionInfo:
|
|
39
|
+
"""Snapshot metadata describing a persistent terminal session."""
|
|
40
|
+
|
|
41
|
+
session_id: str
|
|
42
|
+
pid: int
|
|
43
|
+
alive: bool
|
|
44
|
+
exit_code: int | None
|
|
45
|
+
cwd: str
|
|
46
|
+
shell: str
|
|
47
|
+
buffer_size: int
|
|
48
|
+
created_at: float
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"session_id": self.session_id,
|
|
53
|
+
"pid": self.pid,
|
|
54
|
+
"alive": self.alive,
|
|
55
|
+
"exit_code": self.exit_code,
|
|
56
|
+
"cwd": self.cwd,
|
|
57
|
+
"shell": self.shell,
|
|
58
|
+
"buffer_size": self.buffer_size,
|
|
59
|
+
"created_at": self.created_at,
|
|
60
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Small-model tool-call emitters -> controller tool_call dicts.
|
|
2
|
+
|
|
3
|
+
Small local models often cannot emit a proper OpenAI ``tool_calls`` array and
|
|
4
|
+
instead spell a tool call inside their text using one of several ad-hoc
|
|
5
|
+
formats (qwen ``<tool_call>`` JSON, qwen XML ``<function=...>``,
|
|
6
|
+
llama-server ``<|python_tag|>``, mistral ``[TOOL_CALLS]``). This module
|
|
7
|
+
extracts those spans and reshapes them into the SAME dict shape the
|
|
8
|
+
controller's ``_decode_tool_call`` consumes (``{"function": {"name",
|
|
9
|
+
"arguments"}}``), so the controller can promote them into real tool calls.
|
|
10
|
+
|
|
11
|
+
Standalone on purpose (no controller import) to avoid a circular import; the
|
|
12
|
+
dict shape is duplicated here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ToolCallExtraction:
|
|
25
|
+
calls: list[dict[str, Any]] = field(default_factory=list)
|
|
26
|
+
remaining_text: str = ""
|
|
27
|
+
formats_detected: list[str] = field(default_factory=list)
|
|
28
|
+
errors: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_FORMATS: list[tuple[str, re.Pattern]] = [
|
|
32
|
+
("<tool_call>", re.compile(r"<tool_call>(.*?)</tool_call>", re.S)),
|
|
33
|
+
("llama_python_tag", re.compile(r"<\|python_tag\|>(.*?)(?=<|$)", re.S)),
|
|
34
|
+
("qwen_xml", re.compile(r"<function=([^>]+)>(.*?)</function>", re.S)),
|
|
35
|
+
("mistral_tool_calls", re.compile(r"\[TOOL_CALLS\]\s*(\[[^\]]*\])", re.S)),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# <parameter=K>V</parameter> or <parameter name="K">V</parameter>
|
|
39
|
+
_PARAMETER_RE = re.compile(
|
|
40
|
+
r"<parameter\s*(?:name=\"([^\"]+)\"|=([^>]+))>(.*?)</parameter>", re.S
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_argument(value: str) -> Any:
|
|
45
|
+
"""Parse a qwen-XML parameter value as JSON when possible, else raw string."""
|
|
46
|
+
try:
|
|
47
|
+
return json.loads(value)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return value
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _make_call(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
53
|
+
return {"function": {"name": name, "arguments": arguments}}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _matches_allowed(name: str, allowed_names: list[str] | None) -> bool:
|
|
57
|
+
return not allowed_names or name in allowed_names
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _consume(text: str, spans: list[tuple[int, int]]) -> str:
|
|
61
|
+
"""Drop the given [start, end) spans from text, keeping everything else."""
|
|
62
|
+
if not spans:
|
|
63
|
+
return text
|
|
64
|
+
spans = sorted(spans)
|
|
65
|
+
parts: list[str] = []
|
|
66
|
+
cursor = 0
|
|
67
|
+
for start, end in spans:
|
|
68
|
+
if start > cursor:
|
|
69
|
+
parts.append(text[cursor:start])
|
|
70
|
+
cursor = max(cursor, end)
|
|
71
|
+
parts.append(text[cursor:])
|
|
72
|
+
return "".join(parts)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _extract_json_calls(
|
|
76
|
+
text: str,
|
|
77
|
+
body: str,
|
|
78
|
+
start: int,
|
|
79
|
+
end: int,
|
|
80
|
+
allowed_names: list[str] | None,
|
|
81
|
+
calls: list[dict[str, Any]],
|
|
82
|
+
spans: list[tuple[int, int]],
|
|
83
|
+
is_array: bool,
|
|
84
|
+
errors: list[str],
|
|
85
|
+
format_name: str,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Parse a JSON (object or array) body into calls; consume the span on success."""
|
|
88
|
+
try:
|
|
89
|
+
payload = json.loads(body)
|
|
90
|
+
except (TypeError, ValueError):
|
|
91
|
+
errors.append(f"{format_name}: malformed JSON tool call")
|
|
92
|
+
return # malformed -> span left in remaining_text, no call promoted
|
|
93
|
+
items = payload if is_array and isinstance(payload, list) else [payload]
|
|
94
|
+
if not items:
|
|
95
|
+
return
|
|
96
|
+
promoted: list[dict[str, Any]] = []
|
|
97
|
+
for index, item in enumerate(items):
|
|
98
|
+
if not isinstance(item, dict):
|
|
99
|
+
errors.append(f"{format_name}[{index}]: tool call must be an object")
|
|
100
|
+
continue
|
|
101
|
+
name = item.get("name")
|
|
102
|
+
arguments = item.get("arguments")
|
|
103
|
+
if not isinstance(name, str) or not name or not isinstance(arguments, dict):
|
|
104
|
+
errors.append(f"{format_name}[{index}]: name and object arguments are required")
|
|
105
|
+
continue
|
|
106
|
+
if not _matches_allowed(name, allowed_names):
|
|
107
|
+
return # not allowlisted: keep the whole span in remaining text
|
|
108
|
+
promoted.append(_make_call(name, arguments))
|
|
109
|
+
if promoted:
|
|
110
|
+
calls.extend(promoted)
|
|
111
|
+
spans.append((start, end))
|
|
112
|
+
elif is_array and isinstance(payload, list) and payload:
|
|
113
|
+
# array held only invalid items -> consume the span, but retain errors
|
|
114
|
+
spans.append((start, end))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def extract_tool_calls(
|
|
118
|
+
text: str, allowed_names: list[str] | None = None
|
|
119
|
+
) -> ToolCallExtraction:
|
|
120
|
+
"""Extract small-model tool calls from ``text``.
|
|
121
|
+
|
|
122
|
+
Returns a :class:`ToolCallExtraction`. Consumed spans are removed from
|
|
123
|
+
``remaining_text``; a call whose name is not in ``allowed_names`` (when
|
|
124
|
+
provided) is left untouched in the text. Malformed input never raises —
|
|
125
|
+
worst case an empty ``calls`` list and the original text.
|
|
126
|
+
"""
|
|
127
|
+
if not isinstance(text, str) or not text.strip():
|
|
128
|
+
return ToolCallExtraction(remaining_text=text or "", formats_detected=[])
|
|
129
|
+
|
|
130
|
+
calls: list[dict[str, Any]] = []
|
|
131
|
+
spans: list[tuple[int, int]] = []
|
|
132
|
+
formats_detected: list[str] = []
|
|
133
|
+
errors: list[str] = []
|
|
134
|
+
|
|
135
|
+
for fmt, pattern in _FORMATS:
|
|
136
|
+
matched = False
|
|
137
|
+
for m in pattern.finditer(text):
|
|
138
|
+
matched = True
|
|
139
|
+
start, end = m.span()
|
|
140
|
+
if fmt == "<tool_call>":
|
|
141
|
+
_extract_json_calls(
|
|
142
|
+
text, m.group(1), start, end, allowed_names, calls, spans, False,
|
|
143
|
+
errors, fmt,
|
|
144
|
+
)
|
|
145
|
+
elif fmt == "llama_python_tag":
|
|
146
|
+
_extract_json_calls(
|
|
147
|
+
text, m.group(1), start, end, allowed_names, calls, spans, False,
|
|
148
|
+
errors, fmt,
|
|
149
|
+
)
|
|
150
|
+
elif fmt == "mistral_tool_calls":
|
|
151
|
+
_extract_json_calls(
|
|
152
|
+
text, m.group(1), start, end, allowed_names, calls, spans, True,
|
|
153
|
+
errors, fmt,
|
|
154
|
+
)
|
|
155
|
+
elif fmt == "qwen_xml":
|
|
156
|
+
name = m.group(1).strip()
|
|
157
|
+
arguments: dict[str, Any] = {}
|
|
158
|
+
valid = bool(name)
|
|
159
|
+
for pm in _PARAMETER_RE.finditer(m.group(2)):
|
|
160
|
+
key = (pm.group(1) or pm.group(2) or "").strip()
|
|
161
|
+
if not key:
|
|
162
|
+
continue
|
|
163
|
+
arguments[key] = _parse_argument(pm.group(3))
|
|
164
|
+
if not valid or not _matches_allowed(name, allowed_names):
|
|
165
|
+
if not valid:
|
|
166
|
+
errors.append(f"{fmt}: tool function name is required")
|
|
167
|
+
continue # missing name -> skipped; not allowed -> kept in text
|
|
168
|
+
calls.append(_make_call(name, arguments))
|
|
169
|
+
spans.append((start, end))
|
|
170
|
+
if matched:
|
|
171
|
+
formats_detected.append(fmt)
|
|
172
|
+
|
|
173
|
+
return ToolCallExtraction(
|
|
174
|
+
calls=calls,
|
|
175
|
+
remaining_text=_consume(text, spans),
|
|
176
|
+
formats_detected=formats_detected,
|
|
177
|
+
errors=errors,
|
|
178
|
+
)
|