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.
@@ -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