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.
Files changed (51) hide show
  1. petfishframework/__init__.py +33 -0
  2. petfishframework/config.py +142 -0
  3. petfishframework/core/__init__.py +91 -0
  4. petfishframework/core/agent.py +207 -0
  5. petfishframework/core/compiled.py +89 -0
  6. petfishframework/core/contracts.py +190 -0
  7. petfishframework/core/conversation.py +51 -0
  8. petfishframework/core/environment.py +260 -0
  9. petfishframework/core/events.py +79 -0
  10. petfishframework/core/session.py +182 -0
  11. petfishframework/core/structured.py +202 -0
  12. petfishframework/core/types.py +192 -0
  13. petfishframework/mcp/__init__.py +19 -0
  14. petfishframework/mcp/client.py +96 -0
  15. petfishframework/mcp/server.py +14 -0
  16. petfishframework/mcp/stdio_transport.py +218 -0
  17. petfishframework/mcp/wrapper.py +43 -0
  18. petfishframework/models/__init__.py +14 -0
  19. petfishframework/models/anthropic.py +194 -0
  20. petfishframework/models/fake.py +202 -0
  21. petfishframework/models/openai.py +178 -0
  22. petfishframework/observability/__init__.py +10 -0
  23. petfishframework/observability/sinks.py +23 -0
  24. petfishframework/permissions/__init__.py +32 -0
  25. petfishframework/permissions/model.py +110 -0
  26. petfishframework/reasoning/__init__.py +13 -0
  27. petfishframework/reasoning/lats.py +222 -0
  28. petfishframework/reasoning/llm_plus_p.py +202 -0
  29. petfishframework/reasoning/react.py +176 -0
  30. petfishframework/reliability/__init__.py +78 -0
  31. petfishframework/reliability/cost.py +50 -0
  32. petfishframework/reliability/cost_report.py +101 -0
  33. petfishframework/reliability/pass_at_k.py +232 -0
  34. petfishframework/reliability/replay.py +190 -0
  35. petfishframework/reliability/retry.py +224 -0
  36. petfishframework/reliability/timeout.py +55 -0
  37. petfishframework/retrieval/__init__.py +12 -0
  38. petfishframework/retrieval/adaptive.py +137 -0
  39. petfishframework/retrieval/crag.py +135 -0
  40. petfishframework/retrieval/memory_store.py +72 -0
  41. petfishframework/tools/__init__.py +12 -0
  42. petfishframework/tools/agent_tool.py +47 -0
  43. petfishframework/tools/base.py +75 -0
  44. petfishframework/tools/calculator.py +84 -0
  45. petfishframework/tools/path_planner.py +90 -0
  46. petfishframework/tools/registry.py +158 -0
  47. petfishframework/tools/word_sorter.py +41 -0
  48. petfishframework-0.1.0.dist-info/METADATA +151 -0
  49. petfishframework-0.1.0.dist-info/RECORD +51 -0
  50. petfishframework-0.1.0.dist-info/WHEEL +4 -0
  51. 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")