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,182 @@
|
|
|
1
|
+
"""Session — event-sourced run loop (decision 1 + decision 4)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from petfishframework.permissions.model import PermissionPolicy
|
|
8
|
+
|
|
9
|
+
from .compiled import CompiledContext, EvidenceBundle, MemorySlice, OutputContract, TaskSpec
|
|
10
|
+
from .contracts import (
|
|
11
|
+
MemoryView,
|
|
12
|
+
ModelAdapter,
|
|
13
|
+
ReasoningStrategy,
|
|
14
|
+
Retriever,
|
|
15
|
+
RunContext,
|
|
16
|
+
Tool,
|
|
17
|
+
)
|
|
18
|
+
from .conversation import ConversationStore
|
|
19
|
+
from .environment import RuntimeEnvironment
|
|
20
|
+
from .events import EventEmitter
|
|
21
|
+
from .types import Budget, Message, Result, Role, Task, Usage
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Session:
|
|
26
|
+
"""A single execution of an Agent.
|
|
27
|
+
|
|
28
|
+
Session owns the RuntimeEnvironment, RunContext, and EventEmitter. It is
|
|
29
|
+
the Process abstraction in the Agent:Session :: program:process analogy.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
model: ModelAdapter
|
|
33
|
+
reasoning: ReasoningStrategy
|
|
34
|
+
tools: tuple[Tool, ...]
|
|
35
|
+
retriever: Retriever | None
|
|
36
|
+
policy: PermissionPolicy
|
|
37
|
+
task: Task
|
|
38
|
+
budget: Budget | None
|
|
39
|
+
events: EventEmitter
|
|
40
|
+
session_id: str = field(default_factory=lambda: uuid.uuid4().hex[:16])
|
|
41
|
+
_env: RuntimeEnvironment | None = field(default=None, repr=False)
|
|
42
|
+
conversation_id: str | None = None
|
|
43
|
+
conversation_store: ConversationStore | None = None
|
|
44
|
+
|
|
45
|
+
def run(self) -> Result:
|
|
46
|
+
"""Execute the session and return a Result."""
|
|
47
|
+
ctx = self._prepare_run()
|
|
48
|
+
result = self.reasoning.run(ctx)
|
|
49
|
+
return self._finalize_run(result)
|
|
50
|
+
|
|
51
|
+
async def run_async(self) -> Result:
|
|
52
|
+
"""Execute the session asynchronously and return a Result."""
|
|
53
|
+
ctx = self._prepare_run()
|
|
54
|
+
run_async = getattr(self.reasoning, "run_async", None)
|
|
55
|
+
if run_async is None:
|
|
56
|
+
raise RuntimeError(
|
|
57
|
+
f"Strategy {self.reasoning.name} does not support async. "
|
|
58
|
+
"Implement run_async()."
|
|
59
|
+
)
|
|
60
|
+
result = await run_async(ctx)
|
|
61
|
+
return self._finalize_run(result)
|
|
62
|
+
|
|
63
|
+
def _prepare_run(self) -> RunContext:
|
|
64
|
+
"""Build the RuntimeEnvironment and RunContext; shared by sync/async paths."""
|
|
65
|
+
compiled = CompiledContext(
|
|
66
|
+
task_spec=TaskSpec(task_type="generic"),
|
|
67
|
+
memory_slice=MemorySlice(),
|
|
68
|
+
evidence_bundle=EvidenceBundle(),
|
|
69
|
+
output_contract=OutputContract(),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
budget = self.budget if self.budget is not None else Budget()
|
|
73
|
+
self._env = RuntimeEnvironment(
|
|
74
|
+
model=self.model,
|
|
75
|
+
_tools=self.tools,
|
|
76
|
+
retriever=self.retriever,
|
|
77
|
+
budget=budget,
|
|
78
|
+
events=self.events,
|
|
79
|
+
policy=self.policy,
|
|
80
|
+
session_id=self.session_id,
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
conversation_history = self._load_conversation_history()
|
|
84
|
+
ctx = RunContext(
|
|
85
|
+
task=self.task,
|
|
86
|
+
env=self._env,
|
|
87
|
+
budget=budget,
|
|
88
|
+
memory=MemoryView(),
|
|
89
|
+
events=self.events,
|
|
90
|
+
compiled=compiled,
|
|
91
|
+
conversation_history=conversation_history,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
self.events.emit(
|
|
95
|
+
"session.start",
|
|
96
|
+
{
|
|
97
|
+
"session_id": self.session_id,
|
|
98
|
+
"task": self.task.prompt,
|
|
99
|
+
"reasoning": self.reasoning.name,
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
return ctx
|
|
103
|
+
|
|
104
|
+
def _load_conversation_history(self) -> tuple[Message, ...]:
|
|
105
|
+
"""Load persisted conversation history when a store and id are configured."""
|
|
106
|
+
if self.conversation_store is None or self.conversation_id is None:
|
|
107
|
+
return ()
|
|
108
|
+
|
|
109
|
+
history = tuple(self.conversation_store.load(self.conversation_id))
|
|
110
|
+
self.events.emit(
|
|
111
|
+
"conversation.load",
|
|
112
|
+
{
|
|
113
|
+
"conversation_id": self.conversation_id,
|
|
114
|
+
"message_count": len(history),
|
|
115
|
+
},
|
|
116
|
+
)
|
|
117
|
+
return history
|
|
118
|
+
|
|
119
|
+
def _finalize_run(self, result: Result) -> Result:
|
|
120
|
+
"""Attach usage, persist conversation, and emit session.end."""
|
|
121
|
+
usage = self._env.usage() if self._env is not None else Usage()
|
|
122
|
+
result = Result(
|
|
123
|
+
answer=result.answer,
|
|
124
|
+
trajectory=result.trajectory,
|
|
125
|
+
usage=usage,
|
|
126
|
+
session_id=self.session_id,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
self._save_conversation_history(result)
|
|
130
|
+
|
|
131
|
+
self.events.emit(
|
|
132
|
+
"session.end",
|
|
133
|
+
{
|
|
134
|
+
"session_id": self.session_id,
|
|
135
|
+
"answer": result.answer,
|
|
136
|
+
"usage": {
|
|
137
|
+
"input_tokens": usage.input_tokens,
|
|
138
|
+
"output_tokens": usage.output_tokens,
|
|
139
|
+
"total_tokens": usage.total_tokens,
|
|
140
|
+
"cost_usd": usage.cost_usd,
|
|
141
|
+
"elapsed_s": usage.elapsed_s,
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
def _save_conversation_history(self, result: Result) -> None:
|
|
148
|
+
"""Persist previous history plus the current user/assistant turn."""
|
|
149
|
+
if self.conversation_store is None or self.conversation_id is None:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
previous = list(self.conversation_store.load(self.conversation_id))
|
|
153
|
+
current_turn = [
|
|
154
|
+
Message(role=Role.USER, content=self.task.prompt),
|
|
155
|
+
Message(role=Role.ASSISTANT, content=result.answer),
|
|
156
|
+
]
|
|
157
|
+
new_messages = previous + current_turn
|
|
158
|
+
self.conversation_store.save(self.conversation_id, new_messages)
|
|
159
|
+
self.events.emit(
|
|
160
|
+
"conversation.save",
|
|
161
|
+
{
|
|
162
|
+
"conversation_id": self.conversation_id,
|
|
163
|
+
"message_count": len(new_messages),
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def replay(self) -> tuple:
|
|
168
|
+
"""Return the recorded event log (AUDIT replay mode).
|
|
169
|
+
|
|
170
|
+
RESUME and RERUN replay modes are TODO (open question 3).
|
|
171
|
+
"""
|
|
172
|
+
return self.events.events
|
|
173
|
+
|
|
174
|
+
def checkpoint(self) -> None:
|
|
175
|
+
"""Emit a checkpoint event capturing current state."""
|
|
176
|
+
self.events.emit(
|
|
177
|
+
"checkpoint",
|
|
178
|
+
{
|
|
179
|
+
"session_id": self.session_id,
|
|
180
|
+
"step_count": len(self.events.events),
|
|
181
|
+
},
|
|
182
|
+
)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Structured output parsing — stdlib-only (json + dataclasses).
|
|
2
|
+
|
|
3
|
+
Thin-core extension that turns a model's JSON response into a typed dataclass
|
|
4
|
+
without pulling in Pydantic or any external dependency.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, fields
|
|
11
|
+
from typing import Any, ClassVar, Generic, Protocol, TypeVar, get_type_hints
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DataclassInstance(Protocol):
|
|
15
|
+
"""Protocol matching dataclass instances recognized by stdlib fields()."""
|
|
16
|
+
|
|
17
|
+
__dataclass_fields__: ClassVar[dict[str, Any]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T", bound=DataclassInstance)
|
|
21
|
+
|
|
22
|
+
# Match a fenced JSON code block: ```json ... ``` or ``` ... ```
|
|
23
|
+
_CODE_BLOCK_RE = re.compile(
|
|
24
|
+
r"```(?:json)?\s*(.*?)\s*```",
|
|
25
|
+
re.DOTALL | re.IGNORECASE,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# Match the first JSON object ({...}) or array ([...]) in free text.
|
|
29
|
+
_JSON_OBJECT_RE = re.compile(r"\{[\s\S]*?\}")
|
|
30
|
+
_JSON_ARRAY_RE = re.compile(r"\[[\s\S]*?\]")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class StructuredResult(Generic[T]):
|
|
35
|
+
"""The outcome of a structured agent run.
|
|
36
|
+
|
|
37
|
+
Always carries the raw answer, the parsed object (when possible), an
|
|
38
|
+
optional parse error message, and the originating session id.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
answer: str
|
|
42
|
+
data: T | None
|
|
43
|
+
parse_error: str | None
|
|
44
|
+
session_id: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def extract_json_from_content(content: str) -> str | None:
|
|
48
|
+
"""Extract a JSON string from direct JSON, fenced code blocks, or embedded text.
|
|
49
|
+
|
|
50
|
+
Returns the first plausible JSON object or array found, or None if none
|
|
51
|
+
is detected.
|
|
52
|
+
"""
|
|
53
|
+
if not content or not content.strip():
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
text = content.strip()
|
|
57
|
+
|
|
58
|
+
# 1. Direct JSON.
|
|
59
|
+
try:
|
|
60
|
+
json.loads(text)
|
|
61
|
+
return text
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
# 2. Fenced markdown code block.
|
|
66
|
+
code_match = _CODE_BLOCK_RE.search(text)
|
|
67
|
+
if code_match:
|
|
68
|
+
candidate = code_match.group(1).strip()
|
|
69
|
+
try:
|
|
70
|
+
json.loads(candidate)
|
|
71
|
+
return candidate
|
|
72
|
+
except json.JSONDecodeError:
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
# 3. Embedded object or array.
|
|
76
|
+
object_match = _JSON_OBJECT_RE.search(text)
|
|
77
|
+
if object_match:
|
|
78
|
+
candidate = object_match.group(0)
|
|
79
|
+
try:
|
|
80
|
+
json.loads(candidate)
|
|
81
|
+
return candidate
|
|
82
|
+
except json.JSONDecodeError:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
array_match = _JSON_ARRAY_RE.search(text)
|
|
86
|
+
if array_match:
|
|
87
|
+
candidate = array_match.group(0)
|
|
88
|
+
try:
|
|
89
|
+
json.loads(candidate)
|
|
90
|
+
return candidate
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_json(content: str) -> Any:
|
|
98
|
+
"""Parse JSON from model response content.
|
|
99
|
+
|
|
100
|
+
Handles direct JSON, fenced code blocks, and JSON embedded in prose.
|
|
101
|
+
Returns the parsed value (dict, list, or primitive). Raises ValueError
|
|
102
|
+
when no valid JSON can be extracted.
|
|
103
|
+
"""
|
|
104
|
+
candidate = extract_json_from_content(content)
|
|
105
|
+
if candidate is None:
|
|
106
|
+
raise ValueError("No valid JSON found in content.")
|
|
107
|
+
try:
|
|
108
|
+
return json.loads(candidate)
|
|
109
|
+
except json.JSONDecodeError as exc:
|
|
110
|
+
raise ValueError(f"Invalid JSON: {exc}") from exc
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _is_dataclass_type(output_type: type) -> bool:
|
|
114
|
+
"""True if output_type looks like a dataclass class."""
|
|
115
|
+
return isinstance(output_type, type) and hasattr(output_type, "__dataclass_fields__")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def parse_structured(content: str, output_type: type[T]) -> T:
|
|
119
|
+
"""Parse JSON from content and instantiate output_type.
|
|
120
|
+
|
|
121
|
+
output_type must be a dataclass. Unknown fields in the JSON payload are
|
|
122
|
+
ignored. Raises ValueError if the JSON is invalid or instantiation fails.
|
|
123
|
+
"""
|
|
124
|
+
if not _is_dataclass_type(output_type):
|
|
125
|
+
raise ValueError(f"output_type must be a dataclass, got {output_type!r}")
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
parsed = parse_json(content)
|
|
129
|
+
except ValueError as exc:
|
|
130
|
+
raise ValueError(f"Could not parse structured output: {exc}") from exc
|
|
131
|
+
|
|
132
|
+
if not isinstance(parsed, dict):
|
|
133
|
+
raise ValueError(f"Expected a JSON object for dataclass {output_type.__name__}, got {type(parsed).__name__}")
|
|
134
|
+
|
|
135
|
+
dataclass_fields = {f.name for f in fields(output_type)}
|
|
136
|
+
filtered = {k: v for k, v in parsed.items() if k in dataclass_fields}
|
|
137
|
+
|
|
138
|
+
# Coerce simple JSON types toward the declared field type when safe.
|
|
139
|
+
type_hints = get_type_hints(output_type)
|
|
140
|
+
coerced: dict[str, Any] = {}
|
|
141
|
+
for key, value in filtered.items():
|
|
142
|
+
field_type = type_hints.get(key)
|
|
143
|
+
try:
|
|
144
|
+
coerced[key] = _coerce_value(value, field_type)
|
|
145
|
+
except (TypeError, ValueError) as exc:
|
|
146
|
+
raise ValueError(f"Field '{key}' could not be coerced to {field_type}: {exc}") from exc
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
return output_type(**coerced)
|
|
150
|
+
except (TypeError, ValueError) as exc:
|
|
151
|
+
raise ValueError(f"Could not instantiate {output_type.__name__}: {exc}") from exc
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _coerce_value(value: Any, field_type: Any) -> Any:
|
|
155
|
+
"""Best-effort stdlib coercion for JSON primitives to common dataclass field types."""
|
|
156
|
+
if field_type is None:
|
|
157
|
+
return value
|
|
158
|
+
|
|
159
|
+
# Strip None from Optional[X] to inspect the inner type.
|
|
160
|
+
origin = getattr(field_type, "__origin__", None)
|
|
161
|
+
args = getattr(field_type, "__args__", ())
|
|
162
|
+
|
|
163
|
+
# Optional[X] == Union[X, None]
|
|
164
|
+
if origin is type | None or origin is None.__class__:
|
|
165
|
+
# typing.Optional[X]
|
|
166
|
+
non_none_args = [a for a in args if a is not type(None)]
|
|
167
|
+
if len(non_none_args) == 1:
|
|
168
|
+
field_type = non_none_args[0]
|
|
169
|
+
origin = getattr(field_type, "__origin__", None)
|
|
170
|
+
args = getattr(field_type, "__args__", ())
|
|
171
|
+
|
|
172
|
+
# Direct identity matches.
|
|
173
|
+
if field_type is Any:
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
if isinstance(value, field_type):
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
# Union types (other than Optional handled above) — try each arg.
|
|
180
|
+
if origin is type | None:
|
|
181
|
+
for candidate in args:
|
|
182
|
+
if candidate is type(None) and value is None:
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
return _coerce_value(value, candidate)
|
|
186
|
+
except (TypeError, ValueError):
|
|
187
|
+
continue
|
|
188
|
+
raise TypeError(f"Value {value!r} does not match union {field_type}")
|
|
189
|
+
|
|
190
|
+
# Common JSON-friendly scalar coercions.
|
|
191
|
+
if field_type is int:
|
|
192
|
+
return int(value)
|
|
193
|
+
if field_type is float:
|
|
194
|
+
return float(value)
|
|
195
|
+
if field_type is str:
|
|
196
|
+
return str(value)
|
|
197
|
+
if field_type is bool:
|
|
198
|
+
return bool(value)
|
|
199
|
+
|
|
200
|
+
# Generic containers — keep as parsed lists/dicts; dataclass construction
|
|
201
|
+
# will validate element types via runtime checks on its own.
|
|
202
|
+
return value
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Core types — value objects shared across the framework.
|
|
2
|
+
|
|
3
|
+
This module has ZERO dependencies on concrete implementations.
|
|
4
|
+
Everything here is pure data (dataclasses / enums) that other modules
|
|
5
|
+
build upon. Keeping it dependency-free is what makes the thin-core
|
|
6
|
+
principle (decision 5) hold.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Task & Result
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class Task:
|
|
20
|
+
"""The user's request to the agent."""
|
|
21
|
+
|
|
22
|
+
prompt: str
|
|
23
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Step:
|
|
28
|
+
"""One step in a reasoning trajectory."""
|
|
29
|
+
|
|
30
|
+
thought: str | None = None
|
|
31
|
+
tool_name: str | None = None
|
|
32
|
+
tool_args: dict[str, Any] | None = None
|
|
33
|
+
observation: str | None = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Trajectory:
|
|
38
|
+
"""The full sequence of steps an agent took."""
|
|
39
|
+
|
|
40
|
+
steps: tuple[Step, ...] = ()
|
|
41
|
+
|
|
42
|
+
def append(self, step: Step) -> "Trajectory":
|
|
43
|
+
return Trajectory(steps=self.steps + (step,))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Result:
|
|
48
|
+
"""The final output of an agent run."""
|
|
49
|
+
|
|
50
|
+
answer: str
|
|
51
|
+
trajectory: Trajectory = field(default_factory=Trajectory)
|
|
52
|
+
usage: "Usage" = field(default_factory=lambda: Usage())
|
|
53
|
+
session_id: str = ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
# Budget & Usage (reliability/cost — decision 4)
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Usage:
|
|
62
|
+
"""Accumulated resource consumption."""
|
|
63
|
+
|
|
64
|
+
input_tokens: int = 0
|
|
65
|
+
output_tokens: int = 0
|
|
66
|
+
total_tokens: int = 0
|
|
67
|
+
cost_usd: float = 0.0
|
|
68
|
+
elapsed_s: float = 0.0
|
|
69
|
+
|
|
70
|
+
def add(self, other: "Usage") -> "Usage":
|
|
71
|
+
return Usage(
|
|
72
|
+
input_tokens=self.input_tokens + other.input_tokens,
|
|
73
|
+
output_tokens=self.output_tokens + other.output_tokens,
|
|
74
|
+
total_tokens=self.total_tokens + other.total_tokens,
|
|
75
|
+
cost_usd=self.cost_usd + other.cost_usd,
|
|
76
|
+
elapsed_s=self.elapsed_s + other.elapsed_s,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class Budget:
|
|
82
|
+
"""Hard limits for a single session run.
|
|
83
|
+
|
|
84
|
+
Exceeding any limit raises BudgetExceeded (hard enforcement — decision 4).
|
|
85
|
+
A None field means unlimited for that dimension.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
max_tokens: int | None = None
|
|
89
|
+
max_cost_usd: float | None = None
|
|
90
|
+
max_steps: int | None = None
|
|
91
|
+
max_tool_calls: int | None = None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class BudgetExceeded(Exception):
|
|
95
|
+
"""Raised when a session exceeds its budget (hard enforcement)."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, dimension: str, limit: Any, actual: Any):
|
|
98
|
+
self.dimension = dimension
|
|
99
|
+
self.limit = limit
|
|
100
|
+
self.actual = actual
|
|
101
|
+
super().__init__(f"Budget exceeded: {dimension} limit={limit} actual={actual}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Messaging (model interaction)
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
class Role(Enum):
|
|
109
|
+
SYSTEM = "system"
|
|
110
|
+
USER = "user"
|
|
111
|
+
ASSISTANT = "assistant"
|
|
112
|
+
TOOL = "tool"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class Message:
|
|
117
|
+
"""A single chat message."""
|
|
118
|
+
|
|
119
|
+
role: Role
|
|
120
|
+
content: str = ""
|
|
121
|
+
tool_calls: tuple["ToolCall", ...] = ()
|
|
122
|
+
tool_call_id: str | None = None # for role=TOOL responses
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True)
|
|
126
|
+
class ToolCall:
|
|
127
|
+
"""A tool invocation requested by the model."""
|
|
128
|
+
|
|
129
|
+
id: str
|
|
130
|
+
name: str
|
|
131
|
+
arguments: dict[str, Any]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class ModelRequest:
|
|
136
|
+
"""A request to a language model."""
|
|
137
|
+
|
|
138
|
+
messages: tuple[Message, ...]
|
|
139
|
+
tools: tuple[str, ...] = () # tool names available (for function-calling)
|
|
140
|
+
tool_schemas: tuple[dict[str, Any], ...] = () # full tool definitions: {name, description, input_schema}
|
|
141
|
+
temperature: float = 0.0
|
|
142
|
+
max_tokens: int | None = None
|
|
143
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True)
|
|
147
|
+
class ModelResponse:
|
|
148
|
+
"""A response from a language model."""
|
|
149
|
+
|
|
150
|
+
content: str = ""
|
|
151
|
+
tool_calls: tuple[ToolCall, ...] = ()
|
|
152
|
+
usage: Usage = field(default_factory=Usage)
|
|
153
|
+
finish_reason: str = "stop"
|
|
154
|
+
raw: Any = None # provider-specific raw response
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Tools
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
@dataclass(frozen=True)
|
|
162
|
+
class ToolRef:
|
|
163
|
+
"""A reference to a tool by name."""
|
|
164
|
+
|
|
165
|
+
name: str
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class ToolResult:
|
|
170
|
+
"""The outcome of a tool invocation."""
|
|
171
|
+
|
|
172
|
+
value: Any = None
|
|
173
|
+
error: str | None = None
|
|
174
|
+
masked: bool = False # if MASK decision effect applied
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def is_error(self) -> bool:
|
|
178
|
+
return self.error is not None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# Retrieval
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class Snippet:
|
|
187
|
+
"""A single retrieval result."""
|
|
188
|
+
|
|
189
|
+
content: str
|
|
190
|
+
source: str = ""
|
|
191
|
+
score: float = 0.0
|
|
192
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""MCP integration module — the only module that knows the MCP wire protocol.
|
|
2
|
+
|
|
3
|
+
This package adapts MCP-shaped tools into the framework's canonical Tool
|
|
4
|
+
contract (decision 2). Core and strategies see only `Tool`; all MCP-specific
|
|
5
|
+
concepts live here.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from .client import MCPClient, MCPToolSpec, connect_stdio
|
|
10
|
+
from .server import serve_as_mcp
|
|
11
|
+
from .wrapper import MCPToolWrapper
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"MCPClient",
|
|
15
|
+
"MCPToolSpec",
|
|
16
|
+
"MCPToolWrapper",
|
|
17
|
+
"connect_stdio",
|
|
18
|
+
"serve_as_mcp",
|
|
19
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""MCP client — tool discovery and invocation.
|
|
2
|
+
|
|
3
|
+
The in-process client is constructed from ``MCPToolSpec`` dictionaries. The
|
|
4
|
+
``connect_stdio`` function builds a client from a real MCP server subprocess
|
|
5
|
+
speaking JSON-RPC over stdio.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
from .wrapper import MCPToolWrapper
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class MCPToolSpec:
|
|
17
|
+
"""Static description of an MCP tool callable from Python code.
|
|
18
|
+
|
|
19
|
+
This is the in-process equivalent of an MCP ``tools/list`` entry plus its
|
|
20
|
+
invocation handler. It is used to bootstrap ``MCPClient`` in tests and in
|
|
21
|
+
environments where the server is co-located.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
description: str
|
|
26
|
+
input_schema: dict[str, Any]
|
|
27
|
+
call_fn: Callable[[dict[str, Any]], Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class MCPClient:
|
|
31
|
+
"""Client for MCP tool discovery and invocation.
|
|
32
|
+
|
|
33
|
+
Constructed from a dictionary of tool specs. Clients created by
|
|
34
|
+
``connect_stdio`` additionally hold a reference to the live stdio transport
|
|
35
|
+
so the subprocess stays alive as long as the client is in use.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, tools: dict[str, MCPToolSpec]) -> None:
|
|
39
|
+
self._tools: dict[str, MCPToolSpec] = dict(tools)
|
|
40
|
+
|
|
41
|
+
def discover_tools(self) -> list[MCPToolWrapper]:
|
|
42
|
+
"""Return each registered MCP tool wrapped as a framework ``Tool``."""
|
|
43
|
+
return [
|
|
44
|
+
MCPToolWrapper(
|
|
45
|
+
name=spec.name,
|
|
46
|
+
description=spec.description,
|
|
47
|
+
input_schema=spec.input_schema,
|
|
48
|
+
call_fn=spec.call_fn,
|
|
49
|
+
)
|
|
50
|
+
for spec in self._tools.values()
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
def call_tool(self, name: str, args: dict[str, Any]) -> Any:
|
|
54
|
+
"""Invoke a registered tool by name and return its raw value."""
|
|
55
|
+
spec = self._tools[name]
|
|
56
|
+
return spec.call_fn(args)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def connect_stdio(
|
|
60
|
+
command: str,
|
|
61
|
+
args: list[str],
|
|
62
|
+
env: dict[str, str] | None = None,
|
|
63
|
+
) -> MCPClient:
|
|
64
|
+
"""Connect to an MCP server via stdio and discover its tools.
|
|
65
|
+
|
|
66
|
+
This function spawns ``command`` with ``args`` as a subprocess, performs
|
|
67
|
+
the MCP initialization handshake, lists the available tools, and returns an
|
|
68
|
+
``MCPClient`` populated with ``MCPToolSpec`` instances that forward calls to
|
|
69
|
+
the subprocess via JSON-RPC.
|
|
70
|
+
|
|
71
|
+
The underlying ``StdioMCPClient`` is attached to the returned
|
|
72
|
+
``MCPClient`` as ``_transport`` so it stays alive for the lifetime of the
|
|
73
|
+
client. Callers may use the returned client as a context manager to ensure
|
|
74
|
+
the subprocess is terminated on exit.
|
|
75
|
+
"""
|
|
76
|
+
from .stdio_transport import StdioMCPClient
|
|
77
|
+
|
|
78
|
+
transport = StdioMCPClient(command, args, env=env)
|
|
79
|
+
transport.initialize()
|
|
80
|
+
|
|
81
|
+
tool_defs = transport.list_tools()
|
|
82
|
+
tools: dict[str, MCPToolSpec] = {}
|
|
83
|
+
for tool_def in tool_defs:
|
|
84
|
+
name = tool_def["name"]
|
|
85
|
+
tools[name] = MCPToolSpec(
|
|
86
|
+
name=name,
|
|
87
|
+
description=tool_def.get("description", ""),
|
|
88
|
+
input_schema=tool_def.get("inputSchema", {}),
|
|
89
|
+
call_fn=lambda arguments, tool_name=name: transport.call_tool(
|
|
90
|
+
tool_name, arguments
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
client = MCPClient(tools=tools)
|
|
95
|
+
object.__setattr__(client, "_transport", transport)
|
|
96
|
+
return client
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""MCP server helper — expose framework tools as an MCP server (Phase 4)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from petfishframework.core.contracts import Tool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def serve_as_mcp(tools: list[Tool]) -> None: # noqa: ARG001
|
|
8
|
+
"""Expose framework tools as an MCP server.
|
|
9
|
+
|
|
10
|
+
STUB — Phase 4 work. This function documents the symmetrical direction:
|
|
11
|
+
just as ``mcp/`` consumes external tools into the framework's ``Tool``
|
|
12
|
+
contract, the framework can also export its own tools as an MCP server.
|
|
13
|
+
"""
|
|
14
|
+
raise NotImplementedError("MCP server mode is Phase 4.")
|