pi-python-core 0.8.2__py3-none-any.whl → 0.10.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.
- pi_python/__init__.py +7 -1
- pi_python/_mcp_host.py +221 -0
- pi_python/_mcp_interaction.py +1000 -0
- pi_python/_mcp_process.py +183 -0
- pi_python/_mcp_sampling.py +280 -0
- pi_python/_version.py +1 -1
- pi_python/agent.py +19 -0
- pi_python/features.py +45 -0
- pi_python/function_tools.py +134 -99
- pi_python/loop.py +14 -11
- pi_python/mcp.py +227 -22
- pi_python/plugins/__init__.py +1319 -0
- pi_python/plugins/_compose.py +270 -0
- pi_python/plugins/_frontmatter.py +444 -0
- pi_python/plugins/_resources.py +385 -0
- pi_python/plugins/_subagents.py +259 -0
- pi_python/providers/anthropic.py +3 -1
- pi_python/providers/completions.py +34 -9
- pi_python/providers/oauth.py +3 -10
- pi_python/providers/openai.py +3 -2
- pi_python/providers/transport.py +228 -126
- pi_python/run.py +28 -2
- pi_python/sync.py +73 -1
- pi_python/tasks.py +134 -0
- pi_python/tools.py +81 -16
- {pi_python_core-0.8.2.dist-info → pi_python_core-0.10.0.dist-info}/METADATA +18 -7
- pi_python_core-0.10.0.dist-info/RECORD +50 -0
- pi_python_core-0.8.2.dist-info/RECORD +0 -39
- {pi_python_core-0.8.2.dist-info → pi_python_core-0.10.0.dist-info}/WHEEL +0 -0
- {pi_python_core-0.8.2.dist-info → pi_python_core-0.10.0.dist-info}/licenses/LICENSE +0 -0
- {pi_python_core-0.8.2.dist-info → pi_python_core-0.10.0.dist-info}/licenses/NOTICE +0 -0
pi_python/__init__.py
CHANGED
|
@@ -53,7 +53,9 @@ from .lowlevel import (
|
|
|
53
53
|
from .proxy import ProxyProvider, stream_proxy
|
|
54
54
|
from .testing import ScriptedProvider
|
|
55
55
|
from .function_tools import tool
|
|
56
|
-
from .sync import run_sync
|
|
56
|
+
from .sync import LoopPortal, run_sync
|
|
57
|
+
from .tasks import TaskScope
|
|
58
|
+
from .features import FEATURES, require_features
|
|
57
59
|
from .recovery import (
|
|
58
60
|
is_context_overflow,
|
|
59
61
|
is_recoverable_length,
|
|
@@ -141,6 +143,10 @@ __all__ = [
|
|
|
141
143
|
"Tool",
|
|
142
144
|
"tool",
|
|
143
145
|
"run_sync",
|
|
146
|
+
"LoopPortal",
|
|
147
|
+
"TaskScope",
|
|
148
|
+
"FEATURES",
|
|
149
|
+
"require_features",
|
|
144
150
|
"is_context_overflow",
|
|
145
151
|
"is_recoverable_length",
|
|
146
152
|
"is_retryable_error",
|
pi_python/_mcp_host.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Host-only sampling policy and scoped vendor replay state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from copy import deepcopy
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
import os
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Literal, Mapping
|
|
9
|
+
from uuid import uuid4
|
|
10
|
+
|
|
11
|
+
from .errors import ConfigurationError, UnsupportedCapabilityError
|
|
12
|
+
from .messages import AssistantMessage, TextContent, ThinkingContent, ToolCall
|
|
13
|
+
from .models import ModelInfo
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ._mcp_interaction import MCPRequestContext
|
|
17
|
+
|
|
18
|
+
# A versioned, explicitly negotiated extension, not an MCP protocol capability.
|
|
19
|
+
SAMPLING_EXTENSION = "io.pi-python/sampling-v1"
|
|
20
|
+
SAMPLING_DELTA_EXTENSION = "io.pi-python/sampling-delta-v1"
|
|
21
|
+
MCP_FEATURES = frozenset(
|
|
22
|
+
{
|
|
23
|
+
"sampling-host-state-v1",
|
|
24
|
+
"sampling-profiles-v1",
|
|
25
|
+
"sampling-metering-v1",
|
|
26
|
+
"sampling-retry-v1",
|
|
27
|
+
"sampling-delta-v1",
|
|
28
|
+
}
|
|
29
|
+
| ({"stdio-process-scope-v1"} if os.name == "posix" else set())
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class SamplingProfile:
|
|
35
|
+
"""A named host-approved model configuration. It never crosses the wire."""
|
|
36
|
+
|
|
37
|
+
model: str | ModelInfo
|
|
38
|
+
options: Mapping[str, Any] = field(default_factory=dict)
|
|
39
|
+
tool_choice_format: Literal["openai", "anthropic"] = "openai"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class SamplingRetryPolicy:
|
|
44
|
+
"""Retry model requests only; the callback timeout also bounds all backoff."""
|
|
45
|
+
|
|
46
|
+
max_attempts: int = 1
|
|
47
|
+
initial_delay: float = 0.5
|
|
48
|
+
max_delay: float = 30
|
|
49
|
+
|
|
50
|
+
def __post_init__(self) -> None:
|
|
51
|
+
import math
|
|
52
|
+
|
|
53
|
+
if type(self.max_attempts) is not int or self.max_attempts < 1:
|
|
54
|
+
raise ConfigurationError("Sampling max_attempts must be positive")
|
|
55
|
+
for value in (self.initial_delay, self.max_delay):
|
|
56
|
+
if (
|
|
57
|
+
isinstance(value, bool)
|
|
58
|
+
or not isinstance(value, (float, int))
|
|
59
|
+
or not math.isfinite(value)
|
|
60
|
+
or value < 0
|
|
61
|
+
):
|
|
62
|
+
raise ConfigurationError("Sampling retry delays must be finite and nonnegative")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class SamplingObservation:
|
|
67
|
+
"""One completed Provider attempt; unknown usage is None, never synthesized zero.
|
|
68
|
+
|
|
69
|
+
No prompt, response text, credential or raw exception is included. Observers
|
|
70
|
+
run before retry/conversion and must not raise; an observer failure stops the
|
|
71
|
+
callback without retrying an already completed model response.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
context: MCPRequestContext
|
|
75
|
+
profile: str
|
|
76
|
+
attempt: int
|
|
77
|
+
status: str
|
|
78
|
+
usage: Mapping[str, Any] | None
|
|
79
|
+
response_id: str | None = None
|
|
80
|
+
category: str | None = None
|
|
81
|
+
retry_after: float | None = None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class SamplingFailure(Exception):
|
|
85
|
+
"""Sanitized model failure. Retry is owned by the host, not the business tool."""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
category: str = "provider",
|
|
90
|
+
*,
|
|
91
|
+
retryable: bool = False,
|
|
92
|
+
retry_after: float | None = None,
|
|
93
|
+
attempts: int = 1,
|
|
94
|
+
) -> None:
|
|
95
|
+
super().__init__(f"Sampling model request failed ({category})")
|
|
96
|
+
self.category, self.retryable = category, retryable
|
|
97
|
+
self.retry_after, self.attempts = retry_after, attempts
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _json_equal(left: Any, right: Any) -> bool:
|
|
101
|
+
"""Conservative wire equality: unlike Python ==, bool is not int (nor float)."""
|
|
102
|
+
if type(left) is not type(right):
|
|
103
|
+
return False
|
|
104
|
+
if isinstance(left, dict):
|
|
105
|
+
return left.keys() == right.keys() and all(
|
|
106
|
+
_json_equal(value, right[key]) for key, value in left.items()
|
|
107
|
+
)
|
|
108
|
+
if isinstance(left, list):
|
|
109
|
+
return len(left) == len(right) and all(_json_equal(a, b) for a, b in zip(left, right))
|
|
110
|
+
return bool(left == right)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def visible_message(message: AssistantMessage) -> AssistantMessage:
|
|
114
|
+
"""Projection only: the unmodified original MUST remain in the host state store."""
|
|
115
|
+
if message.deferred is not None:
|
|
116
|
+
raise UnsupportedCapabilityError("Sampling does not support deferred responses")
|
|
117
|
+
# Build just the wire projection. Never clone private thinking/diagnostics
|
|
118
|
+
# merely to throw them away. Mutable tool arguments still get their own copy.
|
|
119
|
+
blocks: list[TextContent | ThinkingContent | ToolCall] = []
|
|
120
|
+
for block in message.content:
|
|
121
|
+
if isinstance(block, ThinkingContent):
|
|
122
|
+
continue
|
|
123
|
+
if isinstance(block, TextContent):
|
|
124
|
+
blocks.append(TextContent(block.text))
|
|
125
|
+
elif isinstance(block, ToolCall):
|
|
126
|
+
if block.namespace is not None:
|
|
127
|
+
raise UnsupportedCapabilityError("Sampling does not support namespaced tools")
|
|
128
|
+
blocks.append(ToolCall(block.id, block.name, deepcopy(block.arguments)))
|
|
129
|
+
else:
|
|
130
|
+
raise UnsupportedCapabilityError("Unsupported sampling response content")
|
|
131
|
+
return AssistantMessage(
|
|
132
|
+
blocks or [TextContent("")],
|
|
133
|
+
stop_reason=message.stop_reason,
|
|
134
|
+
provider=message.provider,
|
|
135
|
+
model=message.model,
|
|
136
|
+
error=message.error,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _SamplingState:
|
|
141
|
+
"""Owned by one outer tool call, never by a process or shared Provider."""
|
|
142
|
+
|
|
143
|
+
def __init__(self) -> None:
|
|
144
|
+
self.responses: dict[
|
|
145
|
+
str, tuple[str, str, AssistantMessage, list[TextContent | ThinkingContent | ToolCall]]
|
|
146
|
+
] = {}
|
|
147
|
+
# These are owned wire projections, not ModelRequests. Prefix blocks and
|
|
148
|
+
# static fields may be shared internally; from_sampling copies mutable
|
|
149
|
+
# fields before any policy hook or Provider sees them.
|
|
150
|
+
self.requests: dict[str, tuple[str, str, dict[str, Any]]] = {}
|
|
151
|
+
|
|
152
|
+
def save(
|
|
153
|
+
self,
|
|
154
|
+
conversation: str,
|
|
155
|
+
profile: str,
|
|
156
|
+
message: AssistantMessage,
|
|
157
|
+
visible: AssistantMessage | None = None,
|
|
158
|
+
) -> str:
|
|
159
|
+
ref = uuid4().hex
|
|
160
|
+
self.responses[ref] = (
|
|
161
|
+
conversation,
|
|
162
|
+
profile,
|
|
163
|
+
deepcopy(message),
|
|
164
|
+
deepcopy((visible if visible is not None else visible_message(message)).content),
|
|
165
|
+
)
|
|
166
|
+
return ref
|
|
167
|
+
|
|
168
|
+
def restore(
|
|
169
|
+
self, ref: str, conversation: str, profile: str, visible: AssistantMessage
|
|
170
|
+
) -> AssistantMessage:
|
|
171
|
+
stored = self.responses.get(ref)
|
|
172
|
+
if stored is None or stored[:2] != (conversation, profile):
|
|
173
|
+
raise UnsupportedCapabilityError("Expired or foreign sampling state reference")
|
|
174
|
+
original = stored[2]
|
|
175
|
+
if not _json_equal([vars(b) for b in stored[3]], [vars(b) for b in visible.content]):
|
|
176
|
+
raise UnsupportedCapabilityError("Sampling history changed a retained response")
|
|
177
|
+
return deepcopy(original)
|
|
178
|
+
|
|
179
|
+
def expand_request(
|
|
180
|
+
self, data: dict[str, Any], delta: Any, conversation: str, profile: str
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
if not isinstance(delta, dict):
|
|
183
|
+
raise UnsupportedCapabilityError("Invalid sampling delta")
|
|
184
|
+
if not delta: # Prime a base with a complete request.
|
|
185
|
+
return data
|
|
186
|
+
if set(delta) != {"base", "prefix", "reuse"} or not isinstance(delta["base"], str):
|
|
187
|
+
raise UnsupportedCapabilityError("Invalid sampling delta")
|
|
188
|
+
stored = self.requests.get(delta["base"])
|
|
189
|
+
if stored is None or stored[:2] != (conversation, profile):
|
|
190
|
+
raise UnsupportedCapabilityError("Expired or foreign sampling delta reference")
|
|
191
|
+
base = stored[2]
|
|
192
|
+
prefix, reuse = delta["prefix"], delta["reuse"]
|
|
193
|
+
if type(prefix) is not int or not 0 <= prefix <= len(base["messages"]):
|
|
194
|
+
raise UnsupportedCapabilityError("Invalid sampling delta prefix")
|
|
195
|
+
if (
|
|
196
|
+
not isinstance(reuse, list)
|
|
197
|
+
or any(
|
|
198
|
+
not isinstance(key, str) or key not in {"systemPrompt", "tools"} for key in reuse
|
|
199
|
+
)
|
|
200
|
+
or len(set(reuse)) != len(reuse)
|
|
201
|
+
):
|
|
202
|
+
raise UnsupportedCapabilityError("Invalid sampling delta fields")
|
|
203
|
+
expanded = {**data, "messages": base["messages"][:prefix] + data["messages"]}
|
|
204
|
+
for key in reuse:
|
|
205
|
+
if key in data or key not in base:
|
|
206
|
+
raise UnsupportedCapabilityError("Conflicting sampling delta field")
|
|
207
|
+
expanded[key] = base[key]
|
|
208
|
+
return expanded
|
|
209
|
+
|
|
210
|
+
def save_request(self, ref: str, conversation: str, profile: str, data: dict[str, Any]) -> None:
|
|
211
|
+
# Take ownership of internal data from params.model_dump / expand_request.
|
|
212
|
+
# No caller/Provider has references to this projection's mutable values.
|
|
213
|
+
self.requests[ref] = (
|
|
214
|
+
conversation,
|
|
215
|
+
profile,
|
|
216
|
+
{key: data[key] for key in ("messages", "systemPrompt", "tools") if key in data},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def clear(self) -> None:
|
|
220
|
+
self.responses.clear()
|
|
221
|
+
self.requests.clear()
|