agentdeck-sdk 3.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentdeck/README.md +50 -0
- agentdeck/__init__.py +51 -0
- agentdeck/adapters/__init__.py +5 -0
- agentdeck/adapters/control/__init__.py +1 -0
- agentdeck/adapters/control/memory/__init__.py +5 -0
- agentdeck/adapters/control/memory/port.py +25 -0
- agentdeck/adapters/control/sqlite/__init__.py +5 -0
- agentdeck/adapters/control/sqlite/port.py +111 -0
- agentdeck/adapters/engines/__init__.py +1 -0
- agentdeck/adapters/engines/langgraph/__init__.py +8 -0
- agentdeck/adapters/engines/langgraph/checkpointer.py +205 -0
- agentdeck/adapters/engines/langgraph/engine.py +410 -0
- agentdeck/adapters/engines/openai_agents/__init__.py +9 -0
- agentdeck/adapters/engines/openai_agents/engine.py +321 -0
- agentdeck/adapters/engines/openai_agents/reconcile.py +178 -0
- agentdeck/adapters/engines/openai_agents/runconfig.py +118 -0
- agentdeck/adapters/engines/openai_agents/sessions.py +100 -0
- agentdeck/adapters/engines/openai_agents/translate.py +124 -0
- agentdeck/adapters/engines/stub/__init__.py +5 -0
- agentdeck/adapters/engines/stub/engine.py +100 -0
- agentdeck/adapters/stores/__init__.py +1 -0
- agentdeck/adapters/stores/memory/__init__.py +5 -0
- agentdeck/adapters/stores/memory/store.py +148 -0
- agentdeck/adapters/stores/postgres/__init__.py +5 -0
- agentdeck/adapters/stores/postgres/store.py +374 -0
- agentdeck/adapters/stores/redis/__init__.py +5 -0
- agentdeck/adapters/stores/redis/store.py +359 -0
- agentdeck/adapters/stores/sqlite/__init__.py +5 -0
- agentdeck/adapters/stores/sqlite/store.py +338 -0
- agentdeck/adapters/telemetry/__init__.py +1 -0
- agentdeck/adapters/telemetry/langfuse/__init__.py +18 -0
- agentdeck/adapters/telemetry/langfuse/client.py +180 -0
- agentdeck/adapters/telemetry/langfuse/sink.py +366 -0
- agentdeck/adapters/telemetry/langfuse/trace.py +88 -0
- agentdeck/adapters/tools/__init__.py +1 -0
- agentdeck/adapters/tools/mcp/__init__.py +18 -0
- agentdeck/adapters/tools/mcp/lifecycle.py +178 -0
- agentdeck/adapters/tools/mcp/source.py +47 -0
- agentdeck/adapters/tools/mcp/transport.py +234 -0
- agentdeck/adapters/tools/mcp/wiring.py +63 -0
- agentdeck/authoring/__init__.py +22 -0
- agentdeck/authoring/agent.py +169 -0
- agentdeck/authoring/compile.py +250 -0
- agentdeck/authoring/graphs.py +145 -0
- agentdeck/authoring/hooks.py +117 -0
- agentdeck/authoring/injection.py +233 -0
- agentdeck/authoring/instructions.py +80 -0
- agentdeck/authoring/interrupts.py +37 -0
- agentdeck/authoring/nodes.py +140 -0
- agentdeck/authoring/runners/__init__.py +6 -0
- agentdeck/authoring/runners/agent.py +171 -0
- agentdeck/authoring/runners/workflow.py +99 -0
- agentdeck/authoring/skills.py +56 -0
- agentdeck/authoring/state.py +44 -0
- agentdeck/authoring/timers.py +44 -0
- agentdeck/authoring/tools.py +147 -0
- agentdeck/authoring/web_search.py +43 -0
- agentdeck/authoring/workflow.py +266 -0
- agentdeck/cli.py +56 -0
- agentdeck/composition.py +213 -0
- agentdeck/core/__init__.py +110 -0
- agentdeck/core/base.py +47 -0
- agentdeck/core/content.py +166 -0
- agentdeck/core/context.py +136 -0
- agentdeck/core/control.py +150 -0
- agentdeck/core/events.py +468 -0
- agentdeck/core/invocable.py +38 -0
- agentdeck/core/ports/__init__.py +26 -0
- agentdeck/core/ports/control.py +35 -0
- agentdeck/core/ports/engine.py +66 -0
- agentdeck/core/ports/sink.py +53 -0
- agentdeck/core/ports/store.py +170 -0
- agentdeck/core/ports/tools.py +57 -0
- agentdeck/core/reporting.py +75 -0
- agentdeck/core/status.py +75 -0
- agentdeck/deck.py +894 -0
- agentdeck/errors.py +67 -0
- agentdeck/mcp.py +82 -0
- agentdeck/observers.py +111 -0
- agentdeck/py.typed +0 -0
- agentdeck/runtime/__init__.py +1 -0
- agentdeck/runtime/capture.py +32 -0
- agentdeck/runtime/config.default.yaml +38 -0
- agentdeck/runtime/discovery.py +175 -0
- agentdeck/runtime/dispatch.py +440 -0
- agentdeck/runtime/registry.py +173 -0
- agentdeck/runtime/service.py +671 -0
- agentdeck/runtime/settings.py +568 -0
- agentdeck/serve.py +330 -0
- agentdeck/skills/__init__.py +114 -0
- agentdeck/skills/bundle.py +65 -0
- agentdeck/surfaces/__init__.py +4 -0
- agentdeck/surfaces/cli/__init__.py +7 -0
- agentdeck/surfaces/cli/chat.py +88 -0
- agentdeck/surfaces/serve/__init__.py +7 -0
- agentdeck/surfaces/serve/app.py +71 -0
- agentdeck/surfaces/serve/compat.py +212 -0
- agentdeck/surfaces/serve/workflows.py +69 -0
- agentdeck/testing.py +364 -0
- agentdeck_sdk-3.1.0.dist-info/METADATA +222 -0
- agentdeck_sdk-3.1.0.dist-info/RECORD +104 -0
- agentdeck_sdk-3.1.0.dist-info/WHEEL +4 -0
- agentdeck_sdk-3.1.0.dist-info/entry_points.txt +3 -0
- agentdeck_sdk-3.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""``ToolSourcePort`` over the MCP server registry.
|
|
2
|
+
|
|
3
|
+
An invocable names the servers it wants in ``InvocableSpec.metadata``; this hands back the
|
|
4
|
+
ones that are connected, the names of the ones that are not, and the banner that makes a
|
|
5
|
+
model report the gap. Unknown, unconfigured and unreachable all resolve the same way — a
|
|
6
|
+
:class:`ToolSet` — because MCP being down degrades a run, never fails it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from agentdeck.adapters.tools.mcp.wiring import mcp_status_banner, resolve_agent_mcp_status
|
|
14
|
+
from agentdeck.core.ports.tools import ToolSet, ToolSourcePort
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from agentdeck.core.invocable import InvocableSpec
|
|
18
|
+
|
|
19
|
+
MCP_SERVER_NAMES_KEY = "mcp_server_names"
|
|
20
|
+
"""``InvocableSpec.metadata`` key holding the MCP server names an invocable declares."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MCPToolSource(ToolSourcePort):
|
|
24
|
+
"""The project's MCP servers, as one tool source.
|
|
25
|
+
|
|
26
|
+
Stateless: the servers themselves live in :class:`MCPLifecycle`, which the composition
|
|
27
|
+
root connects at startup and closes at shutdown — resolving never connects, so it is
|
|
28
|
+
safe to call while building a prompt. Not free, though: on a cold cache the first call
|
|
29
|
+
materialises process settings, which reads the config files off disk.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def resolve(self, spec: InvocableSpec) -> ToolSet:
|
|
33
|
+
declared = spec.metadata.get(MCP_SERVER_NAMES_KEY) or ()
|
|
34
|
+
if isinstance(declared, str): # a lone name, not a list of characters
|
|
35
|
+
declared = (declared,)
|
|
36
|
+
# metadata is free-form, so nothing has checked these are strings yet; a name that
|
|
37
|
+
# isn't one is reported missing rather than exploding somewhere further in.
|
|
38
|
+
declared = tuple(str(name) for name in declared)
|
|
39
|
+
available, missing = resolve_agent_mcp_status(declared)
|
|
40
|
+
return ToolSet(
|
|
41
|
+
tools=tuple(available),
|
|
42
|
+
unavailable=tuple(missing),
|
|
43
|
+
notice=mcp_status_banner(missing),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
__all__ = ["MCP_SERVER_NAMES_KEY", "MCPToolSource"]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""``MCPServerStreamableHttp`` hardened for real deployments.
|
|
2
|
+
|
|
3
|
+
Adds what the SDK leaves out: connect-time retry, and — after an MCP server
|
|
4
|
+
restart drops our session id — detection of the resulting mid-session 404 plus
|
|
5
|
+
a transparent re-``initialize`` + replay, so tool calls self-heal without a
|
|
6
|
+
harness restart.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from contextlib import AsyncExitStack, suppress
|
|
14
|
+
from typing import TYPE_CHECKING, Any, TypeVar
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from agents.mcp import MCPServerStreamableHttp
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Awaitable, Callable
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_T = TypeVar("_T")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MCPServerStreamableHttpResilient(MCPServerStreamableHttp):
|
|
28
|
+
"""HTTP MCP client that retries connect and recovers a dropped session.
|
|
29
|
+
|
|
30
|
+
Recovery splits by whether the connection is still there:
|
|
31
|
+
|
|
32
|
+
* A live-connection hiccup (read timeout, protocol blip, 5xx) is retried on
|
|
33
|
+
the **same session** — cheap, no rebuild (SDK ``max_retry_attempts``).
|
|
34
|
+
* A **gone** connection — a dropped session id (404) or an unreachable server
|
|
35
|
+
(:meth:`_connection_gone`) — skips that retry and escalates at once to a
|
|
36
|
+
fresh ``connect()`` + replay (:meth:`_with_recovery`), the only thing that
|
|
37
|
+
fixes a new-session-needed restart or a torn-down stream. Connect itself
|
|
38
|
+
retries transient errors; auth / bad-URL 404 stay fatal (:meth:`_is_transient`).
|
|
39
|
+
|
|
40
|
+
The SDK wraps transport failures in ``UserError`` (``__cause__`` is the real
|
|
41
|
+
error), so the predicates walk the cause chain.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
TRANSIENT_HTTPX_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
45
|
+
httpx.ConnectError,
|
|
46
|
+
httpx.ConnectTimeout,
|
|
47
|
+
httpx.ReadTimeout,
|
|
48
|
+
httpx.RemoteProtocolError,
|
|
49
|
+
)
|
|
50
|
+
# The connection itself is unavailable — a same-session retry can't help, only a
|
|
51
|
+
# reconnect can. (``ReadTimeout``/``RemoteProtocolError`` are NOT here: the
|
|
52
|
+
# connection is alive, the request just hiccuped, so retrying it is worth it.)
|
|
53
|
+
CONNECTION_GONE_EXCEPTIONS: tuple[type[BaseException], ...] = (
|
|
54
|
+
httpx.ConnectError,
|
|
55
|
+
httpx.ConnectTimeout,
|
|
56
|
+
)
|
|
57
|
+
FATAL_STATUS_CODES: frozenset[int] = frozenset({401, 403, 404})
|
|
58
|
+
# A mid-session 404 means a dropped session id (restart), not a bad URL.
|
|
59
|
+
SESSION_LOST_STATUS_CODES: frozenset[int] = frozenset({404})
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*args: Any,
|
|
64
|
+
connect_max_attempts: int = 3,
|
|
65
|
+
connect_backoff_base_seconds: float = 1.0,
|
|
66
|
+
max_retry_attempts: int = 5, # same-session retry for live hiccups; gone conns skip it
|
|
67
|
+
retry_backoff_seconds_base: float = 1.0,
|
|
68
|
+
reconnect_max_attempts: int = 2,
|
|
69
|
+
reconnect_backoff_base_seconds: float = 0.5,
|
|
70
|
+
**kwargs: Any,
|
|
71
|
+
) -> None:
|
|
72
|
+
super().__init__(
|
|
73
|
+
*args,
|
|
74
|
+
max_retry_attempts=max_retry_attempts,
|
|
75
|
+
retry_backoff_seconds_base=retry_backoff_seconds_base,
|
|
76
|
+
**kwargs,
|
|
77
|
+
)
|
|
78
|
+
self.connect_max_attempts = connect_max_attempts
|
|
79
|
+
self.connect_backoff_base_seconds = connect_backoff_base_seconds
|
|
80
|
+
self.reconnect_max_attempts = reconnect_max_attempts
|
|
81
|
+
self.reconnect_backoff_base_seconds = reconnect_backoff_base_seconds
|
|
82
|
+
|
|
83
|
+
async def connect(self) -> None:
|
|
84
|
+
last_exc: BaseException | None = None
|
|
85
|
+
for attempt in range(1, self.connect_max_attempts + 1):
|
|
86
|
+
try:
|
|
87
|
+
await super().connect()
|
|
88
|
+
if attempt > 1:
|
|
89
|
+
logger.info("MCP connect succeeded for %s on attempt %d", self.name, attempt)
|
|
90
|
+
return
|
|
91
|
+
except BaseException as exc:
|
|
92
|
+
last_exc = exc
|
|
93
|
+
if not self._is_transient(exc):
|
|
94
|
+
raise
|
|
95
|
+
backoff = self.connect_backoff_base_seconds * (2 ** (attempt - 1))
|
|
96
|
+
logger.warning(
|
|
97
|
+
"MCP connect to %s failed (attempt %d/%d) — retrying in %.1fs",
|
|
98
|
+
self.name,
|
|
99
|
+
attempt,
|
|
100
|
+
self.connect_max_attempts,
|
|
101
|
+
backoff,
|
|
102
|
+
)
|
|
103
|
+
await asyncio.sleep(backoff)
|
|
104
|
+
if last_exc is None: # only reachable if the loop ran zero times (attempts < 1)
|
|
105
|
+
raise RuntimeError(f"MCP connect to {self.name} made no attempt")
|
|
106
|
+
raise last_exc
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def _is_transient(cls, exc: BaseException) -> bool:
|
|
110
|
+
"""Retryable connect-time transport error? Walks the cause chain."""
|
|
111
|
+
seen: set[int] = set()
|
|
112
|
+
current: BaseException | None = exc
|
|
113
|
+
while current is not None and id(current) not in seen:
|
|
114
|
+
seen.add(id(current))
|
|
115
|
+
if isinstance(current, httpx.HTTPStatusError):
|
|
116
|
+
return current.response.status_code not in cls.FATAL_STATUS_CODES
|
|
117
|
+
if isinstance(current, cls.TRANSIENT_HTTPX_EXCEPTIONS):
|
|
118
|
+
return True
|
|
119
|
+
current = current.__cause__ or current.__context__
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def _is_session_lost(cls, exc: BaseException) -> bool:
|
|
124
|
+
"""Mid-session 404 (server dropped our session id)? Walks the cause chain."""
|
|
125
|
+
seen: set[int] = set()
|
|
126
|
+
current: BaseException | None = exc
|
|
127
|
+
while current is not None and id(current) not in seen:
|
|
128
|
+
seen.add(id(current))
|
|
129
|
+
if isinstance(current, httpx.HTTPStatusError):
|
|
130
|
+
return current.response.status_code in cls.SESSION_LOST_STATUS_CODES
|
|
131
|
+
current = current.__cause__ or current.__context__
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
def _connection_gone(cls, exc: BaseException) -> bool:
|
|
136
|
+
"""Connection unavailable — dropped session id (404) or unreachable server.
|
|
137
|
+
|
|
138
|
+
These must NOT be retried on the same session (there's nothing live to retry);
|
|
139
|
+
recovery reconnects instead. A live-connection hiccup returns False.
|
|
140
|
+
"""
|
|
141
|
+
seen: set[int] = set()
|
|
142
|
+
current: BaseException | None = exc
|
|
143
|
+
while current is not None and id(current) not in seen:
|
|
144
|
+
seen.add(id(current))
|
|
145
|
+
if isinstance(current, httpx.HTTPStatusError):
|
|
146
|
+
if current.response.status_code in cls.SESSION_LOST_STATUS_CODES:
|
|
147
|
+
return True
|
|
148
|
+
elif isinstance(current, cls.CONNECTION_GONE_EXCEPTIONS):
|
|
149
|
+
return True
|
|
150
|
+
current = current.__cause__ or current.__context__
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
async def _run_with_retries(self, func: Callable[[], Awaitable[_T]]) -> _T:
|
|
154
|
+
"""Same-session retry for live hiccups; skip it when the connection is gone.
|
|
155
|
+
|
|
156
|
+
A dead session / unreachable server (:meth:`_connection_gone`) surfaces at
|
|
157
|
+
once so :meth:`_with_recovery` reconnects instead of retrying a connection
|
|
158
|
+
that isn't there. Everything else keeps the SDK's backoff policy.
|
|
159
|
+
"""
|
|
160
|
+
attempts = 0
|
|
161
|
+
while True:
|
|
162
|
+
try:
|
|
163
|
+
return await func()
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
if self._connection_gone(exc):
|
|
166
|
+
raise
|
|
167
|
+
attempts += 1
|
|
168
|
+
if self.max_retry_attempts != -1 and attempts > self.max_retry_attempts:
|
|
169
|
+
raise
|
|
170
|
+
await asyncio.sleep(self.retry_backoff_seconds_base * (2 ** (attempts - 1)))
|
|
171
|
+
|
|
172
|
+
async def _reconnect(self) -> None:
|
|
173
|
+
"""Drop the stale session and re-run ``initialize`` for a fresh session id."""
|
|
174
|
+
with suppress(Exception): # dead transport teardown is best-effort
|
|
175
|
+
await self.cleanup() # aclose()s the old streams and nulls self.session
|
|
176
|
+
self.exit_stack = AsyncExitStack() # cleanup aclosed the old one
|
|
177
|
+
await self.connect()
|
|
178
|
+
|
|
179
|
+
async def _with_recovery(self, call: Callable[[], Awaitable[_T]]) -> _T:
|
|
180
|
+
"""Run ``call``; only when the connection is *gone* re-initialize and replay.
|
|
181
|
+
|
|
182
|
+
Bounded + backed off. Fires on :meth:`_connection_gone` (dropped session id or
|
|
183
|
+
unreachable server) — a server restart or a torn-down stream. A live-connection
|
|
184
|
+
hiccup is NOT recovered here; it already had its same-session retry in
|
|
185
|
+
:meth:`_run_with_retries` and is re-raised.
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
return await call()
|
|
189
|
+
except BaseException as exc:
|
|
190
|
+
if not self._connection_gone(exc):
|
|
191
|
+
raise
|
|
192
|
+
last: BaseException | None = None
|
|
193
|
+
for attempt in range(1, self.reconnect_max_attempts + 1):
|
|
194
|
+
logger.warning(
|
|
195
|
+
"MCP %s: connection lost (server restart/outage?) — re-initializing (attempt %d/%d)",
|
|
196
|
+
self.name,
|
|
197
|
+
attempt,
|
|
198
|
+
self.reconnect_max_attempts,
|
|
199
|
+
)
|
|
200
|
+
try:
|
|
201
|
+
await self._reconnect()
|
|
202
|
+
result = await call()
|
|
203
|
+
except BaseException as exc:
|
|
204
|
+
last = exc
|
|
205
|
+
if not self._connection_gone(exc):
|
|
206
|
+
raise
|
|
207
|
+
else:
|
|
208
|
+
logger.info("MCP %s: connection recovered on attempt %d", self.name, attempt)
|
|
209
|
+
return result
|
|
210
|
+
await asyncio.sleep(self.reconnect_backoff_base_seconds * (2 ** (attempt - 1)))
|
|
211
|
+
if last is None: # only reachable if reconnect_max_attempts < 1
|
|
212
|
+
raise RuntimeError(f"MCP {self.name} recovery made no attempt")
|
|
213
|
+
logger.error("MCP %s: connection recovery failed after %d attempts", self.name, self.reconnect_max_attempts)
|
|
214
|
+
raise last
|
|
215
|
+
|
|
216
|
+
# ``run_context`` / ``agent`` / return types are the SDK's; typed ``Any`` here so
|
|
217
|
+
# these thin recovery wrappers need no import block just to restate them.
|
|
218
|
+
async def list_tools(self, run_context: Any = None, agent: Any = None) -> Any:
|
|
219
|
+
return await self._with_recovery(
|
|
220
|
+
lambda: super(MCPServerStreamableHttpResilient, self).list_tools(run_context, agent)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
async def call_tool(
|
|
224
|
+
self,
|
|
225
|
+
tool_name: str,
|
|
226
|
+
arguments: dict[str, Any] | None = None,
|
|
227
|
+
meta: dict[str, Any] | None = None,
|
|
228
|
+
) -> Any:
|
|
229
|
+
return await self._with_recovery(
|
|
230
|
+
lambda: super(MCPServerStreamableHttpResilient, self).call_tool(tool_name, arguments, meta)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
__all__ = ["MCPServerStreamableHttpResilient"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Wiring MCP servers into agents, and the strict-protocol banner.
|
|
2
|
+
|
|
3
|
+
Turns an agent's declared server names into the SDK instances to attach, and —
|
|
4
|
+
when some are unavailable — a prompt banner that makes the model report the gap
|
|
5
|
+
instead of fabricating an answer.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from agentdeck.adapters.tools.mcp.lifecycle import MCPLifecycle
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Iterable, Sequence
|
|
16
|
+
|
|
17
|
+
from agents.mcp import MCPServer
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_agent_mcp_status(names: Iterable[str]) -> tuple[list[MCPServer], list[str]]:
|
|
21
|
+
"""Split declared names into ``(available_servers, missing_names)``.
|
|
22
|
+
|
|
23
|
+
"Missing" = unknown to the registry or its connect failed. ``authoring.compile.compile_agent``
|
|
24
|
+
feeds the missing list to :func:`mcp_status_banner`.
|
|
25
|
+
"""
|
|
26
|
+
available: list[MCPServer] = []
|
|
27
|
+
missing: list[str] = []
|
|
28
|
+
for name in names:
|
|
29
|
+
server = MCPLifecycle.resolve_or_pending(name)
|
|
30
|
+
if server is None or not MCPLifecycle.is_available(name):
|
|
31
|
+
missing.append(name)
|
|
32
|
+
else:
|
|
33
|
+
available.append(server)
|
|
34
|
+
return available, missing
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def resolve_agent_mcp_servers(names: Iterable[str]) -> list[MCPServer]:
|
|
38
|
+
"""The available servers for ``names`` (missing ones dropped)."""
|
|
39
|
+
return resolve_agent_mcp_status(names)[0]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def mcp_status_banner(missing: Sequence[str]) -> str:
|
|
43
|
+
"""Strict-protocol banner prepended to an agent's instructions when servers are down.
|
|
44
|
+
|
|
45
|
+
Empty when nothing is missing — keeps the happy-path prompt bit-identical so
|
|
46
|
+
prompt-cache hits are unaffected.
|
|
47
|
+
"""
|
|
48
|
+
if not missing:
|
|
49
|
+
return ""
|
|
50
|
+
names = ", ".join(f"`{n}`" for n in missing)
|
|
51
|
+
return (
|
|
52
|
+
"## Runtime MCP status\n\n"
|
|
53
|
+
f"The following MCP server(s) you depend on are UNAVAILABLE: {names}.\n"
|
|
54
|
+
"Tools from those servers are NOT exposed to you in this turn.\n"
|
|
55
|
+
"If the user's request requires those tools, reply with exactly:\n\n"
|
|
56
|
+
" error: mcp_unavailable: <server-name>\n\n"
|
|
57
|
+
"and stop. Do not invent results. Do not retry. Do not call other "
|
|
58
|
+
"tools as a substitute unless the user explicitly asks for an "
|
|
59
|
+
"alternative.\n\n---\n\n"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = ["mcp_status_banner", "resolve_agent_mcp_servers", "resolve_agent_mcp_status"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""The v3 authoring surface: declare agents and workflows, compile to ``InvocableSpec``.
|
|
2
|
+
|
|
3
|
+
``Deck`` (composition) and the Runtime (execution) are elsewhere — this package only turns
|
|
4
|
+
declarations into what an engine runs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from agentdeck.authoring.agent import Agent, AgentDeclaration
|
|
8
|
+
from agentdeck.authoring.interrupts import InterruptResult
|
|
9
|
+
from agentdeck.authoring.nodes import AgentNode, LoadFileNode
|
|
10
|
+
from agentdeck.authoring.timers import sleep_until
|
|
11
|
+
from agentdeck.authoring.workflow import Workflow, WorkflowDeclaration
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Agent",
|
|
15
|
+
"AgentDeclaration",
|
|
16
|
+
"AgentNode",
|
|
17
|
+
"InterruptResult",
|
|
18
|
+
"LoadFileNode",
|
|
19
|
+
"Workflow",
|
|
20
|
+
"WorkflowDeclaration",
|
|
21
|
+
"sleep_until",
|
|
22
|
+
]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""``Agent``: the one construction API for an SDK agent, plus the declaration it can start from.
|
|
2
|
+
|
|
3
|
+
v1's ``BaseAgent`` (a class users subclassed) is renamed ``AgentDeclaration`` here — it is a
|
|
4
|
+
declarative *input* to ``Agent(...)``, never something invoked on its own (ruling 10,
|
|
5
|
+
plan-phase4-deck.md). ``Agent`` instances are immutable: a ``Deck`` compiles them once at
|
|
6
|
+
``build()``, and mutating one afterwards must have no effect on what was already compiled
|
|
7
|
+
(ruling 3) — immutability makes that true by construction rather than by convention.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any, ClassVar
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
16
|
+
|
|
17
|
+
from agents.agent_output import AgentOutputSchemaBase
|
|
18
|
+
from agents.lifecycle import AgentHooks
|
|
19
|
+
|
|
20
|
+
_UNSET: Any = object()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AgentDeclaration:
|
|
24
|
+
"""Reusable defaults for :class:`Agent`.
|
|
25
|
+
|
|
26
|
+
Subclass and set class attributes to build a shareable base:
|
|
27
|
+
|
|
28
|
+
class BookingBase(AgentDeclaration):
|
|
29
|
+
instructions = "You handle bookings."
|
|
30
|
+
|
|
31
|
+
booking = Agent(base=BookingBase, name="booking", tools=[...])
|
|
32
|
+
|
|
33
|
+
Never constructed or run directly — it exists only to be named as ``Agent(base=...)``.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
name: ClassVar[str | None] = None
|
|
37
|
+
instructions: ClassVar[str | Callable[..., Any]] = ""
|
|
38
|
+
handoff_description: ClassVar[str | None] = None
|
|
39
|
+
model: ClassVar[str | None] = None
|
|
40
|
+
model_settings: ClassVar[Mapping[str, Any]] = {}
|
|
41
|
+
tools: ClassVar[Sequence[Any]] = ()
|
|
42
|
+
handoffs: ClassVar[Sequence[Any]] = ()
|
|
43
|
+
output_type: ClassVar[type[Any] | AgentOutputSchemaBase | None] = None
|
|
44
|
+
hooks: ClassVar[AgentHooks | None] = None
|
|
45
|
+
skills: ClassVar[Sequence[str]] = ()
|
|
46
|
+
mcp: ClassVar[Sequence[str]] = ()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Agent:
|
|
50
|
+
"""A declarative agent: name, instructions, tools, and the names of the skills/MCP
|
|
51
|
+
servers/workflows it depends on — resolved against the owning :class:`~agentdeck.Deck`'s
|
|
52
|
+
catalog at ``build()``, never at construction time, so an ``Agent`` built alone (with no
|
|
53
|
+
``Deck`` yet) never fails for referencing something it cannot see yet.
|
|
54
|
+
|
|
55
|
+
``base=`` is keyword-only by construction: every parameter here follows a bare ``*``, so
|
|
56
|
+
``Agent(SomeDeclaration, name=...)`` is a ``TypeError`` rather than a silently-accepted
|
|
57
|
+
positional base. A value explicitly passed here always wins over ``base``'s, including an
|
|
58
|
+
explicit empty value — omission, not falsiness, is what defers to the base.
|
|
59
|
+
|
|
60
|
+
``instructions=`` takes a plain string, or a callable ``build()`` compiles into the SDK's
|
|
61
|
+
dynamic-instructions shape — one declaring a ``Context[...]`` parameter receives the run's
|
|
62
|
+
environment, and **only what it returns** reaches the model. ``hooks=`` stays an Agents SDK
|
|
63
|
+
``AgentHooks`` object; a hook method that declares ``Context[...]`` first is bridged the
|
|
64
|
+
same way, and one that does not is passed through untouched.
|
|
65
|
+
|
|
66
|
+
``tools=`` takes plain functions — ``build()`` compiles each one, which is what lets a
|
|
67
|
+
parameter annotated ``Context[...]`` be injected without ever appearing in the schema the
|
|
68
|
+
model sees. An already-built Agents SDK tool object is still accepted and passed straight
|
|
69
|
+
through as engine-native, introspected by nothing here. A callable whose signature cannot be
|
|
70
|
+
read is refused at ``build()``, naming the agent and the callable.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
__slots__ = (
|
|
74
|
+
"name",
|
|
75
|
+
"instructions",
|
|
76
|
+
"handoff_description",
|
|
77
|
+
"model",
|
|
78
|
+
"model_settings",
|
|
79
|
+
"tools",
|
|
80
|
+
"handoffs",
|
|
81
|
+
"output_type",
|
|
82
|
+
"hooks",
|
|
83
|
+
"skills",
|
|
84
|
+
"mcp",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Typed alongside `__slots__` so a type checker can see these as real attributes — the
|
|
88
|
+
# values are set via `object.__setattr__` in `__init__`, which a bare `self.x = ...` a
|
|
89
|
+
# checker infers attributes from would not need, but immutability (see `__setattr__`
|
|
90
|
+
# below) does.
|
|
91
|
+
name: str
|
|
92
|
+
instructions: str | Callable[..., Any]
|
|
93
|
+
handoff_description: str | None
|
|
94
|
+
model: str | None
|
|
95
|
+
model_settings: dict[str, Any]
|
|
96
|
+
tools: tuple[Any, ...]
|
|
97
|
+
handoffs: tuple[Any, ...]
|
|
98
|
+
output_type: type[Any] | AgentOutputSchemaBase | None
|
|
99
|
+
hooks: AgentHooks | None
|
|
100
|
+
skills: tuple[str, ...]
|
|
101
|
+
mcp: tuple[str, ...]
|
|
102
|
+
|
|
103
|
+
def __init__(
|
|
104
|
+
self,
|
|
105
|
+
*,
|
|
106
|
+
base: type[AgentDeclaration] | None = None,
|
|
107
|
+
name: str = _UNSET,
|
|
108
|
+
instructions: str | Callable[..., Any] = _UNSET,
|
|
109
|
+
handoff_description: str | None = _UNSET,
|
|
110
|
+
model: str | None = _UNSET,
|
|
111
|
+
model_settings: Mapping[str, Any] = _UNSET,
|
|
112
|
+
tools: Sequence[Any] = _UNSET,
|
|
113
|
+
handoffs: Sequence[Any] = _UNSET,
|
|
114
|
+
output_type: type[Any] | AgentOutputSchemaBase | None = _UNSET,
|
|
115
|
+
hooks: AgentHooks | None = _UNSET,
|
|
116
|
+
skills: Sequence[str] = _UNSET,
|
|
117
|
+
mcp: Sequence[str] = _UNSET,
|
|
118
|
+
) -> None:
|
|
119
|
+
source = base if base is not None else AgentDeclaration
|
|
120
|
+
resolved_name = source.name if name is _UNSET else name
|
|
121
|
+
if not resolved_name and base is not None:
|
|
122
|
+
# A subclassed declaration that never set `name` names itself after its class,
|
|
123
|
+
# the same convenience v1's `cls.name or cls.__name__` gave a `BaseAgent` subclass.
|
|
124
|
+
resolved_name = base.__name__
|
|
125
|
+
if not resolved_name:
|
|
126
|
+
raise ValueError("Agent(name=...) is required (directly, or via base=).")
|
|
127
|
+
object.__setattr__(self, "name", resolved_name)
|
|
128
|
+
object.__setattr__(self, "instructions", source.instructions if instructions is _UNSET else instructions)
|
|
129
|
+
object.__setattr__(
|
|
130
|
+
self,
|
|
131
|
+
"handoff_description",
|
|
132
|
+
source.handoff_description if handoff_description is _UNSET else handoff_description,
|
|
133
|
+
)
|
|
134
|
+
object.__setattr__(self, "model", source.model if model is _UNSET else model)
|
|
135
|
+
object.__setattr__(
|
|
136
|
+
self, "model_settings", dict(source.model_settings if model_settings is _UNSET else model_settings)
|
|
137
|
+
)
|
|
138
|
+
object.__setattr__(self, "tools", tuple(source.tools if tools is _UNSET else tools))
|
|
139
|
+
object.__setattr__(self, "handoffs", tuple(source.handoffs if handoffs is _UNSET else handoffs))
|
|
140
|
+
object.__setattr__(self, "output_type", source.output_type if output_type is _UNSET else output_type)
|
|
141
|
+
object.__setattr__(self, "hooks", source.hooks if hooks is _UNSET else hooks)
|
|
142
|
+
object.__setattr__(self, "skills", tuple(source.skills if skills is _UNSET else skills))
|
|
143
|
+
object.__setattr__(self, "mcp", tuple(source.mcp if mcp is _UNSET else mcp))
|
|
144
|
+
|
|
145
|
+
def build(self) -> Any:
|
|
146
|
+
"""Compile to an SDK-native ``agents.Agent``, standalone (no catalog).
|
|
147
|
+
|
|
148
|
+
Thin wrapper over :func:`agentdeck.authoring.compile.compile_agent` for the common
|
|
149
|
+
case — an agent with no handoffs, or none needing a catalog to resolve. A ``Deck``
|
|
150
|
+
calls ``compile_agent``/``link_handoffs`` directly instead, since it has the catalog.
|
|
151
|
+
"""
|
|
152
|
+
from agentdeck.authoring.compile import compile_agent
|
|
153
|
+
|
|
154
|
+
return compile_agent(self)
|
|
155
|
+
|
|
156
|
+
async def run(self, message: Any = None, **runner_options: Any) -> Any:
|
|
157
|
+
"""One-shot headless run (no event log); returns the SDK ``RunResult``."""
|
|
158
|
+
from agentdeck.authoring.runners.agent import HeadlessRunner
|
|
159
|
+
|
|
160
|
+
return await HeadlessRunner.from_agent(self.build(), **runner_options).run(message)
|
|
161
|
+
|
|
162
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
|
163
|
+
raise AttributeError(f"Agent is immutable; build a new one instead of setting {key!r}.")
|
|
164
|
+
|
|
165
|
+
def __repr__(self) -> str:
|
|
166
|
+
return f"Agent(name={self.name!r})"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
__all__ = ["Agent", "AgentDeclaration"]
|