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