holo-desktop-cli 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. holo_desktop/__init__.py +6 -0
  2. holo_desktop/__main__.py +6 -0
  3. holo_desktop/agent_client/__init__.py +20 -0
  4. holo_desktop/agent_client/client.py +155 -0
  5. holo_desktop/agent_client/desktop_lock.py +69 -0
  6. holo_desktop/agent_client/event_timings.py +251 -0
  7. holo_desktop/agent_client/events.py +192 -0
  8. holo_desktop/agent_client/launcher.py +491 -0
  9. holo_desktop/agent_client/model_gateway.py +34 -0
  10. holo_desktop/agent_client/requests.py +40 -0
  11. holo_desktop/agent_client/runtime_install.py +254 -0
  12. holo_desktop/agent_client/session_runner.py +323 -0
  13. holo_desktop/cli/__init__.py +62 -0
  14. holo_desktop/cli/acp.py +293 -0
  15. holo_desktop/cli/agent_api.py +57 -0
  16. holo_desktop/cli/bootstrap.py +136 -0
  17. holo_desktop/cli/doctor.py +154 -0
  18. holo_desktop/cli/guard.py +43 -0
  19. holo_desktop/cli/hosts.py +324 -0
  20. holo_desktop/cli/install.py +114 -0
  21. holo_desktop/cli/login.py +294 -0
  22. holo_desktop/cli/login_pages.py +162 -0
  23. holo_desktop/cli/mcp.py +110 -0
  24. holo_desktop/cli/profile.py +49 -0
  25. holo_desktop/cli/run.py +302 -0
  26. holo_desktop/cli/serve.py +489 -0
  27. holo_desktop/cli/stop.py +41 -0
  28. holo_desktop/cli/whoami.py +41 -0
  29. holo_desktop/customization.py +243 -0
  30. holo_desktop/fs.py +28 -0
  31. holo_desktop/host_integrations/__init__.py +1 -0
  32. holo_desktop/host_integrations/nemoclaw/__init__.py +1 -0
  33. holo_desktop/host_integrations/nemoclaw/bridge_server.py +573 -0
  34. holo_desktop/host_integrations/nemoclaw/constants.py +14 -0
  35. holo_desktop/host_integrations/nemoclaw/holo_mcp_bridge.mjs +143 -0
  36. holo_desktop/host_integrations/nemoclaw/install.py +395 -0
  37. holo_desktop/host_skills/__init__.py +0 -0
  38. holo_desktop/host_skills/holo-desktop/SKILL.md +56 -0
  39. holo_desktop/killswitch/__init__.py +34 -0
  40. holo_desktop/killswitch/autostart.py +178 -0
  41. holo_desktop/killswitch/channel.py +51 -0
  42. holo_desktop/killswitch/gesture.py +65 -0
  43. holo_desktop/killswitch/listener.py +124 -0
  44. holo_desktop/killswitch/macos_tap.py +157 -0
  45. holo_desktop/py.typed +0 -0
  46. holo_desktop/settings.py +169 -0
  47. holo_desktop/skills/__init__.py +0 -0
  48. holo_desktop/skills/macos/apple-calendar/SKILL.md +47 -0
  49. holo_desktop/skills/macos/apple-contacts/SKILL.md +53 -0
  50. holo_desktop/skills/macos/apple-mail/SKILL.md +37 -0
  51. holo_desktop/skills/macos/apple-messages/SKILL.md +39 -0
  52. holo_desktop/skills/macos/apple-notes/SKILL.md +51 -0
  53. holo_desktop/skills/macos/apple-reminders/SKILL.md +49 -0
  54. holo_desktop/skills/macos/apple-system-settings/SKILL.md +61 -0
  55. holo_desktop/skills/macos/chrome/SKILL.md +52 -0
  56. holo_desktop/skills/macos/discord/SKILL.md +58 -0
  57. holo_desktop/skills/macos/finder/SKILL.md +52 -0
  58. holo_desktop/skills/macos/notion/SKILL.md +60 -0
  59. holo_desktop/skills/macos/safari/SKILL.md +42 -0
  60. holo_desktop/skills/macos/slack/SKILL.md +64 -0
  61. holo_desktop/skills/macos/spotify/SKILL.md +56 -0
  62. holo_desktop/skills/windows/chrome/SKILL.md +51 -0
  63. holo_desktop/skills/windows/discord/SKILL.md +57 -0
  64. holo_desktop/skills/windows/edge/SKILL.md +47 -0
  65. holo_desktop/skills/windows/file-explorer/SKILL.md +55 -0
  66. holo_desktop/skills/windows/notion/SKILL.md +59 -0
  67. holo_desktop/skills/windows/outlook/SKILL.md +59 -0
  68. holo_desktop/skills/windows/slack/SKILL.md +63 -0
  69. holo_desktop/skills/windows/spotify/SKILL.md +55 -0
  70. holo_desktop/skills/windows/windows-settings/SKILL.md +68 -0
  71. holo_desktop/terminal/__init__.py +5 -0
  72. holo_desktop/terminal/feed.py +104 -0
  73. holo_desktop_cli-0.0.1.dist-info/METADATA +443 -0
  74. holo_desktop_cli-0.0.1.dist-info/RECORD +77 -0
  75. holo_desktop_cli-0.0.1.dist-info/WHEEL +4 -0
  76. holo_desktop_cli-0.0.1.dist-info/entry_points.txt +2 -0
  77. holo_desktop_cli-0.0.1.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,6 @@
