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.
Files changed (83) hide show
  1. {deepparallel-0.5.3 → deepparallel-0.5.5}/PKG-INFO +1 -1
  2. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/__init__.py +1 -1
  3. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/agent.py +6 -0
  4. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/cli.py +85 -1
  5. deepparallel-0.5.5/deepparallel/cockpit.py +163 -0
  6. deepparallel-0.5.5/deepparallel/cockpit_observe.py +65 -0
  7. deepparallel-0.5.5/deepparallel/cockpit_panel.py +320 -0
  8. deepparallel-0.5.5/deepparallel/cockpit_sim.py +258 -0
  9. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/PKG-INFO +1 -1
  10. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/SOURCES.txt +7 -0
  11. {deepparallel-0.5.3 → deepparallel-0.5.5}/pyproject.toml +1 -1
  12. deepparallel-0.5.5/tests/test_cockpit.py +282 -0
  13. deepparallel-0.5.5/tests/test_cockpit_panel.py +147 -0
  14. deepparallel-0.5.5/tests/test_cockpit_sim.py +158 -0
  15. {deepparallel-0.5.3 → deepparallel-0.5.5}/README.md +0 -0
  16. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/backend.py +0 -0
  17. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/branding.py +0 -0
  18. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/config.py +0 -0
  19. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/crowe_id.py +0 -0
  20. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/dsml.py +0 -0
  21. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/fusion.py +0 -0
  22. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/licensing.py +0 -0
  23. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/registry.json +0 -0
  24. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/renderer.py +0 -0
  25. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/research/__init__.py +0 -0
  26. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/research/conduit.py +0 -0
  27. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/research/provider.py +0 -0
  28. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/routing.example.json +0 -0
  29. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/routing.py +0 -0
  30. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/serve.py +0 -0
  31. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/supply_chain.py +0 -0
  32. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/system_prompt.txt +0 -0
  33. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/__init__.py +0 -0
  34. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/codeast.py +0 -0
  35. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/edit.py +0 -0
  36. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/files.py +0 -0
  37. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/mcp.py +0 -0
  38. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/registry.py +0 -0
  39. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/sandbox.py +0 -0
  40. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/search.py +0 -0
  41. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/shell.py +0 -0
  42. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/vision.py +0 -0
  43. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/tools/web.py +0 -0
  44. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel/userinput.py +0 -0
  45. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/dependency_links.txt +0 -0
  46. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/entry_points.txt +0 -0
  47. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/requires.txt +0 -0
  48. {deepparallel-0.5.3 → deepparallel-0.5.5}/deepparallel.egg-info/top_level.txt +0 -0
  49. {deepparallel-0.5.3 → deepparallel-0.5.5}/setup.cfg +0 -0
  50. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_agent.py +0 -0
  51. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_backend.py +0 -0
  52. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_backend_chat.py +0 -0
  53. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_backend_stream.py +0 -0
  54. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_branding.py +0 -0
  55. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_cli.py +0 -0
  56. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_config.py +0 -0
  57. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_crowe_backend.py +0 -0
  58. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_crowe_gateway_backend.py +0 -0
  59. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_crowe_id_auth.py +0 -0
  60. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_crowe_payment_required.py +0 -0
  61. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_dsml.py +0 -0
  62. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_fusion.py +0 -0
  63. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_issuer_signer.py +0 -0
  64. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_licensing.py +0 -0
  65. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_renderer.py +0 -0
  66. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_research.py +0 -0
  67. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_research_provider.py +0 -0
  68. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_routing.py +0 -0
  69. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_serve.py +0 -0
  70. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_spinner_color.py +0 -0
  71. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_supply_chain.py +0 -0
  72. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tool_registry.py +0 -0
  73. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_codeast.py +0 -0
  74. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_edit.py +0 -0
  75. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_files.py +0 -0
  76. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_mcp.py +0 -0
  77. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_sandbox.py +0 -0
  78. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_search.py +0 -0
  79. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_shell.py +0 -0
  80. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_vision.py +0 -0
  81. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_tools_web.py +0 -0
  82. {deepparallel-0.5.3 → deepparallel-0.5.5}/tests/test_userinput.py +0 -0
  83. {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
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
@@ -1,3 +1,3 @@
1
1
  """DeepParallel CLI package."""
2
2
 
3
- __version__ = "0.5.3"
3
+ __version__ = "0.5.5"
@@ -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