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
|
@@ -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()
|
agent_foundry/models.py
ADDED
|
@@ -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
|
agent_foundry/planner.py
ADDED
|
@@ -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
|
+
)
|