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,190 @@
1
+ """Core contracts — protocols that define the framework's seams.
2
+
3
+ Every concrete implementation (reasoning strategies, model adapters,
4
+ tools, MCP bridges) depends on these protocols, never the reverse.
5
+ This is the Ports & Adapters boundary (decision 5).
6
+
7
+ Dependencies: core/types.py only. No concrete imports.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from enum import Enum
13
+ from typing import Any, Protocol, runtime_checkable
14
+
15
+ from .types import (
16
+ Budget,
17
+ Message,
18
+ ModelRequest,
19
+ ModelResponse,
20
+ Result,
21
+ Snippet,
22
+ Task,
23
+ ToolRef,
24
+ ToolResult,
25
+ )
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Shared enums (used by Tool definitions and permission decisions)
29
+ # ---------------------------------------------------------------------------
30
+
31
+ class RiskLevel(Enum):
32
+ """Risk classification for tools and actions (from agentShield-dev)."""
33
+
34
+ LOW = "low"
35
+ MEDIUM = "medium"
36
+ HIGH = "high"
37
+ CRITICAL = "critical"
38
+
39
+
40
+ class Clearance(Enum):
41
+ """Data classification levels (from agentShield-dev SARC model)."""
42
+
43
+ PUBLIC = "public"
44
+ INTERNAL = "internal"
45
+ CONFIDENTIAL = "confidential"
46
+ SECRET = "secret"
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Tool contract (decision 2 — MCP-shaped, single tool interface)
51
+ # ---------------------------------------------------------------------------
52
+
53
+ @runtime_checkable
54
+ class Tool(Protocol):
55
+ """The single tool contract. MCP-shaped (decision 2).
56
+
57
+ Native Python tools are wrapped to satisfy this; external MCP servers
58
+ are consumed natively. No 'native vs MCP' duality.
59
+ """
60
+
61
+ name: str
62
+ description: str
63
+ input_schema: dict[str, Any]
64
+ risk_level: RiskLevel
65
+ capabilities: tuple[str, ...] # e.g. ("network", "fs:write", "mcp:sampling")
66
+
67
+ def execute(self, args: dict[str, Any]) -> ToolResult:
68
+ """Execute the tool with validated arguments. Returns result or error."""
69
+ ...
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # Model adapter contract
74
+ # ---------------------------------------------------------------------------
75
+
76
+ @runtime_checkable
77
+ class ModelAdapter(Protocol):
78
+ """Abstracts LLM providers. Implementations live in models/."""
79
+
80
+ name: str
81
+
82
+ def query(self, request: ModelRequest) -> ModelResponse:
83
+ """Send a request to the model, get a response."""
84
+ ...
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # Retriever contract (decision 3 — Environment primitive, NOT an MCP tool)
89
+ # ---------------------------------------------------------------------------
90
+
91
+ @runtime_checkable
92
+ class Retriever(Protocol):
93
+ """Knowledge retrieval. Produces EvidenceBundle content (decision 3, Q1 resolved).
94
+
95
+ This is an Environment primitive, not a tool — confirmed by open question 1.
96
+ """
97
+
98
+ def retrieve(self, query: str, top_k: int = 5) -> list[Snippet]:
99
+ ...
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # Memory contract (decision 3 — Environment primitive, three tiers)
104
+ # ---------------------------------------------------------------------------
105
+
106
+ @dataclass(frozen=True)
107
+ class MemoryView:
108
+ """Read-only view into memory tiers.
109
+
110
+ Working memory: per-step (strategy-managed).
111
+ Episodic memory: per-task (Session-persisted, Reflexion uses this).
112
+ Long-term memory: cross-session (retriever-like capability).
113
+
114
+ Skeleton: empty stub. Concrete memory backends implement this later.
115
+ """
116
+
117
+ working: dict[str, Any] = field(default_factory=dict)
118
+ episodic: tuple[dict[str, Any], ...] = ()
119
+
120
+
121
+ # ---------------------------------------------------------------------------
122
+ # Environment contract — THE chokepoint (decision 3)
123
+ # ---------------------------------------------------------------------------
124
+
125
+ @runtime_checkable
126
+ class Environment(Protocol):
127
+ """The single capability surface strategies may access (decision 3).
128
+
129
+ All tool calls, retrieval, and model queries MUST flow through here.
130
+ This is where permissions (decision 4, two-gate model), cost accounting
131
+ (decision 4, CostAccountant), and audit (decision 4, EventEmitter) enforce.
132
+ No strategy may bypass this chokepoint.
133
+ """
134
+
135
+ def tools(self) -> list[Tool]:
136
+ """Return visible tools (after visibility gate — CapabilityProjection)."""
137
+ ...
138
+
139
+ def call(self, ref: ToolRef, args: dict[str, Any]) -> ToolResult:
140
+ """Invoke a tool. Passes through invocation gate (authorize→execute→sanitize)."""
141
+ ...
142
+
143
+ def retrieve(self, query: str, top_k: int = 5) -> list[Snippet]:
144
+ """Retrieve knowledge snippets (produces EvidenceBundle content)."""
145
+ ...
146
+
147
+ def query_model(self, request: ModelRequest) -> ModelResponse:
148
+ """Query the LLM. Used by strategies for reasoning, value functions, evaluators."""
149
+ ...
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Reasoning strategy contract (decision 3 — pluggable, single interface)
154
+ # ---------------------------------------------------------------------------
155
+
156
+ @runtime_checkable
157
+ class ReasoningStrategy(Protocol):
158
+ """Pluggable reasoning. Standardized I/O, not standardized algorithm.
159
+
160
+ Strategies differ in HOW they search (react loop / tree search / MCTS /
161
+ symbolic planning) but share the same consumption surface (Environment).
162
+ """
163
+
164
+ name: str
165
+
166
+ def run(self, ctx: "RunContext") -> Result:
167
+ """Execute the reasoning strategy within the given context."""
168
+ ...
169
+
170
+
171
+ # ---------------------------------------------------------------------------
172
+ # RunContext — what a strategy receives
173
+ # ---------------------------------------------------------------------------
174
+
175
+ @dataclass(frozen=True)
176
+ class RunContext:
177
+ """Everything a ReasoningStrategy needs to execute.
178
+
179
+ Built by Session before calling strategy.run(). The Environment is the
180
+ ONLY way to access capabilities (chokepoint). Budget is enforced inside
181
+ Environment, not by the strategy.
182
+ """
183
+
184
+ task: Task
185
+ env: Environment
186
+ budget: Budget
187
+ memory: MemoryView
188
+ events: Any # EventEmitter (typed in events.py; Any here to avoid cycle)
189
+ compiled: Any = None # CompiledContext (v0.2 contract compilation layer)
190
+ conversation_history: tuple[Message, ...] = () # cross-session memory
@@ -0,0 +1,51 @@
1
+ """Conversation memory store.
2
+
3
+ Provides a protocol for loading/saving chat history by conversation_id and a
4
+ reference in-memory implementation. Conversation history is stored as full
5
+ Message objects so downstream strategies can reconstruct the exact model input.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Protocol
11
+
12
+ from .types import Message
13
+
14
+
15
+ class ConversationStore(Protocol):
16
+ """Persistent storage for cross-session conversation history.
17
+
18
+ Implementations are expected to be stateful but need not survive process
19
+ restart (file-based / database backends implement that later).
20
+ """
21
+
22
+ def load(self, conversation_id: str) -> list[Message]:
23
+ """Return the conversation messages for *conversation_id* or an empty list."""
24
+ ...
25
+
26
+ def save(self, conversation_id: str, messages: list[Message]) -> None:
27
+ """Replace the stored conversation messages for *conversation_id*."""
28
+ ...
29
+
30
+
31
+ @dataclass
32
+ class InMemoryConversationStore:
33
+ """Volatile in-memory conversation store for testing and single-process use."""
34
+
35
+ _conversations: dict[str, list[Message]] = field(default_factory=dict)
36
+
37
+ def load(self, conversation_id: str) -> list[Message]:
38
+ """Return a shallow copy of the stored messages so callers can mutate safely."""
39
+ return list(self._conversations.get(conversation_id, []))
40
+
41
+ def save(self, conversation_id: str, messages: list[Message]) -> None:
42
+ """Replace the conversation entirely."""
43
+ self._conversations[conversation_id] = list(messages)
44
+
45
+ def clear(self, conversation_id: str) -> None:
46
+ """Delete a single conversation if it exists."""
47
+ self._conversations.pop(conversation_id, None)
48
+
49
+ def clear_all(self) -> None:
50
+ """Delete every stored conversation."""
51
+ self._conversations.clear()
@@ -0,0 +1,260 @@
1
+ """RuntimeEnvironment — the single capability chokepoint (decision 3).
2
+
3
+ All tool calls, model queries, and retrieval pass through here. It hosts the
4
+ permission gate (SARC), cost accounting (Budget), and audit events.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import asyncio
9
+ from dataclasses import dataclass
10
+ from typing import Any
11
+
12
+ from petfishframework.permissions.model import (
13
+ AccessContext,
14
+ Action,
15
+ DecisionEffect,
16
+ PermissionPolicy,
17
+ Resource,
18
+ Subject,
19
+ )
20
+ from petfishframework.reliability.cost import CostAccountant
21
+
22
+ from .contracts import Environment, ModelAdapter, Retriever, Tool
23
+ from .events import EventEmitter
24
+ from .types import (
25
+ Budget,
26
+ ModelRequest,
27
+ ModelResponse,
28
+ Snippet,
29
+ ToolRef,
30
+ ToolResult,
31
+ Usage,
32
+ )
33
+
34
+
35
+ @dataclass
36
+ class RuntimeEnvironment(Environment):
37
+ """Concrete Environment enforcing permissions, budget, and audit."""
38
+
39
+ model: ModelAdapter
40
+ _tools: tuple[Tool, ...]
41
+ retriever: Retriever | None
42
+ budget: Budget
43
+ events: EventEmitter
44
+ policy: PermissionPolicy
45
+ session_id: str = ""
46
+ _accountant: CostAccountant | None = None
47
+
48
+ def __post_init__(self) -> None:
49
+ if self._accountant is None:
50
+ self._accountant = CostAccountant()
51
+
52
+ @property
53
+ def _costs(self) -> CostAccountant:
54
+ """Return the cost accountant, initializing if needed."""
55
+ if self._accountant is None:
56
+ self._accountant = CostAccountant()
57
+ return self._accountant
58
+
59
+ def tools(self) -> list[Tool]:
60
+ """Return visible tools (skeleton: all tools; visibility gate TODO)."""
61
+ return list(self._tools)
62
+
63
+ def call(self, ref: ToolRef, args: dict) -> ToolResult:
64
+ """Invoke a tool through the permission + budget + audit gate."""
65
+ tool, decision = self._prepare_tool_call(ref, args)
66
+
67
+ if tool is None:
68
+ return self._deny_tool(
69
+ ref, args, "unknown tool", DecisionEffect.DENY.value, error_code="unknown_tool"
70
+ )
71
+
72
+ if decision.effect == DecisionEffect.DENY:
73
+ reason = decision.reason or "policy denied"
74
+ return self._deny_tool(ref, args, reason, decision.effect.value)
75
+
76
+ result = tool.execute(args)
77
+ return self._finalize_tool_call(ref, args, tool, decision, result)
78
+
79
+ async def call_async(self, ref: ToolRef, args: dict) -> ToolResult:
80
+ """Async version of call; awaits async tool.execute when detected."""
81
+ tool, decision = self._prepare_tool_call(ref, args)
82
+
83
+ if tool is None:
84
+ return self._deny_tool(
85
+ ref, args, "unknown tool", DecisionEffect.DENY.value, error_code="unknown_tool"
86
+ )
87
+
88
+ if decision.effect == DecisionEffect.DENY:
89
+ reason = decision.reason or "policy denied"
90
+ return self._deny_tool(ref, args, reason, decision.effect.value)
91
+
92
+ if asyncio.iscoroutinefunction(tool.execute):
93
+ result = await tool.execute(args)
94
+ else:
95
+ result = tool.execute(args)
96
+ return self._finalize_tool_call(ref, args, tool, decision, result)
97
+
98
+ def retrieve(self, query: str, top_k: int = 5) -> list[Snippet]:
99
+ """Retrieve knowledge snippets (skeleton: empty if no retriever)."""
100
+ snippets = self._fetch_snippets(query, top_k)
101
+ return self._finalize_retrieval(query, top_k, snippets)
102
+
103
+ async def retrieve_async(self, query: str, top_k: int = 5) -> list[Snippet]:
104
+ """Async version of retrieve; awaits async retriever.retrieve when detected."""
105
+ snippets = await self._fetch_snippets_async(query, top_k)
106
+ return self._finalize_retrieval(query, top_k, snippets)
107
+
108
+ def query_model(self, request: ModelRequest) -> ModelResponse:
109
+ """Query the model, accumulate usage, and enforce budget."""
110
+ response = self.model.query(request)
111
+ return self._finalize_query_model(request, response)
112
+
113
+ async def query_model_async(self, request: ModelRequest) -> ModelResponse:
114
+ """Async version of query_model; awaits async model.query/query_async."""
115
+ query_async = getattr(self.model, "query_async", None)
116
+ if query_async is not None and asyncio.iscoroutinefunction(query_async):
117
+ response = await query_async(request)
118
+ elif asyncio.iscoroutinefunction(self.model.query):
119
+ response = await self.model.query(request)
120
+ else:
121
+ response = self.model.query(request)
122
+ return self._finalize_query_model(request, response)
123
+
124
+ def usage(self) -> Usage:
125
+ """Return accumulated usage from the cost accountant."""
126
+ return self._costs.total()
127
+
128
+ def _find_tool(self, name: str) -> Tool | None:
129
+ for t in self._tools:
130
+ if t.name == name:
131
+ return t
132
+ return None
133
+
134
+ def _prepare_tool_call(self, ref: ToolRef, args: dict) -> tuple[Tool | None, Any]:
135
+ """Permission gate for tool calls; shared by sync and async paths."""
136
+ tool = self._find_tool(ref.name)
137
+
138
+ subject = Subject()
139
+ action = Action(type="call", tool_name=ref.name, args=args)
140
+ resource = Resource(type="tool", classification="public")
141
+ context = AccessContext(session_id=self.session_id, step=0)
142
+ decision = self.policy.evaluate(subject, action, resource, context)
143
+
144
+ return tool, decision
145
+
146
+ def _deny_tool(
147
+ self,
148
+ ref: ToolRef,
149
+ args: dict,
150
+ reason: str,
151
+ effect_value: str,
152
+ error_code: str | None = None,
153
+ ) -> ToolResult:
154
+ """Emit tool.denied and return an error ToolResult."""
155
+ self.events.emit(
156
+ "tool.denied",
157
+ {
158
+ "tool_name": ref.name,
159
+ "args": args,
160
+ "effect": effect_value,
161
+ "reason": reason,
162
+ },
163
+ )
164
+ return ToolResult(error=error_code if error_code is not None else f"denied: {reason}")
165
+
166
+ def _finalize_tool_call(
167
+ self,
168
+ ref: ToolRef,
169
+ args: dict,
170
+ tool: Tool,
171
+ decision: Any,
172
+ result: ToolResult,
173
+ ) -> ToolResult:
174
+ """Post-execution audit, masking, and budget enforcement."""
175
+ if decision.effect == DecisionEffect.MASK:
176
+ masked = ToolResult(value="[MASKED]", masked=True)
177
+ self.events.emit(
178
+ "tool.masked",
179
+ {
180
+ "tool_name": ref.name,
181
+ "args": args,
182
+ "effect": DecisionEffect.MASK.value,
183
+ },
184
+ )
185
+ return masked
186
+
187
+ # Skeleton: treat other non-allow effects as deny for safety.
188
+ if decision.effect != DecisionEffect.ALLOW:
189
+ reason = decision.reason or f"effect {decision.effect.value} not supported"
190
+ return self._deny_tool(ref, args, reason, decision.effect.value)
191
+
192
+ self.events.emit(
193
+ "tool.called",
194
+ {
195
+ "tool_name": ref.name,
196
+ "args": args,
197
+ "result_value": result.value if not result.is_error else None,
198
+ "result_error": result.error if result.is_error else None,
199
+ },
200
+ )
201
+
202
+ self._costs.record_tool_call()
203
+ self._costs.check_budget(self.budget)
204
+ return result
205
+
206
+ def _fetch_snippets(self, query: str, top_k: int) -> list[Snippet]:
207
+ """Sync snippet fetch, including event injection for retrievers."""
208
+ if self.retriever is None:
209
+ return []
210
+
211
+ # Inject EventEmitter for event-aware retrievers (CRAG, Adaptive-RAG)
212
+ if hasattr(self.retriever, "events"):
213
+ object.__setattr__(self.retriever, "events", self.events)
214
+
215
+ return self.retriever.retrieve(query, top_k)
216
+
217
+ async def _fetch_snippets_async(self, query: str, top_k: int) -> list[Snippet]:
218
+ """Async snippet fetch; awaits async retriever.retrieve when detected."""
219
+ if self.retriever is None:
220
+ return []
221
+
222
+ # Inject EventEmitter for event-aware retrievers (CRAG, Adaptive-RAG)
223
+ if hasattr(self.retriever, "events"):
224
+ object.__setattr__(self.retriever, "events", self.events)
225
+
226
+ if asyncio.iscoroutinefunction(self.retriever.retrieve):
227
+ return await self.retriever.retrieve(query, top_k)
228
+ return self.retriever.retrieve(query, top_k)
229
+
230
+ def _finalize_retrieval(self, query: str, top_k: int, snippets: list[Snippet]) -> list[Snippet]:
231
+ """Emit retrieval event; shared by sync and async paths."""
232
+ self.events.emit(
233
+ "retrieval",
234
+ {
235
+ "query": query,
236
+ "top_k": top_k,
237
+ "snippet_count": len(snippets),
238
+ },
239
+ )
240
+ return snippets
241
+
242
+ def _finalize_query_model(self, request: ModelRequest, response: ModelResponse) -> ModelResponse:
243
+ """Emit model.called, accumulate usage, and enforce budget."""
244
+ self.events.emit(
245
+ "model.called",
246
+ {
247
+ "model": self.model.name,
248
+ "usage": {
249
+ "input_tokens": response.usage.input_tokens,
250
+ "output_tokens": response.usage.output_tokens,
251
+ "total_tokens": response.usage.total_tokens,
252
+ "cost_usd": response.usage.cost_usd,
253
+ "elapsed_s": response.usage.elapsed_s,
254
+ },
255
+ },
256
+ )
257
+
258
+ self._costs.record(response.usage)
259
+ self._costs.check_budget(self.budget)
260
+ return response
@@ -0,0 +1,79 @@
1
+ """Event system — the structural reliability mechanism (decision 4).
2
+
3
+ One append-only event stream serves four purposes simultaneously:
4
+ 1. Audit log (every model/tool/retrieval/permission decision recorded)
5
+ 2. Checkpoint source (snapshot = materialized state at a point)
6
+ 3. Cost ledger (usage events accumulate)
7
+ 4. Replay source (re-inject recorded outputs for deterministic replay)
8
+
9
+ This is the 'one stream, many consumers' trick that makes reliability
10
+ structural rather than bolted-on.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import time
15
+ import uuid
16
+ from dataclasses import dataclass, field
17
+ from typing import Any, Callable
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Event:
22
+ """A single recorded event in a session's lifecycle."""
23
+
24
+ type: str
25
+ timestamp: float
26
+ data: dict[str, Any]
27
+ event_id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
28
+
29
+ # Determinism class for replay (open question 3 — ReplayMode)
30
+ # DETERMINISTIC: re-execute (calculator, pure functions)
31
+ # RECORDED: re-inject recorded value (LLM calls, web search)
32
+ # NONDETERMINISTIC: re-call but expect different (time-dependent APIs)
33
+ determinism: str = "RECORDED"
34
+
35
+
36
+ class EventEmitter:
37
+ """Append-only event log with multi-consumer sink support.
38
+
39
+ Events are immutable once emitted. Sinks (observability/, audit, metrics)
40
+ subscribe and receive every event without affecting the log.
41
+ """
42
+
43
+ def __init__(self) -> None:
44
+ self._events: list[Event] = []
45
+ self._sinks: list[Callable[[Event], None]] = []
46
+
47
+ def emit(self, type: str, data: dict[str, Any] | None = None, determinism: str = "RECORDED") -> Event:
48
+ """Record an event and notify all sinks."""
49
+ event = Event(
50
+ type=type,
51
+ timestamp=time.time(),
52
+ data=data or {},
53
+ determinism=determinism,
54
+ )
55
+ self._events.append(event)
56
+ for sink in self._sinks:
57
+ try:
58
+ sink(event)
59
+ except Exception:
60
+ # Sink failures must NOT break the event log (agentShield P0-9 principle)
61
+ pass
62
+ return event
63
+
64
+ def subscribe(self, sink: Callable[[Event], None]) -> None:
65
+ """Register a sink that receives every emitted event."""
66
+ self._sinks.append(sink)
67
+
68
+ @property
69
+ def events(self) -> tuple[Event, ...]:
70
+ """Immutable snapshot of all recorded events."""
71
+ return tuple(self._events)
72
+
73
+ def events_of(self, type: str) -> tuple[Event, ...]:
74
+ """Filter events by type."""
75
+ return tuple(e for e in self._events if e.type == type)
76
+
77
+ def clear(self) -> None:
78
+ """Reset the log. Used for testing only."""
79
+ self._events.clear()