agent-orchestration-process 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.
- agent_orchestration_process/__init__.py +6 -0
- agent_orchestration_process/__main__.py +5 -0
- agent_orchestration_process/batch.py +385 -0
- agent_orchestration_process/cli.py +456 -0
- agent_orchestration_process/integration.py +469 -0
- agent_orchestration_process/locks.py +33 -0
- agent_orchestration_process/model_catalog.py +157 -0
- agent_orchestration_process/model_listing.py +457 -0
- agent_orchestration_process/models.py +117 -0
- agent_orchestration_process/pricing.py +204 -0
- agent_orchestration_process/runner.py +3208 -0
- agent_orchestration_process/worktrees.py +340 -0
- agent_orchestration_process-0.1.0.dist-info/METADATA +1025 -0
- agent_orchestration_process-0.1.0.dist-info/RECORD +17 -0
- agent_orchestration_process-0.1.0.dist-info/WHEEL +4 -0
- agent_orchestration_process-0.1.0.dist-info/entry_points.txt +3 -0
- agent_orchestration_process-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Bounded parallel execution of task manifests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
import tomllib
|
|
9
|
+
import uuid
|
|
10
|
+
from concurrent.futures import FIRST_COMPLETED, Future, ThreadPoolExecutor, wait
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable
|
|
15
|
+
|
|
16
|
+
from .model_listing import AGENTS
|
|
17
|
+
from .runner import AgentRunner, adapter_for, normalize_artifacts
|
|
18
|
+
from .worktrees import AOPError, TASK_ID, WorktreeManager
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
|
|
22
|
+
MODES = {"agent", "participant"}
|
|
23
|
+
SANDBOXES = {"workspace-write", "scratch-write", "danger-full-access"}
|
|
24
|
+
TASK_FIELDS = {
|
|
25
|
+
"id",
|
|
26
|
+
"agent",
|
|
27
|
+
"prompt",
|
|
28
|
+
"prompt_file",
|
|
29
|
+
"base",
|
|
30
|
+
"model",
|
|
31
|
+
"mode",
|
|
32
|
+
"effort",
|
|
33
|
+
"sandbox",
|
|
34
|
+
"timeout",
|
|
35
|
+
"artifacts",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now() -> str:
|
|
40
|
+
return datetime.now(UTC).isoformat()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class BatchTask:
|
|
45
|
+
id: str
|
|
46
|
+
prompt: str
|
|
47
|
+
prompt_source: str
|
|
48
|
+
agent: str = "codex"
|
|
49
|
+
base: str = "HEAD"
|
|
50
|
+
model: str | None = None
|
|
51
|
+
effort: str | None = None
|
|
52
|
+
mode: str = "agent"
|
|
53
|
+
sandbox: str = "workspace-write"
|
|
54
|
+
timeout_seconds: float | None = None
|
|
55
|
+
artifacts: tuple[str, ...] = ()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class BatchTaskResult:
|
|
60
|
+
task: str
|
|
61
|
+
agent: str
|
|
62
|
+
status: str
|
|
63
|
+
mode: str
|
|
64
|
+
model: str | None
|
|
65
|
+
effort: str | None
|
|
66
|
+
run_id: str | None
|
|
67
|
+
session_id: str | None
|
|
68
|
+
duration_seconds: float | None
|
|
69
|
+
input_tokens: int | None
|
|
70
|
+
cached_input_tokens: int | None
|
|
71
|
+
output_tokens: int | None
|
|
72
|
+
reasoning_output_tokens: int | None
|
|
73
|
+
api_equivalent_cost_usd: float | None
|
|
74
|
+
billing_route: str | None
|
|
75
|
+
exit_code: int | None
|
|
76
|
+
error: str | None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class BatchResult:
|
|
81
|
+
batch_id: str
|
|
82
|
+
manifest: str
|
|
83
|
+
jobs: int
|
|
84
|
+
started_at: str
|
|
85
|
+
finished_at: str
|
|
86
|
+
duration_seconds: float
|
|
87
|
+
interrupted: bool
|
|
88
|
+
tasks: list[BatchTaskResult]
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def succeeded(self) -> bool:
|
|
92
|
+
return not self.interrupted and all(
|
|
93
|
+
task.status == "succeeded" for task in self.tasks
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> dict[str, object]:
|
|
97
|
+
return {
|
|
98
|
+
**asdict(self),
|
|
99
|
+
"succeeded": self.succeeded,
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class BatchRunner:
|
|
104
|
+
def __init__(self, manager: WorktreeManager, jobs: int = 4):
|
|
105
|
+
if jobs < 1:
|
|
106
|
+
raise AOPError("jobs must be greater than zero")
|
|
107
|
+
self.manager = manager
|
|
108
|
+
self.jobs = jobs
|
|
109
|
+
|
|
110
|
+
def run(
|
|
111
|
+
self,
|
|
112
|
+
manifest_path: Path,
|
|
113
|
+
progress: Callable[[str], None] | None = None,
|
|
114
|
+
) -> BatchResult:
|
|
115
|
+
report = progress or (lambda _message: None)
|
|
116
|
+
manifest = manifest_path.resolve()
|
|
117
|
+
tasks = load_manifest(manifest)
|
|
118
|
+
batch_id = str(uuid.uuid4())
|
|
119
|
+
started_at = _now()
|
|
120
|
+
started = time.monotonic()
|
|
121
|
+
outcomes: dict[str, BatchTaskResult] = {}
|
|
122
|
+
active: dict[Future[BatchTaskResult], BatchTask] = {}
|
|
123
|
+
next_task = 0
|
|
124
|
+
interrupted = False
|
|
125
|
+
|
|
126
|
+
executor = ThreadPoolExecutor(max_workers=self.jobs, thread_name_prefix="aop")
|
|
127
|
+
|
|
128
|
+
def submit_available() -> None:
|
|
129
|
+
nonlocal next_task
|
|
130
|
+
while len(active) < self.jobs and next_task < len(tasks):
|
|
131
|
+
task = tasks[next_task]
|
|
132
|
+
next_task += 1
|
|
133
|
+
report(f"[{task.id}] started")
|
|
134
|
+
active[executor.submit(self._execute_task, task)] = task
|
|
135
|
+
|
|
136
|
+
def collect(future: Future[BatchTaskResult], task: BatchTask) -> None:
|
|
137
|
+
outcome = future.result()
|
|
138
|
+
outcomes[task.id] = outcome
|
|
139
|
+
detail = f" run_id={outcome.run_id}" if outcome.run_id else ""
|
|
140
|
+
report(f"[{task.id}] {outcome.status}{detail}")
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
submit_available()
|
|
144
|
+
while active:
|
|
145
|
+
completed, _ = wait(active, return_when=FIRST_COMPLETED)
|
|
146
|
+
for future in completed:
|
|
147
|
+
task = active.pop(future)
|
|
148
|
+
collect(future, task)
|
|
149
|
+
submit_available()
|
|
150
|
+
except KeyboardInterrupt:
|
|
151
|
+
interrupted = True
|
|
152
|
+
report(
|
|
153
|
+
"batch interrupted; waiting for active tasks and launching no new work"
|
|
154
|
+
)
|
|
155
|
+
for future, task in list(active.items()):
|
|
156
|
+
collect(future, task)
|
|
157
|
+
active.clear()
|
|
158
|
+
finally:
|
|
159
|
+
executor.shutdown(wait=True, cancel_futures=True)
|
|
160
|
+
|
|
161
|
+
for task in tasks:
|
|
162
|
+
if task.id not in outcomes:
|
|
163
|
+
outcomes[task.id] = BatchTaskResult(
|
|
164
|
+
task=task.id,
|
|
165
|
+
agent=task.agent,
|
|
166
|
+
status="not_started",
|
|
167
|
+
mode=task.mode,
|
|
168
|
+
model=task.model,
|
|
169
|
+
effort=task.effort,
|
|
170
|
+
run_id=None,
|
|
171
|
+
session_id=None,
|
|
172
|
+
duration_seconds=None,
|
|
173
|
+
input_tokens=None,
|
|
174
|
+
cached_input_tokens=None,
|
|
175
|
+
output_tokens=None,
|
|
176
|
+
reasoning_output_tokens=None,
|
|
177
|
+
api_equivalent_cost_usd=None,
|
|
178
|
+
billing_route=None,
|
|
179
|
+
exit_code=None,
|
|
180
|
+
error="batch interrupted before launch",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
result = BatchResult(
|
|
184
|
+
batch_id=batch_id,
|
|
185
|
+
manifest=os.fspath(manifest),
|
|
186
|
+
jobs=self.jobs,
|
|
187
|
+
started_at=started_at,
|
|
188
|
+
finished_at=_now(),
|
|
189
|
+
duration_seconds=round(time.monotonic() - started, 6),
|
|
190
|
+
interrupted=interrupted,
|
|
191
|
+
tasks=[outcomes[task.id] for task in tasks],
|
|
192
|
+
)
|
|
193
|
+
self._write_result(result)
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
def _execute_task(self, task: BatchTask) -> BatchTaskResult:
|
|
197
|
+
try:
|
|
198
|
+
result = AgentRunner(self.manager, adapter_for(task.agent)).run(
|
|
199
|
+
task=task.id,
|
|
200
|
+
prompt=task.prompt,
|
|
201
|
+
base=task.base,
|
|
202
|
+
model=task.model,
|
|
203
|
+
effort=task.effort,
|
|
204
|
+
mode=task.mode,
|
|
205
|
+
sandbox=task.sandbox,
|
|
206
|
+
timeout_seconds=task.timeout_seconds,
|
|
207
|
+
artifacts=task.artifacts,
|
|
208
|
+
)
|
|
209
|
+
except Exception as error:
|
|
210
|
+
return BatchTaskResult(
|
|
211
|
+
task=task.id,
|
|
212
|
+
agent=task.agent,
|
|
213
|
+
status="error",
|
|
214
|
+
mode=task.mode,
|
|
215
|
+
model=task.model,
|
|
216
|
+
effort=task.effort,
|
|
217
|
+
run_id=None,
|
|
218
|
+
session_id=None,
|
|
219
|
+
duration_seconds=None,
|
|
220
|
+
input_tokens=None,
|
|
221
|
+
cached_input_tokens=None,
|
|
222
|
+
output_tokens=None,
|
|
223
|
+
reasoning_output_tokens=None,
|
|
224
|
+
api_equivalent_cost_usd=None,
|
|
225
|
+
billing_route=None,
|
|
226
|
+
exit_code=None,
|
|
227
|
+
error=str(error),
|
|
228
|
+
)
|
|
229
|
+
return BatchTaskResult(
|
|
230
|
+
task=task.id,
|
|
231
|
+
agent=task.agent,
|
|
232
|
+
status="succeeded" if result.succeeded else "failed",
|
|
233
|
+
mode=result.mode,
|
|
234
|
+
model=result.model,
|
|
235
|
+
effort=result.effort,
|
|
236
|
+
run_id=result.run_id,
|
|
237
|
+
session_id=result.session_id,
|
|
238
|
+
duration_seconds=result.duration_seconds,
|
|
239
|
+
input_tokens=result.usage.input_tokens,
|
|
240
|
+
cached_input_tokens=result.usage.cached_input_tokens,
|
|
241
|
+
output_tokens=result.usage.output_tokens,
|
|
242
|
+
reasoning_output_tokens=result.usage.reasoning_output_tokens,
|
|
243
|
+
api_equivalent_cost_usd=(
|
|
244
|
+
result.api_equivalent_cost.amount_usd
|
|
245
|
+
if result.api_equivalent_cost
|
|
246
|
+
else None
|
|
247
|
+
),
|
|
248
|
+
billing_route=result.billing.route,
|
|
249
|
+
exit_code=result.exit_code,
|
|
250
|
+
error=result.error,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def _write_result(self, result: BatchResult) -> None:
|
|
254
|
+
directory = self.manager.state_dir / "batches"
|
|
255
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
destination = directory / f"{result.batch_id}.json"
|
|
257
|
+
temporary = directory / f".{result.batch_id}.{uuid.uuid4().hex}.tmp"
|
|
258
|
+
temporary.write_text(
|
|
259
|
+
f"{json.dumps(result.to_dict(), indent=2, sort_keys=True)}\n"
|
|
260
|
+
)
|
|
261
|
+
os.replace(temporary, destination)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def load_manifest(path: Path) -> list[BatchTask]:
|
|
265
|
+
try:
|
|
266
|
+
with path.open("rb") as handle:
|
|
267
|
+
document = tomllib.load(handle)
|
|
268
|
+
except OSError as error:
|
|
269
|
+
raise AOPError(f"could not read batch manifest {path}: {error}") from error
|
|
270
|
+
except tomllib.TOMLDecodeError as error:
|
|
271
|
+
raise AOPError(f"invalid batch manifest {path}: {error}") from error
|
|
272
|
+
|
|
273
|
+
unknown_top_level = set(document) - {"tasks"}
|
|
274
|
+
if unknown_top_level:
|
|
275
|
+
names = ", ".join(sorted(unknown_top_level))
|
|
276
|
+
raise AOPError(f"unknown batch manifest field(s): {names}")
|
|
277
|
+
raw_tasks = document.get("tasks")
|
|
278
|
+
if not isinstance(raw_tasks, list) or not raw_tasks:
|
|
279
|
+
raise AOPError("batch manifest must contain at least one [[tasks]] entry")
|
|
280
|
+
|
|
281
|
+
tasks = [
|
|
282
|
+
_parse_task(value, index, path.parent) for index, value in enumerate(raw_tasks)
|
|
283
|
+
]
|
|
284
|
+
identifiers = [task.id for task in tasks]
|
|
285
|
+
duplicates = sorted({task for task in identifiers if identifiers.count(task) > 1})
|
|
286
|
+
if duplicates:
|
|
287
|
+
raise AOPError(f"duplicate batch task id(s): {', '.join(duplicates)}")
|
|
288
|
+
return tasks
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _parse_task(value: object, index: int, manifest_dir: Path) -> BatchTask:
|
|
292
|
+
label = f"tasks[{index}]"
|
|
293
|
+
if not isinstance(value, dict):
|
|
294
|
+
raise AOPError(f"{label} must be a table")
|
|
295
|
+
unknown = set(value) - TASK_FIELDS
|
|
296
|
+
if unknown:
|
|
297
|
+
raise AOPError(f"{label} has unknown field(s): {', '.join(sorted(unknown))}")
|
|
298
|
+
|
|
299
|
+
task_id = _required_string(value, "id", label)
|
|
300
|
+
if not TASK_ID.fullmatch(task_id):
|
|
301
|
+
raise AOPError(f"{label}.id is not a valid task id: {task_id}")
|
|
302
|
+
agent = value.get("agent", "codex")
|
|
303
|
+
if not isinstance(agent, str) or agent not in AGENTS:
|
|
304
|
+
raise AOPError(f"{label}.agent must be one of: {', '.join(AGENTS)}")
|
|
305
|
+
|
|
306
|
+
prompt = value.get("prompt")
|
|
307
|
+
prompt_file = value.get("prompt_file")
|
|
308
|
+
if (prompt is None) == (prompt_file is None):
|
|
309
|
+
raise AOPError(f"{label} must define exactly one of prompt or prompt_file")
|
|
310
|
+
if prompt is not None:
|
|
311
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
312
|
+
raise AOPError(f"{label}.prompt must be a non-empty string")
|
|
313
|
+
prompt_text = prompt
|
|
314
|
+
prompt_source = "inline"
|
|
315
|
+
else:
|
|
316
|
+
if not isinstance(prompt_file, str) or not prompt_file:
|
|
317
|
+
raise AOPError(f"{label}.prompt_file must be a non-empty string")
|
|
318
|
+
prompt_path = (manifest_dir / prompt_file).resolve()
|
|
319
|
+
try:
|
|
320
|
+
prompt_text = prompt_path.read_text()
|
|
321
|
+
except OSError as error:
|
|
322
|
+
raise AOPError(
|
|
323
|
+
f"could not read {label}.prompt_file {prompt_path}: {error}"
|
|
324
|
+
) from error
|
|
325
|
+
if not prompt_text.strip():
|
|
326
|
+
raise AOPError(f"{label}.prompt_file is empty: {prompt_path}")
|
|
327
|
+
prompt_source = os.fspath(prompt_path)
|
|
328
|
+
|
|
329
|
+
base = _optional_string(value, "base", label) or "HEAD"
|
|
330
|
+
model = _optional_string(value, "model", label)
|
|
331
|
+
mode = _optional_string(value, "mode", label) or "agent"
|
|
332
|
+
if mode not in MODES:
|
|
333
|
+
raise AOPError(f"{label}.mode must be one of: {', '.join(sorted(MODES))}")
|
|
334
|
+
effort = _optional_string(value, "effort", label)
|
|
335
|
+
if effort is not None and effort not in EFFORTS:
|
|
336
|
+
raise AOPError(f"{label}.effort must be one of: {', '.join(sorted(EFFORTS))}")
|
|
337
|
+
sandbox = _optional_string(value, "sandbox", label) or "workspace-write"
|
|
338
|
+
if sandbox not in SANDBOXES:
|
|
339
|
+
raise AOPError(
|
|
340
|
+
f"{label}.sandbox must be one of: {', '.join(sorted(SANDBOXES))}"
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
timeout = value.get("timeout")
|
|
344
|
+
if timeout is not None:
|
|
345
|
+
if (
|
|
346
|
+
isinstance(timeout, bool)
|
|
347
|
+
or not isinstance(timeout, (int, float))
|
|
348
|
+
or timeout <= 0
|
|
349
|
+
):
|
|
350
|
+
raise AOPError(f"{label}.timeout must be a number greater than zero")
|
|
351
|
+
timeout = float(timeout)
|
|
352
|
+
|
|
353
|
+
artifacts = value.get("artifacts", [])
|
|
354
|
+
if not isinstance(artifacts, list) or not all(
|
|
355
|
+
isinstance(artifact, str) and artifact for artifact in artifacts
|
|
356
|
+
):
|
|
357
|
+
raise AOPError(f"{label}.artifacts must be an array of non-empty strings")
|
|
358
|
+
|
|
359
|
+
return BatchTask(
|
|
360
|
+
id=task_id,
|
|
361
|
+
prompt=prompt_text,
|
|
362
|
+
prompt_source=prompt_source,
|
|
363
|
+
agent=agent,
|
|
364
|
+
base=base,
|
|
365
|
+
model=model,
|
|
366
|
+
effort=effort,
|
|
367
|
+
mode=mode,
|
|
368
|
+
sandbox=sandbox,
|
|
369
|
+
timeout_seconds=timeout,
|
|
370
|
+
artifacts=normalize_artifacts(artifacts),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _required_string(value: dict[str, object], field: str, label: str) -> str:
|
|
375
|
+
result = value.get(field)
|
|
376
|
+
if not isinstance(result, str) or not result:
|
|
377
|
+
raise AOPError(f"{label}.{field} must be a non-empty string")
|
|
378
|
+
return result
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _optional_string(value: dict[str, object], field: str, label: str) -> str | None:
|
|
382
|
+
result = value.get(field)
|
|
383
|
+
if result is not None and (not isinstance(result, str) or not result):
|
|
384
|
+
raise AOPError(f"{label}.{field} must be a non-empty string")
|
|
385
|
+
return result
|