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.
- holo_desktop/__init__.py +6 -0
- holo_desktop/__main__.py +6 -0
- holo_desktop/agent_client/__init__.py +20 -0
- holo_desktop/agent_client/client.py +155 -0
- holo_desktop/agent_client/desktop_lock.py +69 -0
- holo_desktop/agent_client/event_timings.py +251 -0
- holo_desktop/agent_client/events.py +192 -0
- holo_desktop/agent_client/launcher.py +491 -0
- holo_desktop/agent_client/model_gateway.py +34 -0
- holo_desktop/agent_client/requests.py +40 -0
- holo_desktop/agent_client/runtime_install.py +254 -0
- holo_desktop/agent_client/session_runner.py +323 -0
- holo_desktop/cli/__init__.py +62 -0
- holo_desktop/cli/acp.py +293 -0
- holo_desktop/cli/agent_api.py +57 -0
- holo_desktop/cli/bootstrap.py +136 -0
- holo_desktop/cli/doctor.py +154 -0
- holo_desktop/cli/guard.py +43 -0
- holo_desktop/cli/hosts.py +324 -0
- holo_desktop/cli/install.py +114 -0
- holo_desktop/cli/login.py +294 -0
- holo_desktop/cli/login_pages.py +162 -0
- holo_desktop/cli/mcp.py +110 -0
- holo_desktop/cli/profile.py +49 -0
- holo_desktop/cli/run.py +302 -0
- holo_desktop/cli/serve.py +489 -0
- holo_desktop/cli/stop.py +41 -0
- holo_desktop/cli/whoami.py +41 -0
- holo_desktop/customization.py +243 -0
- holo_desktop/fs.py +28 -0
- holo_desktop/host_integrations/__init__.py +1 -0
- holo_desktop/host_integrations/nemoclaw/__init__.py +1 -0
- holo_desktop/host_integrations/nemoclaw/bridge_server.py +573 -0
- holo_desktop/host_integrations/nemoclaw/constants.py +14 -0
- holo_desktop/host_integrations/nemoclaw/holo_mcp_bridge.mjs +143 -0
- holo_desktop/host_integrations/nemoclaw/install.py +395 -0
- holo_desktop/host_skills/__init__.py +0 -0
- holo_desktop/host_skills/holo-desktop/SKILL.md +56 -0
- holo_desktop/killswitch/__init__.py +34 -0
- holo_desktop/killswitch/autostart.py +178 -0
- holo_desktop/killswitch/channel.py +51 -0
- holo_desktop/killswitch/gesture.py +65 -0
- holo_desktop/killswitch/listener.py +124 -0
- holo_desktop/killswitch/macos_tap.py +157 -0
- holo_desktop/py.typed +0 -0
- holo_desktop/settings.py +169 -0
- holo_desktop/skills/__init__.py +0 -0
- holo_desktop/skills/macos/apple-calendar/SKILL.md +47 -0
- holo_desktop/skills/macos/apple-contacts/SKILL.md +53 -0
- holo_desktop/skills/macos/apple-mail/SKILL.md +37 -0
- holo_desktop/skills/macos/apple-messages/SKILL.md +39 -0
- holo_desktop/skills/macos/apple-notes/SKILL.md +51 -0
- holo_desktop/skills/macos/apple-reminders/SKILL.md +49 -0
- holo_desktop/skills/macos/apple-system-settings/SKILL.md +61 -0
- holo_desktop/skills/macos/chrome/SKILL.md +52 -0
- holo_desktop/skills/macos/discord/SKILL.md +58 -0
- holo_desktop/skills/macos/finder/SKILL.md +52 -0
- holo_desktop/skills/macos/notion/SKILL.md +60 -0
- holo_desktop/skills/macos/safari/SKILL.md +42 -0
- holo_desktop/skills/macos/slack/SKILL.md +64 -0
- holo_desktop/skills/macos/spotify/SKILL.md +56 -0
- holo_desktop/skills/windows/chrome/SKILL.md +51 -0
- holo_desktop/skills/windows/discord/SKILL.md +57 -0
- holo_desktop/skills/windows/edge/SKILL.md +47 -0
- holo_desktop/skills/windows/file-explorer/SKILL.md +55 -0
- holo_desktop/skills/windows/notion/SKILL.md +59 -0
- holo_desktop/skills/windows/outlook/SKILL.md +59 -0
- holo_desktop/skills/windows/slack/SKILL.md +63 -0
- holo_desktop/skills/windows/spotify/SKILL.md +55 -0
- holo_desktop/skills/windows/windows-settings/SKILL.md +68 -0
- holo_desktop/terminal/__init__.py +5 -0
- holo_desktop/terminal/feed.py +104 -0
- holo_desktop_cli-0.0.1.dist-info/METADATA +443 -0
- holo_desktop_cli-0.0.1.dist-info/RECORD +77 -0
- holo_desktop_cli-0.0.1.dist-info/WHEEL +4 -0
- holo_desktop_cli-0.0.1.dist-info/entry_points.txt +2 -0
- holo_desktop_cli-0.0.1.dist-info/licenses/LICENSE +201 -0
holo_desktop/__init__.py
ADDED
holo_desktop/__main__.py
ADDED
|
@@ -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 "--"
|