deepparallel 0.4.1__tar.gz → 0.4.2__tar.gz

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.
Files changed (59) hide show
  1. {deepparallel-0.4.1 → deepparallel-0.4.2}/PKG-INFO +7 -2
  2. {deepparallel-0.4.1 → deepparallel-0.4.2}/README.md +6 -1
  3. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/__init__.py +1 -1
  4. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/agent.py +2 -0
  5. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/cli.py +21 -4
  6. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/renderer.py +52 -61
  7. deepparallel-0.4.2/deepparallel/system_prompt.txt +4 -0
  8. deepparallel-0.4.2/deepparallel/tools/shell.py +69 -0
  9. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel.egg-info/PKG-INFO +7 -2
  10. {deepparallel-0.4.1 → deepparallel-0.4.2}/pyproject.toml +1 -1
  11. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_renderer.py +18 -4
  12. deepparallel-0.4.2/tests/test_tools_shell.py +48 -0
  13. deepparallel-0.4.1/deepparallel/system_prompt.txt +0 -4
  14. deepparallel-0.4.1/deepparallel/tools/shell.py +0 -38
  15. deepparallel-0.4.1/tests/test_tools_shell.py +0 -24
  16. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/backend.py +0 -0
  17. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/branding.py +0 -0
  18. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/config.py +0 -0
  19. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/fusion.py +0 -0
  20. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/licensing.py +0 -0
  21. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/registry.json +0 -0
  22. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/research/__init__.py +0 -0
  23. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/research/conduit.py +0 -0
  24. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/supply_chain.py +0 -0
  25. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/tools/__init__.py +0 -0
  26. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/tools/codeast.py +0 -0
  27. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/tools/edit.py +0 -0
  28. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/tools/files.py +0 -0
  29. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/tools/registry.py +0 -0
  30. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/tools/sandbox.py +0 -0
  31. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/tools/search.py +0 -0
  32. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/tools/vision.py +0 -0
  33. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel/tools/web.py +0 -0
  34. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel.egg-info/SOURCES.txt +0 -0
  35. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel.egg-info/dependency_links.txt +0 -0
  36. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel.egg-info/entry_points.txt +0 -0
  37. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel.egg-info/requires.txt +0 -0
  38. {deepparallel-0.4.1 → deepparallel-0.4.2}/deepparallel.egg-info/top_level.txt +0 -0
  39. {deepparallel-0.4.1 → deepparallel-0.4.2}/setup.cfg +0 -0
  40. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_agent.py +0 -0
  41. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_backend.py +0 -0
  42. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_backend_chat.py +0 -0
  43. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_backend_stream.py +0 -0
  44. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_branding.py +0 -0
  45. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_cli.py +0 -0
  46. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_config.py +0 -0
  47. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_fusion.py +0 -0
  48. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_issuer_signer.py +0 -0
  49. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_licensing.py +0 -0
  50. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_research.py +0 -0
  51. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_supply_chain.py +0 -0
  52. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_tool_registry.py +0 -0
  53. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_tools_codeast.py +0 -0
  54. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_tools_edit.py +0 -0
  55. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_tools_files.py +0 -0
  56. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_tools_sandbox.py +0 -0
  57. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_tools_search.py +0 -0
  58. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_tools_vision.py +0 -0
  59. {deepparallel-0.4.1 → deepparallel-0.4.2}/tests/test_tools_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepparallel
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic.
5
5
  Author-email: Michael Crowe <michael@crowelogic.com>
6
6
  License: Apache-2.0
@@ -115,7 +115,12 @@ compose hosted backends (no GPU/weight-merging), so they are API-call stacking.
115
115
  - Read-only (run automatically): `read_file`, `list_dir`, `glob`, `grep`,
116
116
  `ast_symbols`, `ast_show_symbol`, `web_fetch`, `web_search`, `analyze_image`.
117
117
  - Mutating / executing (require confirmation): `write_file`, `edit_file`,
