bareagent-cli 0.1.0__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.
- bareagent/__init__.py +10 -0
- bareagent/concurrency/__init__.py +6 -0
- bareagent/concurrency/background.py +97 -0
- bareagent/concurrency/notification.py +61 -0
- bareagent/concurrency/scheduler.py +136 -0
- bareagent/config.toml +299 -0
- bareagent/core/__init__.py +1 -0
- bareagent/core/config_paths.py +49 -0
- bareagent/core/context.py +127 -0
- bareagent/core/fileutil.py +103 -0
- bareagent/core/goal.py +214 -0
- bareagent/core/handlers/__init__.py +1 -0
- bareagent/core/handlers/bash.py +79 -0
- bareagent/core/handlers/file_edit.py +47 -0
- bareagent/core/handlers/file_read.py +270 -0
- bareagent/core/handlers/file_write.py +34 -0
- bareagent/core/handlers/glob_search.py +30 -0
- bareagent/core/handlers/goal.py +60 -0
- bareagent/core/handlers/grep_search.py +52 -0
- bareagent/core/handlers/memory.py +71 -0
- bareagent/core/handlers/plan.py +106 -0
- bareagent/core/handlers/search_utils.py +77 -0
- bareagent/core/handlers/skill.py +87 -0
- bareagent/core/handlers/subagent_send.py +70 -0
- bareagent/core/handlers/web_fetch.py +126 -0
- bareagent/core/handlers/web_search.py +165 -0
- bareagent/core/handlers/workflow.py +190 -0
- bareagent/core/loop.py +535 -0
- bareagent/core/retry.py +131 -0
- bareagent/core/sandbox.py +27 -0
- bareagent/core/schema.py +21 -0
- bareagent/core/tools.py +779 -0
- bareagent/core/workflow.py +517 -0
- bareagent/core/workflow_registry.py +219 -0
- bareagent/debug/__init__.py +0 -0
- bareagent/debug/interaction_log.py +263 -0
- bareagent/debug/viewer.html +1750 -0
- bareagent/debug/web_viewer.py +157 -0
- bareagent/hooks/__init__.py +32 -0
- bareagent/hooks/config.py +118 -0
- bareagent/hooks/engine.py +197 -0
- bareagent/hooks/errors.py +14 -0
- bareagent/hooks/events.py +22 -0
- bareagent/lsp/__init__.py +63 -0
- bareagent/lsp/config.py +134 -0
- bareagent/lsp/coord.py +118 -0
- bareagent/lsp/diagnostics.py +240 -0
- bareagent/lsp/errors.py +24 -0
- bareagent/lsp/manager.py +866 -0
- bareagent/lsp/tools.py +629 -0
- bareagent/lsp/workspace_edit.py +305 -0
- bareagent/main.py +4205 -0
- bareagent/mcp/__init__.py +69 -0
- bareagent/mcp/_sse.py +69 -0
- bareagent/mcp/client.py +341 -0
- bareagent/mcp/config.py +169 -0
- bareagent/mcp/errors.py +32 -0
- bareagent/mcp/manager.py +318 -0
- bareagent/mcp/protocol.py +187 -0
- bareagent/mcp/registry.py +557 -0
- bareagent/mcp/transport/__init__.py +15 -0
- bareagent/mcp/transport/base.py +149 -0
- bareagent/mcp/transport/http_legacy.py +192 -0
- bareagent/mcp/transport/http_streamable.py +217 -0
- bareagent/mcp/transport/stdio.py +202 -0
- bareagent/memory/__init__.py +1 -0
- bareagent/memory/compact.py +203 -0
- bareagent/memory/conversation_io.py +226 -0
- bareagent/memory/embedding.py +194 -0
- bareagent/memory/persistent.py +515 -0
- bareagent/memory/token_counter.py +67 -0
- bareagent/memory/token_tracker.py +262 -0
- bareagent/memory/transcript.py +100 -0
- bareagent/permission/__init__.py +1 -0
- bareagent/permission/guard.py +329 -0
- bareagent/permission/rules.py +19 -0
- bareagent/planning/__init__.py +19 -0
- bareagent/planning/agent_types.py +169 -0
- bareagent/planning/skill_gen.py +141 -0
- bareagent/planning/skill_store.py +173 -0
- bareagent/planning/skills.py +146 -0
- bareagent/planning/subagent.py +355 -0
- bareagent/planning/subagent_registry.py +77 -0
- bareagent/planning/tasks.py +348 -0
- bareagent/planning/todo.py +153 -0
- bareagent/planning/worktree.py +122 -0
- bareagent/provider/__init__.py +1 -0
- bareagent/provider/anthropic.py +348 -0
- bareagent/provider/base.py +136 -0
- bareagent/provider/factory.py +130 -0
- bareagent/provider/openai.py +881 -0
- bareagent/provider/presets.py +72 -0
- bareagent/provider/setup.py +356 -0
- bareagent/skills/.gitkeep +1 -0
- bareagent/skills/code-review/SKILL.md +68 -0
- bareagent/skills/git/SKILL.md +68 -0
- bareagent/skills/test/SKILL.md +70 -0
- bareagent/team/__init__.py +17 -0
- bareagent/team/autonomous.py +193 -0
- bareagent/team/mailbox.py +239 -0
- bareagent/team/manager.py +155 -0
- bareagent/team/protocols.py +129 -0
- bareagent/tracing/__init__.py +12 -0
- bareagent/tracing/_api.py +92 -0
- bareagent/tracing/_proxy.py +60 -0
- bareagent/tracing/composite.py +115 -0
- bareagent/tracing/json_file.py +115 -0
- bareagent/tracing/langfuse.py +139 -0
- bareagent/tracing/otel.py +107 -0
- bareagent/tracing/setup.py +85 -0
- bareagent/ui/__init__.py +24 -0
- bareagent/ui/console.py +167 -0
- bareagent/ui/prompt.py +78 -0
- bareagent/ui/protocol.py +24 -0
- bareagent/ui/stream.py +66 -0
- bareagent/ui/theme.py +240 -0
- bareagent_cli-0.1.0.dist-info/METADATA +331 -0
- bareagent_cli-0.1.0.dist-info/RECORD +121 -0
- bareagent_cli-0.1.0.dist-info/WHEEL +4 -0
- bareagent_cli-0.1.0.dist-info/entry_points.txt +2 -0
- bareagent_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Session-scoped, thread-safe registry of workflow runs.
|
|
2
|
+
|
|
3
|
+
Backs two features layered on top of the deterministic ``workflow`` tool
|
|
4
|
+
(see ``src/core/workflow.py``): the ``/workflows`` panel (list / inspect runs)
|
|
5
|
+
and ``resume`` (reuse a prior run's node results). Unlike the pure
|
|
6
|
+
``src/core/workflow.py`` engine, this module is *stateful and threaded*: a
|
|
7
|
+
background workflow runs in a daemon thread and updates its run here while the
|
|
8
|
+
REPL's main thread reads snapshots for the panel, so every method holds a lock.
|
|
9
|
+
|
|
10
|
+
Lifecycle mirrors :class:`bareagent.planning.subagent_registry.SubagentRegistry` and
|
|
11
|
+
``spawned_agents``: in-memory, capped FIFO, cleared by the REPL on ``/new`` /
|
|
12
|
+
``/resume`` / ``/import`` / ``/clear`` and kept across ``/compact``. On-disk
|
|
13
|
+
persistence (cross-restart resume) is intentionally out of scope.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, replace
|
|
21
|
+
from enum import Enum
|
|
22
|
+
|
|
23
|
+
from bareagent.core.fileutil import generate_random_id
|
|
24
|
+
from bareagent.core.workflow import NodeResult, NodeStatus, WorkflowSpec
|
|
25
|
+
|
|
26
|
+
_ID_PREFIX = "wf-"
|
|
27
|
+
DEFAULT_MAX_RUNS = 50
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RunStatus(Enum):
|
|
31
|
+
RUNNING = "running"
|
|
32
|
+
DONE = "done"
|
|
33
|
+
FAILED = "failed"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(slots=True)
|
|
37
|
+
class WorkflowRun:
|
|
38
|
+
"""One workflow invocation's live (or terminal) state.
|
|
39
|
+
|
|
40
|
+
``results`` is replaced key-by-key as nodes finish (whole ``NodeResult``
|
|
41
|
+
objects swapped in, never mutated in place), so a shallow copy taken under
|
|
42
|
+
the registry lock is a consistent snapshot. ``delivered`` dedups the async
|
|
43
|
+
result feedback: a finished background run is injected into the LLM exactly
|
|
44
|
+
once (see ``main.py:_drain_workflow_results``).
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
run_id: str
|
|
48
|
+
spec: WorkflowSpec
|
|
49
|
+
results: dict[str, NodeResult]
|
|
50
|
+
background: bool
|
|
51
|
+
token_budget: int
|
|
52
|
+
status: RunStatus = RunStatus.RUNNING
|
|
53
|
+
tokens_spent: int = 0
|
|
54
|
+
summary: str = ""
|
|
55
|
+
error: str = ""
|
|
56
|
+
started_at: float = 0.0
|
|
57
|
+
finished_at: float | None = None
|
|
58
|
+
delivered: bool = False
|
|
59
|
+
|
|
60
|
+
def counts(self) -> dict[str, int]:
|
|
61
|
+
"""Tally node statuses for the panel (pending/running shown as 'running')."""
|
|
62
|
+
tally = {"done": 0, "failed": 0, "skipped": 0, "running": 0, "reused": 0}
|
|
63
|
+
for result in self.results.values():
|
|
64
|
+
if result.reused:
|
|
65
|
+
tally["reused"] += 1
|
|
66
|
+
elif result.status is NodeStatus.DONE:
|
|
67
|
+
tally["done"] += 1
|
|
68
|
+
elif result.status is NodeStatus.FAILED:
|
|
69
|
+
tally["failed"] += 1
|
|
70
|
+
elif result.status is NodeStatus.SKIPPED:
|
|
71
|
+
tally["skipped"] += 1
|
|
72
|
+
else:
|
|
73
|
+
tally["running"] += 1
|
|
74
|
+
return tally
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class WorkflowRegistry:
|
|
78
|
+
"""In-memory, thread-safe, FIFO-capped store of :class:`WorkflowRun`.
|
|
79
|
+
|
|
80
|
+
Holds at most ``max_runs`` runs; ``start`` evicts the oldest once over the
|
|
81
|
+
cap (preferring an already-finished run so an in-flight one keeps its panel
|
|
82
|
+
tracking). Updates to a run that has been evicted or cleared are silent
|
|
83
|
+
no-ops, which keeps a lingering background thread from crashing when the
|
|
84
|
+
session has already moved on.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self, max_runs: int = DEFAULT_MAX_RUNS) -> None:
|
|
88
|
+
self._max = max_runs if max_runs > 0 else DEFAULT_MAX_RUNS
|
|
89
|
+
self._lock = threading.Lock()
|
|
90
|
+
self._runs: dict[str, WorkflowRun] = {}
|
|
91
|
+
|
|
92
|
+
def generate_id(self) -> str:
|
|
93
|
+
"""Return a fresh, unused ``wf-<rand8>`` id."""
|
|
94
|
+
with self._lock:
|
|
95
|
+
while True:
|
|
96
|
+
candidate = _ID_PREFIX + generate_random_id(8)
|
|
97
|
+
if candidate not in self._runs:
|
|
98
|
+
return candidate
|
|
99
|
+
|
|
100
|
+
def start(
|
|
101
|
+
self,
|
|
102
|
+
run_id: str,
|
|
103
|
+
spec: WorkflowSpec,
|
|
104
|
+
*,
|
|
105
|
+
background: bool,
|
|
106
|
+
token_budget: int,
|
|
107
|
+
) -> WorkflowRun:
|
|
108
|
+
"""Register a new RUNNING run (all nodes PENDING) and evict over the cap."""
|
|
109
|
+
run = WorkflowRun(
|
|
110
|
+
run_id=run_id,
|
|
111
|
+
spec=spec,
|
|
112
|
+
results={
|
|
113
|
+
node.id: NodeResult(id=node.id, status=NodeStatus.PENDING) for node in spec.nodes
|
|
114
|
+
},
|
|
115
|
+
background=background,
|
|
116
|
+
token_budget=token_budget,
|
|
117
|
+
started_at=time.time(),
|
|
118
|
+
)
|
|
119
|
+
with self._lock:
|
|
120
|
+
self._runs[run_id] = run
|
|
121
|
+
self._evict_locked()
|
|
122
|
+
return run
|
|
123
|
+
|
|
124
|
+
def update_node(self, run_id: str, node_id: str, result: NodeResult) -> None:
|
|
125
|
+
"""Replace one node's result (no-op if the run is gone)."""
|
|
126
|
+
with self._lock:
|
|
127
|
+
run = self._runs.get(run_id)
|
|
128
|
+
if run is not None and node_id in run.results:
|
|
129
|
+
run.results[node_id] = result
|
|
130
|
+
|
|
131
|
+
def set_tokens(self, run_id: str, tokens_spent: int) -> None:
|
|
132
|
+
with self._lock:
|
|
133
|
+
run = self._runs.get(run_id)
|
|
134
|
+
if run is not None:
|
|
135
|
+
run.tokens_spent = tokens_spent
|
|
136
|
+
|
|
137
|
+
def finish(
|
|
138
|
+
self,
|
|
139
|
+
run_id: str,
|
|
140
|
+
*,
|
|
141
|
+
summary: str,
|
|
142
|
+
tokens_spent: int,
|
|
143
|
+
status: RunStatus = RunStatus.DONE,
|
|
144
|
+
error: str = "",
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Mark a run terminal and stage it for one-shot async delivery."""
|
|
147
|
+
with self._lock:
|
|
148
|
+
run = self._runs.get(run_id)
|
|
149
|
+
if run is None:
|
|
150
|
+
return
|
|
151
|
+
run.status = status
|
|
152
|
+
run.summary = summary
|
|
153
|
+
run.error = error
|
|
154
|
+
run.tokens_spent = tokens_spent
|
|
155
|
+
run.finished_at = time.time()
|
|
156
|
+
run.delivered = False
|
|
157
|
+
|
|
158
|
+
def get_for_resume(self, run_id: str) -> tuple[WorkflowSpec, dict[str, NodeResult]] | None:
|
|
159
|
+
"""Return ``(spec, results copy)`` of a prior run for resume, or None."""
|
|
160
|
+
with self._lock:
|
|
161
|
+
run = self._runs.get(run_id)
|
|
162
|
+
if run is None:
|
|
163
|
+
return None
|
|
164
|
+
return run.spec, dict(run.results)
|
|
165
|
+
|
|
166
|
+
def snapshot(self) -> list[WorkflowRun]:
|
|
167
|
+
"""Return consistent copies of every run, newest last (panel list)."""
|
|
168
|
+
with self._lock:
|
|
169
|
+
return [self._copy_locked(run) for run in self._runs.values()]
|
|
170
|
+
|
|
171
|
+
def get(self, run_id: str) -> WorkflowRun | None:
|
|
172
|
+
"""Return a consistent copy of one run, or None (panel detail)."""
|
|
173
|
+
with self._lock:
|
|
174
|
+
run = self._runs.get(run_id)
|
|
175
|
+
return self._copy_locked(run) if run is not None else None
|
|
176
|
+
|
|
177
|
+
def take_undelivered(self) -> list[WorkflowRun]:
|
|
178
|
+
"""Return copies of finished, not-yet-delivered runs, marking them delivered.
|
|
179
|
+
|
|
180
|
+
Used by the REPL drain to inject a finished background workflow's full
|
|
181
|
+
summary into the LLM exactly once.
|
|
182
|
+
"""
|
|
183
|
+
out: list[WorkflowRun] = []
|
|
184
|
+
with self._lock:
|
|
185
|
+
for run in self._runs.values():
|
|
186
|
+
if run.status is not RunStatus.RUNNING and not run.delivered:
|
|
187
|
+
run.delivered = True
|
|
188
|
+
out.append(self._copy_locked(run))
|
|
189
|
+
return out
|
|
190
|
+
|
|
191
|
+
def clear_finished(self) -> int:
|
|
192
|
+
"""Drop every non-RUNNING run; return how many were removed."""
|
|
193
|
+
with self._lock:
|
|
194
|
+
finished = [rid for rid, run in self._runs.items() if run.status is RunStatus.RUNNING]
|
|
195
|
+
removed = len(self._runs) - len(finished)
|
|
196
|
+
self._runs = {rid: self._runs[rid] for rid in finished}
|
|
197
|
+
return removed
|
|
198
|
+
|
|
199
|
+
def clear(self) -> None:
|
|
200
|
+
with self._lock:
|
|
201
|
+
self._runs.clear()
|
|
202
|
+
|
|
203
|
+
def __len__(self) -> int:
|
|
204
|
+
with self._lock:
|
|
205
|
+
return len(self._runs)
|
|
206
|
+
|
|
207
|
+
def _evict_locked(self) -> None:
|
|
208
|
+
"""Trim to the cap, evicting oldest finished runs first (lock held)."""
|
|
209
|
+
while len(self._runs) > self._max:
|
|
210
|
+
victim = next(
|
|
211
|
+
(rid for rid, run in self._runs.items() if run.status is not RunStatus.RUNNING),
|
|
212
|
+
next(iter(self._runs)),
|
|
213
|
+
)
|
|
214
|
+
del self._runs[victim]
|
|
215
|
+
|
|
216
|
+
@staticmethod
|
|
217
|
+
def _copy_locked(run: WorkflowRun) -> WorkflowRun:
|
|
218
|
+
"""Snapshot a run with a copied results dict (lock held by caller)."""
|
|
219
|
+
return replace(run, results=dict(run.results))
|
|
File without changes
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import queue
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
_DEFAULT_EVENT_QUEUE_SIZE = 256
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class InteractionLogger:
|
|
14
|
+
"""Persist complete LLM request/response payloads for a session."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
log_dir: str | Path = ".logs",
|
|
19
|
+
session_id: str = "default",
|
|
20
|
+
*,
|
|
21
|
+
pretty: bool = True,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._log_dir = Path(log_dir)
|
|
24
|
+
self._session_id = self._validate_session_id(session_id)
|
|
25
|
+
self._pretty = pretty
|
|
26
|
+
self._seq = 0
|
|
27
|
+
self._session_dir: Path | None = None
|
|
28
|
+
self._event_lock = threading.Lock()
|
|
29
|
+
self._event_subscribers: set[queue.Queue[dict[str, Any]]] = set()
|
|
30
|
+
self._legacy_event_queue: queue.Queue[dict[str, Any]] | None = None
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def event_queue(self) -> queue.Queue[dict[str, Any]]:
|
|
34
|
+
if self._legacy_event_queue is None:
|
|
35
|
+
self._legacy_event_queue = self.subscribe_events()
|
|
36
|
+
return self._legacy_event_queue
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def session_id(self) -> str:
|
|
40
|
+
return self._session_id
|
|
41
|
+
|
|
42
|
+
@session_id.setter
|
|
43
|
+
def session_id(self, value: str) -> None:
|
|
44
|
+
self._session_id = self._validate_session_id(value)
|
|
45
|
+
self._session_dir = None
|
|
46
|
+
self._seq = 0
|
|
47
|
+
|
|
48
|
+
def log_request(
|
|
49
|
+
self,
|
|
50
|
+
messages: list[dict[str, Any]],
|
|
51
|
+
tools: list[dict[str, Any]],
|
|
52
|
+
*,
|
|
53
|
+
provider_info: dict[str, Any] | None = None,
|
|
54
|
+
) -> int:
|
|
55
|
+
self._ensure_session_dir()
|
|
56
|
+
seq = self._seq
|
|
57
|
+
payload = {
|
|
58
|
+
"seq": seq,
|
|
59
|
+
"type": "request",
|
|
60
|
+
"timestamp": time.time(),
|
|
61
|
+
"provider": provider_info or {},
|
|
62
|
+
"messages": messages,
|
|
63
|
+
"tools": tools,
|
|
64
|
+
"message_count": len(messages),
|
|
65
|
+
"tool_count": len(tools),
|
|
66
|
+
}
|
|
67
|
+
self._write(f"{seq:03d}_request.json", payload)
|
|
68
|
+
self._seq = seq + 1
|
|
69
|
+
self._push_event("request", seq, payload)
|
|
70
|
+
return seq
|
|
71
|
+
|
|
72
|
+
def log_response(
|
|
73
|
+
self,
|
|
74
|
+
seq: int,
|
|
75
|
+
*,
|
|
76
|
+
text: str = "",
|
|
77
|
+
thinking: str = "",
|
|
78
|
+
tool_calls: list[dict[str, Any]] | None = None,
|
|
79
|
+
input_tokens: int = 0,
|
|
80
|
+
output_tokens: int = 0,
|
|
81
|
+
duration_ms: float = 0,
|
|
82
|
+
error: str | None = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
payload: dict[str, Any] = {
|
|
85
|
+
"seq": seq,
|
|
86
|
+
"type": "response",
|
|
87
|
+
"timestamp": time.time(),
|
|
88
|
+
"text": text,
|
|
89
|
+
"thinking": thinking,
|
|
90
|
+
"tool_calls": list(tool_calls or []),
|
|
91
|
+
"input_tokens": input_tokens,
|
|
92
|
+
"output_tokens": output_tokens,
|
|
93
|
+
"duration_ms": round(duration_ms, 2),
|
|
94
|
+
}
|
|
95
|
+
if error is not None:
|
|
96
|
+
payload["error"] = error
|
|
97
|
+
try:
|
|
98
|
+
self._write(f"{seq:03d}_response.json", payload)
|
|
99
|
+
finally:
|
|
100
|
+
self._seq = max(self._seq, seq + 1)
|
|
101
|
+
self._push_event("response", seq, payload)
|
|
102
|
+
|
|
103
|
+
def list_sessions(self) -> list[str]:
|
|
104
|
+
if not self._log_dir.is_dir():
|
|
105
|
+
return []
|
|
106
|
+
return sorted(path.name for path in self._log_dir.iterdir() if path.is_dir())
|
|
107
|
+
|
|
108
|
+
def list_interactions(self, session_id: str) -> list[dict[str, Any]]:
|
|
109
|
+
session_dir = self._session_path(session_id)
|
|
110
|
+
if not session_dir.is_dir():
|
|
111
|
+
return []
|
|
112
|
+
|
|
113
|
+
interactions: list[dict[str, Any]] = []
|
|
114
|
+
for request_path in sorted(
|
|
115
|
+
session_dir.glob("*_request.json"),
|
|
116
|
+
key=self._path_seq,
|
|
117
|
+
):
|
|
118
|
+
seq = self._path_seq(request_path)
|
|
119
|
+
if seq < 0:
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
request_data = self._read_json(request_path) or {}
|
|
123
|
+
entry: dict[str, Any] = {
|
|
124
|
+
"seq": seq,
|
|
125
|
+
"timestamp": request_data.get("timestamp"),
|
|
126
|
+
"message_count": request_data.get("message_count", 0),
|
|
127
|
+
"tool_count": request_data.get("tool_count", 0),
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
response_data = self._read_json(session_dir / f"{seq:03d}_response.json")
|
|
131
|
+
if response_data is not None:
|
|
132
|
+
entry.update(
|
|
133
|
+
{
|
|
134
|
+
"input_tokens": response_data.get("input_tokens", 0),
|
|
135
|
+
"output_tokens": response_data.get("output_tokens", 0),
|
|
136
|
+
"duration_ms": response_data.get("duration_ms", 0),
|
|
137
|
+
"tool_call_count": len(response_data.get("tool_calls", [])),
|
|
138
|
+
"has_error": "error" in response_data,
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
interactions.append(entry)
|
|
143
|
+
|
|
144
|
+
return interactions
|
|
145
|
+
|
|
146
|
+
def get_interaction(self, session_id: str, seq: int) -> dict[str, Any]:
|
|
147
|
+
session_dir = self._session_path(session_id)
|
|
148
|
+
return {
|
|
149
|
+
"seq": seq,
|
|
150
|
+
"request": self._read_json(session_dir / f"{seq:03d}_request.json"),
|
|
151
|
+
"response": self._read_json(session_dir / f"{seq:03d}_response.json"),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
def subscribe_events(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
maxsize: int = _DEFAULT_EVENT_QUEUE_SIZE,
|
|
158
|
+
) -> queue.Queue[dict[str, Any]]:
|
|
159
|
+
event_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=maxsize)
|
|
160
|
+
with self._event_lock:
|
|
161
|
+
self._event_subscribers.add(event_queue)
|
|
162
|
+
return event_queue
|
|
163
|
+
|
|
164
|
+
def unsubscribe_events(self, event_queue: queue.Queue[dict[str, Any]]) -> None:
|
|
165
|
+
with self._event_lock:
|
|
166
|
+
self._event_subscribers.discard(event_queue)
|
|
167
|
+
if event_queue is self._legacy_event_queue:
|
|
168
|
+
self._legacy_event_queue = None
|
|
169
|
+
|
|
170
|
+
def _ensure_session_dir(self) -> Path:
|
|
171
|
+
if self._session_dir is None:
|
|
172
|
+
self._session_dir = self._session_path(self._session_id)
|
|
173
|
+
self._session_dir.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
self._seq = self._discover_next_seq(self._session_dir)
|
|
175
|
+
return self._session_dir
|
|
176
|
+
|
|
177
|
+
def _session_path(self, session_id: str) -> Path:
|
|
178
|
+
return self._log_dir / self._validate_session_id(session_id)
|
|
179
|
+
|
|
180
|
+
def _validate_session_id(self, session_id: str) -> str:
|
|
181
|
+
value = str(session_id)
|
|
182
|
+
if value in {"", ".", ".."}:
|
|
183
|
+
raise ValueError("Session ID must be a single relative path segment.")
|
|
184
|
+
if "/" in value or "\\" in value:
|
|
185
|
+
raise ValueError("Session ID must not contain path separators.")
|
|
186
|
+
|
|
187
|
+
posix_path = PurePosixPath(value)
|
|
188
|
+
windows_path = PureWindowsPath(value)
|
|
189
|
+
if posix_path.is_absolute() or windows_path.is_absolute():
|
|
190
|
+
raise ValueError("Session ID must not be an absolute path.")
|
|
191
|
+
if posix_path.anchor or windows_path.anchor:
|
|
192
|
+
raise ValueError("Session ID must not include a path anchor.")
|
|
193
|
+
if len(posix_path.parts) != 1 or len(windows_path.parts) != 1:
|
|
194
|
+
raise ValueError("Session ID must be a single path segment.")
|
|
195
|
+
return value
|
|
196
|
+
|
|
197
|
+
def _discover_next_seq(self, session_dir: Path) -> int:
|
|
198
|
+
max_seq = -1
|
|
199
|
+
for path in session_dir.glob("*_*.json"):
|
|
200
|
+
max_seq = max(max_seq, self._path_seq(path))
|
|
201
|
+
return max_seq + 1
|
|
202
|
+
|
|
203
|
+
def _path_seq(self, path: Path) -> int:
|
|
204
|
+
seq_text = path.stem.split("_", 1)[0]
|
|
205
|
+
return int(seq_text) if seq_text.isdigit() else -1
|
|
206
|
+
|
|
207
|
+
def _read_json(self, path: Path) -> dict[str, Any] | None:
|
|
208
|
+
if not path.is_file():
|
|
209
|
+
return None
|
|
210
|
+
try:
|
|
211
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
212
|
+
except (OSError, json.JSONDecodeError):
|
|
213
|
+
return None
|
|
214
|
+
if isinstance(data, dict):
|
|
215
|
+
return data
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def _write(self, filename: str, payload: dict[str, Any]) -> None:
|
|
219
|
+
session_dir = self._ensure_session_dir()
|
|
220
|
+
path = session_dir / filename
|
|
221
|
+
path.write_text(
|
|
222
|
+
json.dumps(
|
|
223
|
+
payload,
|
|
224
|
+
ensure_ascii=False,
|
|
225
|
+
indent=2 if self._pretty else None,
|
|
226
|
+
default=str,
|
|
227
|
+
),
|
|
228
|
+
encoding="utf-8",
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def _push_event(self, event_type: str, seq: int, payload: dict[str, Any]) -> None:
|
|
232
|
+
event = {
|
|
233
|
+
"event": event_type,
|
|
234
|
+
"session_id": self._session_id,
|
|
235
|
+
"seq": seq,
|
|
236
|
+
"timestamp": payload.get("timestamp"),
|
|
237
|
+
}
|
|
238
|
+
with self._event_lock:
|
|
239
|
+
subscribers = tuple(self._event_subscribers)
|
|
240
|
+
|
|
241
|
+
for event_queue in subscribers:
|
|
242
|
+
self._publish_event(event_queue, event)
|
|
243
|
+
|
|
244
|
+
def _publish_event(
|
|
245
|
+
self,
|
|
246
|
+
event_queue: queue.Queue[dict[str, Any]],
|
|
247
|
+
event: dict[str, Any],
|
|
248
|
+
) -> None:
|
|
249
|
+
try:
|
|
250
|
+
event_queue.put_nowait(event)
|
|
251
|
+
return
|
|
252
|
+
except queue.Full:
|
|
253
|
+
pass
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
event_queue.get_nowait()
|
|
257
|
+
except queue.Empty:
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
try:
|
|
261
|
+
event_queue.put_nowait(event)
|
|
262
|
+
except queue.Full:
|
|
263
|
+
return
|