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
Binary file modified .DS_Store
Binary file not shown.
25 changes: 17 additions & 8 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,17 @@ version = "0.1.0"
description = "Composable AI agents with YAML configuration, MCP tool integration, and PostgreSQL persistence"
requires-python = ">=3.11"
dependencies = [
"deepagents>=0.3.12",
"cryptography>=46.0.5",
"langchain-core>=1.2.22",
"deepagents>=0.6.10",
"cryptography>=48.0.1",
"langchain-core>=1.4.7",
"pyasn1>=0.6.3",
"pyjwt>=2.12.0",
"langgraph>=1.0.10",
"pyjwt>=2.13.0",
"langgraph>=1.2.5",
"requests>=2.33.0",
"fastapi>=0.128.4",
"langchain-mcp-adapters>=0.1.0",
"langchain-openai>=1.1.7",
"langchain-mcp-adapters>=0.3.0",
"mcp>=1.27.0",
"langchain-openai>=1.1.15",
"pydantic>=2.12.5",
"pydantic-settings>=2.12.0",
"pyyaml>=6.0.3",
Expand All @@ -34,7 +35,15 @@ dependencies = [
"arize-phoenix-otel==0.15.0",
"openinference-instrumentation-langchain==0.1.62",
"arize-phoenix-client==2.3.0",
"tenacity>=8.0.0"
"tenacity>=8.0.0",
# --- Security: pin above fixed versions of known CVEs (trivy) ---
"aiohttp>=3.14.1",
"idna>=3.15",
"mako>=1.3.12",
"python-dotenv>=1.2.2",
"python-multipart>=0.0.30",
"starlette>=1.3.1",
"urllib3>=2.7.0",
]
[dependency-groups]
dev = [
Expand Down
8 changes: 5 additions & 3 deletions src/alembic/env.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import asyncio
from logging.config import fileConfig

from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config

from alembic import context

# Centralized logging configuration (replaces the legacy alembic.ini fileConfig).
from src.config import Settings

# Side-effect imports: register all models so Base.metadata is populated
from src.infrastructure.database.models.agent_config import AgentConfigModel # noqa: F401
from src.infrastructure.database.models.base import Base
from src.infrastructure.database.models.thread import MessageModel, ThreadModel # noqa: F401
from src.infrastructure.logging import configure_logging

config = context.config

if config.config_file_name is not None:
fileConfig(config.config_file_name)
configure_logging(Settings())

target_metadata = Base.metadata

Expand Down
8 changes: 5 additions & 3 deletions src/application/requests/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from pydantic import BaseModel, Field, model_validator

from src.domain.logging.messages import LogMessage

logger = logging.getLogger(__name__)


Expand All @@ -20,13 +22,13 @@ def validate_input(self):
has_message = self.message is not None
has_hitl = self.tool_call_id is not None
if has_message == has_hitl:
logger.error("Provide either 'message' or HITL fields, not both")
logger.error(LogMessage.VALIDATION_MSG_AND_HITL_EXCLUSIVE)
raise ValueError("Provide either 'message' or HITL fields (tool_call_id + action), not both.")
if has_hitl and self.action is None:
logger.error("'action' is required for HITL decisions")
logger.error(LogMessage.VALIDATION_ACTION_REQUIRED)
raise ValueError("'action' is required for HITL decisions.")
if self.action == "edit" and self.edits is None:
logger.error("'edits' is required for action 'edit'")
logger.error(LogMessage.VALIDATION_EDITS_REQUIRED)
raise ValueError("'edits' is required for action 'edit'.")
return self

Expand Down
1 change: 1 addition & 0 deletions src/application/requests/prompt.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from enum import Enum

from pydantic import BaseModel, Field, field_validator, model_validator


Expand Down
30 changes: 16 additions & 14 deletions src/application/routes/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import re
from typing import Annotated

from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, File, Form, UploadFile, status

from src.application.use_cases.create_agent_config import CreateAgentConfigUseCase
from src.application.use_cases.delete_agent_config import DeleteAgentConfigUseCase
Expand All @@ -18,6 +18,9 @@
)
from src.domain.entities.agent_config import AgentConfig
from src.domain.entities.agent_config_metadata import AgentConfigMetadata
from src.domain.errors.config import ConfigError
from src.domain.errors.messages import ErrorMessage
from src.domain.logging.messages import LogMessage

logger = logging.getLogger(__name__)

Expand All @@ -29,20 +32,19 @@

def _validate_agent_name(name: str) -> None:
if not AGENT_NAME_PATTERN.match(name):
raise HTTPException(
status_code=400,
detail=f"Invalid agent name '{name}'. Must match pattern: alphanumeric, dots, hyphens, underscores, 2-100 chars.",
raise ConfigError(
ErrorMessage.INVALID_AGENT_NAME.format(name=name),
)


async def _read_yaml_upload(file: UploadFile) -> str:
data = await file.read()
if len(data) > MAX_UPLOAD_SIZE:
raise HTTPException(status_code=400, detail=f"File too large. Maximum size is {MAX_UPLOAD_SIZE} bytes.")
raise ConfigError(ErrorMessage.FILE_TOO_LARGE.format(max_size=MAX_UPLOAD_SIZE))
try:
return data.decode("utf-8")
except UnicodeDecodeError as e:
raise HTTPException(status_code=400, detail="File must be valid UTF-8 encoded YAML.") from e
raise ConfigError(ErrorMessage.FILE_NOT_UTF8) from e


@router.get("", response_model=list[AgentConfigMetadata])
Expand All @@ -51,7 +53,7 @@ async def list_agents(
) -> list[AgentConfigMetadata]:
"""List all agent configuration metadata."""
agents = await use_case.execute()
logger.info("Listed %d agent configs", len(agents))
logger.info(LogMessage.AGENT_CONFIG_LISTED, len(agents))
return agents


Expand All @@ -62,7 +64,7 @@ async def get_agent(
) -> AgentConfig:
"""Retrieve a single agent configuration by name."""
_validate_agent_name(agent_name)
logger.info("Getting agent config: %s", agent_name)
logger.info(LogMessage.AGENT_CONFIG_GET, agent_name)
return await use_case.execute(name=agent_name)


Expand All @@ -75,9 +77,9 @@ async def create_agent(
"""Create a new agent configuration from an uploaded YAML file."""
_validate_agent_name(agent_name)
yaml_content = await _read_yaml_upload(file)
logger.info("Creating agent config: %s", agent_name)
logger.info(LogMessage.AGENT_CONFIG_CREATING, agent_name)
result = await use_case.execute(name=agent_name, yaml_content=yaml_content)
logger.info("Agent config created: %s", agent_name)
logger.info(LogMessage.AGENT_CONFIG_CREATED, agent_name)
return result


Expand All @@ -90,9 +92,9 @@ async def update_agent(
"""Update an existing agent configuration from an uploaded YAML file."""
_validate_agent_name(agent_name)
yaml_content = await _read_yaml_upload(file)
logger.info("Updating agent config: %s", agent_name)
logger.info(LogMessage.AGENT_CONFIG_UPDATING, agent_name)
result = await use_case.execute(name=agent_name, yaml_content=yaml_content)
logger.info("Agent config updated: %s", agent_name)
logger.info(LogMessage.AGENT_CONFIG_UPDATED, agent_name)
return result


Expand All @@ -103,6 +105,6 @@ async def delete_agent(
) -> None:
"""Delete an agent configuration."""
_validate_agent_name(agent_name)
logger.info("Deleting agent config: %s", agent_name)
logger.info(LogMessage.AGENT_CONFIG_DELETING, agent_name)
await use_case.execute(name=agent_name)
logger.info("Agent config deleted: %s", agent_name)
logger.info(LogMessage.AGENT_CONFIG_DELETED, agent_name)
11 changes: 6 additions & 5 deletions src/application/routes/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
)
from src.domain.entities.message import Message
from src.domain.entities.stream_event import StreamEvent, StreamEventType
from src.domain.logging.messages import LogMessage

logger = logging.getLogger(__name__)

Expand All @@ -28,9 +29,9 @@ async def send_message(
body: ChatRequest,
use_case: Annotated[SendMessageUseCase, Depends(get_send_message_use_case)],
) -> Message:
logger.info("[thread=%s] POST /chat - message=%s", thread_id, "HITL" if body.message is None else body.message[:80])
logger.info(LogMessage.CHAT_RECEIVE, thread_id, "HITL" if body.message is None else body.message[:80])
result = await use_case.execute(thread_id, body)
logger.info("[thread=%s] Response status=%s content_len=%d", thread_id, result.status, len(result.content or ""))
logger.info(LogMessage.CHAT_RESPONSE, thread_id, result.status, len(result.content or ""))
return result


Expand All @@ -41,7 +42,7 @@ async def stream_message(
use_case: Annotated[StreamMessageUseCase, Depends(get_stream_message_use_case)],
get_thread: Annotated[GetThreadUseCase, Depends(get_get_thread_use_case)],
) -> EventSourceResponse:
logger.info("[thread=%s] POST /chat/stream - message=%s", thread_id, (body.message or "")[:80])
logger.info(LogMessage.CHAT_STREAM_RECEIVE, thread_id, (body.message or "")[:80])
await get_thread.execute(thread_id)

async def event_generator():
Expand All @@ -52,11 +53,11 @@ async def event_generator():
chunk_count += 1
yield {"data": event.model_dump_json()}
yield {"data": "[DONE]"}
logger.info("[thread=%s] Stream complete, %d chunks", thread_id, chunk_count)
logger.info(LogMessage.CHAT_STREAM_COMPLETE, thread_id, chunk_count)
except asyncio.CancelledError:
raise
except Exception as exc:
logger.exception("[thread=%s] Stream error after %d chunks", thread_id, chunk_count)
logger.exception(LogMessage.CHAT_STREAM_ERROR, thread_id, chunk_count)
error_event = StreamEvent(type=StreamEventType.ERROR, data=str(exc))
yield {"data": error_event.model_dump_json()}

Expand Down
30 changes: 7 additions & 23 deletions src/application/routes/prompt.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import logging

from fastapi import APIRouter, Depends, HTTPException
from httpx import HTTPStatusError
from fastapi import APIRouter, Depends

from src.application.requests.prompt import (
CreatePromptRequest,
Expand All @@ -11,29 +10,14 @@
from src.application.use_cases.get_prompt import GetPromptUseCase
from src.application.use_cases.update_prompt import UpdatePromptUseCase
from src.dependencies import get_prompt_manager
from src.domain.logging.messages import LogMessage
from src.domain.ports.prompt_manager import PromptManager

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/prompts", tags=["prompts"])


def _handle_http_error(e: Exception, identifier: str | None = None) -> HTTPException:
"""Map exceptions to appropriate HTTP status codes."""
if isinstance(e, ValueError) and "not found" in str(e).lower():
return HTTPException(status_code=404, detail=str(e))
if isinstance(e, HTTPStatusError):
if e.response.status_code == 404:
return HTTPException(status_code=404, detail=f"Prompt not found: {identifier}")
if e.response.status_code == 409:
return HTTPException(status_code=409, detail=f"Prompt already exists: {identifier}")
if e.response.status_code == 400:
return HTTPException(status_code=400, detail=str(e))
if isinstance(e, ValueError):
return HTTPException(status_code=400, detail=str(e))
return HTTPException(status_code=500, detail=str(e))


@router.post("/create", status_code=201)
async def create_prompt(
request: CreatePromptRequest,
Expand All @@ -42,7 +26,7 @@ async def create_prompt(
"""Create a new prompt."""
use_case = CreatePromptUseCase(prompt_manager)
try:
logger.info("Creating prompt: %s", request.identifier)
logger.info(LogMessage.PROMPT_CREATING, request.identifier)
content_dicts = [msg.model_dump() for msg in request.content]
prompt = await use_case.execute(
identifier=request.identifier,
Expand All @@ -52,10 +36,10 @@ async def create_prompt(
tags=request.tags,
metadata=request.metadata,
)
logger.info("Prompt created: %s", request.identifier)
logger.info(LogMessage.PROMPT_CREATED, request.identifier)
return {"status": "success", "prompt": prompt}
except Exception:
logger.exception("Error creating prompt '%s'", request.identifier)
logger.exception(LogMessage.PROMPT_CREATE_ERROR, request.identifier)
raise


Expand All @@ -76,7 +60,7 @@ async def get_prompt(
)
return {"status": "success", "prompt": prompt}
except Exception:
logger.exception("Error getting prompt '%s'", identifier)
logger.exception(LogMessage.PROMPT_GET_ERROR, identifier)
raise


Expand All @@ -99,5 +83,5 @@ async def update_prompt(
)
return {"status": "success", "prompt": prompt}
except Exception:
logger.exception("Error updating prompt '%s'", identifier)
logger.exception(LogMessage.PROMPT_UPDATE_ERROR, identifier)
raise
13 changes: 7 additions & 6 deletions src/application/routes/threads.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
get_list_threads_use_case,
)
from src.domain.entities.thread import Thread
from src.domain.logging.messages import LogMessage

logger = logging.getLogger(__name__)

Expand All @@ -28,9 +29,9 @@ async def create_thread(
body: CreateThreadRequest,
use_case: Annotated[CreateThreadUseCase, Depends(get_create_thread_use_case)],
) -> Thread:
logger.info("Creating thread for agent=%s", body.agent_name)
logger.info(LogMessage.THREAD_CREATING, body.agent_name)
thread = await use_case.execute(body.agent_name)
logger.info("Thread created: id=%s agent=%s", thread.id, thread.agent_name)
logger.info(LogMessage.THREAD_CREATED, thread.id, thread.agent_name)
return thread


Expand All @@ -39,7 +40,7 @@ async def list_threads(
use_case: Annotated[ListThreadsUseCase, Depends(get_list_threads_use_case)],
) -> list[Thread]:
threads = await use_case.execute()
logger.info("Listed %d threads", len(threads))
logger.info(LogMessage.THREAD_LISTED, len(threads))
return threads


Expand All @@ -48,7 +49,7 @@ async def get_thread(
thread_id: str,
use_case: Annotated[GetThreadUseCase, Depends(get_get_thread_use_case)],
) -> Thread:
logger.info("Getting thread=%s", thread_id)
logger.info(LogMessage.THREAD_GETTING, thread_id)
return await use_case.execute(thread_id)


Expand All @@ -57,7 +58,7 @@ async def delete_thread(
thread_id: str,
use_case: Annotated[DeleteThreadUseCase, Depends(get_delete_thread_use_case)],
) -> None:
logger.info("Deleting thread=%s", thread_id)
logger.info(LogMessage.THREAD_DELETING, thread_id)
await use_case.execute(thread_id)


Expand All @@ -67,5 +68,5 @@ async def list_messages(
use_case: Annotated[GetThreadUseCase, Depends(get_get_thread_use_case)],
) -> list:
thread = await use_case.execute(thread_id)
logger.info("[thread=%s] Listed %d messages", thread_id, len(thread.messages))
logger.info(LogMessage.THREAD_MESSAGES_LISTED, thread_id, len(thread.messages))
return thread.messages
Loading
Loading