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 ADDED
@@ -0,0 +1,3 @@
1
+ """dscli - a local coding agent for your terminal."""
2
+
3
+ __version__ = "0.1.0"
dscli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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)