agentpool-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.
- agentpool/__init__.py +3 -0
- agentpool/agent_io.py +134 -0
- agentpool/artifacts.py +151 -0
- agentpool/cli.py +1199 -0
- agentpool/config.py +373 -0
- agentpool/docs/agentpool-skill.md +85 -0
- agentpool/docs/onboarding.md +169 -0
- agentpool/event_detection.py +150 -0
- agentpool/fixtures/__init__.py +1 -0
- agentpool/fixtures/fake_agents/__init__.py +1 -0
- agentpool/fixtures/fake_agents/fake_approval_agent.py +16 -0
- agentpool/fixtures/fake_agents/fake_common.py +44 -0
- agentpool/fixtures/fake_agents/fake_completed_agent.py +13 -0
- agentpool/fixtures/fake_agents/fake_idle_agent.py +16 -0
- agentpool/fixtures/fake_agents/fake_limit_agent.py +14 -0
- agentpool/fixtures/fake_agents/fake_patch_agent.py +17 -0
- agentpool/fixtures/fake_agents/fake_question_agent.py +16 -0
- agentpool/git_worktree.py +144 -0
- agentpool/mcp/__init__.py +1 -0
- agentpool/mcp/resources.py +64 -0
- agentpool/mcp/tools.py +259 -0
- agentpool/mcp_server.py +487 -0
- agentpool/models.py +310 -0
- agentpool/onboarding.py +1279 -0
- agentpool/policy.py +63 -0
- agentpool/provider_model_catalog.json +997 -0
- agentpool/providers/__init__.py +3 -0
- agentpool/providers/base.py +411 -0
- agentpool/providers/registry.py +139 -0
- agentpool/redaction.py +30 -0
- agentpool/runtimes/__init__.py +3 -0
- agentpool/runtimes/base.py +36 -0
- agentpool/runtimes/tmux.py +133 -0
- agentpool/session_manager.py +1061 -0
- agentpool/stats/__init__.py +6 -0
- agentpool/stats/card.py +74 -0
- agentpool/stats/compute.py +496 -0
- agentpool/stats/queries.py +138 -0
- agentpool/stats/render.py +103 -0
- agentpool/stats/window.py +85 -0
- agentpool/store.py +478 -0
- agentpool/usage/__init__.py +1 -0
- agentpool/usage/_common.py +223 -0
- agentpool/usage/ccusage.py +130 -0
- agentpool/usage/claude.py +23 -0
- agentpool/usage/codex.py +210 -0
- agentpool/usage/codexbar.py +186 -0
- agentpool/usage/combine.py +71 -0
- agentpool/usage/copilot.py +146 -0
- agentpool/usage/devin.py +265 -0
- agentpool/usage/parsers.py +41 -0
- agentpool/usage/probes.py +52 -0
- agentpool/usage/provider_parsers.py +276 -0
- agentpool/usage/summary.py +166 -0
- agentpool/utils.py +59 -0
- agentpool_cli-0.1.0.dist-info/METADATA +292 -0
- agentpool_cli-0.1.0.dist-info/RECORD +60 -0
- agentpool_cli-0.1.0.dist-info/WHEEL +4 -0
- agentpool_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agentpool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
agentpool/stats/card.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from agentpool.models import ToolError
|
|
7
|
+
from agentpool.utils import utc_now_iso
|
|
8
|
+
|
|
9
|
+
CARD_WIDTH = 1200
|
|
10
|
+
CARD_HEIGHT = 630
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render_stats_card(stats: dict[str, Any], output_path: str | Path | None = None) -> dict[str, Any]:
|
|
14
|
+
try:
|
|
15
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
16
|
+
except ImportError as exc:
|
|
17
|
+
raise ToolError(
|
|
18
|
+
"MISSING_OPTIONAL_DEPENDENCY",
|
|
19
|
+
"PNG share cards require the optional `card` extra: pip install 'agentpool[card]'.",
|
|
20
|
+
{"dependency": "pillow"},
|
|
21
|
+
) from exc
|
|
22
|
+
|
|
23
|
+
path = Path(output_path or _default_card_path())
|
|
24
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
image = Image.new("RGB", (CARD_WIDTH, CARD_HEIGHT), color=(15, 23, 42))
|
|
28
|
+
draw = ImageDraw.Draw(image)
|
|
29
|
+
try:
|
|
30
|
+
title_font = ImageFont.truetype("DejaVuSans-Bold.ttf", 48)
|
|
31
|
+
body_font = ImageFont.truetype("DejaVuSans.ttf", 32)
|
|
32
|
+
except OSError:
|
|
33
|
+
title_font = ImageFont.load_default()
|
|
34
|
+
body_font = ImageFont.load_default()
|
|
35
|
+
|
|
36
|
+
window = stats.get("window", {})
|
|
37
|
+
title = f"AgentPool stats — {window.get('label', 'window')}"
|
|
38
|
+
draw.text((60, 60), title, fill=(248, 250, 252), font=title_font)
|
|
39
|
+
|
|
40
|
+
sessions = stats.get("sessions", {})
|
|
41
|
+
parallelism = stats.get("parallelism", {})
|
|
42
|
+
walls = stats.get("walls", {})
|
|
43
|
+
lines = [
|
|
44
|
+
f"Sessions: {sessions.get('total', 0)} total | spawned {sessions.get('spawned', 0)}",
|
|
45
|
+
f"Parallelism ratio: {parallelism.get('ratio', 'n/a')} | peak {parallelism.get('peak_concurrent', 0)}",
|
|
46
|
+
f"Walls avoided: {walls.get('avoided')} | hit {walls.get('hit')} | confidence {walls.get('confidence')}",
|
|
47
|
+
f"Scope: {stats.get('scope')} | schema {stats.get('schema_version')}",
|
|
48
|
+
]
|
|
49
|
+
y = 160
|
|
50
|
+
for line in lines:
|
|
51
|
+
draw.text((60, y), line, fill=(226, 232, 240), font=body_font)
|
|
52
|
+
y += 56
|
|
53
|
+
|
|
54
|
+
image.save(path, format="PNG")
|
|
55
|
+
except Exception as exc:
|
|
56
|
+
raise ToolError(
|
|
57
|
+
"CARD_RENDER_FAILED",
|
|
58
|
+
"Failed to render stats share card.",
|
|
59
|
+
{"reason": str(exc), "path": str(path)},
|
|
60
|
+
) from exc
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
"path": str(path),
|
|
64
|
+
"bytes": path.stat().st_size,
|
|
65
|
+
"width": CARD_WIDTH,
|
|
66
|
+
"height": CARD_HEIGHT,
|
|
67
|
+
"stats_window": window.get("spec"),
|
|
68
|
+
"generated_at": utc_now_iso(),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _default_card_path() -> Path:
|
|
73
|
+
stamp = utc_now_iso().replace(":", "").replace("-", "")
|
|
74
|
+
return Path.home() / ".agentpool" / "cards" / f"stats-{stamp}.png"
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from agentpool.config import AgentPoolConfig
|
|
7
|
+
from agentpool.models import AgentSession, CapacitySnapshot, ProviderDescriptor, SessionState
|
|
8
|
+
from agentpool.providers.registry import ProviderRegistry
|
|
9
|
+
from agentpool.stats.queries import (
|
|
10
|
+
has_any_usage_snapshots,
|
|
11
|
+
list_events_in_window,
|
|
12
|
+
list_sessions_in_window,
|
|
13
|
+
usage_snapshot_at_or_before,
|
|
14
|
+
usage_snapshots_in_window,
|
|
15
|
+
)
|
|
16
|
+
from agentpool.stats.window import Window
|
|
17
|
+
from agentpool.store import Store
|
|
18
|
+
from agentpool.usage.summary import _usable_reason
|
|
19
|
+
from agentpool.utils import utc_now_iso
|
|
20
|
+
|
|
21
|
+
STATS_SCHEMA_VERSION = "stats/v1"
|
|
22
|
+
WALLS_DEFINITION = "see docs/stats.md#walls"
|
|
23
|
+
CORE_KEYS = frozenset({"schema_version", "generated_at", "source", "scope", "window", "filters", "data_quality"})
|
|
24
|
+
SECTION_KEYS = frozenset(
|
|
25
|
+
{
|
|
26
|
+
"sessions",
|
|
27
|
+
"parallelism",
|
|
28
|
+
"walls",
|
|
29
|
+
"quota",
|
|
30
|
+
"utilization",
|
|
31
|
+
"tokens",
|
|
32
|
+
"suggested_next",
|
|
33
|
+
"coordinator_id",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
QUOTA_REASON_PREFIXES = ("limit_reached", "near_limit")
|
|
37
|
+
TOKEN_CAPABLE_PROVIDERS = {"claude-code"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def compute_stats(
|
|
41
|
+
store: Store,
|
|
42
|
+
config: AgentPoolConfig,
|
|
43
|
+
registry: ProviderRegistry,
|
|
44
|
+
window: Window,
|
|
45
|
+
*,
|
|
46
|
+
provider_id: str | None = None,
|
|
47
|
+
scope: str = "all",
|
|
48
|
+
coordinator_id: str | None = None,
|
|
49
|
+
) -> dict[str, Any]:
|
|
50
|
+
scope_normalized = scope if scope in {"mine", "all"} else "all"
|
|
51
|
+
scope_mine = scope_normalized == "mine"
|
|
52
|
+
descriptors = registry.descriptors(include_usage=False)
|
|
53
|
+
descriptor_by_id = {descriptor.id: descriptor for descriptor in descriptors}
|
|
54
|
+
configured_provider_ids = sorted(config.providers.keys())
|
|
55
|
+
|
|
56
|
+
sessions = list_sessions_in_window(
|
|
57
|
+
store,
|
|
58
|
+
window,
|
|
59
|
+
provider_id=provider_id,
|
|
60
|
+
coordinator_id=coordinator_id,
|
|
61
|
+
scope_mine=scope_mine,
|
|
62
|
+
)
|
|
63
|
+
session_ids = {session.id for session in sessions}
|
|
64
|
+
events = list_events_in_window(store, window, session_ids=session_ids or None)
|
|
65
|
+
|
|
66
|
+
data_quality: list[dict[str, Any]] = []
|
|
67
|
+
result: dict[str, Any] = {
|
|
68
|
+
"schema_version": STATS_SCHEMA_VERSION,
|
|
69
|
+
"generated_at": utc_now_iso(),
|
|
70
|
+
"source": "computed",
|
|
71
|
+
"scope": scope_normalized,
|
|
72
|
+
"window": {
|
|
73
|
+
"start": window.start.isoformat(),
|
|
74
|
+
"end": window.end.isoformat(),
|
|
75
|
+
"label": window.label,
|
|
76
|
+
"spec": window.spec,
|
|
77
|
+
},
|
|
78
|
+
"filters": {"provider_id": provider_id},
|
|
79
|
+
"data_quality": data_quality,
|
|
80
|
+
}
|
|
81
|
+
if scope_mine and coordinator_id:
|
|
82
|
+
result["coordinator_id"] = coordinator_id
|
|
83
|
+
|
|
84
|
+
result["sessions"] = _compute_sessions(sessions, events)
|
|
85
|
+
result["parallelism"] = _compute_parallelism(sessions, window.end)
|
|
86
|
+
result["walls"] = _compute_walls(
|
|
87
|
+
store=store,
|
|
88
|
+
events=events,
|
|
89
|
+
configured_provider_ids=configured_provider_ids,
|
|
90
|
+
descriptor_by_id=descriptor_by_id,
|
|
91
|
+
min_remaining_percent=config.policy.min_remaining_percent,
|
|
92
|
+
stale_after_seconds=config.policy.usage_stale_after_seconds,
|
|
93
|
+
data_quality=data_quality,
|
|
94
|
+
)
|
|
95
|
+
result["quota"] = _compute_quota(
|
|
96
|
+
store,
|
|
97
|
+
window,
|
|
98
|
+
configured_provider_ids,
|
|
99
|
+
provider_id,
|
|
100
|
+
data_quality,
|
|
101
|
+
)
|
|
102
|
+
result["utilization"] = _compute_utilization(
|
|
103
|
+
sessions=sessions,
|
|
104
|
+
window=window,
|
|
105
|
+
quota=result["quota"],
|
|
106
|
+
sum_worker_hours=result["parallelism"]["sum_worker_hours"],
|
|
107
|
+
)
|
|
108
|
+
result["tokens"] = _compute_tokens(
|
|
109
|
+
store,
|
|
110
|
+
configured_provider_ids,
|
|
111
|
+
descriptor_by_id,
|
|
112
|
+
data_quality,
|
|
113
|
+
)
|
|
114
|
+
result["suggested_next"] = _suggested_next(window, result)
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def filter_sections(stats: dict[str, Any], sections: list[str] | None) -> dict[str, Any]:
|
|
119
|
+
if not sections:
|
|
120
|
+
return stats
|
|
121
|
+
allowed = {section.strip() for section in sections if section.strip()}
|
|
122
|
+
filtered: dict[str, Any] = {}
|
|
123
|
+
for key in CORE_KEYS:
|
|
124
|
+
if key in stats:
|
|
125
|
+
filtered[key] = stats[key]
|
|
126
|
+
if "coordinator_id" in stats:
|
|
127
|
+
filtered["coordinator_id"] = stats["coordinator_id"]
|
|
128
|
+
for key in allowed:
|
|
129
|
+
if key in SECTION_KEYS and key in stats:
|
|
130
|
+
filtered[key] = stats[key]
|
|
131
|
+
return filtered
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _compute_sessions(sessions: list[AgentSession], events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
135
|
+
by_provider: dict[str, int] = {}
|
|
136
|
+
by_role: dict[str, int] = {}
|
|
137
|
+
by_state: dict[str, int] = {}
|
|
138
|
+
for session in sessions:
|
|
139
|
+
by_provider[session.provider_id] = by_provider.get(session.provider_id, 0) + 1
|
|
140
|
+
by_role[session.role] = by_role.get(session.role, 0) + 1
|
|
141
|
+
state = session.state.value if hasattr(session.state, "value") else str(session.state)
|
|
142
|
+
by_state[state] = by_state.get(state, 0) + 1
|
|
143
|
+
|
|
144
|
+
spawned = sum(1 for event in events if event["event_type"] == "spawn")
|
|
145
|
+
terminated = sum(1 for event in events if event["event_type"] == "terminate")
|
|
146
|
+
interrupted = sum(1 for event in events if event["event_type"] == "interrupt")
|
|
147
|
+
timed_out = sum(1 for event in events if event["event_type"] == "timeout")
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
"total": len(sessions),
|
|
151
|
+
"by_provider": dict(sorted(by_provider.items())),
|
|
152
|
+
"by_role": dict(sorted(by_role.items())),
|
|
153
|
+
"by_state": dict(sorted(by_state.items())),
|
|
154
|
+
"spawned": spawned,
|
|
155
|
+
"terminated": terminated,
|
|
156
|
+
"interrupted": interrupted,
|
|
157
|
+
"timed_out": timed_out,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _compute_parallelism(sessions: list[AgentSession], window_end: datetime) -> dict[str, Any]:
|
|
162
|
+
if not sessions:
|
|
163
|
+
return {
|
|
164
|
+
"wall_clock_hours": 0.0,
|
|
165
|
+
"sum_worker_hours": 0.0,
|
|
166
|
+
"ratio": None,
|
|
167
|
+
"peak_concurrent": 0,
|
|
168
|
+
"peak_at": None,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
intervals: list[tuple[datetime, datetime]] = []
|
|
172
|
+
for session in sessions:
|
|
173
|
+
start = _ensure_utc(session.created_at)
|
|
174
|
+
end = _ensure_utc(session.ended_at) if session.ended_at else window_end
|
|
175
|
+
if end < start:
|
|
176
|
+
end = start
|
|
177
|
+
intervals.append((start, end))
|
|
178
|
+
|
|
179
|
+
earliest = min(start for start, _ in intervals)
|
|
180
|
+
latest = max(end for _, end in intervals)
|
|
181
|
+
wall_clock_hours = max(0.0, (latest - earliest).total_seconds() / 3600.0)
|
|
182
|
+
sum_worker_hours = sum((end - start).total_seconds() / 3600.0 for start, end in intervals)
|
|
183
|
+
ratio = round(sum_worker_hours / wall_clock_hours, 2) if wall_clock_hours > 0 else None
|
|
184
|
+
|
|
185
|
+
timeline: list[tuple[datetime, int]] = []
|
|
186
|
+
for start, end in intervals:
|
|
187
|
+
timeline.append((start, 1))
|
|
188
|
+
timeline.append((end, -1))
|
|
189
|
+
timeline.sort(key=lambda item: (item[0], -item[1]))
|
|
190
|
+
|
|
191
|
+
running = 0
|
|
192
|
+
peak = 0
|
|
193
|
+
peak_at: datetime | None = None
|
|
194
|
+
for ts, delta in timeline:
|
|
195
|
+
running += delta
|
|
196
|
+
if running > peak:
|
|
197
|
+
peak = running
|
|
198
|
+
peak_at = ts
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
"wall_clock_hours": round(wall_clock_hours, 2),
|
|
202
|
+
"sum_worker_hours": round(sum_worker_hours, 2),
|
|
203
|
+
"ratio": ratio,
|
|
204
|
+
"peak_concurrent": peak,
|
|
205
|
+
"peak_at": peak_at.isoformat() if peak_at else None,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _compute_walls(
|
|
210
|
+
*,
|
|
211
|
+
store: Store,
|
|
212
|
+
events: list[dict[str, Any]],
|
|
213
|
+
configured_provider_ids: list[str],
|
|
214
|
+
descriptor_by_id: dict[str, ProviderDescriptor],
|
|
215
|
+
min_remaining_percent: int,
|
|
216
|
+
stale_after_seconds: int,
|
|
217
|
+
data_quality: list[dict[str, Any]],
|
|
218
|
+
) -> dict[str, Any]:
|
|
219
|
+
spawn_events = [event for event in events if event["event_type"] == "spawn"]
|
|
220
|
+
snapshot_max_age = 2 * stale_after_seconds
|
|
221
|
+
total_snapshots = has_any_usage_snapshots(store)
|
|
222
|
+
|
|
223
|
+
if not spawn_events:
|
|
224
|
+
walls = {
|
|
225
|
+
"hit": 0 if total_snapshots else None,
|
|
226
|
+
"avoided": 0 if total_snapshots else None,
|
|
227
|
+
"by_provider": {},
|
|
228
|
+
"confidence": "high" if total_snapshots else "low",
|
|
229
|
+
"definition": WALLS_DEFINITION,
|
|
230
|
+
}
|
|
231
|
+
if not total_snapshots:
|
|
232
|
+
data_quality.append(
|
|
233
|
+
{
|
|
234
|
+
"code": "no_usage_data_in_window",
|
|
235
|
+
"impact": "walls undercount",
|
|
236
|
+
"note": "No usage snapshots available for wall inference.",
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
return walls
|
|
240
|
+
|
|
241
|
+
if not total_snapshots:
|
|
242
|
+
data_quality.append(
|
|
243
|
+
{
|
|
244
|
+
"code": "no_usage_data_in_window",
|
|
245
|
+
"impact": "walls undercount",
|
|
246
|
+
"note": "No usage snapshots available for wall inference.",
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
return {
|
|
250
|
+
"hit": None,
|
|
251
|
+
"avoided": None,
|
|
252
|
+
"by_provider": {},
|
|
253
|
+
"confidence": "low",
|
|
254
|
+
"definition": WALLS_DEFINITION,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
hit = 0
|
|
258
|
+
avoided = 0
|
|
259
|
+
by_provider: dict[str, dict[str, int]] = {}
|
|
260
|
+
unknown_spawns = 0
|
|
261
|
+
|
|
262
|
+
for event in spawn_events:
|
|
263
|
+
provider_id = event["provider_id"]
|
|
264
|
+
spawn_ts = _parse_ts(event["ts"])
|
|
265
|
+
provider_rows = by_provider.setdefault(provider_id, {"hit": 0, "avoided": 0})
|
|
266
|
+
neighbor_unknown = False
|
|
267
|
+
usability: dict[str, tuple[bool, str | None]] = {}
|
|
268
|
+
|
|
269
|
+
for candidate_id in configured_provider_ids:
|
|
270
|
+
snapshot = usage_snapshot_at_or_before(store, candidate_id, spawn_ts, snapshot_max_age)
|
|
271
|
+
if snapshot is None:
|
|
272
|
+
neighbor_unknown = True
|
|
273
|
+
continue
|
|
274
|
+
usable, reason = _snapshot_usability(
|
|
275
|
+
snapshot,
|
|
276
|
+
descriptor_by_id.get(candidate_id),
|
|
277
|
+
spawn_ts,
|
|
278
|
+
min_remaining_percent,
|
|
279
|
+
stale_after_seconds,
|
|
280
|
+
)
|
|
281
|
+
usability[candidate_id] = (usable, reason)
|
|
282
|
+
|
|
283
|
+
if neighbor_unknown:
|
|
284
|
+
unknown_spawns += 1
|
|
285
|
+
|
|
286
|
+
spawn_usable, spawn_reason = usability.get(provider_id, (False, None))
|
|
287
|
+
others_quota_blocked = any(
|
|
288
|
+
candidate_id != provider_id
|
|
289
|
+
and not usable
|
|
290
|
+
and _is_quota_unusable_reason(reason)
|
|
291
|
+
for candidate_id, (usable, reason) in usability.items()
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if spawn_usable and others_quota_blocked:
|
|
295
|
+
avoided += 1
|
|
296
|
+
provider_rows["avoided"] += 1
|
|
297
|
+
elif not spawn_usable and _is_quota_unusable_reason(spawn_reason):
|
|
298
|
+
hit += 1
|
|
299
|
+
provider_rows["hit"] += 1
|
|
300
|
+
|
|
301
|
+
confidence = "low" if unknown_spawns > len(spawn_events) / 2 else "high"
|
|
302
|
+
if confidence == "low":
|
|
303
|
+
data_quality.append(
|
|
304
|
+
{
|
|
305
|
+
"code": "walls_low_confidence",
|
|
306
|
+
"impact": "walls may be undercounted",
|
|
307
|
+
"note": "More than half of spawns lacked fresh neighbor usage snapshots.",
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
"hit": hit,
|
|
313
|
+
"avoided": avoided,
|
|
314
|
+
"by_provider": dict(sorted(by_provider.items())),
|
|
315
|
+
"confidence": confidence,
|
|
316
|
+
"definition": WALLS_DEFINITION,
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _compute_quota(
|
|
321
|
+
store: Store,
|
|
322
|
+
window: Window,
|
|
323
|
+
configured_provider_ids: list[str],
|
|
324
|
+
provider_filter: str | None,
|
|
325
|
+
data_quality: list[dict[str, Any]],
|
|
326
|
+
) -> dict[str, Any]:
|
|
327
|
+
provider_ids = [provider_filter] if provider_filter else configured_provider_ids
|
|
328
|
+
quota: dict[str, Any] = {}
|
|
329
|
+
for pid in provider_ids:
|
|
330
|
+
snapshots = usage_snapshots_in_window(store, window, pid)
|
|
331
|
+
if not snapshots:
|
|
332
|
+
data_quality.append(
|
|
333
|
+
{
|
|
334
|
+
"code": "no_usage_data_for_provider",
|
|
335
|
+
"provider_id": pid,
|
|
336
|
+
"impact": "quota and walls undercount",
|
|
337
|
+
}
|
|
338
|
+
)
|
|
339
|
+
continue
|
|
340
|
+
remaining_values = [_minimum_remaining_percent(snapshot) for snapshot in snapshots]
|
|
341
|
+
remaining_values = [value for value in remaining_values if value is not None]
|
|
342
|
+
latest = snapshots[-1]
|
|
343
|
+
latest_remaining = _minimum_remaining_percent(latest)
|
|
344
|
+
quota[pid] = {
|
|
345
|
+
"current_remaining_percent": latest_remaining,
|
|
346
|
+
"min_in_window": min(remaining_values) if remaining_values else None,
|
|
347
|
+
"max_in_window": max(remaining_values) if remaining_values else None,
|
|
348
|
+
"samples": len(snapshots),
|
|
349
|
+
}
|
|
350
|
+
return quota
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _compute_utilization(
|
|
354
|
+
*,
|
|
355
|
+
sessions: list[AgentSession],
|
|
356
|
+
window: Window,
|
|
357
|
+
quota: dict[str, Any],
|
|
358
|
+
sum_worker_hours: float,
|
|
359
|
+
) -> dict[str, Any]:
|
|
360
|
+
window_hours = max(0.0, (window.end - window.start).total_seconds() / 3600.0)
|
|
361
|
+
usable_providers = len(quota)
|
|
362
|
+
usable_hours = window_hours * usable_providers if usable_providers else 0.0
|
|
363
|
+
ratio = round(sum_worker_hours / usable_hours, 2) if usable_hours > 0 else None
|
|
364
|
+
return {
|
|
365
|
+
"subscription_utilization": ratio,
|
|
366
|
+
"method": "sum(worker_hours)/sum(usable_hours_in_window)",
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _compute_tokens(
|
|
371
|
+
store: Store,
|
|
372
|
+
configured_provider_ids: list[str],
|
|
373
|
+
descriptor_by_id: dict[str, ProviderDescriptor],
|
|
374
|
+
data_quality: list[dict[str, Any]],
|
|
375
|
+
) -> dict[str, Any]:
|
|
376
|
+
by_provider: dict[str, dict[str, int]] = {}
|
|
377
|
+
token_capable_configured = [pid for pid in configured_provider_ids if pid in TOKEN_CAPABLE_PROVIDERS]
|
|
378
|
+
for pid in token_capable_configured:
|
|
379
|
+
snapshots = store.latest_usage_snapshots(pid)
|
|
380
|
+
if not snapshots:
|
|
381
|
+
continue
|
|
382
|
+
snapshot = snapshots[0]
|
|
383
|
+
token_counts = snapshot.raw.get("token_counts") if isinstance(snapshot.raw, dict) else None
|
|
384
|
+
if not isinstance(token_counts, dict):
|
|
385
|
+
continue
|
|
386
|
+
input_tokens = _coerce_int(token_counts.get("input"))
|
|
387
|
+
output_tokens = _coerce_int(token_counts.get("output"))
|
|
388
|
+
if input_tokens is None and output_tokens is None:
|
|
389
|
+
continue
|
|
390
|
+
by_provider[pid] = {
|
|
391
|
+
"input": input_tokens or 0,
|
|
392
|
+
"output": output_tokens or 0,
|
|
393
|
+
"data_quality": "active_5h_block_only",
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
providers_without = [pid for pid in configured_provider_ids if pid not in by_provider]
|
|
397
|
+
if by_provider:
|
|
398
|
+
data_quality.append(
|
|
399
|
+
{
|
|
400
|
+
"code": "tokens_partial",
|
|
401
|
+
"providers": sorted(by_provider.keys()),
|
|
402
|
+
"note": "ccusage exposes only current 5h block",
|
|
403
|
+
}
|
|
404
|
+
)
|
|
405
|
+
elif not token_capable_configured:
|
|
406
|
+
data_quality.append(
|
|
407
|
+
{
|
|
408
|
+
"code": "no_token_capable_providers",
|
|
409
|
+
"impact": "tokens null",
|
|
410
|
+
"note": "no configured provider exposes token counts (only claude-code via ccusage in v1)",
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
else:
|
|
414
|
+
data_quality.append(
|
|
415
|
+
{
|
|
416
|
+
"code": "no_token_data_available",
|
|
417
|
+
"providers": sorted(token_capable_configured),
|
|
418
|
+
"impact": "tokens null",
|
|
419
|
+
"note": "token-capable providers configured but no token_counts found in latest snapshots",
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
totals = {
|
|
423
|
+
"input": sum(row["input"] for row in by_provider.values()),
|
|
424
|
+
"output": sum(row["output"] for row in by_provider.values()),
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
"by_provider": by_provider,
|
|
428
|
+
"totals": totals if by_provider else {"input": None, "output": None},
|
|
429
|
+
"providers_without_token_data": providers_without,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _suggested_next(window: Window, stats: dict[str, Any]) -> list[str]:
|
|
434
|
+
suggestions: list[str] = []
|
|
435
|
+
if stats.get("parallelism", {}).get("peak_at"):
|
|
436
|
+
suggestions.append(
|
|
437
|
+
f"Inspect peak concurrency: agentpool sessions --json (or list_sessions MCP tool)."
|
|
438
|
+
)
|
|
439
|
+
if stats.get("walls", {}).get("avoided"):
|
|
440
|
+
suggestions.append("Review provider distribution with agentpool usage-summary --json.")
|
|
441
|
+
if stats.get("data_quality"):
|
|
442
|
+
suggestions.append("Refresh usage probes before the next delegation window (get_usage_snapshot refresh=true).")
|
|
443
|
+
return suggestions
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _snapshot_usability(
|
|
447
|
+
snapshot: CapacitySnapshot,
|
|
448
|
+
descriptor: ProviderDescriptor | None,
|
|
449
|
+
at: datetime,
|
|
450
|
+
min_remaining_percent: int,
|
|
451
|
+
stale_after_seconds: int,
|
|
452
|
+
) -> tuple[bool, str | None]:
|
|
453
|
+
windows = [
|
|
454
|
+
{
|
|
455
|
+
"name": window.name,
|
|
456
|
+
"remaining_percent": window.remaining_percent,
|
|
457
|
+
}
|
|
458
|
+
for window in snapshot.windows
|
|
459
|
+
]
|
|
460
|
+
age_seconds = max(0.0, (at - _ensure_utc(snapshot.checked_at)).total_seconds())
|
|
461
|
+
stale = age_seconds > stale_after_seconds
|
|
462
|
+
return _usable_reason(snapshot, windows, min_remaining_percent, descriptor, stale)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _is_quota_unusable_reason(reason: str | None) -> bool:
|
|
466
|
+
if reason is None:
|
|
467
|
+
return False
|
|
468
|
+
if reason in QUOTA_REASON_PREFIXES:
|
|
469
|
+
return True
|
|
470
|
+
return "_below_" in reason and reason.endswith("_percent")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _minimum_remaining_percent(snapshot: CapacitySnapshot) -> float | None:
|
|
474
|
+
values = [window.remaining_percent for window in snapshot.windows if window.remaining_percent is not None]
|
|
475
|
+
return min(values) if values else None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _coerce_int(value: Any) -> int | None:
|
|
479
|
+
if value is None:
|
|
480
|
+
return None
|
|
481
|
+
try:
|
|
482
|
+
return int(value)
|
|
483
|
+
except (TypeError, ValueError):
|
|
484
|
+
return None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _ensure_utc(value: datetime) -> datetime:
|
|
488
|
+
if value.tzinfo is None:
|
|
489
|
+
return value.replace(tzinfo=timezone.utc)
|
|
490
|
+
return value.astimezone(timezone.utc)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _parse_ts(value: str) -> datetime:
|
|
494
|
+
text = value.replace("Z", "+00:00")
|
|
495
|
+
parsed = datetime.fromisoformat(text)
|
|
496
|
+
return _ensure_utc(parsed)
|