Skip to content

Commit af8d8bb

Browse files
authored
Align API with official LangGraph Server protocol and improve Studio compatibility (#33)
* feat: add Scalar API docs alongside Swagger UI * fix assistant/search 422 issue * feat: align streaming response with official LangGraph Server * fix ci issue * fix ci issue
1 parent abc2ef2 commit af8d8bb

25 files changed

Lines changed: 1510 additions & 211 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
.coverage
33
.agentseek/
44
.pytest_cache/
5+
.ruff_cache/
56
.tmp/
67
.venv/
78
__pycache__/
@@ -15,3 +16,5 @@ venv/
1516
ENV/
1617
env.bak/
1718
venv.bak/
19+
graph/
20+
.langgraph_api/

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ dev = [
4444
"httpx>=0.27.0",
4545
"ruff>=0.6.0",
4646
"a2a-sdk>=1.0.3",
47+
"langgraph-cli[inmem]",
4748
]
4849

4950
[build-system]

src/agentseek_api/api/assistants.py

Lines changed: 100 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from typing import Any
2+
13
from sqlalchemy import select
24

3-
from fastapi import APIRouter, Depends, HTTPException, Response
5+
from fastapi import APIRouter, Depends, HTTPException, Query, Response
6+
from langchain_core.runnables.utils import create_model
47

58
from agentseek_api.core.auth_deps import get_current_user
69
from agentseek_api.core.database import db_manager
@@ -13,12 +16,13 @@
1316
AssistantVersionInfo,
1417
ErrorDetailResponse,
1518
)
19+
from agentseek_api.services.default_assistants import resolve_assistant_id
1620
from agentseek_api.services.langgraph_service import get_langgraph_service
1721

1822
router = APIRouter(dependencies=[Depends(get_current_user)])
19-
ASSISTANT_SUBGRAPHS_UNSUPPORTED = "Assistant subgraph inspection is not supported"
2023
ASSISTANT_VERSION_PROMOTION_UNSUPPORTED = "Assistant version promotion is not supported"
2124
DELETE_THREADS_UNSUPPORTED = "delete_threads=true is not supported"
25+
SUBGRAPHS_UNSUPPORTED = "The graph does not support subgraphs"
2226

2327

2428
def _detail_response(*, description: str, detail: str) -> dict[str, object]:
@@ -111,19 +115,21 @@ async def count_assistants(payload: AssistantSearchRequest) -> int:
111115

112116
@router.get("/{assistant_id}", response_model=AssistantRead)
113117
async def get_assistant(assistant_id: str) -> AssistantRead:
118+
resolved_id = resolve_assistant_id(assistant_id)
114119
session_factory = db_manager.get_session_factory()
115120
async with session_factory() as session:
116-
row = await session.scalar(select(Assistant).where(Assistant.assistant_id == assistant_id))
121+
row = await session.scalar(select(Assistant).where(Assistant.assistant_id == resolved_id))
117122
if row is None:
118123
raise HTTPException(status_code=404, detail="Assistant not found")
119124
return _to_read_model(row)
120125

121126

122127
@router.patch("/{assistant_id}", response_model=AssistantRead)
123128
async def patch_assistant(assistant_id: str, payload: AssistantPatch) -> AssistantRead:
129+
resolved_id = resolve_assistant_id(assistant_id)
124130
session_factory = db_manager.get_session_factory()
125131
async with session_factory() as session:
126-
row = await session.scalar(select(Assistant).where(Assistant.assistant_id == assistant_id))
132+
row = await session.scalar(select(Assistant).where(Assistant.assistant_id == resolved_id))
127133
if row is None:
128134
raise HTTPException(status_code=404, detail="Assistant not found")
129135
if payload.graph_id is not None:
@@ -154,9 +160,10 @@ async def patch_assistant(assistant_id: str, payload: AssistantPatch) -> Assista
154160
async def delete_assistant(assistant_id: str, delete_threads: bool = False) -> Response:
155161
if delete_threads:
156162
raise HTTPException(status_code=400, detail=DELETE_THREADS_UNSUPPORTED)
163+
resolved_id = resolve_assistant_id(assistant_id)
157164
session_factory = db_manager.get_session_factory()
158165
async with session_factory() as session:
159-
row = await session.scalar(select(Assistant).where(Assistant.assistant_id == assistant_id))
166+
row = await session.scalar(select(Assistant).where(Assistant.assistant_id == resolved_id))
160167
if row is None:
161168
raise HTTPException(status_code=404, detail="Assistant not found")
162169
await session.delete(row)
@@ -165,55 +172,119 @@ async def delete_assistant(assistant_id: str, delete_threads: bool = False) -> R
165172

166173

