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 docs/2026_05_SUPERVAIZER_v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,12 +375,19 @@ Common action IDs:
| `job.stop` | Stop or cancel agent work. |
| `job.sync` | Return a convergent state snapshot for Studio. |
| `step.awaiting.submit` | Submit operator input for a HITL step. |
| `context.assign` | Assign a frozen Studio context selection to a job; agent fetches content via `ContextClient` and stores provenance (`ref`, `version`, `hash`, `synced_at`). |
| `resource.<id>.<operation>` | Run a resource operation. |
| `dataset.<id>.query` | Query an agent-owned dataset. |
| `artifact.get` | Load artifact content by reference. |

Action requests include `actor`, `workspace`, `mission_id`, `agent_slug`, `surface`, `action`, `input`, and optional correlation fields such as `job_id`, `case_id`, `step_id`, `draft_session_id`, and `idempotency_key`.

### Context assignment semantics

`context.assign` carries a `V2ContextAssignment` payload: the selected items (`ref`, `version`, `scope`, `title`), the `job_id`, an optional `mission_id`, and a Studio-stamped `assigned_at`. An empty `items` list is an explicit "no context" assignment, not an error.

The payload intentionally contains references, not content. On receipt the agent fetches each item once through `ContextClient.open()` and freezes the returned content as its own snapshot with provenance (`ref`, `version`, content `hash`, `synced_at`). `ContextClient.open()` returns the item's current `version`; the agent MUST compare it against the assigned `version` and fail the whole assignment with an explicit error when they differ — the item changed between selection and sync, and the operator should re-assign. Agents must not silently store content under a version it does not match, and must not re-read Studio context during job execution; a new `context.assign` is the only refresh path.

## Sync and Offline Semantics

Studio owns operator-facing job lifecycle, but the agent owns actual business state. `job.sync` is the reconciliation point between the two.
Expand Down
16 changes: 16 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

### Added

- **`context.assign` contract** — New typed wire contract for assigning a frozen Studio context selection to a job: `V2ContextAssignment` (items, `mission_id`, `job_id`, `assigned_at`), `V2ContextAssignmentItem` (`ref`, `version`, `scope`, `title`), and the `V2_ACTION_CONTEXT_ASSIGN` action id in `supervaizer.contracts`, exported at package level. Additive only; dispatch uses the existing generic `Server.v2_action` machinery.

### Tests

- `tests/test_common.py` — structured JSON log output for API access-denial records
- `just test`

| Status | Count |
| ---------- | ----- |
| ✅ Passed | 677 |
| 🤔 Skipped | 0 |
| 🔴 Failed | 0 |
| ⏱️ in | 65s |

## [1.2.0] - 2026-05-27

