diff --git a/AGENTS.md b/AGENTS.md index d861267..b41e3ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,3 +18,10 @@ Reference specific personas when requesting work: - `server.register.details.url` is expected to represent the controller `public_url` (reachable URL), not necessarily the local bind address. - Agent registration payloads currently expose the controller agent identifier as `id`; coordinate with Studio if renaming/removing this field (Studio may also support legacy `agent_id`). - If changing registration/event payload fields, update `supervaizer` tests and validate compatibility against Studio’s controller-event processing. + +## Learned User Preferences + +## Learned Workspace Facts + +- `ADMIN_ALLOWED_IPS` restricts `/admin` when set (comma-separated IPs/CIDR); unset or empty allows all client IPs. +- In `9agents/agent_interviewer`, empty `MANAGE_ALLOWED_IPS` still requires `MANAGE_AUTH_TOKEN` when that env is set; supervaizer’s admin IP middleware has no equivalent token fallback when the allowlist is empty. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 76ea3e9..204e440 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,12 +19,27 @@ All notable changes to this project will be documented in this file. ## Unreleased -## v0.13.0 +### Changed + +- **Dynamic choices request context** — `POST .../start/dynamic_choices` now passes `workspace_slug` through to `dynamic_choices_callback` alongside `workspace_id` and `mission_id` (Supervaize Studio sends it in the JSON body). + +### Unit Tests Results + +`just test` + +| Status | Count | +| ---------- | ----- | +| ✅ Passed | 466 | +| 🤔 Skipped | 0 | +| 🔴 Failed | 0 | +| ⏱️ in | 54s | + +## v0.13.1 ### Added - **`ADMIN_ALLOWED_IPS` for admin UI** — When set, only matching client IPs may access `/admin` (HTML, APIs, static files, WebSocket). Comma-separated IPs and optional CIDR notation; empty or unset allows all. Uses the first address in `X-Forwarded-For` when present. -- **Dynamic choices for `AgentMethodField`** — Fields can now use `dynamic_choices` instead of static `choices` to resolve options at runtime via a callback. Add a `dynamic_choices_callback` callable (signature: `(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]`) to the `Agent` constructor and a `dynamic_choices` key to your `AgentMethodField`. Supervaize Studio fetches choices from the new `POST /supervaizer/agents/{slug}/start/dynamic_choices` endpoint (with `workspace_id` and `mission_id` in the request body) when rendering the job start form. Static `choices` and `dynamic_choices` are mutually exclusive on a field. See [Dynamic Choices guide](https://docs.runwaize.com/docs/supervaizer-controller/dynamic-choices). +- **Dynamic choices for `AgentMethodField`** — Fields can now use `dynamic_choices` instead of static `choices` to resolve options at runtime via a callback. Add a `dynamic_choices_callback` callable (signature: `(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]`) to the `Agent` constructor and a `dynamic_choices` key to your `AgentMethodField`. Supervaize Studio fetches choices from the new `POST /supervaizer/agents/{slug}/start/dynamic_choices` endpoint (with `workspace_id`, `workspace_slug`, and `mission_id` in the request body) when rendering the job start form. Static `choices` and `dynamic_choices` are mutually exclusive on a field. See [Dynamic Choices guide](https://docs.runwaize.com/docs/supervaizer-controller/dynamic-choices). ### Unit Tests Results diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 4ff9171..49fd224 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -635,7 +635,7 @@ "Supervision" ], "summary": "Get dynamic choices for agent: competitor_summary start method", - "description": "Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context for contextualized choices.", + "description": "Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context (including workspace slug) for contextualized choices.", "operationId": "get_dynamic_choices_supervaizer_agents_competitor_summary_start_dynamic_choices_post", "requestBody": { "content": { diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index f71ef15..0e57231 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -627,7 +627,7 @@ class AgentAbstract(SvBaseModel): ) dynamic_choices_callback: Any | None = Field( default=None, - description="Callable that returns dynamic choices for method fields. Signature: (method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]", + description="Callable that returns dynamic choices for method fields. Signature: (method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]. Context includes workspace_id, workspace_slug, mission_id from the dynamic_choices request body.", exclude=True, ) diff --git a/src/supervaizer/job.py b/src/supervaizer/job.py index afd8c0d..573d23e 100644 --- a/src/supervaizer/job.py +++ b/src/supervaizer/job.py @@ -10,6 +10,8 @@ from datetime import datetime from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional +from pydantic import field_validator + from supervaizer.__version__ import VERSION from supervaizer.common import SvBaseModel, log, singleton from supervaizer.lifecycle import ( @@ -160,6 +162,16 @@ class JobContext(SvBaseModel): mission_context: Any = None job_instructions: Optional[JobInstructions] = None + @field_validator("workspace_id", mode="before") + @classmethod + def coerce_workspace_id(cls, v: Any) -> Any: + """Supervaize API may send numeric workspace ids (JSON); normalize to str.""" + if isinstance(v, str): + return v.strip() + if isinstance(v, (int, float)): + return str(v) + return v + @property def registration_info(self) -> Dict[str, Any]: """Returns registration info for the job context""" diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index d0edbf0..b21f840 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -621,7 +621,7 @@ async def validate_method_fields( @router.post( "/start/dynamic_choices", summary=f"Get dynamic choices for agent: {agent.name} start method", - description="Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context for contextualized choices.", + description="Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context (including workspace slug) for contextualized choices.", response_model=Dict[str, Any], responses={ http_status.HTTP_200_OK: {"model": Dict[str, Any]}, @@ -649,6 +649,7 @@ async def get_dynamic_choices( context = { "workspace_id": body_params.get("workspace_id"), + "workspace_slug": body_params.get("workspace_slug"), "mission_id": body_params.get("mission_id"), } diff --git a/tests/test_job.py b/tests/test_job.py index cd9ccbd..a6620d5 100644 --- a/tests/test_job.py +++ b/tests/test_job.py @@ -36,6 +36,18 @@ def job_fixture(context_fixture: JobContext) -> Job: ) +def test_job_context_workspace_id_int_coerced_to_str() -> None: + ctx = JobContext( + workspace_id=23, + job_id="j1", + started_by="u", + started_at=datetime.now(), + mission_id="m1", + mission_name="M", + ) + assert ctx.workspace_id == "23" + + def test_job_creation(context_fixture: JobContext, job_fixture: Job) -> None: job_context = context_fixture job = job_fixture diff --git a/tests/test_routes.py b/tests/test_routes.py index 1ba4f2e..305a0f5 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -119,13 +119,55 @@ def mock_dynamic_choices( resp = client.post( f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers, - json={"workspace_id": "ws-1", "mission_id": "m-1"}, + json={ + "workspace_id": "ws-1", + "workspace_slug": "adl", + "mission_id": "m-1", + }, ) assert resp.status_code == 200 data = resp.json() assert data["choices"]["projects"] == [["P1", "Project 1"], ["P2", "Project 2"]] +def test_dynamic_choices_endpoint_passes_workspace_slug_in_context( + server_fixture: Server, +) -> None: + """Callback context includes workspace_slug from the request body.""" + + captured: dict[str, Any] = {} + + def mock_dynamic_choices( + method_name: str, context: dict + ) -> dict[str, list[tuple[str, str]]]: + captured["context"] = dict(context) + return {"projects": [("P1", "Project 1")]} + + agent = server_fixture.agents[0] + agent.dynamic_choices_callback = mock_dynamic_choices + + 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}/start/dynamic_choices", + headers=headers, + json={ + "workspace_id": 23, + "workspace_slug": "adl", + "mission_id": "01KNPPT249HFSSW662KW9R477N", + }, + ) + assert resp.status_code == 200 + assert captured["context"] == { + "workspace_id": 23, + "workspace_slug": "adl", + "mission_id": "01KNPPT249HFSSW662KW9R477N", + } + + def test_dynamic_choices_endpoint_multiple_keys( server_fixture: Server, ) -> None: @@ -150,7 +192,11 @@ def mock_dynamic_choices( resp = client.post( f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers, - json={"workspace_id": "ws-1", "mission_id": "m-1"}, + json={ + "workspace_id": "ws-1", + "workspace_slug": "slug-1", + "mission_id": "m-1", + }, ) assert resp.status_code == 200 data = resp.json() @@ -179,7 +225,11 @@ def mock_dynamic_choices( resp = client.post( f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers, - json={"workspace_id": "ws-1", "mission_id": "m-1"}, + json={ + "workspace_id": "ws-1", + "workspace_slug": "slug-1", + "mission_id": "m-1", + }, ) assert resp.status_code == 200 data = resp.json() @@ -226,6 +276,10 @@ def test_dynamic_choices_endpoint_no_callback( resp = client.post( f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers, - json={"workspace_id": "ws-1", "mission_id": "m-1"}, + json={ + "workspace_id": "ws-1", + "workspace_slug": "slug-1", + "mission_id": "m-1", + }, ) assert resp.status_code == 404