petfishframework 0.1.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.
- petfishframework/__init__.py +33 -0
- petfishframework/config.py +142 -0
- petfishframework/core/__init__.py +91 -0
- petfishframework/core/agent.py +207 -0
- petfishframework/core/compiled.py +89 -0
- petfishframework/core/contracts.py +190 -0
- petfishframework/core/conversation.py +51 -0
- petfishframework/core/environment.py +260 -0
- petfishframework/core/events.py +79 -0
- petfishframework/core/session.py +182 -0
- petfishframework/core/structured.py +202 -0
- petfishframework/core/types.py +192 -0
- petfishframework/mcp/__init__.py +19 -0
- petfishframework/mcp/client.py +96 -0
- petfishframework/mcp/server.py +14 -0
- petfishframework/mcp/stdio_transport.py +218 -0
- petfishframework/mcp/wrapper.py +43 -0
- petfishframework/models/__init__.py +14 -0
- petfishframework/models/anthropic.py +194 -0
- petfishframework/models/fake.py +202 -0
- petfishframework/models/openai.py +178 -0
- petfishframework/observability/__init__.py +10 -0
- petfishframework/observability/sinks.py +23 -0
- petfishframework/permissions/__init__.py +32 -0
- petfishframework/permissions/model.py +110 -0
- petfishframework/reasoning/__init__.py +13 -0
- petfishframework/reasoning/lats.py +222 -0
- petfishframework/reasoning/llm_plus_p.py +202 -0
- petfishframework/reasoning/react.py +176 -0
- petfishframework/reliability/__init__.py +78 -0
- petfishframework/reliability/cost.py +50 -0
- petfishframework/reliability/cost_report.py +101 -0
- petfishframework/reliability/pass_at_k.py +232 -0
- petfishframework/reliability/replay.py +190 -0
- petfishframework/reliability/retry.py +224 -0
- petfishframework/reliability/timeout.py +55 -0
- petfishframework/retrieval/__init__.py +12 -0
- petfishframework/retrieval/adaptive.py +137 -0
- petfishframework/retrieval/crag.py +135 -0
- petfishframework/retrieval/memory_store.py +72 -0
- petfishframework/tools/__init__.py +12 -0
- petfishframework/tools/agent_tool.py +47 -0
- petfishframework/tools/base.py +75 -0
- petfishframework/tools/calculator.py +84 -0
- petfishframework/tools/path_planner.py +90 -0
- petfishframework/tools/registry.py +158 -0
- petfishframework/tools/word_sorter.py +41 -0
- petfishframework-0.1.0.dist-info/METADATA +151 -0
- petfishframework-0.1.0.dist-info/RECORD +51 -0
- petfishframework-0.1.0.dist-info/WHEEL +4 -0
- petfishframework-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Retrieval package — base store, CRAG, and Adaptive-RAG strategies."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .adaptive import AdaptiveRetriever
|
|
5
|
+
from .crag import CRAGRetriever
|
|
6
|
+
from .memory_store import MemoryRetriever
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AdaptiveRetriever",
|
|
10
|
+
"CRAGRetriever",
|
|
11
|
+
"MemoryRetriever",
|
|
12
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Adaptive-RAG retriever implementation.
|
|
2
|
+
|
|
3
|
+
Reference: Jeong et al., "Adaptive-RAG: Learning to Adapt Retrieval-Augmented
|
|
4
|
+
Large Language Models through Question Complexity", arXiv:2403.14403.
|
|
5
|
+
|
|
6
|
+
The core loop is:
|
|
7
|
+
|
|
8
|
+
1. Classify query complexity (no-retrieval / single-step / multi-step).
|
|
9
|
+
2. Route to the appropriate retrieval strategy.
|
|
10
|
+
- no_retrieval -> no context needed; return []
|
|
11
|
+
- single_step -> one retrieval pass
|
|
12
|
+
- multi_step -> iterative retrieval with sub-queries
|
|
13
|
+
|
|
14
|
+
This module implements that flow purely through the ``Retriever`` protocol
|
|
15
|
+
so no changes to ``core/`` are required.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
from petfishframework.core.contracts import Retriever
|
|
24
|
+
from petfishframework.core.events import EventEmitter
|
|
25
|
+
from petfishframework.core.types import Snippet
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _default_classifier(query: str) -> str:
|
|
29
|
+
"""Heuristic classifier used when no model-based classifier is supplied."""
|
|
30
|
+
lower = query.lower()
|
|
31
|
+
|
|
32
|
+
# Multi-step signals: composition, comparison, or analysis.
|
|
33
|
+
multi_step_patterns = (
|
|
34
|
+
r"\bcompare\b",
|
|
35
|
+
r"\bcontrasting\b",
|
|
36
|
+
r"\banalyze\b",
|
|
37
|
+
r"\bexplain\b.*\band\b.*\bhow\b",
|
|
38
|
+
r"\bbetween\b.*\band\b",
|
|
39
|
+
r"\bdifference\b.*\band\b",
|
|
40
|
+
r"\bsummarize\b.*\bthen\b",
|
|
41
|
+
)
|
|
42
|
+
for pattern in multi_step_patterns:
|
|
43
|
+
if re.search(pattern, lower):
|
|
44
|
+
return "multi_step"
|
|
45
|
+
|
|
46
|
+
# Single-step signals: factual lookup.
|
|
47
|
+
single_step_patterns = (
|
|
48
|
+
r"^(what|who|when|where|which|how many|how much|define)\b",
|
|
49
|
+
)
|
|
50
|
+
for pattern in single_step_patterns:
|
|
51
|
+
if re.search(pattern, lower):
|
|
52
|
+
return "single_step"
|
|
53
|
+
|
|
54
|
+
return "no_retrieval"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class AdaptiveRetriever(Retriever):
|
|
59
|
+
"""Adaptive RAG retriever.
|
|
60
|
+
|
|
61
|
+
Wraps any ``Retriever`` and optionally a complexity classifier.
|
|
62
|
+
Depending on the classification, it performs no retrieval, a single
|
|
63
|
+
retrieval, or a simple iterative multi-step retrieval.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
base_retriever: Retriever
|
|
67
|
+
classifier: Callable[[str], str] | None = None
|
|
68
|
+
events: EventEmitter | None = field(default=None, repr=False)
|
|
69
|
+
|
|
70
|
+
def retrieve(self, query: str, top_k: int = 5) -> list[Snippet]:
|
|
71
|
+
"""Classify query complexity and route to the matching strategy."""
|
|
72
|
+
classify_fn = self.classifier or _default_classifier
|
|
73
|
+
classification = classify_fn(query)
|
|
74
|
+
|
|
75
|
+
if self.events is not None:
|
|
76
|
+
self.events.emit(
|
|
77
|
+
"adaptive.classify",
|
|
78
|
+
{"query": query, "classification": classification},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
strategy: str
|
|
82
|
+
snippets: list[Snippet]
|
|
83
|
+
|
|
84
|
+
if classification == "no_retrieval":
|
|
85
|
+
strategy = "none"
|
|
86
|
+
snippets = []
|
|
87
|
+
elif classification == "multi_step":
|
|
88
|
+
strategy = "iterative"
|
|
89
|
+
snippets = self._iterative_retrieval(query, top_k)
|
|
90
|
+
else: # single_step (default)
|
|
91
|
+
strategy = "single"
|
|
92
|
+
snippets = self.base_retriever.retrieve(query, top_k)
|
|
93
|
+
|
|
94
|
+
if self.events is not None:
|
|
95
|
+
self.events.emit(
|
|
96
|
+
"adaptive.route",
|
|
97
|
+
{
|
|
98
|
+
"query": query,
|
|
99
|
+
"classification": classification,
|
|
100
|
+
"strategy": strategy,
|
|
101
|
+
"snippet_count": len(snippets),
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return snippets
|
|
106
|
+
|
|
107
|
+
def _iterative_retrieval(self, query: str, top_k: int) -> list[Snippet]:
|
|
108
|
+
"""Skeleton multi-step retrieval: two rounds with deduplication.
|
|
109
|
+
|
|
110
|
+
Round 1 uses the original query. Round 2 uses a refined query built
|
|
111
|
+
from the top result of round 1. This is intentionally simple to keep
|
|
112
|
+
the skeleton dependency-free and deterministic.
|
|
113
|
+
"""
|
|
114
|
+
first_pass = self.base_retriever.retrieve(query, top_k)
|
|
115
|
+
|
|
116
|
+
# Build a simple refined query by appending key terms from the
|
|
117
|
+
# highest-scoring snippet. If there are no first-pass results, stop.
|
|
118
|
+
if not first_pass:
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
top_snippet = first_pass[0]
|
|
122
|
+
top_terms = " ".join(sorted(top_snippet.content.split()[:5]))
|
|
123
|
+
refined_query = f"{query} {top_terms}".strip()
|
|
124
|
+
|
|
125
|
+
second_pass = self.base_retriever.retrieve(refined_query, top_k)
|
|
126
|
+
|
|
127
|
+
# Merge and deduplicate by content, preserving score order.
|
|
128
|
+
seen: set[str] = set()
|
|
129
|
+
merged: list[Snippet] = []
|
|
130
|
+
for snippet in first_pass + second_pass:
|
|
131
|
+
if snippet.content not in seen:
|
|
132
|
+
seen.add(snippet.content)
|
|
133
|
+
merged.append(snippet)
|
|
134
|
+
if len(merged) >= top_k:
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
return merged
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""CRAG (Corrective RAG) retriever implementation.
|
|
2
|
+
|
|
3
|
+
Reference: Yan et al., "Corrective Retrieval Augmented Generation",
|
|
4
|
+
arXiv:2401.15884. The core loop is:
|
|
5
|
+
|
|
6
|
+
1. Retrieve from a base retriever.
|
|
7
|
+
2. Evaluate retrieval quality (relevant / ambiguous / irrelevant).
|
|
8
|
+
3. Route:
|
|
9
|
+
- relevant -> return retrieved docs
|
|
10
|
+
- ambiguous -> combine retrieved docs with external knowledge
|
|
11
|
+
- irrelevant -> replace retrieved docs with external knowledge
|
|
12
|
+
|
|
13
|
+
This module implements that flow purely through the ``Retriever`` protocol
|
|
14
|
+
so no changes to ``core/`` are required.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
from petfishframework.core.contracts import Retriever
|
|
22
|
+
from petfishframework.core.events import EventEmitter
|
|
23
|
+
from petfishframework.core.types import Snippet
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _default_evaluator(query: str, snippets: list[Snippet]) -> str:
|
|
27
|
+
"""Heuristic evaluator used when no model-based evaluator is supplied.
|
|
28
|
+
|
|
29
|
+
Thresholds mirror the CRAG paper's intent but use query-relative keyword
|
|
30
|
+
coverage rather than the normalized document score. This gives crisper
|
|
31
|
+
separation between relevant, ambiguous, and irrelevant retrievals.
|
|
32
|
+
"""
|
|
33
|
+
if not snippets:
|
|
34
|
+
return "irrelevant"
|
|
35
|
+
|
|
36
|
+
from petfishframework.retrieval.memory_store import _tokenize
|
|
37
|
+
|
|
38
|
+
query_tokens = _tokenize(query)
|
|
39
|
+
if not query_tokens:
|
|
40
|
+
return "irrelevant"
|
|
41
|
+
|
|
42
|
+
def _coverage(snippet: Snippet) -> float:
|
|
43
|
+
snippet_tokens = _tokenize(snippet.content)
|
|
44
|
+
shared = query_tokens & snippet_tokens
|
|
45
|
+
return len(shared) / len(query_tokens)
|
|
46
|
+
|
|
47
|
+
best_coverage = max(_coverage(snippet) for snippet in snippets)
|
|
48
|
+
if best_coverage >= 0.5:
|
|
49
|
+
return "relevant"
|
|
50
|
+
if best_coverage >= 0.2:
|
|
51
|
+
return "ambiguous"
|
|
52
|
+
return "irrelevant"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _default_web_search(query: str) -> list[Snippet]:
|
|
56
|
+
"""Stub web-search fallback.
|
|
57
|
+
|
|
58
|
+
Returns an empty list. Production systems can inject a real search tool
|
|
59
|
+
here without touching CRAG's routing logic.
|
|
60
|
+
"""
|
|
61
|
+
return []
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class CRAGRetriever(Retriever):
|
|
66
|
+
"""Corrective RAG retriever.
|
|
67
|
+
|
|
68
|
+
Wraps any ``Retriever`` and optionally an evaluator and web-search
|
|
69
|
+
fallback. When the evaluator decides the base retrieval is poor, CRAG
|
|
70
|
+
routes to the external knowledge source rather than passing bad evidence
|
|
71
|
+
downstream.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
base_retriever: Retriever
|
|
75
|
+
evaluator: Callable[[str, list[Snippet]], str] | None = None
|
|
76
|
+
web_search: Callable[[str], list[Snippet]] | None = None
|
|
77
|
+
events: EventEmitter | None = field(default=None, repr=False)
|
|
78
|
+
|
|
79
|
+
def retrieve(self, query: str, top_k: int = 5) -> list[Snippet]:
|
|
80
|
+
"""Retrieve, evaluate quality, and route to the appropriate source."""
|
|
81
|
+
base_snippets = self.base_retriever.retrieve(query, top_k)
|
|
82
|
+
|
|
83
|
+
evaluate_fn = self.evaluator or _default_evaluator
|
|
84
|
+
assessment = evaluate_fn(query, base_snippets)
|
|
85
|
+
|
|
86
|
+
if self.events is not None:
|
|
87
|
+
self.events.emit(
|
|
88
|
+
"crag.evaluate",
|
|
89
|
+
{"query": query, "assessment": assessment, "snippet_count": len(base_snippets)},
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
web_snippets: list[Snippet] = []
|
|
93
|
+
final_snippets: list[Snippet]
|
|
94
|
+
action: str
|
|
95
|
+
|
|
96
|
+
if assessment == "relevant":
|
|
97
|
+
final_snippets = base_snippets
|
|
98
|
+
action = "use_base"
|
|
99
|
+
elif assessment == "ambiguous":
|
|
100
|
+
search_fn = self.web_search or _default_web_search
|
|
101
|
+
web_snippets = search_fn(query)
|
|
102
|
+
final_snippets = self._merge(base_snippets, web_snippets, top_k)
|
|
103
|
+
action = "combine"
|
|
104
|
+
else: # irrelevant
|
|
105
|
+
search_fn = self.web_search or _default_web_search
|
|
106
|
+
final_snippets = search_fn(query)
|
|
107
|
+
action = "fallback"
|
|
108
|
+
|
|
109
|
+
if self.events is not None:
|
|
110
|
+
self.events.emit(
|
|
111
|
+
"crag.route",
|
|
112
|
+
{
|
|
113
|
+
"query": query,
|
|
114
|
+
"assessment": assessment,
|
|
115
|
+
"action": action,
|
|
116
|
+
"base_count": len(base_snippets),
|
|
117
|
+
"web_count": len(web_snippets),
|
|
118
|
+
"final_count": len(final_snippets),
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return final_snippets
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _merge(base: list[Snippet], web: list[Snippet], top_k: int) -> list[Snippet]:
|
|
126
|
+
"""Merge two snippet lists, deduplicate by content, and truncate."""
|
|
127
|
+
seen: set[str] = set()
|
|
128
|
+
merged: list[Snippet] = []
|
|
129
|
+
for snippet in base + web:
|
|
130
|
+
if snippet.content not in seen:
|
|
131
|
+
seen.add(snippet.content)
|
|
132
|
+
merged.append(snippet)
|
|
133
|
+
if len(merged) >= top_k:
|
|
134
|
+
break
|
|
135
|
+
return merged
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Simple in-memory retriever used as a base for CRAG and Adaptive-RAG.
|
|
2
|
+
|
|
3
|
+
No external vector DB required: scoring uses keyword overlap normalized by
|
|
4
|
+
document length. This keeps the skeleton lightweight while still providing a
|
|
5
|
+
plausible Retriever protocol implementation.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from petfishframework.core.contracts import Retriever
|
|
14
|
+
from petfishframework.core.types import Snippet
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _tokenize(text: str) -> set[str]:
|
|
18
|
+
"""Lower-case, alphanumeric tokens of at least two characters."""
|
|
19
|
+
return {token for token in re.findall(r"[a-zA-Z0-9]+", text.lower()) if len(token) >= 2}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MemoryRetriever(Retriever):
|
|
24
|
+
"""In-memory retriever with keyword-overlap scoring.
|
|
25
|
+
|
|
26
|
+
Implements the ``Retriever`` protocol so it can be wrapped by CRAG and
|
|
27
|
+
Adaptive-RAG strategies without any changes to ``core/``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_documents: list[dict[str, Any]] = field(default_factory=list, repr=False)
|
|
31
|
+
|
|
32
|
+
def add(self, content: str, metadata: dict[str, Any] | None = None) -> None:
|
|
33
|
+
"""Add a document to the in-memory store."""
|
|
34
|
+
self._documents.append(
|
|
35
|
+
{
|
|
36
|
+
"content": content,
|
|
37
|
+
"metadata": metadata or {},
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
def retrieve(self, query: str, top_k: int = 5) -> list[Snippet]:
|
|
42
|
+
"""Return the top_k documents by keyword overlap score.
|
|
43
|
+
|
|
44
|
+
Score = (count of shared query tokens in doc) / (doc token count).
|
|
45
|
+
"""
|
|
46
|
+
query_tokens = _tokenize(query)
|
|
47
|
+
if not query_tokens or not self._documents:
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
scored: list[tuple[float, Snippet]] = []
|
|
51
|
+
for doc in self._documents:
|
|
52
|
+
content = doc["content"]
|
|
53
|
+
doc_tokens = _tokenize(content)
|
|
54
|
+
if not doc_tokens:
|
|
55
|
+
continue
|
|
56
|
+
|
|
57
|
+
shared = len(query_tokens & doc_tokens)
|
|
58
|
+
score = shared / len(doc_tokens)
|
|
59
|
+
scored.append(
|
|
60
|
+
(
|
|
61
|
+
score,
|
|
62
|
+
Snippet(
|
|
63
|
+
content=content,
|
|
64
|
+
source=doc["metadata"].get("source", ""),
|
|
65
|
+
score=score,
|
|
66
|
+
metadata=doc["metadata"],
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
scored.sort(key=lambda item: item[0], reverse=True)
|
|
72
|
+
return [snippet for _, snippet in scored[:top_k]]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Tools — native Python wrappers matching the MCP-shaped Tool contract.
|
|
2
|
+
|
|
3
|
+
Skeleton scope: BaseTool wrapper + calculator demonstration.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from .agent_tool import AgentAsTool
|
|
8
|
+
from .base import BaseTool, tool
|
|
9
|
+
from .calculator import Calculator
|
|
10
|
+
from .word_sorter import WordSorter
|
|
11
|
+
|
|
12
|
+
__all__ = ["AgentAsTool", "BaseTool", "Calculator", "WordSorter", "tool"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Agent-as-Tool wrapper enabling multi-agent delegation.
|
|
2
|
+
|
|
3
|
+
A supervisor agent can delegate work to a sub-agent by treating the sub-agent
|
|
4
|
+
as just another tool. The sub-agent runs through the full framework
|
|
5
|
+
(Session, Environment, events, budget) with its own budget, while the
|
|
6
|
+
supervisor sees only the returned answer as a tool result.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from petfishframework.core.agent import Agent
|
|
13
|
+
from petfishframework.core.contracts import RiskLevel
|
|
14
|
+
from petfishframework.core.types import ToolResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AgentAsTool:
|
|
18
|
+
"""Wrap an Agent so it can be invoked as a Tool by another agent."""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
agent: Agent,
|
|
23
|
+
name: str = "sub_agent",
|
|
24
|
+
description: str = "Delegate a task to a sub-agent",
|
|
25
|
+
risk_level: RiskLevel = RiskLevel.MEDIUM,
|
|
26
|
+
capabilities: tuple[str, ...] = (),
|
|
27
|
+
) -> None:
|
|
28
|
+
self._agent = agent
|
|
29
|
+
self.name = name
|
|
30
|
+
self.description = description
|
|
31
|
+
self.input_schema = {
|
|
32
|
+
"task": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "The task to delegate to the sub-agent",
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
self.risk_level = risk_level
|
|
38
|
+
self.capabilities = capabilities
|
|
39
|
+
|
|
40
|
+
def execute(self, args: dict[str, Any]) -> ToolResult:
|
|
41
|
+
"""Run the wrapped agent with the supplied task and return its answer."""
|
|
42
|
+
task = args.get("task", "")
|
|
43
|
+
try:
|
|
44
|
+
result = self._agent.run(task)
|
|
45
|
+
return ToolResult(value=result.answer)
|
|
46
|
+
except Exception as exc: # noqa: BLE001
|
|
47
|
+
return ToolResult(error=str(exc))
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Base tool wrapper that turns a Python callable into the Tool protocol.
|
|
2
|
+
|
|
3
|
+
Native tools and MCP tools both satisfy the same contract (decision 2):
|
|
4
|
+
name/description/input_schema/risk_level/capabilities + execute(args).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any, Callable
|
|
10
|
+
|
|
11
|
+
from petfishframework.core.contracts import RiskLevel
|
|
12
|
+
from petfishframework.core.types import ToolResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class BaseTool:
|
|
17
|
+
"""Concrete wrapper implementing the Tool protocol.
|
|
18
|
+
|
|
19
|
+
All fields have defaults so subclasses can override them safely.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
name: str = "tool"
|
|
23
|
+
description: str = ""
|
|
24
|
+
input_schema: dict[str, Any] = field(default_factory=dict)
|
|
25
|
+
risk_level: RiskLevel = RiskLevel.LOW
|
|
26
|
+
capabilities: tuple[str, ...] = ()
|
|
27
|
+
_func: Callable[[dict[str, Any]], ToolResult] = field(
|
|
28
|
+
default_factory=lambda: _not_implemented,
|
|
29
|
+
compare=False,
|
|
30
|
+
repr=False,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def execute(self, args: dict[str, Any]) -> ToolResult:
|
|
34
|
+
"""Execute the wrapped function."""
|
|
35
|
+
return self._func(args)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _not_implemented(_args: dict[str, Any]) -> ToolResult:
|
|
39
|
+
return ToolResult(error="tool not implemented")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def tool(
|
|
43
|
+
name: str,
|
|
44
|
+
description: str,
|
|
45
|
+
input_schema: dict[str, Any] | None = None,
|
|
46
|
+
risk_level: RiskLevel = RiskLevel.LOW,
|
|
47
|
+
capabilities: tuple[str, ...] = (),
|
|
48
|
+
) -> Callable[[Callable[..., Any]], BaseTool]:
|
|
49
|
+
"""Decorator that wraps a function as a BaseTool.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
@tool("echo", "Echo the input", {"type": "object", "properties": {"x": {"type": "string"}}})
|
|
53
|
+
def echo(x: str) -> str:
|
|
54
|
+
return x
|
|
55
|
+
"""
|
|
56
|
+
schema = input_schema if input_schema is not None else {"type": "object", "properties": {}}
|
|
57
|
+
|
|
58
|
+
def decorator(func: Callable[..., Any]) -> BaseTool:
|
|
59
|
+
def wrapper(args: dict[str, Any]) -> ToolResult:
|
|
60
|
+
try:
|
|
61
|
+
value = func(**args)
|
|
62
|
+
return ToolResult(value=value)
|
|
63
|
+
except Exception as exc: # noqa: BLE001
|
|
64
|
+
return ToolResult(error=str(exc))
|
|
65
|
+
|
|
66
|
+
return BaseTool(
|
|
67
|
+
name=name,
|
|
68
|
+
description=description,
|
|
69
|
+
input_schema=schema,
|
|
70
|
+
risk_level=risk_level,
|
|
71
|
+
capabilities=capabilities,
|
|
72
|
+
_func=wrapper,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
return decorator
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""A safe arithmetic calculator tool."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import ast
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from petfishframework.core.contracts import RiskLevel
|
|
8
|
+
from petfishframework.core.types import ToolResult
|
|
9
|
+
|
|
10
|
+
from .base import BaseTool
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _calc_schema() -> dict:
|
|
14
|
+
return {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"properties": {
|
|
17
|
+
"expression": {
|
|
18
|
+
"type": "string",
|
|
19
|
+
"description": "A simple arithmetic expression, e.g. '2 + 3 * 4'.",
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"required": ["expression"],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Calculator(BaseTool):
|
|
28
|
+
"""Safely evaluate a basic arithmetic expression."""
|
|
29
|
+
|
|
30
|
+
name: str = "calculator"
|
|
31
|
+
description: str = "Perform arithmetic"
|
|
32
|
+
input_schema: dict = field(default_factory=_calc_schema)
|
|
33
|
+
risk_level: RiskLevel = RiskLevel.LOW
|
|
34
|
+
capabilities: tuple[str, ...] = ()
|
|
35
|
+
|
|
36
|
+
def execute(self, args: dict) -> ToolResult:
|
|
37
|
+
"""Evaluate the expression field safely."""
|
|
38
|
+
expression = args.get("expression", "")
|
|
39
|
+
try:
|
|
40
|
+
result = self._evaluate(expression)
|
|
41
|
+
# Normalize: return int for whole numbers to avoid 391.0 vs 391 ambiguity
|
|
42
|
+
if isinstance(result, float) and result.is_integer():
|
|
43
|
+
result = int(result)
|
|
44
|
+
return ToolResult(value=result)
|
|
45
|
+
except Exception as exc: # noqa: BLE001
|
|
46
|
+
return ToolResult(error=str(exc))
|
|
47
|
+
|
|
48
|
+
def _evaluate(self, expression: str) -> float:
|
|
49
|
+
"""Parse and evaluate a restricted arithmetic expression using ast."""
|
|
50
|
+
# Convert ^ to ** for power (models/users often use ^ for exponentiation)
|
|
51
|
+
expression = expression.replace("^", "**")
|
|
52
|
+
node = ast.parse(expression, mode="eval")
|
|
53
|
+
return self._eval_node(node.body)
|
|
54
|
+
|
|
55
|
+
def _eval_node(self, node: ast.AST) -> float:
|
|
56
|
+
if isinstance(node, ast.BinOp):
|
|
57
|
+
left = self._eval_node(node.left)
|
|
58
|
+
right = self._eval_node(node.right)
|
|
59
|
+
if isinstance(node.op, ast.Add):
|
|
60
|
+
return left + right
|
|
61
|
+
if isinstance(node.op, ast.Sub):
|
|
62
|
+
return left - right
|
|
63
|
+
if isinstance(node.op, ast.Mult):
|
|
64
|
+
return left * right
|
|
65
|
+
if isinstance(node.op, ast.Div):
|
|
66
|
+
if right == 0:
|
|
67
|
+
raise ZeroDivisionError("division by zero")
|
|
68
|
+
return left / right
|
|
69
|
+
if isinstance(node.op, ast.Pow):
|
|
70
|
+
return left ** right
|
|
71
|
+
raise ValueError(f"Unsupported operator: {type(node.op).__name__}")
|
|
72
|
+
|
|
73
|
+
if isinstance(node, ast.UnaryOp):
|
|
74
|
+
operand = self._eval_node(node.operand)
|
|
75
|
+
if isinstance(node.op, ast.UAdd):
|
|
76
|
+
return +operand
|
|
77
|
+
if isinstance(node.op, ast.USub):
|
|
78
|
+
return -operand
|
|
79
|
+
raise ValueError(f"Unsupported unary operator: {type(node.op).__name__}")
|
|
80
|
+
|
|
81
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
|
82
|
+
return float(node.value)
|
|
83
|
+
|
|
84
|
+
raise ValueError(f"Unsupported expression node: {type(node).__name__}")
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""BFS pathfinder tool for LLM+P's planner-as-tool pattern.
|
|
2
|
+
|
|
3
|
+
Deterministic symbolic planner: same input always produces the same output.
|
|
4
|
+
This makes it replay-friendly and validates that a planner can be exposed
|
|
5
|
+
as an Environment primitive (audited, budget-metered, permission-gated).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import deque
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from petfishframework.core.contracts import RiskLevel
|
|
14
|
+
from petfishframework.core.types import ToolResult
|
|
15
|
+
|
|
16
|
+
from .base import BaseTool
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _path_planner_schema() -> dict[str, Any]:
|
|
20
|
+
return {
|
|
21
|
+
"type": "object",
|
|
22
|
+
"properties": {
|
|
23
|
+
"start": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"description": "Starting node identifier.",
|
|
26
|
+
},
|
|
27
|
+
"goal": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Target node identifier.",
|
|
30
|
+
},
|
|
31
|
+
"edges": {
|
|
32
|
+
"type": "array",
|
|
33
|
+
"description": "Directed edges as [from, to] pairs.",
|
|
34
|
+
"items": {
|
|
35
|
+
"type": "array",
|
|
36
|
+
"minItems": 2,
|
|
37
|
+
"maxItems": 2,
|
|
38
|
+
"items": {"type": "string"},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
"required": ["start", "goal", "edges"],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class PathPlanner(BaseTool):
|
|
48
|
+
"""Find the shortest path between two nodes using BFS."""
|
|
49
|
+
|
|
50
|
+
name: str = "path_planner"
|
|
51
|
+
description: str = "Find shortest path between nodes in a graph"
|
|
52
|
+
input_schema: dict[str, Any] = field(default_factory=_path_planner_schema)
|
|
53
|
+
risk_level: RiskLevel = RiskLevel.LOW
|
|
54
|
+
capabilities: tuple[str, ...] = ()
|
|
55
|
+
|
|
56
|
+
def execute(self, args: dict[str, Any]) -> ToolResult:
|
|
57
|
+
"""Run BFS and return the shortest path or an error."""
|
|
58
|
+
start = args.get("start", "")
|
|
59
|
+
goal = args.get("goal", "")
|
|
60
|
+
edges = args.get("edges", [])
|
|
61
|
+
|
|
62
|
+
if not isinstance(edges, list):
|
|
63
|
+
return ToolResult(error="edges must be a list of [from, to] pairs")
|
|
64
|
+
|
|
65
|
+
adjacency: dict[str, list[str]] = {}
|
|
66
|
+
for edge in edges:
|
|
67
|
+
if not isinstance(edge, (list, tuple)) or len(edge) != 2:
|
|
68
|
+
return ToolResult(error="each edge must be a [from, to] pair")
|
|
69
|
+
src, dst = edge[0], edge[1]
|
|
70
|
+
adjacency.setdefault(src, []).append(dst)
|
|
71
|
+
|
|
72
|
+
if start == goal:
|
|
73
|
+
return ToolResult(value={"path": [start], "steps": 0})
|
|
74
|
+
|
|
75
|
+
visited = {start}
|
|
76
|
+
queue: deque[list[str]] = deque([[start]])
|
|
77
|
+
|
|
78
|
+
while queue:
|
|
79
|
+
path = queue.popleft()
|
|
80
|
+
node = path[-1]
|
|
81
|
+
for neighbor in adjacency.get(node, []):
|
|
82
|
+
if neighbor in visited:
|
|
83
|
+
continue
|
|
84
|
+
visited.add(neighbor)
|
|
85
|
+
new_path = path + [neighbor]
|
|
86
|
+
if neighbor == goal:
|
|
87
|
+
return ToolResult(value={"path": new_path, "steps": len(new_path) - 1})
|
|
88
|
+
queue.append(new_path)
|
|
89
|
+
|
|
90
|
+
return ToolResult(error="no path found")
|