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,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))