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.
Files changed (60) hide show
  1. agentpool/__init__.py +3 -0
  2. agentpool/agent_io.py +134 -0
  3. agentpool/artifacts.py +151 -0
  4. agentpool/cli.py +1199 -0
  5. agentpool/config.py +373 -0
  6. agentpool/docs/agentpool-skill.md +85 -0
  7. agentpool/docs/onboarding.md +169 -0
  8. agentpool/event_detection.py +150 -0
  9. agentpool/fixtures/__init__.py +1 -0
  10. agentpool/fixtures/fake_agents/__init__.py +1 -0
  11. agentpool/fixtures/fake_agents/fake_approval_agent.py +16 -0
  12. agentpool/fixtures/fake_agents/fake_common.py +44 -0
  13. agentpool/fixtures/fake_agents/fake_completed_agent.py +13 -0
  14. agentpool/fixtures/fake_agents/fake_idle_agent.py +16 -0
  15. agentpool/fixtures/fake_agents/fake_limit_agent.py +14 -0
  16. agentpool/fixtures/fake_agents/fake_patch_agent.py +17 -0
  17. agentpool/fixtures/fake_agents/fake_question_agent.py +16 -0
  18. agentpool/git_worktree.py +144 -0
  19. agentpool/mcp/__init__.py +1 -0
  20. agentpool/mcp/resources.py +64 -0
  21. agentpool/mcp/tools.py +259 -0
  22. agentpool/mcp_server.py +487 -0
  23. agentpool/models.py +310 -0
  24. agentpool/onboarding.py +1279 -0
  25. agentpool/policy.py +63 -0
  26. agentpool/provider_model_catalog.json +997 -0
  27. agentpool/providers/__init__.py +3 -0
  28. agentpool/providers/base.py +411 -0
  29. agentpool/providers/registry.py +139 -0
  30. agentpool/redaction.py +30 -0
  31. agentpool/runtimes/__init__.py +3 -0
  32. agentpool/runtimes/base.py +36 -0
  33. agentpool/runtimes/tmux.py +133 -0
  34. agentpool/session_manager.py +1061 -0
  35. agentpool/stats/__init__.py +6 -0
  36. agentpool/stats/card.py +74 -0
  37. agentpool/stats/compute.py +496 -0
  38. agentpool/stats/queries.py +138 -0
  39. agentpool/stats/render.py +103 -0
  40. agentpool/stats/window.py +85 -0
  41. agentpool/store.py +478 -0
  42. agentpool/usage/__init__.py +1 -0
  43. agentpool/usage/_common.py +223 -0
  44. agentpool/usage/ccusage.py +130 -0
  45. agentpool/usage/claude.py +23 -0
  46. agentpool/usage/codex.py +210 -0
  47. agentpool/usage/codexbar.py +186 -0
  48. agentpool/usage/combine.py +71 -0
  49. agentpool/usage/copilot.py +146 -0
  50. agentpool/usage/devin.py +265 -0
  51. agentpool/usage/parsers.py +41 -0
  52. agentpool/usage/probes.py +52 -0
  53. agentpool/usage/provider_parsers.py +276 -0
  54. agentpool/usage/summary.py +166 -0
  55. agentpool/utils.py +59 -0
  56. agentpool_cli-0.1.0.dist-info/METADATA +292 -0
  57. agentpool_cli-0.1.0.dist-info/RECORD +60 -0
  58. agentpool_cli-0.1.0.dist-info/WHEEL +4 -0
  59. agentpool_cli-0.1.0.dist-info/entry_points.txt +2 -0
  60. agentpool_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,6 @@
1
+ from agentpool.stats.compute import compute_stats
2
+ from agentpool.stats.window import Window, parse_window
3
+
4
+ STATS_SCHEMA_VERSION = "stats/v1"
5
+
6
+ __all__ = ["STATS_SCHEMA_VERSION", "Window", "compute_stats", "parse_window"]
@@ -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)