maof 1.0.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.
- maof/__init__.py +73 -0
- maof/agents/__init__.py +0 -0
- maof/agents/base.py +74 -0
- maof/agents/client.py +199 -0
- maof/agents/mcp_adapter.py +138 -0
- maof/agents/registry_runtime.py +92 -0
- maof/approval/__init__.py +0 -0
- maof/approval/service.py +265 -0
- maof/approval/tokens.py +28 -0
- maof/authz.py +118 -0
- maof/cli.py +515 -0
- maof/config.py +186 -0
- maof/context/__init__.py +0 -0
- maof/context/budget.py +86 -0
- maof/context/builder.py +81 -0
- maof/context/compaction.py +98 -0
- maof/context/jit.py +56 -0
- maof/context/notes.py +79 -0
- maof/context/redactor.py +69 -0
- maof/context/sources/__init__.py +0 -0
- maof/context/sources/builtins.py +87 -0
- maof/cost/__init__.py +0 -0
- maof/cost/accounting.py +106 -0
- maof/errors.py +71 -0
- maof/eval/__init__.py +0 -0
- maof/eval/judge.py +80 -0
- maof/eval/rubrics.py +49 -0
- maof/eval/runner.py +89 -0
- maof/identity.py +192 -0
- maof/memory/__init__.py +0 -0
- maof/memory/base.py +48 -0
- maof/memory/pgvector.py +71 -0
- maof/memory/service.py +53 -0
- maof/models/__init__.py +0 -0
- maof/models/anthropic.py +59 -0
- maof/models/azure_openai.py +53 -0
- maof/models/base.py +205 -0
- maof/models/bedrock.py +61 -0
- maof/models/cohere.py +53 -0
- maof/models/gateway.py +78 -0
- maof/models/mistral.py +53 -0
- maof/models/ollama.py +89 -0
- maof/models/openai.py +91 -0
- maof/models/vertex_gemini.py +60 -0
- maof/observability/__init__.py +0 -0
- maof/observability/events.py +51 -0
- maof/observability/otel.py +92 -0
- maof/observability/sinks/__init__.py +0 -0
- maof/observability/sinks/postgres_sink.py +39 -0
- maof/observability/sinks/stdout_sink.py +29 -0
- maof/observability/sinks/webhook_sink.py +172 -0
- maof/observability/trajectory.py +58 -0
- maof/orchestrator/__init__.py +0 -0
- maof/orchestrator/context_delegation.py +84 -0
- maof/orchestrator/coordinator.py +252 -0
- maof/orchestrator/delegation.py +44 -0
- maof/orchestrator/l1.py +172 -0
- maof/orchestrator/lifecycle.py +444 -0
- maof/orchestrator/loop.py +123 -0
- maof/orchestrator/messages.py +130 -0
- maof/orchestrator/pipeline.py +71 -0
- maof/orchestrator/stages/__init__.py +18 -0
- maof/orchestrator/stages/action_plan.py +81 -0
- maof/orchestrator/stages/approval.py +53 -0
- maof/orchestrator/stages/chat.py +27 -0
- maof/orchestrator/stages/intent.py +31 -0
- maof/orchestrator/stages/publish.py +79 -0
- maof/persistence/__init__.py +0 -0
- maof/persistence/base.py +116 -0
- maof/persistence/migrations/0001_init.sql +229 -0
- maof/persistence/postgres.py +433 -0
- maof/policy/__init__.py +0 -0
- maof/policy/actions.py +86 -0
- maof/policy/dsl.py +97 -0
- maof/policy/engine.py +228 -0
- maof/policy/rulesets.py +35 -0
- maof/py.typed +0 -0
- maof/registry/__init__.py +0 -0
- maof/registry/a2a.py +113 -0
- maof/registry/admin_api.py +71 -0
- maof/registry/loader.py +106 -0
- maof/registry/models.py +71 -0
- maof/registry/search.py +88 -0
- maof/registry/signing.py +73 -0
- maof/registry/store.py +121 -0
- maof/runs/__init__.py +0 -0
- maof/runs/artifacts.py +92 -0
- maof/runs/checkpoint.py +102 -0
- maof/runs/idempotency.py +95 -0
- maof/runs/ops.py +198 -0
- maof/runs/retention.py +61 -0
- maof/runs/store.py +179 -0
- maof/schemas/__init__.py +0 -0
- maof/schemas/registry.py +64 -0
- maof/tenancy.py +30 -0
- maof/transport/__init__.py +0 -0
- maof/transport/base.py +43 -0
- maof/transport/consumers.py +134 -0
- maof/transport/factory.py +50 -0
- maof/transport/fake.py +114 -0
- maof/transport/kafka.py +134 -0
- maof/transport/rabbitmq.py +208 -0
- maof/transport/redis.py +126 -0
- maof/transport/retry.py +50 -0
- maof/transport/signing.py +327 -0
- maof/transport/sqs.py +136 -0
- maof/transport/wire.py +34 -0
- maof/types.py +518 -0
- maof/workers/__init__.py +0 -0
- maof/workers/pool.py +172 -0
- maof/workflows/__init__.py +1 -0
- maof/workflows/definition.py +117 -0
- maof/workflows/executor.py +184 -0
- maof/workflows/promote.py +124 -0
- maof/workflows/store.py +244 -0
- maof-1.0.0.dist-info/METADATA +214 -0
- maof-1.0.0.dist-info/RECORD +121 -0
- maof-1.0.0.dist-info/WHEEL +4 -0
- maof-1.0.0.dist-info/entry_points.txt +2 -0
- maof-1.0.0.dist-info/licenses/LICENSE +201 -0
- maof-1.0.0.dist-info/licenses/NOTICE +8 -0
maof/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""MAOF — Multi-Agent Orchestration Framework.
|
|
2
|
+
|
|
3
|
+
The reusable orchestration + governance layer of a hierarchical (L1 -> L2)
|
|
4
|
+
multi-agent system. Ships zero domain content: adopters inject their own L1
|
|
5
|
+
orchestrator, L2 agents, skills, task schemas, and policy rulesets.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from maof.config import Settings
|
|
11
|
+
from maof.errors import (
|
|
12
|
+
ApprovalRequired,
|
|
13
|
+
BudgetExceeded,
|
|
14
|
+
ConfigError,
|
|
15
|
+
IdempotencyError,
|
|
16
|
+
MAOFError,
|
|
17
|
+
PolicyDenied,
|
|
18
|
+
RegistryTrustError,
|
|
19
|
+
SchemaValidationError,
|
|
20
|
+
SignatureError,
|
|
21
|
+
TenancyError,
|
|
22
|
+
TransportError,
|
|
23
|
+
)
|
|
24
|
+
from maof.types import (
|
|
25
|
+
ContextEnvelope,
|
|
26
|
+
CoordinationMode,
|
|
27
|
+
DataPointer,
|
|
28
|
+
EffortBudget,
|
|
29
|
+
Envelope,
|
|
30
|
+
Intent,
|
|
31
|
+
OrchestrationMode,
|
|
32
|
+
Plan,
|
|
33
|
+
Stage,
|
|
34
|
+
StageContext,
|
|
35
|
+
Task,
|
|
36
|
+
TaskResult,
|
|
37
|
+
TenantContext,
|
|
38
|
+
ToolRef,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__version__ = "1.0.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"__version__",
|
|
45
|
+
"Settings",
|
|
46
|
+
# errors
|
|
47
|
+
"MAOFError",
|
|
48
|
+
"ConfigError",
|
|
49
|
+
"SignatureError",
|
|
50
|
+
"SchemaValidationError",
|
|
51
|
+
"PolicyDenied",
|
|
52
|
+
"ApprovalRequired",
|
|
53
|
+
"IdempotencyError",
|
|
54
|
+
"TransportError",
|
|
55
|
+
"RegistryTrustError",
|
|
56
|
+
"BudgetExceeded",
|
|
57
|
+
"TenancyError",
|
|
58
|
+
# core types
|
|
59
|
+
"Stage",
|
|
60
|
+
"CoordinationMode",
|
|
61
|
+
"OrchestrationMode",
|
|
62
|
+
"TenantContext",
|
|
63
|
+
"Envelope",
|
|
64
|
+
"ContextEnvelope",
|
|
65
|
+
"Task",
|
|
66
|
+
"TaskResult",
|
|
67
|
+
"Intent",
|
|
68
|
+
"Plan",
|
|
69
|
+
"ToolRef",
|
|
70
|
+
"DataPointer",
|
|
71
|
+
"EffortBudget",
|
|
72
|
+
"StageContext",
|
|
73
|
+
]
|
maof/agents/__init__.py
ADDED
|
File without changes
|
maof/agents/base.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Agent contracts.
|
|
2
|
+
|
|
3
|
+
First-party agents implement these Protocols and register via decorators /
|
|
4
|
+
entry points; the MCP adapter wraps remote MCP servers to satisfy the
|
|
5
|
+
same L2Agent contract. ``BaseL2Agent`` defaults
|
|
6
|
+
``context_delegation = []`` so a simple agent that side-loads nothing need not
|
|
7
|
+
declare anything.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from maof.registry.models import ContextDeclaration
|
|
16
|
+
from maof.types import L2Context, OrchestrationResult, TaskResult, TenantContext
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@runtime_checkable
|
|
20
|
+
class Skill(Protocol):
|
|
21
|
+
name: str
|
|
22
|
+
version: str
|
|
23
|
+
|
|
24
|
+
async def run(self, payload: dict[str, Any], ctx: L2Context) -> dict[str, Any]: ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@runtime_checkable
|
|
28
|
+
class L2Agent(Protocol):
|
|
29
|
+
name: str
|
|
30
|
+
accepted_task_types: list[str]
|
|
31
|
+
skills: list[Skill]
|
|
32
|
+
context_delegation: list[ContextDeclaration] # declares any side-loaded context
|
|
33
|
+
|
|
34
|
+
async def handle(self, task: dict[str, Any], ctx: L2Context) -> TaskResult: ...
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class L1Orchestrator(Protocol):
|
|
39
|
+
async def run(self, goal: str, tenant: TenantContext) -> OrchestrationResult: ...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class BaseSkill:
|
|
43
|
+
"""Convenience base for a skill (an L2 agent's internal chain step)."""
|
|
44
|
+
|
|
45
|
+
name: str = "skill"
|
|
46
|
+
version: str = "v1"
|
|
47
|
+
|
|
48
|
+
async def run(self, payload: dict[str, Any], ctx: L2Context) -> dict[str, Any]:
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class BaseL2Agent:
|
|
53
|
+
"""Convenience base for an L2 agent. Defaults ``context_delegation = []`` so a
|
|
54
|
+
simple agent that side-loads nothing need not declare anything."""
|
|
55
|
+
|
|
56
|
+
name: str = "base_l2_agent"
|
|
57
|
+
accepted_task_types: list[str] = []
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
*,
|
|
62
|
+
skills: list[Skill] | None = None,
|
|
63
|
+
context_delegation: list[ContextDeclaration] | None = None,
|
|
64
|
+
) -> None:
|
|
65
|
+
self.skills: list[Skill] = list(skills) if skills else []
|
|
66
|
+
self.context_delegation: list[ContextDeclaration] = (
|
|
67
|
+
list(context_delegation) if context_delegation else []
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def handle(self, task: dict[str, Any], ctx: L2Context) -> TaskResult:
|
|
71
|
+
raise NotImplementedError
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
__all__ = ["Skill", "L2Agent", "L1Orchestrator", "BaseSkill", "BaseL2Agent"]
|
maof/agents/client.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Hosting machinery for source-of-truth agents.
|
|
2
|
+
|
|
3
|
+
- :class:`AgentClientFactory` — registry-resolved, RBAC-scoped MCP clients for
|
|
4
|
+
agent→agent consultation (``ctx.agents``); every consultation is audited.
|
|
5
|
+
- :func:`attach_registry_context_sources` — approved ``context_source`` entries
|
|
6
|
+
auto-attach to the ContextBuilder (``required`` fails closed; contributions
|
|
7
|
+
cache per ``ContextDeclaration.mutable``).
|
|
8
|
+
- :func:`attach_registry_resolvers` — manifest ``resolver_schemes`` register as
|
|
9
|
+
JIT reference loaders (adopter-defined schemes, e.g. ``catalog://``, ``datastore://``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Callable
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from maof.errors import MAOFError, RegistryTrustError
|
|
18
|
+
from maof.observability.events import AuditEvent
|
|
19
|
+
from maof.registry.loader import _scopes_granted
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from maof.context.builder import ContextBuilder
|
|
23
|
+
from maof.context.jit import DefaultReferenceResolver
|
|
24
|
+
from maof.observability.events import EventSink
|
|
25
|
+
from maof.registry.loader import RegistryLoader
|
|
26
|
+
from maof.registry.models import AgentManifest
|
|
27
|
+
from maof.types import ContextEnvelope, StageContext, TenantContext
|
|
28
|
+
|
|
29
|
+
#: Builds a connected client (MCP stdio/HTTP) for an approved manifest. Injected so
|
|
30
|
+
#: deployments choose their MCP client library; tests inject fakes.
|
|
31
|
+
ClientBuilder = Callable[["AgentManifest"], Any]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AgentClientFactory:
|
|
35
|
+
"""Resolve an approved+signed registry agent into an RBAC-scoped client."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
loader: RegistryLoader,
|
|
40
|
+
*,
|
|
41
|
+
tenant: TenantContext,
|
|
42
|
+
client_builder: ClientBuilder,
|
|
43
|
+
event_sink: EventSink | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
self._loader = loader
|
|
46
|
+
self._tenant = tenant
|
|
47
|
+
self._client_builder = client_builder
|
|
48
|
+
self._event_sink = event_sink
|
|
49
|
+
self._cache: dict[str, Any] = {}
|
|
50
|
+
|
|
51
|
+
async def client(self, agent_id: str) -> Any:
|
|
52
|
+
if agent_id in self._cache:
|
|
53
|
+
await self._emit(agent_id)
|
|
54
|
+
return self._cache[agent_id]
|
|
55
|
+
manifest = next((m for m in await self._loader.manifests() if m.id == agent_id), None)
|
|
56
|
+
if manifest is None:
|
|
57
|
+
raise RegistryTrustError(f"no approved registry agent {agent_id!r}")
|
|
58
|
+
if not _scopes_granted(manifest, self._tenant):
|
|
59
|
+
raise RegistryTrustError(
|
|
60
|
+
f"tenant {self._tenant.tenant_id!r} lacks scopes for {agent_id!r}"
|
|
61
|
+
)
|
|
62
|
+
client = self._client_builder(manifest)
|
|
63
|
+
self._cache[agent_id] = client
|
|
64
|
+
await self._emit(agent_id)
|
|
65
|
+
return client
|
|
66
|
+
|
|
67
|
+
async def _emit(self, agent_id: str) -> None:
|
|
68
|
+
if self._event_sink is None:
|
|
69
|
+
return
|
|
70
|
+
await self._event_sink.emit(
|
|
71
|
+
AuditEvent(
|
|
72
|
+
tenant_id=self._tenant.tenant_id,
|
|
73
|
+
intent_id=None,
|
|
74
|
+
event_type="agent_consulted",
|
|
75
|
+
envelope={"agent": agent_id},
|
|
76
|
+
details={},
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ContextSourceCache:
|
|
82
|
+
"""Caches context-source contributions keyed (entry_id, tenant, version)."""
|
|
83
|
+
|
|
84
|
+
def __init__(self) -> None:
|
|
85
|
+
self._cache: dict[tuple[str, str, str], Any] = {}
|
|
86
|
+
|
|
87
|
+
def get(self, manifest: AgentManifest, tenant_id: str) -> Any | None:
|
|
88
|
+
return self._cache.get((manifest.id, tenant_id, manifest.version))
|
|
89
|
+
|
|
90
|
+
def set(self, manifest: AgentManifest, tenant_id: str, value: Any) -> None:
|
|
91
|
+
self._cache[(manifest.id, tenant_id, manifest.version)] = value
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RegistryContextSource:
|
|
95
|
+
"""Wraps an approved ``context_source`` entry as a ContextBuilder source."""
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
manifest: AgentManifest,
|
|
100
|
+
client: Any,
|
|
101
|
+
*,
|
|
102
|
+
cache: ContextSourceCache | None = None,
|
|
103
|
+
event_sink: EventSink | None = None,
|
|
104
|
+
) -> None:
|
|
105
|
+
# The stable registry id is the lookup key — display names are not contracts.
|
|
106
|
+
self.name = manifest.id
|
|
107
|
+
self._manifest = manifest
|
|
108
|
+
self._client = client
|
|
109
|
+
self._cache = cache
|
|
110
|
+
self._event_sink = event_sink
|
|
111
|
+
# mutable declarations force re-fetch every build (ContextDeclaration.mutable)
|
|
112
|
+
self._cacheable = not any(d.mutable for d in manifest.side_loaded_context)
|
|
113
|
+
|
|
114
|
+
async def contribute(self, sc: StageContext, env: ContextEnvelope) -> ContextEnvelope:
|
|
115
|
+
data: Any | None = None
|
|
116
|
+
if self._cache is not None and self._cacheable:
|
|
117
|
+
data = self._cache.get(self._manifest, sc.tenant.tenant_id)
|
|
118
|
+
if data is None:
|
|
119
|
+
try:
|
|
120
|
+
data = await self._client.read_resource(self._manifest.id)
|
|
121
|
+
except Exception as exc:
|
|
122
|
+
if self._manifest.required:
|
|
123
|
+
raise MAOFError(
|
|
124
|
+
f"required context source {self._manifest.id!r} unavailable "
|
|
125
|
+
f"(fail closed): {exc}"
|
|
126
|
+
) from exc
|
|
127
|
+
return env # optional source: skip, keep planning
|
|
128
|
+
if self._cache is not None and self._cacheable:
|
|
129
|
+
self._cache.set(self._manifest, sc.tenant.tenant_id, data)
|
|
130
|
+
env.semantic_model[self.name] = data
|
|
131
|
+
env.extras.setdefault("registry_context_sources", []).append(self._manifest.id)
|
|
132
|
+
if self._event_sink is not None:
|
|
133
|
+
await self._event_sink.emit(
|
|
134
|
+
AuditEvent(
|
|
135
|
+
tenant_id=sc.tenant.tenant_id,
|
|
136
|
+
intent_id=env.intent_id,
|
|
137
|
+
event_type="context_delegated",
|
|
138
|
+
envelope={"agent": self._manifest.id, "kind": "registry_context_source"},
|
|
139
|
+
details={"version": self._manifest.version},
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
return env
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def attach_registry_context_sources(
|
|
146
|
+
builder: ContextBuilder,
|
|
147
|
+
loader: RegistryLoader,
|
|
148
|
+
*,
|
|
149
|
+
tenant: TenantContext,
|
|
150
|
+
client_builder: ClientBuilder,
|
|
151
|
+
cache: ContextSourceCache | None = None,
|
|
152
|
+
event_sink: EventSink | None = None,
|
|
153
|
+
) -> list[AgentManifest]:
|
|
154
|
+
"""Attach every approved + RBAC-granted ``context_source`` to the builder."""
|
|
155
|
+
attached: list[AgentManifest] = []
|
|
156
|
+
for manifest in await loader.context_sources(tenant=tenant):
|
|
157
|
+
builder.add_source(
|
|
158
|
+
RegistryContextSource(
|
|
159
|
+
manifest, client_builder(manifest), cache=cache, event_sink=event_sink
|
|
160
|
+
)
|
|
161
|
+
)
|
|
162
|
+
attached.append(manifest)
|
|
163
|
+
return attached
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def attach_registry_resolvers(
|
|
167
|
+
resolver: DefaultReferenceResolver,
|
|
168
|
+
loader: RegistryLoader,
|
|
169
|
+
*,
|
|
170
|
+
client_builder: ClientBuilder,
|
|
171
|
+
tenant: TenantContext | None = None,
|
|
172
|
+
) -> list[str]:
|
|
173
|
+
"""Register every approved manifest's ``resolver_schemes`` as JIT loaders, so
|
|
174
|
+
``<scheme>://<ref>`` references resolve through the owning agent."""
|
|
175
|
+
registered: list[str] = []
|
|
176
|
+
manifests = await loader.manifests()
|
|
177
|
+
for manifest in manifests:
|
|
178
|
+
if tenant is not None and not _scopes_granted(manifest, tenant):
|
|
179
|
+
continue
|
|
180
|
+
for scheme in manifest.resolver_schemes:
|
|
181
|
+
client = client_builder(manifest)
|
|
182
|
+
|
|
183
|
+
async def _load(rest: str, _client: Any = client) -> str:
|
|
184
|
+
data = await _client.read_resource(rest)
|
|
185
|
+
return data if isinstance(data, str) else repr(data)
|
|
186
|
+
|
|
187
|
+
resolver.register_loader(scheme, _load)
|
|
188
|
+
registered.append(scheme)
|
|
189
|
+
return registered
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
__all__ = [
|
|
193
|
+
"ClientBuilder",
|
|
194
|
+
"AgentClientFactory",
|
|
195
|
+
"ContextSourceCache",
|
|
196
|
+
"RegistryContextSource",
|
|
197
|
+
"attach_registry_context_sources",
|
|
198
|
+
"attach_registry_resolvers",
|
|
199
|
+
]
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""MCP adapter.
|
|
2
|
+
|
|
3
|
+
Wraps a remote MCP server so it satisfies the L2Agent contract (and, separately,
|
|
4
|
+
the ContextSource contract). The MCP client is injected so this module imports
|
|
5
|
+
without the ``mcp`` extra; adopters pass a connected client (stdio or HTTP) —
|
|
6
|
+
either anything satisfying the duck-typed surface (``call_tool``/``read_resource``)
|
|
7
|
+
or :class:`MCPSDKClient`, which adapts an official ``mcp`` SDK session to it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import AsyncIterator
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from maof.errors import ConfigError, MAOFError
|
|
18
|
+
from maof.types import TaskResult
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from maof.registry.models import AgentManifest, ContextDeclaration
|
|
22
|
+
from maof.types import ContextEnvelope, L2Context, StageContext
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MCPAgentAdapter:
|
|
26
|
+
"""Presents an MCP server as an L2 agent: each task maps to an MCP tool call."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, manifest: AgentManifest, client: Any) -> None:
|
|
29
|
+
self.name = manifest.name
|
|
30
|
+
self.accepted_task_types = list(manifest.accepted_task_types)
|
|
31
|
+
self.skills: list[Any] = []
|
|
32
|
+
self.context_delegation: list[ContextDeclaration] = list(manifest.side_loaded_context)
|
|
33
|
+
self._client = client
|
|
34
|
+
self._manifest = manifest
|
|
35
|
+
|
|
36
|
+
async def handle(self, task: dict[str, Any], ctx: L2Context) -> TaskResult:
|
|
37
|
+
tool = task.get("tool") or task.get("task_type", "")
|
|
38
|
+
result = await self._client.call_tool(tool, task)
|
|
39
|
+
return TaskResult(
|
|
40
|
+
status="ok",
|
|
41
|
+
task_id=str(task.get("task_id", "")),
|
|
42
|
+
output={"mcp_tool": tool, "result": result},
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MCPContextSource:
|
|
47
|
+
"""Uses an MCP server purely as a context provider (a read-only slice)."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self, manifest: AgentManifest, client: Any, *, resource: str | None = None
|
|
51
|
+
) -> None:
|
|
52
|
+
self.name = manifest.name
|
|
53
|
+
self._client = client
|
|
54
|
+
self._resource = resource or manifest.id
|
|
55
|
+
|
|
56
|
+
async def contribute(self, sc: StageContext, env: ContextEnvelope) -> ContextEnvelope:
|
|
57
|
+
data = await self._client.read_resource(self._resource)
|
|
58
|
+
env.semantic_model[self.name] = data
|
|
59
|
+
return env
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _parse_payload(text: str) -> Any:
|
|
63
|
+
"""Tool/resource payloads are JSON when the server sends JSON, else raw text."""
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(text)
|
|
66
|
+
except (json.JSONDecodeError, ValueError):
|
|
67
|
+
return text
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class MCPSDKClient:
|
|
71
|
+
"""Adapts an official ``mcp`` SDK ``ClientSession`` to the duck-typed surface
|
|
72
|
+
the adapters above expect (``call_tool(name, args)`` / ``read_resource(ref)``).
|
|
73
|
+
|
|
74
|
+
Importing this module never requires the ``mcp`` extra; connecting does (see
|
|
75
|
+
:func:`mcp_stdio_client`). Fake clients keep satisfying the same surface.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def __init__(self, session: Any, *, default_resource_scheme: str = "resource") -> None:
|
|
79
|
+
self._session = session
|
|
80
|
+
self._scheme = default_resource_scheme
|
|
81
|
+
|
|
82
|
+
async def call_tool(self, name: str, args: dict[str, Any]) -> Any:
|
|
83
|
+
result = await self._session.call_tool(name, arguments=args)
|
|
84
|
+
structured = getattr(result, "structuredContent", None)
|
|
85
|
+
if structured:
|
|
86
|
+
return structured
|
|
87
|
+
parts: list[str] = []
|
|
88
|
+
for block in getattr(result, "content", None) or []:
|
|
89
|
+
text = getattr(block, "text", None)
|
|
90
|
+
if text is not None:
|
|
91
|
+
parts.append(str(text))
|
|
92
|
+
text_out = "\n".join(parts)
|
|
93
|
+
if getattr(result, "isError", False):
|
|
94
|
+
raise MAOFError(f"MCP tool {name!r} returned an error: {text_out or 'no detail'}")
|
|
95
|
+
return _parse_payload(text_out)
|
|
96
|
+
|
|
97
|
+
async def read_resource(self, ref: str) -> Any:
|
|
98
|
+
uri = ref if "://" in ref else f"{self._scheme}://{ref}"
|
|
99
|
+
result = await self._session.read_resource(uri)
|
|
100
|
+
for content in getattr(result, "contents", None) or []:
|
|
101
|
+
text = getattr(content, "text", None)
|
|
102
|
+
if text is not None:
|
|
103
|
+
return _parse_payload(str(text))
|
|
104
|
+
blob = getattr(content, "blob", None)
|
|
105
|
+
if blob is not None:
|
|
106
|
+
return blob
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@contextlib.asynccontextmanager
|
|
111
|
+
async def mcp_stdio_client(
|
|
112
|
+
command: str,
|
|
113
|
+
args: list[str] | None = None,
|
|
114
|
+
*,
|
|
115
|
+
env: dict[str, str] | None = None,
|
|
116
|
+
default_resource_scheme: str = "resource",
|
|
117
|
+
) -> AsyncIterator[MCPSDKClient]:
|
|
118
|
+
"""Spawn an MCP server over stdio and yield a connected :class:`MCPSDKClient`.
|
|
119
|
+
|
|
120
|
+
The official SDK is imported lazily so the module works without the extra.
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
from mcp import ClientSession, StdioServerParameters
|
|
124
|
+
from mcp.client.stdio import stdio_client
|
|
125
|
+
except ImportError as exc: # pragma: no cover - depends on installed extras
|
|
126
|
+
raise ConfigError(
|
|
127
|
+
"mcp_stdio_client requires the 'mcp' extra (pip install maof[mcp])"
|
|
128
|
+
) from exc
|
|
129
|
+
params = StdioServerParameters(command=command, args=list(args or []), env=env)
|
|
130
|
+
async with (
|
|
131
|
+
stdio_client(params) as (read_stream, write_stream),
|
|
132
|
+
ClientSession(read_stream, write_stream) as session,
|
|
133
|
+
):
|
|
134
|
+
await session.initialize()
|
|
135
|
+
yield MCPSDKClient(session, default_resource_scheme=default_resource_scheme)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
__all__ = ["MCPAgentAdapter", "MCPContextSource", "MCPSDKClient", "mcp_stdio_client"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Runtime agent/skill registry.
|
|
2
|
+
|
|
3
|
+
In-process map of locally injected L2 agents and skills (compile/deploy-time
|
|
4
|
+
trust), populated via decorators and/or ``importlib.metadata`` entry points
|
|
5
|
+
(groups ``maof.l2_agents`` / ``maof.skills``). Distinct from the persisted,
|
|
6
|
+
admin-gated discovery registry in ``maof.registry``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from importlib import metadata
|
|
13
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from maof.agents.base import L2Agent, Skill
|
|
17
|
+
|
|
18
|
+
A = TypeVar("A", bound="L2Agent")
|
|
19
|
+
S = TypeVar("S", bound="Skill")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AgentRegistry:
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._agents: dict[str, L2Agent] = {}
|
|
25
|
+
self._by_task_type: dict[str, L2Agent] = {}
|
|
26
|
+
self._skills: dict[str, Skill] = {}
|
|
27
|
+
|
|
28
|
+
def register_agent(self, agent: L2Agent) -> None:
|
|
29
|
+
self._agents[agent.name] = agent
|
|
30
|
+
for task_type in agent.accepted_task_types:
|
|
31
|
+
self._by_task_type[task_type] = agent
|
|
32
|
+
|
|
33
|
+
def register_skill(self, skill: Skill) -> None:
|
|
34
|
+
self._skills[skill.name] = skill
|
|
35
|
+
|
|
36
|
+
def agent(self, name: str) -> L2Agent | None:
|
|
37
|
+
return self._agents.get(name)
|
|
38
|
+
|
|
39
|
+
def agent_for_task_type(self, task_type: str) -> L2Agent | None:
|
|
40
|
+
return self._by_task_type.get(task_type)
|
|
41
|
+
|
|
42
|
+
def skill(self, name: str) -> Skill | None:
|
|
43
|
+
return self._skills.get(name)
|
|
44
|
+
|
|
45
|
+
def agents(self) -> list[L2Agent]:
|
|
46
|
+
return list(self._agents.values())
|
|
47
|
+
|
|
48
|
+
def load_entry_points(self) -> None:
|
|
49
|
+
"""Discover adopter agents/skills declared via packaging metadata."""
|
|
50
|
+
for ep in metadata.entry_points(group="maof.l2_agents"):
|
|
51
|
+
factory = ep.load()
|
|
52
|
+
self.register_agent(factory() if callable(factory) else factory)
|
|
53
|
+
for ep in metadata.entry_points(group="maof.skills"):
|
|
54
|
+
factory = ep.load()
|
|
55
|
+
self.register_skill(factory() if callable(factory) else factory)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
#: A process-global default registry that the decorators target.
|
|
59
|
+
default_registry = AgentRegistry()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def register_l2_agent(
|
|
63
|
+
registry: AgentRegistry | None = None,
|
|
64
|
+
) -> Callable[[type[A]], type[A]]:
|
|
65
|
+
"""Class decorator: instantiate and register an L2 agent on import."""
|
|
66
|
+
target = registry or default_registry
|
|
67
|
+
|
|
68
|
+
def decorator(cls: type[A]) -> type[A]:
|
|
69
|
+
target.register_agent(cls())
|
|
70
|
+
return cls
|
|
71
|
+
|
|
72
|
+
return decorator
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def register_skill(
|
|
76
|
+
registry: AgentRegistry | None = None,
|
|
77
|
+
) -> Callable[[type[S]], type[S]]:
|
|
78
|
+
target = registry or default_registry
|
|
79
|
+
|
|
80
|
+
def decorator(cls: type[S]) -> type[S]:
|
|
81
|
+
target.register_skill(cls())
|
|
82
|
+
return cls
|
|
83
|
+
|
|
84
|
+
return decorator
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
__all__ = [
|
|
88
|
+
"AgentRegistry",
|
|
89
|
+
"default_registry",
|
|
90
|
+
"register_l2_agent",
|
|
91
|
+
"register_skill",
|
|
92
|
+
]
|
|
File without changes
|