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/__init__.py
ADDED
dscli/__main__.py
ADDED
dscli/agent.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Callable
|
|
9
|
+
|
|
10
|
+
from .attachments import Attachment
|
|
11
|
+
from .browser import ChatSession
|
|
12
|
+
from .config import Config
|
|
13
|
+
from .display import AgentDisplay, PlainDisplay
|
|
14
|
+
from .prompts import (
|
|
15
|
+
build_followup_prompt,
|
|
16
|
+
build_initial_prompt,
|
|
17
|
+
build_protocol_repair,
|
|
18
|
+
build_tool_results_prompt,
|
|
19
|
+
build_wrapup_prompt,
|
|
20
|
+
)
|
|
21
|
+
from .protocol import ProtocolError, Step, extract_final_prefix, parse_step
|
|
22
|
+
from .tools import ApprovalRequired, ToolResult, Toolbox, workspace_preview
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger("dscli")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AgentOutcome:
|
|
29
|
+
final: str
|
|
30
|
+
changed: list[str] = field(default_factory=list)
|
|
31
|
+
turns: int = 0
|
|
32
|
+
limit_reached: bool = False
|
|
33
|
+
denied: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Agent:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
*,
|
|
40
|
+
session: ChatSession,
|
|
41
|
+
config: Config,
|
|
42
|
+
toolbox: Toolbox,
|
|
43
|
+
task: str,
|
|
44
|
+
workspace: Path,
|
|
45
|
+
attachments: list[Attachment],
|
|
46
|
+
verbose: bool = False,
|
|
47
|
+
quiet: bool = False,
|
|
48
|
+
followup: bool = False,
|
|
49
|
+
on_final_snapshot: Callable[[str], None] | None = None,
|
|
50
|
+
display: AgentDisplay | None = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.session = session
|
|
53
|
+
self.cfg = config
|
|
54
|
+
self.toolbox = toolbox
|
|
55
|
+
self.task = task
|
|
56
|
+
self.workspace = workspace
|
|
57
|
+
self.attachments = attachments
|
|
58
|
+
self.verbose = verbose
|
|
59
|
+
self.followup = followup
|
|
60
|
+
self.on_final_snapshot = on_final_snapshot
|
|
61
|
+
self.display = display or PlainDisplay(quiet=quiet)
|
|
62
|
+
self._signature_counts: dict[str, int] = {}
|
|
63
|
+
self._denied = False
|
|
64
|
+
self._streamed_final = False
|
|
65
|
+
|
|
66
|
+
def _send_and_wait(self, message: str, files: list[Path] | None = None) -> str:
|
|
67
|
+
if self.on_final_snapshot is None:
|
|
68
|
+
return self.session.send_and_wait(message, files=files)
|
|
69
|
+
last_final: str | None = None
|
|
70
|
+
|
|
71
|
+
def on_reply_snapshot(raw: str) -> None:
|
|
72
|
+
nonlocal last_final
|
|
73
|
+
final = extract_final_prefix(raw)
|
|
74
|
+
if final is not None and final != last_final:
|
|
75
|
+
last_final = final
|
|
76
|
+
if not self._streamed_final:
|
|
77
|
+
self._streamed_final = True
|
|
78
|
+
self.display.stream_started()
|
|
79
|
+
self.on_final_snapshot(final)
|
|
80
|
+
|
|
81
|
+
return self.session.send_and_wait(
|
|
82
|
+
message, files=files, on_reply_snapshot=on_reply_snapshot
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def run(self) -> AgentOutcome:
|
|
86
|
+
if self.followup:
|
|
87
|
+
initial = build_followup_prompt(self.task)
|
|
88
|
+
else:
|
|
89
|
+
initial = build_initial_prompt(
|
|
90
|
+
task=self.task,
|
|
91
|
+
workspace=self.workspace,
|
|
92
|
+
attachments=self.attachments,
|
|
93
|
+
workspace_tree=workspace_preview(self.workspace),
|
|
94
|
+
allow_shell=self.cfg.allow_shell,
|
|
95
|
+
allow_outside=self.cfg.allow_outside,
|
|
96
|
+
max_steps=self.cfg.max_steps,
|
|
97
|
+
)
|
|
98
|
+
if len(initial) > 80000:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
"The prompt is too large. Attach long material as a file instead of "
|
|
101
|
+
"pasting it into the task."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
attachment_paths = [a.path for a in self.attachments]
|
|
105
|
+
message = initial
|
|
106
|
+
files = attachment_paths if attachment_paths else None
|
|
107
|
+
turn = 0
|
|
108
|
+
protocol_failures = 0
|
|
109
|
+
last_reply = ""
|
|
110
|
+
|
|
111
|
+
while True:
|
|
112
|
+
turn += 1
|
|
113
|
+
self.display.turn_started(
|
|
114
|
+
turn, self.cfg.max_steps, len(files) if files else 0
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
last_reply = self._send_and_wait(message, files=files)
|
|
118
|
+
finally:
|
|
119
|
+
files = None
|
|
120
|
+
|
|
121
|
+
if self.verbose and last_reply:
|
|
122
|
+
self.display.ensure_line_break()
|
|
123
|
+
log.debug("Raw reply (%d chars):\n%s", len(last_reply), last_reply)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
step = parse_step(last_reply)
|
|
127
|
+
except ProtocolError as exc:
|
|
128
|
+
protocol_failures += 1
|
|
129
|
+
self.session.save_last_response(last_reply, "protocol-error")
|
|
130
|
+
if protocol_failures > self.cfg.max_protocol_retries:
|
|
131
|
+
raise ProtocolError(
|
|
132
|
+
f"{exc} The raw reply was saved to the dscli debug directory.",
|
|
133
|
+
last_reply,
|
|
134
|
+
) from exc
|
|
135
|
+
self.display.protocol_error(str(exc))
|
|
136
|
+
if turn >= self.cfg.max_steps:
|
|
137
|
+
return self._wrap_up(turn, last_reply or str(exc))
|
|
138
|
+
message = build_protocol_repair(
|
|
139
|
+
str(exc), task=self.task, step=turn, max_steps=self.cfg.max_steps
|
|
140
|
+
)
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
if step.final is not None and not step.tool_calls:
|
|
144
|
+
return self._finish(step, turn)
|
|
145
|
+
|
|
146
|
+
if step.thought and self.verbose:
|
|
147
|
+
log.debug("thought: %s", step.thought)
|
|
148
|
+
if self.verbose:
|
|
149
|
+
self.display.thought(step.thought)
|
|
150
|
+
|
|
151
|
+
if not step.tool_calls:
|
|
152
|
+
# Should have been caught by the parser; treat it as a protocol miss.
|
|
153
|
+
protocol_failures += 1
|
|
154
|
+
if protocol_failures > self.cfg.max_protocol_retries:
|
|
155
|
+
raise ProtocolError(
|
|
156
|
+
"The model returned an envelope with no tool calls and no final answer.",
|
|
157
|
+
last_reply,
|
|
158
|
+
)
|
|
159
|
+
if turn >= self.cfg.max_steps:
|
|
160
|
+
return self._wrap_up(turn, last_reply)
|
|
161
|
+
message = build_protocol_repair(
|
|
162
|
+
"The envelope had neither tool_calls nor final.",
|
|
163
|
+
task=self.task,
|
|
164
|
+
step=turn,
|
|
165
|
+
max_steps=self.cfg.max_steps,
|
|
166
|
+
)
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
if turn >= self.cfg.max_steps:
|
|
170
|
+
self.display.budget_exhausted()
|
|
171
|
+
return self._wrap_up(turn, last_reply)
|
|
172
|
+
|
|
173
|
+
calls = step.tool_calls[: self.cfg.max_tool_calls]
|
|
174
|
+
results: list[ToolResult] = []
|
|
175
|
+
for call in calls:
|
|
176
|
+
self.display.tool_started(call)
|
|
177
|
+
started = time.monotonic()
|
|
178
|
+
result = self.toolbox.execute(call)
|
|
179
|
+
elapsed = time.monotonic() - started
|
|
180
|
+
results.append(result)
|
|
181
|
+
self.display.tool_finished(result, elapsed)
|
|
182
|
+
if "denied" in (result.output or "").lower():
|
|
183
|
+
self._denied = True
|
|
184
|
+
|
|
185
|
+
if len(step.tool_calls) > len(calls):
|
|
186
|
+
skipped = len(step.tool_calls) - len(calls)
|
|
187
|
+
results.append(
|
|
188
|
+
ToolResult(
|
|
189
|
+
name="harness",
|
|
190
|
+
arguments={},
|
|
191
|
+
ok=False,
|
|
192
|
+
output=(
|
|
193
|
+
f"{skipped} additional tool calls were not executed because the "
|
|
194
|
+
f"per-turn limit is {self.cfg.max_tool_calls}."
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
notice_parts: list[str] = []
|
|
200
|
+
for result in results:
|
|
201
|
+
signature = result.name + ":" + json.dumps(result.arguments, sort_keys=True, ensure_ascii=False)
|
|
202
|
+
if not result.ok:
|
|
203
|
+
self._signature_counts[signature] = self._signature_counts.get(signature, 0) + 1
|
|
204
|
+
if self._signature_counts[signature] >= 2:
|
|
205
|
+
notice_parts.append(
|
|
206
|
+
"A tool call has now failed twice with the same arguments. Do not "
|
|
207
|
+
"repeat it; change strategy or finish and explain the blocker."
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
self._signature_counts[signature] = self._signature_counts.get(signature, 0) + 1
|
|
211
|
+
if self._signature_counts[signature] >= 3:
|
|
212
|
+
notice_parts.append(
|
|
213
|
+
"You have repeated the same successful call three times. Use the "
|
|
214
|
+
"result you already have and move on."
|
|
215
|
+
)
|
|
216
|
+
if self._denied:
|
|
217
|
+
notice_parts.append(
|
|
218
|
+
"The user denied at least one action. Do not try to bypass the denial."
|
|
219
|
+
)
|
|
220
|
+
notice = "\n".join(dict.fromkeys(notice_parts)) or None
|
|
221
|
+
|
|
222
|
+
message = build_tool_results_prompt(
|
|
223
|
+
task=self.task,
|
|
224
|
+
workspace=self.workspace,
|
|
225
|
+
results=results,
|
|
226
|
+
step=turn,
|
|
227
|
+
max_steps=self.cfg.max_steps,
|
|
228
|
+
changed=self.toolbox.changed_files,
|
|
229
|
+
allow_shell=self.cfg.allow_shell,
|
|
230
|
+
per_result_chars=self.cfg.max_tool_output_chars,
|
|
231
|
+
total_chars=self.cfg.max_turn_output_chars,
|
|
232
|
+
notice=notice,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
def _finish(self, step: Step, turn: int) -> AgentOutcome:
|
|
236
|
+
final = (step.final or "").strip()
|
|
237
|
+
if not final:
|
|
238
|
+
final = "Done."
|
|
239
|
+
self.display.finished(
|
|
240
|
+
self.toolbox.changed_files,
|
|
241
|
+
turn,
|
|
242
|
+
show_status=not self._streamed_final,
|
|
243
|
+
)
|
|
244
|
+
return AgentOutcome(
|
|
245
|
+
final=final,
|
|
246
|
+
changed=self.toolbox.changed_files,
|
|
247
|
+
turns=turn,
|
|
248
|
+
denied=self._denied,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
def _wrap_up(self, turn: int, last_reply: str) -> AgentOutcome:
|
|
252
|
+
prompt = build_wrapup_prompt(
|
|
253
|
+
task=self.task,
|
|
254
|
+
workspace=self.workspace,
|
|
255
|
+
changed=self.toolbox.changed_files,
|
|
256
|
+
step=turn,
|
|
257
|
+
)
|
|
258
|
+
self.display.wrapup_started()
|
|
259
|
+
reply = self._send_and_wait(prompt)
|
|
260
|
+
try:
|
|
261
|
+
step = parse_step(reply)
|
|
262
|
+
except ProtocolError:
|
|
263
|
+
self.session.save_last_response(reply, "wrapup-unparsed")
|
|
264
|
+
self._progress(ui.warn, "could not parse the wrap-up reply; showing raw text")
|
|
265
|
+
return AgentOutcome(
|
|
266
|
+
final=reply.strip() or "Stopped: the model-turn budget was exhausted.",
|
|
267
|
+
changed=self.toolbox.changed_files,
|
|
268
|
+
turns=turn,
|
|
269
|
+
limit_reached=True,
|
|
270
|
+
denied=self._denied,
|
|
271
|
+
)
|
|
272
|
+
if step.final is not None:
|
|
273
|
+
final = (step.final or "").strip() or "Stopped: the model-turn budget was exhausted."
|
|
274
|
+
else:
|
|
275
|
+
self.session.save_last_response(reply, "wrapup-with-tools")
|
|
276
|
+
final = reply.strip() or "Stopped: the model-turn budget was exhausted."
|
|
277
|
+
return AgentOutcome(
|
|
278
|
+
final=final,
|
|
279
|
+
changed=self.toolbox.changed_files,
|
|
280
|
+
turns=turn,
|
|
281
|
+
limit_reached=True,
|
|
282
|
+
denied=self._denied,
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def run_agent(
|
|
287
|
+
*,
|
|
288
|
+
session: ChatSession,
|
|
289
|
+
config: Config,
|
|
290
|
+
toolbox: Toolbox,
|
|
291
|
+
task: str,
|
|
292
|
+
workspace: Path,
|
|
293
|
+
attachments: list[Attachment],
|
|
294
|
+
verbose: bool = False,
|
|
295
|
+
quiet: bool = False,
|
|
296
|
+
followup: bool = False,
|
|
297
|
+
on_final_snapshot: Callable[[str], None] | None = None,
|
|
298
|
+
display: AgentDisplay | None = None,
|
|
299
|
+
) -> AgentOutcome:
|
|
300
|
+
return Agent(
|
|
301
|
+
session=session,
|
|
302
|
+
config=config,
|
|
303
|
+
toolbox=toolbox,
|
|
304
|
+
task=task,
|
|
305
|
+
workspace=workspace,
|
|
306
|
+
attachments=attachments,
|
|
307
|
+
verbose=verbose,
|
|
308
|
+
quiet=quiet,
|
|
309
|
+
followup=followup,
|
|
310
|
+
on_final_snapshot=on_final_snapshot,
|
|
311
|
+
display=display,
|
|
312
|
+
).run()
|
dscli/attachments.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AttachmentError(ValueError):
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Attachment:
|
|
15
|
+
path: Path
|
|
16
|
+
size: int
|
|
17
|
+
inside_workspace: bool
|
|
18
|
+
kind: str
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str:
|
|
22
|
+
return self.path.name
|
|
23
|
+
|
|
24
|
+
def display_path(self, root: Path) -> str:
|
|
25
|
+
try:
|
|
26
|
+
return self.path.relative_to(root).as_posix()
|
|
27
|
+
except ValueError:
|
|
28
|
+
return str(self.path)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def size_human(self) -> str:
|
|
32
|
+
size = float(self.size)
|
|
33
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
34
|
+
if size < 1024 or unit == "GB":
|
|
35
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
36
|
+
size /= 1024
|
|
37
|
+
return f"{size:.1f} GB"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _kind(path: Path) -> str:
|
|
41
|
+
suffix = path.suffix.lower()
|
|
42
|
+
if suffix in {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg", ".ico", ".tif", ".tiff"}:
|
|
43
|
+
return "image"
|
|
44
|
+
try:
|
|
45
|
+
with path.open("rb") as fh:
|
|
46
|
+
head = fh.read(8192)
|
|
47
|
+
except OSError:
|
|
48
|
+
return "unknown"
|
|
49
|
+
if b"\x00" in head:
|
|
50
|
+
return "binary"
|
|
51
|
+
return "text"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def collect_attachments(
|
|
55
|
+
raw_paths: list[str],
|
|
56
|
+
workspace: Path,
|
|
57
|
+
*,
|
|
58
|
+
max_count: int = 20,
|
|
59
|
+
max_mb: int = 50,
|
|
60
|
+
) -> list[Attachment]:
|
|
61
|
+
if not raw_paths:
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
expanded: list[Path] = []
|
|
65
|
+
for raw in raw_paths:
|
|
66
|
+
text = os.path.expanduser(str(raw).strip())
|
|
67
|
+
if not text:
|
|
68
|
+
continue
|
|
69
|
+
if any(ch in text for ch in "*?["):
|
|
70
|
+
matches = [Path(p) for p in glob.glob(text, recursive=True)]
|
|
71
|
+
if not matches:
|
|
72
|
+
raise AttachmentError(f"No files matched: {raw}")
|
|
73
|
+
expanded.extend(matches)
|
|
74
|
+
else:
|
|
75
|
+
expanded.append(Path(text))
|
|
76
|
+
|
|
77
|
+
seen: set[str] = set()
|
|
78
|
+
result: list[Attachment] = []
|
|
79
|
+
for path in expanded:
|
|
80
|
+
try:
|
|
81
|
+
resolved = path.resolve(strict=True)
|
|
82
|
+
except FileNotFoundError:
|
|
83
|
+
raise AttachmentError(f"File not found: {path}") from None
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
raise AttachmentError(f"Cannot read {path}: {exc}") from None
|
|
86
|
+
if resolved.is_dir():
|
|
87
|
+
raise AttachmentError(
|
|
88
|
+
f"{path} is a directory. Pass individual files (globs are supported)."
|
|
89
|
+
)
|
|
90
|
+
if not resolved.is_file():
|
|
91
|
+
raise AttachmentError(f"Not a regular file: {path}")
|
|
92
|
+
key = str(resolved).casefold() if os.name == "nt" else str(resolved)
|
|
93
|
+
if key in seen:
|
|
94
|
+
continue
|
|
95
|
+
seen.add(key)
|
|
96
|
+
size = resolved.stat().st_size
|
|
97
|
+
if size > max_mb * 1024 * 1024:
|
|
98
|
+
raise AttachmentError(
|
|
99
|
+
f"{path} is {size / (1024 * 1024):.1f} MB; the limit is {max_mb} MB."
|
|
100
|
+
)
|
|
101
|
+
try:
|
|
102
|
+
inside = resolved.is_relative_to(workspace.resolve())
|
|
103
|
+
except ValueError:
|
|
104
|
+
inside = False
|
|
105
|
+
result.append(Attachment(path=resolved, size=size, inside_workspace=inside, kind=_kind(resolved)))
|
|
106
|
+
if len(result) > max_count:
|
|
107
|
+
raise AttachmentError(
|
|
108
|
+
f"Too many attachments ({len(result)}). The limit is {max_count}."
|
|
109
|
+
)
|
|
110
|
+
return result
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def format_manifest(attachments: list[Attachment], root: Path) -> str:
|
|
114
|
+
if not attachments:
|
|
115
|
+
return "(no files were attached to this chat)"
|
|
116
|
+
lines = []
|
|
117
|
+
for att in attachments:
|
|
118
|
+
note = "" if att.inside_workspace else " [outside workspace: attached read-only; local tools cannot edit it]"
|
|
119
|
+
if att.kind != "text":
|
|
120
|
+
note += f" [{att.kind}: use the attachment itself]"
|
|
121
|
+
lines.append(f"- {att.display_path(root)} ({att.size_human}){note}")
|
|
122
|
+
return "\n".join(lines)
|