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,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()
@@ -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
+ )