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.
Files changed (104) hide show
  1. agentdeck/README.md +50 -0
  2. agentdeck/__init__.py +51 -0
  3. agentdeck/adapters/__init__.py +5 -0
  4. agentdeck/adapters/control/__init__.py +1 -0
  5. agentdeck/adapters/control/memory/__init__.py +5 -0
  6. agentdeck/adapters/control/memory/port.py +25 -0
  7. agentdeck/adapters/control/sqlite/__init__.py +5 -0
  8. agentdeck/adapters/control/sqlite/port.py +111 -0
  9. agentdeck/adapters/engines/__init__.py +1 -0
  10. agentdeck/adapters/engines/langgraph/__init__.py +8 -0
  11. agentdeck/adapters/engines/langgraph/checkpointer.py +205 -0
  12. agentdeck/adapters/engines/langgraph/engine.py +410 -0
  13. agentdeck/adapters/engines/openai_agents/__init__.py +9 -0
  14. agentdeck/adapters/engines/openai_agents/engine.py +321 -0
  15. agentdeck/adapters/engines/openai_agents/reconcile.py +178 -0
  16. agentdeck/adapters/engines/openai_agents/runconfig.py +118 -0
  17. agentdeck/adapters/engines/openai_agents/sessions.py +100 -0
  18. agentdeck/adapters/engines/openai_agents/translate.py +124 -0
  19. agentdeck/adapters/engines/stub/__init__.py +5 -0
  20. agentdeck/adapters/engines/stub/engine.py +100 -0
  21. agentdeck/adapters/stores/__init__.py +1 -0
  22. agentdeck/adapters/stores/memory/__init__.py +5 -0
  23. agentdeck/adapters/stores/memory/store.py +148 -0
  24. agentdeck/adapters/stores/postgres/__init__.py +5 -0
  25. agentdeck/adapters/stores/postgres/store.py +374 -0
  26. agentdeck/adapters/stores/redis/__init__.py +5 -0
  27. agentdeck/adapters/stores/redis/store.py +359 -0
  28. agentdeck/adapters/stores/sqlite/__init__.py +5 -0
  29. agentdeck/adapters/stores/sqlite/store.py +338 -0
  30. agentdeck/adapters/telemetry/__init__.py +1 -0
  31. agentdeck/adapters/telemetry/langfuse/__init__.py +18 -0
  32. agentdeck/adapters/telemetry/langfuse/client.py +180 -0
  33. agentdeck/adapters/telemetry/langfuse/sink.py +366 -0
  34. agentdeck/adapters/telemetry/langfuse/trace.py +88 -0
  35. agentdeck/adapters/tools/__init__.py +1 -0
  36. agentdeck/adapters/tools/mcp/__init__.py +18 -0
  37. agentdeck/adapters/tools/mcp/lifecycle.py +178 -0
  38. agentdeck/adapters/tools/mcp/source.py +47 -0
  39. agentdeck/adapters/tools/mcp/transport.py +234 -0
  40. agentdeck/adapters/tools/mcp/wiring.py +63 -0
  41. agentdeck/authoring/__init__.py +22 -0
  42. agentdeck/authoring/agent.py +169 -0
  43. agentdeck/authoring/compile.py +250 -0
  44. agentdeck/authoring/graphs.py +145 -0
  45. agentdeck/authoring/hooks.py +117 -0
  46. agentdeck/authoring/injection.py +233 -0
  47. agentdeck/authoring/instructions.py +80 -0
  48. agentdeck/authoring/interrupts.py +37 -0
  49. agentdeck/authoring/nodes.py +140 -0
  50. agentdeck/authoring/runners/__init__.py +6 -0
  51. agentdeck/authoring/runners/agent.py +171 -0
  52. agentdeck/authoring/runners/workflow.py +99 -0
  53. agentdeck/authoring/skills.py +56 -0
  54. agentdeck/authoring/state.py +44 -0
  55. agentdeck/authoring/timers.py +44 -0
  56. agentdeck/authoring/tools.py +147 -0
  57. agentdeck/authoring/web_search.py +43 -0
  58. agentdeck/authoring/workflow.py +266 -0
  59. agentdeck/cli.py +56 -0
  60. agentdeck/composition.py +213 -0
  61. agentdeck/core/__init__.py +110 -0
  62. agentdeck/core/base.py +47 -0
  63. agentdeck/core/content.py +166 -0
  64. agentdeck/core/context.py +136 -0
  65. agentdeck/core/control.py +150 -0
  66. agentdeck/core/events.py +468 -0
  67. agentdeck/core/invocable.py +38 -0
  68. agentdeck/core/ports/__init__.py +26 -0
  69. agentdeck/core/ports/control.py +35 -0
  70. agentdeck/core/ports/engine.py +66 -0
  71. agentdeck/core/ports/sink.py +53 -0
  72. agentdeck/core/ports/store.py +170 -0
  73. agentdeck/core/ports/tools.py +57 -0
  74. agentdeck/core/reporting.py +75 -0
  75. agentdeck/core/status.py +75 -0
  76. agentdeck/deck.py +894 -0
  77. agentdeck/errors.py +67 -0
  78. agentdeck/mcp.py +82 -0
  79. agentdeck/observers.py +111 -0
  80. agentdeck/py.typed +0 -0
  81. agentdeck/runtime/__init__.py +1 -0
  82. agentdeck/runtime/capture.py +32 -0
  83. agentdeck/runtime/config.default.yaml +38 -0
  84. agentdeck/runtime/discovery.py +175 -0
  85. agentdeck/runtime/dispatch.py +440 -0
  86. agentdeck/runtime/registry.py +173 -0
  87. agentdeck/runtime/service.py +671 -0
  88. agentdeck/runtime/settings.py +568 -0
  89. agentdeck/serve.py +330 -0
  90. agentdeck/skills/__init__.py +114 -0
  91. agentdeck/skills/bundle.py +65 -0
  92. agentdeck/surfaces/__init__.py +4 -0
  93. agentdeck/surfaces/cli/__init__.py +7 -0
  94. agentdeck/surfaces/cli/chat.py +88 -0
  95. agentdeck/surfaces/serve/__init__.py +7 -0
  96. agentdeck/surfaces/serve/app.py +71 -0
  97. agentdeck/surfaces/serve/compat.py +212 -0
  98. agentdeck/surfaces/serve/workflows.py +69 -0
  99. agentdeck/testing.py +364 -0
  100. agentdeck_sdk-3.1.0.dist-info/METADATA +222 -0
  101. agentdeck_sdk-3.1.0.dist-info/RECORD +104 -0
  102. agentdeck_sdk-3.1.0.dist-info/WHEEL +4 -0
  103. agentdeck_sdk-3.1.0.dist-info/entry_points.txt +3 -0
  104. 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"]