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_agent_runtime-0.1.0.dist-info/METADATA +364 -0
- forge_agent_runtime-0.1.0.dist-info/RECORD +25 -0
- forge_agent_runtime-0.1.0.dist-info/WHEEL +4 -0
- forge_agent_runtime-0.1.0.dist-info/entry_points.txt +2 -0
- forge_agent_runtime-0.1.0.dist-info/licenses/LICENSE +17 -0
- forge_runtime/__init__.py +32 -0
- forge_runtime/api.py +139 -0
- forge_runtime/cli.py +400 -0
- forge_runtime/config.py +41 -0
- forge_runtime/evaluation.py +22 -0
- forge_runtime/graph.py +85 -0
- forge_runtime/models.py +137 -0
- forge_runtime/orchestrator.py +289 -0
- forge_runtime/providers/__init__.py +1 -0
- forge_runtime/providers/anthropic_provider.py +256 -0
- forge_runtime/providers/base.py +10 -0
- forge_runtime/providers/factory.py +46 -0
- forge_runtime/providers/mock.py +24 -0
- forge_runtime/providers/openai_provider.py +264 -0
- forge_runtime/registry.py +245 -0
- forge_runtime/sandbox.py +872 -0
- forge_runtime/sandbox_assets/Dockerfile +51 -0
- forge_runtime/sandbox_assets/__init__.py +1 -0
- forge_runtime/storage.py +82 -0
- forge_runtime/workspace.py +280 -0
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()
|
forge_runtime/config.py
ADDED
|
@@ -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
|
forge_runtime/models.py
ADDED
|
@@ -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)
|