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
|
@@ -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)
|