167174
@router.get("/{assistant_id}/graph")
168-
async def get_assistant_graph(assistant_id: str) -> dict[str, object]:
175+
async def get_assistant_graph(
176+
assistant_id: str,
177+
xray: bool | int | None = Query(
178+
None,
179+
description="Expand subgraph nodes. Pass true or a positive integer depth.",
180+
),
181+
) -> dict[str, object]:
169182
assistant = await get_assistant(assistant_id)
170-
return {
171-
"assistant_id": assistant.assistant_id,
172-
"graph_id": assistant.graph_id,
173-
"registered_graph_ids": get_langgraph_service().registered_graph_ids(),
174-
}
183+
entry = get_langgraph_service().get_entry(assistant.graph_id)
184+
graph = entry.build_graph()
185+
if isinstance(xray, int) and not isinstance(xray, bool) and xray <= 0:
186+
raise HTTPException(status_code=422, detail="Invalid xray value")
187+
xray_value: bool | int = xray if xray is not None else False
188+
try:
189+
drawable_graph = await graph.aget_graph(xray=xray_value)
190+
except NotImplementedError as exc:
191+
raise HTTPException(status_code=422, detail="The graph does not support visualization") from exc
192+
json_graph = drawable_graph.to_json()
193+
for node in json_graph.get("nodes", []):
194+
data = node.get("data") if isinstance(node, dict) else None
195+
if isinstance(data, dict):
196+
data.pop("id", None)
197+
return json_graph
175198

176199

177200
@router.get("/{assistant_id}/schemas")
178201
async def get_assistant_schemas(assistant_id: str) -> dict[str, object]:
179202
assistant = await get_assistant(assistant_id)
180203
entry = get_langgraph_service().get_entry(assistant.graph_id)
204+
graph = entry.build_graph()
205+
return {"graph_id": assistant.graph_id, **_extract_graph_schemas(graph)}
206+
207+
208+
def _safe_schema(getter, *args, **kwargs) -> dict[str, object] | None:
209+
try:
210+
return getter(*args, **kwargs)
211+
except Exception: # noqa: BLE001 - graph helpers raise broad errors
212+
return None
213+
214+
215+
def _state_jsonschema(graph) -> dict[str, object] | None:
216+
channel_list = getattr(graph, "stream_channels_list", None)
217+
channels = getattr(graph, "channels", None)
218+
if not channel_list or channels is None:
219+
return None
220+
fields: dict[str, tuple[object, object]] = {}
221+
for key in channel_list:
222+
channel = channels.get(key) if isinstance(channels, dict) else getattr(channels, key, None)
223+
update_type = getattr(channel, "UpdateType", Any) if channel is not None else Any
224+
fields[key] = (update_type, None)
225+
try:
226+
name = graph.get_name("State") if hasattr(graph, "get_name") else "State"
227+
return create_model(name, **fields).model_json_schema()
228+
except Exception: # noqa: BLE001
229+
return None
230+
231+
232+
def _extract_graph_schemas(graph) -> dict[str, object | None]:
181233
return {
182-
"assistant_id": assistant.assistant_id,
183-
"graph_id": assistant.graph_id,
184-
"name": assistant.name,
185-
"description": assistant.description,
186-
"input_schema": entry.input_schema,
187-
"output_schema": entry.output_schema,
234+
"input_schema": _safe_schema(graph.get_input_jsonschema),
235+
"output_schema": _safe_schema(graph.get_output_jsonschema),
236+
"state_schema": _state_jsonschema(graph),
237+
"config_schema": _safe_schema(graph.get_config_jsonschema) if hasattr(graph, "get_config_jsonschema") else None,
238+
"context_schema": _safe_schema(graph.get_context_jsonschema) if hasattr(graph, "get_context_jsonschema") else None,
188239
}
189240

190241

242+
async def _collect_subgraphs(assistant_id: str, *, namespace: str | None, recurse: bool) -> dict[str, dict[str, object | None]]:
243+
assistant = await get_assistant(assistant_id)
244+
entry = get_langgraph_service().get_entry(assistant.graph_id)
245+
graph = entry.build_graph()
246+
aget_subgraphs = getattr(graph, "aget_subgraphs", None)
247+
if not callable(aget_subgraphs):
248+
raise HTTPException(status_code=422, detail=SUBGRAPHS_UNSUPPORTED)
249+
try:
250+
return {
251+
ns: _extract_graph_schemas(subgraph)
252+
async for ns, subgraph in aget_subgraphs(namespace=namespace, recurse=recurse)
253+
}
254+
except NotImplementedError as exc:
255+
raise HTTPException(status_code=422, detail=SUBGRAPHS_UNSUPPORTED) from exc
256+
257+
191258
@router.get(
192259
"/{assistant_id}/subgraphs",
193-
status_code=501,
194-
response_model=None,
260+
response_model=dict[str, dict[str, object | None]],
195261
responses={
196262
404: _detail_response(description="Assistant not found", detail="Assistant not found"),
197-
501: _detail_response(description="Unsupported helper endpoint", detail=ASSISTANT_SUBGRAPHS_UNSUPPORTED),
263+
422: _detail_response(description="Graph does not support subgraphs", detail=SUBGRAPHS_UNSUPPORTED),
198264
},
199265
)
200-
async def get_assistant_subgraphs(assistant_id: str) -> None:
201-
_ = await get_assistant(assistant_id)
202-
raise HTTPException(status_code=501, detail=ASSISTANT_SUBGRAPHS_UNSUPPORTED)
266+
async def get_assistant_subgraphs(
267+
assistant_id: str,
268+
recurse: bool = Query(False, description="Recursively include nested subgraphs."),
269+
namespace: str | None = Query(None, description="Filter to a specific subgraph namespace."),
270+
) -> dict[str, dict[str, object | None]]:
271+
return await _collect_subgraphs(assistant_id, namespace=namespace, recurse=recurse)
203272