118
- `ast_replace_symbol`, `run_shell`, `run_code`.
118
+ `ast_replace_symbol`, `run_shell`, `run_code`, `open_path` (open a file, folder,
119
+ or URL in the OS default app - e.g. an HTML report in the browser).
120
+
121
+ In interactive chat, type `/auto` to toggle auto-approve (tools run without
122
+ asking; edits outside the project and unknown dependencies still confirm), or
123
+ start with `deepparallel --yes`.
119
124
 
120
125
  `web_search` needs `DEEPPARALLEL_SEARCH_API_KEY`; `analyze_image` works out of the
121
126
  box on a multimodal deployment (override with `DEEPPARALLEL_VISION_DEPLOYMENT`).
@@ -91,7 +91,12 @@ compose hosted backends (no GPU/weight-merging), so they are API-call stacking.
91
91
  - Read-only (run automatically): `read_file`, `list_dir`, `glob`, `grep`,
92
92
  `ast_symbols`, `ast_show_symbol`, `web_fetch`, `web_search`, `analyze_image`.
93
93
  - Mutating / executing (require confirmation): `write_file`, `edit_file`,
94
- `ast_replace_symbol`, `run_shell`, `run_code`.
94
+ `ast_replace_symbol`, `run_shell`, `run_code`, `open_path` (open a file, folder,
95
+ or URL in the OS default app - e.g. an HTML report in the browser).
96
+
97
+ In interactive chat, type `/auto` to toggle auto-approve (tools run without
98
+ asking; edits outside the project and unknown dependencies still confirm), or
99
+ start with `deepparallel --yes`.
95
100
 
96
101
  `web_search` needs `DEEPPARALLEL_SEARCH_API_KEY`; `analyze_image` works out of the
97
102
  box on a multimodal deployment (override with `DEEPPARALLEL_VISION_DEPLOYMENT`).
@@ -1,3 +1,3 @@
1
1
  """DeepParallel CLI package."""
2
2
 
3
- __version__ = "0.4.1"
3
+ __version__ = "0.4.2"
@@ -71,6 +71,8 @@ def _summarize_result(name: str, result: str) -> str:
71
71
  return "edited"
72
72
  if name == "ast_replace_symbol":
73
73
  return "replaced"
74
+ if name == "open_path":
75
+ return f"opened {obj.get('opened', '')}" if "opened" in obj else "ok"
74
76
  if name == "run_shell":
75
77
  lines = (obj.get("stdout") or "").count("\n")
76
78
  return f"rc {obj.get('return_code')} · {_plural(lines, 'line', 'lines')}"
@@ -289,8 +289,10 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
289
289
  messages: list[dict] = [{"role": "system", "content": system}]
290
290
  mode = settings.fusion_mode if settings.fusion_mode in ("reason", "escalate") else "off"
291
291
  deep_next = False
292
+ auto = settings.auto_approve
292
293
  while True:
293
- tag = f"[{mode}] " if mode != "off" else ""
294
+ bits = ([mode] if mode != "off" else []) + (["auto"] if auto else [])
295
+ tag = f"[{' · '.join(bits)}] " if bits else ""
294
296
  try:
295
297
  user_msg = console.input(f"{tag}{branding.user_prefix()}› ").strip()
296
298
  except (EOFError, KeyboardInterrupt):
@@ -302,9 +304,19 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
302
304
  break
303
305
  if user_msg == "/help":
304
306
  branding.info(
305
- "/quit · /reset · /info · /tools · /fast //fuse //escalate //deep · prompt"
307
+ "/quit · /reset · /info · /tools · /auto · /fast //fuse //escalate //deep · prompt"
306
308
  )
307
309
  continue
310
+ if user_msg in {"/auto", "/yes"}:
311
+ auto = not auto
312
+ if auto:
313
+ branding.info(
314
+ "auto-approve ON - tools run without asking. Edits outside this "
315
+ "project and unknown dependencies still confirm. /auto to turn off."
316
+ )
317
+ else:
318
+ branding.info("auto-approve OFF - mutating tools will ask first.")
319
+ continue
308
320
  if user_msg == "/reset":
309
321
  messages = [{"role": "system", "content": system}]