### Changed
Expand Down
9 changes: 9 additions & 0 deletions src/supervaizer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@
"supervaizer.contracts",
"WORKSPACE_BINDING_OPTIONS_ACTION",
),
"V2_ACTION_CONTEXT_ASSIGN": (
"supervaizer.contracts",
"V2_ACTION_CONTEXT_ASSIGN",
),
"AgentMethodContract": ("supervaizer.contracts", "AgentMethodContract"),
"AgentMethodsContract": ("supervaizer.contracts", "AgentMethodsContract"),
"AgentRegistrationContract": ("supervaizer.contracts", "AgentRegistrationContract"),
Expand Down Expand Up @@ -150,6 +154,11 @@
"V2AwaitingState": ("supervaizer.contracts", "V2AwaitingState"),
"V2CaseLaneDefinition": ("supervaizer.contracts", "V2CaseLaneDefinition"),
"V2CaseSnapshot": ("supervaizer.contracts", "V2CaseSnapshot"),
"V2ContextAssignment": ("supervaizer.contracts", "V2ContextAssignment"),
"V2ContextAssignmentItem": (
"supervaizer.contracts",
"V2ContextAssignmentItem",
),
"V2DashboardDefinition": ("supervaizer.contracts", "V2DashboardDefinition"),
"V2DashboardWidgetDataRef": (
"supervaizer.contracts",
Expand Down
28 changes: 28 additions & 0 deletions src/supervaizer/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,34 @@ class V2ActionRequest(ContractModel):
workspace_authorization: V2VerifiedWorkspaceContext | None = None


V2_ACTION_CONTEXT_ASSIGN = "context.assign"


class V2ContextAssignmentItem(ContractModel):
ref: str # Studio ContextItem ref, e.g. "supervaize.context.mission.january-brief"
version: int # ContextItem version at selection time
Comment thread
alain-sv marked this conversation as resolved.
scope: Literal["workspace", "mission"]
title: str
Comment thread
qodo-code-review[bot] marked this conversation as resolved.


class V2ContextAssignment(ContractModel):
items: list[V2ContextAssignmentItem] # empty list = explicit "no context"
mission_id: str | None = None
Comment thread
alain-sv marked this conversation as resolved.
job_id: str
assigned_at: str # ISO-8601, stamped by Studio

@model_validator(mode="after")
def _require_mission_id_for_mission_items(self) -> "V2ContextAssignment":
if (
any(item.scope == "mission" for item in self.items)
and not (self.mission_id or "").strip()
):
raise ValueError(
"mission_id is required when the assignment contains mission-scoped items"
)
return self


class V2SurfaceRequest(ContractModel):
request_id: str
actor: V2ActorContext
Expand Down
88 changes: 88 additions & 0 deletions tests/test_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
V2AgentMethods,
V2AwaitingState,
V2CaseSnapshot,
V2ContextAssignment,
V2ContextAssignmentItem,
V2DashboardWidgetDataRef,
V2DashboardWidgetDefinition,
V2DashboardWidgetVisualization,
Expand All @@ -45,6 +47,7 @@
V2VerifiedWorkspaceContext,
V2WorkspaceAuthorizationSettings,
V2WorkspaceBindingDefinition,
V2_ACTION_CONTEXT_ASSIGN,
build_data_resource_context_headers,
build_v2_agent_registration,
controller_contract_info,
Expand Down Expand Up @@ -730,3 +733,88 @@ def test_v2_contract_models_are_public_sdk_exports() -> None:
)
assert supervaizer.V2WorkspaceBindingDefinition is V2WorkspaceBindingDefinition
assert supervaizer.build_v2_agent_registration is build_v2_agent_registration


def test_v2_context_assignment_parses_items() -> None:
assignment = V2ContextAssignment(
items=[
V2ContextAssignmentItem(
ref="supervaize.context.mission.january-brief",
version=3,
scope="mission",
title="January brief",
)
],
mission_id="mis_1",
job_id="job_1",
assigned_at="2026-07-02T09:00:00+00:00",
)
assert assignment.items[0].version == 3
assert V2_ACTION_CONTEXT_ASSIGN == "context.assign"


def test_v2_context_assignment_allows_empty_items() -> None:
assignment = V2ContextAssignment(
items=[], job_id="job_1", assigned_at="2026-07-02T09:00:00+00:00"
)
assert assignment.items == []
assert assignment.mission_id is None


def test_v2_context_assignment_requires_mission_id_for_mission_items() -> None:
with pytest.raises(ValidationError, match="mission_id is required"):
V2ContextAssignment(
items=[
V2ContextAssignmentItem(
ref="supervaize.context.mission.january-brief",
version=3,
scope="mission",
title="January brief",
)
],
job_id="job_1",
assigned_at="2026-07-02T09:00:00+00:00",
)


def test_v2_context_assignment_rejects_blank_mission_id_for_mission_items() -> None:
with pytest.raises(ValidationError, match="mission_id is required"):
V2ContextAssignment(
items=[
V2ContextAssignmentItem(
ref="supervaize.context.mission.january-brief",
version=3,
scope="mission",
title="January brief",
)
],
mission_id=" ",
job_id="job_1",
assigned_at="2026-07-02T09:00:00+00:00",
)


def test_v2_context_assignment_workspace_items_do_not_require_mission_id() -> None:
assignment = V2ContextAssignment(
items=[
V2ContextAssignmentItem(
ref="supervaize.context.workspace.brand-voice",
version=1,
scope="workspace",
title="Brand voice",
)
],
job_id="job_1",
assigned_at="2026-07-02T09:00:00+00:00",
)
assert assignment.mission_id is None


def test_v2_context_assignment_item_rejects_unknown_scope() -> None:
with pytest.raises(ValidationError):
V2ContextAssignmentItem(
ref="supervaize.context.mission.january-brief",
version=3,
scope="missions",
title="January brief",
)
Loading