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/config.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Config management: ~/.config/agent-foundry/config.toml."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import dataclasses
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
CONFIG_PATH = Path.home() / ".config" / "agent-foundry" / "config.toml"
|
|
14
|
+
LOG_DB_PATH = Path.home() / ".config" / "agent-foundry" / "executions.db"
|
|
15
|
+
INDEX_CACHE_TTL_DEFAULT = 60 # seconds
|
|
16
|
+
SCHEMA_VERSION = 1 # bumped on any config-shape change; reject mismatches on load
|
|
17
|
+
|
|
18
|
+
DEFAULT_CONFIG = """# Agent Foundry configuration
|
|
19
|
+
# Edit this file to tune the orchestrator.
|
|
20
|
+
|
|
21
|
+
[core]
|
|
22
|
+
skills_dir = "~/.claude/skills/agent-foundry"
|
|
23
|
+
index_path = "~/.config/agent-foundry/skills-index.json"
|
|
24
|
+
default_llm = "claude-3-7-sonnet-20250219"
|
|
25
|
+
token_budget = 100000
|
|
26
|
+
daemon_port = 8765
|
|
27
|
+
index_cache_ttl_seconds = 60
|
|
28
|
+
harness_type = "claude-code"
|
|
29
|
+
executor_timeout_seconds = 120
|
|
30
|
+
executor_max_tokens = 0
|
|
31
|
+
executor_retries = 2
|
|
32
|
+
|
|
33
|
+
[planner]
|
|
34
|
+
# Score added per matched trigger pattern (regex).
|
|
35
|
+
pattern_match_weight = 0.3
|
|
36
|
+
# Score added if skill name or description appears in the prompt.
|
|
37
|
+
name_in_prompt_boost = 0.2
|
|
38
|
+
# Larger value = weaker cost penalty in the ranker.
|
|
39
|
+
cost_penalty_divisor = 5000.0
|
|
40
|
+
# Default top-N returned by /plan.
|
|
41
|
+
max_results = 5
|
|
42
|
+
# Semantic fallback (optional; requires the `semantic` extra).
|
|
43
|
+
semantic_enabled = false
|
|
44
|
+
semantic_model = "all-MiniLM-L6-v2"
|
|
45
|
+
semantic_weight = 0.35
|
|
46
|
+
rerank_enabled = false
|
|
47
|
+
rerank_top_k = 10
|
|
48
|
+
|
|
49
|
+
# System prompt for the generic-reasoning fallback skill.
|
|
50
|
+
# Edit to override the built-in default.
|
|
51
|
+
# fallback_system_prompt = "You are a helpful assistant..."
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class PlannerConfig:
|
|
57
|
+
"""Tunable planner weights. Frozen — construct a new instance to change."""
|
|
58
|
+
pattern_match_weight: float = 0.3
|
|
59
|
+
name_in_prompt_boost: float = 0.2
|
|
60
|
+
cost_penalty_divisor: float = 5000.0
|
|
61
|
+
max_results: int = 5
|
|
62
|
+
semantic_enabled: bool = False
|
|
63
|
+
semantic_model: str = "all-MiniLM-L6-v2"
|
|
64
|
+
semantic_weight: float = 0.35
|
|
65
|
+
rerank_enabled: bool = False
|
|
66
|
+
rerank_top_k: int = 10
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class CoreConfig:
|
|
71
|
+
"""Runtime config. Frozen — construct a new instance to change."""
|
|
72
|
+
skills_dir: str = "~/.claude/skills/agent-foundry"
|
|
73
|
+
index_path: str = "~/.config/agent-foundry/skills-index.json"
|
|
74
|
+
default_llm: str = "claude-3-7-sonnet-20250219"
|
|
75
|
+
token_budget: int = 100000
|
|
76
|
+
daemon_port: int = 8765
|
|
77
|
+
index_cache_ttl_seconds: int = 60
|
|
78
|
+
harness_type: str = "claude-code"
|
|
79
|
+
executor_timeout_seconds: int = 120
|
|
80
|
+
executor_max_tokens: int = 0
|
|
81
|
+
executor_retries: int = 2
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class Config:
|
|
86
|
+
"""Top-level config. Frozen; one instance per load.
|
|
87
|
+
|
|
88
|
+
The runtime should treat the loaded Config as immutable. To change a value
|
|
89
|
+
(e.g. after a `/plan --max N` call), construct a new Config with the change
|
|
90
|
+
applied. Tests that want to override behavior should patch the fields they
|
|
91
|
+
care about via dataclasses.replace.
|
|
92
|
+
"""
|
|
93
|
+
core: CoreConfig = field(default_factory=CoreConfig)
|
|
94
|
+
planner: PlannerConfig = field(default_factory=PlannerConfig)
|
|
95
|
+
fallback_system_prompt: str | None = None
|
|
96
|
+
config_path: Path = CONFIG_PATH
|
|
97
|
+
schema_version: int = SCHEMA_VERSION
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def load(cls, path: Path | None = None) -> "Config":
|
|
101
|
+
"""Load config from path (default: ~/.config/agent-foundry/config.toml).
|
|
102
|
+
|
|
103
|
+
Missing file or sections fall back to defaults. The returned Config
|
|
104
|
+
is frozen; use `dataclasses.replace(cfg, core=...)` to derive a new
|
|
105
|
+
instance with overrides (e.g. CLI flags).
|
|
106
|
+
"""
|
|
107
|
+
p = Path(path) if path else CONFIG_PATH
|
|
108
|
+
cfg = cls()
|
|
109
|
+
if not p.exists():
|
|
110
|
+
return dataclasses.replace(cfg, config_path=p)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
with open(p, "rb") as f:
|
|
114
|
+
data = tomllib.load(f)
|
|
115
|
+
except Exception as exc:
|
|
116
|
+
print(f"WARN: failed to parse {p}: {exc}", file=sys.stderr)
|
|
117
|
+
return dataclasses.replace(cfg, config_path=p)
|
|
118
|
+
|
|
119
|
+
# Schema version check: warn on mismatch (older files load with defaults
|
|
120
|
+
# applied; future bumps may refuse).
|
|
121
|
+
on_disk_version = data.get("schema_version", 0)
|
|
122
|
+
if on_disk_version and on_disk_version != SCHEMA_VERSION:
|
|
123
|
+
print(
|
|
124
|
+
f"WARN: {p} has schema_version={on_disk_version}, "
|
|
125
|
+
f"running version={SCHEMA_VERSION}. Some keys may be ignored.",
|
|
126
|
+
file=sys.stderr,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
core_overrides = {
|
|
130
|
+
k: data["core"][k]
|
|
131
|
+
for k in cfg.core.__dataclass_fields__
|
|
132
|
+
if k in data.get("core", {})
|
|
133
|
+
}
|
|
134
|
+
planner_overrides = {
|
|
135
|
+
k: data["planner"][k]
|
|
136
|
+
for k in cfg.planner.__dataclass_fields__
|
|
137
|
+
if k in data.get("planner", {})
|
|
138
|
+
}
|
|
139
|
+
return dataclasses.replace(
|
|
140
|
+
cfg,
|
|
141
|
+
core=dataclasses.replace(cfg.core, **core_overrides),
|
|
142
|
+
planner=dataclasses.replace(cfg.planner, **planner_overrides),
|
|
143
|
+
fallback_system_prompt=data.get("fallback_system_prompt"),
|
|
144
|
+
config_path=p,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def save(self, path: Path | None = None) -> Path:
|
|
148
|
+
"""Write current config to disk (overwrites)."""
|
|
149
|
+
p = Path(path) if path else self.config_path
|
|
150
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
|
|
152
|
+
# Render manually (preserves section comments)
|
|
153
|
+
lines: list[str] = []
|
|
154
|
+
lines.append("# Agent Foundry configuration")
|
|
155
|
+
lines.append("# Generated by `agent-foundry init`. Edit to tune.")
|
|
156
|
+
lines.append("")
|
|
157
|
+
lines.append(f"schema_version = {SCHEMA_VERSION}")
|
|
158
|
+
lines.append("")
|
|
159
|
+
lines.append("[core]")
|
|
160
|
+
for k, v in vars(self.core).items():
|
|
161
|
+
v_str = f'"{v}"' if isinstance(v, str) else str(v)
|
|
162
|
+
lines.append(f"{k} = {v_str}")
|
|
163
|
+
lines.append("")
|
|
164
|
+
lines.append("[planner]")
|
|
165
|
+
for k, v in vars(self.planner).items():
|
|
166
|
+
v_str = f'"{v}"' if isinstance(v, str) else str(v)
|
|
167
|
+
lines.append(f"{k} = {v_str}")
|
|
168
|
+
lines.append("")
|
|
169
|
+
if self.fallback_system_prompt is not None:
|
|
170
|
+
# toml multiline
|
|
171
|
+
lines.append("# Optional override for the generic-reasoning fallback prompt.")
|
|
172
|
+
escaped = self.fallback_system_prompt.replace("\\", "\\\\").replace('"', '\\"')
|
|
173
|
+
lines.append(f'fallback_system_prompt = "{escaped}"')
|
|
174
|
+
lines.append("")
|
|
175
|
+
else:
|
|
176
|
+
lines.append("# Uncomment to override the fallback prompt:")
|
|
177
|
+
lines.append('# fallback_system_prompt = "You are a helpful assistant..."')
|
|
178
|
+
lines.append("")
|
|
179
|
+
|
|
180
|
+
p.write_text("\n".join(lines))
|
|
181
|
+
return p
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def init_config(force: bool = False) -> Config:
|
|
185
|
+
"""Create config dir + default config file. Returns loaded config."""
|
|
186
|
+
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
if not CONFIG_PATH.exists() or force:
|
|
188
|
+
cfg = Config()
|
|
189
|
+
cfg.save()
|
|
190
|
+
return Config.load()
|
agent_foundry/daemon.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""FastAPI daemon: /health, /index, /plan, /execute, /loop, /events (SSE), /site-data."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from fastapi import FastAPI, HTTPException
|
|
11
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
12
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
13
|
+
|
|
14
|
+
# Repo + generated web assets (used by the live-update SSE endpoint for local dev).
|
|
15
|
+
_REPO = Path(__file__).resolve().parent.parent
|
|
16
|
+
_WEB = _REPO / "web"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _site_signature() -> str:
|
|
20
|
+
"""A cheap fingerprint of the skill/agent sources so the SSE loop can detect edits."""
|
|
21
|
+
parts = []
|
|
22
|
+
for base in ("skills", "agents"):
|
|
23
|
+
d = _REPO / base
|
|
24
|
+
if not d.exists():
|
|
25
|
+
continue
|
|
26
|
+
for f in sorted(d.rglob("*.md")):
|
|
27
|
+
try:
|
|
28
|
+
parts.append(f"{f.relative_to(_REPO)}:{int(f.stat().st_mtime)}")
|
|
29
|
+
except OSError:
|
|
30
|
+
continue
|
|
31
|
+
return "|".join(parts)
|
|
32
|
+
|
|
33
|
+
from .config import Config, LOG_DB_PATH
|
|
34
|
+
from .executor import execute as executor_run
|
|
35
|
+
from .indexer import (
|
|
36
|
+
build_index,
|
|
37
|
+
get_index_cached,
|
|
38
|
+
invalidate_cache,
|
|
39
|
+
write_index,
|
|
40
|
+
)
|
|
41
|
+
from .logging_db import (
|
|
42
|
+
count_executions,
|
|
43
|
+
log_execution,
|
|
44
|
+
set_feedback,
|
|
45
|
+
learn,
|
|
46
|
+
list_instincts,
|
|
47
|
+
approve_instinct,
|
|
48
|
+
get_stats as db_stats,
|
|
49
|
+
engram_lookup,
|
|
50
|
+
get_skill_weights,
|
|
51
|
+
get_loop_session,
|
|
52
|
+
)
|
|
53
|
+
from .loop import resume_loop, run_loop
|
|
54
|
+
from .models import (
|
|
55
|
+
ExecuteRequest,
|
|
56
|
+
FeedbackRequest,
|
|
57
|
+
IndexResponse,
|
|
58
|
+
LearnResponse,
|
|
59
|
+
LoopRequest,
|
|
60
|
+
LoopResumeRequest,
|
|
61
|
+
PlanRequest,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def create_app(cfg: Config | None = None) -> FastAPI:
|
|
66
|
+
cfg = cfg or Config.load()
|
|
67
|
+
app = FastAPI(title="Agent Foundry", version="0.2.0")
|
|
68
|
+
db_path = LOG_DB_PATH
|
|
69
|
+
|
|
70
|
+
# Dev-only: allow the statically served site (different origin) to reach the
|
|
71
|
+
# SSE + site-data endpoints for instant live updates.
|
|
72
|
+
app.add_middleware(
|
|
73
|
+
CORSMiddleware,
|
|
74
|
+
allow_origins=["*"],
|
|
75
|
+
allow_methods=["GET"],
|
|
76
|
+
allow_headers=["*"],
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
@app.get("/health")
|
|
80
|
+
def health():
|
|
81
|
+
return {"status": "ok", "skills_indexed": 0}
|
|
82
|
+
|
|
83
|
+
@app.get("/site-data")
|
|
84
|
+
def site_data():
|
|
85
|
+
"""Serve the generated data.json consumed by the reactive store (store.js)."""
|
|
86
|
+
f = _WEB / "data.json"
|
|
87
|
+
if not f.exists():
|
|
88
|
+
raise HTTPException(status_code=404, detail="data.json not generated yet — run scripts/gen-site-data.py")
|
|
89
|
+
return JSONResponse(json.loads(f.read_text(encoding="utf-8")))
|
|
90
|
+
|
|
91
|
+
@app.get("/events")
|
|
92
|
+
async def events():
|
|
93
|
+
"""Server-Sent Events: emit a 'changed' event when skill/agent sources change."""
|
|
94
|
+
async def stream():
|
|
95
|
+
last = _site_signature()
|
|
96
|
+
yield "event: changed\ndata: init\n\n"
|
|
97
|
+
beat = 0
|
|
98
|
+
while True:
|
|
99
|
+
await asyncio.sleep(1.0)
|
|
100
|
+
sig = _site_signature()
|
|
101
|
+
if sig != last:
|
|
102
|
+
last = sig
|
|
103
|
+
yield "event: changed\ndata: update\n\n"
|
|
104
|
+
else:
|
|
105
|
+
beat += 1
|
|
106
|
+
if beat >= 15: # heartbeat keeps the connection alive
|
|
107
|
+
beat = 0
|
|
108
|
+
yield ": keep-alive\n\n"
|
|
109
|
+
return StreamingResponse(stream(), media_type="text/event-stream")
|
|
110
|
+
|
|
111
|
+
@app.post("/index", response_model=IndexResponse)
|
|
112
|
+
def do_index():
|
|
113
|
+
skills_dir = Path(cfg.core.skills_dir).expanduser()
|
|
114
|
+
if not skills_dir.exists():
|
|
115
|
+
raise HTTPException(status_code=400, detail=f"skills_dir does not exist: {skills_dir}")
|
|
116
|
+
|
|
117
|
+
t0 = time.time()
|
|
118
|
+
idx = build_index(skills_dir)
|
|
119
|
+
out = Path(cfg.core.index_path).expanduser()
|
|
120
|
+
write_index(idx, out)
|
|
121
|
+
invalidate_cache(out)
|
|
122
|
+
|
|
123
|
+
# Refresh /health cache lazily
|
|
124
|
+
return IndexResponse(
|
|
125
|
+
total_skills=len(idx.skills),
|
|
126
|
+
took_ms=(time.time() - t0) * 1000.0,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
@app.post("/plan")
|
|
130
|
+
def do_plan(req: PlanRequest):
|
|
131
|
+
idx = get_index_cached(Path(cfg.core.index_path).expanduser(),
|
|
132
|
+
ttl_seconds=cfg.core.index_cache_ttl_seconds)
|
|
133
|
+
if idx is None:
|
|
134
|
+
raise HTTPException(status_code=409,
|
|
135
|
+
detail="No index. POST /index first.")
|
|
136
|
+
# Engram fast-path: if we have a high-confidence cached routing for this
|
|
137
|
+
# prompt's N-gram fingerprint, use it directly. Falls through to planner
|
|
138
|
+
# otherwise.
|
|
139
|
+
rerank = cfg.core.default_llm if cfg.planner.rerank_enabled else None
|
|
140
|
+
eg = engram_lookup(db_path, req.prompt, min_confidence=0.6)
|
|
141
|
+
if eg:
|
|
142
|
+
from .planner import plan as planner_run
|
|
143
|
+
full = planner_run(req, idx, cfg.planner, weights=get_skill_weights(db_path),
|
|
144
|
+
rerank_model=rerank)
|
|
145
|
+
return {**full.model_dump(), "engram_cache_hit": eg}
|
|
146
|
+
from .planner import plan as planner_run
|
|
147
|
+
return planner_run(req, idx, cfg.planner, weights=get_skill_weights(db_path),
|
|
148
|
+
rerank_model=rerank)
|
|
149
|
+
|
|
150
|
+
@app.get("/engram")
|
|
151
|
+
def do_engram_lookup(prompt: str):
|
|
152
|
+
"""Engram-style O(1) instinct lookup for a prompt."""
|
|
153
|
+
hit = engram_lookup(db_path, prompt, min_confidence=0.5)
|
|
154
|
+
return {"hit": hit}
|
|
155
|
+
|
|
156
|
+
@app.post("/execute")
|
|
157
|
+
def do_execute(req: ExecuteRequest):
|
|
158
|
+
idx = get_index_cached(Path(cfg.core.index_path).expanduser(),
|
|
159
|
+
ttl_seconds=cfg.core.index_cache_ttl_seconds)
|
|
160
|
+
if idx is None:
|
|
161
|
+
raise HTTPException(status_code=409,
|
|
162
|
+
detail="No index. POST /index first.")
|
|
163
|
+
return executor_run(req, model=cfg.core.default_llm,
|
|
164
|
+
skill_paths=idx.skill_paths,
|
|
165
|
+
db_path=db_path,
|
|
166
|
+
skill_hashes={s.id: s.sha256 for s in idx.skills},
|
|
167
|
+
timeout_seconds=cfg.core.executor_timeout_seconds,
|
|
168
|
+
max_tokens=cfg.core.executor_max_tokens,
|
|
169
|
+
retries=cfg.core.executor_retries)
|
|
170
|
+
|
|
171
|
+
@app.post("/loop")
|
|
172
|
+
def do_loop(req: LoopRequest):
|
|
173
|
+
# Resolve model override if any
|
|
174
|
+
model_override = req.model
|
|
175
|
+
# run_loop uses cfg.core.default_llm unless model_override provided
|
|
176
|
+
return run_loop(req, cfg=cfg, db_path=db_path, model_override=model_override)
|
|
177
|
+
|
|
178
|
+
@app.post("/loop/resume")
|
|
179
|
+
def do_loop_resume(req: LoopResumeRequest):
|
|
180
|
+
return resume_loop(req, cfg=cfg, db_path=db_path, model_override=req.model)
|
|
181
|
+
|
|
182
|
+
@app.get("/loop/{session_id}")
|
|
183
|
+
def get_loop(session_id: int):
|
|
184
|
+
session = get_loop_session(db_path, session_id)
|
|
185
|
+
if session is None:
|
|
186
|
+
raise HTTPException(status_code=404, detail="Loop session not found")
|
|
187
|
+
return session
|
|
188
|
+
|
|
189
|
+
@app.get("/stats")
|
|
190
|
+
def stats():
|
|
191
|
+
return {"executions": count_executions(db_path)}
|
|
192
|
+
|
|
193
|
+
@app.post("/feedback")
|
|
194
|
+
def do_feedback(req: FeedbackRequest):
|
|
195
|
+
ok = set_feedback(db_path, req.execution_id, req.feedback)
|
|
196
|
+
if not ok:
|
|
197
|
+
raise HTTPException(status_code=404, detail="Execution not found or invalid feedback")
|
|
198
|
+
return {"ok": True}
|
|
199
|
+
|
|
200
|
+
@app.post("/learn")
|
|
201
|
+
def do_learn():
|
|
202
|
+
result = learn(db_path)
|
|
203
|
+
return LearnResponse(
|
|
204
|
+
new_instincts=result["new_instincts"],
|
|
205
|
+
updated_instincts=result["updated_instincts"],
|
|
206
|
+
patterns_found=result["patterns_found"],
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
@app.get("/instincts")
|
|
210
|
+
def get_instincts(approved: bool = False):
|
|
211
|
+
return {"instincts": list_instincts(db_path, approved_only=approved)}
|
|
212
|
+
|
|
213
|
+
@app.post("/instincts/{instinct_id}/approve")
|
|
214
|
+
def approve(instinct_id: int):
|
|
215
|
+
ok = approve_instinct(db_path, instinct_id)
|
|
216
|
+
if not ok:
|
|
217
|
+
raise HTTPException(status_code=404, detail="Instinct not found")
|
|
218
|
+
return {"ok": True}
|
|
219
|
+
|
|
220
|
+
return app
|
agent_foundry/eval.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Paired skill evaluation: same task with and without a skill.
|
|
2
|
+
|
|
3
|
+
Task file format (JSON array):
|
|
4
|
+
[{"id": "t1", "prompt": "...", "skill": "api-design", "verify": "grep -qi ok"}]
|
|
5
|
+
|
|
6
|
+
`verify` is an optional shell command. It receives the model output on stdin
|
|
7
|
+
and passes when the exit code is 0. Without a verifier the run is recorded as
|
|
8
|
+
unscored.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .config import Config, LOG_DB_PATH
|
|
18
|
+
from .executor import execute
|
|
19
|
+
from .indexer import GENERIC_REASONING_ID, get_index_cached
|
|
20
|
+
from .models import ExecuteRequest
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _verify(command: str, output: str) -> bool | None:
|
|
24
|
+
if not command:
|
|
25
|
+
return None
|
|
26
|
+
try:
|
|
27
|
+
proc = subprocess.run(
|
|
28
|
+
command, shell=True, input=output, text=True,
|
|
29
|
+
capture_output=True, timeout=60,
|
|
30
|
+
)
|
|
31
|
+
return proc.returncode == 0
|
|
32
|
+
except Exception:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_eval(tasks_path: Path, *, cfg: Config, model: str | None = None,
|
|
37
|
+
db_path: Path | None = None) -> dict[str, Any]:
|
|
38
|
+
idx = get_index_cached(Path(cfg.core.index_path).expanduser(),
|
|
39
|
+
ttl_seconds=cfg.core.index_cache_ttl_seconds)
|
|
40
|
+
if idx is None:
|
|
41
|
+
raise RuntimeError("No skill index. Run `agent-foundry index` first.")
|
|
42
|
+
tasks = json.loads(Path(tasks_path).read_text(encoding="utf-8"))
|
|
43
|
+
if not isinstance(tasks, list) or not tasks:
|
|
44
|
+
raise ValueError("task file must be a non-empty JSON array")
|
|
45
|
+
|
|
46
|
+
model_name = model or cfg.core.default_llm
|
|
47
|
+
hashes = {s.id: s.sha256 for s in idx.skills}
|
|
48
|
+
results = []
|
|
49
|
+
for task in tasks:
|
|
50
|
+
prompt = str(task["prompt"])
|
|
51
|
+
skill_id = str(task.get("skill") or GENERIC_REASONING_ID)
|
|
52
|
+
verify = str(task.get("verify") or "")
|
|
53
|
+
|
|
54
|
+
with_resp = execute(
|
|
55
|
+
ExecuteRequest(skill_id=skill_id, prompt=prompt, force=True),
|
|
56
|
+
model=model_name, skill_paths=idx.skill_paths,
|
|
57
|
+
db_path=db_path, skill_hashes=hashes,
|
|
58
|
+
timeout_seconds=cfg.core.executor_timeout_seconds,
|
|
59
|
+
max_tokens=cfg.core.executor_max_tokens,
|
|
60
|
+
retries=cfg.core.executor_retries,
|
|
61
|
+
)
|
|
62
|
+
no_resp = execute(
|
|
63
|
+
ExecuteRequest(skill_id=GENERIC_REASONING_ID, prompt=prompt, force=True),
|
|
64
|
+
model=model_name, skill_paths=idx.skill_paths,
|
|
65
|
+
db_path=db_path, skill_hashes=hashes,
|
|
66
|
+
timeout_seconds=cfg.core.executor_timeout_seconds,
|
|
67
|
+
max_tokens=cfg.core.executor_max_tokens,
|
|
68
|
+
retries=cfg.core.executor_retries,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
with_pass = _verify(verify, with_resp.output) if with_resp.success else False
|
|
72
|
+
no_pass = _verify(verify, no_resp.output) if no_resp.success else False
|
|
73
|
+
results.append({
|
|
74
|
+
"id": task.get("id", prompt[:40]),
|
|
75
|
+
"skill": skill_id,
|
|
76
|
+
"with_skill_pass": with_pass,
|
|
77
|
+
"no_skill_pass": no_pass,
|
|
78
|
+
"with_skill_cost_usd": with_resp.cost_usd,
|
|
79
|
+
"no_skill_cost_usd": no_resp.cost_usd,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
scored = [r for r in results if r["with_skill_pass"] is not None]
|
|
83
|
+
with_rate = (sum(1 for r in scored if r["with_skill_pass"]) / len(scored)) if scored else 0.0
|
|
84
|
+
no_rate = (sum(1 for r in scored if r["no_skill_pass"]) / len(scored)) if scored else 0.0
|
|
85
|
+
return {
|
|
86
|
+
"tasks": results,
|
|
87
|
+
"scored": len(scored),
|
|
88
|
+
"with_skill_pass_rate": round(with_rate, 3),
|
|
89
|
+
"no_skill_pass_rate": round(no_rate, 3),
|
|
90
|
+
"skill_lift": round(with_rate - no_rate, 3),
|
|
91
|
+
"total_cost_usd": round(sum(r["with_skill_cost_usd"] + r["no_skill_cost_usd"]
|
|
92
|
+
for r in results), 4),
|
|
93
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Executor: run a skill (prompt + body) via LiteLLM."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .indexer import GENERIC_REASONING_ID, GENERIC_REASONING_SYSTEM_PROMPT
|
|
10
|
+
from .models import (
|
|
11
|
+
ExecuteRequest,
|
|
12
|
+
ExecuteResponse,
|
|
13
|
+
TokenEstimate,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# API response cap. Full output is always stored in the log DB.
|
|
18
|
+
OUTPUT_MAX_CHARS = 8000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def estimate_prompt_tokens(prompt: str) -> int:
|
|
22
|
+
return max(1, len(prompt) // 4)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_skill_path(skill_paths: dict[str, str], skill_id: str) -> str | None:
|
|
26
|
+
return skill_paths.get(skill_id)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _verify_integrity(path: Path, expected_sha256: str | None) -> None:
|
|
30
|
+
"""Refuse a skill file whose bytes changed since indexing."""
|
|
31
|
+
if not expected_sha256:
|
|
32
|
+
return
|
|
33
|
+
actual = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
34
|
+
if actual != expected_sha256:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"skill integrity check failed for {path.name}: "
|
|
37
|
+
f"expected {expected_sha256[:12]}, got {actual[:12]}. Re-run `agent-foundry index`."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_messages(skill_id: str, prompt: str, skill_paths: dict[str, str],
|
|
42
|
+
fallback_prompt: str | None = None,
|
|
43
|
+
expected_sha256: str | None = None) -> tuple[list[dict], bool]:
|
|
44
|
+
"""Return (messages, is_fallback). Raises ValueError on integrity mismatch."""
|
|
45
|
+
path = find_skill_path(skill_paths, skill_id)
|
|
46
|
+
if skill_id == GENERIC_REASONING_ID or not path or path.startswith("__virtual__:"):
|
|
47
|
+
system = fallback_prompt or GENERIC_REASONING_SYSTEM_PROMPT
|
|
48
|
+
return ([{"role": "system", "content": system},
|
|
49
|
+
{"role": "user", "content": prompt}], True)
|
|
50
|
+
|
|
51
|
+
file_path = Path(path)
|
|
52
|
+
_verify_integrity(file_path, expected_sha256)
|
|
53
|
+
try:
|
|
54
|
+
system = file_path.read_text(encoding="utf-8")
|
|
55
|
+
except Exception as e:
|
|
56
|
+
return ([{"role": "system", "content": f"(failed to load skill: {e})"},
|
|
57
|
+
{"role": "user", "content": prompt}], False)
|
|
58
|
+
return ([{"role": "system", "content": system},
|
|
59
|
+
{"role": "user", "content": prompt}], False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def complete(messages: list[dict], *, model: str, timeout_seconds: int = 120,
|
|
63
|
+
max_tokens: int = 0, retries: int = 2) -> tuple[str, int, float]:
|
|
64
|
+
"""Run one completion. Returns (full_text, tokens_used, cost_usd)."""
|
|
65
|
+
import litellm
|
|
66
|
+
|
|
67
|
+
kwargs: dict[str, Any] = {
|
|
68
|
+
"model": model,
|
|
69
|
+
"messages": messages,
|
|
70
|
+
"timeout": timeout_seconds,
|
|
71
|
+
"num_retries": retries,
|
|
72
|
+
}
|
|
73
|
+
if max_tokens and max_tokens > 0:
|
|
74
|
+
kwargs["max_tokens"] = max_tokens
|
|
75
|
+
|
|
76
|
+
resp = litellm.completion(**kwargs)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
text = resp.choices[0].message.content or ""
|
|
80
|
+
except Exception:
|
|
81
|
+
text = ""
|
|
82
|
+
if not isinstance(text, str):
|
|
83
|
+
text = str(text)
|
|
84
|
+
|
|
85
|
+
usage: dict[str, Any] = {}
|
|
86
|
+
try:
|
|
87
|
+
usage = resp.usage.model_dump() if hasattr(resp.usage, "model_dump") else dict(resp.usage or {})
|
|
88
|
+
except Exception:
|
|
89
|
+
pass
|
|
90
|
+
tokens_used = int(usage.get("total_tokens") or 0)
|
|
91
|
+
if tokens_used == 0:
|
|
92
|
+
pt = int(usage.get("prompt_tokens") or 0)
|
|
93
|
+
ct = int(usage.get("completion_tokens") or 0)
|
|
94
|
+
tokens_used = pt + ct
|
|
95
|
+
|
|
96
|
+
cost_usd = 0.0
|
|
97
|
+
try:
|
|
98
|
+
cost_usd = float(litellm.completion_cost(completion_response=resp) or 0.0)
|
|
99
|
+
except Exception:
|
|
100
|
+
pass
|
|
101
|
+
return text, tokens_used, cost_usd
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def execute(request: ExecuteRequest, *, model: str, skill_paths: dict[str, str],
|
|
105
|
+
db_path: Path | None = None, planner_score: float | None = None,
|
|
106
|
+
fallback_prompt: str | None = None,
|
|
107
|
+
skill_hashes: dict[str, str] | None = None,
|
|
108
|
+
timeout_seconds: int = 120, max_tokens: int = 0,
|
|
109
|
+
retries: int = 2) -> ExecuteResponse:
|
|
110
|
+
"""Run the skill via LiteLLM. Optionally log to SQLite."""
|
|
111
|
+
t0 = time.time()
|
|
112
|
+
was_fallback = False
|
|
113
|
+
try:
|
|
114
|
+
was_fallback = request.skill_id == GENERIC_REASONING_ID
|
|
115
|
+
messages, was_fallback = build_messages(
|
|
116
|
+
request.skill_id, request.prompt, skill_paths,
|
|
117
|
+
fallback_prompt=fallback_prompt,
|
|
118
|
+
expected_sha256=(skill_hashes or {}).get(request.skill_id),
|
|
119
|
+
)
|
|
120
|
+
text, tokens_used, cost_usd = complete(
|
|
121
|
+
messages, model=model, timeout_seconds=timeout_seconds,
|
|
122
|
+
max_tokens=max_tokens, retries=retries,
|
|
123
|
+
)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
err = str(exc)
|
|
126
|
+
dur = time.time() - t0
|
|
127
|
+
if db_path is not None:
|
|
128
|
+
from .logging_db import log_execution
|
|
129
|
+
log_execution(db_path, skill_id=request.skill_id, prompt=request.prompt,
|
|
130
|
+
output="", tokens_used=0, duration_seconds=dur,
|
|
131
|
+
success=False, error=err[:1000],
|
|
132
|
+
planner_score=planner_score, was_fallback=was_fallback)
|
|
133
|
+
return ExecuteResponse(
|
|
134
|
+
skill_id=request.skill_id,
|
|
135
|
+
output="",
|
|
136
|
+
tokens_used=0,
|
|
137
|
+
duration_seconds=dur,
|
|
138
|
+
success=False,
|
|
139
|
+
error=err[:1000],
|
|
140
|
+
was_fallback=was_fallback,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
dur = time.time() - t0
|
|
144
|
+
if db_path is not None:
|
|
145
|
+
from .logging_db import log_execution
|
|
146
|
+
log_execution(db_path, skill_id=request.skill_id, prompt=request.prompt,
|
|
147
|
+
output=text, tokens_used=tokens_used,
|
|
148
|
+
duration_seconds=dur, success=True,
|
|
149
|
+
planner_score=planner_score, was_fallback=was_fallback)
|
|
150
|
+
|
|
151
|
+
return ExecuteResponse(
|
|
152
|
+
skill_id=request.skill_id,
|
|
153
|
+
output=text[:OUTPUT_MAX_CHARS],
|
|
154
|
+
tokens_used=tokens_used,
|
|
155
|
+
duration_seconds=dur,
|
|
156
|
+
success=True,
|
|
157
|
+
was_fallback=was_fallback,
|
|
158
|
+
cost_usd=cost_usd,
|
|
159
|
+
)
|