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
agentdeck/serve.py ADDED
@@ -0,0 +1,330 @@
1
+ """Minimal HTTP surface: serve a project over FastAPI.
2
+
3
+ agentdeck-serve # console script; HOST / PORT env override
4
+
5
+ Every endpoint below runs on the v2 ``Runtime`` a ``Deck`` composes: the handler builds a
6
+ ``RunContext``, calls ``Runtime.run`` / ``Runtime.resume`` / ``Runtime.pending``, and hands the
7
+ canonical events to ``surfaces/serve/compat.py``, which renders v1's frames. So a turn — chat
8
+ or workflow — leaves one canonical event log behind. The wire below is unchanged by that; that
9
+ is the point, and ``tests/golden/`` is what proves it.
10
+
11
+ ``create_app()`` is ``Deck.from_project().asgi()`` — kept as a module-level function because the
12
+ console script and every existing test import it by name; ``build_asgi_app(deck)`` is what
13
+ :meth:`agentdeck.deck.Deck.asgi` actually calls, for a ``Deck`` built any other way.
14
+
15
+ Endpoints:
16
+ GET /health -> {"status": "ok", agents, workflows, skills}
17
+ 503 {"status": "starting"} before the lifespan runs
18
+ POST /agents/{name}/chat -> {"session_id", "message"} -> {"output"}
19
+ POST /agents/{name}/chat?stream=true
20
+ -> text/event-stream: "delta" events, then one
21
+ "done" event carrying {"output", "usage"};
22
+ an "error" event replaces "done" if the turn fails
23
+ POST /workflows/{name} -> JSON state in, final state out — or
24
+ {"type": "interrupt", "payload", "thread_id"} when the
25
+ run pauses on a human decision;
26
+ 409 while that thread's previous turn is unfinished,
27
+ an unanswered approval included
28
+ POST /workflows/{name}?stream=true
29
+ -> text/event-stream: "node_update"/"custom" events per
30
+ the LangGraph node updates/custom stream, then one
31
+ "done" event carrying the final state — or one
32
+ "interrupt" event in its place when the run pauses;
33
+ an "error" event replaces either if the run fails
34
+ GET /workflows/{name}/pending -> [{"type": "interrupt", "payload", "thread_id"}, ...]
35
+ POST /workflows/{name}/{thread_id}/resume
36
+ -> {"value": ...} -> final state, or the next interrupt;
37
+ 404 when the thread has no paused run to answer
38
+ POST /runs/{run_id}/pause -> {"reason": ...}? -> {"run_id", "verb", "recorded": true}
39
+ POST /runs/{run_id}/cancel -> {"reason": ...}? -> {"run_id", "verb", "recorded": true}
40
+ Recorded, not applied: the run stops at its next safe
41
+ point, and its own run.paused / run.cancelled event is
42
+ what says it did
43
+ POST /runs/{run_id}/resume -> {"reason": ...}? -> {"run_id", "status", "events"} for
44
+ the continuation this call played — 409 if the run is
45
+ not paused
46
+
47
+ The workflow inbox above reads the event log, while ``Deck.due_resumes()`` still reads
48
+ the graph's checkpointer — so the two disagree once approvals are driven through both doors
49
+ (see the CHANGELOG for the plan to join them).
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ import argparse
55
+ import logging
56
+ import os
57
+ from contextlib import aclosing, asynccontextmanager
58
+ from typing import TYPE_CHECKING, Any
59
+
60
+ from agentdeck.core.content import coerce_input
61
+ from agentdeck.core.status import status_of
62
+ from agentdeck.errors import AgentdeckError, NotFoundError, SessionBusyError
63
+ from agentdeck.surfaces.serve.compat import (
64
+ chat_frames,
65
+ chat_result,
66
+ interrupt_inbox,
67
+ workflow_frames,
68
+ workflow_result,
69
+ )
70
+
71
+ if TYPE_CHECKING:
72
+ from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
73
+
74
+ from fastapi import FastAPI
75
+
76
+ from agentdeck.core.events import Event
77
+ from agentdeck.deck import Deck
78
+
79
+ logger = logging.getLogger(__name__)
80
+
81
+
82
+ def create_app() -> Any:
83
+ from agentdeck.deck import Deck
84
+
85
+ return Deck.from_project().asgi()
86
+
87
+
88
+ def _require_agent(deck: Deck, name: str) -> None:
89
+ """v1's 404 wording, owned here now that ``Deck`` exposes only the public ``agents``
90
+ mapping — a route that needs "unknown agent" as a client-facing miss checks membership
91
+ itself rather than reaching for a private lookup."""
92
+ if name not in deck.agents:
93
+ raise NotFoundError(f"No agent named {name!r}. Available: {sorted(deck.agents)}.")
94
+
95
+
96
+ def _require_workflow(deck: Deck, name: str) -> None:
97
+ if name not in deck.workflows:
98
+ raise NotFoundError(f"No workflow named {name!r}. Available: {sorted(deck.workflows)}.")
99
+
100
+
101
+ def build_asgi_app(deck: Deck) -> Any:
102
+ """The FastAPI app whose lifespan opens and closes ``deck`` — what
103
+ :meth:`agentdeck.deck.Deck.asgi` calls. Kept here, not in ``deck.py``: this is the one
104
+ module allowed to import FastAPI and the v1 wire-rendering helpers, the same rule every
105
+ other adapter directory follows for its own external system.
106
+ """
107
+ from fastapi import FastAPI, HTTPException, Request
108
+ from fastapi.responses import JSONResponse, StreamingResponse
109
+
110
+ @asynccontextmanager
111
+ async def lifespan(api: FastAPI) -> AsyncIterator[None]:
112
+ # `async with deck` closes the Redis session client + MCP servers on shutdown
113
+ # (including SIGTERM from `compose stop`), so no connection is leaked. Opening it is
114
+ # also where `resolve_event_store`/`resolve_control_port` run, which is where a
115
+ # `memory://` backend now logs its own "won't survive a restart" warning — composition
116
+ # time, not a server-specific check here.
117
+ async with deck:
118
+ api.state.deck = deck
119
+ yield
120
+ api.state.deck = None
121
+
122
+ api = FastAPI(title="agentdeck", lifespan=lifespan)
123
+ api.state.deck = None # set by the lifespan; None means "not started yet"
124
+
125
+ def _deck() -> Deck:
126
+ if api.state.deck is None:
127
+ raise HTTPException(status_code=503, detail="agentdeck is not started")
128
+ return api.state.deck
129
+
130
+ # Starlette resolves a handler by walking the exception's MRO, so the
131
+ # AgentdeckError entry below would already catch NotFoundError. It gets its
132
+ # own entry because it is the one AgentdeckError caused by client input, and
133
+ # so the only one whose message is safe to echo back.
134
+ @api.exception_handler(NotFoundError)
135
+ async def not_found(_request: Request, exc: NotFoundError) -> JSONResponse:
136
+ return JSONResponse(status_code=404, content={"detail": str(exc)})
137
+
138
+ # The one refusal that is neither the client's malformed input nor a server fault: the log
139
+ # answered, and the answer was "this session already has a turn in flight". Its message
140
+ # names the holding run, which is what a caller retries behind.
141
+ @api.exception_handler(SessionBusyError)
142
+ async def session_busy(_request: Request, exc: SessionBusyError) -> JSONResponse:
143
+ return JSONResponse(status_code=409, content={"detail": str(exc)})
144
+
145
+ @api.exception_handler(AgentdeckError)
146
+ async def internal_error(request: Request, exc: AgentdeckError) -> JSONResponse:
147
+ # Every other AgentdeckError is a server-side fault, and its message can
148
+ # carry secrets (skill stderr, config values) — log it, never ship it.
149
+ logger.exception("%s serving %s", type(exc).__name__, request.url.path, exc_info=exc)
150
+ return JSONResponse(status_code=500, content={"detail": "internal error"})
151
+
152
+ @api.get("/health")
153
+ async def health() -> Any:
154
+ if api.state.deck is None:
155
+ return JSONResponse({"status": "starting"}, status_code=503)
156
+ return {"status": "ok", **_inventory(api.state.deck)}
157
+
158
+ @api.post("/agents/{name}/chat")
159
+ async def chat(name: str, body: dict[str, Any], stream: bool = False) -> Any:
160
+ # Read and validate the body up front: inside the generator either failure would
161
+ # surface as a 200 that just stops, since the response headers are already on the wire.
162
+ try:
163
+ session_id, message = body["session_id"], body["message"]
164
+ except KeyError as exc:
165
+ raise HTTPException(status_code=422, detail=f"missing field: {exc.args[0]}") from exc
166
+ # A body shape this endpoint cannot run is the client's mistake, not a server fault: 4xx
167
+ # like every other bad body here, never a 500 in somebody's alerting. Both fields are
168
+ # checked, because both reach a model that only accepts a string.
169
+ if not isinstance(session_id, str):
170
+ raise HTTPException(status_code=422, detail=f"session_id must be a string, got {type(session_id).__name__}")
171
+ try:
172
+ content = coerce_input(message)
173
+ except TypeError as exc:
174
+ raise HTTPException(
175
+ status_code=422, detail=f"message must be a string, got {type(message).__name__}"
176
+ ) from exc
177
+ deck = _deck() # resolve before streaming so a pre-startup 503 keeps its status code
178
+ # Resolved against the agent catalog, not the Runtime's invocables: this route is
179
+ # agents-only (a workflow name must still 404 here), and the message matches v1's own.
180
+ _require_agent(deck, name)
181
+ run = deck.stream(name, content, session_id=session_id)
182
+ if stream:
183
+ return StreamingResponse(
184
+ chat_frames(await _opened(run)),
185
+ media_type="text/event-stream",
186
+ # Proxies buffer streamed responses by default; nginx needs X-Accel-Buffering.
187
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
188
+ )
189
+ return await chat_result(run)
190
+
191
+ @api.post("/workflows/{name}")
192
+ async def run_workflow(name: str, state: dict[str, Any], stream: bool = False, thread_id: str | None = None) -> Any:
193
+ deck = _deck() # resolve before streaming so a pre-startup 503 keeps its status code
194
+ # Workflows-only, resolved against the workflow catalog rather than the Runtime's
195
+ # invocables: an agent name must still 404 here, with v1's message.
196
+ _require_workflow(deck, name)
197
+ # The posted state *is* the graph's input, and the thread the caller named is the
198
+ # session it runs under: one turn per thread at a time, and a resume can find it later.
199
+ run = deck.stream(name, state, session_id=thread_id)
200
+ if stream:
201
+ return StreamingResponse(
202
+ workflow_frames(await _opened(run)),
203
+ media_type="text/event-stream",
204
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
205
+ )
206
+ return await workflow_result(run)
207
+
208
+ @api.post("/runs/{run_id}/pause")
209
+ async def pause_run(run_id: str, body: dict[str, Any] | None = None) -> Any:
210
+ return await _control(_deck().pause, run_id, body, "pause")
211
+
212
+ @api.post("/runs/{run_id}/cancel")
213
+ async def cancel_run(run_id: str, body: dict[str, Any] | None = None) -> Any:
214
+ return await _control(_deck().cancel, run_id, body, "cancel")
215
+
216
+ @api.post("/runs/{run_id}/resume")
217
+ async def resume_run(run_id: str, body: dict[str, Any] | None = None) -> Any:
218
+ events = await _deck().resume(run_id, _reason(body))
219
+ if not events:
220
+ # 409, not 404: the run may well exist and simply not be paused — running,
221
+ # finished, cancelled, or already picked up by another worker. All of those are the
222
+ # same answer to this request, and none of them is an error the caller should retry.
223
+ raise HTTPException(status_code=409, detail=f"run {run_id!r} is not paused")
224
+ return {"run_id": run_id, "status": status_of(events).value, "events": len(events)}
225
+
226
+ def _reason(body: dict[str, Any] | None) -> str | None:
227
+ """The optional ``reason`` a control request carries, validated at the trust boundary:
228
+ it is recorded in the log and read by whoever asks why a run stopped."""
229
+ reason = (body or {}).get("reason")
230
+ if reason is not None and not isinstance(reason, str):
231
+ raise HTTPException(status_code=422, detail=f"reason must be a string, got {type(reason).__name__}")
232
+ return reason
233
+
234
+ async def _control(
235
+ operation: Callable[[str, str | None], Awaitable[bool]], run_id: str, body: dict[str, Any] | None, verb: str
236
+ ) -> Any:
237
+ """Pause and cancel are the same request: record intent for a ``run_id``, answer at once.
238
+
239
+ ``recorded`` says the request is written down, never that the run has stopped — a run
240
+ inside a tool call stops at its next safe point, and the run's own ``run.paused`` /
241
+ ``run.cancelled`` event is what reports that. Runs that already ended are accepted and
242
+ do nothing, which is what makes a double-clicked cancel harmless.
243
+ """
244
+ if not await operation(run_id, _reason(body)):
245
+ raise HTTPException(status_code=503, detail="run control is unavailable: no control backend is configured")
246
+ return {"run_id": run_id, "verb": verb, "recorded": True}
247
+
248
+ @api.get("/workflows/{name}/pending")
249
+ async def pending_interrupts(name: str) -> Any:
250
+ deck = _deck()
251
+ _require_workflow(deck, name)
252
+ return interrupt_inbox(await deck.pending(), name)
253
+
254
+ @api.post("/workflows/{name}/{thread_id}/resume")
255
+ async def resume_workflow(name: str, thread_id: str, body: dict[str, Any]) -> Any:
256
+ if "value" not in body:
257
+ raise HTTPException(status_code=422, detail="missing field: value")
258
+ deck = _deck()
259
+ _require_workflow(deck, name)
260
+ # v1's own 404 names the thread, not a run_id the caller never posted — so the lookup
261
+ # stays here rather than moving into Deck.answer, whose own miss talks about run_id.
262
+ paused = next(
263
+ (run for run in await deck.pending() if run.invocable == name and run.thread_id == thread_id),
264
+ None,
265
+ )
266
+ if paused is None:
267
+ raise NotFoundError(f"No paused run of {name!r} on thread {thread_id!r}.")
268
+ return await deck.answer(paused.run_id, body["value"])
269
+
270
+ return api
271
+
272
+
273
+ def _inventory(deck: Deck) -> dict[str, list[str]]:
274
+ """v1's ``{"agents": [...], "workflows": [...], "skills": [...]}`` — built from ``Deck``'s
275
+ own properties rather than a separate cache, since they are already read-only mappings.
276
+
277
+ ``skills.list()``, not ``.build()``: ``build()`` re-scans disk and overwrites the registry
278
+ every call, which would make a hot probe endpoint re-read every ``SKILL.md`` on each request
279
+ and let one edited after startup change what ``load_skill`` returns mid-run — the catalog is
280
+ supposed to be immutable once ``BUILT``. ``Deck.build()`` already populated the cache
281
+ ``list()`` reads.
282
+ """
283
+ return {
284
+ "agents": sorted(deck.agents),
285
+ "workflows": sorted(deck.workflows),
286
+ "skills": sorted(deck.skills.list()) if deck.skills is not None else [],
287
+ }
288
+
289
+
290
+ async def _opened(run: AsyncGenerator[Event, None]) -> AsyncGenerator[Event, None]:
291
+ """``run`` with its opening event already pulled, so a refusal is still an answer.
292
+
293
+ A run is claimed on its first event, so a generator handed straight to
294
+ ``StreamingResponse`` has committed ``200`` and ``text/event-stream`` before the claim is
295
+ even attempted — and a ``SessionBusyError`` then reaches the client in-band as a body that
296
+ stops, indistinguishable from a run that produced nothing. Pulling the opening event here
297
+ lets the refusal reach the exception handler that answers it **409**. The wire is unchanged:
298
+ ``run.started`` renders to no v1 frame, streamed or not.
299
+ """
300
+ opening = await anext(run)
301
+
302
+ async def replayed() -> AsyncGenerator[Event, None]:
303
+ # Closing this generator does not close the one it delegates to — a consumer that walks
304
+ # away must still land as a ``GeneratorExit`` inside the run, which is what closes it in
305
+ # the log.
306
+ async with aclosing(run):
307
+ yield opening
308
+ async for event in run:
309
+ yield event
310
+
311
+ return replayed()
312
+
313
+
314
+ def main(argv: list[str] | None = None) -> None:
315
+ parser = argparse.ArgumentParser(
316
+ prog="agentdeck-serve",
317
+ description="Serves the deck discovered in ./.agentdeck over the v1 HTTP/SSE surface.",
318
+ epilog="Run it from the directory that holds .agentdeck — there is no --project-dir flag.",
319
+ )
320
+ parser.add_argument("--host", default=os.getenv("HOST", "0.0.0.0"), help="interface to bind (env: HOST)")
321
+ parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")), help="port to bind (env: PORT)")
322
+ args = parser.parse_args(argv)
323
+
324
+ import uvicorn
325
+
326
+ uvicorn.run(create_app(), host=args.host, port=args.port)
327
+
328
+
329
+ if __name__ == "__main__":
330
+ main()
@@ -0,0 +1,114 @@
1
+ """``Skills`` — the capability object a ``Deck`` composes for skill disclosure.
2
+
3
+ A skill is progressive knowledge disclosure inside an agent's own execution, not a program to
4
+ run (``docs/delivery/plan-skills.md``). ``Skills`` owns one or more root directories, scans each
5
+ one's direct children for ``<root>/<name>/SKILL.md`` — never recursively — and merges the result
6
+ into one name-keyed registry at :meth:`Skills.build`. Constructing it reads nothing from disk;
7
+ ``build()`` is the one place a bad skill directory is reported.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import TYPE_CHECKING
14
+
15
+ from agentdeck.errors import ConfigError, NotFoundError
16
+ from agentdeck.skills.bundle import SKILL_MD_FILENAME, SkillBundle
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Sequence
20
+
21
+
22
+ class Skills:
23
+ """One or more skill roots, merged into one registry at :meth:`build`.
24
+
25
+ ``validate=True`` (the default) enforces what the Agent Skills contract requires and a
26
+ permissive scan would not catch: a ``SKILL.md``'s frontmatter ``name`` must match its
27
+ directory name, and it must declare a non-empty ``description`` — the text a model reads to
28
+ decide whether to use the skill at all. Both fail ``build()`` loudly instead of registering
29
+ a skill nothing can address or nothing can choose.
30
+ """
31
+
32
+ def __init__(self, *roots: str | Path, validate: bool = True) -> None:
33
+ if not roots:
34
+ raise ConfigError("Skills() needs at least one root directory.")
35
+ self._roots = tuple(Path(r).expanduser() for r in roots)
36
+ self._validate = validate
37
+ self._bundles: dict[str, SkillBundle] | None = None
38
+
39
+ @property
40
+ def roots(self) -> tuple[Path, ...]:
41
+ return self._roots
42
+
43
+ def build(self) -> dict[str, SkillBundle]:
44
+ """Scan every root (direct children only) and merge into one registry.
45
+
46
+ A root that does not exist on disk contributes no skills rather than failing — a
47
+ project need not have created its skills directory yet. Re-scans on every call; nothing
48
+ here holds a resource worth caching across calls.
49
+ """
50
+ merged: dict[str, SkillBundle] = {}
51
+ found_under: dict[str, Path] = {}
52
+ for root in self._roots:
53
+ resolved = root.resolve()
54
+ if not resolved.is_dir():
55
+ continue
56
+ for child in sorted(resolved.iterdir()):
57
+ if not child.is_dir() or child.name.startswith((".", "_")):
58
+ continue
59
+ if not (child / SKILL_MD_FILENAME).is_file():
60
+ continue
61
+ bundle = SkillBundle.from_path(child)
62
+ if self._validate:
63
+ _validate_bundle(bundle, child)
64
+ if bundle.name in merged:
65
+ raise ConfigError(
66
+ f"skill {bundle.name!r} found under both {found_under[bundle.name]} and "
67
+ f"{child} — a skill name must be unique across roots."
68
+ )
69
+ merged[bundle.name] = bundle
70
+ found_under[bundle.name] = child
71
+ self._bundles = merged
72
+ return dict(merged)
73
+
74
+ def list(self, *, refresh: bool = False) -> dict[str, SkillBundle]:
75
+ if refresh or self._bundles is None:
76
+ self.build()
77
+ assert self._bundles is not None # populated by build() on the line above
78
+ return dict(self._bundles)
79
+
80
+ def get(self, name: str) -> SkillBundle:
81
+ bundles = self.list()
82
+ try:
83
+ return bundles[name]
84
+ except KeyError:
85
+ raise NotFoundError(
86
+ f"No skill named {name!r} under {[str(r) for r in self._roots]}. Available: {sorted(bundles)}."
87
+ ) from None
88
+
89
+ def disclosure_text(self, names: Sequence[str]) -> str:
90
+ """The instructions-block for an agent's own ``skills=[...]``: each name and
91
+ description, never the full body — a model still decides whether to use one instead of
92
+ finding it pre-loaded into its context."""
93
+ if not names:
94
+ return ""
95
+ lines = [
96
+ "\n\n### Skills available",
97
+ "Each entry below is a name and a description. Call `load_skill(name)` to read the "
98
+ "full instructions before following one.",
99
+ ]
100
+ lines.extend(f"- {name}: {self.get(name).description}" for name in names)
101
+ return "\n".join(lines)
102
+
103
+
104
+ def _validate_bundle(bundle: SkillBundle, path: Path) -> None:
105
+ if bundle.name != path.name:
106
+ raise ConfigError(
107
+ f"{path / SKILL_MD_FILENAME}: frontmatter declares name {bundle.name!r}, which must "
108
+ f"match its directory name {path.name!r}."
109
+ )
110
+ if not bundle.description:
111
+ raise ConfigError(f"{path / SKILL_MD_FILENAME}: missing a 'description' in its frontmatter.")
112
+
113
+
114
+ __all__ = ["Skills", "SkillBundle"]
@@ -0,0 +1,65 @@
1
+ """Parse one ``SKILL.md`` bundle directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import yaml
11
+
12
+ from agentdeck.errors import ConfigError
13
+
14
+ SKILL_MD_FILENAME = "SKILL.md"
15
+
16
+ _FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL)
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class SkillBundle:
21
+ """A parsed skill bundle on disk. Construct via :meth:`from_path`."""
22
+
23
+ name: str
24
+ path: Path
25
+ description: str = ""
26
+ frontmatter: dict[str, Any] = field(default_factory=dict)
27
+ body: str = ""
28
+
29
+ @classmethod
30
+ def from_path(cls, path: str | Path, *, name: str | None = None) -> SkillBundle:
31
+ bundle_path = Path(path).expanduser().resolve()
32
+ if not bundle_path.is_dir():
33
+ raise FileNotFoundError(f"skill bundle not found: {bundle_path}")
34
+ skill_md = bundle_path / SKILL_MD_FILENAME
35
+ if not skill_md.is_file():
36
+ raise FileNotFoundError(f"missing {SKILL_MD_FILENAME} under {bundle_path}")
37
+ frontmatter, body = _split_frontmatter(skill_md.read_text(encoding="utf-8"))
38
+ return cls(
39
+ name=name or _str_field(frontmatter, "name") or bundle_path.name,
40
+ path=bundle_path,
41
+ description=_str_field(frontmatter, "description").strip(),
42
+ frontmatter=frontmatter,
43
+ body=body,
44
+ )
45
+
46
+
47
+ def _str_field(frontmatter: dict[str, Any], key: str) -> str:
48
+ value = frontmatter.get(key)
49
+ return value if isinstance(value, str) else ""
50
+
51
+
52
+ def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]:
53
+ match = _FRONTMATTER_RE.match(text)
54
+ if not match:
55
+ return {}, text
56
+ try:
57
+ data = yaml.safe_load(match.group(1)) or {}
58
+ except yaml.YAMLError as exc:
59
+ raise ConfigError(f"invalid YAML frontmatter: {exc}") from exc
60
+ if not isinstance(data, dict):
61
+ raise ConfigError(f"frontmatter must be a YAML mapping, got {type(data).__name__}")
62
+ return data, text[match.end() :]
63
+
64
+
65
+ __all__ = ["SKILL_MD_FILENAME", "SkillBundle"]
@@ -0,0 +1,4 @@
1
+ """Ring 3: thin readers of a ``Runtime`` handed to them by the composition root.
2
+ Never import an adapter directly — wiring is the composition root's job, and the
3
+ import linter enforces it.
4
+ """
@@ -0,0 +1,7 @@
1
+ """The ~50-line CLI chat renderer — the switch-loop reference consumer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agentdeck.surfaces.cli.chat import render, stream_chat
6
+
7
+ __all__ = ["render", "stream_chat"]
@@ -0,0 +1,88 @@
1
+ """~50-line CLI chat renderer — the reference consumer for the event stream.
2
+
3
+ Distinguishes bubbles using **only** ``event.origin`` and ``payload.message_id`` — the
4
+ label is ``origin``, the bubble boundary is ``message_id``; grep this file for any other
5
+ mechanism and you won't find one. The transcript is rebuilt from
6
+ ``message.completed`` alone: ``text.delta`` is read for nothing, so there is no delta
7
+ assembly to get wrong.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import TYPE_CHECKING
14
+
15
+ from agentdeck.core.content import TextBlock
16
+ from agentdeck.core.events import (
17
+ ControlObserved,
18
+ ControlRequested,
19
+ Event,
20
+ MessageCompleted,
21
+ ProgressReported,
22
+ RunCancelled,
23
+ RunCompleted,
24
+ RunFailed,
25
+ RunStarted,
26
+ StatusReported,
27
+ ToolCallCompleted,
28
+ ToolCallStarted,
29
+ )
30
+
31
+ if TYPE_CHECKING:
32
+ from collections.abc import AsyncIterator
33
+
34
+ import httpx
35
+
36
+
37
+ async def render(lines: AsyncIterator[str]) -> None:
38
+ """Print one line per bubble: a user turn, a tool notice, a completed message, a control
39
+ notice, or the run's close. Everything else (deltas, ``custom``, ``node.updated``, ...)
40
+ is skipped by design, per this module's docstring — the ``case _`` default below."""
41
+ async for line in lines:
42
+ if not line.startswith("data: "):
43
+ continue # blank keep-alives and any `event: ...` framing line
44
+ event = Event.model_validate(json.loads(line.removeprefix("data: ")))
45
+ match event.payload:
46
+ case RunStarted(input=blocks):
47
+ text = " ".join(block.text for block in blocks if isinstance(block, TextBlock))
48
+ print(f"You: {text}")
49
+ case ToolCallStarted(tool=tool):
50
+ print(f"[tool] {tool} called")
51
+ case ToolCallCompleted(tool=tool, result_preview=preview):
52
+ print(f"[tool] {tool} -> {preview}")
53
+ case StatusReported(message=message):
54
+ print(f"[status] {message}")
55
+ case ProgressReported(step=step, current=current, total=total):
56
+ print(f"[progress] {step}{_counted(current, total)}")
57
+ case MessageCompleted(message_id=message_id, text=text):
58
+ print(f"{event.origin} [{message_id}]: {text}")
59
+ case ControlRequested(verb=verb, reason=reason):
60
+ # Between a request and the run acting on it there can be a whole tool call,
61
+ # so a reader watching a stream that has gone quiet is told which it is.
62
+ print(f"[control] {verb} requested" + (f": {reason}" if reason else ""))
63
+ case ControlObserved(verb=verb, safe_point=safe_point):
64
+ print(f"[control] {verb} observed at {safe_point}")
65
+ case RunCompleted() | RunFailed() | RunCancelled():
66
+ print(f"-- {event.kind} --")
67
+ case _:
68
+ pass
69
+
70
+
71
+ def _counted(current: int | None, total: int | None) -> str:
72
+ """``" (2/4)"`` when counted, ``""`` when the stage has no numbers — and never a
73
+ percentage, which a missing ``total`` would make up."""
74
+ if current is None and total is None:
75
+ return ""
76
+ return f" ({'?' if current is None else current}/{'?' if total is None else total})"
77
+
78
+
79
+ async def stream_chat(client: httpx.AsyncClient, name: str, session_id: str, message: str) -> None:
80
+ """Post one chat turn to the crude SSE route (``surfaces/serve/app.py``) and render it
81
+ live — the thing this whole module exists to prove works end to end."""
82
+ async with client.stream(
83
+ "POST", f"/v2/invocables/{name}/chat", json={"session_id": session_id, "message": message}
84
+ ) as response:
85
+ await render(response.aiter_lines())
86
+
87
+
88
+ __all__ = ["render", "stream_chat"]
@@ -0,0 +1,7 @@
1
+ """The crude v2 SSE surface — a skeleton component, hardened or discarded at the M0 review."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agentdeck.surfaces.serve.app import build_app
6
+
7
+ __all__ = ["build_app"]