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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,10 @@ Reference specific personas when requesting work:
- `server.register.details.url` is expected to represent the controller `public_url` (reachable URL), not necessarily the local bind address.
- Agent registration payloads currently expose the controller agent identifier as `id`; coordinate with Studio if renaming/removing this field (Studio may also support legacy `agent_id`).
- If changing registration/event payload fields, update `supervaizer` tests and validate compatibility against Studio’s controller-event processing.

## Learned User Preferences

## Learned Workspace Facts

- `ADMIN_ALLOWED_IPS` restricts `/admin` when set (comma-separated IPs/CIDR); unset or empty allows all client IPs.
- In `9agents/agent_interviewer`, empty `MANAGE_ALLOWED_IPS` still requires `MANAGE_AUTH_TOKEN` when that env is set; supervaizer’s admin IP middleware has no equivalent token fallback when the allowlist is empty.
19 changes: 17 additions & 2 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,27 @@ All notable changes to this project will be documented in this file.

## Unreleased

## v0.13.0
### Changed

- **Dynamic choices request context** — `POST .../start/dynamic_choices` now passes `workspace_slug` through to `dynamic_choices_callback` alongside `workspace_id` and `mission_id` (Supervaize Studio sends it in the JSON body).

### Unit Tests Results

`just test`

| Status | Count |
| ---------- | ----- |
| ✅ Passed | 466 |
| 🤔 Skipped | 0 |
| 🔴 Failed | 0 |
| ⏱️ in | 54s |

## v0.13.1

### Added