1
+ """HoloDesktop CLI: thin client to the hai-agent-runtime desktop agent, powered by H Company's Holo3 VLM."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("holo-desktop-cli")
6
+ __all__ = ["__version__"]
@@ -0,0 +1,6 @@
1
+ """Make `python -m holo_desktop ...` work; real entry point is `holo_desktop.cli.main`."""
2
+
3
+ from holo_desktop.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,20 @@
1
+ """Thin client to the hai-agent-runtime binary."""
2
+
3
+ from holo_desktop.agent_client.client import AgentApiClient, SessionStream
4
+ from holo_desktop.agent_client.launcher import (
5
+ AgentDaemon,
6
+ SpawnConfig,
7
+ ensure_running,
8
+ runtime_log_path,
9
+ runtime_log_tail,
10
+ )
11
+
12
+ __all__ = [
13
+ "AgentApiClient",
14
+ "AgentDaemon",
15
+ "SessionStream",
16
+ "SpawnConfig",
17
+ "ensure_running",
18
+ "runtime_log_path",
19
+ "runtime_log_tail",
20
+ ]
@@ -0,0 +1,155 @@
1
+ """Async HTTP client for the hai-agent-runtime agent-API surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import AsyncIterator
7
+ from types import TracebackType
8
+
9
+ import httpx
10
+ from agent_interface.definition import UserMessageEvent
11
+ from agent_interface.specs.session import SessionRequest, SessionStatus
12
+ from agp_types import TrajectoryChanges, TrajectoryEvent, TrajectoryStatus
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ API_PREFIX = "/api/v2"
17
+ # Long-poll window per request; modest to stay under the server's cap and keep Ctrl+C responsive.
18
+ POLL_WAIT_S = 10
19
+
20
+ _KNOWN_STATUSES = frozenset(s.value for s in TrajectoryStatus)
21
+
22
+
23
+ def _coerce_status(payload: object) -> object:
24
+ if isinstance(payload, dict):
25
+ status = payload.get("status")
26
+ if isinstance(status, str) and status not in _KNOWN_STATUSES:
27
+ # End the turn instead of mapping to a non-terminal status: an unknown *terminal* status
28
+ # would otherwise never trip end-of-turn and the stream would poll until external limits hit.
29
+ logger.warning("unrecognized session status %r from runtime; ending turn as failed", status)
30
+ patched = {**payload, "status": TrajectoryStatus.FAILED.value}
31
+ if not patched.get("error"):
32
+ patched["error"] = f"runtime reported unrecognized session status {status!r}"
33
+ return patched
34
+ return payload
35
+
36
+
37
+ class AgentApiClient:
38
+ """Authenticated async client bound to one agent-API base URL."""
39
+
40
+ def __init__(self, base_url: str, token: str, *, timeout: float = 60.0) -> None:
41
+ self._http = httpx.AsyncClient(
42
+ base_url=f"{base_url}{API_PREFIX}",
43
+ headers={"Authorization": f"Bearer {token}"},
44
+ timeout=httpx.Timeout(timeout, connect=5.0),
45
+ )
46
+
47
+ async def __aenter__(self) -> AgentApiClient:
48
+ return self
49
+
50
+ async def __aexit__(
51
+ self,
52
+ exc_type: type[BaseException] | None,
53
+ exc: BaseException | None,
54
+ tb: TracebackType | None,
55
+ ) -> None:
56
+ await self.aclose()
57
+
58
+ async def aclose(self) -> None:
59
+ await self._http.aclose()
60
+
61
+ async def create_session(self, request: SessionRequest) -> str:
62
+ """Create a session and return its id."""
63
+ resp = await self._http.post("/sessions", json=request.model_dump(mode="json", exclude_none=True))
64
+ resp.raise_for_status()
65
+ return str(resp.json()["id"])
66
+
67
+ async def get_changes(
68
+ self, session_id: str, from_index: int, *, wait_for_seconds: int, include_events: bool
69
+ ) -> TrajectoryChanges | None:
70
+ """One long-poll for changes since ``from_index``; ``None`` when nothing arrived (204)."""
71
+ resp = await self._http.get(
72
+ f"/sessions/{session_id}/changes",
73
+ params={"from_index": from_index, "wait_for_seconds": wait_for_seconds, "include_events": include_events},
74
+ )
75
+ if resp.status_code == 204:
76
+ return None
77
+ resp.raise_for_status()
78
+ return TrajectoryChanges.model_validate(_coerce_status(resp.json()))
79
+
80
+ async def get_status(self, session_id: str) -> SessionStatus:
81
+ """Live session status; authoritative for terminal detection (``/changes`` 204s past the tail)."""
82
+ resp = await self._http.get(f"/sessions/{session_id}/status")
83
+ resp.raise_for_status()
84
+ return SessionStatus.model_validate(_coerce_status(resp.json()))
85
+
86
+ async def send_message(self, session_id: str, text: str) -> None:
87
+ # Body is a server-side tagged union, so the typed model's "type" discriminator is required.
88
+ body = UserMessageEvent(message=text).model_dump(mode="json")
89
+ resp = await self._http.post(f"/sessions/{session_id}/messages", json=body)
90
+ resp.raise_for_status()
91
+
92
+ async def pause(self, session_id: str) -> None:
93
+ """Freeze the agent after its current step; pairs with cancel for a responsive stop."""
94
+ resp = await self._http.post(f"/sessions/{session_id}/pause")
95
+ resp.raise_for_status()
96
+
97
+ async def cancel(self, session_id: str) -> None:
98
+ resp = await self._http.delete(f"/sessions/{session_id}")
99
+ if resp.status_code not in (200, 204):
100
+ resp.raise_for_status()
101
+
102
+ def stream(self, session_id: str, *, from_index: int = 0) -> SessionStream:
103
+ return SessionStream(self, session_id, from_index=from_index)
104
+
105
+
106
+ def _is_end_of_turn(status: TrajectoryStatus) -> bool:
107
+ """Terminal, or IDLE (interactive session answered and awaits the next task)."""
108
+ return status.is_terminal or status == TrajectoryStatus.IDLE
109
+
110
+
111
+ class SessionStream:
112
+ """Tails a session's events to end-of-turn, accumulating the final projection."""
113
+
114
+ def __init__(self, client: AgentApiClient, session_id: str, *, from_index: int = 0) -> None:
115
+ self._client = client
116
+ self._session_id = session_id
117
+ self.next_index = from_index
118
+ self.answer: str | dict[str, object] | None = None
119
+ self.status: TrajectoryStatus | None = None
120
+ self.error: str | None = None
121
+
122
+ async def events(self) -> AsyncIterator[TrajectoryEvent]:
123
+ """Yield each new ``TrajectoryEvent`` until the session reaches end-of-turn."""
124
+ while True:
125
+ changes = await self._client.get_changes(
126
+ self._session_id, self.next_index, wait_for_seconds=POLL_WAIT_S, include_events=True
127
+ )
128
+ if changes is not None:
129
+ self.next_index += len(changes.new_events)
130
+ if changes.answer is not None:
131
+ self.answer = changes.answer
132
+ # status + error are one coherent snapshot: track the latest so a recovered
133
+ # session does not finish carrying a stale failure from an earlier batch.
134
+ self.status = changes.status
135
+ self.error = changes.error
136
+ for event in changes.new_events:
137
+ yield event
138
+ if _is_end_of_turn(changes.status):
139
+ return
140
+ continue
141
+ status = await self._client.get_status(self._session_id)
142
+ if _is_end_of_turn(status.status):
143
+ await self._finalize(status)
144
+ return
145
+
146
+ async def _finalize(self, status: SessionStatus) -> None:
147
+ """Adopt the authoritative status and pick up the final answer the 204 hid from us."""
148
+ self.status = status.status
149
+ self.error = status.error
150
+ if self.answer is not None:
151
+ return
152
+ final = await self._client.get_changes(self._session_id, 0, wait_for_seconds=0, include_events=False)
153
+ if final is not None:
154
+ self.answer = final.answer
155
+ self.error = self.error or final.error
@@ -0,0 +1,69 @@
1
+ """Machine-wide advisory lock: at most one desktop turn runs at a time across all holo processes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import logging
8
+ import os
9
+ import sys
10
+ from collections.abc import AsyncIterator
11
+ from pathlib import Path
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ LOCK_PATH = Path.home() / ".holo" / "desktop.lock"
16
+ # Poll cadence while another process or turn holds the lock; keeps the wait cancellable on Ctrl+C.
17
+ _POLL_S = 0.25
18
+
19
+ if sys.platform == "win32":
20
+ import msvcrt
21
+
22
+ def _try_acquire(fd: int) -> bool:
23
+ try:
24
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
25
+ return True
26
+ except OSError:
27
+ return False
28
+
29
+ def _release(fd: int) -> None:
30
+ with contextlib.suppress(OSError):
31
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
32
+ else:
33
+ import fcntl
34
+
35
+ def _try_acquire(fd: int) -> bool:
36
+ try:
37
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
38
+ return True
39
+ except OSError:
40
+ return False
41
+
42
+ def _release(fd: int) -> None:
43
+ with contextlib.suppress(OSError):
44
+ fcntl.flock(fd, fcntl.LOCK_UN)
45
+
46
+
47
+ @contextlib.asynccontextmanager
48
+ async def desktop_turn() -> AsyncIterator[None]:
49
+ """Hold the machine-wide desktop lock for one turn, polling until it is free.
50
+
51
+ Not reentrant: each ``desktop_turn`` opens its own fd and the lock contends across fds even in
52
+ one process, so a second ``desktop_turn`` entered while the first is held waits forever. One turn
53
+ driver owns the lock at a time.
54
+ """
55
+ LOCK_PATH.parent.mkdir(parents=True, exist_ok=True)
56
+ fd = os.open(LOCK_PATH, os.O_RDWR | os.O_CREAT, 0o600)
57
+ try:
58
+ waited = False
59
+ while not _try_acquire(fd):
60
+ if not waited:
61
+ logger.info("another desktop turn is in progress; waiting for the machine-wide lock")
62
+ waited = True
63
+ await asyncio.sleep(_POLL_S)
64
+ try:
65
+ yield
66
+ finally:
67
+ _release(fd)
68
+ finally:
69
+ os.close(fd)
@@ -0,0 +1,251 @@
1
+ """Derive profile timing summaries from runtime ``events.jsonl`` traces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import statistics
7
+ from collections.abc import Iterable
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timedelta
10
+ from pathlib import Path
11
+
12
+ from agent_interface.agent_events import (
13
+ AgentEventData,
14
+ ErrorEvent,
15
+ ObservationEvent,
16
+ PolicyEvent,
17
+ ToolResultEvent,
18
+ )
19
+ from pydantic import BaseModel, ConfigDict
20
+ from rich.console import Console
21
+ from rich.table import Table
22
+
23
+ from holo_desktop.agent_client.events import parse_agent_event_data
24
+
25
+ DEFAULT_RUNS_DIR = Path("~/.holo/runs").expanduser()
26
+ # Error origins that close a step as a failed tool execution.
27
+ _TOOL_ERROR_ORIGINS = frozenset({"tool", "tool_validation"})
28
+
29
+
30
+ @dataclass
31
+ class _StepTiming:
32
+ """Mutable accumulator for one step's phase durations while walking the trace."""
33
+
34
+ step_idx: int
35
+ tool_name: str | None = None
36
+ observe_s: float | None = None
37
+ llm_s: float | None = None
38
+ tool_s: float | None = None
39
+ failed: bool = False
40
+
41
+ @property
42
+ def total_s(self) -> float:
43
+ return sum(value for value in (self.observe_s, self.llm_s, self.tool_s) if value is not None)
44
+
45
+
46
+ class StepTiming(BaseModel):
47
+ """Per-step phase durations (seconds); ``None`` where a phase was not observed."""
48
+
49
+ model_config = ConfigDict(frozen=True)
50
+ step_idx: int
51
+ tool_name: str | None
52
+ observe_s: float | None
53
+ llm_s: float | None
54
+ tool_s: float | None
55
+ failed: bool
56
+
57
+
58
+ class StepTimingsSummary(BaseModel):
59
+ """Per-step timings plus phase averages across one session's trace."""
60
+
61
+ model_config = ConfigDict(frozen=True)
62
+ steps: tuple[StepTiming, ...]
63
+ avg_observe_s: float | None
64
+ avg_llm_s: float | None
65
+ avg_tool_s: float | None
66
+ avg_step_s: float | None
67
+ steps_timed: int
68
+
69
+
70
+ def find_session_event_log(runs_dir: Path | None, session_id: str) -> Path | None:
71
+ """``events.jsonl`` for ``session_id``; only falls back to the sole log when attribution is unambiguous."""
72
+ root = DEFAULT_RUNS_DIR if runs_dir is None else runs_dir.expanduser()
73
+ if not root.exists():
74
+ return None
75
+ candidates = [path for path in root.rglob("events.jsonl") if path.is_file()]
76
+ if not candidates:
77
+ return None
78
+ if session_id:
79
+ matches = [path for path in candidates if session_id in str(path)]
80
+ if matches:
81
+ return max(matches, key=lambda path: path.stat().st_mtime)
82
+ # Multiple runs and none attributable to this session: refuse to show another run's timings.
83
+ if len(candidates) > 1:
84
+ return None
85
+ return max(candidates, key=lambda path: path.stat().st_mtime)
86
+
87
+
88
+ def extract_step_timings(event_log: Path | None) -> StepTimingsSummary | None:
89
+ """Compute per-step profile timings from raw agent-event timestamp deltas."""
90
+ steps = _derive_steps(_load_records(event_log))
91
+ if not steps:
92
+ return None
93
+ return StepTimingsSummary(
94
+ steps=tuple(
95
+ StepTiming(
96
+ step_idx=step.step_idx,
97
+ tool_name=step.tool_name,
98
+ observe_s=step.observe_s,
99
+ llm_s=step.llm_s,
100
+ tool_s=step.tool_s,
101
+ failed=step.failed,
102
+ )
103
+ for step in steps
104
+ ),
105
+ avg_observe_s=_mean(step.observe_s for step in steps),
106
+ avg_llm_s=_mean(step.llm_s for step in steps),
107
+ avg_tool_s=_mean(step.tool_s for step in steps),
108
+ avg_step_s=_mean(step.total_s for step in steps),
109
+ steps_timed=len(steps),
110
+ )
111
+
112
+
113
+ def render_step_timings(summary: StepTimingsSummary, console: Console) -> None:
114
+ """Render a timing summary as a per-step phase table plus averages."""
115
+ table = Table(title="step timings (s)", title_justify="left", header_style="bold")
116
+ for column in ("step", "tool", "observe", "llm", "tool exec", "total"):
117
+ table.add_column(column, justify="right" if column != "tool" else "left")
118
+ for step in summary.steps:
119
+ total = sum(value or 0.0 for value in (step.observe_s, step.llm_s, step.tool_s))
120
+ table.add_row(
121
+ str(step.step_idx),
122
+ step.tool_name or "--",
123
+ _fmt_s(step.observe_s),
124
+ _fmt_s(step.llm_s),
125
+ _fmt_s(step.tool_s),
126
+ f"{total:.2f}",
127
+ )
128
+ table.add_section()
129
+ table.add_row(
130
+ "avg",
131
+ "",
132
+ _fmt_s(summary.avg_observe_s),
133
+ _fmt_s(summary.avg_llm_s),
134
+ _fmt_s(summary.avg_tool_s),
135
+ _fmt_s(summary.avg_step_s),
136
+ style="bold",
137
+ )
138
+ console.print(table)
139
+
140
+
141
+ def _load_records(event_log: Path | None) -> list[tuple[datetime, AgentEventData]]:
142
+ if event_log is None or not event_log.exists():
143
+ return []
144
+ records: list[tuple[datetime, AgentEventData]] = []
145
+ for line in event_log.read_text(encoding="utf-8").splitlines():
146
+ try:
147
+ record = json.loads(line)
148
+ except json.JSONDecodeError:
149
+ continue
150
+ if not isinstance(record, dict):
151
+ continue
152
+ raw_event = record.get("event")
153
+ if not isinstance(raw_event, dict):
154
+ continue
155
+ ts = _parse_ts(record.get("ts"))
156
+ if ts is None:
157
+ continue
158
+ parsed = parse_agent_event_data(raw_event)
159
+ if parsed is None:
160
+ continue
161
+ records.append((ts, parsed))
162
+ return records
163
+
164
+
165
+ def _derive_steps(records: list[tuple[datetime, AgentEventData]]) -> list[_StepTiming]:
166
+ steps: list[_StepTiming] = []
167
+ first_event_at: datetime | None = None
168
+ prev_step_end_at: datetime | None = None
169
+ observe_at: datetime | None = None
170
+ llm_at: datetime | None = None
171
+ pending: _StepTiming | None = None
172
+
173
+ for ts, event in records:
174
+ if first_event_at is None:
175
+ first_event_at = ts
176
+ match event:
177
+ case ObservationEvent():
178
+ if pending is not None:
179
+ steps.append(pending)
180
+ baseline = prev_step_end_at or first_event_at or ts
181
+ pending = _StepTiming(step_idx=len(steps) + 1, observe_s=_seconds(ts - baseline))
182
+ observe_at = ts
183
+ llm_at = None
184
+ case PolicyEvent():
185
+ if pending is None:
186
+ pending = _StepTiming(step_idx=len(steps) + 1, observe_s=None)
187
+ observe_at = None
188
+ llm_at = None
189
+ boundary = observe_at or prev_step_end_at or first_event_at
190
+ if boundary is not None:
191
+ pending.llm_s = _seconds(ts - boundary)
192
+ pending.tool_name = _first_tool_name(event)
193
+ llm_at = ts
194
+ case ToolResultEvent() if pending is not None:
195
+ _close_step(steps, pending, ts=ts, llm_at=llm_at, failed=False)
196
+ pending = None
197
+ prev_step_end_at = ts
198
+ observe_at = None
199
+ llm_at = None
200
+ case ErrorEvent() if pending is not None and event.origin in _TOOL_ERROR_ORIGINS:
201
+ _close_step(steps, pending, ts=ts, llm_at=llm_at, failed=True)
202
+ pending = None
203
+ prev_step_end_at = ts
204
+ observe_at = None
205
+ llm_at = None
206
+ if pending is not None:
207
+ steps.append(pending)
208
+ return steps
209
+
210
+
211
+ def _close_step(
212
+ steps: list[_StepTiming],
213
+ pending: _StepTiming,
214
+ *,
215
+ ts: datetime,
216
+ llm_at: datetime | None,
217
+ failed: bool,
218
+ ) -> None:
219
+ if llm_at is not None:
220
+ pending.tool_s = _seconds(ts - llm_at)
221
+ pending.failed = failed
222
+ steps.append(pending)
223
+
224
+
225
+ def _first_tool_name(event: PolicyEvent) -> str | None:
226
+ return event.tool_reqs[0].tool_name if event.tool_reqs else None
227
+
228
+
229
+ def _parse_ts(raw: object) -> datetime | None:
230
+ if not isinstance(raw, str):
231
+ return None
232
+ try:
233
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
234
+ except ValueError:
235
+ return None
236
+
237
+
238
+ def _mean(values: Iterable[float | None]) -> float | None:
239
+ present = [value for value in values if value is not None]
240
+ if not present:
241
+ return None
242
+ return round(statistics.fmean(present), 3)
243
+
244
+
245
+ def _seconds(delta: timedelta) -> float:
246
+ # Clamp: out-of-order or clock-skewed timestamps must not yield a negative phase duration.
247
+ return round(max(0.0, delta.total_seconds()), 3)
248
+
249
+
250
+ def _fmt_s(value: float | None) -> str:
251
+ return f"{value:.2f}" if value is not None else "--"