From e694939d1d1896698cff451e6ebf255319d0f799 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 19:03:52 +0300 Subject: [PATCH 01/15] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20dynamic=5Fchoic?= =?UTF-8?q?es=20attribute=20to=20AgentMethodField?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-04-08-dynamic-choices.md | 1247 +++++++++++++++++ src/supervaizer/agent.py | 15 +- .../examples/controller_template.py | 19 + tests/test_agent.py | 40 + 4 files changed, 1320 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/plans/2026-04-08-dynamic-choices.md diff --git a/docs/superpowers/plans/2026-04-08-dynamic-choices.md b/docs/superpowers/plans/2026-04-08-dynamic-choices.md new file mode 100644 index 0000000..4ab473b --- /dev/null +++ b/docs/superpowers/plans/2026-04-08-dynamic-choices.md @@ -0,0 +1,1247 @@ +# Dynamic Choices for AgentMethodField — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow AgentMethodField to declare dynamic choices that are resolved at runtime via a callback on the Agent, exposed through a new API endpoint that Supervaize Studio calls when rendering job-start forms. + +**Architecture:** A new `dynamic_choices: str | None` attribute on `AgentMethodField` acts as a key. The `Agent` class gets a `get_dynamic_choices` callable that maps method names to `{key: [(value, label), ...]}`. A new GET endpoint `/agents/{slug}/start/dynamic_choices` invokes this callback and returns the choices. Static `choices` and `dynamic_choices` are mutually exclusive on a field. + +**Tech Stack:** Python 3.12+, Pydantic v2, FastAPI + +--- + +### Task 1: Add `dynamic_choices` attribute to `AgentMethodField` + +**Files:** +- Modify: `src/supervaizer/agent.py:55-128` (AgentMethodField class) +- Test: `tests/test_agent.py` + +- [ ] **Step 1: Write the failing test for the new attribute** + +In `tests/test_agent.py`, add: + +```python +def test_agent_method_field_dynamic_choices(): + """Test that AgentMethodField accepts dynamic_choices attribute.""" + field = AgentMethodField( + name="List of projects", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ) + assert field.dynamic_choices == "projects" + assert field.choices is None + + +def test_agent_method_field_dynamic_choices_default_none(): + """Test that dynamic_choices defaults to None.""" + field = AgentMethodField( + name="color", + type=str, + field_type="ChoiceField", + choices=[("R", "Red"), ("B", "Blue")], + required=True, + ) + assert field.dynamic_choices is None + + +def test_agent_method_field_dynamic_choices_mutual_exclusion(): + """Test that choices and dynamic_choices cannot both be set.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError, match="mutually exclusive"): + AgentMethodField( + name="List of projects", + type=str, + field_type="ChoiceField", + choices=[("A", "Option A")], + dynamic_choices="projects", + required=True, + ) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest tests/test_agent.py::test_agent_method_field_dynamic_choices tests/test_agent.py::test_agent_method_field_dynamic_choices_default_none tests/test_agent.py::test_agent_method_field_dynamic_choices_mutual_exclusion -v` + +Expected: FAIL — `dynamic_choices` not recognized as a field + +- [ ] **Step 3: Add `dynamic_choices` field and validator to `AgentMethodField`** + +In `src/supervaizer/agent.py`, inside the `AgentMethodField` class (after the `required` field, before `model_config`): + +```python + dynamic_choices: str | None = Field( + default=None, + description="Key name for dynamic choices resolved at runtime via Agent.get_dynamic_choices callback. Mutually exclusive with 'choices'.", + ) + + @model_validator(mode="after") + def validate_choices_mutual_exclusion(self) -> "AgentMethodField": + if self.choices is not None and self.dynamic_choices is not None: + raise ValueError( + "'choices' and 'dynamic_choices' are mutually exclusive. " + "Use 'choices' for static options or 'dynamic_choices' for runtime-resolved options." + ) + return self +``` + +Add `model_validator` to the pydantic imports at the top of the file if not already present. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest tests/test_agent.py::test_agent_method_field_dynamic_choices tests/test_agent.py::test_agent_method_field_dynamic_choices_default_none tests/test_agent.py::test_agent_method_field_dynamic_choices_mutual_exclusion -v` + +Expected: All 3 PASS + +- [ ] **Step 5: Run full test suite to verify no regressions** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest -v` + +Expected: All existing tests still PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/supervaizer/agent.py tests/test_agent.py +git commit -m "feat: add dynamic_choices attribute to AgentMethodField" +``` + +--- + +### Task 2: Add `dynamic_choices` to `fields_definitions` serialization + +**Files:** +- Modify: `src/supervaizer/agent.py:229-250` (AgentMethod.fields_definitions property) +- Test: `tests/test_agent.py` + +The `fields_definitions` property currently uses `field.__dict__` which will automatically include `dynamic_choices`. We need to verify this works and that `registration_info` propagates it correctly. + +- [ ] **Step 1: Write the failing test** + +In `tests/test_agent.py`, add: + +```python +def test_agent_method_fields_definitions_includes_dynamic_choices(): + """Test that fields_definitions includes dynamic_choices in the output.""" + method = AgentMethod( + name="start", + method="my_module.start", + fields=[ + AgentMethodField( + name="Project", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ), + ], + description="Start", + ) + definitions = method.fields_definitions + assert len(definitions) == 1 + assert definitions[0]["dynamic_choices"] == "projects" + assert definitions[0]["choices"] is None + + +def test_agent_method_registration_info_includes_dynamic_choices(): + """Test that registration_info propagates dynamic_choices through fields.""" + method = AgentMethod( + name="start", + method="my_module.start", + fields=[ + AgentMethodField( + name="Project", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ), + ], + description="Start", + ) + info = method.registration_info + assert info["fields"][0]["dynamic_choices"] == "projects" +``` + +- [ ] **Step 2: Run tests to verify they pass (they should already work via `__dict__`)** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest tests/test_agent.py::test_agent_method_fields_definitions_includes_dynamic_choices tests/test_agent.py::test_agent_method_registration_info_includes_dynamic_choices -v` + +Expected: PASS — `fields_definitions` uses `field.__dict__` which already includes `dynamic_choices` + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_agent.py +git commit -m "test: verify dynamic_choices serialization in fields_definitions" +``` + +--- + +### Task 3: Add `get_dynamic_choices` callback to `Agent` + +**Files:** +- Modify: `src/supervaizer/agent.py:558-618` (AgentAbstract) and `src/supervaizer/agent.py:621-695` (Agent.__init__) +- Test: `tests/test_agent.py` + +- [ ] **Step 1: Write the failing test** + +In `tests/test_agent.py`, add: + +```python +def test_agent_with_dynamic_choices_callback(agent_method_fixture: AgentMethod): + """Test that Agent accepts a get_dynamic_choices callable.""" + + def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + return {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} + + agent = Agent( + name="dynamicAgent", + author="test", + version="1.0", + description="test agent", + methods=AgentMethods(job_start=agent_method_fixture), + get_dynamic_choices=my_dynamic_choices, + ) + assert agent.get_dynamic_choices is not None + result = agent.get_dynamic_choices("start") + assert result == {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} + + +def test_agent_without_dynamic_choices_callback(agent_method_fixture: AgentMethod): + """Test that get_dynamic_choices defaults to None.""" + agent = Agent( + name="staticAgent", + author="test", + version="1.0", + description="test agent", + methods=AgentMethods(job_start=agent_method_fixture), + ) + assert agent.get_dynamic_choices is None +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest tests/test_agent.py::test_agent_with_dynamic_choices_callback tests/test_agent.py::test_agent_without_dynamic_choices_callback -v` + +Expected: FAIL — `get_dynamic_choices` not a recognized attribute + +- [ ] **Step 3: Add `get_dynamic_choices` to `AgentAbstract` and `Agent.__init__`** + +In `src/supervaizer/agent.py`, in `AgentAbstract` class, add after the `custom_routes` field (line ~614): + +```python + get_dynamic_choices: Any | None = Field( + default=None, + description="Callable that returns dynamic choices for method fields. Signature: (method_name: str) -> dict[str, list[tuple[str, str]]]", + exclude=True, + ) +``` + +In `Agent.__init__` (line ~622), add the parameter to the signature after `custom_routes`: + +```python + get_dynamic_choices: Any | None = None, +``` + +And pass it through to `super().__init__()`: + +```python + get_dynamic_choices=get_dynamic_choices, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest tests/test_agent.py::test_agent_with_dynamic_choices_callback tests/test_agent.py::test_agent_without_dynamic_choices_callback -v` + +Expected: PASS + +- [ ] **Step 5: Run full test suite** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest -v` + +Expected: All tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/supervaizer/agent.py tests/test_agent.py +git commit -m "feat: add get_dynamic_choices callback to Agent" +``` + +--- + +### Task 4: Add the `/start/dynamic_choices` endpoint + +**Files:** +- Modify: `src/supervaizer/routes.py` (inside `create_agent_route` function, after validate-method-fields endpoint ~line 619) +- Test: `tests/test_routes.py` + +- [ ] **Step 1: Write the failing tests** + +In `tests/test_routes.py`, add: + +```python +from supervaizer.agent import AgentMethodField +from supervaizer.routes import create_agents_routes + + +def test_dynamic_choices_endpoint(server_fixture: Server, mocker: Any) -> None: + """Test GET /supervaizer/agents/{slug}/start/dynamic_choices returns choices.""" + + def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + if method_name == "start": + return {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} + return {} + + # Set the callback on the agent + agent = server_fixture.agents[0] + agent.get_dynamic_choices = 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.get( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + ) + assert resp.status_code == 200 + data = resp.json() + assert data["choices"]["projects"] == [["P1", "Project 1"], ["P2", "Project 2"]] + + +def test_dynamic_choices_endpoint_no_callback( + server_fixture: Server, mocker: Any +) -> None: + """Test that endpoint returns 404 when no callback is registered.""" + agent = server_fixture.agents[0] + agent.get_dynamic_choices = None + + 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.get( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + ) + assert resp.status_code == 404 +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest tests/test_routes.py::test_dynamic_choices_endpoint tests/test_routes.py::test_dynamic_choices_endpoint_no_callback -v` + +Expected: FAIL — endpoint does not exist (404 for both, but for wrong reason on the first) + +- [ ] **Step 3: Add the endpoint to `create_agent_route`** + +In `src/supervaizer/routes.py`, inside `create_agent_route()`, add after the `validate_method_fields` endpoint (after ~line 619, before the job model creation): + +```python + @router.get( + "/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", + response_model=Dict[str, Any], + responses={ + http_status.HTTP_200_OK: {"model": Dict[str, Any]}, + http_status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}, + http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse}, + }, + dependencies=[Security(server.verify_api_key)], + ) + @handle_route_errors() + async def get_dynamic_choices( + agent: Agent = Depends(get_agent), + ) -> Dict[str, Any] | JSONResponse: + """Get dynamic choices for the start method fields.""" + log.info( + f"📥 GET /start/dynamic_choices [Dynamic choices] {agent.name}" + ) + + if not agent.get_dynamic_choices: + raise HTTPException( + status_code=http_status.HTTP_404_NOT_FOUND, + detail=f"Agent {agent.name} does not have dynamic choices configured", + ) + + choices = agent.get_dynamic_choices("start") + + log.info( + f"📤 Agent {agent.name}: Dynamic choices keys: {list(choices.keys())}" + ) + return {"choices": choices} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest tests/test_routes.py::test_dynamic_choices_endpoint tests/test_routes.py::test_dynamic_choices_endpoint_no_callback -v` + +Expected: PASS + +- [ ] **Step 5: Run full test suite** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest -v` + +Expected: All tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add src/supervaizer/routes.py tests/test_routes.py +git commit -m "feat: add /start/dynamic_choices endpoint" +``` + +--- + +### Task 5: Update the example template + +**Files:** +- Modify: `src/supervaizer/examples/controller_template.py` + +- [ ] **Step 1: Wire the existing `get_dynamic_choices` function to the Agent** + +In `src/supervaizer/examples/controller_template.py`, the function `get_dynamic_choices` already exists at line 39. Update the `Agent` constructor (line ~176) to pass it: + +```python +agent: Agent = Agent( + name=agent_name, + id=shortuuid.uuid(f"{agent_name}"), + author="John Doe", + developer="Developer", + maintainer="Ive Maintained", + editor="DevAiExperts", + version="1.3", + description="This is a test agent", + tags=["testtag", "testtag2"], + methods=AgentMethods( + job_start=job_start_method, + job_stop=job_stop_method, + job_status=job_status_method, + chat=None, + custom={"custom1": custom_method, "custom2": custom_method2}, + ), + parameters_setup=agent_parameters, + instructions_path="supervaize_instructions.html", + get_dynamic_choices=get_dynamic_choices, +) +``` + +- [ ] **Step 2: Run full test suite to verify no regressions** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest -v` + +Expected: All tests PASS + +- [ ] **Step 3: Commit** + +```bash +git add src/supervaizer/examples/controller_template.py +git commit -m "feat: wire get_dynamic_choices in example controller template" +``` + +--- + +### Task 6: Comprehensive unit tests + +**Files:** +- Modify: `tests/test_agent.py` +- Modify: `tests/test_routes.py` + +This task adds additional edge-case and integration tests beyond the basic ones written in Tasks 1-4. + +- [ ] **Step 1: Add edge-case tests for `AgentMethodField`** + +In `tests/test_agent.py`, add: + +```python +def test_agent_method_field_dynamic_choices_in_model_dump(): + """Test that dynamic_choices appears in model_dump output.""" + field = AgentMethodField( + name="Project", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ) + dumped = field.model_dump() + assert dumped["dynamic_choices"] == "projects" + assert dumped["choices"] is None + + +def test_agent_method_field_static_choices_no_dynamic(): + """Test that static choices field has dynamic_choices=None in model_dump.""" + field = AgentMethodField( + name="Color", + type=str, + field_type="ChoiceField", + choices=[("R", "Red"), ("B", "Blue")], + required=True, + ) + dumped = field.model_dump() + assert dumped["dynamic_choices"] is None + assert dumped["choices"] == [("R", "Red"), ("B", "Blue")] + + +def test_agent_method_field_non_choice_field_with_dynamic_choices(): + """Test that dynamic_choices can be set on a ChoiceField type.""" + field = AgentMethodField( + name="Items", + type=str, + field_type="ChoiceField", + dynamic_choices="items", + required=False, + ) + assert field.dynamic_choices == "items" + assert field.field_type == "ChoiceField" + + +def test_agent_method_mixed_static_and_dynamic_fields(): + """Test a method with both static and dynamic choice fields.""" + method = AgentMethod( + name="start", + method="my_module.start", + fields=[ + AgentMethodField( + name="Type", + type=str, + field_type="ChoiceField", + choices=[("A", "Alpha"), ("B", "Beta")], + required=True, + ), + AgentMethodField( + name="Project", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ), + AgentMethodField( + name="Name", + type=str, + field_type="CharField", + required=True, + ), + ], + description="Start", + ) + defs = method.fields_definitions + assert defs[0]["choices"] == [("A", "Alpha"), ("B", "Beta")] + assert defs[0]["dynamic_choices"] is None + assert defs[1]["choices"] is None + assert defs[1]["dynamic_choices"] == "projects" + assert defs[2]["dynamic_choices"] is None +``` + +- [ ] **Step 2: Add callback edge-case tests for `Agent`** + +In `tests/test_agent.py`, add: + +```python +def test_agent_dynamic_choices_callback_returns_empty(agent_method_fixture: AgentMethod): + """Test callback that returns empty dict for unknown method.""" + + def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + if method_name == "start": + return {"projects": [("P1", "Project 1")]} + return {} + + agent = Agent( + name="emptyCallbackAgent", + author="test", + version="1.0", + description="test", + methods=AgentMethods(job_start=agent_method_fixture), + get_dynamic_choices=my_dynamic_choices, + ) + assert agent.get_dynamic_choices("start") == {"projects": [("P1", "Project 1")]} + assert agent.get_dynamic_choices("unknown") == {} + + +def test_agent_dynamic_choices_callback_multiple_keys(agent_method_fixture: AgentMethod): + """Test callback returning multiple choice keys.""" + + def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + return { + "projects": [("P1", "Project 1"), ("P2", "Project 2")], + "teams": [("T1", "Team Alpha"), ("T2", "Team Beta")], + } + + agent = Agent( + name="multiKeyAgent", + author="test", + version="1.0", + description="test", + methods=AgentMethods(job_start=agent_method_fixture), + get_dynamic_choices=my_dynamic_choices, + ) + result = agent.get_dynamic_choices("start") + assert "projects" in result + assert "teams" in result + assert len(result["projects"]) == 2 + assert len(result["teams"]) == 2 +``` + +- [ ] **Step 3: Add route endpoint edge-case tests** + +In `tests/test_routes.py`, add: + +```python +def test_dynamic_choices_endpoint_multiple_keys( + server_fixture: Server, mocker: Any +) -> None: + """Test endpoint returns multiple choice keys.""" + + def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + return { + "projects": [("P1", "Project 1")], + "teams": [("T1", "Team Alpha")], + } + + agent = server_fixture.agents[0] + agent.get_dynamic_choices = 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.get( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + ) + assert resp.status_code == 200 + data = resp.json() + assert "projects" in data["choices"] + assert "teams" in data["choices"] + + +def test_dynamic_choices_endpoint_empty_result( + server_fixture: Server, mocker: Any +) -> None: + """Test endpoint returns empty choices when callback returns empty dict.""" + + def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + return {} + + agent = server_fixture.agents[0] + agent.get_dynamic_choices = 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.get( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + ) + assert resp.status_code == 200 + data = resp.json() + assert data["choices"] == {} + + +def test_dynamic_choices_endpoint_requires_api_key( + server_fixture: Server, mocker: Any +) -> None: + """Test that the dynamic choices endpoint requires API key authentication.""" + + def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + return {"projects": [("P1", "Project 1")]} + + agent = server_fixture.agents[0] + agent.get_dynamic_choices = mock_dynamic_choices + + app = server_fixture.app + app.include_router(create_agents_routes(server_fixture)) + client = TestClient(app) + + # No API key header + resp = client.get( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices" + ) + assert resp.status_code == 403 +``` + +- [ ] **Step 4: Run all tests** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest -v` + +Expected: All tests PASS + +- [ ] **Step 5: Commit** + +```bash +git add tests/test_agent.py tests/test_routes.py +git commit -m "test: add comprehensive unit tests for dynamic choices" +``` + +--- + +### Task 7: Clean code review — Pass 1 (agent.py) + +**Files:** +- Review & modify: `src/supervaizer/agent.py` + +- [ ] **Step 1: Run the clean-code skill on agent.py** + +Use the `/simplify` skill (or `clean-code-clean-general` skill) focused on the changes made in Tasks 1-3 within `src/supervaizer/agent.py`. Review for: +- Naming clarity of `dynamic_choices` field and `get_dynamic_choices` callback +- Validator readability +- Consistency with existing code patterns in the file +- Any unnecessary complexity introduced + +- [ ] **Step 2: Apply fixes if needed and run tests** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest -v` + +Expected: All tests PASS + +- [ ] **Step 3: Commit if changes were made** + +```bash +git add src/supervaizer/agent.py +git commit -m "refactor: clean code pass on agent.py dynamic choices" +``` + +--- + +### Task 8: Clean code review — Pass 2 (routes.py) + +**Files:** +- Review & modify: `src/supervaizer/routes.py` + +- [ ] **Step 1: Run the clean-code skill on routes.py** + +Use the `/simplify` skill focused on the new `get_dynamic_choices` endpoint added in Task 4. Review for: +- Consistency with other endpoint patterns in the same file (logging style, error handling, response format) +- Unnecessary code or over-engineering +- Proper use of FastAPI patterns (dependencies, response models) + +- [ ] **Step 2: Apply fixes if needed and run tests** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest -v` + +Expected: All tests PASS + +- [ ] **Step 3: Commit if changes were made** + +```bash +git add src/supervaizer/routes.py +git commit -m "refactor: clean code pass on routes.py dynamic choices endpoint" +``` + +--- + +### Task 9: Clean code review — Pass 3 (tests) + +**Files:** +- Review & modify: `tests/test_agent.py` +- Review & modify: `tests/test_routes.py` + +- [ ] **Step 1: Run the clean-code skill on the test files** + +Use the `/simplify` skill focused on all new tests added in Tasks 1-4 and Task 6. Review for: +- Test naming consistency +- Unnecessary duplication between tests +- Missing assertions or redundant assertions +- Fixture reuse opportunities +- Compliance with existing test patterns in the files + +- [ ] **Step 2: Apply fixes if needed and run tests** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest -v` + +Expected: All tests PASS + +- [ ] **Step 3: Commit if changes were made** + +```bash +git add tests/test_agent.py tests/test_routes.py +git commit -m "refactor: clean code pass on dynamic choices tests" +``` + +--- + +### Task 10: Update supervaize-doc — model reference + +**Files:** +- Modify: `/Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaize-doc/docs/supervaizer-controller/model_reference/model_core.md:213-288` (AgentMethodField section) + +- [ ] **Step 1: Add `dynamic_choices` to the AgentMethodField field table** + +In the field table at line ~234, add a new row after the `choices` row: + +```markdown +| `dynamic_choices` | `str` | `None` | Key name for runtime-resolved choices via `Agent.get_dynamic_choices` callback. Mutually exclusive with `choices`. | +``` + +- [ ] **Step 2: Add a dynamic choices example after the existing examples** + +After "Example 2" (line ~287), add: + +```markdown +**Example 3: Dynamic choices field** + +```json +{ + "name": "List of projects", + "type": "str", + "field_type": "ChoiceField", + "dynamic_choices": "projects", + "choices": null, + "required": true +} +``` + +> When `dynamic_choices` is set, choices are not embedded in the field definition. Instead, Supervaize Studio fetches them at runtime from the `GET /agents/{slug}/start/dynamic_choices` endpoint. See [Dynamic Choices](/docs/supervaizer-controller/dynamic-choices) for setup instructions. +``` + +- [ ] **Step 3: Add `get_dynamic_choices` to the Agent class documentation** + +Find the `agent.AgentAbstract` section in model_core.md and add to its field table: + +```markdown +| `get_dynamic_choices` | `Callable` | `None` | Callback that returns dynamic choices for method fields. Signature: `(method_name: str) -> dict[str, list[tuple[str, str]]]`. Excluded from serialization. | +``` + +- [ ] **Step 4: Commit** + +```bash +cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaize-doc +git add docs/supervaizer-controller/model_reference/model_core.md +git commit -m "docs: add dynamic_choices to model reference" +``` + +--- + +### Task 11: Update supervaize-doc — controller setup guide + +**Files:** +- Modify: `/Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaize-doc/docs/supervaizer-controller/controller-setup.mdx:145-175` (section 3: fields) + +- [ ] **Step 1: Add a dynamic choices subsection after the existing field setup** + +After the closing `` tag of section 3 (line ~174), and before section 4 "Declare the agent", add: + +```markdown +### Dynamic choices + +For fields whose options are determined at runtime (e.g., a list of projects fetched from a database), use `dynamic_choices` instead of static `choices`: + +```python +AgentMethodField( + name="List of projects", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", # key name — resolved at runtime + required=True, +) +``` + +Then define a callback function and pass it to your Agent: + +```python +def get_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + if method_name == "start": + # Fetch from database, API, or any source + return { + "projects": [("P1", "Project 1"), ("P2", "Project 2"), ("P3", "Project 3")], + } + return {} + +agent = Agent( + name="my_agent", + # ... other fields ... + get_dynamic_choices=get_dynamic_choices, +) +``` + +:::info +`choices` and `dynamic_choices` are mutually exclusive on a field. Use `choices` for fixed options known at definition time, and `dynamic_choices` for options that change at runtime. +::: + +See the full [Dynamic Choices guide](/docs/supervaizer-controller/dynamic-choices) for details. +``` + +- [ ] **Step 2: In section 4 "Declare the agent", mention the optional callback** + +After the existing Agent example prompt (line ~205), add a note: + +```markdown +:::tip +If your agent uses dynamic choice fields, pass the `get_dynamic_choices` callback to the Agent constructor. See [Dynamic Choices](/docs/supervaizer-controller/dynamic-choices). +::: +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaize-doc +git add docs/supervaizer-controller/controller-setup.mdx +git commit -m "docs: add dynamic choices section to controller setup guide" +``` + +--- + +### Task 12: Update supervaize-doc — new dynamic choices guide page + +**Files:** +- Create: `/Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaize-doc/docs/supervaizer-controller/dynamic-choices.mdx` +- Modify: `/Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaize-doc/sidebars.ts` + +- [ ] **Step 1: Create the new doc page** + +Create `/Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaize-doc/docs/supervaizer-controller/dynamic-choices.mdx`: + +```mdx +--- +id: dynamic-choices +title: Dynamic Choices +displayed_sidebar: supervaizerControllerSidebar +slug: dynamic-choices +--- + +# Dynamic Choices + +Dynamic choices allow your agent's form fields to display options that are resolved at runtime rather than being hardcoded in the field definition. This is useful when the available options depend on external data sources like databases, APIs, or configuration files. + +## When to use + +Use dynamic choices when: +- The list of options changes over time (e.g., active projects, team members, available models) +- The options come from an external source (database, API) +- The options are environment-specific (dev vs. prod) + +Use static `choices` when the options are fixed and known at definition time (e.g., country codes, status values). + +## Setup + +### 1. Define the field with `dynamic_choices` + +Instead of providing a `choices` list, set `dynamic_choices` to a key name: + +```python +from supervaizer import AgentMethodField + +field = AgentMethodField( + name="List of projects", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", # key name + required=True, +) +``` + +:::warning +`choices` and `dynamic_choices` are mutually exclusive. Setting both will raise a validation error. +::: + +### 2. Define the callback function + +Create a function that takes a method name and returns a dictionary mapping choice keys to their value/label pairs: + +```python +def get_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + """Return dynamic choices for the given method. + + Args: + method_name: The method requesting choices (e.g., "start") + + Returns: + Dict mapping choice key names to lists of (value, label) tuples + """ + if method_name == "start": + # Fetch from your data source + projects = fetch_projects_from_db() + return { + "projects": [(p.id, p.name) for p in projects], + } + return {} +``` + +The return format is `dict[str, list[tuple[str, str]]]`: +- **Keys** match the `dynamic_choices` values on your fields +- **Values** are lists of `(value, label)` tuples — same format as static `choices` + +You can return multiple keys if multiple fields use dynamic choices: + +```python +def get_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + return { + "projects": [("P1", "Project 1"), ("P2", "Project 2")], + "teams": [("T1", "Team Alpha"), ("T2", "Team Beta")], + } +``` + +### 3. Pass the callback to the Agent + +```python +from supervaizer import Agent + +agent = Agent( + name="my_agent", + author="Your Name", + version="1.0", + description="My agent with dynamic choices", + methods=AgentMethods(job_start=job_start_method), + get_dynamic_choices=get_dynamic_choices, +) +``` + +## How it works + +When Supervaize Studio renders the job start form: + +1. Studio checks if any field has `dynamic_choices` set +2. If so, it calls `GET /supervaizer/agents/{agent_slug}/start/dynamic_choices` +3. The endpoint invokes your `get_dynamic_choices("start")` callback +4. The returned choices are used to populate the form dropdowns + +``` +┌──────────────┐ GET /start/dynamic_choices ┌───────────────┐ +│ Supervaize │ ──────────────────────────────────► │ Supervaizer │ +│ Studio │ │ Controller │ +│ │ ◄────────────────────────────────── │ │ +│ (renders │ {"choices": {"projects": ...}} │ (calls your │ +│ the form) │ │ callback) │ +└──────────────┘ └───────────────┘ +``` + +## API Reference + +### Endpoint + +``` +GET /supervaizer/agents/{agent_slug}/start/dynamic_choices +``` + +**Headers:** `X-API-Key: {your_api_key}` + +### Response + +```json +{ + "choices": { + "projects": [ + ["P1", "Project 1"], + ["P2", "Project 2"], + ["P3", "Project 3"] + ] + } +} +``` + +### Error responses + +| Status | Meaning | +|--------|---------| +| 200 | Choices returned successfully | +| 404 | Agent has no `get_dynamic_choices` callback | +| 500 | Callback raised an error | + +## Complete example + +```python +from supervaizer import Agent, AgentMethod, AgentMethodField, AgentMethods, Server + + +def get_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + if method_name == "start": + return { + "projects": [("P1", "Project 1"), ("P2", "Project 2"), ("P3", "Project 3")], + } + return {} + + +job_start = AgentMethod( + name="start", + method="my_agent.start_job", + fields=[ + AgentMethodField( + name="Company name", + type=str, + field_type="CharField", + required=True, + ), + AgentMethodField( + name="Project", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ), + ], + description="Start a new research job", +) + +agent = Agent( + name="research_agent", + author="Your Name", + version="1.0", + description="Research agent with dynamic project selection", + methods=AgentMethods(job_start=job_start), + get_dynamic_choices=get_dynamic_choices, +) + +server = Server(agents=[agent]) +``` +``` + +- [ ] **Step 2: Add to sidebar** + +In `/Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaize-doc/sidebars.ts`, add a new entry after the "Controller Setup Guide" item (line ~31): + +```typescript + { + type: "doc", + id: "supervaizer-controller/dynamic-choices", + label: "Dynamic Choices", + }, +``` + +The items array should look like: + +```typescript +items: [ + { type: "doc", id: "supervaizer-controller/quickstart", label: "Quick Start" }, + { type: "doc", id: "supervaizer-controller/core-concepts", label: "Core Concepts" }, + { type: "doc", id: "supervaizer-controller/controller-setup", label: "Controller Setup Guide" }, + { type: "doc", id: "supervaizer-controller/dynamic-choices", label: "Dynamic Choices" }, // <-- NEW + { type: "doc", id: "supervaizer-controller/application-flow-control", label: "Application Flow Control" }, + // ... rest +``` + +- [ ] **Step 3: Verify the doc builds** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaize-doc && just build` + +Expected: Build succeeds with no errors + +- [ ] **Step 4: Commit** + +```bash +cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaize-doc +git add docs/supervaizer-controller/dynamic-choices.mdx sidebars.ts +git commit -m "docs: add dynamic choices guide page" +``` + +--- + +### Task 13: Update changelog + +**Files:** +- Modify: `/Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer/docs/CHANGELOG.md` + +- [ ] **Step 1: Add entry under Unreleased** + +In `docs/CHANGELOG.md`, add to the `## Unreleased` → `### Added` section (after the existing ADMIN_ALLOWED_IPS entry): + +```markdown +- **Dynamic choices for `AgentMethodField`** — Fields can now use `dynamic_choices` instead of static `choices` to resolve options at runtime via a callback. Add a `get_dynamic_choices` callable to the `Agent` constructor and a `dynamic_choices` key to your `AgentMethodField`. Supervaize Studio fetches choices from the new `GET /agents/{slug}/start/dynamic_choices` endpoint when rendering the job start form. Static `choices` and `dynamic_choices` are mutually exclusive on a field. +``` + +- [ ] **Step 2: Update the test count table** + +Run the test suite and update the test count table in the Unreleased section to reflect the new test count: + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest -v` + +Update the table with the new counts. + +- [ ] **Step 3: Commit** + +```bash +cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer +git add docs/CHANGELOG.md +git commit -m "docs: update changelog with dynamic choices feature" +``` + +--- + +### Task 14: Final verification + +- [ ] **Step 1: Run the full supervaizer test suite** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && uv run pytest -v` + +Expected: All tests PASS + +- [ ] **Step 2: Run pre-commit hooks** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaizer && just precommit` + +Expected: All hooks PASS + +- [ ] **Step 3: Verify supervaize-doc builds** + +Run: `cd /Volumes/SSDext1TB/Documents/GitRepo/RUNWAIZE/supervaize-doc && just build` + +Expected: Build succeeds + +--- + +## Supervaize Studio Integration Instructions + +For the Studio team to integrate dynamic choices into the job-start form: + +### Detection + +When rendering a job-start form, check if any field in the method's `fields` array has a non-null `dynamic_choices` key. This key is included in `registration_info` → `methods` → `job_start` → `fields`. + +```python +# In the registration_info response, a dynamic field looks like: +{ + "name": "List of projects", + "type": "str", + "field_type": "ChoiceField", + "dynamic_choices": "projects", # <-- this is the indicator + "choices": null, # <-- always null when dynamic_choices is set + "required": true, + ... +} +``` + +### API Call + +When the form is opened (before rendering), if any field has `dynamic_choices` set, call: + +``` +GET {supervaizer_url}/supervaizer/agents/{agent_slug}/start/dynamic_choices +Headers: X-API-Key: {api_key} +``` + +### Response Format + +```json +{ + "choices": { + "projects": [["P1", "Project 1"], ["P2", "Project 2"], ["P3", "Project 3"]] + } +} +``` + +Each key in `choices` maps to a `dynamic_choices` value on a field. The value is a list of `[value, label]` pairs — same format as static `choices`. + +### Form Rendering + +1. Fetch dynamic choices once when the form opens +2. For each field where `dynamic_choices` is set, look up the matching key in the response +3. Populate the field's choices with those values +4. Render as a standard `ChoiceField` dropdown + +### Error Handling + +- **404**: Agent has no `get_dynamic_choices` callback — render the field as disabled with "Options not available" +- **500**: Callback raised an error — render the field as disabled with "Could not load options" +- **Network error**: Retry once, then show error state + +### Caching + +Do NOT cache dynamic choices — they should be fetched fresh each time the form is opened. The whole point is that they change (e.g., project lists, user lists, etc.). + +### Future: Conditional Fields + +A future iteration may add `POST /start/dynamic_choices` accepting `{"field_values": {...}}` for fields that depend on other field values. The GET endpoint will remain for non-conditional dynamic choices. diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 5c458d2..d59c669 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -19,7 +19,7 @@ cast, ) import shortuuid -from pydantic import BaseModel, ConfigDict, field_validator, Field +from pydantic import BaseModel, ConfigDict, field_validator, model_validator, Field from rich import inspect, print from slugify import slugify from supervaizer.__version__ import VERSION @@ -99,6 +99,19 @@ class AgentMethodField(BaseModel): required: bool = Field( default=False, description="Whether field is required for form submission" ) + dynamic_choices: str | None = Field( + default=None, + description="Key name for dynamic choices resolved at runtime via Agent.get_dynamic_choices callback. Mutually exclusive with 'choices'.", + ) + + @model_validator(mode="after") + def validate_choices_mutual_exclusion(self) -> "AgentMethodField": + if self.choices is not None and self.dynamic_choices is not None: + raise ValueError( + "'choices' and 'dynamic_choices' are mutually exclusive. " + "Use 'choices' for static options or 'dynamic_choices' for runtime-resolved options." + ) + return self model_config = cast( ConfigDict, diff --git a/src/supervaizer/examples/controller_template.py b/src/supervaizer/examples/controller_template.py index 2850629..ebd5e24 100644 --- a/src/supervaizer/examples/controller_template.py +++ b/src/supervaizer/examples/controller_template.py @@ -9,6 +9,7 @@ # and edited to configure your agent(s) import os + import shortuuid from rich.console import Console @@ -34,6 +35,17 @@ # Public url of your hosted agent PROD_PUBLIC_URL = "https://myagent.cloud-hosting.net:8001" + +def get_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + if method_name == "start": + return { + "projects": [("P1", "Project 1"), ("P2", "Project 2"), ("P3", "Project 3")], + } + return { + "projects": [("P1", "Project 1"), ("P2", "Project 2"), ("P3", "Project 3")], + } + + # Define the parameters and secrets expected by the agent agent_parameters: ParametersSetup | None = ParametersSetup.from_list([ Parameter( @@ -91,6 +103,13 @@ widget="RadioSelect", required=True, ), + AgentMethodField( + name="List of projects", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ), AgentMethodField( name="Details of research", type=str, diff --git a/tests/test_agent.py b/tests/test_agent.py index b62a995..55954ee 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -790,6 +790,46 @@ def test_custom_method_key_validation_empty_dict() -> None: assert methods.custom == {} +def test_agent_method_field_dynamic_choices(): + """Test that AgentMethodField accepts dynamic_choices attribute.""" + field = AgentMethodField( + name="List of projects", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ) + assert field.dynamic_choices == "projects" + assert field.choices is None + + +def test_agent_method_field_dynamic_choices_default_none(): + """Test that dynamic_choices defaults to None.""" + field = AgentMethodField( + name="color", + type=str, + field_type="ChoiceField", + choices=[("R", "Red"), ("B", "Blue")], + required=True, + ) + assert field.dynamic_choices is None + + +def test_agent_method_field_dynamic_choices_mutual_exclusion(): + """Test that choices and dynamic_choices cannot both be set.""" + from pydantic import ValidationError + + with pytest.raises(ValidationError, match="mutually exclusive"): + AgentMethodField( + name="List of projects", + type=str, + field_type="ChoiceField", + choices=[("A", "Option A")], + dynamic_choices="projects", + required=True, + ) + + def test_agent_method_fields_definitions() -> None: from supervaizer.agent import AgentMethod, AgentMethodField From aa0290e1fbb909cb30f406e635645d2031562309 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 19:09:55 +0300 Subject: [PATCH 02/15] =?UTF-8?q?=E2=9C=85=20test:=20verify=20dynamic=5Fch?= =?UTF-8?q?oices=20serialization=20in=20fields=5Fdefinitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_agent.py | 73 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/test_agent.py b/tests/test_agent.py index 55954ee..b621e63 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -830,6 +830,79 @@ def test_agent_method_field_dynamic_choices_mutual_exclusion(): ) +def test_agent_method_fields_definitions_includes_dynamic_choices(): + """Test that fields_definitions includes dynamic_choices in the output.""" + method = AgentMethod( + name="start", + method="my_module.start", + fields=[ + AgentMethodField( + name="Project", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ), + ], + description="Start", + ) + definitions = method.fields_definitions + assert len(definitions) == 1 + assert definitions[0]["dynamic_choices"] == "projects" + assert definitions[0]["choices"] is None + + +def test_agent_method_registration_info_includes_dynamic_choices(): + """Test that registration_info propagates dynamic_choices through fields.""" + method = AgentMethod( + name="start", + method="my_module.start", + fields=[ + AgentMethodField( + name="Project", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ), + ], + description="Start", + ) + info = method.registration_info + assert info["fields"][0]["dynamic_choices"] == "projects" + + +def test_agent_with_dynamic_choices_callback(agent_method_fixture: AgentMethod): + """Test that Agent accepts a get_dynamic_choices callable.""" + + def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + return {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} + + agent = Agent( + name="dynamicAgent", + author="test", + version="1.0", + description="test agent", + methods=AgentMethods(job_start=agent_method_fixture), + get_dynamic_choices=my_dynamic_choices, + ) + assert agent.get_dynamic_choices is not None + result = agent.get_dynamic_choices("start") + assert result == {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} + + +def test_agent_without_dynamic_choices_callback(agent_method_fixture: AgentMethod): + """Test that get_dynamic_choices defaults to None.""" + agent = Agent( + name="staticAgent", + author="test", + version="1.0", + description="test agent", + methods=AgentMethods(job_start=agent_method_fixture), + ) + assert agent.get_dynamic_choices is None + + def test_agent_method_fields_definitions() -> None: from supervaizer.agent import AgentMethod, AgentMethodField From c7b2e974bb1323feb4ebd4a0066a23b08df0251c Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 19:11:08 +0300 Subject: [PATCH 03/15] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20get=5Fdynamic?= =?UTF-8?q?=5Fchoices=20callback=20to=20Agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/agent.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index d59c669..60572a9 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -625,6 +625,11 @@ class AgentAbstract(SvBaseModel): description="Optional FastAPI APIRouter with custom routes for this agent", exclude=True, ) + get_dynamic_choices: Any | None = Field( + default=None, + description="Callable that returns dynamic choices for method fields. Signature: (method_name: str) -> dict[str, list[tuple[str, str]]]", + exclude=True, + ) model_config = cast( ConfigDict, {"reference_group": "Core", "arbitrary_types_allowed": True} @@ -651,6 +656,7 @@ def __init__( server_encrypted_parameters: str | None = None, max_execution_time: int = 60 * 60, # 1 hour (in seconds) custom_routes: Any | None = None, + get_dynamic_choices: Any | None = None, **kwargs: Any, ) -> None: """ @@ -704,6 +710,7 @@ def __init__( server_encrypted_parameters=server_encrypted_parameters, max_execution_time=max_execution_time, custom_routes=custom_routes, + get_dynamic_choices=get_dynamic_choices, **kwargs, ) From 5336277f9469c1c63475bb8294daf1b6a5e698e1 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 19:14:48 +0300 Subject: [PATCH 04/15] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20/start/dynamic?= =?UTF-8?q?=5Fchoices=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/routes.py | 34 ++++++++++++++++++++++++++++++ tests/test_routes.py | 44 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index 249fe8f..04ee81b 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -618,6 +618,40 @@ async def validate_method_fields( ) return result + @router.get( + "/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", + response_model=Dict[str, Any], + responses={ + http_status.HTTP_200_OK: {"model": Dict[str, Any]}, + http_status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}, + http_status.HTTP_500_INTERNAL_SERVER_ERROR: {"model": ErrorResponse}, + }, + dependencies=[Security(server.verify_api_key)], + ) + @handle_route_errors() + async def get_dynamic_choices( + agent: Agent = Depends(get_agent), + ) -> Dict[str, Any] | JSONResponse: + """Get dynamic choices for the start method fields.""" + log.info( + f"📥 GET /start/dynamic_choices [Dynamic choices] {agent.name}" + ) + + if not agent.get_dynamic_choices: + raise HTTPException( + status_code=http_status.HTTP_404_NOT_FOUND, + detail=f"Agent {agent.name} does not have dynamic choices configured", + ) + + choices = agent.get_dynamic_choices("start") + + log.info( + f"📤 Agent {agent.name}: Dynamic choices keys: {list(choices.keys())}" + ) + return {"choices": choices} + if not agent.methods: raise ValueError(f"Agent {agent.name} has no methods defined") diff --git a/tests/test_routes.py b/tests/test_routes.py index 0801975..db25ba4 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -9,7 +9,7 @@ from fastapi.testclient import TestClient from supervaizer import Agent, Job, Server -from supervaizer.routes import create_default_routes, create_utils_routes +from supervaizer.routes import create_agents_routes, create_default_routes, create_utils_routes def test_utils_public_key_and_encrypt(server_fixture: Server, mocker: Any) -> None: @@ -92,3 +92,45 @@ def test_get_agents_and_agent_details( resp = client.get("/supervaizer/agent/doesnotexist", headers=headers) assert resp.status_code == 404 assert "not found" in resp.json()["detail"].lower() + + +def test_dynamic_choices_endpoint(server_fixture: Server, mocker: Any) -> None: + """Test GET /supervaizer/agents/{slug}/start/dynamic_choices returns choices.""" + + def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + if method_name == "start": + return {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} + return {} + + agent = server_fixture.agents[0] + agent.get_dynamic_choices = 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.get( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + ) + assert resp.status_code == 200 + data = resp.json() + assert data["choices"]["projects"] == [["P1", "Project 1"], ["P2", "Project 2"]] + + +def test_dynamic_choices_endpoint_no_callback( + server_fixture: Server, mocker: Any +) -> None: + """Test that endpoint returns 404 when no callback is registered.""" + agent = server_fixture.agents[0] + agent.get_dynamic_choices = None + + 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.get( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + ) + assert resp.status_code == 404 From 67de5a2b1c8f7a07240d9e385d73e559df8a118e Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 19:17:25 +0300 Subject: [PATCH 05/15] =?UTF-8?q?=E2=9C=A8=20feat:=20wire=20get=5Fdynamic?= =?UTF-8?q?=5Fchoices=20in=20example=20controller=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/examples/controller_template.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/supervaizer/examples/controller_template.py b/src/supervaizer/examples/controller_template.py index ebd5e24..d71c5a4 100644 --- a/src/supervaizer/examples/controller_template.py +++ b/src/supervaizer/examples/controller_template.py @@ -192,6 +192,7 @@ def get_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: ), parameters_setup=agent_parameters, instructions_path="supervaize_instructions.html", # Path where instructions page is served + get_dynamic_choices=get_dynamic_choices, ) # For export purposes, use dummy values if environment variables are not set From f79c76cf04f118d6188663f30f056ab39de09407 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 19:21:06 +0300 Subject: [PATCH 06/15] =?UTF-8?q?=E2=9C=85=20test:=20add=20comprehensive?= =?UTF-8?q?=20unit=20tests=20for=20dynamic=20choices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_agent.py | 122 +++++++++++++++++++++++++++++++++++++++++++ tests/test_routes.py | 74 ++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) diff --git a/tests/test_agent.py b/tests/test_agent.py index b621e63..b7980eb 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -903,6 +903,128 @@ def test_agent_without_dynamic_choices_callback(agent_method_fixture: AgentMetho assert agent.get_dynamic_choices is None +def test_agent_method_field_dynamic_choices_in_model_dump(): + """Test that dynamic_choices appears in model_dump output.""" + field = AgentMethodField( + name="Project", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ) + dumped = field.model_dump() + assert dumped["dynamic_choices"] == "projects" + assert dumped["choices"] is None + + +def test_agent_method_field_static_choices_no_dynamic(): + """Test that static choices field has dynamic_choices=None in model_dump.""" + field = AgentMethodField( + name="Color", + type=str, + field_type="ChoiceField", + choices=[("R", "Red"), ("B", "Blue")], + required=True, + ) + dumped = field.model_dump() + assert dumped["dynamic_choices"] is None + assert dumped["choices"] == [("R", "Red"), ("B", "Blue")] + + +def test_agent_method_field_non_choice_field_with_dynamic_choices(): + """Test that dynamic_choices can be set on a ChoiceField type.""" + field = AgentMethodField( + name="Items", + type=str, + field_type="ChoiceField", + dynamic_choices="items", + required=False, + ) + assert field.dynamic_choices == "items" + assert field.field_type == "ChoiceField" + + +def test_agent_method_mixed_static_and_dynamic_fields(): + """Test a method with both static and dynamic choice fields.""" + method = AgentMethod( + name="start", + method="my_module.start", + fields=[ + AgentMethodField( + name="Type", + type=str, + field_type="ChoiceField", + choices=[("A", "Alpha"), ("B", "Beta")], + required=True, + ), + AgentMethodField( + name="Project", + type=str, + field_type="ChoiceField", + dynamic_choices="projects", + required=True, + ), + AgentMethodField( + name="Name", + type=str, + field_type="CharField", + required=True, + ), + ], + description="Start", + ) + defs = method.fields_definitions + assert defs[0]["choices"] == [("A", "Alpha"), ("B", "Beta")] + assert defs[0]["dynamic_choices"] is None + assert defs[1]["choices"] is None + assert defs[1]["dynamic_choices"] == "projects" + assert defs[2]["dynamic_choices"] is None + + +def test_agent_dynamic_choices_callback_returns_empty(agent_method_fixture: AgentMethod): + """Test callback that returns empty dict for unknown method.""" + + def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + if method_name == "start": + return {"projects": [("P1", "Project 1")]} + return {} + + agent = Agent( + name="emptyCallbackAgent", + author="test", + version="1.0", + description="test", + methods=AgentMethods(job_start=agent_method_fixture), + get_dynamic_choices=my_dynamic_choices, + ) + assert agent.get_dynamic_choices("start") == {"projects": [("P1", "Project 1")]} + assert agent.get_dynamic_choices("unknown") == {} + + +def test_agent_dynamic_choices_callback_multiple_keys(agent_method_fixture: AgentMethod): + """Test callback returning multiple choice keys.""" + + def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + return { + "projects": [("P1", "Project 1"), ("P2", "Project 2")], + "teams": [("T1", "Team Alpha"), ("T2", "Team Beta")], + } + + agent = Agent( + name="multiKeyAgent", + author="test", + version="1.0", + description="test", + methods=AgentMethods(job_start=agent_method_fixture), + get_dynamic_choices=my_dynamic_choices, + ) + result = agent.get_dynamic_choices("start") + assert "projects" in result + assert "teams" in result + assert len(result["projects"]) == 2 + assert len(result["teams"]) == 2 + + def test_agent_method_fields_definitions() -> None: from supervaizer.agent import AgentMethod, AgentMethodField diff --git a/tests/test_routes.py b/tests/test_routes.py index db25ba4..df4e824 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -118,6 +118,80 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: assert data["choices"]["projects"] == [["P1", "Project 1"], ["P2", "Project 2"]] +def test_dynamic_choices_endpoint_multiple_keys( + server_fixture: Server, mocker: Any +) -> None: + """Test endpoint returns multiple choice keys.""" + + def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + return { + "projects": [("P1", "Project 1")], + "teams": [("T1", "Team Alpha")], + } + + agent = server_fixture.agents[0] + agent.get_dynamic_choices = 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.get( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + ) + assert resp.status_code == 200 + data = resp.json() + assert "projects" in data["choices"] + assert "teams" in data["choices"] + + +def test_dynamic_choices_endpoint_empty_result( + server_fixture: Server, mocker: Any +) -> None: + """Test endpoint returns empty choices when callback returns empty dict.""" + + def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + return {} + + agent = server_fixture.agents[0] + agent.get_dynamic_choices = 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.get( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + ) + assert resp.status_code == 200 + data = resp.json() + assert data["choices"] == {} + + +def test_dynamic_choices_endpoint_requires_api_key( + server_fixture: Server, mocker: Any +) -> None: + """Test that the dynamic choices endpoint requires API key authentication.""" + + def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + return {"projects": [("P1", "Project 1")]} + + agent = server_fixture.agents[0] + agent.get_dynamic_choices = mock_dynamic_choices + + app = server_fixture.app + app.include_router(create_agents_routes(server_fixture)) + client = TestClient(app) + + # No API key header + resp = client.get( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices" + ) + assert resp.status_code == 401 + + def test_dynamic_choices_endpoint_no_callback( server_fixture: Server, mocker: Any ) -> None: From 045be4453d9db66e07148952a6c08ad116f4a8b7 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 19:23:50 +0300 Subject: [PATCH 07/15] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20clean=20?= =?UTF-8?q?code=20pass=20on=20agent.py=20dynamic=20choices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/agent.py | 8 +++---- .../examples/controller_template.py | 2 +- src/supervaizer/routes.py | 4 ++-- tests/test_agent.py | 22 +++++++++---------- tests/test_routes.py | 10 ++++----- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 60572a9..3358afe 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -101,7 +101,7 @@ class AgentMethodField(BaseModel): ) dynamic_choices: str | None = Field( default=None, - description="Key name for dynamic choices resolved at runtime via Agent.get_dynamic_choices callback. Mutually exclusive with 'choices'.", + description="Key name for dynamic choices resolved at runtime via Agent.dynamic_choices_callback. Mutually exclusive with 'choices'.", ) @model_validator(mode="after") @@ -625,7 +625,7 @@ class AgentAbstract(SvBaseModel): description="Optional FastAPI APIRouter with custom routes for this agent", exclude=True, ) - get_dynamic_choices: Any | None = Field( + dynamic_choices_callback: Any | None = Field( default=None, description="Callable that returns dynamic choices for method fields. Signature: (method_name: str) -> dict[str, list[tuple[str, str]]]", exclude=True, @@ -656,7 +656,7 @@ def __init__( server_encrypted_parameters: str | None = None, max_execution_time: int = 60 * 60, # 1 hour (in seconds) custom_routes: Any | None = None, - get_dynamic_choices: Any | None = None, + dynamic_choices_callback: Any | None = None, **kwargs: Any, ) -> None: """ @@ -710,7 +710,7 @@ def __init__( server_encrypted_parameters=server_encrypted_parameters, max_execution_time=max_execution_time, custom_routes=custom_routes, - get_dynamic_choices=get_dynamic_choices, + dynamic_choices_callback=dynamic_choices_callback, **kwargs, ) diff --git a/src/supervaizer/examples/controller_template.py b/src/supervaizer/examples/controller_template.py index d71c5a4..cd515e8 100644 --- a/src/supervaizer/examples/controller_template.py +++ b/src/supervaizer/examples/controller_template.py @@ -192,7 +192,7 @@ def get_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: ), parameters_setup=agent_parameters, instructions_path="supervaize_instructions.html", # Path where instructions page is served - get_dynamic_choices=get_dynamic_choices, + dynamic_choices_callback=get_dynamic_choices, ) # For export purposes, use dummy values if environment variables are not set diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index 04ee81b..863e706 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -639,13 +639,13 @@ async def get_dynamic_choices( f"📥 GET /start/dynamic_choices [Dynamic choices] {agent.name}" ) - if not agent.get_dynamic_choices: + if not agent.dynamic_choices_callback: raise HTTPException( status_code=http_status.HTTP_404_NOT_FOUND, detail=f"Agent {agent.name} does not have dynamic choices configured", ) - choices = agent.get_dynamic_choices("start") + choices = agent.dynamic_choices_callback("start") log.info( f"📤 Agent {agent.name}: Dynamic choices keys: {list(choices.keys())}" diff --git a/tests/test_agent.py b/tests/test_agent.py index b7980eb..9974757 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -873,7 +873,7 @@ def test_agent_method_registration_info_includes_dynamic_choices(): def test_agent_with_dynamic_choices_callback(agent_method_fixture: AgentMethod): - """Test that Agent accepts a get_dynamic_choices callable.""" + """Test that Agent accepts a dynamic_choices_callback callable.""" def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: return {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} @@ -884,15 +884,15 @@ def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: version="1.0", description="test agent", methods=AgentMethods(job_start=agent_method_fixture), - get_dynamic_choices=my_dynamic_choices, + dynamic_choices_callback=my_dynamic_choices, ) - assert agent.get_dynamic_choices is not None - result = agent.get_dynamic_choices("start") + assert agent.dynamic_choices_callback is not None + result = agent.dynamic_choices_callback("start") assert result == {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} def test_agent_without_dynamic_choices_callback(agent_method_fixture: AgentMethod): - """Test that get_dynamic_choices defaults to None.""" + """Test that dynamic_choices_callback defaults to None.""" agent = Agent( name="staticAgent", author="test", @@ -900,7 +900,7 @@ def test_agent_without_dynamic_choices_callback(agent_method_fixture: AgentMetho description="test agent", methods=AgentMethods(job_start=agent_method_fixture), ) - assert agent.get_dynamic_choices is None + assert agent.dynamic_choices_callback is None def test_agent_method_field_dynamic_choices_in_model_dump(): @@ -995,10 +995,10 @@ def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: version="1.0", description="test", methods=AgentMethods(job_start=agent_method_fixture), - get_dynamic_choices=my_dynamic_choices, + dynamic_choices_callback=my_dynamic_choices, ) - assert agent.get_dynamic_choices("start") == {"projects": [("P1", "Project 1")]} - assert agent.get_dynamic_choices("unknown") == {} + assert agent.dynamic_choices_callback("start") == {"projects": [("P1", "Project 1")]} + assert agent.dynamic_choices_callback("unknown") == {} def test_agent_dynamic_choices_callback_multiple_keys(agent_method_fixture: AgentMethod): @@ -1016,9 +1016,9 @@ def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: version="1.0", description="test", methods=AgentMethods(job_start=agent_method_fixture), - get_dynamic_choices=my_dynamic_choices, + dynamic_choices_callback=my_dynamic_choices, ) - result = agent.get_dynamic_choices("start") + result = agent.dynamic_choices_callback("start") assert "projects" in result assert "teams" in result assert len(result["projects"]) == 2 diff --git a/tests/test_routes.py b/tests/test_routes.py index df4e824..ec6f7d5 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -103,7 +103,7 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: return {} agent = server_fixture.agents[0] - agent.get_dynamic_choices = mock_dynamic_choices + agent.dynamic_choices_callback = mock_dynamic_choices app = server_fixture.app app.include_router(create_agents_routes(server_fixture)) @@ -130,7 +130,7 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: } agent = server_fixture.agents[0] - agent.get_dynamic_choices = mock_dynamic_choices + agent.dynamic_choices_callback = mock_dynamic_choices app = server_fixture.app app.include_router(create_agents_routes(server_fixture)) @@ -155,7 +155,7 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: return {} agent = server_fixture.agents[0] - agent.get_dynamic_choices = mock_dynamic_choices + agent.dynamic_choices_callback = mock_dynamic_choices app = server_fixture.app app.include_router(create_agents_routes(server_fixture)) @@ -179,7 +179,7 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: return {"projects": [("P1", "Project 1")]} agent = server_fixture.agents[0] - agent.get_dynamic_choices = mock_dynamic_choices + agent.dynamic_choices_callback = mock_dynamic_choices app = server_fixture.app app.include_router(create_agents_routes(server_fixture)) @@ -197,7 +197,7 @@ def test_dynamic_choices_endpoint_no_callback( ) -> None: """Test that endpoint returns 404 when no callback is registered.""" agent = server_fixture.agents[0] - agent.get_dynamic_choices = None + agent.dynamic_choices_callback = None app = server_fixture.app app.include_router(create_agents_routes(server_fixture)) From 52b18a165cb4d168c9e676fd0475681682183d3c Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 19:31:03 +0300 Subject: [PATCH 08/15] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20clean=20?= =?UTF-8?q?code=20pass=20on=20dynamic=5Fchoices=20routes=20and=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove dead `| JSONResponse` union from `get_dynamic_choices` return type (endpoint never returns JSONResponse, only raises HTTPException or returns dict) - Drop unused `mocker: Any` params from all five dynamic_choices route tests - Strengthen `test_dynamic_choices_endpoint_multiple_keys` to assert values, not just key presence - Rename two misleading test_agent.py tests: `non_choice_field` → `optional_choice_field`, `returns_empty` → `dispatches_by_method_name` Co-Authored-By: Claude Sonnet 4.6 --- src/supervaizer/routes.py | 2 +- tests/test_agent.py | 8 ++++---- tests/test_routes.py | 14 +++++++------- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index 863e706..ea8f043 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -633,7 +633,7 @@ async def validate_method_fields( @handle_route_errors() async def get_dynamic_choices( agent: Agent = Depends(get_agent), - ) -> Dict[str, Any] | JSONResponse: + ) -> Dict[str, Any]: """Get dynamic choices for the start method fields.""" log.info( f"📥 GET /start/dynamic_choices [Dynamic choices] {agent.name}" diff --git a/tests/test_agent.py b/tests/test_agent.py index 9974757..b1be6c9 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -931,8 +931,8 @@ def test_agent_method_field_static_choices_no_dynamic(): assert dumped["choices"] == [("R", "Red"), ("B", "Blue")] -def test_agent_method_field_non_choice_field_with_dynamic_choices(): - """Test that dynamic_choices can be set on a ChoiceField type.""" +def test_agent_method_field_optional_choice_field_with_dynamic_choices(): + """Test that dynamic_choices can be set on an optional (required=False) ChoiceField.""" field = AgentMethodField( name="Items", type=str, @@ -981,8 +981,8 @@ def test_agent_method_mixed_static_and_dynamic_fields(): assert defs[2]["dynamic_choices"] is None -def test_agent_dynamic_choices_callback_returns_empty(agent_method_fixture: AgentMethod): - """Test callback that returns empty dict for unknown method.""" +def test_agent_dynamic_choices_callback_dispatches_by_method_name(agent_method_fixture: AgentMethod): + """Test that the callback receives the method name and can return different results per method.""" def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: if method_name == "start": diff --git a/tests/test_routes.py b/tests/test_routes.py index ec6f7d5..d1e6fbb 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -94,7 +94,7 @@ def test_get_agents_and_agent_details( assert "not found" in resp.json()["detail"].lower() -def test_dynamic_choices_endpoint(server_fixture: Server, mocker: Any) -> None: +def test_dynamic_choices_endpoint(server_fixture: Server) -> None: """Test GET /supervaizer/agents/{slug}/start/dynamic_choices returns choices.""" def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: @@ -119,7 +119,7 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: def test_dynamic_choices_endpoint_multiple_keys( - server_fixture: Server, mocker: Any + server_fixture: Server, ) -> None: """Test endpoint returns multiple choice keys.""" @@ -142,12 +142,12 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: ) assert resp.status_code == 200 data = resp.json() - assert "projects" in data["choices"] - assert "teams" in data["choices"] + assert data["choices"]["projects"] == [["P1", "Project 1"]] + assert data["choices"]["teams"] == [["T1", "Team Alpha"]] def test_dynamic_choices_endpoint_empty_result( - server_fixture: Server, mocker: Any + server_fixture: Server, ) -> None: """Test endpoint returns empty choices when callback returns empty dict.""" @@ -171,7 +171,7 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: def test_dynamic_choices_endpoint_requires_api_key( - server_fixture: Server, mocker: Any + server_fixture: Server, ) -> None: """Test that the dynamic choices endpoint requires API key authentication.""" @@ -193,7 +193,7 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: def test_dynamic_choices_endpoint_no_callback( - server_fixture: Server, mocker: Any + server_fixture: Server, ) -> None: """Test that endpoint returns 404 when no callback is registered.""" agent = server_fixture.agents[0] From 28bb3b17923ca3b0f38a2849779c19620d382e4a Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 19:38:24 +0300 Subject: [PATCH 09/15] =?UTF-8?q?=F0=9F=93=96=20docs:=20update=20changelog?= =?UTF-8?q?=20with=20dynamic=20choices=20feature?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d5d46d3..77a3228 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to this project will be documented in this file. ### 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 to the `Agent` constructor and a `dynamic_choices` key to your `AgentMethodField`. Supervaize Studio fetches choices from the new `GET /agents/{slug}/start/dynamic_choices` endpoint when rendering the job start form. Static `choices` and `dynamic_choices` are mutually exclusive on a field. ### Unit Tests Results @@ -29,10 +30,10 @@ All notable changes to this project will be documented in this file. | Status | Count | | ---------- | ----- | -| ✅ Passed | 446 | +| ✅ Passed | 464 | | 🤔 Skipped | 0 | | 🔴 Failed | 0 | -| ⏱️ in | 48s | +| ⏱️ in | 65s | ## v0.12.0 From bc3d4575a57459e320261407cf5873b54d2c95eb Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 19:47:10 +0300 Subject: [PATCH 10/15] =?UTF-8?q?=F0=9F=93=96=20docs:=20add=20documentatio?= =?UTF-8?q?n=20link=20to=20changelog=20dynamic=20choices=20entry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG.md | 2 +- docs/api/openapi.json | 2 +- docs/model_reference/model_core.md | 2 +- docs/model_reference/model_extra.md | 2 +- src/supervaizer/routes.py | 8 ++------ tests/test_agent.py | 12 +++++++++--- tests/test_routes.py | 10 ++++++---- 7 files changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 77a3228..8b10e43 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,7 +22,7 @@ All notable changes to this project will be documented in this file. ### 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 to the `Agent` constructor and a `dynamic_choices` key to your `AgentMethodField`. Supervaize Studio fetches choices from the new `GET /agents/{slug}/start/dynamic_choices` endpoint when rendering the job start form. Static `choices` and `dynamic_choices` are mutually exclusive on a field. +- **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 to the `Agent` constructor and a `dynamic_choices` key to your `AgentMethodField`. Supervaize Studio fetches choices from the new `GET /agents/{slug}/start/dynamic_choices` endpoint 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 b6cc146..e537bce 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -4413,4 +4413,4 @@ } } } -} \ No newline at end of file +} diff --git a/docs/model_reference/model_core.md b/docs/model_reference/model_core.md index 36958e2..97bc210 100644 --- a/docs/model_reference/model_core.md +++ b/docs/model_reference/model_core.md @@ -438,4 +438,4 @@ public_url: full url (including scheme and port) to use for outbound connections ``` -*Uploaded on 2026-03-23 22:47:03* \ No newline at end of file +*Uploaded on 2026-03-23 22:47:03* diff --git a/docs/model_reference/model_extra.md b/docs/model_reference/model_extra.md index 7749721..716abca 100644 --- a/docs/model_reference/model_extra.md +++ b/docs/model_reference/model_extra.md @@ -490,4 +490,4 @@ A base class for creating Pydantic models. | `details` | `Dict[str, Any]` | **required** | | -*Uploaded on 2026-03-23 22:47:03* \ No newline at end of file +*Uploaded on 2026-03-23 22:47:03* diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index ea8f043..e561125 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -635,9 +635,7 @@ async def get_dynamic_choices( agent: Agent = Depends(get_agent), ) -> Dict[str, Any]: """Get dynamic choices for the start method fields.""" - log.info( - f"📥 GET /start/dynamic_choices [Dynamic choices] {agent.name}" - ) + log.info(f"📥 GET /start/dynamic_choices [Dynamic choices] {agent.name}") if not agent.dynamic_choices_callback: raise HTTPException( @@ -647,9 +645,7 @@ async def get_dynamic_choices( choices = agent.dynamic_choices_callback("start") - log.info( - f"📤 Agent {agent.name}: Dynamic choices keys: {list(choices.keys())}" - ) + log.info(f"📤 Agent {agent.name}: Dynamic choices keys: {list(choices.keys())}") return {"choices": choices} if not agent.methods: diff --git a/tests/test_agent.py b/tests/test_agent.py index b1be6c9..d150c1c 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -981,7 +981,9 @@ def test_agent_method_mixed_static_and_dynamic_fields(): assert defs[2]["dynamic_choices"] is None -def test_agent_dynamic_choices_callback_dispatches_by_method_name(agent_method_fixture: AgentMethod): +def test_agent_dynamic_choices_callback_dispatches_by_method_name( + agent_method_fixture: AgentMethod, +): """Test that the callback receives the method name and can return different results per method.""" def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: @@ -997,11 +999,15 @@ def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: methods=AgentMethods(job_start=agent_method_fixture), dynamic_choices_callback=my_dynamic_choices, ) - assert agent.dynamic_choices_callback("start") == {"projects": [("P1", "Project 1")]} + assert agent.dynamic_choices_callback("start") == { + "projects": [("P1", "Project 1")] + } assert agent.dynamic_choices_callback("unknown") == {} -def test_agent_dynamic_choices_callback_multiple_keys(agent_method_fixture: AgentMethod): +def test_agent_dynamic_choices_callback_multiple_keys( + agent_method_fixture: AgentMethod, +): """Test callback returning multiple choice keys.""" def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: diff --git a/tests/test_routes.py b/tests/test_routes.py index d1e6fbb..c44f2a3 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -9,7 +9,11 @@ from fastapi.testclient import TestClient from supervaizer import Agent, Job, Server -from supervaizer.routes import create_agents_routes, create_default_routes, create_utils_routes +from supervaizer.routes import ( + create_agents_routes, + create_default_routes, + create_utils_routes, +) def test_utils_public_key_and_encrypt(server_fixture: Server, mocker: Any) -> None: @@ -186,9 +190,7 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: client = TestClient(app) # No API key header - resp = client.get( - f"/supervaizer/agents/{agent.slug}/start/dynamic_choices" - ) + resp = client.get(f"/supervaizer/agents/{agent.slug}/start/dynamic_choices") assert resp.status_code == 401 From f34eed644b7ede3af1134a184bc9dc3db91bd0a0 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 23:09:07 +0300 Subject: [PATCH 11/15] =?UTF-8?q?=F0=9F=93=96=20docs:=20regenerate=20opena?= =?UTF-8?q?pi.json=20with=20dynamic=5Fchoices=20endpoint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api/openapi.json | 64 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/docs/api/openapi.json b/docs/api/openapi.json index e537bce..b0b3135 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -628,6 +628,56 @@ ] } }, + "/supervaizer/agents/competitor-summary/start/dynamic_choices": { + "get": { + "tags": [ + "Supervision", + "Supervision" + ], + "summary": "Get dynamic choices for agent: competitor_summary start method", + "description": "Returns dynamic choice values for fields that use dynamic_choices", + "operationId": "get_dynamic_choices_supervaizer_agents_competitor_summary_start_dynamic_choices_get", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": true, + "type": "object", + "title": "Response 200 Get Dynamic Choices Supervaizer Agents Competitor Summary Start Dynamic Choices Get" + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "APIKeyHeader": [] + } + ] + } + }, "/supervaizer/agents/competitor-summary/jobs": { "post": { "tags": [ @@ -3578,6 +3628,18 @@ "title": "Required", "description": "Whether field is required for form submission", "default": false + }, + "dynamic_choices": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Dynamic Choices", + "description": "Key name for dynamic choices resolved at runtime via Agent.dynamic_choices_callback. Mutually exclusive with 'choices'." } }, "type": "object", @@ -4023,7 +4085,7 @@ "type": "string", "format": "date-time", "title": "Timestamp", - "default": "2026-03-23T22:47:03.534997" + "default": "2026-04-08T23:08:57.508080" }, "status_code": { "type": "integer", From 94a9715b986bfbbc547f546a372fe708cf3b55ce Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 23:09:33 +0300 Subject: [PATCH 12/15] =?UTF-8?q?=F0=9F=93=96=20docs:=20fix=20endpoint=20p?= =?UTF-8?q?ath=20in=20changelog=20to=20include=20/supervaizer=20prefix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG.md | 2 +- docs/api/openapi.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8b10e43..251d4ad 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,7 +22,7 @@ All notable changes to this project will be documented in this file. ### 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 to the `Agent` constructor and a `dynamic_choices` key to your `AgentMethodField`. Supervaize Studio fetches choices from the new `GET /agents/{slug}/start/dynamic_choices` endpoint 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 to the `Agent` constructor and a `dynamic_choices` key to your `AgentMethodField`. Supervaize Studio fetches choices from the new `GET /supervaizer/agents/{slug}/start/dynamic_choices` endpoint 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 b0b3135..92f468f 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -4475,4 +4475,4 @@ } } } -} +} \ No newline at end of file From c1ed4e081356e636f19331eee1330d5d6cc7ae7e Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 23:57:17 +0300 Subject: [PATCH 13/15] =?UTF-8?q?=E2=9C=A8=20feat:=20change=20dynamic=5Fch?= =?UTF-8?q?oices=20endpoint=20to=20POST=20with=20workspace/mission=20conte?= =?UTF-8?q?xt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/supervaizer/agent.py | 2 +- .../examples/controller_template.py | 2 +- src/supervaizer/routes.py | 23 ++++++++--- tests/test_agent.py | 14 +++---- tests/test_routes.py | 39 ++++++++++++------- 5 files changed, 52 insertions(+), 28 deletions(-) diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index 3358afe..f71ef15 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) -> 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]]]", exclude=True, ) diff --git a/src/supervaizer/examples/controller_template.py b/src/supervaizer/examples/controller_template.py index cd515e8..f29d38c 100644 --- a/src/supervaizer/examples/controller_template.py +++ b/src/supervaizer/examples/controller_template.py @@ -36,7 +36,7 @@ PROD_PUBLIC_URL = "https://myagent.cloud-hosting.net:8001" -def get_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: +def get_dynamic_choices(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]: if method_name == "start": return { "projects": [("P1", "Project 1"), ("P2", "Project 2"), ("P3", "Project 3")], diff --git a/src/supervaizer/routes.py b/src/supervaizer/routes.py index e561125..7ad6c98 100644 --- a/src/supervaizer/routes.py +++ b/src/supervaizer/routes.py @@ -618,10 +618,10 @@ async def validate_method_fields( ) return result - @router.get( + @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", + description="Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context for contextualized choices.", response_model=Dict[str, Any], responses={ http_status.HTTP_200_OK: {"model": Dict[str, Any]}, @@ -632,10 +632,13 @@ async def validate_method_fields( ) @handle_route_errors() async def get_dynamic_choices( + body_params: Any = Body(...), agent: Agent = Depends(get_agent), ) -> Dict[str, Any]: """Get dynamic choices for the start method fields.""" - log.info(f"📥 GET /start/dynamic_choices [Dynamic choices] {agent.name}") + log.info( + f"📥 POST /start/dynamic_choices [Dynamic choices] {agent.name}" + ) if not agent.dynamic_choices_callback: raise HTTPException( @@ -643,9 +646,19 @@ async def get_dynamic_choices( detail=f"Agent {agent.name} does not have dynamic choices configured", ) - choices = agent.dynamic_choices_callback("start") + if body_params is None: + body_params = {} + + context = { + "workspace_id": body_params.get("workspace_id"), + "mission_id": body_params.get("mission_id"), + } + + choices = agent.dynamic_choices_callback("start", context) - log.info(f"📤 Agent {agent.name}: Dynamic choices keys: {list(choices.keys())}") + log.info( + f"📤 Agent {agent.name}: Dynamic choices keys: {list(choices.keys())}" + ) return {"choices": choices} if not agent.methods: diff --git a/tests/test_agent.py b/tests/test_agent.py index d150c1c..83a8cb8 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -875,7 +875,7 @@ def test_agent_method_registration_info_includes_dynamic_choices(): def test_agent_with_dynamic_choices_callback(agent_method_fixture: AgentMethod): """Test that Agent accepts a dynamic_choices_callback callable.""" - def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + def my_dynamic_choices(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]: return {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} agent = Agent( @@ -887,7 +887,7 @@ def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: dynamic_choices_callback=my_dynamic_choices, ) assert agent.dynamic_choices_callback is not None - result = agent.dynamic_choices_callback("start") + result = agent.dynamic_choices_callback("start", {}) assert result == {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} @@ -986,7 +986,7 @@ def test_agent_dynamic_choices_callback_dispatches_by_method_name( ): """Test that the callback receives the method name and can return different results per method.""" - def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + def my_dynamic_choices(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]: if method_name == "start": return {"projects": [("P1", "Project 1")]} return {} @@ -999,10 +999,10 @@ def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: methods=AgentMethods(job_start=agent_method_fixture), dynamic_choices_callback=my_dynamic_choices, ) - assert agent.dynamic_choices_callback("start") == { + assert agent.dynamic_choices_callback("start", {}) == { "projects": [("P1", "Project 1")] } - assert agent.dynamic_choices_callback("unknown") == {} + assert agent.dynamic_choices_callback("unknown", {}) == {} def test_agent_dynamic_choices_callback_multiple_keys( @@ -1010,7 +1010,7 @@ def test_agent_dynamic_choices_callback_multiple_keys( ): """Test callback returning multiple choice keys.""" - def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + def my_dynamic_choices(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]: return { "projects": [("P1", "Project 1"), ("P2", "Project 2")], "teams": [("T1", "Team Alpha"), ("T2", "Team Beta")], @@ -1024,7 +1024,7 @@ def my_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: methods=AgentMethods(job_start=agent_method_fixture), dynamic_choices_callback=my_dynamic_choices, ) - result = agent.dynamic_choices_callback("start") + result = agent.dynamic_choices_callback("start", {}) assert "projects" in result assert "teams" in result assert len(result["projects"]) == 2 diff --git a/tests/test_routes.py b/tests/test_routes.py index c44f2a3..66ba378 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -99,9 +99,9 @@ def test_get_agents_and_agent_details( def test_dynamic_choices_endpoint(server_fixture: Server) -> None: - """Test GET /supervaizer/agents/{slug}/start/dynamic_choices returns choices.""" + """Test POST /supervaizer/agents/{slug}/start/dynamic_choices returns choices.""" - def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + def mock_dynamic_choices(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]: if method_name == "start": return {"projects": [("P1", "Project 1"), ("P2", "Project 2")]} return {} @@ -114,8 +114,10 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: client = TestClient(app) headers = {"X-API-Key": server_fixture.api_key or ""} - resp = client.get( - f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + resp = client.post( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", + headers=headers, + json={"workspace_id": "ws-1", "mission_id": "m-1"}, ) assert resp.status_code == 200 data = resp.json() @@ -127,7 +129,7 @@ def test_dynamic_choices_endpoint_multiple_keys( ) -> None: """Test endpoint returns multiple choice keys.""" - def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + def mock_dynamic_choices(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]: return { "projects": [("P1", "Project 1")], "teams": [("T1", "Team Alpha")], @@ -141,8 +143,10 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: client = TestClient(app) headers = {"X-API-Key": server_fixture.api_key or ""} - resp = client.get( - f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + resp = client.post( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", + headers=headers, + json={"workspace_id": "ws-1", "mission_id": "m-1"}, ) assert resp.status_code == 200 data = resp.json() @@ -155,7 +159,7 @@ def test_dynamic_choices_endpoint_empty_result( ) -> None: """Test endpoint returns empty choices when callback returns empty dict.""" - def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + def mock_dynamic_choices(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]: return {} agent = server_fixture.agents[0] @@ -166,8 +170,10 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: client = TestClient(app) headers = {"X-API-Key": server_fixture.api_key or ""} - resp = client.get( - f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + resp = client.post( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", + headers=headers, + json={"workspace_id": "ws-1", "mission_id": "m-1"}, ) assert resp.status_code == 200 data = resp.json() @@ -179,7 +185,7 @@ def test_dynamic_choices_endpoint_requires_api_key( ) -> None: """Test that the dynamic choices endpoint requires API key authentication.""" - def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: + def mock_dynamic_choices(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]: return {"projects": [("P1", "Project 1")]} agent = server_fixture.agents[0] @@ -190,7 +196,10 @@ def mock_dynamic_choices(method_name: str) -> dict[str, list[tuple[str, str]]]: client = TestClient(app) # No API key header - resp = client.get(f"/supervaizer/agents/{agent.slug}/start/dynamic_choices") + resp = client.post( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", + json={}, + ) assert resp.status_code == 401 @@ -206,7 +215,9 @@ def test_dynamic_choices_endpoint_no_callback( client = TestClient(app) headers = {"X-API-Key": server_fixture.api_key or ""} - resp = client.get( - f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", headers=headers + resp = client.post( + f"/supervaizer/agents/{agent.slug}/start/dynamic_choices", + headers=headers, + json={"workspace_id": "ws-1", "mission_id": "m-1"}, ) assert resp.status_code == 404 From 6c8d1bb168721c40d10071796c00afc93502be6b Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Wed, 8 Apr 2026 23:59:53 +0300 Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=93=96=20docs:=20update=20changelog?= =?UTF-8?q?=20and=20regenerate=20openapi.json=20for=20POST=20dynamic=5Fcho?= =?UTF-8?q?ices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CHANGELOG.md | 2 +- docs/api/openapi.json | 30 +++++++++++++++++++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 251d4ad..96d6853 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -22,7 +22,7 @@ All notable changes to this project will be documented in this file. ### 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 to the `Agent` constructor and a `dynamic_choices` key to your `AgentMethodField`. Supervaize Studio fetches choices from the new `GET /supervaizer/agents/{slug}/start/dynamic_choices` endpoint 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` 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 92f468f..c559d37 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -629,14 +629,24 @@ } }, "/supervaizer/agents/competitor-summary/start/dynamic_choices": { - "get": { + "post": { "tags": [ "Supervision", "Supervision" ], "summary": "Get dynamic choices for agent: competitor_summary start method", - "description": "Returns dynamic choice values for fields that use dynamic_choices", - "operationId": "get_dynamic_choices_supervaizer_agents_competitor_summary_start_dynamic_choices_get", + "description": "Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context for contextualized choices.", + "operationId": "get_dynamic_choices_supervaizer_agents_competitor_summary_start_dynamic_choices_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "title": "Body Params" + } + } + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", @@ -645,7 +655,7 @@ "schema": { "additionalProperties": true, "type": "object", - "title": "Response 200 Get Dynamic Choices Supervaizer Agents Competitor Summary Start Dynamic Choices Get" + "title": "Response 200 Get Dynamic Choices Supervaizer Agents Competitor Summary Start Dynamic Choices Post" } } } @@ -669,6 +679,16 @@ } } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } }, "security": [ @@ -4085,7 +4105,7 @@ "type": "string", "format": "date-time", "title": "Timestamp", - "default": "2026-04-08T23:08:57.508080" + "default": "2026-04-08T23:59:53.027216" }, "status_code": { "type": "integer", From 0b941d82a7508365bc0b61e2913b06e618001952 Mon Sep 17 00:00:00 2001 From: Alain Prasquier Date: Thu, 9 Apr 2026 00:05:08 +0300 Subject: [PATCH 15/15] =?UTF-8?q?Bump=20version:=200.12.0=20=E2=86=92=200.?= =?UTF-8?q?13.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- src/supervaizer/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 15247f5..bce6598 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -126,7 +126,7 @@ mypy_path = "src" disallow_any_expr = false [tool.bumpversion] -current_version = "0.12.0" +current_version = "0.13.0" commit = true tag = true tag_name = "v{new_version}" diff --git a/src/supervaizer/__version__.py b/src/supervaizer/__version__.py index c770ccc..93d0680 100644 --- a/src/supervaizer/__version__.py +++ b/src/supervaizer/__version__.py @@ -5,6 +5,6 @@ # https://mozilla.org/MPL/2.0/. -VERSION = "0.12.0" +VERSION = "0.13.0" API_VERSION = "v1" TELEMETRY_VERSION = "v1"