310
322
  branding.info("conversation cleared.")
@@ -334,7 +346,7 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
334
346
  settings,
335
347
  renderer,
336
348
  interactive=True,
337
- auto_approve=settings.auto_approve,
349
+ auto_approve=auto,
338
350
  stream=True,
339
351
  guardian=guardian,
340
352
  )
@@ -376,14 +388,19 @@ def _chat_loop(settings: Settings) -> None:
376
388
  )
377
389
  @click.version_option(__version__, prog_name="deepparallel")
378
390
  @click.option("--temperature", "-t", default=None, type=float, help="Sampling temperature.")
391
+ @click.option(
392
+ "--yes", "-y", "assume_yes", is_flag=True, help="Auto-approve tool actions (toggle with /auto)."
393
+ )
379
394
  @click.pass_context
380
- def main(ctx: click.Context, temperature: float | None) -> None:
395
+ def main(ctx: click.Context, temperature: float | None, assume_yes: bool) -> None:
381
396
  """DeepParallel - a focused agentic CLI for the DeepParallel model."""
382
397
  load_dotenv()
383
398
  ctx.ensure_object(dict)
384
399
  settings = resolve_settings()
385
400
  if temperature is not None:
386
401
  settings = replace(settings, temperature=temperature)
402
+ if assume_yes:
403
+ settings = replace(settings, auto_approve=True)
387
404
  ctx.obj["settings"] = settings
388
405
  if ctx.invoked_subcommand is None:
389
406
  _chat_loop(settings)
@@ -13,7 +13,6 @@ incrementally (no redrawing Live regions) so output never ghosts.
13
13
  from __future__ import annotations
14
14
 
15
15
  import sys
16
- import threading
17
16
  import time
18
17
  from abc import ABC, abstractmethod
19
18
  from typing import Iterable
@@ -132,8 +131,6 @@ class RichRenderer(Renderer):
132
131
  self._console = console or branding.console
133
132
  self._input_fn = input_fn or self._console.input
134
133
  self._cur: str | None = None
135
- self._timer_stop: threading.Event | None = None
136
- self._timer_thread: threading.Thread | None = None
137
134
 
138
135
  def welcome(self, backend_label, *, version="", tool_count=0, fusion_modes=()) -> None:
139
136
  animate = self._console.is_terminal and _REVEAL_SECONDS > 0
@@ -155,12 +152,14 @@ class RichRenderer(Renderer):
155
152
  self._console.print(branding.build_transcript_markdown(self._console, text))
156
153
 
157
154
  def answer_stream(self, chunks: Iterable[str]) -> str:
158
- """Stream the answer. On a real terminal, render Markdown live in a panel
159
- that grows as tokens arrive (headings, code, lists format in place); the
160
- final frame is the same panel `answer()` would print, so there is no
161
- double render. On a pipe / non-tty, fall back to raw inline streaming."""
155
+ """Stream the answer. On a real terminal, render Markdown block by block,
156
+ append-only: each complete block (a paragraph/heading/list ended by a
157
+ blank line, or a closed code fence) is rendered once as it settles. This
158
+ never moves the cursor up, so it cannot ghost or stack the way a growing
159
+ Live region does - it works the same on any terminal and at any answer
160
+ length. On a pipe / non-tty, fall back to raw inline streaming."""
162
161
  if self._console.is_terminal:
163
- return self._stream_live_markdown(chunks)
162
+ return self._stream_markdown_blocks(chunks)
164
163
  return self._stream_inline(chunks)
165
164
 
166
165
  def _stream_inline(self, chunks: Iterable[str]) -> str:
@@ -185,71 +184,63 @@ class RichRenderer(Renderer):
185
184
  self._console.print()
186
185
  return "".join(parts)
187
186
 
188
- def _stream_live_markdown(self, chunks: Iterable[str]) -> str:
189
- from rich.live import Live
187
+ def _stream_markdown_blocks(self, chunks: Iterable[str]) -> str:
188
+ from rich.markdown import Markdown
189
+ from rich.padding import Padding
190
190
 
