From ad1b1a64a2ef387e6dd322a969fe3f19a76df6eb Mon Sep 17 00:00:00 2001 From: Paulo Lacerda Date: Sun, 31 May 2026 08:33:10 -0300 Subject: [PATCH] fix(prompt_deploy): normalize Foundry definition to dict for SDK 2.x compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `created` action path of `prompt_deploy stage` was producing an invalid request body against azure-ai-projects 2.x because: 1. `_copy_definition` called `.copy()` on the typed `PromptAgentDefinition` returned by `get_version`. In SDK 2.x that returns a stripped base `Model` whose JSON shape is `{"_data": {...}}` instead of the flat payload the Foundry Agents service expects. 2. `_create_agent_version` also wrote `kind` at the body root via `definition.get("kind")` — but the post-`.copy()` model returned `None` for that key, AND the new API treats `kind` strictly as the polymorphic discriminator inside `definition`. The combined effect was a request body like `{"kind": null, "definition": {"_data": {"kind": "prompt", ...}}, ...}`, which the service rejected with 'invalid_payload — Required properties ["kind"] are not present'. This regression only fired on the `created` path (user changed the prompt relative to the seed). The `reused` and bootstrap paths never round-trip the typed model through `.copy()`, so they were unaffected. Fix: - New `_definition_to_dict` helper accepts dicts, typed SDK models with `_data`, mapping-like objects with `.items()`, or anything with `as_dict()`. - `_copy_definition` now always returns a deep-copied plain dict. - `_create_agent_version` drops the root-level `kind` from the body and sends a clean `{definition, metadata, description}` shape. Verified locally: serialization of a real `PromptAgentDefinition` (2.2.0) through the fixed path produces the expected flat shape with `kind` inside `definition`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 17 ++++++ src/agentops/pipeline/prompt_deploy.py | 46 +++++++++++---- tests/unit/test_prompt_deploy.py | 80 ++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 10 deletions(-) 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"