mvgeos-core 0.6.6__tar.gz
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.
- mvgeos_core-0.6.6/PKG-INFO +7 -0
- mvgeos_core-0.6.6/pyproject.toml +14 -0
- mvgeos_core-0.6.6/pyproject.toml.orig +16 -0
- mvgeos_core-0.6.6/src/mvgeos_core/__init__.py +228 -0
- mvgeos_core-0.6.6/src/mvgeos_core/abort.py +94 -0
- mvgeos_core-0.6.6/src/mvgeos_core/approval.py +217 -0
- mvgeos_core-0.6.6/src/mvgeos_core/channel.py +114 -0
- mvgeos_core-0.6.6/src/mvgeos_core/constants.py +31 -0
- mvgeos_core-0.6.6/src/mvgeos_core/dispatcher.py +567 -0
- mvgeos_core-0.6.6/src/mvgeos_core/errors.py +144 -0
- mvgeos_core-0.6.6/src/mvgeos_core/event_bus.py +45 -0
- mvgeos_core-0.6.6/src/mvgeos_core/events.py +92 -0
- mvgeos_core-0.6.6/src/mvgeos_core/invocations.py +75 -0
- mvgeos_core-0.6.6/src/mvgeos_core/layers.py +342 -0
- mvgeos_core-0.6.6/src/mvgeos_core/loop.py +452 -0
- mvgeos_core-0.6.6/src/mvgeos_core/py.typed +0 -0
- mvgeos_core-0.6.6/src/mvgeos_core/sandbox.py +298 -0
- mvgeos_core-0.6.6/src/mvgeos_core/spell_schema.py +31 -0
- mvgeos_core-0.6.6/src/mvgeos_core/spells.py +155 -0
- mvgeos_core-0.6.6/src/mvgeos_core/telemetry.py +114 -0
- mvgeos_core-0.6.6/src/mvgeos_core/truncate.py +218 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mvgeos-core"
|
|
3
|
+
version = "0.6.6"
|
|
4
|
+
description = "Canonical loop vocabulary for MvgeOS: abort, invocations, spells, events, and the pure turn loop"
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
dependencies = ["pydantic>=2.0"]
|
|
8
|
+
|
|
9
|
+
[build-system]
|
|
10
|
+
requires = ["uv_build>=0.11.2,<0.13"]
|
|
11
|
+
build-backend = "uv_build"
|
|
12
|
+
|
|
13
|
+
[tool.ruff]
|
|
14
|
+
extend = "../pyproject.toml"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mvgeos-core"
|
|
3
|
+
version = "0.6.6"
|
|
4
|
+
description = "Canonical loop vocabulary for MvgeOS: abort, invocations, spells, events, and the pure turn loop"
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"pydantic>=2.0",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["uv_build>=0.11.2,<0.13"]
|
|
13
|
+
build-backend = "uv_build"
|
|
14
|
+
|
|
15
|
+
[tool.ruff]
|
|
16
|
+
extend = "../pyproject.toml"
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Canonical loop vocabulary for MvgeOS.
|
|
2
|
+
|
|
3
|
+
Zero first-party dependencies. Leaf packages (provider, runes, tome) and the
|
|
4
|
+
agent depend on core, never the reverse.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from mvgeos_core.abort import AbortController, AbortError, AbortSignal
|
|
8
|
+
from mvgeos_core.approval import (
|
|
9
|
+
ApprovalDecision,
|
|
10
|
+
ApprovalOutcome,
|
|
11
|
+
ApprovalPresenter,
|
|
12
|
+
ApprovalReasonCode,
|
|
13
|
+
ApprovalRequest,
|
|
14
|
+
ApprovalScope,
|
|
15
|
+
SpellGateHandler,
|
|
16
|
+
allow,
|
|
17
|
+
deny,
|
|
18
|
+
normalize_arguments,
|
|
19
|
+
)
|
|
20
|
+
from mvgeos_core.channel import (
|
|
21
|
+
ChannelConfig,
|
|
22
|
+
Model,
|
|
23
|
+
MvgeResponse,
|
|
24
|
+
RealmResponse,
|
|
25
|
+
StopReason,
|
|
26
|
+
)
|
|
27
|
+
from mvgeos_core.constants import (
|
|
28
|
+
DEFAULT_AGENT_NAME,
|
|
29
|
+
DEFAULT_MODEL,
|
|
30
|
+
GLOBAL_DIR_ENV,
|
|
31
|
+
PROJECT_RUNE_PATH,
|
|
32
|
+
)
|
|
33
|
+
from mvgeos_core.dispatcher import BatchResult, SpellDispatcher
|
|
34
|
+
from mvgeos_core.errors import (
|
|
35
|
+
AuthenticationError,
|
|
36
|
+
MaxTurnsExceededError,
|
|
37
|
+
MissingApiKeyError,
|
|
38
|
+
MvgeError,
|
|
39
|
+
RateLimitError,
|
|
40
|
+
SpellDiscoveryError,
|
|
41
|
+
SpellNotFoundError,
|
|
42
|
+
SpellTimeoutError,
|
|
43
|
+
TomeIncompatibleError,
|
|
44
|
+
TomeResumeError,
|
|
45
|
+
UpstreamTimeoutError,
|
|
46
|
+
to_error,
|
|
47
|
+
)
|
|
48
|
+
from mvgeos_core.event_bus import EventBus
|
|
49
|
+
from mvgeos_core.events import (
|
|
50
|
+
ContemplationLevel,
|
|
51
|
+
ContentType,
|
|
52
|
+
MvgeEvent,
|
|
53
|
+
MvgeEventType,
|
|
54
|
+
PromptSource,
|
|
55
|
+
QueueMode,
|
|
56
|
+
)
|
|
57
|
+
from mvgeos_core.invocations import MvgeInvocation, SummonerRequest
|
|
58
|
+
from mvgeos_core.layers import (
|
|
59
|
+
SCOPE_PRECEDENCE,
|
|
60
|
+
ResolvedLayer,
|
|
61
|
+
Scope,
|
|
62
|
+
agent_dir,
|
|
63
|
+
agent_extensions_dir,
|
|
64
|
+
agent_skills_dir,
|
|
65
|
+
agents_dir,
|
|
66
|
+
approval_dir,
|
|
67
|
+
auth_dir,
|
|
68
|
+
auth_file,
|
|
69
|
+
default_rune_paths,
|
|
70
|
+
extensions_dir,
|
|
71
|
+
global_agents_dir,
|
|
72
|
+
global_file,
|
|
73
|
+
history_file,
|
|
74
|
+
models_file,
|
|
75
|
+
resolve_rune_layers,
|
|
76
|
+
resolve_rune_paths,
|
|
77
|
+
scope_rank,
|
|
78
|
+
sessions_dir,
|
|
79
|
+
skills_dir,
|
|
80
|
+
)
|
|
81
|
+
from mvgeos_core.loop import (
|
|
82
|
+
EmitSink,
|
|
83
|
+
LoopCallbacks,
|
|
84
|
+
LoopContext,
|
|
85
|
+
StreamFn,
|
|
86
|
+
run_loop,
|
|
87
|
+
)
|
|
88
|
+
from mvgeos_core.sandbox import MvgeSandbox, SandboxTimeoutError
|
|
89
|
+
from mvgeos_core.spell_schema import generate_spell_schema
|
|
90
|
+
from mvgeos_core.spells import (
|
|
91
|
+
ExecutionMode,
|
|
92
|
+
MvgeSpell,
|
|
93
|
+
SpellExecutionMode,
|
|
94
|
+
SpellResult,
|
|
95
|
+
SpellResultMessage,
|
|
96
|
+
SpellSignal,
|
|
97
|
+
SpellStatus,
|
|
98
|
+
SpellUpdateCallback,
|
|
99
|
+
)
|
|
100
|
+
from mvgeos_core.telemetry import (
|
|
101
|
+
GEN_AI_AGENT_NAME,
|
|
102
|
+
GEN_AI_CONVERSATION_ID,
|
|
103
|
+
GEN_AI_OPERATION_NAME,
|
|
104
|
+
GEN_AI_REQUEST_MAX_TOKENS,
|
|
105
|
+
GEN_AI_REQUEST_MODEL,
|
|
106
|
+
GEN_AI_REQUEST_TEMPERATURE,
|
|
107
|
+
GEN_AI_REQUEST_TOP_P,
|
|
108
|
+
GEN_AI_RESPONSE_FINISH_REASONS,
|
|
109
|
+
GEN_AI_RESPONSE_MODEL,
|
|
110
|
+
GEN_AI_SYSTEM,
|
|
111
|
+
GEN_AI_TOOL_CALL_ID,
|
|
112
|
+
GEN_AI_TOOL_DEFINITIONS,
|
|
113
|
+
GEN_AI_TOOL_NAME,
|
|
114
|
+
GEN_AI_TOOL_TYPE,
|
|
115
|
+
GEN_AI_USAGE_INPUT_TOKENS,
|
|
116
|
+
GEN_AI_USAGE_OUTPUT_TOKENS,
|
|
117
|
+
GEN_AI_USAGE_TOTAL_TOKENS,
|
|
118
|
+
MVGEOS_ORCHESTRATOR,
|
|
119
|
+
create_genai_chat_attributes,
|
|
120
|
+
create_genai_tool_attributes,
|
|
121
|
+
derive_provider_from_model,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
__all__ = [
|
|
125
|
+
"AbortController",
|
|
126
|
+
"AbortError",
|
|
127
|
+
"AbortSignal",
|
|
128
|
+
"ApprovalDecision",
|
|
129
|
+
"ApprovalOutcome",
|
|
130
|
+
"ApprovalPresenter",
|
|
131
|
+
"ApprovalReasonCode",
|
|
132
|
+
"ApprovalRequest",
|
|
133
|
+
"ApprovalScope",
|
|
134
|
+
"SpellGateHandler",
|
|
135
|
+
"allow",
|
|
136
|
+
"deny",
|
|
137
|
+
"normalize_arguments",
|
|
138
|
+
"AuthenticationError",
|
|
139
|
+
"BatchResult",
|
|
140
|
+
"ChannelConfig",
|
|
141
|
+
"ContemplationLevel",
|
|
142
|
+
"ContentType",
|
|
143
|
+
"DEFAULT_AGENT_NAME",
|
|
144
|
+
"DEFAULT_MODEL",
|
|
145
|
+
"GLOBAL_DIR_ENV",
|
|
146
|
+
"EmitSink",
|
|
147
|
+
"EventBus",
|
|
148
|
+
"ExecutionMode",
|
|
149
|
+
"GEN_AI_AGENT_NAME",
|
|
150
|
+
"GEN_AI_CONVERSATION_ID",
|
|
151
|
+
"GEN_AI_OPERATION_NAME",
|
|
152
|
+
"PROJECT_RUNE_PATH",
|
|
153
|
+
"agent_dir",
|
|
154
|
+
"agent_extensions_dir",
|
|
155
|
+
"agent_skills_dir",
|
|
156
|
+
"agents_dir",
|
|
157
|
+
"approval_dir",
|
|
158
|
+
"auth_dir",
|
|
159
|
+
"auth_file",
|
|
160
|
+
"global_file",
|
|
161
|
+
"history_file",
|
|
162
|
+
"models_file",
|
|
163
|
+
"skills_dir",
|
|
164
|
+
"default_rune_paths",
|
|
165
|
+
"extensions_dir",
|
|
166
|
+
"global_agents_dir",
|
|
167
|
+
"GEN_AI_REQUEST_MAX_TOKENS",
|
|
168
|
+
"GEN_AI_REQUEST_MODEL",
|
|
169
|
+
"GEN_AI_REQUEST_TEMPERATURE",
|
|
170
|
+
"GEN_AI_REQUEST_TOP_P",
|
|
171
|
+
"GEN_AI_RESPONSE_FINISH_REASONS",
|
|
172
|
+
"GEN_AI_RESPONSE_MODEL",
|
|
173
|
+
"GEN_AI_SYSTEM",
|
|
174
|
+
"GEN_AI_TOOL_CALL_ID",
|
|
175
|
+
"GEN_AI_TOOL_DEFINITIONS",
|
|
176
|
+
"GEN_AI_TOOL_NAME",
|
|
177
|
+
"GEN_AI_TOOL_TYPE",
|
|
178
|
+
"GEN_AI_USAGE_INPUT_TOKENS",
|
|
179
|
+
"GEN_AI_USAGE_OUTPUT_TOKENS",
|
|
180
|
+
"GEN_AI_USAGE_TOTAL_TOKENS",
|
|
181
|
+
"LoopCallbacks",
|
|
182
|
+
"LoopContext",
|
|
183
|
+
"MaxTurnsExceededError",
|
|
184
|
+
"MissingApiKeyError",
|
|
185
|
+
"Model",
|
|
186
|
+
"MvgeError",
|
|
187
|
+
"MvgeEvent",
|
|
188
|
+
"MvgeEventType",
|
|
189
|
+
"MvgeInvocation",
|
|
190
|
+
"MvgeResponse",
|
|
191
|
+
"MvgeSandbox",
|
|
192
|
+
"MvgeSpell",
|
|
193
|
+
"MVGEOS_ORCHESTRATOR",
|
|
194
|
+
"PromptSource",
|
|
195
|
+
"QueueMode",
|
|
196
|
+
"RateLimitError",
|
|
197
|
+
"RealmResponse",
|
|
198
|
+
"ResolvedLayer",
|
|
199
|
+
"SCOPE_PRECEDENCE",
|
|
200
|
+
"SandboxTimeoutError",
|
|
201
|
+
"SpellDispatcher",
|
|
202
|
+
"SpellDiscoveryError",
|
|
203
|
+
"SpellExecutionMode",
|
|
204
|
+
"SpellNotFoundError",
|
|
205
|
+
"SpellResult",
|
|
206
|
+
"SpellResultMessage",
|
|
207
|
+
"SpellSignal",
|
|
208
|
+
"SpellStatus",
|
|
209
|
+
"SpellTimeoutError",
|
|
210
|
+
"SpellUpdateCallback",
|
|
211
|
+
"StopReason",
|
|
212
|
+
"Scope",
|
|
213
|
+
"StreamFn",
|
|
214
|
+
"SummonerRequest",
|
|
215
|
+
"TomeIncompatibleError",
|
|
216
|
+
"TomeResumeError",
|
|
217
|
+
"UpstreamTimeoutError",
|
|
218
|
+
"create_genai_chat_attributes",
|
|
219
|
+
"create_genai_tool_attributes",
|
|
220
|
+
"derive_provider_from_model",
|
|
221
|
+
"generate_spell_schema",
|
|
222
|
+
"resolve_rune_layers",
|
|
223
|
+
"resolve_rune_paths",
|
|
224
|
+
"scope_rank",
|
|
225
|
+
"sessions_dir",
|
|
226
|
+
"run_loop",
|
|
227
|
+
"to_error",
|
|
228
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AbortError(Exception):
|
|
9
|
+
"""Raised when an operation is cancelled via AbortSignal."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AbortSignal:
|
|
13
|
+
"""Cancellation signal, mirroring the web AbortSignal API (Pi-compatible).
|
|
14
|
+
|
|
15
|
+
A signal is created with ``aborted`` False. Calling ``controller.abort()``
|
|
16
|
+
flips the flag and fires any registered callbacks. Consumers should check
|
|
17
|
+
``aborted`` or call ``raise_if_aborted()`` at safe checkpoints.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, controller: AbortController) -> None:
|
|
21
|
+
self._controller = controller
|
|
22
|
+
self._aborted = False
|
|
23
|
+
self._callbacks: list[Callable[[], None]] = []
|
|
24
|
+
self._wait_future: asyncio.Future[None] | None = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def aborted(self) -> bool:
|
|
28
|
+
return self._aborted
|
|
29
|
+
|
|
30
|
+
def on_abort(self, callback: Callable[[], None]) -> None:
|
|
31
|
+
if self._aborted:
|
|
32
|
+
callback()
|
|
33
|
+
else:
|
|
34
|
+
self._callbacks.append(callback)
|
|
35
|
+
|
|
36
|
+
def raise_if_aborted(self) -> None:
|
|
37
|
+
if self._aborted:
|
|
38
|
+
raise AbortError("Operation aborted")
|
|
39
|
+
|
|
40
|
+
async def wait(self) -> None:
|
|
41
|
+
"""Block until the signal is aborted or the await is cancelled.
|
|
42
|
+
|
|
43
|
+
Multiple concurrent calls to wait() will all resolve when the signal is aborted.
|
|
44
|
+
"""
|
|
45
|
+
if self._aborted:
|
|
46
|
+
return
|
|
47
|
+
if self._wait_future is not None and not self._wait_future.done():
|
|
48
|
+
await self._wait_future
|
|
49
|
+
return
|
|
50
|
+
self._wait_future = asyncio.get_event_loop().create_future()
|
|
51
|
+
|
|
52
|
+
def _set_result() -> None:
|
|
53
|
+
if self._wait_future is not None and not self._wait_future.done():
|
|
54
|
+
self._wait_future.set_result(None)
|
|
55
|
+
|
|
56
|
+
self._callbacks.append(_set_result)
|
|
57
|
+
try:
|
|
58
|
+
await self._wait_future
|
|
59
|
+
finally:
|
|
60
|
+
if _set_result in self._callbacks:
|
|
61
|
+
self._callbacks.remove(_set_result)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AbortController:
|
|
65
|
+
"""Controller that owns an :class:`AbortSignal` and can abort it.
|
|
66
|
+
|
|
67
|
+
Mirrors the web ``AbortController`` API and Pi's cancellation model.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(self) -> None:
|
|
71
|
+
self._signal = AbortSignal(self)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def signal(self) -> AbortSignal:
|
|
75
|
+
return self._signal
|
|
76
|
+
|
|
77
|
+
def abort(self) -> None:
|
|
78
|
+
if self._signal._aborted:
|
|
79
|
+
return
|
|
80
|
+
self._signal._aborted = True
|
|
81
|
+
for callback in list(self._signal._callbacks):
|
|
82
|
+
with contextlib.suppress(Exception):
|
|
83
|
+
callback()
|
|
84
|
+
self._signal._callbacks.clear()
|
|
85
|
+
wait_future = self._signal._wait_future
|
|
86
|
+
if wait_future is not None and not wait_future.done():
|
|
87
|
+
wait_future.set_result(None)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"AbortController",
|
|
92
|
+
"AbortError",
|
|
93
|
+
"AbortSignal",
|
|
94
|
+
]
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Gate vocabulary for the critical spell-approval path.
|
|
2
|
+
|
|
3
|
+
These types are the contract between the spell dispatcher (which builds the
|
|
4
|
+
request), the critical gate handlers registered by runes (which decide), and
|
|
5
|
+
the host-bound presenter (which surfaces the decision to the Summoner).
|
|
6
|
+
|
|
7
|
+
The gate is fail-closed: any exception, cancellation, malformed response, or
|
|
8
|
+
stale digest denies the cast. This module carries no I/O and no first-party
|
|
9
|
+
dependencies.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from types import MappingProxyType
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ApprovalOutcome(StrEnum):
|
|
24
|
+
ALLOW = "allow"
|
|
25
|
+
DENY = "deny"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ApprovalScope(StrEnum):
|
|
29
|
+
ONCE = "once"
|
|
30
|
+
SPELL = "spell"
|
|
31
|
+
SESSION = "session"
|
|
32
|
+
PROJECT = "project"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ApprovalReasonCode(StrEnum):
|
|
36
|
+
USER = "user"
|
|
37
|
+
RULE = "rule"
|
|
38
|
+
READ_ONLY = "read_only"
|
|
39
|
+
FAILURE = "failure"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _coerce_outcome(value: ApprovalOutcome | str) -> ApprovalOutcome:
|
|
43
|
+
try:
|
|
44
|
+
return ApprovalOutcome(value)
|
|
45
|
+
except ValueError as exc:
|
|
46
|
+
raise ValueError(f"Unknown approval outcome: {value!r}") from exc
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _coerce_scope(value: ApprovalScope | str) -> ApprovalScope:
|
|
50
|
+
try:
|
|
51
|
+
return ApprovalScope(value)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise ValueError(f"Unknown approval scope: {value!r}") from exc
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _coerce_reason_code(value: ApprovalReasonCode | str) -> ApprovalReasonCode:
|
|
57
|
+
try:
|
|
58
|
+
return ApprovalReasonCode(value)
|
|
59
|
+
except ValueError as exc:
|
|
60
|
+
raise ValueError(f"Unknown approval reason code: {value!r}") from exc
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _freeze_mapping(value: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
64
|
+
if isinstance(value, MappingProxyType):
|
|
65
|
+
return value
|
|
66
|
+
return MappingProxyType(dict(value))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class ApprovalRequest:
|
|
71
|
+
"""One immutable approval question for a single spell cast.
|
|
72
|
+
|
|
73
|
+
The dispatcher builds this from the validated, normalized argument copy
|
|
74
|
+
and the gate approves exactly this data; the dispatcher then executes the
|
|
75
|
+
approved copy. ``spell_identity`` is engine-derived (never rune-chosen)
|
|
76
|
+
attribution the policy layer matches rules against.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
cast_id: str
|
|
80
|
+
spell_name: str
|
|
81
|
+
spell_identity: Mapping[str, str] = field(default_factory=dict)
|
|
82
|
+
arguments: Mapping[str, Any] = field(default_factory=dict)
|
|
83
|
+
argument_digest: str = ""
|
|
84
|
+
project_root: str = ""
|
|
85
|
+
tome_id: str = ""
|
|
86
|
+
agent_name: str = ""
|
|
87
|
+
# True when the spell carried a parameter schema and the dispatcher
|
|
88
|
+
# validated/normalized arguments against it before building this
|
|
89
|
+
# request. False marks a schemaless cast: policy may never match an
|
|
90
|
+
# allow rule against it, and presenters must show an "unvalidated
|
|
91
|
+
# arguments" warning.
|
|
92
|
+
schema_validated: bool = True
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
object.__setattr__(self, "spell_identity", _freeze_mapping(self.spell_identity))
|
|
96
|
+
object.__setattr__(self, "arguments", _freeze_mapping(self.arguments))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ApprovalDecision:
|
|
101
|
+
"""The verdict for one :class:`ApprovalRequest`.
|
|
102
|
+
|
|
103
|
+
``request_digest`` must equal the request's ``argument_digest``; a
|
|
104
|
+
decision bound to any other digest is stale and denies the cast.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
outcome: ApprovalOutcome
|
|
108
|
+
scope: ApprovalScope = ApprovalScope.ONCE
|
|
109
|
+
reason_code: ApprovalReasonCode = ApprovalReasonCode.RULE
|
|
110
|
+
request_digest: str = ""
|
|
111
|
+
|
|
112
|
+
def __post_init__(self) -> None:
|
|
113
|
+
object.__setattr__(self, "outcome", _coerce_outcome(self.outcome))
|
|
114
|
+
object.__setattr__(self, "scope", _coerce_scope(self.scope))
|
|
115
|
+
object.__setattr__(self, "reason_code", _coerce_reason_code(self.reason_code))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def deny(
|
|
119
|
+
request: ApprovalRequest,
|
|
120
|
+
reason_code: ApprovalReasonCode | str = ApprovalReasonCode.FAILURE,
|
|
121
|
+
scope: ApprovalScope | str = ApprovalScope.ONCE,
|
|
122
|
+
) -> ApprovalDecision:
|
|
123
|
+
"""Build a denial decision already bound to ``request``."""
|
|
124
|
+
return ApprovalDecision(
|
|
125
|
+
outcome=ApprovalOutcome.DENY,
|
|
126
|
+
scope=_coerce_scope(scope),
|
|
127
|
+
reason_code=_coerce_reason_code(reason_code),
|
|
128
|
+
request_digest=request.argument_digest,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def allow(
|
|
133
|
+
request: ApprovalRequest,
|
|
134
|
+
reason_code: ApprovalReasonCode | str = ApprovalReasonCode.RULE,
|
|
135
|
+
scope: ApprovalScope | str = ApprovalScope.ONCE,
|
|
136
|
+
) -> ApprovalDecision:
|
|
137
|
+
"""Build an allow decision already bound to ``request``."""
|
|
138
|
+
return ApprovalDecision(
|
|
139
|
+
outcome=ApprovalOutcome.ALLOW,
|
|
140
|
+
scope=_coerce_scope(scope),
|
|
141
|
+
reason_code=_coerce_reason_code(reason_code),
|
|
142
|
+
request_digest=request.argument_digest,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _deep_freeze(value: Any) -> Any:
|
|
147
|
+
"""Recursively freeze JSON-like data into immutable containers.
|
|
148
|
+
|
|
149
|
+
dicts become ``MappingProxyType``, lists/tuples become tuples; scalars
|
|
150
|
+
pass through. The frozen tree shares no mutable state with the input.
|
|
151
|
+
"""
|
|
152
|
+
if isinstance(value, dict):
|
|
153
|
+
return MappingProxyType(
|
|
154
|
+
{key: _deep_freeze(item) for key, item in value.items()}
|
|
155
|
+
)
|
|
156
|
+
if isinstance(value, (list, tuple)):
|
|
157
|
+
return tuple(_deep_freeze(item) for item in value)
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def normalize_arguments(
|
|
162
|
+
arguments: Any,
|
|
163
|
+
) -> tuple[Mapping[str, Any], str]:
|
|
164
|
+
"""Freeze arguments to canonical JSON and digest them.
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
arguments: The validated argument mapping for one cast.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
A tuple of (recursively immutable argument view, ``"sha256:<hex>"``
|
|
171
|
+
digest) where the digest is computed over ``json.dumps`` with
|
|
172
|
+
``sort_keys=True, separators=(",", ":"), ensure_ascii=True``.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValueError: If ``arguments`` is not a dict or is not JSON-serializable.
|
|
176
|
+
"""
|
|
177
|
+
if not isinstance(arguments, dict):
|
|
178
|
+
raise ValueError(
|
|
179
|
+
"Spell arguments must be a JSON object (dict); "
|
|
180
|
+
f"got {type(arguments).__name__}"
|
|
181
|
+
)
|
|
182
|
+
try:
|
|
183
|
+
canonical = json.dumps(
|
|
184
|
+
arguments, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
|
185
|
+
)
|
|
186
|
+
except (TypeError, ValueError) as exc:
|
|
187
|
+
raise ValueError(f"Spell arguments are not JSON-serializable: {exc}") from exc
|
|
188
|
+
digest = "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
189
|
+
# Freeze after dumping: the frozen tree shares no mutable state with the
|
|
190
|
+
# input, so a gate cannot mutate nested values after display and change
|
|
191
|
+
# what the dispatcher executes.
|
|
192
|
+
frozen = _deep_freeze(arguments)
|
|
193
|
+
# `arguments` is a dict, so `_deep_freeze` returned a MappingProxyType.
|
|
194
|
+
return frozen, digest
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
SpellGateHandler = Callable[
|
|
198
|
+
[ApprovalRequest], "ApprovalDecision | Awaitable[ApprovalDecision]"
|
|
199
|
+
]
|
|
200
|
+
"""A critical gate handler: receives the request, returns or awaits a decision."""
|
|
201
|
+
|
|
202
|
+
ApprovalPresenter = Callable[[ApprovalRequest], Awaitable[ApprovalDecision]]
|
|
203
|
+
"""Host-bound UI surface: awaits the Summoner's decision for one request."""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
__all__ = [
|
|
207
|
+
"ApprovalDecision",
|
|
208
|
+
"ApprovalOutcome",
|
|
209
|
+
"ApprovalPresenter",
|
|
210
|
+
"ApprovalReasonCode",
|
|
211
|
+
"ApprovalRequest",
|
|
212
|
+
"ApprovalScope",
|
|
213
|
+
"SpellGateHandler",
|
|
214
|
+
"allow",
|
|
215
|
+
"deny",
|
|
216
|
+
"normalize_arguments",
|
|
217
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import StrEnum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StopReason(StrEnum):
|
|
9
|
+
PENDING = "pending"
|
|
10
|
+
STOP = "stop"
|
|
11
|
+
LENGTH = "length"
|
|
12
|
+
SPELL_USE = "spellUse"
|
|
13
|
+
ERROR = "error"
|
|
14
|
+
ABORTED = "aborted"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class MvgeResponse:
|
|
19
|
+
role: str = "assistant"
|
|
20
|
+
content: list[dict[str, Any]] = field(default_factory=list)
|
|
21
|
+
realm: str = ""
|
|
22
|
+
model: str = ""
|
|
23
|
+
mana_usage: dict[str, float] = field(default_factory=dict)
|
|
24
|
+
stop_reason: StopReason = StopReason.PENDING
|
|
25
|
+
error_message: str | None = None
|
|
26
|
+
timestamp: float = 0.0
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Model:
|
|
31
|
+
id: str
|
|
32
|
+
name: str
|
|
33
|
+
realm: str
|
|
34
|
+
base_url: str
|
|
35
|
+
api_key: str
|
|
36
|
+
max_completion_mana: int = 0
|
|
37
|
+
context_window: int = 128000
|
|
38
|
+
max_tokens: int = 4096
|
|
39
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
40
|
+
supported_parameters: list[str] = field(default_factory=list)
|
|
41
|
+
supported_contemplation_levels: list[str] = field(default_factory=list)
|
|
42
|
+
is_free: bool = False
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def free(self) -> bool:
|
|
46
|
+
"""Return True if the model is free of charge."""
|
|
47
|
+
return self.is_free or self.id.endswith(":free") or self.id == "openrouter/free"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def provider(self) -> str:
|
|
51
|
+
"""The organization that provides this model (derived from the model ID)."""
|
|
52
|
+
return self.id.split("/")[0] if "/" in self.id else self.realm
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def provider_prefix(self) -> str:
|
|
56
|
+
"""The provider prefix before '/' in id, or realm, or id."""
|
|
57
|
+
return self.id.split("/")[0] if "/" in self.id else (self.realm or self.id)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def supports_contemplation(self) -> bool:
|
|
61
|
+
"""Return True if the model supports contemplation/reasoning."""
|
|
62
|
+
return (
|
|
63
|
+
bool(self.supported_contemplation_levels)
|
|
64
|
+
or "reasoning" in self.supported_parameters
|
|
65
|
+
or "thinking" in self.supported_parameters
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class ChannelConfig:
|
|
71
|
+
model: Model
|
|
72
|
+
temperature: float = 0.7
|
|
73
|
+
max_tokens: int = 4096
|
|
74
|
+
max_output_mana: int | None = None
|
|
75
|
+
timeout_ms: int = 120000
|
|
76
|
+
max_retries: int = 3
|
|
77
|
+
contemplation_level: str = "medium"
|
|
78
|
+
contemplation_budget: int | None = None
|
|
79
|
+
exclude_contemplation: bool = False
|
|
80
|
+
spells: list[dict[str, Any]] = field(default_factory=list)
|
|
81
|
+
tools: list[dict[str, Any]] = field(default_factory=list)
|
|
82
|
+
meta_data: dict[str, Any] = field(default_factory=dict)
|
|
83
|
+
system_prompt: str = ""
|
|
84
|
+
|
|
85
|
+
def __post_init__(self) -> None:
|
|
86
|
+
if self.tools and not self.spells:
|
|
87
|
+
self.spells = self.tools
|
|
88
|
+
elif self.spells and not self.tools:
|
|
89
|
+
self.tools = self.spells
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class RealmResponse:
|
|
94
|
+
model: Model
|
|
95
|
+
invocation: Any | None = None
|
|
96
|
+
mana_used: int = 0
|
|
97
|
+
stop_reason: str = "stop"
|
|
98
|
+
error_message: str | None = None
|
|
99
|
+
error_code: str | None = None
|
|
100
|
+
retry_after: float | None = None
|
|
101
|
+
limit_source: str | None = None
|
|
102
|
+
remedy_hint: str | None = None
|
|
103
|
+
reset_at: float | None = None
|
|
104
|
+
quota_limit: int | None = None
|
|
105
|
+
quota_remaining: int | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
__all__ = [
|
|
109
|
+
"ChannelConfig",
|
|
110
|
+
"Model",
|
|
111
|
+
"MvgeResponse",
|
|
112
|
+
"RealmResponse",
|
|
113
|
+
"StopReason",
|
|
114
|
+
]
|