191
- parts: list[str] = []
191
+ full: list[str] = []
192
+ buf = "" # text not yet split into complete lines
193
+ block: list[str] = [] # lines of the block currently accumulating
194
+ in_fence = False
192
195
  started = False
193
- last_draw = 0.0
194
- live = Live(
195
- console=self._console,
196
- auto_refresh=False, # we drive refreshes; deterministic, no bg thread
197
- vertical_overflow="visible", # let answers taller than the screen scroll
198
- )
199
- try:
200
- for c in chunks:
201
- parts.append(c)
202
- if not started:
203
- if not "".join(parts).strip():
204
- continue # hold the panel back until real content arrives
205
- started = True
206
- live.start()
207
- now = time.monotonic()
208
- if now - last_draw >= 0.06: # throttle to ~16 fps
209
- live.update(self._answer_panel("".join(parts)), refresh=True)
210
- last_draw = now
211
- if started: # final frame: the complete, settled answer
212
- live.update(self._answer_panel("".join(parts)), refresh=True)
213
- finally:
214
- if started:
215
- live.stop()
216
- return "".join(parts)
217
196
 
218
- def _answer_panel(self, text: str):
219
- return branding.build_transcript_markdown(self._console, _balance_fences(text))
197
+ def emit() -> None:
198
+ nonlocal block, started
199
+ text = "\n".join(block).strip("\n")
200
+ block = []
201
+ if not text.strip():
202
+ return
203
+ if not started:
204
+ started = True
205
+ self._console.print(f"[{branding.DP_ACCENT}]{branding.MARK}[/]", highlight=False)
206
+ self._console.print(
207
+ Padding(Markdown(_balance_fences(text)), (0, 0, 0, branding.GUTTER))
208
+ )
209
+
210
+ for c in chunks:
211
+ full.append(c)
212
+ buf += c
213
+ while "\n" in buf: # only act on complete lines; keep the partial
214
+ line, buf = buf.split("\n", 1)
215
+ if line.strip().startswith("```"):
216
+ block.append(line)
217
+ if in_fence: # this line closes the fence -> block is complete
218
+ in_fence = False
219
+ emit()
220
+ else:
221
+ in_fence = True
222
+ elif in_fence:
223
+ block.append(line)
224
+ elif line.strip() == "": # blank line ends a prose block
225
+ emit()
226
+ else:
227
+ block.append(line)
228
+ if buf.strip(): # trailing partial line at end of stream
229
+ block.append(buf)
230
+ emit() # flush whatever block remains (fence balanced if still open)
231
+ return "".join(full)
220
232
 
221
233
  def reasoning(self, text: str) -> None:
222
234
  self._console.print(branding.build_reasoning_panel(self._console, text))
223
235
 
224
236
  def tool_start(self, name: str, args_preview: str) -> None:
237
+ # Append-only: render the running card and stop. A per-tick elapsed timer
238
+ # needs carriage-return overwrites, which some terminals don't honor and
239
+ # then flood with hundreds of lines; the final card carries the duration.
225
240
  self._cur = name
226
241
  branding.render_tool_card(self._console, name, args_preview, status="running")
227
- if self._console.is_terminal:
228
- self._timer_stop = threading.Event()
229
- start = time.monotonic()
230
- fh = self._console.file
231
-
232
- def tick() -> None:
233
- while not self._timer_stop.wait(0.5): # type: ignore[union-attr]
234
- fh.write(f"\r {name}... {time.monotonic() - start:.0f}s ")
235
- fh.flush()
236
-
237
- self._timer_thread = threading.Thread(target=tick, daemon=True)
238
- self._timer_thread.start()
239
-
240
- def _stop_timer(self) -> None:
241
- if self._timer_stop is None:
242
- return
243
- self._timer_stop.set()
244
- if self._timer_thread is not None:
245
- self._timer_thread.join(timeout=1.0)
246
- self._timer_stop = None
247
- self._timer_thread = None
248
- self._console.file.write("\r" + " " * 48 + "\r") # clear the timer line
249
- self._console.file.flush()
250
242
 
