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/indexer.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Indexer: scan skills/*/SKILL.md, build SkillIndex."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import time
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from .models import SkillIndex, SkillManifest
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
FRONTMATTER_RE = re.compile(r"^---[\r\n]+(.*?)[\r\n]+---", re.DOTALL)
|
|
16
|
+
ID_RE = re.compile(r"^[a-z0-9-]+$")
|
|
17
|
+
|
|
18
|
+
# Virtual fallback skill — injected by the indexer, not loaded from disk.
|
|
19
|
+
GENERIC_REASONING_ID = "generic-reasoning"
|
|
20
|
+
GENERIC_REASONING_SYSTEM_PROMPT = (
|
|
21
|
+
"You are a helpful assistant. The user\'s prompt didn\'t match any specific "
|
|
22
|
+
"skill. Respond thoughtfully and thoroughly."
|
|
23
|
+
)
|
|
24
|
+
GENERIC_REASONING_MANIFEST: dict[str, Any] = {
|
|
25
|
+
"id": GENERIC_REASONING_ID,
|
|
26
|
+
"name": "Generic Reasoning",
|
|
27
|
+
"description": "Fallback skill for prompts that match no specific skill.",
|
|
28
|
+
"trigger_patterns": [],
|
|
29
|
+
"estimated_token_cost": {"input": 500, "output": 1500},
|
|
30
|
+
"estimated_time_seconds": 5,
|
|
31
|
+
"version": "0.1.0",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"category": "core",
|
|
34
|
+
"location": "__virtual__:generic-reasoning",
|
|
35
|
+
"tags": ["fallback", "builtin"],
|
|
36
|
+
"is_fallback": True,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _coerce_value(value: str):
|
|
41
|
+
"""Strip quotes and parse inline YAML lists like [a, b, c]."""
|
|
42
|
+
value = value.strip()
|
|
43
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
|
44
|
+
return value[1:-1]
|
|
45
|
+
if value.startswith("[") and value.endswith("]"):
|
|
46
|
+
inner = value[1:-1].strip()
|
|
47
|
+
if not inner:
|
|
48
|
+
return []
|
|
49
|
+
return [item.strip().strip('"').strip("'") for item in inner.split(",") if item.strip()]
|
|
50
|
+
return value
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_simple_frontmatter(body: str) -> dict:
|
|
54
|
+
"""Parse frontmatter body without depending on PyYAML.
|
|
55
|
+
|
|
56
|
+
Handles:
|
|
57
|
+
- 'key: value' lines
|
|
58
|
+
- ' - list item' under the most-recent key
|
|
59
|
+
- Quoted values (single or double quotes)
|
|
60
|
+
- Both \\n and \\r\\n newlines
|
|
61
|
+
- Continuation lines (multi-line values appended with a space)
|
|
62
|
+
"""
|
|
63
|
+
result: dict[str, object] = {}
|
|
64
|
+
current_key: str | None = None
|
|
65
|
+
|
|
66
|
+
# Normalize to \n so split behaves identically
|
|
67
|
+
body = body.replace("\r\n", "\n").replace("\r", "\n")
|
|
68
|
+
|
|
69
|
+
for raw_line in body.split("\n"):
|
|
70
|
+
if not raw_line.strip():
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
# Top-level "key: value"
|
|
74
|
+
if ":" in raw_line and not raw_line.startswith((" ", "\t")) and not raw_line.startswith("-"):
|
|
75
|
+
key, _, value = raw_line.partition(":")
|
|
76
|
+
key = key.strip()
|
|
77
|
+
value = value.strip()
|
|
78
|
+
result[key] = _coerce_value(value)
|
|
79
|
+
current_key = key
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
# List item under current key
|
|
83
|
+
stripped = raw_line.lstrip()
|
|
84
|
+
if stripped.startswith("- ") and current_key:
|
|
85
|
+
item = stripped[2:].strip()
|
|
86
|
+
if len(item) >= 2 and item[0] == item[-1] and item[0] in ('"', "'"):
|
|
87
|
+
item = item[1:-1]
|
|
88
|
+
existing = result.get(current_key)
|
|
89
|
+
if not isinstance(existing, list):
|
|
90
|
+
result[current_key] = [existing] if existing is not None else []
|
|
91
|
+
if isinstance(result[current_key], list):
|
|
92
|
+
result[current_key].append(item)
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
# Continuation of current value (best-effort)
|
|
96
|
+
if current_key and isinstance(result.get(current_key), str):
|
|
97
|
+
result[current_key] = str(result[current_key]) + " " + raw_line.strip()
|
|
98
|
+
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def parse_frontmatter(text: str) -> dict | None:
|
|
103
|
+
"""Extract YAML frontmatter between --- markers.
|
|
104
|
+
|
|
105
|
+
Uses a simple line-based parser (no PyYAML needed) so descriptions
|
|
106
|
+
with colons, quoted strings, or list items work reliably.
|
|
107
|
+
"""
|
|
108
|
+
m = FRONTMATTER_RE.match(text)
|
|
109
|
+
if not m:
|
|
110
|
+
return None
|
|
111
|
+
return _parse_simple_frontmatter(m.group(1))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def estimate_cost_from_desc(name: str, description: str) -> dict:
|
|
115
|
+
"""Heuristic cost estimate."""
|
|
116
|
+
nl = (name or "").lower()
|
|
117
|
+
if any(w in nl for w in ["check", "audit", "verify", "review"]):
|
|
118
|
+
return {"input": 100, "output": 150}
|
|
119
|
+
if any(w in nl for w in ["design", "strategy", "decompose", "plan"]):
|
|
120
|
+
return {"input": 350, "output": 500}
|
|
121
|
+
desc_len = len(description or "")
|
|
122
|
+
return {
|
|
123
|
+
"input": 200 + min(desc_len * 2, 400),
|
|
124
|
+
"output": 300 + min(desc_len, 600),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def estimate_time_from_name(name: str) -> int:
|
|
129
|
+
nl = (name or "").lower()
|
|
130
|
+
if any(w in nl for w in ["check", "verify", "audit"]):
|
|
131
|
+
return 2
|
|
132
|
+
if any(w in nl for w in ["design", "decompose", "strategy"]):
|
|
133
|
+
return 8
|
|
134
|
+
return 3
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def derive_triggers(description: str) -> list:
|
|
138
|
+
"""Pull regex triggers from the description."""
|
|
139
|
+
if not description:
|
|
140
|
+
return []
|
|
141
|
+
desc = description.strip().strip('"').strip("'")
|
|
142
|
+
triggers: list = []
|
|
143
|
+
m = re.search(r"Use when\s+(.+?)(?:\.\s|\.$|$)", desc, re.IGNORECASE | re.DOTALL)
|
|
144
|
+
if m:
|
|
145
|
+
clause = m.group(1).strip()
|
|
146
|
+
for piece in re.split(r"\s*[,;]\s*|\s+or\s+", clause):
|
|
147
|
+
piece = piece.strip().strip('"').strip("'").rstrip(".").strip()
|
|
148
|
+
if 3 < len(piece) < 80:
|
|
149
|
+
triggers.append(r"\b" + re.escape(piece.lower()))
|
|
150
|
+
words = re.findall(r"[a-zA-Z][a-zA-Z0-9-]{2,}", desc.lower())[:3]
|
|
151
|
+
if words:
|
|
152
|
+
triggers.append(r"\b" + r"\s+".join(re.escape(w) for w in words))
|
|
153
|
+
seen: set = set()
|
|
154
|
+
out: list = []
|
|
155
|
+
for t in triggers:
|
|
156
|
+
if t not in seen and len(out) < 6:
|
|
157
|
+
out.append(t)
|
|
158
|
+
seen.add(t)
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def build_manifest_from_frontmatter(fm: dict, location: Path) -> dict:
|
|
163
|
+
"""Construct a manifest dict from parsed frontmatter + heuristics."""
|
|
164
|
+
name = (fm.get("name") or "").strip() or location.parent.name
|
|
165
|
+
description = (fm.get("description") or "").strip().strip('"').strip("'")
|
|
166
|
+
skill_id = location.parent.name
|
|
167
|
+
if not ID_RE.match(skill_id):
|
|
168
|
+
skill_id = re.sub(r"[^a-z0-9-]+", "-", skill_id.lower()).strip("-")
|
|
169
|
+
|
|
170
|
+
cost = fm.get("estimated_token_cost") or estimate_cost_from_desc(name, description)
|
|
171
|
+
time_s = fm.get("estimated_time_seconds") or estimate_time_from_name(name)
|
|
172
|
+
|
|
173
|
+
location_str = str(location).replace("\\\\", "/")
|
|
174
|
+
category = "core" if "/core/" in location_str else ("optional" if "/optional/" in location_str else "core")
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
"id": skill_id,
|
|
178
|
+
"name": name,
|
|
179
|
+
"description": description,
|
|
180
|
+
"version": str(fm.get("version", "0.1.0")),
|
|
181
|
+
"license": str(fm.get("license", "MIT")),
|
|
182
|
+
"category": category,
|
|
183
|
+
"location": location_str,
|
|
184
|
+
"trigger_patterns": fm.get("trigger_patterns") or derive_triggers(description),
|
|
185
|
+
"estimated_token_cost": {
|
|
186
|
+
"input": int(cost.get("input", 200) if isinstance(cost, dict) else 200),
|
|
187
|
+
"output": int(cost.get("output", 300) if isinstance(cost, dict) else 300),
|
|
188
|
+
},
|
|
189
|
+
"estimated_time_seconds": int(time_s),
|
|
190
|
+
"tags": fm.get("tags", []) or [],
|
|
191
|
+
"is_fallback": False,
|
|
192
|
+
"sha256": hashlib.sha256(location.read_bytes()).hexdigest(),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def build_index(skills_dir: Path) -> SkillIndex:
|
|
197
|
+
"""Walk skills_dir for SKILL.md files, build SkillIndex with the virtual fallback injected."""
|
|
198
|
+
skills: list = []
|
|
199
|
+
skill_paths: dict[str, str] = {}
|
|
200
|
+
|
|
201
|
+
if skills_dir.exists():
|
|
202
|
+
for path in sorted(skills_dir.rglob("SKILL.md")):
|
|
203
|
+
try:
|
|
204
|
+
text = path.read_text(encoding="utf-8")
|
|
205
|
+
except OSError:
|
|
206
|
+
continue
|
|
207
|
+
fm = parse_frontmatter(text)
|
|
208
|
+
if not fm:
|
|
209
|
+
continue
|
|
210
|
+
try:
|
|
211
|
+
m = build_manifest_from_frontmatter(fm, path)
|
|
212
|
+
manifest = SkillManifest(**m)
|
|
213
|
+
except Exception:
|
|
214
|
+
continue
|
|
215
|
+
skills.append(manifest)
|
|
216
|
+
skill_paths[manifest.id] = str(path)
|
|
217
|
+
|
|
218
|
+
fallback = SkillManifest(**GENERIC_REASONING_MANIFEST)
|
|
219
|
+
skill_paths[fallback.id] = "__virtual__:" + GENERIC_REASONING_ID
|
|
220
|
+
skills.append(fallback)
|
|
221
|
+
|
|
222
|
+
return SkillIndex(
|
|
223
|
+
version="1",
|
|
224
|
+
generated_at=datetime.now(timezone.utc),
|
|
225
|
+
skills=skills,
|
|
226
|
+
skill_paths=skill_paths,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def write_index(index: SkillIndex, out_path: Path) -> Path:
|
|
231
|
+
"""Write the index as JSON."""
|
|
232
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
out_path.write_text(json.dumps(index.model_dump(mode="json"), indent=2))
|
|
234
|
+
return out_path
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def read_index(path: Path) -> SkillIndex | None:
|
|
238
|
+
"""Read the index from JSON. None if missing or invalid."""
|
|
239
|
+
if not path.exists():
|
|
240
|
+
return None
|
|
241
|
+
try:
|
|
242
|
+
data = json.loads(path.read_text())
|
|
243
|
+
return SkillIndex(**data)
|
|
244
|
+
except Exception:
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# ----- Module-level cache (TTL) -----
|
|
249
|
+
|
|
250
|
+
_CACHE: dict[str, tuple] = {}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def get_index_cached(index_path: Path, ttl_seconds: int = 60) -> SkillIndex | None:
|
|
254
|
+
"""Return cached index if still fresh; else rebuild from disk if present."""
|
|
255
|
+
key = str(index_path.resolve())
|
|
256
|
+
now = time.time()
|
|
257
|
+
if key in _CACHE:
|
|
258
|
+
ts, idx = _CACHE[key]
|
|
259
|
+
if now - ts < ttl_seconds:
|
|
260
|
+
return idx
|
|
261
|
+
|
|
262
|
+
idx = read_index(index_path)
|
|
263
|
+
if idx is not None:
|
|
264
|
+
_CACHE[key] = (now, idx)
|
|
265
|
+
return idx
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def invalidate_cache(index_path: Path | None = None) -> None:
|
|
269
|
+
if index_path is None:
|
|
270
|
+
_CACHE.clear()
|
|
271
|
+
else:
|
|
272
|
+
_CACHE.pop(str(index_path.resolve()), None)
|
agent_foundry/judge.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Judge: score an output against a rubric using af-critic."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from .models import TokenEstimate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# When the LLM-judge runs on a known small rubric, these defaults are fine.
|
|
13
|
+
DEFAULT_JUDGE_INPUT_HARD = 200
|
|
14
|
+
DEFAULT_JUDGE_INPUT_PER_1K_CHARS = 200 # input tokens per 1k output chars
|
|
15
|
+
DEFAULT_JUDGE_OUTPUT = 100
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def estimate_judge_cost(output_chars: int) -> TokenEstimate:
|
|
19
|
+
inp = DEFAULT_JUDGE_INPUT_HARD + (output_chars // 1000) * DEFAULT_JUDGE_INPUT_PER_1K_CHARS
|
|
20
|
+
return TokenEstimate(input=inp, output=DEFAULT_JUDGE_OUTPUT)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _parse_json_score(text: str) -> dict[str, Any] | None:
|
|
24
|
+
"""Extract the first JSON object from the judge output."""
|
|
25
|
+
if not text:
|
|
26
|
+
return None
|
|
27
|
+
# Try direct parse first
|
|
28
|
+
try:
|
|
29
|
+
data = json.loads(text)
|
|
30
|
+
if isinstance(data, dict):
|
|
31
|
+
return data
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
# Fallback: find the JSON block
|
|
35
|
+
m = re.search(r"\{[^{}]*\"correctness\"[^{}]*\}", text, re.DOTALL)
|
|
36
|
+
if m:
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(m.group(0))
|
|
39
|
+
if isinstance(data, dict):
|
|
40
|
+
return data
|
|
41
|
+
except Exception:
|
|
42
|
+
pass
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def judge_output(
|
|
47
|
+
task: str,
|
|
48
|
+
output: str,
|
|
49
|
+
*,
|
|
50
|
+
model: str,
|
|
51
|
+
judge_skill_id: str = "af-critic",
|
|
52
|
+
skill_paths: dict[str, str] | None = None,
|
|
53
|
+
fallback_prompt: str | None = None,
|
|
54
|
+
) -> dict | None:
|
|
55
|
+
"""Use the af-critic skill to score output against the task. Returns the parsed
|
|
56
|
+
judge JSON or None if the call fails / returns unparseable output."""
|
|
57
|
+
import litellm
|
|
58
|
+
from .executor import build_messages
|
|
59
|
+
|
|
60
|
+
if skill_paths is None:
|
|
61
|
+
skill_paths = {}
|
|
62
|
+
|
|
63
|
+
judge_prompt = (
|
|
64
|
+
f"## Task\n{task}\n\n## Output\n{output}\n\n"
|
|
65
|
+
"Score the output per the rubric in your system prompt. "
|
|
66
|
+
"Return only the JSON object."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
messages, _ = build_messages(judge_skill_id, judge_prompt, skill_paths,
|
|
70
|
+
fallback_prompt=fallback_prompt)
|
|
71
|
+
|
|
72
|
+
t0 = time.time()
|
|
73
|
+
try:
|
|
74
|
+
resp = litellm.completion(
|
|
75
|
+
model=model,
|
|
76
|
+
messages=messages,
|
|
77
|
+
response_format={"type": "json_object"},
|
|
78
|
+
)
|
|
79
|
+
text = resp.choices[0].message.content or ""
|
|
80
|
+
except Exception:
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
dur = time.time() - t0
|
|
84
|
+
parsed = _parse_json_score(text)
|
|
85
|
+
if parsed is None:
|
|
86
|
+
return None
|
|
87
|
+
parsed["_judge_duration_seconds"] = round(dur, 3)
|
|
88
|
+
return parsed
|