pi-python-core 0.8.1__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/errors.py ADDED
@@ -0,0 +1,49 @@
1
+ """Stable exception classes; exceptions never imply that side effects were rolled back."""
2
+
3
+
4
+ class PiError(Exception):
5
+ pass
6
+
7
+
8
+ class ConfigurationError(PiError):
9
+ pass
10
+
11
+
12
+ class MessageValidationError(PiError):
13
+ pass
14
+
15
+
16
+ class UnsupportedCapabilityError(MessageValidationError):
17
+ pass
18
+
19
+
20
+ class ProviderProtocolError(PiError):
21
+ pass
22
+
23
+
24
+ class AgentBusyError(PiError):
25
+ pass
26
+
27
+
28
+ class AgentClosedError(PiError):
29
+ pass
30
+
31
+
32
+ class InvalidContinuationError(PiError):
33
+ pass
34
+
35
+
36
+ class ToolOutcomeUnknownError(PiError):
37
+ pass
38
+
39
+
40
+ class CleanupTimeoutError(PiError):
41
+ pass
42
+
43
+
44
+ class SubscriptionError(PiError):
45
+ pass
46
+
47
+
48
+ class CandidateValidationError(PiError):
49
+ pass
pi_python/estimate.py ADDED
@@ -0,0 +1,144 @@
1
+ """Context-size estimate ported from Pi a13d35a utils/estimate.ts (MIT); see NOTICE.
2
+
3
+ A character heuristic, not a tokenizer: four UTF-16 code units per token and a fixed
4
+ charge per image. It sizes output limits and compaction decisions; the service still
5
+ enforces the real context limit.
6
+ """
7
+
8
+ from __future__ import annotations
9
+ from typing import Any
10
+ import json
11
+ import math
12
+ from .messages import (
13
+ AssistantMessage,
14
+ ImageContent,
15
+ Message,
16
+ SystemMessage,
17
+ TextContent,
18
+ ThinkingContent,
19
+ ToolResultMessage,
20
+ UserMessage,
21
+ )
22
+ from .transcript import system_message_text
23
+
24
+ _CHARS_PER_TOKEN = 4
25
+ _IMAGE_CHARS = 4800
26
+ _SAFETY_TOKENS = 4096
27
+
28
+
29
+ def _length(text: str) -> int:
30
+ """JavaScript string length: UTF-16 code units."""
31
+ return len(text.encode("utf-16-le", "surrogatepass")) // 2
32
+
33
+
34
+ def _json(value: Any) -> str:
35
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
36
+
37
+
38
+ def _tokens(chars: int) -> int:
39
+ return math.ceil(chars / _CHARS_PER_TOKEN)
40
+
41
+
42
+ def _content_tokens(content: str | list[TextContent | ImageContent]) -> int:
43
+ if isinstance(content, str):
44
+ return _tokens(_length(content))
45
+ return _tokens(
46
+ sum(_length(b.text) if isinstance(b, TextContent) else _IMAGE_CHARS for b in content)
47
+ )
48
+
49
+
50
+ def estimate_message_tokens(message: Message) -> int:
51
+ if isinstance(message, SystemMessage):
52
+ tokens = _tokens(_length(system_message_text(message)))
53
+ if message.tools_added:
54
+ tools = [
55
+ {"name": t.name, "description": t.description, "parameters": t.input_schema}
56
+ for t in message.tools_added
57
+ ]
58
+ tokens += _tokens(_length(_json(tools)))
59
+ if message.tools_removed:
60
+ tokens += _tokens(_length(_json([{"name": n} for n in message.tools_removed])))
61
+ return tokens
62
+ if isinstance(message, (UserMessage, ToolResultMessage)):
63
+ return _content_tokens(message.content)
64
+ if isinstance(message, AssistantMessage):
65
+ chars = 0
66
+ for block in message.content:
67
+ if isinstance(block, TextContent):
68
+ chars += _length(block.text)
69
+ elif isinstance(block, ThinkingContent):
70
+ chars += _length(block.thinking)
71
+ elif not isinstance(block, ImageContent):
72
+ chars += _length(block.name) + _length(_json(block.arguments))
73
+ return _tokens(chars)
74
+ return 0
75
+
76
+
77
+ def _usage_tokens(usage: dict) -> int:
78
+ total = usage.get("total_tokens") or 0
79
+ return total or sum(
80
+ usage.get(k, 0) or 0 for k in ("input", "output", "cache_read", "cache_write")
81
+ )
82
+
83
+
84
+ def estimate_context_tokens(messages: list[Message]) -> int:
85
+ """Last valid reported usage plus an estimate of everything after it."""
86
+ latest = -math.inf
87
+ anchor = None
88
+ for index, message in enumerate(messages):
89
+ if isinstance(message, AssistantMessage):
90
+ # A newer prefix message inserted after this response invalidates its usage.
91
+ if (
92
+ message.timestamp >= latest
93
+ and message.stop_reason not in {"aborted", "error"}
94
+ and _usage_tokens(message.usage) > 0
95
+ ):
96
+ anchor = index
97
+ latest = max(latest, message.timestamp)
98
+ if anchor is None:
99
+ return sum(estimate_message_tokens(m) for m in messages)
100
+ message = messages[anchor]
101
+ assert isinstance(message, AssistantMessage)
102
+ return _usage_tokens(message.usage) + sum(
103
+ estimate_message_tokens(m) for m in messages[anchor + 1 :]
104
+ )
105
+
106
+
107
+ def clamp_max_tokens_to_context(
108
+ context_window: int, messages: list[Message], max_tokens: int
109
+ ) -> int:
110
+ """Pi clampMaxTokensToContext: leave room for the estimated prompt and a margin."""
111
+ if context_window <= 0:
112
+ return max(1, max_tokens)
113
+ available = context_window - estimate_context_tokens(messages) - _SAFETY_TOKENS
114
+ return min(max_tokens, max(1, available))
115
+
116
+
117
+ def short_hash(text: str) -> str:
118
+ """Pi utils/hash.ts shortHash, bit for bit (32-bit Math.imul over UTF-16 units)."""
119
+
120
+ def imul(a: int, b: int) -> int:
121
+ result = (a * b) & 0xFFFFFFFF
122
+ return result - 0x100000000 if result & 0x80000000 else result
123
+
124
+ def u32(value: int) -> int:
125
+ return value & 0xFFFFFFFF
126
+
127
+ def base36(value: int) -> str:
128
+ digits = "0123456789abcdefghijklmnopqrstuvwxyz"
129
+ out = ""
130
+ while True:
131
+ value, rest = divmod(value, 36)
132
+ out = digits[rest] + out
133
+ if not value:
134
+ return out
135
+
136
+ h1, h2 = 0xDEADBEEF, 0x41C6CE57
137
+ units = text.encode("utf-16-le", "surrogatepass")
138
+ for i in range(0, len(units), 2):
139
+ ch = units[i] | units[i + 1] << 8
140
+ h1 = imul(u32(h1) ^ ch, 2654435761)
141
+ h2 = imul(u32(h2) ^ ch, 1597334677)
142
+ h1 = imul(u32(h1) ^ (u32(h1) >> 16), 2246822507) ^ imul(u32(h2) ^ (u32(h2) >> 13), 3266489909)
143
+ h2 = imul(u32(h2) ^ (u32(h2) >> 16), 2246822507) ^ imul(u32(h1) ^ (u32(h1) >> 13), 3266489909)
144
+ return base36(u32(h2)) + base36(u32(h1))
pi_python/events.py ADDED
@@ -0,0 +1,138 @@
1
+ """Sequential critical delivery, detached payloads and independent failure diagnostics."""
2
+
3
+ from __future__ import annotations
4
+ import asyncio
5
+ import inspect
6
+ import json
7
+ from collections.abc import Awaitable, Callable
8
+ from copy import deepcopy
9
+ from dataclasses import asdict, dataclass, field
10
+ from typing import Any
11
+ from .errors import MessageValidationError, SubscriptionError
12
+ from .messages import validate_json
13
+
14
+
15
+ @dataclass
16
+ class Event:
17
+ type: str
18
+ run_id: str
19
+ turn_id: int
20
+ sequence: int
21
+ call_id: str | None = None
22
+ data: dict[str, Any] = field(default_factory=dict)
23
+ schema_version: int = 1
24
+
25
+
26
+ EventListener = Callable[[Event], Awaitable[None] | None]
27
+ Unsubscribe = Callable[[], None]
28
+
29
+
30
+ def encode_event(event: Event) -> str:
31
+ data = asdict(event)
32
+ validate_json(data)
33
+ decode_event(json.dumps(data))
34
+ return json.dumps(data, ensure_ascii=False, allow_nan=False)
35
+
36
+
37
+ def decode_event(value: str) -> Event:
38
+ try:
39
+ d = json.loads(value)
40
+ validate_json(d)
41
+ if type(d.get("schema_version")) is not int or d["schema_version"] != 1:
42
+ raise MessageValidationError("Unsupported event schema version")
43
+ e = Event(**d)
44
+ if (
45
+ not isinstance(e.type, str)
46
+ or not isinstance(e.run_id, str)
47
+ or not isinstance(e.data, dict)
48
+ ):
49
+ raise MessageValidationError("Invalid event")
50
+ if (
51
+ type(e.sequence) is not int
52
+ or e.sequence < 1
53
+ or type(e.turn_id) is not int
54
+ or e.turn_id < 0
55
+ ):
56
+ raise MessageValidationError("Invalid event sequence or turn")
57
+ if e.call_id is not None and not isinstance(e.call_id, str):
58
+ raise MessageValidationError("Invalid event call ID")
59
+ return e
60
+ except (ValueError, TypeError, AttributeError) as exc:
61
+ raise MessageValidationError(str(exc)) from exc
62
+
63
+
64
+ class EventDispatcher:
65
+ def __init__(self) -> None:
66
+ self.listeners: list[EventListener] = []
67
+ self.diagnostics: list[dict[str, Any]] = []
68
+ self.failed = False
69
+ self.sequence = 0
70
+ self.lock = asyncio.Lock()
71
+
72
+ def subscribe(self, listener: EventListener) -> Unsubscribe:
73
+ self.listeners.append(listener)
74
+ active = True
75
+
76
+ def unsubscribe() -> None:
77
+ nonlocal active
78
+ if active:
79
+ active = False
80
+ self.listeners.remove(listener)
81
+
82
+ return unsubscribe
83
+
84
+ async def emit(
85
+ self,
86
+ kind: str,
87
+ run_id: str,
88
+ turn_id: int,
89
+ data: dict[str, Any],
90
+ call_id: str | None,
91
+ await_owned: Callable,
92
+ ) -> None:
93
+ if self.failed:
94
+ return
95
+ async with self.lock:
96
+ if self.failed:
97
+ return
98
+ self.sequence += 1
99
+ event = Event(kind, run_id, turn_id, self.sequence, call_id, deepcopy(data))
100
+ encode_event(event)
101
+ listeners = list(self.listeners)
102
+ for index, listener in enumerate(listeners):
103
+ try:
104
+ result = listener(deepcopy(event))
105
+ if inspect.isawaitable(result):
106
+ await await_owned(result)
107
+ except Exception as exc:
108
+ self.failed = True
109
+ self.diagnostics.append(
110
+ {
111
+ "event": kind,
112
+ "sequence": event.sequence,
113
+ "listener_index": index,
114
+ "unhandled_listener_indices": list(range(index + 1, len(listeners))),
115
+ "error": f"{type(exc).__name__}: {exc}",
116
+ }
117
+ )
118
+ raise SubscriptionError(f"{kind} subscriber {index} failed: {exc}") from exc
119
+
120
+
121
+ class EventQueue:
122
+ """A bounded display adapter. Only message deltas may be dropped, explicitly counted."""
123
+
124
+ def __init__(self, maxsize: int = 128, *, drop_text_updates: bool = False):
125
+ if maxsize <= 0:
126
+ raise ValueError("maxsize must be positive")
127
+ self.queue: asyncio.Queue[Event] = asyncio.Queue(maxsize)
128
+ self.drop_text_updates = drop_text_updates
129
+ self.dropped_updates = 0
130
+
131
+ async def __call__(self, event: Event) -> None:
132
+ if self.drop_text_updates and event.type == "message_update" and self.queue.full():
133
+ self.dropped_updates += 1
134
+ return
135
+ await self.queue.put(deepcopy(event))
136
+
137
+ async def get(self) -> Event:
138
+ return await self.queue.get()