forge-agent-runtime 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.
forge_runtime/cli.py ADDED
@@ -0,0 +1,400 @@
1
+ """Forge Runtime command-line interface."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import importlib.util
6
+ import json
7
+ import os
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from forge_runtime.config import Settings
16
+ from forge_runtime.orchestrator import ForgeMaster
17
+ from forge_runtime.providers.factory import create_provider
18
+ from forge_runtime.registry import AgentForgeRegistry, RegistryError
19
+ from forge_runtime.sandbox import DockerSandboxExecutor, SandboxError
20
+ from forge_runtime.storage import SQLiteStore
21
+
22
+ app = typer.Typer(name="forge", help="Forge Runtime — multi-agent orchestration.")
23
+ registry_cli = typer.Typer(help="Inspect the AgentForge registry.")
24
+ agent_cli = typer.Typer(help="Inspect specialist agents.")
25
+ skill_cli = typer.Typer(help="Inspect skills.")
26
+ rule_cli = typer.Typer(help="Inspect engineering rules.")
27
+ workflow_cli = typer.Typer(help="Inspect delivery workflows.")
28
+ runs_cli = typer.Typer(help="Inspect orchestration runs.")
29
+ sandbox_cli = typer.Typer(help="Build and operate the isolated Docker command sandbox.")
30
+ app.add_typer(registry_cli, name="registry")
31
+ app.add_typer(agent_cli, name="agent")
32
+ app.add_typer(skill_cli, name="skill")
33
+ app.add_typer(rule_cli, name="rule")
34
+ app.add_typer(workflow_cli, name="workflow")
35
+ app.add_typer(runs_cli, name="runs")
36
+ app.add_typer(sandbox_cli, name="sandbox")
37
+ console = Console()
38
+
39
+
40
+ def _update_dotenv(path: Path, values: dict[str, str]) -> None:
41
+ """Atomically update selected keys without overwriting unrelated settings."""
42
+ if path.is_symlink():
43
+ raise ValueError(f"Refusing to write through a symlink: {path}")
44
+ for key, value in values.items():
45
+ if "\n" in value or "\r" in value:
46
+ raise ValueError(f"Environment value for {key} cannot contain a newline.")
47
+ existing = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
48
+ pending = dict(values)
49
+ output: list[str] = []
50
+ for line in existing:
51
+ stripped = line.strip()
52
+ if stripped and not stripped.startswith("#") and "=" in line:
53
+ key = line.split("=", 1)[0].strip()
54
+ if key in pending:
55
+ output.append(f"{key}={pending.pop(key)}")
56
+ continue
57
+ output.append(line)
58
+ for key, value in pending.items():
59
+ output.append(f"{key}={value}")
60
+
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ temporary_name: str | None = None
63
+ try:
64
+ with tempfile.NamedTemporaryFile(
65
+ mode="w",
66
+ encoding="utf-8",
67
+ newline="\n",
68
+ prefix=".forge-env-",
69
+ dir=path.parent,
70
+ delete=False,
71
+ ) as temporary:
72
+ temporary.write("\n".join(output) + "\n")
73
+ temporary.flush()
74
+ os.fsync(temporary.fileno())
75
+ temporary_name = temporary.name
76
+ os.chmod(temporary_name, 0o600)
77
+ os.replace(temporary_name, path)
78
+ finally:
79
+ if temporary_name and os.path.exists(temporary_name):
80
+ os.unlink(temporary_name)
81
+
82
+
83
+ @app.command("setup")
84
+ def setup(
85
+ provider: str = typer.Option(
86
+ "openai",
87
+ "--provider",
88
+ help="Model provider to configure: openai, anthropic or mock.",
89
+ ),
90
+ ) -> None:
91
+ """Configure a provider and build the Docker sandbox in one step."""
92
+ normalized = provider.strip().lower()
93
+ if normalized not in {"openai", "anthropic", "mock"}:
94
+ console.print("[red]Provider must be openai, anthropic or mock.[/red]")
95
+ raise typer.Exit(2)
96
+
97
+ sdk_name = {"openai": "openai", "anthropic": "anthropic"}.get(normalized)
98
+ if sdk_name and importlib.util.find_spec(sdk_name) is None:
99
+ extra = "openai" if normalized == "openai" else "anthropic"
100
+ console.print(
101
+ f'[red]Provider SDK missing.[/red] Install it with: pip install -e ".[{extra}]"'
102
+ )
103
+ raise typer.Exit(2)
104
+
105
+ cfg = Settings()
106
+ updates = {"FORGE_DEFAULT_PROVIDER": normalized}
107
+ if cfg.sandbox_image == "forge-runtime-sandbox:latest":
108
+ updates["FORGE_SANDBOX_IMAGE"] = "forge-runtime-sandbox:internet-v1"
109
+ if normalized == "openai" and not cfg.openai_api_key:
110
+ key = typer.prompt("OpenAI API key", hide_input=True, confirmation_prompt=True).strip()
111
+ if not key:
112
+ console.print("[red]An OpenAI API key is required.[/red]")
113
+ raise typer.Exit(2)
114
+ updates["OPENAI_API_KEY"] = key
115
+ elif normalized == "anthropic" and not cfg.anthropic_api_key:
116
+ key = typer.prompt("Anthropic API key", hide_input=True, confirmation_prompt=True).strip()
117
+ if not key:
118
+ console.print("[red]An Anthropic API key is required.[/red]")
119
+ raise typer.Exit(2)
120
+ updates["ANTHROPIC_API_KEY"] = key
121
+
122
+ try:
123
+ _update_dotenv(Path.cwd() / ".env", updates)
124
+ executor = get_sandbox()
125
+ status = asyncio.run(executor.check())
126
+ if not status["image_available"]:
127
+ console.print(f"Building [bold]{executor.image}[/bold]...")
128
+ output = asyncio.run(executor.build_image())
129
+ if output.strip():
130
+ console.print(output)
131
+ final_status = asyncio.run(executor.check())
132
+ if not final_status["image_available"]:
133
+ raise SandboxError("Docker image build finished but the image is still unavailable.")
134
+ if executor.network == "bridge":
135
+ internet = asyncio.run(
136
+ executor.run(
137
+ [
138
+ "curl",
139
+ "--fail",
140
+ "--silent",
141
+ "--show-error",
142
+ "--max-time",
143
+ "15",
144
+ "-I",
145
+ "https://pypi.org",
146
+ ],
147
+ writable=False,
148
+ timeout_seconds=20,
149
+ )
150
+ )
151
+ if internet.exit_code != 0:
152
+ detail = internet.stderr.strip() or internet.stdout.strip()
153
+ raise SandboxError(
154
+ "The Docker bridge is enabled but internet access could not be verified. "
155
+ "Check your firewall, VPN or proxy settings. "
156
+ f"curl output: {detail[:1000]}"
157
+ )
158
+ except (SandboxError, OSError, ValueError, TimeoutError) as exc:
159
+ console.print(f"[red]Setup failed:[/red] {exc}")
160
+ raise typer.Exit(1) from exc
161
+
162
+ console.print(
163
+ f"[green]Forge Runtime is ready.[/green] Provider: [bold]{normalized}[/bold]. "
164
+ f"Sandbox network: [bold]{executor.network}[/bold]."
165
+ )
166
+ console.print('Start with: [bold]forge run "Describe the project task"[/bold]')
167
+
168
+
169
+ def get_sandbox() -> DockerSandboxExecutor:
170
+ cfg = Settings()
171
+ return DockerSandboxExecutor(
172
+ workspace_root=cfg.workspace_root,
173
+ image=cfg.sandbox_image,
174
+ docker_binary=cfg.sandbox_docker_binary,
175
+ cpu_limit=cfg.sandbox_cpu_limit,
176
+ memory_mb=cfg.sandbox_memory_mb,
177
+ pids_limit=cfg.sandbox_pids_limit,
178
+ default_timeout_seconds=cfg.sandbox_default_timeout_seconds,
179
+ max_timeout_seconds=cfg.sandbox_max_timeout_seconds,
180
+ output_chars=cfg.sandbox_output_chars,
181
+ network=cfg.sandbox_network,
182
+ max_snapshot_files=cfg.sandbox_max_snapshot_files,
183
+ max_snapshot_bytes=cfg.sandbox_max_snapshot_mb * 1024 * 1024,
184
+ )
185
+
186
+
187
+ @sandbox_cli.command("check")
188
+ def sandbox_check() -> None:
189
+ executor = get_sandbox()
190
+ try:
191
+ status = asyncio.run(executor.check())
192
+ except SandboxError as exc:
193
+ console.print(f"[red]Sandbox unavailable:[/red] {exc}")
194
+ raise typer.Exit(1) from exc
195
+ console.print(f"[green]Docker Engine:[/green] {status['docker_server_version']}")
196
+ console.print(f"[bold]Workspace:[/bold] {status['workspace']}")
197
+ if status["image_available"]:
198
+ console.print(f"[green]Sandbox image ready:[/green] {status['image']}")
199
+ else:
200
+ console.print(f"[yellow]Image missing:[/yellow] {status['image']}")
201
+ console.print("Run [bold]forge sandbox build[/bold] to build it.")
202
+
203
+
204
+ @sandbox_cli.command("build")
205
+ def sandbox_build() -> None:
206
+ executor = get_sandbox()
207
+ console.print(f"Building [bold]{executor.image}[/bold]...")
208
+ try:
209
+ output = asyncio.run(executor.build_image())
210
+ except (SandboxError, TimeoutError) as exc:
211
+ console.print(f"[red]Sandbox image build failed:[/red] {exc}")
212
+ raise typer.Exit(1) from exc
213
+ if output.strip():
214
+ console.print(output)
215
+
216
+
217
+ @sandbox_cli.command("run")
218
+ def sandbox_run(
219
+ argv: list[str] = typer.Argument(..., help="Allowlisted command and arguments."),
220
+ timeout_seconds: int | None = typer.Option(None, min=1, max=600),
221
+ read_write: bool = typer.Option(
222
+ False,
223
+ "--read-write",
224
+ help="Copy modified files back to the project (use only for trusted local runs).",
225
+ ),
226
+ ) -> None:
227
+ executor = get_sandbox()
228
+ try:
229
+ result = asyncio.run(
230
+ executor.run(
231
+ argv,
232
+ writable=read_write,
233
+ timeout_seconds=timeout_seconds,
234
+ )
235
+ )
236
+ except SandboxError as exc:
237
+ console.print(f"[red]Sandbox execution failed:[/red] {exc}")
238
+ raise typer.Exit(1) from exc
239
+ console.print_json(json.dumps(result.as_dict(), ensure_ascii=False))
240
+ if result.exit_code != 0:
241
+ raise typer.Exit(result.exit_code or 1)
242
+
243
+
244
+ def get_registry(path: Path | None = None) -> AgentForgeRegistry:
245
+ cfg = Settings()
246
+ registry = AgentForgeRegistry(
247
+ local_path=path or cfg.agentforge_local_path,
248
+ repository=cfg.agentforge_repository,
249
+ ref=cfg.agentforge_ref,
250
+ source=cfg.agentforge_source,
251
+ )
252
+ registry.load()
253
+ return registry
254
+
255
+
256
+ @registry_cli.command("validate")
257
+ def registry_validate(
258
+ registry_path: Path | None = typer.Option(None, "--registry-path"),
259
+ ) -> None:
260
+ try:
261
+ registry = get_registry(registry_path)
262
+ console.print(
263
+ f"[green]AgentForge registry ready:[/green] "
264
+ f"{len(registry.agents)} agents, {len(registry.skills)} skills, "
265
+ f"{len(registry.rules)} rules, {len(registry.workflows)} workflows."
266
+ )
267
+ console.print(f"[dim]Source: {registry.loaded_from}[/dim]")
268
+ except RegistryError as exc:
269
+ console.print(f"[red]{exc}[/red]")
270
+ raise typer.Exit(1) from exc
271
+
272
+
273
+ @agent_cli.command("list")
274
+ def agent_list(
275
+ registry_path: Path | None = typer.Option(None, "--registry-path"),
276
+ ) -> None:
277
+ registry = get_registry(registry_path)
278
+ table = Table("Agent", "Mode", "Recommended skills", "Description")
279
+ for agent in registry.agents.values():
280
+ table.add_row(
281
+ agent.name,
282
+ agent.mode,
283
+ ", ".join(agent.recommended_skills),
284
+ agent.description,
285
+ )
286
+ console.print(table)
287
+
288
+
289
+ @agent_cli.command("show")
290
+ def agent_show(
291
+ name: str,
292
+ registry_path: Path | None = typer.Option(None, "--registry-path"),
293
+ ) -> None:
294
+ agent = get_registry(registry_path).get_agent(name)
295
+ console.print(f"[bold]{agent.name}[/bold] ({agent.mode})")
296
+ console.print(agent.description)
297
+ console.print(agent.content)
298
+
299
+
300
+ @skill_cli.command("list")
301
+ def skill_list(
302
+ registry_path: Path | None = typer.Option(None, "--registry-path"),
303
+ ) -> None:
304
+ registry = get_registry(registry_path)
305
+ table = Table("Skill", "Description")
306
+ for skill in registry.skills.values():
307
+ table.add_row(skill.name, skill.description)
308
+ console.print(table)
309
+
310
+
311
+ @rule_cli.command("list")
312
+ def rule_list(
313
+ registry_path: Path | None = typer.Option(None, "--registry-path"),
314
+ ) -> None:
315
+ registry = get_registry(registry_path)
316
+ for name in registry.rules:
317
+ console.print(f"- {name}")
318
+
319
+
320
+ @workflow_cli.command("list")
321
+ def workflow_list(
322
+ registry_path: Path | None = typer.Option(None, "--registry-path"),
323
+ ) -> None:
324
+ registry = get_registry(registry_path)
325
+ for name in registry.workflows:
326
+ console.print(f"- {name}")
327
+
328
+
329
+ @app.command("run")
330
+ def run(
331
+ goal: str = typer.Argument(..., help="Outcome to plan and delegate."),
332
+ provider: str | None = typer.Option(
333
+ None, "--provider", help="Provider override: mock, openai or anthropic."
334
+ ),
335
+ registry_path: Path | None = typer.Option(None, "--registry-path"),
336
+ max_parallel: int | None = typer.Option(None, min=1, max=64),
337
+ ) -> None:
338
+ cfg = Settings(
339
+ max_parallel_tasks=max_parallel or Settings().max_parallel_tasks,
340
+ )
341
+ registry = AgentForgeRegistry(
342
+ local_path=registry_path or cfg.agentforge_local_path,
343
+ repository=cfg.agentforge_repository,
344
+ ref=cfg.agentforge_ref,
345
+ source=cfg.agentforge_source,
346
+ )
347
+ runtime = ForgeMaster(
348
+ registry=registry,
349
+ provider=create_provider(provider or cfg.default_provider, cfg),
350
+ settings=cfg,
351
+ )
352
+ result = asyncio.run(runtime.execute(goal))
353
+ console.print(f"[bold]Run:[/bold] {result.id}")
354
+ console.print(f"[bold]Status:[/bold] {result.status.value}")
355
+ console.print(result.summary)
356
+
357
+
358
+ @app.command("serve")
359
+ def serve(
360
+ host: str | None = typer.Option(None, help="Bind address. Defaults to 127.0.0.1."),
361
+ port: int | None = typer.Option(None, min=1, max=65535),
362
+ ) -> None:
363
+ cfg = Settings()
364
+ bind_host = host or cfg.api_host
365
+ bind_port = port or cfg.api_port
366
+ local_hosts = {"127.0.0.1", "localhost", "::1"}
367
+ if bind_host not in local_hosts and not cfg.api_token:
368
+ console.print(
369
+ "[red]Refusing a network bind without FORGE_API_TOKEN.[/red] "
370
+ "Set a strong token in the environment or bind to 127.0.0.1."
371
+ )
372
+ raise typer.Exit(2)
373
+ try:
374
+ import uvicorn
375
+ except ImportError as exc:
376
+ console.print('[red]Install the API extra: pip install "forge-runtime[api]"[/red]')
377
+ raise typer.Exit(2) from exc
378
+ console.print(f"Forge Runtime API listening on http://{bind_host}:{bind_port}")
379
+ uvicorn.run("forge_runtime.api:app", host=bind_host, port=bind_port)
380
+
381
+
382
+ @runs_cli.command("list")
383
+ def runs_list(limit: int = typer.Option(20, min=1, max=100)) -> None:
384
+ table = Table("Run ID", "Status", "Goal", "Created")
385
+ for row in SQLiteStore(Settings().database_url).list_runs(limit):
386
+ table.add_row(row["id"], row["status"], row["goal"], row["created_at"])
387
+ console.print(table)
388
+
389
+
390
+ @runs_cli.command("show")
391
+ def runs_show(run_id: str) -> None:
392
+ data = SQLiteStore(Settings().database_url).get_run(run_id)
393
+ if data is None:
394
+ console.print("[red]Run not found.[/red]")
395
+ raise typer.Exit(1)
396
+ console.print_json(json.dumps(data, ensure_ascii=False, default=str))
397
+
398
+
399
+ if __name__ == "__main__":
400
+ app()
@@ -0,0 +1,41 @@
1
+ """Environment-backed runtime configuration."""
2
+ from pathlib import Path
3
+ from typing import Literal
4
+
5
+ from pydantic import Field
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ model_config = SettingsConfigDict(env_prefix="FORGE_", env_file=".env", extra="ignore")
11
+ environment: str = "development"
12
+ log_level: str = "INFO"
13
+ database_url: str = "sqlite:///./.forge/forge.db"
14
+ workspace_root: Path = Path(".")
15
+ max_parallel_tasks: int = Field(default=4, ge=1, le=64)
16
+ task_timeout_seconds: int = Field(default=900, ge=1, le=86400)
17
+ sandbox_enabled: bool = True
18
+ sandbox_network: Literal["bridge", "none"] = "bridge"
19
+ sandbox_image: str = "forge-runtime-sandbox:internet-v1"
20
+ sandbox_docker_binary: str = "docker"
21
+ sandbox_cpu_limit: float = Field(default=2.0, ge=0.25, le=16)
22
+ sandbox_memory_mb: int = Field(default=2048, ge=256, le=32768)
23
+ sandbox_pids_limit: int = Field(default=128, ge=32, le=2048)
24
+ sandbox_default_timeout_seconds: int = Field(default=180, ge=1, le=600)
25
+ sandbox_max_timeout_seconds: int = Field(default=600, ge=1, le=600)
26
+ sandbox_output_chars: int = Field(default=24000, ge=1000, le=100000)
27
+ sandbox_max_snapshot_files: int = Field(default=5000, ge=1, le=50000)
28
+ sandbox_max_snapshot_mb: int = Field(default=512, ge=1, le=4096)
29
+ default_provider: str = "mock"
30
+ agentforge_repository: str = "https://github.com/mhingan/agent-forge"
31
+ agentforge_ref: str = "main"
32
+ agentforge_source: Literal["auto", "installed", "local", "github"] = "auto"
33
+ agentforge_local_path: Path | None = None
34
+ openai_model: str = Field(default="gpt-4.1", validation_alias="OPENAI_MODEL")
35
+ openai_api_key: str | None = Field(default=None, validation_alias="OPENAI_API_KEY")
36
+ openai_base_url: str | None = Field(default=None, validation_alias="OPENAI_BASE_URL")
37
+ anthropic_model: str = Field(default="claude-sonnet-4-5", validation_alias="ANTHROPIC_MODEL")
38
+ anthropic_api_key: str | None = Field(default=None, validation_alias="ANTHROPIC_API_KEY")
39
+ api_host: str = "127.0.0.1"
40
+ api_port: int = Field(default=8787, ge=1, le=65535)
41
+ api_token: str | None = None
@@ -0,0 +1,22 @@
1
+ """Baseline output checks; replace or extend for domain-specific quality gates."""
2
+ from dataclasses import dataclass
3
+
4
+ from forge_runtime.models import AgentResult, TaskSpec
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Evaluation:
9
+ passed: bool
10
+ reasons: tuple[str, ...]
11
+
12
+
13
+ class Evaluator:
14
+ def evaluate(self, task: TaskSpec, result: AgentResult) -> Evaluation:
15
+ reasons = []
16
+ if not result.output.strip():
17
+ reasons.append("Output is empty.")
18
+ if not result.summary.strip():
19
+ reasons.append("Summary is empty.")
20
+ if len(result.output) > 100_000:
21
+ reasons.append("Output exceeds 100,000 characters.")
22
+ return Evaluation(passed=not reasons, reasons=tuple(reasons))
forge_runtime/graph.py ADDED
@@ -0,0 +1,85 @@
1
+ """Validate execution plans before any specialist task is dispatched."""
2
+ from collections import defaultdict, deque
3
+
4
+ from forge_runtime.models import ExecutionPlan
5
+
6
+
7
+ class GraphError(ValueError):
8
+ """An execution plan violates a runtime invariant."""
9
+
10
+
11
+ def validate_plan(
12
+ plan: ExecutionPlan,
13
+ known_agents: set[str],
14
+ known_skills: set[str],
15
+ ) -> None:
16
+ if not plan.goal.strip():
17
+ raise GraphError("Plan goal cannot be empty.")
18
+ if not plan.tasks or len(plan.tasks) > 100:
19
+ raise GraphError("Plan must contain between 1 and 100 tasks.")
20
+
21
+ ids = [task.id for task in plan.tasks]
22
+ if len(ids) != len(set(ids)):
23
+ raise GraphError("Duplicate task IDs in execution plan.")
24
+ known_ids = set(ids)
25
+
26
+ for task in plan.tasks:
27
+ if task.agent not in known_agents:
28
+ raise GraphError(
29
+ f"Unknown agent for task {task.id}: {task.agent}"
30
+ )
31
+ unknown_skills = set(task.skill_names) - known_skills
32
+ if unknown_skills:
33
+ raise GraphError(
34
+ f"Task {task.id} references unknown skills: {sorted(unknown_skills)}"
35
+ )
36
+ if task.requires_approval:
37
+ raise GraphError(
38
+ f"Task {task.id} requires human approval, but approval gates are not "
39
+ "enabled in this runtime version. The task was not dispatched."
40
+ )
41
+ if task.id in task.dependencies:
42
+ raise GraphError(f"Task {task.id} cannot depend on itself.")
43
+ missing = set(task.dependencies) - known_ids
44
+ if missing:
45
+ raise GraphError(
46
+ f"Task {task.id} references missing dependencies: {sorted(missing)}"
47
+ )
48
+
49
+ indegree = {task.id: len(task.dependencies) for task in plan.tasks}
50
+ children: dict[str, list[str]] = defaultdict(list)
51
+ for task in plan.tasks:
52
+ for dependency in task.dependencies:
53
+ children[dependency].append(task.id)
54
+
55
+ queue = deque(task_id for task_id, degree in indegree.items() if degree == 0)
56
+ visited = 0
57
+ while queue:
58
+ current = queue.popleft()
59
+ visited += 1
60
+ for child in children[current]:
61
+ indegree[child] -= 1
62
+ if indegree[child] == 0:
63
+ queue.append(child)
64
+
65
+ if visited != len(plan.tasks):
66
+ raise GraphError("Task dependencies contain a cycle.")
67
+
68
+
69
+ def blocked_tasks(
70
+ plan: ExecutionPlan,
71
+ failed: set[str],
72
+ completed: set[str],
73
+ ) -> set[str]:
74
+ """Return every nonterminal task that depends on failed work, directly or indirectly."""
75
+ blocked: set[str] = set()
76
+ changed = True
77
+ while changed:
78
+ changed = False
79
+ for task in plan.tasks:
80
+ if task.id in completed | failed | blocked:
81
+ continue
82
+ if set(task.dependencies) & (failed | blocked):
83
+ blocked.add(task.id)
84
+ changed = True
85
+ return blocked
@@ -0,0 +1,137 @@
1
+ """Typed contracts for plans, tasks, agents, runs and events."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import UTC, datetime
5
+ from enum import StrEnum
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+
12
+ def new_id(prefix: str) -> str:
13
+ return f"{prefix}_{uuid4().hex[:12]}"
14
+
15
+
16
+ def utc_now() -> datetime:
17
+ return datetime.now(UTC)
18
+
19
+
20
+ class RunStatus(StrEnum):
21
+ PENDING = "pending"
22
+ PLANNING = "planning"
23
+ RUNNING = "running"
24
+ WAITING_APPROVAL = "waiting_approval"
25
+ SUCCEEDED = "succeeded"
26
+ FAILED = "failed"
27
+ CANCELLED = "cancelled"
28
+
29
+
30
+ class TaskStatus(StrEnum):
31
+ PENDING = "pending"
32
+ READY = "ready"
33
+ RUNNING = "running"
34
+ SUCCEEDED = "succeeded"
35
+ FAILED = "failed"
36
+ BLOCKED = "blocked"
37
+ CANCELLED = "cancelled"
38
+
39
+
40
+ class EventLevel(StrEnum):
41
+ DEBUG = "debug"
42
+ INFO = "info"
43
+ WARNING = "warning"
44
+ ERROR = "error"
45
+
46
+
47
+ class AgentDefinition(BaseModel):
48
+ model_config = ConfigDict(frozen=True)
49
+ name: str
50
+ description: str
51
+ path: str
52
+ content: str
53
+ recommended_skills: tuple[str, ...] = ()
54
+ mode: str = "subagent"
55
+
56
+
57
+ class SkillDefinition(BaseModel):
58
+ model_config = ConfigDict(frozen=True)
59
+ name: str
60
+ description: str
61
+ path: str
62
+ content: str = ""
63
+ references: dict[str, str] = Field(default_factory=dict)
64
+
65
+
66
+ class TaskSpec(BaseModel):
67
+ id: str = Field(default_factory=lambda: new_id("task"))
68
+ title: str
69
+ description: str
70
+ agent: str
71
+ dependencies: list[str] = Field(default_factory=list)
72
+ acceptance_criteria: list[str] = Field(default_factory=list)
73
+ skill_names: list[str] = Field(default_factory=list)
74
+ max_attempts: int = Field(default=2, ge=1, le=5)
75
+ timeout_seconds: int = Field(default=900, ge=1)
76
+ requires_approval: bool = False
77
+ metadata: dict[str, Any] = Field(default_factory=dict)
78
+
79
+
80
+ class ExecutionPlan(BaseModel):
81
+ goal: str
82
+ summary: str
83
+ tasks: list[TaskSpec]
84
+ final_acceptance_criteria: list[str] = Field(default_factory=list)
85
+
86
+
87
+ class AgentRequest(BaseModel):
88
+ run_id: str
89
+ task: TaskSpec
90
+ agent: AgentDefinition
91
+ skills: list[SkillDefinition] = Field(default_factory=list)
92
+ project_context: str = ""
93
+ previous_results: dict[str, str] = Field(default_factory=dict)
94
+ attempt: int = 1
95
+
96
+
97
+ class AgentResult(BaseModel):
98
+ output: str
99
+ summary: str
100
+ artifacts: list[dict[str, Any]] = Field(default_factory=list)
101
+ metadata: dict[str, Any] = Field(default_factory=dict)
102
+ usage: dict[str, Any] = Field(default_factory=dict)
103
+
104
+
105
+ class TaskRecord(BaseModel):
106
+ id: str
107
+ run_id: str
108
+ title: str
109
+ agent: str
110
+ status: TaskStatus = TaskStatus.PENDING
111
+ attempt: int = 0
112
+ output: str | None = None
113
+ error: str | None = None
114
+ started_at: datetime | None = None
115
+ finished_at: datetime | None = None
116
+
117
+
118
+ class RunRecord(BaseModel):
119
+ id: str = Field(default_factory=lambda: new_id("run"))
120
+ goal: str
121
+ status: RunStatus = RunStatus.PENDING
122
+ plan: ExecutionPlan | None = None
123
+ summary: str | None = None
124
+ created_at: datetime = Field(default_factory=utc_now)
125
+ started_at: datetime | None = None
126
+ finished_at: datetime | None = None
127
+
128
+
129
+ class RuntimeEvent(BaseModel):
130
+ id: str = Field(default_factory=lambda: new_id("evt"))
131
+ run_id: str
132
+ task_id: str | None = None
133
+ level: EventLevel = EventLevel.INFO
134
+ event_type: str
135
+ message: str
136
+ data: dict[str, Any] = Field(default_factory=dict)
137
+ created_at: datetime = Field(default_factory=utc_now)