dscli-agent 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.
- dscli/__init__.py +3 -0
- dscli/__main__.py +4 -0
- dscli/agent.py +312 -0
- dscli/attachments.py +122 -0
- dscli/browser.py +1354 -0
- dscli/cli.py +842 -0
- dscli/config.py +191 -0
- dscli/display.py +108 -0
- dscli/prompts.py +263 -0
- dscli/protocol.py +633 -0
- dscli/scraper.py +268 -0
- dscli/tools.py +674 -0
- dscli/tui.py +574 -0
- dscli/ui.py +72 -0
- dscli_agent-0.1.0.dist-info/METADATA +379 -0
- dscli_agent-0.1.0.dist-info/RECORD +20 -0
- dscli_agent-0.1.0.dist-info/WHEEL +5 -0
- dscli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- dscli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- dscli_agent-0.1.0.dist-info/top_level.txt +1 -0
dscli/protocol.py
ADDED
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any, Iterable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
CANONICAL_TOOLS = {
|
|
11
|
+
"read_file",
|
|
12
|
+
"write_file",
|
|
13
|
+
"apply_patch",
|
|
14
|
+
"list_files",
|
|
15
|
+
"delete_file",
|
|
16
|
+
"run_command",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
TOOL_ALIASES = {
|
|
20
|
+
"read": "read_file",
|
|
21
|
+
"cat": "read_file",
|
|
22
|
+
"open_file": "read_file",
|
|
23
|
+
"view_file": "read_file",
|
|
24
|
+
"write": "write_file",
|
|
25
|
+
"create_file": "write_file",
|
|
26
|
+
"create": "write_file",
|
|
27
|
+
"save_file": "write_file",
|
|
28
|
+
"overwrite_file": "write_file",
|
|
29
|
+
"edit": "apply_patch",
|
|
30
|
+
"edit_file": "apply_patch",
|
|
31
|
+
"patch": "apply_patch",
|
|
32
|
+
"replace": "apply_patch",
|
|
33
|
+
"replace_in_file": "apply_patch",
|
|
34
|
+
"list": "list_files",
|
|
35
|
+
"ls": "list_files",
|
|
36
|
+
"list_dir": "list_files",
|
|
37
|
+
"list_directory": "list_files",
|
|
38
|
+
"rm": "delete_file",
|
|
39
|
+
"remove_file": "delete_file",
|
|
40
|
+
"bash": "run_command",
|
|
41
|
+
"shell": "run_command",
|
|
42
|
+
"exec": "run_command",
|
|
43
|
+
"execute": "run_command",
|
|
44
|
+
"run": "run_command",
|
|
45
|
+
"terminal": "run_command",
|
|
46
|
+
"finish": "finish",
|
|
47
|
+
"done": "finish",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class ToolCall:
|
|
53
|
+
name: str
|
|
54
|
+
arguments: dict[str, Any] = field(default_factory=dict)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Step:
|
|
59
|
+
thought: str = ""
|
|
60
|
+
tool_calls: list[ToolCall] = field(default_factory=list)
|
|
61
|
+
final: str | None = None
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def is_final(self) -> bool:
|
|
65
|
+
return self.final is not None and not self.tool_calls
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ProtocolError(ValueError):
|
|
69
|
+
def __init__(self, message: str, raw: str = "") -> None:
|
|
70
|
+
super().__init__(message)
|
|
71
|
+
self.raw = raw
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def canonical_tool_name(name: Any) -> str:
|
|
75
|
+
if not isinstance(name, str):
|
|
76
|
+
return ""
|
|
77
|
+
cleaned = name.strip().lower().replace("-", "_").replace(" ", "_")
|
|
78
|
+
cleaned = TOOL_ALIASES.get(cleaned, cleaned)
|
|
79
|
+
return cleaned
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _coerce_args(value: Any) -> dict[str, Any]:
|
|
83
|
+
if value is None:
|
|
84
|
+
return {}
|
|
85
|
+
if isinstance(value, dict):
|
|
86
|
+
return value
|
|
87
|
+
if isinstance(value, str):
|
|
88
|
+
text = value.strip()
|
|
89
|
+
if not text:
|
|
90
|
+
return {}
|
|
91
|
+
try:
|
|
92
|
+
parsed = json.loads(text)
|
|
93
|
+
except json.JSONDecodeError:
|
|
94
|
+
return {"input": value}
|
|
95
|
+
if isinstance(parsed, dict):
|
|
96
|
+
return parsed
|
|
97
|
+
return {"input": parsed}
|
|
98
|
+
if isinstance(value, list):
|
|
99
|
+
return {"items": value}
|
|
100
|
+
return {"input": value}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _normalize_call(obj: Any) -> ToolCall | None:
|
|
104
|
+
if isinstance(obj, str):
|
|
105
|
+
name = canonical_tool_name(obj)
|
|
106
|
+
if name:
|
|
107
|
+
return ToolCall(name=name, arguments={})
|
|
108
|
+
return None
|
|
109
|
+
if not isinstance(obj, dict):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
fn = obj.get("function")
|
|
113
|
+
if isinstance(fn, dict):
|
|
114
|
+
name = fn.get("name") or fn.get("tool") or fn.get("action")
|
|
115
|
+
args = fn.get("arguments", fn.get("parameters", fn.get("args", {})))
|
|
116
|
+
else:
|
|
117
|
+
name = obj.get("name") or obj.get("tool") or obj.get("action") or obj.get("type")
|
|
118
|
+
if isinstance(name, str) and name.strip().lower() in {"tool_call", "tool", "function"}:
|
|
119
|
+
name = obj.get("tool_name") or obj.get("function_name") or obj.get("name")
|
|
120
|
+
args = obj.get(
|
|
121
|
+
"arguments",
|
|
122
|
+
obj.get("args", obj.get("parameters", obj.get("action_input", obj.get("input")))),
|
|
123
|
+
)
|
|
124
|
+
if args is None:
|
|
125
|
+
# Some models flatten arguments into the call object:
|
|
126
|
+
# {"name":"read_file","path":"x"}.
|
|
127
|
+
reserved = {
|
|
128
|
+
"name", "tool", "action", "type", "function", "tool_name",
|
|
129
|
+
"function_name", "thought", "reasoning",
|
|
130
|
+
}
|
|
131
|
+
flattened = {k: v for k, v in obj.items() if k not in reserved}
|
|
132
|
+
args = flattened if flattened else {}
|
|
133
|
+
canonical = canonical_tool_name(name)
|
|
134
|
+
if not canonical:
|
|
135
|
+
return None
|
|
136
|
+
return ToolCall(name=canonical, arguments=_coerce_args(args))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _collect_calls(data: dict[str, Any]) -> list[ToolCall]:
|
|
140
|
+
raw = None
|
|
141
|
+
for key in ("tool_calls", "toolCalls", "tools", "calls", "actions"):
|
|
142
|
+
if key in data and data[key] is not None:
|
|
143
|
+
raw = data[key]
|
|
144
|
+
break
|
|
145
|
+
if raw is None:
|
|
146
|
+
return []
|
|
147
|
+
if isinstance(raw, dict):
|
|
148
|
+
raw = [raw]
|
|
149
|
+
if isinstance(raw, str):
|
|
150
|
+
try:
|
|
151
|
+
parsed = json.loads(raw)
|
|
152
|
+
except json.JSONDecodeError:
|
|
153
|
+
parsed = [raw]
|
|
154
|
+
raw = parsed if isinstance(parsed, list) else [parsed]
|
|
155
|
+
if not isinstance(raw, list):
|
|
156
|
+
return []
|
|
157
|
+
calls: list[ToolCall] = []
|
|
158
|
+
for item in raw:
|
|
159
|
+
call = _normalize_call(item)
|
|
160
|
+
if call:
|
|
161
|
+
calls.append(call)
|
|
162
|
+
return calls
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _coerce_final(value: Any) -> str | None:
|
|
166
|
+
if value is None:
|
|
167
|
+
return None
|
|
168
|
+
if isinstance(value, str):
|
|
169
|
+
return value
|
|
170
|
+
if isinstance(value, (dict, list)):
|
|
171
|
+
return json.dumps(value, ensure_ascii=False, indent=2)
|
|
172
|
+
return str(value)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _normalize(data: Any) -> Step | None:
|
|
176
|
+
if isinstance(data, list):
|
|
177
|
+
calls = [c for c in (_normalize_call(x) for x in data) if c]
|
|
178
|
+
calls, finish_text = _finish_to_final(calls)
|
|
179
|
+
if calls or finish_text is not None:
|
|
180
|
+
return Step(tool_calls=calls, final=finish_text)
|
|
181
|
+
return None
|
|
182
|
+
if not isinstance(data, dict):
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
thought = data.get("thought") or data.get("reasoning") or data.get("thinking") or ""
|
|
186
|
+
if not isinstance(thought, str):
|
|
187
|
+
thought = json.dumps(thought, ensure_ascii=False)
|
|
188
|
+
|
|
189
|
+
calls = _collect_calls(data)
|
|
190
|
+
|
|
191
|
+
final: Any = None
|
|
192
|
+
for key in ("final", "final_answer", "answer", "response"):
|
|
193
|
+
if key in data:
|
|
194
|
+
final = data[key]
|
|
195
|
+
break
|
|
196
|
+
|
|
197
|
+
dtype = str(data.get("type", "")).strip().lower()
|
|
198
|
+
if final is None and dtype in {"final", "answer", "result"}:
|
|
199
|
+
final = data.get("content", data.get("message", data.get("text")))
|
|
200
|
+
if not calls and dtype in {"tool", "tool_call", "action"}:
|
|
201
|
+
single = _normalize_call(data)
|
|
202
|
+
if single:
|
|
203
|
+
calls = [single]
|
|
204
|
+
if not calls and isinstance(data.get("action"), str):
|
|
205
|
+
action_name = canonical_tool_name(data.get("action"))
|
|
206
|
+
if action_name in CANONICAL_TOOLS or action_name == "finish":
|
|
207
|
+
calls = [
|
|
208
|
+
ToolCall(
|
|
209
|
+
name=action_name,
|
|
210
|
+
arguments=_coerce_args(
|
|
211
|
+
data.get("action_input", data.get("arguments", data.get("input", {})))
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
]
|
|
215
|
+
if not calls and isinstance(data.get("tool"), str):
|
|
216
|
+
single = _normalize_call(data)
|
|
217
|
+
if single:
|
|
218
|
+
calls = [single]
|
|
219
|
+
if not calls and isinstance(data.get("name"), str):
|
|
220
|
+
single = _normalize_call(data)
|
|
221
|
+
if single:
|
|
222
|
+
calls = [single]
|
|
223
|
+
|
|
224
|
+
calls, finish_text = _finish_to_final(calls)
|
|
225
|
+
final_text = _coerce_final(final)
|
|
226
|
+
if finish_text is not None:
|
|
227
|
+
final_text = finish_text if final_text is None else final_text
|
|
228
|
+
|
|
229
|
+
if not calls and final_text is None:
|
|
230
|
+
# Some models use a bare {"done": true} at the end.
|
|
231
|
+
if data.get("done") is True:
|
|
232
|
+
final_text = _coerce_final(data.get("summary", "")) or "Done."
|
|
233
|
+
else:
|
|
234
|
+
return None
|
|
235
|
+
if final_text is not None:
|
|
236
|
+
final_text = final_text.strip() if final_text.strip() else final_text
|
|
237
|
+
|
|
238
|
+
return Step(thought=thought.strip(), tool_calls=calls, final=final_text)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _finish_to_final(calls: list[ToolCall]) -> tuple[list[ToolCall], str | None]:
|
|
242
|
+
remaining: list[ToolCall] = []
|
|
243
|
+
finish_text: str | None = None
|
|
244
|
+
for call in calls:
|
|
245
|
+
if call.name == "finish":
|
|
246
|
+
finish_text = (
|
|
247
|
+
call.arguments.get("summary")
|
|
248
|
+
or call.arguments.get("message")
|
|
249
|
+
or call.arguments.get("final")
|
|
250
|
+
or call.arguments.get("text")
|
|
251
|
+
or ""
|
|
252
|
+
)
|
|
253
|
+
finish_text = _coerce_final(finish_text) or ""
|
|
254
|
+
else:
|
|
255
|
+
remaining.append(call)
|
|
256
|
+
return remaining, finish_text
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _balanced_slices(text: str, max_candidates: int = 40) -> Iterable[str]:
|
|
260
|
+
"""Yield balanced JSON-looking spans starting at every { or [.
|
|
261
|
+
|
|
262
|
+
A small model response is short; O(n^2) worst-case is acceptable here.
|
|
263
|
+
Strings and escapes are respected so braces inside strings are ignored.
|
|
264
|
+
"""
|
|
265
|
+
n = len(text)
|
|
266
|
+
produced = 0
|
|
267
|
+
for start in range(n):
|
|
268
|
+
if text[start] not in "{[":
|
|
269
|
+
continue
|
|
270
|
+
stack: list[str] = []
|
|
271
|
+
in_string = False
|
|
272
|
+
escaped = False
|
|
273
|
+
for i in range(start, n):
|
|
274
|
+
ch = text[i]
|
|
275
|
+
if in_string:
|
|
276
|
+
if escaped:
|
|
277
|
+
escaped = False
|
|
278
|
+
elif ch == "\\":
|
|
279
|
+
escaped = True
|
|
280
|
+
elif ch == '"':
|
|
281
|
+
in_string = False
|
|
282
|
+
continue
|
|
283
|
+
if ch == '"':
|
|
284
|
+
in_string = True
|
|
285
|
+
elif ch in "{[":
|
|
286
|
+
stack.append(ch)
|
|
287
|
+
elif ch in "}]":
|
|
288
|
+
if not stack:
|
|
289
|
+
break
|
|
290
|
+
opener = stack.pop()
|
|
291
|
+
if (opener == "{" and ch != "}") or (opener == "[" and ch != "]"):
|
|
292
|
+
break
|
|
293
|
+
if not stack:
|
|
294
|
+
yield text[start : i + 1]
|
|
295
|
+
produced += 1
|
|
296
|
+
break
|
|
297
|
+
if produced >= max_candidates:
|
|
298
|
+
break
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
_FENCE_RE = re.compile(r"```[ \t]*(?:json|JSON)?[ \t]*\r?\n?(.*?)```", re.DOTALL)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _json_candidates(raw: str) -> list[str]:
|
|
305
|
+
candidates: list[str] = []
|
|
306
|
+
seen: set[str] = set()
|
|
307
|
+
|
|
308
|
+
def add(text: str) -> None:
|
|
309
|
+
text = text.strip()
|
|
310
|
+
if text and text not in seen:
|
|
311
|
+
seen.add(text)
|
|
312
|
+
candidates.append(text)
|
|
313
|
+
|
|
314
|
+
add(raw)
|
|
315
|
+
for match in _FENCE_RE.finditer(raw):
|
|
316
|
+
add(match.group(1))
|
|
317
|
+
for span in _balanced_slices(raw):
|
|
318
|
+
add(span)
|
|
319
|
+
# A fenced block may itself contain prose around a JSON object; the
|
|
320
|
+
# balanced scan above already covers that.
|
|
321
|
+
return candidates
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _repair_invalid_escapes(text: str) -> str:
|
|
325
|
+
repaired: list[str] = []
|
|
326
|
+
in_string = False
|
|
327
|
+
index = 0
|
|
328
|
+
while index < len(text):
|
|
329
|
+
char = text[index]
|
|
330
|
+
if char == '"':
|
|
331
|
+
in_string = not in_string
|
|
332
|
+
repaired.append(char)
|
|
333
|
+
index += 1
|
|
334
|
+
continue
|
|
335
|
+
if char != "\\" or not in_string or index + 1 >= len(text):
|
|
336
|
+
repaired.append(char)
|
|
337
|
+
index += 1
|
|
338
|
+
continue
|
|
339
|
+
|
|
340
|
+
escape = text[index + 1]
|
|
341
|
+
if escape in {'"', "\\", "/", "b", "f", "n", "r", "t"}:
|
|
342
|
+
repaired.append(text[index : index + 2])
|
|
343
|
+
index += 2
|
|
344
|
+
elif (
|
|
345
|
+
escape == "u"
|
|
346
|
+
and index + 6 <= len(text)
|
|
347
|
+
and all(c in "0123456789abcdefABCDEF" for c in text[index + 2 : index + 6])
|
|
348
|
+
):
|
|
349
|
+
repaired.append(text[index : index + 6])
|
|
350
|
+
index += 6
|
|
351
|
+
elif escape == "u":
|
|
352
|
+
repaired.append(char)
|
|
353
|
+
index += 1
|
|
354
|
+
else:
|
|
355
|
+
repaired.extend(("\\", "\\", escape))
|
|
356
|
+
index += 2
|
|
357
|
+
return "".join(repaired)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _repair_string_content(text: str, max_attempts: int = 300) -> str | None:
|
|
361
|
+
"""Escape quotes and backslashes that break otherwise valid JSON strings.
|
|
362
|
+
|
|
363
|
+
Models often paste code into ``content`` or ``command`` values without
|
|
364
|
+
escaping its double quotes. Each parse error tells us which quote the
|
|
365
|
+
parser just treated as the end of a string, so escape that one and retry.
|
|
366
|
+
"""
|
|
367
|
+
pending = deque([text])
|
|
368
|
+
seen = {text}
|
|
369
|
+
while pending and len(seen) <= max_attempts:
|
|
370
|
+
current = pending.popleft()
|
|
371
|
+
try:
|
|
372
|
+
json.loads(current)
|
|
373
|
+
except json.JSONDecodeError as exc:
|
|
374
|
+
repairs: list[str] = []
|
|
375
|
+
if exc.msg == "Expecting ',' delimiter":
|
|
376
|
+
positions: list[int] = []
|
|
377
|
+
anchor = exc.pos - 1
|
|
378
|
+
while anchor >= 0 and current[anchor].isspace():
|
|
379
|
+
anchor -= 1
|
|
380
|
+
if anchor >= 0 and current[anchor] == '"':
|
|
381
|
+
positions.append(anchor)
|
|
382
|
+
if exc.pos < len(current) and current[exc.pos] == '"':
|
|
383
|
+
positions.append(exc.pos)
|
|
384
|
+
for pos in positions:
|
|
385
|
+
repairs.append(current[:pos] + "\\" + current[pos:])
|
|
386
|
+
elif exc.msg == "Invalid \\escape":
|
|
387
|
+
if exc.pos < len(current) and current[exc.pos] == "\\":
|
|
388
|
+
repairs.append(current[: exc.pos] + "\\" + current[exc.pos:])
|
|
389
|
+
for repaired in repairs:
|
|
390
|
+
if repaired not in seen:
|
|
391
|
+
seen.add(repaired)
|
|
392
|
+
pending.append(repaired)
|
|
393
|
+
else:
|
|
394
|
+
return current
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _try_loads(text: str) -> Any:
|
|
399
|
+
bases = [text]
|
|
400
|
+
stripped = text.strip()
|
|
401
|
+
if stripped.lower().startswith("json\n"):
|
|
402
|
+
bases.append(stripped[5:])
|
|
403
|
+
attempts: list[str] = []
|
|
404
|
+
for base in bases:
|
|
405
|
+
repaired = _repair_invalid_escapes(base)
|
|
406
|
+
attempts.extend(
|
|
407
|
+
[
|
|
408
|
+
base,
|
|
409
|
+
re.sub(r",\s*([}\]])", r"\1", base),
|
|
410
|
+
repaired,
|
|
411
|
+
re.sub(r",\s*([}\]])", r"\1", repaired),
|
|
412
|
+
]
|
|
413
|
+
)
|
|
414
|
+
last_error: Exception | None = None
|
|
415
|
+
for attempt in attempts:
|
|
416
|
+
try:
|
|
417
|
+
return json.loads(attempt)
|
|
418
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
419
|
+
last_error = exc
|
|
420
|
+
for attempt in dict.fromkeys(attempts):
|
|
421
|
+
repaired = _repair_string_content(attempt)
|
|
422
|
+
if repaired is None:
|
|
423
|
+
continue
|
|
424
|
+
for candidate in (repaired, re.sub(r",\s*([}\]])", r"\1", repaired)):
|
|
425
|
+
try:
|
|
426
|
+
return json.loads(candidate)
|
|
427
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
428
|
+
last_error = exc
|
|
429
|
+
raise last_error or ValueError("empty JSON")
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _complete_json_string(text: str, start: int) -> tuple[str, int] | None:
|
|
433
|
+
escaped = False
|
|
434
|
+
for index in range(start + 1, len(text)):
|
|
435
|
+
char = text[index]
|
|
436
|
+
if escaped:
|
|
437
|
+
escaped = False
|
|
438
|
+
elif char == "\\":
|
|
439
|
+
escaped = True
|
|
440
|
+
elif char == '"':
|
|
441
|
+
try:
|
|
442
|
+
value = json.loads(text[start : index + 1])
|
|
443
|
+
except json.JSONDecodeError:
|
|
444
|
+
return None
|
|
445
|
+
return value, index + 1
|
|
446
|
+
return None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _json_value_end(text: str, start: int) -> int | None:
|
|
450
|
+
depth = 0
|
|
451
|
+
in_string = False
|
|
452
|
+
escaped = False
|
|
453
|
+
for index in range(start, len(text)):
|
|
454
|
+
char = text[index]
|
|
455
|
+
if in_string:
|
|
456
|
+
if escaped:
|
|
457
|
+
escaped = False
|
|
458
|
+
elif char == "\\":
|
|
459
|
+
escaped = True
|
|
460
|
+
elif char == '"':
|
|
461
|
+
in_string = False
|
|
462
|
+
continue
|
|
463
|
+
if char == '"':
|
|
464
|
+
in_string = True
|
|
465
|
+
elif char in "[{":
|
|
466
|
+
depth += 1
|
|
467
|
+
elif char in "]}":
|
|
468
|
+
if depth == 0:
|
|
469
|
+
return index if char == "}" else None
|
|
470
|
+
depth -= 1
|
|
471
|
+
elif char == "," and depth == 0:
|
|
472
|
+
return index
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _decode_final_prefix(text: str, start: int) -> str:
|
|
477
|
+
decoded: list[str] = []
|
|
478
|
+
escapes = {
|
|
479
|
+
'"': '"',
|
|
480
|
+
"\\": "\\",
|
|
481
|
+
"/": "/",
|
|
482
|
+
"b": "\b",
|
|
483
|
+
"f": "\f",
|
|
484
|
+
"n": "\n",
|
|
485
|
+
"r": "\r",
|
|
486
|
+
"t": "\t",
|
|
487
|
+
}
|
|
488
|
+
index = start + 1
|
|
489
|
+
while index < len(text):
|
|
490
|
+
char = text[index]
|
|
491
|
+
if char == '"':
|
|
492
|
+
break
|
|
493
|
+
if char != "\\":
|
|
494
|
+
decoded.append(char)
|
|
495
|
+
index += 1
|
|
496
|
+
continue
|
|
497
|
+
if index + 1 >= len(text):
|
|
498
|
+
break
|
|
499
|
+
escape = text[index + 1]
|
|
500
|
+
if escape in escapes:
|
|
501
|
+
decoded.append(escapes[escape])
|
|
502
|
+
index += 2
|
|
503
|
+
continue
|
|
504
|
+
if escape != "u":
|
|
505
|
+
decoded.extend(("\\", escape))
|
|
506
|
+
index += 2
|
|
507
|
+
continue
|
|
508
|
+
if index + 6 > len(text):
|
|
509
|
+
break
|
|
510
|
+
digits = text[index + 2 : index + 6]
|
|
511
|
+
if any(c not in "0123456789abcdefABCDEF" for c in digits):
|
|
512
|
+
break
|
|
513
|
+
codepoint = int(digits, 16)
|
|
514
|
+
next_index = index + 6
|
|
515
|
+
if 0xD800 <= codepoint <= 0xDFFF:
|
|
516
|
+
if next_index + 6 > len(text) or text[next_index : next_index + 2] != "\\u":
|
|
517
|
+
break
|
|
518
|
+
low_digits = text[next_index + 2 : next_index + 6]
|
|
519
|
+
if any(c not in "0123456789abcdefABCDEF" for c in low_digits):
|
|
520
|
+
break
|
|
521
|
+
low = int(low_digits, 16)
|
|
522
|
+
if not (0xD800 <= codepoint <= 0xDBFF and 0xDC00 <= low <= 0xDFFF):
|
|
523
|
+
break
|
|
524
|
+
codepoint = 0x10000 + ((codepoint - 0xD800) << 10) + (low - 0xDC00)
|
|
525
|
+
next_index += 6
|
|
526
|
+
decoded.append(chr(codepoint))
|
|
527
|
+
index = next_index
|
|
528
|
+
return "".join(decoded)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
_FENCE_OPEN_RE = re.compile(r"```[ \t]*(?:json)?[ \t]*\r?\n", re.IGNORECASE)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def extract_final_prefix(raw: str) -> str | None:
|
|
535
|
+
text = raw.lstrip()
|
|
536
|
+
fence = _FENCE_OPEN_RE.match(text)
|
|
537
|
+
if fence:
|
|
538
|
+
text = text[fence.end() :].lstrip()
|
|
539
|
+
if not text.startswith("{"):
|
|
540
|
+
return None
|
|
541
|
+
index = 1
|
|
542
|
+
tool_calls_empty = False
|
|
543
|
+
while index < len(text):
|
|
544
|
+
while index < len(text) and text[index].isspace():
|
|
545
|
+
index += 1
|
|
546
|
+
if index >= len(text) or text[index] != '"':
|
|
547
|
+
return None
|
|
548
|
+
key_result = _complete_json_string(text, index)
|
|
549
|
+
if key_result is None:
|
|
550
|
+
return None
|
|
551
|
+
key, index = key_result
|
|
552
|
+
while index < len(text) and text[index].isspace():
|
|
553
|
+
index += 1
|
|
554
|
+
if index >= len(text) or text[index] != ":":
|
|
555
|
+
return None
|
|
556
|
+
index += 1
|
|
557
|
+
while index < len(text) and text[index].isspace():
|
|
558
|
+
index += 1
|
|
559
|
+
if index >= len(text):
|
|
560
|
+
return None
|
|
561
|
+
if key == "final":
|
|
562
|
+
if not tool_calls_empty or text[index] != '"':
|
|
563
|
+
return None
|
|
564
|
+
return _decode_final_prefix(text, index)
|
|
565
|
+
|
|
566
|
+
value_end = _json_value_end(text, index)
|
|
567
|
+
if value_end is None:
|
|
568
|
+
return None
|
|
569
|
+
if key == "tool_calls":
|
|
570
|
+
tool_calls_empty = re.fullmatch(r"\[\s*\]", text[index:value_end]) is not None
|
|
571
|
+
index = value_end
|
|
572
|
+
if index >= len(text) or text[index] != ",":
|
|
573
|
+
return None
|
|
574
|
+
index += 1
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _score(data: Any) -> int:
|
|
579
|
+
if isinstance(data, dict):
|
|
580
|
+
score = 0
|
|
581
|
+
keys = set(data)
|
|
582
|
+
if "tool_calls" in keys or "toolCalls" in keys:
|
|
583
|
+
score += 120
|
|
584
|
+
if "final" in keys or "final_answer" in keys:
|
|
585
|
+
score += 110
|
|
586
|
+
if "answer" in keys or "response" in keys:
|
|
587
|
+
score += 60
|
|
588
|
+
if "name" in keys and ("arguments" in keys or "args" in keys):
|
|
589
|
+
score += 80
|
|
590
|
+
if "tool" in keys:
|
|
591
|
+
score += 50
|
|
592
|
+
if "action" in keys:
|
|
593
|
+
score += 20
|
|
594
|
+
if "thought" in keys or "reasoning" in keys:
|
|
595
|
+
score += 10
|
|
596
|
+
return score
|
|
597
|
+
if isinstance(data, list) and data and all(isinstance(x, dict) for x in data):
|
|
598
|
+
return 40
|
|
599
|
+
return 0
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def parse_step(raw: str) -> Step:
|
|
603
|
+
if not raw or not raw.strip():
|
|
604
|
+
raise ProtocolError("The model returned an empty response.", raw)
|
|
605
|
+
|
|
606
|
+
best: tuple[int, int, Step] | None = None
|
|
607
|
+
errors: list[str] = []
|
|
608
|
+
for candidate in _json_candidates(raw):
|
|
609
|
+
try:
|
|
610
|
+
data = _try_loads(candidate)
|
|
611
|
+
except Exception as exc:
|
|
612
|
+
errors.append(f"{type(exc).__name__}: {exc}")
|
|
613
|
+
continue
|
|
614
|
+
step = _normalize(data)
|
|
615
|
+
if step is None:
|
|
616
|
+
continue
|
|
617
|
+
score = _score(data)
|
|
618
|
+
rank = (score, len(candidate), step)
|
|
619
|
+
if best is None or rank[:2] > best[:2]:
|
|
620
|
+
best = rank
|
|
621
|
+
if best is None:
|
|
622
|
+
detail = errors[-1] if errors else "no complete JSON object was found"
|
|
623
|
+
raise ProtocolError(
|
|
624
|
+
"The model's response did not contain a valid dscli JSON envelope "
|
|
625
|
+
f"({detail}).",
|
|
626
|
+
raw,
|
|
627
|
+
)
|
|
628
|
+
step = best[2]
|
|
629
|
+
if not step.tool_calls and step.final is None:
|
|
630
|
+
raise ProtocolError(
|
|
631
|
+
"The JSON envelope has neither tool_calls nor a final answer.", raw
|
|
632
|
+
)
|
|
633
|
+
return step
|