Files
forge/apps/api/forge/routers/assistant.py
T
nihalashetty ae67bff5a3 feat: deep-agent canvas, live observability, and multi-environment tooling
Self-hosted platform for building, testing, and shipping LangChain/LangGraph agents. Deep-agent sub-agents on the canvas, a live tracing/observability timeline, auto-provisioned built-in tools with import/export, per-environment tool variables, streamed evaluations, and per-user auth token forwarding.
2026-07-28 01:49:19 +05:30

91 lines
3.3 KiB
Python

"""Forge Assistant endpoint - streams the meta-agent's narration + actions over SSE."""
from __future__ import annotations
import json
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sse_starlette.sse import EventSourceResponse
from forge.deps import current_tenant_id, get_checkpointer, get_session
from forge.services.assistant import AssistantService
from forge.services.projects import ProjectService
router = APIRouter(prefix="/v1/projects/{project_id}/assistant", tags=["assistant"])
SSE_HEADERS = {
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
}
class AssistantMessage(BaseModel):
role: str = "user"
content: str
class AssistantIn(BaseModel):
# Preferred: one new message + a stable thread_id (the checkpointer holds history,
# todos, and files for the thread). `messages` remains for back-compat - when
# thread_id is absent the full transcript is replayed statelessly.
message: str | None = None
thread_id: str | None = None
messages: list[AssistantMessage] = []
class AssistantResumeIn(BaseModel):
thread_id: str
decision: str = "approve" # approve | reject
@router.post("/stream")
async def assistant_stream(
project_id: str,
body: AssistantIn,
tenant_id: str = Depends(current_tenant_id),
checkpointer=Depends(get_checkpointer),
session: AsyncSession = Depends(get_session),
):
# Ownership check (audit H4): the assistant runs tools scoped to the URL's project_id; without
# this, a caller in tenant A could target tenant B's project and read/write its resources. 404
# (not 403) so a foreign project id is indistinguishable from a missing one.
if await ProjectService.get(session, tenant_id, project_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found")
if body.message:
messages = [{"role": "user", "content": body.message}]
else:
messages = [m.model_dump() for m in body.messages]
async def event_gen():
async for frame in AssistantService.stream(
tenant_id=tenant_id, project_id=project_id, messages=messages,
thread_id=body.thread_id, checkpointer=checkpointer,
):
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str)}
return EventSourceResponse(event_gen(), headers=SSE_HEADERS)
@router.post("/resume")
async def assistant_resume(
project_id: str,
body: AssistantResumeIn,
tenant_id: str = Depends(current_tenant_id),
checkpointer=Depends(get_checkpointer),
session: AsyncSession = Depends(get_session),
):
"""Resume a paused assistant thread (HITL approval for destructive tools)."""
if await ProjectService.get(session, tenant_id, project_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "project not found")
async def event_gen():
async for frame in AssistantService.resume(
tenant_id=tenant_id, project_id=project_id, thread_id=body.thread_id,
decision=body.decision, checkpointer=checkpointer,
):
yield {"event": frame["event"], "data": json.dumps(frame["data"], default=str)}
return EventSourceResponse(event_gen(), headers=SSE_HEADERS)