251
243
  def tool_result(self, ok: bool, summary: str, duration_s: float) -> None:
252
- self._stop_timer()
253
244
  branding.render_tool_card(
254
245
  self._console,
255
246
  self._cur or "",
@@ -0,0 +1,4 @@
1
+ You are DeepParallel, a precise and capable coding assistant served via Crowe Logic.
2
+ Answer clearly and directly. When a problem benefits from step-by-step reasoning, reason carefully before giving the final answer. Be concise unless asked for depth.
3
+
4
+ You can use tools to read, search, analyze, edit, open, and run code. Use them when they help; do not call them speculatively. When the user asks to "open" a file (an HTML report, image, PDF, or folder) for viewing, use open_path to launch it in the default app rather than read_file, which only returns text contents. When asked to run something with different parameters, prefer non-destructive approaches (command-line arguments, environment variables, or a temporary copy) over editing the user's source files. Only edit a source file when changing that file is the actual goal, and explain what you changed.
@@ -0,0 +1,69 @@
1
+ """Shell execution tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+
10
+ from deepparallel.tools import tool
11
+
12
+
13
+ @tool(dangerous=True)
14
+ def run_shell(command: str, working_directory: str = "", timeout_seconds: int = 120) -> str:
15
+ """Run a shell command and capture its output.
16
+
17
+ :param command: Command to execute via the shell.
18
+ :param working_directory: Directory to run in (default: current).
19
+ :param timeout_seconds: Maximum execution time in seconds.
20
+ """
21
+ timeout_seconds = min(int(timeout_seconds), 600)
22
+ try:
23
+ r = subprocess.run(
24
+ command,
25
+ shell=True,
26
+ capture_output=True,
27
+ text=True,
28
+ timeout=timeout_seconds,
29
+ cwd=working_directory or None,
30
+ )
31
+ except subprocess.TimeoutExpired:
32
+ return json.dumps(
33
+ {"error": f"Command timed out after {timeout_seconds}s", "return_code": -1}
34
+ )
35
+ stdout = r.stdout or ""
36
+ if len(stdout) > 50000:
37
+ stdout = stdout[:50000] + "\n... (output truncated at 50KB)"
38
+ return json.dumps(
39
+ {"stdout": stdout, "stderr": (r.stderr or "")[:10000], "return_code": r.returncode}
40
+ )
41
+
42
+
43
+ def _opener_argv(path: str) -> list[str]:
44
+ if sys.platform == "darwin":
45
+ return ["open", path]
46
+ if sys.platform.startswith("win"):
47
+ return ["cmd", "/c", "start", "", path]
48
+ return ["xdg-open", path]
49
+
50
+
51
+ @tool(dangerous=True)
52
+ def open_path(path: str) -> str:
53
+ """Open a file, directory, or URL in the operating system's default app.
54
+
55
+ Use this when the user asks to "open" something for viewing - an HTML report
56
+ in the browser, a PNG/PDF in the image/document viewer, a folder in the file
57
+ manager. This launches the real app; it does not return the file contents
58
+ (use read_file for text or analyze_image to inspect an image).
59
+
60
+ :param path: File path, directory, or URL to open.
61
+ """
62
+ argv = _opener_argv(path)
63
+ if shutil.which(argv[0]) is None:
64
+ return json.dumps({"error": f"no opener available ({argv[0]} not found)"})
65
+ try:
66
+ subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
67
+ except Exception as e: # noqa: BLE001 - surface launch failure to the model
68
+ return json.dumps({"error": f"{type(e).__name__}: {e}"})
69
+ return json.dumps({"opened": path})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepparallel
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic.
5
5
  Author-email: Michael Crowe <michael@crowelogic.com>
6
6
  License: Apache-2.0
@@ -115,7 +115,12 @@ compose hosted backends (no GPU/weight-merging), so they are API-call stacking.
115
115
  - Read-only (run automatically): `read_file`, `list_dir`, `glob`, `grep`,
116
116
  `ast_symbols`, `ast_show_symbol`, `web_fetch`, `web_search`, `analyze_image`.
117
117
  - Mutating / executing (require confirmation): `write_file`, `edit_file`,
118
- `ast_replace_symbol`, `run_shell`, `run_code`.
118
+ `ast_replace_symbol`, `run_shell`, `run_code`, `open_path` (open a file, folder,
119
+ or URL in the OS default app - e.g. an HTML report in the browser).
120
+
121
+ In interactive chat, type `/auto` to toggle auto-approve (tools run without
122
+ asking; edits outside the project and unknown dependencies still confirm), or
123
+ start with `deepparallel --yes`.
119
124
 
120
125
  `web_search` needs `DEEPPARALLEL_SEARCH_API_KEY`; `analyze_image` works out of the
121
126
  box on a multimodal deployment (override with `DEEPPARALLEL_VISION_DEPLOYMENT`).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "deepparallel"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "DeepParallel - a multi-model agentic coding CLI with cross-model Guardian review, served via Crowe Logic."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -95,7 +95,8 @@ def test_rich_tool_timer_path_renders_card(monkeypatch):
95
95
  out = buf.getvalue()
96
96
  assert "run_shell" in out
97
97
  assert "✓" in out and "rc 0" in out
98
- assert r._timer_thread is None # timer cleaned up
98
+ # append-only: no ticking timer thread is spawned
99
+ assert not hasattr(r, "_timer_thread")
99
100
 
100
101
 
101
102
  def test_rich_error_uses_cross():
@@ -123,15 +124,28 @@ def test_rich_confirm_shows_detail():
123
124
  assert "added line" in out
124
125
 
125
126
 
126
- def test_rich_answer_stream_returns_text_and_renders_live_markdown():
127
+ def test_rich_answer_stream_returns_text_and_renders_markdown():
127
128
  buf = io.StringIO()
128
129
  con = Console(no_color=True, width=80, file=buf, force_terminal=True, highlight=False)
129
130
  r = RichRenderer(console=con)
130
131
  full = r.answer_stream(iter(["Hel", "lo ", "world"]))
131
132
  out = buf.getvalue()
132
133
  assert full == "Hello world" # full text preserved for history
133
- assert "Hello world" in out # rendered in the live answer panel
134
- assert "◆" in out and "answer" in out # panel marker + title
134
+ assert "Hello world" in out # rendered as a markdown block
135
+ assert "◆" in out # answer marker printed once
136
+
137
+
138
+ def test_rich_answer_stream_code_block_renders_atomically():
139
+ # a code block with an internal blank line must not be split mid-block
140
+ buf = io.StringIO()
141
+ con = Console(no_color=True, width=80, file=buf, force_terminal=True, highlight=False)
142
+ r = RichRenderer(console=con)
143
+ code = "intro\n\n```python\na = 1\n\nb = 2\n```\n\ndone"
144
+ full = r.answer_stream(iter([code]))
145
+ out = buf.getvalue()
146
+ assert full == code
147
+ assert "a = 1" in out and "b = 2" in out and "intro" in out and "done" in out
148
+ assert "```" not in out # fence rendered, not shown literally
135
149
 
136
150
 
137
151
  def test_rich_answer_stream_renders_markdown_formatting():
@@ -0,0 +1,48 @@
1
+ import json
2
+
3
+ import deepparallel.tools.shell as shell_mod
4
+ from deepparallel.tools import get_registry
5
+
6
+
7
+ def test_run_shell_captures_stdout_and_rc():
8
+ out = json.loads(shell_mod.run_shell("echo hello"))
9
+ assert out["return_code"] == 0
10
+ assert "hello" in out["stdout"]
11
+
12
+
13
+ def test_run_shell_respects_cwd(tmp_path):
14
+ out = json.loads(shell_mod.run_shell("pwd", working_directory=str(tmp_path)))
15
+ assert str(tmp_path) in out["stdout"]
16
+
17
+
18
+ def test_run_shell_timeout_returns_error():
19
+ out = json.loads(shell_mod.run_shell("sleep 5", timeout_seconds=1))
20
+ assert "timed out" in out["error"]
21
+
22
+
23
+ def test_run_shell_is_dangerous():
24
+ assert get_registry().get("run_shell").dangerous is True
25
+
26
+
27
+ def test_open_path_launches_default_opener(monkeypatch):
28
+ calls = {}
29
+ monkeypatch.setattr(shell_mod.shutil, "which", lambda _: "/usr/bin/open")
30
+
31
+ def fake_popen(argv, **kw):
32
+ calls["argv"] = argv
33
+ return object()
34
+
35
+ monkeypatch.setattr(shell_mod.subprocess, "Popen", fake_popen)
36
+ out = json.loads(shell_mod.open_path("/tmp/report.html"))
37
+ assert out["opened"] == "/tmp/report.html"
38
+ assert "/tmp/report.html" in calls["argv"]
39
+
40
+
41
+ def test_open_path_reports_missing_opener(monkeypatch):
42
+ monkeypatch.setattr(shell_mod.shutil, "which", lambda _: None)
43
+ out = json.loads(shell_mod.open_path("/tmp/x.png"))
44
+ assert "error" in out
45
+
46
+
47
+ def test_open_path_is_dangerous():
48
+ assert get_registry().get("open_path").dangerous is True
@@ -1,4 +0,0 @@
1
- You are DeepParallel, a precise and capable coding assistant served via Crowe Logic.
2
- Answer clearly and directly. When a problem benefits from step-by-step reasoning, reason carefully before giving the final answer. Be concise unless asked for depth.
3
-
4
- You can use tools to read, search, analyze, edit, and run code. Use them when they help; do not call them speculatively. When asked to run something with different parameters, prefer non-destructive approaches (command-line arguments, environment variables, or a temporary copy) over editing the user's source files. Only edit a source file when changing that file is the actual goal, and explain what you changed.
@@ -1,38 +0,0 @@
1
- """Shell execution tool."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import subprocess
7
-
8
- from deepparallel.tools import tool
9
-
10
-
11
- @tool(dangerous=True)
12
- def run_shell(command: str, working_directory: str = "", timeout_seconds: int = 120) -> str:
13
- """Run a shell command and capture its output.
14
-
15
- :param command: Command to execute via the shell.
16
- :param working_directory: Directory to run in (default: current).
17
- :param timeout_seconds: Maximum execution time in seconds.
18
- """
19
- timeout_seconds = min(int(timeout_seconds), 600)
20
- try:
21
- r = subprocess.run(
22
- command,
23
- shell=True,
24
- capture_output=True,
25
- text=True,
26
- timeout=timeout_seconds,
27
- cwd=working_directory or None,
28
- )
29
- except subprocess.TimeoutExpired:
30
- return json.dumps(
31
- {"error": f"Command timed out after {timeout_seconds}s", "return_code": -1}
32
- )
33
- stdout = r.stdout or ""
34
- if len(stdout) > 50000:
35
- stdout = stdout[:50000] + "\n... (output truncated at 50KB)"
36
- return json.dumps(
37
- {"stdout": stdout, "stderr": (r.stderr or "")[:10000], "return_code": r.returncode}
38
- )
@@ -1,24 +0,0 @@
1
- import json
2
-
3
- import deepparallel.tools.shell as shell_mod
4
- from deepparallel.tools import get_registry
5
-
6
-
7
- def test_run_shell_captures_stdout_and_rc():
8
- out = json.loads(shell_mod.run_shell("echo hello"))
9
- assert out["return_code"] == 0
10
- assert "hello" in out["stdout"]
11
-
12
-
13
- def test_run_shell_respects_cwd(tmp_path):
14
- out = json.loads(shell_mod.run_shell("pwd", working_directory=str(tmp_path)))
15
- assert str(tmp_path) in out["stdout"]
16
-
17
-
18
- def test_run_shell_timeout_returns_error():
19
- out = json.loads(shell_mod.run_shell("sleep 5", timeout_seconds=1))
20
- assert "timed out" in out["error"]
21
-
22
-
23
- def test_run_shell_is_dangerous():
24
- assert get_registry().get("run_shell").dangerous is True
File without changes