- **`ADMIN_ALLOWED_IPS` for admin UI** — When set, only matching client IPs may access `/admin` (HTML, APIs, static files, WebSocket). Comma-separated IPs and optional CIDR notation; empty or unset allows all. Uses the first address in `X-Forwarded-For` when present.
- **Dynamic choices for `AgentMethodField`** — Fields can now use `dynamic_choices` instead of static `choices` to resolve options at runtime via a callback. Add a `dynamic_choices_callback` callable (signature: `(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]`) to the `Agent` constructor and a `dynamic_choices` key to your `AgentMethodField`. Supervaize Studio fetches choices from the new `POST /supervaizer/agents/{slug}/start/dynamic_choices` endpoint (with `workspace_id` and `mission_id` in the request body) when rendering the job start form. Static `choices` and `dynamic_choices` are mutually exclusive on a field. See [Dynamic Choices guide](https://docs.runwaize.com/docs/supervaizer-controller/dynamic-choices).
- **Dynamic choices for `AgentMethodField`** — Fields can now use `dynamic_choices` instead of static `choices` to resolve options at runtime via a callback. Add a `dynamic_choices_callback` callable (signature: `(method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]`) to the `Agent` constructor and a `dynamic_choices` key to your `AgentMethodField`. Supervaize Studio fetches choices from the new `POST /supervaizer/agents/{slug}/start/dynamic_choices` endpoint (with `workspace_id`, `workspace_slug`, and `mission_id` in the request body) when rendering the job start form. Static `choices` and `dynamic_choices` are mutually exclusive on a field. See [Dynamic Choices guide](https://docs.runwaize.com/docs/supervaizer-controller/dynamic-choices).

### Unit Tests Results

Expand Down
2 changes: 1 addition & 1 deletion docs/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,7 @@
"Supervision"
],
"summary": "Get dynamic choices for agent: competitor_summary start method",
"description": "Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context for contextualized choices.",
"description": "Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context (including workspace slug) for contextualized choices.",
"operationId": "get_dynamic_choices_supervaizer_agents_competitor_summary_start_dynamic_choices_post",
"requestBody": {
"content": {
Expand Down
2 changes: 1 addition & 1 deletion src/supervaizer/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,7 +627,7 @@ class AgentAbstract(SvBaseModel):
)
dynamic_choices_callback: Any | None = Field(
default=None,
description="Callable that returns dynamic choices for method fields. Signature: (method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]",
description="Callable that returns dynamic choices for method fields. Signature: (method_name: str, context: dict) -> dict[str, list[tuple[str, str]]]. Context includes workspace_id, workspace_slug, mission_id from the dynamic_choices request body.",
exclude=True,
)

Expand Down
12 changes: 12 additions & 0 deletions src/supervaizer/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
from datetime import datetime
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional

from pydantic import field_validator

from supervaizer.__version__ import VERSION
from supervaizer.common import SvBaseModel, log, singleton
from supervaizer.lifecycle import (
Expand Down Expand Up @@ -160,6 +162,16 @@ class JobContext(SvBaseModel):
mission_context: Any = None
job_instructions: Optional[JobInstructions] = None

@field_validator("workspace_id", mode="before")
@classmethod
def coerce_workspace_id(cls, v: Any) -> Any:
"""Supervaize API may send numeric workspace ids (JSON); normalize to str."""
if isinstance(v, str):
return v.strip()
if isinstance(v, (int, float)):
return str(v)
Comment on lines +171 to +172

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Bool workspace_id coerced 🐞 Bug ≡ Correctness

JobContext.coerce_workspace_id treats bool as an int and converts True/False into string workspace
IDs ("True"/"False"), silently accepting invalid input. This can create jobs with incorrect
workspace_id values instead of failing validation.
Agent Prompt
## Issue description
`JobContext.coerce_workspace_id` currently coerces any `(int, float)` to `str`. Because `bool` is an `int` subclass in Python, `workspace_id=True/False` will be accepted and converted to `"True"`/`"False"`, silently producing invalid workspace identifiers.

## Issue Context
`JobContext.workspace_id` is declared as `str`, so non-string values should either be normalized safely (for numeric IDs) or rejected when clearly invalid (like booleans).

## Fix Focus Areas
- src/supervaizer/job.py[165-173]

### Suggested change
Add an explicit `bool` guard before the numeric coercion, e.g.:
- if `isinstance(v, bool)`: raise `ValueError` (or return `v` and let Pydantic reject it)
- then handle `(int, float)` coercion

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return v

@property
def registration_info(self) -> Dict[str, Any]:
"""Returns registration info for the job context"""
Expand Down
3 changes: 2 additions & 1 deletion src/supervaizer/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@ async def validate_method_fields(
@router.post(
"/start/dynamic_choices",
summary=f"Get dynamic choices for agent: {agent.name} start method",
description="Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context for contextualized choices.",
description="Returns dynamic choice values for fields that use dynamic_choices. Accepts workspace and mission context (including workspace slug) for contextualized choices.",
response_model=Dict[str, Any],
responses={
http_status.HTTP_200_OK: {"model": Dict[str, Any]},
Expand Down Expand Up @@ -649,6 +649,7 @@ async def get_dynamic_choices(

context = {
"workspace_id": body_params.get("workspace_id"),
"workspace_slug": body_params.get("workspace_slug"),
"mission_id": body_params.get("mission_id"),
}

Expand Down
12 changes: 12 additions & 0 deletions tests/test_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,18 @@ def job_fixture(context_fixture: JobContext) -> Job:
)


def test_job_context_workspace_id_int_coerced_to_str() -> None:
ctx = JobContext(
workspace_id=23,
job_id="j1",
started_by="u",
started_at=datetime.now(),
mission_id="m1",
mission_name="M",
)
assert ctx.workspace_id == "23"


def test_job_creation(context_fixture: JobContext, job_fixture: Job) -> None:
job_context = context_fixture
job = job_fixture
Expand Down
62 changes: 58 additions & 4 deletions tests/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,55 @@ def mock_dynamic_choices(
resp = client.post(
f"/supervaizer/agents/{agent.slug}/start/dynamic_choices",
headers=headers,
json={"workspace_id": "ws-1", "mission_id": "m-1"},
json={
"workspace_id": "ws-1",
"workspace_slug": "adl",
"mission_id": "m-1",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["choices"]["projects"] == [["P1", "Project 1"], ["P2", "Project 2"]]


def test_dynamic_choices_endpoint_passes_workspace_slug_in_context(
server_fixture: Server,
) -> None:
"""Callback context includes workspace_slug from the request body."""

captured: dict[str, Any] = {}

def mock_dynamic_choices(
method_name: str, context: dict
) -> dict[str, list[tuple[str, str]]]:
captured["context"] = dict(context)
return {"projects": [("P1", "Project 1")]}

agent = server_fixture.agents[0]
agent.dynamic_choices_callback = mock_dynamic_choices

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

resp = client.post(
f"/supervaizer/agents/{agent.slug}/start/dynamic_choices",
headers=headers,
json={
"workspace_id": 23,
"workspace_slug": "adl",
"mission_id": "01KNPPT249HFSSW662KW9R477N",
},
)
assert resp.status_code == 200
assert captured["context"] == {
"workspace_id": 23,
"workspace_slug": "adl",
"mission_id": "01KNPPT249HFSSW662KW9R477N",
}


def test_dynamic_choices_endpoint_multiple_keys(
server_fixture: Server,
) -> None:
Expand All @@ -150,7 +192,11 @@ def mock_dynamic_choices(
resp = client.post(
f"/supervaizer/agents/{agent.slug}/start/dynamic_choices",
headers=headers,
json={"workspace_id": "ws-1", "mission_id": "m-1"},
json={
"workspace_id": "ws-1",
"workspace_slug": "slug-1",
"mission_id": "m-1",
},
)
assert resp.status_code == 200
data = resp.json()
Expand Down Expand Up @@ -179,7 +225,11 @@ def mock_dynamic_choices(
resp = client.post(
f"/supervaizer/agents/{agent.slug}/start/dynamic_choices",
headers=headers,
json={"workspace_id": "ws-1", "mission_id": "m-1"},
json={
"workspace_id": "ws-1",
"workspace_slug": "slug-1",
"mission_id": "m-1",
},
)
assert resp.status_code == 200
data = resp.json()
Expand Down Expand Up @@ -226,6 +276,10 @@ def test_dynamic_choices_endpoint_no_callback(
resp = client.post(
f"/supervaizer/agents/{agent.slug}/start/dynamic_choices",
headers=headers,
json={"workspace_id": "ws-1", "mission_id": "m-1"},
json={
"workspace_id": "ws-1",
"workspace_slug": "slug-1",
"mission_id": "m-1",
},
)
assert resp.status_code == 404
Loading