diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 2983ec9..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 4b6b646..5984975 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ coverage.xml opencode.json .vscode/ opencode.json +.DS_Store diff --git a/.trivyignore b/.trivyignore index e769bb9..2bd7598 100644 --- a/.trivyignore +++ b/.trivyignore @@ -32,4 +32,22 @@ CVE-2026-42496 # Fix uniquement dans unstable (perl 5.40.1-8). # Tracker: https://security-tracker.debian.org/tracker/CVE-2026-8376 # Review: 2026-06-18 -CVE-2026-8376 \ No newline at end of file +CVE-2026-8376 + +# libxml2: CVE-2026-6653 - Denial of Service via crafted XML input. +# No fixed version available in Debian bookworm (status: fix_deferred). +# The service does not parse untrusted XML input directly. +# Review: 2026-07-23 +CVE-2026-6653 + +# perl-base: CVE-2026-13221 - Silently incorrect regular expression matching. +# No fixed version available in Debian bookworm (status: affected). +# The service does not invoke Perl. Fix needs Perl 5.44 backport. +# Review: 2026-07-23 +CVE-2026-13221 + +# perl-base: CVE-2026-57433 - Storable signed integer overflow. +# No fixed version available in Debian bookworm (status: affected). +# The service does not invoke Perl or Storable. +# Review: 2026-07-23 +CVE-2026-57433 \ No newline at end of file diff --git a/README.md b/README.md index 333f1f6..476fc7d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Configure Deep Agent LangGraph agents in YAML and expose them via FastAPI. **composable-agents** is a Python framework that lets you declare AI agents as simple YAML files and instantly expose them as a full-featured HTTP API. It is built on [deepagents](https://pypi.org/project/deepagents/) (LangGraph-based Deep Agent) with a strict hexagonal architecture, making every component testable and replaceable. -The server supports **multi-agent mode**: multiple agents are defined as separate YAML files in an `agents/` directory, each thread is bound to a specific agent at creation time, and agents are lazily instantiated on first use. +The server supports **multi-agent mode**: multiple agents are defined as separate YAML files in an `agents/` directory, each thread is bound to a specific agent at creation time, and agents are lazily instantiated on first use. Sub-agents declared in the `subagents` config are fully traced: every event emitted by a sub-agent carries its name in the `source` field of the corresponding `TraceEvent`, so the client can group events per sub-agent (see [TraceEvent](#traceevent-format)). --- @@ -37,6 +37,8 @@ DATABASE_URL=postgresql://raganything:raganything@localhost:5433/raganything > **⚠️ Breaking change:** The `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, and `POSTGRES_DATABASE` environment variables have been replaced by a single `DATABASE_URL` variable. If you are upgrading from a previous version, construct your `DATABASE_URL` as `postgresql://:@:/` and remove the old `POSTGRES_*` variables from your `.env`. +> **⚠️ Breaking change (trace events):** The legacy `StreamEvent` SSE format and the `messages` table have been removed. The `/stream` and WebSocket endpoints now emit `TraceEvent` JSON objects. See [Breaking Changes](#breaking-changes) for migration details. + ### Configure your agents Each agent is a standalone YAML file inside the `agents/` directory. A minimal agent only needs a name. Create `agents/my-agent.yaml`: @@ -82,13 +84,24 @@ curl -X POST http://localhost:8000/api/v1/threads \ curl -X POST http://localhost:8000/api/v1/chat/ \ -H "Content-Type: application/json" \ -d '{"message": "Hello, what can you do?"}' + +# Stream a message (yields TraceEvent JSON objects, one per SSE line, ends with [DONE]) +curl -N -X POST http://localhost:8000/api/v1/chat//stream \ + -H "Content-Type: application/json" \ + -d '{"message": "Hello, what can you do?"}' + +# Get the full thread history (thread + turns grouped by turn_id) +curl http://localhost:8000/api/v1/threads//history + +# Get the flat trace of events for a thread +curl http://localhost:8000/api/v1/threads//trace ``` --- ## Multi-Agent Architecture -composable-agents now supports running **multiple agents simultaneously**. Each agent is defined by a separate YAML file in the `agents/` directory. +composable-agents now supports running **multiple agents simultaneously**. Each agent is defined by a separate YAML file in the `agents/` directory. Sub-agents declared via `subagents` are traced individually: each `TraceEvent` they emit includes the sub-agent name in its `source` field, so the timeline can be grouped per sub-agent on the client side. ### How it works @@ -151,6 +164,7 @@ Every agent is defined by a single YAML file validated against the `AgentConfig` | `subagents` | `list[SubAgentConfig]` | `[]` | Sub-agent definitions for delegation. | | `mcp_servers` | `list[McpServerConfig]` | `[]` | MCP server connections. See [MCP Servers](#mcp-servers). | | `debug` | `bool` | `false` | Enable debug mode. | +| `response_format` | `dict` | `null` | Inline JSON Schema dict for structured output. See [Structured Output (response_format)](#structured-output-response_format). | ### SubAgentConfig @@ -194,7 +208,81 @@ Allowed decisions: `approve`, `edit`, `reject`. --- -## Supported Models +## Structured Output (`response_format`) + +The `response_format` field in agent YAML configures structured output — forcing the LLM to reply with JSON conforming to a JSON Schema. Define it inline as a dict (a valid JSON Schema) in the agent's YAML: + +```yaml +name: invoice-extractor +model: claude-sonnet-4-5-20250929 +response_format: + type: object + properties: + invoice_number: { type: string } + total_cents: { type: integer } + currency: { type: string, enum: ["USD", "EUR", "GBP"] } + paid: { anyOf: [{ type: boolean }, { type: "null" }] } + required: [invoice_number, total_cents, currency] + additionalProperties: false +``` + +### Native passthrough to deepagents/langchain + +The dict is passed **natively** to `create_deep_agent(response_format=dict)`. No custom tool injection or prompt instruction concatenation is performed — the previous "tool leurre" hack (`_create_response_tool`, `_JSON_TYPE_MAP`, `STRUCTURED_OUTPUT_INSTRUCTION`) has been deleted. + +langchain uses an **`AutoStrategy`** internally to pick the right delivery mechanism based on the model name: + +- **`ProviderStrategy`** — the schema is passed as a native provider parameter (Anthropic `response_format`/tool_use strict mode, OpenAI `response_format` with `json_schema`, etc.). +- **`ToolStrategy`** — when the provider does not support native structured output, langchain injects a real tool whose schema is the JSON Schema, and the LLM is asked to call it. + +You do not need to choose the strategy yourself — `AutoStrategy` selects based on the configured `model`. + +### `structured_response` delivery + +When the LLM produces a structured response, it is attached to the `Message` as `structured_response` (a dict validated against the schema). The `AI_MESSAGE` trace event carries the structured payload **inside `content`** as a JSON-serialized `Message` — it is **not** placed in `metadata`. The `metadata` of an `AI_MESSAGE` now only contains `{"status": ...}`. + +Clients consuming `AI_MESSAGE` events must JSON-parse `content` and read `structured_response` from the resulting `Message` object. + +### Missing structured response + +If the LLM fails to produce a structured response despite a `response_format` being configured: + +- A warning is logged (`STRUCTURED_RESPONSE_MISSING`). +- `Message.structured_response` is set to `None`. +- **No error is raised** — the client decides how to handle the absence. + +### Supported JSON Schema constructs + +`schema_utils.py` converts the JSON Schema dict to a Pydantic model at agent build time. The converter supports: + +- `type` (string, integer, number, boolean, object, array, string) +- `type: ["string", "null"]` — array form for nullable scalars +- `anyOf` — nullable fields (use `anyOf: [{type: }, {type: "null"}]`) +- `enum` — maps to `Literal` on the Pydantic side +- `properties` / `required` — nested objects +- `items` — arrays of objects or scalars + +Not supported (will raise at build time or be ignored): + +- `$ref`, `$defs` +- `oneOf`, `allOf` +- `if` / `then` / `else` +- `dependentSchemas` +- `patternProperties` + +### Provider strict-mode constraints + +When `AutoStrategy` selects `ProviderStrategy` against a provider that enforces strict mode (e.g. Anthropic's strict tool-use), the schema must satisfy the provider's constraints or the request will be rejected: + +- Set `additionalProperties: false` on every object (recommended default). +- Use `anyOf` for nullable fields — do not use `type: ["string", "null"]` for Anthropic strict mode; use `anyOf: [{type: "string"}, {type: "null"}]` instead. +- Do **not** put `default` on `required` fields (Anthropic strict mode forbids it). +- All properties listed in `required` must appear in `properties`. + +These constraints only apply when the provider enforces strict mode; `ToolStrategy` is more permissive. Since `AutoStrategy` picks automatically, authoring schemas that satisfy the strict constraints up front is the safest approach. + +--- + | Provider | Format | Example | |---|---|---| @@ -288,7 +376,9 @@ All endpoints are prefixed appropriately. The server runs on `http://localhost:8 | `GET` | `/api/v1/threads` | List all threads | `200` | | `GET` | `/api/v1/threads/{thread_id}` | Get a specific thread | `200` | | `DELETE` | `/api/v1/threads/{thread_id}` | Delete a thread | `204` | -| `GET` | `/api/v1/threads/{thread_id}/messages` | List messages in a thread | `200` | +| `GET` | `/api/v1/threads/{thread_id}/history` | Get thread history grouped by turn (`ThreadHistory`) | `200` | +| `GET` | `/api/v1/threads/{thread_id}/trace` | Get the flat list of `TraceEvent`s for a thread | `200` | +| `GET` | `/api/v1/threads/{thread_id}/messages` | List messages in a thread (projection from `trace_events`: `HUMAN_MESSAGE` + `AI_MESSAGE` only, backward-compat) | `200` | | `POST` | `/api/v1/chat/{thread_id}` | Send a message and get the full response | `200` | | `POST` | `/api/v1/chat/{thread_id}/stream` | Send a message and stream the response (SSE) | `200` | | `POST` | `/api/v1/threads/{thread_id}/hitl` | Submit a human-in-the-loop decision | `200` | @@ -460,41 +550,115 @@ curl -N -X POST http://localhost:8000/api/v1/chat/a1b2c3d4-e5f6-7890-abcd-ef1234 -d '{"message": "Write a haiku about programming."}' ``` -Response (Server-Sent Events): +Response (Server-Sent Events, one `TraceEvent` JSON object per line): ``` -data: {"type":"thinking","data":"Hmm, a haiku needs 5-7-5 syllables..."} +data: {"id":"...","thread_id":"...","turn_id":"...","type":"HUMAN_MESSAGE","source":null,"name":null,"content":"Write a haiku about programming.","metadata":{},"timestamp":"2025-04-24T10:30:00.000000Z","sequence":0} -data: {"type":"content","data":"Lines"} +data: {"id":"...","thread_id":"...","turn_id":"...","type":"THINKING","source":null,"name":null,"content":"Hmm, a haiku needs 5-7-5 syllables...","metadata":{},"timestamp":"...","sequence":1} -data: {"type":"content","data":" of"} +data: {"id":"...","thread_id":"...","turn_id":"...","type":"CONTENT","source":null,"name":null,"content":"Lines","metadata":{},"timestamp":"...","sequence":2} -data: {"type":"content","data":" code"} +data: {"id":"...","thread_id":"...","turn_id":"...","type":"CONTENT","source":null,"name":null,"content":" of","metadata":{},"timestamp":"...","sequence":3} -data: {"type":"content","data":" align"} +data: {"id":"...","thread_id":"...","turn_id":"...","type":"CONTENT","source":null,"name":null,"content":" code","metadata":{},"timestamp":"...","sequence":4} -data: {"type":"content","data":"..."} - -data: {"type":"message","data":"{\"role\":\"ai\",\"content\":\"Lines of code align...\",\"timestamp\":\"2025-04-24T10:30:05.000000Z\",\"tool_calls\":null,\"status\":\"completed\",\"structured_response\":null,\"thinking\":\"Hmm, a haiku needs 5-7-5 syllables...\"}"} +data: {"id":"...","thread_id":"...","turn_id":"...","type":"AI_MESSAGE","source":null,"name":null,"content":"Lines of code align...","metadata":{},"timestamp":"...","sequence":5} data: [DONE] ``` -The stream emits **typed `StreamEvent` JSON objects** over SSE: +#### `TraceEvent` format + +Each `data:` line (except the final `[DONE]`) is a JSON-serialized `TraceEvent` with the following fields: -| `type` | Description | Persisted? | +| Field | Type | Description | |---|---|---| -| `thinking` | Reasoning / chain-of-thought tokens from extended-thinking models (e.g., Claude reasoning). | Yes — saved in `Message.thinking` | -| `content` | Response text / markdown tokens as they are generated. | Yes — aggregated into `Message.content` | -| `message` | The final complete `Message` JSON with all fields (`role`, `content`, `timestamp`, `tool_calls`, `status`, `structured_response`, `thinking`). Identical in format to the synchronous `POST /chat/{thread_id}` response. | Yes — persisted as the AI turn in the thread | +| `id` | `string` | Unique event ID. | +| `thread_id` | `string` | Owning thread ID. | +| `turn_id` | `string` | Turn ID grouping all events from one user message to the next AI reply. | +| `type` | `enum` | One of `HUMAN_MESSAGE`, `AI_MESSAGE`, `THINKING`, `CONTENT`, `TOOL_CALL`, `TOOL_RESULT`. | +| `source` | `string \| null` | Sub-agent name when the event was emitted by a sub-agent, `null` for the parent agent. | +| `name` | `string \| null` | Tool name (only for `TOOL_CALL` / `TOOL_RESULT`). | +| `content` | `string` | Text payload (message text, thinking text, content chunk, tool arguments/result). | +| `metadata` | `object` | Additional structured data (e.g. tool call ID, `{"status": ...}` for `AI_MESSAGE`). The structured response for an `AI_MESSAGE` is **not** in `metadata` — it lives in `content` as part of the JSON-serialized `Message`. See [Structured Output](#structured-output-response_format). | +| `timestamp` | `string` | ISO 8601 timestamp. | +| `sequence` | `int` | Monotonic sequence number within the thread (ordering). | + +#### Error payload + +On error the stream emits a single JSON object (NOT a valid `TraceEvent`) followed by `[DONE]`: + +``` +data: {"type":"error","data":"Agent execution failed: ..."} + +data: [DONE] +``` + +Clients should check for `type === "error"` before parsing as `TraceEvent`. + +#### Rendering guidance -The stream ends with `data: [DONE]`. +- Render `THINKING` events in a collapsible reasoning panel. +- Append `CONTENT` events directly to the chat bubble (or the relevant sub-agent panel when `source` is set). +- Render `TOOL_CALL` as a badge and `TOOL_RESULT` as a terminal-style block. +- Group events by `source` to display sub-agent panels separately. +- Wait for the `AI_MESSAGE` event to finalize the turn. -This design prevents Cloudflare timeout issues (~100s on idle connections) because chunks and SSE pings (every 15s) keep the connection active. Clients can switch rendering based on `type`: +This design prevents Cloudflare timeout issues (~100s on idle connections) because chunks and SSE pings (every 15s) keep the connection active. -- Render `thinking` events in a collapsible reasoning panel. -- Append `content` events directly to the chat bubble. -- Wait for the `message` event to finalize metadata (status, structure, tool calls). +### 6b. Get Thread History + +```bash +curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/history +``` + +Response (`200`) — `ThreadHistory`: + +```json +{ + "thread": { + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "agent_name": "example-agent", + "created_at": "2025-01-15T10:30:00.000000", + "updated_at": "2025-01-15T10:30:05.000000" + }, + "turns": [ + { + "turn_id": "turn-uuid-1", + "human_message": { "id": "...", "type": "HUMAN_MESSAGE", "content": "Hello", ... }, + "ai_message": { "id": "...", "type": "AI_MESSAGE", "content": "Hi!", ... }, + "events": [ + { "id": "...", "type": "THINKING", "source": null, "content": "...", ... }, + { "id": "...", "type": "TOOL_CALL", "source": "researcher", "name": "search", ... }, + { "id": "...", "type": "TOOL_RESULT", "source": "researcher", "name": "search", ... } + ] + } + ] +} +``` + +`events` contains the intermediate events (`THINKING`, `CONTENT`, `TOOL_CALL`, `TOOL_RESULT`) for the turn, in `sequence` order. `human_message` and `ai_message` are the terminal `HUMAN_MESSAGE` / `AI_MESSAGE` events. + +### 6c. Get Flat Trace + +```bash +curl http://localhost:8000/api/v1/threads/a1b2c3d4-e5f6-7890-abcd-ef1234567890/trace +``` + +Response (`200`): + +```json +{ + "events": [ + { "id": "...", "type": "HUMAN_MESSAGE", "content": "Hello", ... }, + { "id": "...", "type": "THINKING", "content": "...", ... }, + { "id": "...", "type": "AI_MESSAGE", "content": "Hi!", ... } + ] +} +``` + +Returns the full flat list of `TraceEvent`s for the thread, ordered by `sequence`. ### 7. List All Threads @@ -747,15 +911,18 @@ ws.onmessage = (event) => { return; } const data = JSON.parse(event.data); + if (data.type === "error") { console.error("Error:", data.data); return; } switch (data.type) { - case "thinking": console.log("[Thinking]", data.data); break; - case "content": process.stdout.write(data.data); break; - case "message": console.log("Final message:", data.data); break; + case "THINKING": console.log("[Thinking]", data.content); break; + case "CONTENT": process.stdout.write(data.content); break; + case "AI_MESSAGE": console.log("Final message:", data.content); break; + case "TOOL_CALL": console.log("Tool call:", data.name, data.content); break; + case "TOOL_RESULT":console.log("Tool result:", data.name, data.content); break; } }; ``` -The WebSocket stream emits typed `StreamEvent` JSON objects: `thinking` (reasoning tokens), `content` (response text), `message` (final full `Message` JSON), then `[END]`. +The WebSocket stream emits `TraceEvent` JSON objects (same shape as the `/stream` SSE endpoint), then `[END]`. On error, emits `{"type":"error","data":"..."}` before `[END]`. --- @@ -857,18 +1024,25 @@ composable-agents/ versions/ 001_create_agent_configs_table.py 002_create_threads_and_messages_tables.py + 005_create_trace_events_table.py # Create trace_events table + 006_migrate_messages_to_trace_events.py # Backfill trace_events from messages + 007_drop_messages_table.py # Drop legacy messages table application/ requests/ chat.py # Request models (ChatRequest, CreateThreadRequest, HITLDecisionRequest) + responses/ + thread_history.py # ThreadHistory response DTO (thread + turns) routes/ health.py # GET /health threads.py # CRUD /api/v1/threads chat.py # POST /api/v1/chat/{id} and /stream + trace.py # GET /api/v1/threads/{id}/history and /trace agents.py # GET /api/v1/agents websocket.py # WS /api/v1/ws/{id} use_cases/ send_message.py # Invoke agent synchronously stream_message.py # Stream agent response + get_thread_history.py # Build ThreadHistory from trace_events (group by turn_id) create_agent_config.py # Create agent config (MinIO + Postgres) update_agent_config.py # Update agent config delete_agent_config.py # Delete agent config @@ -882,8 +1056,9 @@ composable-agents/ agent_config.py # AgentConfig, BackendConfig, HITLConfig, SubAgentConfig agent_config_metadata.py # AgentConfigMetadata mcp_server_config.py # McpServerConfig, McpTransportType - message.py # Message (role, content, timestamp, tool_calls) - thread.py # Thread (id, agent_name, messages, timestamps) + message.py # Message (role, content, timestamp, tool_calls) — projection model + thread.py # Thread (id, agent_name, timestamps) — no more MessageModel + trace_event.py # TraceEvent entity + TraceEventType enum (6 types) tracing_config.py # TracingConfig, TracingProviderType ports/ agent_config_loader.py # Abstract: load config from file @@ -893,6 +1068,7 @@ composable-agents/ agent_runner.py # Abstract: invoke, stream, HITL operations mcp_tool_loader.py # Abstract: load MCP tools thread_repository.py # Abstract: CRUD for threads + trace_event_repository.py # Abstract: persist/append/list TraceEvents tracing_provider.py # Abstract: tracing lifecycle exceptions.py # DomainError hierarchy (incl. AgentNotFoundError, StorageError) infrastructure/ @@ -901,9 +1077,10 @@ composable-agents/ models/ base.py # SQLAlchemy DeclarativeBase agent_config.py # AgentConfigModel (ORM) - thread.py # ThreadModel + MessageModel (ORM) + thread.py # ThreadModel (ORM) — MessageModel removed + trace_event.py # TraceEventModel (ORM) deepagent/ - adapter.py # DeepAgentRunner (LangGraph adapter) + adapter.py # DeepAgentRunner (LangGraph adapter) — emits TraceEvent factory.py # create_agent_from_config (resolves tools, middleware, backend) registry.py # DeepAgentRegistry (lazy loading + caching from agents/ dir) example_tools.py # Example tools: current_time, word_count @@ -917,7 +1094,9 @@ composable-agents/ adapter.py # PostgresAgentConfigRepository postgres_thread/ adapter.py # PostgresThreadRepository (thread persistence) - models.py # Re-exports ThreadModel, MessageModel + models.py # Re-exports ThreadModel + postgres_trace/ + adapter.py # PostgresTraceEventRepository (trace_events persistence) yaml_config/ adapter.py # YamlAgentConfigLoader tracing/ @@ -1063,21 +1242,34 @@ Thread and agent config persistence is backed by PostgreSQL, accessed via SQLAlc ### Schema -The database uses a flat normalized schema with two tables for thread persistence: +The database uses a flat normalized schema. Thread persistence relies on a single `trace_events` table as the source of truth for all conversation activity (the legacy `messages` table has been dropped — see [Breaking changes](#breaking-changes)). | Table | Description | |---|---| | `threads` | One row per conversation thread. Columns: `id` (PK, VARCHAR 36), `agent_name`, `created_at`, `updated_at`. | -| `messages` | One row per message. Columns: `id` (PK), `thread_id` (FK to `threads.id`, CASCADE delete), `role`, `content`, `timestamp`, `tool_calls` (JSONB), `status`, `structured_response` (JSONB). | +| `trace_events` | One row per trace event. Columns: `id` (PK), `thread_id` (FK to `threads.id`, CASCADE delete), `turn_id`, `type` (enum: `HUMAN_MESSAGE`, `AI_MESSAGE`, `THINKING`, `CONTENT`, `TOOL_CALL`, `TOOL_RESULT`), `source` (sub-agent name or null), `name` (tool name or null), `content` (text), `metadata` (JSONB), `timestamp`, `sequence` (int, monotonic per thread). | +| `agent_configs` | Agent configuration metadata. | + +Indexes on `trace_events`: -Indexes: `ix_messages_thread_id`, `ix_messages_thread_id_timestamp`, `ix_threads_agent_name`. +- `ix_trace_events_thread_id` — fast lookup of all events for a thread. +- `ix_trace_events_thread_id_sequence` — ordered retrieval of events within a thread (used by `/trace` and `/history`). +- `ix_trace_events_thread_id_turn_id` — grouping events by turn (used by `/history`). -A third table, `agent_configs`, stores agent configuration metadata. +The `messages` table has been **dropped** (migration `007`). Its data was backfilled into `trace_events` by migration `006` (each old `Message` row became a `HUMAN_MESSAGE` or `AI_MESSAGE` event). The legacy `GET /api/v1/threads/{id}/messages` endpoint is preserved as a backward-compatible projection that filters `trace_events` to `HUMAN_MESSAGE` + `AI_MESSAGE` rows. ### Migrations (Alembic) Alembic migrations live in `src/alembic/versions/` and run **automatically at startup** (via `asyncio.to_thread()` in the FastAPI lifespan). You never need to run `alembic upgrade` manually in normal operation. +Relevant migrations for the trace events refactor: + +| Migration | Description | +|---|---| +| `005_create_trace_events_table` | Creates the `trace_events` table with the 3 indexes above. | +| `006_migrate_messages_to_trace_events` | Backfills `trace_events` from existing `messages` rows (`role = "human"` → `HUMAN_MESSAGE`, `role = "ai"` → `AI_MESSAGE`). | +| `007_drop_messages_table` | Drops the legacy `messages` table. | + To create a new migration manually: ```bash @@ -1101,12 +1293,40 @@ uv run alembic current ### Architecture Decisions -- **Hexagonal architecture**: `ThreadRepository` (port) -> `PostgresThreadRepository` (adapter). The domain layer has no knowledge of SQLAlchemy. +- **Hexagonal architecture**: `ThreadRepository` (port) -> `PostgresThreadRepository` (adapter), `TraceEventRepository` (port) -> `PostgresTraceEventRepository` (adapter). The domain layer has no knowledge of SQLAlchemy. - **Session-per-method**: Each repository method creates its own `AsyncSession` from the engine, ensuring thread-safety for concurrent FastAPI requests. - **Connection pooling**: `AsyncAdaptedQueuePool` with `pool_size=20`, `max_overflow=20`, and `pool_pre_ping=True`. -- **Cascade deletes**: Deleting a thread automatically deletes all its messages via `ON DELETE CASCADE` at both the SQL and ORM level. -- **Message ordering**: Messages are sorted by `timestamp` (oldest first). The ORM relationship specifies `order_by`, and the adapter applies a defensive Python sort as well. -- **JSONB columns**: `tool_calls` and `structured_response` are stored as PostgreSQL `JSONB`, allowing structured data without additional join tables. +- **Cascade deletes**: Deleting a thread automatically deletes all its `trace_events` via `ON DELETE CASCADE` at both the SQL and ORM level. +- **Event ordering**: `trace_events` are sorted by `sequence` (monotonic per thread). The adapter applies a defensive Python sort as well. +- **JSONB columns**: `metadata` is stored as PostgreSQL `JSONB`, allowing structured data (tool call IDs, structured responses) without additional join tables. +- **Single source of truth**: `trace_events` is the only persistence for conversation activity. `messages` is no longer a table; the `/messages` endpoint is a read-only projection. + +--- + +## Breaking Changes + +This release replaces the legacy `StreamEvent` / `messages`-based model with a unified `TraceEvent` model. + +### `StreamEvent` removed + +The old SSE payload format (`{"type": "thinking" | "content" | "message" | "structured" | "error", "data": "..."}`) is **removed**. The `/stream` and WebSocket endpoints now emit `TraceEvent.model_dump_json()` objects (see [TraceEvent format](#traceevent-format)). Clients must be updated to parse the new schema. The only non-`TraceEvent` payload is the error object `{"type": "error", "data": "..."}` emitted on failure (followed by `[DONE]`). + +### `messages` table dropped + +The `messages` PostgreSQL table has been dropped (migration `007`). All conversation activity is now stored in `trace_events`. Migration `006` backfills `trace_events` from existing `messages` rows, so no data is lost when upgrading. The `GET /api/v1/threads/{id}/messages` endpoint is preserved as a backward-compatible projection (filters `trace_events` to `HUMAN_MESSAGE` + `AI_MESSAGE`). + +### `AgentRunner` API + +The `AgentRunner` port signatures have changed: + +- `invoke(thread_id, message, turn_id) -> tuple[Message, list[TraceEvent]]` +- `stream(thread_id, message, turn_id) -> AsyncIterator[TraceEvent]` + +Adapters and tests calling the old `invoke(thread_id, message) -> Message` / `stream(...) -> AsyncIterator[StreamEvent]` signatures must be updated. + +### Migrations + +Migrations `005`, `006`, `007` run automatically on startup. They are idempotent and safe to run on an existing database with data. --- diff --git a/agents/.DS_Store b/agents/.DS_Store deleted file mode 100644 index 425b79c..0000000 Binary files a/agents/.DS_Store and /dev/null differ diff --git a/agents/README.md b/agents/README.md new file mode 100644 index 0000000..cff8479 --- /dev/null +++ b/agents/README.md @@ -0,0 +1,18 @@ +# Agent Catalog + +## Single Agents + +| File | Description | +|------|-------------| +| `single/haiku-files.yaml` | File management agent using MCP file tools | +| `single/haiku-files-local.yaml` | File management agent with local filesystem access | +| `single/haiku-files-local-structured.yaml` | File management agent with structured response output | +| `single/haiku-rag.yaml` | RAG agent using classical indexing and query endpoints | +| `single/haiku-rag-local.yaml` | RAG agent with local MinIO configuration | +| `single/haiku-rag-formation.yaml` | RAG agent specialized for formation content | + +## Subagent Orchestrators + +| File | Description | +|------|-------------| +| `subagents/orchestrator-test-structured.yaml` | Test orchestrator with structured subagent responses | \ No newline at end of file diff --git a/agents/bricks/.DS_Store b/agents/bricks/.DS_Store deleted file mode 100644 index 8fe0458..0000000 Binary files a/agents/bricks/.DS_Store and /dev/null differ diff --git a/agents/single/haiku-files-local-structured.yaml b/agents/single/haiku-files-local-structured.yaml new file mode 100644 index 0000000..4914d0c --- /dev/null +++ b/agents/single/haiku-files-local-structured.yaml @@ -0,0 +1,147 @@ +name: haiku-files-local-structured +model: openai:anthropic/claude-haiku-4.5:nitro +system_prompt: | + Tu es un extracteur de données spécialisé dans les documents immobiliers (crowdfunding). + + ## Objectif + + L'utilisateur te donne un identifiant de projet (working_dir). Tu dois extraire toutes les données + factuelles des documents de ce projet en utilisant l'outil mcp de lecture de fichiers, en gardant la + **traçabilité exacte par champ** (document, page, extrait, score), puis publier le résultat + avec ces sources via l'outil mcp `publish_section_version`. + + **Attention en cas de plusieurs lots, ne confonds pas la valeur d'un lot individuel avec la valeur du projet global.** + + ## Règles STRICTES + + - Retourne UNIQUEMENT les données trouvées dans les documents. + - Si une donnée n'est pas trouvée, retourne null pour le champ correspondant. + - Normalise les montants en nombres : "450K €" → 450000, "1,2 M€" → 1200000, "1 200 €" → 1200. + - Normalise les surfaces : "120 m²" → 120, "120m2" → 120. + - Pour les durées : "18 mois" → 18, "2 ans" → 24. + - Normalisation des années de construction : "avant 1949" → 1949. + - PROJET GLOBAL vs LOT INDIVIDUEL : ne JAMAIS confondre la valeur d'un lot individuel avec la valeur du projet global. + - Si le projet contient 3 lots achetés 100K€ chacun, `content.financial.acquisitionPrice` = 300000. + - Si un bail donne un loyer de 800€ pour un lot, `content.property.monthlyRentExcludingTax` = SOMME des loyers. + - Vérification croisée : pour les données importantes (prix, prêt, surface), fais une deuxième requête pour confirmer. + + ## Traçabilité par champ (OBLIGATOIRE) + + Pour CHAQUE champ non-null de `content`, tu DOIS ajouter une entrée dans `fieldSources` avec : + - `fieldName` : chemin pointé en camelCase (`financial.acquisitionPrice`, `property.livingArea`). + - `documentName` : nom exact du fichier source. + - `pageNumber` : entier ou null. + - `excerpt` : extrait court et fidèle (1-2 phrases, recopié du document). + - `score` : confiance entre 0 et 1, ou null. + + Un champ null n'a pas d'entrée dans `fieldSources`. + + ## Méthode (étape par étape) + + Tu DOIS procéder EXACTEMENT étape par étape. + + **Étape 1 : financial** + - Requêtes : "budget global projet prix acquisition vente montant financement prêt garantie apport travaux cout total" + - Confirmation : "durée projet calendrier planning mois date livraison" + - Remplis `content.financial.*` et `content.project.durationMonths` + les `fieldSources` correspondants. + + **Étape 2 : property** + - Requêtes : "surface totale habitable adresse bien immobilier localisation type immeuble copropriété" + - Confirmation : "loyer mensuel total charges locatives lots unités nombre copropriété revenus locatifs" + - Confirmation : "année construction avant étage ascenseur pièces salles bain terrain jardin balcon terrasse DPE" + - Remplis `content.property.*` + les `fieldSources`. + + **Étape 3 : carrier & company** + - Requêtes : "expérience porteur projet promoteur chef de file nombre opérations réalisées litige incident bancaire Bricks performance" + - Requêtes : "société projet SPE forme juridique création durée existence résultat net dette capitaux propres ratio endettement Kbis statuts" + - Remplis `content.carrier.*` et `content.company.*` + les `fieldSources`. + + **Étape 4 : Finalisation et publication** + - Finalise `content` (tous les champs présents, null si non trouvés) et `fieldSources` (une entrée par champ non-null). + - Publie via l'outil mcp `publish_section_version` avec `project_unique_id`, `content`, `field_sources`. + - NE LANCE PAS DE SOUS AGENTS EN PARALLÈLE POUR LIRE LES FICHIERS. + +response_format: + type: object + additionalProperties: false + properties: + content: + type: object + additionalProperties: false + properties: + financial: + type: object + additionalProperties: false + properties: + acquisitionPrice: {anyOf: [{type: number}, {type: "null"}]} + acquisitionPricePerSqm: {anyOf: [{type: number}, {type: "null"}]} + marketPricePerSqm: {anyOf: [{type: number}, {type: "null"}]} + worksCost: {anyOf: [{type: number}, {type: "null"}]} + plannedResalePrice: {anyOf: [{type: number}, {type: "null"}]} + personalContribution: {anyOf: [{type: number}, {type: "null"}]} + loanAmount: {anyOf: [{type: number}, {type: "null"}]} + guaranteeValue: {anyOf: [{type: number}, {type: "null"}]} + guaranteeType: {anyOf: [{type: string}, {type: "null"}]} + required: [acquisitionPrice, acquisitionPricePerSqm, marketPricePerSqm, worksCost, plannedResalePrice, personalContribution, loanAmount, guaranteeValue, guaranteeType] + project: + type: object + additionalProperties: false + properties: + durationMonths: {anyOf: [{type: integer}, {type: "null"}]} + required: [durationMonths] + property: + type: object + additionalProperties: false + properties: + address: {anyOf: [{type: string}, {type: "null"}]} + livingArea: {anyOf: [{type: number}, {type: "null"}]} + monthlyRentExcludingTax: {anyOf: [{type: number}, {type: "null"}]} + annualCharges: {anyOf: [{type: number}, {type: "null"}]} + presoldUnits: {anyOf: [{type: integer}, {type: "null"}]} + totalUnits: {anyOf: [{type: integer}, {type: "null"}]} + preMarketingRate: {anyOf: [{type: number}, {type: "null"}]} + presentationDescription: {anyOf: [{type: string}, {type: "null"}]} + required: [address, livingArea, monthlyRentExcludingTax, annualCharges, presoldUnits, totalUnits, preMarketingRate, presentationDescription] + carrier: + type: object + additionalProperties: false + properties: + experienceYears: {anyOf: [{type: integer}, {type: "null"}]} + successfulOperations: {anyOf: [{type: integer}, {type: "null"}]} + hasActiveLitigation: {anyOf: [{type: boolean}, {type: "null"}]} + bankingIncidents: {anyOf: [{type: integer}, {type: "null"}]} + bricksPerformance: {anyOf: [{type: string}, {type: "null"}]} + required: [experienceYears, successfulOperations, hasActiveLitigation, bankingIncidents, bricksPerformance] + company: + type: object + additionalProperties: false + properties: + yearsOfExistence: {anyOf: [{type: integer}, {type: "null"}]} + netResultYear1: {anyOf: [{type: number}, {type: "null"}]} + netResultYear2: {anyOf: [{type: number}, {type: "null"}]} + netResultYear3: {anyOf: [{type: number}, {type: "null"}]} + totalDebt: {anyOf: [{type: number}, {type: "null"}]} + equity: {anyOf: [{type: number}, {type: "null"}]} + debtRatio: {anyOf: [{type: number}, {type: "null"}]} + required: [yearsOfExistence, netResultYear1, netResultYear2, netResultYear3, totalDebt, equity, debtRatio] + required: [financial, project, property, carrier, company] + fieldSources: + type: array + items: + type: object + additionalProperties: false + properties: + fieldName: {type: string} + documentName: {type: string} + pageNumber: {anyOf: [{type: integer}, {type: "null"}]} + excerpt: {type: string} + score: {anyOf: [{type: number}, {type: "null"}]} + required: [fieldName, documentName, pageNumber, excerpt, score] + required: [content, fieldSources] + +mcp_servers: +- name: bricks + transport: http + url: http://raganything-api:8000/bricks/mcp + headers: + X-API-Key: "${MCP_RAGANYTHING_API_KEY}" \ No newline at end of file diff --git a/agents/bricks/single/haiku-files-local.yaml b/agents/single/haiku-files-local.yaml similarity index 100% rename from agents/bricks/single/haiku-files-local.yaml rename to agents/single/haiku-files-local.yaml diff --git a/agents/bricks/single/haiku-files.yaml b/agents/single/haiku-files.yaml similarity index 98% rename from agents/bricks/single/haiku-files.yaml rename to agents/single/haiku-files.yaml index 847b235..9d7c8c5 100644 --- a/agents/bricks/single/haiku-files.yaml +++ b/agents/single/haiku-files.yaml @@ -179,6 +179,4 @@ system_prompt: | mcp_servers: - name: bricks transport: http - url: http://raganything:8000/bricks/mcp` - headers: - X-API-Key: "${MCP_RAGANYTHING_API_KEY}" \ No newline at end of file + url: http://composable-api-mcp:8000/bricks/mcp diff --git a/agents/single/haiku-rag-formation.yaml b/agents/single/haiku-rag-formation.yaml new file mode 100644 index 0000000..6cb34c0 --- /dev/null +++ b/agents/single/haiku-rag-formation.yaml @@ -0,0 +1,67 @@ +name: haiku-rag-formation +model: openai:anthropic/claude-haiku-4.5:nitro +system_prompt: | + ## Objectif + + Tu es un assistant RAG spécialisé dans l'aide aux freelance + + Ton but est de comprendre la question du client et de chercher dans les working_dir qui semblent + les plus pertinents. + + Il se peut que la question du client trouve sa potentielles réponse dans plusieurs working_dir. + + Tu as accès a plusieurs working_dir RAG : + - **la-facturation**: concerne les differentes méthodes de facturation et des conseils sur celle-ci + - **les-contrats**: contient tous les conseils concernant les contrats + - **plateformes-freelance**: contient tous les conseils conernant le fonctionnement des différentes plateformes freelance, ainsi que des conseils pour y améliorer son profil. + - **prospection**: contient tous les conseils concernant la prospection + - **se-lancer-en-freelance**: contient tous les conseils stratégiques pour commencer/pérénniser son activité de freelance + - **se-vendre-et-negocier**: contient tous les conseils pour se vendre auprès de différents profils + - **trouver-son-positionnement**: contient tous les conseils pour trouver un positionnement efficace sur le marché + + Les documents sont des documents de formation pour réussir son aventure freelance. + + ## Règles STRICTES + + - Utilise UNIQUEMENT l'outil mcp `classical_query` pour chercher dans la base de connaissances. + - Réponds UNIQUEMENT à partir des passages retournés par la requête RAG. N'invente rien. + - Si la base de connaissances ne retourne rien ou si les passages ne sont pas pertinents, dis-le clairement. + - Cite toujours le fichier source et l'extrait pertinent pour chaque information que tu donnes. + - Si une information n'est pas trouvée, dis "Information non trouvée dans les documents indexés". + + ## Méthode + + **Étape 1 : Comprendre la question** + - Identifie les mots-clés et thèmes de la question de l'utilisateur. + + **Étape 2 : Requête RAG** + - Utilise l'outil `classical_query` avec : + - `working_dir` : l'identifiant du projet fourni par l'utilisateur + - `query` : reformule la question en une requête de recherche pertinente + - `mode` : "hybrid" (combinaison BM25 + vectoriel pour de meilleurs résultats) + - `top_k` : 10 (maximum de passages à récupérer) + - Si la première requête ne retourne pas de résultats pertinents, fais une deuxième requête + avec une formulation différente (synonymes, termes plus généraux ou plus spécifiques). + + **Étape 3 : Analyse des résultats** + - Examine les passages retournés par la requête RAG. + - Filtre les passages non pertinents ou redondants. + - Identifie les informations qui répondent à la question. + + **Étape 4 : Réponse** + - Réponds de façon claire et structurée. + - Pour chaque information fournie, cite : + - Le fichier source (`file_path`) + - Un extrait court du passage pertinent + - Si les informations sont contradictoires entre plusieurs passages, signale-le. + - Si une information est absente, dis-le explicitement. + + ## Format de réponse + + Réponds en texte naturel (pas de JSON), de façon claire et concise. + Structure ta réponse avec des sections si la question couvre plusieurs thèmes. + +mcp_servers: +- name: raganything + transport: http + url: http://raganything-api:8000/classical/mcp diff --git a/agents/single/haiku-rag-local.yaml b/agents/single/haiku-rag-local.yaml new file mode 100644 index 0000000..16b2fa1 --- /dev/null +++ b/agents/single/haiku-rag-local.yaml @@ -0,0 +1,76 @@ +name: haiku-rag-local +model: openai:anthropic/claude-haiku-4.5:nitro +system_prompt: | + Tu es un assistant RAG spécialisé dans l'analyse de documents immobiliers (crowdfunding). + Tu réponds aux questions de l'utilisateur en interrogeant la base de connaissances indexée. + + ## Objectif + + L'utilisateur te donne un identifiant de projet (working_dir) et une question. Tu dois interroger + la base de connaissances RAG pour trouver les passages pertinents, puis répondre en citant tes sources. + + ## Règles STRICTES + + - Utilise UNIQUEMENT l'outil mcp `classical_query` pour chercher dans la base de connaissances. + - Réponds UNIQUEMENT à partir des passages retournés par la requête RAG. N'invente rien. + - Si la base de connaissances ne retourne rien ou si les passages ne sont pas pertinents, dis-le clairement. + - Cite toujours le fichier source et l'extrait pertinent pour chaque information que tu donnes. + - Si une information n'est pas trouvée, dis "Information non trouvée dans les documents indexés". + + ## Méthode + + **Étape 1 : Comprendre la question** + - Identifie les mots-clés et thèmes de la question de l'utilisateur. + + **Étape 2 : Requête RAG** + - Utilise l'outil `classical_query` avec : + - `working_dir` : l'identifiant du projet fourni par l'utilisateur + - `query` : reformule la question en une requête de recherche pertinente + - `mode` : "hybrid" (combinaison BM25 + vectoriel pour de meilleurs résultats) + - `top_k` : 10 (maximum de passages à récupérer) + - Si la première requête ne retourne pas de résultats pertinents, fais une deuxième requête + avec une formulation différente (synonymes, termes plus généraux ou plus spécifiques). + + **Étape 3 : Analyse des résultats** + - Examine les passages retournés par la requête RAG. + - Filtre les passages non pertinents ou redondants. + - Identifie les informations qui répondent à la question. + + **Étape 4 : Réponse** + - Réponds de façon claire et structurée. + - Pour chaque information fournie, cite : + - Le fichier source (`file_path`) + - Un extrait court du passage pertinent + - Si les informations sont contradictoires entre plusieurs passages, signale-le. + - Si une information est absente, dis-le explicitement. + + ## Format de réponse + + Réponds en texte naturel (pas de JSON), de façon claire et concise. + Structure ta réponse avec des sections si la question couvre plusieurs thèmes. + + Exemple de réponse : + --- + D'après les documents indexés : + + **Prix d'acquisition** + Le coût d'acquisition du projet s'élève à 250 000 €. + Source : `synthese_financiere.pdf` — "Le coût total d'acquisition du projet est de 250 000 euros." + + **Surface habitable** + La surface habitable totale est de 65 m². + Source : `descriptif_bien.pdf` — "La surface habitable est de 65 m²." + + **Information non trouvée** + Le ratio d'endettement n'est pas mentionné dans les documents indexés. + --- + + NE LANCE PAS DE SOUS AGENTS EN PARALLÈLE POUR LES REQUÊTES RAG. + Fais les requêtes séquentiellement. + +mcp_servers: +- name: raganything + transport: http + url: http://raganything-api:8000/classical/mcp + headers: + X-API-Key: "${MCP_RAGANYTHING_API_KEY}" \ No newline at end of file diff --git a/agents/bricks/single/haiku-rag.yaml b/agents/single/haiku-rag.yaml similarity index 100% rename from agents/bricks/single/haiku-rag.yaml rename to agents/single/haiku-rag.yaml diff --git a/agents/subagents/orchestrator-test-structured.yaml b/agents/subagents/orchestrator-test-structured.yaml new file mode 100644 index 0000000..c0d97dc --- /dev/null +++ b/agents/subagents/orchestrator-test-structured.yaml @@ -0,0 +1,39 @@ +name: orchestrator-test-structured +model: openai:anthropic/claude-haiku-4.5:nitro +system_prompt: | + Tu es un orchestrateur de test. Tu disposes de deux sous-agents : + - "summer" : calcule la somme de deux nombres. + - "echoer" : renvoie un résumé textuel. + Appelle les DEUX sous-agents via le tool `task`, puis renvoie leurs + deux résultats structurés combinés dans ta réponse finale. +subagents: + - name: summer + description: Calcule la somme de deux nombres fournis dans la description. + instructions: | + Tu reçois deux nombres dans la description. Calcule leur somme exacte + et renvoie le résultat structuré. + model: openai:anthropic/claude-haiku-4.5:nitro + response_format: + type: object + additionalProperties: false + properties: + sum: { type: number } + required: [sum] + - name: echoer + description: Renvoie un résumé court du texte fourni dans la description. + instructions: | + Tu reçois un texte. Produis un résumé d'une phrase. + model: openai:anthropic/claude-haiku-4.5:nitro + response_format: + type: object + additionalProperties: false + properties: + summary: { type: string } + required: [summary] +response_format: + type: object + additionalProperties: false + properties: + sumResult: { type: number } + summaryResult: { type: string } + required: [sumResult, summaryResult] diff --git a/src/alembic/env.py b/src/alembic/env.py index 0cf89d4..0915778 100644 --- a/src/alembic/env.py +++ b/src/alembic/env.py @@ -11,7 +11,8 @@ # 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.database.models.thread import ThreadModel # noqa: F401 +from src.infrastructure.database.models.trace_event import TraceEventModel # noqa: F401 from src.infrastructure.logging import configure_logging config = context.config diff --git a/src/alembic/versions/005_create_trace_events_table.py b/src/alembic/versions/005_create_trace_events_table.py new file mode 100644 index 0000000..f0eca20 --- /dev/null +++ b/src/alembic/versions/005_create_trace_events_table.py @@ -0,0 +1,65 @@ +"""Create trace_events table. + +Revision ID: 005 +Revises: 004 +Create Date: 2026-07-20 + +Single source of truth for everything that happened during a conversation turn. +Indexes: + - ix_trace_events_thread_turn (thread_id, turn_id) — list_by_turn + - ix_trace_events_thread_type (thread_id, type) — list_messages + - ix_trace_events_thread_ts (thread_id, timestamp) — list_by_thread ordering +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "005" +down_revision: str | None = "004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE IF NOT EXISTS trace_events ( + id VARCHAR(36) PRIMARY KEY, + thread_id VARCHAR(36) NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + turn_id VARCHAR(36) NOT NULL, + type VARCHAR(30) NOT NULL, + source VARCHAR(100), + name VARCHAR(200), + content TEXT, + metadata JSONB, + timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), + sequence INTEGER NOT NULL DEFAULT 0 + ); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_trace_events_thread_turn + ON trace_events(thread_id, turn_id); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_trace_events_thread_type + ON trace_events(thread_id, type); + """ + ) + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_trace_events_thread_ts + ON trace_events(thread_id, timestamp); + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_trace_events_thread_ts;") + op.execute("DROP INDEX IF EXISTS ix_trace_events_thread_type;") + op.execute("DROP INDEX IF EXISTS ix_trace_events_thread_turn;") + op.execute("DROP TABLE IF EXISTS trace_events;") diff --git a/src/alembic/versions/006_migrate_messages_to_trace_events.py b/src/alembic/versions/006_migrate_messages_to_trace_events.py new file mode 100644 index 0000000..60013aa --- /dev/null +++ b/src/alembic/versions/006_migrate_messages_to_trace_events.py @@ -0,0 +1,209 @@ +"""Migrate messages rows into trace_events. + +Revision ID: 006 +Revises: 005 +Create Date: 2026-07-20 + +Mapping from legacy messages.role to trace_events.type: + - human -> human_message (content kept as-is) + - ai -> ai_message (content becomes JSON payload of the Message) + - tool -> tool_result (content kept as-is, tool_calls -> metadata) + - system -> content (content kept as-is) + +Each legacy message gets a fresh turn_id (uuid) — the legacy schema did not +track turns. The AI message payload is reconstructed as the JSON serialization +of the relevant Message fields so that ``Message.from_trace_event`` can rebuild +the exact same entity. + +Note: The legacy messages table did not have a tool_call_id column — tool_call +IDs were embedded in the tool_calls JSONB field. The migration preserves +tool_calls in the trace_events metadata, so no data is lost. + +Downgrade limitation: the downgrade only backfills human_message and ai_message +rows. tool_result and content events are not reconstructed as legacy messages +on rollback. This is acceptable because the legacy messages table only had +human/ai/tool/system roles, and tool messages were ephemeral — losing them on +rollback does not affect conversation history. +""" + +import json +from collections.abc import Sequence +from typing import Any +from uuid import uuid4 + +from sqlalchemy import text + +from alembic import op + +revision: str = "006" +down_revision: str | None = "005" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +_ROLE_TO_TYPE = { + "human": "human_message", + "ai": "ai_message", + "tool": "tool_result", + "system": "content", +} + + +def _fetch_messages(conn: Any) -> list[Any]: + return list( + conn.execute( + text( + "SELECT id, thread_id, role, content, timestamp, tool_calls, " + "status, structured_response, thinking " + "FROM messages ORDER BY thread_id, timestamp" + ) + ) + ) + + +def _build_event_row(row: Any) -> tuple[Any, ...]: + role = row.role + event_type = _ROLE_TO_TYPE.get(role, "content") + turn_id = str(uuid4()) + content: str | None + metadata: dict | None = None + + if event_type == "ai_message": + payload: dict = {"content": row.content} + if row.tool_calls is not None: + payload["tool_calls"] = row.tool_calls + if row.status is not None: + payload["status"] = row.status + if row.structured_response is not None: + payload["structured_response"] = row.structured_response + if row.thinking is not None: + payload["thinking"] = row.thinking + content = json.dumps(payload) + elif event_type == "tool_result": + content = row.content + if row.tool_calls is not None: + metadata = {"tool_calls": row.tool_calls} + if row.status is not None: + metadata = {**(metadata or {}), "status": row.status} + else: + content = row.content + + return ( + str(uuid4()), + row.thread_id, + turn_id, + event_type, + None, + None, + content, + json.dumps(metadata) if metadata is not None else None, + row.timestamp, + 0, + ) + + +def upgrade() -> None: + conn = op.get_bind() + rows = _fetch_messages(conn) + if not rows: + return + + values = [_build_event_row(row) for row in rows] + conn.execute( + text( + "INSERT INTO trace_events " + "(id, thread_id, turn_id, type, source, name, content, metadata, timestamp, sequence) " + "VALUES (:id, :thread_id, :turn_id, :type, :source, :name, :content, :metadata, :timestamp, :sequence)" + ), + [ + { + "id": v[0], + "thread_id": v[1], + "turn_id": v[2], + "type": v[3], + "source": v[4], + "name": v[5], + "content": v[6], + "metadata": v[7], + "timestamp": v[8], + "sequence": v[9], + } + for v in values + ], + ) + + +def downgrade() -> None: + conn = op.get_bind() + + conn.execute( + text( + "CREATE TABLE IF NOT EXISTS messages (" + "id VARCHAR(36) PRIMARY KEY, " + "thread_id VARCHAR(36) NOT NULL REFERENCES threads(id) ON DELETE CASCADE, " + "role VARCHAR(20) NOT NULL, " + "content TEXT, " + "timestamp TIMESTAMPTZ NOT NULL DEFAULT now(), " + "tool_calls JSONB, " + "status VARCHAR(50), " + "structured_response JSONB, " + "thinking TEXT)" + ) + ) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_messages_thread_id ON messages(thread_id);")) + conn.execute(text("CREATE INDEX IF NOT EXISTS ix_messages_thread_id_timestamp ON messages(thread_id, timestamp);")) + + rows = list( + conn.execute( + text( + "SELECT id, thread_id, turn_id, type, content, metadata, timestamp " + "FROM trace_events WHERE type IN ('human_message', 'ai_message') " + "ORDER BY thread_id, timestamp" + ) + ) + ) + + type_to_role = {"human_message": "human", "ai_message": "ai"} + values: list[dict] = [] + for row in rows: + role = type_to_role[row.type] + content: str | None = None + tool_calls = None + status = None + structured_response = None + thinking = None + + if row.type == "ai_message": + payload = json.loads(row.content) if row.content else {} + content = payload.get("content") + tool_calls = payload.get("tool_calls") + status = payload.get("status") + structured_response = payload.get("structured_response") + thinking = payload.get("thinking") + else: + content = row.content + + values.append( + { + "id": row.id, + "thread_id": row.thread_id, + "role": role, + "content": content, + "timestamp": row.timestamp, + "tool_calls": json.dumps(tool_calls) if tool_calls is not None else None, + "status": status, + "structured_response": json.dumps(structured_response) if structured_response is not None else None, + "thinking": thinking, + } + ) + + if values: + conn.execute( + text( + "INSERT INTO messages " + "(id, thread_id, role, content, timestamp, tool_calls, status, structured_response, thinking) " + "VALUES (:id, :thread_id, :role, :content, :timestamp, :tool_calls, :status, " + ":structured_response, :thinking)" + ), + values, + ) diff --git a/src/alembic/versions/007_drop_messages_table.py b/src/alembic/versions/007_drop_messages_table.py new file mode 100644 index 0000000..b627ade --- /dev/null +++ b/src/alembic/versions/007_drop_messages_table.py @@ -0,0 +1,32 @@ +"""Drop legacy messages table. + +Revision ID: 007 +Revises: 006 +Create Date: 2026-07-20 + +The ``messages`` table is replaced by ``trace_events`` as the single source of +truth. The downgrade rebuilds it from the HUMAN_MESSAGE + AI_MESSAGE events +(see revision 006's downgrade for the actual data backfill). +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "007" +down_revision: str | None = "006" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_messages_thread_id_timestamp;") + op.execute("DROP INDEX IF EXISTS ix_messages_thread_id;") + op.execute("DROP TABLE IF EXISTS messages;") + + +def downgrade() -> None: + # The schema is recreated by revision 006's downgrade, which runs before + # this one when walking down. Nothing to do here except ensure the table + # exists (idempotent) — the data backfill is handled in 006. + pass diff --git a/src/application/responses/thread_history.py b/src/application/responses/thread_history.py new file mode 100644 index 0000000..4055b94 --- /dev/null +++ b/src/application/responses/thread_history.py @@ -0,0 +1,41 @@ +"""Response DTOs for thread history (Ticket 3). + +Groups TraceEvents by turn, with the reconstructed HUMAN/AI Messages and +the list of intermediate trace events (THINKING, CONTENT, TOOL_CALL, …). +""" + +from pydantic import BaseModel + +from src.domain.entities.message import Message +from src.domain.entities.thread import Thread +from src.domain.entities.trace_event import TraceEvent + + +class Turn(BaseModel): + """A single conversation turn inside a thread. + + Attributes: + turn_id: Identifier grouping all events of this turn. + human_message: Reconstructed human Message (None if missing). + ai_message: Reconstructed AI Message (None if the turn crashed before + the AI_MESSAGE event was emitted). + events: Intermediate TraceEvents (everything except HUMAN_MESSAGE and + AI_MESSAGE), ordered by sequence. + """ + + turn_id: str + human_message: Message | None + ai_message: Message | None + events: list[TraceEvent] + + +class ThreadHistory(BaseModel): + """Full history of a thread grouped by turn. + + Attributes: + thread: The parent Thread. + turns: List of Turns, one per turn_id. + """ + + thread: Thread + turns: list[Turn] diff --git a/src/application/routes/chat.py b/src/application/routes/chat.py index f5a84b3..112b2aa 100644 --- a/src/application/routes/chat.py +++ b/src/application/routes/chat.py @@ -1,4 +1,13 @@ +"""Chat HTTP routes: POST /chat/{id} and POST /chat/{id}/stream. + +Emits TraceEvent records (not the legacy StreamEvent). On stream error, the +generator emits a plain JSON error payload ``{"type": "error", "data": "..."}`` +that is NOT a TraceEvent (TraceEventType has no ERROR variant) — this matches +the previous SSE contract for errors; the frontend already handles it. +""" + import asyncio +import json import logging from typing import Annotated @@ -15,7 +24,6 @@ get_stream_message_use_case, ) 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__) @@ -29,6 +37,15 @@ async def send_message( body: ChatRequest, use_case: Annotated[SendMessageUseCase, Depends(get_send_message_use_case)], ) -> Message: + """Send a human message or an HITL decision and return the final AI Message. + + Args: + thread_id: Conversation thread identifier. + body: Chat request (message XOR HITL fields). + + Returns: + The final AI Message. + """ logger.info(LogMessage.CHAT_RECEIVE, thread_id, "HITL" if body.message is None else body.message[:80]) result = await use_case.execute( thread_id, @@ -49,23 +66,36 @@ 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: + """Stream all TraceEvents of a turn as SSE, then a ``[DONE]`` terminator. + + Each ``data:`` line carries a JSON-serialized TraceEvent. On error, a + plain JSON error payload ``{"type": "error", "data": "..."}`` is emitted + (this is NOT a TraceEvent — :class:`TraceEventType` has no ERROR variant), + matching the previous SSE contract so the frontend keeps working. + + Args: + thread_id: Conversation thread identifier. + body: Chat request (must contain ``message``). + get_thread: GetThreadUseCase used to validate the thread exists. + + Returns: + An ``EventSourceResponse`` streaming TraceEvents. + """ logger.info(LogMessage.CHAT_STREAM_RECEIVE, thread_id, (body.message or "")[:80]) await get_thread.execute(thread_id) async def event_generator(): - chunk_count = 0 + event_count = 0 try: - async for event in use_case.execute(thread_id, body.message): - if event.type in (StreamEventType.THINKING, StreamEventType.CONTENT): - chunk_count += 1 + async for event in use_case.execute(thread_id, body.message or ""): + event_count += 1 yield {"data": event.model_dump_json()} yield {"data": "[DONE]"} - logger.info(LogMessage.CHAT_STREAM_COMPLETE, thread_id, chunk_count) + logger.info(LogMessage.CHAT_STREAM_COMPLETE, thread_id, event_count) except asyncio.CancelledError: raise except Exception as exc: - 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()} + logger.exception(LogMessage.CHAT_STREAM_ERROR, thread_id, event_count) + yield {"data": json.dumps({"type": "error", "data": str(exc)})} return EventSourceResponse(event_generator(), sep="\r\n", ping=15) diff --git a/src/application/routes/threads.py b/src/application/routes/threads.py index 32a36ca..b0be2e0 100644 --- a/src/application/routes/threads.py +++ b/src/application/routes/threads.py @@ -4,13 +4,16 @@ from fastapi import APIRouter, Depends, status from src.application.requests.chat import CreateThreadRequest +from src.application.responses.thread_history import ThreadHistory from src.application.use_cases.create_thread import CreateThreadUseCase from src.application.use_cases.delete_thread import DeleteThreadUseCase from src.application.use_cases.get_thread import GetThreadUseCase +from src.application.use_cases.get_thread_history import GetThreadHistoryUseCase from src.application.use_cases.list_threads import ListThreadsUseCase from src.dependencies import ( get_create_thread_use_case, get_delete_thread_use_case, + get_get_thread_history_use_case, get_get_thread_use_case, get_list_threads_use_case, ) @@ -69,3 +72,21 @@ async def list_messages( thread = await use_case.execute(thread_id) logger.info(LogMessage.THREAD_MESSAGES_LISTED, thread_id, len(thread.messages)) return thread.messages + + +@router.get("/{thread_id}/history") +async def get_thread_history( + thread_id: str, + use_case: Annotated[GetThreadHistoryUseCase, Depends(get_get_thread_history_use_case)], +) -> ThreadHistory: + """Return the full history of a thread grouped by turn. + + Args: + thread_id: Conversation thread identifier. + use_case: GetThreadHistoryUseCase wired at startup. + + Returns: + A :class:`ThreadHistory` containing the thread and its turns. + """ + logger.info(LogMessage.THREAD_GETTING, thread_id) + return await use_case.execute(thread_id) diff --git a/src/application/routes/trace.py b/src/application/routes/trace.py new file mode 100644 index 0000000..dc534fd --- /dev/null +++ b/src/application/routes/trace.py @@ -0,0 +1,43 @@ +"""Trace HTTP route: GET /api/v1/threads/{id}/trace. + +Returns the flat list of TraceEvents for a thread (ordered by timestamp). +""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends + +from src.dependencies import get_get_thread_use_case, get_trace_event_repository +from src.domain.entities.trace_event import TraceEvent +from src.domain.logging.messages import LogMessage +from src.domain.ports.trace_event_repository import TraceEventRepository +from src.application.use_cases.get_thread import GetThreadUseCase + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/threads", tags=["trace"]) + + +@router.get("/{thread_id}/trace") +async def list_trace( + thread_id: str, + repo: Annotated[TraceEventRepository, Depends(get_trace_event_repository)], + use_case: Annotated[GetThreadUseCase, Depends(get_get_thread_use_case)], +) -> dict: + """Return the flat list of all TraceEvents for a thread. + + Args: + thread_id: Conversation thread identifier. + repo: TraceEventRepository wired at startup. + use_case: GetThreadUseCase used to validate the thread exists. + + Returns: + ``{"events": [TraceEvent, ...]}`` ordered by timestamp. + """ + logger.info(LogMessage.THREAD_GETTING, thread_id) + # Validate the thread exists first to align with /history and /messages + # (which raise 404 for unknown threads). + await use_case.execute(thread_id) + events: list[TraceEvent] = await repo.list_by_thread(thread_id) + return {"events": events} diff --git a/src/application/routes/websocket.py b/src/application/routes/websocket.py index 00628fe..911a65f 100644 --- a/src/application/routes/websocket.py +++ b/src/application/routes/websocket.py @@ -1,3 +1,11 @@ +"""WebSocket chat route: streams TraceEvents then ``[END]``. + +Emits TraceEvent records (not the legacy StreamEvent). On error, emits a plain +JSON error payload ``{"type": "error", "data": "..."}`` that is NOT a +TraceEvent (TraceEventType has no ERROR variant), matching the previous +contract. +""" + import json import logging from typing import Annotated @@ -6,7 +14,6 @@ from src.application.use_cases.stream_message import StreamMessageUseCase from src.dependencies import get_security, get_stream_message_use_case -from src.domain.entities.stream_event import StreamEvent, StreamEventType from src.domain.logging.messages import LogMessage from src.security import ComposableAgentsSecurity @@ -22,6 +29,18 @@ async def websocket_chat( security: Annotated[ComposableAgentsSecurity, Depends(get_security)], use_case: Annotated[StreamMessageUseCase, Depends(get_stream_message_use_case)], ) -> None: + """Stream TraceEvents over a WebSocket for each received message. + + For each received text payload ``{"message": "..."}``, emits one + ``TraceEvent.model_dump_json()`` frame per event, then an ``[END]`` frame. + On error, emits ``{"type": "error", "data": "..."}`` (not a TraceEvent). + + Args: + websocket: The incoming WebSocket connection. + thread_id: Conversation thread identifier. + security: Security validator (API key check at handshake). + use_case: StreamMessageUseCase wired with real repositories + mock runner. + """ # Validate API key before accepting the WebSocket handshake. await security.verify_api_key_ws(websocket) await websocket.accept() @@ -37,18 +56,16 @@ async def websocket_chat( continue message = payload.get("message", "") logger.info(LogMessage.WS_MESSAGE_RECEIVED, thread_id, message[:80]) - chunk_count = 0 + event_count = 0 try: async for event in use_case.execute(thread_id, message): - if event.type in (StreamEventType.THINKING, StreamEventType.CONTENT): - chunk_count += 1 + event_count += 1 await websocket.send_text(event.model_dump_json()) await websocket.send_text("[END]") - logger.info(LogMessage.WS_STREAM_COMPLETE, thread_id, chunk_count) + logger.info(LogMessage.WS_STREAM_COMPLETE, thread_id, event_count) except Exception as exc: - logger.exception(LogMessage.WS_STREAM_ERROR, thread_id, chunk_count) - error_event = StreamEvent(type=StreamEventType.ERROR, data=str(exc)) - await websocket.send_text(error_event.model_dump_json()) + logger.exception(LogMessage.WS_STREAM_ERROR, thread_id, event_count) + await websocket.send_text(json.dumps({"type": "error", "data": str(exc)})) except WebSocketDisconnect: logger.info(LogMessage.WS_DISCONNECTED, thread_id) except Exception: diff --git a/src/application/use_cases/create_agent_config.py b/src/application/use_cases/create_agent_config.py index 1cc6186..37daf4e 100644 --- a/src/application/use_cases/create_agent_config.py +++ b/src/application/use_cases/create_agent_config.py @@ -44,9 +44,7 @@ async def execute(self, name: str, yaml_content: str) -> AgentConfig: config = self._config_loader.load_from_string(yaml_content) if config.name != name: - raise ConfigError( - ErrorMessage.AGENT_NAME_MISMATCH.format(yaml_name=config.name, name=name) - ) + raise ConfigError(ErrorMessage.AGENT_NAME_MISMATCH.format(yaml_name=config.name, name=name)) if await self._config_repository.exists(name): raise AgentConfigAlreadyExistsError(ErrorMessage.AGENT_CONFIG_ALREADY_EXISTS.format(name=name)) diff --git a/src/application/use_cases/get_thread_history.py b/src/application/use_cases/get_thread_history.py new file mode 100644 index 0000000..e902285 --- /dev/null +++ b/src/application/use_cases/get_thread_history.py @@ -0,0 +1,70 @@ +"""GetThreadHistoryUseCase — rebuild the full history of a thread grouped by turn. + +Loads the Thread and its TraceEvents, then groups events by ``turn_id`` +(ordered by sequence) and rebuilds the HUMAN/AI Messages via +:meth:`Message.from_trace_event`. Intermediate events (THINKING, CONTENT, +TOOL_CALL, TOOL_RESULT) are kept in ``Turn.events``. +""" + +from collections import defaultdict + +from src.application.responses.thread_history import ThreadHistory, Turn +from src.domain.entities.message import Message +from src.domain.entities.trace_event import TraceEventType +from src.domain.ports.thread_repository import ThreadRepository +from src.domain.ports.trace_event_repository import TraceEventRepository + + +class GetThreadHistoryUseCase: + """Rebuild the full history of a thread grouped by turn.""" + + def __init__(self, threads: ThreadRepository, trace_repo: TraceEventRepository) -> None: + self._threads = threads + self._trace_repo = trace_repo + + async def execute(self, thread_id: str) -> ThreadHistory: + """Return the thread history grouped by turn. + + Args: + thread_id: The conversation thread identifier. + + Returns: + A :class:`ThreadHistory` containing the thread and its turns. + + Raises: + ThreadNotFoundError: If the thread does not exist. + """ + thread = await self._threads.get(thread_id) + trace_events = await self._trace_repo.list_by_thread(thread_id) + + # Sort by timestamp first (chronological), then by sequence within a turn. + # turn_id is a UUID v4 (random), so sorting by turn_id would NOT preserve + # chronological order. We track turn_ids in first-seen order. + sorted_events = sorted(trace_events, key=lambda e: (e.timestamp, e.sequence)) + turns_map: dict[str, list] = defaultdict(list) + turn_order: list[str] = [] + for ev in sorted_events: + if ev.turn_id not in turns_map: + turn_order.append(ev.turn_id) + turns_map[ev.turn_id].append(ev) + + turns: list[Turn] = [] + for turn_id in turn_order: + events = turns_map[turn_id] + human_ev = next((e for e in events if e.type == TraceEventType.HUMAN_MESSAGE), None) + ai_ev = next((e for e in events if e.type == TraceEventType.AI_MESSAGE), None) + human_msg = Message.from_trace_event(human_ev) if human_ev else None + ai_msg = Message.from_trace_event(ai_ev) if ai_ev else None + intermediate = [ + e for e in events if e.type not in (TraceEventType.HUMAN_MESSAGE, TraceEventType.AI_MESSAGE) + ] + turns.append( + Turn( + turn_id=turn_id, + human_message=human_msg, + ai_message=ai_msg, + events=intermediate, + ) + ) + + return ThreadHistory(thread=thread, turns=turns) diff --git a/src/application/use_cases/send_message.py b/src/application/use_cases/send_message.py index c8d939c..ea2b506 100644 --- a/src/application/use_cases/send_message.py +++ b/src/application/use_cases/send_message.py @@ -1,45 +1,43 @@ +"""SendMessageUseCase — send a message or an HITL decision to the agent. + +Ticket 3 rewrite: the use case now depends on TraceEventRepository + the new +runner API ``invoke(thread_id, message, turn_id) -> (Message, list[TraceEvent])``. +The full trace is persisted in a single batch via ``trace_repo.add_batch``. +The HITL path (approve/reject/edit) returns the runner Message directly +without persisting trace events. +""" + import logging import time +import uuid from typing import Any -from src.domain.entities.message import Message, MessageRole +from src.domain.entities.message import Message from src.domain.errors.hitl import InvalidHitlActionError from src.domain.errors.messages import ErrorMessage from src.domain.logging.messages import LogMessage from src.domain.ports.agent_registry import AgentRegistry from src.domain.ports.thread_repository import ThreadRepository +from src.domain.ports.trace_event_repository import TraceEventRepository logger = logging.getLogger(__name__) class SendMessageUseCase: - """Envoie un message ou une decision HITL a l'agent et retourne la reponse.""" - - def __init__(self, registry: AgentRegistry, threads: ThreadRepository) -> None: - self._registry = registry - self._threads = threads + """Send a human message or an HITL decision to the agent and return the response. - @staticmethod - def _is_duplicate_human_message(messages: list[Message], message: str) -> bool: - """Detect duplicate HUMAN message submissions (crash/retry scenario). + For a human message: generates a fresh ``turn_id``, invokes the runner, + persists the full trace in a batch, and returns the final AI Message. - When a request crashes before the AI response is persisted, the last DB message - is HUMAN with status=None. On client retry, this check prevents storing a - duplicate HUMAN message in the DB. + For HITL decisions (approve/reject/edit): calls the corresponding runner + method and returns the Message directly (no trace persistence — HITL does + not currently emit trace events). + """ - NOTE: The graph invocation still proceeds (LangGraph will add the human message - to its internal checkpoint state). This is intentional — the graph needs to be - invoked to produce a response. The trade-off is that the LangGraph checkpoint - may accumulate duplicate human messages, but the DB projection remains clean. - """ - if not messages: - return False - last = messages[-1] - return ( - last.role == MessageRole.HUMAN - and last.content == message - and last.status is None - ) + def __init__(self, registry: AgentRegistry, threads: ThreadRepository, trace_repo: TraceEventRepository) -> None: + self._registry = registry + self._threads = threads + self._trace_repo = trace_repo async def execute( self, @@ -47,57 +45,70 @@ async def execute( *, message: str | None = None, action: str | None = None, - tool_call_id: str | None = None, - reason: str | None = None, - edits: dict[str, Any] | None = None, + tool_call_id: str | None = None, # noqa: ARG002 + reason: str | None = None, # noqa: ARG002 + edits: dict[str, Any] | None = None, # noqa: ARG002 ) -> Message: + """Execute the use case. + + Args: + thread_id: Conversation thread identifier. + message: Human message text (mutually exclusive with HITL fields). + action: HITL action ("approve", "reject", "edit"). + tool_call_id: Tool call id targeted by the HITL decision. + reason: Optional reject reason. + edits: Edited args for the "edit" action. + + Returns: + The final AI Message. + + Raises: + InvalidHitlActionError: If ``action`` is not a supported HITL action. + AgentError: On runner failure. + ThreadNotFoundError: If the thread does not exist. + """ + # Validate HITL action name up-front to keep the 422 contract intact. + if message is None: + match action: + case "approve" | "reject" | "edit": + pass + case _: + raise InvalidHitlActionError(ErrorMessage.INVALID_HITL_ACTION.format(action=action)) + thread = await self._threads.get(thread_id) runner = await self._registry.get_runner(thread.agent_name) if message is not None: logger.info(LogMessage.CHAT_SENDING_HUMAN, thread_id, thread.agent_name) - if not self._is_duplicate_human_message(thread.messages, message): - human_msg = Message(role=MessageRole.HUMAN, content=message) - await self._threads.add_message(thread_id, human_msg) - else: - logger.info(LogMessage.CHAT_SKIP_DUPLICATE_HUMAN, thread_id) + turn_id = str(uuid.uuid4()) start = time.monotonic() - response = await runner.invoke(thread_id, message) + final_message, trace = await runner.invoke(thread_id, message, turn_id) + # Persist all trace events of the turn in a single batch. + await self._trace_repo.add_batch(thread_id, trace) elapsed = time.monotonic() - start logger.info( LogMessage.CHAT_INVOKE_COMPLETE, thread_id, thread.agent_name, elapsed, - response.status, - len(response.content or ""), - ) - else: - logger.info( - LogMessage.CHAT_HITL_RECEIVED, - thread_id, - thread.agent_name, - action, - tool_call_id, - ) - start = time.monotonic() - match action: - case "approve": - response = await runner.approve_hitl(thread_id, tool_call_id) - case "reject": - response = await runner.reject_hitl(thread_id, tool_call_id, reason) - case "edit": - response = await runner.edit_hitl(thread_id, tool_call_id, edits) - case _: - raise InvalidHitlActionError(ErrorMessage.INVALID_HITL_ACTION.format(action=action)) - elapsed = time.monotonic() - start - logger.info( - LogMessage.CHAT_HITL_COMPLETE, - thread_id, - thread.agent_name, - elapsed, - response.status, + final_message.status, + len(final_message.content or ""), ) + return final_message - await self._threads.add_message(thread_id, response) + # HITL path — returns the runner Message directly, no trace persistence. + logger.info(LogMessage.CHAT_HITL_RECEIVED, thread_id, thread.agent_name, action, tool_call_id) + start = time.monotonic() + match action: + case "approve": + response = await runner.approve_hitl(thread_id, tool_call_id) # type: ignore[arg-type] + case "reject": + response = await runner.reject_hitl(thread_id, tool_call_id, reason) # type: ignore[arg-type] + case "edit": + response = await runner.edit_hitl(thread_id, tool_call_id, edits) # type: ignore[arg-type] + case _: + # Defensive — already validated above, but keeps mypy happy. + raise InvalidHitlActionError(ErrorMessage.INVALID_HITL_ACTION.format(action=action)) + elapsed = time.monotonic() - start + logger.info(LogMessage.CHAT_HITL_COMPLETE, thread_id, thread.agent_name, elapsed, response.status) return response diff --git a/src/application/use_cases/stream_message.py b/src/application/use_cases/stream_message.py index 4f47433..29bf2af 100644 --- a/src/application/use_cases/stream_message.py +++ b/src/application/use_cases/stream_message.py @@ -1,91 +1,69 @@ -import json +"""StreamMessageUseCase — stream all TraceEvents of a turn and persist each one. + +Ticket 3 rewrite: the use case depends on TraceEventRepository + the new runner +API ``stream(thread_id, message, turn_id) -> AsyncIterator[TraceEvent]``. Each +emitted event is persisted via ``trace_repo.add`` before being yielded to the +HTTP/WebSocket layer. +""" + import logging -import time +import uuid from collections.abc import AsyncGenerator -from src.domain.entities.message import Message, MessageRole -from src.domain.entities.stream_event import StreamEvent, StreamEventType +from src.domain.entities.trace_event import TraceEvent from src.domain.errors.messages import ErrorMessage from src.domain.errors.storage import StorageError from src.domain.logging.messages import LogMessage from src.domain.ports.agent_registry import AgentRegistry from src.domain.ports.thread_repository import ThreadRepository +from src.domain.ports.trace_event_repository import TraceEventRepository logger = logging.getLogger(__name__) class StreamMessageUseCase: - """Envoie un message a l'agent et streame la reponse avec le Message final.""" + """Stream all TraceEvents of a turn and persist each one. + + Each event emitted by the runner is persisted via ``trace_repo.add`` before + being yielded, so the trace is durably stored even if the client disconnects + mid-stream. + """ - def __init__(self, registry: AgentRegistry, threads: ThreadRepository) -> None: + def __init__(self, registry: AgentRegistry, threads: ThreadRepository, trace_repo: TraceEventRepository) -> None: self._registry = registry self._threads = threads + self._trace_repo = trace_repo - @staticmethod - def _is_duplicate_human_message(messages: list, message: str) -> bool: - """Detect duplicate HUMAN message submissions (crash/retry scenario). + async def execute(self, thread_id: str, message: str) -> AsyncGenerator[TraceEvent, None]: + """Execute the use case. - When a stream crashes before the AI response is persisted, the last DB message - is HUMAN with status=None. On client retry, this check prevents storing a - duplicate HUMAN message in the DB. + Args: + thread_id: Conversation thread identifier. + message: Human message text. - NOTE: The graph invocation still proceeds (LangGraph will add the human message - to its internal checkpoint state). This is intentional — the graph needs to be - invoked to produce a response. The trade-off is that the LangGraph checkpoint - may accumulate duplicate human messages, but the DB projection remains clean. - """ - if not messages: - return False - last = messages[-1] - return ( - last.role == MessageRole.HUMAN - and last.content == message - and last.status is None - ) + Yields: + Each :class:`TraceEvent` emitted by the runner (HUMAN_MESSAGE, + intermediates, then AI_MESSAGE), in turn order. - async def execute(self, thread_id: str, message: str) -> AsyncGenerator[StreamEvent, None]: + Raises: + ThreadNotFoundError: If the thread does not exist. + AgentError: On runner failure. + StorageError: If persisting an event fails. + """ thread = await self._threads.get(thread_id) - if not self._is_duplicate_human_message(thread.messages, message): - human_msg = Message(role=MessageRole.HUMAN, content=message) - await self._threads.add_message(thread_id, human_msg) - else: - logger.info(LogMessage.CHAT_SKIP_DUPLICATE_HUMAN, thread_id) runner = await self._registry.get_runner(thread.agent_name) - start = time.monotonic() + turn_id = str(uuid.uuid4()) logger.info(LogMessage.CHAT_STREAM_STARTED, thread_id, thread.agent_name) chunk_count = 0 - final_message = None try: - async for event in runner.stream_with_message(thread_id, message): - if event.type in (StreamEventType.THINKING, StreamEventType.CONTENT): - chunk_count += 1 - yield event - elif event.type == StreamEventType.MESSAGE: - final_message = Message.model_validate_json(event.data) - if final_message and final_message.structured_response is not None: - event = StreamEvent( - type=StreamEventType.STRUCTURED, - data=json.dumps(final_message.structured_response) - ) - yield event + async for event in runner.stream(thread_id, message, turn_id): + try: + await self._trace_repo.add(thread_id, event) + except Exception as exc: + logger.exception(LogMessage.CHAT_STREAM_PERSIST_FAILED, thread_id, thread.agent_name) + raise StorageError(ErrorMessage.STORAGE_FAILED_PERSIST_STREAM.format(error=exc)) from exc + chunk_count += 1 + yield event except Exception: - logger.exception( - LogMessage.CHAT_STREAM_ERROR_UC, thread_id, thread.agent_name, chunk_count - ) + logger.exception(LogMessage.CHAT_STREAM_ERROR_UC, thread_id, thread.agent_name, chunk_count) raise - elapsed = time.monotonic() - start - if final_message is not None: - try: - await self._threads.add_message(thread_id, final_message) - logger.info( - LogMessage.CHAT_STREAM_COMPLETE_PERSISTED, - thread_id, - thread.agent_name, - chunk_count, - elapsed, - ) - except Exception as exc: - logger.exception( - LogMessage.CHAT_STREAM_PERSIST_FAILED, thread_id, thread.agent_name - ) - raise StorageError(ErrorMessage.STORAGE_FAILED_PERSIST_STREAM.format(error=exc)) from exc diff --git a/src/application/use_cases/update_agent_config.py b/src/application/use_cases/update_agent_config.py index 46ce850..ec930c2 100644 --- a/src/application/use_cases/update_agent_config.py +++ b/src/application/use_cases/update_agent_config.py @@ -47,9 +47,7 @@ async def execute(self, name: str, yaml_content: str) -> AgentConfig: config = self._config_loader.load_from_string(yaml_content) if config.name != name: - raise ConfigError( - ErrorMessage.AGENT_NAME_MISMATCH_URL.format(yaml_name=config.name, name=name) - ) + raise ConfigError(ErrorMessage.AGENT_NAME_MISMATCH_URL.format(yaml_name=config.name, name=name)) await self._config_store.put(name, yaml_content) diff --git a/src/config.py b/src/config.py index 64b1803..3986d80 100644 --- a/src/config.py +++ b/src/config.py @@ -40,7 +40,9 @@ class Settings(BaseSettings): minio_bucket: str = "composable-agents" minio_secure: bool = False - database_url: str = Field(description="PostgreSQL connection string (postgresql://, postgres://, or postgresql+asyncpg://). Required.") + database_url: str = Field( + description="PostgreSQL connection string (postgresql://, postgres://, or postgresql+asyncpg://). Required." + ) postgres_statement_cache_size: int | None = None _ssl_mode: str | None = PrivateAttr(default=None) diff --git a/src/dependencies.py b/src/dependencies.py index 2655929..8755eba 100644 --- a/src/dependencies.py +++ b/src/dependencies.py @@ -14,6 +14,7 @@ from src.application.use_cases.get_agent_config import GetAgentConfigUseCase from src.application.use_cases.get_prompt import GetPromptContentUseCase, GetPromptUseCase from src.application.use_cases.get_thread import GetThreadUseCase +from src.application.use_cases.get_thread_history import GetThreadHistoryUseCase from src.application.use_cases.list_agent_configs import ListAgentConfigsUseCase from src.application.use_cases.list_threads import ListThreadsUseCase from src.application.use_cases.load_agent_config import LoadAgentConfigUseCase @@ -28,12 +29,14 @@ from src.domain.ports.agent_registry import AgentRegistry from src.domain.ports.prompt_manager import PromptManager from src.domain.ports.thread_repository import ThreadRepository +from src.domain.ports.trace_event_repository import TraceEventRepository from src.domain.ports.tracing_provider import TracingProvider from src.infrastructure.mcp.adapter import LangchainMcpToolLoader from src.infrastructure.minio_store.adapter import MinioAgentConfigStore from src.infrastructure.persistent_registry.adapter import PersistentAgentRegistry from src.infrastructure.postgres_repository.adapter import PostgresAgentConfigRepository from src.infrastructure.postgres_thread.adapter import PostgresThreadRepository +from src.infrastructure.postgres_trace.adapter import PostgresTraceEventRepository from src.infrastructure.prompt_management.adapter import PhoenixPromptManagerProvider from src.infrastructure.tracing.noop_adapter import NoopTracingProvider from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader @@ -110,6 +113,7 @@ def get_security() -> ComposableAgentsSecurity: """ return security + # ============= PERSISTENCE (initialized at startup) ============= @@ -122,6 +126,7 @@ class CompositionRoot: pg_repository: PostgresAgentConfigRepository | None = None agent_registry: AgentRegistry | None = None thread_repository: ThreadRepository | None = None + trace_event_repository: TraceEventRepository | None = None _root = CompositionRoot() @@ -166,6 +171,7 @@ async def init_persistence() -> None: _root.pg_repository = PostgresAgentConfigRepository(engine=_root.async_engine) _root.thread_repository = PostgresThreadRepository(engine=_root.async_engine) + _root.trace_event_repository = PostgresTraceEventRepository(engine=_root.async_engine) logger.info(LogMessage.POSTGRES_REPOS_INITIALIZED) minio_client = Minio( @@ -213,6 +219,7 @@ def reset() -> None: _root.pg_repository = None _root.agent_registry = None _root.thread_repository = None + _root.trace_event_repository = None logger.info(LogMessage.DEPENDENCIES_INITIALIZED) @@ -228,6 +235,18 @@ def _require_thread_repository() -> ThreadRepository: return _root.thread_repository +def _require_trace_event_repository() -> TraceEventRepository: + """Return trace event repository or raise StorageError if not initialized.""" + if _root.trace_event_repository is None: + raise StorageError(ErrorMessage.STORAGE_REPO_NOT_INITIALIZED) + return _root.trace_event_repository + + +def get_trace_event_repository() -> TraceEventRepository: + """Provide a TraceEventRepository instance (singleton wired at startup).""" + return _require_trace_event_repository() + + def _require_agent_registry() -> AgentRegistry: """Return agent registry or raise StorageError if not initialized.""" if _root.agent_registry is None: @@ -237,12 +256,21 @@ def _require_agent_registry() -> AgentRegistry: def get_send_message_use_case() -> SendMessageUseCase: """Provide a SendMessageUseCase instance.""" - return SendMessageUseCase(_require_agent_registry(), _require_thread_repository()) + return SendMessageUseCase( + _require_agent_registry(), _require_thread_repository(), _require_trace_event_repository() + ) def get_stream_message_use_case() -> StreamMessageUseCase: """Provide a StreamMessageUseCase instance.""" - return StreamMessageUseCase(_require_agent_registry(), _require_thread_repository()) + return StreamMessageUseCase( + _require_agent_registry(), _require_thread_repository(), _require_trace_event_repository() + ) + + +def get_get_thread_history_use_case() -> GetThreadHistoryUseCase: + """Provide a GetThreadHistoryUseCase instance.""" + return GetThreadHistoryUseCase(_require_thread_repository(), _require_trace_event_repository()) def get_create_thread_use_case() -> CreateThreadUseCase: diff --git a/src/domain/entities/message.py b/src/domain/entities/message.py index 7143166..7f1c55a 100644 --- a/src/domain/entities/message.py +++ b/src/domain/entities/message.py @@ -1,8 +1,22 @@ +"""Message domain entity. + +A Message is a backward-compatible projection of HUMAN_MESSAGE and AI_MESSAGE +:class:`~src.domain.entities.trace_event.TraceEvent` records. It is no longer the +primary persistence unit — ``trace_events`` is the single source of truth. +""" + +import json from datetime import UTC, datetime from enum import StrEnum +from typing import TYPE_CHECKING from pydantic import BaseModel, Field +from src.domain.errors.thread import MessageBuildError + +if TYPE_CHECKING: + from src.domain.entities.trace_event import TraceEvent + class MessageRole(StrEnum): HUMAN = "human" @@ -17,6 +31,8 @@ class MessageStatus(StrEnum): class Message(BaseModel, frozen=True): + """An immutable conversation message (projection of TraceEvent).""" + role: MessageRole content: str | None = None timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC)) @@ -24,3 +40,48 @@ class Message(BaseModel, frozen=True): status: MessageStatus | None = None structured_response: dict | None = None thinking: str | None = None + turn_id: str | None = None + + @staticmethod + def from_trace_event(event: "TraceEvent") -> "Message": + """Reconstruct a Message from a HUMAN_MESSAGE or AI_MESSAGE trace event. + + Args: + event: A TraceEvent of type HUMAN_MESSAGE or AI_MESSAGE. + + Returns: + A Message projection of the event. + + Raises: + ValueError: If the event type is not HUMAN_MESSAGE or AI_MESSAGE, or + if an AI_MESSAGE event's content is not valid JSON. + """ + from src.domain.entities.trace_event import TraceEventType + + if event.type == TraceEventType.HUMAN_MESSAGE: + return Message( + role=MessageRole.HUMAN, + content=event.content, + timestamp=event.timestamp, + turn_id=event.turn_id, + ) + + if event.type == TraceEventType.AI_MESSAGE: + payload: dict = {} + if event.content: + payload = json.loads(event.content) + status_value = payload.get("status") + return Message( + role=MessageRole.AI, + content=payload.get("content"), + timestamp=event.timestamp, + tool_calls=payload.get("tool_calls"), + status=MessageStatus(status_value) if status_value else None, + structured_response=payload.get("structured_response"), + thinking=payload.get("thinking"), + turn_id=event.turn_id, + ) + + raise MessageBuildError( + f"Cannot build Message from trace event type {event.type!r}" + ) diff --git a/src/domain/entities/stream_event.py b/src/domain/entities/stream_event.py deleted file mode 100644 index 1e5e696..0000000 --- a/src/domain/entities/stream_event.py +++ /dev/null @@ -1,16 +0,0 @@ -from enum import StrEnum - -from pydantic import BaseModel - - -class StreamEventType(StrEnum): - THINKING = "thinking" - CONTENT = "content" - MESSAGE = "message" - STRUCTURED = "structured" - ERROR = "error" - - -class StreamEvent(BaseModel, frozen=True): - type: StreamEventType - data: str diff --git a/src/domain/entities/thread.py b/src/domain/entities/thread.py index 684638f..6984312 100644 --- a/src/domain/entities/thread.py +++ b/src/domain/entities/thread.py @@ -1,14 +1,38 @@ +"""Thread domain entity. + +A Thread groups all :class:`~src.domain.entities.trace_event.TraceEvent` records +for a conversation. The legacy ``messages`` field is now a backward-compatible +computed projection rebuilt from HUMAN_MESSAGE + AI_MESSAGE trace events. +""" + import uuid from datetime import UTC, datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, computed_field from src.domain.entities.message import Message +from src.domain.entities.trace_event import TraceEvent, TraceEventType class Thread(BaseModel): + """A conversation thread backed by trace events.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4())) agent_name: str - messages: list[Message] = Field(default_factory=list) + trace_events: list[TraceEvent] = Field(default_factory=list) created_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) updated_at: datetime = Field(default_factory=lambda: datetime.now(UTC)) + + @computed_field # type: ignore[prop-decorator] + @property + def messages(self) -> list[Message]: + """Backward-compatible message list, projected from trace_events. + + Filters HUMAN_MESSAGE + AI_MESSAGE events and rebuilds Message objects + via :meth:`Message.from_trace_event`, ordered by timestamp. + """ + message_events = sorted( + (e for e in self.trace_events if e.type in (TraceEventType.HUMAN_MESSAGE, TraceEventType.AI_MESSAGE)), + key=lambda e: (e.timestamp, e.sequence), + ) + return [Message.from_trace_event(e) for e in message_events] diff --git a/src/domain/entities/trace_event.py b/src/domain/entities/trace_event.py new file mode 100644 index 0000000..cf6f2ad --- /dev/null +++ b/src/domain/entities/trace_event.py @@ -0,0 +1,55 @@ +"""TraceEvent domain entity. + +A TraceEvent is an immutable record of anything that happened during a conversation +turn inside a thread: a human message, an AI message, a thinking chunk, a content +chunk, a tool call or a tool result. The persistence layer stores every event in a +single ``trace_events`` table; ``Message`` is now a backward-compatible projection +of the HUMAN_MESSAGE + AI_MESSAGE events. +""" + +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel, Field + + +class TraceEventType(StrEnum): + """Type of trace event. + + Values are stored as plain strings in the database. + """ + + HUMAN_MESSAGE = "human_message" + AI_MESSAGE = "ai_message" + THINKING = "thinking" + CONTENT = "content" + TOOL_CALL = "tool_call" + TOOL_RESULT = "tool_result" + + +class TraceEvent(BaseModel, frozen=True): + """An immutable trace event belonging to a thread. + + Attributes: + id: UUID string identifying the event. + thread_id: Parent thread id. + turn_id: Identifier grouping events of a single conversation turn. + type: The :class:`TraceEventType` of this event. + source: Name of the subagent that produced this event (None = parent). + name: Tool name for TOOL_CALL / TOOL_RESULT events. + content: Text content or JSON-serialized payload. + metadata: Optional structured metadata. + timestamp: When the event occurred. + sequence: Monotonic sequence number inside a turn. + """ + + id: str + thread_id: str + turn_id: str + type: TraceEventType + source: str | None = None + name: str | None = None + content: str | None = None + metadata: dict | None = None + timestamp: datetime + sequence: int = Field(ge=0) diff --git a/src/domain/errors/messages.py b/src/domain/errors/messages.py index 34920a1..d2f4672 100644 --- a/src/domain/errors/messages.py +++ b/src/domain/errors/messages.py @@ -16,8 +16,7 @@ class ErrorMessage(StrEnum): # --- Configuration --- INVALID_AGENT_NAME = ( - "Invalid agent name '{name}'. Must match pattern: alphanumeric, " - "dots, hyphens, underscores, 2-100 chars." + "Invalid agent name '{name}'. Must match pattern: alphanumeric, dots, hyphens, underscores, 2-100 chars." ) FILE_TOO_LARGE = "File too large. Maximum size is {max_size} bytes." FILE_NOT_UTF8 = "File must be valid UTF-8 encoded YAML." @@ -59,12 +58,15 @@ class ErrorMessage(StrEnum): THREAD_FAILED_DELETE = "Failed to delete thread {thread_id}: {error}" THREAD_FAILED_ADD_MESSAGE = "Failed to add message to thread {thread_id}: {error}" + # --- Trace events --- + TRACE_FAILED_ADD = "Failed to add trace event to thread {thread_id}: {error}" + TRACE_FAILED_LIST = "Failed to list trace events for thread {thread_id}: {error}" + TRACE_FAILED_ADD_BATCH = "Failed to add batch trace events to thread {thread_id}: {error}" + # --- Storage / persistence --- STORAGE_REPO_NOT_INITIALIZED = "Thread repository not initialized. Check PostgreSQL connectivity." STORAGE_REGISTRY_NOT_INITIALIZED = "Agent registry not initialized. Check MinIO/PostgreSQL connectivity." - STORAGE_PERSISTENCE_NOT_INITIALIZED = ( - "Persistence layer not initialized. Check MinIO/PostgreSQL connectivity." - ) + STORAGE_PERSISTENCE_NOT_INITIALIZED = "Persistence layer not initialized. Check MinIO/PostgreSQL connectivity." STORAGE_FAILED_SAVE_AGENT_CONFIG = "Failed to save agent config metadata '{name}': {error}" STORAGE_FAILED_GET_AGENT_CONFIG = "Failed to get agent config metadata '{name}': {error}" STORAGE_FAILED_LIST_AGENT_CONFIG = "Failed to list agent config metadata: {error}" @@ -85,9 +87,7 @@ class ErrorMessage(StrEnum): PROMPT_NOT_FOUND = "Prompt not found: {identifier}" PROMPT_ALREADY_EXISTS = "Prompt already exists: {identifier}" PROMPT_MANAGER_UNAVAILABLE = "Prompt manager unavailable during '{operation}' for '{identifier}': {error}" - PROMPT_MANAGER_SERVER_ERROR = ( - "Prompt manager server error ({status_code}) during '{operation}' for '{identifier}'" - ) + PROMPT_MANAGER_SERVER_ERROR = "Prompt manager server error ({status_code}) during '{operation}' for '{identifier}'" # --- Security --- API_KEY_UNAUTHORIZED = "The Api Key you provided is unauthorized" diff --git a/src/domain/errors/security.py b/src/domain/errors/security.py index 382ea35..42d3248 100644 --- a/src/domain/errors/security.py +++ b/src/domain/errors/security.py @@ -1,11 +1,13 @@ from src.domain.errors.base import DomainError from src.domain.errors.codes import ErrorCode + class SecurityError(DomainError): """Base error for security concerns.""" status_code = ErrorCode.INTERNAL_SERVER_ERROR + class InvalidApiKeyError(SecurityError): """Error when api key sent by client is not matching""" diff --git a/src/domain/errors/thread.py b/src/domain/errors/thread.py index 86c5789..3f8ac34 100644 --- a/src/domain/errors/thread.py +++ b/src/domain/errors/thread.py @@ -8,3 +8,9 @@ class ThreadNotFoundError(DomainError): """Conversation thread not found.""" status_code = ErrorCode.NOT_FOUND + + +class MessageBuildError(DomainError): + """Cannot build a Message from an incompatible trace event.""" + + status_code = ErrorCode.INTERNAL_SERVER_ERROR diff --git a/src/domain/logging/messages.py b/src/domain/logging/messages.py index d28f9ee..a2efc28 100644 --- a/src/domain/logging/messages.py +++ b/src/domain/logging/messages.py @@ -10,7 +10,6 @@ stdlib logging lazy interpolation. """ - from enum import StrEnum @@ -39,7 +38,9 @@ class LogMessage(StrEnum): TRACING_PHOENIX_INIT = "Initializing Phoenix tracing provider (endpoint=%s)" TRACING_DISABLED = "Tracing disabled, using NoopTracingProvider" PERSISTENCE_INITIALIZING = "Initializing persistence layer" - SQLALCHEMY_ENGINE_CREATED = "SQLAlchemy async engine created (pool: AsyncAdaptedQueuePool, size=20, max_overflow=20)" + SQLALCHEMY_ENGINE_CREATED = ( + "SQLAlchemy async engine created (pool: AsyncAdaptedQueuePool, size=20, max_overflow=20)" + ) POSTGRES_REPOS_INITIALIZED = "PostgreSQL repositories initialized" MINIO_STORE_INITIALIZED = "MinIO store initialized (bucket=%s)" PERSISTENCE_REGISTRY_SET = "Persistence layer initialized, agent_registry set to PersistentAgentRegistry" @@ -139,7 +140,9 @@ class LogMessage(StrEnum): CHAT_HITL_COMPLETE = "[thread=%s][agent=%s] HITL elapsed=%.2fs, status=%s" CHAT_STREAM_STARTED = "[thread=%s][agent=%s] Stream started" CHAT_STREAM_ERROR_UC = "[thread=%s][agent=%s] Stream error after %d chunks" - CHAT_STREAM_COMPLETE_PERSISTED = "[thread=%s][agent=%s] Stream complete, %d chunks, elapsed=%.2fs, message=persisted" + CHAT_STREAM_COMPLETE_PERSISTED = ( + "[thread=%s][agent=%s] Stream complete, %d chunks, elapsed=%.2fs, message=persisted" + ) # --- DeepAgent runner lifecycle --- AGENT_INVOKING = "[thread=%s] Invoking agent" @@ -169,6 +172,7 @@ class LogMessage(StrEnum): TOOLS_NODE_NO_BOUND = "'tools' node has no 'bound' attribute; cannot patch handle_tool_errors" TOOLNODE_PATCHED = "Patched ToolNode handle_tool_errors=True" TOOLNODE_PATCH_MISSING_ATTR = "ToolNode bound object missing _handle_tool_errors; patch not applied" + STRUCTURED_RESPONSE_MISSING = "Structured response missing despite response_format being configured" STRUCTURED_RESPONSE_VALIDATION_FAILED = "Failed to validate structured_response against schema, returning raw data" STRUCTURED_FIELD_STRIPPED = "Stripped extra field from structured_response: '%s'" STRUCTURED_NESTED_FIELD_STRIPPED = "Stripped extra nested field: '%s.%s'" diff --git a/src/domain/ports/agent_runner.py b/src/domain/ports/agent_runner.py index 2d09742..ab9c4dc 100644 --- a/src/domain/ports/agent_runner.py +++ b/src/domain/ports/agent_runner.py @@ -1,19 +1,54 @@ +"""Outbound port: AgentRunner. + +The runner is the LLM/graph boundary. It captures a full conversation turn as +a stream of :class:`~src.domain.entities.trace_event.TraceEvent` records: +HUMAN_MESSAGE first, intermediate events (THINKING/CONTENT/TOOL_CALL/ +TOOL_RESULT), then a final AI_MESSAGE. +""" + from abc import ABC, abstractmethod from collections.abc import AsyncIterator from src.domain.entities.message import Message -from src.domain.entities.stream_event import StreamEvent +from src.domain.entities.trace_event import TraceEvent class AgentRunner(ABC): - @abstractmethod - async def invoke(self, thread_id: str, message: str) -> Message: ... + """Outbound port implemented by the deep-agent infrastructure adapter.""" @abstractmethod - async def stream(self, thread_id: str, message: str) -> AsyncIterator[StreamEvent]: ... + async def invoke(self, thread_id: str, message: str, turn_id: str) -> tuple[Message, list[TraceEvent]]: + """Invoke the agent and return the final Message + the full trace of events. + + Args: + thread_id: The conversation thread identifier. + message: The human message text to send to the agent. + turn_id: Identifier grouping all events of this turn. + + Returns: + A tuple ``(final_message, trace_events)`` where ``final_message`` is the + AI :class:`Message` reconstructed from the trailing AI_MESSAGE event, + and ``trace_events`` is the full ordered list of TraceEvents. + """ + ... @abstractmethod - async def stream_with_message(self, thread_id: str, message: str) -> AsyncIterator[StreamEvent]: ... + def stream(self, thread_id: str, message: str, turn_id: str) -> AsyncIterator[TraceEvent]: + """Stream all TraceEvents of a turn. + + Emits HUMAN_MESSAGE first, intermediate events (THINKING, CONTENT, + TOOL_CALL, TOOL_RESULT) as they arrive from the graph, then AI_MESSAGE + as the trailing event. + + Args: + thread_id: The conversation thread identifier. + message: The human message text to send to the agent. + turn_id: Identifier grouping all events of this turn. + + Yields: + TraceEvent instances in turn order. + """ + ... @abstractmethod async def approve_hitl(self, thread_id: str, tool_call_id: str) -> Message: ... diff --git a/src/domain/ports/thread_repository.py b/src/domain/ports/thread_repository.py index 14e0c37..954abc6 100644 --- a/src/domain/ports/thread_repository.py +++ b/src/domain/ports/thread_repository.py @@ -1,6 +1,5 @@ from abc import ABC, abstractmethod -from src.domain.entities.message import Message from src.domain.entities.thread import Thread @@ -24,8 +23,3 @@ async def list_all(self) -> list[Thread]: async def delete(self, thread_id: str) -> None: """Supprime un thread.""" ... - - @abstractmethod - async def add_message(self, thread_id: str, message: Message) -> Thread: - """Ajoute un message a un thread existant.""" - ... diff --git a/src/domain/ports/trace_event_repository.py b/src/domain/ports/trace_event_repository.py new file mode 100644 index 0000000..95a62f3 --- /dev/null +++ b/src/domain/ports/trace_event_repository.py @@ -0,0 +1,83 @@ +"""Outbound port: TraceEventRepository. + +Persistence boundary for :class:`~src.domain.entities.trace_event.TraceEvent`. +The repository is the single source of truth for everything that happened inside +a thread; ``Message`` projections are rebuilt from the HUMAN_MESSAGE + AI_MESSAGE +events. +""" + +from abc import ABC, abstractmethod + +from src.domain.entities.trace_event import TraceEvent + + +class TraceEventRepository(ABC): + """Outbound port for persisting and retrieving trace events.""" + + @abstractmethod + async def add(self, thread_id: str, event: TraceEvent) -> None: + """Persist a single trace event. + + Args: + thread_id: Parent thread id. + event: The trace event to persist. + + Raises: + ThreadNotFoundError: If the thread does not exist. + StorageError: On infrastructure failure. + """ + ... + + @abstractmethod + async def add_batch(self, thread_id: str, events: list[TraceEvent]) -> None: + """Persist a batch of trace events atomically. + + Args: + thread_id: Parent thread id. + events: The trace events to persist. + + Raises: + ThreadNotFoundError: If the thread does not exist. + StorageError: On infrastructure failure. + """ + ... + + @abstractmethod + async def list_by_thread(self, thread_id: str) -> list[TraceEvent]: + """List all trace events for a thread, ordered by timestamp. + + Args: + thread_id: Parent thread id. + + Returns: + A list of TraceEvent ordered by timestamp (oldest first). + """ + ... + + @abstractmethod + async def list_by_turn(self, thread_id: str, turn_id: str) -> list[TraceEvent]: + """List trace events for a specific turn of a thread. + + Args: + thread_id: Parent thread id. + turn_id: Turn identifier. + + Returns: + A list of TraceEvent ordered by timestamp. + """ + ... + + @abstractmethod + async def list_messages(self, thread_id: str) -> list[TraceEvent]: + """List only HUMAN_MESSAGE + AI_MESSAGE events for a thread. + + Used to rebuild the backward-compatible ``Message`` projection. + + Args: + thread_id: Parent thread id. + + Returns: + A list of TraceEvent filtered to HUMAN_MESSAGE + AI_MESSAGE, + ordered by timestamp (oldest first). + """ + ... diff --git a/src/infrastructure/database/models/thread.py b/src/infrastructure/database/models/thread.py index c848403..27dd800 100644 --- a/src/infrastructure/database/models/thread.py +++ b/src/infrastructure/database/models/thread.py @@ -1,10 +1,14 @@ from datetime import datetime +from typing import TYPE_CHECKING -from sqlalchemy import JSON, DateTime, ForeignKey, String, Text +from sqlalchemy import DateTime, String from sqlalchemy.orm import Mapped, mapped_column, relationship from src.infrastructure.database.models.base import Base +if TYPE_CHECKING: + from src.infrastructure.database.models.trace_event import TraceEventModel + class ThreadModel(Base): __tablename__ = "threads" @@ -14,26 +18,12 @@ class ThreadModel(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - messages: Mapped[list["MessageModel"]] = relationship( - "MessageModel", + # lazy="raise" prevents silent N+1 queries. Always load trace_events + # explicitly via trace_repo.list_by_thread(thread_id) or selectinload. + trace_events: Mapped[list["TraceEventModel"]] = relationship( + "TraceEventModel", back_populates="thread", cascade="all, delete-orphan", - order_by="MessageModel.timestamp", + order_by="TraceEventModel.timestamp", lazy="raise", ) - - -class MessageModel(Base): - __tablename__ = "messages" - - id: Mapped[str] = mapped_column(String(36), primary_key=True) - thread_id: Mapped[str] = mapped_column(String(36), ForeignKey("threads.id", ondelete="CASCADE"), nullable=False) - role: Mapped[str] = mapped_column(String(20), nullable=False) - content: Mapped[str | None] = mapped_column(Text, nullable=True) - timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - tool_calls: Mapped[list[dict] | None] = mapped_column(JSON, nullable=True) - status: Mapped[str | None] = mapped_column(String(50), nullable=True) - structured_response: Mapped[dict | None] = mapped_column(JSON, nullable=True) - thinking: Mapped[str | None] = mapped_column(Text, nullable=True) - - thread: Mapped["ThreadModel"] = relationship("ThreadModel", back_populates="messages") diff --git a/src/infrastructure/database/models/trace_event.py b/src/infrastructure/database/models/trace_event.py new file mode 100644 index 0000000..34ba2a6 --- /dev/null +++ b/src/infrastructure/database/models/trace_event.py @@ -0,0 +1,40 @@ +"""SQLAlchemy ORM model for the trace_events table.""" + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import JSON, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from src.infrastructure.database.models.base import Base + +if TYPE_CHECKING: + from src.infrastructure.database.models.thread import ThreadModel + + +class TraceEventModel(Base): + """ORM model for a single trace event row. + + A trace event records any atomic occurrence during a conversation turn + (human message, AI message, thinking chunk, content chunk, tool call, + tool result). The ``type`` column discriminates the event kind. + """ + + __tablename__ = "trace_events" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + thread_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("threads.id", ondelete="CASCADE"), + nullable=False, + ) + turn_id: Mapped[str] = mapped_column(String(36), nullable=False) + type: Mapped[str] = mapped_column(String(30), nullable=False) + source: Mapped[str | None] = mapped_column(String(100), nullable=True) + name: Mapped[str | None] = mapped_column(String(200), nullable=True) + content: Mapped[str | None] = mapped_column(Text, nullable=True) + event_metadata: Mapped[dict | None] = mapped_column("metadata", JSON, nullable=True) + timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + sequence: Mapped[int] = mapped_column(Integer, nullable=False) + + thread: Mapped["ThreadModel"] = relationship("ThreadModel", back_populates="trace_events") diff --git a/src/infrastructure/deepagent/adapter.py b/src/infrastructure/deepagent/adapter.py index 563bf20..687c9d8 100644 --- a/src/infrastructure/deepagent/adapter.py +++ b/src/infrastructure/deepagent/adapter.py @@ -1,17 +1,40 @@ +"""DeepAgentRunner: TraceEvent-based capture for the deepagents graph. + +The runner streams a full conversation turn as a sequence of +:class:`~src.domain.entities.trace_event.TraceEvent` records: + + 1. ``HUMAN_MESSAGE`` — emitted immediately with the input text. + 2. Intermediate events — ``THINKING``, ``CONTENT``, ``TOOL_CALL``, + ``TOOL_RESULT`` as the graph streams chunks. + 3. ``AI_MESSAGE`` — emitted last, with the final ``Message`` payload + (content, status, structured_response, thinking). + +The ``invoke`` method materializes the whole trace and returns the final +``Message`` alongside the event list. ``stream`` yields each event as it is +produced. +""" + import asyncio import contextlib import json import logging -import re import time +import uuid from collections.abc import AsyncIterator +from datetime import UTC, datetime +from langchain_core.messages import ToolMessage + +try: + from langgraph._internal._constants import NS_SEP +except ImportError: + NS_SEP = "|" from langgraph.graph.state import CompiledStateGraph from langgraph.types import Command from pydantic import BaseModel from src.domain.entities.message import Message, MessageRole, MessageStatus -from src.domain.entities.stream_event import StreamEvent, StreamEventType +from src.domain.entities.trace_event import TraceEvent, TraceEventType from src.domain.errors.agent import AgentError from src.domain.errors.messages import ErrorMessage from src.domain.logging.messages import LogMessage @@ -21,7 +44,14 @@ logger = logging.getLogger(__name__) +# Tuple produced by ``_classify`` and consumed by ``_collect_trace``. +# (type, name, content, metadata) +ClassifiedEvent = tuple[TraceEventType, str | None, str | None, dict | None] + + class DeepAgentRunner(AgentRunner): + """Adapter that turns a deepagents CompiledStateGraph into TraceEvents.""" + def __init__( self, graph: CompiledStateGraph, @@ -33,23 +63,27 @@ def __init__( self._graph = graph self._tracing_provider = tracing_provider self._response_format_model = response_format_model - # Max idle window (s) between streamed chunks before the graph is considered - # stuck (e.g. a tool result was lost) and aborted. Max wall time (s) for a - # non-streaming invoke/HITL call. + # Max idle window (s) between streamed chunks before the graph is + # considered stuck (e.g. a tool result was lost) and aborted. Max wall + # time (s) for a non-streaming invoke/HITL call. self._stream_idle_timeout = stream_idle_timeout self._invoke_timeout = invoke_timeout self._patch_tool_node_error_handling() + # ------------------------------------------------------------------ # + # Construction helpers + # ------------------------------------------------------------------ # + def _patch_tool_node_error_handling(self) -> None: """Patch ToolNode to catch all tool errors (not just ToolInvocationError). - By default, LangGraph's ToolNode only catches ToolInvocationError, which means - Pydantic ValidationError from hallucinated parameters crashes the graph. Setting - _handle_tool_errors=True causes any exception to be surfaced as a ToolMessage, - allowing the LLM to self-correct. + By default, LangGraph's ToolNode only catches ToolInvocationError, which + means Pydantic ValidationError from hallucinated parameters crashes the + graph. Setting ``_handle_tool_errors=True`` causes any exception to be + surfaced as a ToolMessage, allowing the LLM to self-correct. - TODO: Prefer configuring handle_tool_errors=True at ToolNode construction time - in the factory, rather than monkey-patching at runtime. + TODO: Prefer configuring handle_tool_errors=True at ToolNode construction + time in the factory, rather than monkey-patching at runtime. """ tools_node = self._graph.nodes.get("tools") if tools_node is None: @@ -65,25 +99,9 @@ def _patch_tool_node_error_handling(self) -> None: else: logger.warning(LogMessage.TOOLNODE_PATCH_MISSING_ATTR) - @staticmethod - def _try_parse_json(content: str) -> dict | None: - if not content: - return None - try: - parsed = json.loads(content) - if isinstance(parsed, dict): - return parsed - except (json.JSONDecodeError, TypeError): - pass - match = re.search(r"```(?:json)?\s*\n(.*?)\n```", content, re.DOTALL) - if match: - try: - parsed = json.loads(match.group(1)) - if isinstance(parsed, dict): - return parsed - except (json.JSONDecodeError, TypeError): - pass - return None + # ------------------------------------------------------------------ # + # Structured-response helpers (preserved from previous implementation) + # ------------------------------------------------------------------ # def _validate_structured_response(self, data: dict) -> dict: """Validate structured_response against the response_format model. @@ -91,7 +109,7 @@ def _validate_structured_response(self, data: dict) -> dict: Strips any extra fields not defined in the schema and logs warnings. """ try: - validated = self._response_format_model.model_validate(data) + validated = self._response_format_model.model_validate(data) # type: ignore[union-attr] cleaned = validated.model_dump() self._log_extra_fields(data, cleaned) return cleaned @@ -114,20 +132,6 @@ def _log_extra_fields(original: dict, cleaned: dict) -> None: def _is_nonblank_str(val: object) -> bool: return isinstance(val, str) and val.strip() != "" - @staticmethod - def _classify_chunk(chunk) -> tuple[StreamEventType, str] | None: - if chunk.type != "AIMessageChunk": - return None - additional = getattr(chunk, "additional_kwargs", {}) - reasoning = additional.get("reasoning_content") - if DeepAgentRunner._is_nonblank_str(reasoning): - return (StreamEventType.THINKING, reasoning) - if additional.get("type") == "thinking" and DeepAgentRunner._is_nonblank_str(chunk.content): - return (StreamEventType.THINKING, chunk.content) - if DeepAgentRunner._is_nonblank_str(chunk.content): - return (StreamEventType.CONTENT, chunk.content) - return None - def _build_config(self, thread_id: str) -> dict: config: dict = {"configurable": {"thread_id": thread_id}} if self._tracing_provider: @@ -136,30 +140,8 @@ def _build_config(self, thread_id: str) -> dict: config["callbacks"] = callbacks return config - def _extract_structured_response(self, messages: list) -> dict | None: - """Extract structured_response from tool_calls in messages.""" - if not messages: - return None - # Walk messages in reverse to find the most recent structured_response tool call - for msg in reversed(messages): - tool_calls = getattr(msg, "tool_calls", None) or [] - if isinstance(tool_calls, list): - for tc in tool_calls: - if tc.get("name") == "structured_response": - args = tc.get("args") - if args: - if isinstance(args, dict): - return args - if isinstance(args, str): - try: - parsed = json.loads(args) - if isinstance(parsed, dict): - return parsed - except (json.JSONDecodeError, TypeError): - pass - return None - def _build_response(self, result: dict, config: dict, thinking: str | None) -> Message: + """Build the final AI Message from the graph state.""" messages = result.get("messages", []) if not messages: raise AgentError(ErrorMessage.AGENT_NO_FINAL_MESSAGES) @@ -168,25 +150,20 @@ def _build_response(self, result: dict, config: dict, thinking: str | None) -> M state = self._graph.get_state(config) status = MessageStatus.AWAITING_HITL if state.interrupts else MessageStatus.COMPLETED - # 1. Try extracting structured_response from tool_calls (ToolStrategy mode) - structured_response = self._extract_structured_response(messages) + # 1. Native structured_response (ProviderStrategy/ToolStrategy native mode). + raw_structured = result.get("structured_response") + structured_response: dict | None = None + if hasattr(raw_structured, "model_dump"): + structured_response = raw_structured.model_dump() + elif isinstance(raw_structured, dict): + structured_response = raw_structured - # 2. Fallback to result structured_response (ProviderStrategy/ToolStrategy native mode) - if structured_response is None: - raw_structured = result.get("structured_response") - if raw_structured is not None: - if hasattr(raw_structured, "model_dump"): - structured_response = raw_structured.model_dump() - elif isinstance(raw_structured, dict): - structured_response = raw_structured - - # 3. Fallback to parsing the last message content as JSON - if structured_response is None: - structured_response = self._try_parse_json(last_message.content) - - # 4. Validate against response_format schema (strip extra fields) + # 2. Validate against response_format schema (strip extra fields). if structured_response is not None and self._response_format_model is not None: structured_response = self._validate_structured_response(structured_response) + elif structured_response is None and self._response_format_model is not None: + # 3. Warn when a model was configured but no structured_response was produced. + logger.warning(LogMessage.STRUCTURED_RESPONSE_MISSING) return Message( role=MessageRole.AI, @@ -197,121 +174,342 @@ def _build_response(self, result: dict, config: dict, thinking: str | None) -> M thinking=thinking, ) - async def invoke(self, thread_id: str, message: str) -> Message: - config = self._build_config(thread_id) - logger.info(LogMessage.AGENT_INVOKING, thread_id) - logger.info(LogMessage.AGENT_MESSAGE, thread_id, message[:200]) - try: - start = time.monotonic() - result = await asyncio.wait_for( - self._graph.ainvoke( - {"messages": [{"role": "human", "content": message}]}, - config=config, - ), - timeout=self._invoke_timeout, - ) - elapsed = time.monotonic() - start - response = self._build_response(result, config, None) - logger.info(LogMessage.AGENT_INVOKE_COMPLETE, thread_id, response.status, elapsed) - return response - except TimeoutError as e: - logger.error(LogMessage.AGENT_INVOKE_TIMEOUT, thread_id, self._invoke_timeout) - raise AgentError( - ErrorMessage.AGENT_INVOKE_TIMEOUT.format(thread_id=thread_id, timeout=self._invoke_timeout) - ) from e - except Exception as e: - logger.exception(LogMessage.AGENT_EXECUTION_ERROR_LOG, thread_id) - raise AgentError(ErrorMessage.AGENT_EXECUTION_ERROR.format(error=e)) from e + # ------------------------------------------------------------------ # + # TraceEvent classification helpers + # ------------------------------------------------------------------ # + + @staticmethod + def _extract_source(metadata: dict) -> str | None: + """Extract subagent name from ``langgraph_checkpoint_ns``. + + The ``langgraph_checkpoint_ns`` value uses ``NS_SEP`` ("|") as a + separator and looks like ``"Agent:task:security-auditor:tools"`` for + subagent events. We look for the ``"task"`` token and return the part + that follows it. - async def _yield_chunks( - self, thread_id: str, message: str, config: dict, stats: dict - ) -> AsyncIterator[StreamEvent]: - start = time.monotonic() - first_chunk = True - chunk_count = 0 + Args: + metadata: LangGraph stream metadata dict. + + Returns: + The subagent name, or ``None`` for parent-agent events / missing ns. + """ + ns = metadata.get("langgraph_checkpoint_ns", "") + if not ns: + return None + parts = ns.split(NS_SEP) + if "task" in parts: + idx = parts.index("task") + if idx + 1 < len(parts): + return parts[idx + 1] + return None + + @staticmethod + def _classify_thinking(additional: dict, chunk) -> tuple[ClassifiedEvent | None, bool]: + """Classify thinking chunks. Returns (event, is_thinking).""" + reasoning = additional.get("reasoning_content") + if DeepAgentRunner._is_nonblank_str(reasoning): + return (TraceEventType.THINKING, None, reasoning, None), True + if additional.get("type") == "thinking" and DeepAgentRunner._is_nonblank_str(getattr(chunk, "content", "")): + return (TraceEventType.THINKING, None, chunk.content, None), True + return None, False + + @staticmethod + def _classify_tool_call_chunks(tool_call_chunks) -> list[ClassifiedEvent]: + """Classify tool call announcement chunks, skipping incomplete ones.""" + events: list[ClassifiedEvent] = [] + for tc in tool_call_chunks: + if not isinstance(tc, dict): + continue + tc_name = tc.get("name") + if not tc_name: + continue + tc_args = tc.get("args") + tc_id = tc.get("id") + if isinstance(tc_args, str): + args_str: str | None = tc_args + elif tc_args is not None: + args_str = json.dumps(tc_args) + else: + args_str = None + meta = {"tool_call_id": tc_id} if tc_id else None + events.append((TraceEventType.TOOL_CALL, tc_name, args_str, meta)) + return events + + @staticmethod + def _classify_tool_result(chunk) -> ClassifiedEvent: + """Classify a ToolMessage into a TOOL_RESULT event.""" + tool_name = getattr(chunk, "name", None) + tool_call_id = getattr(chunk, "tool_call_id", None) + meta = {"tool_call_id": tool_call_id} if tool_call_id else None + raw_content = chunk.content + if isinstance(raw_content, str): + content_str: str | None = raw_content + elif raw_content is None: + content_str = None + else: + content_str = json.dumps(raw_content) + return (TraceEventType.TOOL_RESULT, tool_name, content_str, meta) + + @staticmethod + def _classify(chunk, _metadata: dict, _source: str | None) -> list[ClassifiedEvent]: + """Classify a stream chunk into a list of ``(type, name, content, metadata)`` tuples. + + Args: + chunk: A langchain message chunk (AIMessageChunk / ToolMessage / ...). + _metadata: LangGraph stream metadata dict (unused, kept for future use). + _source: Subagent name extracted from metadata (unused, kept for future use). + + Returns: + List of tuples to be wrapped into TraceEvent by ``_collect_trace``. + """ + events: list[ClassifiedEvent] = [] + additional = getattr(chunk, "additional_kwargs", {}) or {} + + thinking_event, is_thinking = DeepAgentRunner._classify_thinking(additional, chunk) + if thinking_event: + events.append(thinking_event) + + tool_call_chunks = getattr(chunk, "tool_call_chunks", None) + if tool_call_chunks: + events.extend(DeepAgentRunner._classify_tool_call_chunks(tool_call_chunks)) + + if isinstance(chunk, ToolMessage): + events.append(DeepAgentRunner._classify_tool_result(chunk)) + elif ( + not is_thinking and not tool_call_chunks and DeepAgentRunner._is_nonblank_str(getattr(chunk, "content", "")) + ): + events.append((TraceEventType.CONTENT, None, chunk.content, None)) + + return events + + # ------------------------------------------------------------------ # + # Core: collect the full trace of a turn + # ------------------------------------------------------------------ # + + @staticmethod + def _unpack_stream_item(item) -> tuple: + """Unpack a streamed item into (chunk, metadata) or (None, {}) if unusable.""" + if not (isinstance(item, tuple) and len(item) == 2): + return None, {} + first, second = item + if isinstance(second, tuple) and len(second) == 2: + _, raw_metadata = second + return second[0], raw_metadata if isinstance(raw_metadata, dict) else {} + return first, second if isinstance(second, dict) else {} + + @staticmethod + def _make_trace_event( + thread_id: str, turn_id: str, seq: int, ev_type, source, name, content, metadata + ) -> TraceEvent: + return TraceEvent( + id=str(uuid.uuid4()), + thread_id=thread_id, + turn_id=turn_id, + type=ev_type, + source=source, + name=name, + content=content, + metadata=metadata, + timestamp=datetime.now(UTC), + sequence=seq, + ) + + async def _stream_intermediate_events( + self, + stream_iter, + thread_id: str, + ) -> AsyncIterator[tuple[str, str | None, str | None, str | None, dict | None, str | None]]: + """Yield (source, ev_type, ev_name, ev_content, ev_meta, thinking_content) from the stream.""" + import time + + stream_start = time.monotonic() + first_chunk_time: float | None = None + while True: + try: + item = await asyncio.wait_for(anext(stream_iter), timeout=self._stream_idle_timeout) + except StopAsyncIteration: + break + except TimeoutError as e: + logger.error(LogMessage.AGENT_STREAM_IDLE_TIMEOUT, thread_id, self._stream_idle_timeout) + raise AgentError( + ErrorMessage.AGENT_STREAM_IDLE_TIMEOUT.format( + thread_id=thread_id, timeout=self._stream_idle_timeout + ) + ) from e + + if first_chunk_time is None: + first_chunk_time = time.monotonic() + logger.info(LogMessage.AGENT_FIRST_CHUNK, thread_id, first_chunk_time - stream_start) + + chunk, metadata = self._unpack_stream_item(item) + if chunk is None: + continue + + source = self._extract_source(metadata) + for ev_type, ev_name, ev_content, ev_meta in self._classify(chunk, metadata, source): + thinking_content = ev_content if ev_type == TraceEventType.THINKING and ev_content else None + yield source, ev_type, ev_name, ev_content, ev_meta, thinking_content + + async def _collect_trace( + self, + thread_id: str, + message: str, + config: dict, + turn_id: str, + ) -> AsyncIterator[TraceEvent]: + """Yield every TraceEvent of a turn: HUMAN_MESSAGE, intermediates, AI_MESSAGE. + + Args: + thread_id: Conversation thread identifier. + message: Human input text. + config: LangGraph runnable config (thread_id + tracing callbacks). + turn_id: Identifier grouping all events of this turn. + + Yields: + TraceEvent instances in turn order, with monotonic sequences. + """ + seq = 0 + + yield self._make_trace_event(thread_id, turn_id, seq, TraceEventType.HUMAN_MESSAGE, None, None, message, None) + seq += 1 + + thinking_parts: list[str] = [] stream_iter = aiter( self._graph.astream( {"messages": [{"role": "human", "content": message}]}, config=config, stream_mode="messages", + subgraphs=True, ) ) - # Consume chunks with an idle timeout: if no chunk arrives within the window - # the graph is considered stuck (e.g. a tool result was lost) and aborted. try: - while True: - try: - chunk, _metadata = await asyncio.wait_for( - anext(stream_iter), timeout=self._stream_idle_timeout - ) - except StopAsyncIteration: - break - except TimeoutError as e: - logger.error(LogMessage.AGENT_STREAM_IDLE_TIMEOUT, thread_id, self._stream_idle_timeout) - raise AgentError( - ErrorMessage.AGENT_STREAM_IDLE_TIMEOUT.format(thread_id=thread_id, timeout=self._stream_idle_timeout) - ) from e - classification = self._classify_chunk(chunk) - if classification: - event_type, data = classification - if first_chunk: - logger.info(LogMessage.AGENT_FIRST_CHUNK, thread_id, time.monotonic() - start) - first_chunk = False - chunk_count += 1 - yield StreamEvent(type=event_type, data=data) + async for ( + source, + ev_type, + ev_name, + ev_content, + ev_meta, + thinking_content, + ) in self._stream_intermediate_events(stream_iter, thread_id): + if thinking_content: + thinking_parts.append(thinking_content) + yield self._make_trace_event(thread_id, turn_id, seq, ev_type, source, ev_name, ev_content, ev_meta) + seq += 1 finally: aclose = getattr(stream_iter, "aclose", None) if aclose is not None: - # Generator may already be running (e.g. consumer closed us - # mid-yield); let Python GC handle the underlying iterator. with contextlib.suppress(RuntimeError): await aclose() - stats["chunk_count"] = chunk_count - stats["elapsed"] = time.monotonic() - start - async def stream(self, thread_id: str, message: str) -> AsyncIterator[StreamEvent]: + state = self._graph.get_state(config) + values = getattr(state, "values", None) or {} + result = { + "messages": values.get("messages", []), + "structured_response": values.get("structured_response"), + } + thinking = "".join(thinking_parts) if thinking_parts else None + final_message = self._build_response(result, config, thinking) + + yield self._make_trace_event( + thread_id, + turn_id, + seq, + TraceEventType.AI_MESSAGE, + None, + None, + final_message.model_dump_json(), + None, + ) + + # ------------------------------------------------------------------ # + # Public API + # ------------------------------------------------------------------ # + + def stream(self, thread_id: str, message: str, turn_id: str) -> AsyncIterator[TraceEvent]: + """Stream all TraceEvents of a turn. + + Args: + thread_id: Conversation thread identifier. + message: Human input text. + turn_id: Identifier grouping all events of this turn. + + Yields: + TraceEvent instances in turn order. + """ config = self._build_config(thread_id) logger.info(LogMessage.AGENT_STREAMING, thread_id) + return self._stream_impl(thread_id, message, config, turn_id) + + async def _stream_impl( + self, + thread_id: str, + message: str, + config: dict, + turn_id: str, + ) -> AsyncIterator[TraceEvent]: + """Async generator backing ``stream`` (wraps ``_collect_trace`` with error handling).""" try: - stats: dict = {} - async for event in self._yield_chunks(thread_id, message, config, stats): + async for event in self._collect_trace(thread_id, message, config, turn_id): yield event - logger.info( - LogMessage.AGENT_STREAM_COMPLETE, - thread_id, - stats["chunk_count"], - stats["elapsed"], - ) + except AgentError: + raise except Exception as e: logger.exception(LogMessage.AGENT_STREAMING_ERROR_LOG, thread_id) raise AgentError(ErrorMessage.AGENT_STREAMING_ERROR.format(error=e)) from e - async def stream_with_message(self, thread_id: str, message: str) -> AsyncIterator[StreamEvent]: + async def invoke(self, thread_id: str, message: str, turn_id: str) -> tuple[Message, list[TraceEvent]]: + """Invoke the agent and return the final Message + the full trace. + + Args: + thread_id: Conversation thread identifier. + message: Human input text. + turn_id: Identifier grouping all events of this turn. + + Returns: + Tuple ``(final_message, trace_events)``. + + Raises: + AgentError: On timeout, graph execution failure, or missing final state. + """ config = self._build_config(thread_id) - logger.info(LogMessage.AGENT_STREAMING_WITH_MESSAGE, thread_id) + logger.info(LogMessage.AGENT_INVOKING, thread_id) + logger.info(LogMessage.AGENT_MESSAGE, thread_id, message[:200]) try: - stats: dict = {} - thinking_parts = [] - async for event in self._yield_chunks(thread_id, message, config, stats): - yield event - if event.type == StreamEventType.THINKING: - thinking_parts.append(event.data) - state = self._graph.get_state(config) - values = getattr(state, "values", None) or {} - result = {"messages": values.get("messages", []), "structured_response": values.get("structured_response")} - thinking = "".join(thinking_parts) if thinking_parts else None - response = self._build_response(result, config, thinking) - logger.info( - LogMessage.AGENT_STREAM_WITH_MESSAGE_COMPLETE, - thread_id, - stats["chunk_count"], - stats["elapsed"], - response.status, - ) - yield StreamEvent(type=StreamEventType.MESSAGE, data=response.model_dump_json()) + start = time.monotonic() + trace: list[TraceEvent] = [] + final_message: Message | None = None + async for event in self._collect_trace(thread_id, message, config, turn_id): + trace.append(event) + if event.type == TraceEventType.AI_MESSAGE: + final_message = Message.from_trace_event(event) + elapsed = time.monotonic() - start + if final_message is None: + # Fallback: no AI_MESSAGE was emitted (shouldn't happen with a + # well-behaved graph). Build the response directly from ainvoke. + result = await asyncio.wait_for( + self._graph.ainvoke( + {"messages": [{"role": "human", "content": message}]}, + config=config, + ), + timeout=self._invoke_timeout, + ) + final_message = self._build_response(result, config, None) + logger.info(LogMessage.AGENT_INVOKE_COMPLETE, thread_id, final_message.status, elapsed) + return final_message, trace + except TimeoutError as e: + logger.error(LogMessage.AGENT_INVOKE_TIMEOUT, thread_id, self._invoke_timeout) + raise AgentError( + ErrorMessage.AGENT_INVOKE_TIMEOUT.format(thread_id=thread_id, timeout=self._invoke_timeout) + ) from e + except AgentError: + raise except Exception as e: - logger.exception(LogMessage.AGENT_STREAMING_ERROR_LOG, thread_id) - raise AgentError(ErrorMessage.AGENT_STREAMING_ERROR.format(error=e)) from e + logger.exception(LogMessage.AGENT_EXECUTION_ERROR_LOG, thread_id) + raise AgentError(ErrorMessage.AGENT_EXECUTION_ERROR.format(error=e)) from e + + # ------------------------------------------------------------------ # + # HITL (signatures unchanged; still return Message) + # ------------------------------------------------------------------ # async def approve_hitl(self, thread_id: str, _tool_call_id: str) -> Message: config = self._build_config(thread_id) diff --git a/src/infrastructure/deepagent/factory.py b/src/infrastructure/deepagent/factory.py index d9682e2..92f6025 100644 --- a/src/infrastructure/deepagent/factory.py +++ b/src/infrastructure/deepagent/factory.py @@ -1,70 +1,37 @@ -import hashlib import importlib -import json import logging from typing import Any from deepagents import create_deep_agent from deepagents.backends import FilesystemBackend, StoreBackend -from langchain_core.tools import StructuredTool from langgraph.checkpoint.memory import MemorySaver from langgraph.store.memory import InMemoryStore -from pydantic import Field as PydanticField -from pydantic import create_model +from pydantic import BaseModel -from src.domain.entities.agent_config import AgentConfig, BackendType +from src.domain.entities.agent_config import AgentConfig, BackendType, SubAgentConfig from src.domain.logging.messages import LogMessage from src.domain.ports.mcp_tool_loader import McpToolLoader from src.domain.ports.prompt_manager import PromptManager -from src.infrastructure.deepagent.schema_utils import make_validation_model +from src.infrastructure.deepagent.schema_utils import schema_to_pydantic_model logger = logging.getLogger(__name__) -STRUCTURED_OUTPUT_INSTRUCTION = ( - "\n\nYou MUST use the 'structured_response' tool to return your final answer in the expected structured format." -) +def _resolve_response_format(value: dict[str, Any] | None) -> tuple[type[BaseModel] | None, dict[str, Any] | None]: + """Resolve a ``response_format`` config value into ``(model, schema_dict)``. -_JSON_TYPE_MAP: dict[str, type] = { - "string": str, - "number": float, - "integer": int, - "boolean": bool, - "array": list, - "object": dict, -} - - -def _create_response_tool(schema: dict[str, Any]) -> StructuredTool: - """Create a StructuredTool from a JSON Schema dict for structured output. + Args: + value: The agent/subagent ``response_format`` config (a JSON Schema dict + or ``None``). - Builds a Pydantic model dynamically so the LLM knows the expected fields. - A sync func is required because deepagents invokes subagent tools synchronously. + Returns: + Tuple of ``(pydantic_model_or_none, schema_dict_or_none)``. The model is + used for post-extraction validation/stripping; the dict is passed to + deepagents' native ``response_format`` parameter. """ - properties = schema.get("properties", {}) - required_fields = set(schema.get("required", [])) - - field_definitions: dict[str, Any] = {} - for field_name, prop in properties.items(): - python_type = _JSON_TYPE_MAP.get(prop.get("type", "string"), str) - description = prop.get("description", "") - if field_name in required_fields: - field_definitions[field_name] = (python_type, PydanticField(description=description)) - else: - field_definitions[field_name] = (python_type | None, PydanticField(default=None, description=description)) - - schema_hash = hashlib.sha256(json.dumps(schema, sort_keys=True).encode()).hexdigest()[:8] - args_model = create_model(f"StructuredResponseArgs_{schema_hash}", **field_definitions) - - def _return_structured(**kwargs: Any) -> str: - return json.dumps(kwargs) - - return StructuredTool.from_function( - func=_return_structured, - name="structured_response", - description="Return your final answer using this tool with the expected structured format.", - args_schema=args_model, - ) + if value is None: + return None, None + return schema_to_pydantic_model(value), value def _resolve_tools(config: AgentConfig) -> list | None: @@ -126,6 +93,19 @@ def _resolve_interrupt_on(config: AgentConfig) -> dict | None: return result +async def _resolve_subagent_instructions(sa: SubAgentConfig, prompt_manager: PromptManager | None) -> str | None: + """Resolve subagent instructions, falling back to YAML if Phoenix load fails.""" + instructions = sa.instructions + if not prompt_manager: + return instructions + try: + content = await prompt_manager.get_prompt_content(sa.name) + return content.get("content") + except Exception: + logger.warning(LogMessage.SUBAGENT_PROMPT_LOAD_FAILED, sa.name) + return sa.instructions + + async def _resolve_subagents( config: AgentConfig, mcp_tool_loader: McpToolLoader | None = None, @@ -150,19 +130,7 @@ async def _resolve_subagents( mcp_tools = await mcp_tool_loader.load_tools(sa.mcp_servers) all_tools = (local_tools or []) + mcp_tools if (local_tools or mcp_tools) else None - instructions = sa.instructions - if prompt_manager: - try: - content = await prompt_manager.get_prompt_content(sa.name) - instructions = content.get("content") - except Exception: - logger.warning(LogMessage.SUBAGENT_PROMPT_LOAD_FAILED, sa.name) - instructions = sa.instructions - - if sa.response_format: - response_tool = _create_response_tool(sa.response_format) - all_tools = (all_tools or []) + [response_tool] - instructions = (instructions or "") + STRUCTURED_OUTPUT_INSTRUCTION + instructions = await _resolve_subagent_instructions(sa, prompt_manager) subagents.append( { @@ -171,6 +139,7 @@ async def _resolve_subagents( "system_prompt": instructions, "model": sa.model, "tools": all_tools, + "response_format": sa.response_format, } ) return subagents @@ -189,6 +158,20 @@ def _resolve_tools_list(tool_paths: list[str]) -> list | None: return tools or None +def _apply_optional_kwargs(kwargs: dict, config: AgentConfig) -> None: + """Populate optional kwargs from config if their values are set.""" + backend = _resolve_backend(config) + if backend: + kwargs["backend"] = backend + interrupt_on = _resolve_interrupt_on(config) + if interrupt_on: + kwargs["interrupt_on"] = interrupt_on + if config.memory: + kwargs["memory"] = config.memory + if config.skills: + kwargs["skills"] = config.skills + + async def create_agent_from_config( config: AgentConfig, mcp_tool_loader: McpToolLoader | None = None, @@ -206,8 +189,6 @@ async def create_agent_from_config( logger.info(LogMessage.AGENT_CREATING, config.name, config.model) checkpointer = MemorySaver() store = InMemoryStore() - interrupt_on = _resolve_interrupt_on(config) - system_prompt = None local_tools = _resolve_tools(config) mcp_tools: list = [] @@ -218,13 +199,11 @@ async def create_agent_from_config( all_tools = (local_tools or []) + mcp_tools if (local_tools or mcp_tools) else None logger.info(LogMessage.AGENT_TOOLS_TOTAL, config.name, len(all_tools) if all_tools else 0) - if prompt_manager: - system_prompt = await get_system_prompt_from_phoenix(config.name, prompt_manager) + system_prompt = await get_system_prompt_from_phoenix(config.name, prompt_manager) if prompt_manager else None kwargs = { "name": config.name, "model": config.model, - # Fall back to YAML system_prompt "system_prompt": system_prompt if system_prompt else config.system_prompt, "tools": all_tools, "middleware": [], @@ -232,26 +211,11 @@ async def create_agent_from_config( "store": store, } - backend = _resolve_backend(config) - if backend: - kwargs["backend"] = backend - - if interrupt_on: - kwargs["interrupt_on"] = interrupt_on - - if config.memory: - kwargs["memory"] = config.memory - - if config.skills: - kwargs["skills"] = config.skills + _apply_optional_kwargs(kwargs, config) if config.response_format: - response_format_model = make_validation_model(config.response_format) - response_tool = _create_response_tool(config.response_format) - all_tools = (all_tools or []) + [response_tool] - kwargs["tools"] = all_tools - current_prompt = kwargs.get("system_prompt", "") - kwargs["system_prompt"] = (current_prompt or "") + STRUCTURED_OUTPUT_INSTRUCTION + response_format_model, response_format_dict = _resolve_response_format(config.response_format) + kwargs["response_format"] = response_format_dict else: response_format_model = None diff --git a/src/infrastructure/deepagent/schema_utils.py b/src/infrastructure/deepagent/schema_utils.py index 35fba14..535cac5 100644 --- a/src/infrastructure/deepagent/schema_utils.py +++ b/src/infrastructure/deepagent/schema_utils.py @@ -1,9 +1,13 @@ import hashlib import json -from typing import Any +from functools import reduce +from operator import or_ +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, create_model +_PRIMITIVES: dict[str, type] = {"string": str, "number": float, "integer": int, "boolean": bool} + def schema_to_pydantic_model(schema: dict[str, Any], model_name: str = "DynamicModel") -> type[BaseModel]: """Convert a JSON Schema dict to a Pydantic BaseModel with extra='ignore'. @@ -14,7 +18,7 @@ def schema_to_pydantic_model(schema: dict[str, Any], model_name: str = "DynamicM return _build_model(schema, model_name) -def _build_model(schema: dict[str, Any], name: str) -> type[BaseModel]: +def _build_model(schema: dict[str, Any], name: str) -> type[BaseModel] | type: schema_type = schema.get("type", "object") if schema_type == "object": @@ -22,66 +26,104 @@ def _build_model(schema: dict[str, Any], name: str) -> type[BaseModel]: if schema_type == "array": return _build_array_model(schema, name) - primitives = {"string": str, "number": float, "integer": int, "boolean": bool} - return primitives.get(schema_type, str) + return _PRIMITIVES.get(schema_type, str) def _build_object_model(schema: dict[str, Any], name: str) -> type[BaseModel]: properties = schema.get("properties", {}) required = set(schema.get("required", [])) - if not properties: - return create_model( - name, - __config__=ConfigDict(extra="ignore"), - **{_sanitize(k): (dict | None, Field(default=None)) for k in properties}, - ) - field_defs: dict[str, Any] = {} for prop_name, prop_schema in properties.items(): python_name = _sanitize(prop_name) - prop_type = prop_schema.get("type", "string") - - if prop_type == "object": - nested_name = f"{name}_{_sanitize(prop_name).capitalize()}" - nested_model = _build_object_model(prop_schema, nested_name) - if prop_name in required: - field_defs[python_name] = (nested_model, Field(description=prop_schema.get("description", ""))) - else: - field_defs[python_name] = (nested_model | None, Field(default=None, description=prop_schema.get("description", ""))) - elif prop_type == "array": - items_schema = prop_schema.get("items", {}) - items_type = items_schema.get("type", "string") - if items_type == "object": - nested_name = f"{name}_{_sanitize(prop_name).capitalize()}Item" - nested_model = _build_object_model(items_schema, nested_name) - list_type = list[nested_model] - else: - primitives = {"string": str, "number": float, "integer": int, "boolean": bool} - list_type = list[primitives.get(items_type, str)] - if prop_name in required: - field_defs[python_name] = (list_type, Field(default_factory=list, description=prop_schema.get("description", ""))) - else: - field_defs[python_name] = (list_type | None, Field(default=None, description=prop_schema.get("description", ""))) + prop_type = _resolve_prop_type(prop_schema, name, prop_name) + if prop_name in required: + field_defs[python_name] = (prop_type, Field(description=prop_schema.get("description", ""))) else: - primitives = {"string": str, "number": float, "integer": int, "boolean": bool} - python_type = primitives.get(prop_type, str) - if prop_name in required: - field_defs[python_name] = (python_type, Field(description=prop_schema.get("description", ""))) - else: - field_defs[python_name] = (python_type | None, Field(default=None, description=prop_schema.get("description", ""))) + field_defs[python_name] = (prop_type, Field(default=None, description=prop_schema.get("description", ""))) return create_model(name, __config__=ConfigDict(extra="ignore"), **field_defs) +def _resolve_prop_type(prop_schema: dict[str, Any], parent_name: str, prop_name: str) -> type: + """Resolve the Python type for a single property schema.""" + if "enum" in prop_schema: + return _build_enum_type(prop_schema["enum"]) + + if "anyOf" in prop_schema: + return _build_union_type(prop_schema["anyOf"], parent_name, prop_name) + + prop_type = prop_schema.get("type", "string") + + if isinstance(prop_type, list): + return _build_type_array_union(prop_type, prop_schema, parent_name, prop_name) + + if prop_type == "object": + nested_name = f"{parent_name}_{_sanitize(prop_name).capitalize()}" + return _build_object_model(prop_schema, nested_name) + if prop_type == "array": + return _build_array_model(prop_schema, f"{parent_name}_{_sanitize(prop_name).capitalize()}Item") + + return _PRIMITIVES.get(prop_type, str) + + +def _build_enum_type(values: list[Any]) -> type: + """Build a Literal type from an enum's value list.""" + return Literal[tuple(values)] # type: ignore[valid-type] + + +def _build_union_type(variants: list[dict[str, Any]], parent_name: str, prop_name: str) -> type: + """Build a Union type from an anyOf variant list. + + A variant of ``{"type": "null"}`` maps to ``types.NoneType`` so the + resulting union is nullable. + """ + variant_name = f"{parent_name}_{_sanitize(prop_name).capitalize()}Variant" + non_null_types: list[type] = [] + has_null = False + for variant in variants: + if variant.get("type") == "null": + has_null = True + continue + non_null_types.append(_build_model(variant, variant_name)) + + return _join_union(non_null_types, has_null) + + +def _build_type_array_union(types: list[str], prop_schema: dict[str, Any], parent_name: str, prop_name: str) -> type: + """Build a Union type from a ``type: ["a", "b", ...]`` list form. + + Object/array variants are built as nested models (preserving properties/items + constraints) by delegating to ``_build_object_model``/``_build_array_model``. + """ + nested_name = f"{parent_name}_{_sanitize(prop_name).capitalize()}" + has_null = "null" in types + non_null_types: list[type] = [] + for t in types: + if t == "null": + continue + if t == "object": + non_null_types.append(_build_object_model(prop_schema, nested_name)) + elif t == "array": + non_null_types.append(_build_array_model(prop_schema, nested_name)) + else: + non_null_types.append(_PRIMITIVES.get(t, str)) + return _join_union(non_null_types, has_null) + + +def _join_union(non_null_types: list[type], has_null: bool) -> type: + """Combine a list of non-null types with optional ``None`` into a union.""" + base = non_null_types[0] if len(non_null_types) == 1 else reduce(or_, non_null_types) + return base | None if has_null else base + + def _build_array_model(schema: dict[str, Any], name: str) -> type: items_schema = schema.get("items", {}) items_type = items_schema.get("type", "string") if items_type == "object": nested_model = _build_object_model(items_schema, f"{name}Item") return list[nested_model] - primitives = {"string": str, "number": float, "integer": int, "boolean": bool} - return list[primitives.get(items_type, str)] + return list[_PRIMITIVES.get(items_type, str)] def _sanitize(name: str) -> str: diff --git a/src/infrastructure/mcp/adapter.py b/src/infrastructure/mcp/adapter.py index a3ea000..e7516fd 100644 --- a/src/infrastructure/mcp/adapter.py +++ b/src/infrastructure/mcp/adapter.py @@ -14,6 +14,7 @@ logger = logging.getLogger(__name__) + class LangchainMcpToolLoader(McpToolLoader): """Adapter MCP utilisant langchain-mcp-adapters pour charger des outils.""" diff --git a/src/infrastructure/persistent_registry/adapter.py b/src/infrastructure/persistent_registry/adapter.py index b22e9f7..c5aaf52 100644 --- a/src/infrastructure/persistent_registry/adapter.py +++ b/src/infrastructure/persistent_registry/adapter.py @@ -64,7 +64,9 @@ async def get_runner(self, agent_name: str) -> AgentRunner: logger.info(LogMessage.AGENT_BUILDING, agent_name) yaml_content = await self._config_store.get(agent_name) config = self._config_loader.load_from_string(yaml_content) - graph, response_format_model = await create_agent_from_config(config, self._mcp_tool_loader, self._prompt_manager) + graph, response_format_model = await create_agent_from_config( + config, self._mcp_tool_loader, self._prompt_manager + ) runner = DeepAgentRunner( graph, tracing_provider=self._tracing_provider, diff --git a/src/infrastructure/postgres_repository/adapter.py b/src/infrastructure/postgres_repository/adapter.py index fb78287..7bd8fd9 100644 --- a/src/infrastructure/postgres_repository/adapter.py +++ b/src/infrastructure/postgres_repository/adapter.py @@ -85,9 +85,7 @@ async def get(self, name: str) -> AgentConfigMetadata: except AgentNotFoundError: raise except SQLAlchemyError as e: - raise StorageError( - ErrorMessage.STORAGE_FAILED_GET_AGENT_CONFIG.format(name=name, error=e) - ) from e + raise StorageError(ErrorMessage.STORAGE_FAILED_GET_AGENT_CONFIG.format(name=name, error=e)) from e async def list_all(self) -> list[AgentConfigMetadata]: """List all agent configuration metadata. @@ -104,9 +102,7 @@ async def list_all(self) -> list[AgentConfigMetadata]: models = result.scalars().all() return [_model_to_metadata(m) for m in models] except SQLAlchemyError as e: - raise StorageError( - ErrorMessage.STORAGE_FAILED_LIST_AGENT_CONFIG.format(error=e) - ) from e + raise StorageError(ErrorMessage.STORAGE_FAILED_LIST_AGENT_CONFIG.format(error=e)) from e async def delete(self, name: str) -> None: """Delete metadata by agent name. @@ -129,9 +125,7 @@ async def delete(self, name: str) -> None: except AgentNotFoundError: raise except SQLAlchemyError as e: - raise StorageError( - ErrorMessage.STORAGE_FAILED_DELETE_AGENT_CONFIG.format(name=name, error=e) - ) from e + raise StorageError(ErrorMessage.STORAGE_FAILED_DELETE_AGENT_CONFIG.format(name=name, error=e)) from e async def exists(self, name: str) -> bool: """Check whether metadata exists for the given agent name. @@ -150,6 +144,4 @@ async def exists(self, name: str) -> bool: model = await session.get(AgentConfigModel, name) return model is not None except SQLAlchemyError as e: - raise StorageError( - ErrorMessage.STORAGE_FAILED_EXISTS_AGENT_CONFIG.format(name=name, error=e) - ) from e + raise StorageError(ErrorMessage.STORAGE_FAILED_EXISTS_AGENT_CONFIG.format(name=name, error=e)) from e diff --git a/src/infrastructure/postgres_thread/adapter.py b/src/infrastructure/postgres_thread/adapter.py index 17ada5e..ea6e8a1 100644 --- a/src/infrastructure/postgres_thread/adapter.py +++ b/src/infrastructure/postgres_thread/adapter.py @@ -1,3 +1,10 @@ +"""PostgreSQL adapter for the ThreadRepository port. + +Each method opens its own :class:`AsyncSession` (session-per-method). The +``add_message`` method has been removed — message persistence now goes through +:class:`~src.infrastructure.postgres_trace.adapter.PostgresTraceEventRepository`. +""" + import logging from datetime import UTC, datetime from uuid import uuid4 @@ -7,51 +14,50 @@ from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from sqlalchemy.orm import selectinload -from src.domain.entities.message import Message, MessageRole, MessageStatus from src.domain.entities.thread import Thread +from src.domain.entities.trace_event import TraceEvent, TraceEventType from src.domain.errors.messages import ErrorMessage from src.domain.errors.storage import StorageError from src.domain.errors.thread import ThreadNotFoundError from src.domain.ports.thread_repository import ThreadRepository -from src.infrastructure.database.models.thread import MessageModel, ThreadModel +from src.infrastructure.database.models.thread import ThreadModel logger = logging.getLogger(__name__) -def _safe_str(val: object) -> str | None: - """Return a string if the value is a real string, else None.""" - return val if isinstance(val, str) else None - - def _model_to_thread(thread_model: ThreadModel) -> Thread: - """Reconstruct a domain Thread from ORM ThreadModel with its MessageModels. + """Reconstruct a domain Thread from ORM ThreadModel with its TraceEventModels. - Messages are sorted by timestamp (oldest first). - The database relationship has order_by, but sort is kept as a defensive measure. + Trace events are sorted by timestamp (oldest first). The database + relationship already has ``order_by``, but sorting here is a defensive + measure. Args: - thread_model: The ORM thread model with loaded messages relationship. + thread_model: The ORM thread model with loaded trace_events relationship. Returns: - A domain Thread entity with all messages. + A domain Thread entity with all trace events. """ - messages_sorted = sorted(thread_model.messages, key=lambda m: m.timestamp) - messages = [ - Message( - role=MessageRole(msg.role), - content=msg.content, - timestamp=msg.timestamp, - tool_calls=msg.tool_calls, - status=MessageStatus(msg.status) if msg.status else None, - structured_response=msg.structured_response, - thinking=_safe_str(msg.thinking), + events_sorted = sorted(thread_model.trace_events, key=lambda m: m.timestamp) + trace_events = [ + TraceEvent( + id=m.id, + thread_id=m.thread_id, + turn_id=m.turn_id, + type=TraceEventType(m.type), + source=m.source, + name=m.name, + content=m.content, + metadata=m.event_metadata, + timestamp=m.timestamp, + sequence=m.sequence, ) - for msg in messages_sorted + for m in events_sorted ] return Thread( id=thread_model.id, agent_name=thread_model.agent_name, - messages=messages, + trace_events=trace_events, created_at=thread_model.created_at, updated_at=thread_model.updated_at, ) @@ -90,11 +96,11 @@ async def create(self, agent_name: str) -> Thread: ) session.add(model) await session.commit() - # New thread has no messages — construct directly to avoid lazy='raise' + # New thread has no trace_events — construct directly to avoid lazy='raise' return Thread( id=model.id, agent_name=model.agent_name, - messages=[], + trace_events=[], created_at=model.created_at, updated_at=model.updated_at, ) @@ -108,7 +114,7 @@ async def get(self, thread_id: str) -> Thread: thread_id: The unique thread identifier. Returns: - The domain Thread with all messages. + The domain Thread with all trace events. Raises: ThreadNotFoundError: If no thread exists with this ID. @@ -116,7 +122,7 @@ async def get(self, thread_id: str) -> Thread: """ async with AsyncSession(self._engine, expire_on_commit=False) as session: try: - model = await session.get(ThreadModel, thread_id, options=[selectinload(ThreadModel.messages)]) + model = await session.get(ThreadModel, thread_id, options=[selectinload(ThreadModel.trace_events)]) if model is None: raise ThreadNotFoundError(ErrorMessage.THREAD_NOT_FOUND.format(thread_id=thread_id)) return _model_to_thread(model) @@ -138,7 +144,7 @@ async def list_all(self) -> list[Thread]: try: result = await session.execute( select(ThreadModel) - .options(selectinload(ThreadModel.messages)) + .options(selectinload(ThreadModel.trace_events)) .order_by(ThreadModel.created_at.desc()) ) models = result.scalars().all() @@ -147,7 +153,7 @@ async def list_all(self) -> list[Thread]: raise StorageError(ErrorMessage.THREAD_FAILED_LIST.format(error=e)) from e async def delete(self, thread_id: str) -> None: - """Delete a thread and all its messages. + """Delete a thread and all its trace events. Args: thread_id: The unique thread identifier. @@ -167,52 +173,3 @@ async def delete(self, thread_id: str) -> None: raise except SQLAlchemyError as e: raise StorageError(ErrorMessage.THREAD_FAILED_DELETE.format(thread_id=thread_id, error=e)) from e - - async def add_message(self, thread_id: str, message: Message) -> Thread: - """Add a message to an existing thread. - - Args: - thread_id: The unique thread identifier. - message: The domain Message to add. - - Returns: - The updated Thread with the new message included. - - Raises: - ThreadNotFoundError: If no thread exists with this ID. - StorageError: If the database operation fails. - """ - async with AsyncSession(self._engine, expire_on_commit=False) as session: - try: - thread_model = await session.get(ThreadModel, thread_id) - if thread_model is None: - raise ThreadNotFoundError(ErrorMessage.THREAD_NOT_FOUND.format(thread_id=thread_id)) - - msg_model = MessageModel( - id=str(uuid4()), - thread_id=thread_id, - role=message.role.value, - content=message.content, - timestamp=message.timestamp, - tool_calls=message.tool_calls, - status=message.status.value if message.status else None, - structured_response=message.structured_response, - thinking=message.thinking, - ) - session.add(msg_model) - thread_model.updated_at = datetime.now(UTC) - await session.commit() - # session.get() with an expired identity-map object does NOT re-apply - # selectinload options — use execute(select(...)) to force a real DB query. - result = await session.execute( - select(ThreadModel) - .where(ThreadModel.id == thread_id) - .options(selectinload(ThreadModel.messages)) - .execution_options(populate_existing=True) - ) - updated_model = result.scalar_one() - return _model_to_thread(updated_model) - except ThreadNotFoundError: - raise - except SQLAlchemyError as e: - raise StorageError(ErrorMessage.THREAD_FAILED_ADD_MESSAGE.format(thread_id=thread_id, error=e)) from e diff --git a/src/infrastructure/postgres_trace/__init__.py b/src/infrastructure/postgres_trace/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/infrastructure/postgres_trace/adapter.py b/src/infrastructure/postgres_trace/adapter.py new file mode 100644 index 0000000..9bd6333 --- /dev/null +++ b/src/infrastructure/postgres_trace/adapter.py @@ -0,0 +1,189 @@ +"""PostgreSQL adapter for the TraceEventRepository port. + +Each method opens its own :class:`AsyncSession` (session-per-method) to ensure +thread-safety and proper session lifecycle under concurrent FastAPI requests. +""" + +import logging + +from sqlalchemy import select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession + +from src.domain.entities.trace_event import TraceEvent, TraceEventType +from src.domain.errors.messages import ErrorMessage +from src.domain.errors.storage import StorageError +from src.domain.errors.thread import ThreadNotFoundError +from src.domain.ports.trace_event_repository import TraceEventRepository +from src.infrastructure.database.models.thread import ThreadModel +from src.infrastructure.database.models.trace_event import TraceEventModel + +logger = logging.getLogger(__name__) + + +def _model_to_event(model: TraceEventModel) -> TraceEvent: + """Reconstruct a domain TraceEvent from its ORM model.""" + return TraceEvent( + id=model.id, + thread_id=model.thread_id, + turn_id=model.turn_id, + type=TraceEventType(model.type), + source=model.source, + name=model.name, + content=model.content, + metadata=model.event_metadata, + timestamp=model.timestamp, + sequence=model.sequence, + ) + + +class PostgresTraceEventRepository(TraceEventRepository): + """Adapter that persists trace events in PostgreSQL via SQLAlchemy async.""" + + def __init__(self, engine: AsyncEngine) -> None: + self._engine = engine + + async def _assert_thread_exists(self, session: AsyncSession, thread_id: str) -> None: + """Raise ThreadNotFoundError if the thread is not present.""" + thread = await session.get(ThreadModel, thread_id) + if thread is None: + raise ThreadNotFoundError(ErrorMessage.THREAD_NOT_FOUND.format(thread_id=thread_id)) + + async def add(self, thread_id: str, event: TraceEvent) -> None: + """Persist a single trace event. + + Args: + thread_id: Parent thread id. + event: The trace event to persist. + + Raises: + ThreadNotFoundError: If the thread does not exist. + StorageError: On infrastructure failure. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + await self._assert_thread_exists(session, thread_id) + session.add(self._to_model(event)) + await session.commit() + except ThreadNotFoundError: + raise + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.TRACE_FAILED_ADD.format(thread_id=thread_id, error=e)) from e + + async def add_batch(self, thread_id: str, events: list[TraceEvent]) -> None: + """Persist a batch of trace events atomically. + + Uses ``session.add_all`` for a single round-trip insert. + + Args: + thread_id: Parent thread id. + events: The trace events to persist. + + Raises: + ThreadNotFoundError: If the thread does not exist. + StorageError: On infrastructure failure. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + await self._assert_thread_exists(session, thread_id) + session.add_all([self._to_model(e) for e in events]) + await session.commit() + except ThreadNotFoundError: + raise + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.TRACE_FAILED_ADD_BATCH.format(thread_id=thread_id, error=e)) from e + + async def list_by_thread(self, thread_id: str) -> list[TraceEvent]: + """List all trace events for a thread, ordered by timestamp. + + Args: + thread_id: Parent thread id. + + Returns: + A list of TraceEvent ordered by timestamp (oldest first). + + Raises: + ThreadNotFoundError: If the thread does not exist. + StorageError: On infrastructure failure. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + await self._assert_thread_exists(session, thread_id) + result = await session.execute( + select(TraceEventModel) + .where(TraceEventModel.thread_id == thread_id) + .order_by(TraceEventModel.timestamp, TraceEventModel.sequence) + ) + return [_model_to_event(m) for m in result.scalars().all()] + except ThreadNotFoundError: + raise + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.TRACE_FAILED_LIST.format(thread_id=thread_id, error=e)) from e + + async def list_by_turn(self, thread_id: str, turn_id: str) -> list[TraceEvent]: + """List trace events for a specific turn of a thread. + + Args: + thread_id: Parent thread id. + turn_id: Turn identifier. + + Returns: + A list of TraceEvent ordered by timestamp. + + Raises: + StorageError: On infrastructure failure. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + result = await session.execute( + select(TraceEventModel) + .where(TraceEventModel.thread_id == thread_id) + .where(TraceEventModel.turn_id == turn_id) + .order_by(TraceEventModel.timestamp, TraceEventModel.sequence) + ) + return [_model_to_event(m) for m in result.scalars().all()] + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.TRACE_FAILED_LIST.format(thread_id=thread_id, error=e)) from e + + async def list_messages(self, thread_id: str) -> list[TraceEvent]: + """List only HUMAN_MESSAGE + AI_MESSAGE events for a thread. + + Args: + thread_id: Parent thread id. + + Returns: + A list of TraceEvent filtered to HUMAN_MESSAGE + AI_MESSAGE, + ordered by timestamp (oldest first). + + Raises: + StorageError: On infrastructure failure. + """ + async with AsyncSession(self._engine, expire_on_commit=False) as session: + try: + result = await session.execute( + select(TraceEventModel) + .where(TraceEventModel.thread_id == thread_id) + .where( + TraceEventModel.type.in_([TraceEventType.HUMAN_MESSAGE.value, TraceEventType.AI_MESSAGE.value]) + ) + .order_by(TraceEventModel.timestamp, TraceEventModel.sequence) + ) + return [_model_to_event(m) for m in result.scalars().all()] + except SQLAlchemyError as e: + raise StorageError(ErrorMessage.TRACE_FAILED_LIST.format(thread_id=thread_id, error=e)) from e + + @staticmethod + def _to_model(event: TraceEvent) -> TraceEventModel: + """Convert a domain TraceEvent to its ORM model.""" + return TraceEventModel( + id=event.id, + thread_id=event.thread_id, + turn_id=event.turn_id, + type=event.type.value, + source=event.source, + name=event.name, + content=event.content, + event_metadata=event.metadata, + timestamp=event.timestamp, + sequence=event.sequence, + ) diff --git a/src/infrastructure/prompt_management/adapter.py b/src/infrastructure/prompt_management/adapter.py index dbc02cd..2846cf9 100644 --- a/src/infrastructure/prompt_management/adapter.py +++ b/src/infrastructure/prompt_management/adapter.py @@ -46,9 +46,7 @@ def _wrap_phoenix_error(operation: str, identifier: str, e: Exception) -> Except """ if isinstance(e, (httpx.TimeoutException, httpx.ConnectError)): return PromptManagerUnavailableError( - ErrorMessage.PROMPT_MANAGER_UNAVAILABLE.format( - operation=operation, identifier=identifier, error=e - ) + ErrorMessage.PROMPT_MANAGER_UNAVAILABLE.format(operation=operation, identifier=identifier, error=e) ) if isinstance(e, httpx.HTTPStatusError): status_code = e.response.status_code @@ -76,7 +74,8 @@ def _extract_messages(phoenix_prompt: Any) -> list[dict[str, str]]: raw_content = msg.get("content", "") if isinstance(raw_content, list): text = " ".join( - block.get("text", "") for block in raw_content + block.get("text", "") + for block in raw_content if isinstance(block, dict) and block.get("type") == "text" ) else: @@ -165,7 +164,9 @@ async def get_prompt( if not prompt_obj: raise PromptNotFoundError(ErrorMessage.PROMPT_NOT_FOUND.format(identifier=identifier)) - tags = self._client.prompts.tags.list(prompt_version_id=prompt_obj.id) if prompt_obj and prompt_obj.id else [] + tags = ( + self._client.prompts.tags.list(prompt_version_id=prompt_obj.id) if prompt_obj and prompt_obj.id else [] + ) tag_names = [t["name"] for t in tags] logger.info(LogMessage.PROMPT_RETRIEVED, identifier, version_id, tag_names) @@ -209,7 +210,9 @@ async def get_prompt_content( tag=tag, ) - tags = self._client.prompts.tags.list(prompt_version_id=prompt_obj.id) if prompt_obj and prompt_obj.id else [] + tags = ( + self._client.prompts.tags.list(prompt_version_id=prompt_obj.id) if prompt_obj and prompt_obj.id else [] + ) logger.info(LogMessage.PROMPT_RETRIEVED, identifier, version_id, [t["name"] for t in tags]) messages = _extract_messages(prompt_obj) @@ -277,10 +280,7 @@ async def update_prompt( if not self._client: raise PromptManagerUnavailableError(ErrorMessage.PROMPT_MANAGER_NOT_INITIALIZED) if description is not None: - logger.warning( - LogMessage.PHOENIX_DESC_UPDATE_UNSUPPORTED, - identifier - ) + logger.warning(LogMessage.PHOENIX_DESC_UPDATE_UNSUPPORTED, identifier) try: current = await self.get_prompt(identifier) updated = self._client.prompts.create( diff --git a/src/infrastructure/yaml_config/adapter.py b/src/infrastructure/yaml_config/adapter.py index bec5433..119b632 100644 --- a/src/infrastructure/yaml_config/adapter.py +++ b/src/infrastructure/yaml_config/adapter.py @@ -68,8 +68,6 @@ def load_from_string(self, yaml_content: str, source: str = "") -> Agent if raw.get("system_prompt_file"): logger.error(LogMessage.YAML_SYSTEM_PROMPT_FILE_DISALLOWED, source) - raise ConfigError( - ErrorMessage.YAML_SYSTEM_PROMPT_FILE_DISALLOWED.format(source=source) - ) + raise ConfigError(ErrorMessage.YAML_SYSTEM_PROMPT_FILE_DISALLOWED.format(source=source)) return self._validate(raw, source) diff --git a/src/main.py b/src/main.py index 1600f3e..94a2683 100644 --- a/src/main.py +++ b/src/main.py @@ -14,6 +14,7 @@ from src.application.routes.health import router as health_router from src.application.routes.prompt import router as prompt_router from src.application.routes.threads import router as threads_router +from src.application.routes.trace import router as trace_router from src.application.routes.websocket import router as websocket_router from src.config import Settings from src.dependencies import ( @@ -113,6 +114,7 @@ async def lifespan(_app: FastAPI): protected = APIRouter(dependencies=[Depends(security.verify_api_key)]) protected.include_router(threads_router) protected.include_router(chat_router) +protected.include_router(trace_router) protected.include_router(agents_router) protected.include_router(prompt_router) app.include_router(protected) diff --git a/tests/conftest.py b/tests/conftest.py index 82416e5..466d857 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,6 +20,7 @@ from src.infrastructure.database.models.base import Base from src.infrastructure.postgres_thread.adapter import PostgresThreadRepository +from src.infrastructure.postgres_trace.adapter import PostgresTraceEventRepository from src.infrastructure.tracing.noop_adapter import NoopTracingProvider from src.infrastructure.yaml_config.adapter import YamlAgentConfigLoader @@ -67,6 +68,12 @@ async def thread_repo(db_engine) -> PostgresThreadRepository: return PostgresThreadRepository(engine=db_engine) +@pytest_asyncio.fixture +async def trace_repo(db_engine) -> PostgresTraceEventRepository: + """Provide a real PostgresTraceEventRepository backed by in-memory SQLite.""" + return PostgresTraceEventRepository(engine=db_engine) + + @pytest.fixture def yaml_loader() -> YamlAgentConfigLoader: """Provide a real YamlAgentConfigLoader for each test.""" diff --git a/tests/integration/test_deepagent_real_graph.py b/tests/integration/test_deepagent_real_graph.py index b9815a1..87e0611 100644 --- a/tests/integration/test_deepagent_real_graph.py +++ b/tests/integration/test_deepagent_real_graph.py @@ -35,9 +35,7 @@ def _fake_model() -> _FakeToolCallingModel: [ AIMessage( content="", - tool_calls=[ - {"name": "echo", "args": {"text": "hello"}, "id": "call_1", "type": "tool_call"} - ], + tool_calls=[{"name": "echo", "args": {"text": "hello"}, "id": "call_1", "type": "tool_call"}], ), AIMessage(content="final answer"), AIMessage(content="final answer"), diff --git a/tests/unit/test_agent_config.py b/tests/unit/test_agent_config.py index 51f22cc..a18b1da 100644 --- a/tests/unit/test_agent_config.py +++ b/tests/unit/test_agent_config.py @@ -202,9 +202,7 @@ def test_response_format_stores_dict(self): } # Act - sa = SubAgentConfig( - name="auditor", description="Security auditor", response_format=schema - ) + sa = SubAgentConfig(name="auditor", description="Security auditor", response_format=schema) # Assert assert sa.response_format == schema diff --git a/tests/unit/test_agent_crud.py b/tests/unit/test_agent_crud.py index 644a7c2..3799dd2 100644 --- a/tests/unit/test_agent_crud.py +++ b/tests/unit/test_agent_crud.py @@ -50,9 +50,7 @@ def use_case(self, yaml_loader, mock_agent_config_store, mock_agent_config_repos config_repository=mock_agent_config_repository, ) - async def test_returns_config_with_provided_name_when_created( - self, use_case, mock_agent_config_repository - ): + async def test_returns_config_with_provided_name_when_created(self, use_case, mock_agent_config_repository): """Should return parsed config with the provided name.""" # Arrange mock_agent_config_repository.exists.return_value = False @@ -63,9 +61,7 @@ async def test_returns_config_with_provided_name_when_created( # Assert assert result.name == "test-agent" - async def test_returns_config_with_parsed_model_when_created( - self, use_case, mock_agent_config_repository - ): + async def test_returns_config_with_parsed_model_when_created(self, use_case, mock_agent_config_repository): """Should return parsed config with the YAML model.""" # Arrange mock_agent_config_repository.exists.return_value = False @@ -76,9 +72,7 @@ async def test_returns_config_with_parsed_model_when_created( # Assert assert result.model == "claude-sonnet-4-5-20250929" - async def test_returns_config_with_parsed_system_prompt_when_created( - self, use_case, mock_agent_config_repository - ): + async def test_returns_config_with_parsed_system_prompt_when_created(self, use_case, mock_agent_config_repository): """Should return parsed config with the YAML system_prompt.""" # Arrange mock_agent_config_repository.exists.return_value = False @@ -89,9 +83,7 @@ async def test_returns_config_with_parsed_system_prompt_when_created( # Assert assert result.system_prompt == "You are a test agent." - async def test_checks_existence_with_repository_when_created( - self, use_case, mock_agent_config_repository - ): + async def test_checks_existence_with_repository_when_created(self, use_case, mock_agent_config_repository): """Should check existence on the repository with the agent name.""" # Arrange mock_agent_config_repository.exists.return_value = False @@ -115,9 +107,7 @@ async def test_stores_yaml_in_store_when_created( # Assert mock_agent_config_store.put.assert_awaited_once() - async def test_saves_metadata_in_repository_when_created( - self, use_case, mock_agent_config_repository - ): + async def test_saves_metadata_in_repository_when_created(self, use_case, mock_agent_config_repository): """Should save metadata in the repository.""" # Arrange mock_agent_config_repository.exists.return_value = False @@ -128,9 +118,7 @@ async def test_saves_metadata_in_repository_when_created( # Assert mock_agent_config_repository.save.assert_awaited_once() - async def test_raises_already_exists_when_agent_present( - self, use_case, mock_agent_config_repository - ): + async def test_raises_already_exists_when_agent_present(self, use_case, mock_agent_config_repository): """Should raise AgentConfigAlreadyExistsError when agent already exists.""" # Arrange mock_agent_config_repository.exists.return_value = True @@ -139,9 +127,7 @@ async def test_raises_already_exists_when_agent_present( with pytest.raises(AgentConfigAlreadyExistsError): await use_case.execute(name="test-agent", yaml_content=VALID_YAML) - async def test_raises_config_error_when_yaml_invalid( - self, use_case, mock_agent_config_repository - ): + async def test_raises_config_error_when_yaml_invalid(self, use_case, mock_agent_config_repository): """Should raise ConfigError when YAML is invalid (via real loader).""" # Arrange mock_agent_config_repository.exists.return_value = False @@ -159,9 +145,7 @@ def mock_registry(self): return AsyncMock(spec=AgentRegistry) @pytest.fixture - def use_case( - self, yaml_loader, mock_agent_config_store, mock_agent_config_repository, mock_registry - ): + def use_case(self, yaml_loader, mock_agent_config_store, mock_agent_config_repository, mock_registry): return UpdateAgentConfigUseCase( config_loader=yaml_loader, config_store=mock_agent_config_store, @@ -232,9 +216,7 @@ async def test_invalidates_registry_cache_when_updated( # Assert mock_registry.invalidate.assert_awaited_once_with("test-agent") - async def test_raises_not_found_when_agent_absent( - self, use_case, mock_agent_config_repository - ): + async def test_raises_not_found_when_agent_absent(self, use_case, mock_agent_config_repository): """Should raise AgentNotFoundError when agent does not exist.""" # Arrange mock_agent_config_repository.get.side_effect = AgentNotFoundError("not found") @@ -250,8 +232,7 @@ async def test_raises_config_error_when_name_mismatch( # Arrange mock_agent_config_repository.get.return_value = existing_metadata mismatched_yaml = ( - 'name: different-name\nmodel: claude-sonnet-4-5-20250929\n' - 'system_prompt: "You are a test agent."\n' + 'name: different-name\nmodel: claude-sonnet-4-5-20250929\nsystem_prompt: "You are a test agent."\n' ) # Act & Assert @@ -324,9 +305,7 @@ async def test_invalidates_registry_cache_when_deleted( # Assert mock_registry.invalidate.assert_awaited_once_with("test-agent") - async def test_raises_not_found_when_agent_absent( - self, use_case, mock_agent_config_repository - ): + async def test_raises_not_found_when_agent_absent(self, use_case, mock_agent_config_repository): """Should raise AgentNotFoundError when agent does not exist.""" # Arrange mock_agent_config_repository.get.side_effect = AgentNotFoundError("not found") @@ -346,9 +325,7 @@ def use_case(self, yaml_loader, mock_agent_config_store): config_store=mock_agent_config_store, ) - async def test_returns_config_with_name_when_found( - self, use_case, mock_agent_config_store - ): + async def test_returns_config_with_name_when_found(self, use_case, mock_agent_config_store): """Should return parsed config with the agent name.""" # Arrange mock_agent_config_store.get.return_value = VALID_YAML @@ -359,9 +336,7 @@ async def test_returns_config_with_name_when_found( # Assert assert result.name == "test-agent" - async def test_returns_config_with_model_when_found( - self, use_case, mock_agent_config_store - ): + async def test_returns_config_with_model_when_found(self, use_case, mock_agent_config_store): """Should return parsed config with the YAML model.""" # Arrange mock_agent_config_store.get.return_value = VALID_YAML @@ -372,9 +347,7 @@ async def test_returns_config_with_model_when_found( # Assert assert result.model == "claude-sonnet-4-5-20250929" - async def test_returns_config_with_system_prompt_when_found( - self, use_case, mock_agent_config_store - ): + async def test_returns_config_with_system_prompt_when_found(self, use_case, mock_agent_config_store): """Should return parsed config with the YAML system_prompt.""" # Arrange mock_agent_config_store.get.return_value = VALID_YAML @@ -385,9 +358,7 @@ async def test_returns_config_with_system_prompt_when_found( # Assert assert result.system_prompt == "You are a test agent." - async def test_fetches_yaml_from_store_with_name( - self, use_case, mock_agent_config_store - ): + async def test_fetches_yaml_from_store_with_name(self, use_case, mock_agent_config_store): """Should fetch the YAML from the store with the agent name.""" # Arrange mock_agent_config_store.get.return_value = VALID_YAML @@ -439,9 +410,7 @@ async def test_returns_two_entries_when_two_in_repository( # Assert assert len(result) == 2 - async def test_returns_first_metadata_name( - self, use_case, mock_agent_config_repository, two_metadatas - ): + async def test_returns_first_metadata_name(self, use_case, mock_agent_config_repository, two_metadatas): """Should preserve the order of the first metadata.""" # Arrange mock_agent_config_repository.list_all.return_value = two_metadatas @@ -452,9 +421,7 @@ async def test_returns_first_metadata_name( # Assert assert result[0].name == "agent-a" - async def test_returns_second_metadata_name( - self, use_case, mock_agent_config_repository, two_metadatas - ): + async def test_returns_second_metadata_name(self, use_case, mock_agent_config_repository, two_metadatas): """Should preserve the order of the second metadata.""" # Arrange mock_agent_config_repository.list_all.return_value = two_metadatas @@ -465,9 +432,7 @@ async def test_returns_second_metadata_name( # Assert assert result[1].name == "agent-b" - async def test_queries_repository_when_executed( - self, use_case, mock_agent_config_repository, two_metadatas - ): + async def test_queries_repository_when_executed(self, use_case, mock_agent_config_repository, two_metadatas): """Should call list_all on the repository.""" # Arrange mock_agent_config_repository.list_all.return_value = two_metadatas diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 690cf10..af8210d 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -31,14 +31,10 @@ def test_sslmode_and_channel_binding_stripped_from_url(self): settings = Settings( database_url="postgresql://neondb_owner:password@ep-xxx.neon.tech/neondb?sslmode=require&channel_binding=require" ) - assert settings.database_url == ( - "postgresql+asyncpg://neondb_owner:password@ep-xxx.neon.tech/neondb" - ) + assert settings.database_url == ("postgresql+asyncpg://neondb_owner:password@ep-xxx.neon.tech/neondb") def test_sslmode_extracted_to_property(self): - settings = Settings( - database_url="postgresql://user:pass@host:5432/db?sslmode=require" - ) + settings = Settings(database_url="postgresql://user:pass@host:5432/db?sslmode=require") assert settings.ssl_mode == "require" def test_no_sslmode_returns_none(self): @@ -51,9 +47,7 @@ def test_missing_database_url_raises_validation_error(self, monkeypatch): Settings() def test_other_query_params_preserved(self): - settings = Settings( - database_url="postgresql://user:pass@host:5432/db?sslmode=require&application_name=myapp" - ) + settings = Settings(database_url="postgresql://user:pass@host:5432/db?sslmode=require&application_name=myapp") assert "application_name=myapp" in settings.database_url assert "sslmode" not in settings.database_url diff --git a/tests/unit/test_deep_agent_runner.py b/tests/unit/test_deep_agent_runner.py index 5c1cfb9..a3dc217 100644 --- a/tests/unit/test_deep_agent_runner.py +++ b/tests/unit/test_deep_agent_runner.py @@ -12,21 +12,32 @@ import pytest from src.domain.entities.message import MessageRole, MessageStatus -from src.domain.entities.stream_event import StreamEventType +from src.domain.entities.trace_event import TraceEventType from src.domain.errors.agent import AgentError from src.infrastructure.deepagent.adapter import DeepAgentRunner -from src.infrastructure.deepagent.schema_utils import make_validation_model +from src.infrastructure.deepagent.schema_utils import schema_to_pydantic_model def _make_graph(messages, interrupts=(), state_values=None): - """Create a mock graph with ainvoke result and get_state.""" + """Create a mock graph with astream (empty) and get_state. + + The new runner uses ``astream`` + ``get_state`` instead of ``ainvoke``. + We make ``astream`` yield nothing so the runner falls back to reading + the final state from ``get_state``. + """ mock_graph = AsyncMock() - mock_graph.ainvoke.return_value = {"messages": messages} state = MagicMock() state.interrupts = interrupts - state.values = state_values or {} + state.values = state_values or {"messages": messages} mock_graph.get_state = MagicMock(return_value=state) mock_graph.nodes = {} + + async def _empty_astream(_input, **_kwargs): + return + yield # noqa: F841 — makes this an async generator + + mock_graph.astream = _empty_astream + mock_graph.ainvoke.return_value = {"messages": messages} return mock_graph @@ -44,7 +55,7 @@ async def test_invoke_returns_ai_message(self): # Act runner = DeepAgentRunner(graph) - result = await runner.invoke("thread-1", "Hello") + result, trace = await runner.invoke("thread-1", "Hello", "turn-1") # Assert assert result.role == MessageRole.AI @@ -62,7 +73,7 @@ async def test_invoke_uses_only_last_message_tool_calls(self): # Act runner = DeepAgentRunner(graph) - result = await runner.invoke("thread-1", "count words in hello") + result, trace = await runner.invoke("thread-1", "count words in hello", "turn-1") # Assert assert result.content == "The text has 1 word." @@ -76,7 +87,7 @@ async def test_invoke_detects_hitl_interruption(self): # Act runner = DeepAgentRunner(graph) - result = await runner.invoke("thread-1", "count words") + result, trace = await runner.invoke("thread-1", "count words", "turn-1") # Assert assert result.status == MessageStatus.AWAITING_HITL @@ -88,7 +99,7 @@ async def test_invoke_completed_when_no_interrupts(self): # Act runner = DeepAgentRunner(graph) - result = await runner.invoke("thread-1", "Hello") + result, trace = await runner.invoke("thread-1", "Hello", "turn-1") # Assert assert result.status == MessageStatus.COMPLETED @@ -105,7 +116,7 @@ async def test_invoke_returns_none_tool_calls_when_last_message_empty(self): # Act runner = DeepAgentRunner(graph) - result = await runner.invoke("thread-1", "new question") + result, trace = await runner.invoke("thread-1", "new question", "turn-1") # Assert assert result.content == "New response" @@ -120,22 +131,18 @@ async def test_invoke_raises_agent_error_on_graph_failure(self): # Act & Assert runner = DeepAgentRunner(graph) with pytest.raises(AgentError, match="Agent execution error"): - await runner.invoke("thread-1", "Hello") + await runner.invoke("thread-1", "Hello", "turn-1") class TestInvokeStructuredResponse: async def test_invoke_extracts_structured_response_dict(self): # Arrange msg = _make_msg("Weather report") - graph = _make_graph([msg]) - graph.ainvoke.return_value = { - "messages": [msg], - "structured_response": {"temperature": 22, "condition": "sunny"}, - } + graph = _make_graph([msg], state_values={"messages": [msg], "structured_response": {"temperature": 22, "condition": "sunny"}}) # Act runner = DeepAgentRunner(graph) - result = await runner.invoke("thread-1", "weather?") + result, trace = await runner.invoke("thread-1", "weather?", "turn-1") # Assert assert result.structured_response == {"temperature": 22, "condition": "sunny"} @@ -145,15 +152,11 @@ async def test_invoke_extracts_structured_response_via_model_dump(self): msg = _make_msg("Report") pydantic_obj = MagicMock() pydantic_obj.model_dump.return_value = {"temperature": 15, "condition": "cloudy"} - graph = _make_graph([msg]) - graph.ainvoke.return_value = { - "messages": [msg], - "structured_response": pydantic_obj, - } + graph = _make_graph([msg], state_values={"messages": [msg], "structured_response": pydantic_obj}) # Act runner = DeepAgentRunner(graph) - result = await runner.invoke("thread-1", "weather?") + result, trace = await runner.invoke("thread-1", "weather?", "turn-1") # Assert assert result.structured_response == {"temperature": 15, "condition": "cloudy"} @@ -164,7 +167,7 @@ async def test_invoke_no_structured_response_returns_none(self): # Act runner = DeepAgentRunner(graph) - result = await runner.invoke("thread-1", "hi") + result, trace = await runner.invoke("thread-1", "hi", "turn-1") # Assert assert result.structured_response is None @@ -177,17 +180,13 @@ async def test_invoke_validates_and_strips_extra_top_level_fields(self): "properties": {"name": {"type": "string"}, "age": {"type": "integer"}}, "required": ["name"], } - model = make_validation_model(schema) + model = schema_to_pydantic_model(schema) msg = _make_msg("Result") - graph = _make_graph([msg]) - graph.ainvoke.return_value = { - "messages": [msg], - "structured_response": {"name": "Alice", "age": 30, "terraceArea": 50, "parkingSpaces": 2}, - } + graph = _make_graph([msg], state_values={"messages": [msg], "structured_response": {"name": "Alice", "age": 30, "terraceArea": 50, "parkingSpaces": 2}}) # Act runner = DeepAgentRunner(graph, response_format_model=model) - result = await runner.invoke("thread-1", "analyze") + result, trace = await runner.invoke("thread-1", "analyze", "turn-1") # Assert assert result.structured_response == {"name": "Alice", "age": 30} @@ -206,17 +205,13 @@ async def test_invoke_validates_and_strips_nested_extra_fields(self): }, "required": ["building"], } - model = make_validation_model(schema) + model = schema_to_pydantic_model(schema) msg = _make_msg("Result") - graph = _make_graph([msg]) - graph.ainvoke.return_value = { - "messages": [msg], - "structured_response": {"building": {"floors": 3, "rooftop": True}}, - } + graph = _make_graph([msg], state_values={"messages": [msg], "structured_response": {"building": {"floors": 3, "rooftop": True}}}) # Act runner = DeepAgentRunner(graph, response_format_model=model) - result = await runner.invoke("thread-1", "analyze") + result, trace = await runner.invoke("thread-1", "analyze", "turn-1") # Assert assert result.structured_response == {"building": {"floors": 3}} @@ -224,15 +219,11 @@ async def test_invoke_validates_and_strips_nested_extra_fields(self): async def test_invoke_no_response_format_model_passes_raw(self): # Arrange msg = _make_msg("Result") - graph = _make_graph([msg]) - graph.ainvoke.return_value = { - "messages": [msg], - "structured_response": {"name": "test", "extra": True}, - } + graph = _make_graph([msg], state_values={"messages": [msg], "structured_response": {"name": "test", "extra": True}}) # Act runner = DeepAgentRunner(graph, response_format_model=None) - result = await runner.invoke("thread-1", "analyze") + result, trace = await runner.invoke("thread-1", "analyze", "turn-1") # Assert assert result.structured_response == {"name": "test", "extra": True} @@ -245,16 +236,19 @@ async def test_invoke_validates_structured_response_from_tool_call(self): "properties": {"summary": {"type": "string"}}, "required": ["summary"], } - model = make_validation_model(schema) + model = schema_to_pydantic_model(schema) ai_msg = _make_msg( "Done", tool_calls=[{"name": "structured_response", "args": {"summary": "ok", "hallucinated": 99}, "id": "tc-1"}], ) - graph = _make_graph([ai_msg]) + graph = _make_graph( + [ai_msg], + state_values={"messages": [ai_msg], "structured_response": {"summary": "ok", "hallucinated": 99}}, + ) # Act runner = DeepAgentRunner(graph, response_format_model=model) - result = await runner.invoke("thread-1", "summarize") + result, trace = await runner.invoke("thread-1", "summarize", "turn-1") # Assert assert result.structured_response == {"summary": "ok"} @@ -372,16 +366,16 @@ async def test_invoke_timeout_raises_agent_error(self): graph = AsyncMock() graph.nodes = {} - async def _ainvoke_hang(_input, **_kwargs): + async def _astream_hang(_input, **_kwargs): + yield ("", MagicMock()) await asyncio.sleep(10) - return {"messages": []} - graph.ainvoke = _ainvoke_hang + graph.astream = _astream_hang # Act & Assert - runner = DeepAgentRunner(graph, invoke_timeout=0.05) - with pytest.raises(AgentError, match="timed out"): - await runner.invoke("thread-1", "hello") + runner = DeepAgentRunner(graph, stream_idle_timeout=0.05) + with pytest.raises(AgentError, match="stream idle"): + await runner.invoke("thread-1", "hello", "turn-1") class TestStream: @@ -394,6 +388,7 @@ async def _astream(_input, **_kwargs): chunk = _make_msg("chunk") chunk.type = "AIMessageChunk" chunk.additional_kwargs = {} + chunk.tool_call_chunks = None yield (chunk, MagicMock()) graph.astream = _astream @@ -403,9 +398,10 @@ async def _astream(_input, **_kwargs): # Act runner = DeepAgentRunner(graph) - events = [e async for e in runner.stream("thread-1", "Hi")] + events = [e async for e in runner.stream("thread-1", "Hi", "turn-1")] - # Assert - assert len(events) == 1 - assert events[0].type == StreamEventType.CONTENT - assert events[0].data == "chunk" + # Assert: HUMAN + CONTENT + AI_MESSAGE = 3 + assert len(events) == 3 + content_events = [e for e in events if e.type == TraceEventType.CONTENT] + assert len(content_events) == 1 + assert content_events[0].content == "chunk" diff --git a/tests/unit/test_deep_agent_runner_stream_with_message.py b/tests/unit/test_deep_agent_runner_stream_with_message.py deleted file mode 100644 index 96db1e3..0000000 --- a/tests/unit/test_deep_agent_runner_stream_with_message.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Tests for DeepAgentRunner.stream_with_message. - -The runner is the SUT (internal) and is instantiated for real. -The LangGraph CompiledStateGraph is an external boundary and is mocked. -""" - -import asyncio -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from src.domain.entities.message import Message, MessageRole, MessageStatus -from src.domain.entities.stream_event import StreamEventType -from src.domain.errors.agent import AgentError -from src.infrastructure.deepagent.adapter import DeepAgentRunner - - -def _make_streaming_graph( - chunks: list[tuple[StreamEventType, str]], - final_messages: list | None = None, - interrupts=(), - state_values: dict | None = None, - structured_response=None, -): - mock_graph = AsyncMock() - mock_graph.nodes = {} - - async def _astream(_input, **_kwargs): - for event_type, chunk_text in chunks: - chunk = MagicMock() - chunk.content = chunk_text - chunk.type = "AIMessageChunk" - chunk.additional_kwargs = {} - if event_type == StreamEventType.THINKING: - chunk.additional_kwargs = {"type": "thinking"} - yield chunk, MagicMock() - - mock_graph.astream = _astream - state = MagicMock() - state.interrupts = interrupts - - if final_messages is None: - content = "".join(text for etype, text in chunks if etype == StreamEventType.CONTENT) - final_ai = MagicMock() - final_ai.content = content - final_ai.tool_calls = None - final_messages = [final_ai] - - values = state_values or {} - if "messages" not in values: - values["messages"] = final_messages - if structured_response is not None: - values["structured_response"] = structured_response - state.values = values - - mock_graph.get_state = MagicMock(return_value=state) - mock_graph.ainvoke.return_value = { - "messages": final_messages, - "structured_response": structured_response, - } - return mock_graph - - -class TestStreamWithMessage: - async def test_yields_content_events_then_final_message(self): - # Arrange - chunks = [(StreamEventType.CONTENT, "Hello "), (StreamEventType.CONTENT, "world!")] - graph = _make_streaming_graph(chunks) - - # Act - runner = DeepAgentRunner(graph) - collected = [event async for event in runner.stream_with_message("thread-1", "hi")] - - # Assert - content_events = collected[:-1] - assert all(e.type == StreamEventType.CONTENT for e in content_events) - assert [e.data for e in content_events] == ["Hello ", "world!"] - final_event = collected[-1] - assert final_event.type == StreamEventType.MESSAGE - msg = Message.model_validate_json(final_event.data) - assert msg.role == MessageRole.AI - assert msg.content == "Hello world!" - assert msg.status == MessageStatus.COMPLETED - - async def test_yields_thinking_then_content(self): - # Arrange - chunks = [ - (StreamEventType.THINKING, "Let me think..."), - (StreamEventType.CONTENT, "Here is the answer."), - ] - graph = _make_streaming_graph(chunks) - - # Act - runner = DeepAgentRunner(graph) - collected = [event async for event in runner.stream_with_message("thread-1", "hi")] - - # Assert - events = collected[:-1] - assert events[0].type == StreamEventType.THINKING - assert events[0].data == "Let me think..." - assert events[1].type == StreamEventType.CONTENT - assert events[1].data == "Here is the answer." - msg = Message.model_validate_json(collected[-1].data) - assert msg.thinking == "Let me think..." - assert msg.content == "Here is the answer." - - async def test_final_message_has_tool_calls(self): - # Arrange - chunks = [(StreamEventType.CONTENT, "Processing...")] - ai_msg = MagicMock() - ai_msg.content = "Processing..." - ai_msg.tool_calls = [{"name": "search", "args": {"q": "test"}, "id": "tc-1"}] - graph = _make_streaming_graph(chunks, final_messages=[ai_msg]) - - # Act - runner = DeepAgentRunner(graph) - collected = [event async for event in runner.stream_with_message("thread-1", "search for test")] - - # Assert - msg = Message.model_validate_json(collected[-1].data) - assert msg.tool_calls is not None - assert len(msg.tool_calls) == 1 - assert msg.tool_calls[0]["name"] == "search" - - async def test_final_message_has_structured_response(self): - # Arrange - chunks = [(StreamEventType.CONTENT, "Weather report")] - ai_msg = MagicMock() - ai_msg.content = "Weather report" - ai_msg.tool_calls = None - graph = _make_streaming_graph( - chunks, - final_messages=[ai_msg], - structured_response={"temperature": 22, "condition": "sunny"}, - ) - - # Act - runner = DeepAgentRunner(graph) - collected = [event async for event in runner.stream_with_message("thread-1", "weather?")] - - # Assert - msg = Message.model_validate_json(collected[-1].data) - assert msg.structured_response == {"temperature": 22, "condition": "sunny"} - - async def test_detects_hitl_interrupt(self): - # Arrange - chunks = [(StreamEventType.CONTENT, "Waiting for approval")] - ai_msg = MagicMock() - ai_msg.content = "" - ai_msg.tool_calls = [{"name": "delete_file", "args": {"path": "/tmp/x"}, "id": "tc-1"}] - interrupt = MagicMock() - graph = _make_streaming_graph(chunks, final_messages=[ai_msg], interrupts=(interrupt,)) - - # Act - runner = DeepAgentRunner(graph) - collected = [event async for event in runner.stream_with_message("thread-1", "delete file")] - - # Assert - msg = Message.model_validate_json(collected[-1].data) - assert msg.status == MessageStatus.AWAITING_HITL - - async def test_no_chunks_yields_only_message(self): - # Arrange - graph = _make_streaming_graph([]) - - # Act - runner = DeepAgentRunner(graph) - collected = [event async for event in runner.stream_with_message("thread-1", "hello")] - - # Assert - assert len(collected) == 1 - assert collected[0].type == StreamEventType.MESSAGE - msg = Message.model_validate_json(collected[0].data) - assert msg.role == MessageRole.AI - - async def test_raises_agent_error_on_graph_failure(self): - # Arrange - graph = AsyncMock() - graph.nodes = {} - - async def _astream_error(_input, _config=None, _stream_mode=None): - raise RuntimeError("LLM streaming error") - - graph.astream = _astream_error - - # Act & Assert - runner = DeepAgentRunner(graph) - with pytest.raises(AgentError, match="Streaming error"): - async for _event in runner.stream_with_message("thread-1", "hello"): - pass - - async def test_idle_timeout_raises_agent_error(self): - # Arrange - graph = AsyncMock() - graph.nodes = {} - - async def _astream_hang(_input, **_kwargs): - chunk = MagicMock() - chunk.content = "first" - chunk.type = "AIMessageChunk" - chunk.additional_kwargs = {} - yield chunk, MagicMock() - await asyncio.sleep(10) - - graph.astream = _astream_hang - - # Act & Assert - runner = DeepAgentRunner(graph, stream_idle_timeout=0.05) - with pytest.raises(AgentError, match="idle"): - async for _event in runner.stream_with_message("thread-1", "hello"): - pass diff --git a/tests/unit/test_extract_source.py b/tests/unit/test_extract_source.py new file mode 100644 index 0000000..5da660a --- /dev/null +++ b/tests/unit/test_extract_source.py @@ -0,0 +1,45 @@ +"""Unit tests for DeepAgentRunner._extract_source static method.""" + +from src.infrastructure.deepagent.adapter import DeepAgentRunner + + +class TestExtractSource: + """Tests for _extract_source namespace parsing.""" + + def test_extracts_subagent_name_from_task_namespace(self) -> None: + """Should extract subagent name from 'Agent|task|name|tools' pattern.""" + metadata = {"langgraph_checkpoint_ns": "Agent|task|security-auditor|tools"} + result = DeepAgentRunner._extract_source(metadata) + assert result == "security-auditor" + + def test_returns_none_for_empty_namespace(self) -> None: + """Should return None when namespace is empty.""" + result = DeepAgentRunner._extract_source({"langgraph_checkpoint_ns": ""}) + assert result is None + + def test_returns_none_for_missing_namespace_key(self) -> None: + """Should return None when langgraph_checkpoint_ns key is absent.""" + result = DeepAgentRunner._extract_source({}) + assert result is None + + def test_returns_none_when_task_not_in_namespace(self) -> None: + """Should return None when 'task' token is not found.""" + result = DeepAgentRunner._extract_source( + {"langgraph_checkpoint_ns": "Agent|some-other-path"} + ) + assert result is None + + def test_returns_none_when_task_is_last_token(self) -> None: + """Should return None when 'task' is the last token (no name follows).""" + result = DeepAgentRunner._extract_source( + {"langgraph_checkpoint_ns": "Agent|task"} + ) + assert result is None + + def test_extracts_name_with_multiple_separators(self) -> None: + """Should extract the token immediately after 'task'.""" + metadata = { + "langgraph_checkpoint_ns": "Agent|task|my-agent|some|extra|tokens" + } + result = DeepAgentRunner._extract_source(metadata) + assert result == "my-agent" \ No newline at end of file diff --git a/tests/unit/test_factory.py b/tests/unit/test_factory.py index 1320b67..b6cb801 100644 --- a/tests/unit/test_factory.py +++ b/tests/unit/test_factory.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, patch import pytest +from pydantic import BaseModel from src.domain.entities.agent_config import AgentConfig from src.infrastructure.deepagent.factory import create_agent_from_config @@ -20,6 +21,25 @@ "required": ["temperature", "condition"], } +NESTED_SUBAGENT_SCHEMA = { + "type": "object", + "properties": { + "summary": {"type": "string"}, + "details": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": {"type": "string"}, + "value": {"type": "number"}, + }, + "required": ["label"], + }, + }, + }, + "required": ["summary"], +} + class TestCreateAgentFromConfig: @patch("src.infrastructure.deepagent.factory.create_deep_agent") @@ -145,7 +165,7 @@ async def test_state_backend_omits_backend_kwarg(self, mock_create): class TestResponseFormatIntegration: @patch("src.infrastructure.deepagent.factory.create_deep_agent") - async def test_injects_structured_response_tool_when_response_format_set(self, mock_create): + async def test_passes_response_format_kwarg_when_set(self, mock_create): # Arrange mock_create.return_value = MagicMock() config = AgentConfig(name="test", response_format=WEATHER_SCHEMA) @@ -155,38 +175,80 @@ async def test_injects_structured_response_tool_when_response_format_set(self, m # Assert kwargs = mock_create.call_args.kwargs - assert "response_format" not in kwargs - tool_names = [t.name for t in kwargs["tools"]] - assert "structured_response" in tool_names - assert "structured_response" in kwargs.get("system_prompt", "") + assert "response_format" in kwargs + assert kwargs["response_format"] is not None + assert kwargs["response_format"] == WEATHER_SCHEMA @patch("src.infrastructure.deepagent.factory.create_deep_agent") - async def test_omits_structured_response_tool_when_none(self, mock_create): + async def test_does_not_inject_structured_response_tool_when_set(self, mock_create): # Arrange mock_create.return_value = MagicMock() - config = AgentConfig(name="test") + config = AgentConfig(name="test", response_format=WEATHER_SCHEMA) # Act await create_agent_from_config(config) # Assert kwargs = mock_create.call_args.kwargs - assert "response_format" not in kwargs if kwargs.get("tools"): - tool_names = [t.name for t in kwargs["tools"]] + tool_names = [getattr(t, "name", None) for t in kwargs["tools"]] assert "structured_response" not in tool_names @patch("src.infrastructure.deepagent.factory.create_deep_agent") - async def test_returns_response_format_model_when_set(self, mock_create): + async def test_does_not_append_structured_output_instruction_when_set(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test", response_format=WEATHER_SCHEMA) + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + system_prompt = kwargs.get("system_prompt") or "" + assert "structured_response" not in system_prompt + assert "structured format" not in system_prompt.lower() + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_returns_pydantic_basemodel_subclass_when_set(self, mock_create): # Arrange mock_create.return_value = MagicMock() config = AgentConfig(name="test", response_format=WEATHER_SCHEMA) # Act - graph, model = await create_agent_from_config(config) + _graph, model = await create_agent_from_config(config) # Assert assert model is not None + assert issubclass(model, BaseModel) + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_omits_response_format_kwarg_when_none(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test") + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + assert kwargs.get("response_format") is None + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_omits_structured_response_tool_when_no_response_format(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig(name="test") + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + if kwargs.get("tools"): + tool_names = [getattr(t, "name", None) for t in kwargs["tools"]] + assert "structured_response" not in tool_names @patch("src.infrastructure.deepagent.factory.create_deep_agent") async def test_returns_none_model_when_no_response_format(self, mock_create): @@ -195,7 +257,7 @@ async def test_returns_none_model_when_no_response_format(self, mock_create): config = AgentConfig(name="test") # Act - graph, model = await create_agent_from_config(config) + _graph, model = await create_agent_from_config(config) # Assert assert model is None @@ -203,7 +265,7 @@ async def test_returns_none_model_when_no_response_format(self, mock_create): class TestSubagentStructuredOutput: @patch("src.infrastructure.deepagent.factory.create_deep_agent") - async def test_subagent_with_response_format_gets_structured_tool(self, mock_create): + async def test_subagent_with_response_format_passes_response_format_in_spec(self, mock_create): # Arrange mock_create.return_value = MagicMock() config = AgentConfig( @@ -224,9 +286,82 @@ async def test_subagent_with_response_format_gets_structured_tool(self, mock_cre # Assert kwargs = mock_create.call_args.kwargs subagents = kwargs["subagents"] - tool_names = [t.name for t in subagents[0]["tools"]] - assert "structured_response" in tool_names - assert "structured_response" in subagents[0]["system_prompt"] + assert subagents[0]["response_format"] == WEATHER_SCHEMA + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_subagent_with_response_format_does_not_inject_structured_tool(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig( + name="parent", + subagents=[ + { + "name": "auditor", + "description": "Security auditor", + "instructions": "Analyze code", + "response_format": WEATHER_SCHEMA, + } + ], + ) + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + subagents = kwargs["subagents"] + tools = subagents[0].get("tools") or [] + tool_names = [getattr(t, "name", None) for t in tools] + assert "structured_response" not in tool_names + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_subagent_with_response_format_does_not_append_instruction(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig( + name="parent", + subagents=[ + { + "name": "auditor", + "description": "Security auditor", + "instructions": "Analyze code", + "response_format": WEATHER_SCHEMA, + } + ], + ) + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + subagents = kwargs["subagents"] + system_prompt = subagents[0].get("system_prompt") or "" + assert "structured_response" not in system_prompt + assert "structured format" not in system_prompt.lower() + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_subagent_without_response_format_has_response_format_none(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig( + name="parent", + subagents=[ + { + "name": "helper", + "description": "A helper", + "instructions": "Help the user", + } + ], + ) + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + subagents = kwargs["subagents"] + assert subagents[0].get("response_format") is None @patch("src.infrastructure.deepagent.factory.create_deep_agent") async def test_subagent_without_response_format_has_no_structured_tool(self, mock_create): @@ -249,5 +384,30 @@ async def test_subagent_without_response_format_has_no_structured_tool(self, moc # Assert kwargs = mock_create.call_args.kwargs subagents = kwargs["subagents"] - assert subagents[0]["tools"] is None - assert "structured_response" not in (subagents[0]["system_prompt"] or "") + tools = subagents[0].get("tools") or [] + tool_names = [getattr(t, "name", None) for t in tools] + assert "structured_response" not in tool_names + + @patch("src.infrastructure.deepagent.factory.create_deep_agent") + async def test_subagent_with_nested_response_format_passes_dict_as_is(self, mock_create): + # Arrange + mock_create.return_value = MagicMock() + config = AgentConfig( + name="parent", + subagents=[ + { + "name": "reporter", + "description": "Builds structured reports", + "instructions": "Build a report", + "response_format": NESTED_SUBAGENT_SCHEMA, + } + ], + ) + + # Act + await create_agent_from_config(config) + + # Assert + kwargs = mock_create.call_args.kwargs + subagents = kwargs["subagents"] + assert subagents[0]["response_format"] == NESTED_SUBAGENT_SCHEMA diff --git a/tests/unit/test_get_thread_history.py b/tests/unit/test_get_thread_history.py new file mode 100644 index 0000000..95618fd --- /dev/null +++ b/tests/unit/test_get_thread_history.py @@ -0,0 +1,194 @@ +"""Tests for GetThreadHistoryUseCase (Ticket 3). + +Groups TraceEvents by turn, reconstructs human/ai Messages and filters +intermediate events. +""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from src.application.use_cases.get_thread_history import GetThreadHistoryUseCase +from src.domain.entities.message import Message, MessageRole, MessageStatus +from src.domain.entities.trace_event import TraceEvent, TraceEventType + + +def _event( + thread_id: str, + turn_id: str, + type_: TraceEventType, + content: str, + seq: int, + *, + timestamp: datetime | None = None, +) -> TraceEvent: + return TraceEvent( + id=str(uuid4()), + thread_id=thread_id, + turn_id=turn_id, + type=type_, + content=content, + timestamp=timestamp or datetime.now(UTC), + sequence=seq, + ) + + +class TestGetThreadHistoryUseCase: + @pytest.fixture + def use_case(self, thread_repo, trace_repo): + return GetThreadHistoryUseCase(thread_repo, trace_repo) + + async def test_execute_returns_history_grouped_by_turn(self, use_case, thread_repo, trace_repo): + # Arrange — thread with 2 turns, each with HUMAN + intermediate + AI + thread = await thread_repo.create("test-agent") + final1 = Message(role=MessageRole.AI, content="answer1", status=MessageStatus.COMPLETED) + final2 = Message(role=MessageRole.AI, content="answer2", status=MessageStatus.COMPLETED) + base = datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC) + # Turn 1 + await trace_repo.add( + thread.id, + _event(thread.id, "turn-1", TraceEventType.HUMAN_MESSAGE, "q1", seq=0, timestamp=base), + ) + await trace_repo.add( + thread.id, + _event(thread.id, "turn-1", TraceEventType.THINKING, "hmm", seq=1, timestamp=base), + ) + await trace_repo.add( + thread.id, + _event(thread.id, "turn-1", TraceEventType.AI_MESSAGE, final1.model_dump_json(), seq=2, timestamp=base), + ) + # Turn 2 + await trace_repo.add( + thread.id, + _event(thread.id, "turn-2", TraceEventType.HUMAN_MESSAGE, "q2", seq=0, timestamp=base), + ) + await trace_repo.add( + thread.id, + _event(thread.id, "turn-2", TraceEventType.CONTENT, "chunk", seq=1, timestamp=base), + ) + await trace_repo.add( + thread.id, + _event(thread.id, "turn-2", TraceEventType.AI_MESSAGE, final2.model_dump_json(), seq=2, timestamp=base), + ) + + # Act + history = await use_case.execute(thread.id) + + # Assert + assert history.thread.id == thread.id + assert len(history.turns) == 2 + # Turn ordering is not guaranteed by dict; verify both turn_ids present + turn_ids = {t.turn_id for t in history.turns} + assert turn_ids == {"turn-1", "turn-2"} + for turn in history.turns: + assert turn.human_message is not None + assert turn.human_message.role == MessageRole.HUMAN + assert turn.ai_message is not None + assert turn.ai_message.role == MessageRole.AI + # Intermediate events: only THINKING / CONTENT (no HUMAN/AI) + types = {e.type for e in turn.events} + assert TraceEventType.HUMAN_MESSAGE not in types + assert TraceEventType.AI_MESSAGE not in types + assert len(turn.events) == 1 # one intermediate per turn + + async def test_execute_turn_without_ai_message(self, use_case, thread_repo, trace_repo): + # Arrange — turn crashed mid-run: only HUMAN + THINKING, no AI + thread = await thread_repo.create("test-agent") + await trace_repo.add( + thread.id, + _event(thread.id, "turn-1", TraceEventType.HUMAN_MESSAGE, "q1", seq=0), + ) + await trace_repo.add( + thread.id, + _event(thread.id, "turn-1", TraceEventType.THINKING, "hmm", seq=1), + ) + + # Act + history = await use_case.execute(thread.id) + + # Assert + assert len(history.turns) == 1 + turn = history.turns[0] + assert turn.human_message is not None + assert turn.human_message.content == "q1" + assert turn.ai_message is None # crashed before AI_MESSAGE + # intermediate THINKING is still listed + assert len(turn.events) == 1 + assert turn.events[0].type == TraceEventType.THINKING + + async def test_execute_empty_thread_returns_no_turns(self, use_case, thread_repo): + # Arrange + thread = await thread_repo.create("test-agent") + + # Act + history = await use_case.execute(thread.id) + + # Assert + assert history.thread.id == thread.id + assert history.turns == [] + + async def test_execute_orders_turns_chronologically_by_timestamp(self, use_case, thread_repo, trace_repo): + """Turns must be ordered by timestamp, not by turn_id (which is a random UUID v4).""" + thread = await thread_repo.create("test-agent") + final = Message(role=MessageRole.AI, content="ans", status=MessageStatus.COMPLETED) + # Turn B has a random-looking turn_id that sorts BEFORE turn A alphabetically, + # but turn B happened LATER in time. The use case must order by timestamp. + early = datetime(2025, 1, 1, 10, 0, 0, tzinfo=UTC) + late = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) + # Turn A (earlier) — turn_id "zzzz" sorts AFTER "aaaa" alphabetically + await trace_repo.add( + thread.id, + _event(thread.id, "zzzz-first-chronologically", TraceEventType.HUMAN_MESSAGE, "early q", seq=0, timestamp=early), + ) + await trace_repo.add( + thread.id, + _event(thread.id, "zzzz-first-chronologically", TraceEventType.AI_MESSAGE, final.model_dump_json(), seq=1, timestamp=early), + ) + # Turn B (later) — turn_id "aaaa" sorts BEFORE "zzzz" alphabetically + await trace_repo.add( + thread.id, + _event(thread.id, "aaaa-second-chronologically", TraceEventType.HUMAN_MESSAGE, "late q", seq=0, timestamp=late), + ) + await trace_repo.add( + thread.id, + _event(thread.id, "aaaa-second-chronologically", TraceEventType.AI_MESSAGE, final.model_dump_json(), seq=1, timestamp=late), + ) + + history = await use_case.execute(thread.id) + + assert len(history.turns) == 2 + # The first turn must be the one with the earlier timestamp, even if + # its turn_id sorts alphabetically after the other. + assert history.turns[0].human_message is not None + assert history.turns[0].human_message.content == "early q" + assert history.turns[1].human_message is not None + assert history.turns[1].human_message.content == "late q" + # Arrange — insert events out of sequence order; use_case must sort by sequence + thread = await thread_repo.create("test-agent") + final = Message(role=MessageRole.AI, content="ans", status=MessageStatus.COMPLETED) + # Insert AI first (seq=2), then intermediate (seq=1), then HUMAN (seq=0) + await trace_repo.add( + thread.id, + _event(thread.id, "turn-1", TraceEventType.AI_MESSAGE, final.model_dump_json(), seq=2), + ) + await trace_repo.add( + thread.id, + _event(thread.id, "turn-1", TraceEventType.CONTENT, "chunk", seq=1), + ) + await trace_repo.add( + thread.id, + _event(thread.id, "turn-1", TraceEventType.HUMAN_MESSAGE, "q1", seq=0), + ) + + # Act + history = await use_case.execute(thread.id) + + # Assert + assert len(history.turns) == 1 + turn = history.turns[0] + assert turn.human_message is not None + assert turn.ai_message is not None + # Intermediate events sorted by sequence (CONTENT seq=1) + assert len(turn.events) == 1 + assert turn.events[0].sequence == 1 diff --git a/tests/unit/test_mcp_server_config.py b/tests/unit/test_mcp_server_config.py index e60a02b..9ad0353 100644 --- a/tests/unit/test_mcp_server_config.py +++ b/tests/unit/test_mcp_server_config.py @@ -61,9 +61,7 @@ def test_http_without_url_raises(self): def test_frozen_immutability_blocks_assignment(self): # Arrange - config = McpServerConfig( - name="test", transport=McpTransportType.STDIO, command="echo" - ) + config = McpServerConfig(name="test", transport=McpTransportType.STDIO, command="echo") # Act & Assert with pytest.raises(ValidationError): diff --git a/tests/unit/test_noop_tracing.py b/tests/unit/test_noop_tracing.py index e4a92a9..ca45291 100644 --- a/tests/unit/test_noop_tracing.py +++ b/tests/unit/test_noop_tracing.py @@ -4,7 +4,6 @@ """ - class TestNoopTracingProvider: def test_get_callbacks_returns_empty_list(self, noop_tracing): # Arrange diff --git a/tests/unit/test_phoenix_prompt_manager.py b/tests/unit/test_phoenix_prompt_manager.py index 33bb529..11d7acf 100644 --- a/tests/unit/test_phoenix_prompt_manager.py +++ b/tests/unit/test_phoenix_prompt_manager.py @@ -95,9 +95,7 @@ class TestPhoenixPromptManagerProviderGet: @pytest.fixture def manager(self): with patch("src.infrastructure.prompt_management.adapter.Client") as mock_client_cls: - prompt_obj = _make_phoenix_prompt_obj( - messages=[{"role": "system", "content": "Hello"}] - ) + prompt_obj = _make_phoenix_prompt_obj(messages=[{"role": "system", "content": "Hello"}]) client = _make_client_mock(prompt_obj, tags_list=[{"name": "production"}]) mock_client_cls.return_value = client return PhoenixPromptManagerProvider(base_url="http://localhost:6006", api_key="test-key") diff --git a/tests/unit/test_postgres_thread_repository.py b/tests/unit/test_postgres_thread_repository.py index 72d6193..8f66c0d 100644 --- a/tests/unit/test_postgres_thread_repository.py +++ b/tests/unit/test_postgres_thread_repository.py @@ -1,29 +1,67 @@ """Tests for PostgresThreadRepository against a real in-memory SQLite engine.""" +import json from datetime import UTC, datetime import pytest -from src.domain.entities.message import Message, MessageRole, MessageStatus +from src.domain.entities.message import MessageRole, MessageStatus from src.domain.entities.thread import Thread +from src.domain.entities.trace_event import TraceEvent, TraceEventType from src.domain.errors.thread import ThreadNotFoundError +def _make_trace_event( + thread_id: str, + turn_id: str, + type_: TraceEventType, + *, + content: str | None = None, + sequence: int = 0, + timestamp: datetime | None = None, + source: str | None = None, + name: str | None = None, + metadata: dict | None = None, +) -> TraceEvent: + from uuid import uuid4 + + return TraceEvent( + id=str(uuid4()), + thread_id=thread_id, + turn_id=turn_id, + type=type_, + content=content, + source=source, + name=name, + metadata=metadata, + timestamp=timestamp or datetime.now(UTC), + sequence=sequence, + ) + + class TestPostgresThreadRepository: - async def test_create_returns_thread_with_empty_messages(self, thread_repo): + async def test_create_returns_thread_with_empty_trace_events(self, thread_repo): # Act result = await thread_repo.create("test-agent") # Assert assert isinstance(result, Thread) assert result.agent_name == "test-agent" - assert result.messages == [] + assert result.trace_events == [] assert result.id is not None - async def test_get_returns_persisted_thread_with_messages(self, thread_repo): + async def test_get_returns_persisted_thread_with_messages_reconstructed(self, thread_repo, trace_repo): # Arrange created = await thread_repo.create("test-agent") - await thread_repo.add_message(created.id, Message(role=MessageRole.HUMAN, content="hello")) + await trace_repo.add( + created.id, + _make_trace_event( + created.id, + "turn-1", + TraceEventType.HUMAN_MESSAGE, + content="hello", + ), + ) # Act result = await thread_repo.get(created.id) @@ -32,8 +70,12 @@ async def test_get_returns_persisted_thread_with_messages(self, thread_repo): assert isinstance(result, Thread) assert result.id == created.id assert result.agent_name == "test-agent" + assert len(result.trace_events) == 1 + assert result.trace_events[0].content == "hello" + # Backward compat: messages computed from trace_events assert len(result.messages) == 1 assert result.messages[0].content == "hello" + assert result.messages[0].role == MessageRole.HUMAN async def test_get_not_found_raises(self, thread_repo): # Arrange @@ -86,35 +128,33 @@ async def test_delete_not_found_raises(self, thread_repo): with pytest.raises(ThreadNotFoundError): await thread_repo.delete("nonexistent-id") - async def test_add_message_returns_updated_thread(self, thread_repo): - # Arrange - created = await thread_repo.create("test-agent") - message = Message(role=MessageRole.HUMAN, content="Hello, world!") - - # Act - result = await thread_repo.add_message(created.id, message) - - # Assert - assert isinstance(result, Thread) - assert len(result.messages) == 1 - assert result.messages[0].content == "Hello, world!" - assert result.messages[0].role == MessageRole.HUMAN - - async def test_add_message_not_found_raises(self, thread_repo): - # Arrange - message = Message(role=MessageRole.HUMAN, content="Hello") - - # Act / Assert - with pytest.raises(ThreadNotFoundError): - await thread_repo.add_message("nonexistent-id", message) - - async def test_add_message_orders_messages_by_timestamp(self, thread_repo): + async def test_messages_ordered_by_timestamp_via_trace_events(self, thread_repo, trace_repo): # Arrange created = await thread_repo.create("test-agent") earlier = datetime(2025, 1, 1, 10, 0, 0, tzinfo=UTC) later = datetime(2025, 1, 1, 11, 0, 0, tzinfo=UTC) - await thread_repo.add_message(created.id, Message(role=MessageRole.AI, content="late", timestamp=later)) - await thread_repo.add_message(created.id, Message(role=MessageRole.HUMAN, content="early", timestamp=earlier)) + await trace_repo.add( + created.id, + _make_trace_event( + created.id, + "turn-1", + TraceEventType.AI_MESSAGE, + content=json.dumps({"content": "late"}), + sequence=1, + timestamp=later, + ), + ) + await trace_repo.add( + created.id, + _make_trace_event( + created.id, + "turn-1", + TraceEventType.HUMAN_MESSAGE, + content="early", + sequence=0, + timestamp=earlier, + ), + ) # Act result = await thread_repo.get(created.id) @@ -122,34 +162,29 @@ async def test_add_message_orders_messages_by_timestamp(self, thread_repo): # Assert assert [m.content for m in result.messages] == ["early", "late"] - async def test_add_message_updates_thread_updated_at(self, thread_repo): - # Arrange - created = await thread_repo.create("test-agent") - before = (await thread_repo.get(created.id)).updated_at - message = Message(role=MessageRole.AI, content="Response") - - # Act - await thread_repo.add_message(created.id, message) - - # Assert - after = (await thread_repo.get(created.id)).updated_at - assert after >= before - - async def test_message_serialization_roundtrip_preserves_all_fields(self, thread_repo): + async def test_ai_message_roundtrip_preserves_all_fields(self, thread_repo, trace_repo): # Arrange created = await thread_repo.create("analyzer") now = datetime.now(UTC) - original = Message( - role=MessageRole.AI, - content="Analysis complete", - timestamp=now, - tool_calls=None, - status=MessageStatus.COMPLETED, - structured_response={"score": 95, "label": "pass"}, + payload = { + "content": "Analysis complete", + "tool_calls": None, + "status": "completed", + "structured_response": {"score": 95, "label": "pass"}, + } + await trace_repo.add( + created.id, + _make_trace_event( + created.id, + "turn-1", + TraceEventType.AI_MESSAGE, + content=json.dumps(payload), + sequence=0, + timestamp=now, + ), ) # Act - await thread_repo.add_message(created.id, original) result = await thread_repo.get(created.id) # Assert @@ -159,21 +194,29 @@ async def test_message_serialization_roundtrip_preserves_all_fields(self, thread assert roundtripped.status == MessageStatus.COMPLETED assert roundtripped.structured_response == {"score": 95, "label": "pass"} - async def test_message_with_tool_calls_jsonb_survives_roundtrip(self, thread_repo): + async def test_ai_message_with_tool_calls_survives_roundtrip(self, thread_repo, trace_repo): # Arrange created = await thread_repo.create("search-agent") tool_calls_data = [ {"name": "search_documents", "args": {"query": "python asyncio", "limit": 10}, "id": "call_abc123"}, {"name": "fetch_url", "args": {"url": "https://docs.python.org"}, "id": "call_def456"}, ] - original = Message( - role=MessageRole.AI, - content="Let me search for that.", - tool_calls=tool_calls_data, + payload = { + "content": "Let me search for that.", + "tool_calls": tool_calls_data, + } + await trace_repo.add( + created.id, + _make_trace_event( + created.id, + "turn-1", + TraceEventType.AI_MESSAGE, + content=json.dumps(payload), + sequence=0, + ), ) # Act - await thread_repo.add_message(created.id, original) result = await thread_repo.get(created.id) # Assert diff --git a/tests/unit/test_routes.py b/tests/unit/test_routes.py index 75565f9..78f15f0 100644 --- a/tests/unit/test_routes.py +++ b/tests/unit/test_routes.py @@ -12,6 +12,7 @@ import json from datetime import UTC, datetime from unittest.mock import AsyncMock +from uuid import uuid4 import pytest from httpx import ASGITransport, AsyncClient @@ -22,6 +23,7 @@ from src.application.use_cases.delete_thread import DeleteThreadUseCase from src.application.use_cases.get_agent_config import GetAgentConfigUseCase from src.application.use_cases.get_thread import GetThreadUseCase +from src.application.use_cases.get_thread_history import GetThreadHistoryUseCase from src.application.use_cases.list_agent_configs import ListAgentConfigsUseCase from src.application.use_cases.list_threads import ListThreadsUseCase from src.application.use_cases.send_message import SendMessageUseCase @@ -33,16 +35,18 @@ get_delete_agent_config_use_case, get_delete_thread_use_case, get_get_agent_config_use_case, + get_get_thread_history_use_case, get_get_thread_use_case, get_list_agent_configs_use_case, get_list_threads_use_case, get_send_message_use_case, get_stream_message_use_case, + get_trace_event_repository, get_update_agent_config_use_case, ) from src.domain.entities.agent_config_metadata import AgentConfigMetadata from src.domain.entities.message import Message, MessageRole, MessageStatus -from src.domain.entities.stream_event import StreamEvent, StreamEventType +from src.domain.entities.trace_event import TraceEvent, TraceEventType from src.domain.errors.agent import AgentError from src.domain.ports.agent_registry import AgentRegistry from src.domain.ports.agent_runner import AgentRunner @@ -96,9 +100,20 @@ async def close(self) -> None: def mock_runner(): """AsyncMock for the agent runner (external LLM boundary).""" runner = AsyncMock(spec=AgentRunner) - runner.invoke.return_value = Message( - role=MessageRole.AI, content="I am a mock agent.", status=MessageStatus.COMPLETED - ) + final_msg = Message(role=MessageRole.AI, content="I am a mock agent.", status=MessageStatus.COMPLETED) + + async def _invoke(_thread_id: str, _message: str, _turn_id: str): + # Return (Message, list[TraceEvent]) with the real thread_id/turn_id + # so persistence FK constraints are satisfied. + return ( + final_msg, + [ + _trace_event(_thread_id, _turn_id, TraceEventType.HUMAN_MESSAGE, _message, seq=0), + _trace_event(_thread_id, _turn_id, TraceEventType.AI_MESSAGE, final_msg.model_dump_json(), seq=1), + ], + ) + + runner.invoke.side_effect = _invoke runner.approve_hitl.return_value = Message( role=MessageRole.AI, content="Action approved.", status=MessageStatus.COMPLETED ) @@ -109,29 +124,42 @@ def mock_runner(): role=MessageRole.AI, content="Action edited and approved.", status=MessageStatus.COMPLETED ) - async def mock_stream(_thread_id, _message): - for word in ["I", "am", "a", "mock", "agent."]: - yield StreamEvent(type=StreamEventType.CONTENT, data=word + " ") - - runner.stream = mock_stream - - async def mock_stream_with_message(_thread_id, _message): + async def mock_stream(_thread_id, _message, _turn_id): + # Yield a full TraceEvent sequence: HUMAN_MESSAGE, intermediates, AI_MESSAGE. + yield _trace_event(_thread_id, _turn_id, TraceEventType.HUMAN_MESSAGE, _message, seq=0) + yield _trace_event(_thread_id, _turn_id, TraceEventType.THINKING, "hmm", seq=1) for word in ["I", "am", "a", "mock", "agent."]: - yield StreamEvent(type=StreamEventType.CONTENT, data=word + " ") - yield StreamEvent( - type=StreamEventType.MESSAGE, - data=Message( + yield _trace_event(_thread_id, _turn_id, TraceEventType.CONTENT, word + " ", seq=2) + yield _trace_event( + _thread_id, + _turn_id, + TraceEventType.AI_MESSAGE, + Message( role=MessageRole.AI, content="I am a mock agent.", status=MessageStatus.COMPLETED, structured_response={"key": "value"}, ).model_dump_json(), + seq=3, ) - runner.stream_with_message = mock_stream_with_message + runner.stream = mock_stream return runner +def _trace_event(thread_id: str, turn_id: str, type_: TraceEventType, content: str, seq: int) -> TraceEvent: + """Helper to build a TraceEvent for tests.""" + return TraceEvent( + id=str(uuid4()), + thread_id=thread_id, + turn_id=turn_id, + type=type_, + content=content, + timestamp=datetime.now(UTC), + sequence=seq, + ) + + @pytest.fixture def stub_registry(mock_runner): return StubAgentRegistry(AGENTS, mock_runner) @@ -172,15 +200,18 @@ def mock_config_repository(): @pytest.fixture(autouse=True) -def _override_dependencies(stub_registry, thread_repo, mock_config_store, mock_config_repository): +def _override_dependencies(stub_registry, thread_repo, trace_repo, mock_config_store, mock_config_repository): """Wire real internal components + mocked runner via app.dependency_overrides.""" yaml_loader = YamlAgentConfigLoader() def _send_message(): - return SendMessageUseCase(stub_registry, thread_repo) + return SendMessageUseCase(stub_registry, thread_repo, trace_repo) def _stream_message(): - return StreamMessageUseCase(stub_registry, thread_repo) + return StreamMessageUseCase(stub_registry, thread_repo, trace_repo) + + def _get_thread_history(): + return GetThreadHistoryUseCase(thread_repo, trace_repo) def _create_thread(): return CreateThreadUseCase(thread_repo, stub_registry) @@ -215,6 +246,8 @@ def _delete_agent_config(): app.dependency_overrides[get_send_message_use_case] = _send_message app.dependency_overrides[get_stream_message_use_case] = _stream_message + app.dependency_overrides[get_get_thread_history_use_case] = _get_thread_history + app.dependency_overrides[get_trace_event_repository] = lambda: trace_repo app.dependency_overrides[get_create_thread_use_case] = _create_thread app.dependency_overrides[get_get_thread_use_case] = _get_thread app.dependency_overrides[get_list_threads_use_case] = _list_threads @@ -346,14 +379,21 @@ async def test_list_messages_after_chat_returns_human_and_ai(self, client): # Arrange create_resp = await client.post("/api/v1/threads", json={"agent_name": "my-agent"}) thread_id = create_resp.json()["id"] - await client.post(f"/api/v1/chat/{thread_id}", json={"message": "Hello"}) # Act - resp = await client.get(f"/api/v1/threads/{thread_id}/messages") + chat_resp = await client.post(f"/api/v1/chat/{thread_id}", json={"message": "Hello"}) + msgs_resp = await client.get(f"/api/v1/threads/{thread_id}/messages") # Assert - assert resp.status_code == 200 - assert len(resp.json()) == 2 + assert chat_resp.status_code == 200 + assert chat_resp.json()["role"] == "ai" + assert msgs_resp.status_code == 200 + messages = msgs_resp.json() + assert len(messages) == 2 + assert messages[0]["role"] == "human" + assert messages[0]["content"] == "Hello" + assert messages[1]["role"] == "ai" + assert messages[1]["content"] == "I am a mock agent." # -- Chat ----------------------------------------------------------------------- @@ -372,9 +412,10 @@ async def test_send_message_returns_ai_message(self, client): # Assert assert resp.status_code == 200 - data = resp.json() - assert data["role"] == "ai" - assert "content" in data + body = resp.json() + assert body["role"] == "ai" + assert body["content"] == "I am a mock agent." + assert body["status"] == "completed" async def test_send_message_thread_not_found_returns_404(self, client): # Arrange @@ -442,7 +483,7 @@ async def test_approve_returns_200_with_approved_content(self, client): # Assert assert resp.status_code == 200 - assert "approved" in resp.json()["content"].lower() + assert resp.json()["content"] == "Action approved." async def test_reject_returns_200_with_rejected_content(self, client): # Arrange @@ -457,7 +498,7 @@ async def test_reject_returns_200_with_rejected_content(self, client): # Assert assert resp.status_code == 200 - assert "rejected" in resp.json()["content"].lower() + assert resp.json()["content"] == "Action rejected: Too risky" async def test_edit_returns_200_with_edited_content(self, client): # Arrange @@ -472,7 +513,7 @@ async def test_edit_returns_200_with_edited_content(self, client): # Assert assert resp.status_code == 200 - assert "edited" in resp.json()["content"].lower() + assert resp.json()["content"] == "Action edited and approved." async def test_edit_without_edits_returns_422(self, client): # Arrange @@ -583,20 +624,17 @@ async def test_agent_error_returns_502(self, client, mock_runner): assert resp.status_code == 502 assert "Backend failed" in resp.json()["detail"] - # Reset for other tests + # Reset for other tests — restore the side_effect-based invoke. mock_runner.invoke.side_effect = None - mock_runner.invoke.return_value = Message( - role=MessageRole.AI, content="I am a mock agent.", status=MessageStatus.COMPLETED - ) # -- Stream Message Event ------------------------------------------------------ class TestStreamMessageEvent: - """Tests for SSE stream: JSON message then [DONE] terminator.""" + """Tests for SSE stream: TraceEvents then [DONE] terminator.""" - async def test_stream_ends_with_done(self, client): + async def test_stream_yields_trace_events_then_done(self, client): # Arrange create_resp = await client.post("/api/v1/threads", json={"agent_name": "my-agent"}) thread_id = create_resp.json()["id"] @@ -609,10 +647,21 @@ async def test_stream_ends_with_done(self, client): # Assert assert resp.status_code == 200 + assert "text/event-stream" in resp.headers["content-type"] data_lines = _extract_data_lines(resp.text) + # Last line is [DONE] assert data_lines[-1] == "[DONE]" - - async def test_stream_emits_structured_event_before_done(self, client): + # Parse the trace events: HUMAN_MESSAGE, THINKING, CONTENT..., AI_MESSAGE + event_types: list[str] = [] + for line in data_lines[:-1]: + event = json.loads(line) + event_types.append(event["type"]) + assert event_types[0] == TraceEventType.HUMAN_MESSAGE.value + assert event_types[-1] == TraceEventType.AI_MESSAGE.value + assert TraceEventType.THINKING.value in event_types + assert TraceEventType.CONTENT.value in event_types + + async def test_stream_emits_ai_message_event(self, client): # Arrange create_resp = await client.post("/api/v1/threads", json={"agent_name": "my-agent"}) thread_id = create_resp.json()["id"] @@ -626,34 +675,86 @@ async def test_stream_emits_structured_event_before_done(self, client): # Assert assert resp.status_code == 200 data_lines = _extract_data_lines(resp.text) - assert data_lines[-1] == "[DONE]" - structured_event = _find_event(data_lines[:-1], "structured") - assert structured_event is not None - assert json.loads(structured_event["data"]) == {"key": "value"} + ai_event = _find_event(data_lines, TraceEventType.AI_MESSAGE.value) + assert ai_event is not None + # AI_MESSAGE content is a JSON-serialized Message payload + payload = json.loads(ai_event["content"]) + assert payload["content"] == "I am a mock agent." + assert payload["status"] == "completed" + + +class TestThreadHistoryRoute: + """Tests for GET /api/v1/threads/{id}/history.""" + + async def test_history_returns_thread_history(self, client): + # Arrange — send a message first so trace events exist + create_resp = await client.post("/api/v1/threads", json={"agent_name": "my-agent"}) + thread_id = create_resp.json()["id"] + await client.post(f"/api/v1/chat/{thread_id}", json={"message": "Hello"}) - async def test_stream_message_format_matches_sync(self, client): + # Act + resp = await client.get(f"/api/v1/threads/{thread_id}/history") + + # Assert + assert resp.status_code == 200 + body = resp.json() + assert body["thread"]["id"] == thread_id + assert len(body["turns"]) == 1 + turn = body["turns"][0] + assert turn["human_message"] is not None + assert turn["human_message"]["role"] == "human" + assert turn["ai_message"] is not None + assert turn["ai_message"]["role"] == "ai" + # Intermediate events (THINKING/CONTENT) present, no HUMAN/AI duplicates + types = {e["type"] for e in turn["events"]} + assert "human_message" not in types + assert "ai_message" not in types + + async def test_history_empty_thread_returns_no_turns(self, client): # Arrange create_resp = await client.post("/api/v1/threads", json={"agent_name": "my-agent"}) thread_id = create_resp.json()["id"] # Act - sync_resp = await client.post(f"/api/v1/chat/{thread_id}", json={"message": "Compare me"}) - create_resp2 = await client.post("/api/v1/threads", json={"agent_name": "my-agent"}) - thread_id2 = create_resp2.json()["id"] - stream_resp = await client.post( - f"/api/v1/chat/{thread_id2}/stream", - json={"message": "Hello agent"}, - ) + resp = await client.get(f"/api/v1/threads/{thread_id}/history") + + # Assert + assert resp.status_code == 200 + assert resp.json()["turns"] == [] + + +class TestTraceRoute: + """Tests for GET /api/v1/threads/{id}/trace (flat list of trace events).""" + + async def test_trace_endpoint_returns_events(self, client): + # Arrange — send a message first so trace events exist + create_resp = await client.post("/api/v1/threads", json={"agent_name": "my-agent"}) + thread_id = create_resp.json()["id"] + await client.post(f"/api/v1/chat/{thread_id}", json={"message": "Hello"}) + + # Act + resp = await client.get(f"/api/v1/threads/{thread_id}/trace") # Assert - assert sync_resp.status_code == 200 - assert stream_resp.status_code == 200 - data_lines = _extract_data_lines(stream_resp.text) - content_events = [ln for ln in data_lines if ln != "[DONE]" and json.loads(ln).get("type") == "content"] - structured_event = _find_event([ln for ln in data_lines if ln != "[DONE]"], "structured") - assert len(content_events) > 0 - assert structured_event is not None - assert sync_resp.json()["role"] == "ai" + assert resp.status_code == 200 + body = resp.json() + assert "events" in body + assert len(body["events"]) >= 2 + types = [e["type"] for e in body["events"]] + assert "human_message" in types + assert "ai_message" in types + + async def test_trace_endpoint_empty_thread(self, client): + # Arrange + create_resp = await client.post("/api/v1/threads", json={"agent_name": "my-agent"}) + thread_id = create_resp.json()["id"] + + # Act + resp = await client.get(f"/api/v1/threads/{thread_id}/trace") + + # Assert + assert resp.status_code == 200 + assert resp.json()["events"] == [] def _extract_data_lines(body: str) -> list[str]: diff --git a/tests/unit/test_runner_tracing.py b/tests/unit/test_runner_tracing.py index 8219ad3..b0668e9 100644 --- a/tests/unit/test_runner_tracing.py +++ b/tests/unit/test_runner_tracing.py @@ -8,13 +8,48 @@ from unittest.mock import AsyncMock, MagicMock -from src.domain.entities.stream_event import StreamEventType +from src.domain.entities.trace_event import TraceEventType from src.infrastructure.deepagent.adapter import DeepAgentRunner -def _config_from_call(mock_ainvoke): - """Extract the config dict from a mock ainvoke call.""" - call = mock_ainvoke.call_args +def _msg(content="Hello"): + msg = MagicMock() + msg.content = content + msg.tool_calls = None + return msg + + +def _streaming_graph(tracing_provider=None): # noqa: ARG001 + """Build a graph whose astream yields a single content chunk. + + Returns the graph and the astream MagicMock wrapper so tests can inspect + the config passed to the underlying call. + """ + graph = AsyncMock() + graph.nodes = {} + astream_mock = MagicMock() + + async def _astream(_input, **kwargs): + astream_mock(_input, **kwargs) + chunk = MagicMock() + chunk.content = "chunk" + chunk.type = "AIMessageChunk" + chunk.additional_kwargs = {} + chunk.tool_call_chunks = None + yield (chunk, {"langgraph_checkpoint_ns": "Agent"}) + + graph.astream = _astream + final = _msg("chunk") + state = MagicMock() + state.values = {"messages": [final]} + state.interrupts = () + graph.get_state = MagicMock(return_value=state) + return graph, astream_mock + + +def _config_from_call(astream_mock): + """Extract the config dict from the recorded astream call.""" + call = astream_mock.call_args return call[1]["config"] if "config" in call[1] else call[0][1] @@ -23,57 +58,39 @@ async def test_invoke_with_tracing_injects_callbacks(self, mock_tracing_provider # Arrange mock_callback = MagicMock() mock_tracing_provider.get_callbacks.return_value = [mock_callback] - graph = AsyncMock() - graph.nodes = {} - msg = MagicMock() - msg.content = "Hello" - msg.tool_calls = None - graph.ainvoke.return_value = {"messages": [msg]} - graph.get_state = MagicMock(return_value=MagicMock(interrupts=())) + graph, astream = _streaming_graph() # Act runner = DeepAgentRunner(graph, tracing_provider=mock_tracing_provider) - await runner.invoke("thread-1", "Hi") + await runner.invoke("thread-1", "Hi", "turn-1") # Assert - config = _config_from_call(graph.ainvoke) + config = _config_from_call(astream) assert "callbacks" in config assert mock_callback in config["callbacks"] async def test_invoke_without_tracing_has_no_callbacks(self): # Arrange - graph = AsyncMock() - graph.nodes = {} - msg = MagicMock() - msg.content = "Hello" - msg.tool_calls = None - graph.ainvoke.return_value = {"messages": [msg]} - graph.get_state = MagicMock(return_value=MagicMock(interrupts=())) + graph, astream = _streaming_graph() # Act runner = DeepAgentRunner(graph) - await runner.invoke("thread-1", "Hi") + await runner.invoke("thread-1", "Hi", "turn-1") # Assert - config = _config_from_call(graph.ainvoke) + config = _config_from_call(astream) assert "callbacks" not in config async def test_invoke_with_noop_tracing_has_no_callbacks(self, noop_tracing): # Arrange - graph = AsyncMock() - graph.nodes = {} - msg = MagicMock() - msg.content = "Hello" - msg.tool_calls = None - graph.ainvoke.return_value = {"messages": [msg]} - graph.get_state = MagicMock(return_value=MagicMock(interrupts=())) + graph, astream = _streaming_graph() # Act runner = DeepAgentRunner(graph, tracing_provider=noop_tracing) - await runner.invoke("thread-1", "Hi") + await runner.invoke("thread-1", "Hi", "turn-1") # Assert - config = _config_from_call(graph.ainvoke) + config = _config_from_call(astream) assert "callbacks" not in config @@ -82,29 +99,19 @@ async def test_stream_with_tracing_returns_content_events(self, mock_tracing_pro # Arrange mock_callback = MagicMock() mock_tracing_provider.get_callbacks.return_value = [mock_callback] - graph = AsyncMock() - graph.nodes = {} - - async def mock_astream(*_args, **_kwargs): - chunk = MagicMock() - chunk.content = "chunk" - chunk.type = "AIMessageChunk" - chunk.additional_kwargs = {} - yield (chunk, {"langgraph_node": "agent"}) - - graph.astream = mock_astream - graph.get_state = MagicMock( - return_value=MagicMock( - values={"messages": [MagicMock(content="chunk", tool_calls=None)]}, - interrupts=(), - ) - ) + graph, astream = _streaming_graph() # Act runner = DeepAgentRunner(graph, tracing_provider=mock_tracing_provider) - events = [e async for e in runner.stream("thread-1", "Hi")] + events = [e async for e in runner.stream("thread-1", "Hi", "turn-1")] - # Assert - assert len(events) == 1 - assert events[0].type == StreamEventType.CONTENT - assert events[0].data == "chunk" + # Assert: HUMAN + CONTENT + AI_MESSAGE = 3 + assert len(events) == 3 + # Find the CONTENT event (between HUMAN and AI_MESSAGE). + content_events = [e for e in events if e.type == TraceEventType.CONTENT] + assert len(content_events) == 1 + assert content_events[0].content == "chunk" + + config = _config_from_call(astream) + assert "callbacks" in config + assert mock_callback in config["callbacks"] diff --git a/tests/unit/test_schema_utils.py b/tests/unit/test_schema_utils.py index d7bb843..b8d3bf2 100644 --- a/tests/unit/test_schema_utils.py +++ b/tests/unit/test_schema_utils.py @@ -4,7 +4,8 @@ public functions below. """ -from pydantic import BaseModel +import pytest +from pydantic import BaseModel, ValidationError from src.infrastructure.deepagent.schema_utils import ( make_validation_model, @@ -241,9 +242,7 @@ def test_array_of_primitives_returns_list(self): # Arrange schema = { "type": "object", - "properties": { - "tags": {"type": "array", "items": {"type": "string"}} - }, + "properties": {"tags": {"type": "array", "items": {"type": "string"}}}, "required": ["tags"], } model = schema_to_pydantic_model(schema, "ArrayModel") @@ -416,3 +415,448 @@ def test_different_schema_produces_different_hash(self): # Assert assert model1.__name__ != model2.__name__ + + +# --------------------------------------------------------------------------- # +# anyOf / nullable types — NEW (red phase for response_format migration) +# --------------------------------------------------------------------------- # + + +class TestSchemaAnyOf: + """Tests for schema_to_pydantic_model with `anyOf` constructs. + + JSON Schema expresses nullable fields via ``anyOf: [{type: X}, {type: "null"}]``. + The converter must produce a ``X | None`` field. + """ + + def test_anyof_number_and_null_accepts_float(self): + """A nullable number field should accept a float value.""" + # Arrange + schema = { + "type": "object", + "properties": { + "temperature": { + "anyOf": [{"type": "number"}, {"type": "null"}], + } + }, + "required": ["temperature"], + } + + # Act + model = schema_to_pydantic_model(schema, "NullableNumberModel") + instance = model.model_validate({"temperature": 22.5}) + + # Assert + assert instance.temperature == 22.5 + + def test_anyof_number_and_null_accepts_none(self): + """A nullable number field should accept None.""" + # Arrange + schema = { + "type": "object", + "properties": { + "temperature": { + "anyOf": [{"type": "number"}, {"type": "null"}], + } + }, + "required": ["temperature"], + } + + # Act + model = schema_to_pydantic_model(schema, "NullableNumberModel") + instance = model.model_validate({"temperature": None}) + + # Assert + assert instance.temperature is None + + def test_anyof_number_and_null_rejects_string(self): + """A nullable number field should reject a string value.""" + # Arrange + schema = { + "type": "object", + "properties": { + "temperature": { + "anyOf": [{"type": "number"}, {"type": "null"}], + } + }, + "required": ["temperature"], + } + + # Act + model = schema_to_pydantic_model(schema, "NullableNumberModel") + + # Assert + with pytest.raises(ValidationError): + model.model_validate({"temperature": "not a number"}) + + def test_anyof_integer_and_null_accepts_integer(self): + """A nullable integer field should accept an int value.""" + # Arrange + schema = { + "type": "object", + "properties": { + "count": { + "anyOf": [{"type": "integer"}, {"type": "null"}], + } + }, + "required": ["count"], + } + + # Act + model = schema_to_pydantic_model(schema, "NullableIntModel") + instance = model.model_validate({"count": 7}) + + # Assert + assert instance.count == 7 + + def test_anyof_string_and_null_accepts_none(self): + """A nullable string field should accept None.""" + # Arrange + schema = { + "type": "object", + "properties": { + "city": { + "anyOf": [{"type": "string"}, {"type": "null"}], + } + }, + "required": ["city"], + } + + # Act + model = schema_to_pydantic_model(schema, "NullableStrModel") + instance = model.model_validate({"city": None}) + + # Assert + assert instance.city is None + + def test_anyof_optional_when_not_in_required_defaults_to_none(self): + """A nullable field that is NOT required should default to None.""" + # Arrange + schema = { + "type": "object", + "properties": { + "score": { + "anyOf": [{"type": "number"}, {"type": "null"}], + } + }, + "required": [], + } + + # Act + model = schema_to_pydantic_model(schema, "OptionalNullableModel") + instance = model.model_validate({}) + + # Assert + assert instance.score is None + + +class TestSchemaEnum: + """Tests for schema_to_pydantic_model with `enum` constraints. + + JSON Schema enums restrict a field to a fixed set of values. The converter + must produce a ``Literal[...]`` field. + """ + + def test_enum_string_accepts_allowed_value(self): + """A string enum field should accept one of the allowed values.""" + # Arrange + schema = { + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["active", "inactive"]}, + }, + "required": ["status"], + } + + # Act + model = schema_to_pydantic_model(schema, "EnumModel") + instance = model.model_validate({"status": "active"}) + + # Assert + assert instance.status == "active" + + def test_enum_string_rejects_value_outside_enum(self): + """A string enum field should reject a value not in the enum.""" + # Arrange + schema = { + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["active", "inactive"]}, + }, + "required": ["status"], + } + + # Act + model = schema_to_pydantic_model(schema, "EnumModel") + + # Assert + with pytest.raises(ValidationError): + model.model_validate({"status": "canceled"}) + + def test_enum_string_accepts_second_allowed_value(self): + """A string enum field should accept any of the allowed values.""" + # Arrange + schema = { + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["active", "inactive"]}, + }, + "required": ["status"], + } + + # Act + model = schema_to_pydantic_model(schema, "EnumModel") + instance = model.model_validate({"status": "inactive"}) + + # Assert + assert instance.status == "inactive" + + def test_enum_without_explicit_type_treats_as_string_enum(self): + """An enum block without an explicit `type` should be treated as a string enum.""" + # Arrange + schema = { + "type": "object", + "properties": { + "level": {"enum": ["low", "medium", "high"]}, + }, + "required": ["level"], + } + + # Act + model = schema_to_pydantic_model(schema, "LevelEnumModel") + instance = model.model_validate({"level": "medium"}) + + # Assert + assert instance.level == "medium" + + def test_enum_without_explicit_type_rejects_outside_enum(self): + """An enum block without an explicit `type` should reject values outside the enum.""" + # Arrange + schema = { + "type": "object", + "properties": { + "level": {"enum": ["low", "medium", "high"]}, + }, + "required": ["level"], + } + + # Act + model = schema_to_pydantic_model(schema, "LevelEnumModel") + + # Assert + with pytest.raises(ValidationError): + model.model_validate({"level": "critical"}) + + +class TestSchemaTypeArray: + """Tests for schema_to_pydantic_model with `type: ["string", "null"]` array form. + + OpenAPI/JSON Schema allows expressing nullable types as a list: + ``type: ["string", "null"]``. The converter must produce a ``str | None`` field. + """ + + def test_type_array_string_null_accepts_string(self): + """A `type: ["string", "null"]` field should accept a string.""" + # Arrange + schema = { + "type": "object", + "properties": { + "nickname": {"type": ["string", "null"]}, + }, + "required": ["nickname"], + } + + # Act + model = schema_to_pydantic_model(schema, "TypeArrayModel") + instance = model.model_validate({"nickname": "toto"}) + + # Assert + assert instance.nickname == "toto" + + def test_type_array_string_null_accepts_none(self): + """A `type: ["string", "null"]` field should accept None.""" + # Arrange + schema = { + "type": "object", + "properties": { + "nickname": {"type": ["string", "null"]}, + }, + "required": ["nickname"], + } + + # Act + model = schema_to_pydantic_model(schema, "TypeArrayModel") + instance = model.model_validate({"nickname": None}) + + # Assert + assert instance.nickname is None + + def test_type_array_string_null_rejects_integer(self): + """A `type: ["string", "null"]` field should reject an integer.""" + # Arrange + schema = { + "type": "object", + "properties": { + "nickname": {"type": ["string", "null"]}, + }, + "required": ["nickname"], + } + + # Act + model = schema_to_pydantic_model(schema, "TypeArrayModel") + + # Assert + with pytest.raises(ValidationError): + model.model_validate({"nickname": 42}) + + def test_type_array_number_null_accepts_float(self): + """A `type: ["number", "null"]` field should accept a float.""" + # Arrange + schema = { + "type": "object", + "properties": { + "price": {"type": ["number", "null"]}, + }, + "required": ["price"], + } + + # Act + model = schema_to_pydantic_model(schema, "TypeArrayNumModel") + instance = model.model_validate({"price": 9.99}) + + # Assert + assert instance.price == 9.99 + + def test_type_array_number_null_accepts_none(self): + """A `type: ["number", "null"]` field should accept None.""" + # Arrange + schema = { + "type": "object", + "properties": { + "price": {"type": ["number", "null"]}, + }, + "required": ["price"], + } + + # Act + model = schema_to_pydantic_model(schema, "TypeArrayNumModel") + instance = model.model_validate({"price": None}) + + # Assert + assert instance.price is None + + +class TestSchemaNestedRegression: + """Regression tests ensuring nested object + array of objects still work. + + These must keep passing after the migration adds anyOf/enum/type-array support. + """ + + def test_nested_object_with_array_of_objects_parses(self): + """A schema combining a nested object and an array of objects should still parse.""" + # Arrange + schema = { + "type": "object", + "properties": { + "metadata": { + "type": "object", + "properties": {"version": {"type": "integer"}}, + "required": ["version"], + }, + "items": { + "type": "array", + "items": { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + }, + }, + "required": ["metadata", "items"], + } + + # Act + model = schema_to_pydantic_model(schema, "ComplexModel") + instance = model.model_validate( + { + "metadata": {"version": 3}, + "items": [{"id": "a"}, {"id": "b"}], + } + ) + + # Assert + assert instance.metadata.version == 3 + assert len(instance.items) == 2 + assert instance.items[0].id == "a" + + def test_nested_object_with_array_of_objects_strips_extras(self): + """Extras in nested objects/arrays should still be stripped.""" + # Arrange + schema = { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": {"id": {"type": "string"}}, + "required": ["id"], + }, + } + }, + "required": ["items"], + } + + # Act + model = schema_to_pydantic_model(schema, "StripArrayModel") + instance = model.model_validate({"items": [{"id": "a", "junk": 1}]}) + + # Assert + assert "junk" not in instance.items[0].model_dump() + + +class TestSchemaExtraIgnore: + """Tests that extra='ignore' is preserved across all generated models. + + Extra fields in input dicts must be silently stripped (no ValidationError). + """ + + def test_extra_top_level_field_stripped_no_error(self): + """Extra top-level fields should be stripped without raising.""" + # Arrange + schema = { + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + } + + # Act + model = schema_to_pydantic_model(schema, "StrictModel") + instance = model.model_validate({"name": "Alice", "ghost": "boo"}) + + # Assert + assert instance.name == "Alice" + assert "ghost" not in instance.model_dump() + + def test_extra_nested_field_stripped_no_error(self): + """Extra nested fields should be stripped without raising.""" + # Arrange + schema = { + "type": "object", + "properties": { + "address": { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + } + }, + "required": ["address"], + } + + # Act + model = schema_to_pydantic_model(schema, "NestedStrictModel") + instance = model.model_validate({"address": {"city": "Paris", "phantom": 1}}) + + # Assert + assert instance.address.city == "Paris" + assert "phantom" not in instance.address.model_dump() diff --git a/tests/unit/test_security.py b/tests/unit/test_security.py index a645266..faa6028 100644 --- a/tests/unit/test_security.py +++ b/tests/unit/test_security.py @@ -223,9 +223,7 @@ async def test_verify_api_key_ws_sends_401_when_missing(self, caplog): body = json.loads(ws.sent_messages[1]["body"]) assert body["detail"] == str(ErrorMessage.API_KEY_EMPTY) assert any( - ErrorMessage.API_KEY_EMPTY in record.message - for record in caplog.records - if record.name == "src.security" + ErrorMessage.API_KEY_EMPTY in record.message for record in caplog.records if record.name == "src.security" ) async def test_verify_api_key_ws_sends_401_when_invalid(self, caplog): diff --git a/tests/unit/test_send_message.py b/tests/unit/test_send_message.py index 00720bf..54c22cf 100644 --- a/tests/unit/test_send_message.py +++ b/tests/unit/test_send_message.py @@ -1,14 +1,21 @@ -"""Tests for SendMessageUseCase. +"""Tests for SendMessageUseCase (Ticket 3 rewrite). -Uses real PostgresThreadRepository (internal, from conftest). -Uses mock_agent_runner (external LLM boundary, from external.py). -A small real fake registry wraps the mock runner. +The use case now depends on TraceEventRepository + the new runner API +``invoke(thread_id, message, turn_id) -> (Message, list[TraceEvent])``. +Internal repositories (PostgresThreadRepository, PostgresTraceEventRepository) +are used for real; only the LLM runner (AgentRunner) is mocked at the port +boundary. """ +from datetime import UTC, datetime +from uuid import uuid4 + import pytest from src.application.use_cases.send_message import SendMessageUseCase from src.domain.entities.message import Message, MessageRole, MessageStatus +from src.domain.entities.trace_event import TraceEvent, TraceEventType +from src.domain.errors.agent import AgentError from src.domain.errors.hitl import InvalidHitlActionError from src.domain.ports.agent_registry import AgentRegistry from src.domain.ports.agent_runner import AgentRunner @@ -26,98 +33,169 @@ async def get_runner(self, agent_name: str) -> AgentRunner: # noqa: ARG002 async def list_agents(self) -> list[str]: return ["test-agent"] - async def invalidate(self, agent_name: str) -> None: + async def invalidate(self, agent_name: str) -> None: # noqa: ARG002 pass async def close(self) -> None: pass +def _human_event(thread_id: str, turn_id: str, content: str, seq: int = 0) -> TraceEvent: + return TraceEvent( + id=str(uuid4()), + thread_id=thread_id, + turn_id=turn_id, + type=TraceEventType.HUMAN_MESSAGE, + content=content, + timestamp=datetime.now(UTC), + sequence=seq, + ) + + +def _ai_event(thread_id: str, turn_id: str, message: Message, seq: int = 1) -> TraceEvent: + return TraceEvent( + id=str(uuid4()), + thread_id=thread_id, + turn_id=turn_id, + type=TraceEventType.AI_MESSAGE, + content=message.model_dump_json(), + timestamp=datetime.now(UTC), + sequence=seq, + ) + + class TestSendMessageUseCase: @pytest.fixture def registry(self, mock_agent_runner): return _FakeRegistry(mock_agent_runner) @pytest.fixture - def use_case(self, registry, thread_repo): - return SendMessageUseCase(registry, thread_repo) + def use_case(self, registry, thread_repo, trace_repo): + return SendMessageUseCase(registry, thread_repo, trace_repo) - async def test_sends_message_and_saves_response(self, use_case, mock_agent_runner, thread_repo): + async def test_unsupported_hitl_action_raises(self, use_case, thread_repo): # Arrange thread = await thread_repo.create("test-agent") - mock_agent_runner.invoke.return_value = Message( - role=MessageRole.AI, content="Hello human!", status=MessageStatus.COMPLETED - ) - - # Act - response = await use_case.execute(thread.id, message="Hello agent!") - # Assert - assert response.content == "Hello human!" - updated = await thread_repo.get(thread.id) - assert len(updated.messages) == 2 + # Act / Assert + with pytest.raises(InvalidHitlActionError, match="Unsupported HITL action"): + await use_case.execute(thread.id, action="unknown_action", tool_call_id="tc-1") - async def test_approve_hitl_saves_response(self, use_case, mock_agent_runner, thread_repo): + async def test_sends_message_and_persists_trace(self, use_case, mock_agent_runner, thread_repo, trace_repo): # Arrange thread = await thread_repo.create("test-agent") - mock_agent_runner.approve_hitl.return_value = Message(role=MessageRole.AI, content="Action approved.") + final_message = Message(role=MessageRole.AI, content="Hello human!", status=MessageStatus.COMPLETED) + # Runner.invoke returns (Message, list[TraceEvent]). + # Provide a minimal trace: HUMAN_MESSAGE + AI_MESSAGE. + turn_id = "turn-returned-by-runner" # the use case generates its own; runner just echoes + trace = [ + _human_event(thread.id, turn_id, "Hello agent!", seq=0), + _ai_event(thread.id, turn_id, final_message, seq=1), + ] + mock_agent_runner.invoke.return_value = (final_message, trace) # Act - response = await use_case.execute(thread.id, action="approve", tool_call_id="tc-1") + result = await use_case.execute(thread.id, message="Hello agent!") + + # Assert — returns the final AI Message + assert result.role == MessageRole.AI + assert result.content == "Hello human!" + assert result.status == MessageStatus.COMPLETED + # And persists the trace events in a batch + mock_agent_runner.invoke.assert_awaited_once() + # Verify trace events are now in the repository + events = await trace_repo.list_by_thread(thread.id) + assert len(events) == 2 + assert events[0].type == TraceEventType.HUMAN_MESSAGE + assert events[1].type == TraceEventType.AI_MESSAGE + + async def test_each_call_generates_new_turn_id(self, use_case, mock_agent_runner, thread_repo, trace_repo): + # Arrange — two consecutive calls must produce two distinct turns + thread = await thread_repo.create("test-agent") + msg1 = Message(role=MessageRole.AI, content="first", status=MessageStatus.COMPLETED) + msg2 = Message(role=MessageRole.AI, content="second", status=MessageStatus.COMPLETED) - # Assert - assert "approved" in response.content.lower() - updated = await thread_repo.get(thread.id) - assert len(updated.messages) == 1 + # Capture the turn_id passed to the runner across two calls + captured_turn_ids: list[str] = [] + + async def _invoke(_tid: str, _message: str, turn_id: str) -> tuple[Message, list[TraceEvent]]: + captured_turn_ids.append(turn_id) + trace = [_human_event(_tid, turn_id, _message, seq=0), _ai_event(_tid, turn_id, msg1, seq=1)] + return (msg1, trace) + + mock_agent_runner.invoke.side_effect = _invoke + mock_agent_runner.invoke.return_value = None # clear the default to use side_effect - async def test_reject_hitl_saves_response(self, use_case, mock_agent_runner, thread_repo): + await use_case.execute(thread.id, message="q1") + + # Reset return for second call with msg2 + async def _invoke2(_tid: str, _message: str, turn_id: str) -> tuple[Message, list[TraceEvent]]: + captured_turn_ids.append(turn_id) + trace = [_human_event(_tid, turn_id, _message, seq=0), _ai_event(_tid, turn_id, msg2, seq=1)] + return (msg2, trace) + + mock_agent_runner.invoke.side_effect = _invoke2 + await use_case.execute(thread.id, message="q2") + + # Assert — two distinct turn_ids generated by the use case + assert len(captured_turn_ids) == 2 + assert captured_turn_ids[0] != captured_turn_ids[1] + # And 4 trace events persisted (2 per turn) + events = await trace_repo.list_by_thread(thread.id) + assert len(events) == 4 + + async def test_approve_hitl_returns_message_no_trace(self, use_case, mock_agent_runner, thread_repo, trace_repo): # Arrange thread = await thread_repo.create("test-agent") - mock_agent_runner.reject_hitl.return_value = Message( - role=MessageRole.AI, content="Action rejected: Too risky" - ) + approved = Message(role=MessageRole.AI, content="Action approved.", status=MessageStatus.COMPLETED) + mock_agent_runner.approve_hitl.return_value = approved # Act - response = await use_case.execute(thread.id, action="reject", tool_call_id="tc-1", reason="Too risky") + result = await use_case.execute(thread.id, action="approve", tool_call_id="tc-1") # Assert - assert "rejected" in response.content.lower() - updated = await thread_repo.get(thread.id) - assert len(updated.messages) == 1 + assert result.content == "Action approved." + mock_agent_runner.approve_hitl.assert_awaited_once_with(thread.id, "tc-1") + # HITL path does not persist trace events + events = await trace_repo.list_by_thread(thread.id) + assert events == [] - async def test_edit_hitl_saves_response(self, use_case, mock_agent_runner, thread_repo): + async def test_reject_hitl_returns_message(self, use_case, mock_agent_runner, thread_repo, trace_repo): # Arrange thread = await thread_repo.create("test-agent") - mock_agent_runner.edit_hitl.return_value = Message( - role=MessageRole.AI, content="Action edited and approved." - ) + rejected = Message(role=MessageRole.AI, content="Action rejected: Too risky", status=MessageStatus.COMPLETED) + mock_agent_runner.reject_hitl.return_value = rejected # Act - response = await use_case.execute(thread.id, action="edit", tool_call_id="tc-1", edits={"param": "value"}) + result = await use_case.execute(thread.id, action="reject", tool_call_id="tc-1", reason="Too risky") # Assert - assert "edited" in response.content.lower() - updated = await thread_repo.get(thread.id) - assert len(updated.messages) == 1 + assert result.content == "Action rejected: Too risky" + mock_agent_runner.reject_hitl.assert_awaited_once_with(thread.id, "tc-1", "Too risky") + events = await trace_repo.list_by_thread(thread.id) + assert events == [] - async def test_unsupported_hitl_action_raises(self, use_case, thread_repo): + async def test_edit_hitl_returns_message(self, use_case, mock_agent_runner, thread_repo, trace_repo): # Arrange thread = await thread_repo.create("test-agent") + edited = Message(role=MessageRole.AI, content="Action edited and approved.", status=MessageStatus.COMPLETED) + mock_agent_runner.edit_hitl.return_value = edited - # Act / Assert - with pytest.raises(InvalidHitlActionError, match="Unsupported HITL action"): - await use_case.execute(thread.id, action="unknown_action", tool_call_id="tc-1") + # Act + result = await use_case.execute(thread.id, action="edit", tool_call_id="tc-1", edits={"param": "value"}) + + # Assert + assert result.content == "Action edited and approved." + mock_agent_runner.edit_hitl.assert_awaited_once_with(thread.id, "tc-1", {"param": "value"}) + events = await trace_repo.list_by_thread(thread.id) + assert events == [] - async def test_execute_skips_duplicate_human_message(self, use_case, mock_agent_runner, thread_repo): + async def test_runner_error_propagates(self, use_case, mock_agent_runner, thread_repo): # Arrange thread = await thread_repo.create("test-agent") - await thread_repo.add_message(thread.id, Message(role=MessageRole.HUMAN, content="Hello agent!")) - mock_agent_runner.invoke.return_value = Message(role=MessageRole.AI, content="Hi!") + mock_agent_runner.invoke.side_effect = AgentError("Backend failed") + mock_agent_runner.invoke.return_value = None - # Act - await use_case.execute(thread.id, message="Hello agent!") - - # Assert - updated = await thread_repo.get(thread.id) - human_msgs = [m for m in updated.messages if m.role == MessageRole.HUMAN] - assert len(human_msgs) == 1 + # Act / Assert + with pytest.raises(AgentError, match="Backend failed"): + await use_case.execute(thread.id, message="Hello") diff --git a/tests/unit/test_stream_message.py b/tests/unit/test_stream_message.py index d5c3332..392d22c 100644 --- a/tests/unit/test_stream_message.py +++ b/tests/unit/test_stream_message.py @@ -1,15 +1,21 @@ -"""Tests for StreamMessageUseCase. +"""Tests for StreamMessageUseCase (Ticket 3 rewrite). -Uses real PostgresThreadRepository (internal, from conftest). -Uses mock_agent_runner (external LLM boundary, from external.py). -A small real fake registry wraps the mock runner. +The use case now depends on TraceEventRepository + the new runner API +``stream(thread_id, message, turn_id) -> AsyncIterator[TraceEvent]``. +Internal repositories are used for real; only the LLM runner is mocked. """ +import json from collections.abc import AsyncIterator +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest from src.application.use_cases.stream_message import StreamMessageUseCase from src.domain.entities.message import Message, MessageRole, MessageStatus -from src.domain.entities.stream_event import StreamEvent, StreamEventType +from src.domain.entities.trace_event import TraceEvent, TraceEventType +from src.domain.errors.agent import AgentError from src.domain.ports.agent_registry import AgentRegistry from src.domain.ports.agent_runner import AgentRunner @@ -26,89 +32,122 @@ async def get_runner(self, agent_name: str) -> AgentRunner: # noqa: ARG002 async def list_agents(self) -> list[str]: return ["test-agent"] - async def invalidate(self, agent_name: str) -> None: + async def invalidate(self, agent_name: str) -> None: # noqa: ARG002 pass async def close(self) -> None: pass +def _event(thread_id: str, turn_id: str, type_: TraceEventType, content: str, seq: int) -> TraceEvent: + return TraceEvent( + id=str(uuid4()), + thread_id=thread_id, + turn_id=turn_id, + type=type_, + content=content, + timestamp=datetime.now(UTC), + sequence=seq, + ) + + class TestStreamMessageUseCase: - def _build_runner(self, mock_agent_runner) -> AgentRunner: - async def _stream_with_message( - _thread_id: str, _message: str - ) -> AsyncIterator[StreamEvent]: - yield StreamEvent(type=StreamEventType.CONTENT, data="Hi") - yield StreamEvent( - type=StreamEventType.MESSAGE, - data=Message( - role=MessageRole.AI, - content="Hi", - status=MessageStatus.COMPLETED, - ).model_dump_json(), - ) + @pytest.fixture + def registry(self, mock_agent_runner): + return _FakeRegistry(mock_agent_runner) - mock_agent_runner.stream_with_message = _stream_with_message - return mock_agent_runner + @pytest.fixture + def use_case(self, registry, thread_repo, trace_repo): + return StreamMessageUseCase(registry, thread_repo, trace_repo) - async def test_execute_streams_events_and_persists_ai_message(self, mock_agent_runner, thread_repo): + async def test_execute_yields_events_and_persists(self, use_case, mock_agent_runner, thread_repo, trace_repo): # Arrange - runner = self._build_runner(mock_agent_runner) - registry = _FakeRegistry(runner) - use_case = StreamMessageUseCase(registry, thread_repo) thread = await thread_repo.create("test-agent") + final_msg = Message(role=MessageRole.AI, content="Hi!", status=MessageStatus.COMPLETED) + events = [ + _event(thread.id, "turn-1", TraceEventType.HUMAN_MESSAGE, "Hello", seq=0), + _event(thread.id, "turn-1", TraceEventType.THINKING, "hmm", seq=1), + _event(thread.id, "turn-1", TraceEventType.CONTENT, "Hi", seq=2), + _event(thread.id, "turn-1", TraceEventType.AI_MESSAGE, final_msg.model_dump_json(), seq=3), + ] - # Act - events = [event async for event in use_case.execute(thread.id, "Hello")] + async def _stream(_tid: str, _message: str, _turn_id: str) -> AsyncIterator[TraceEvent]: + for ev in events: + yield ev - # Assert - assert len(events) == 2 - assert events[0].type == StreamEventType.CONTENT - assert events[1].type == StreamEventType.MESSAGE + mock_agent_runner.stream = _stream - async def test_execute_persists_final_ai_message(self, mock_agent_runner, thread_repo): + # Act + yielded: list[TraceEvent] = [ev async for ev in use_case.execute(thread.id, "Hello")] + + # Assert — all events yielded + assert len(yielded) == 4 + assert [e.type for e in yielded] == [ + TraceEventType.HUMAN_MESSAGE, + TraceEventType.THINKING, + TraceEventType.CONTENT, + TraceEventType.AI_MESSAGE, + ] + # And all events persisted via trace_repo.add + persisted = await trace_repo.list_by_thread(thread.id) + assert len(persisted) == 4 + + async def test_execute_persists_human_message(self, use_case, mock_agent_runner, thread_repo, trace_repo): # Arrange - runner = self._build_runner(mock_agent_runner) - registry = _FakeRegistry(runner) - use_case = StreamMessageUseCase(registry, thread_repo) thread = await thread_repo.create("test-agent") + async def _stream(_tid: str, _message: str, _turn_id: str) -> AsyncIterator[TraceEvent]: + yield _event(thread.id, _turn_id, TraceEventType.HUMAN_MESSAGE, "Hello", seq=0) + yield _event( + thread.id, + _turn_id, + TraceEventType.AI_MESSAGE, + Message(role=MessageRole.AI, content="Hi!", status=MessageStatus.COMPLETED).model_dump_json(), + seq=1, + ) + + mock_agent_runner.stream = _stream + # Act - _ = [event async for event in use_case.execute(thread.id, "Hello")] + _ = [ev async for ev in use_case.execute(thread.id, "Hello")] - # Assert - updated = await thread_repo.get(thread.id) - ai_msgs = [m for m in updated.messages if m.role == MessageRole.AI] - assert len(ai_msgs) == 1 - assert ai_msgs[0].content == "Hi" + # Assert — HUMAN_MESSAGE is persisted + persisted = await trace_repo.list_by_thread(thread.id) + human_events = [e for e in persisted if e.type == TraceEventType.HUMAN_MESSAGE] + assert len(human_events) == 1 + assert human_events[0].content == "Hello" - async def test_execute_persists_human_message_when_not_duplicate(self, mock_agent_runner, thread_repo): + async def test_execute_persists_ai_message(self, use_case, mock_agent_runner, thread_repo, trace_repo): # Arrange - runner = self._build_runner(mock_agent_runner) - registry = _FakeRegistry(runner) - use_case = StreamMessageUseCase(registry, thread_repo) thread = await thread_repo.create("test-agent") + final_msg = Message(role=MessageRole.AI, content="Final answer.", status=MessageStatus.COMPLETED) + + async def _stream(_tid: str, _message: str, _turn_id: str) -> AsyncIterator[TraceEvent]: + yield _event(thread.id, _turn_id, TraceEventType.HUMAN_MESSAGE, "Hello", seq=0) + yield _event(thread.id, _turn_id, TraceEventType.AI_MESSAGE, final_msg.model_dump_json(), seq=1) + + mock_agent_runner.stream = _stream # Act - _ = [event async for event in use_case.execute(thread.id, "Hello")] + _ = [ev async for ev in use_case.execute(thread.id, "Hello")] - # Assert - updated = await thread_repo.get(thread.id) - human_msgs = [m for m in updated.messages if m.role == MessageRole.HUMAN] - assert len(human_msgs) == 1 + # Assert — AI_MESSAGE is persisted and its content is valid JSON (the Message payload) + persisted = await trace_repo.list_by_thread(thread.id) + ai_events = [e for e in persisted if e.type == TraceEventType.AI_MESSAGE] + assert len(ai_events) == 1 + payload = json.loads(ai_events[0].content) + assert payload["content"] == "Final answer." - async def test_execute_skips_duplicate_human_message(self, mock_agent_runner, thread_repo): + async def test_execute_propagates_runner_error(self, use_case, mock_agent_runner, thread_repo): # Arrange - runner = self._build_runner(mock_agent_runner) - registry = _FakeRegistry(runner) - use_case = StreamMessageUseCase(registry, thread_repo) thread = await thread_repo.create("test-agent") - await thread_repo.add_message(thread.id, Message(role=MessageRole.HUMAN, content="Hello")) - # Act - _ = [event async for event in use_case.execute(thread.id, "Hello")] + async def _stream(_tid: str, _message: str, _turn_id: str) -> AsyncIterator[TraceEvent]: + yield _event(thread.id, _turn_id, TraceEventType.HUMAN_MESSAGE, "Hello", seq=0) + raise AgentError("graph crashed") + + mock_agent_runner.stream = _stream - # Assert - updated = await thread_repo.get(thread.id) - human_msgs = [m for m in updated.messages if m.role == MessageRole.HUMAN] - assert len(human_msgs) == 1 + # Act / Assert — runner error propagates out of the async generator + with pytest.raises(AgentError, match="graph crashed"): + _ = [ev async for ev in use_case.execute(thread.id, "Hello")] diff --git a/tests/unit/test_thread_management.py b/tests/unit/test_thread_management.py index 207c142..66e76f3 100644 --- a/tests/unit/test_thread_management.py +++ b/tests/unit/test_thread_management.py @@ -24,13 +24,7 @@ from src.domain.errors.thread import ThreadNotFoundError from src.infrastructure.persistent_registry.adapter import PersistentAgentRegistry -VALID_YAML = ( - "name: test-agent\n" - "model: test-model\n" - 'system_prompt: "Test."\n' - "tools: []\n" - "debug: false\n" -) +VALID_YAML = 'name: test-agent\nmodel: test-model\nsystem_prompt: "Test."\ntools: []\ndebug: false\n' class TestCreateThreadUseCase: @@ -92,14 +86,10 @@ async def test_creates_thread_with_id(self, use_case): # Assert assert thread.id is not None - async def test_raises_when_agent_not_found( - self, use_case, mock_agent_config_store_with_yaml - ): + async def test_raises_when_agent_not_found(self, use_case, mock_agent_config_store_with_yaml): """Should raise AgentNotFoundError when the agent is unknown.""" # Arrange - mock_agent_config_store_with_yaml.get.side_effect = AgentNotFoundError( - "not found" - ) + mock_agent_config_store_with_yaml.get.side_effect = AgentNotFoundError("not found") # Act & Assert with pytest.raises(AgentNotFoundError): @@ -159,9 +149,7 @@ class TestDeleteThreadUseCase: def use_case(self, thread_repo): return DeleteThreadUseCase(thread_repo) - async def test_deletes_thread_so_get_raises( - self, use_case, thread_repo - ): + async def test_deletes_thread_so_get_raises(self, use_case, thread_repo): """Should delete the thread so it is no longer retrievable.""" # Arrange created = await thread_repo.create("test-agent") diff --git a/tests/unit/test_trace_event.py b/tests/unit/test_trace_event.py new file mode 100644 index 0000000..267c198 --- /dev/null +++ b/tests/unit/test_trace_event.py @@ -0,0 +1,147 @@ +"""Tests for TraceEvent domain entity and Message.from_trace_event factory.""" + +from datetime import UTC, datetime + +import pytest +from pydantic import ValidationError + +from src.domain.entities.message import Message, MessageRole, MessageStatus +from src.domain.entities.trace_event import TraceEvent, TraceEventType + + +class TestTraceEvent: + """Tests for TraceEvent entity.""" + + def test_frozen_entity_cannot_be_mutated(self): + # Arrange + event = TraceEvent( + id="evt-1", + thread_id="thread-1", + turn_id="turn-1", + type=TraceEventType.HUMAN_MESSAGE, + content="hello", + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + sequence=0, + ) + + # Act / Assert + with pytest.raises(ValidationError): + event.content = "mutated" # type: ignore[misc] + + def test_validates_event_type(self): + # Arrange / Act / Assert + with pytest.raises(ValidationError): + TraceEvent( + id="evt-1", + thread_id="thread-1", + turn_id="turn-1", + type="not_a_real_type", # type: ignore[arg-type] + content="hello", + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + sequence=0, + ) + + def test_optional_fields_default_to_none(self): + # Arrange / Act + event = TraceEvent( + id="evt-1", + thread_id="thread-1", + turn_id="turn-1", + type=TraceEventType.HUMAN_MESSAGE, + content="hello", + timestamp=datetime(2025, 1, 1, tzinfo=UTC), + sequence=0, + ) + + # Assert + assert event.source is None + assert event.name is None + assert event.metadata is None + + +class TestMessageFromTraceEvent: + """Tests for Message.from_trace_event static factory.""" + + def test_human_message_event_reconstructs_human_message(self): + # Arrange + ts = datetime(2025, 1, 1, 12, 0, 0, tzinfo=UTC) + event = TraceEvent( + id="evt-1", + thread_id="thread-1", + turn_id="turn-1", + type=TraceEventType.HUMAN_MESSAGE, + content="Hello agent!", + timestamp=ts, + sequence=0, + ) + + # Act + msg = Message.from_trace_event(event) + + # Assert + assert msg.role == MessageRole.HUMAN + assert msg.content == "Hello agent!" + assert msg.timestamp == ts + assert msg.turn_id == "turn-1" + assert msg.tool_calls is None + assert msg.status is None + assert msg.structured_response is None + assert msg.thinking is None + + def test_ai_message_event_reconstructs_full_ai_message(self): + # Arrange + ts = datetime(2025, 1, 1, 12, 0, 5, tzinfo=UTC) + payload = { + "content": "Analysis complete", + "tool_calls": [{"name": "search", "args": {"q": "x"}, "id": "c1"}], + "status": "completed", + "structured_response": {"score": 95}, + "thinking": "I should search first", + } + event = TraceEvent( + id="evt-2", + thread_id="thread-1", + turn_id="turn-1", + type=TraceEventType.AI_MESSAGE, + content=__import__("json").dumps(payload), + timestamp=ts, + sequence=1, + ) + + # Act + msg = Message.from_trace_event(event) + + # Assert + assert msg.role == MessageRole.AI + assert msg.content == "Analysis complete" + assert msg.timestamp == ts + assert msg.turn_id == "turn-1" + assert msg.tool_calls == [{"name": "search", "args": {"q": "x"}, "id": "c1"}] + assert msg.status == MessageStatus.COMPLETED + assert msg.structured_response == {"score": 95} + assert msg.thinking == "I should search first" + + def test_ai_message_event_with_minimal_payload(self): + # Arrange + ts = datetime(2025, 1, 1, 12, 0, 5, tzinfo=UTC) + event = TraceEvent( + id="evt-3", + thread_id="thread-1", + turn_id="turn-2", + type=TraceEventType.AI_MESSAGE, + content="{}", # empty payload + timestamp=ts, + sequence=1, + ) + + # Act + msg = Message.from_trace_event(event) + + # Assert + assert msg.role == MessageRole.AI + assert msg.content is None + assert msg.turn_id == "turn-2" + assert msg.tool_calls is None + assert msg.status is None + assert msg.structured_response is None + assert msg.thinking is None diff --git a/tests/unit/test_trace_repository.py b/tests/unit/test_trace_repository.py new file mode 100644 index 0000000..524a927 --- /dev/null +++ b/tests/unit/test_trace_repository.py @@ -0,0 +1,161 @@ +"""Tests for PostgresTraceEventRepository against a real in-memory SQLite engine.""" + +from datetime import UTC, datetime +from uuid import uuid4 + +import pytest + +from src.domain.entities.trace_event import TraceEvent, TraceEventType +from src.domain.errors.thread import ThreadNotFoundError + + +def _make_event( + thread_id: str, + turn_id: str, + type_: TraceEventType, + *, + content: str | None = None, + sequence: int = 0, + timestamp: datetime | None = None, + source: str | None = None, + name: str | None = None, + metadata: dict | None = None, +) -> TraceEvent: + return TraceEvent( + id=str(uuid4()), + thread_id=thread_id, + turn_id=turn_id, + type=type_, + content=content, + source=source, + name=name, + metadata=metadata, + timestamp=timestamp or datetime.now(UTC), + sequence=sequence, + ) + + +class TestPostgresTraceEventRepository: + async def test_add_persists_event_and_list_by_thread_returns_it(self, thread_repo, trace_repo): + # Arrange + thread = await thread_repo.create("test-agent") + event = _make_event(thread.id, "turn-1", TraceEventType.HUMAN_MESSAGE, content="hello") + + # Act + await trace_repo.add(thread.id, event) + events = await trace_repo.list_by_thread(thread.id) + + # Assert + assert len(events) == 1 + assert events[0].thread_id == thread.id + assert events[0].type == TraceEventType.HUMAN_MESSAGE + assert events[0].content == "hello" + assert events[0].turn_id == "turn-1" + + async def test_add_batch_persists_multiple_events(self, thread_repo, trace_repo): + # Arrange + thread = await thread_repo.create("test-agent") + events = [ + _make_event(thread.id, "turn-1", TraceEventType.HUMAN_MESSAGE, content="hi", sequence=0), + _make_event(thread.id, "turn-1", TraceEventType.AI_MESSAGE, content='{"content":"hi back"}', sequence=1), + _make_event(thread.id, "turn-1", TraceEventType.THINKING, content="thinking", sequence=2), + ] + + # Act + await trace_repo.add_batch(thread.id, events) + result = await trace_repo.list_by_thread(thread.id) + + # Assert + assert len(result) == 3 + + async def test_list_by_turn_filters_by_turn_id(self, thread_repo, trace_repo): + # Arrange + thread = await thread_repo.create("test-agent") + await trace_repo.add_batch( + thread.id, + [ + _make_event(thread.id, "turn-1", TraceEventType.HUMAN_MESSAGE, content="m1", sequence=0), + _make_event(thread.id, "turn-2", TraceEventType.HUMAN_MESSAGE, content="m2", sequence=1), + _make_event(thread.id, "turn-1", TraceEventType.AI_MESSAGE, content='{"content":"r1"}', sequence=2), + ], + ) + + # Act + result = await trace_repo.list_by_turn(thread.id, "turn-1") + + # Assert + assert len(result) == 2 + assert all(e.turn_id == "turn-1" for e in result) + + async def test_list_messages_returns_only_human_and_ai_sorted_by_timestamp(self, thread_repo, trace_repo): + # Arrange + thread = await thread_repo.create("test-agent") + early = datetime(2025, 1, 1, 10, 0, 0, tzinfo=UTC) + mid = datetime(2025, 1, 1, 10, 0, 5, tzinfo=UTC) + late = datetime(2025, 1, 1, 10, 0, 10, tzinfo=UTC) + await trace_repo.add_batch( + thread.id, + [ + _make_event(thread.id, "turn-1", TraceEventType.THINKING, content="t", sequence=0, timestamp=mid), + _make_event( + thread.id, + "turn-1", + TraceEventType.AI_MESSAGE, + content='{"content":"late"}', + sequence=2, + timestamp=late, + ), + _make_event( + thread.id, "turn-1", TraceEventType.HUMAN_MESSAGE, content="early", sequence=1, timestamp=early + ), + _make_event(thread.id, "turn-1", TraceEventType.TOOL_CALL, content="tool", sequence=3, timestamp=mid), + ], + ) + + # Act + result = await trace_repo.list_messages(thread.id) + + # Assert + assert len(result) == 2 + assert [r.type for r in result] == [TraceEventType.HUMAN_MESSAGE, TraceEventType.AI_MESSAGE] + assert [r.content for r in result] == ["early", '{"content":"late"}'] + + async def test_add_raises_thread_not_found_when_thread_missing(self, trace_repo): + # Arrange + event = _make_event("nonexistent", "turn-1", TraceEventType.HUMAN_MESSAGE, content="hi") + + # Act / Assert + with pytest.raises(ThreadNotFoundError): + await trace_repo.add("nonexistent", event) + + async def test_add_batch_raises_thread_not_found_when_thread_missing(self, trace_repo): + # Arrange + events = [_make_event("nonexistent", "turn-1", TraceEventType.HUMAN_MESSAGE, content="hi")] + + # Act / Assert + with pytest.raises(ThreadNotFoundError): + await trace_repo.add_batch("nonexistent", events) + + async def test_list_by_thread_returns_empty_for_thread_without_events(self, thread_repo, trace_repo): + # Arrange + thread = await thread_repo.create("test-agent") + + # Act + result = await trace_repo.list_by_thread(thread.id) + + # Assert + assert result == [] + + async def test_list_messages_returns_empty_when_no_messages(self, thread_repo, trace_repo): + # Arrange + thread = await thread_repo.create("test-agent") + await trace_repo.add( + thread.id, + _make_event(thread.id, "turn-1", TraceEventType.THINKING, content="t"), + ) + + # Act + result = await trace_repo.list_messages(thread.id) + + # Assert + assert result == [] diff --git a/tests/unit/test_yaml_loader.py b/tests/unit/test_yaml_loader.py index c160a7f..8315ab1 100644 --- a/tests/unit/test_yaml_loader.py +++ b/tests/unit/test_yaml_loader.py @@ -98,11 +98,11 @@ def test_loads_full_config_returns_debug_flag(self, yaml_loader, tmp_path): 'model: "openai:gpt-4o"\n' 'system_prompt: "You are helpful."\n' "middleware:\n - todo_list\n - filesystem\n" - "backend:\n type: filesystem\n root_dir: \"./workspace\"\n" + 'backend:\n type: filesystem\n root_dir: "./workspace"\n' "hitl:\n rules:\n write_file: true\n" - "memory:\n - \"./AGENTS.md\"\n" - "skills:\n - \"./skills/\"\n" - "subagents:\n - name: sub\n description: \"A subagent\"\n" + 'memory:\n - "./AGENTS.md"\n' + 'skills:\n - "./skills/"\n' + 'subagents:\n - name: sub\n description: "A subagent"\n' "debug: true\n" ) yaml_file = tmp_path / "agent.yaml" @@ -117,11 +117,7 @@ def test_loads_full_config_returns_debug_flag(self, yaml_loader, tmp_path): def test_loads_full_config_returns_middleware(self, yaml_loader, tmp_path): """Should parse the middleware list from a full YAML config.""" # Arrange - yaml_content = ( - "name: full-agent\n" - "middleware:\n - todo_list\n - filesystem\n" - "debug: true\n" - ) + yaml_content = "name: full-agent\nmiddleware:\n - todo_list\n - filesystem\ndebug: true\n" yaml_file = tmp_path / "agent.yaml" yaml_file.write_text(yaml_content) @@ -134,10 +130,7 @@ def test_loads_full_config_returns_middleware(self, yaml_loader, tmp_path): def test_loads_full_config_returns_backend_root_dir(self, yaml_loader, tmp_path): """Should parse backend root_dir from a full YAML config.""" # Arrange - yaml_content = ( - "name: full-agent\n" - "backend:\n type: filesystem\n root_dir: \"./workspace\"\n" - ) + yaml_content = 'name: full-agent\nbackend:\n type: filesystem\n root_dir: "./workspace"\n' yaml_file = tmp_path / "agent.yaml" yaml_file.write_text(yaml_content) @@ -150,10 +143,7 @@ def test_loads_full_config_returns_backend_root_dir(self, yaml_loader, tmp_path) def test_loads_full_config_returns_subagents(self, yaml_loader, tmp_path): """Should parse the subagents list from a full YAML config.""" # Arrange - yaml_content = ( - "name: full-agent\n" - "subagents:\n - name: sub\n description: \"A subagent\"\n" - ) + yaml_content = 'name: full-agent\nsubagents:\n - name: sub\n description: "A subagent"\n' yaml_file = tmp_path / "agent.yaml" yaml_file.write_text(yaml_content) @@ -197,11 +187,7 @@ def test_returns_name_for_valid_yaml(self, yaml_loader): def test_returns_model_for_valid_yaml(self, yaml_loader): """Should parse valid YAML string and return the agent model.""" # Arrange - yaml_content = ( - "name: test-agent\n" - "model: claude-sonnet-4-5-20250929\n" - 'system_prompt: "You are a test agent."\n' - ) + yaml_content = 'name: test-agent\nmodel: claude-sonnet-4-5-20250929\nsystem_prompt: "You are a test agent."\n' # Act config = yaml_loader.load_from_string(yaml_content) @@ -212,10 +198,7 @@ def test_returns_model_for_valid_yaml(self, yaml_loader): def test_returns_system_prompt_for_valid_yaml(self, yaml_loader): """Should parse valid YAML string and return the system_prompt.""" # Arrange - yaml_content = ( - "name: test-agent\n" - 'system_prompt: "You are a test agent."\n' - ) + yaml_content = 'name: test-agent\nsystem_prompt: "You are a test agent."\n' # Act config = yaml_loader.load_from_string(yaml_content)