deepparallel 0.5.2__tar.gz → 0.5.4__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.
- {deepparallel-0.5.2 → deepparallel-0.5.4}/PKG-INFO +1 -1
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/__init__.py +1 -1
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/agent.py +6 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/cli.py +72 -0
- deepparallel-0.5.4/deepparallel/cockpit.py +143 -0
- deepparallel-0.5.4/deepparallel/cockpit_observe.py +58 -0
- deepparallel-0.5.4/deepparallel/cockpit_panel.py +320 -0
- deepparallel-0.5.4/deepparallel/cockpit_sim.py +258 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/system_prompt.txt +4 -0
- deepparallel-0.5.4/deepparallel/tools/web.py +187 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/PKG-INFO +1 -1
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/SOURCES.txt +7 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/pyproject.toml +1 -1
- deepparallel-0.5.4/tests/test_cockpit.py +241 -0
- deepparallel-0.5.4/tests/test_cockpit_panel.py +147 -0
- deepparallel-0.5.4/tests/test_cockpit_sim.py +158 -0
- deepparallel-0.5.4/tests/test_tools_web.py +140 -0
- deepparallel-0.5.2/deepparallel/tools/web.py +0 -82
- deepparallel-0.5.2/tests/test_tools_web.py +0 -97
- {deepparallel-0.5.2 → deepparallel-0.5.4}/README.md +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/backend.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/branding.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/config.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/crowe_id.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/dsml.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/fusion.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/licensing.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/registry.json +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/renderer.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/research/__init__.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/research/conduit.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/research/provider.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/routing.example.json +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/routing.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/serve.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/supply_chain.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/__init__.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/codeast.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/edit.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/files.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/mcp.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/registry.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/sandbox.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/search.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/shell.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/tools/vision.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel/userinput.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/dependency_links.txt +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/entry_points.txt +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/requires.txt +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/deepparallel.egg-info/top_level.txt +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/setup.cfg +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_agent.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_backend.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_backend_chat.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_backend_stream.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_branding.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_cli.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_config.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_crowe_backend.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_crowe_gateway_backend.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_crowe_id_auth.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_crowe_payment_required.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_dsml.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_fusion.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_issuer_signer.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_licensing.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_renderer.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_research.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_research_provider.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_routing.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_serve.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_spinner_color.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_supply_chain.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tool_registry.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_codeast.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_edit.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_files.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_mcp.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_sandbox.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_search.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_shell.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_tools_vision.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_userinput.py +0 -0
- {deepparallel-0.5.2 → deepparallel-0.5.4}/tests/test_userinput_paste.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deepparallel
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.4
|
|
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
|
|
@@ -307,6 +307,7 @@ def run_agent(
|
|
|
307
307
|
max_steps: int | None = None,
|
|
308
308
|
stream: bool = False,
|
|
309
309
|
guardian=None,
|
|
310
|
+
on_event=None,
|
|
310
311
|
) -> str:
|
|
311
312
|
steps = max_steps if max_steps is not None else settings.max_steps
|
|
312
313
|
schemas = registry.schemas()
|
|
@@ -354,6 +355,11 @@ def run_agent(
|
|
|
354
355
|
except Exception as e: # noqa: BLE001 - surface tool failure to model
|
|
355
356
|
result = json.dumps({"error": f"{type(e).__name__}: {e}"})
|
|
356
357
|
result = str(result)[:_MAX_TOOL_RESULT]
|
|
358
|
+
if on_event is not None:
|
|
359
|
+
try:
|
|
360
|
+
on_event(name, args, result)
|
|
361
|
+
except Exception: # noqa: BLE001 - observation must never break the loop
|
|
362
|
+
pass
|
|
357
363
|
ok = '"error"' not in result[:30]
|
|
358
364
|
renderer.tool_result(ok, _summarize_result(name, result), time.monotonic() - start)
|
|
359
365
|
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": result})
|
|
@@ -16,6 +16,8 @@ apply to chat and run.
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
19
21
|
import sys
|
|
20
22
|
from dataclasses import replace
|
|
21
23
|
from pathlib import Path
|
|
@@ -53,6 +55,8 @@ from deepparallel.fusion import (
|
|
|
53
55
|
)
|
|
54
56
|
from deepparallel.renderer import PlainRenderer, Renderer, RichRenderer
|
|
55
57
|
from deepparallel.tools import get_registry
|
|
58
|
+
from deepparallel.cockpit import Cockpit
|
|
59
|
+
from deepparallel.cockpit_observe import make_observer
|
|
56
60
|
|
|
57
61
|
|
|
58
62
|
def _build_messages(history: list[tuple[str, str]], system: str, user_msg: str) -> list[dict]:
|
|
@@ -486,6 +490,74 @@ def run(
|
|
|
486
490
|
sys.exit(1)
|
|
487
491
|
|
|
488
492
|
|
|
493
|
+
@main.command()
|
|
494
|
+
@click.option("--yes", "-y", "assume_yes", is_flag=True, help="Auto-approve tool calls.")
|
|
495
|
+
@click.argument("prompt", nargs=-1, required=True)
|
|
496
|
+
@click.pass_context
|
|
497
|
+
def cockpit(ctx: click.Context, assume_yes: bool, prompt: tuple[str, ...]) -> None:
|
|
498
|
+
"""Run a task in cockpit mode: a live data window plus WaveTerm render blocks.
|
|
499
|
+
|
|
500
|
+
The terminal streams the agent while an adjacent block shows a live data
|
|
501
|
+
window (sources, citations, claims) and pages the agent reads open as web
|
|
502
|
+
blocks. Outside WaveTerm it runs normally and still logs the event stream.
|
|
503
|
+
"""
|
|
504
|
+
settings = _apply_flags(ctx.obj["settings"], False, assume_yes)
|
|
505
|
+
backend = _require_ready(settings)
|
|
506
|
+
events_path = os.path.abspath(os.path.join(".cockpit", "events.jsonl"))
|
|
507
|
+
os.makedirs(os.path.dirname(events_path), exist_ok=True)
|
|
508
|
+
open(events_path, "w").close()
|
|
509
|
+
cp = Cockpit(events_path)
|
|
510
|
+
cp.status("start", "cockpit online")
|
|
511
|
+
if cp.in_waveterm():
|
|
512
|
+
try:
|
|
513
|
+
subprocess.Popen(
|
|
514
|
+
["wsh", "run", "--", "dp", "_cockpit-panel", events_path],
|
|
515
|
+
stdout=subprocess.DEVNULL,
|
|
516
|
+
stderr=subprocess.DEVNULL,
|
|
517
|
+
)
|
|
518
|
+
except Exception: # noqa: BLE001 - the panel block is best-effort
|
|
519
|
+
pass
|
|
520
|
+
backend = _wrap_fusion(backend, settings)
|
|
521
|
+
system = load_system_prompt()
|
|
522
|
+
messages = _build_messages([], system, " ".join(prompt))
|
|
523
|
+
renderer = _make_renderer(force_plain=False, assume_yes=settings.auto_approve)
|
|
524
|
+
try:
|
|
525
|
+
run_agent(
|
|
526
|
+
backend,
|
|
527
|
+
get_registry(),
|
|
528
|
+
messages,
|
|
529
|
+
settings,
|
|
530
|
+
renderer,
|
|
531
|
+
interactive=True,
|
|
532
|
+
auto_approve=settings.auto_approve,
|
|
533
|
+
stream=True,
|
|
534
|
+
on_event=make_observer(cp),
|
|
535
|
+
)
|
|
536
|
+
except Exception as e: # noqa: BLE001 - surface as friendly message
|
|
537
|
+
renderer.error(_translate_error(e))
|
|
538
|
+
failed = True
|
|
539
|
+
else:
|
|
540
|
+
failed = False
|
|
541
|
+
cp.status("done", "cockpit complete")
|
|
542
|
+
cp.notify("DeepParallel cockpit run complete")
|
|
543
|
+
if failed:
|
|
544
|
+
sys.exit(1)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@main.command(name="_cockpit-panel", hidden=True)
|
|
548
|
+
@click.argument("events_path")
|
|
549
|
+
def _cockpit_panel(events_path: str) -> None:
|
|
550
|
+
"""Internal: render the live cockpit data window for an events log."""
|
|
551
|
+
from deepparallel.cockpit_panel import run_panel
|
|
552
|
+
|
|
553
|
+
def _complete(state) -> bool:
|
|
554
|
+
# cli.cockpit emits status(phase="done", message="cockpit complete")
|
|
555
|
+
# at the end of a run; stop the panel so its process does not leak.
|
|
556
|
+
return "cockpit complete" in getattr(state, "status", "")
|
|
557
|
+
|
|
558
|
+
run_panel(events_path, stop_when=_complete)
|
|
559
|
+
|
|
560
|
+
|
|
489
561
|
@main.command()
|
|
490
562
|
@click.option("--diff", "as_diff", is_flag=True, help="Read a unified diff from stdin.")
|
|
491
563
|
@click.argument("path", required=False)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Copyright 2026, Crowe Logic Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""WaveTerm orchestration and event-log layer for the DeepParallel cockpit.
|
|
5
|
+
|
|
6
|
+
The terminal (WaveTerm) is the control and DATA WINDOW; the agent spawns
|
|
7
|
+
adjacent RENDER blocks (web, file preview) via WaveTerm's `wsh` CLI. Everything
|
|
8
|
+
flows through ONE append-only event log: a `.jsonl` file, one JSON object per
|
|
9
|
+
line, default `./.cockpit/events.jsonl`. Producers append; consumers tail.
|
|
10
|
+
|
|
11
|
+
Event shape:
|
|
12
|
+
{"t": <float epoch seconds>, "type": <string>, "data": <object>}
|
|
13
|
+
|
|
14
|
+
`Cockpit` is the single producer surface. `emit` appends a contract event line
|
|
15
|
+
(creating the parent dir, never raising). `open_web` / `view_file` shell out to
|
|
16
|
+
`wsh`, parse the "created block <id>" line, emit a matching "block" event, and
|
|
17
|
+
return the block id. Off WaveTerm (env `WAVETERM` / `WAVETERM_BLOCKID` unset) or
|
|
18
|
+
on any failure these block helpers are graceful no-ops returning None, while
|
|
19
|
+
`emit` still records events so the log stays useful in plain terminals.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import subprocess
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
# Bound every shell-out so a hung `wsh` can never stall the agent loop.
|
|
33
|
+
_WSH_TIMEOUT_S = 5.0
|
|
34
|
+
|
|
35
|
+
# `wsh web open` / `wsh view` print a line like "created block <blockid>".
|
|
36
|
+
_BLOCK_RE = re.compile(r"created block\s+(\S+)", re.IGNORECASE)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Cockpit:
|
|
40
|
+
"""Append-only event log plus WaveTerm `wsh` block orchestration.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
events_path: path to the append-only `.jsonl` event log.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, events_path: str = ".cockpit/events.jsonl"):
|
|
47
|
+
self.events_path = Path(events_path)
|
|
48
|
+
|
|
49
|
+
# --- environment -------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
def in_waveterm(self) -> bool:
|
|
52
|
+
"""True when running inside a WaveTerm block (env signals present)."""
|
|
53
|
+
return bool(os.environ.get("WAVETERM") or os.environ.get("WAVETERM_BLOCKID"))
|
|
54
|
+
|
|
55
|
+
# --- event log ---------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def emit(self, type: str, **data: Any) -> None:
|
|
58
|
+
"""Append one contract event line. Creates the parent dir. Never raises."""
|
|
59
|
+
event = {"t": time.time(), "type": type, "data": data}
|
|
60
|
+
try:
|
|
61
|
+
self.events_path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
line = json.dumps(event, ensure_ascii=False, default=str)
|
|
63
|
+
with self.events_path.open("a", encoding="utf-8") as fh:
|
|
64
|
+
fh.write(line + "\n")
|
|
65
|
+
except Exception:
|
|
66
|
+
# The event log is best-effort; a failed write must not break the
|
|
67
|
+
# agent loop or any producer.
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
# --- wsh block orchestration ------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _run_wsh(self, args: list[str]) -> str | None:
|
|
73
|
+
"""Run `wsh <args>` time-bounded; return stdout or None on any failure."""
|
|
74
|
+
if not self.in_waveterm():
|
|
75
|
+
return None
|
|
76
|
+
try:
|
|
77
|
+
proc = subprocess.run(
|
|
78
|
+
["wsh", *args],
|
|
79
|
+
capture_output=True,
|
|
80
|
+
text=True,
|
|
81
|
+
timeout=_WSH_TIMEOUT_S,
|
|
82
|
+
check=False,
|
|
83
|
+
)
|
|
84
|
+
except Exception:
|
|
85
|
+
return None
|
|
86
|
+
if proc.returncode != 0:
|
|
87
|
+
return None
|
|
88
|
+
return proc.stdout
|
|
89
|
+
|
|
90
|
+
@staticmethod
|
|
91
|
+
def _parse_blockid(stdout: str | None) -> str | None:
|
|
92
|
+
"""Extract the block id from a `wsh` "created block <id>" line."""
|
|
93
|
+
if not stdout:
|
|
94
|
+
return None
|
|
95
|
+
match = _BLOCK_RE.search(stdout)
|
|
96
|
+
return match.group(1) if match else None
|
|
97
|
+
|
|
98
|
+
def open_web(self, url: str) -> str | None:
|
|
99
|
+
"""Open a WaveTerm web block for `url`; emit a "block" event; return id.
|
|
100
|
+
|
|
101
|
+
No-op returning None off WaveTerm or on any `wsh` failure.
|
|
102
|
+
"""
|
|
103
|
+
blockid = self._parse_blockid(self._run_wsh(["web", "open", url]))
|
|
104
|
+
if blockid is not None:
|
|
105
|
+
self.emit("block", action="open", kind="web", target=url, blockid=blockid)
|
|
106
|
+
return blockid
|
|
107
|
+
|
|
108
|
+
def view_file(self, path: str) -> str | None:
|
|
109
|
+
"""Open a WaveTerm preview block for `path`; emit a "block" event; return id.
|
|
110
|
+
|
|
111
|
+
No-op returning None off WaveTerm or on any `wsh` failure.
|
|
112
|
+
"""
|
|
113
|
+
blockid = self._parse_blockid(self._run_wsh(["view", path]))
|
|
114
|
+
if blockid is not None:
|
|
115
|
+
self.emit("block", action="open", kind="file", target=path, blockid=blockid)
|
|
116
|
+
return blockid
|
|
117
|
+
|
|
118
|
+
def notify(self, message: str) -> None:
|
|
119
|
+
"""Best-effort `wsh notify`. Silent no-op off WaveTerm or on failure."""
|
|
120
|
+
self._run_wsh(["notify", message])
|
|
121
|
+
|
|
122
|
+
# --- contract event helpers -------------------------------------------
|
|
123
|
+
|
|
124
|
+
def status(self, phase: str, message: str) -> None:
|
|
125
|
+
"""Emit a "status" event."""
|
|
126
|
+
self.emit("status", phase=phase, message=message)
|
|
127
|
+
|
|
128
|
+
def source(self, url: str, title: str, provider: str) -> None:
|
|
129
|
+
"""Emit a "source" event for a grounded source fetched."""
|
|
130
|
+
self.emit("source", url=url, title=title, provider=provider)
|
|
131
|
+
|
|
132
|
+
def citation(self, ref: int, url: str, title: str) -> None:
|
|
133
|
+
"""Emit a "citation" event for a cited reference."""
|
|
134
|
+
self.emit("citation", ref=ref, url=url, title=title)
|
|
135
|
+
|
|
136
|
+
def claim(self, text: str, grounded: bool, confidence: float) -> None:
|
|
137
|
+
"""Emit a "claim" event. Confidence is clamped to the 0..1 contract range."""
|
|
138
|
+
confidence = min(1.0, max(0.0, confidence))
|
|
139
|
+
self.emit("claim", text=text, grounded=grounded, confidence=confidence)
|
|
140
|
+
|
|
141
|
+
def sim(self, step: int, arm: str, metric: str, value: float) -> None:
|
|
142
|
+
"""Emit a "sim" event for one A/B simulation datapoint."""
|
|
143
|
+
self.emit("sim", step=step, arm=arm, metric=metric, value=value)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Copyright 2026, Crowe Logic Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
"""Translate agent tool results into cockpit events and render blocks.
|
|
5
|
+
|
|
6
|
+
`make_observer(cockpit)` returns a callback the agent loop invokes after every
|
|
7
|
+
tool call as `observe(name, args, result)`. It reads grounding tool results
|
|
8
|
+
(web_search, web_fetch, mcp_search) and emits the matching contract events so
|
|
9
|
+
the live data window updates, and opens the pages the agent reads as adjacent
|
|
10
|
+
WaveTerm web blocks (capped, so a long run does not spawn dozens of blocks).
|
|
11
|
+
Observation is best-effort: it never raises into the agent loop.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from typing import Any, Callable
|
|
18
|
+
|
|
19
|
+
_MAX_WEB_BLOCKS = 4 # cap render blocks opened from fetched pages
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def make_observer(cockpit) -> Callable[[str, dict, str], None]:
|
|
23
|
+
"""Return an observe(name, args, result) callback bound to `cockpit`."""
|
|
24
|
+
counters = {"ref": 0, "blocks": 0}
|
|
25
|
+
|
|
26
|
+
def observe(name: str, args: dict, result: str) -> None:
|
|
27
|
+
try:
|
|
28
|
+
obj: Any = json.loads(result)
|
|
29
|
+
except (ValueError, TypeError):
|
|
30
|
+
obj = None
|
|
31
|
+
try:
|
|
32
|
+
if isinstance(obj, dict) and "error" not in obj:
|
|
33
|
+
if name == "web_search":
|
|
34
|
+
provider = obj.get("provider", "web")
|
|
35
|
+
for r in (obj.get("results") or [])[:8]:
|
|
36
|
+
url = r.get("url")
|
|
37
|
+
if not url:
|
|
38
|
+
continue
|
|
39
|
+
counters["ref"] += 1
|
|
40
|
+
cockpit.source(url=url, title=r.get("title", ""), provider=provider)
|
|
41
|
+
cockpit.citation(ref=counters["ref"], url=url, title=r.get("title", ""))
|
|
42
|
+
answer = obj.get("answer")
|
|
43
|
+
if answer:
|
|
44
|
+
cockpit.claim(text=str(answer)[:240], grounded=True, confidence=0.7)
|
|
45
|
+
elif name == "web_fetch":
|
|
46
|
+
url = obj.get("url") or (args or {}).get("url", "")
|
|
47
|
+
if url:
|
|
48
|
+
cockpit.source(url=url, title=obj.get("title", ""), provider="fetch")
|
|
49
|
+
if counters["blocks"] < _MAX_WEB_BLOCKS and cockpit.open_web(url):
|
|
50
|
+
counters["blocks"] += 1
|
|
51
|
+
elif name == "mcp_search":
|
|
52
|
+
for s in (obj.get("servers") or [])[:5]:
|
|
53
|
+
cockpit.source(url="", title=s.get("name", ""), provider="mcp")
|
|
54
|
+
cockpit.status("tool", name)
|
|
55
|
+
except Exception: # noqa: BLE001 - observation must never break the loop
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
return observe
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Live terminal DATA WINDOW for the DeepParallel cockpit.
|
|
2
|
+
|
|
3
|
+
The cockpit's terminal pane (WaveTerm) is the control surface and data window;
|
|
4
|
+
the agent spawns adjacent RENDER blocks for the visual output. This module owns
|
|
5
|
+
the data window: it tails the shared append-only event log (one JSON object per
|
|
6
|
+
line, see the cockpit contract) and drives a dense, no-chrome Rich panel that
|
|
7
|
+
tallies sources, citations, claims, and the A/B simulation metrics in real time.
|
|
8
|
+
|
|
9
|
+
It is UI-only and side-effect-free apart from tailing the log: state is a plain
|
|
10
|
+
dataclass, `apply_event` mutates it from one contract event, and `render`
|
|
11
|
+
returns a Rich renderable. `run_panel` wires those into a `rich.live.Live` at a
|
|
12
|
+
calm refresh rate and is safe to start before the log file exists.
|
|
13
|
+
|
|
14
|
+
Aesthetic follows the DeepParallel CLI family (see `branding`): cyan identity
|
|
15
|
+
accent, the shared glyph alphabet, status hues for grounded vs unverified, no
|
|
16
|
+
emojis and no em dashes in any user-facing string.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from typing import Callable
|
|
25
|
+
|
|
26
|
+
from rich import box
|
|
27
|
+
from rich.console import Group
|
|
28
|
+
from rich.panel import Panel
|
|
29
|
+
from rich.table import Table
|
|
30
|
+
from rich.text import Text
|
|
31
|
+
|
|
32
|
+
from deepparallel import branding
|
|
33
|
+
|
|
34
|
+
# Refresh cadence for the Live region. ~6 fps is dense enough to feel live while
|
|
35
|
+
# leaving the terminal idle between event bursts.
|
|
36
|
+
_FPS = 6
|
|
37
|
+
_POLL_SECONDS = 1.0 / _FPS
|
|
38
|
+
# How many of the most recent sources to show; older ones collapse into a count.
|
|
39
|
+
_SOURCES_SHOWN = 4
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class CockpitState:
|
|
44
|
+
"""Accumulated view of the cockpit event stream.
|
|
45
|
+
|
|
46
|
+
Append-only collections mirror the log; `sim` keeps only the latest value
|
|
47
|
+
per (arm, metric) so the A/B table stays current rather than historical.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
sources: list[dict] = field(default_factory=list)
|
|
51
|
+
citations: list[dict] = field(default_factory=list)
|
|
52
|
+
claims: list[dict] = field(default_factory=list)
|
|
53
|
+
sim: dict[tuple[str, str], float] = field(default_factory=dict)
|
|
54
|
+
status: str = "idle"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def grounded_claims(self) -> int:
|
|
58
|
+
return sum(1 for c in self.claims if c.get("grounded"))
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def unverified_claims(self) -> int:
|
|
62
|
+
return sum(1 for c in self.claims if not c.get("grounded"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def apply_event(state: CockpitState, event: dict) -> None:
|
|
66
|
+
"""Mutate `state` from one contract event. Unknown or malformed events are
|
|
67
|
+
ignored so a single bad line never derails the data window."""
|
|
68
|
+
if not isinstance(event, dict):
|
|
69
|
+
return
|
|
70
|
+
etype = event.get("type")
|
|
71
|
+
data = event.get("data")
|
|
72
|
+
if not isinstance(data, dict):
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if etype == "status":
|
|
76
|
+
phase = str(data.get("phase", "")).strip()
|
|
77
|
+
message = str(data.get("message", "")).strip()
|
|
78
|
+
state.status = " ".join(p for p in (phase, message) if p) or state.status
|
|
79
|
+
elif etype == "source":
|
|
80
|
+
state.sources.append(
|
|
81
|
+
{
|
|
82
|
+
"url": str(data.get("url", "")),
|
|
83
|
+
"title": str(data.get("title", "")),
|
|
84
|
+
"provider": str(data.get("provider", "")),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
elif etype == "citation":
|
|
88
|
+
state.citations.append(
|
|
89
|
+
{
|
|
90
|
+
"ref": data.get("ref"),
|
|
91
|
+
"url": str(data.get("url", "")),
|
|
92
|
+
"title": str(data.get("title", "")),
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
elif etype == "claim":
|
|
96
|
+
state.claims.append(
|
|
97
|
+
{
|
|
98
|
+
"text": str(data.get("text", "")),
|
|
99
|
+
"grounded": bool(data.get("grounded", False)),
|
|
100
|
+
"confidence": min(1.0, max(0.0, _as_float(data.get("confidence"), 0.0))),
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
elif etype == "sim":
|
|
104
|
+
arm = str(data.get("arm", ""))
|
|
105
|
+
metric = str(data.get("metric", ""))
|
|
106
|
+
if arm and metric:
|
|
107
|
+
state.sim[(arm, metric)] = _as_float(data.get("value"), 0.0)
|
|
108
|
+
# "block" events drive render blocks elsewhere; the data window ignores them.
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _as_float(value: object, default: float) -> float:
|
|
112
|
+
try:
|
|
113
|
+
return float(value) # type: ignore[arg-type]
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
return default
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _counter(label: str, value: object, accent: str) -> Text:
|
|
119
|
+
"""A compact 'label value' counter chip."""
|
|
120
|
+
t = Text()
|
|
121
|
+
t.append(f"{label} ", style=branding.DIM)
|
|
122
|
+
t.append(str(value), style=f"bold {accent}")
|
|
123
|
+
return t
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _sources_section(state: CockpitState) -> Group:
|
|
127
|
+
header = Text()
|
|
128
|
+
header.append(f"{branding.MARK} Sources ", style=f"bold {branding.DP_ACCENT}")
|
|
129
|
+
header.append(str(len(state.sources)), style=f"bold {branding.BLUE_HEX}")
|
|
130
|
+
if not state.sources:
|
|
131
|
+
return Group(header, Text(" (none yet)", style=branding.DIM))
|
|
132
|
+
|
|
133
|
+
table = Table.grid(padding=(0, 1))
|
|
134
|
+
table.add_column(justify="right", style=branding.DIM, no_wrap=True)
|
|
135
|
+
table.add_column(style="white", no_wrap=True, max_width=44)
|
|
136
|
+
table.add_column(style=branding.DIM, no_wrap=True)
|
|
137
|
+
recent = state.sources[-_SOURCES_SHOWN:]
|
|
138
|
+
start = len(state.sources) - len(recent) + 1
|
|
139
|
+
for i, src in enumerate(recent, start=start):
|
|
140
|
+
title = src.get("title") or src.get("url") or "(untitled)"
|
|
141
|
+
provider = src.get("provider") or ""
|
|
142
|
+
table.add_row(f"{i}.", _ellipsize(title, 44), provider)
|
|
143
|
+
return Group(header, table)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _claims_section(state: CockpitState) -> Group:
|
|
147
|
+
total = len(state.claims)
|
|
148
|
+
header = Text()
|
|
149
|
+
header.append(f"{branding.MARK} Claims ", style=f"bold {branding.DP_ACCENT}")
|
|
150
|
+
header.append(str(total), style="bold white")
|
|
151
|
+
|
|
152
|
+
line = Text(" ")
|
|
153
|
+
line.append(f"{branding.CHECK} grounded ", style=branding.GREEN_HEX)
|
|
154
|
+
line.append(str(state.grounded_claims), style=f"bold {branding.GREEN_HEX}")
|
|
155
|
+
line.append(f" {branding.DOT} ", style=branding.DIM)
|
|
156
|
+
line.append(f"{branding.CROSS} unverified ", style=branding.AMBER_HEX)
|
|
157
|
+
line.append(str(state.unverified_claims), style=f"bold {branding.AMBER_HEX}")
|
|
158
|
+
return Group(header, line)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _sim_table(state: CockpitState) -> Table:
|
|
162
|
+
"""A/B simulation metrics: one row per metric, latest A vs B values, with the
|
|
163
|
+
leading arm highlighted. Arms beyond A/B are tolerated but not columned."""
|
|
164
|
+
table = Table(
|
|
165
|
+
box=box.SIMPLE_HEAD,
|
|
166
|
+
padding=(0, 1),
|
|
167
|
+
title=Text(f"{branding.MARK} Sim metrics", style=f"bold {branding.DP_ACCENT}"),
|
|
168
|
+
title_justify="left",
|
|
169
|
+
expand=False,
|
|
170
|
+
)
|
|
171
|
+
table.add_column("metric", style=branding.DIM, no_wrap=True)
|
|
172
|
+
table.add_column("arm A", justify="right", no_wrap=True)
|
|
173
|
+
table.add_column("arm B", justify="right", no_wrap=True)
|
|
174
|
+
|
|
175
|
+
metrics = sorted({metric for (_arm, metric) in state.sim})
|
|
176
|
+
if not metrics:
|
|
177
|
+
table.add_row("(none yet)", "-", "-")
|
|
178
|
+
return table
|
|
179
|
+
for metric in metrics:
|
|
180
|
+
a = state.sim.get(("A", metric))
|
|
181
|
+
b = state.sim.get(("B", metric))
|
|
182
|
+
table.add_row(metric, _arm_cell(a, a, b), _arm_cell(b, a, b))
|
|
183
|
+
return table
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _arm_cell(value: float | None, a: float | None, b: float | None) -> Text:
|
|
187
|
+
"""Format one arm cell, bolding the leader when both arms are present."""
|
|
188
|
+
if value is None:
|
|
189
|
+
return Text("-", style=branding.DIM)
|
|
190
|
+
leading = a is not None and b is not None and value == max(a, b) and a != b
|
|
191
|
+
style = f"bold {branding.GREEN_HEX}" if leading else "white"
|
|
192
|
+
return Text(f"{value:.4g}", style=style)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _status_line(state: CockpitState) -> Text:
|
|
196
|
+
line = Text()
|
|
197
|
+
line.append(f"{branding.ARROW} ", style=branding.DP_ACCENT)
|
|
198
|
+
line.append("status ", style=branding.DIM)
|
|
199
|
+
line.append(state.status or "idle", style=f"bold {branding.DP_ACCENT}")
|
|
200
|
+
return line
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def render(state: CockpitState):
|
|
204
|
+
"""Build the data-window panel: counters, sources, claims, sim, status.
|
|
205
|
+
|
|
206
|
+
Returns a Rich renderable (a titled `Panel`) so callers can print it once or
|
|
207
|
+
feed it to a `Live` region.
|
|
208
|
+
"""
|
|
209
|
+
counters = Table.grid(padding=(0, 3))
|
|
210
|
+
counters.add_column()
|
|
211
|
+
counters.add_column()
|
|
212
|
+
counters.add_column()
|
|
213
|
+
counters.add_row(
|
|
214
|
+
_counter("sources", len(state.sources), branding.BLUE_HEX),
|
|
215
|
+
_counter("citations", len(state.citations), branding.DP_ACCENT),
|
|
216
|
+
_counter("claims", len(state.claims), "white"),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
body = Group(
|
|
220
|
+
counters,
|
|
221
|
+
Text(branding.RULE * 40, style=branding.DIM),
|
|
222
|
+
_sources_section(state),
|
|
223
|
+
Text(),
|
|
224
|
+
_claims_section(state),
|
|
225
|
+
Text(),
|
|
226
|
+
_sim_table(state),
|
|
227
|
+
Text(branding.RULE * 40, style=branding.DIM),
|
|
228
|
+
_status_line(state),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
title = Text()
|
|
232
|
+
title.append("DeepParallel ", style=f"bold {branding.DP_ACCENT}")
|
|
233
|
+
title.append("data window", style=branding.DIM)
|
|
234
|
+
return Panel(
|
|
235
|
+
body,
|
|
236
|
+
title=title,
|
|
237
|
+
title_align="left",
|
|
238
|
+
border_style=branding.DP_ACCENT,
|
|
239
|
+
box=box.ROUNDED,
|
|
240
|
+
padding=(0, 1),
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _ellipsize(text: str, width: int) -> str:
|
|
245
|
+
text = text.replace("\n", " ").strip()
|
|
246
|
+
if len(text) <= width:
|
|
247
|
+
return text
|
|
248
|
+
return text[: max(1, width - 1)] + branding.DOT
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _read_new_lines(path: str, offset: int) -> tuple[list[str], int]:
|
|
252
|
+
"""Read any lines appended past `offset`. Returns (lines, new_offset). Safe
|
|
253
|
+
when the file is missing or was truncated/rotated (offset resets to 0)."""
|
|
254
|
+
try:
|
|
255
|
+
size = _file_size(path)
|
|
256
|
+
if size < offset: # truncated or rotated: start over
|
|
257
|
+
offset = 0
|
|
258
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
259
|
+
fh.seek(offset)
|
|
260
|
+
chunk = fh.read()
|
|
261
|
+
new_offset = fh.tell()
|
|
262
|
+
except FileNotFoundError:
|
|
263
|
+
return [], offset
|
|
264
|
+
except OSError:
|
|
265
|
+
return [], offset
|
|
266
|
+
if not chunk:
|
|
267
|
+
return [], new_offset
|
|
268
|
+
# Keep only complete lines; a trailing partial line stays for the next poll.
|
|
269
|
+
if not chunk.endswith("\n"):
|
|
270
|
+
last_nl = chunk.rfind("\n")
|
|
271
|
+
if last_nl == -1:
|
|
272
|
+
return [], offset # no complete line yet; do not advance
|
|
273
|
+
new_offset = offset + len(chunk[: last_nl + 1].encode("utf-8"))
|
|
274
|
+
chunk = chunk[: last_nl + 1]
|
|
275
|
+
return [ln for ln in chunk.splitlines() if ln.strip()], new_offset
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _file_size(path: str) -> int:
|
|
279
|
+
import os
|
|
280
|
+
|
|
281
|
+
return os.path.getsize(path)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def run_panel(
|
|
285
|
+
events_path: str,
|
|
286
|
+
stop_when: Callable[[CockpitState], bool] | None = None,
|
|
287
|
+
*,
|
|
288
|
+
console=None,
|
|
289
|
+
poll_seconds: float = _POLL_SECONDS,
|
|
290
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
291
|
+
) -> CockpitState:
|
|
292
|
+
"""Tail `events_path` and drive a Live data window at ~6 fps.
|
|
293
|
+
|
|
294
|
+
Appended events are applied to a fresh `CockpitState` and the panel
|
|
295
|
+
re-rendered each tick. Returns the final state. Stops when `stop_when(state)`
|
|
296
|
+
is truthy (checked after each batch) or on KeyboardInterrupt; if `stop_when`
|
|
297
|
+
is None it runs until interrupted. Safe to start before the file exists.
|
|
298
|
+
"""
|
|
299
|
+
from rich.live import Live
|
|
300
|
+
|
|
301
|
+
con = console or branding.console
|
|
302
|
+
state = CockpitState()
|
|
303
|
+
offset = 0
|
|
304
|
+
|
|
305
|
+
with Live(render(state), console=con, refresh_per_second=_FPS, transient=False) as live:
|
|
306
|
+
try:
|
|
307
|
+
while True:
|
|
308
|
+
lines, offset = _read_new_lines(events_path, offset)
|
|
309
|
+
for line in lines:
|
|
310
|
+
try:
|
|
311
|
+
apply_event(state, json.loads(line))
|
|
312
|
+
except (json.JSONDecodeError, ValueError):
|
|
313
|
+
continue # tolerate a partial or malformed line
|
|
314
|
+
live.update(render(state))
|
|
315
|
+
if stop_when is not None and stop_when(state):
|
|
316
|
+
break
|
|
317
|
+
sleep(poll_seconds)
|
|
318
|
+
except KeyboardInterrupt:
|
|
319
|
+
pass
|
|
320
|
+
return state
|