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,191 @@
1
+ """Minimal MCP stdio server exposing plan/execute/list_skills/get_skill.
2
+
3
+ Implements JSON-RPC 2.0 over stdin/stdout with no extra dependencies.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from .config import Config, LOG_DB_PATH
13
+ from .executor import execute as execute_skill
14
+ from .indexer import get_index_cached
15
+ from .models import ExecuteRequest
16
+ from .planner import rank_skills
17
+
18
+ PROTOCOL_VERSION = "2025-06-18"
19
+ SERVER_VERSION = "0.4.0"
20
+
21
+ TOOLS = [
22
+ {
23
+ "name": "plan",
24
+ "description": "Rank Agent Foundry skills for a prompt.",
25
+ "inputSchema": {
26
+ "type": "object",
27
+ "properties": {
28
+ "prompt": {"type": "string"},
29
+ "max_results": {"type": "integer", "default": 5},
30
+ },
31
+ "required": ["prompt"],
32
+ },
33
+ },
34
+ {
35
+ "name": "execute",
36
+ "description": "Run one skill against a prompt via the configured LLM.",
37
+ "inputSchema": {
38
+ "type": "object",
39
+ "properties": {
40
+ "skill_id": {"type": "string"},
41
+ "prompt": {"type": "string"},
42
+ "model": {"type": "string"},
43
+ },
44
+ "required": ["skill_id", "prompt"],
45
+ },
46
+ },
47
+ {
48
+ "name": "list_skills",
49
+ "description": "List indexed skills with category and description.",
50
+ "inputSchema": {"type": "object", "properties": {}},
51
+ },
52
+ {
53
+ "name": "get_skill",
54
+ "description": "Return the full SKILL.md body for one skill.",
55
+ "inputSchema": {
56
+ "type": "object",
57
+ "properties": {"skill_id": {"type": "string"}},
58
+ "required": ["skill_id"],
59
+ },
60
+ },
61
+ ]
62
+
63
+
64
+ class McpServer:
65
+ def __init__(self, cfg: Config | None = None, db_path: Path | None = None):
66
+ self.cfg = cfg or Config.load()
67
+ self.db_path = db_path or LOG_DB_PATH
68
+
69
+ def _index(self):
70
+ idx = get_index_cached(Path(self.cfg.core.index_path).expanduser(),
71
+ ttl_seconds=self.cfg.core.index_cache_ttl_seconds)
72
+ if idx is None:
73
+ raise RuntimeError("No skill index. Run `agent-foundry index` first.")
74
+ return idx
75
+
76
+ def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
77
+ if name == "plan":
78
+ idx = self._index()
79
+ prompt = str(arguments.get("prompt", ""))
80
+ max_results = int(arguments.get("max_results", 5))
81
+ ranked = rank_skills(prompt, idx, self.cfg.planner,
82
+ weights=_weights(self.db_path),
83
+ rerank_model=(self.cfg.core.default_llm
84
+ if self.cfg.planner.rerank_enabled else None))
85
+ result = {
86
+ "prompt": prompt,
87
+ "results": [r.model_dump() for r in ranked[:max_results]],
88
+ "total_available": len(idx.skills),
89
+ }
90
+ return _text(result)
91
+
92
+ if name == "execute":
93
+ idx = self._index()
94
+ req = ExecuteRequest(
95
+ skill_id=str(arguments["skill_id"]),
96
+ prompt=str(arguments["prompt"]),
97
+ model=arguments.get("model"),
98
+ )
99
+ resp = execute_skill(
100
+ req, model=req.model or self.cfg.core.default_llm,
101
+ skill_paths=idx.skill_paths,
102
+ db_path=self.db_path,
103
+ skill_hashes={s.id: s.sha256 for s in idx.skills},
104
+ timeout_seconds=self.cfg.core.executor_timeout_seconds,
105
+ max_tokens=self.cfg.core.executor_max_tokens,
106
+ retries=self.cfg.core.executor_retries,
107
+ )
108
+ return _text(resp.model_dump())
109
+
110
+ if name == "list_skills":
111
+ idx = self._index()
112
+ skills = [
113
+ {"id": s.id, "name": s.name, "category": s.category,
114
+ "description": s.description}
115
+ for s in idx.skills if not s.is_fallback
116
+ ]
117
+ return _text({"skills": skills, "count": len(skills)})
118
+
119
+ if name == "get_skill":
120
+ idx = self._index()
121
+ skill_id = str(arguments["skill_id"])
122
+ path = idx.skill_paths.get(skill_id)
123
+ if not path or path.startswith("__virtual__:"):
124
+ raise RuntimeError(f"unknown skill: {skill_id}")
125
+ return _text({"skill_id": skill_id,
126
+ "content": Path(path).read_text(encoding="utf-8")})
127
+
128
+ raise RuntimeError(f"unknown tool: {name}")
129
+
130
+ def handle(self, msg: dict[str, Any]) -> dict[str, Any] | None:
131
+ method = msg.get("method")
132
+ msg_id = msg.get("id")
133
+
134
+ if method == "initialize":
135
+ return _result(msg_id, {
136
+ "protocolVersion": PROTOCOL_VERSION,
137
+ "capabilities": {"tools": {}},
138
+ "serverInfo": {"name": "agent-foundry", "version": SERVER_VERSION},
139
+ })
140
+ if method in ("notifications/initialized", "initialized"):
141
+ return None
142
+ if method == "ping":
143
+ return _result(msg_id, {})
144
+ if method == "tools/list":
145
+ return _result(msg_id, {"tools": TOOLS})
146
+ if method == "tools/call":
147
+ params = msg.get("params") or {}
148
+ try:
149
+ payload = self.call_tool(params.get("name", ""), params.get("arguments") or {})
150
+ return _result(msg_id, payload)
151
+ except Exception as exc:
152
+ return _result(msg_id, {"content": [{"type": "text", "text": str(exc)}],
153
+ "isError": True})
154
+ return {
155
+ "jsonrpc": "2.0",
156
+ "id": msg_id,
157
+ "error": {"code": -32601, "message": f"method not found: {method}"},
158
+ }
159
+
160
+
161
+ def _weights(db_path: Path) -> dict[str, float]:
162
+ try:
163
+ from .logging_db import get_skill_weights
164
+ return get_skill_weights(db_path)
165
+ except Exception:
166
+ return {}
167
+
168
+
169
+ def _text(value: Any) -> dict[str, Any]:
170
+ return {"content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False)}]}
171
+
172
+
173
+ def _result(msg_id: Any, result: dict[str, Any]) -> dict[str, Any]:
174
+ return {"jsonrpc": "2.0", "id": msg_id, "result": result}
175
+
176
+
177
+ def run_stdio(cfg: Config | None = None) -> None:
178
+ """Serve MCP over stdin/stdout until EOF."""
179
+ server = McpServer(cfg)
180
+ for line in sys.stdin:
181
+ line = line.strip()
182
+ if not line:
183
+ continue
184
+ try:
185
+ msg = json.loads(line)
186
+ except json.JSONDecodeError:
187
+ continue
188
+ response = server.handle(msg)
189
+ if response is not None:
190
+ sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
191
+ sys.stdout.flush()
@@ -0,0 +1,166 @@
1
+ """Pydantic data models for Agent Foundry."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from typing import Literal
6
+ from pydantic import BaseModel, Field, field_validator, model_validator
7
+
8
+
9
+ class TokenEstimate(BaseModel):
10
+ """Cost estimate in tokens. Total is auto-computed if not provided."""
11
+ input: int = Field(ge=0, description="Input tokens (skill body + prompt)")
12
+ output: int = Field(ge=0, description="Estimated output tokens")
13
+ total: int = Field(default=0, ge=0, description="input + output (computed)")
14
+
15
+ @model_validator(mode="before")
16
+ @classmethod
17
+ def _compute_total(cls, values):
18
+ if isinstance(values, dict):
19
+ inp = values.get("input", 0) or 0
20
+ out = values.get("output", 0) or 0
21
+ values = dict(values)
22
+ values.setdefault("total", inp + out)
23
+ return values
24
+
25
+
26
+ class SkillManifest(BaseModel):
27
+ """One skill, as stored in skills/index.json."""
28
+ id: str = Field(pattern=r"^[a-z0-9-]+$")
29
+ name: str
30
+ description: str = Field(max_length=500)
31
+ trigger_patterns: list[str] = Field(default_factory=list)
32
+ estimated_token_cost: dict = Field(default_factory=lambda: {"input": 200, "output": 300})
33
+ estimated_time_seconds: int = 3
34
+ version: str = "0.1.0"
35
+ license: str = "MIT"
36
+ category: Literal["core", "optional"] = "core"
37
+ location: str
38
+ tags: list[str] = Field(default_factory=list)
39
+ is_fallback: bool = False
40
+ sha256: str = ""
41
+
42
+ def total_cost(self) -> int:
43
+ c = self.estimated_token_cost or {}
44
+ return (c.get("input", 0) or 0) + (c.get("output", 0) or 0)
45
+
46
+
47
+ class SkillIndex(BaseModel):
48
+ """The full skill index."""
49
+ version: str = "1"
50
+ generated_at: datetime
51
+ skills: list[SkillManifest]
52
+ skill_paths: dict[str, str] = Field(default_factory=dict)
53
+
54
+
55
+ # ===== Planner =====
56
+
57
+ class PlanRequest(BaseModel):
58
+ prompt: str
59
+ max_results: int = 5
60
+ graph_path: str | None = None
61
+
62
+
63
+ class PlanResult(BaseModel):
64
+ """One ranked skill in a plan."""
65
+ skill_id: str
66
+ name: str
67
+ description: str
68
+ score: float
69
+ matched_patterns: list[str] = Field(default_factory=list)
70
+ estimated_cost: TokenEstimate
71
+ location: str | None = None
72
+
73
+
74
+ class PlanResponse(BaseModel):
75
+ """Plan endpoint reply: ranked list of skills."""
76
+ prompt: str
77
+ results: list[PlanResult]
78
+ total_available: int
79
+
80
+
81
+ # ===== Executor =====
82
+
83
+ class ExecuteRequest(BaseModel):
84
+ skill_id: str
85
+ prompt: str
86
+ force: bool = False
87
+ model: str | None = None
88
+
89
+
90
+ class ExecuteResponse(BaseModel):
91
+ """Execute endpoint reply."""
92
+ skill_id: str
93
+ output: str = ""
94
+ tokens_used: int = 0
95
+ duration_seconds: float = 0.0
96
+ success: bool = True
97
+ error: str | None = None
98
+ requires_confirmation: bool = False
99
+ estimated_cost: TokenEstimate | None = None
100
+ was_fallback: bool = False
101
+ cost_usd: float = 0.0
102
+
103
+
104
+ # ===== Loop =====
105
+
106
+ class LoopRequest(BaseModel):
107
+ prompt: str
108
+ force: bool = False
109
+ model: str | None = None
110
+ budget: int | None = None
111
+ judge: bool = False # if true, score output via af-critic
112
+
113
+
114
+ class LoopResponse(BaseModel):
115
+ """Loop endpoint reply."""
116
+ plan: list[PlanResult] = Field(default_factory=list)
117
+ skill_id: str | None = None
118
+ output: str = ""
119
+ tokens_used: int = 0
120
+ duration_seconds: float = 0.0
121
+ success: bool = True
122
+ error: str | None = None
123
+ requires_confirmation: bool = False
124
+ estimated_cost: TokenEstimate | None = None
125
+ was_fallback: bool = False
126
+ judge_score: dict | None = None # correctness/slop/scope/verdict if --judge
127
+ judged: bool = False
128
+ cost_usd: float = 0.0
129
+ session_id: int | None = None
130
+
131
+
132
+ class LoopResumeRequest(BaseModel):
133
+ session_id: int
134
+ prompt: str
135
+ model: str | None = None
136
+
137
+
138
+ # ===== Index =====
139
+
140
+ class StatsResponse(BaseModel):
141
+ total_executions: int = 0
142
+ successful: int = 0
143
+ failed: int = 0
144
+ fallback_count: int = 0
145
+ avg_tokens: int = 0
146
+ avg_duration: float = 0.0
147
+ thumbs_up: int = 0
148
+ thumbs_down: int = 0
149
+ instincts_count: int = 0
150
+
151
+
152
+ class FeedbackRequest(BaseModel):
153
+ execution_id: int
154
+ feedback: int = Field(..., description="1 = thumbs_up, -1 = thumbs_down")
155
+
156
+
157
+ class LearnResponse(BaseModel):
158
+ new_instincts: int = 0
159
+ updated_instincts: int = 0
160
+ patterns_found: list[dict] = Field(default_factory=list)
161
+
162
+
163
+ class IndexResponse(BaseModel):
164
+ total_skills: int
165
+ took_ms: float
166
+ regenerated: bool = True
@@ -0,0 +1,288 @@
1
+ """Planner: rank skills by prompt relevance + cost penalty + graph relationships."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+ from typing import Iterable
9
+
10
+ from .config import PlannerConfig
11
+ from .indexer import GENERIC_REASONING_ID, get_index_cached
12
+ from .models import (
13
+ PlanRequest,
14
+ PlanResponse,
15
+ PlanResult,
16
+ SkillIndex,
17
+ SkillManifest,
18
+ TokenEstimate,
19
+ )
20
+
21
+
22
+ _STOPWORDS = frozenset({
23
+ "the", "and", "for", "that", "this", "with", "from", "how", "what",
24
+ "when", "why", "where", "which", "your", "have", "will", "can", "are",
25
+ "not", "but", "all", "was", "out", "use", "into", "any", "its", "our",
26
+ })
27
+
28
+
29
+ def _keyword_overlap(prompt: str, skill: SkillManifest, limit: int = 3) -> int:
30
+ """Cheap lexical fallback when trigger regexes miss. Count shared content words."""
31
+ p = {w for w in re.findall(r"[a-z]{3,}", prompt.lower()) if w not in _STOPWORDS}
32
+ d = {w for w in re.findall(r"[a-z]{3,}", (skill.description or "").lower())
33
+ if w not in _STOPWORDS}
34
+ return min(limit, len(p & d))
35
+
36
+
37
+ def _cost_penalty(total_cost: int, divisor: float) -> float:
38
+ return 1.0 / (1.0 + total_cost / divisor)
39
+
40
+
41
+ def _match_patterns(prompt: str, patterns: Iterable[str]) -> list[str]:
42
+ matched: list[str] = []
43
+ p_lower = prompt.lower()
44
+ for pat in patterns or []:
45
+ if not pat:
46
+ continue
47
+ try:
48
+ if re.search(pat, prompt, re.IGNORECASE | re.DOTALL):
49
+ matched.append(pat)
50
+ continue
51
+ except re.error:
52
+ pass
53
+ raw = pat.replace(r"\b", "").replace("\\\\", "")
54
+ if raw and raw.lower() in p_lower:
55
+ matched.append(pat)
56
+ return matched
57
+
58
+
59
+ def _name_in_prompt(prompt: str, skill: SkillManifest) -> bool:
60
+ p_lower = prompt.lower()
61
+ name_lower = (skill.name or "").lower()
62
+ if name_lower and name_lower in p_lower:
63
+ return True
64
+ desc_lower = (skill.description or "").lower()
65
+ if desc_lower and desc_lower[:60] in p_lower:
66
+ return True
67
+ return False
68
+
69
+
70
+ _GRAPH_CACHE: dict[str, tuple[float, dict[str, list[str]]]] = {}
71
+ _SEM_CACHE: dict = {"key": None, "model": None, "ids": [], "vectors": []}
72
+
73
+
74
+ def _graph_adjacency(graph_path: Path) -> dict[str, list[str]]:
75
+ """Parse the graph once per mtime and return an undirected adjacency map."""
76
+ try:
77
+ mtime = graph_path.stat().st_mtime
78
+ except OSError:
79
+ return {}
80
+ cached = _GRAPH_CACHE.get(str(graph_path))
81
+ if cached and cached[0] == mtime:
82
+ return cached[1]
83
+
84
+ try:
85
+ data = json.loads(graph_path.read_text())
86
+ edges = data.get("links", data.get("edges", []))
87
+ adj: dict[str, list[str]] = {}
88
+ for e in edges:
89
+ src = e.get("source", "")
90
+ tgt = e.get("target", "")
91
+ if not src or not tgt:
92
+ continue
93
+ adj.setdefault(src, []).append(tgt)
94
+ adj.setdefault(tgt, []).append(src)
95
+ except Exception:
96
+ return {}
97
+ _GRAPH_CACHE[str(graph_path)] = (mtime, adj)
98
+ return adj
99
+
100
+
101
+ def _graph_related(skill_id: str, graph_path: Path | None,
102
+ depth: int = 1) -> list[str]:
103
+ """Return skill IDs connected in the knowledge graph (max 3 per depth)."""
104
+ if not graph_path or not graph_path.exists():
105
+ return []
106
+ adj = _graph_adjacency(graph_path)
107
+ related: list[str] = []
108
+ frontier = [skill_id]
109
+ seen = {skill_id}
110
+ for _ in range(max(1, depth)):
111
+ nxt: list[str] = []
112
+ for node in frontier:
113
+ for neigh in adj.get(node, []):
114
+ if neigh not in seen:
115
+ seen.add(neigh)
116
+ nxt.append(neigh)
117
+ related.extend(nxt[:3])
118
+ frontier = nxt
119
+ return related
120
+
121
+
122
+ def _semantic_scores(prompt: str, skills: list[SkillManifest], model_name: str) -> dict[str, float]:
123
+ """Cosine similarity of prompt vs skill descriptions. Empty if unavailable."""
124
+ try:
125
+ from sentence_transformers import SentenceTransformer # type: ignore
126
+ except Exception:
127
+ return {}
128
+
129
+ signature = hashlib.sha1(
130
+ (model_name + "|" + "|".join(f"{s.id}:{s.description}" for s in skills)).encode()
131
+ ).hexdigest()
132
+ if _SEM_CACHE["key"] != signature:
133
+ try:
134
+ model = _SEM_CACHE["model"]
135
+ if model is None or _SEM_CACHE.get("model_name") != model_name:
136
+ model = SentenceTransformer(model_name)
137
+ vectors = model.encode([s.description for s in skills], normalize_embeddings=True)
138
+ _SEM_CACHE.update({
139
+ "key": signature,
140
+ "model": model,
141
+ "model_name": model_name,
142
+ "ids": [s.id for s in skills],
143
+ "vectors": [list(map(float, v)) for v in vectors],
144
+ })
145
+ except Exception:
146
+ return {}
147
+
148
+ try:
149
+ q = _SEM_CACHE["model"].encode([prompt], normalize_embeddings=True)[0]
150
+ q = list(map(float, q))
151
+ except Exception:
152
+ return {}
153
+
154
+ out: dict[str, float] = {}
155
+ for sid, vec in zip(_SEM_CACHE["ids"], _SEM_CACHE["vectors"]):
156
+ out[sid] = sum(a * b for a, b in zip(q, vec)) # vectors are normalized
157
+ return out
158
+
159
+
160
+ def _llm_rerank(prompt: str, results: list[tuple[float, PlanResult]],
161
+ model: str, top_k: int) -> list[tuple[float, PlanResult]]:
162
+ """Optional LLM ordering of the top candidates. Returns input on any failure."""
163
+ try:
164
+ import litellm
165
+
166
+ candidates = [
167
+ {"id": r.skill_id, "description": (r.description or "")[:160]}
168
+ for _, r in results[:top_k]
169
+ ]
170
+ messages = [
171
+ {"role": "system",
172
+ "content": 'Reorder the candidate skills for the prompt. Return only JSON: {"order": ["id", ...]}.'},
173
+ {"role": "user",
174
+ "content": json.dumps({"prompt": prompt, "candidates": candidates})},
175
+ ]
176
+ resp = litellm.completion(
177
+ model=model, messages=messages, timeout=30,
178
+ response_format={"type": "json_object"},
179
+ )
180
+ data = json.loads(resp.choices[0].message.content or "{}")
181
+ order = data.get("order") or []
182
+ rank = {sid: i for i, sid in enumerate(order)}
183
+ head = sorted(results[:top_k], key=lambda pair: rank.get(pair[1].skill_id, 999))
184
+ return head + results[top_k:]
185
+ except Exception:
186
+ return results
187
+
188
+
189
+ def rank_skills(prompt: str, idx: SkillIndex, cfg: PlannerConfig,
190
+ graph_path: Path | None = None,
191
+ weights: dict[str, float] | None = None,
192
+ rerank_model: str | None = None) -> list[PlanResult]:
193
+ """Return skills ranked by score (desc). Graph-aware: boosts skills related to the top match."""
194
+ results: list[tuple[float, PlanResult]] = []
195
+
196
+ semantic: dict[str, float] = {}
197
+ if cfg.semantic_enabled:
198
+ semantic = _semantic_scores(prompt, [s for s in idx.skills if not s.is_fallback],
199
+ cfg.semantic_model)
200
+
201
+ for skill in idx.skills:
202
+ if skill.is_fallback:
203
+ continue
204
+
205
+ matched = _match_patterns(prompt, skill.trigger_patterns)
206
+ score = len(matched) * cfg.pattern_match_weight
207
+
208
+ if _name_in_prompt(prompt, skill):
209
+ score += cfg.name_in_prompt_boost
210
+
211
+ overlap = _keyword_overlap(prompt, skill)
212
+ if overlap:
213
+ score += overlap * 0.04
214
+
215
+ if semantic:
216
+ score += max(0.0, semantic.get(skill.id, 0.0)) * cfg.semantic_weight
217
+
218
+ if score <= 0:
219
+ continue
220
+
221
+ total_cost = skill.total_cost()
222
+ score *= _cost_penalty(total_cost, cfg.cost_penalty_divisor)
223
+ if weights:
224
+ score *= max(0.1, float(weights.get(skill.id, 1.0)))
225
+
226
+ cost = TokenEstimate(
227
+ input=int(skill.estimated_token_cost.get("input", 0)),
228
+ output=int(skill.estimated_token_cost.get("output", 0)),
229
+ )
230
+ results.append((
231
+ score,
232
+ PlanResult(
233
+ skill_id=skill.id,
234
+ name=skill.name,
235
+ description=skill.description,
236
+ score=round(score, 4),
237
+ matched_patterns=matched,
238
+ estimated_cost=cost,
239
+ location=skill.location,
240
+ ),
241
+ ))
242
+
243
+ results.sort(key=lambda x: x[0], reverse=True)
244
+
245
+ # Graph-aware boost: top match boosts its 2-hop neighborhood.
246
+ if graph_path and results:
247
+ top_id = results[0][1].skill_id
248
+ related = _graph_related(top_id, graph_path, depth=2)
249
+ if related:
250
+ boost = {sid: (1.15 if i < 3 else 1.08) for i, sid in enumerate(related)}
251
+ for i, (s, r) in enumerate(results):
252
+ factor = boost.get(r.skill_id)
253
+ if factor:
254
+ results[i] = (s * factor, r)
255
+
256
+ results.sort(key=lambda x: x[0], reverse=True)
257
+ if cfg.rerank_enabled and rerank_model and len(results) > 1:
258
+ results = _llm_rerank(prompt, results, rerank_model, cfg.rerank_top_k)
259
+ return [r for _, r in results[:cfg.max_results]]
260
+
261
+
262
+ def _safe_graph_path(raw: str | None) -> Path | None:
263
+ """Only allow graph JSON inside the repo root or current working directory."""
264
+ if not raw:
265
+ return None
266
+ try:
267
+ path = Path(raw).expanduser().resolve()
268
+ except OSError:
269
+ return None
270
+ if path.suffix.lower() != ".json" or not path.is_file():
271
+ return None
272
+ roots = [Path.cwd().resolve(), Path(__file__).resolve().parent.parent]
273
+ if not any(path.is_relative_to(root) for root in roots):
274
+ return None
275
+ return path
276
+
277
+
278
+ def plan(request: PlanRequest, idx: SkillIndex, cfg: PlannerConfig,
279
+ weights: dict[str, float] | None = None,
280
+ rerank_model: str | None = None) -> PlanResponse:
281
+ graph_path = _safe_graph_path(request.graph_path)
282
+ ranked = rank_skills(request.prompt, idx, cfg, graph_path=graph_path,
283
+ weights=weights, rerank_model=rerank_model)
284
+ return PlanResponse(
285
+ prompt=request.prompt,
286
+ results=ranked,
287
+ total_available=len(idx.skills),
288
+ )