Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 22 additions & 24 deletions backend/app/server.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
"""FastAPI app for the seal durable agent — the UI-facing surface.

Endpoints (mounted under ``/api`` by ``vercel.json``):

POST /chat run a turn, stream the AI SDK UI message stream
GET /chat/{id}/stream resume an in-flight stream
GET /sessions list sessions
POST /sessions create a session
GET /sessions/{id} session metadata + UI message history
POST /sessions/{id}/title generate a title from the first user message
DELETE /sessions/{id} delete a session
POST /upload, GET /files/{p} private blob upload + proxy

`vercel dev` serves this app; the workflow worker (`worker.py`) drives the run.
This process also calls ``vercel.workflow.start``, which imports and replays the
workflow modules, so the preamble mirrors ``worker.py``.
Endpoints:

POST /api/chat run a turn, stream the AI SDK UI message stream
GET /api/chat/{id}/stream resume an in-flight stream
GET /api/sessions list sessions
POST /api/sessions create a session
GET /api/sessions/{id} session metadata + UI message history
POST /api/sessions/{id}/title generate a title from the first user message
DELETE /api/sessions/{id} delete a session
POST /api/upload, GET /api/files/{p} private blob upload + proxy

The workflow worker (``worker.py``) drives the run.
"""

from __future__ import annotations
Expand Down Expand Up @@ -66,7 +64,7 @@ async def lifespan(_app: fastapi.FastAPI) -> collections.abc.AsyncIterator[None]
)


@app.get("/health")
@app.get("/api/health")
async def health() -> dict[str, str]:
return {"status": "ok"}

Expand All @@ -79,7 +77,7 @@ class ChatRequest(pydantic.BaseModel):
messages: list[ai_sdk.UIMessage]


@app.post("/chat")
@app.post("/api/chat")
async def post_chat(request: ChatRequest) -> fastapi.responses.StreamingResponse:
messages, approvals = ai_sdk.to_messages(request.messages)
await sessions.touch(request.session_id)
Expand Down Expand Up @@ -120,7 +118,7 @@ async def post_chat(request: ChatRequest) -> fastapi.responses.StreamingResponse
)


@app.get("/chat/{session_id}/stream")
@app.get("/api/chat/{session_id}/stream")
async def resume_chat(session_id: str) -> fastapi.responses.Response:
# ``useChat({ resume: true })`` GETs this on mount. Re-tail the durable
# stream from the in-flight run's start; 204 when nothing is running.
Expand All @@ -141,17 +139,17 @@ class CreateSessionRequest(pydantic.BaseModel):
title: str | None = None


@app.get("/sessions")
@app.get("/api/sessions")
async def list_sessions() -> list[sessions.SessionMeta]:
return await sessions.list_sessions()


@app.post("/sessions", status_code=201)
@app.post("/api/sessions", status_code=201)
async def create_session(body: CreateSessionRequest) -> sessions.SessionMeta:
return await sessions.create_session(body.id, title=body.title)


@app.get("/sessions/{session_id}")
@app.get("/api/sessions/{session_id}")
async def get_session(session_id: str) -> dict[str, object]:
meta = await sessions.get_session(session_id)
if meta is None:
Expand All @@ -165,7 +163,7 @@ async def get_session(session_id: str) -> dict[str, object]:
return {**meta.model_dump(), "messages": serialized}


@app.post("/sessions/{session_id}/title")
@app.post("/api/sessions/{session_id}/title")
async def generate_title(session_id: str) -> sessions.SessionMeta:
meta = await sessions.get_session(session_id)
if meta is None:
Expand All @@ -186,7 +184,7 @@ async def generate_title(session_id: str) -> sessions.SessionMeta:
return updated


@app.delete("/sessions/{session_id}")
@app.delete("/api/sessions/{session_id}")
async def delete_session(session_id: str) -> dict[str, str]:
if not await sessions.delete_session(session_id):
raise fastapi.HTTPException(status_code=404, detail="Session not found")
Expand All @@ -202,7 +200,7 @@ class UploadResponse(pydantic.BaseModel):
filename: str


@app.post("/upload")
@app.post("/api/upload")
async def upload(file: fastapi.UploadFile) -> UploadResponse:
content = await file.read()
media_type = file.content_type or "application/octet-stream"
Expand All @@ -222,7 +220,7 @@ async def upload(file: fastapi.UploadFile) -> UploadResponse:
)


@app.get("/files/{pathname:path}")
@app.get("/api/files/{pathname:path}")
async def get_file(pathname: str) -> fastapi.responses.Response:
async with AsyncBlobClient() as client:
result = await client.get(pathname, access="private")
Expand Down
9 changes: 4 additions & 5 deletions backend/app/worker.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
"""Worker entrypoint for the durable agent.

`vercel dev` runs this as the `__wkf_*` queue consumer. Importing the workflow
modules constructs their `Workflows()` registries, which registers the queue
Importing `agent` constructs the `Workflows()` registry, which registers the queue
handlers that actually drive `run_session` / `run_turn` to completion.

The preamble (env defaults + the `ai` sandbox passthrough) must run before the
The preamble (env defaults, log settup) must run before the
workflow libraries are imported, so it lives at module top.
"""

Expand Down Expand Up @@ -37,7 +36,7 @@
os.path.join(_BACKEND_DIR, ".seal"),
)

# Importing the driver pulls in turn/session/stream; constructing each module's
# `Workflows()` registers its queue handlers.
# Importing the driver pulls in turn/session/stream.
import agent.driver # noqa: E402, F401
import agent.turn # noqa: E402, F401
from agent import workflow as workflow # noqa: E402, F401
6 changes: 6 additions & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ dependencies = [
"vercel>=0.7.0",
]

[tool.vercel]
entrypoint = "app.server:app"

[[tool.vercel.workflows]]
entrypoint = "app.worker:workflow"

[tool.mypy]
python_version = "3.13"
strict = true
Expand Down
2 changes: 1 addition & 1 deletion backend/tests/test_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ async def collect() -> list[str]:
await sessions.create_session(session_id)
transport = httpx.ASGITransport(app=server.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get(f"/sessions/{session_id}")
response = await client.get(f"/api/sessions/{session_id}")
assert response.status_code == 200
ui_messages: list[dict[str, Any]] = response.json()["messages"]
return sse, ui_messages
Expand Down
23 changes: 9 additions & 14 deletions vercel.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,15 @@
{
"fluid": true,
"experimentalServices": {
"services": {
"frontend": {
"framework": "vite",
"entrypoint": "frontend",
"routePrefix": "/"
"root": "frontend/"
},
"backend": {
"framework": "fastapi",
"entrypoint": "backend/app/server.py",
"routePrefix": "/api"
},
"agent": {
"type": "worker",
"entrypoint": "backend/app/worker.py",
"topics": ["__wkf_*"]
"root": "backend/",
"entrypoint": "pyproject.toml"
}
}
},
"rewrites": [
{ "source": "/api/(.*)", "destination": { "service": "backend" } },
{ "source": "/(.*)", "destination": { "service": "frontend" } }
]
}
Loading