vsl-maf 0.2.2__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.
- vsl_maf/__init__.py +23 -0
- vsl_maf/adapter.py +180 -0
- vsl_maf/integration.py +167 -0
- vsl_maf-0.2.2.dist-info/METADATA +239 -0
- vsl_maf-0.2.2.dist-info/RECORD +8 -0
- vsl_maf-0.2.2.dist-info/WHEEL +5 -0
- vsl_maf-0.2.2.dist-info/licenses/LICENSE +21 -0
- vsl_maf-0.2.2.dist-info/top_level.txt +1 -0
vsl_maf/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import vsl_core as _vsl_core
|
|
2
|
+
|
|
3
|
+
from .adapter import MAFAdapter
|
|
4
|
+
from .integration import VSLFunctionMiddleware, VSLGateDenied, tool_arguments
|
|
5
|
+
|
|
6
|
+
__version__ = "0.2.0"
|
|
7
|
+
|
|
8
|
+
# Gate-decision records (VerbaLedger.write_gate_decision) and the 2026.10 Gamma
|
|
9
|
+
# rule arrived in vsl-core 0.2.0; this package's docs and examples rely on both.
|
|
10
|
+
_MIN_VSL_CORE = (0, 2, 0)
|
|
11
|
+
_found = tuple(int(part) for part in _vsl_core.__version__.split(".")[:3])
|
|
12
|
+
if _found < _MIN_VSL_CORE:
|
|
13
|
+
raise ImportError(
|
|
14
|
+
f"vsl-maf {__version__} needs vsl-core >= 0.2.0, found {_vsl_core.__version__}. "
|
|
15
|
+
"Upgrade: pip install --upgrade git+https://github.com/supersemantics/VSL-Core.git"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"MAFAdapter",
|
|
20
|
+
"VSLFunctionMiddleware",
|
|
21
|
+
"VSLGateDenied",
|
|
22
|
+
"tool_arguments",
|
|
23
|
+
]
|
vsl_maf/adapter.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""MAFAdapter: a VSLAdapter conformant with vsl-core's conformance contract,
|
|
2
|
+
intended for use with the Microsoft Agent Framework (agent-framework).
|
|
3
|
+
|
|
4
|
+
MAF's FunctionMiddleware provides a natural pre-commitment hook: a middleware's
|
|
5
|
+
process() is called before any tool side effect fires. VSLFunctionMiddleware
|
|
6
|
+
(see integration.py) uses that hook to call a compiled gate, preserving F1
|
|
7
|
+
pre-commitment.
|
|
8
|
+
|
|
9
|
+
What this package adds on top (see integration.py) is VSLFunctionMiddleware,
|
|
10
|
+
a FunctionMiddleware subclass that calls a compiled gate before the tool runs
|
|
11
|
+
and raises AutomationDeniedException on denial, so the caller can intercept it
|
|
12
|
+
via a standard try/except around agent.run().
|
|
13
|
+
|
|
14
|
+
This module is framework-agnostic -- it never imports agent_framework.
|
|
15
|
+
The compiled gate it returns is the same async callable vsl-core's own
|
|
16
|
+
PlainPythonReferenceAdapter produces. The only MAF-specific logic lives in
|
|
17
|
+
integration.py (VSLFunctionMiddleware), not here.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import inspect
|
|
23
|
+
import warnings
|
|
24
|
+
from typing import Any, Callable
|
|
25
|
+
|
|
26
|
+
from vsl_core.conformance.protocol import CompiledGate
|
|
27
|
+
from vsl_core.constructs import Fallback, Invariant, PreNode
|
|
28
|
+
from vsl_core.exceptions import AutomationDeniedException, InvariantViolation
|
|
29
|
+
from vsl_core.metrics import F2Modification
|
|
30
|
+
|
|
31
|
+
_DEFAULT_FALLBACK = Fallback()
|
|
32
|
+
|
|
33
|
+
# Source-text markers for hosted, black-box model APIs. A gate whose
|
|
34
|
+
# monitor/rule reaches one of these cannot honestly claim F2Modification.FULL
|
|
35
|
+
# -- FULL requires direct access to the energy function itself (logits,
|
|
36
|
+
# weights, activations), which a hosted API call structurally cannot expose.
|
|
37
|
+
# The honest ceiling for a hosted-API-backed check is PARTIAL (MEDIUM), per
|
|
38
|
+
# vsl-core's own README example. This is a heuristic, not a proof: it scans
|
|
39
|
+
# source text -- the monitor/rule's OWN body (inspect.getsource(fn), not its
|
|
40
|
+
# containing module's entire source), plus one level of indirection through
|
|
41
|
+
# whatever names it directly calls -- catching the common "the actual API
|
|
42
|
+
# call lives in a helper module" shape. It can still miss deeper indirection
|
|
43
|
+
# and cannot confirm a genuine FULL claim is correct either.
|
|
44
|
+
_HOSTED_API_MARKERS = (
|
|
45
|
+
"openai",
|
|
46
|
+
"anthropic",
|
|
47
|
+
"google.genai",
|
|
48
|
+
"google.generativeai",
|
|
49
|
+
"cohere",
|
|
50
|
+
"mistralai",
|
|
51
|
+
# MAF's own hosted-model clients (FoundryChatClient, OpenAIChatClient,
|
|
52
|
+
# AzureOpenAIChatClient, ...) and Azure AI inference: a monitor that asks
|
|
53
|
+
# another agent or a chat client to judge is a hosted-model call too.
|
|
54
|
+
"chatclient",
|
|
55
|
+
"azure.ai.inference",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _reaches_hosted_api(fn: Callable[..., Any], _seen: set | None = None) -> bool:
|
|
60
|
+
"""Scoped to fn's OWN source (inspect.getsource(fn)), not its containing
|
|
61
|
+
module's entire source. One level of indirection through directly-called
|
|
62
|
+
helpers is also checked (same logic as vsl-langgraph's adapter.py).
|
|
63
|
+
"""
|
|
64
|
+
if _seen is None:
|
|
65
|
+
_seen = set()
|
|
66
|
+
if fn in _seen:
|
|
67
|
+
return False
|
|
68
|
+
_seen.add(fn)
|
|
69
|
+
|
|
70
|
+
if inspect.getmodule(fn) is None:
|
|
71
|
+
return False
|
|
72
|
+
try:
|
|
73
|
+
source = inspect.getsource(fn)
|
|
74
|
+
except (OSError, TypeError):
|
|
75
|
+
return False
|
|
76
|
+
# Case-insensitive: real code capitalises SDK class names (AsyncAnthropic,
|
|
77
|
+
# AsyncOpenAI), which a case-sensitive check against lowercase markers misses.
|
|
78
|
+
source_lower = source.lower()
|
|
79
|
+
if any(marker in source_lower for marker in _HOSTED_API_MARKERS):
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
code = getattr(fn, "__code__", None)
|
|
83
|
+
globalns = getattr(fn, "__globals__", None)
|
|
84
|
+
if code is None or globalns is None:
|
|
85
|
+
return False
|
|
86
|
+
for referenced_name in code.co_names:
|
|
87
|
+
candidate = globalns.get(referenced_name)
|
|
88
|
+
if callable(candidate) and candidate is not fn:
|
|
89
|
+
if _reaches_hosted_api(candidate, _seen):
|
|
90
|
+
return True
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _warn_if_full_claim_reaches_hosted_api(
|
|
95
|
+
name: str, assurance_basis: Any, fn: Callable[..., Any]
|
|
96
|
+
) -> None:
|
|
97
|
+
if assurance_basis.f2_modification != F2Modification.FULL:
|
|
98
|
+
return
|
|
99
|
+
if not _reaches_hosted_api(fn):
|
|
100
|
+
return
|
|
101
|
+
warnings.warn(
|
|
102
|
+
f"'{name}' claims F2Modification.FULL, but its monitor/rule appears to "
|
|
103
|
+
"call a hosted model API (openai/anthropic/google.genai/cohere/mistralai "
|
|
104
|
+
"detected in its source). A hosted, black-box API call cannot directly "
|
|
105
|
+
"modify the energy function -- FULL is not achievable this way. "
|
|
106
|
+
"The honest ceiling is F2Modification.PARTIAL (MEDIUM assurance), per "
|
|
107
|
+
"vsl-core's own README F1/F2 example. This is a heuristic check, not "
|
|
108
|
+
"proof -- it can miss indirect calls, and passing it does not confirm "
|
|
109
|
+
"FULL is correct either.",
|
|
110
|
+
stacklevel=3,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class MAFAdapter:
|
|
115
|
+
"""VSLAdapter for the Microsoft Agent Framework (agent-framework).
|
|
116
|
+
|
|
117
|
+
Compiles PreNode/Invariant into callable gates for use inside a
|
|
118
|
+
VSLFunctionMiddleware registered on an Agent instance (see integration.py),
|
|
119
|
+
or for direct inline calls before any guarded action runs.
|
|
120
|
+
|
|
121
|
+
This class never imports agent_framework -- the compiled gate is the same
|
|
122
|
+
framework-agnostic async callable vsl-core's own PlainPythonReferenceAdapter
|
|
123
|
+
produces. VSLFunctionMiddleware (integration.py) provides the MAF-specific
|
|
124
|
+
middleware hook.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, *, identity_key: str = "vsl-maf") -> None:
|
|
128
|
+
"""`identity_key` is recorded on every denial this adapter's gates raise;
|
|
129
|
+
pass the agent's own identity so a denial says which agent it stopped."""
|
|
130
|
+
if not identity_key:
|
|
131
|
+
raise ValueError("identity_key must be a non-empty string.")
|
|
132
|
+
self.identity_key = identity_key
|
|
133
|
+
|
|
134
|
+
def compile_pre_node(self, pre_node: PreNode) -> CompiledGate:
|
|
135
|
+
if pre_node.fallback != _DEFAULT_FALLBACK:
|
|
136
|
+
warnings.warn(
|
|
137
|
+
f"PreNode '{pre_node.name}' sets a non-default Fallback "
|
|
138
|
+
f"({pre_node.fallback!r}), but VSLFunctionMiddleware does not consume "
|
|
139
|
+
"Fallback -- a denial raises once, immediately, with no retry loop. "
|
|
140
|
+
"Implement retry behavior yourself around the compiled gate if needed.",
|
|
141
|
+
stacklevel=2,
|
|
142
|
+
)
|
|
143
|
+
_warn_if_full_claim_reaches_hosted_api(
|
|
144
|
+
f"PreNode '{pre_node.name}'", pre_node.assurance_basis, pre_node.monitor
|
|
145
|
+
)
|
|
146
|
+
identity_key = self.identity_key
|
|
147
|
+
|
|
148
|
+
async def gate(candidate_input: Any) -> None:
|
|
149
|
+
estimate = await pre_node.monitor(candidate_input)
|
|
150
|
+
if not estimate.sufficient(pre_node.gamma_threshold):
|
|
151
|
+
raise AutomationDeniedException(
|
|
152
|
+
reason=f"Pre-node '{pre_node.name}' insufficient Gamma",
|
|
153
|
+
identity_key=identity_key,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return gate
|
|
157
|
+
|
|
158
|
+
def compile_invariant(self, invariant: Invariant) -> CompiledGate:
|
|
159
|
+
_warn_if_full_claim_reaches_hosted_api(
|
|
160
|
+
f"Invariant '{invariant.name}'", invariant.assurance_basis, invariant.rule
|
|
161
|
+
)
|
|
162
|
+
identity_key = self.identity_key
|
|
163
|
+
|
|
164
|
+
async def gate(candidate_input: Any) -> None:
|
|
165
|
+
holds = await invariant.rule(candidate_input)
|
|
166
|
+
if not holds:
|
|
167
|
+
reason = f"Invariant '{invariant.name}' violated"
|
|
168
|
+
terminal_state_name = (
|
|
169
|
+
invariant.on_violation.name if invariant.on_violation is not None else None
|
|
170
|
+
)
|
|
171
|
+
if terminal_state_name is not None:
|
|
172
|
+
reason += f" -- entering terminal state '{terminal_state_name}'"
|
|
173
|
+
raise InvariantViolation(
|
|
174
|
+
reason=reason,
|
|
175
|
+
identity_key=identity_key,
|
|
176
|
+
invariant_name=invariant.name,
|
|
177
|
+
terminal_state_name=terminal_state_name,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return gate
|
vsl_maf/integration.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""VSLFunctionMiddleware: MAF FunctionMiddleware that applies a compiled VSL gate
|
|
2
|
+
before a tool (function) call runs.
|
|
3
|
+
|
|
4
|
+
vsl-core does not intercept anything on its own: a compiled gate only ever
|
|
5
|
+
raises or returns, on demand, when called. VSLFunctionMiddleware is that call
|
|
6
|
+
site, made reusable for MAF's middleware mechanism. It calls the gate
|
|
7
|
+
immediately before the tool would run, so the gate completes strictly before the
|
|
8
|
+
guarded tool's side effect (F1 pre-commitment).
|
|
9
|
+
|
|
10
|
+
How a denial reaches your code (agent-framework 1.x)
|
|
11
|
+
----------------------------------------------------
|
|
12
|
+
MAF's function-invocation loop converts an ordinary exception raised by function
|
|
13
|
+
middleware into a tool-error result and keeps running: the tool does not run, but
|
|
14
|
+
the model only sees "Error: Function failed.", may retry or carry on, and
|
|
15
|
+
``agent.run()`` returns normally. For an enforcement layer that is fail-open at
|
|
16
|
+
the level of the run. MAF's fail-closed signal is ``MiddlewareFailure``: never
|
|
17
|
+
turned into a tool result, it cancels the current batch of tool calls and
|
|
18
|
+
propagates to the caller of ``agent.run()`` (for streaming runs, when the stream
|
|
19
|
+
is consumed).
|
|
20
|
+
|
|
21
|
+
So by default (``on_denial="abort"``) a denial raises ``VSLGateDenied``, a
|
|
22
|
+
``MiddlewareFailure`` that carries the original ``AutomationDeniedException`` (or
|
|
23
|
+
``InvariantViolation``) as ``.denial`` and as ``__cause__``:
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
result = await agent.run("Approve $5000")
|
|
27
|
+
except VSLGateDenied as denied:
|
|
28
|
+
print(denied.denial.reason)
|
|
29
|
+
if isinstance(denied.denial, InvariantViolation): ...
|
|
30
|
+
|
|
31
|
+
``on_denial="return_to_model"`` keeps MAF's default instead: the denial becomes a
|
|
32
|
+
tool-error result, the model is told the call failed and the run continues. The
|
|
33
|
+
tool still never runs. Use it only when you want the model to explain or try
|
|
34
|
+
another route, and remember that your code is then not told about the denial.
|
|
35
|
+
|
|
36
|
+
If the gate itself fails (a monitor that raises something other than a denial),
|
|
37
|
+
the middleware fails closed in "abort" mode too: ``MiddlewareFailure`` chained to
|
|
38
|
+
the error, so the tool never runs unguarded.
|
|
39
|
+
|
|
40
|
+
MAF FunctionMiddleware semantics used here:
|
|
41
|
+
- context.function.name -- the tool being called
|
|
42
|
+
- context.arguments -- the arguments, a Mapping or a pydantic model
|
|
43
|
+
- call_next() -- continues to the next middleware or the tool;
|
|
44
|
+
takes NO arguments in MAF.
|
|
45
|
+
|
|
46
|
+
Ordering: register VSL middleware after any argument-repair middleware. MAF fails
|
|
47
|
+
closed if arguments change after a security or policy middleware has seen them.
|
|
48
|
+
|
|
49
|
+
`agent_framework` must be importable at runtime for registration on an Agent. The
|
|
50
|
+
module still imports without it, so the gate logic can be unit tested alone.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
from collections.abc import Mapping
|
|
56
|
+
from typing import Any, Awaitable, Callable, Literal
|
|
57
|
+
|
|
58
|
+
from vsl_core.conformance.protocol import CompiledGate
|
|
59
|
+
from vsl_core.exceptions import AutomationDeniedException
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
from agent_framework import FunctionMiddleware as _FunctionMiddleware
|
|
63
|
+
from agent_framework import MiddlewareFailure as _MiddlewareFailure
|
|
64
|
+
|
|
65
|
+
_MIDDLEWARE_BASE: type = _FunctionMiddleware
|
|
66
|
+
_FAILURE_BASE: type = _MiddlewareFailure
|
|
67
|
+
except ImportError: # allow tests to import this module without agent-framework
|
|
68
|
+
_MIDDLEWARE_BASE = object # type: ignore[assignment]
|
|
69
|
+
|
|
70
|
+
class _FallbackFailure(Exception):
|
|
71
|
+
"""Stand-in for agent_framework.MiddlewareFailure when MAF is absent."""
|
|
72
|
+
|
|
73
|
+
_FAILURE_BASE = _FallbackFailure
|
|
74
|
+
|
|
75
|
+
OnDenial = Literal["abort", "return_to_model"]
|
|
76
|
+
_ON_DENIAL_VALUES = ("abort", "return_to_model")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class VSLGateDenied(_FAILURE_BASE): # type: ignore[misc, valid-type]
|
|
80
|
+
"""A VSL gate denied a tool call and the run was aborted, fail-closed.
|
|
81
|
+
|
|
82
|
+
A ``MiddlewareFailure``, so MAF propagates it to the caller of ``agent.run()``
|
|
83
|
+
instead of handing the model a tool error. ``denial`` is the gate's own
|
|
84
|
+
``AutomationDeniedException`` (or the more specific ``InvariantViolation``);
|
|
85
|
+
it is also the exception's ``__cause__``. ``function_name`` is the tool that
|
|
86
|
+
was not run.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, denial: AutomationDeniedException, function_name: str | None = None) -> None:
|
|
90
|
+
tool = f" '{function_name}'" if function_name else ""
|
|
91
|
+
super().__init__(f"VSL gate denied tool call{tool}: {denial}")
|
|
92
|
+
self.denial = denial
|
|
93
|
+
self.function_name = function_name
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def tool_arguments(context: Any) -> dict[str, Any]:
|
|
97
|
+
"""The tool call's arguments as a plain dict, whether MAF passed a Mapping or
|
|
98
|
+
a pydantic model. The usual ``candidate_input_fn`` for argument-based gates."""
|
|
99
|
+
arguments = getattr(context, "arguments", None)
|
|
100
|
+
if arguments is None:
|
|
101
|
+
return {}
|
|
102
|
+
if isinstance(arguments, Mapping):
|
|
103
|
+
return dict(arguments)
|
|
104
|
+
model_dump = getattr(arguments, "model_dump", None)
|
|
105
|
+
if callable(model_dump):
|
|
106
|
+
return dict(model_dump())
|
|
107
|
+
raise TypeError(f"Unsupported tool-argument type {type(arguments).__name__}.")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class VSLFunctionMiddleware(_MIDDLEWARE_BASE): # type: ignore[misc]
|
|
111
|
+
"""MAF FunctionMiddleware that applies a VSL gate before a tool runs.
|
|
112
|
+
|
|
113
|
+
On denial, ``call_next()`` is never called and the guarded tool never runs.
|
|
114
|
+
What the caller sees depends on ``on_denial`` (see the module docstring):
|
|
115
|
+
``"abort"`` (default) raises ``VSLGateDenied`` out of ``agent.run()``;
|
|
116
|
+
``"return_to_model"`` lets MAF hand the model a tool error.
|
|
117
|
+
|
|
118
|
+
``candidate_input_fn`` extracts what the gate evaluates from the
|
|
119
|
+
``FunctionInvocationContext``; it defaults to the whole context. For
|
|
120
|
+
argument-based gates use ``tool_arguments``.
|
|
121
|
+
|
|
122
|
+
``function_name`` scopes the gate to one tool; other tools pass through.
|
|
123
|
+
None means every tool call this middleware receives is gated.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
gate: CompiledGate,
|
|
129
|
+
*,
|
|
130
|
+
candidate_input_fn: Callable[[Any], Any] | None = None,
|
|
131
|
+
function_name: str | None = None,
|
|
132
|
+
on_denial: OnDenial = "abort",
|
|
133
|
+
) -> None:
|
|
134
|
+
if on_denial not in _ON_DENIAL_VALUES:
|
|
135
|
+
raise ValueError(f"on_denial must be one of {_ON_DENIAL_VALUES}, got {on_denial!r}.")
|
|
136
|
+
super().__init__()
|
|
137
|
+
self._gate = gate
|
|
138
|
+
self._extract = candidate_input_fn or (lambda ctx: ctx)
|
|
139
|
+
self._function_name = function_name
|
|
140
|
+
self._on_denial = on_denial
|
|
141
|
+
|
|
142
|
+
async def process(
|
|
143
|
+
self,
|
|
144
|
+
context: Any,
|
|
145
|
+
call_next: Callable[[], Awaitable[None]],
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Gate the tool call. MAF invokes this before the tool runs."""
|
|
148
|
+
tool_name = getattr(getattr(context, "function", None), "name", None)
|
|
149
|
+
if self._function_name is not None and tool_name != self._function_name:
|
|
150
|
+
await call_next()
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
await self._gate(self._extract(context))
|
|
155
|
+
except AutomationDeniedException as denial:
|
|
156
|
+
if self._on_denial == "abort":
|
|
157
|
+
raise VSLGateDenied(denial, tool_name) from denial
|
|
158
|
+
raise
|
|
159
|
+
except _FAILURE_BASE:
|
|
160
|
+
raise
|
|
161
|
+
except Exception as error:
|
|
162
|
+
if self._on_denial == "abort":
|
|
163
|
+
raise _FAILURE_BASE(f"VSL gate for '{tool_name}' failed; not running the tool unguarded") from error
|
|
164
|
+
raise
|
|
165
|
+
|
|
166
|
+
# Only reached if the gate returned normally (F1 pre-commitment).
|
|
167
|
+
await call_next()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vsl-maf
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Microsoft Agent Framework (agent-framework) adapter for VSL (VERBA Specification Language) governance primitives -- implements vsl-core's VSLAdapter conformance contract.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/supersemantics/VSL-Microsoft-agent-framework
|
|
7
|
+
Project-URL: Repository, https://github.com/supersemantics/VSL-Microsoft-agent-framework
|
|
8
|
+
Project-URL: Changelog, https://github.com/supersemantics/VSL-Microsoft-agent-framework/blob/HEAD/CHANGELOG.md
|
|
9
|
+
Project-URL: Issues, https://github.com/supersemantics/VSL-Microsoft-agent-framework/issues
|
|
10
|
+
Keywords: governance,ai-safety,guardrails,agent-framework,vsl,verba,microsoft-agent-framework
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Development Status :: 3 - Alpha
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: super-semantics-vsl<0.4,>=0.3
|
|
19
|
+
Requires-Dist: agent-framework-core>=1.25.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# vsl-maf
|
|
26
|
+
|
|
27
|
+
A [Microsoft Agent Framework](https://learn.microsoft.com/en-us/agent-framework/overview/) (`agent-framework`) adapter for [vsl-core](https://github.com/supersemantics/VSL-Core): implements `vsl_core.conformance.protocol.VSLAdapter` and provides `VSLFunctionMiddleware`, a `FunctionMiddleware` subclass that gates every tool call an `Agent` makes before it fires.
|
|
28
|
+
|
|
29
|
+
Requires vsl-core 0.2.0 or later and agent-framework-core 1.25 or later. Handover records (below) need vsl-core 0.3.0.
|
|
30
|
+
|
|
31
|
+
Is:
|
|
32
|
+
|
|
33
|
+
- A `VSLAdapter` implementation (`MAFAdapter`) that passes `vsl_core.conformance.suite.run_conformance_suite` with an empty result.
|
|
34
|
+
- `VSLFunctionMiddleware`, which calls a compiled gate inside `process()` before `call_next()`, so the gate completes strictly before the guarded tool's side effect (F1 pre-commitment), and makes a denial abort the run fail-closed.
|
|
35
|
+
|
|
36
|
+
Is not:
|
|
37
|
+
|
|
38
|
+
- A replacement for `vsl-core`: the PreNode/Invariant/Fallback/TerminalState constructs, the ledger and the governance vocabulary all live there. This package only compiles and wires them into MAF.
|
|
39
|
+
- Automatic interception. `VSLFunctionMiddleware` must be passed in the `middleware=[...]` list when creating an `Agent`.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install vsl-maf
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This installs vsl-core (`super-semantics-vsl`, imported as `vsl_core`) and `agent-framework-core`
|
|
48
|
+
with it. From a local clone of this repo: `pip install -e .`
|
|
49
|
+
|
|
50
|
+
Add the MAF connector for your model service as well, for example `agent-framework-foundry`.
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from typing import Annotated
|
|
56
|
+
|
|
57
|
+
from agent_framework import Agent, tool
|
|
58
|
+
from agent_framework.foundry import FoundryChatClient
|
|
59
|
+
from azure.identity.aio import AzureCliCredential
|
|
60
|
+
from pydantic import Field
|
|
61
|
+
|
|
62
|
+
from vsl_core.constructs import PreNode
|
|
63
|
+
from vsl_core.ledger import VerbaLedger
|
|
64
|
+
from vsl_core.metrics import ROBUST_GAMMA_DEFAULT_THRESHOLD, AssuranceBasis, F2Modification, GammaEstimate
|
|
65
|
+
from vsl_maf import MAFAdapter, VSLFunctionMiddleware, VSLGateDenied, tool_arguments
|
|
66
|
+
|
|
67
|
+
REFUND_LIMIT = 500.0
|
|
68
|
+
REFUND_GATE = "refund-within-policy"
|
|
69
|
+
ledger = VerbaLedger()
|
|
70
|
+
|
|
71
|
+
@tool(approval_mode="never_require")
|
|
72
|
+
def issue_refund(amount: Annotated[float, Field(description="Refund amount")]) -> str:
|
|
73
|
+
return f"Refunded {amount}"
|
|
74
|
+
|
|
75
|
+
async def refund_headroom(candidate) -> GammaEstimate:
|
|
76
|
+
amount = float(candidate.get("amount", 0.0))
|
|
77
|
+
# A ratio where 1.0 is break-even; 0.0 for a missing amount, so the gate fails closed.
|
|
78
|
+
estimate = GammaEstimate(gamma_hat=REFUND_LIMIT / amount if amount > 0 else 0.0)
|
|
79
|
+
ledger.write_gate_decision(
|
|
80
|
+
identity_key="refund-agent",
|
|
81
|
+
primitive_name=REFUND_GATE,
|
|
82
|
+
estimate=estimate,
|
|
83
|
+
gamma_threshold=ROBUST_GAMMA_DEFAULT_THRESHOLD,
|
|
84
|
+
)
|
|
85
|
+
return estimate
|
|
86
|
+
|
|
87
|
+
gate = MAFAdapter(identity_key="refund-agent").compile_pre_node(PreNode(
|
|
88
|
+
name=REFUND_GATE,
|
|
89
|
+
description="Refunds must stay comfortably inside the policy limit.",
|
|
90
|
+
monitor=refund_headroom,
|
|
91
|
+
# No model at the guarded call: the gate only allows or blocks.
|
|
92
|
+
assurance_basis=AssuranceBasis(f1_pre_commitment=True, f2_modification=F2Modification.NONE),
|
|
93
|
+
gamma_threshold=ROBUST_GAMMA_DEFAULT_THRESHOLD,
|
|
94
|
+
))
|
|
95
|
+
|
|
96
|
+
async with Agent(
|
|
97
|
+
client=FoundryChatClient(project_endpoint="...", model="gpt-4o-mini", credential=AzureCliCredential()),
|
|
98
|
+
name="RefundAgent",
|
|
99
|
+
instructions="Use issue_refund to process refund requests.",
|
|
100
|
+
tools=issue_refund,
|
|
101
|
+
middleware=[VSLFunctionMiddleware(gate, candidate_input_fn=tool_arguments, function_name="issue_refund")],
|
|
102
|
+
) as agent:
|
|
103
|
+
try:
|
|
104
|
+
result = await agent.run("Refund 900 to order 1234")
|
|
105
|
+
except VSLGateDenied as denied:
|
|
106
|
+
print(f"Refund denied before it ran: {denied.denial.reason}")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`compile_invariant` works the same way with an `Invariant`. Its denial carries the more specific `InvariantViolation` as `denied.denial`.
|
|
110
|
+
|
|
111
|
+
## How a denial reaches your code
|
|
112
|
+
|
|
113
|
+
MAF's function-invocation loop turns an ordinary exception from function middleware into a tool-error result and keeps running. The tool does not run, but the model is only told "Error: Function failed.", may retry or carry on, and `agent.run()` returns normally, so your code never learns about the denial. For a governance gate that is fail-open at the level of the run.
|
|
114
|
+
|
|
115
|
+
So by default (`on_denial="abort"`) a denial raises `VSLGateDenied`, a subclass of MAF's `MiddlewareFailure`. MAF's documented fail-closed signal is never turned into a tool result: it cancels the current batch of tool calls and propagates to the caller of `agent.run()` (for streaming runs, when the stream is consumed). `denied.denial` is the gate's `AutomationDeniedException` or `InvariantViolation` (also the exception's `__cause__`), and `denied.function_name` is the tool that did not run.
|
|
116
|
+
|
|
117
|
+
`on_denial="return_to_model"` keeps MAF's default: the model gets a tool error and the run continues. The tool still never runs. Use it only when you want the model to explain the refusal or take another route, knowing your code is not told.
|
|
118
|
+
|
|
119
|
+
A gate that fails for another reason (a monitor raising `RuntimeError`, say) also aborts with `MiddlewareFailure` in `"abort"` mode, so the tool never runs unguarded.
|
|
120
|
+
|
|
121
|
+
A caveat from MAF itself: when the model asks for several tools at once, an abort cancels the rest of the batch cooperatively. A synchronous tool body already running in a worker thread cannot be interrupted, though its result is discarded. Each gated tool's own gate still runs before that tool.
|
|
122
|
+
|
|
123
|
+
## How it works in MAF
|
|
124
|
+
|
|
125
|
+
MAF calls `process(context, call_next)` before every tool invocation:
|
|
126
|
+
|
|
127
|
+
- `context.function.name`: the tool being called.
|
|
128
|
+
- `context.arguments`: the arguments, as a mapping or a pydantic model. `tool_arguments(context)` returns a plain dict either way.
|
|
129
|
+
- `call_next()`: continues to the next middleware or the tool. It takes no arguments.
|
|
130
|
+
|
|
131
|
+
If the gate raises, `call_next()` is never reached and the tool never runs.
|
|
132
|
+
|
|
133
|
+
Register VSL middleware after any argument-repair middleware: MAF fails closed if arguments change after a policy middleware has seen them.
|
|
134
|
+
|
|
135
|
+
## Chaining multiple gates
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
middleware=[
|
|
139
|
+
VSLFunctionMiddleware(cap_gate, candidate_input_fn=tool_arguments, function_name="approve_purchase"),
|
|
140
|
+
VSLFunctionMiddleware(budget_gate, candidate_input_fn=tool_arguments, function_name="approve_purchase"),
|
|
141
|
+
]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
MAF runs middleware in registration order. If the first gate denies, the second gate and the tool never run.
|
|
145
|
+
|
|
146
|
+
## Writing gate values honestly
|
|
147
|
+
|
|
148
|
+
vsl-core never measures Gamma: your monitor computes `gamma_hat` and the gate only compares it, passing when it is strictly above `max(threshold, 1 + delta_estimation_error)` (vsl-core 0.2.0 rules, version `2026.10`).
|
|
149
|
+
|
|
150
|
+
- Build it as a ratio where 1.0 is break-even: a limit divided by the requested amount, a judge's score divided by its break-even score.
|
|
151
|
+
- Return `0.0` when the input is missing or unreadable, so the gate fails closed.
|
|
152
|
+
- Never pass a raw 0–1 confidence, or a confidence the model supplies in the tool's own arguments.
|
|
153
|
+
- Keep thresholds above 1.0 (`ROBUST_GAMMA_HIGH_STAKES_THRESHOLD`, 1.2, for payments, medical and legal actions). For a rule that must never be broken, use an `Invariant`.
|
|
154
|
+
|
|
155
|
+
## `VSLFunctionMiddleware` doesn't consume `Fallback`
|
|
156
|
+
|
|
157
|
+
A `PreNode`'s `Fallback` is policy data only here: on denial the middleware raises once, immediately, with no retry loop. `compile_pre_node` warns (`UserWarning`) if a `Fallback` is non-default, so this can't be silently assumed. vsl-core's own reference adapter doesn't consume `Fallback` either.
|
|
158
|
+
|
|
159
|
+
## Recording decisions: the ledger doesn't write itself
|
|
160
|
+
|
|
161
|
+
Nothing in this package writes to a `VerbaLedger`. That is the application's choice, and vsl-core's conformance contract forbids an adapter from making it. Record each gate's decision from inside its monitor or rule with `write_gate_decision()` (vsl-core 0.2.0), as in the usage example above:
|
|
162
|
+
|
|
163
|
+
- For a PreNode, pass the monitor's `GammaEstimate` and the PreNode's `gamma_threshold`. `allowed` is then computed with the same check the gate makes, and a contradiction is refused.
|
|
164
|
+
- For an Invariant, pass `allowed=holds`.
|
|
165
|
+
- Pass a `decision_id` to group a decision's records.
|
|
166
|
+
|
|
167
|
+
A denied gate's record flags drift, so follow it with the decision's next entry (`PRE_NODE`, then `VERIFICATION`, or `TERMINAL`) from your `except VSLGateDenied` block, as `examples/financial-agents/purchase_approval_agent.py` does. An agent with no ledger calls produces working gates and no audit trail, and this package won't warn you.
|
|
168
|
+
|
|
169
|
+
`VerbaLedger` accepts any `LedgerStore`: `JsonlLedgerStore` for a local append-only file, or the optional immudb store in vsl-core.
|
|
170
|
+
|
|
171
|
+
## Multi-agent workflows: handovers and Cluster Gamma
|
|
172
|
+
|
|
173
|
+
MAF composes agents: one agent can call another as a tool (`agent.as_tool()`), or a workflow can hand a conversation from one agent to the next. Each agent's own gates can pass while the hand-off between them goes unchecked.
|
|
174
|
+
|
|
175
|
+
With vsl-core 0.3.0, record the hand-off before the receiving agent acts, and have the receiver name it in its first gate decision:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
handover = ledger.write_handover(
|
|
179
|
+
identity_key="intake-agent",
|
|
180
|
+
to_identity_key="refund-agent",
|
|
181
|
+
decision_id=decision_id,
|
|
182
|
+
reason="customer asked for a refund",
|
|
183
|
+
)
|
|
184
|
+
# ...inside the refund agent's monitor:
|
|
185
|
+
ledger.write_gate_decision(
|
|
186
|
+
identity_key="refund-agent",
|
|
187
|
+
primitive_name=REFUND_GATE,
|
|
188
|
+
estimate=estimate,
|
|
189
|
+
gamma_threshold=ROBUST_GAMMA_DEFAULT_THRESHOLD,
|
|
190
|
+
decision_id=decision_id,
|
|
191
|
+
caused_by=handover.entry_id, # shows the receiver checked what it was handed
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
When the sub-agent is called as a tool, the calling agent's `VSLFunctionMiddleware` sees that call like any other, so the same gating applies to the hand-off itself.
|
|
196
|
+
|
|
197
|
+
Gating every tool call does not make the composed system safe. vsl-core's motivating example is the 2010 Flash Crash: every participant's Gamma was above 1, and the Cluster-level Gamma was still 0. If several agents coordinate on one outcome, declare them as a `vsl_core.cluster.Cluster` and pass its `cluster_key` on their records. The Cluster's `invariants` are plain `Invariant` objects, compiled and wired through `MAFAdapter`/`VSLFunctionMiddleware` like any other.
|
|
198
|
+
|
|
199
|
+
## The F1/F2 assurance distinction
|
|
200
|
+
|
|
201
|
+
Every `PreNode`/`Invariant` requires an `AssuranceBasis`. F2 is judged at the call the gate guards and is never earned by the gate itself:
|
|
202
|
+
|
|
203
|
+
| Gate | F1 | F2 | Level |
|
|
204
|
+
|---|---|---|---|
|
|
205
|
+
| Amount cap or budget check before a tool (no model at the call) | True | `NONE` | LOW |
|
|
206
|
+
| Gate before a hosted-model call whose system prompt the application writes | True | `PARTIAL` | MEDIUM |
|
|
207
|
+
| Gate before a self-hosted model where the application steers logits or activations | True | `FULL` | HIGH |
|
|
208
|
+
|
|
209
|
+
The level measures influence on the model, not enforcement: a LOW gate still blocks every tool call it denies. Report "blocks before the action: yes" separately rather than raising F2.
|
|
210
|
+
|
|
211
|
+
The basis is self-declared, not verified. `MAFAdapter` carries one heuristic check: `compile_pre_node`/`compile_invariant` warn (`UserWarning`) when a monitor or rule claims `F2Modification.FULL` but its source appears to call a hosted model API (`openai`, `anthropic`, `google.genai`, `cohere`, `mistralai`, Azure AI inference, or a MAF chat client such as `FoundryChatClient`). A hosted, black-box call cannot modify the energy function, so the honest ceiling is `PARTIAL`. The check can miss indirect calls, and passing it doesn't confirm a `FULL` claim.
|
|
212
|
+
|
|
213
|
+
## Sample agents
|
|
214
|
+
|
|
215
|
+
`examples/financial-agents/purchase_approval_agent.py` chains an Invariant (hard cap) and a PreNode (budget headroom) on a real MAF `Agent` with `FoundryChatClient`, records every decision, and handles denials. It needs Azure/Foundry credentials; see `examples/README.md`.
|
|
216
|
+
|
|
217
|
+
## Layout
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
src/vsl_maf/
|
|
221
|
+
├── adapter.py MAFAdapter (VSLAdapter conformance contract)
|
|
222
|
+
└── integration.py VSLFunctionMiddleware, VSLGateDenied, tool_arguments
|
|
223
|
+
examples/
|
|
224
|
+
└── financial-agents/purchase_approval_agent.py
|
|
225
|
+
tests/
|
|
226
|
+
├── test_conformance.py run_conformance_suite(MAFAdapter()) == []
|
|
227
|
+
├── test_integration.py VSLFunctionMiddleware.process() against a mock context
|
|
228
|
+
└── test_real_agent.py a real MAF Agent with a scripted chat client (no credentials)
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Status
|
|
232
|
+
|
|
233
|
+
Alpha. Conformance-suite green. Tested against agent-framework-core 1.25.0, including end-to-end runs through a real `Agent` and MAF's own function-invocation loop with a scripted chat client: allowed calls, denials aborting the run, `return_to_model`, Invariant denials and recorded gate decisions. The Foundry example has not been run against a live Foundry endpoint.
|
|
234
|
+
|
|
235
|
+
See [CHANGELOG.md](https://github.com/supersemantics/VSL-Microsoft-agent-framework/blob/HEAD/CHANGELOG.md).
|
|
236
|
+
|
|
237
|
+
## License
|
|
238
|
+
|
|
239
|
+
MIT -- see LICENSE. Copyright Super Semantics.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
vsl_maf/__init__.py,sha256=sXn7q4yvedQeyfNvuAs32NaveXwOEznYaUGSAbJW384,769
|
|
2
|
+
vsl_maf/adapter.py,sha256=1ktLeO19Yn7mJcHNzIfhJ-1a6NCSXCaO5Nqn36-Y14I,7778
|
|
3
|
+
vsl_maf/integration.py,sha256=HOvfv51y7iz5PuPScXDPBcHMfVE5sj0NppsluwGLkXI,7361
|
|
4
|
+
vsl_maf-0.2.2.dist-info/licenses/LICENSE,sha256=3bJgBD5-Y1rBZa2-Sp3pk9dOIhyZmkBpzahRKvYbylk,1067
|
|
5
|
+
vsl_maf-0.2.2.dist-info/METADATA,sha256=CVZHWiHKTY7qxlfplYVvzItZ_mdBf0EeuBee0p4nsws,14025
|
|
6
|
+
vsl_maf-0.2.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
7
|
+
vsl_maf-0.2.2.dist-info/top_level.txt,sha256=zEgqlofnqdOCwW-BaqTiJgesjAGGsvdMaOzbHexG_VM,8
|
|
8
|
+
vsl_maf-0.2.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Super Semantics
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
vsl_maf
|