adapt-agent 0.2.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.
- adapt_agent/__init__.py +100 -0
- adapt_agent/__main__.py +8 -0
- adapt_agent/adapters/__init__.py +47 -0
- adapt_agent/adapters/_governed.py +496 -0
- adapt_agent/adapters/base.py +79 -0
- adapt_agent/adapters/claude_agent/__init__.py +49 -0
- adapt_agent/adapters/crewai/__init__.py +71 -0
- adapt_agent/adapters/google_adk/__init__.py +50 -0
- adapt_agent/adapters/langgraph/__init__.py +45 -0
- adapt_agent/adapters/microsoft_agent_framework/__init__.py +47 -0
- adapt_agent/adapters/openai_agents/__init__.py +65 -0
- adapt_agent/adapters/pydantic_ai/__init__.py +45 -0
- adapt_agent/adversarial/__init__.py +254 -0
- adapt_agent/cli/__init__.py +885 -0
- adapt_agent/cli/__main__.py +8 -0
- adapt_agent/core/__init__.py +13 -0
- adapt_agent/core/memory.py +223 -0
- adapt_agent/core/middleware.py +288 -0
- adapt_agent/core/policy.py +404 -0
- adapt_agent/core/trust.py +200 -0
- adapt_agent/core/types.py +82 -0
- adapt_agent/evaluation/__init__.py +209 -0
- adapt_agent/exceptions.py +52 -0
- adapt_agent/observability/__init__.py +287 -0
- adapt_agent/optimization/__init__.py +191 -0
- adapt_agent/optimization/config.py +701 -0
- adapt_agent/optimization/dataset.py +271 -0
- adapt_agent/optimization/evals.py +257 -0
- adapt_agent/optimization/evaluation.py +329 -0
- adapt_agent/optimization/extractors.py +346 -0
- adapt_agent/optimization/introspection/__init__.py +305 -0
- adapt_agent/optimization/introspection/claude_agent.py +150 -0
- adapt_agent/optimization/introspection/crewai.py +190 -0
- adapt_agent/optimization/introspection/google_adk.py +218 -0
- adapt_agent/optimization/introspection/langgraph.py +245 -0
- adapt_agent/optimization/introspection/microsoft_agent_framework.py +177 -0
- adapt_agent/optimization/introspection/openai_agents.py +219 -0
- adapt_agent/optimization/introspection/pydantic_ai.py +238 -0
- adapt_agent/optimization/judge.py +771 -0
- adapt_agent/optimization/judges.py +254 -0
- adapt_agent/optimization/metrics.py +424 -0
- adapt_agent/optimization/optimizers.py +815 -0
- adapt_agent/optimization/parameters.py +307 -0
- adapt_agent/optimization/performance.py +201 -0
- adapt_agent/optimization/proposers.py +464 -0
- adapt_agent/optimization/providers.py +783 -0
- adapt_agent/optimization/runners.py +244 -0
- adapt_agent/optimization/target.py +318 -0
- adapt_agent/patches/__init__.py +114 -0
- adapt_agent/py.typed +0 -0
- adapt_agent/security/__init__.py +11 -0
- adapt_agent/security/firewall.py +366 -0
- adapt_agent/security/taint_tracker.py +346 -0
- adapt_agent/skills/__init__.py +460 -0
- adapt_agent/skills/adapt-agent/SKILL.md +232 -0
- adapt_agent/skills/adapt-agent/references/evals.md +248 -0
- adapt_agent/skills/adapt-agent/references/guardrails.md +313 -0
- adapt_agent/skills/adapt-agent/references/optimization.md +227 -0
- adapt_agent-0.2.0.dist-info/METADATA +401 -0
- adapt_agent-0.2.0.dist-info/RECORD +64 -0
- adapt_agent-0.2.0.dist-info/WHEEL +5 -0
- adapt_agent-0.2.0.dist-info/entry_points.txt +3 -0
- adapt_agent-0.2.0.dist-info/licenses/LICENSE +21 -0
- adapt_agent-0.2.0.dist-info/top_level.txt +1 -0
adapt_agent/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ADAPT-Agent: Adversarial Defense & Policy Training for LLM Agents
|
|
3
|
+
|
|
4
|
+
A comprehensive library for LLM agent optimization and security.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
__version__ = "0.2.0"
|
|
8
|
+
|
|
9
|
+
# Core exports
|
|
10
|
+
# Framework adapters
|
|
11
|
+
from adapt_agent.adapters import BaseAdapter
|
|
12
|
+
|
|
13
|
+
# Adversarial defense
|
|
14
|
+
from adapt_agent.adversarial import AdversarialDefense
|
|
15
|
+
from adapt_agent.core import (
|
|
16
|
+
MemorySystem,
|
|
17
|
+
Middleware,
|
|
18
|
+
PolicyEnforcer,
|
|
19
|
+
TrustManager,
|
|
20
|
+
)
|
|
21
|
+
from adapt_agent.core.types import (
|
|
22
|
+
Adapter,
|
|
23
|
+
Agent,
|
|
24
|
+
AgentMessage,
|
|
25
|
+
AgentState,
|
|
26
|
+
PolicyRule,
|
|
27
|
+
SecurityEvent,
|
|
28
|
+
TrustScore,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Evaluation, observability, optimization
|
|
32
|
+
from adapt_agent.evaluation import AgentEvaluator
|
|
33
|
+
from adapt_agent.observability import AgentObserver
|
|
34
|
+
from adapt_agent.optimization import (
|
|
35
|
+
AgentOptimizer,
|
|
36
|
+
CoordinateAscentOptimizer,
|
|
37
|
+
EvaluationHarness,
|
|
38
|
+
Example,
|
|
39
|
+
GoldenDataset,
|
|
40
|
+
LLMJudge,
|
|
41
|
+
ModelProvider,
|
|
42
|
+
OptimizableAgent,
|
|
43
|
+
Parameter,
|
|
44
|
+
ParameterKind,
|
|
45
|
+
get_provider,
|
|
46
|
+
make_default_optimizer,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# Patches
|
|
50
|
+
from adapt_agent.patches import PatchManager
|
|
51
|
+
|
|
52
|
+
# Security exports
|
|
53
|
+
from adapt_agent.security import (
|
|
54
|
+
Firewall,
|
|
55
|
+
TaintLevel,
|
|
56
|
+
TaintSource,
|
|
57
|
+
TaintTracker,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
__all__ = [
|
|
61
|
+
"__version__",
|
|
62
|
+
# Core
|
|
63
|
+
"TrustManager",
|
|
64
|
+
"PolicyEnforcer",
|
|
65
|
+
"MemorySystem",
|
|
66
|
+
"Middleware",
|
|
67
|
+
# Types
|
|
68
|
+
"Agent",
|
|
69
|
+
"Adapter",
|
|
70
|
+
"AgentMessage",
|
|
71
|
+
"AgentState",
|
|
72
|
+
"TrustScore",
|
|
73
|
+
"PolicyRule",
|
|
74
|
+
"SecurityEvent",
|
|
75
|
+
# Security
|
|
76
|
+
"Firewall",
|
|
77
|
+
"TaintTracker",
|
|
78
|
+
"TaintLevel",
|
|
79
|
+
"TaintSource",
|
|
80
|
+
# Adversarial / evaluation / observability / optimization
|
|
81
|
+
"AdversarialDefense",
|
|
82
|
+
"AgentEvaluator",
|
|
83
|
+
"AgentObserver",
|
|
84
|
+
"AgentOptimizer",
|
|
85
|
+
# Dataset-driven optimization & evaluation
|
|
86
|
+
"GoldenDataset",
|
|
87
|
+
"Example",
|
|
88
|
+
"EvaluationHarness",
|
|
89
|
+
"LLMJudge",
|
|
90
|
+
"ModelProvider",
|
|
91
|
+
"get_provider",
|
|
92
|
+
"OptimizableAgent",
|
|
93
|
+
"Parameter",
|
|
94
|
+
"ParameterKind",
|
|
95
|
+
"CoordinateAscentOptimizer",
|
|
96
|
+
"make_default_optimizer",
|
|
97
|
+
# Patches & adapters
|
|
98
|
+
"PatchManager",
|
|
99
|
+
"BaseAdapter",
|
|
100
|
+
]
|
adapt_agent/__main__.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Framework adapters for ADAPT-Agent.
|
|
2
|
+
|
|
3
|
+
Adapters integrate ADAPT-Agent's security and governance primitives with
|
|
4
|
+
third-party LLM agent frameworks. Every adapter applies the same pipeline on
|
|
5
|
+
each ``execute`` call -- input screening, policy enforcement, middleware, traced
|
|
6
|
+
execution, output screening -- regardless of the underlying framework.
|
|
7
|
+
|
|
8
|
+
Support matrix:
|
|
9
|
+
|
|
10
|
+
========================== ========== =========================================
|
|
11
|
+
Framework Status Class
|
|
12
|
+
========================== ========== =========================================
|
|
13
|
+
LangGraph Supported :class:`LangGraphAdapter`
|
|
14
|
+
Microsoft Agent Framework Supported :class:`MicrosoftAgentFrameworkAdapter`
|
|
15
|
+
Google ADK Supported :class:`GoogleADKAdapter`
|
|
16
|
+
Pydantic AI Supported :class:`PydanticAIAdapter`
|
|
17
|
+
CrewAI Supported :class:`CrewAIAdapter`
|
|
18
|
+
OpenAI Agents SDK Supported :class:`OpenAIAgentsAdapter`
|
|
19
|
+
Claude Agent SDK Supported :class:`ClaudeAgentSDKAdapter`
|
|
20
|
+
========================== ========== =========================================
|
|
21
|
+
|
|
22
|
+
Importing an adapter class never imports the underlying framework; the framework
|
|
23
|
+
is only imported lazily when you actually wrap and run an agent, so
|
|
24
|
+
``adapt_agent`` stays import-safe without optional dependencies installed.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from adapt_agent.adapters._governed import GovernedAdapter
|
|
28
|
+
from adapt_agent.adapters.base import BaseAdapter
|
|
29
|
+
from adapt_agent.adapters.claude_agent import ClaudeAgentSDKAdapter
|
|
30
|
+
from adapt_agent.adapters.crewai import CrewAIAdapter
|
|
31
|
+
from adapt_agent.adapters.google_adk import GoogleADKAdapter
|
|
32
|
+
from adapt_agent.adapters.langgraph import LangGraphAdapter
|
|
33
|
+
from adapt_agent.adapters.microsoft_agent_framework import MicrosoftAgentFrameworkAdapter
|
|
34
|
+
from adapt_agent.adapters.openai_agents import OpenAIAgentsAdapter
|
|
35
|
+
from adapt_agent.adapters.pydantic_ai import PydanticAIAdapter
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"BaseAdapter",
|
|
39
|
+
"GovernedAdapter",
|
|
40
|
+
"LangGraphAdapter",
|
|
41
|
+
"MicrosoftAgentFrameworkAdapter",
|
|
42
|
+
"GoogleADKAdapter",
|
|
43
|
+
"PydanticAIAdapter",
|
|
44
|
+
"CrewAIAdapter",
|
|
45
|
+
"OpenAIAgentsAdapter",
|
|
46
|
+
"ClaudeAgentSDKAdapter",
|
|
47
|
+
]
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Shared governance machinery for ADAPT-Agent framework adapters.
|
|
2
|
+
|
|
3
|
+
This module factors out the framework-agnostic parts of an adapter so that each
|
|
4
|
+
concrete adapter (LangGraph, CrewAI, Pydantic AI, ...) only has to declare a few
|
|
5
|
+
small details: which method runs the underlying agent, how to shape the input,
|
|
6
|
+
and a human-readable framework name.
|
|
7
|
+
|
|
8
|
+
The :class:`GovernedAdapter` applies, in order, on every ``execute`` call:
|
|
9
|
+
|
|
10
|
+
1. **Input screening** -- :class:`~adapt_agent.security.Firewall` and
|
|
11
|
+
:class:`~adapt_agent.adversarial.AdversarialDefense` scan every string found
|
|
12
|
+
in the payload.
|
|
13
|
+
2. **Policy enforcement** -- a :class:`~adapt_agent.core.PolicyEnforcer` is
|
|
14
|
+
evaluated against the extracted :class:`~adapt_agent.core.types.AgentState`.
|
|
15
|
+
3. **Pre-middleware** -- a :class:`~adapt_agent.core.Middleware` pipeline may
|
|
16
|
+
rewrite the payload.
|
|
17
|
+
4. **Traced execution** -- the framework agent runs, optionally traced by an
|
|
18
|
+
:class:`~adapt_agent.observability.AgentObserver`.
|
|
19
|
+
5. **Post-middleware** -- the pipeline may rewrite the result.
|
|
20
|
+
6. **Output screening** -- the firewall scans every string in the result.
|
|
21
|
+
|
|
22
|
+
The design is intentionally *structural*: importing an adapter never imports the
|
|
23
|
+
underlying framework. The framework is only needed at runtime to build the agent
|
|
24
|
+
you pass to :meth:`wrap_agent`. Adapters wrap anything that exposes a recognized
|
|
25
|
+
"run" method -- or any plain callable -- so they work without the optional
|
|
26
|
+
dependency installed and are trivial to unit-test.
|
|
27
|
+
|
|
28
|
+
Async support
|
|
29
|
+
-------------
|
|
30
|
+
Several modern frameworks expose async-only entry points. ``execute`` therefore
|
|
31
|
+
resolves the framework result transparently:
|
|
32
|
+
|
|
33
|
+
* a coroutine is run to completion;
|
|
34
|
+
* an async generator (or any async iterator) is drained into a list;
|
|
35
|
+
* a sync generator is likewise materialised into a list.
|
|
36
|
+
|
|
37
|
+
When called from inside a running event loop, blocking on a coroutine is not
|
|
38
|
+
possible; in that case a clear :class:`AdapterError` is raised pointing the
|
|
39
|
+
caller at the framework's native async API.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import asyncio
|
|
43
|
+
import inspect
|
|
44
|
+
import uuid
|
|
45
|
+
from collections.abc import AsyncIterable, Callable
|
|
46
|
+
from typing import Any, cast
|
|
47
|
+
|
|
48
|
+
from adapt_agent.adapters.base import BaseAdapter
|
|
49
|
+
from adapt_agent.adversarial import AdversarialDefense
|
|
50
|
+
from adapt_agent.core.middleware import Middleware
|
|
51
|
+
from adapt_agent.core.policy import PolicyEnforcer
|
|
52
|
+
from adapt_agent.core.types import Agent, AgentState
|
|
53
|
+
from adapt_agent.exceptions import AdapterError, SecurityBlockedError
|
|
54
|
+
from adapt_agent.observability import AgentObserver
|
|
55
|
+
from adapt_agent.security.firewall import Firewall
|
|
56
|
+
|
|
57
|
+
#: A callable that runs a framework agent given its prepared input. Accepts
|
|
58
|
+
#: arbitrary call signatures (some adapters call it with keyword arguments).
|
|
59
|
+
Runner = Callable[..., Any]
|
|
60
|
+
|
|
61
|
+
#: Attribute names that commonly hold human-readable text on framework result
|
|
62
|
+
#: and message objects (Pydantic AI ``.output``, Microsoft Agent Framework
|
|
63
|
+
#: ``.text``, CrewAI ``.raw``, OpenAI Agents ``.final_output``, Claude
|
|
64
|
+
#: ``ResultMessage.result`` / ``TextBlock.text``, LangChain ``.content`` ...).
|
|
65
|
+
_TEXT_ATTRS = (
|
|
66
|
+
"content",
|
|
67
|
+
"text",
|
|
68
|
+
"output",
|
|
69
|
+
"final_output",
|
|
70
|
+
"raw",
|
|
71
|
+
"result",
|
|
72
|
+
"data",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
#: Attribute names that hold *containers* of further text-bearing objects
|
|
76
|
+
#: (e.g. ``Content.parts`` in Google ADK, message lists, CrewAI ``tasks_output``).
|
|
77
|
+
#: These are recursed into even when they are framework objects rather than
|
|
78
|
+
#: plain dicts/lists, so screening reaches deeply-structured results.
|
|
79
|
+
_RECURSE_ATTRS = (
|
|
80
|
+
"content",
|
|
81
|
+
"parts",
|
|
82
|
+
"messages",
|
|
83
|
+
"tasks_output",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _safe_getattr(obj: Any, attr: str) -> Any:
|
|
88
|
+
"""``getattr(obj, attr, None)`` that never propagates.
|
|
89
|
+
|
|
90
|
+
Framework result objects may expose attributes via descriptors/properties
|
|
91
|
+
that raise. Since text extraction feeds the security pipeline, a raising
|
|
92
|
+
attribute must not crash the whole execution -- treat it as absent.
|
|
93
|
+
"""
|
|
94
|
+
try:
|
|
95
|
+
return getattr(obj, attr, None)
|
|
96
|
+
except Exception:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _extract_texts(data: Any) -> list[str]:
|
|
101
|
+
"""Best-effort extraction of human-readable text from an arbitrary payload.
|
|
102
|
+
|
|
103
|
+
Adapter payloads are typically dicts that may contain a ``messages`` list or
|
|
104
|
+
arbitrary string fields, but framework result objects expose their text via
|
|
105
|
+
attributes instead (see :data:`_TEXT_ATTRS`). We collect every string we can
|
|
106
|
+
reach so the security controls can scan it without assuming a fixed schema.
|
|
107
|
+
"""
|
|
108
|
+
texts: list[str] = []
|
|
109
|
+
|
|
110
|
+
def _walk(value: Any, depth: int = 0) -> None:
|
|
111
|
+
if depth > 6: # bound recursion (defensive against pathological nesting)
|
|
112
|
+
return
|
|
113
|
+
if isinstance(value, str):
|
|
114
|
+
texts.append(value)
|
|
115
|
+
elif value is None or isinstance(value, (int, float, bool)):
|
|
116
|
+
# Primitives carry no text and have no attributes worth probing;
|
|
117
|
+
# returning early avoids pointless getattr lookups on large payloads.
|
|
118
|
+
return
|
|
119
|
+
elif isinstance(value, dict):
|
|
120
|
+
for v in value.values():
|
|
121
|
+
_walk(v, depth + 1)
|
|
122
|
+
elif isinstance(value, (list, tuple)):
|
|
123
|
+
for v in value:
|
|
124
|
+
_walk(v, depth + 1)
|
|
125
|
+
else:
|
|
126
|
+
# Framework message / result objects expose their text via one of a
|
|
127
|
+
# handful of well-known attributes. Recurse into anything walkable.
|
|
128
|
+
for attr in _TEXT_ATTRS:
|
|
129
|
+
inner = _safe_getattr(value, attr)
|
|
130
|
+
if isinstance(inner, str):
|
|
131
|
+
texts.append(inner)
|
|
132
|
+
elif isinstance(inner, (dict, list, tuple)):
|
|
133
|
+
_walk(inner, depth + 1)
|
|
134
|
+
# Structured content containers (e.g. genai ``Content.parts``) hold
|
|
135
|
+
# further objects whose text we still want to scan.
|
|
136
|
+
for attr in _RECURSE_ATTRS:
|
|
137
|
+
inner = _safe_getattr(value, attr)
|
|
138
|
+
if inner is not None and not isinstance(inner, (str, int, float, bool)):
|
|
139
|
+
_walk(inner, depth + 1)
|
|
140
|
+
|
|
141
|
+
_walk(data)
|
|
142
|
+
return texts
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _extract_prompt(payload: Any) -> str:
|
|
146
|
+
"""Derive a single prompt string from an adapter payload.
|
|
147
|
+
|
|
148
|
+
Many frameworks (Pydantic AI, OpenAI Agents, Microsoft Agent Framework,
|
|
149
|
+
Claude Agent SDK) run from a string prompt rather than a state dict. This
|
|
150
|
+
helper picks the most plausible prompt: the latest user message, then a
|
|
151
|
+
common prompt-like key, then a string fallback. It accepts a state dict, a
|
|
152
|
+
bare list/tuple of messages, or a plain string.
|
|
153
|
+
"""
|
|
154
|
+
if isinstance(payload, str):
|
|
155
|
+
return payload
|
|
156
|
+
|
|
157
|
+
if isinstance(payload, dict):
|
|
158
|
+
messages: Any = payload.get("messages")
|
|
159
|
+
elif isinstance(payload, (list, tuple)):
|
|
160
|
+
messages = payload
|
|
161
|
+
else:
|
|
162
|
+
messages = None
|
|
163
|
+
|
|
164
|
+
if isinstance(messages, (list, tuple)) and messages:
|
|
165
|
+
for message in reversed(messages):
|
|
166
|
+
if isinstance(message, dict):
|
|
167
|
+
role, content = message.get("role"), message.get("content")
|
|
168
|
+
else:
|
|
169
|
+
role = _safe_getattr(message, "role")
|
|
170
|
+
content = _safe_getattr(message, "content")
|
|
171
|
+
role_str = role.lower() if isinstance(role, str) else None
|
|
172
|
+
if isinstance(content, str) and (role_str == "user" or role is None):
|
|
173
|
+
return content
|
|
174
|
+
last = messages[-1]
|
|
175
|
+
last_content = (
|
|
176
|
+
last.get("content") if isinstance(last, dict) else _safe_getattr(last, "content")
|
|
177
|
+
)
|
|
178
|
+
if isinstance(last_content, str):
|
|
179
|
+
return last_content
|
|
180
|
+
|
|
181
|
+
if isinstance(payload, dict):
|
|
182
|
+
for key in ("prompt", "input", "query", "text"):
|
|
183
|
+
value = payload.get(key)
|
|
184
|
+
if isinstance(value, str):
|
|
185
|
+
return value
|
|
186
|
+
return str(payload)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolve_result(value: Any) -> Any:
|
|
190
|
+
"""Materialise a sync/async framework result into a concrete value.
|
|
191
|
+
|
|
192
|
+
* Coroutines are run to completion.
|
|
193
|
+
* Async generators / async iterators are drained into a list.
|
|
194
|
+
* Sync generators are materialised into a list.
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
AdapterError: If an awaitable is produced while an event loop is already
|
|
198
|
+
running on this thread (where blocking is unsafe).
|
|
199
|
+
"""
|
|
200
|
+
# ``AsyncIterable`` covers both ``async def`` generators and custom async
|
|
201
|
+
# iterators (``__aiter__``/``__anext__``), which streaming SDKs often return.
|
|
202
|
+
if isinstance(value, AsyncIterable):
|
|
203
|
+
return _run_coro(_drain_async_gen(value))
|
|
204
|
+
if inspect.isawaitable(value):
|
|
205
|
+
return _run_coro(_await(value))
|
|
206
|
+
if inspect.isgenerator(value):
|
|
207
|
+
return list(value)
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def _await(value: Any) -> Any:
|
|
212
|
+
return await value
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
async def _drain_async_gen(gen: Any) -> list[Any]:
|
|
216
|
+
return [item async for item in gen]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _run_coro(coro: Any) -> Any:
|
|
220
|
+
try:
|
|
221
|
+
running = asyncio.get_running_loop()
|
|
222
|
+
except RuntimeError:
|
|
223
|
+
running = None
|
|
224
|
+
if running is not None:
|
|
225
|
+
coro.close()
|
|
226
|
+
raise AdapterError(
|
|
227
|
+
"Cannot synchronously run an async agent from inside a running event "
|
|
228
|
+
"loop. Call the framework's native async API directly, or run "
|
|
229
|
+
"execute() in a worker thread."
|
|
230
|
+
)
|
|
231
|
+
return asyncio.run(coro)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class GovernedAdapter(BaseAdapter):
|
|
235
|
+
"""Base adapter that wraps a framework agent with ADAPT-Agent governance.
|
|
236
|
+
|
|
237
|
+
Subclasses customise behaviour by overriding the class attributes below and,
|
|
238
|
+
where a framework needs special handling, the :meth:`_resolve_runner`,
|
|
239
|
+
:meth:`_prepare_input` and :meth:`_call_runner` hooks.
|
|
240
|
+
|
|
241
|
+
Class attributes:
|
|
242
|
+
framework_name: Human-readable framework name (used by
|
|
243
|
+
:meth:`get_framework_name` and default ``agent_id``).
|
|
244
|
+
run_method_names: Method names to look for on the wrapped object, in
|
|
245
|
+
priority order. The first callable match becomes the runner. If none
|
|
246
|
+
match and the object itself is callable, the object is used directly.
|
|
247
|
+
operation: Trace operation label recorded by the observer.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
config: Optional adapter configuration dictionary.
|
|
251
|
+
firewall: Optional :class:`Firewall` used to screen inputs/outputs.
|
|
252
|
+
defense: Optional :class:`AdversarialDefense` used on inputs.
|
|
253
|
+
policy_enforcer: Optional :class:`PolicyEnforcer` evaluated against the
|
|
254
|
+
extracted agent state before execution.
|
|
255
|
+
observer: Optional :class:`AgentObserver` used to trace executions.
|
|
256
|
+
middleware: Optional :class:`Middleware` pipeline applied to the input
|
|
257
|
+
payload (pre) and the result payload (post).
|
|
258
|
+
agent_id: Stable identifier used in traces and policy checks. Defaults to
|
|
259
|
+
``"<framework>-agent"``.
|
|
260
|
+
block_on_violation: When ``True`` (default), a firewall/defense hit or a
|
|
261
|
+
``block`` policy action raises :class:`SecurityBlockedError`. When
|
|
262
|
+
``False`` the execution proceeds but threats are still recorded.
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
framework_name: str = "Governed"
|
|
266
|
+
run_method_names: tuple[str, ...] = ("invoke",)
|
|
267
|
+
operation: str = "invoke"
|
|
268
|
+
|
|
269
|
+
def __init__(
|
|
270
|
+
self,
|
|
271
|
+
config: dict[str, Any] | None = None,
|
|
272
|
+
*,
|
|
273
|
+
firewall: Firewall | None = None,
|
|
274
|
+
defense: AdversarialDefense | None = None,
|
|
275
|
+
policy_enforcer: PolicyEnforcer | None = None,
|
|
276
|
+
observer: AgentObserver | None = None,
|
|
277
|
+
middleware: Middleware | None = None,
|
|
278
|
+
agent_id: str | None = None,
|
|
279
|
+
block_on_violation: bool = True,
|
|
280
|
+
):
|
|
281
|
+
super().__init__(config)
|
|
282
|
+
self.firewall = firewall
|
|
283
|
+
self.defense = defense
|
|
284
|
+
self.policy_enforcer = policy_enforcer
|
|
285
|
+
self.observer = observer
|
|
286
|
+
self.middleware = middleware
|
|
287
|
+
self.agent_id = agent_id or f"{self.framework_name.lower().replace(' ', '-')}-agent"
|
|
288
|
+
self.block_on_violation = block_on_violation
|
|
289
|
+
|
|
290
|
+
# -- framework-specific hooks ---------------------------------------------
|
|
291
|
+
|
|
292
|
+
def _resolve_runner(self, agent: Any) -> Runner | None:
|
|
293
|
+
"""Return a callable that runs ``agent`` given prepared input, or ``None``.
|
|
294
|
+
|
|
295
|
+
The default looks for the first callable attribute named in
|
|
296
|
+
:attr:`run_method_names`, then falls back to the object itself if it is
|
|
297
|
+
callable. Subclasses may override to support runner/agent frameworks.
|
|
298
|
+
"""
|
|
299
|
+
for name in self.run_method_names:
|
|
300
|
+
candidate = getattr(agent, name, None)
|
|
301
|
+
if callable(candidate):
|
|
302
|
+
return cast(Runner, candidate)
|
|
303
|
+
if callable(agent):
|
|
304
|
+
return cast(Runner, agent)
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
def _prepare_input(self, payload: dict[str, Any]) -> Any:
|
|
308
|
+
"""Transform the (post-middleware) payload into the runner's argument.
|
|
309
|
+
|
|
310
|
+
The default passes the payload through unchanged (state-dict frameworks
|
|
311
|
+
such as LangGraph). Prompt-based frameworks override this to return a
|
|
312
|
+
string via :func:`_extract_prompt`.
|
|
313
|
+
"""
|
|
314
|
+
return payload
|
|
315
|
+
|
|
316
|
+
def _call_runner(self, runner: Runner, payload: dict[str, Any]) -> Any:
|
|
317
|
+
"""Invoke ``runner`` with the prepared input and return the raw result."""
|
|
318
|
+
return runner(self._prepare_input(payload))
|
|
319
|
+
|
|
320
|
+
# -- BaseAdapter contract --------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def validate_agent(self, agent: Any) -> bool:
|
|
323
|
+
"""Return ``True`` if a runner can be resolved from ``agent``."""
|
|
324
|
+
return self._resolve_runner(agent) is not None
|
|
325
|
+
|
|
326
|
+
def wrap_agent(self, agent: Any) -> Agent:
|
|
327
|
+
"""Wrap a framework agent with ADAPT-Agent governance.
|
|
328
|
+
|
|
329
|
+
Raises:
|
|
330
|
+
AdapterError: If no runnable entry point can be resolved.
|
|
331
|
+
"""
|
|
332
|
+
if not self.validate_agent(agent):
|
|
333
|
+
raise AdapterError(
|
|
334
|
+
f"{type(self).__name__}.wrap_agent could not find a runnable entry "
|
|
335
|
+
f"point on the supplied object. Expected one of "
|
|
336
|
+
f"{', '.join(self.run_method_names)!r} or a plain callable. See the "
|
|
337
|
+
f"adapter docstring for the object to wrap."
|
|
338
|
+
)
|
|
339
|
+
return _GovernedAgent(agent, self)
|
|
340
|
+
|
|
341
|
+
def extract_state(self, agent: Any) -> AgentState:
|
|
342
|
+
"""Extract a normalized :class:`AgentState` from a state payload or agent.
|
|
343
|
+
|
|
344
|
+
Accepts either a state mapping (e.g. ``{"messages": [...], ...}``) or a
|
|
345
|
+
stateful object exposing ``get_state``. Extraction is best-effort and
|
|
346
|
+
always returns a well-formed :class:`AgentState`.
|
|
347
|
+
"""
|
|
348
|
+
raw: Any = agent
|
|
349
|
+
get_state = getattr(agent, "get_state", None)
|
|
350
|
+
if callable(get_state):
|
|
351
|
+
try:
|
|
352
|
+
snapshot = get_state(self.config.get("graph_config", {}))
|
|
353
|
+
raw = getattr(snapshot, "values", snapshot)
|
|
354
|
+
except Exception:
|
|
355
|
+
raw = {}
|
|
356
|
+
|
|
357
|
+
if not isinstance(raw, dict):
|
|
358
|
+
raw = {}
|
|
359
|
+
|
|
360
|
+
messages = raw.get("messages", [])
|
|
361
|
+
if not isinstance(messages, list):
|
|
362
|
+
messages = []
|
|
363
|
+
|
|
364
|
+
context = {k: v for k, v in raw.items() if k != "messages"}
|
|
365
|
+
|
|
366
|
+
state: AgentState = {"messages": messages, "context": context}
|
|
367
|
+
if "trust_score" in raw and isinstance(raw["trust_score"], (int, float)):
|
|
368
|
+
state["trust_score"] = float(raw["trust_score"])
|
|
369
|
+
if "policy_violations" in raw and isinstance(raw["policy_violations"], list):
|
|
370
|
+
state["policy_violations"] = raw["policy_violations"]
|
|
371
|
+
return state
|
|
372
|
+
|
|
373
|
+
def inject_middleware(self, agent: Any, middleware: Any) -> Any:
|
|
374
|
+
"""Attach a middleware pipeline and return a freshly wrapped agent."""
|
|
375
|
+
if not isinstance(middleware, Middleware):
|
|
376
|
+
raise AdapterError("inject_middleware expects an adapt_agent.core.Middleware instance.")
|
|
377
|
+
self.middleware = middleware
|
|
378
|
+
return self.wrap_agent(agent)
|
|
379
|
+
|
|
380
|
+
def get_framework_name(self) -> str:
|
|
381
|
+
"""Return the human-readable framework name."""
|
|
382
|
+
return self.framework_name
|
|
383
|
+
|
|
384
|
+
# -- internal helpers shared with the wrapped agent ------------------------
|
|
385
|
+
|
|
386
|
+
def _screen_input(self, payload: Any) -> list[str]:
|
|
387
|
+
"""Run firewall + adversarial defense over a payload, returning threats."""
|
|
388
|
+
threats: list[str] = []
|
|
389
|
+
for text in _extract_texts(payload):
|
|
390
|
+
if self.firewall is not None and not self.firewall.check_input(text):
|
|
391
|
+
threats.append("firewall")
|
|
392
|
+
if self.defense is not None:
|
|
393
|
+
analysis = self.defense.analyze_input(text)
|
|
394
|
+
if not analysis["is_safe"]:
|
|
395
|
+
threats.extend(analysis["threats_detected"])
|
|
396
|
+
return threats
|
|
397
|
+
|
|
398
|
+
def _screen_output(self, payload: Any) -> list[str]:
|
|
399
|
+
"""Run firewall over an output payload, returning threats."""
|
|
400
|
+
threats: list[str] = []
|
|
401
|
+
if self.firewall is None:
|
|
402
|
+
return threats
|
|
403
|
+
for text in _extract_texts(payload):
|
|
404
|
+
if not self.firewall.check_output(text):
|
|
405
|
+
threats.append("firewall")
|
|
406
|
+
return threats
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class _GovernedAgent:
|
|
410
|
+
"""A framework agent wrapped with ADAPT-Agent governance.
|
|
411
|
+
|
|
412
|
+
Implements the :class:`~adapt_agent.core.types.Agent` protocol (``execute``
|
|
413
|
+
and ``get_state``).
|
|
414
|
+
"""
|
|
415
|
+
|
|
416
|
+
def __init__(self, agent: Any, adapter: GovernedAdapter):
|
|
417
|
+
self._agent = agent
|
|
418
|
+
self._adapter = adapter
|
|
419
|
+
# ``wrap_agent`` validates the agent first, so a runner always resolves.
|
|
420
|
+
self._runner: Runner = cast(Runner, adapter._resolve_runner(agent))
|
|
421
|
+
self._last_state: AgentState = {"messages": [], "context": {}}
|
|
422
|
+
|
|
423
|
+
def execute(self, input_data: dict[str, Any]) -> dict[str, Any]:
|
|
424
|
+
"""Run the wrapped agent with governance applied.
|
|
425
|
+
|
|
426
|
+
Order of operations: input screening -> policy check -> pre-middleware
|
|
427
|
+
-> traced run -> post-middleware -> output screening.
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
SecurityBlockedError: If a control blocks the input/output (and
|
|
431
|
+
``block_on_violation`` is enabled).
|
|
432
|
+
"""
|
|
433
|
+
adapter = self._adapter
|
|
434
|
+
trace_id = uuid.uuid4().hex
|
|
435
|
+
|
|
436
|
+
# 1. Input screening.
|
|
437
|
+
in_threats = adapter._screen_input(input_data)
|
|
438
|
+
if in_threats and adapter.block_on_violation:
|
|
439
|
+
raise SecurityBlockedError("Input blocked by security controls", in_threats)
|
|
440
|
+
|
|
441
|
+
# 2. Policy enforcement against the extracted state.
|
|
442
|
+
self._last_state = adapter.extract_state(input_data)
|
|
443
|
+
if adapter.policy_enforcer is not None:
|
|
444
|
+
violations = adapter.policy_enforcer.check_state(self._last_state)
|
|
445
|
+
if violations and adapter.block_on_violation:
|
|
446
|
+
blocking = []
|
|
447
|
+
for v in violations:
|
|
448
|
+
rule = adapter.policy_enforcer.get_rule(v)
|
|
449
|
+
if rule is not None and rule.get("action") == "block":
|
|
450
|
+
blocking.append(v)
|
|
451
|
+
if blocking:
|
|
452
|
+
raise SecurityBlockedError(
|
|
453
|
+
"Input blocked by policy", [f"policy:{v}" for v in blocking]
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
# 3. Pre-middleware.
|
|
457
|
+
payload = input_data
|
|
458
|
+
if adapter.middleware is not None:
|
|
459
|
+
payload = adapter.middleware.process_input(input_data)
|
|
460
|
+
|
|
461
|
+
# 4. Traced execution.
|
|
462
|
+
if adapter.observer is not None:
|
|
463
|
+
adapter.observer.start_trace(trace_id, adapter.agent_id, adapter.operation)
|
|
464
|
+
try:
|
|
465
|
+
raw = adapter._call_runner(self._runner, payload)
|
|
466
|
+
result = _resolve_result(raw)
|
|
467
|
+
except Exception as exc:
|
|
468
|
+
if adapter.observer is not None:
|
|
469
|
+
adapter.observer.end_trace(trace_id, status="error", result=str(exc))
|
|
470
|
+
raise
|
|
471
|
+
if adapter.observer is not None:
|
|
472
|
+
adapter.observer.end_trace(trace_id, status="completed")
|
|
473
|
+
|
|
474
|
+
# 5. Post-middleware.
|
|
475
|
+
if adapter.middleware is not None:
|
|
476
|
+
wrapped = adapter.middleware.process_output({"result": result})
|
|
477
|
+
result = wrapped["result"]
|
|
478
|
+
|
|
479
|
+
# 6. Output screening.
|
|
480
|
+
out_threats = adapter._screen_output(result)
|
|
481
|
+
if out_threats and adapter.block_on_violation:
|
|
482
|
+
raise SecurityBlockedError("Output blocked by security controls", out_threats)
|
|
483
|
+
|
|
484
|
+
# Track state from the actual returned payload. Non-dict framework
|
|
485
|
+
# results (AgentRunResult, CrewOutput, ...) are wrapped in {"result": ...}
|
|
486
|
+
# so get_state() reflects the latest execution rather than stale input.
|
|
487
|
+
output_payload = result if isinstance(result, dict) else {"result": result}
|
|
488
|
+
self._last_state = adapter.extract_state(output_payload)
|
|
489
|
+
return output_payload
|
|
490
|
+
|
|
491
|
+
def get_state(self) -> AgentState:
|
|
492
|
+
"""Return the most recently observed agent state."""
|
|
493
|
+
return self._last_state
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
__all__ = ["GovernedAdapter", "_GovernedAgent", "_extract_texts", "_extract_prompt"]
|