Skip to content

Commit f83eefb

Browse files
authored
Merge pull request #3687 from Agenta-AI/feat/refine-ai-feature
feat(api): AI services backend — refine prompt tool call
2 parents dc34e5a + 5072009 commit f83eefb

File tree

33 files changed

+4223
-20
lines changed

33 files changed

+4223
-20
lines changed

AGENTS.md

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,137 @@ async def create_workflow(
204204
...
205205
```
206206

207+
### Domain-level exceptions
208+
209+
1. **Define domain exceptions in the core layer** (`core/{domain}/types.py` or `core/{domain}/dtos.py`) — never raise `HTTPException` from services or DAOs.
210+
2. **Catch domain exceptions at the API boundary** — in the router or via a decorator — and convert them to HTTP responses.
211+
3. **Use a base exception per domain** so callers can catch broadly or narrowly.
212+
4. **Include structured context** (not just a message string) so the router can build rich HTTP error responses.
213+
214+
**Example 1 — Folder exceptions (best example of the full pattern):**
215+
216+
Definition in `api/oss/src/core/folders/types.py`:
217+
```python
218+
class FolderNameInvalid(Exception):
219+
def __init__(self, message: str = "Folder name contains invalid characters."):
220+
self.message = message
221+
super().__init__(message)
222+
223+
class FolderPathConflict(Exception):
224+
def __init__(self, message: str = "A folder with this path already exists."):
225+
self.message = message
226+
super().__init__(message)
227+
```
228+
229+
Raised in service `api/oss/src/core/folders/service.py`:
230+
```python
231+
def _validate_folder_name(name: Optional[str]) -> None:
232+
if not name or not fullmatch(r"[\w -]+", name):
233+
raise FolderNameInvalid()
234+
```
235+
236+
Caught in router via decorator `api/oss/src/apis/fastapi/folders/router.py`:
237+
```python
238+
def handle_folder_exceptions():
239+
def decorator(func):
240+
@wraps(func)
241+
async def wrapper(*args, **kwargs):
242+
try:
243+
return await func(*args, **kwargs)
244+
except FolderNameInvalid as e:
245+
raise FolderNameInvalidException(message=e.message) from e
246+
except FolderPathConflict as e:
247+
raise FolderPathConflictException(message=e.message) from e
248+
return wrapper
249+
return decorator
250+
```
251+
252+
**Example 2 — Filtering exception (inline catch in router):**
253+
254+
Definition in `api/oss/src/core/tracing/dtos.py`:
255+
```python
256+
class FilteringException(Exception):
257+
pass
258+
```
259+
260+
Caught in `api/oss/src/apis/fastapi/tracing/router.py`:
261+
```python
262+
except FilteringException as e:
263+
raise HTTPException(status_code=400, detail=str(e)) from e
264+
```
265+
266+
**Example 3 — EE organization exceptions (base class hierarchy):**
267+
268+
Definition in `api/ee/src/core/organizations/exceptions.py`:
269+
```python
270+
class OrganizationError(Exception):
271+
"""Base exception for organization-related errors."""
272+
pass
273+
274+
class OrganizationSlugConflictError(OrganizationError):
275+
def __init__(self, slug: str, message: str = None):
276+
self.slug = slug
277+
self.message = message or f"Organization slug '{slug}' is already in use."
278+
super().__init__(self.message)
279+
280+
class OrganizationNotFoundError(OrganizationError):
281+
def __init__(self, organization_id: str, message: str = None):
282+
self.organization_id = organization_id
283+
self.message = message or f"Organization '{organization_id}' not found."
284+
super().__init__(self.message)
285+
```
286+
287+
Anti-patterns:
288+
- Do NOT return error dicts like `{"_error": True, "status_code": 502, "detail": "..."}` from services or clients.
289+
- Do NOT raise `HTTPException` from core services — that couples the domain to HTTP.
290+
- Do NOT use bare `Exception` or `ValueError` for domain errors when a typed exception would be clearer.
291+
292+
### Typed DTO returns from services and clients
293+
294+
1. **Service methods must return typed DTOs** (Pydantic `BaseModel` subclasses), not raw dicts, tuples, or `Any`.
295+
2. **HTTP clients should return a response DTO**, not `Tuple[Optional[Any], Optional[str]]`.
296+
3. **Define DTOs in `core/{domain}/dtos.py`** alongside the domain's other data contracts.
297+
4. **Use `Optional[DTO]` for missing entities**, `List[DTO]` for collections.
298+
299+
**Example — Workflow service returns typed DTOs:**
300+
301+
```python
302+
# core/workflows/dtos.py
303+
class Workflow(Artifact):
304+
pass
305+
306+
class WorkflowVariant(Variant):
307+
pass
308+
309+
# core/workflows/service.py
310+
async def create_workflow(self, *, project_id, user_id, workflow_create) -> Optional[Workflow]:
311+
artifact = await self.workflows_dao.create_artifact(...)
312+
if not artifact:
313+
return None
314+
return Workflow(**artifact.model_dump(mode="json"))
315+
```
316+
317+
**Example — HTTP client returns a DTO instead of a raw tuple:**
318+
319+
```python
320+
# Instead of:
321+
async def invoke(...) -> Tuple[Optional[Any], Optional[str]]:
322+
return data, trace_id # untyped
323+
324+
# Do:
325+
class InvokeResponse(BaseModel):
326+
data: Any
327+
trace_id: Optional[str] = None
328+
329+
async def invoke(...) -> InvokeResponse:
330+
return InvokeResponse(data=data, trace_id=trace_id)
331+
```
332+
333+
Anti-patterns:
334+
- Do NOT return raw dicts from service methods.
335+
- Do NOT return tuples like `(data, trace_id)` — use a named DTO.
336+
- Do NOT use `Dict[str, Any]` as a return type when you can define a proper model.
337+
207338
### Migration seams (do not copy for net-new code)
208339

209340
These exist during transition but should not be copied into new implementations:

api/ee/src/core/entitlements/types.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class Category(str, Enum):
7575
TRACING_SLOW = "tracing_slow"
7676
SERVICES_FAST = "services_fast"
7777
SERVICES_SLOW = "services_slow"
78+
AI_SERVICES = "ai_services"
7879

7980

8081
class Method(str, Enum):
@@ -122,6 +123,9 @@ class Throttle(BaseModel):
122123
Category.SERVICES_SLOW: [
123124
# None defined yet
124125
],
126+
Category.AI_SERVICES: [
127+
(Method.POST, "/ai/services/tools/call"),
128+
],
125129
Category.STANDARD: [
126130
# None defined yet
127131
# CATCH ALL
@@ -364,6 +368,16 @@ class Throttle(BaseModel):
364368
rate=1,
365369
),
366370
),
371+
Throttle(
372+
categories=[
373+
Category.AI_SERVICES,
374+
],
375+
mode=Mode.INCLUDE,
376+
bucket=Bucket(
377+
capacity=10,
378+
rate=30,
379+
),
380+
),
367381
],
368382
},
369383
Plan.CLOUD_V0_PRO: {
@@ -436,6 +450,16 @@ class Throttle(BaseModel):
436450
rate=1,
437451
),
438452
),
453+
Throttle(
454+
categories=[
455+
Category.AI_SERVICES,
456+
],
457+
mode=Mode.INCLUDE,
458+
bucket=Bucket(
459+
capacity=30,
460+
rate=90,
461+
),
462+
),
439463
],
440464
},
441465
Plan.CLOUD_V0_BUSINESS: {
@@ -506,6 +530,16 @@ class Throttle(BaseModel):
506530
rate=1,
507531
),
508532
),
533+
Throttle(
534+
categories=[
535+
Category.AI_SERVICES,
536+
],
537+
mode=Mode.INCLUDE,
538+
bucket=Bucket(
539+
capacity=300,
540+
rate=900,
541+
),
542+
),
509543
],
510544
},
511545
Plan.CLOUD_V0_HUMANITY_LABS: {

api/entrypoints/routers.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,9 @@
103103
from oss.src.apis.fastapi.evaluations.router import EvaluationsRouter
104104
from oss.src.apis.fastapi.evaluations.router import SimpleEvaluationsRouter
105105

106+
from oss.src.core.ai_services.service import AIServicesService
107+
from oss.src.apis.fastapi.ai_services.router import AIServicesRouter
108+
106109

107110
from oss.src.routers import (
108111
admin_router,
@@ -431,6 +434,13 @@ async def lifespan(*args, **kwargs):
431434
annotations_service=annotations_service,
432435
)
433436

437+
# AI SERVICES ------------------------------------------------------------------
438+
439+
ai_services_service = AIServicesService.from_env()
440+
ai_services = AIServicesRouter(
441+
ai_services_service=ai_services_service,
442+
)
443+
434444
# MOUNTING ROUTERS TO APP ROUTES -----------------------------------------------
435445

436446
app.include_router(
@@ -532,6 +542,12 @@ async def lifespan(*args, **kwargs):
532542
tags=["Workflows"],
533543
)
534544

545+
app.include_router(
546+
router=ai_services.router,
547+
prefix="/ai/services",
548+
tags=["AI Services"],
549+
)
550+
535551
app.include_router(
536552
router=evaluators.router,
537553
prefix="/preview/evaluators",
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
from oss.src.core.ai_services.dtos import (
2+
AIServicesStatus,
3+
ToolCallRequest,
4+
ToolCallResponse,
5+
)
6+
7+
8+
class AIServicesStatusResponse(AIServicesStatus):
9+
pass
10+
11+
12+
class ToolCallRequestModel(ToolCallRequest):
13+
pass
14+
15+
16+
class ToolCallResponseModel(ToolCallResponse):
17+
pass
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
from __future__ import annotations
2+
3+
from fastapi import APIRouter, HTTPException, status
4+
from pydantic import ValidationError
5+
6+
from oss.src.utils.exceptions import intercept_exceptions
7+
8+
from oss.src.core.ai_services.dtos import AIServicesUnknownToolError
9+
from oss.src.core.ai_services.service import AIServicesService
10+
from oss.src.apis.fastapi.ai_services.models import (
11+
AIServicesStatusResponse,
12+
ToolCallRequestModel,
13+
ToolCallResponseModel,
14+
)
15+
16+
17+
class AIServicesRouter:
18+
def __init__(
19+
self,
20+
*,
21+
ai_services_service: AIServicesService,
22+
):
23+
self.service = ai_services_service
24+
self.router = APIRouter()
25+
26+
self.router.add_api_route(
27+
"/status",
28+
self.get_status,
29+
methods=["GET"],
30+
operation_id="ai_services_status",
31+
status_code=status.HTTP_200_OK,
32+
response_model=AIServicesStatusResponse,
33+
response_model_exclude_none=True,
34+
)
35+
36+
self.router.add_api_route(
37+
"/tools/call",
38+
self.call_tool,
39+
methods=["POST"],
40+
operation_id="ai_services_tools_call",
41+
status_code=status.HTTP_200_OK,
42+
response_model=ToolCallResponseModel,
43+
response_model_exclude_none=True,
44+
)
45+
46+
@intercept_exceptions()
47+
async def get_status(self) -> AIServicesStatusResponse:
48+
# TODO: Access control should be org-level feature flag (org owner
49+
# enables/disables AI services for the whole org) rather than
50+
# per-user permissions. For now, env-var gating is sufficient.
51+
return self.service.status()
52+
53+
@intercept_exceptions()
54+
async def call_tool(
55+
self,
56+
*,
57+
tool_call: ToolCallRequestModel,
58+
) -> ToolCallResponseModel:
59+
if not self.service.enabled:
60+
raise HTTPException(status_code=503, detail="AI services are disabled")
61+
62+
# TODO: Access control should be org-level feature flag (org owner
63+
# enables/disables AI services for the whole org) rather than
64+
# per-user permissions. For now, env-var gating is sufficient.
65+
66+
try:
67+
return await self.service.call_tool(
68+
name=tool_call.name,
69+
arguments=tool_call.arguments,
70+
)
71+
except AIServicesUnknownToolError as e:
72+
raise HTTPException(status_code=400, detail=e.message) from e
73+
except ValidationError as e:
74+
raise HTTPException(status_code=400, detail=e.errors()) from e
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+

0 commit comments

Comments
 (0)