204273

205274
@router.get(
206275
"/{assistant_id}/subgraphs/{namespace}",
207-
status_code=501,
208-
response_model=None,
276+
response_model=dict[str, dict[str, object | None]],
209277
responses={
210278
404: _detail_response(description="Assistant not found", detail="Assistant not found"),
211-
501: _detail_response(description="Unsupported helper endpoint", detail=ASSISTANT_SUBGRAPHS_UNSUPPORTED),
279+
422: _detail_response(description="Graph does not support subgraphs", detail=SUBGRAPHS_UNSUPPORTED),
212280
},
213281
)
214-
async def get_assistant_subgraphs_by_namespace(assistant_id: str, namespace: str) -> None:
215-
_ = (await get_assistant(assistant_id), namespace)
216-
raise HTTPException(status_code=501, detail=ASSISTANT_SUBGRAPHS_UNSUPPORTED)
282+
async def get_assistant_subgraphs_by_namespace(
283+
assistant_id: str,
284+
namespace: str,
285+
recurse: bool = Query(False, description="Recursively include nested subgraphs."),
286+
) -> dict[str, dict[str, object | None]]:
287+
return await _collect_subgraphs(assistant_id, namespace=namespace, recurse=recurse)
217288

218289

219290
@router.post(

src/agentseek_api/api/crons.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,19 @@
1717
)
1818
from agentseek_api.models.auth import User
1919
from agentseek_api.services import cron_service
20+
from agentseek_api.services.default_assistants import resolve_assistant_id
2021

2122
router = APIRouter(tags=["Crons"])
2223

2324

24-
async def _ensure_assistant_exists(*, assistant_id: str) -> None:
25+
async def _ensure_assistant_exists(*, assistant_id: str) -> str:
26+
resolved_id = resolve_assistant_id(assistant_id)
2527
session_factory = db_manager.get_session_factory()
2628
async with session_factory() as session:
27-
existing = await session.scalar(select(Assistant.assistant_id).where(Assistant.assistant_id == assistant_id))
29+
existing = await session.scalar(select(Assistant.assistant_id).where(Assistant.assistant_id == resolved_id))
2830
if existing is None:
2931
raise HTTPException(status_code=404, detail="Assistant not found")
32+
return resolved_id
3033

3134

3235
async def _create_cron(
@@ -44,19 +47,19 @@ async def _create_cron(
4447

4548
@router.post("/runs/crons", response_model=CronRead)
4649
async def create_stateless_cron(payload: CronCreate, user: User = Depends(get_current_user)) -> CronRead:
47-
await _ensure_assistant_exists(assistant_id=payload.assistant_id)
48-
return await _create_cron(assistant_id=payload.assistant_id, thread_id=None, payload=payload, user=user)
50+
resolved_id = await _ensure_assistant_exists(assistant_id=payload.assistant_id)
51+
return await _create_cron(assistant_id=resolved_id, thread_id=None, payload=payload, user=user)
4952

5053

5154
@router.post("/threads/{thread_id}/runs/crons", response_model=CronRead)
5255
async def create_thread_cron(thread_id: str, payload: CronCreate, user: User = Depends(get_current_user)) -> CronRead:
53-
await _ensure_assistant_exists(assistant_id=payload.assistant_id)
56+
resolved_id = await _ensure_assistant_exists(assistant_id=payload.assistant_id)
5457
session_factory = db_manager.get_session_factory()
5558
async with session_factory() as session:
5659
thread = await session.scalar(select(Thread).where(Thread.thread_id == thread_id, Thread.user_id == user.identity))
5760
if thread is None:
5861
raise HTTPException(status_code=404, detail="Thread not found")
59-
return await _create_cron(assistant_id=payload.assistant_id, thread_id=thread_id, payload=payload, user=user)
62+
return await _create_cron(assistant_id=resolved_id, thread_id=thread_id, payload=payload, user=user)
6063

6164

6265
@router.post("/runs/crons/search", response_model=CronSearchResponse)

0 commit comments

Comments
 (0)