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/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