Skip to content
Closed
11 changes: 8 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,13 @@ dependencies = [
"langgraph>=1.1.10,<1.2",

# LLM Providers
"langchain-groq>=0.1.0",
"langchain-google-genai>=1.0.0",
"langchain-openai",
"langchain-litellm",

# Tools
"requests>=2.31.0",
"html2text>=2020.1.16",
"detect-secrets>=1.5,<2",
"pathspec>=0.12.1",

# Data & Config
"pydantic>=2.0.0",
Expand Down Expand Up @@ -56,4 +55,10 @@ addopts = "--basetemp=.pytest-tmp"
cache_dir = ".pytest-cache"
filterwarnings = [
"ignore::langgraph.warnings.LangGraphDeprecatedSinceV10",
# LangChain <-> pydantic type-widening noise: library declares base types
# (Generation/BaseMessage) but emits subclasses (ChatGeneration/AIMessage)
# at serialize time. Cosmetic, library-side, not actionable in this repo.
# Matched by message text because pydantic registers the warning class
# dynamically (not importable as pydantic.PydanticSerializationUnexpectedValue).
'ignore:Pydantic serializer warnings:UserWarning',
]
5 changes: 1 addition & 4 deletions src/agent/api/routes/health.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
from fastapi import APIRouter
from agent.api.schemas import HealthResponse
from agent.config import get_llm_provider, get_generation_model

router = APIRouter(tags=["Health"])

@router.get("/health", response_model=HealthResponse)
async def health_check():
"""Health check + current LLM configuration."""
"""Health check endpoint."""
return HealthResponse(
status="healthy",
version="1.0.0",
provider=get_llm_provider(),
model=get_generation_model(),
)
49 changes: 41 additions & 8 deletions src/agent/api/routes/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@
from datetime import datetime, timezone
from uuid import uuid4

from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from langgraph.types import Command

from agent.api.schemas.streaming import ResumeRequest, StreamRequest, build_messages
from agent.streaming.runner import request_cancel, stream_graph
from agent.streaming.runner import graph, request_cancel, stream_graph


router = APIRouter(tags=["Generation"])
Expand All @@ -21,7 +21,12 @@ async def generate_stream(request: StreamRequest):
"""Start a new streaming workflow generation."""
thread_id = request.thread_id
run_id = f"run_{uuid4().hex[:12]}"
config = {"configurable": {"thread_id": thread_id}}
config = {
"configurable": {
"thread_id": thread_id,
"llm_config": request.llm.model_dump(),
}
}

initial_state = {
"messages": build_messages(request.history, request.prompt),
Expand Down Expand Up @@ -69,10 +74,30 @@ async def generate_stream(request: StreamRequest):

@router.post("/generate/{thread_id}/stream/resume")
async def resume_stream(thread_id: str, request: ResumeRequest):
"""Resume an interrupted workflow generation stream."""
"""Resume an interrupted workflow generation stream.

Raises:
HTTPException: 404 if thread_id does not exist
"""
# Validate thread exists before attempting resume
config = {
"configurable": {
"thread_id": thread_id,
"llm_config": request.llm.model_dump(),
}
}

state = graph.get_state(config)

# MemorySaver returns empty state for unknown threads
if not state.values:
raise HTTPException(
status_code=404,
detail=f"Thread '{thread_id}' not found. Cannot resume non-existent thread.",
)

run_id = f"run_{uuid4().hex[:12]}"
config = {"configurable": {"thread_id": thread_id}}


return StreamingResponse(
stream_graph(
Command(resume=request.response),
Expand All @@ -93,7 +118,15 @@ async def resume_stream(thread_id: str, request: ResumeRequest):

@router.post("/generate/runs/{run_id}/cancel")
async def cancel_generation(run_id: str):
"""Signal a running generation stream to stop."""
"""Signal a running generation stream to stop.

Raises:
HTTPException: 404 if run_id does not exist
"""
if request_cancel(run_id):
return {"status": "cancelling", "run_id": run_id}
return {"status": "not_found", "run_id": run_id}

raise HTTPException(
status_code=404,
detail=f"Run '{run_id}' not found or already completed.",
)
2 changes: 0 additions & 2 deletions src/agent/api/schemas/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,3 @@ class HealthResponse(BaseModel):

status: str
version: str
provider: str
model: str
48 changes: 46 additions & 2 deletions src/agent/api/schemas/streaming.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,52 @@

from __future__ import annotations

from typing import Literal
from typing import Literal, Optional

from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator


Provider = Literal[
"openai",
"anthropic",
"gemini", # Google Generative AI
"groq",
"openai_compatible", # vLLM, Ollama /v1, LM Studio, OpenRouter, Together, ngrok, …
]


class LLMConfig(BaseModel):
"""Fully-resolved LLM settings supplied by the caller. REQUIRED on every request."""
provider: Provider = Field(..., description="LiteLLM provider discriminator.")
model: str = Field(..., description="Model name, e.g. 'gpt-4o', "
"'claude-3-5-sonnet-20241022', 'llama-3.3-70b-versatile'.")
api_key: Optional[str] = Field(
default=None,
description="API key. Required for openai, anthropic, gemini, groq. "
"Optional for openai_compatible (local servers may need none; "
"hosted services like OpenRouter still require one). "
"Never logged.",
)
base_url: Optional[str] = Field(
default=None,
description="Custom base URL. REQUIRED for 'openai_compatible' "
"(must be OpenAI-shaped, i.e. expose /v1/...). "
"Optional override for the first-class providers.",
)
temperature: float = Field(default=0, description="Sampling temperature. 0 = "
"deterministic, the right default for structured "
"YAML generation. Caller can override per request.")

@model_validator(mode="after")
def _validate_provider_requirements(self) -> "LLMConfig":
if self.provider == "openai_compatible":
if not self.base_url:
raise ValueError("provider='openai_compatible' requires base_url")
else:
if not self.api_key:
raise ValueError(f"provider='{self.provider}' requires api_key")
return self


class MessageItem(BaseModel):
Expand Down Expand Up @@ -47,9 +89,11 @@ class StreamRequest(BaseModel):
default="",
description="Current thread title. Empty on first turn, agent generates one.",
)
llm: LLMConfig = Field(..., description="Per-request LLM configuration (required).")


class ResumeRequest(BaseModel):
"""Request body for resuming an interrupted stream."""

response: str = Field(..., description="User answer to the pending question")
llm: LLMConfig = Field(..., description="Per-request LLM configuration (required).")
50 changes: 0 additions & 50 deletions src/agent/config.py

This file was deleted.

8 changes: 7 additions & 1 deletion src/agent/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
"""Package initialization for agent-wide exceptions."""

from .config import AgentConfigError
from .llm import LLMInvocationError
from .tools import PermissionRequiredException

__all__ = ["PermissionRequiredException"]
__all__ = [
"AgentConfigError",
"LLMInvocationError",
"PermissionRequiredException",
]
14 changes: 14 additions & 0 deletions src/agent/exceptions/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""Configuration-related exceptions for the agent."""


class AgentConfigError(ValueError):
"""Raised when agent configuration is invalid or incomplete.

Examples:
- Unknown LLM provider
- Missing required configuration fields
- Invalid model names
- Malformed configuration structure

This is a caller-fixable error that should fail fast with a clear message.
"""
48 changes: 48 additions & 0 deletions src/agent/exceptions/llm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""LLM invocation exceptions with actionable classification."""

from __future__ import annotations


class LLMInvocationError(Exception):
"""LLM/LangChain invocation failure with actionable classification.

This exception wraps provider-specific errors (LiteLLM, OpenAI, Anthropic, etc.)
into a consistent, actionable format for error handling and user notification.

Attributes:
provider: The LLM provider (e.g., "openai", "anthropic", "groq")
reason: Human-readable error description
severity: "transient" (retryable) or "fatal" (do not retry)
category: Error category for user notification
- "rate_limit": 429 Too Many Requests
- "timeout": Request timeout
- "unavailable": 5xx Server errors
- "parse": Structured output schema mismatch
- "auth": 401 Authentication failed
- "bad_request": 400 Malformed request
- "unknown": Unclassified error
status_code: HTTP status code if applicable
attempts: Number of attempts made before giving up
"""

def __init__(
self,
provider: str,
reason: str,
*,
severity: str,
category: str,
status_code: int | None = None,
attempts: int = 0,
):
self.provider = provider
self.reason = reason
self.severity = severity
self.category = category
self.status_code = status_code
self.attempts = attempts

super().__init__(
f"[{provider}] {category} ({severity}): {reason}"
+ (f" after {attempts} attempts" if attempts > 1 else "")
)
Loading
Loading