Skip to content
Open
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
32 changes: 21 additions & 11 deletions code_puppy/agents/_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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
Expand Down
4 changes: 2 additions & 2 deletions code_puppy/agents/_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
20 changes: 17 additions & 3 deletions code_puppy/plugins/azure_foundry/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
17 changes: 14 additions & 3 deletions code_puppy/plugins/azure_foundry/register_callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
38 changes: 38 additions & 0 deletions tests/agents/test_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
47 changes: 44 additions & 3 deletions tests/plugins/test_azure_foundry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
Loading