diff --git a/CHANGELOG.md b/CHANGELOG.md index 1455615e..86ee8972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project are documented in this file. Format follows For narrative release notes written for operators and product owners, see [RELEASE_NOTES.md](RELEASE_NOTES.md). +## [1.6.1] - 2026-07-16 + +Patch release fixing two agent-invocation regressions. Agents bound to a Mantle-provider model (e.g. `openai.gpt-5.4`) no longer misroute to Bedrock and fail with "invalid model identifier" — the invocation path now backfills the model's registered `provider` server-side. And interrupt-resume turns (OAuth-consent or tool-approval flows, most visibly "connect to Gmail") no longer 500/424: `effective_enabled_tools` is now bound on the resume branch. No infra or migration; ship through `backend.yml`. + +### 🐛 Fixed + +- Agent-bound invocations resolve the model provider correctly: agent (assistant) bindings persist only `model_id`, so an agent bound to a Mantle model resolved to `provider=None` and misrouted to Bedrock ConverseStream, which rejected it with "The provided model identifier is invalid" even though the same model works from normal chat. `_resolve_model_settings` now also returns the model's registered `provider` and the invocation path (plus the app-tool-call / app-context-update rebuild paths) backfills `effective_provider` from it — fixing all existing provider-less bindings with no data backfill. The Agent Designer save payload now also persists the selected model's `provider` so new bindings are self-describing (#661) +- Interrupt-resume turns no longer crash with `NameError: effective_enabled_tools`: on resume (OAuth-gated MCP consent or tool-approval) the `stream_with_quota_warning` closure referenced `effective_enabled_tools` unconditionally, but it was only assigned on the non-resume branch — so the closure raised before its first yield, the inference-api container returned 500, and the Runtime translated it to a 424 for app-api and the SPA. This broke every interrupt-resume turn since the agent-designer tool-binding refactor (most visibly the "connect to Gmail" OAuth-consent resume). The variable is now bound from the paused-turn snapshot on the resume branch (#662) + ## [1.6.0] - 2026-07-15 Conversation-sharing and chat-reliability release. Large conversations can now be shared — their snapshots offload to a new S3 bucket instead of overflowing the 400 KB DynamoDB item limit. **Stop** now actually stops the server-side turn (distributed cancellation over the session lease), and a per-session single-flight lease plus restore-time history repair close a class of bugs where a tab switch or duplicate invocation could permanently brick a conversation. Web sources become removable and editor-manageable, and model RBAC grants written from the model admin page finally take effect. Requires a CDK deploy for the new shared-conversations S3 bucket and IAM grants. diff --git a/README.md b/README.md index 104f936e..06114924 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ **An open-source, production-ready Generative AI platform for institutions** *Built by Boise State University, designed for everyone.* -[![Release](https://img.shields.io/badge/Release-v1.6.0-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) +[![Release](https://img.shields.io/badge/Release-v1.6.1-6366f1?style=flat&logo=github&logoColor=white)](RELEASE_NOTES.md) [![Nightly](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml/badge.svg)](https://github.com/Boise-State-Development/agentcore-public-stack/actions/workflows/nightly.yml) ![Python](https://img.shields.io/badge/Python-3.13+-3776AB?style=flat&logo=python&logoColor=white) @@ -296,7 +296,7 @@ agentcore-public-stack/ See [RELEASE_NOTES.md](RELEASE_NOTES.md) for the full changelog, including new features, bug fixes, platform upgrades, and deployment notes for each release. -**Current release:** v1.6.0 +**Current release:** v1.6.1 --- diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 73a013df..4e05577e 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,3 +1,25 @@ +# Release Notes — v1.6.1 + +**Release Date:** July 16, 2026 +**Previous Release:** v1.6.0 (July 15, 2026) + +--- + +## Highlights + +v1.6.1 is a patch release fixing two agent-invocation regressions. Agents bound to a Mantle-provider model (like `openai.gpt-5.4`) were misrouting to Bedrock and failing with an "invalid model identifier" error, because agent bindings only persist a model id and the invocation path had no provider to key on — it now recovers the provider server-side from the managed-model registry. Separately, every interrupt-resume turn — the OAuth-consent and tool-approval flows, most visibly "connect to Gmail" — was crashing with a 500/424 because a streaming variable was left unbound on the resume path. No infrastructure change and no migration; ships through `backend.yml`. + +## 🐛 Bug fixes + +- **Agents bound to Mantle models no longer fail with "invalid model identifier."** Agent (assistant) model bindings persist only `model_id`, never `provider`, so previewing or invoking an agent bound to a Mantle model (e.g. `openai.gpt-5.4`) resolved to `provider=None` and misrouted the model to Bedrock ConverseStream — which rejected it, even though the same model works from the normal chat path (which always sends `provider` alongside `model_id`). `_resolve_model_settings` in `apis/inference_api/chat/routes.py` now also returns the model's registered `provider` from the managed-model registry, and the invocation path backfills `effective_provider` from it when the request or binding didn't carry one — fixing all existing provider-less bindings with no data backfill, mirroring how `mantle_api_mode` / `mantle_region` are already recovered. The app-tool-call and app-context-update rebuild paths get the same fallback so a rebuilt agent keys on the same provider as its main turn. On the frontend, the Agent Designer save payload now persists the selected model's `provider` (from the catalog `meta.provider`) alongside `modelId`, so newly created/edited bindings are self-describing (#661) +- **Interrupt-resume turns no longer 500/424.** Resume turns (OAuth-gated MCP consent or tool-approval — `interrupt_responses` set) crashed with `NameError: cannot access free variable 'effective_enabled_tools'`. The variable is referenced unconditionally by the `stream_with_quota_warning` streaming closure (attachment guidance + tabular inventory) but was only assigned on the non-resume branch, so on resume the closure raised before its first yield, the inference-api container returned 500, and the AgentCore Runtime data plane translated that into a 424 Failed Dependency to app-api and the SPA. This broke every interrupt-resume turn since the agent-designer tool-binding refactor — most visibly "connect to Gmail for employees," which completes via an OAuth-consent resume. `effective_enabled_tools` is now bound from the paused-turn snapshot on the resume branch (the same source the resume `get_agent` call uses), with a resume-path regression test in `tests/routes/test_inference.py` (#662) + +## 🚀 Deployment notes + +No special steps. Both fixes are backend/frontend code only — no CDK deploy, no new AWS resources, no data migration. Ship through `backend.yml` (app-api + inference-api images) and the frontend deploy; the Agent Designer provider-persistence change rides the standard frontend deploy. + +--- + # Release Notes — v1.6.0 **Release Date:** July 15, 2026 diff --git a/VERSION b/VERSION index dc1e644a..9c6d6293 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.0 +1.6.1 diff --git a/backend/pyproject.toml b/backend/pyproject.toml index df349ac9..b83d9526 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "agentcore-stack" -version = "1.6.0" +version = "1.6.1" requires-python = ">=3.10" description = "Multi-agent conversational AI system with AWS Bedrock AgentCore" readme = "README.md" diff --git a/backend/src/apis/inference_api/chat/routes.py b/backend/src/apis/inference_api/chat/routes.py index e9d1dd85..36c7ccf0 100644 --- a/backend/src/apis/inference_api/chat/routes.py +++ b/backend/src/apis/inference_api/chat/routes.py @@ -336,20 +336,25 @@ async def _resolve_model_settings( model_id: str | None, explicit_caching_enabled: bool | None, request_inference_params: dict | None, -) -> tuple[bool | None, dict, str | None, str | None]: +) -> tuple[bool | None, dict, str | None, str | None, str | None]: """Resolve runtime model knobs from the managed-model registry. Returns ``(caching_enabled, inference_params, mantle_api_mode, - mantle_region)``. A single registry lookup drives all of them. The Mantle - fields are server-authoritative (recorded on the model): ``mantle_api_mode`` - selects Chat Completions vs the Responses API and ``mantle_region`` optionally - pins inference to a specific region; both ``None`` for non-Mantle models. - Resolving them here keeps them off the client request — the SPA can't override. + mantle_region, provider)``. A single registry lookup drives all of them. + The Mantle fields are server-authoritative (recorded on the model): + ``mantle_api_mode`` selects Chat Completions vs the Responses API and + ``mantle_region`` optionally pins inference to a specific region; both + ``None`` for non-Mantle models. ``provider`` is the model's registered + provider (e.g. ``"mantle"``), returned so callers can recover it when the + request/binding didn't carry one — without it a Mantle model like + ``openai.gpt-5.4`` misroutes to Bedrock ConverseStream and fails with an + invalid-model-identifier error. Resolving these here keeps them off the + client request — the SPA can't override. """ request_params = dict(request_inference_params or {}) if not model_id: - return explicit_caching_enabled, request_params, None, None + return explicit_caching_enabled, request_params, None, None, None managed_model = await _find_managed_model(model_id) @@ -370,14 +375,19 @@ async def _resolve_model_settings( if managed_model is not None else None ) + provider = ( + getattr(managed_model, "provider", None) + if managed_model is not None + else None + ) inference_params = _merge_inference_params(managed_model, request_params) - return caching, inference_params, mantle_api_mode, mantle_region + return caching, inference_params, mantle_api_mode, mantle_region, provider async def _resolve_caching_enabled(model_id: str | None, explicit_caching_enabled: bool | None) -> bool | None: """Backward-compat wrapper around :func:`_resolve_model_settings`.""" - caching, _, _, _ = await _resolve_model_settings(model_id, explicit_caching_enabled, None) + caching, _, _, _, _ = await _resolve_model_settings(model_id, explicit_caching_enabled, None) return caching @@ -933,7 +943,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g atc = input_data.app_tool_call try: request_inference_params = dict(input_data.inference_params or {}) - caching_enabled, inference_params, mantle_api_mode, mantle_region = await _resolve_model_settings( + caching_enabled, inference_params, mantle_api_mode, mantle_region, registry_provider = await _resolve_model_settings( model_id=input_data.model_id, explicit_caching_enabled=input_data.caching_enabled, request_inference_params=request_inference_params, @@ -946,7 +956,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g model_id=input_data.model_id, system_prompt=input_data.system_prompt, caching_enabled=caching_enabled, - provider=input_data.provider, + provider=input_data.provider or registry_provider, inference_params=inference_params, mantle_api_mode=mantle_api_mode, mantle_region=mantle_region, @@ -981,7 +991,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g acu = input_data.app_context_update try: request_inference_params = dict(input_data.inference_params or {}) - caching_enabled, inference_params, mantle_api_mode, mantle_region = await _resolve_model_settings( + caching_enabled, inference_params, mantle_api_mode, mantle_region, registry_provider = await _resolve_model_settings( model_id=input_data.model_id, explicit_caching_enabled=input_data.caching_enabled, request_inference_params=request_inference_params, @@ -994,7 +1004,7 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g model_id=input_data.model_id, system_prompt=input_data.system_prompt, caching_enabled=caching_enabled, - provider=input_data.provider, + provider=input_data.provider or registry_provider, inference_params=inference_params, mantle_api_mode=mantle_api_mode, mantle_region=mantle_region, @@ -1652,6 +1662,16 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g else None ), ) + # The `stream_with_quota_warning` closure below references + # `effective_enabled_tools` unconditionally (attachment guidance, + # tabular inventory). It is only assigned in the non-resume branch, + # so a resume turn — e.g. the client re-invoking after granting an + # OAuth-gated MCP tool's consent, or answering a tool-approval + # interrupt — would otherwise raise `NameError: cannot access free + # variable 'effective_enabled_tools'`, surfacing as a 500 from the + # container and a 424 Failed Dependency to the caller. Bind it to the + # snapshot's toolset, the same source the resume `get_agent` used. + effective_enabled_tools = snapshot.enabled_tools else: # Build the canonical request inference-params dict. The frontend # sends ``inference_params`` directly; legacy ``temperature`` / @@ -1700,14 +1720,24 @@ async def invocations(request: InvocationRequest, current_user: User = Depends(g request_inference_params = {**agent_model_override.params, **request_inference_params} # Single registry lookup resolves caching + inference params + - # the Mantle endpoint path, merging admin defaults with request - # overrides. - caching_enabled, inference_params, mantle_api_mode, mantle_region = await _resolve_model_settings( + # the Mantle endpoint path + provider, merging admin defaults with + # request overrides. + caching_enabled, inference_params, mantle_api_mode, mantle_region, registry_provider = await _resolve_model_settings( model_id=effective_model_id, explicit_caching_enabled=input_data.caching_enabled, request_inference_params=request_inference_params, ) + # Recover the provider from the registry when neither the request nor + # the Agent's model binding carried one. Agent bindings persist only + # ``model_id`` (no provider), so without this a Mantle model like + # ``openai.gpt-5.4`` resolves to provider=None → Bedrock and blows up + # in ConverseStream with "invalid model identifier" — even though the + # same model works from the normal chat path, which always sends + # ``provider`` alongside ``model_id``. + if not effective_provider and registry_provider: + effective_provider = registry_provider + if caching_enabled is False: logger.info("Prompt caching disabled for model") diff --git a/backend/tests/routes/test_inference.py b/backend/tests/routes/test_inference.py index baace684..cb5f7505 100644 --- a/backend/tests/routes/test_inference.py +++ b/backend/tests/routes/test_inference.py @@ -430,3 +430,95 @@ def test_preview_session_skips_the_guard(self, authed_app, authed_client): assert resp.status_code == 200 acquire_mock.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Resume path: interrupt_responses set (OAuth-gated MCP consent / tool approval) +# --------------------------------------------------------------------------- + + +class TestInvocationsResume: + """POST /invocations on the resume path must not raise. + + Regression for the scope bug where ``effective_enabled_tools`` was assigned + only in the non-resume branch but referenced unconditionally by the + ``stream_with_quota_warning`` closure (attachment guidance + tabular + inventory). A resume turn — e.g. the client re-invoking after the user + grants an OAuth-gated MCP tool's consent ("connect to Gmail for employees") + or answers a tool-approval interrupt — hit + ``NameError: cannot access free variable 'effective_enabled_tools'`` before + the streaming closure's first yield. That surfaced as a 500 from the + inference-api container and, once the AgentCore Runtime data plane + translated it, a ``424 Failed Dependency`` to app-api and the SPA. + + The fix binds ``effective_enabled_tools`` from the paused-turn snapshot on + the resume branch (the same source the resume ``get_agent`` call uses). + """ + + @staticmethod + def _paused_agent(interrupt_id): + """Mock agent whose ``_interrupt_state`` advertises ``interrupt_id`` as a + known paused interrupt, so the route's resume id-validation accepts the + submitted response instead of rejecting it with a 400.""" + mock_agent = MagicMock() + interrupt_state = MagicMock() + interrupt_state.activated = True + interrupt_state.interrupts = {interrupt_id: MagicMock()} + mock_agent.agent = MagicMock() + mock_agent.agent._interrupt_state = interrupt_state + + async def fake_stream(*args, **kwargs): + yield 'event: message_start\ndata: {"role": "assistant"}\n\n' + yield "event: done\ndata: {}\n\n" + + mock_agent.stream_async = fake_stream + return mock_agent + + @staticmethod + def _snapshot(enabled_tools): + from datetime import datetime, timedelta, timezone + + from apis.shared.sessions.models import PausedTurnSnapshot + + now = datetime.now(timezone.utc) + return PausedTurnSnapshot( + enabled_tools=enabled_tools, + model_id="anthropic.claude-sonnet-4", + provider="bedrock", + system_prompt="You are helpful.", + agent_type="chat", + captured_at=now.isoformat(), + expires_at=(now + timedelta(minutes=10)).isoformat(), + ) + + def test_resume_turn_does_not_raise_nameerror(self, authed_app, authed_client): + """A resume request (interrupt_responses set) streams a 200 instead of + 500-ing on the unbound ``effective_enabled_tools``.""" + interrupt_id = "int-abc" + snapshot = self._snapshot(enabled_tools=["gmail_employee"]) + + with patch( + "apis.inference_api.chat.routes.get_agent", + return_value=self._paused_agent(interrupt_id), + ), patch( + "apis.inference_api.chat.routes.is_quota_enforcement_enabled", + return_value=False, + ), patch( + "apis.shared.sessions.metadata.get_paused_turn", + return_value=snapshot, + ): + resp = authed_client.post( + "/invocations", + json={ + "session_id": "sess-resume-1", + "message": "", + "interrupt_responses": [ + {"interruptId": interrupt_id, "response": {"approved": True}} + ], + }, + ) + body = resp.text # force the streaming generator to run to completion + + assert resp.status_code == 200 + assert "text/event-stream" in resp.headers["content-type"] + assert "event: done" in body diff --git a/backend/tests/routes/test_resolve_model_settings_provider.py b/backend/tests/routes/test_resolve_model_settings_provider.py new file mode 100644 index 00000000..2d7b5fe6 --- /dev/null +++ b/backend/tests/routes/test_resolve_model_settings_provider.py @@ -0,0 +1,80 @@ +"""Regression tests for provider recovery in `_resolve_model_settings`. + +Agent (assistant) model bindings persist only `model_id` — never `provider` +(see `AgentModelConfig`). When such an agent is previewed/invoked, the request +also carries no provider, so without server-side recovery a Mantle model like +`openai.gpt-5.4` resolves to provider=None → Bedrock and fails in ConverseStream +with "The provided model identifier is invalid" — even though the same model +works from the normal chat path (which always sends `provider` with `model_id`). + +`_resolve_model_settings` therefore returns the model's registered provider so +the caller can backfill it. These tests pin that contract. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest + +from apis.inference_api.chat import routes + + +def _managed(**kwargs): + """Minimal managed-model stand-in for the resolver's attribute reads.""" + defaults = dict( + model_id="openai.gpt-5.4", + provider="mantle", + supports_caching=False, + mantle_api_mode="responses", + mantle_region=None, + supported_params=None, + ) + defaults.update(kwargs) + return SimpleNamespace(**defaults) + + +@pytest.mark.asyncio +async def test_returns_provider_for_mantle_model(): + with patch.object( + routes, "_find_managed_model", AsyncMock(return_value=_managed()) + ): + ( + caching, + _params, + mantle_api_mode, + mantle_region, + provider, + ) = await routes._resolve_model_settings( + model_id="openai.gpt-5.4", + explicit_caching_enabled=None, + request_inference_params=None, + ) + + assert provider == "mantle" + assert mantle_api_mode == "responses" + assert mantle_region is None + assert caching is False + + +@pytest.mark.asyncio +async def test_provider_none_when_model_unknown(): + with patch.object(routes, "_find_managed_model", AsyncMock(return_value=None)): + _, _, _, _, provider = await routes._resolve_model_settings( + model_id="openai.gpt-5.4", + explicit_caching_enabled=None, + request_inference_params=None, + ) + assert provider is None + + +@pytest.mark.asyncio +async def test_provider_none_when_no_model_id(): + # No registry lookup happens without a model id; provider stays None. + with patch.object(routes, "_find_managed_model", AsyncMock()) as find: + _, _, _, _, provider = await routes._resolve_model_settings( + model_id=None, + explicit_caching_enabled=None, + request_inference_params=None, + ) + assert provider is None + find.assert_not_awaited() diff --git a/backend/uv.lock b/backend/uv.lock index d06f3f5f..08813d1e 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -12,7 +12,7 @@ resolution-markers = [ [[package]] name = "agentcore-stack" -version = "1.6.0" +version = "1.6.1" source = { editable = "." } dependencies = [ { name = "aiofiles" }, diff --git a/frontend/ai.client/package-lock.json b/frontend/ai.client/package-lock.json index 5d2980d4..c5261d69 100644 --- a/frontend/ai.client/package-lock.json +++ b/frontend/ai.client/package-lock.json @@ -1,12 +1,12 @@ { "name": "ai.client", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ai.client", - "version": "1.6.0", + "version": "1.6.1", "dependencies": { "@angular/cdk": "21.2.14", "@angular/common": "21.2.17", diff --git a/frontend/ai.client/package.json b/frontend/ai.client/package.json index 4f83a631..b5385938 100644 --- a/frontend/ai.client/package.json +++ b/frontend/ai.client/package.json @@ -1,6 +1,6 @@ { "name": "ai.client", - "version": "1.6.0", + "version": "1.6.1", "scripts": { "ng": "ng", "start": "ng serve", diff --git a/frontend/ai.client/src/app/agents/agent-form/agent-form.page.ts b/frontend/ai.client/src/app/agents/agent-form/agent-form.page.ts index e4d27700..73dea67d 100644 --- a/frontend/ai.client/src/app/agents/agent-form/agent-form.page.ts +++ b/frontend/ai.client/src/app/agents/agent-form/agent-form.page.ts @@ -509,6 +509,12 @@ export class AgentFormPage implements OnInit, OnDestroy { const v = this.form.value; const params = this.modelParams(); + // Persist the model's provider alongside its id. The runtime resolver needs + // provider to route (e.g. Mantle models like `openai.gpt-5.4` go through the + // Responses API, not Bedrock ConverseStream); without it the binding resolves + // to provider=None and a Mantle model fails with an invalid-model-identifier + // error. Mirrors what the normal chat path sends with every request. + const provider = this.selectedModel()?.meta?.['provider'] as string | undefined; const payload = { name: v.name, description: v.description, @@ -520,6 +526,7 @@ export class AgentFormPage implements OnInit, OnDestroy { // Omit `params` when empty so the agent falls back to today's exact resolution. modelConfig: { modelId: this.selectedModelId()!, + ...(provider ? { provider } : {}), ...(Object.keys(params).length ? { params } : {}), }, bindings: this.buildBindings(), diff --git a/infrastructure/package-lock.json b/infrastructure/package-lock.json index 629ee674..a19993b8 100644 --- a/infrastructure/package-lock.json +++ b/infrastructure/package-lock.json @@ -1,12 +1,12 @@ { "name": "infrastructure", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "infrastructure", - "version": "1.6.0", + "version": "1.6.1", "dependencies": { "aws-cdk-lib": "2.260.0", "constructs": "10.6.0" diff --git a/infrastructure/package.json b/infrastructure/package.json index d930087a..ca449e9c 100644 --- a/infrastructure/package.json +++ b/infrastructure/package.json @@ -1,6 +1,6 @@ { "name": "infrastructure", - "version": "1.6.0", + "version": "1.6.1", "bin": { "infrastructure": "bin/infrastructure.js" },