agent-foundry-runtime 0.4.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_foundry/__init__.py +2 -0
- agent_foundry/__main__.py +5 -0
- agent_foundry/cli.py +613 -0
- agent_foundry/config.py +190 -0
- agent_foundry/daemon.py +220 -0
- agent_foundry/eval.py +93 -0
- agent_foundry/executor.py +159 -0
- agent_foundry/indexer.py +272 -0
- agent_foundry/judge.py +88 -0
- agent_foundry/logging_db.py +437 -0
- agent_foundry/loop.py +226 -0
- agent_foundry/mcp_server.py +191 -0
- agent_foundry/models.py +166 -0
- agent_foundry/planner.py +288 -0
- agent_foundry_runtime-0.4.0.dist-info/METADATA +382 -0
- agent_foundry_runtime-0.4.0.dist-info/RECORD +21 -0
- agent_foundry_runtime-0.4.0.dist-info/WHEEL +5 -0
- agent_foundry_runtime-0.4.0.dist-info/entry_points.txt +2 -0
- agent_foundry_runtime-0.4.0.dist-info/licenses/AUTHORSHIP.md +7 -0
- agent_foundry_runtime-0.4.0.dist-info/licenses/LICENSE +21 -0
- agent_foundry_runtime-0.4.0.dist-info/top_level.txt +1 -0
agent_foundry/cli.py
ADDED
|
@@ -0,0 +1,613 @@
|
|
|
1
|
+
"""Agent Foundry CLI (Click)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from .config import Config, init_config, LOG_DB_PATH
|
|
14
|
+
from .indexer import (
|
|
15
|
+
build_index,
|
|
16
|
+
get_index_cached,
|
|
17
|
+
invalidate_cache,
|
|
18
|
+
read_index,
|
|
19
|
+
write_index,
|
|
20
|
+
)
|
|
21
|
+
from .models import (
|
|
22
|
+
ExecuteRequest,
|
|
23
|
+
LoopRequest,
|
|
24
|
+
PlanRequest,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _ensure_daemon(cfg: Config) -> bool:
|
|
29
|
+
"""Best-effort daemon health check (lazy start is run elsewhere)."""
|
|
30
|
+
try:
|
|
31
|
+
import httpx
|
|
32
|
+
url = f"http://127.0.0.1:{cfg.core.daemon_port}/health"
|
|
33
|
+
r = httpx.get(url, timeout=0.5)
|
|
34
|
+
return r.status_code == 200
|
|
35
|
+
except Exception:
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _start_daemon(cfg: Config) -> bool:
|
|
40
|
+
"""Spawn the daemon in background and wait for /health. Returns True if started."""
|
|
41
|
+
import subprocess
|
|
42
|
+
import time as _t
|
|
43
|
+
log_path = Path.home() / ".config" / "agent-foundry" / "daemon.log"
|
|
44
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
log_file = log_path.open("ab")
|
|
46
|
+
pid_file = Path.home() / ".config" / "agent-foundry" / "daemon.pid"
|
|
47
|
+
try:
|
|
48
|
+
proc = subprocess.Popen(
|
|
49
|
+
[sys.executable, "-m", "agent_foundry.cli", "serve",
|
|
50
|
+
"--port", str(cfg.core.daemon_port), "--detach"],
|
|
51
|
+
stdout=log_file, stderr=log_file, start_new_session=True,
|
|
52
|
+
)
|
|
53
|
+
pid_file.write_text(str(proc.pid))
|
|
54
|
+
except Exception:
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
import httpx
|
|
58
|
+
url = f"http://127.0.0.1:{cfg.core.daemon_port}/health"
|
|
59
|
+
for _ in range(50):
|
|
60
|
+
try:
|
|
61
|
+
r = httpx.get(url, timeout=0.5)
|
|
62
|
+
if r.status_code == 200:
|
|
63
|
+
return True
|
|
64
|
+
except Exception:
|
|
65
|
+
pass
|
|
66
|
+
_t.sleep(0.1)
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _call_or_daemon(cfg: Config, method: str, path: str, payload: dict,
|
|
71
|
+
timeout: float = 30.0) -> dict:
|
|
72
|
+
"""POST/GET via daemon (lazy-start if needed)."""
|
|
73
|
+
import httpx
|
|
74
|
+
base = f"http://127.0.0.1:{cfg.core.daemon_port}"
|
|
75
|
+
if not _ensure_daemon(cfg):
|
|
76
|
+
if not _start_daemon(cfg):
|
|
77
|
+
raise click.ClickException(
|
|
78
|
+
f"Could not start daemon on port {cfg.core.daemon_port}"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
url = f"{base}{path}"
|
|
82
|
+
try:
|
|
83
|
+
if method == "GET":
|
|
84
|
+
r = httpx.get(url, timeout=timeout)
|
|
85
|
+
else:
|
|
86
|
+
r = httpx.post(url, json=payload, timeout=timeout)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
raise click.ClickException(f"HTTP error: {e}")
|
|
89
|
+
if r.status_code not in (200, 400):
|
|
90
|
+
raise click.ClickException(f"Daemon returned {r.status_code}: {r.text[:200]}")
|
|
91
|
+
try:
|
|
92
|
+
return r.json()
|
|
93
|
+
except Exception:
|
|
94
|
+
return {}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@click.group()
|
|
98
|
+
@click.option("--config", "config_path", type=click.Path(exists=False),
|
|
99
|
+
default=None, help="Path to config.toml")
|
|
100
|
+
@click.pass_context
|
|
101
|
+
def cli(ctx, config_path):
|
|
102
|
+
"""Agent Foundry — orchestrate skills for Claude Code."""
|
|
103
|
+
ctx.ensure_object(dict)
|
|
104
|
+
if config_path:
|
|
105
|
+
from .config import Config as _Cfg
|
|
106
|
+
cfg = _Cfg.load(Path(config_path))
|
|
107
|
+
else:
|
|
108
|
+
cfg = Config.load()
|
|
109
|
+
ctx.obj["cfg"] = cfg
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@cli.command("init")
|
|
113
|
+
@click.option("--force", is_flag=True, help="Overwrite existing config")
|
|
114
|
+
@click.option("--skills-dir", default=None, help="Override skills directory")
|
|
115
|
+
@click.pass_context
|
|
116
|
+
def cmd_init(ctx, force, skills_dir):
|
|
117
|
+
"""Create config.toml and build the initial skill index."""
|
|
118
|
+
cfg = ctx.obj["cfg"]
|
|
119
|
+
if skills_dir:
|
|
120
|
+
# Config is frozen — derive a new instance with the override
|
|
121
|
+
cfg = dataclasses.replace(
|
|
122
|
+
cfg, core=dataclasses.replace(cfg.core, skills_dir=skills_dir)
|
|
123
|
+
)
|
|
124
|
+
if force or not cfg.config_path.exists():
|
|
125
|
+
cfg.save()
|
|
126
|
+
click.echo(f"Wrote config: {cfg.config_path}")
|
|
127
|
+
else:
|
|
128
|
+
click.echo(f"Config already exists: {cfg.config_path} (use --force to overwrite)")
|
|
129
|
+
|
|
130
|
+
skills_dir_path = Path(cfg.core.skills_dir).expanduser()
|
|
131
|
+
skills_dir_path.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
click.echo(f"Skills dir: {skills_dir_path}")
|
|
133
|
+
idx = build_index(skills_dir_path)
|
|
134
|
+
out = Path(cfg.core.index_path).expanduser()
|
|
135
|
+
write_index(idx, out)
|
|
136
|
+
invalidate_cache(out)
|
|
137
|
+
click.echo(f"Indexed {len(idx.skills)} skills (incl. virtual fallback) -> {out}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@cli.command("index")
|
|
141
|
+
@click.pass_context
|
|
142
|
+
def cmd_index(ctx):
|
|
143
|
+
"""Rebuild the skill index from disk."""
|
|
144
|
+
cfg = ctx.obj["cfg"]
|
|
145
|
+
skills_dir = Path(cfg.core.skills_dir).expanduser()
|
|
146
|
+
if not skills_dir.exists():
|
|
147
|
+
raise click.ClickException(f"skills_dir does not exist: {skills_dir}")
|
|
148
|
+
idx = build_index(skills_dir)
|
|
149
|
+
out = Path(cfg.core.index_path).expanduser()
|
|
150
|
+
write_index(idx, out)
|
|
151
|
+
invalidate_cache(out)
|
|
152
|
+
click.echo(f"Indexed {len(idx.skills)} skills -> {out}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@cli.command("cost-report")
|
|
156
|
+
@click.option("--cheapest", type=int, default=None, help="Top N cheapest")
|
|
157
|
+
@click.option("--core", "only_core", is_flag=True)
|
|
158
|
+
@click.option("--optional", "only_optional", is_flag=True)
|
|
159
|
+
@click.pass_context
|
|
160
|
+
def cmd_cost_report(ctx, cheapest, only_core, only_optional):
|
|
161
|
+
"""Print a table of every skill with cost estimates."""
|
|
162
|
+
cfg = ctx.obj["cfg"]
|
|
163
|
+
idx = get_index_cached(
|
|
164
|
+
Path(cfg.core.index_path).expanduser(),
|
|
165
|
+
ttl_seconds=cfg.core.index_cache_ttl_seconds,
|
|
166
|
+
)
|
|
167
|
+
if idx is None:
|
|
168
|
+
idx = read_index(Path(cfg.core.index_path).expanduser())
|
|
169
|
+
if idx is None:
|
|
170
|
+
raise click.ClickException("No index. Run `agent-foundry init` first.")
|
|
171
|
+
|
|
172
|
+
skills = idx.skills
|
|
173
|
+
if only_core:
|
|
174
|
+
skills = [s for s in skills if s.category == "core"]
|
|
175
|
+
if only_optional:
|
|
176
|
+
skills = [s for s in skills if s.category == "optional"]
|
|
177
|
+
|
|
178
|
+
def cost(s):
|
|
179
|
+
c = s.estimated_token_cost or {}
|
|
180
|
+
return int(c.get("input", 0)) + int(c.get("output", 0))
|
|
181
|
+
|
|
182
|
+
if cheapest:
|
|
183
|
+
skills = sorted(skills, key=cost)[:cheapest]
|
|
184
|
+
|
|
185
|
+
click.echo(f"{'Skill':<32} {'In':>6} {'Out':>6} {'Total':>7} {'Sec':>5} Cat")
|
|
186
|
+
click.echo(f"{'-'*32} {'-'*6} {'-'*6} {'-'*7} {'-'*5} {'-'*4}")
|
|
187
|
+
ti = to = tt = 0
|
|
188
|
+
for s in skills:
|
|
189
|
+
c = s.estimated_token_cost or {}
|
|
190
|
+
ii = int(c.get("input", 0))
|
|
191
|
+
oo = int(c.get("output", 0))
|
|
192
|
+
ti += ii; to += oo; tt += ii + oo
|
|
193
|
+
click.echo(f"{s.id:<32} {ii:>6} {oo:>6} {ii+oo:>7} {s.estimated_time_seconds:>5} {s.category[:1]}")
|
|
194
|
+
click.echo(f"{'-'*32} {'-'*6} {'-'*6} {'-'*7} {'-'*5}")
|
|
195
|
+
click.echo(f"{'TOTAL (' + str(len(skills)) + ')':<32} {ti:>6} {to:>6} {tt:>7}")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@cli.command("plan")
|
|
199
|
+
@click.argument("prompt")
|
|
200
|
+
@click.option("--max", "max_results", type=int, default=None, help="Top N")
|
|
201
|
+
@click.pass_context
|
|
202
|
+
def cmd_plan(ctx, prompt, max_results):
|
|
203
|
+
"""Rank skills for a prompt."""
|
|
204
|
+
cfg = ctx.obj["cfg"]
|
|
205
|
+
if max_results:
|
|
206
|
+
cfg.planner.max_results = max_results
|
|
207
|
+
payload = {"prompt": prompt, "max_results": cfg.planner.max_results}
|
|
208
|
+
data = _call_or_daemon(cfg, "POST", "/plan", payload)
|
|
209
|
+
results = data.get("results", [])
|
|
210
|
+
if not results:
|
|
211
|
+
click.echo(f"No skills matched. (Total available: {data.get('total_available', 0)})")
|
|
212
|
+
return
|
|
213
|
+
for i, r in enumerate(results, 1):
|
|
214
|
+
c = r.get("estimated_cost", {})
|
|
215
|
+
click.echo(
|
|
216
|
+
f"{i}. {r['skill_id']} score={r['score']:.3f} "
|
|
217
|
+
f"cost={c.get('total', 0)} tok ({c.get('input', 0)}in/{c.get('output', 0)}out)"
|
|
218
|
+
)
|
|
219
|
+
if r.get("matched_patterns"):
|
|
220
|
+
click.echo(f" matched: {r['matched_patterns']}")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@cli.command("execute")
|
|
224
|
+
@click.argument("skill_id")
|
|
225
|
+
@click.argument("prompt")
|
|
226
|
+
@click.option("--model", default=None)
|
|
227
|
+
@click.option("--force", is_flag=True, help="Skip budget confirmation")
|
|
228
|
+
@click.pass_context
|
|
229
|
+
def cmd_execute(ctx, skill_id, prompt, model, force):
|
|
230
|
+
"""Run a specific skill on a prompt."""
|
|
231
|
+
cfg = ctx.obj["cfg"]
|
|
232
|
+
payload = {"skill_id": skill_id, "prompt": prompt, "force": force, "model": model}
|
|
233
|
+
data = _call_or_daemon(cfg, "POST", "/execute", payload)
|
|
234
|
+
|
|
235
|
+
if data.get("requires_confirmation"):
|
|
236
|
+
c = data.get("estimated_cost", {})
|
|
237
|
+
click.echo(
|
|
238
|
+
f"Estimated cost: {c.get('total', 0)} tokens "
|
|
239
|
+
f"(input {c.get('input')}, output {c.get('output')})."
|
|
240
|
+
)
|
|
241
|
+
if force or not sys.stdin.isatty():
|
|
242
|
+
click.echo("Non-TTY or --force set; declining to avoid hanging.")
|
|
243
|
+
return
|
|
244
|
+
if not click.confirm("Proceed? [y/N]", default=False):
|
|
245
|
+
click.echo("Aborted — no tokens spent.")
|
|
246
|
+
return
|
|
247
|
+
payload["force"] = True
|
|
248
|
+
data = _call_or_daemon(cfg, "POST", "/execute", payload)
|
|
249
|
+
|
|
250
|
+
click.echo(data.get("output", ""))
|
|
251
|
+
if data.get("error"):
|
|
252
|
+
click.echo(f"\n[error] {data['error']}", err=True)
|
|
253
|
+
sys.exit(1)
|
|
254
|
+
click.echo(
|
|
255
|
+
f"\n[tokens={data.get('tokens_used', 0)} "
|
|
256
|
+
f"time={data.get('duration_seconds', 0):.2f}s]",
|
|
257
|
+
err=True,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@cli.command("run")
|
|
262
|
+
@click.argument("prompt")
|
|
263
|
+
@click.option("--model", default=None)
|
|
264
|
+
@click.option("--budget", type=int, default=None)
|
|
265
|
+
@click.option("--force", is_flag=True, help="Skip budget confirmation prompt")
|
|
266
|
+
@click.option("--judge", is_flag=True, help="Score output via af-critic after execution")
|
|
267
|
+
@click.pass_context
|
|
268
|
+
def cmd_run(ctx, prompt, model, budget, force, judge):
|
|
269
|
+
"""Plan + execute the top-ranked skill."""
|
|
270
|
+
cfg = ctx.obj["cfg"]
|
|
271
|
+
payload = {"prompt": prompt, "force": force, "model": model,
|
|
272
|
+
"budget": budget, "judge": judge}
|
|
273
|
+
|
|
274
|
+
def call(req):
|
|
275
|
+
return _call_or_daemon(cfg, "POST", "/loop", req)
|
|
276
|
+
|
|
277
|
+
data = call(payload)
|
|
278
|
+
if data.get("requires_confirmation"):
|
|
279
|
+
c = data.get("estimated_cost", {})
|
|
280
|
+
click.echo(
|
|
281
|
+
f"Estimated cost: {c.get('total', 0)} tokens "
|
|
282
|
+
f"(input {c.get('input')}, output {c.get('output')}, budget "
|
|
283
|
+
f"{budget or cfg.core.token_budget})."
|
|
284
|
+
)
|
|
285
|
+
click.echo(
|
|
286
|
+
f"Selected skill: {data.get('skill_id')} "
|
|
287
|
+
f"(plan: {[p['skill_id'] for p in data.get('plan', [])]})"
|
|
288
|
+
)
|
|
289
|
+
if not force and sys.stdin.isatty() and click.confirm("Proceed? [y/N]", default=False):
|
|
290
|
+
payload["force"] = True
|
|
291
|
+
data = call(payload)
|
|
292
|
+
else:
|
|
293
|
+
if not force and not sys.stdin.isatty():
|
|
294
|
+
click.echo("Non-TTY input; declining without --force.")
|
|
295
|
+
else:
|
|
296
|
+
click.echo("Aborted — no tokens spent.")
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
fb = " [FALLBACK]" if data.get("was_fallback") else ""
|
|
300
|
+
plan_skills = [p["skill_id"] for p in data.get("plan", [])]
|
|
301
|
+
click.echo(f"--> Skill: {data.get('skill_id')}{fb} Plan: {plan_skills}")
|
|
302
|
+
if data.get("session_id"):
|
|
303
|
+
click.echo(f" session {data['session_id']} — continue with: "
|
|
304
|
+
f"agent-foundry resume {data['session_id']} \"...\"", err=True)
|
|
305
|
+
click.echo("---")
|
|
306
|
+
click.echo(data.get("output", ""))
|
|
307
|
+
if data.get("error"):
|
|
308
|
+
click.echo(f"\n[error] {data['error']}", err=True)
|
|
309
|
+
sys.exit(1)
|
|
310
|
+
click.echo(
|
|
311
|
+
f"\n[tokens={data.get('tokens_used', 0)} "
|
|
312
|
+
f"time={data.get('duration_seconds', 0):.2f}s]",
|
|
313
|
+
err=True,
|
|
314
|
+
)
|
|
315
|
+
if data.get("judged") and data.get("judge_score"):
|
|
316
|
+
click.echo(f"\nJudge (af-critic): {json.dumps(data['judge_score'], indent=2)}",
|
|
317
|
+
err=True)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@cli.command("resume")
|
|
321
|
+
@click.argument("session_id", type=int)
|
|
322
|
+
@click.argument("prompt")
|
|
323
|
+
@click.option("--model", default=None)
|
|
324
|
+
@click.pass_context
|
|
325
|
+
def cmd_resume(ctx, session_id, prompt, model):
|
|
326
|
+
"""Continue a loop session with a follow-up prompt."""
|
|
327
|
+
cfg = ctx.obj["cfg"]
|
|
328
|
+
data = _call_or_daemon(
|
|
329
|
+
cfg, "POST", "/loop/resume",
|
|
330
|
+
{"session_id": session_id, "prompt": prompt, "model": model},
|
|
331
|
+
timeout=max(60.0, float(cfg.core.executor_timeout_seconds) + 15.0),
|
|
332
|
+
)
|
|
333
|
+
click.echo(f"--> Session: {data.get('session_id')} Skill: {data.get('skill_id')}")
|
|
334
|
+
click.echo("---")
|
|
335
|
+
click.echo(data.get("output", ""))
|
|
336
|
+
if data.get("error"):
|
|
337
|
+
click.echo(f"\n[error] {data['error']}", err=True)
|
|
338
|
+
sys.exit(1)
|
|
339
|
+
click.echo(
|
|
340
|
+
f"\n[tokens={data.get('tokens_used', 0)} "
|
|
341
|
+
f"time={data.get('duration_seconds', 0):.2f}s "
|
|
342
|
+
f"cost=${data.get('cost_usd', 0.0):.6f}]",
|
|
343
|
+
err=True,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@cli.command("session")
|
|
348
|
+
@click.argument("session_id", type=int)
|
|
349
|
+
@click.pass_context
|
|
350
|
+
def cmd_session(ctx, session_id):
|
|
351
|
+
"""Show a stored loop session."""
|
|
352
|
+
cfg = ctx.obj["cfg"]
|
|
353
|
+
data = _call_or_daemon(cfg, "GET", f"/loop/{session_id}", {})
|
|
354
|
+
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@cli.command("doctor")
|
|
358
|
+
@click.pass_context
|
|
359
|
+
def cmd_doctor(ctx):
|
|
360
|
+
"""Health-check: config, index, daemon, API key."""
|
|
361
|
+
cfg = ctx.obj.get("cfg") if ctx.obj else None
|
|
362
|
+
if cfg is None:
|
|
363
|
+
import agent_foundry.config as _cfg
|
|
364
|
+
cfg = _cfg.Config.load()
|
|
365
|
+
ok = True
|
|
366
|
+
click.echo("=== Agent Foundry Doctor ===")
|
|
367
|
+
if cfg.config_path.exists():
|
|
368
|
+
click.echo(f"[PASS] Config: {cfg.config_path}")
|
|
369
|
+
else:
|
|
370
|
+
click.echo(f"[FAIL] Config not found: {cfg.config_path}")
|
|
371
|
+
ok = False
|
|
372
|
+
idx_path = cfg.core.index_path
|
|
373
|
+
if os.path.exists(str(idx_path)):
|
|
374
|
+
click.echo(f"[PASS] Index: {idx_path}")
|
|
375
|
+
from .indexer import read_index
|
|
376
|
+
idx = read_index(Path(idx_path).expanduser())
|
|
377
|
+
if idx:
|
|
378
|
+
real = [s for s in idx.skills if not s.is_fallback]
|
|
379
|
+
click.echo(f"[PASS] Skills indexed: {len(real)}")
|
|
380
|
+
else:
|
|
381
|
+
click.echo("[FAIL] Index unreadable")
|
|
382
|
+
ok = False
|
|
383
|
+
else:
|
|
384
|
+
click.echo(f"[WARN] Index not found: {idx_path} (run init)")
|
|
385
|
+
from .logging_db import get_skill_weights
|
|
386
|
+
weights = get_skill_weights(LOG_DB_PATH)
|
|
387
|
+
if weights:
|
|
388
|
+
click.echo(f"[PASS] Learned skill weights: {len(weights)}")
|
|
389
|
+
graph = Path(__file__).resolve().parent.parent / "web" / "graph-stats.js"
|
|
390
|
+
if graph.exists():
|
|
391
|
+
click.echo(f"[PASS] Knowledge graph stats: {graph.name}")
|
|
392
|
+
else:
|
|
393
|
+
click.echo("[WARN] Knowledge graph stats missing (run graphify)")
|
|
394
|
+
import httpx
|
|
395
|
+
try:
|
|
396
|
+
r = httpx.get(f"http://127.0.0.1:{cfg.core.daemon_port}/health", timeout=2)
|
|
397
|
+
click.echo(f"[PASS] Daemon reachable at port {cfg.core.daemon_port}")
|
|
398
|
+
except Exception:
|
|
399
|
+
click.echo(f"[WARN] Daemon not reachable (lazy-start on next command)")
|
|
400
|
+
import os as _os
|
|
401
|
+
ak = _os.environ.get("ANTHROPIC_API_KEY", _os.environ.get("OPENAI_API_KEY", ""))
|
|
402
|
+
if ak:
|
|
403
|
+
click.echo(f"[PASS] API key set ({ak[:8]}...)")
|
|
404
|
+
else:
|
|
405
|
+
click.echo(f"[FAIL] No API key set (ANTHROPIC_API_KEY or OPENAI_API_KEY)")
|
|
406
|
+
ok = False
|
|
407
|
+
if ok:
|
|
408
|
+
click.echo("\nResult: All checks passed.")
|
|
409
|
+
else:
|
|
410
|
+
click.echo("\nResult: Some checks failed — see above.")
|
|
411
|
+
|
|
412
|
+
@cli.command("status")
|
|
413
|
+
@click.option("--markdown", is_flag=True, help="Output in portable markdown")
|
|
414
|
+
@click.pass_context
|
|
415
|
+
def cmd_status(ctx, markdown):
|
|
416
|
+
"""Show orchestrator status: routing accuracy, fallback rate."""
|
|
417
|
+
from .logging_db import LOG_DB_PATH, get_stats
|
|
418
|
+
cfg = ctx.obj.get("cfg") if ctx.obj else None
|
|
419
|
+
if cfg is None:
|
|
420
|
+
import agent_foundry.config as _cfg
|
|
421
|
+
cfg = _cfg.Config.load()
|
|
422
|
+
db_path = LOG_DB_PATH
|
|
423
|
+
stats = get_stats(db_path)
|
|
424
|
+
if markdown:
|
|
425
|
+
click.echo("## Agent Foundry Status\n")
|
|
426
|
+
click.echo(f"| Metric | Value |\n|--------|-------|")
|
|
427
|
+
for k, v in stats.items():
|
|
428
|
+
click.echo(f"| {k} | {v} |")
|
|
429
|
+
else:
|
|
430
|
+
click.echo("=== Agent Foundry Status ===")
|
|
431
|
+
for k, v in stats.items():
|
|
432
|
+
click.echo(f" {k}: {v}")
|
|
433
|
+
|
|
434
|
+
@cli.command("consult")
|
|
435
|
+
@click.argument("need")
|
|
436
|
+
@click.pass_context
|
|
437
|
+
def cmd_consult(ctx, need):
|
|
438
|
+
"""Recommend components to install or enable for a need."""
|
|
439
|
+
from agent_foundry.planner import plan
|
|
440
|
+
from agent_foundry.config import Config
|
|
441
|
+
from agent_foundry.indexer import read_index
|
|
442
|
+
from agent_foundry.models import PlanRequest
|
|
443
|
+
from pathlib import Path
|
|
444
|
+
|
|
445
|
+
cfg = ctx.obj.get("cfg") if ctx.obj else None
|
|
446
|
+
if cfg is None:
|
|
447
|
+
cfg = Config.load()
|
|
448
|
+
idx = read_index(Path(cfg.core.index_path).expanduser())
|
|
449
|
+
if not idx:
|
|
450
|
+
click.echo("No index found. Run 'init' first.", err=True)
|
|
451
|
+
return
|
|
452
|
+
req = PlanRequest(prompt=need)
|
|
453
|
+
resp = plan(req, idx, cfg.planner)
|
|
454
|
+
click.echo(f"Components recommended for: {need}\n")
|
|
455
|
+
for r in resp.results[:5]:
|
|
456
|
+
click.echo(f" {r.skill_id} (score {r.score})")
|
|
457
|
+
click.echo(f" {r.description[:100]}")
|
|
458
|
+
click.echo(f"\nTo install: agent-foundry install {r.skill_id} (TODO)")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@cli.command("serve")
|
|
462
|
+
|
|
463
|
+
@click.option("--port", type=int, default=None, help="Override daemon port")
|
|
464
|
+
@click.option("--host", default="127.0.0.1", help="Bind host")
|
|
465
|
+
@click.option("--detach", is_flag=True, help="Detach mode (used by lazy-start)")
|
|
466
|
+
@click.pass_context
|
|
467
|
+
def cmd_serve(ctx, port, host, detach):
|
|
468
|
+
"""Run the daemon (foreground by default)."""
|
|
469
|
+
import uvicorn
|
|
470
|
+
|
|
471
|
+
cfg = ctx.obj.get("cfg") if ctx.obj else None
|
|
472
|
+
if cfg is None:
|
|
473
|
+
cfg = Config.load()
|
|
474
|
+
if port:
|
|
475
|
+
# Config is frozen — derive a new instance with the override
|
|
476
|
+
cfg = dataclasses.replace(
|
|
477
|
+
cfg, core=dataclasses.replace(cfg.core, daemon_port=port)
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
from .daemon import create_app
|
|
481
|
+
app = create_app(cfg)
|
|
482
|
+
|
|
483
|
+
if detach:
|
|
484
|
+
config = uvicorn.Config(
|
|
485
|
+
app, host=host, port=cfg.core.daemon_port, log_level="warning"
|
|
486
|
+
)
|
|
487
|
+
server = uvicorn.Server(config)
|
|
488
|
+
server.run()
|
|
489
|
+
else:
|
|
490
|
+
click.echo(
|
|
491
|
+
f"Agent Foundry daemon listening on http://{host}:{cfg.core.daemon_port}"
|
|
492
|
+
)
|
|
493
|
+
uvicorn.run(app, host=host, port=cfg.core.daemon_port)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@cli.command("feedback")
|
|
497
|
+
@click.argument("execution_id", type=int)
|
|
498
|
+
@click.argument("score", type=int)
|
|
499
|
+
@click.pass_context
|
|
500
|
+
def cmd_feedback(ctx, execution_id, score):
|
|
501
|
+
"""Rate an execution: 1 = thumbs_up, -1 = thumbs_down."""
|
|
502
|
+
if score not in (1, -1):
|
|
503
|
+
click.echo("Score must be 1 (up) or -1 (down)", err=True)
|
|
504
|
+
ctx.exit(1)
|
|
505
|
+
from .logging_db import set_feedback, LOG_DB_PATH
|
|
506
|
+
ok = set_feedback(LOG_DB_PATH, execution_id, score)
|
|
507
|
+
click.echo("✓ Feedback recorded" if ok else "✗ Execution not found")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@cli.command("learn")
|
|
511
|
+
@click.pass_context
|
|
512
|
+
def cmd_learn(ctx):
|
|
513
|
+
"""Analyze execution log and generate trigger-pattern instincts."""
|
|
514
|
+
from .logging_db import learn, LOG_DB_PATH
|
|
515
|
+
result = learn(LOG_DB_PATH)
|
|
516
|
+
click.echo(f"New instincts: {result['new_instincts']}")
|
|
517
|
+
click.echo(f"Updated: {result['updated_instincts']}")
|
|
518
|
+
click.echo(f"Skill weights updated: {result.get('weights_updated', 0)}")
|
|
519
|
+
for p in result["patterns_found"]:
|
|
520
|
+
click.echo(f" {p['skill']} → \"{p['pattern']}\"")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
@cli.command("instincts")
|
|
524
|
+
@click.option("--approved", is_flag=True, help="Show approved only")
|
|
525
|
+
@click.pass_context
|
|
526
|
+
def cmd_instincts(ctx, approved):
|
|
527
|
+
"""List learned trigger-pattern instincts."""
|
|
528
|
+
from .logging_db import list_instincts, LOG_DB_PATH
|
|
529
|
+
for inst in list_instincts(LOG_DB_PATH, approved_only=approved):
|
|
530
|
+
flag = "✓" if inst["approved"] else "○"
|
|
531
|
+
click.echo(f" {flag} #{inst['id']} [{inst['skill']}] \"{inst['pattern']}\" conf={inst['confidence']:.2f} ({inst['samples']} samples)")
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@cli.command("instinct-approve")
|
|
535
|
+
@click.argument("instinct_id", type=int)
|
|
536
|
+
@click.pass_context
|
|
537
|
+
def cmd_instinct_approve(ctx, instinct_id):
|
|
538
|
+
"""Approve a suggested instinct."""
|
|
539
|
+
from .logging_db import approve_instinct, LOG_DB_PATH
|
|
540
|
+
ok = approve_instinct(LOG_DB_PATH, instinct_id)
|
|
541
|
+
click.echo("✓ Approved" if ok else "✗ Not found")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
@cli.command("mcp")
|
|
545
|
+
@click.pass_context
|
|
546
|
+
def cmd_mcp(ctx):
|
|
547
|
+
"""Run the MCP stdio server (plan/execute/list_skills/get_skill)."""
|
|
548
|
+
from .mcp_server import run_stdio
|
|
549
|
+
run_stdio(ctx.obj.get("cfg"))
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
@cli.command("browse")
|
|
553
|
+
@click.option("--search", default="", help="Filter by id, name, description, or tag")
|
|
554
|
+
@click.option("--read", "read_id", default=None, help="Print one skill's SKILL.md")
|
|
555
|
+
@click.pass_context
|
|
556
|
+
def cmd_browse(ctx, search, read_id):
|
|
557
|
+
"""Browse the skill catalog without leaving the terminal."""
|
|
558
|
+
cfg = ctx.obj.get("cfg")
|
|
559
|
+
idx = read_index(Path(cfg.core.index_path).expanduser())
|
|
560
|
+
if idx is None:
|
|
561
|
+
raise click.ClickException("No index. Run `agent-foundry index` first.")
|
|
562
|
+
|
|
563
|
+
if read_id:
|
|
564
|
+
path = idx.skill_paths.get(read_id)
|
|
565
|
+
if not path or path.startswith("__virtual__:"):
|
|
566
|
+
raise click.ClickException(f"Unknown skill: {read_id}")
|
|
567
|
+
click.echo(Path(path).read_text(encoding="utf-8"))
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
q = search.lower().strip()
|
|
571
|
+
shown = 0
|
|
572
|
+
for s in idx.skills:
|
|
573
|
+
if s.is_fallback:
|
|
574
|
+
continue
|
|
575
|
+
haystack = " ".join([s.id, s.name, s.description, " ".join(s.tags)]).lower()
|
|
576
|
+
if q and q not in haystack:
|
|
577
|
+
continue
|
|
578
|
+
click.echo(f"{s.id:<28} [{s.category:<8}] {s.description[:90]}")
|
|
579
|
+
shown += 1
|
|
580
|
+
click.echo(f"\n{shown} skill(s). Use --read <id> to print one.")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
@cli.command("eval")
|
|
584
|
+
@click.option("--tasks", "tasks_path", required=True, type=click.Path(exists=True),
|
|
585
|
+
help="JSON task file: [{id,prompt,skill,verify}]")
|
|
586
|
+
@click.option("--model", default=None)
|
|
587
|
+
@click.option("--json", "as_json", is_flag=True, help="Print raw JSON result")
|
|
588
|
+
@click.pass_context
|
|
589
|
+
def cmd_eval(ctx, tasks_path, model, as_json):
|
|
590
|
+
"""Run a paired skill eval (with vs without skill) and report lift."""
|
|
591
|
+
from .eval import run_eval
|
|
592
|
+
cfg = ctx.obj.get("cfg")
|
|
593
|
+
result = run_eval(Path(tasks_path), cfg=cfg, model=model, db_path=LOG_DB_PATH)
|
|
594
|
+
if as_json:
|
|
595
|
+
click.echo(json.dumps(result, indent=2))
|
|
596
|
+
return
|
|
597
|
+
for t in result["tasks"]:
|
|
598
|
+
click.echo(
|
|
599
|
+
f"{t['id']:<30} with={t['with_skill_pass']} without={t['no_skill_pass']}"
|
|
600
|
+
)
|
|
601
|
+
click.echo(
|
|
602
|
+
f"\nscored={result['scored']} with={result['with_skill_pass_rate']} "
|
|
603
|
+
f"without={result['no_skill_pass_rate']} lift={result['skill_lift']} "
|
|
604
|
+
f"cost=${result['total_cost_usd']}"
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def main():
|
|
609
|
+
cli(obj={})
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
if __name__ == "__main__":
|
|
613
|
+
main()
|