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