diff --git a/code_puppy/agents/_compaction.py b/code_puppy/agents/_compaction.py index 14c3b3e96..5ebf1095b 100644 --- a/code_puppy/agents/_compaction.py +++ b/code_puppy/agents/_compaction.py @@ -373,6 +373,24 @@ def compact( return result_messages, summarized_messages +def _is_stripable_thinking_part(part: Any) -> bool: + """True if a ThinkingPart carries no content AND no provider state. + + A ThinkingPart with ``signature``, ``id``, or ``provider_details`` is + holding encrypted reasoning state (e.g. OpenAI Responses API ``rs_...`` + items) that must be sent back to pair with subsequent ``msg_...`` items. + Dropping those orphans the paired message and the API rejects the + request with "provided without its required 'reasoning' item". + """ + if not isinstance(part, ThinkingPart): + return False + if part.content: + return False + if part.signature or part.id or part.provider_details: + return False + return True + + def _strip_empty_thinking_parts( messages: List[ModelMessage], ) -> Tuple[List[ModelMessage], int]: @@ -381,21 +399,13 @@ def _strip_empty_thinking_parts( filtered_count = 0 for msg in messages: parts = list(msg.parts) - if ( - len(parts) == 1 - and isinstance(parts[0], ThinkingPart) - and not parts[0].content - ): + if len(parts) == 1 and _is_stripable_thinking_part(parts[0]): filtered_count += 1 continue - if any(isinstance(p, ThinkingPart) and not p.content for p in parts): + if any(_is_stripable_thinking_part(p) for p in parts): msg = dataclasses.replace( msg, - parts=[ - p - for p in parts - if not (isinstance(p, ThinkingPart) and not p.content) - ], + parts=[p for p in parts if not _is_stripable_thinking_part(p)], ) if not msg.parts: filtered_count += 1 diff --git a/code_puppy/agents/_runtime.py b/code_puppy/agents/_runtime.py index 0d5e2b281..70a7dc563 100644 --- a/code_puppy/agents/_runtime.py +++ b/code_puppy/agents/_runtime.py @@ -409,8 +409,8 @@ async def run_agent_task() -> Any: except* Exception as other: unexpected = _collect_exceptions( other, - lambda e: not isinstance( - e, (asyncio.CancelledError, UsageLimitExceeded) + lambda e: ( + not isinstance(e, (asyncio.CancelledError, UsageLimitExceeded)) ), ) for exc in unexpected: diff --git a/code_puppy/plugins/azure_foundry/config.py b/code_puppy/plugins/azure_foundry/config.py index 5ade17be4..8040a5787 100644 --- a/code_puppy/plugins/azure_foundry/config.py +++ b/code_puppy/plugins/azure_foundry/config.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Any -from code_puppy.config import DATA_DIR +from code_puppy.config import DATA_DIR, get_value, set_config_value # Azure AD scope for Cognitive Services (used for token acquisition) # This scope is required for authenticating with Azure AI Foundry @@ -77,14 +77,28 @@ def get_openai_context_length(model_name: str) -> int: ENV_FOUNDRY_RESOURCE = "ANTHROPIC_FOUNDRY_RESOURCE" ENV_FOUNDRY_BASE_URL = "ANTHROPIC_FOUNDRY_BASE_URL" +# puppy.cfg key for persisting the resource name across sessions +CFG_KEY_FOUNDRY_RESOURCE = "azure_foundry_resource" + def get_foundry_resource() -> str | None: - """Get the Azure Foundry resource name from environment. + """Get the Azure Foundry resource name. + + Resolution order: environment variable first (for explicit overrides), + then puppy.cfg (persisted from a prior ``/foundry-setup``). Returns: The resource name if set, None otherwise. """ - return os.environ.get(ENV_FOUNDRY_RESOURCE) + env_value = os.environ.get(ENV_FOUNDRY_RESOURCE) + if env_value: + return env_value + return get_value(CFG_KEY_FOUNDRY_RESOURCE) or None + + +def set_foundry_resource(resource: str) -> None: + """Persist the Azure Foundry resource name to puppy.cfg.""" + set_config_value(CFG_KEY_FOUNDRY_RESOURCE, resource) def get_foundry_base_url() -> str | None: diff --git a/code_puppy/plugins/azure_foundry/register_callbacks.py b/code_puppy/plugins/azure_foundry/register_callbacks.py index 5705a6dd8..e5c038f69 100644 --- a/code_puppy/plugins/azure_foundry/register_callbacks.py +++ b/code_puppy/plugins/azure_foundry/register_callbacks.py @@ -22,6 +22,7 @@ DEFAULT_DEPLOYMENT_NAMES, ENV_FOUNDRY_RESOURCE, get_foundry_resource, + set_foundry_resource, ) from .discovery import find_account, list_deployments from .token import get_token_provider @@ -78,7 +79,10 @@ def _handle_foundry_status() -> None: if resource: emit_info(f"Foundry Resource: {resource}") else: - emit_warning(f"Foundry Resource: Not set (set {ENV_FOUNDRY_RESOURCE})") + emit_warning( + f"Foundry Resource: Not set " + f"(run /foundry-setup or set {ENV_FOUNDRY_RESOURCE})" + ) emit_info("") if foundry_models: @@ -191,9 +195,16 @@ def _print(msg: str = "") -> None: # Step 4: Save configuration _print("Step 4: Saving configuration...") - if not get_foundry_resource(): + # Persist the resource name so the next run pre-populates it. + # (Env var still wins at lookup time if the user sets one explicitly.) + try: + set_foundry_resource(resource_name) + _print(f" Saved resource '{resource_name}' to puppy.cfg") + except Exception as e: + logger.warning("Failed to persist foundry resource to puppy.cfg: %s", e) _print( - f" Tip: Set {ENV_FOUNDRY_RESOURCE}={resource_name} in your environment" + f" Warning: could not save to puppy.cfg " + f"(set {ENV_FOUNDRY_RESOURCE}={resource_name} to persist)" ) if succeeded is not None: diff --git a/tests/agents/test_compaction.py b/tests/agents/test_compaction.py index 026d1833e..adbce70e4 100644 --- a/tests/agents/test_compaction.py +++ b/tests/agents/test_compaction.py @@ -516,6 +516,44 @@ def test_strips_empty_thinking_parts(self): "empty ThinkingPart should have been stripped" ) + def test_preserves_empty_thinking_with_signature(self): + """ThinkingParts with encrypted reasoning state must NOT be stripped. + + GPT-5 on the Responses API returns reasoning items with empty content + but non-empty ``signature`` / ``id`` (the encrypted reasoning payload). + These must round-trip back to the API or the paired ``msg_...`` item + in the next turn errors with "provided without its required + 'reasoning' item". + """ + agent = _FakeAgent() + processor = make_history_processor(agent) + + reasoning_msg = ModelResponse( + parts=[ + ThinkingPart( + content="", + id="rs_abc123", + signature="encrypted-blob", + provider_name="openai", + ), + TextPart(content="hi", id="msg_abc123"), + ] + ) + msgs = [_sys_msg(), _user_msg("q"), reasoning_msg, _user_msg("q2")] + result = processor(msgs) + + # The reasoning ThinkingPart must survive — dropping it would orphan + # the paired TextPart(id="msg_...") on the next API request. + thinking_found = False + for msg in result: + for p in msg.parts: + if isinstance(p, ThinkingPart) and p.id == "rs_abc123": + thinking_found = True + assert p.signature == "encrypted-blob" + assert thinking_found, ( + "ThinkingPart with signature must be preserved for Responses API" + ) + def test_triggers_compaction_over_threshold(self): """When over threshold, the processor must call compact() and shrink history.""" agent = _FakeAgent(model_max=5_000, overhead=100) diff --git a/tests/plugins/test_azure_foundry.py b/tests/plugins/test_azure_foundry.py index bea32e6a5..f53f3aa91 100644 --- a/tests/plugins/test_azure_foundry.py +++ b/tests/plugins/test_azure_foundry.py @@ -87,13 +87,54 @@ def test_get_foundry_resource_from_env(self): assert get_foundry_resource() == "test-resource" def test_get_foundry_resource_not_set(self): - """Test getting resource name when not set.""" + """Test getting resource name when neither env var nor puppy.cfg is set.""" from code_puppy.plugins.azure_foundry.config import get_foundry_resource with patch.dict(os.environ, {}, clear=True): - # Remove the env var if it exists os.environ.pop("ANTHROPIC_FOUNDRY_RESOURCE", None) - assert get_foundry_resource() is None + with patch( + "code_puppy.plugins.azure_foundry.config.get_value", + return_value=None, + ): + assert get_foundry_resource() is None + + def test_get_foundry_resource_from_cfg(self): + """When no env var is set, fall back to puppy.cfg.""" + from code_puppy.plugins.azure_foundry.config import get_foundry_resource + + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("ANTHROPIC_FOUNDRY_RESOURCE", None) + with patch( + "code_puppy.plugins.azure_foundry.config.get_value", + return_value="saved-resource", + ): + assert get_foundry_resource() == "saved-resource" + + def test_get_foundry_resource_env_overrides_cfg(self): + """Env var should take precedence over puppy.cfg value.""" + from code_puppy.plugins.azure_foundry.config import get_foundry_resource + + with patch.dict( + os.environ, {"ANTHROPIC_FOUNDRY_RESOURCE": "from-env"}, clear=True + ): + with patch( + "code_puppy.plugins.azure_foundry.config.get_value", + return_value="from-cfg", + ): + assert get_foundry_resource() == "from-env" + + def test_set_foundry_resource_persists_to_cfg(self): + """set_foundry_resource writes to puppy.cfg under the expected key.""" + from code_puppy.plugins.azure_foundry.config import ( + CFG_KEY_FOUNDRY_RESOURCE, + set_foundry_resource, + ) + + with patch( + "code_puppy.plugins.azure_foundry.config.set_config_value" + ) as mock_set: + set_foundry_resource("my-resource") + mock_set.assert_called_once_with(CFG_KEY_FOUNDRY_RESOURCE, "my-resource") def test_get_foundry_base_url_from_resource(self): """Test constructing base URL from resource name."""