deepparallel 0.5.3__tar.gz → 0.5.5__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.3 → deepparallel-0.5.5}/PKG-INFO +1 -1
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/__init__.py +1 -1
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/agent.py +6 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/cli.py +85 -1
- deepparallel-0.5.5/deepparallel/cockpit.py +163 -0
- deepparallel-0.5.5/deepparallel/cockpit_observe.py +65 -0
- deepparallel-0.5.5/deepparallel/cockpit_panel.py +320 -0
- deepparallel-0.5.5/deepparallel/cockpit_sim.py +258 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/PKG-INFO +1 -1
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/SOURCES.txt +7 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/pyproject.toml +1 -1
- deepparallel-0.5.5/tests/test_cockpit.py +282 -0
- deepparallel-0.5.5/tests/test_cockpit_panel.py +147 -0
- deepparallel-0.5.5/tests/test_cockpit_sim.py +158 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/README.md +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/backend.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/branding.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/config.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/crowe_id.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/dsml.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/fusion.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/licensing.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/registry.json +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/renderer.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/research/__init__.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/research/conduit.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/research/provider.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/routing.example.json +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/routing.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/serve.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/supply_chain.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/system_prompt.txt +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/__init__.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/codeast.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/edit.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/files.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/mcp.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/registry.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/sandbox.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/search.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/shell.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/vision.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/web.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/userinput.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/dependency_links.txt +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/entry_points.txt +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/requires.txt +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/top_level.txt +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/setup.cfg +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_agent.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_backend.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_backend_chat.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_backend_stream.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_branding.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_cli.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_config.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_crowe_backend.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_crowe_gateway_backend.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_crowe_id_auth.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_crowe_payment_required.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_dsml.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_fusion.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_issuer_signer.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_licensing.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_renderer.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_research.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_research_provider.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_routing.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_serve.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_spinner_color.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_supply_chain.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tool_registry.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_codeast.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_edit.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_files.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_mcp.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_sandbox.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_search.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_shell.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_vision.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_web.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_userinput.py +0 -0
- {deepparallel-0.5.3 → deepparallel-0.5.5}/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.5
|
|
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,7 @@ apply to chat and run.
|
|
|
16
16
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
|
+
import os
|
|
19
20
|
import sys
|
|
20
21
|
from dataclasses import replace
|
|
21
22
|
from pathlib import Path
|
|
@@ -53,6 +54,8 @@ from deepparallel.fusion import (
|
|
|
53
54
|
)
|
|
54
55
|
from deepparallel.renderer import PlainRenderer, Renderer, RichRenderer
|
|
55
56
|
from deepparallel.tools import get_registry
|
|
57
|
+
from deepparallel.cockpit import Cockpit
|
|
58
|
+
from deepparallel.cockpit_observe import make_observer
|
|
56
59
|
|
|
57
60
|
|
|
58
61
|
def _build_messages(history: list[tuple[str, str]], system: str, user_msg: str) -> list[dict]:
|
|
@@ -291,6 +294,13 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
|
|
|
291
294
|
mode = settings.fusion_mode if settings.fusion_mode in ("reason", "escalate") else "off"
|
|
292
295
|
deep_next = False
|
|
293
296
|
auto = settings.auto_approve
|
|
297
|
+
cockpit = Cockpit(os.path.abspath(os.path.join(".cockpit", "events.jsonl")))
|
|
298
|
+
cockpit_on = cockpit.in_waveterm()
|
|
299
|
+
observer = make_observer(cockpit)
|
|
300
|
+
if cockpit_on:
|
|
301
|
+
cockpit.events_path.parent.mkdir(parents=True, exist_ok=True)
|
|
302
|
+
cockpit.events_path.write_text("")
|
|
303
|
+
cockpit.status("start", "cockpit ready")
|
|
294
304
|
while True:
|
|
295
305
|
bits = ([mode] if mode != "off" else []) + (["auto"] if auto else [])
|
|
296
306
|
tag = f"[{' · '.join(bits)}] " if bits else ""
|
|
@@ -305,7 +315,7 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
|
|
|
305
315
|
break
|
|
306
316
|
if user_msg == "/help":
|
|
307
317
|
branding.info(
|
|
308
|
-
"/quit · /reset · /info · /tools · /auto · /fast //fuse //escalate //deep · prompt"
|
|
318
|
+
"/quit · /reset · /info · /tools · /auto · /cockpit · /fast //fuse //escalate //deep · prompt"
|
|
309
319
|
)
|
|
310
320
|
continue
|
|
311
321
|
if user_msg in {"/auto", "/yes"}:
|
|
@@ -333,6 +343,17 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
|
|
|
333
343
|
deep_next = True
|
|
334
344
|
branding.info("next prompt runs as a multi-model deep query")
|
|
335
345
|
continue
|
|
346
|
+
if user_msg == "/cockpit":
|
|
347
|
+
cockpit_on = not cockpit_on
|
|
348
|
+
if cockpit_on:
|
|
349
|
+
cockpit.events_path.parent.mkdir(parents=True, exist_ok=True)
|
|
350
|
+
cockpit.events_path.write_text("")
|
|
351
|
+
cockpit._panel_opened = False
|
|
352
|
+
branding.info("cockpit ON - the data window opens when research starts.")
|
|
353
|
+
else:
|
|
354
|
+
cockpit.status("done", "cockpit complete")
|
|
355
|
+
branding.info("cockpit OFF.")
|
|
356
|
+
continue
|
|
336
357
|
|
|
337
358
|
messages.append({"role": "user", "content": user_msg})
|
|
338
359
|
if deep_next:
|
|
@@ -350,12 +371,15 @@ def _agent_repl(backend: Backend, settings: Settings, renderer: Renderer) -> Non
|
|
|
350
371
|
auto_approve=auto,
|
|
351
372
|
stream=True,
|
|
352
373
|
guardian=guardian,
|
|
374
|
+
on_event=observer if cockpit_on else None,
|
|
353
375
|
)
|
|
354
376
|
if mode in ("reason", "escalate"):
|
|
355
377
|
branding.info("reasoned by CroweLM Reason · answered by DeepParallel")
|
|
356
378
|
except Exception as e: # noqa: BLE001 - surface as friendly message
|
|
357
379
|
renderer.error(_translate_error(e))
|
|
358
380
|
|
|
381
|
+
if cockpit_on:
|
|
382
|
+
cockpit.status("done", "cockpit complete")
|
|
359
383
|
branding.attribution_footer()
|
|
360
384
|
|
|
361
385
|
|
|
@@ -486,6 +510,66 @@ def run(
|
|
|
486
510
|
sys.exit(1)
|
|
487
511
|
|
|
488
512
|
|
|
513
|
+
@main.command()
|
|
514
|
+
@click.option("--yes", "-y", "assume_yes", is_flag=True, help="Auto-approve tool calls.")
|
|
515
|
+
@click.argument("prompt", nargs=-1, required=True)
|
|
516
|
+
@click.pass_context
|
|
517
|
+
def cockpit(ctx: click.Context, assume_yes: bool, prompt: tuple[str, ...]) -> None:
|
|
518
|
+
"""Run a task in cockpit mode: a live data window plus WaveTerm render blocks.
|
|
519
|
+
|
|
520
|
+
The terminal streams the agent while an adjacent block shows a live data
|
|
521
|
+
window (sources, citations, claims) and pages the agent reads open as web
|
|
522
|
+
blocks. Outside WaveTerm it runs normally and still logs the event stream.
|
|
523
|
+
"""
|
|
524
|
+
settings = _apply_flags(ctx.obj["settings"], False, assume_yes)
|
|
525
|
+
backend = _require_ready(settings)
|
|
526
|
+
events_path = os.path.abspath(os.path.join(".cockpit", "events.jsonl"))
|
|
527
|
+
os.makedirs(os.path.dirname(events_path), exist_ok=True)
|
|
528
|
+
open(events_path, "w").close()
|
|
529
|
+
cp = Cockpit(events_path)
|
|
530
|
+
cp.status("start", "cockpit online")
|
|
531
|
+
cp.ensure_panel()
|
|
532
|
+
backend = _wrap_fusion(backend, settings)
|
|
533
|
+
system = load_system_prompt()
|
|
534
|
+
messages = _build_messages([], system, " ".join(prompt))
|
|
535
|
+
renderer = _make_renderer(force_plain=False, assume_yes=settings.auto_approve)
|
|
536
|
+
try:
|
|
537
|
+
run_agent(
|
|
538
|
+
backend,
|
|
539
|
+
get_registry(),
|
|
540
|
+
messages,
|
|
541
|
+
settings,
|
|
542
|
+
renderer,
|
|
543
|
+
interactive=True,
|
|
544
|
+
auto_approve=settings.auto_approve,
|
|
545
|
+
stream=True,
|
|
546
|
+
on_event=make_observer(cp),
|
|
547
|
+
)
|
|
548
|
+
except Exception as e: # noqa: BLE001 - surface as friendly message
|
|
549
|
+
renderer.error(_translate_error(e))
|
|
550
|
+
failed = True
|
|
551
|
+
else:
|
|
552
|
+
failed = False
|
|
553
|
+
cp.status("done", "cockpit complete")
|
|
554
|
+
cp.notify("DeepParallel cockpit run complete")
|
|
555
|
+
if failed:
|
|
556
|
+
sys.exit(1)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@main.command(name="_cockpit-panel", hidden=True)
|
|
560
|
+
@click.argument("events_path")
|
|
561
|
+
def _cockpit_panel(events_path: str) -> None:
|
|
562
|
+
"""Internal: render the live cockpit data window for an events log."""
|
|
563
|
+
from deepparallel.cockpit_panel import run_panel
|
|
564
|
+
|
|
565
|
+
def _complete(state) -> bool:
|
|
566
|
+
# cli.cockpit emits status(phase="done", message="cockpit complete")
|
|
567
|
+
# at the end of a run; stop the panel so its process does not leak.
|
|
568
|
+
return "cockpit complete" in getattr(state, "status", "")
|
|
569
|
+
|
|
570
|
+
run_panel(events_path, stop_when=_complete)
|
|
571
|
+
|
|
572
|
+
|
|
489
573
|
@main.command()
|
|
490
574
|
@click.option("--diff", "as_diff", is_flag=True, help="Read a unified diff from stdin.")
|
|
491
575
|
@click.argument("path", required=False)
|
|
@@ -0,0 +1,163 @@
|
|
|
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
|
+
self._panel_opened = False
|
|
49
|
+
|
|
50
|
+
# --- environment -------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def in_waveterm(self) -> bool:
|
|
53
|
+
"""True when running inside a WaveTerm block (env signals present)."""
|
|
54
|
+
return bool(os.environ.get("WAVETERM") or os.environ.get("WAVETERM_BLOCKID"))
|
|
55
|
+
|
|
56
|
+
# --- event log ---------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def emit(self, type: str, **data: Any) -> None:
|
|
59
|
+
"""Append one contract event line. Creates the parent dir. Never raises."""
|
|
60
|
+
event = {"t": time.time(), "type": type, "data": data}
|
|
61
|
+
try:
|
|
62
|
+
self.events_path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
line = json.dumps(event, ensure_ascii=False, default=str)
|
|
64
|
+
with self.events_path.open("a", encoding="utf-8") as fh:
|
|
65
|
+
fh.write(line + "\n")
|
|
66
|
+
except Exception:
|
|
67
|
+
# The event log is best-effort; a failed write must not break the
|
|
68
|
+
# agent loop or any producer.
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
# --- wsh block orchestration ------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _run_wsh(self, args: list[str]) -> str | None:
|
|
74
|
+
"""Run `wsh <args>` time-bounded; return stdout or None on any failure."""
|
|
75
|
+
if not self.in_waveterm():
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
proc = subprocess.run(
|
|
79
|
+
["wsh", *args],
|
|
80
|
+
capture_output=True,
|
|
81
|
+
text=True,
|
|
82
|
+
timeout=_WSH_TIMEOUT_S,
|
|
83
|
+
check=False,
|
|
84
|
+
)
|
|
85
|
+
except Exception:
|
|
86
|
+
return None
|
|
87
|
+
if proc.returncode != 0:
|
|
88
|
+
return None
|
|
89
|
+
return proc.stdout
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _parse_blockid(stdout: str | None) -> str | None:
|
|
93
|
+
"""Extract the block id from a `wsh` "created block <id>" line."""
|
|
94
|
+
if not stdout:
|
|
95
|
+
return None
|
|
96
|
+
match = _BLOCK_RE.search(stdout)
|
|
97
|
+
return match.group(1) if match else None
|
|
98
|
+
|
|
99
|
+
def open_web(self, url: str) -> str | None:
|
|
100
|
+
"""Open a WaveTerm web block for `url`; emit a "block" event; return id.
|
|
101
|
+
|
|
102
|
+
No-op returning None off WaveTerm or on any `wsh` failure.
|
|
103
|
+
"""
|
|
104
|
+
blockid = self._parse_blockid(self._run_wsh(["web", "open", url]))
|
|
105
|
+
if blockid is not None:
|
|
106
|
+
self.emit("block", action="open", kind="web", target=url, blockid=blockid)
|
|
107
|
+
return blockid
|
|
108
|
+
|
|
109
|
+
def view_file(self, path: str) -> str | None:
|
|
110
|
+
"""Open a WaveTerm preview block for `path`; emit a "block" event; return id.
|
|
111
|
+
|
|
112
|
+
No-op returning None off WaveTerm or on any `wsh` failure.
|
|
113
|
+
"""
|
|
114
|
+
blockid = self._parse_blockid(self._run_wsh(["view", path]))
|
|
115
|
+
if blockid is not None:
|
|
116
|
+
self.emit("block", action="open", kind="file", target=path, blockid=blockid)
|
|
117
|
+
return blockid
|
|
118
|
+
|
|
119
|
+
def notify(self, message: str) -> None:
|
|
120
|
+
"""Best-effort `wsh notify`. Silent no-op off WaveTerm or on failure."""
|
|
121
|
+
self._run_wsh(["notify", message])
|
|
122
|
+
|
|
123
|
+
def ensure_panel(self) -> None:
|
|
124
|
+
"""Open the live data-window block once per session (idempotent).
|
|
125
|
+
|
|
126
|
+
Spawns `dp _cockpit-panel <abs-path>` in an adjacent WaveTerm block on
|
|
127
|
+
the first call so the cockpit appears only when research actually
|
|
128
|
+
starts; repeat calls and runs outside WaveTerm are no-ops.
|
|
129
|
+
"""
|
|
130
|
+
if self._panel_opened or not self.in_waveterm():
|
|
131
|
+
return
|
|
132
|
+
self._panel_opened = True
|
|
133
|
+
try:
|
|
134
|
+
subprocess.Popen(
|
|
135
|
+
["wsh", "run", "--", "dp", "_cockpit-panel", str(self.events_path.resolve())],
|
|
136
|
+
stdout=subprocess.DEVNULL,
|
|
137
|
+
stderr=subprocess.DEVNULL,
|
|
138
|
+
)
|
|
139
|
+
except Exception: # noqa: BLE001 - the panel block is best-effort
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
# --- contract event helpers -------------------------------------------
|
|
143
|
+
|
|
144
|
+
def status(self, phase: str, message: str) -> None:
|
|
145
|
+
"""Emit a "status" event."""
|
|
146
|
+
self.emit("status", phase=phase, message=message)
|
|
147
|
+
|
|
148
|
+
def source(self, url: str, title: str, provider: str) -> None:
|
|
149
|
+
"""Emit a "source" event for a grounded source fetched."""
|
|
150
|
+
self.emit("source", url=url, title=title, provider=provider)
|
|
151
|
+
|
|
152
|
+
def citation(self, ref: int, url: str, title: str) -> None:
|
|
153
|
+
"""Emit a "citation" event for a cited reference."""
|
|
154
|
+
self.emit("citation", ref=ref, url=url, title=title)
|
|
155
|
+
|
|
156
|
+
def claim(self, text: str, grounded: bool, confidence: float) -> None:
|
|
157
|
+
"""Emit a "claim" event. Confidence is clamped to the 0..1 contract range."""
|
|
158
|
+
confidence = min(1.0, max(0.0, confidence))
|
|
159
|
+
self.emit("claim", text=text, grounded=grounded, confidence=confidence)
|
|
160
|
+
|
|
161
|
+
def sim(self, step: int, arm: str, metric: str, value: float) -> None:
|
|
162
|
+
"""Emit a "sim" event for one A/B simulation datapoint."""
|
|
163
|
+
self.emit("sim", step=step, arm=arm, metric=metric, value=value)
|
|
@@ -0,0 +1,65 @@
|
|
|
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
|
+
results = obj.get("results") or []
|
|
35
|
+
if results:
|
|
36
|
+
cockpit.ensure_panel()
|
|
37
|
+
provider = obj.get("provider", "web")
|
|
38
|
+
for r in results[:8]:
|
|
39
|
+
url = r.get("url")
|
|
40
|
+
if not url:
|
|
41
|
+
continue
|
|
42
|
+
counters["ref"] += 1
|
|
43
|
+
cockpit.source(url=url, title=r.get("title", ""), provider=provider)
|
|
44
|
+
cockpit.citation(ref=counters["ref"], url=url, title=r.get("title", ""))
|
|
45
|
+
answer = obj.get("answer")
|
|
46
|
+
if answer:
|
|
47
|
+
cockpit.claim(text=str(answer)[:240], grounded=True, confidence=0.7)
|
|
48
|
+
elif name == "web_fetch":
|
|
49
|
+
url = obj.get("url") or (args or {}).get("url", "")
|
|
50
|
+
if url:
|
|
51
|
+
cockpit.ensure_panel()
|
|
52
|
+
cockpit.source(url=url, title=obj.get("title", ""), provider="fetch")
|
|
53
|
+
if counters["blocks"] < _MAX_WEB_BLOCKS and cockpit.open_web(url):
|
|
54
|
+
counters["blocks"] += 1
|
|
55
|
+
elif name == "mcp_search":
|
|
56
|
+
servers = obj.get("servers") or []
|
|
57
|
+
if servers:
|
|
58
|
+
cockpit.ensure_panel()
|
|
59
|
+
for s in servers[:5]:
|
|
60
|
+
cockpit.source(url="", title=s.get("name", ""), provider="mcp")
|
|
61
|
+
cockpit.status("tool", name)
|
|
62
|
+
except Exception: # noqa: BLE001 - observation must never break the loop
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
return observe
|