diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 49fd224..c09260f 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -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" } } } diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index ba1b8d2..3fdb5ef 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -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) @@ -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 diff --git a/src/supervaizer/case.py b/src/supervaizer/case.py index 25b67d7..87664ce 100644 --- a/src/supervaizer/case.py +++ b/src/supervaizer/case.py @@ -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) @@ -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) diff --git a/src/supervaizer/contracts.py b/src/supervaizer/contracts.py index 419be78..3d146eb 100644 --- a/src/supervaizer/contracts.py +++ b/src/supervaizer/contracts.py @@ -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" @@ -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", diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index 486709b..bcf1799 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -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} @@ -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, @@ -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")) @@ -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", diff --git a/tests/test_contracts.py b/tests/test_contracts.py index 46b2d62..b497b8e 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -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}/" @@ -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"), diff --git a/tests/test_routes.py b/tests/test_routes.py index 18f2cc9..a099d92 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -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, @@ -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: