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
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,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"]
|