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,1061 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import shutil
|
|
5
|
+
import uuid
|
|
6
|
+
from collections import deque
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from agentpool.artifacts import (
|
|
12
|
+
append_transcript,
|
|
13
|
+
artifact_manifest,
|
|
14
|
+
collect_artifacts,
|
|
15
|
+
create_artifact_dir,
|
|
16
|
+
initialize_artifacts,
|
|
17
|
+
)
|
|
18
|
+
from agentpool.config import AgentPoolConfig, load_config
|
|
19
|
+
from agentpool.config import default_provider_config
|
|
20
|
+
from agentpool.event_detection import detect_event, screen_hash, trim_excerpt
|
|
21
|
+
from agentpool.git_worktree import cleanup_worktree, create_worktree, delete_agentpool_branch, list_agentpool_worktrees
|
|
22
|
+
from agentpool.models import (
|
|
23
|
+
AgentSession,
|
|
24
|
+
ArtifactRecord,
|
|
25
|
+
FileLease,
|
|
26
|
+
ObserveEvent,
|
|
27
|
+
ObserveWorkerResponse,
|
|
28
|
+
RuntimeKind,
|
|
29
|
+
SessionState,
|
|
30
|
+
SpawnWorkerRequest,
|
|
31
|
+
ToolError,
|
|
32
|
+
)
|
|
33
|
+
from agentpool.policy import active_state, enforce_raw_keys_policy, enforce_spawn_policy
|
|
34
|
+
from agentpool.providers.registry import ProviderRegistry, build_registry
|
|
35
|
+
from agentpool.redaction import redact_text
|
|
36
|
+
from agentpool.runtimes.tmux import TmuxRuntime
|
|
37
|
+
from agentpool.store import Store
|
|
38
|
+
from agentpool.usage.summary import build_usage_summary
|
|
39
|
+
from agentpool.utils import append_jsonl, new_session_id, utc_now_iso, write_json
|
|
40
|
+
|
|
41
|
+
DEFAULT_SESSION_LIMIT = 50
|
|
42
|
+
MAX_SESSION_LIMIT = 500
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SessionManager:
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
config: AgentPoolConfig | None = None,
|
|
49
|
+
store: Store | None = None,
|
|
50
|
+
registry: ProviderRegistry | None = None,
|
|
51
|
+
runtime: TmuxRuntime | None = None,
|
|
52
|
+
coordinator_id: str | None = None,
|
|
53
|
+
scope_sessions_by_coordinator: bool = False,
|
|
54
|
+
):
|
|
55
|
+
self.config = config or load_config()
|
|
56
|
+
if not self.config.providers:
|
|
57
|
+
self.config.providers = default_provider_config()
|
|
58
|
+
self.store = store or Store(self.config.storage.db)
|
|
59
|
+
self.registry = registry or build_registry(self.config)
|
|
60
|
+
self.runtime = runtime or TmuxRuntime()
|
|
61
|
+
self.coordinator_id = coordinator_id or f"coord_{uuid.uuid4().hex[:12]}"
|
|
62
|
+
self.scope_sessions_by_coordinator = scope_sessions_by_coordinator
|
|
63
|
+
|
|
64
|
+
def inventory(self, include_usage: bool = True) -> dict[str, Any]:
|
|
65
|
+
self.reconcile_sessions()
|
|
66
|
+
return {
|
|
67
|
+
"providers": [p.model_dump(mode="json") for p in self.registry.descriptors(include_usage)],
|
|
68
|
+
"policy": self.config.policy.model_dump(mode="json"),
|
|
69
|
+
"checked_at": utc_now_iso(),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
def usage_snapshot(self, provider_id: str | None = None, backend: str = "combined") -> dict[str, Any]:
|
|
73
|
+
self.reconcile_sessions()
|
|
74
|
+
snapshots = self.registry.usage(provider_id, backend=backend)
|
|
75
|
+
for snapshot in snapshots:
|
|
76
|
+
self.store.save_usage_snapshot(snapshot)
|
|
77
|
+
return {
|
|
78
|
+
"snapshots": [snapshot.model_dump(mode="json") for snapshot in snapshots],
|
|
79
|
+
"source": "live_probe",
|
|
80
|
+
"backend": backend,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
def cached_usage_snapshot(self, provider_id: str | None = None) -> dict[str, Any]:
|
|
84
|
+
self.reconcile_sessions()
|
|
85
|
+
snapshots = self._configured_usage_snapshots(self.store.latest_usage_snapshots(provider_id), provider_id)
|
|
86
|
+
return {
|
|
87
|
+
"snapshots": [snapshot.model_dump(mode="json") for snapshot in snapshots],
|
|
88
|
+
"source": "sqlite_cache",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
def usage_summary(self, provider_id: str | None = None, refresh: bool = False, backend: str = "combined") -> dict[str, Any]:
|
|
92
|
+
self.reconcile_sessions()
|
|
93
|
+
descriptors = self.registry.descriptors(include_usage=False)
|
|
94
|
+
if refresh:
|
|
95
|
+
snapshots = self.registry.usage(provider_id, backend=backend)
|
|
96
|
+
for snapshot in snapshots:
|
|
97
|
+
self.store.save_usage_snapshot(snapshot)
|
|
98
|
+
source = "live_probe"
|
|
99
|
+
else:
|
|
100
|
+
snapshots = self._configured_usage_snapshots(self.store.latest_usage_snapshots(provider_id), provider_id)
|
|
101
|
+
source = "sqlite_cache"
|
|
102
|
+
return {
|
|
103
|
+
**build_usage_summary(
|
|
104
|
+
snapshots,
|
|
105
|
+
min_remaining_percent=self.config.policy.min_remaining_percent,
|
|
106
|
+
stale_after_seconds=self.config.policy.usage_stale_after_seconds,
|
|
107
|
+
provider_descriptors=descriptors,
|
|
108
|
+
),
|
|
109
|
+
"source": source,
|
|
110
|
+
"backend": backend if refresh else "cache",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
def provider_models(self, provider_id: str | None = None) -> dict[str, Any]:
|
|
114
|
+
rows = []
|
|
115
|
+
for descriptor in self.inventory(include_usage=False)["providers"]:
|
|
116
|
+
if provider_id and descriptor["id"] != provider_id:
|
|
117
|
+
continue
|
|
118
|
+
metadata = descriptor.get("metadata") or {}
|
|
119
|
+
rows.append(
|
|
120
|
+
{
|
|
121
|
+
"provider_id": descriptor["id"],
|
|
122
|
+
"installed": descriptor["installed"],
|
|
123
|
+
"default_model": metadata.get("default_model"),
|
|
124
|
+
"smoke_model": metadata.get("smoke_model"),
|
|
125
|
+
"model_arg": metadata.get("model_arg"),
|
|
126
|
+
"model_selection": metadata.get("model_selection", "model_arg"),
|
|
127
|
+
"default_initial_prompt_mode": metadata.get("default_initial_prompt_mode", "send_after_launch"),
|
|
128
|
+
"reasoning_effort_config_key": metadata.get("reasoning_effort_config_key"),
|
|
129
|
+
"service_tier_config_key": metadata.get("service_tier_config_key"),
|
|
130
|
+
"submit_keys": metadata.get("submit_keys", []),
|
|
131
|
+
"catalog_completeness": metadata.get("catalog_completeness"),
|
|
132
|
+
"quirks": metadata.get("quirks", []),
|
|
133
|
+
"models": descriptor.get("models", []),
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
if provider_id and not rows:
|
|
137
|
+
raise ToolError(
|
|
138
|
+
"PROVIDER_NOT_FOUND",
|
|
139
|
+
f"Provider {provider_id} is not configured.",
|
|
140
|
+
{"provider_id": provider_id},
|
|
141
|
+
)
|
|
142
|
+
return {"providers": rows}
|
|
143
|
+
|
|
144
|
+
def filter_candidates(
|
|
145
|
+
self,
|
|
146
|
+
required_capabilities: list[str] | None = None,
|
|
147
|
+
avoid_statuses: list[str] | None = None,
|
|
148
|
+
allowed_providers: list[str] | None = None,
|
|
149
|
+
include_usage_unknown: bool = True,
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
required = set(required_capabilities or [])
|
|
152
|
+
avoid = set(avoid_statuses or [])
|
|
153
|
+
allowed = set(allowed_providers or [])
|
|
154
|
+
candidates: list[dict[str, Any]] = []
|
|
155
|
+
excluded: list[dict[str, Any]] = []
|
|
156
|
+
for descriptor in self.registry.descriptors(include_usage=True):
|
|
157
|
+
reasons: list[str] = []
|
|
158
|
+
blocked: list[str] = []
|
|
159
|
+
if allowed and descriptor.id not in allowed:
|
|
160
|
+
blocked.append("not in allowed_providers filter")
|
|
161
|
+
if not descriptor.installed:
|
|
162
|
+
blocked.append("not installed")
|
|
163
|
+
capabilities = {str(cap.value if hasattr(cap, "value") else cap) for cap in descriptor.capabilities}
|
|
164
|
+
missing = sorted(required - capabilities)
|
|
165
|
+
if missing:
|
|
166
|
+
blocked.append(f"missing capabilities: {', '.join(missing)}")
|
|
167
|
+
status = descriptor.usage.status if descriptor.usage else "unknown"
|
|
168
|
+
status_value = status.value if hasattr(status, "value") else str(status)
|
|
169
|
+
if status_value in avoid:
|
|
170
|
+
blocked.append(f"usage status avoided: {status_value}")
|
|
171
|
+
if status_value == "unknown" and not include_usage_unknown:
|
|
172
|
+
blocked.append("usage unknown")
|
|
173
|
+
if descriptor.id in self.config.policy.denied_providers:
|
|
174
|
+
blocked.append("policy: provider denied")
|
|
175
|
+
if blocked:
|
|
176
|
+
excluded.append({"provider_id": descriptor.id, "excluded_reasons": blocked})
|
|
177
|
+
else:
|
|
178
|
+
reasons.extend(["installed", "supports tmux"])
|
|
179
|
+
reasons.append("usage available or unknown" if include_usage_unknown else f"usage {status_value}")
|
|
180
|
+
candidates.append({"provider_id": descriptor.id, "included_reasons": reasons})
|
|
181
|
+
return {"candidates": candidates, "excluded": excluded}
|
|
182
|
+
|
|
183
|
+
def spawn_worker(self, request: SpawnWorkerRequest) -> dict[str, Any]:
|
|
184
|
+
self.reconcile_sessions()
|
|
185
|
+
enforce_spawn_policy(self.config, request.provider_id, request.role, request.isolation)
|
|
186
|
+
if request.runtime != RuntimeKind.TMUX:
|
|
187
|
+
raise ToolError("POLICY_BLOCKED", "Only tmux runtime is supported in v0.1.", {"runtime": request.runtime})
|
|
188
|
+
active_sessions = [
|
|
189
|
+
session
|
|
190
|
+
for session in self.store.list_sessions()
|
|
191
|
+
if active_state(session.state) and self._session_in_scope(session)
|
|
192
|
+
]
|
|
193
|
+
if len(active_sessions) >= self.config.policy.max_parallel_sessions:
|
|
194
|
+
raise ToolError(
|
|
195
|
+
"POLICY_BLOCKED",
|
|
196
|
+
"Max parallel sessions reached.",
|
|
197
|
+
{
|
|
198
|
+
"max_parallel_sessions": self.config.policy.max_parallel_sessions,
|
|
199
|
+
"active_count": len(active_sessions),
|
|
200
|
+
"active_sessions": [_session_summary(session) for session in active_sessions],
|
|
201
|
+
},
|
|
202
|
+
)
|
|
203
|
+
self._enforce_cached_usage_policy(request.provider_id)
|
|
204
|
+
adapter = self.registry.get(request.provider_id)
|
|
205
|
+
descriptor = adapter.detect()
|
|
206
|
+
if not descriptor.installed:
|
|
207
|
+
raise ToolError(
|
|
208
|
+
"PROVIDER_NOT_INSTALLED",
|
|
209
|
+
f"Provider {request.provider_id} is not installed.",
|
|
210
|
+
{"provider_id": request.provider_id, "warnings": descriptor.warnings},
|
|
211
|
+
)
|
|
212
|
+
model_was_explicit = request.model is not None
|
|
213
|
+
default_model = _default_model(adapter.config.metadata)
|
|
214
|
+
if not request.model and default_model:
|
|
215
|
+
request = request.model_copy(update={"model": default_model})
|
|
216
|
+
if model_was_explicit and not request.reasoning_effort:
|
|
217
|
+
default_reasoning = _default_reasoning_effort(adapter.config.metadata, adapter.config.models, request.model)
|
|
218
|
+
if default_reasoning:
|
|
219
|
+
request = request.model_copy(update={"reasoning_effort": default_reasoning})
|
|
220
|
+
effective_prompt_mode = _effective_initial_prompt_mode(request.initial_prompt_mode, adapter.config.metadata)
|
|
221
|
+
if effective_prompt_mode != request.initial_prompt_mode:
|
|
222
|
+
request = request.model_copy(update={"initial_prompt_mode": effective_prompt_mode})
|
|
223
|
+
if request.max_runtime_seconds is not None and request.max_runtime_seconds <= 0:
|
|
224
|
+
raise ToolError(
|
|
225
|
+
"INVALID_LIMIT",
|
|
226
|
+
"max_runtime_seconds must be greater than zero when provided.",
|
|
227
|
+
{"max_runtime_seconds": request.max_runtime_seconds},
|
|
228
|
+
)
|
|
229
|
+
if request.max_turns is not None and request.max_turns <= 0:
|
|
230
|
+
raise ToolError(
|
|
231
|
+
"INVALID_LIMIT",
|
|
232
|
+
"max_turns must be greater than zero when provided.",
|
|
233
|
+
{"max_turns": request.max_turns},
|
|
234
|
+
)
|
|
235
|
+
repo_path = Path(request.repo_path).expanduser().resolve()
|
|
236
|
+
session_id = new_session_id()
|
|
237
|
+
workdir = repo_path
|
|
238
|
+
worktree_path = None
|
|
239
|
+
artifact_dir = None
|
|
240
|
+
try:
|
|
241
|
+
if request.isolation == "worktree":
|
|
242
|
+
workdir = create_worktree(repo_path, request.provider_id, session_id)
|
|
243
|
+
worktree_path = str(workdir)
|
|
244
|
+
artifact_dir = create_artifact_dir(self.config.storage.artifacts, repo_path, session_id)
|
|
245
|
+
except Exception:
|
|
246
|
+
if worktree_path:
|
|
247
|
+
self._rollback_spawn_files(repo_path, worktree_path, request.provider_id, session_id, artifact_dir)
|
|
248
|
+
raise
|
|
249
|
+
tmux_name = _tmux_name(self.config.runtime.tmux.session_prefix, request.provider_id, session_id)
|
|
250
|
+
now = datetime.now(timezone.utc)
|
|
251
|
+
metadata = dict(request.metadata)
|
|
252
|
+
metadata["coordinator_id"] = self.coordinator_id
|
|
253
|
+
metadata["initial_prompt_mode"] = request.initial_prompt_mode
|
|
254
|
+
if request.reasoning_effort:
|
|
255
|
+
metadata["reasoning_effort"] = request.reasoning_effort
|
|
256
|
+
if request.service_tier:
|
|
257
|
+
metadata["service_tier"] = request.service_tier
|
|
258
|
+
if request.max_runtime_seconds:
|
|
259
|
+
metadata["deadline_at"] = datetime.fromtimestamp(
|
|
260
|
+
time.time() + request.max_runtime_seconds,
|
|
261
|
+
tz=timezone.utc,
|
|
262
|
+
).isoformat()
|
|
263
|
+
if request.max_turns:
|
|
264
|
+
metadata["max_turns"] = request.max_turns
|
|
265
|
+
metadata["turns_sent"] = 0
|
|
266
|
+
session = AgentSession(
|
|
267
|
+
id=session_id,
|
|
268
|
+
provider_id=request.provider_id,
|
|
269
|
+
model=request.model,
|
|
270
|
+
harness=adapter.harness,
|
|
271
|
+
account=request.account,
|
|
272
|
+
role=request.role,
|
|
273
|
+
task=request.task,
|
|
274
|
+
repo_path=str(repo_path),
|
|
275
|
+
worktree_path=worktree_path,
|
|
276
|
+
runtime=RuntimeKind.TMUX,
|
|
277
|
+
state=SessionState.STARTING,
|
|
278
|
+
created_at=now,
|
|
279
|
+
updated_at=now,
|
|
280
|
+
artifact_dir=str(artifact_dir),
|
|
281
|
+
transcript_path=str(artifact_dir / "transcript.txt"),
|
|
282
|
+
events_path=str(artifact_dir / "events.jsonl"),
|
|
283
|
+
metadata=metadata,
|
|
284
|
+
)
|
|
285
|
+
self.store.save_session(session)
|
|
286
|
+
try:
|
|
287
|
+
prompt = adapter.build_initial_prompt(request, session_id, workdir)
|
|
288
|
+
initialize_artifacts(session, prompt)
|
|
289
|
+
command = adapter.build_launch_command(request, workdir)
|
|
290
|
+
if request.initial_prompt_mode == "arg":
|
|
291
|
+
command = [*command, prompt]
|
|
292
|
+
ref = self.runtime.spawn(command, workdir, {}, tmux_name)
|
|
293
|
+
except Exception:
|
|
294
|
+
self.store.update_session_state(session_id, SessionState.FAILED, ended_at=utc_now_iso())
|
|
295
|
+
self._rollback_spawn_files(repo_path, worktree_path, request.provider_id, session_id, artifact_dir)
|
|
296
|
+
raise
|
|
297
|
+
try:
|
|
298
|
+
session.tmux = ref
|
|
299
|
+
session.state = SessionState.RUNNING if request.initial_prompt_mode == "arg" else SessionState.READY
|
|
300
|
+
session.updated_at = datetime.now(timezone.utc)
|
|
301
|
+
self.store.save_session(session)
|
|
302
|
+
except Exception:
|
|
303
|
+
if self.runtime.exists(ref):
|
|
304
|
+
self.runtime.terminate(ref)
|
|
305
|
+
self.store.update_session_state(session_id, SessionState.FAILED, ended_at=utc_now_iso())
|
|
306
|
+
self._rollback_spawn_files(repo_path, worktree_path, request.provider_id, session_id, artifact_dir)
|
|
307
|
+
raise
|
|
308
|
+
event_command = list(command)
|
|
309
|
+
if request.initial_prompt_mode == "arg" and event_command:
|
|
310
|
+
event_command[-1] = "<agentpool-initial-prompt>"
|
|
311
|
+
self._event(
|
|
312
|
+
session,
|
|
313
|
+
"spawn",
|
|
314
|
+
state=session.state.value,
|
|
315
|
+
metadata={"command": event_command, "initial_prompt_mode": request.initial_prompt_mode},
|
|
316
|
+
)
|
|
317
|
+
if request.initial_prompt_mode == "send_after_launch":
|
|
318
|
+
time.sleep(0.3)
|
|
319
|
+
self.runtime.send_message(ref, prompt, submit=True)
|
|
320
|
+
session.state = SessionState.RUNNING
|
|
321
|
+
session.updated_at = datetime.now(timezone.utc)
|
|
322
|
+
session.metadata["initial_prompt_sent"] = True
|
|
323
|
+
self.store.save_session(session)
|
|
324
|
+
self._event(session, "send_initial_prompt", state=session.state.value)
|
|
325
|
+
return {
|
|
326
|
+
"session": session.model_dump(mode="json"),
|
|
327
|
+
"attach_command": self.runtime.attach_command(ref),
|
|
328
|
+
"live_control": {
|
|
329
|
+
"can_capture_screen": True,
|
|
330
|
+
"can_send_message": True,
|
|
331
|
+
"can_send_keys": self.config.policy.allow_raw_keys,
|
|
332
|
+
"can_interrupt": True,
|
|
333
|
+
"can_attach": True,
|
|
334
|
+
"initial_prompt_mode": request.initial_prompt_mode,
|
|
335
|
+
},
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
def observe_worker(
|
|
339
|
+
self,
|
|
340
|
+
session_id: str,
|
|
341
|
+
wait_for: list[str] | None = None,
|
|
342
|
+
timeout_seconds: int = 0,
|
|
343
|
+
include_screen: bool = True,
|
|
344
|
+
include_recent_log: bool = True,
|
|
345
|
+
max_lines: int | None = None,
|
|
346
|
+
) -> ObserveWorkerResponse:
|
|
347
|
+
session = self._require_session(session_id)
|
|
348
|
+
timed_out = self._enforce_deadline(session)
|
|
349
|
+
if timed_out:
|
|
350
|
+
return timed_out
|
|
351
|
+
if not session.tmux:
|
|
352
|
+
raise ToolError("TMUX_SESSION_NOT_FOUND", "Session has no tmux reference.", {"session_id": session_id})
|
|
353
|
+
deadline = time.monotonic() + max(0, timeout_seconds)
|
|
354
|
+
wanted = set(wait_for or [])
|
|
355
|
+
previous_hash = session.metadata.get("last_screen_hash")
|
|
356
|
+
while True:
|
|
357
|
+
timed_out = self._enforce_deadline(session)
|
|
358
|
+
if timed_out:
|
|
359
|
+
return timed_out
|
|
360
|
+
screen = self.runtime.capture(session.tmux, max_lines or self.config.runtime.tmux.capture_lines)
|
|
361
|
+
clean = redact_text(screen)
|
|
362
|
+
detection = detect_event(clean, previous_hash)
|
|
363
|
+
current_hash = screen_hash(clean)
|
|
364
|
+
readiness = _classify_readiness(session, detection, previous_hash, current_hash, clean)
|
|
365
|
+
observe_metadata = {
|
|
366
|
+
"screen_hash": current_hash,
|
|
367
|
+
"readiness": readiness,
|
|
368
|
+
"unchanged_screen": bool(previous_hash and previous_hash == current_hash),
|
|
369
|
+
"startup_warnings": _startup_warning_summary(clean),
|
|
370
|
+
}
|
|
371
|
+
session.state = detection.state
|
|
372
|
+
session.updated_at = datetime.now(timezone.utc)
|
|
373
|
+
session.metadata["last_screen_hash"] = current_hash
|
|
374
|
+
self.store.save_session(session)
|
|
375
|
+
append_transcript(session, clean)
|
|
376
|
+
(Path(session.artifact_dir) / "latest_screen.txt").write_text(clean, encoding="utf-8")
|
|
377
|
+
self._event(
|
|
378
|
+
session,
|
|
379
|
+
f"observe:{detection.event.value}",
|
|
380
|
+
state=session.state.value,
|
|
381
|
+
screen_hash=current_hash,
|
|
382
|
+
excerpt=trim_excerpt(clean, 1000),
|
|
383
|
+
metadata={"readiness": readiness},
|
|
384
|
+
)
|
|
385
|
+
event_value = detection.event.value
|
|
386
|
+
if not wanted or event_value in wanted or _alias_event(event_value) in wanted or timeout_seconds <= 0:
|
|
387
|
+
return ObserveWorkerResponse(
|
|
388
|
+
session_id=session_id,
|
|
389
|
+
state=session.state,
|
|
390
|
+
event=detection.event,
|
|
391
|
+
screen_excerpt=trim_excerpt(clean) if include_screen else None,
|
|
392
|
+
recent_log=trim_excerpt(clean, 2000) if include_recent_log else None,
|
|
393
|
+
parsed_question=detection.parsed_question,
|
|
394
|
+
confidence=detection.confidence,
|
|
395
|
+
metadata=observe_metadata,
|
|
396
|
+
)
|
|
397
|
+
if time.monotonic() >= deadline:
|
|
398
|
+
return ObserveWorkerResponse(
|
|
399
|
+
session_id=session_id,
|
|
400
|
+
state=session.state,
|
|
401
|
+
event=ObserveEvent.TIMEOUT,
|
|
402
|
+
screen_excerpt=trim_excerpt(clean) if include_screen else None,
|
|
403
|
+
recent_log=trim_excerpt(clean, 2000) if include_recent_log else None,
|
|
404
|
+
confidence=detection.confidence,
|
|
405
|
+
metadata={**observe_metadata, "readiness": "timeout"},
|
|
406
|
+
)
|
|
407
|
+
previous_hash = current_hash
|
|
408
|
+
time.sleep(0.5)
|
|
409
|
+
|
|
410
|
+
def send_worker_message(self, session_id: str, message: str, submit: bool = True) -> dict[str, Any]:
|
|
411
|
+
session = self._require_session(session_id)
|
|
412
|
+
self._enforce_deadline_or_raise(session)
|
|
413
|
+
self._enforce_turn_limit(session)
|
|
414
|
+
if not session.tmux:
|
|
415
|
+
raise ToolError("TMUX_SESSION_NOT_FOUND", "Session has no tmux reference.", {"session_id": session_id})
|
|
416
|
+
submit_keys = None
|
|
417
|
+
sent_empty_submit = False
|
|
418
|
+
if submit and message == "":
|
|
419
|
+
submit_keys = ["Enter"]
|
|
420
|
+
self.runtime.send_keys(session.tmux, submit_keys)
|
|
421
|
+
sent_empty_submit = True
|
|
422
|
+
elif submit and not _looks_like_menu_choice(message):
|
|
423
|
+
submit_keys = self.registry.get(session.provider_id).submit_keys()
|
|
424
|
+
if submit_keys and not sent_empty_submit:
|
|
425
|
+
self.runtime.send_message(session.tmux, message, submit=False)
|
|
426
|
+
time.sleep(0.1)
|
|
427
|
+
self.runtime.send_keys(session.tmux, submit_keys)
|
|
428
|
+
elif not sent_empty_submit and (message != "" or not submit):
|
|
429
|
+
self.runtime.send_message(session.tmux, message, submit=submit)
|
|
430
|
+
event_id = self._event(
|
|
431
|
+
session,
|
|
432
|
+
"send_message",
|
|
433
|
+
state=session.state.value,
|
|
434
|
+
metadata={"submit": submit, "submit_keys": submit_keys},
|
|
435
|
+
)
|
|
436
|
+
session.metadata["turns_sent"] = int(session.metadata.get("turns_sent") or 0) + 1
|
|
437
|
+
session.updated_at = datetime.now(timezone.utc)
|
|
438
|
+
self.store.save_session(session)
|
|
439
|
+
append_transcript(session, f"\n[agentpool sent]\n{redact_text(message)}\n")
|
|
440
|
+
return {"ok": True, "session_id": session_id, "event_id": event_id}
|
|
441
|
+
|
|
442
|
+
def send_worker_keys(self, session_id: str, keys: list[str]) -> dict[str, Any]:
|
|
443
|
+
enforce_raw_keys_policy(self.config, keys)
|
|
444
|
+
session = self._require_session(session_id)
|
|
445
|
+
self._enforce_deadline_or_raise(session)
|
|
446
|
+
if not session.tmux:
|
|
447
|
+
raise ToolError("TMUX_SESSION_NOT_FOUND", "Session has no tmux reference.", {"session_id": session_id})
|
|
448
|
+
self.runtime.send_keys(session.tmux, keys)
|
|
449
|
+
self._event(session, "send_keys", state=session.state.value, metadata={"keys": keys})
|
|
450
|
+
return {"ok": True, "session_id": session_id}
|
|
451
|
+
|
|
452
|
+
def interrupt_worker(self, session_id: str) -> dict[str, Any]:
|
|
453
|
+
session = self._require_session(session_id)
|
|
454
|
+
if not session.tmux:
|
|
455
|
+
raise ToolError("TMUX_SESSION_NOT_FOUND", "Session has no tmux reference.", {"session_id": session_id})
|
|
456
|
+
self.runtime.interrupt(session.tmux)
|
|
457
|
+
self._event(session, "interrupt", state=session.state.value)
|
|
458
|
+
return {"ok": True, "state": session.state.value if hasattr(session.state, "value") else session.state}
|
|
459
|
+
|
|
460
|
+
def attach_info(self, session_id: str) -> dict[str, Any]:
|
|
461
|
+
session = self._require_session(session_id)
|
|
462
|
+
if not session.tmux:
|
|
463
|
+
raise ToolError("TMUX_SESSION_NOT_FOUND", "Session has no tmux reference.", {"session_id": session_id})
|
|
464
|
+
return {
|
|
465
|
+
"session_id": session_id,
|
|
466
|
+
"attach_command": self.runtime.attach_command(session.tmux),
|
|
467
|
+
"tmux_session": session.tmux.session_name,
|
|
468
|
+
"pane_target": session.tmux.target,
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
def collect_worker_artifacts(
|
|
472
|
+
self,
|
|
473
|
+
session_id: str,
|
|
474
|
+
include_diff: bool = True,
|
|
475
|
+
include_transcript: bool = True,
|
|
476
|
+
mark_completed: bool = False,
|
|
477
|
+
) -> dict[str, Any]:
|
|
478
|
+
session = self._require_session(session_id)
|
|
479
|
+
screen = ""
|
|
480
|
+
if session.tmux and self.runtime.exists(session.tmux):
|
|
481
|
+
try:
|
|
482
|
+
screen = self.runtime.capture(session.tmux, self.config.runtime.tmux.capture_lines)
|
|
483
|
+
except ToolError as exc:
|
|
484
|
+
if exc.error.code != "TMUX_SESSION_NOT_FOUND":
|
|
485
|
+
raise
|
|
486
|
+
latest_screen = Path(session.artifact_dir) / "latest_screen.txt"
|
|
487
|
+
screen = latest_screen.read_text(encoding="utf-8") if latest_screen.exists() else ""
|
|
488
|
+
screen = redact_text(screen)
|
|
489
|
+
result = collect_artifacts(session, screen, include_diff=include_diff)
|
|
490
|
+
for artifact in result["artifacts"]:
|
|
491
|
+
self.store.save_artifact(session_id, artifact=ArtifactRecord.model_validate(artifact))
|
|
492
|
+
if mark_completed and session.state not in {SessionState.CANCELLED, SessionState.FAILED}:
|
|
493
|
+
session.state = SessionState.COMPLETED
|
|
494
|
+
self.store.update_session_state(session_id, SessionState.COMPLETED, ended_at=utc_now_iso())
|
|
495
|
+
result["state"] = SessionState.COMPLETED.value
|
|
496
|
+
self._event(session, "collect", state=result["state"], metadata={"include_diff": include_diff})
|
|
497
|
+
if not include_transcript:
|
|
498
|
+
result["artifacts"] = [a for a in result["artifacts"] if a["kind"] != "transcript"]
|
|
499
|
+
return result
|
|
500
|
+
|
|
501
|
+
def artifact_manifest(self, session_id: str) -> dict[str, Any]:
|
|
502
|
+
return artifact_manifest(self._require_session(session_id))
|
|
503
|
+
|
|
504
|
+
def read_transcript(
|
|
505
|
+
self,
|
|
506
|
+
session_id: str,
|
|
507
|
+
offset: int = 0,
|
|
508
|
+
limit: int = 4000,
|
|
509
|
+
tail_lines: int | None = None,
|
|
510
|
+
) -> dict[str, Any]:
|
|
511
|
+
session = self._require_session(session_id)
|
|
512
|
+
if offset < 0:
|
|
513
|
+
raise ToolError("INVALID_TRANSCRIPT_RANGE", "offset must be zero or greater.", {"offset": offset})
|
|
514
|
+
if limit <= 0 or limit > 200_000:
|
|
515
|
+
raise ToolError(
|
|
516
|
+
"INVALID_TRANSCRIPT_RANGE",
|
|
517
|
+
"limit must be between 1 and 200000 bytes.",
|
|
518
|
+
{"limit": limit},
|
|
519
|
+
)
|
|
520
|
+
if tail_lines is not None and (tail_lines <= 0 or tail_lines > 10_000):
|
|
521
|
+
raise ToolError(
|
|
522
|
+
"INVALID_TRANSCRIPT_RANGE",
|
|
523
|
+
"tail_lines must be between 1 and 10000.",
|
|
524
|
+
{"tail_lines": tail_lines},
|
|
525
|
+
)
|
|
526
|
+
if tail_lines is not None and offset:
|
|
527
|
+
raise ToolError(
|
|
528
|
+
"INVALID_TRANSCRIPT_RANGE",
|
|
529
|
+
"Use either offset pagination or tail_lines, not both.",
|
|
530
|
+
{"offset": offset, "tail_lines": tail_lines},
|
|
531
|
+
)
|
|
532
|
+
path = Path(session.transcript_path)
|
|
533
|
+
if not path.exists():
|
|
534
|
+
return _transcript_payload(session, path, "", offset=0, next_offset=0, size_bytes=0, mode="missing")
|
|
535
|
+
size_bytes = path.stat().st_size
|
|
536
|
+
if tail_lines is not None:
|
|
537
|
+
lines = deque(maxlen=tail_lines)
|
|
538
|
+
with path.open("r", encoding="utf-8", errors="replace") as fh:
|
|
539
|
+
lines.extend(fh)
|
|
540
|
+
text = "".join(lines)
|
|
541
|
+
return _transcript_payload(
|
|
542
|
+
session,
|
|
543
|
+
path,
|
|
544
|
+
text,
|
|
545
|
+
offset=None,
|
|
546
|
+
next_offset=None,
|
|
547
|
+
size_bytes=size_bytes,
|
|
548
|
+
mode="tail",
|
|
549
|
+
tail_lines=tail_lines,
|
|
550
|
+
has_more=len(lines) == tail_lines and bool(size_bytes),
|
|
551
|
+
)
|
|
552
|
+
start = min(offset, size_bytes)
|
|
553
|
+
with path.open("rb") as fh:
|
|
554
|
+
fh.seek(start)
|
|
555
|
+
raw = fh.read(limit)
|
|
556
|
+
next_offset = start + len(raw)
|
|
557
|
+
return _transcript_payload(
|
|
558
|
+
session,
|
|
559
|
+
path,
|
|
560
|
+
raw.decode("utf-8", errors="replace"),
|
|
561
|
+
offset=start,
|
|
562
|
+
next_offset=next_offset,
|
|
563
|
+
size_bytes=size_bytes,
|
|
564
|
+
mode="page",
|
|
565
|
+
limit=limit,
|
|
566
|
+
has_more=next_offset < size_bytes,
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
def acquire_file_lease(
|
|
570
|
+
self,
|
|
571
|
+
session_id: str,
|
|
572
|
+
file_path: str,
|
|
573
|
+
mode: str = "write",
|
|
574
|
+
ttl_seconds: int | None = None,
|
|
575
|
+
metadata: dict[str, Any] | None = None,
|
|
576
|
+
) -> dict[str, Any]:
|
|
577
|
+
session = self._require_session(session_id)
|
|
578
|
+
if mode not in {"read", "write"}:
|
|
579
|
+
raise ToolError("INVALID_LEASE_MODE", "Lease mode must be read or write.", {"mode": mode})
|
|
580
|
+
expires_at = None
|
|
581
|
+
if ttl_seconds:
|
|
582
|
+
expires_at = datetime.fromtimestamp(time.time() + ttl_seconds, tz=timezone.utc).isoformat()
|
|
583
|
+
normalized = _normalize_file_path(session, file_path)
|
|
584
|
+
lease = self.store.acquire_file_lease(
|
|
585
|
+
session_id=session_id,
|
|
586
|
+
repo_path=session.repo_path,
|
|
587
|
+
file_path=normalized,
|
|
588
|
+
mode=mode,
|
|
589
|
+
expires_at=expires_at,
|
|
590
|
+
metadata=metadata,
|
|
591
|
+
)
|
|
592
|
+
if lease.session_id != session_id:
|
|
593
|
+
raise ToolError(
|
|
594
|
+
"LEASE_CONFLICT",
|
|
595
|
+
f"File is already leased by session {lease.session_id}.",
|
|
596
|
+
{"file_path": normalized, "lease": lease.model_dump(mode="json")},
|
|
597
|
+
)
|
|
598
|
+
self._event(session, "lease_acquire", state=session.state.value, metadata={"file_path": normalized, "mode": mode})
|
|
599
|
+
return {"ok": True, "lease": lease.model_dump(mode="json")}
|
|
600
|
+
|
|
601
|
+
def list_file_leases(
|
|
602
|
+
self,
|
|
603
|
+
session_id: str | None = None,
|
|
604
|
+
repo_path: str | None = None,
|
|
605
|
+
active_only: bool = True,
|
|
606
|
+
) -> dict[str, Any]:
|
|
607
|
+
normalized_repo = str(Path(repo_path).expanduser().resolve()) if repo_path else None
|
|
608
|
+
leases = self.store.list_file_leases(session_id=session_id, repo_path=normalized_repo, active_only=active_only)
|
|
609
|
+
return {"leases": [lease.model_dump(mode="json") for lease in leases]}
|
|
610
|
+
|
|
611
|
+
def release_file_lease(
|
|
612
|
+
self,
|
|
613
|
+
lease_id: int | None = None,
|
|
614
|
+
session_id: str | None = None,
|
|
615
|
+
file_path: str | None = None,
|
|
616
|
+
) -> dict[str, Any]:
|
|
617
|
+
normalized = file_path
|
|
618
|
+
if session_id and file_path:
|
|
619
|
+
normalized = _normalize_file_path(self._require_session(session_id), file_path)
|
|
620
|
+
released = self.store.release_file_lease(lease_id=lease_id, session_id=session_id, file_path=normalized)
|
|
621
|
+
if session_id:
|
|
622
|
+
session = self._require_session(session_id)
|
|
623
|
+
self._event(session, "lease_release", state=session.state.value, metadata={"lease_id": lease_id, "file_path": normalized})
|
|
624
|
+
return {"ok": True, "released": released}
|
|
625
|
+
|
|
626
|
+
def list_worktrees(self, repo_path: str) -> dict[str, Any]:
|
|
627
|
+
repo = Path(repo_path).expanduser().resolve()
|
|
628
|
+
active_worktrees = {
|
|
629
|
+
str(session.worktree_path)
|
|
630
|
+
for session in self.store.list_sessions()
|
|
631
|
+
if session.worktree_path and active_state(session.state)
|
|
632
|
+
}
|
|
633
|
+
worktrees = []
|
|
634
|
+
for entry in list_agentpool_worktrees(repo):
|
|
635
|
+
path = str(entry.get("path") or "")
|
|
636
|
+
worktrees.append({**entry, "active": path in active_worktrees})
|
|
637
|
+
return {"repo_path": str(repo), "worktrees": worktrees}
|
|
638
|
+
|
|
639
|
+
def cleanup_worktree(self, session_id: str, force: bool = False) -> dict[str, Any]:
|
|
640
|
+
session = self._require_session(session_id)
|
|
641
|
+
if not session.worktree_path:
|
|
642
|
+
return {"removed": False, "reason": "session has no worktree", "session_id": session_id}
|
|
643
|
+
if active_state(session.state) and not force:
|
|
644
|
+
raise ToolError(
|
|
645
|
+
"WORKTREE_ACTIVE",
|
|
646
|
+
"Refusing to remove an active session worktree; terminate first or pass force.",
|
|
647
|
+
{"session_id": session_id, "state": session.state.value if hasattr(session.state, "value") else session.state},
|
|
648
|
+
)
|
|
649
|
+
result = cleanup_worktree(Path(session.repo_path), Path(session.worktree_path), force=force)
|
|
650
|
+
self._event(session, "worktree_cleanup", state=session.state.value, metadata={"force": force, **result})
|
|
651
|
+
return {"session_id": session_id, **result}
|
|
652
|
+
|
|
653
|
+
def list_sessions(
|
|
654
|
+
self,
|
|
655
|
+
states: list[str] | str | None = None,
|
|
656
|
+
provider_id: str | None = None,
|
|
657
|
+
include_all: bool = False,
|
|
658
|
+
limit: int | None = DEFAULT_SESSION_LIMIT,
|
|
659
|
+
offset: int = 0,
|
|
660
|
+
) -> dict[str, Any]:
|
|
661
|
+
self.reconcile_sessions()
|
|
662
|
+
limit = _normalize_session_limit(limit)
|
|
663
|
+
if offset < 0:
|
|
664
|
+
raise ToolError("INVALID_SESSION_PAGE", "offset must be zero or greater.", {"offset": offset})
|
|
665
|
+
normalized_states = _normalize_state_filter(states)
|
|
666
|
+
if self.scope_sessions_by_coordinator and not include_all:
|
|
667
|
+
all_sessions = [
|
|
668
|
+
session
|
|
669
|
+
for session in self.store.list_sessions(normalized_states, provider_id)
|
|
670
|
+
if self._session_in_scope(session)
|
|
671
|
+
]
|
|
672
|
+
total = len(all_sessions)
|
|
673
|
+
sessions = all_sessions[offset:] if limit is None else all_sessions[offset : offset + limit]
|
|
674
|
+
else:
|
|
675
|
+
total = self.store.count_sessions(normalized_states, provider_id)
|
|
676
|
+
sessions = self.store.list_sessions(normalized_states, provider_id, limit=limit, offset=offset)
|
|
677
|
+
next_offset = offset + len(sessions)
|
|
678
|
+
has_more = next_offset < total
|
|
679
|
+
return {
|
|
680
|
+
"sessions": [session.model_dump(mode="json") for session in sessions],
|
|
681
|
+
"pagination": {
|
|
682
|
+
"limit": limit,
|
|
683
|
+
"offset": offset,
|
|
684
|
+
"count": len(sessions),
|
|
685
|
+
"total": total,
|
|
686
|
+
"has_more": has_more,
|
|
687
|
+
"next_offset": next_offset if has_more else None,
|
|
688
|
+
},
|
|
689
|
+
"scope": {
|
|
690
|
+
"coordinator_id": self.coordinator_id,
|
|
691
|
+
"current_coordinator_only": self.scope_sessions_by_coordinator and not include_all,
|
|
692
|
+
},
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
def get_session(self, session_id: str) -> dict[str, Any]:
|
|
696
|
+
return {"session": self._require_session(session_id).model_dump(mode="json")}
|
|
697
|
+
|
|
698
|
+
def terminate_worker(self, session_id: str, reason: str | None = None) -> dict[str, Any]:
|
|
699
|
+
session = self._require_session(session_id)
|
|
700
|
+
if session.tmux and self.runtime.exists(session.tmux):
|
|
701
|
+
self.runtime.terminate(session.tmux)
|
|
702
|
+
state = session.state if isinstance(session.state, SessionState) else SessionState(session.state)
|
|
703
|
+
final_state = state if not active_state(state) else SessionState.CANCELLED
|
|
704
|
+
self.store.update_session_state(session_id, final_state, ended_at=utc_now_iso())
|
|
705
|
+
self._event(session, "terminate", state=final_state.value, metadata={"reason": reason})
|
|
706
|
+
return {"ok": True, "state": final_state.value}
|
|
707
|
+
|
|
708
|
+
def reconcile_sessions(self) -> dict[str, Any]:
|
|
709
|
+
reconciled = []
|
|
710
|
+
for session in self.store.list_sessions():
|
|
711
|
+
if not active_state(session.state) or not session.tmux:
|
|
712
|
+
continue
|
|
713
|
+
timed_out = self._enforce_deadline(session)
|
|
714
|
+
if timed_out:
|
|
715
|
+
reconciled.append(session.id)
|
|
716
|
+
continue
|
|
717
|
+
if self.runtime.exists(session.tmux):
|
|
718
|
+
continue
|
|
719
|
+
self.store.update_session_state(session.id, SessionState.FAILED, ended_at=utc_now_iso())
|
|
720
|
+
self._event(
|
|
721
|
+
session,
|
|
722
|
+
"reconcile_dead_tmux",
|
|
723
|
+
state=SessionState.FAILED.value,
|
|
724
|
+
metadata={"tmux_session": session.tmux.session_name},
|
|
725
|
+
)
|
|
726
|
+
reconciled.append(session.id)
|
|
727
|
+
return {"reconciled": reconciled, "count": len(reconciled)}
|
|
728
|
+
|
|
729
|
+
def _require_session(self, session_id: str) -> AgentSession:
|
|
730
|
+
session = self.store.get_session(session_id)
|
|
731
|
+
if not session:
|
|
732
|
+
raise ToolError("TMUX_SESSION_NOT_FOUND", f"Session {session_id} was not found.", {"session_id": session_id})
|
|
733
|
+
return session
|
|
734
|
+
|
|
735
|
+
def _session_in_scope(self, session: AgentSession) -> bool:
|
|
736
|
+
if not self.scope_sessions_by_coordinator:
|
|
737
|
+
return True
|
|
738
|
+
return (session.metadata or {}).get("coordinator_id") == self.coordinator_id
|
|
739
|
+
|
|
740
|
+
def _event(
|
|
741
|
+
self,
|
|
742
|
+
session: AgentSession,
|
|
743
|
+
event_type: str,
|
|
744
|
+
state: str | None = None,
|
|
745
|
+
screen_hash: str | None = None,
|
|
746
|
+
excerpt: str | None = None,
|
|
747
|
+
metadata: dict[str, Any] | None = None,
|
|
748
|
+
) -> int:
|
|
749
|
+
event_id = self.store.append_event(
|
|
750
|
+
session.id,
|
|
751
|
+
event_type,
|
|
752
|
+
state=state,
|
|
753
|
+
screen_hash=screen_hash,
|
|
754
|
+
excerpt=excerpt,
|
|
755
|
+
metadata=metadata,
|
|
756
|
+
)
|
|
757
|
+
append_jsonl(
|
|
758
|
+
Path(session.events_path),
|
|
759
|
+
{
|
|
760
|
+
"event_id": event_id,
|
|
761
|
+
"ts": utc_now_iso(),
|
|
762
|
+
"event_type": event_type,
|
|
763
|
+
"state": state,
|
|
764
|
+
"screen_hash": screen_hash,
|
|
765
|
+
"excerpt": excerpt,
|
|
766
|
+
"metadata": metadata or {},
|
|
767
|
+
},
|
|
768
|
+
)
|
|
769
|
+
return event_id
|
|
770
|
+
|
|
771
|
+
def _enforce_cached_usage_policy(self, provider_id: str) -> None:
|
|
772
|
+
snapshots = self.store.latest_usage_snapshots(provider_id)
|
|
773
|
+
if not snapshots:
|
|
774
|
+
return
|
|
775
|
+
snapshot = snapshots[0]
|
|
776
|
+
blocked = set(self.config.policy.block_on_usage_statuses)
|
|
777
|
+
status = snapshot.status.value if hasattr(snapshot.status, "value") else str(snapshot.status)
|
|
778
|
+
if status not in blocked:
|
|
779
|
+
return
|
|
780
|
+
raise ToolError(
|
|
781
|
+
"USAGE_POLICY_BLOCKED",
|
|
782
|
+
f"Provider {provider_id} is blocked by cached usage status: {status}.",
|
|
783
|
+
{
|
|
784
|
+
"provider_id": provider_id,
|
|
785
|
+
"status": status,
|
|
786
|
+
"policy": "block_on_usage_statuses",
|
|
787
|
+
"source": "sqlite_cache",
|
|
788
|
+
"checked_at": snapshot.checked_at.isoformat(),
|
|
789
|
+
},
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
def _configured_usage_snapshots(
|
|
793
|
+
self,
|
|
794
|
+
snapshots: list[Any],
|
|
795
|
+
provider_id: str | None = None,
|
|
796
|
+
) -> list[Any]:
|
|
797
|
+
if provider_id:
|
|
798
|
+
return snapshots
|
|
799
|
+
configured = set(self.config.providers)
|
|
800
|
+
return [snapshot for snapshot in snapshots if snapshot.provider_id in configured]
|
|
801
|
+
|
|
802
|
+
def _enforce_deadline(self, session: AgentSession) -> ObserveWorkerResponse | None:
|
|
803
|
+
deadline_at = session.metadata.get("deadline_at")
|
|
804
|
+
if not deadline_at or not active_state(session.state):
|
|
805
|
+
return None
|
|
806
|
+
try:
|
|
807
|
+
deadline = datetime.fromisoformat(str(deadline_at))
|
|
808
|
+
except ValueError:
|
|
809
|
+
return None
|
|
810
|
+
if deadline.tzinfo is None:
|
|
811
|
+
deadline = deadline.replace(tzinfo=timezone.utc)
|
|
812
|
+
if datetime.now(timezone.utc) <= deadline:
|
|
813
|
+
return None
|
|
814
|
+
if session.tmux and self.runtime.exists(session.tmux):
|
|
815
|
+
self.runtime.terminate(session.tmux)
|
|
816
|
+
session.state = SessionState.CANCELLED
|
|
817
|
+
session.ended_at = datetime.now(timezone.utc)
|
|
818
|
+
self.store.update_session_state(session.id, SessionState.CANCELLED, ended_at=session.ended_at.isoformat())
|
|
819
|
+
self._event(session, "timeout", state=SessionState.CANCELLED.value, metadata={"deadline_at": str(deadline_at)})
|
|
820
|
+
return ObserveWorkerResponse(
|
|
821
|
+
session_id=session.id,
|
|
822
|
+
state=SessionState.CANCELLED,
|
|
823
|
+
event=ObserveEvent.TIMEOUT,
|
|
824
|
+
confidence="observed",
|
|
825
|
+
metadata={"deadline_at": str(deadline_at)},
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
def _enforce_deadline_or_raise(self, session: AgentSession) -> None:
|
|
829
|
+
timed_out = self._enforce_deadline(session)
|
|
830
|
+
if timed_out:
|
|
831
|
+
raise ToolError(
|
|
832
|
+
"SESSION_TIMEOUT",
|
|
833
|
+
"Session exceeded max_runtime_seconds and was terminated.",
|
|
834
|
+
{"session_id": session.id, **timed_out.metadata},
|
|
835
|
+
)
|
|
836
|
+
|
|
837
|
+
def _enforce_turn_limit(self, session: AgentSession) -> None:
|
|
838
|
+
max_turns = session.metadata.get("max_turns")
|
|
839
|
+
if max_turns is None:
|
|
840
|
+
return
|
|
841
|
+
turns_sent = int(session.metadata.get("turns_sent") or 0)
|
|
842
|
+
if turns_sent < int(max_turns):
|
|
843
|
+
return
|
|
844
|
+
raise ToolError(
|
|
845
|
+
"TURN_LIMIT_REACHED",
|
|
846
|
+
"Session reached max_turns.",
|
|
847
|
+
{"session_id": session.id, "max_turns": max_turns, "turns_sent": turns_sent},
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
def _rollback_spawn_files(
|
|
851
|
+
self,
|
|
852
|
+
repo_path: Path,
|
|
853
|
+
worktree_path: str | None,
|
|
854
|
+
provider_id: str,
|
|
855
|
+
session_id: str,
|
|
856
|
+
artifact_dir: Path | None,
|
|
857
|
+
) -> None:
|
|
858
|
+
if worktree_path:
|
|
859
|
+
try:
|
|
860
|
+
cleanup_worktree(repo_path, Path(worktree_path), force=True)
|
|
861
|
+
except ToolError:
|
|
862
|
+
pass
|
|
863
|
+
delete_agentpool_branch(repo_path, provider_id, session_id)
|
|
864
|
+
if artifact_dir and artifact_dir.exists():
|
|
865
|
+
shutil.rmtree(artifact_dir, ignore_errors=True)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _tmux_name(prefix: str, provider_id: str, session_id: str) -> str:
|
|
869
|
+
safe_provider = "".join(ch if ch.isalnum() else "-" for ch in provider_id).strip("-")
|
|
870
|
+
return f"{prefix}-{safe_provider}-{session_id[-6:]}"
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _default_model(metadata: dict[str, Any]) -> str | None:
|
|
874
|
+
value = metadata.get("default_model")
|
|
875
|
+
return str(value) if value else None
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _normalize_state_filter(states: list[str] | str | None) -> list[str] | None:
|
|
879
|
+
if states is None:
|
|
880
|
+
return None
|
|
881
|
+
raw_states = [states] if isinstance(states, str) else list(states)
|
|
882
|
+
normalized: list[str] = []
|
|
883
|
+
for state in raw_states:
|
|
884
|
+
value = str(state)
|
|
885
|
+
try:
|
|
886
|
+
normalized.append(SessionState(value).value)
|
|
887
|
+
continue
|
|
888
|
+
except ValueError:
|
|
889
|
+
pass
|
|
890
|
+
upper = value.upper()
|
|
891
|
+
try:
|
|
892
|
+
normalized.append(SessionState(upper).value)
|
|
893
|
+
except ValueError:
|
|
894
|
+
normalized.append(value)
|
|
895
|
+
return normalized
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _normalize_session_limit(limit: int | None) -> int | None:
|
|
899
|
+
if limit is None:
|
|
900
|
+
return None
|
|
901
|
+
if limit <= 0 or limit > MAX_SESSION_LIMIT:
|
|
902
|
+
raise ToolError(
|
|
903
|
+
"INVALID_SESSION_PAGE",
|
|
904
|
+
f"limit must be between 1 and {MAX_SESSION_LIMIT}.",
|
|
905
|
+
{"limit": limit, "max_limit": MAX_SESSION_LIMIT},
|
|
906
|
+
)
|
|
907
|
+
return limit
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _session_summary(session: AgentSession) -> dict[str, Any]:
|
|
911
|
+
metadata = session.metadata or {}
|
|
912
|
+
return {
|
|
913
|
+
"id": session.id,
|
|
914
|
+
"provider_id": session.provider_id,
|
|
915
|
+
"state": session.state.value if hasattr(session.state, "value") else session.state,
|
|
916
|
+
"created_at": session.created_at.isoformat(),
|
|
917
|
+
"deadline_at": metadata.get("deadline_at"),
|
|
918
|
+
"tmux_session": session.tmux.session_name if session.tmux else None,
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _default_reasoning_effort(
|
|
923
|
+
provider_metadata: dict[str, Any],
|
|
924
|
+
models: list[dict[str, Any]],
|
|
925
|
+
model: str | None,
|
|
926
|
+
) -> str | None:
|
|
927
|
+
if not model or not provider_metadata.get("reasoning_effort_config_key"):
|
|
928
|
+
return None
|
|
929
|
+
for entry in models:
|
|
930
|
+
if not isinstance(entry, dict) or entry.get("id") != model:
|
|
931
|
+
continue
|
|
932
|
+
model_metadata = entry.get("metadata") or {}
|
|
933
|
+
reasoning = model_metadata.get("reasoning") if isinstance(model_metadata, dict) else None
|
|
934
|
+
if isinstance(reasoning, dict) and reasoning.get("default"):
|
|
935
|
+
return str(reasoning["default"])
|
|
936
|
+
return None
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _effective_initial_prompt_mode(requested: str, metadata: dict[str, Any]) -> str:
|
|
940
|
+
if requested != "provider_default":
|
|
941
|
+
return requested
|
|
942
|
+
configured = metadata.get("default_initial_prompt_mode")
|
|
943
|
+
if configured in {"send_after_launch", "arg", "stdin"}:
|
|
944
|
+
return str(configured)
|
|
945
|
+
return "send_after_launch"
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _alias_event(event: str) -> str:
|
|
949
|
+
return {"approval_prompt": "approval"}.get(event, event)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _looks_like_menu_choice(message: str) -> bool:
|
|
953
|
+
stripped = message.strip()
|
|
954
|
+
return bool(stripped and len(stripped) <= 3 and stripped.isalnum())
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def _classify_readiness(
|
|
958
|
+
session: AgentSession,
|
|
959
|
+
detection: Any,
|
|
960
|
+
previous_hash: str | None,
|
|
961
|
+
current_hash: str,
|
|
962
|
+
screen: str,
|
|
963
|
+
) -> str:
|
|
964
|
+
if detection.event == ObserveEvent.COMPLETED:
|
|
965
|
+
return "completed"
|
|
966
|
+
if detection.event == ObserveEvent.ERROR:
|
|
967
|
+
return "failed"
|
|
968
|
+
if detection.event == ObserveEvent.QUESTION:
|
|
969
|
+
return "waiting_on_question"
|
|
970
|
+
if detection.event == ObserveEvent.OVERAGE_PROMPT:
|
|
971
|
+
return "waiting_on_overage_prompt"
|
|
972
|
+
if detection.event == ObserveEvent.APPROVAL_PROMPT:
|
|
973
|
+
return "waiting_on_startup_prompt" if _looks_like_startup_prompt(screen) else "waiting_on_approval"
|
|
974
|
+
if detection.event == ObserveEvent.LIMIT_WARNING:
|
|
975
|
+
return "running_limit_warning"
|
|
976
|
+
if previous_hash and previous_hash == current_hash:
|
|
977
|
+
return "stuck_unchanged_screen"
|
|
978
|
+
if session.state == SessionState.READY and session.metadata.get("initial_prompt_mode") == "send_after_launch":
|
|
979
|
+
return "pasted_but_not_submitted"
|
|
980
|
+
return "running"
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _looks_like_startup_prompt(screen: str) -> bool:
|
|
984
|
+
lowered = screen.lower()
|
|
985
|
+
return any(
|
|
986
|
+
phrase in lowered
|
|
987
|
+
for phrase in [
|
|
988
|
+
"update available",
|
|
989
|
+
"do you trust the contents of this directory",
|
|
990
|
+
"hooks need review",
|
|
991
|
+
"mcp startup",
|
|
992
|
+
"mcp client for",
|
|
993
|
+
]
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _startup_warning_summary(screen: str) -> list[str]:
|
|
998
|
+
warnings = []
|
|
999
|
+
lowered = screen.lower()
|
|
1000
|
+
if "update available" in lowered:
|
|
1001
|
+
warnings.append("update_available")
|
|
1002
|
+
if "mcp client for" in lowered or "mcp startup" in lowered:
|
|
1003
|
+
warnings.append("mcp_startup_warning")
|
|
1004
|
+
if "do you trust the contents of this directory" in lowered:
|
|
1005
|
+
warnings.append("directory_trust_prompt")
|
|
1006
|
+
if "hooks need review" in lowered:
|
|
1007
|
+
warnings.append("hooks_need_review")
|
|
1008
|
+
return warnings
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _normalize_file_path(session: AgentSession, file_path: str) -> str:
|
|
1012
|
+
raw = Path(file_path).expanduser()
|
|
1013
|
+
if not raw.is_absolute():
|
|
1014
|
+
return raw.as_posix()
|
|
1015
|
+
for base in [session.worktree_path, session.repo_path]:
|
|
1016
|
+
if not base:
|
|
1017
|
+
continue
|
|
1018
|
+
try:
|
|
1019
|
+
return raw.resolve().relative_to(Path(base).expanduser().resolve()).as_posix()
|
|
1020
|
+
except ValueError:
|
|
1021
|
+
continue
|
|
1022
|
+
return raw.as_posix()
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _transcript_payload(
|
|
1026
|
+
session: AgentSession,
|
|
1027
|
+
path: Path,
|
|
1028
|
+
text: str,
|
|
1029
|
+
*,
|
|
1030
|
+
offset: int | None,
|
|
1031
|
+
next_offset: int | None,
|
|
1032
|
+
size_bytes: int,
|
|
1033
|
+
mode: str,
|
|
1034
|
+
limit: int | None = None,
|
|
1035
|
+
tail_lines: int | None = None,
|
|
1036
|
+
has_more: bool = False,
|
|
1037
|
+
) -> dict[str, Any]:
|
|
1038
|
+
return {
|
|
1039
|
+
"session_id": session.id,
|
|
1040
|
+
"path": str(path),
|
|
1041
|
+
"exists": path.exists(),
|
|
1042
|
+
"mode": mode,
|
|
1043
|
+
"offset": offset,
|
|
1044
|
+
"limit": limit,
|
|
1045
|
+
"tail_lines": tail_lines,
|
|
1046
|
+
"next_offset": next_offset,
|
|
1047
|
+
"has_more": has_more,
|
|
1048
|
+
"size_bytes": size_bytes,
|
|
1049
|
+
"text": text,
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def manager_from_config(path: Path | None = None) -> SessionManager:
|
|
1054
|
+
config = load_config(path)
|
|
1055
|
+
return SessionManager(config=config)
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def write_default_config(path: Path) -> None:
|
|
1059
|
+
config = load_config(Path("__missing_agentpool_config__.yaml"))
|
|
1060
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1061
|
+
write_json(path, config.model_dump(mode="json"))
|