pawly 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 (206) hide show
  1. pawly/__init__.py +154 -0
  2. pawly/__pycache__/__init__.cpython-311.pyc +0 -0
  3. pawly/__pycache__/__init__.cpython-313.pyc +0 -0
  4. pawly/__pycache__/action_selection.cpython-311.pyc +0 -0
  5. pawly/__pycache__/action_selection.cpython-313.pyc +0 -0
  6. pawly/__pycache__/api.cpython-311.pyc +0 -0
  7. pawly/__pycache__/api.cpython-313.pyc +0 -0
  8. pawly/__pycache__/contracts.cpython-311.pyc +0 -0
  9. pawly/__pycache__/contracts.cpython-313.pyc +0 -0
  10. pawly/__pycache__/decision_engine.cpython-311.pyc +0 -0
  11. pawly/__pycache__/decision_engine.cpython-313.pyc +0 -0
  12. pawly/__pycache__/pawprint_loader.cpython-311.pyc +0 -0
  13. pawly/__pycache__/pawprint_loader.cpython-313.pyc +0 -0
  14. pawly/__pycache__/protected_oss.cpython-311.pyc +0 -0
  15. pawly/__pycache__/protected_oss.cpython-313.pyc +0 -0
  16. pawly/__pycache__/runtime.cpython-311.pyc +0 -0
  17. pawly/__pycache__/runtime.cpython-313.pyc +0 -0
  18. pawly/__pycache__/runtime_bootstrap.cpython-311.pyc +0 -0
  19. pawly/__pycache__/runtime_bootstrap.cpython-313.pyc +0 -0
  20. pawly/__pycache__/runtime_decision.cpython-311.pyc +0 -0
  21. pawly/__pycache__/runtime_decision.cpython-313.pyc +0 -0
  22. pawly/__pycache__/runtime_execute.cpython-311.pyc +0 -0
  23. pawly/__pycache__/runtime_execute.cpython-313.pyc +0 -0
  24. pawly/__pycache__/runtime_flow.cpython-311.pyc +0 -0
  25. pawly/__pycache__/runtime_flow.cpython-313.pyc +0 -0
  26. pawly/__pycache__/runtime_ids.cpython-311.pyc +0 -0
  27. pawly/__pycache__/runtime_ids.cpython-313.pyc +0 -0
  28. pawly/__pycache__/runtime_report.cpython-311.pyc +0 -0
  29. pawly/__pycache__/runtime_report.cpython-313.pyc +0 -0
  30. pawly/__pycache__/runtime_request.cpython-311.pyc +0 -0
  31. pawly/__pycache__/runtime_request.cpython-313.pyc +0 -0
  32. pawly/__pycache__/runtime_result.cpython-311.pyc +0 -0
  33. pawly/__pycache__/runtime_result.cpython-313.pyc +0 -0
  34. pawly/__pycache__/runtime_scoring.cpython-311.pyc +0 -0
  35. pawly/__pycache__/runtime_scoring.cpython-313.pyc +0 -0
  36. pawly/__pycache__/shield.cpython-311.pyc +0 -0
  37. pawly/__pycache__/shield.cpython-313.pyc +0 -0
  38. pawly/__pycache__/skill_registry.cpython-311.pyc +0 -0
  39. pawly/__pycache__/skill_registry.cpython-313.pyc +0 -0
  40. pawly/__pycache__/task_request.cpython-311.pyc +0 -0
  41. pawly/__pycache__/task_request.cpython-313.pyc +0 -0
  42. pawly/__pycache__/types.cpython-311.pyc +0 -0
  43. pawly/__pycache__/types.cpython-313.pyc +0 -0
  44. pawly/action_selection.py +64 -0
  45. pawly/adapters/__init__.py +25 -0
  46. pawly/adapters/__pycache__/__init__.cpython-311.pyc +0 -0
  47. pawly/adapters/__pycache__/__init__.cpython-313.pyc +0 -0
  48. pawly/adapters/__pycache__/claude_skills.cpython-311.pyc +0 -0
  49. pawly/adapters/__pycache__/claude_skills.cpython-313.pyc +0 -0
  50. pawly/adapters/__pycache__/openai_agents.cpython-311.pyc +0 -0
  51. pawly/adapters/__pycache__/openai_agents.cpython-313.pyc +0 -0
  52. pawly/adapters/claude_skills.py +210 -0
  53. pawly/adapters/openai_agents.py +208 -0
  54. pawly/api.py +57 -0
  55. pawly/approval/__init__.py +18 -0
  56. pawly/approval/__pycache__/__init__.cpython-311.pyc +0 -0
  57. pawly/approval/__pycache__/__init__.cpython-313.pyc +0 -0
  58. pawly/approval/__pycache__/callback.cpython-311.pyc +0 -0
  59. pawly/approval/__pycache__/callback.cpython-313.pyc +0 -0
  60. pawly/approval/__pycache__/handler.cpython-311.pyc +0 -0
  61. pawly/approval/__pycache__/handler.cpython-313.pyc +0 -0
  62. pawly/approval/__pycache__/models.cpython-311.pyc +0 -0
  63. pawly/approval/__pycache__/models.cpython-313.pyc +0 -0
  64. pawly/approval/__pycache__/queue.cpython-311.pyc +0 -0
  65. pawly/approval/__pycache__/queue.cpython-313.pyc +0 -0
  66. pawly/approval/__pycache__/router.cpython-311.pyc +0 -0
  67. pawly/approval/__pycache__/router.cpython-313.pyc +0 -0
  68. pawly/approval/__pycache__/timeout.cpython-311.pyc +0 -0
  69. pawly/approval/__pycache__/timeout.cpython-313.pyc +0 -0
  70. pawly/approval/callback.py +16 -0
  71. pawly/approval/handler.py +34 -0
  72. pawly/approval/models.py +119 -0
  73. pawly/approval/queue.py +109 -0
  74. pawly/approval/router.py +66 -0
  75. pawly/approval/timeout.py +18 -0
  76. pawly/audit/__pycache__/diff.cpython-311.pyc +0 -0
  77. pawly/audit/__pycache__/diff.cpython-313.pyc +0 -0
  78. pawly/audit/__pycache__/events.cpython-311.pyc +0 -0
  79. pawly/audit/__pycache__/events.cpython-313.pyc +0 -0
  80. pawly/audit/__pycache__/ledger.cpython-311.pyc +0 -0
  81. pawly/audit/__pycache__/ledger.cpython-313.pyc +0 -0
  82. pawly/audit/__pycache__/replay.cpython-311.pyc +0 -0
  83. pawly/audit/__pycache__/replay.cpython-313.pyc +0 -0
  84. pawly/audit/diff.py +24 -0
  85. pawly/audit/events.py +156 -0
  86. pawly/audit/ledger.py +39 -0
  87. pawly/audit/replay.py +45 -0
  88. pawly/backends/__init__.py +39 -0
  89. pawly/backends/__pycache__/__init__.cpython-311.pyc +0 -0
  90. pawly/backends/__pycache__/__init__.cpython-313.pyc +0 -0
  91. pawly/backends/__pycache__/approval.cpython-311.pyc +0 -0
  92. pawly/backends/__pycache__/approval.cpython-313.pyc +0 -0
  93. pawly/backends/__pycache__/audit.cpython-311.pyc +0 -0
  94. pawly/backends/__pycache__/audit.cpython-313.pyc +0 -0
  95. pawly/backends/__pycache__/reviewer.cpython-311.pyc +0 -0
  96. pawly/backends/__pycache__/reviewer.cpython-313.pyc +0 -0
  97. pawly/backends/__pycache__/risk.cpython-311.pyc +0 -0
  98. pawly/backends/__pycache__/risk.cpython-313.pyc +0 -0
  99. pawly/backends/approval.py +85 -0
  100. pawly/backends/audit.py +189 -0
  101. pawly/backends/reviewer.py +127 -0
  102. pawly/backends/risk.py +71 -0
  103. pawly/budget/__pycache__/checker.cpython-311.pyc +0 -0
  104. pawly/budget/__pycache__/checker.cpython-313.pyc +0 -0
  105. pawly/budget/__pycache__/state.cpython-311.pyc +0 -0
  106. pawly/budget/__pycache__/state.cpython-313.pyc +0 -0
  107. pawly/budget/checker.py +37 -0
  108. pawly/budget/state.py +15 -0
  109. pawly/cli.py +44 -0
  110. pawly/contracts.py +153 -0
  111. pawly/decision_engine.py +6 -0
  112. pawly/escalation/__pycache__/patterns.cpython-311.pyc +0 -0
  113. pawly/escalation/__pycache__/patterns.cpython-313.pyc +0 -0
  114. pawly/escalation/patterns.py +33 -0
  115. pawly/gateway/__init__.py +4 -0
  116. pawly/gateway/__pycache__/__init__.cpython-311.pyc +0 -0
  117. pawly/gateway/__pycache__/__init__.cpython-313.pyc +0 -0
  118. pawly/gateway/__pycache__/adapter_support.cpython-311.pyc +0 -0
  119. pawly/gateway/__pycache__/adapter_support.cpython-313.pyc +0 -0
  120. pawly/gateway/__pycache__/protocol.cpython-311.pyc +0 -0
  121. pawly/gateway/__pycache__/protocol.cpython-313.pyc +0 -0
  122. pawly/gateway/__pycache__/result.cpython-311.pyc +0 -0
  123. pawly/gateway/__pycache__/result.cpython-313.pyc +0 -0
  124. pawly/gateway/__pycache__/wrapper.cpython-311.pyc +0 -0
  125. pawly/gateway/__pycache__/wrapper.cpython-313.pyc +0 -0
  126. pawly/gateway/adapter_support.py +56 -0
  127. pawly/gateway/protocol.py +24 -0
  128. pawly/gateway/result.py +67 -0
  129. pawly/gateway/wrapper.py +273 -0
  130. pawly/loader/__pycache__/schema_loader.cpython-311.pyc +0 -0
  131. pawly/loader/__pycache__/schema_loader.cpython-313.pyc +0 -0
  132. pawly/loader/__pycache__/yaml_loader.cpython-311.pyc +0 -0
  133. pawly/loader/__pycache__/yaml_loader.cpython-313.pyc +0 -0
  134. pawly/loader/schema_loader.py +51 -0
  135. pawly/loader/yaml_loader.py +164 -0
  136. pawly/memory/__pycache__/store.cpython-311.pyc +0 -0
  137. pawly/memory/__pycache__/store.cpython-313.pyc +0 -0
  138. pawly/memory/__pycache__/tiers.cpython-311.pyc +0 -0
  139. pawly/memory/__pycache__/tiers.cpython-313.pyc +0 -0
  140. pawly/memory/store.py +23 -0
  141. pawly/memory/tiers.py +9 -0
  142. pawly/middleware/__init__.py +1 -0
  143. pawly/middleware/__pycache__/__init__.cpython-311.pyc +0 -0
  144. pawly/middleware/__pycache__/__init__.cpython-313.pyc +0 -0
  145. pawly/middleware/__pycache__/hooks.cpython-311.pyc +0 -0
  146. pawly/middleware/__pycache__/hooks.cpython-313.pyc +0 -0
  147. pawly/middleware/hooks.py +20 -0
  148. pawly/pawprint_loader.py +301 -0
  149. pawly/performance/__pycache__/metrics.cpython-311.pyc +0 -0
  150. pawly/performance/__pycache__/metrics.cpython-313.pyc +0 -0
  151. pawly/performance/__pycache__/report.cpython-311.pyc +0 -0
  152. pawly/performance/__pycache__/report.cpython-313.pyc +0 -0
  153. pawly/performance/metrics.py +48 -0
  154. pawly/performance/report.py +44 -0
  155. pawly/policy/__init__.py +15 -0
  156. pawly/policy/__pycache__/__init__.cpython-311.pyc +0 -0
  157. pawly/policy/__pycache__/__init__.cpython-313.pyc +0 -0
  158. pawly/policy/__pycache__/base.cpython-311.pyc +0 -0
  159. pawly/policy/__pycache__/base.cpython-313.pyc +0 -0
  160. pawly/policy/__pycache__/heuristic.cpython-311.pyc +0 -0
  161. pawly/policy/__pycache__/heuristic.cpython-313.pyc +0 -0
  162. pawly/policy/__pycache__/resolve.cpython-311.pyc +0 -0
  163. pawly/policy/__pycache__/resolve.cpython-313.pyc +0 -0
  164. pawly/policy/base.py +84 -0
  165. pawly/policy/cloud.py +18 -0
  166. pawly/policy/heuristic.py +141 -0
  167. pawly/policy/resolve.py +60 -0
  168. pawly/policy_engine/__pycache__/decision.cpython-311.pyc +0 -0
  169. pawly/policy_engine/__pycache__/decision.cpython-313.pyc +0 -0
  170. pawly/policy_engine/__pycache__/engine.cpython-311.pyc +0 -0
  171. pawly/policy_engine/__pycache__/engine.cpython-313.pyc +0 -0
  172. pawly/policy_engine/__pycache__/models.cpython-311.pyc +0 -0
  173. pawly/policy_engine/__pycache__/models.cpython-313.pyc +0 -0
  174. pawly/policy_engine/__pycache__/runtime_overlay.cpython-311.pyc +0 -0
  175. pawly/policy_engine/__pycache__/runtime_overlay.cpython-313.pyc +0 -0
  176. pawly/policy_engine/decision.py +40 -0
  177. pawly/policy_engine/engine.py +152 -0
  178. pawly/policy_engine/models.py +62 -0
  179. pawly/policy_engine/risk.py +24 -0
  180. pawly/policy_engine/runtime_overlay.py +37 -0
  181. pawly/protected_oss.py +168 -0
  182. pawly/runtime.py +1004 -0
  183. pawly/runtime_bootstrap.py +104 -0
  184. pawly/runtime_decision.py +52 -0
  185. pawly/runtime_execute.py +59 -0
  186. pawly/runtime_flow.py +50 -0
  187. pawly/runtime_ids.py +19 -0
  188. pawly/runtime_report.py +18 -0
  189. pawly/runtime_request.py +23 -0
  190. pawly/runtime_result.py +142 -0
  191. pawly/runtime_scoring.py +25 -0
  192. pawly/schemas/decision.schema.json +38 -0
  193. pawly/schemas/intent.schema.json +33 -0
  194. pawly/schemas/report.schema.json +36 -0
  195. pawly/shield.py +436 -0
  196. pawly/skill_registry.py +31 -0
  197. pawly/task_request.py +27 -0
  198. pawly/types.py +15 -0
  199. pawly/validator/__pycache__/validator.cpython-311.pyc +0 -0
  200. pawly/validator/__pycache__/validator.cpython-313.pyc +0 -0
  201. pawly/validator/validator.py +177 -0
  202. pawly-0.1.0.dist-info/METADATA +7 -0
  203. pawly-0.1.0.dist-info/RECORD +206 -0
  204. pawly-0.1.0.dist-info/WHEEL +4 -0
  205. pawly-0.1.0.dist-info/entry_points.txt +2 -0
  206. pawly-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,208 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field, replace
