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,138 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from typing import Any
6
+
7
+ from agentpool.models import AgentSession, CapacitySnapshot
8
+ from agentpool.stats.window import Window
9
+ from agentpool.store import Store
10
+
11
+
12
+ def list_sessions_in_window(
13
+ store: Store,
14
+ window: Window,
15
+ provider_id: str | None = None,
16
+ coordinator_id: str | None = None,
17
+ scope_mine: bool = False,
18
+ ) -> list[AgentSession]:
19
+ clauses = ["created_at >= ?", "created_at < ?"]
20
+ args: list[Any] = [window.start.isoformat(), window.end.isoformat()]
21
+ if provider_id:
22
+ clauses.append("provider_id = ?")
23
+ args.append(provider_id)
24
+ where = " WHERE " + " AND ".join(clauses)
25
+ with store.connect() as conn:
26
+ rows = conn.execute(f"SELECT * FROM sessions{where} ORDER BY created_at ASC", args).fetchall()
27
+ sessions = [store._row_to_session(row) for row in rows]
28
+ if scope_mine and coordinator_id:
29
+ sessions = [
30
+ session
31
+ for session in sessions
32
+ if (session.metadata or {}).get("coordinator_id") == coordinator_id
33
+ ]
34
+ return sessions
35
+
36
+
37
+ def list_events_in_window(
38
+ store: Store,
39
+ window: Window,
40
+ event_types: list[str] | None = None,
41
+ session_ids: set[str] | None = None,
42
+ ) -> list[dict[str, Any]]:
43
+ if session_ids is not None and not session_ids:
44
+ return []
45
+ clauses = ["e.ts >= ?", "e.ts < ?"]
46
+ args: list[Any] = [window.start.isoformat(), window.end.isoformat()]
47
+ if event_types:
48
+ clauses.append(f"e.event_type IN ({','.join('?' for _ in event_types)})")
49
+ args.extend(event_types)
50
+ if session_ids:
51
+ clauses.append(f"e.session_id IN ({','.join('?' for _ in session_ids)})")
52
+ args.extend(sorted(session_ids))
53
+ where = " WHERE " + " AND ".join(clauses)
54
+ with store.connect() as conn:
55
+ rows = conn.execute(
56
+ f"""
57
+ SELECT e.*, s.provider_id AS session_provider_id, s.metadata_json AS session_metadata_json
58
+ FROM events e
59
+ JOIN sessions s ON s.id = e.session_id
60
+ {where}
61
+ ORDER BY e.ts ASC, e.id ASC
62
+ """,
63
+ args,
64
+ ).fetchall()
65
+ return [
66
+ {
67
+ "id": row["id"],
68
+ "session_id": row["session_id"],
69
+ "ts": row["ts"],
70
+ "event_type": row["event_type"],
71
+ "state": row["state"],
72
+ "metadata": json.loads(row["metadata_json"]),
73
+ "provider_id": row["session_provider_id"],
74
+ "session_metadata": json.loads(row["session_metadata_json"]),
75
+ }
76
+ for row in rows
77
+ ]
78
+
79
+
80
+ def usage_snapshot_at_or_before(
81
+ store: Store,
82
+ provider_id: str,
83
+ ts: datetime,
84
+ max_age_seconds: float,
85
+ ) -> CapacitySnapshot | None:
86
+ with store.connect() as conn:
87
+ row = conn.execute(
88
+ """
89
+ SELECT raw_json, ts
90
+ FROM usage_snapshots
91
+ WHERE provider_id = ? AND ts <= ?
92
+ ORDER BY ts DESC, id DESC
93
+ LIMIT 1
94
+ """,
95
+ (provider_id, ts.isoformat()),
96
+ ).fetchone()
97
+ if row is None:
98
+ return None
99
+ snapshot_ts = datetime.fromisoformat(row["ts"].replace("Z", "+00:00"))
100
+ if snapshot_ts.tzinfo is None:
101
+ snapshot_ts = snapshot_ts.replace(tzinfo=timezone.utc)
102
+ age = (ts - snapshot_ts).total_seconds()
103
+ if age > max_age_seconds:
104
+ return None
105
+ return CapacitySnapshot.model_validate_json(row["raw_json"])
106
+
107
+
108
+ def usage_snapshots_in_window(store: Store, window: Window, provider_id: str | None = None) -> list[CapacitySnapshot]:
109
+ clauses = ["ts >= ?", "ts < ?"]
110
+ args: list[Any] = [window.start.isoformat(), window.end.isoformat()]
111
+ if provider_id:
112
+ clauses.append("provider_id = ?")
113
+ args.append(provider_id)
114
+ where = " WHERE " + " AND ".join(clauses)
115
+ with store.connect() as conn:
116
+ rows = conn.execute(
117
+ f"SELECT raw_json FROM usage_snapshots{where} ORDER BY ts ASC, id ASC",
118
+ args,
119
+ ).fetchall()
120
+ return [CapacitySnapshot.model_validate_json(row["raw_json"]) for row in rows]
121
+
122
+
123
+ def has_any_usage_snapshots(store: Store) -> bool:
124
+ with store.connect() as conn:
125
+ row = conn.execute("SELECT 1 FROM usage_snapshots LIMIT 1").fetchone()
126
+ return row is not None
127
+
128
+
129
+ def count_usage_snapshots_in_window(store: Store, window: Window, provider_id: str | None = None) -> int:
130
+ clauses = ["ts >= ?", "ts < ?"]
131
+ args: list[Any] = [window.start.isoformat(), window.end.isoformat()]
132
+ if provider_id:
133
+ clauses.append("provider_id = ?")
134
+ args.append(provider_id)
135
+ where = " WHERE " + " AND ".join(clauses)
136
+ with store.connect() as conn:
137
+ row = conn.execute(f"SELECT COUNT(*) AS count FROM usage_snapshots{where}", args).fetchone()
138
+ return int(row["count"]) if row else 0
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from rich.console import Group, RenderableType
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ from rich.text import Text
9
+
10
+
11
+ def render_stats_panel(stats: dict[str, Any], *, plain: bool = False) -> RenderableType | str:
12
+ if plain:
13
+ return render_stats_plain(stats)
14
+
15
+ sections: list[RenderableType] = []
16
+ window = stats.get("window", {})
17
+ header = Text.assemble(
18
+ ("AgentPool stats ", "bold"),
19
+ (window.get("label", ""), "cyan"),
20
+ (f" scope={stats.get('scope', 'all')}", "dim"),
21
+ )
22
+ sections.append(header)
23
+
24
+ sessions = stats.get("sessions", {})
25
+ session_line = (
26
+ f"sessions: {sessions.get('total', 0)} total | "
27
+ f"spawned {sessions.get('spawned', 0)} | "
28
+ f"terminated {sessions.get('terminated', 0)}"
29
+ )
30
+ sections.append(Text(session_line))
31
+
32
+ parallelism = stats.get("parallelism", {})
33
+ ratio = parallelism.get("ratio")
34
+ ratio_text = f"{ratio:.2f}" if ratio is not None else "n/a"
35
+ sections.append(
36
+ Text(
37
+ f"parallelism: ratio {ratio_text} | peak {parallelism.get('peak_concurrent', 0)} "
38
+ f"| worker-hours {parallelism.get('sum_worker_hours', 0)}"
39
+ )
40
+ )
41
+
42
+ walls = stats.get("walls", {})
43
+ walls_line = f"walls: avoided {walls.get('avoided')} | hit {walls.get('hit')} | confidence {walls.get('confidence')}"
44
+ if walls.get("confidence") == "low":
45
+ walls_line = f"[yellow]{walls_line}[/yellow]"
46
+ sections.append(Text.from_markup(walls_line))
47
+
48
+ tokens = stats.get("tokens", {})
49
+ if tokens.get("by_provider"):
50
+ totals = tokens.get("totals", {})
51
+ token_prefix = "tokens (partial: claude-code only): "
52
+ sections.append(
53
+ Text(
54
+ f"{token_prefix}input {totals.get('input')} | output {totals.get('output')}"
55
+ )
56
+ )
57
+
58
+ quota = stats.get("quota", {})
59
+ if quota:
60
+ table = Table("Provider", "Current %", "Min", "Max", "Samples", show_header=True, header_style="bold")
61
+ for provider_id, row in sorted(quota.items()):
62
+ table.add_row(
63
+ provider_id,
64
+ _fmt(row.get("current_remaining_percent")),
65
+ _fmt(row.get("min_in_window")),
66
+ _fmt(row.get("max_in_window")),
67
+ str(row.get("samples", 0)),
68
+ )
69
+ sections.append(table)
70
+
71
+ data_quality = stats.get("data_quality") or []
72
+ if data_quality:
73
+ dq_lines = [f"- {entry.get('code')}: {entry.get('note') or entry.get('impact')}" for entry in data_quality]
74
+ sections.append(Panel("\n".join(dq_lines), title="data quality", border_style="yellow"))
75
+
76
+ return Panel(Group(*sections), title="agentpool stats", border_style="blue")
77
+
78
+
79
+ def render_stats_plain(stats: dict[str, Any]) -> str:
80
+ lines = [
81
+ f"schema_version={stats.get('schema_version')}",
82
+ f"scope={stats.get('scope')}",
83
+ f"window={stats.get('window', {}).get('spec')}",
84
+ ]
85
+ sessions = stats.get("sessions", {})
86
+ lines.append(f"sessions.total={sessions.get('total', 0)}")
87
+ lines.append(f"sessions.spawned={sessions.get('spawned', 0)}")
88
+ parallelism = stats.get("parallelism", {})
89
+ lines.append(f"parallelism.ratio={parallelism.get('ratio')}")
90
+ lines.append(f"parallelism.peak_concurrent={parallelism.get('peak_concurrent', 0)}")
91
+ walls = stats.get("walls", {})
92
+ lines.append(f"walls.avoided={walls.get('avoided')}")
93
+ lines.append(f"walls.hit={walls.get('hit')}")
94
+ lines.append(f"walls.confidence={walls.get('confidence')}")
95
+ return "\n".join(lines)
96
+
97
+
98
+ def _fmt(value: Any) -> str:
99
+ if value is None:
100
+ return "n/a"
101
+ if isinstance(value, float):
102
+ return f"{value:g}"
103
+ return str(value)
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta, timezone
6
+
7
+ from agentpool.models import ToolError
8
+
9
+ _DURATION_RE = re.compile(r"^(\d+)(h|d|w)$", re.I)
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Window:
14
+ start: datetime
15
+ end: datetime
16
+ label: str
17
+ spec: str
18
+
19
+
20
+ def parse_window(spec: str, now: datetime | None = None) -> Window:
21
+ normalized = (spec or "").strip()
22
+ if not normalized:
23
+ raise ToolError("INVALID_WINDOW", "Window spec must not be empty.", {"spec": spec})
24
+
25
+ current = now or datetime.now(timezone.utc)
26
+ if current.tzinfo is None:
27
+ current = current.replace(tzinfo=timezone.utc)
28
+
29
+ lowered = normalized.lower()
30
+ if lowered == "all":
31
+ return Window(
32
+ start=datetime.fromtimestamp(0, tz=timezone.utc),
33
+ end=current,
34
+ label="all time",
35
+ spec="all",
36
+ )
37
+
38
+ if "/" in normalized:
39
+ start_text, end_text = normalized.split("/", 1)
40
+ start = _parse_iso(start_text.strip())
41
+ end = _parse_iso(end_text.strip())
42
+ if end <= start:
43
+ raise ToolError(
44
+ "INVALID_WINDOW",
45
+ "Window end must be after start.",
46
+ {"spec": spec, "start": start.isoformat(), "end": end.isoformat()},
47
+ )
48
+ return Window(start=start, end=end, label=f"{start.date()} to {end.date()}", spec=normalized)
49
+
50
+ duration = _DURATION_RE.match(lowered)
51
+ if duration:
52
+ amount = int(duration.group(1))
53
+ unit = duration.group(2).lower()
54
+ if unit == "h":
55
+ delta = timedelta(hours=amount)
56
+ label = f"last {amount}h"
57
+ elif unit == "d":
58
+ delta = timedelta(days=amount)
59
+ label = f"last {amount}d"
60
+ else:
61
+ delta = timedelta(weeks=amount)
62
+ label = f"last {amount}w"
63
+ return Window(start=current - delta, end=current, label=label, spec=lowered)
64
+
65
+ try:
66
+ point = _parse_iso(normalized)
67
+ except ToolError:
68
+ raise ToolError("INVALID_WINDOW", f"Unrecognized window spec: {spec!r}.", {"spec": spec}) from None
69
+ end = point + timedelta(days=1)
70
+ return Window(start=point, end=min(end, current), label=str(point.date()), spec=normalized)
71
+
72
+
73
+ def _parse_iso(value: str) -> datetime:
74
+ text = value.strip()
75
+ if not text:
76
+ raise ToolError("INVALID_WINDOW", "ISO timestamp must not be empty.")
77
+ if text.endswith("Z"):
78
+ text = f"{text[:-1]}+00:00"
79
+ try:
80
+ parsed = datetime.fromisoformat(text)
81
+ except ValueError as exc:
82
+ raise ToolError("INVALID_WINDOW", f"Invalid ISO timestamp: {value!r}.", {"value": value}) from exc
83
+ if parsed.tzinfo is None:
84
+ parsed = parsed.replace(tzinfo=timezone.utc)
85
+ return parsed.astimezone(timezone.utc)