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,218 @@
|
|
|
1
|
+
"""Real MCP stdio transport using JSON-RPC 2.0 over subprocess pipes.
|
|
2
|
+
|
|
3
|
+
This module implements the client side of the Model Context Protocol stdio
|
|
4
|
+
transport. It spawns a subprocess, speaks newline-delimited JSON-RPC 2.0 over
|
|
5
|
+
``stdin``/``stdout``, and exposes a small synchronous API for tool discovery
|
|
6
|
+
and invocation.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StdioMCPClient:
|
|
17
|
+
"""A synchronous MCP client that uses stdio JSON-RPC.
|
|
18
|
+
|
|
19
|
+
The client is designed to be used as a context manager so the subprocess is
|
|
20
|
+
always terminated on exit:
|
|
21
|
+
|
|
22
|
+
with StdioMCPClient("uvx", ["mcp-server-time"]) as client:
|
|
23
|
+
tools = client.list_tools()
|
|
24
|
+
result = client.call_tool("get_current_time", {"timezone": "UTC"})
|
|
25
|
+
|
|
26
|
+
Outside a context manager, callers must call :meth:`close` explicitly.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
command: str,
|
|
32
|
+
args: list[str],
|
|
33
|
+
env: dict[str, str] | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._command = command
|
|
36
|
+
self._args = args
|
|
37
|
+
self._env = env
|
|
38
|
+
self._process: subprocess.Popen[str] | None = None
|
|
39
|
+
self._next_id = 1
|
|
40
|
+
self._server_capabilities: dict[str, Any] = {}
|
|
41
|
+
|
|
42
|
+
def _get_env(self) -> dict[str, str] | None:
|
|
43
|
+
"""Return the environment for the subprocess.
|
|
44
|
+
|
|
45
|
+
A shallow copy of ``os.environ`` is merged with any user-supplied extra
|
|
46
|
+
variables. ``PATH`` is preserved unless explicitly overridden.
|
|
47
|
+
"""
|
|
48
|
+
if self._env is None:
|
|
49
|
+
return None
|
|
50
|
+
merged = dict(os.environ)
|
|
51
|
+
merged.update(self._env)
|
|
52
|
+
return merged
|
|
53
|
+
|
|
54
|
+
def _spawn(self) -> subprocess.Popen[str]:
|
|
55
|
+
"""Start the MCP server subprocess with text-mode pipes.
|
|
56
|
+
|
|
57
|
+
On Windows, commands like 'npx' are .cmd files that Popen can't find
|
|
58
|
+
without shell=True. We resolve the full path via shutil.which instead.
|
|
59
|
+
"""
|
|
60
|
+
import shutil
|
|
61
|
+
|
|
62
|
+
resolved = shutil.which(self._command)
|
|
63
|
+
cmd = resolved or self._command
|
|
64
|
+
return subprocess.Popen(
|
|
65
|
+
[cmd] + self._args,
|
|
66
|
+
stdin=subprocess.PIPE,
|
|
67
|
+
stdout=subprocess.PIPE,
|
|
68
|
+
stderr=subprocess.PIPE,
|
|
69
|
+
text=True,
|
|
70
|
+
env=self._get_env(),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def _proc(self) -> subprocess.Popen[str]:
|
|
75
|
+
"""Return the active subprocess, raising if it has not been started."""
|
|
76
|
+
if self._process is None:
|
|
77
|
+
raise RuntimeError("StdioMCPClient is not initialized")
|
|
78
|
+
return self._process
|
|
79
|
+
|
|
80
|
+
def _read_message(self) -> dict[str, Any]:
|
|
81
|
+
"""Read and parse one newline-delimited JSON-RPC message."""
|
|
82
|
+
proc = self._proc
|
|
83
|
+
if proc.stdout is None:
|
|
84
|
+
raise RuntimeError("subprocess stdout pipe is closed")
|
|
85
|
+
line = proc.stdout.readline()
|
|
86
|
+
if not line:
|
|
87
|
+
raise RuntimeError("subprocess closed stdout before returning a response")
|
|
88
|
+
return json.loads(line)
|
|
89
|
+
|
|
90
|
+
def _send_request(self, method: str, params: dict[str, Any] | None = None) -> Any:
|
|
91
|
+
"""Send a JSON-RPC request and return the ``result`` field."""
|
|
92
|
+
proc = self._proc
|
|
93
|
+
if proc.stdin is None:
|
|
94
|
+
raise RuntimeError("subprocess stdin pipe is closed")
|
|
95
|
+
|
|
96
|
+
request_id = self._next_id
|
|
97
|
+
self._next_id += 1
|
|
98
|
+
|
|
99
|
+
message: dict[str, Any] = {
|
|
100
|
+
"jsonrpc": "2.0",
|
|
101
|
+
"id": request_id,
|
|
102
|
+
"method": method,
|
|
103
|
+
}
|
|
104
|
+
if params is not None:
|
|
105
|
+
message["params"] = params
|
|
106
|
+
|
|
107
|
+
proc.stdin.write(json.dumps(message) + "\n")
|
|
108
|
+
proc.stdin.flush()
|
|
109
|
+
|
|
110
|
+
while True:
|
|
111
|
+
response = self._read_message()
|
|
112
|
+
# Ignore notifications / unsolicited messages while waiting for
|
|
113
|
+
# the matching response.
|
|
114
|
+
if response.get("id") == request_id:
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
if "error" in response:
|
|
118
|
+
error = response["error"]
|
|
119
|
+
raise RuntimeError(
|
|
120
|
+
f"JSON-RPC error for {method}: "
|
|
121
|
+
f"{error.get('code')} {error.get('message')}"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return response.get("result")
|
|
125
|
+
|
|
126
|
+
def _send_notification(
|
|
127
|
+
self,
|
|
128
|
+
method: str,
|
|
129
|
+
params: dict[str, Any] | None = None,
|
|
130
|
+
) -> None:
|
|
131
|
+
"""Send a JSON-RPC notification (no response expected)."""
|
|
132
|
+
proc = self._proc
|
|
133
|
+
if proc.stdin is None:
|
|
134
|
+
raise RuntimeError("subprocess stdin pipe is closed")
|
|
135
|
+
|
|
136
|
+
message: dict[str, Any] = {
|
|
137
|
+
"jsonrpc": "2.0",
|
|
138
|
+
"method": method,
|
|
139
|
+
}
|
|
140
|
+
if params is not None:
|
|
141
|
+
message["params"] = params
|
|
142
|
+
|
|
143
|
+
proc.stdin.write(json.dumps(message) + "\n")
|
|
144
|
+
proc.stdin.flush()
|
|
145
|
+
|
|
146
|
+
def initialize(self) -> None:
|
|
147
|
+
"""Perform the MCP initialization handshake."""
|
|
148
|
+
if self._process is None:
|
|
149
|
+
self._process = self._spawn()
|
|
150
|
+
|
|
151
|
+
result = self._send_request(
|
|
152
|
+
"initialize",
|
|
153
|
+
{
|
|
154
|
+
"protocolVersion": "2024-11-05",
|
|
155
|
+
"capabilities": {},
|
|
156
|
+
"clientInfo": {"name": "petfishframework", "version": "0.1.0"},
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
self._server_capabilities = (result or {}).get("capabilities", {})
|
|
160
|
+
self._send_notification("notifications/initialized")
|
|
161
|
+
|
|
162
|
+
def list_tools(self) -> list[dict[str, Any]]:
|
|
163
|
+
"""Return the list of tools exposed by the server."""
|
|
164
|
+
result = self._send_request("tools/list", {})
|
|
165
|
+
if not isinstance(result, dict):
|
|
166
|
+
raise RuntimeError(f"unexpected tools/list response: {result!r}")
|
|
167
|
+
tools = result.get("tools", [])
|
|
168
|
+
if not isinstance(tools, list):
|
|
169
|
+
raise RuntimeError(f"unexpected tools/list tools field: {tools!r}")
|
|
170
|
+
return tools
|
|
171
|
+
|
|
172
|
+
def call_tool(self, name: str, arguments: dict[str, Any]) -> Any:
|
|
173
|
+
"""Call a tool by name and return the raw result."""
|
|
174
|
+
return self._send_request(
|
|
175
|
+
"tools/call",
|
|
176
|
+
{"name": name, "arguments": arguments},
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
def close(self) -> None:
|
|
180
|
+
"""Terminate the subprocess and close pipes.
|
|
181
|
+
|
|
182
|
+
This method is idempotent: repeated calls are safe.
|
|
183
|
+
"""
|
|
184
|
+
proc = self._process
|
|
185
|
+
if proc is None:
|
|
186
|
+
return
|
|
187
|
+
|
|
188
|
+
self._process = None
|
|
189
|
+
|
|
190
|
+
if proc.stdin is not None:
|
|
191
|
+
try:
|
|
192
|
+
proc.stdin.close()
|
|
193
|
+
except BrokenPipeError:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
proc.terminate()
|
|
198
|
+
try:
|
|
199
|
+
proc.wait(timeout=5)
|
|
200
|
+
except subprocess.TimeoutExpired:
|
|
201
|
+
proc.kill()
|
|
202
|
+
proc.wait()
|
|
203
|
+
except ProcessLookupError:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
for pipe in (proc.stdout, proc.stderr):
|
|
207
|
+
if pipe is not None:
|
|
208
|
+
try:
|
|
209
|
+
pipe.close()
|
|
210
|
+
except BrokenPipeError:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
def __enter__(self) -> "StdioMCPClient":
|
|
214
|
+
self.initialize()
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
def __exit__(self, *exc: object) -> None:
|
|
218
|
+
self.close()
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""MCPToolWrapper — adapts an MCP-shaped tool spec to the framework Tool protocol.
|
|
2
|
+
|
|
3
|
+
This is the leakage-isolation boundary: everything inside `mcp/` may speak MCP,
|
|
4
|
+
but the wrapper exported here is a plain `Tool` and is indistinguishable from a
|
|
5
|
+
native tool at the framework boundary.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from petfishframework.core.contracts import RiskLevel
|
|
13
|
+
from petfishframework.core.types import ToolResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class MCPToolWrapper:
|
|
18
|
+
"""A Tool-protocol adapter around an MCP tool specification.
|
|
19
|
+
|
|
20
|
+
The constructor mirrors the fields expected by the `Tool` protocol. The
|
|
21
|
+
bundled `call_fn` is the only MCP-specific artifact and is not part of the
|
|
22
|
+
public Tool surface.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
description: str
|
|
27
|
+
input_schema: dict[str, Any]
|
|
28
|
+
call_fn: Callable[[dict[str, Any]], Any] = field(compare=False, repr=False)
|
|
29
|
+
risk_level: RiskLevel = RiskLevel.LOW
|
|
30
|
+
capabilities: tuple[str, ...] = ()
|
|
31
|
+
|
|
32
|
+
def execute(self, args: dict[str, Any]) -> ToolResult:
|
|
33
|
+
"""Invoke the wrapped MCP call and translate the outcome.
|
|
34
|
+
|
|
35
|
+
A successful call becomes ``ToolResult(value=...)``. Any exception is
|
|
36
|
+
caught and returned as ``ToolResult(error=...)`` so callers never need
|
|
37
|
+
to know whether the underlying implementation is native or MCP.
|
|
38
|
+
"""
|
|
39
|
+
try:
|
|
40
|
+
value = self.call_fn(args)
|
|
41
|
+
except Exception as exc: # noqa: BLE001
|
|
42
|
+
return ToolResult(error=str(exc))
|
|
43
|
+
return ToolResult(value=value)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Model adapters — concrete implementations of the ModelAdapter protocol.
|
|
2
|
+
|
|
3
|
+
Provides ``FakeModel`` for deterministic testing, ``OpenAIModel`` for live
|
|
4
|
+
OpenAI API calls, and ``AnthropicModel`` for Anthropic's Messages API.
|
|
5
|
+
All provider dependencies are imported lazily at runtime so the framework core
|
|
6
|
+
remains optional-dependency free.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from .anthropic import AnthropicModel
|
|
11
|
+
from .fake import AsyncFakeModel, FakeModel
|
|
12
|
+
from .openai import OpenAIModel
|
|
13
|
+
|
|
14
|
+
__all__ = ["AnthropicModel", "AsyncFakeModel", "FakeModel", "OpenAIModel"]
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Anthropic (Claude) model adapter for the ModelAdapter protocol.
|
|
2
|
+
|
|
3
|
+
The ``anthropic`` package is imported lazily inside ``AnthropicModel.__init__`` so
|
|
4
|
+
framework core remains free of the optional Anthropic dependency at import time.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from petfishframework.core.contracts import ModelAdapter
|
|
13
|
+
from petfishframework.core.types import (
|
|
14
|
+
Message,
|
|
15
|
+
ModelRequest,
|
|
16
|
+
ModelResponse,
|
|
17
|
+
Role,
|
|
18
|
+
ToolCall,
|
|
19
|
+
Usage,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AnthropicModel(ModelAdapter):
|
|
24
|
+
"""Adapter that queries Anthropic's Messages API."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
model: str = "claude-sonnet-4-5-20250514",
|
|
29
|
+
api_key: str | None = None,
|
|
30
|
+
base_url: str | None = None,
|
|
31
|
+
**kwargs: Any,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Create an Anthropic adapter with a lazily imported Anthropic client.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
model: The Anthropic model name (e.g. ``claude-sonnet-4-5-20250514``).
|
|
37
|
+
api_key: Anthropic API key. If not provided, ``ANTHROPIC_API_KEY`` is used.
|
|
38
|
+
base_url: Optional custom base URL for the Anthropic API.
|
|
39
|
+
**kwargs: Additional keyword arguments forwarded to ``anthropic.Anthropic``.
|
|
40
|
+
|
|
41
|
+
Raises:
|
|
42
|
+
ImportError: If the ``anthropic`` package is not installed.
|
|
43
|
+
ValueError: If no API key is available.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
import anthropic
|
|
47
|
+
from anthropic import Anthropic
|
|
48
|
+
except ImportError as exc:
|
|
49
|
+
raise ImportError(
|
|
50
|
+
"The 'anthropic' package is required to use AnthropicModel. "
|
|
51
|
+
"Install it with 'pip install petfishframework[anthropic]'."
|
|
52
|
+
) from exc
|
|
53
|
+
|
|
54
|
+
resolved_key = api_key if api_key is not None else os.environ.get("ANTHROPIC_API_KEY")
|
|
55
|
+
if resolved_key is None:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"AnthropicModel requires an api_key or the ANTHROPIC_API_KEY environment variable."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
self.name = model
|
|
61
|
+
self._model = model
|
|
62
|
+
self._anthropic = anthropic
|
|
63
|
+
self._client = Anthropic(api_key=resolved_key, base_url=base_url, **kwargs)
|
|
64
|
+
|
|
65
|
+
def query(self, request: ModelRequest) -> ModelResponse:
|
|
66
|
+
"""Send a Messages API request and convert the response."""
|
|
67
|
+
system, messages = self._build_messages(request.messages)
|
|
68
|
+
tools = self._build_tools(request.tools)
|
|
69
|
+
|
|
70
|
+
max_tokens = request.max_tokens if request.max_tokens is not None else 4096
|
|
71
|
+
params: dict[str, Any] = {
|
|
72
|
+
"model": self._model,
|
|
73
|
+
"max_tokens": max_tokens,
|
|
74
|
+
"messages": messages,
|
|
75
|
+
"temperature": request.temperature,
|
|
76
|
+
}
|
|
77
|
+
if system:
|
|
78
|
+
params["system"] = system
|
|
79
|
+
if tools:
|
|
80
|
+
params["tools"] = tools
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
response = self._client.messages.create(**params)
|
|
84
|
+
except self._anthropic.APIError as exc:
|
|
85
|
+
raise RuntimeError(f"Anthropic API error: {exc}") from exc
|
|
86
|
+
|
|
87
|
+
return self._build_response(response)
|
|
88
|
+
|
|
89
|
+
def _build_messages(
|
|
90
|
+
self,
|
|
91
|
+
messages: tuple[Message, ...],
|
|
92
|
+
) -> tuple[str | None, list[dict[str, Any]]]:
|
|
93
|
+
"""Convert framework messages to Anthropic Messages API format.
|
|
94
|
+
|
|
95
|
+
Anthropic requires system messages to be passed in a separate ``system``
|
|
96
|
+
parameter and supports only ``user`` and ``assistant`` roles in the
|
|
97
|
+
messages list. Tool result messages are emitted as ``tool_result``
|
|
98
|
+
content blocks inside a ``user`` message.
|
|
99
|
+
"""
|
|
100
|
+
system_parts: list[str] = []
|
|
101
|
+
anthropic_messages: list[dict[str, Any]] = []
|
|
102
|
+
|
|
103
|
+
for msg in messages:
|
|
104
|
+
if msg.role is Role.SYSTEM:
|
|
105
|
+
system_parts.append(msg.content)
|
|
106
|
+
continue
|
|
107
|
+
|
|
108
|
+
if msg.role is Role.TOOL:
|
|
109
|
+
entry: dict[str, Any] = {
|
|
110
|
+
"role": "user",
|
|
111
|
+
"content": [
|
|
112
|
+
{
|
|
113
|
+
"type": "tool_result",
|
|
114
|
+
"tool_use_id": msg.tool_call_id or "",
|
|
115
|
+
"content": msg.content,
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
}
|
|
119
|
+
anthropic_messages.append(entry)
|
|
120
|
+
continue
|
|
121
|
+
|
|
122
|
+
if msg.role is Role.ASSISTANT and msg.tool_calls:
|
|
123
|
+
content_blocks: list[dict[str, Any]] = []
|
|
124
|
+
if msg.content:
|
|
125
|
+
content_blocks.append({"type": "text", "text": msg.content})
|
|
126
|
+
for tool_call in msg.tool_calls:
|
|
127
|
+
content_blocks.append(
|
|
128
|
+
{
|
|
129
|
+
"type": "tool_use",
|
|
130
|
+
"id": tool_call.id,
|
|
131
|
+
"name": tool_call.name,
|
|
132
|
+
"input": tool_call.arguments,
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
anthropic_messages.append({"role": "assistant", "content": content_blocks})
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
anthropic_messages.append(
|
|
139
|
+
{"role": msg.role.value, "content": msg.content}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
system = "\n".join(system_parts) if system_parts else None
|
|
143
|
+
return system, anthropic_messages
|
|
144
|
+
|
|
145
|
+
def _build_tools(self, tool_names: tuple[str, ...]) -> list[dict[str, Any]] | None:
|
|
146
|
+
"""Convert tool names to Anthropic tool definitions."""
|
|
147
|
+
if not tool_names:
|
|
148
|
+
return None
|
|
149
|
+
return [
|
|
150
|
+
{
|
|
151
|
+
"name": name,
|
|
152
|
+
"description": "",
|
|
153
|
+
"input_schema": {"type": "object", "properties": {}},
|
|
154
|
+
}
|
|
155
|
+
for name in tool_names
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
def _usage_from_response(self, response: Any) -> Usage:
|
|
159
|
+
"""Extract token usage from an Anthropic response."""
|
|
160
|
+
usage = response.usage
|
|
161
|
+
return Usage(
|
|
162
|
+
input_tokens=getattr(usage, "input_tokens", 0),
|
|
163
|
+
output_tokens=getattr(usage, "output_tokens", 0),
|
|
164
|
+
total_tokens=getattr(usage, "input_tokens", 0) + getattr(usage, "output_tokens", 0),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def _build_response(self, response: Any) -> ModelResponse:
|
|
168
|
+
"""Convert an Anthropic Messages API response to a ModelResponse."""
|
|
169
|
+
text_parts: list[str] = []
|
|
170
|
+
tool_calls: list[ToolCall] = []
|
|
171
|
+
|
|
172
|
+
for block in response.content:
|
|
173
|
+
block_type = getattr(block, "type", None)
|
|
174
|
+
if block_type == "text":
|
|
175
|
+
text_parts.append(getattr(block, "text", ""))
|
|
176
|
+
elif block_type == "tool_use":
|
|
177
|
+
tool_input = getattr(block, "input", {})
|
|
178
|
+
if isinstance(tool_input, str):
|
|
179
|
+
tool_input = json.loads(tool_input)
|
|
180
|
+
tool_calls.append(
|
|
181
|
+
ToolCall(
|
|
182
|
+
id=getattr(block, "id", ""),
|
|
183
|
+
name=getattr(block, "name", ""),
|
|
184
|
+
arguments=tool_input,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return ModelResponse(
|
|
189
|
+
content="".join(text_parts),
|
|
190
|
+
tool_calls=tuple(tool_calls),
|
|
191
|
+
usage=self._usage_from_response(response),
|
|
192
|
+
finish_reason=response.stop_reason or "stop",
|
|
193
|
+
raw=response,
|
|
194
|
+
)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Fake model adapter for deterministic tests and offline development."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from petfishframework.core.contracts import ModelAdapter
|
|
11
|
+
from petfishframework.core.types import ModelRequest, ModelResponse, ToolCall, Usage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class FakeModel(ModelAdapter):
|
|
16
|
+
"""A scripted model that replays pre-defined responses in order.
|
|
17
|
+
|
|
18
|
+
Useful for unit tests: you can script a tool call followed by a final answer
|
|
19
|
+
and verify the end-to-end agent behavior without network calls.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
responses: tuple[ModelResponse, ...] = ()
|
|
23
|
+
name: str = "fake"
|
|
24
|
+
_index: int = field(default=0, repr=False)
|
|
25
|
+
_calls: int = field(default=0, repr=False)
|
|
26
|
+
_requests: list[ModelRequest] = field(default_factory=list, repr=False)
|
|
27
|
+
|
|
28
|
+
def query(self, request: ModelRequest) -> ModelResponse:
|
|
29
|
+
"""Return the next scripted response, or the last one available."""
|
|
30
|
+
self._requests.append(request)
|
|
31
|
+
if not self.responses:
|
|
32
|
+
return ModelResponse(content="", usage=self._per_call_usage())
|
|
33
|
+
|
|
34
|
+
if self._index >= len(self.responses):
|
|
35
|
+
response = self.responses[-1]
|
|
36
|
+
else:
|
|
37
|
+
response = self.responses[self._index]
|
|
38
|
+
self._index += 1
|
|
39
|
+
|
|
40
|
+
self._calls += 1
|
|
41
|
+
# If the scripted response did not set usage, apply deterministic usage.
|
|
42
|
+
usage = response.usage if response.usage.total_tokens > 0 else self._per_call_usage()
|
|
43
|
+
# Ensure every returned response carries deterministic usage.
|
|
44
|
+
return ModelResponse(
|
|
45
|
+
content=response.content,
|
|
46
|
+
tool_calls=response.tool_calls,
|
|
47
|
+
usage=usage,
|
|
48
|
+
finish_reason=response.finish_reason,
|
|
49
|
+
raw=response.raw,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def query_stream(self, request: ModelRequest) -> Iterator[str]:
|
|
53
|
+
"""Return the scripted response content as a stream of text chunks.
|
|
54
|
+
|
|
55
|
+
Chunks are contiguous words or whitespace slices, so joining them
|
|
56
|
+
reproduces the exact response content from :meth:`query`.
|
|
57
|
+
"""
|
|
58
|
+
response = self.query(request)
|
|
59
|
+
return (match.group(0) for match in re.finditer(r"\S+|\s+", response.content))
|
|
60
|
+
|
|
61
|
+
def _per_call_usage(self) -> Usage:
|
|
62
|
+
"""Deterministic per-call usage for testing budget enforcement."""
|
|
63
|
+
return Usage(input_tokens=10, output_tokens=20, total_tokens=30)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def call_count(self) -> int:
|
|
67
|
+
"""Number of times query() has been invoked."""
|
|
68
|
+
return self._calls
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def requests(self) -> tuple[ModelRequest, ...]:
|
|
72
|
+
"""All model requests received by query(), in order."""
|
|
73
|
+
return tuple(self._requests)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def script_tool_then_answer(
|
|
77
|
+
cls,
|
|
78
|
+
tool_name: str,
|
|
79
|
+
tool_args: dict[str, Any],
|
|
80
|
+
final_answer: str,
|
|
81
|
+
) -> "FakeModel":
|
|
82
|
+
"""Create a two-response script: one tool call, then a final answer."""
|
|
83
|
+
return cls(
|
|
84
|
+
responses=(
|
|
85
|
+
ModelResponse(
|
|
86
|
+
content=f"I will use the {tool_name} tool.",
|
|
87
|
+
tool_calls=(
|
|
88
|
+
ToolCall(
|
|
89
|
+
id=uuid.uuid4().hex[:12],
|
|
90
|
+
name=tool_name,
|
|
91
|
+
arguments=tool_args,
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
ModelResponse(content=final_answer),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def lats_scenario(
|
|
101
|
+
cls,
|
|
102
|
+
tool_name: str = "calculator",
|
|
103
|
+
candidate_groups: tuple[tuple[dict[str, Any], ...], ...] = (
|
|
104
|
+
({"expression": "2+3"}, {"expression": "2*4"}, {"expression": "3+4"}),
|
|
105
|
+
({"expression": "5*4"}, {"expression": "5+4"}, {"expression": "5-4"}),
|
|
106
|
+
),
|
|
107
|
+
score_groups: tuple[tuple[int | float, ...], ...] = (
|
|
108
|
+
(9, 3, 4),
|
|
109
|
+
(9, 4, 2),
|
|
110
|
+
),
|
|
111
|
+
final_answer: str = "The answer is 20.",
|
|
112
|
+
) -> "FakeModel":
|
|
113
|
+
"""Create a FakeModel scripted for the LATS search scenario.
|
|
114
|
+
|
|
115
|
+
The returned model alternates: candidate generation responses
|
|
116
|
+
(each carrying multiple tool_calls), value-scoring responses
|
|
117
|
+
(one per candidate), and a final plain-text answer.
|
|
118
|
+
"""
|
|
119
|
+
responses: list[ModelResponse] = []
|
|
120
|
+
for candidates, scores in zip(candidate_groups, score_groups, strict=True):
|
|
121
|
+
tool_calls = tuple(
|
|
122
|
+
ToolCall(
|
|
123
|
+
id=uuid.uuid4().hex[:12],
|
|
124
|
+
name=tool_name,
|
|
125
|
+
arguments=args,
|
|
126
|
+
)
|
|
127
|
+
for args in candidates
|
|
128
|
+
)
|
|
129
|
+
responses.append(
|
|
130
|
+
ModelResponse(
|
|
131
|
+
content="Here are candidate next actions.",
|
|
132
|
+
tool_calls=tool_calls,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
for score in scores:
|
|
136
|
+
responses.append(ModelResponse(content=f"Score: {score}"))
|
|
137
|
+
responses.append(ModelResponse(content=final_answer))
|
|
138
|
+
return cls(responses=tuple(responses))
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def llm_plus_p_scenario(
|
|
142
|
+
cls,
|
|
143
|
+
translate_content: str = '{"start": "A", "goal": "C", "edges": [["A", "B"], ["B", "C"]]}',
|
|
144
|
+
backtranslate_content: str = "The shortest path is A -> B -> C.",
|
|
145
|
+
) -> "FakeModel":
|
|
146
|
+
"""Create a FakeModel scripted for the LLM+P three-phase scenario.
|
|
147
|
+
|
|
148
|
+
Response 1: structured problem extraction (used as planner input).
|
|
149
|
+
Response 2: natural-language answer derived from the planner output.
|
|
150
|
+
"""
|
|
151
|
+
return cls(
|
|
152
|
+
responses=(
|
|
153
|
+
ModelResponse(content=translate_content),
|
|
154
|
+
ModelResponse(content=backtranslate_content),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class AsyncFakeModel(ModelAdapter):
|
|
161
|
+
"""Async scripted model adapter for deterministic async tests.
|
|
162
|
+
|
|
163
|
+
Wraps a sync FakeModel so all scripting utilities are reused; only the
|
|
164
|
+
query() coroutine wrapper is added. This makes AsyncFakeModel compatible
|
|
165
|
+
with RuntimeEnvironment.query_model_async() via
|
|
166
|
+
``asyncio.iscoroutinefunction()`` detection.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
_inner: FakeModel = field(default_factory=FakeModel)
|
|
170
|
+
name: str = "async_fake"
|
|
171
|
+
|
|
172
|
+
def query(self, request: ModelRequest) -> ModelResponse:
|
|
173
|
+
"""Return the next scripted response (synchronous protocol hook)."""
|
|
174
|
+
return self._inner.query(request)
|
|
175
|
+
|
|
176
|
+
async def query_async(self, request: ModelRequest) -> ModelResponse:
|
|
177
|
+
"""Return the next scripted response asynchronously."""
|
|
178
|
+
return self._inner.query(request)
|
|
179
|
+
|
|
180
|
+
def query_stream(self, request: ModelRequest) -> Iterator[str]:
|
|
181
|
+
"""Return the scripted response content as a synchronous text stream."""
|
|
182
|
+
return self._inner.query_stream(request)
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def call_count(self) -> int:
|
|
186
|
+
"""Number of times query() has been invoked on the underlying model."""
|
|
187
|
+
return self._inner.call_count
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def requests(self) -> tuple[ModelRequest, ...]:
|
|
191
|
+
"""All model requests received by the underlying query()."""
|
|
192
|
+
return tuple(self._inner.requests)
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def script_tool_then_answer(
|
|
196
|
+
cls,
|
|
197
|
+
tool_name: str,
|
|
198
|
+
tool_args: dict[str, Any],
|
|
199
|
+
final_answer: str,
|
|
200
|
+
) -> "AsyncFakeModel":
|
|
201
|
+
"""Create a two-response async script: one tool call, then a final answer."""
|
|
202
|
+
return cls(_inner=FakeModel.script_tool_then_answer(tool_name, tool_args, final_answer))
|