Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions docs/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -1029,17 +1029,37 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AgentResponse"
"$ref": "#/components/schemas/JobResponse"
}
}
}
},
"202": {
"description": "Accepted",
"400": {
"description": "Bad Request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AgentResponse"
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"404": {
"description": "Not Found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {
"description": "Internal Server Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
Expand Down
9 changes: 7 additions & 2 deletions src/supervaizer/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -846,8 +846,11 @@ def update_parameters_from_server(
log.debug("[No encrypted parameters] for {self.name}")

def _execute(self, action: str, params: Dict[str, Any] = {}) -> JobResponse:
"""
Execute an agent method and return a JobResponse
"""Execute an agent method and return a JobResponse.

Runs synchronously in the caller's thread; HTTP entrypoints typically
offload this via ``asyncio.to_thread`` so user code cannot block the
event loop.
"""

module_name, func_name = action.rsplit(".", 1)
Expand Down Expand Up @@ -975,12 +978,14 @@ def job_start(
return job

def job_stop(self, params: Dict[str, Any] = {}) -> Any:
"""Synchronous stop hook; controller ``POST /stop`` runs it in a worker thread."""
if not self.methods or not self.methods.job_stop:
raise ValueError("Agent methods not defined")
method = self.methods.job_stop.method
return self._execute(method, params)

def job_status(self, params: Dict[str, Any] = {}) -> Any:
"""Synchronous status hook; controller ``POST /status`` runs it in a worker thread."""
if not self.methods or not self.methods.job_status:
raise ValueError("Agent methods not defined")
method = self.methods.job_status.method
Expand Down
4 changes: 2 additions & 2 deletions src/supervaizer/case.py
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ async def request_human_input(
self, updateCaseNode: CaseNodeUpdate, message: str, **kwargs: Any
) -> None:
updateCaseNode.index = len(self.updates) + 1
log.info(
log.debug(
f"[Update case human_input] CaseRef {self.case_ref} with update {updateCaseNode}"
)
await self.account.send_update_case(self, updateCaseNode)
Expand All @@ -350,7 +350,7 @@ def request_human_input_sync(
self, updateCaseNode: CaseNodeUpdate, message: str, **kwargs: Any
) -> None:
updateCaseNode.index = len(self.updates) + 1
log.info(
log.debug(
f"[Update case human_input] CaseRef {self.case_ref} with update {updateCaseNode}"
)
self.account.send_update_case_sync(self, updateCaseNode)
Expand Down
2 changes: 2 additions & 0 deletions src/supervaizer/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class ContractModel(BaseModel):
class ControllerEndpoint(StrEnum):
POST_AGENT_JOB_START = "POST_AGENT_JOB_START"
POST_AGENT_JOB_CUSTOM = "POST_AGENT_JOB_CUSTOM"
POST_AGENT_STATUS = "POST_AGENT_STATUS"
GET_JOB_STATUS = "GET_JOB_STATUS"
GET_AGENT_JOB_STATUS = "GET_AGENT_JOB_STATUS"
POST_AGENT_STOP = "POST_AGENT_STOP"
Expand All @@ -53,6 +54,7 @@ class ControllerEndpoint(StrEnum):
CONTROLLER_ENDPOINTS: dict[ControllerEndpoint, str] = {
ControllerEndpoint.POST_AGENT_JOB_START: "/api/supervaizer/agents/{agent_slug}/jobs",
ControllerEndpoint.POST_AGENT_JOB_CUSTOM: "/api/supervaizer/agents/{agent_slug}/custom/{method_name}",
ControllerEndpoint.POST_AGENT_STATUS: "/api/supervaizer/agents/{agent_slug}/status",
ControllerEndpoint.GET_JOB_STATUS: "/api/supervaizer/jobs/{job_id}",
ControllerEndpoint.GET_AGENT_JOB_STATUS: "/api/supervaizer/agents/{agent_slug}/jobs/{job_id}",
ControllerEndpoint.POST_AGENT_STOP: "/api/supervaizer/agents/{agent_slug}/stop",
Expand Down
31 changes: 18 additions & 13 deletions src/supervaizer/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,9 @@ async def get_dynamic_choices(
"mission_id": body_params.get("mission_id"),
}

choices = agent.dynamic_choices_callback("start", context)
choices = await asyncio.to_thread(
agent.dynamic_choices_callback, "start", context
)

log.info(f"📤 Agent {agent.name}: Dynamic choices keys: {list(choices.keys())}")
return {"choices": choices}
Expand Down Expand Up @@ -866,7 +868,7 @@ async def stop_agent(
log.info(f"📥 POST /stop [Stop agent] {agent.name} with params {params}")
# Pass job_context as 'context' parameter to match agent method expectations
job_context = params.get("job_context", {})
result = agent.job_stop({"context": job_context})
result = await asyncio.to_thread(agent.job_stop, {"context": job_context})
res_info = result.registration_info if result else {}
return AgentResponse(
name=agent.name,
Expand All @@ -881,8 +883,13 @@ async def stop_agent(
"/status",
summary=f"Get the status of the agent: {agent.name}",
description="Get the status of the agent",
response_model=JobResponse,
status_code=http_status.HTTP_200_OK,
responses={
http_status.HTTP_202_ACCEPTED: {"model": AgentResponse},
http_status.HTTP_200_OK: {"model": JobResponse},
http_status.HTTP_400_BAD_REQUEST: {"model": ErrorResponse},
http_status.HTTP_404_NOT_FOUND: {"model": ErrorResponse},
http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse},
},
dependencies=[
Depends(require_scope("write"))
Expand All @@ -891,17 +898,15 @@ async def stop_agent(
@handle_route_errors()
async def status_agent(
params: AgentMethodParams, agent: Agent = Depends(get_agent)
) -> AgentResponse:
) -> JobResponse:
log.info(f"📥 POST /status [Status agent] {agent.name} with params {params}")
result = agent.job_status(params.params)
return AgentResponse(
name=agent.name,
id=agent.id,
version=agent.version,
api_path=agent.path,
description=agent.description,
**result if result else {},
)
result = await asyncio.to_thread(agent.job_status, params.params)
if result is None:
raise HTTPException(
status_code=http_status.HTTP_404_NOT_FOUND,
detail=f"Agent {agent.name} did not return a job status",
)
return result

@router.post(
"/parameters",
Expand Down
12 changes: 12 additions & 0 deletions tests/test_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ def test_controller_contract_endpoints_are_api_prefixed() -> None:
info["endpoints"]["POST_AGENT_JOB_START"]
== "/api/supervaizer/agents/{agent_slug}/jobs"
)
assert (
info["endpoints"]["POST_AGENT_STATUS"]
== "/api/supervaizer/agents/{agent_slug}/status"
)
assert (
info["endpoints"]["DATA_RESOURCE"]
== "/api/agents/{agent_slug}/data/{resource_name}/"
Expand Down Expand Up @@ -64,6 +68,14 @@ def test_resolve_controller_endpoint() -> None:
)
== "/api/supervaizer/agents/agent-interviewer/jobs"
)
assert (
resolve_controller_endpoint(
contract,
ControllerEndpoint.POST_AGENT_STATUS,
agent_slug="agent-interviewer",
)
== "/api/supervaizer/agents/agent-interviewer/status"
)
assert (
resolve_controller_endpoint(
contract.model_dump(mode="json"),
Expand Down
35 changes: 35 additions & 0 deletions tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@
AgentMethod,
AgentMethods,
Job,
JobResponse,
Server,
)
from supervaizer.data_resource import DataResource, DataResourceContext
from supervaizer.lifecycle import EntityStatus
from supervaizer.parameter import ParametersSetup
from supervaizer.routes import (
create_agents_routes,
Expand Down Expand Up @@ -146,6 +148,39 @@ def mock_dynamic_choices(
assert data["choices"]["projects"] == [["P1", "Project 1"], ["P2", "Project 2"]]


def test_agent_status_endpoint_returns_job_status_response(
server_fixture: Server, mocker: Any
) -> None:
"""POST /status returns the agent job_status method response."""
agent = server_fixture.agents[0]
mocker.patch(
"supervaizer.agent.Agent.job_status",
return_value=JobResponse(
job_id="job-123",
status=EntityStatus.IN_PROGRESS,
message="Campaign: in_progress",
payload={"campaign": {"status": "in_progress"}},
),
)

app = server_fixture.app
app.include_router(create_agents_routes(server_fixture))
client = TestClient(app)
headers = {"X-API-Key": server_fixture.api_key or ""}

resp = client.post(
f"/supervaizer/agents/{agent.slug}/status",
headers=headers,
json={"params": {"job_id": "job-123"}},
)

assert resp.status_code == 200
data = resp.json()
assert data["job_id"] == "job-123"
assert data["status"] == "in_progress"
assert data["payload"]["campaign"]["status"] == "in_progress"


def test_dynamic_choices_endpoint_passes_workspace_slug_in_context(
server_fixture: Server,
) -> None:
Expand Down