diff --git a/CHANGELOG.md b/CHANGELOG.md index 850c525..e8df3c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ This format follows [Keep a Changelog](https://keepachangelog.com/) and adheres ## [Unreleased] ### Fixed +- **Prompt-agent deploy: `stage` no longer fails with `Required properties ["kind"] are not present` against `azure-ai-projects` 2.x.** + `_copy_definition` previously called `.copy()` on the typed + `PromptAgentDefinition` returned by `get_version`. In SDK 1.x that + preserved the typed model so the body serialized as a flat + `{"kind": "prompt", "model": ..., "instructions": ...}`. In SDK 2.x + the same `.copy()` returns a stripped base `Model` whose JSON shape + is `{"_data": {"kind": "prompt", ...}}`, and `.get("kind")` returns + `None` — so the request body that reached the Foundry Agents service + contained `definition: {"_data": {...}}` with no top-level `kind`, + and the service rejected it with `invalid_payload`. This regression + only fired on the `created` action path (i.e. when the user's prompt + differed from the seed); the `reused` and bootstrap paths were + unaffected because they don't round-trip the typed model through + `.copy()`. `_copy_definition` now normalizes any SDK definition + object to a plain `dict` before mutation, and `_create_agent_version` + no longer puts a root-level `kind` on the request body (the new API + treats `kind` strictly as the discriminator inside `definition`). - **Tutorial: prompt-agent step 13 now shows the steady-state `foundry-agent.json` (action: reused) instead of the bootstrap edge case.** The example JSON in step 13 previously showed `action: bootstrapped` with `candidate_agent: "travel-agent:1"` and a "the two numbers are diff --git a/src/agentops/pipeline/prompt_deploy.py b/src/agentops/pipeline/prompt_deploy.py index b8464a0..331c21c 100644 --- a/src/agentops/pipeline/prompt_deploy.py +++ b/src/agentops/pipeline/prompt_deploy.py @@ -318,11 +318,9 @@ def _create_agent_version( description: str, ) -> Any: client = _project_client(endpoint) - # The current Foundry Agents create_version endpoint validates both a root - # `kind` discriminator and the nested `definition` payload. - body = { - "kind": _get_definition_value(definition, "kind"), - "definition": definition, + definition_dict = _definition_to_dict(definition) + body: Dict[str, Any] = { + "definition": definition_dict, "metadata": metadata, "description": description, } @@ -380,13 +378,41 @@ def _get_mapping_value(value: Any, key: str) -> Any: return None -def _copy_definition(definition: Any) -> Any: - if hasattr(definition, "copy"): +def _copy_definition(definition: Any) -> Dict[str, Any]: + """Return a deep copy of ``definition`` as a plain dict. + + The Foundry SDK's typed definition models (e.g. ``PromptAgentDefinition``) + expose ``.copy()``, but in ``azure-ai-projects`` 2.x that returns a stripped + base ``Model`` whose JSON shape is ``{"_data": {...}}`` instead of the + flattened payload the service expects. To stay compatible across SDK + versions we always normalize to a plain dict here. + """ + + return copy.deepcopy(_definition_to_dict(definition)) + + +def _definition_to_dict(definition: Any) -> Dict[str, Any]: + """Best-effort conversion of an SDK definition object into a plain dict.""" + + if isinstance(definition, dict): + return dict(definition) + data = getattr(definition, "_data", None) + if isinstance(data, dict): + return dict(data) + if hasattr(definition, "items"): try: - return definition.copy() - except TypeError: + return {key: value for key, value in definition.items()} + except Exception: # noqa: BLE001 — fall through to attribute scrape pass - return copy.deepcopy(definition) + if hasattr(definition, "as_dict"): + try: + return dict(definition.as_dict()) + except Exception: # noqa: BLE001 + pass + raise TypeError( + f"Cannot convert Foundry agent definition of type {type(definition).__name__} " + "to a dict; expected a mapping-compatible object." + ) def _deployment_metadata(*, environment: str, prompt_hash: str) -> Dict[str, str]: diff --git a/tests/unit/test_prompt_deploy.py b/tests/unit/test_prompt_deploy.py index 2816cb7..92f0256 100644 --- a/tests/unit/test_prompt_deploy.py +++ b/tests/unit/test_prompt_deploy.py @@ -374,3 +374,83 @@ def test_is_not_found_error_handles_404_and_rejects_others() -> None: assert not prompt_deploy._is_not_found_error(_make_not_found(403)) assert not prompt_deploy._is_not_found_error(_make_not_found(500)) assert not prompt_deploy._is_not_found_error(Exception("no status")) + + +def test_copy_definition_returns_plain_dict_from_sdk_typed_model() -> None: + """Regression: ``azure-ai-projects`` 2.x typed definition models expose a + ``.copy()`` method that returns a stripped base ``Model`` whose JSON shape + is ``{"_data": {...}}`` instead of the flat fields the Foundry service + expects. ``_copy_definition`` must normalize to a plain dict so the + ``definition`` payload reaches the wire as a flat object with ``kind`` at + the top level. + """ + + class _FakeTypedModel: + """Mimics the surface of ``PromptAgentDefinition`` from SDK 2.x.""" + + def __init__(self, data: dict) -> None: + self._data = dict(data) + + def get(self, key, default=None): + return self._data.get(key, default) + + def items(self): + return self._data.items() + + def copy(self): + stripped = _FakeTypedModel.__new__(_FakeTypedModel) + stripped._data = dict(self._data) + return stripped + + typed = _FakeTypedModel( + {"kind": "prompt", "model": "gpt-4o-mini", "instructions": "hi"} + ) + + copied = prompt_deploy._copy_definition(typed) + + assert isinstance(copied, dict) + assert copied == { + "kind": "prompt", + "model": "gpt-4o-mini", + "instructions": "hi", + } + assert "_data" not in copied + + +def test_create_agent_version_body_uses_flat_definition_dict(monkeypatch) -> None: + """The body sent to ``client.agents.create_version`` must contain a flat + ``definition`` dict (not the SDK's ``{"_data": {...}}`` shape) and must + not include a body-root ``kind`` — the new Foundry API treats ``kind`` as + the polymorphic discriminator inside ``definition``. + """ + + captured: dict = {} + + class _FakeAgents: + def create_version(self, agent_name, *, body): + captured["agent_name"] = agent_name + captured["body"] = body + return SimpleNamespace(id="agent-version-9", version="9") + + class _FakeClient: + agents = _FakeAgents() + + monkeypatch.setattr(prompt_deploy, "_project_client", lambda endpoint: _FakeClient()) + + definition = {"kind": "prompt", "model": "gpt-4o-mini", "instructions": "hi"} + + result = prompt_deploy._create_agent_version( + "https://example/api/projects/p", + "travel-agent", + definition, + metadata={"agentops.env": "dev"}, + description="desc", + ) + + assert result.version == "9" + body = captured["body"] + assert "kind" not in body, "body-root 'kind' is no longer valid in SDK 2.x" + assert body["definition"] == definition + assert body["definition"]["kind"] == "prompt" + assert body["metadata"] == {"agentops.env": "dev"} + assert body["description"] == "desc"