4
+ from typing import Any, Callable, Iterable, Mapping
5
+
6
+ from pawly.gateway import GatewayProtocol
7
+ from pawly.gateway.adapter_support import build_gateway, execute_adapter_action, metadata_from_intent
8
+ from pawly.runtime import PawlyRuntime
9
+
10
+
11
+ @dataclass(slots=True)
12
+ class OpenAIAgentAction:
13
+ task: str
14
+ tool_name: str
15
+ confidence: float
16
+ payload: dict[str, Any] = field(default_factory=dict)
17
+ raw_item: Any = None
18
+
19
+ @classmethod
20
+ def from_value(
21
+ cls,
22
+ value: Any,
23
+ *,
24
+ task: str | None = None,
25
+ confidence: float | None = None,
26
+ payload: Mapping[str, Any] | None = None,
27
+ tool_name_field: str = "tool_name",
28
+ task_field: str = "task",
29
+ confidence_field: str = "confidence",
30
+ payload_field: str = "payload",
31
+ ) -> "OpenAIAgentAction":
32
+ tool_name = _extract_value(value, tool_name_field)
33
+ resolved_task = task if task is not None else _extract_value(value, task_field, required=False, default="")
34
+ resolved_confidence = confidence if confidence is not None else _extract_value(
35
+ value,
36
+ confidence_field,
37
+ required=False,
38
+ default=1.0,
39
+ )
40
+ resolved_payload = dict(payload) if payload is not None else dict(
41
+ _extract_value(value, payload_field, required=False, default={}) or {}
42
+ )
43
+ return cls(
44
+ task=str(resolved_task),
45
+ tool_name=str(tool_name),
46
+ confidence=float(resolved_confidence),
47
+ payload=resolved_payload,
48
+ raw_item=value,
49
+ )
50
+
51
+
52
+ ExecutorHook = Callable[[OpenAIAgentAction], Any]
53
+ AuditHook = Callable[[dict[str, Any]], None]
54
+
55
+
56
+ class OpenAIAgentsPawAdapter:
57
+ """Gateway-backed adapter for OpenAI-style tool invocations."""
58
+
59
+ def __init__(
60
+ self,
61
+ runtime: PawlyRuntime,
62
+ audit_hook: AuditHook | None = None,
63
+ approval_handler=None,
64
+ gateway: GatewayProtocol | None = None,
65
+ ) -> None:
66
+ self.gateway = build_gateway(runtime, approval_handler=approval_handler, gateway=gateway)
67
+ self.audit_hook = audit_hook
68
+
69
+ def execute_tool(self, action: OpenAIAgentAction, executor: ExecutorHook) -> dict[str, Any]:
70
+ return execute_adapter_action(
71
+ gateway=self.gateway,
72
+ item=action,
73
+ task=action.task,
74
+ action=action.tool_name,
75
+ confidence=action.confidence,
76
+ metadata=action.payload,
77
+ executor=executor,
78
+ remap=lambda current, intent: replace(
79
+ current,
80
+ task=intent.summary,
81
+ tool_name=intent.action.name,
82
+ confidence=intent.confidence,
83
+ payload=metadata_from_intent(intent),
84
+ ),
85
+ audit_hook=self.audit_hook,
86
+ audit_payload=lambda outcome: {
87
+ "framework": "openai-agents",
88
+ "event": "tool-execution",
89
+ "tool_name": action.tool_name,
90
+ "decision_type": outcome["type"],
91
+ "executed": outcome["execution"]["executed"],
92
+ },
93
+ )
94
+
95
+ def execute_native_tool(
96
+ self,
97
+ value: Any,
98
+ executor: ExecutorHook,
99
+ *,
100
+ task: str | None = None,
101
+ confidence: float | None = None,
102
+ payload: Mapping[str, Any] | None = None,
103
+ tool_name_field: str = "tool_name",
104
+ task_field: str = "task",
105
+ confidence_field: str = "confidence",
106
+ payload_field: str = "payload",
107
+ ) -> dict[str, Any]:
108
+ return self.execute_tool(
109
+ OpenAIAgentAction.from_value(
110
+ value,
111
+ task=task,
112
+ confidence=confidence,
113
+ payload=payload,
114
+ tool_name_field=tool_name_field,
115
+ task_field=task_field,
116
+ confidence_field=confidence_field,
117
+ payload_field=payload_field,
118
+ ),
119
+ executor,
120
+ )
121
+
122
+
123
+ def wrap_openai_tool_executor(
124
+ runtime: PawlyRuntime,
125
+ executor: ExecutorHook,
126
+ *,
127
+ audit_hook: AuditHook | None = None,
128
+ approval_handler=None,
129
+ gateway: GatewayProtocol | None = None,
130
+ task: str | None = None,
131
+ confidence: float | None = None,
132
+ payload: Mapping[str, Any] | None = None,
133
+ tool_name_field: str = "tool_name",
134
+ task_field: str = "task",
135
+ confidence_field: str = "confidence",
136
+ payload_field: str = "payload",
137
+ ) -> Callable[[Any], dict[str, Any]]:
138
+ adapter = OpenAIAgentsPawAdapter(
139
+ runtime,
140
+ audit_hook=audit_hook,
141
+ approval_handler=approval_handler,
142
+ gateway=gateway,
143
+ )
144
+
145
+ def wrapped(value: Any) -> dict[str, Any]:
146
+ return adapter.execute_native_tool(
147
+ value,
148
+ executor,
149
+ task=task,
150
+ confidence=confidence,
151
+ payload=payload,
152
+ tool_name_field=tool_name_field,
153
+ task_field=task_field,
154
+ confidence_field=confidence_field,
155
+ payload_field=payload_field,
156
+ )
157
+
158
+ return wrapped
159
+
160
+
161
+ def wrap_openai_tools(
162
+ runtime: PawlyRuntime,
163
+ tools: Iterable[Any],
164
+ *,
165
+ audit_hook: AuditHook | None = None,
166
+ approval_handler=None,
167
+ gateway: GatewayProtocol | None = None,
168
+ executor_field: str = "executor",
169
+ tool_name_field: str = "tool_name",
170
+ task: str | None = None,
171
+ confidence: float | None = None,
172
+ payload: Mapping[str, Any] | None = None,
173
+ task_field: str = "task",
174
+ confidence_field: str = "confidence",
175
+ payload_field: str = "payload",
176
+ ) -> dict[str, Callable[[Any], dict[str, Any]]]:
177
+ wrapped_tools: dict[str, Callable[[Any], dict[str, Any]]] = {}
178
+ for tool in tools:
179
+ tool_name = str(_extract_value(tool, tool_name_field))
180
+ executor = _extract_value(tool, executor_field)
181
+ if not callable(executor):
182
+ raise TypeError(f"executor for tool '{tool_name}' must be callable")
183
+ wrapped_tools[tool_name] = wrap_openai_tool_executor(
184
+ runtime,
185
+ executor,
186
+ audit_hook=audit_hook,
187
+ approval_handler=approval_handler,
188
+ gateway=gateway,
189
+ task=task,
190
+ confidence=confidence,
191
+ payload=payload,
192
+ tool_name_field=tool_name_field,
193
+ task_field=task_field,
194
+ confidence_field=confidence_field,
195
+ payload_field=payload_field,
196
+ )
197
+ return wrapped_tools
198
+
199
+
200
+ def _extract_value(value: Any, field: str, *, required: bool = True, default: Any = None) -> Any:
201
+ if isinstance(value, Mapping):
202
+ if field in value:
203
+ return value[field]
204
+ elif hasattr(value, field):
205
+ return getattr(value, field)
206
+ if required:
207
+ raise ValueError(f"unable to extract required field '{field}'")
208
+ return default
pawly/api.py ADDED
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from pawly.action_selection import ActionDecision
8
+ from pawly.contracts import Action
9
+ from pawly.pawprint_loader import PawprintConfig
10
+ from pawly.runtime import DecisionEngine
11
+ from pawly.skill_registry import SkillRegistry
12
+
13
+
14
+ def decide(
15
+ pawprint: str | Path,
16
+ *,
17
+ state: Mapping[str, Any] | None,
18
+ actions: Sequence[Action],
19
+ pawprint_config: PawprintConfig | None = None,
20
+ **engine_kwargs: Any,
21
+ ) -> ActionDecision:
22
+ engine = DecisionEngine(pawprint, **engine_kwargs)
23
+ return engine.decide_actions(state, actions, pawprint_config)
24
+
25
+
26
+ def run(
27
+ pawprint: str | Path,
28
+ *,
29
+ task: str,
30
+ action: str,
31
+ confidence: float,
32
+ metadata: dict[str, Any] | None = None,
33
+ **engine_kwargs: Any,
34
+ ) -> dict[str, Any]:
35
+ engine = DecisionEngine(pawprint, **engine_kwargs)
36
+ return engine.evaluate(task, action, confidence, metadata)
37
+
38
+
39
+ def run_actions(
40
+ pawprint: str | Path,
41
+ *,
42
+ state: Mapping[str, Any] | None,
43
+ actions: Sequence[Action],
44
+ context: Mapping[str, Any] | None = None,
45
+ skill_registry: SkillRegistry | None = None,
46
+ pawprint_config: PawprintConfig | None = None,
47
+ **engine_kwargs: Any,
48
+ ) -> dict[str, Any]:
49
+ engine = DecisionEngine(pawprint, **engine_kwargs)
50
+ if skill_registry is not None:
51
+ engine.register_skills(skill_registry)
52
+ return engine.run_actions(
53
+ state=state,
54
+ actions=actions,
55
+ context=context,
56
+ pawprint_config=pawprint_config,
57
+ )
@@ -0,0 +1,18 @@
1
+ from .callback import CallbackApprovalNotifier
2
+ from .handler import ApprovalHandler, StaticApprovalHandler
3
+ from .models import ApprovalRecord, ApprovalRequest, ApprovalResponse, ApprovalStatus
4
+ from .queue import FileApprovalQueue, InMemoryApprovalQueue
5
+ from .router import ApprovalRouter
6
+
7
+ __all__ = [
8
+ "ApprovalHandler",
9
+ "ApprovalRecord",
10
+ "ApprovalRequest",
11
+ "ApprovalResponse",
12
+ "ApprovalRouter",
13
+ "ApprovalStatus",
14
+ "CallbackApprovalNotifier",
15
+ "FileApprovalQueue",
16
+ "InMemoryApprovalQueue",
17
+ "StaticApprovalHandler",
18
+ ]
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ from pawly.approval.models import ApprovalRecord
6
+
7
+
8
+ CallbackHook = Callable[[ApprovalRecord], None]
9
+
10
+
11
+ class CallbackApprovalNotifier:
12
+ def __init__(self, callback: CallbackHook) -> None:
13
+ self.callback = callback
14
+
15
+ def notify(self, record: ApprovalRecord) -> None:
16
+ self.callback(record)
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Protocol
4
+
5
+ from pawly.approval.models import ApprovalRequest, ApprovalResponse, ApprovalStatus
6
+ from pawly.types import IntentAction
7
+
8
+
9
+ class ApprovalHandler(Protocol):
10
+ def review(self, request: ApprovalRequest) -> ApprovalResponse: ...
11
+
12
+
13
+ class StaticApprovalHandler:
14
+ def __init__(
15
+ self,
16
+ *,
17
+ approved: bool,
18
+ reviewer: str = "local-reviewer",
19
+ notes: list[str] | None = None,
20
+ edited_action: IntentAction | None = None,
21
+ ) -> None:
22
+ self.approved = approved
23
+ self.reviewer = reviewer
24
+ self.notes = notes or []
25
+ self.edited_action = edited_action
26
+
27
+ def review(self, request: ApprovalRequest) -> ApprovalResponse:
28
+ del request
29
+ return ApprovalResponse(
30
+ status=ApprovalStatus.APPROVED if self.approved else ApprovalStatus.REJECTED,
31
+ reviewer=self.reviewer,
32
+ notes=list(self.notes),
33
+ edited_action=self.edited_action,
34
+ )
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime, timezone
5
+ from enum import Enum
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from pawly.contracts import Decision
10
+ from pawly.types import Intent, IntentAction
11
+
12
+
13
+ def utc_now() -> datetime:
14
+ return datetime.now(timezone.utc)
15
+
16
+
17
+ class ApprovalStatus(str, Enum):
18
+ PENDING = "pending"
19
+ APPROVED = "approved"
20
+ REJECTED = "rejected"
21
+ EXPIRED = "expired"
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class ApprovalRequest:
26
+ intent: Intent
27
+ decision: Decision
28
+ record: "ApprovalRecord | None" = None
29
+
30
+
31
+ @dataclass(slots=True)
32
+ class ApprovalResponse:
33
+ status: ApprovalStatus
34
+ reviewer: str | None = "local-reviewer"
35
+ notes: list[str] = field(default_factory=list)
36
+ edited_action: IntentAction | None = None
37
+
38
+
39
+ @dataclass(slots=True)
40
+ class ApprovalRecord:
41
+ record_id: str
42
+ intent: Intent
43
+ proposed_action: IntentAction
44
+ edited_action: IntentAction | None
45
+ status: ApprovalStatus
46
+ created_at: str
47
+ updated_at: str
48
+ expires_at: str | None
49
+ decision: Decision
50
+ reviewer: str | None = None
51
+ notes: list[str] = field(default_factory=list)
52
+
53
+ @classmethod
54
+ def create(
55
+ cls,
56
+ *,
57
+ intent: Intent,
58
+ decision: Decision,
59
+ expires_at: datetime | None,
60
+ ) -> "ApprovalRecord":
61
+ now = utc_now().isoformat()
62
+ return cls(
63
+ record_id=f"approval-{uuid4().hex}",
64
+ intent=intent,
65
+ proposed_action=intent.action,
66
+ edited_action=None,
67
+ status=ApprovalStatus.PENDING,
68
+ created_at=now,
69
+ updated_at=now,
70
+ expires_at=expires_at.isoformat() if expires_at is not None else None,
71
+ decision=decision,
72
+ )
73
+
74
+ def apply_response(self, response: ApprovalResponse) -> None:
75
+ self.status = response.status
76
+ self.reviewer = response.reviewer
77
+ self.notes = list(response.notes)
78
+ self.edited_action = response.edited_action
79
+ self.updated_at = utc_now().isoformat()
80
+
81
+ def mark_expired(self) -> None:
82
+ self.status = ApprovalStatus.EXPIRED
83
+ self.updated_at = utc_now().isoformat()
84
+ if "approval request expired" not in self.notes:
85
+ self.notes.append("approval request expired")
86
+
87
+ def approved_action(self) -> IntentAction:
88
+ return self.edited_action or self.proposed_action
89
+
90
+ def approved_intent(self) -> Intent:
91
+ action = self.approved_action()
92
+ return Intent(
93
+ intent_id=self.intent.intent_id,
94
+ source=self.intent.source,
95
+ action=action,
96
+ summary=self.intent.summary,
97
+ confidence=self.intent.confidence,
98
+ metadata=self.intent.metadata,
99
+ )
100
+
101
+ def to_dict(self) -> dict[str, Any]:
102
+ payload = {
103
+ "record_id": self.record_id,
104
+ "intent": self.intent.to_dict(),
105
+ "proposed_action": self.proposed_action.to_dict(),
106
+ "status": self.status.value,
107
+ "created_at": self.created_at,
108
+ "updated_at": self.updated_at,
109
+ "decision": self.decision.to_dict(),
110
+ }
111
+ if self.edited_action is not None:
112
+ payload["edited_action"] = self.edited_action.to_dict()
113
+ if self.expires_at is not None:
114
+ payload["expires_at"] = self.expires_at
115
+ if self.reviewer is not None:
116
+ payload["reviewer"] = self.reviewer
117
+ if self.notes:
118
+ payload["notes"] = self.notes
119
+ return payload
@@ -0,0 +1,109 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Protocol
6
+
7
+ from pawly.contracts import Action, Decision, Intent, IntentSource
8
+ from pawly.approval.models import ApprovalRecord, ApprovalStatus
9
+ from pawly.approval.timeout import is_expired
10
+ from pawly.types import IntentAction
11
+
12
+
13
+ class ApprovalQueue(Protocol):
14
+ def create(self, record: ApprovalRecord) -> ApprovalRecord: ...
15
+ def get(self, record_id: str) -> ApprovalRecord | None: ...
16
+ def update(self, record: ApprovalRecord) -> ApprovalRecord: ...
17
+ def list_pending(self) -> list[ApprovalRecord]: ...
18
+ def expire_due(self) -> list[ApprovalRecord]: ...
19
+
20
+
21
+ class InMemoryApprovalQueue:
22
+ def __init__(self) -> None:
23
+ self._records: dict[str, ApprovalRecord] = {}
24
+
25
+ def create(self, record: ApprovalRecord) -> ApprovalRecord:
26
+ self._records[record.record_id] = record
27
+ return record
28
+
29
+ def get(self, record_id: str) -> ApprovalRecord | None:
30
+ return self._records.get(record_id)
31
+
32
+ def update(self, record: ApprovalRecord) -> ApprovalRecord:
33
+ self._records[record.record_id] = record
34
+ return record
35
+
36
+ def list_pending(self) -> list[ApprovalRecord]:
37
+ return [record for record in self._records.values() if record.status == ApprovalStatus.PENDING]
38
+
39
+ def expire_due(self) -> list[ApprovalRecord]:
40
+ expired: list[ApprovalRecord] = []
41
+ for record in self.list_pending():
42
+ if is_expired(record):
43
+ record.mark_expired()
44
+ self.update(record)
45
+ expired.append(record)
46
+ return expired
47
+
48
+
49
+ class FileApprovalQueue:
50
+ def __init__(self, path: str | Path) -> None:
51
+ self.path = Path(path)
52
+
53
+ def create(self, record: ApprovalRecord) -> ApprovalRecord:
54
+ records = self._load()
55
+ records[record.record_id] = record
56
+ self._save(records)
57
+ return record
58
+
59
+ def get(self, record_id: str) -> ApprovalRecord | None:
60
+ return self._load().get(record_id)
61
+
62
+ def update(self, record: ApprovalRecord) -> ApprovalRecord:
63
+ records = self._load()
64
+ records[record.record_id] = record
65
+ self._save(records)
66
+ return record
67
+
68
+ def list_pending(self) -> list[ApprovalRecord]:
69
+ return [record for record in self._load().values() if record.status == ApprovalStatus.PENDING]
70
+
71
+ def expire_due(self) -> list[ApprovalRecord]:
72
+ records = self._load()
73
+ expired: list[ApprovalRecord] = []
74
+ for record in records.values():
75
+ if record.status == ApprovalStatus.PENDING and is_expired(record):
76
+ record.mark_expired()
77
+ expired.append(record)
78
+ self._save(records)
79
+ return expired
80
+
81
+ def _load(self) -> dict[str, ApprovalRecord]:
82
+ if not self.path.exists():
83
+ return {}
84
+ payload = json.loads(self.path.read_text(encoding="utf-8"))
85
+ return {record_id: _record_from_dict(item) for record_id, item in payload.items()}
86
+
87
+ def _save(self, records: dict[str, ApprovalRecord]) -> None:
88
+ self.path.parent.mkdir(parents=True, exist_ok=True)
89
+ payload = {record_id: record.to_dict() for record_id, record in records.items()}
90
+ self.path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
91
+
92
+
93
+ def _record_from_dict(value: dict) -> ApprovalRecord:
94
+ intent = Intent.from_dict(value["intent"])
95
+ proposed = value["proposed_action"]
96
+ edited = value.get("edited_action")
97
+ return ApprovalRecord(
98
+ record_id=value["record_id"],
99
+ intent=intent,
100
+ proposed_action=Action.from_dict(proposed),
101
+ edited_action=Action.from_dict(edited) if edited is not None else None,
102
+ status=ApprovalStatus(value["status"]),
103
+ created_at=value["created_at"],
104
+ updated_at=value["updated_at"],
105
+ expires_at=value.get("expires_at"),
106
+ decision=Decision.from_dict(value["decision"]),
107
+ reviewer=value.get("reviewer"),
108
+ notes=value.get("notes", []),
109
+ )
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from pawly.contracts import Decision
4
+ from pawly.approval.handler import ApprovalHandler
5
+ from pawly.approval.models import ApprovalRecord, ApprovalRequest, ApprovalResponse, ApprovalStatus
6
+ from pawly.approval.queue import ApprovalQueue, InMemoryApprovalQueue
7
+ from pawly.approval.timeout import expiry_from_now
8
+ from pawly.types import Intent
9
+
10
+
11
+ class ApprovalRouter:
12
+ def __init__(
13
+ self,
14
+ *,
15
+ queue: ApprovalQueue | None = None,
16
+ handler: ApprovalHandler | None = None,
17
+ timeout_seconds: int | None = 300,
18
+ ) -> None:
19
+ self.queue = queue or InMemoryApprovalQueue()
20
+ self.handler = handler
21
+ self.timeout_seconds = timeout_seconds
22
+
23
+ def submit(self, intent: Intent, decision: Decision) -> ApprovalRecord:
24
+ record = ApprovalRecord.create(
25
+ intent=intent,
26
+ decision=decision,
27
+ expires_at=expiry_from_now(self.timeout_seconds),
28
+ )
29
+ self.queue.create(record)
30
+ if self.handler is not None:
31
+ response = self.handler.review(ApprovalRequest(intent=intent, decision=decision, record=record))
32
+ return self.apply_response(record.record_id, response)
33
+ self.queue.expire_due()
34
+ return self.get(record.record_id)
35
+
36
+ def get(self, record_id: str) -> ApprovalRecord:
37
+ self.queue.expire_due()
38
+ record = self.queue.get(record_id)
39
+ if record is None:
40
+ raise KeyError(record_id)
41
+ return record
42
+
43
+ def apply_response(self, record_id: str, response: ApprovalResponse) -> ApprovalRecord:
44
+ record = self.get(record_id)
45
+ record.apply_response(response)
46
+ self.queue.update(record)
47
+ self.queue.expire_due()
48
+ return self.get(record_id)
49
+
50
+ def expire_due(self) -> list[ApprovalRecord]:
51
+ return self.queue.expire_due()
52
+
53
+ def status_payload(self, record: ApprovalRecord) -> dict:
54
+ return {
55
+ "record_id": record.record_id,
56
+ "status": record.status.value,
57
+ "approved": record.status == ApprovalStatus.APPROVED,
58
+ "reviewer": record.reviewer,
59
+ "notes": record.notes,
60
+ "proposed_action": record.proposed_action.to_dict(),
61
+ "edited_action": record.edited_action.to_dict() if record.edited_action is not None else None,
62
+ "decision": record.decision.to_dict(),
63
+ "created_at": record.created_at,
64
+ "updated_at": record.updated_at,
65
+ "expires_at": record.expires_at,
66
+ }
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime, timedelta, timezone
4
+
5
+ from pawly.approval.models import ApprovalRecord
6
+
7
+
8
+ def expiry_from_now(timeout_seconds: int | None) -> datetime | None:
9
+ if timeout_seconds is None:
10
+ return None
11
+ return datetime.now(timezone.utc) + timedelta(seconds=timeout_seconds)
12
+
13
+
14
+ def is_expired(record: ApprovalRecord, *, now: datetime | None = None) -> bool:
15
+ if record.expires_at is None:
16
+ return False
17
+ current = now or datetime.now(timezone.utc)
18
+ return current >= datetime.fromisoformat(record.expires_at)