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.
@@ -0,0 +1,6 @@
1
+ """Agent Orchestration Process."""
2
+
3
+ from importlib.metadata import version
4
+
5
+
6
+ __version__ = version("agent-orchestration-process")
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -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