From bcfb244242758b7f6dc15db7b6b8dfec77467edd Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 15:13:31 -0400 Subject: [PATCH 1/2] Run orchestrated Ask an agent steps on Cosmos agent records Every orchestrated Ask an agent step was refused just before the agent ran, with "This step's agent or action isn't available to you right now". The external-source provider required the agent returned by resolve_delegation_agent to be exactly a dict, and azure-cosmos 4.9.0 point reads return CosmosDict, a dict subclass that deepcopy keeps. - resolve_delegation_agent returns the stored agent as a plain dict, for every caller. - The provider accepts any dict from its agent resolver, copies it and still requires the exact scoped reference to match the catalog selection. - Each provider refusal is logged once as "[ORCHESTRATION_EXTERNAL_SOURCES] A step's source was refused." with sc_stage, sc_authority_reason and sc_reason (the check that failed), using hashed identifiers only. - The shared test fake returns point reads as CosmosDictLike, so checks that require exactly dict on stored records fail in tests as they did in production. - Version 0.261.291. Adds the fix doc and updates the admin troubleshooting table, the external source access feature doc and the logging tags reference. Fixes #1699 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- application/single_app/config.py | 2 +- .../single_app/functions_agent_delegation.py | 3 +- ...unctions_orchestration_external_sources.py | 163 +++++-- docs/admin/orchestration.md | 13 +- .../ORCHESTRATION_EXTERNAL_SOURCE_ACCESS.md | 23 + .../ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md | 239 +++++++++ ..._SELECTED_AGENT_DISABLED_PREFERENCE_FIX.md | 10 + docs/reference/logging-tags.md | 41 +- ...t_orchestration_agent_document_type_fix.py | 455 ++++++++++++++++++ .../test_support/agent_delegation.py | 18 +- 10 files changed, 908 insertions(+), 59 deletions(-) create mode 100644 docs/explanation/fixes/ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md create mode 100644 functional_tests/test_orchestration_agent_document_type_fix.py diff --git a/application/single_app/config.py b/application/single_app/config.py index efe40558c..3317a1190 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -101,7 +101,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.261.289" +VERSION = "0.261.291" IS_DEVELOPMENT = is_development_env_enabled() # Opt-out for deployments where App Service Easy Auth is active but the platform diff --git a/application/single_app/functions_agent_delegation.py b/application/single_app/functions_agent_delegation.py index 505eda655..99fc88cd9 100644 --- a/application/single_app/functions_agent_delegation.py +++ b/application/single_app/functions_agent_delegation.py @@ -226,7 +226,8 @@ def _read_agent(reference): def _canonical_agent(record, scope_type, scope_id): - result = deepcopy(record) + # A Cosmos point read returns CosmosDict, a dict subclass that deepcopy keeps. + result = deepcopy(dict(record)) if scope_type == "personal" and result.get("user_id") != scope_id: raise PermissionError(UNAVAILABLE_MESSAGE) if scope_type == "group" and result.get("group_id") != scope_id: diff --git a/application/single_app/functions_orchestration_external_sources.py b/application/single_app/functions_orchestration_external_sources.py index 935fcc87d..2c3e733c0 100644 --- a/application/single_app/functions_orchestration_external_sources.py +++ b/application/single_app/functions_orchestration_external_sources.py @@ -1,9 +1,10 @@ # functions_orchestration_external_sources.py """Server admission and current access for retained external content. -Version: 0.261.289 +Version: 0.261.291 Session-trusted agent and action sources in: 0.261.270 Hand-selected agent honoured while the agent preference is off in: 0.261.289 +Stored agents accepted as any dictionary, and refusals logged by check, in: 0.261.291 (#1699) No fetch, recall, plugin invocation, settings discovery, or credential persistence occurs here. Content digests attest the exact retained payload, not a remote page @@ -17,15 +18,22 @@ planning, execution and classic chat already treat it, so it is honoured while the user's general agent preference is off. Only that agent is: the catalog is narrowed to the run's selection, and current scope, membership and governance are still rechecked. + +A resolved agent is accepted as any dictionary, because a Cosmos point read returns a +dict subclass; its exact scoped reference is what has to match. Each refusal is logged +once, naming the check that failed, with hashed identifiers only. """ +from contextlib import contextmanager from dataclasses import dataclass import hashlib +import logging import re from functions_action_catalog import resolve_action_manifest from functions_action_manifest import get_action_origin from functions_agent_delegation import agent_reference, resolve_delegation_agent +from functions_appinsights import log_event, workflow_log_context from functions_orchestration_context import ( CatalogResolutionError, build_capability_request_context, @@ -81,6 +89,48 @@ if source_type in SESSION_TRUSTED_SOURCE_TYPES ) _SESSION_TRUSTED_VERSION = "orchestration-session-trusted-source-v1" +_STAGE_PREFLIGHT = "external_source_preflight" +_STAGE_ADMISSION = "external_source_admission" +_STAGE_READ = "external_source_read" + + +def _refusal(code, check): + """A refusal that also names the check that failed, for diagnostics only. + + The check is never shown to the user, and the invocation capture keeps only ``code``. + """ + error = ResultUnavailableError(code) + error.refusal_check = check + return error + + +@contextmanager +def _logged_refusals(producer, stage): + """Log a refusal once, with its check and hashed identifiers only, then re-raise it.""" + try: + yield + except ResultUnavailableError as error: + correlation = {} + if type(producer) is ProducerIdentity: + correlation = { + **workflow_log_context( + conversation_id=producer.conversation_id, run_id=producer.run_id, + step_id=producer.step_id, + ), + "capability_id": producer.capability_id, + } + check = getattr(error, "refusal_check", None) + log_event( + "[ORCHESTRATION_EXTERNAL_SOURCES] A step's source was refused.", + # Saved results are rechecked whenever they're opened, so a read refusal can follow + # any later access change. + level=logging.INFO if stage == _STAGE_READ else logging.WARNING, + extra={ + **correlation, "stage": stage, "authority_reason": error.code, + **({"reason": check} if type(check) is str else {}), + }, + ) + raise @dataclass(frozen=True) @@ -269,14 +319,14 @@ def _catalog(self, source_type, identity, settings, *, seeded_agent=None): except CatalogResolutionError as exc: if exc.code != "selected_agent_unavailable": raise - raise ResultUnavailableError("result_external_source_unavailable") from exc + raise _refusal("result_external_source_unavailable", "selected_agent_unavailable") from exc if ( type(catalog) is not list or len(catalog) > MAX_CATALOG_ITEMS or any(type(item) is not dict for item in catalog) ): raise ResultUnavailableError("result_external_catalog_invalid") if seeded_agent is not None and len(catalog) > 1: - raise ResultUnavailableError("result_external_source_unavailable") + raise _refusal("result_external_source_unavailable", "selected_agent_ambiguous") return catalog def _capability( @@ -284,7 +334,7 @@ def _capability( seeded_agent=None, invocation_user_urls=None, ): if any(settings.get(key) is not True for key in _REQUIRED_SETTINGS[source_type]): - raise ResultUnavailableError("result_external_capability_unavailable") + raise _refusal("result_external_capability_unavailable", "required_setting_off") if source_type == "fact_memory": return if source_type != _GATHER_SOURCES.get(producer.capability_id): @@ -293,7 +343,7 @@ def _capability( if allowed is not None and ( type(allowed) is not list or any(type(item) is not str for item in allowed) ): - raise ResultUnavailableError("result_external_capability_unavailable") + raise _refusal("result_external_capability_unavailable", "capability_allowlist_invalid") context = build_capability_request_context( identity.user_id, { @@ -315,13 +365,13 @@ def _capability( candidate_ids=(producer.capability_id,), include_runtime_bindings=False, ) if producer.capability_id not in available: - raise ResultUnavailableError("result_external_capability_unavailable") + raise _refusal("result_external_capability_unavailable", "capability_not_available") if source_type == "url": # Source-review parsing/client dependencies are only needed for URL authorization. from functions_source_review import is_url_access_enabled_for_user if not is_url_access_enabled_for_user(settings, user_roles=identity.roles): - raise ResultUnavailableError("result_external_capability_unavailable") + raise _refusal("result_external_capability_unavailable", "url_access_not_permitted") def _configuration(self, source_type, producer, settings, source, *, selector=None, for_admission=False): if self.read_configuration is None: @@ -353,8 +403,12 @@ def _resolve_integration(self, source_type, selected, identity, settings): raise ResultUnavailableError("result_external_resolver_required") expected = agent_reference(selected, identity.user_id) resolved = self.agent_resolver(expected, user_id=identity.user_id, settings=settings) - if type(resolved) is not dict or agent_reference(resolved, identity.user_id) != expected: - raise ResultUnavailableError("result_external_source_unavailable") + # A stored agent can arrive as the SDK's dict subclass; only its reference proves identity. + if not isinstance(resolved, dict): + raise _refusal("result_external_source_unavailable", "integration_not_mapping") + resolved = dict(resolved) + if agent_reference(resolved, identity.user_id) != expected: + raise _refusal("result_external_source_unavailable", "integration_reference_mismatch") else: if self.action_resolver is None: raise ResultUnavailableError("result_external_resolver_required") @@ -362,17 +416,21 @@ def _resolve_integration(self, source_type, selected, identity, settings): identity.user_id, selected["action_ref"], settings=settings, ) origin = get_action_origin(resolved) - if not isinstance(resolved, dict) or origin is None or any( + if not isinstance(resolved, dict): + raise _refusal("result_external_source_unavailable", "integration_not_mapping") + if origin is None or any( resolved.get(key) != selected.get(key) for key in ("action_ref", "id", "scope_type", "scope_id") ) or ( origin.action_id, origin.scope_type, origin.scope_id ) != (selected.get("id"), selected.get("scope_type"), selected.get("scope_id")): - raise ResultUnavailableError("result_external_source_unavailable") + raise _refusal("result_external_source_unavailable", "integration_origin_mismatch") except ResultUnavailableError: raise - except (PermissionError, LookupError) as exc: - raise ResultUnavailableError("result_external_source_unavailable") from exc + except PermissionError as exc: + raise _refusal("result_external_source_unavailable", "integration_access_denied") from exc + except LookupError as exc: + raise _refusal("result_external_source_unavailable", "integration_not_found") from exc return resolved @staticmethod @@ -439,7 +497,8 @@ def preflight_gather_invocation(self, *, producer, selector=None): user-URL approval, transport safety, and execution/budget guards. Success is not a URL grant, configuration attestation, or retained-result alias. """ - self._gather_invocation_state(producer, selector) + with _logged_refusals(producer, _STAGE_PREFLIGHT): + self._gather_invocation_state(producer, selector) def preflight_gather_acquisition(self, source_type, *, producer, settings, source=None, selector=None): """Check current authority/support and actual configuration before effects. @@ -453,13 +512,14 @@ def preflight_gather_acquisition(self, source_type, *, producer, settings, sourc or _GATHER_SOURCES.get(producer.capability_id) != source_type ): raise ResultContractError("result_external_producer_invalid") - if self.acquisition_validator is None: - raise ResultUnavailableError("result_external_acquisition_validator_required") - _, current_settings, current_source = self._gather_invocation_state(producer, selector) - validated = self.acquisition_validator( - source_type, producer=producer, settings=settings, source=source, selector=selector, - current_settings=current_settings, current_source=current_source, - ) + with _logged_refusals(producer, _STAGE_PREFLIGHT): + if self.acquisition_validator is None: + raise ResultUnavailableError("result_external_acquisition_validator_required") + _, current_settings, current_source = self._gather_invocation_state(producer, selector) + validated = self.acquisition_validator( + source_type, producer=producer, settings=settings, source=source, selector=selector, + current_settings=current_settings, current_source=current_source, + ) if validated is not None: raise ResultContractError("result_external_acquisition_validation_invalid") @@ -475,7 +535,8 @@ def preflight_session_acquisition(self, source_type, *, producer, selector=None) or _GATHER_SOURCES.get(producer.capability_id) != source_type ): raise ResultContractError("result_external_producer_invalid") - self._gather_invocation_state(producer, selector) + with _logged_refusals(producer, _STAGE_PREFLIGHT): + self._gather_invocation_state(producer, selector) def _gather_invocation_state(self, producer, selector): if type(producer) is not ProducerIdentity or producer.capability_id not in _GATHER_SOURCES: @@ -502,7 +563,10 @@ def _gather_invocation_state(self, producer, selector): if type(entry.get(key)) is str and entry[key] and entry[key] == selector ] if len(selected) != 1: - raise ResultUnavailableError("result_external_source_unavailable") + raise _refusal( + "result_external_source_unavailable", + "selection_ambiguous" if selected else "selection_not_in_catalog", + ) self._check_integration_selection(source_type, producer, run, selected[0], selector) resolved = self._resolve_integration(source_type, selected[0], identity, settings) elif selector is not None: @@ -549,7 +613,11 @@ def _reference( ) ] if len(matches) != 1: - raise ResultUnavailableError("result_external_source_unavailable") + if previous is None: + check = "selection_ambiguous" if matches else "selection_not_in_catalog" + else: + check = "reference_ambiguous" if matches else "reference_not_in_catalog" + raise _refusal("result_external_source_unavailable", check) selected = matches[0] selector = selected[key] if previous is None: @@ -603,34 +671,37 @@ def admit_gather_result(self, *, producer, prepared, selector=None): or prepared.get("capability_id") != producer.capability_id ): raise ResultContractError("result_external_content_invalid") - reference = self._reference( - producer=producer, source_type=_GATHER_SOURCES[producer.capability_id], - content_sha256=_content_digest(prepared), - state=self._current(producer, for_write=True), selector=selector, - ) + with _logged_refusals(producer, _STAGE_ADMISSION): + reference = self._reference( + producer=producer, source_type=_GATHER_SOURCES[producer.capability_id], + content_sha256=_content_digest(prepared), + state=self._current(producer, for_write=True), selector=selector, + ) return {"external_" + canonical_digest(reference.to_dict())[:48]: reference} def admit_memory_result(self, *, producer, prepared): """Attest explicit retained memory use; never search or refresh saved facts.""" - reference = self._reference( - producer=producer, source_type="fact_memory", - content_sha256=_content_digest(prepared), state=self._current(producer, for_write=True), - ) + with _logged_refusals(producer, _STAGE_ADMISSION): + reference = self._reference( + producer=producer, source_type="fact_memory", + content_sha256=_content_digest(prepared), state=self._current(producer, for_write=True), + ) return {"external_" + canonical_digest(reference.to_dict())[:48]: reference} def authorize(self, reference, *, producer, user_id, conversation_id): """Return current exact identity from a trusted committed binding, not a catalog.""" - if ( - type(reference) is not ExternalSourceRef or type(producer) is not ProducerIdentity - or user_id != self.access.user_id or conversation_id != self.access.conversation_id - or reference.capability_id != producer.capability_id or reference.content_sha256 is None - ): - raise ResultUnavailableError("result_external_source_unavailable") - current = self._reference( - producer=producer, source_type=reference.source_type, - content_sha256=reference.content_sha256, - state=self._current(producer, for_write=False), previous=reference, - ) - if current.identity() != reference.identity(): - raise ResultUnavailableError("result_external_source_unavailable") + with _logged_refusals(producer, _STAGE_READ): + if ( + type(reference) is not ExternalSourceRef or type(producer) is not ProducerIdentity + or user_id != self.access.user_id or conversation_id != self.access.conversation_id + or reference.capability_id != producer.capability_id or reference.content_sha256 is None + ): + raise _refusal("result_external_source_unavailable", "reference_binding_mismatch") + current = self._reference( + producer=producer, source_type=reference.source_type, + content_sha256=reference.content_sha256, + state=self._current(producer, for_write=False), previous=reference, + ) + if current.identity() != reference.identity(): + raise _refusal("result_external_source_unavailable", "reference_identity_changed") return current diff --git a/docs/admin/orchestration.md b/docs/admin/orchestration.md index 60409ae0d..744c48cba 100644 --- a/docs/admin/orchestration.md +++ b/docs/admin/orchestration.md @@ -384,6 +384,11 @@ setting is the agents button on the classic chat page (`/chats`). V2 has no cont it, and it's saved as off for a user whose settings were saved before they turned agents on, so many V2 users have it off. It still applies to agents the user didn't choose. +Before **0.261.291**, every Ask an agent step was refused just before the agent ran, with +"This step's agent or action isn't available to you right now". The step's access check +accepted only a plain dictionary, and Cosmos DB returns a stored agent as an SDK subclass +of one. Chat without Orchestrate was unaffected. + The opt-in adds no second action allowlist or approval system. Existing scope, ownership, group membership, enablement and governance rules still determine which actions are available, and access is checked again when work runs. An action removed or revoked @@ -693,6 +698,11 @@ run ID to correlate its hashed identifier in The HTTP status alone is insufficient: a planning stream can return HTTP 200 and then report a rejected proposal. +Since **0.261.291**, when a web search, linked-page, deep research, agent, action or +memory step is refused, `[ORCHESTRATION_EXTERNAL_SOURCES] A step's source was refused.` +names the check that failed in `sc_reason`. It shares `sc_run_id_hash` and +`sc_step_id_hash` with the step's failure event. + | Symptom | Likely cause | Fix | | --- | --- | --- | | No orchestration control appears in chat | The setting is off, or the user is in the classic interface. | Confirm Enable Chat Orchestration is on, and that the user is on a V2 chat page. | @@ -716,7 +726,8 @@ then report a rejected proposal. | A Microsoft 365 step says plans can only read Microsoft 365 data | The action enables only send, invitation or mark-as-read functions, which plans never run. | Enable a read function on the action, or use it from chat without a plan. | | Before 0.261.270, a Microsoft 365 step said plans can't use Microsoft 365 in a shared conversation | Earlier versions refused Microsoft 365 steps in shared conversations. | Upgrade to 0.261.270 or later. The user's own request now counts as consent to share what the step reads. See [Microsoft 365 actions in plans](#microsoft-365-actions-in-plans). | | Before 0.261.270, every action or agent step failed with "A required retained result is unavailable or changed", while the same request worked with Orchestrate off | The step's configuration check refused it. For Microsoft 365 actions, the check couldn't see the action's Microsoft 365 selection. Local agents were refused outright. Failure events log `sc_authority_reason=external_configuration_unavailable` for `action_invoke` or `agent_invoke`. | Upgrade to 0.261.270 or later. Agent and action steps now trust the signed-in session, as manual chat does. See [Retained external-source authorization](#retained-external-source-authorization). | -| Before 0.261.289, an Ask an agent step for an agent the user picked, or tagged with @, failed with "This step's agent or action isn't available to you right now, so it didn't run" | The user's own agents setting was off. Planning offered the picked agent, but the step's access check didn't count the pick as permission. Failure events log `sc_authority_reason=result_external_capability_unavailable` for `agent_invoke`. It isn't specific to shared conversations: a request without a picked agent can still work because its plan uses an action instead. | Upgrade to 0.261.289 or later. Before upgrading, the user can turn agents on with the agents button on the classic chat page (`/chats`). See the [selected agent with agents turned off fix]({{ '/explanation/fixes/ORCHESTRATION_SELECTED_AGENT_DISABLED_PREFERENCE_FIX/' | relative_url }}). | +| Before 0.261.289, an Ask an agent step for an agent the user picked, or tagged with @, failed with "This step's agent or action isn't available to you right now, so it didn't run" | The user's own agents setting was off. Planning offered the picked agent, but the step's access check didn't count the pick as permission. Failure events log `sc_authority_reason=result_external_capability_unavailable` for `agent_invoke`. It isn't specific to shared conversations: a request without a picked agent can still work because its plan uses an action instead. | Upgrade to 0.261.291 or later. 0.261.289 fixed this check, but the step still failed at a later one until 0.261.291, so turning agents on doesn't help before then; see the next row. See the [selected agent with agents turned off fix]({{ '/explanation/fixes/ORCHESTRATION_SELECTED_AGENT_DISABLED_PREFERENCE_FIX/' | relative_url }}). | +| Before 0.261.291, every Ask an agent step failed with "This step's agent or action isn't available to you right now", while the same agent answered with Orchestrate off | Cosmos DB returns a stored agent as an SDK subclass of a dictionary, and the step's access check accepted only a plain dictionary, so it refused every agent. Failure events log `sc_authority_reason=result_external_source_unavailable` for `agent_invoke`. | Upgrade to 0.261.291 or later. No setting or data change is needed. Before upgrading, turn Orchestrate off to use the agent. See the [agent document type fix]({{ '/explanation/fixes/ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX/' | relative_url }}). | | Before 0.261.270, an @mention of a person in a shared conversation was sent to the model when Orchestrate was on | Orchestrate planned every message without applying the shared conversation's send rule. | Upgrade to 0.261.270 or later. See [Shared conversations](#shared-conversations). | | A participant sees "Only the person who started this shared conversation can use Orchestrate here" | A request reached the planner from someone other than the person who started the shared conversation. The V2 composer normally answers their requests the classic way instead. | Turn off Orchestrate for that request, or ask the person who started the conversation to ask it. | | The person who started a shared conversation sees "Orchestrate can't be used in this shared conversation because an earlier version kept another participant's private copy of it" | Before 0.261.270, Orchestrate saved a private copy for whoever used it first, under the shared conversation's ID. Only one record can have that ID. | Turn off Orchestrate to ask the assistant. To restore Orchestrate, remove that participant's record from the conversations container: its ID is the shared conversation's ID. The `[ORCHESTRATION]` warning logs `sc_reason=shared_conversation_stale_copy`. | diff --git a/docs/explanation/features/ORCHESTRATION_EXTERNAL_SOURCE_ACCESS.md b/docs/explanation/features/ORCHESTRATION_EXTERNAL_SOURCE_ACCESS.md index 08a934a7e..12cc4ef24 100644 --- a/docs/explanation/features/ORCHESTRATION_EXTERNAL_SOURCE_ACCESS.md +++ b/docs/explanation/features/ORCHESTRATION_EXTERNAL_SOURCE_ACCESS.md @@ -16,6 +16,13 @@ agent failed while the preference was off, even though planning and execution accepted it. See the [selected agent with agents turned off fix](../fixes/ORCHESTRATION_SELECTED_AGENT_DISABLED_PREFERENCE_FIX.md). +**Updated in version: 0.261.291.** The provider accepts the stored agent as any +dictionary, because a Cosmos DB point read returns a dictionary subclass, and still +requires its exact scoped reference to match. Before this, every Ask an agent step was +refused just before it ran. Each refusal is now logged with the check that failed. +See the [agent document type fix](../fixes/ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md) +([#1699](https://github.com/microsoft/simplechat/issues/1699)). + ## Purpose and scope An external Gather result is the content an authorized adapter actually returned, @@ -65,6 +72,10 @@ Membership in a catalog alone is insufficient: the provider also calls `resolve_delegation_agent` or `resolve_action_manifest` for the exact stored integration. These existing APIs recheck scope membership, governance, enablement, and identity without invoking a model or plugin. +`resolve_delegation_agent` returns the stored agent as a plain dictionary. The +provider accepts any dictionary from an agent resolver, copies it into a plain one, +and requires its scoped reference (ID, scope type and scope ID) to equal the catalog +selection's. The mapping's own type is never treated as proof of identity. An injected action resolver must retain the real `ScopedActionManifest` origin; matching fields in a plain dictionary are not proof of a scoped authorized read. @@ -348,6 +359,18 @@ Every other refusal stays `result_unavailable`. Exception text is never read, and the reason code is never shown to the user. Step, saved-wait, finalization and composition failure events log it as `sc_authority_reason`. +Since 0.261.291, the provider also logs each refusal once, as +`[ORCHESTRATION_EXTERNAL_SOURCES] A step's source was refused.`, before the +invocation capture normalizes it. The event carries `sc_stage` +(`external_source_preflight`, `external_source_admission` or +`external_source_read`), `sc_authority_reason`, `sc_capability_id`, the hashed +conversation, run and step identifiers, and `sc_reason`, the check that failed, +such as `integration_not_found` or `capability_not_available`. Reads log at +Information level, because saved results are rechecked whenever they're opened. +Only the refusal code crosses the invocation capture; the check stays in this +event. See the [orchestration failure diagnostics](../../reference/logging-tags.md#orchestration-failure-diagnostics) +for the full list of checks. + ### Invocation failure classification `ExternalIdentityServiceError` and `ExternalConfigurationServiceError` inherit diff --git a/docs/explanation/fixes/ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md b/docs/explanation/fixes/ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md new file mode 100644 index 000000000..a1ea19815 --- /dev/null +++ b/docs/explanation/fixes/ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md @@ -0,0 +1,239 @@ +# Orchestration Agent Document Type Fix + +**Version: 0.261.291** + +Fixed in version: **0.261.291**, recorded in +`application/single_app/config.py`. Fixes +[#1699](https://github.com/microsoft/simplechat/issues/1699). + +This affects the React V2 branch (`paullizer-react-v2-ui`) and deployments built +from it. + +## Issue + +In V2 chat with **Orchestrate** on, every **Ask an agent** (`agent_invoke`) step +failed before the agent ran: + +> This step's agent or action isn't available to you right now, so it didn't run. +> Check that it still exists, is enabled and is shared with you, then retry. + +The answering step then reported "A required dependency did not complete." It +happened in personal and shared conversations, for the agent the user picked and +for agents the planner chose. The same agent answered when Orchestrate was off. + +It persisted after 0.261.289, the +[selected agent with agents turned off fix](ORCHESTRATION_SELECTED_AGENT_DISABLED_PREFERENCE_FIX.md). +Before that update, the reported deployment logged these failures with +`sc_authority_reason=result_external_capability_unavailable`. After it, the step +got past that check and failed at a later one: + +- `[ORCHESTRATION_EXECUTOR] A dependency-bound step could not complete.` +- `sc_capability_id=agent_invoke` +- `sc_error_type=OrchestrationInvocationDeniedError` +- `sc_failure_code=integration_unavailable` +- `sc_authority_reason=result_external_source_unavailable` + +## Root cause + +Before an agent step runs, when its result is saved, and whenever the result is +read again, `OrchestrationExternalSourceProvider` rechecks that the user can still +reach that exact agent. It calls `resolve_delegation_agent`, which rechecks the +agent's scope settings, owner or group membership, governance and enabled state, +and returns the stored agent. The provider then required that agent to be exactly +a `dict`: + +```python +if type(resolved) is not dict or agent_reference(resolved, identity.user_id) != expected: + raise ResultUnavailableError("result_external_source_unavailable") +``` + +`resolve_delegation_agent` returned `deepcopy()` of a Cosmos DB point read. The +pinned `azure-cosmos==4.9.0` returns point reads as `CosmosDict`, a `dict` subclass +that carries response headers, and `deepcopy` keeps the subclass. So the check +refused every agent the resolver had just authorized. Action steps weren't +affected, because the action branch of the same method already used +`isinstance`. + +The check has been in the provider since 0.261.127, when the Gather, Reason and +Render harness was added. It's the same class of bug as the +[Cosmos response compatibility fix](ORCHESTRATION_COSMOS_RESPONSE_COMPATIBILITY_FIX.md) +(0.261.140) and the [settings document type fix](ORCHESTRATION_SETTINGS_DOCUMENT_TYPE_FIX.md) +(0.261.237). + +Chat without Orchestrate was unaffected because it loads the selected agent +through the Semantic Kernel loader, which never runs this check. + +### How it was found + +The provider logged one reason, `result_external_source_unavailable`, from nine +different places. The Azure SDK request logs in Application Insights placed it. +In both failed runs, the shared one and the personal one, the executor: + +1. Read the user's settings, the conversation and the run. +2. Queried the agent catalogs. Narrowing to the picked agent succeeded: no + `[ORCHESTRATION_CONTEXT] The selected agent is no longer available.` warning. +3. Made one point read of the picked agent's document, which returned 200. +4. Logged the refusal about 50 to 70 ms later. + +The point read happens only after the resolver's scope and governance checks pass, +and a 200 rules out a missing agent. That left the checks after the read. The +existing provider tests, with only the fake container's point reads changed to +the real azure-cosmos 4.9.0 `CosmosDict`, reproduced the refusal after exactly one +agent point read. + +### Why the tests missed it + +The shared test fake, `functional_tests/test_support/agent_delegation.py`, +returned point reads as plain `dict` copies, so no test ever gave the provider the +SDK's type. The 0.261.289 deployment check confirmed the agent record's contents, +not the type the SDK returns. + +## Changes + +### The stored agent is a plain dictionary + +In `functions_agent_delegation.py`, `_canonical_agent()` copies the point read into +a plain `dict` before it returns it. Every caller of `resolve_delegation_agent` +benefits, including chat delegation and prompt variables. The document's own +fields, including `_etag`, are unchanged. Only the SDK's separate response-header +object is dropped, and no caller uses it. + +### The provider accepts any dictionary and still matches the exact agent + +In `functions_orchestration_external_sources.py`, `_resolve_integration()` accepts +any `dict` from the agent resolver and copies it into a plain `dict`. It then +compares the agent's exact scoped reference (ID, scope type and scope ID) with +the catalog selection, as before. The provider never trusts the mapping's own +type or claims: the copy is what it checks and what it returns. + +The 0.261.237 fix kept the provider's settings check exact and fixed the settings +store instead. That check is unchanged. Agents are handled at both layers so that +another reader of stored agents can't reintroduce this failure. + +### Each refusal names the check that failed + +The provider now logs each refusal once: +`[ORCHESTRATION_EXTERNAL_SOURCES] A step's source was refused.` It's a Warning +before and during a step, and Information when a saved result is read again, +because saved results are rechecked whenever they're opened. Its properties are: + +- `sc_stage`: `external_source_preflight`, `external_source_admission` or + `external_source_read`. +- `sc_authority_reason`: the refusal code the executor also logs. +- `sc_reason`: the check that failed, when the code covers more than one. +- `sc_capability_id`, plus `sc_conversation_id_hash`, `sc_run_id_hash` and + `sc_step_id_hash`, which match the executor's failure event for the same step. + +No user, agent, action, group or selector identifiers are logged. + +| `sc_reason` | What failed | +| --- | --- | +| `selected_agent_unavailable` | The agent picked for the run no longer resolves in the user's catalog. | +| `selected_agent_ambiguous` | The catalog narrowed to the picked agent has more than one agent. | +| `selection_not_in_catalog`, `selection_ambiguous` | The step's agent or action isn't in the user's current catalog, or matches more than one entry. | +| `reference_not_in_catalog`, `reference_ambiguous` | A saved result's agent or action isn't in the user's current catalog, or matches more than one entry. | +| `integration_not_mapping` | The resolver didn't return a dictionary. | +| `integration_reference_mismatch` | The resolved agent isn't the exact agent selected. | +| `integration_origin_mismatch` | The resolved action isn't the exact action selected. | +| `integration_access_denied` | The resolver refused access: the scope is turned off, the user isn't the owner or a group member, or governance denies it. | +| `integration_not_found` | The agent or action is missing or disabled. | +| `reference_binding_mismatch` | A saved reference doesn't belong to this user, conversation or step. | +| `reference_identity_changed` | A saved reference no longer matches the current agent or action. | +| `required_setting_off` | A deployment setting the source needs is off. | +| `capability_allowlist_invalid` | The admin capability allowlist is malformed. | +| `capability_not_available` | The capability isn't available to this request, for example because the user's agents setting is off and no agent was picked. | +| `url_access_not_permitted` | The user's roles don't allow linked pages. | + +The executor's failure event and the user's message are unchanged. + +### The test fake returns the SDK's type + +`DelegationServices` point reads now return `CosmosDictLike`, a `dict` subclass +that stands in for `CosmosDict`. Queries still return plain dictionaries, as the SDK +does. Any code that requires exactly `dict` on a stored record now fails in tests +as it did in production. + +### Files modified + +| File | Change | +| --- | --- | +| `application/single_app/functions_agent_delegation.py` | `_canonical_agent()` returns a plain `dict`. | +| `application/single_app/functions_orchestration_external_sources.py` | Accepts any `dict` from the agent resolver, names each refusal's check, and logs refusals. | +| `application/single_app/config.py` | Version `0.261.291`. | +| `functional_tests/test_support/agent_delegation.py` | Point reads return `CosmosDictLike`. | +| `functional_tests/test_orchestration_agent_document_type_fix.py` | New regression tests. | +| `docs/admin/orchestration.md` | Troubleshooting row. | +| `docs/explanation/features/ORCHESTRATION_EXTERNAL_SOURCE_ACCESS.md` | Resolver contract and refusal logging. | +| `docs/reference/logging-tags.md` | New tag and event. | + +No setting, deployment or data change is needed. + +## Validation + +### Tests + +`functional_tests/test_orchestration_agent_document_type_fix.py` runs the real +provider, `resolve_agent_catalog` and `resolve_delegation_agent` over the test fake: + +- The fake's point reads behave like the real azure-cosmos 4.9.0 `CosmosDict`, and + its queries return plain dictionaries. +- `resolve_delegation_agent` returns a plain `dict`, with `_etag` kept, for + personal, group and global agents. +- The reported case: a personal `m365` agent picked in the composer, with the + agents setting on or off, passes the checks before and during the step, is saved, + and stays readable. Picked and unpicked group agents do too. +- The provider accepts a dictionary subclass from its resolver, and refuses a + non-dictionary or a different agent. +- Disabled agents, a group the user left, governance denial, group agents turned + off, another user's agent and an agent missing from the catalog are still + refused, each with its own `sc_reason`. +- Each refusal logs once, with the right stage and level, correlation hashes that + match the executor's, and no identifiers. The properties survive the logging + allowlist. Successful steps log no refusal. +- Action steps are unchanged. + +On the previous code, 27 of its 31 tests fail. All 31 pass with the fix. + +With the fake returning the SDK's type and before the fix, 35 tests and 9 subtests +failed in the existing suites, all of them Ask an agent steps refused at the same +check. With the fix, those suites match their plain-dictionary results exactly, +both with the updated fake and with the real azure-cosmos 4.9.0 `CosmosDict`: 968 +tests and 232 subtests pass. The suites are `test_agent_delegation_*`, +`test_workspace_authoring_backend.py`, `test_app_settings_store_plain_dict_reads.py` +and the ten external-source orchestration suites. The 98 tests that fail do so with +or without this change: 79 in `test_workspace_authoring_backend.py`, and 19 +`NameError`s in `test_agent_delegation_action.py` and +`test_agent_delegation_workflow_actor.py`. + +These suites also pass with the change: `test_orchestration_agent_selection.py`, +`test_orchestration_v2_plan_backend.py`, `test_orchestration_external_identity.py`, +`test_orchestration_external_bootstrap.py` and `test_orchestration_external_pre_effect.py` +(202 tests and 180 subtests). + +### Before and after + +| Before | After | +| --- | --- | +| Every orchestrated Ask an agent step failed with "This step's agent or action isn't available to you right now". | The step runs the agent, as chat without Orchestrate does. | +| One refusal code came from nine places, and finding which one failed took request-level SDK logs. | A refusal event names the failed check. | +| Tests returned plain dictionaries for stored records, unlike the SDK. | Tests return the SDK's dictionary subclass. | +| Agents the user can't reach were refused. | Unchanged. | + +## Limitations + +- An agent that loads Microsoft 365 actions next goes through the existing + Microsoft 365 checks, which can still stop the step to ask the user to sign in, + approve, or use the agent from chat. Those have their own messages. +- Other orchestration code that reads Cosmos DB point reads wasn't audited for + checks that require exactly `dict`. +- The executor's failure event doesn't carry `sc_reason`. Join it to the refusal + event on `sc_run_id_hash` and `sc_step_id_hash`; see + [orchestration failure diagnostics](../../reference/logging-tags.md#orchestration-failure-diagnostics). + +## Related + +- [Selected agent with agents turned off fix](ORCHESTRATION_SELECTED_AGENT_DISABLED_PREFERENCE_FIX.md) +- [Orchestration settings document type fix](ORCHESTRATION_SETTINGS_DOCUMENT_TYPE_FIX.md) +- [Orchestration Cosmos response compatibility fix](ORCHESTRATION_COSMOS_RESPONSE_COMPATIBILITY_FIX.md) +- [Retained orchestration external source access](../features/ORCHESTRATION_EXTERNAL_SOURCE_ACCESS.md) +- [Orchestration troubleshooting](../../admin/orchestration.md#troubleshooting) diff --git a/docs/explanation/fixes/ORCHESTRATION_SELECTED_AGENT_DISABLED_PREFERENCE_FIX.md b/docs/explanation/fixes/ORCHESTRATION_SELECTED_AGENT_DISABLED_PREFERENCE_FIX.md index 069368a97..d6730d88c 100644 --- a/docs/explanation/fixes/ORCHESTRATION_SELECTED_AGENT_DISABLED_PREFERENCE_FIX.md +++ b/docs/explanation/fixes/ORCHESTRATION_SELECTED_AGENT_DISABLED_PREFERENCE_FIX.md @@ -5,6 +5,15 @@ Fixed in version: **0.261.289**, recorded in `application/single_app/config.py`. +> **Follow-up in 0.261.291:** after this fix, the same Ask an agent steps got past +> this check and failed at a later one with +> `sc_authority_reason=result_external_source_unavailable`. The provider required +> the stored agent to be exactly a `dict`, and Cosmos DB returns a dictionary +> subclass. The deployment check below confirmed the agent record's contents, not +> the type the SDK returns. Before 0.261.291, turning agents on doesn't avoid the +> failure; only a plan that uses an action instead of an agent works. See the +> [agent document type fix](ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md). + ## Issue A user picked their personal `m365` agent in the V2 composer, turned on Orchestrate and @@ -144,6 +153,7 @@ plan uses the action directly, as it did in the user's own conversation. ## Related +- [Agent document type fix](ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md), the follow-up in 0.261.291 - [Retained orchestration external source access](../features/ORCHESTRATION_EXTERNAL_SOURCE_ACCESS.md) - [Orchestration session-trusted action and agent steps fix](ORCHESTRATION_SESSION_TRUSTED_ACTION_AGENT_STEPS_FIX.md) - [Actions and agents](../../admin/orchestration.md#actions-and-agents) diff --git a/docs/reference/logging-tags.md b/docs/reference/logging-tags.md index 4a4743506..d1806ef6b 100644 --- a/docs/reference/logging-tags.md +++ b/docs/reference/logging-tags.md @@ -183,6 +183,7 @@ Last inventoried: 2026-08-10 - `[ORCHESTRATION_ADAPTERS]` - `[ORCHESTRATION_CONTEXT]` - `[ORCHESTRATION_EXECUTOR]` +- `[ORCHESTRATION_EXTERNAL_SOURCES]` - `[ORCHESTRATION_M365]` - `[ORCHESTRATION_PLANNER]` - `[ORCHESTRATION_REGISTRY]` @@ -307,21 +308,24 @@ events were added in version **0.261.141**. The `[ORCHESTRATION_CONTEXT]`, `[ORCHESTRATION_PLANNER]`, `[ORCHESTRATION_RUNS]`, `[ORCHESTRATION]`, `[ORCHESTRATION_EXECUTOR]`, and `[ORCHESTRATION_ADAPTERS]` failure events distinguish planning validation, execution admission, step execution, saved-status projection, and -scheduler recovery. `[CONTENT_SCREENING]` events record why a source's current authority +scheduler recovery. Since **0.261.291**, `[ORCHESTRATION_EXTERNAL_SOURCES]` events name +the check that refused a step's web, linked-page, deep research, agent, action or +memory source. `[CONTENT_SCREENING]` events record why a source's current authority could not be verified. Their safe properties appear in Application Insights `customDimensions` or Log Analytics `AppTraces.Properties`. | Property | Meaning | | --- | --- | | `sc_conversation_id_hash`, `sc_turn_id_hash`, `sc_run_id_hash`, `sc_step_id_hash` | SHA-256 of the exact workflow identifier encoded as UTF-8. Only applicable identifiers are present; these hashes do not grant access to the underlying records. Step IDs repeat across runs, so narrow by the run hash before the step hash. | -| `sc_stage` | The failing boundary, such as `plan_normalization`, `claim_validation`, `settings`, `context`, `identity`, `result_binding`, `model_binding`, `run_detail`, or `scheduler_item`. | +| `sc_stage` | The failing boundary, such as `plan_normalization`, `claim_validation`, `settings`, `context`, `identity`, `result_binding`, `model_binding`, `run_detail`, or `scheduler_item`. On `[ORCHESTRATION_EXTERNAL_SOURCES]` refusal events, `external_source_preflight` (before or during the step's acquisition), `external_source_admission` (saving the step's result), or `external_source_read` (reading a saved result again). | | `sc_validation_code` | Validation category, including `deliverables_invalid`, `source_kind_invalid`, or `source_binding_required`. | | `sc_validation_rule` | Specific application-owned rejection, such as `invalid_quantity`, `non_file_format`, `answer_producer_mismatch`, `missing_final_response`, `file_format_mismatch`, `narrative_source_required`, or `document_sources_required`. | | `sc_response_failure` | A missing, incomplete, or refused planner completion, when applicable. | | `sc_attempt` | Planner proposal number: 1 for the initial proposal and 2 for its single correction. Present on deliverables correction/failure events. | | `sc_execution_code`, `sc_output_code` | Safe execution or output-store failure category, where available. | | `sc_failure_code` | On step and adapter events, a step failure category, such as `step_timeout`, `run_timeout`, or `context_unavailable`. On `[CONTENT_SCREENING]` events, the source-authority code, such as `source_authority_unverified`, `source_authority_unavailable`, or `screening_revision_conflict`. | -| `sc_authority_reason` | Which check failed for a source. With `source_authority_unverified`, the check that could not verify the source's current authority: `document_record_invalid`, `screening_marker_invalid`, `screening_state_unknown`, `screening_marker_incomplete`, `document_scope_invalid`, `workspace_record_invalid`, `scan_record_invalid`, `provenance_mismatch`, `document_id_invalid`, `manifest_context_invalid`, `manifest_scope_missing`, `manifest_revision_invalid`, or `manifest_batch_invalid`. With `screening_revision_conflict`, what saved evidence no longer matches in the current document: `source_revision_changed` (the document was re-versioned), `screening_generation_changed` (it was re-screened or remediated), `source_scope_changed` (its id or workspace differs), `provenance_shape_invalid` (the saved provenance has a different set of fields), `screened_record_changed` (a saved copy of the screened record differs), `source_version_changed` (a legacy reference without provenance), or `cached_evidence_unproven` (cached evidence from before screening enrollment). On `[ORCHESTRATION_EXECUTOR]` step, saved-wait, finalization and content-preparation failure events (since 0.261.209), the stable code of a refused external source, such as `external_identity_session_unavailable` (the step ran in a background continuation with no signed-in session), `external_identity_role_required`, `external_identity_access_restricted`, `external_identity_conversation_unavailable`, `external_identity_settings_unavailable`, or `external_configuration_changed`. | +| `sc_authority_reason` | Which check failed for a source. With `source_authority_unverified`, the check that could not verify the source's current authority: `document_record_invalid`, `screening_marker_invalid`, `screening_state_unknown`, `screening_marker_incomplete`, `document_scope_invalid`, `workspace_record_invalid`, `scan_record_invalid`, `provenance_mismatch`, `document_id_invalid`, `manifest_context_invalid`, `manifest_scope_missing`, `manifest_revision_invalid`, or `manifest_batch_invalid`. With `screening_revision_conflict`, what saved evidence no longer matches in the current document: `source_revision_changed` (the document was re-versioned), `screening_generation_changed` (it was re-screened or remediated), `source_scope_changed` (its id or workspace differs), `provenance_shape_invalid` (the saved provenance has a different set of fields), `screened_record_changed` (a saved copy of the screened record differs), `source_version_changed` (a legacy reference without provenance), or `cached_evidence_unproven` (cached evidence from before screening enrollment). On `[ORCHESTRATION_EXECUTOR]` step, saved-wait, finalization and content-preparation failure events (since 0.261.209), the stable code of a refused external source, such as `external_identity_session_unavailable` (the step ran in a background continuation with no signed-in session), `external_identity_role_required`, `external_identity_access_restricted`, `external_identity_conversation_unavailable`, `external_identity_settings_unavailable`, or `external_configuration_changed`. On `[ORCHESTRATION_EXTERNAL_SOURCES]` refusal events (since 0.261.291), the same refusal code, such as `result_external_source_unavailable` or `result_external_capability_unavailable`, with the check that failed in `sc_reason`. | +| `sc_reason` | On `[ORCHESTRATION_EXTERNAL_SOURCES]` refusal events (since 0.261.291), the check that failed, when the refusal code covers more than one: `selected_agent_unavailable` or `selected_agent_ambiguous` (the agent picked for the run), `selection_not_in_catalog` or `selection_ambiguous` (the step's agent or action in the user's current catalog), `reference_not_in_catalog` or `reference_ambiguous` (a saved result's agent or action), `integration_not_mapping`, `integration_reference_mismatch` or `integration_origin_mismatch` (the resolved agent or action isn't the one selected), `integration_access_denied` (scope off, not the owner or a group member, or governance), `integration_not_found` (missing or disabled), `reference_binding_mismatch` or `reference_identity_changed` (a saved reference), `required_setting_off`, `capability_allowlist_invalid`, `capability_not_available`, or `url_access_not_permitted`. Other events use `sc_reason` for their own codes. | | `sc_capability_id` | The capability the step ran, such as `compose`, `render_file`, `generate_image`, or `document_compare`. | | `sc_output_format` | The file format of a render attempt, such as `csv`, `xlsx`, or `docx`. | | `sc_error_type`, `sc_response_type` | Exception class and, on runner admission/preparation failures, the record's Python type. | @@ -386,18 +390,41 @@ file names, file content, prompts, or model responses. | `[ORCHESTRATION_EXECUTOR] A step finished after its time budget; its finished result was kept.` | Warning | `sc_failure_code` (`step_timeout` or `run_timeout`), `sc_elapsed_ms`, `sc_step_timeout_seconds`. | | `[ORCHESTRATION_EXECUTOR] Prepared content did not match its declared outputs; asking once more.` | Information | `sc_reason` (`compose_output_retry`), `sc_error_type`, and `sc_execution_code` when the mismatch has one. | | `[ORCHESTRATION_EXECUTOR] Content preparation could not complete.` | Warning | `sc_failure_code`, `sc_execution_code`, `sc_error_type`, and `sc_authority_reason` when a retained source was refused. | +| `[ORCHESTRATION_EXTERNAL_SOURCES] A step's source was refused.` | Warning; Information at `external_source_read` | Since **0.261.291**. `sc_stage`, `sc_authority_reason`, `sc_reason` (the check that failed), `sc_capability_id`, and the run, conversation and step hashes of the step that produced the source. Logged once per refusal, ahead of the step's failure event when there is one. Reads are Information because saved results are rechecked whenever they're opened. | | `[ORCHESTRATION] Retrying the answer model without a JSON response format.` | Information | `sc_reason` (`json_format_retry`), `sc_stage`, `sc_error_type`. Content preparation asks the endpoint for a JSON object; an endpoint that refuses that option is asked again without it. | | `[ORCHESTRATION_ADAPTERS] Native result bridge did not complete.` | Warning | `sc_stage`, `sc_failure_code`, `sc_execution_code`, such as `native_compute_source_identity_mismatch` when a saved spreadsheet location now points at a different revision. | | `[CONTENT_SCREENING] Current source authority could not be verified.` | Warning | `sc_failure_code`, `sc_authority_reason`, `sc_error_type`. | | `[ORCHESTRATION_M365] A Microsoft 365 step stopped.` | Warning | Since **0.261.238**. `sc_failure_code` (`m365_sign_in_required`, `m365_approval_required`, `m365_unavailable`, `m365_shared_conversation`, `m365_read_only` or `external_session_required`), `sc_authority_reason` (the Microsoft 365 refusal code, such as `interactive_auth_required`, `authentication_required`, `m365_action_not_authorized` or `m365_shared_conversation_unsupported`), `sc_capability_id`, and `sc_source_count`. | | `[MS_GRAPH_PLUGIN] Microsoft 365 operation could not complete.` | Warning | `sc_operation`. Since **0.261.238**, also `sc_failure_code`, the refusal code (for example `m365_context_required`), and `sc_resource`, the source (`email`, `calendar`, `onedrive` or `spo`). Earlier versions logged only the code's length, `sc_error_code_length`. | -Every `[ORCHESTRATION_EXECUTOR]` and `[ORCHESTRATION_ADAPTERS]` event in this table -carries `sc_run_id_hash`, `sc_conversation_id_hash`, and `sc_step_id_hash`; the -executor events also carry `sc_capability_id`. The `[ORCHESTRATION]` and +Every `[ORCHESTRATION_EXECUTOR]`, `[ORCHESTRATION_ADAPTERS]` and +`[ORCHESTRATION_EXTERNAL_SOURCES]` event in this table carries `sc_run_id_hash`, +`sc_conversation_id_hash`, and `sc_step_id_hash`; the executor and external-source +events also carry `sc_capability_id`. The `[ORCHESTRATION]` and `[CONTENT_SCREENING]` events in this table have no workflow hashes, so match them by time to the step event that follows them. +To find which check refused a step, read the step's failure event together with its +refusal event: + +```kusto +let runHash = hash_sha256(""); +AppTraces +| where TimeGenerated > ago(2h) +| where tostring(Properties.sc_run_id_hash) == runHash +| extend message = tostring(Properties.sc_message) +| where message contains "A dependency-bound step could not complete" + or message contains "A step's source was refused" +| project TimeGenerated, message, + stepHash = tostring(Properties.sc_step_id_hash), + capability = tostring(Properties.sc_capability_id), + stage = tostring(Properties.sc_stage), + authorityReason = tostring(Properties.sc_authority_reason), + check = tostring(Properties.sc_reason), + failureCode = tostring(Properties.sc_failure_code) +| order by TimeGenerated asc +``` + A render attempt records how its time was spent: - `sc_render_ms`, `sc_verify_ms`, `sc_stage_ms`, and `sc_commit_ms` are the time @@ -464,7 +491,7 @@ findings, caveats, notes, prompts, file names, or model responses. | Event message | Severity | Properties | | --- | --- | --- | | `[DOCUMENT_ANALYSIS] Final findings validated` | Information when the result is `valid`; otherwise Warning | `sc_validation_code`, the record, window and evidence counts described below, and one `sc_issue__count` per validation issue code. | -| `[ORCHESTRATION_EXECUTOR] A dependency-bound step could not complete.` | Warning | `sc_failure_code`, `sc_execution_code`, `sc_error_type`, and, when the step refused a partial input, the `sc_producer_*` fields described below. | +| `[ORCHESTRATION_EXECUTOR] A dependency-bound step could not complete.` | Warning | `sc_failure_code`, `sc_execution_code`, `sc_error_type`, `sc_authority_reason` when an access check refused the step (its `[ORCHESTRATION_EXTERNAL_SOURCES]` refusal event names the check), and, when the step refused a partial input, the `sc_producer_*` fields described below. | Analyze writes its event each time it validates newly assembled final findings. A step that resumes from an already saved final result does not write it again. The diff --git a/functional_tests/test_orchestration_agent_document_type_fix.py b/functional_tests/test_orchestration_agent_document_type_fix.py new file mode 100644 index 000000000..a58d2a79d --- /dev/null +++ b/functional_tests/test_orchestration_agent_document_type_fix.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +# test_orchestration_agent_document_type_fix.py +""" +Functional test for Ask an agent steps refused because of the stored agent's type. +Version: 0.261.291 +Implemented in: 0.261.291 + +The pinned azure-cosmos 4.9.0 returns a stored agent from a point read as CosmosDict, a +dict subclass that deepcopy keeps. The external-source provider required the agent that +resolve_delegation_agent returned to be exactly a dict, so it refused every orchestrated +Ask an agent step before the agent ran, with result_external_source_unavailable +(microsoft/simplechat#1699). This test ensures the resolver returns a plain dict, the +provider accepts any dict while still matching the exact scoped reference, agents the +user can't reach are still refused, and each refusal logs the check that failed without +logging identifiers. +""" + +from copy import deepcopy +import logging +import os +import sys +import types +from unittest.mock import Mock, patch + +import pytest + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +from test_orchestration_external_sources import ExternalSourceWorld # noqa: E402 +from functions_appinsights import _build_logger_extra, workflow_log_context # noqa: E402 +from functions_orchestration_context import CatalogResolutionError, resolve_agent_catalog # noqa: E402 +from functions_orchestration_results import ResultUnavailableError # noqa: E402 +from test_support.agent_delegation import ( # noqa: E402 + CosmosDictLike, + DelegationServices, + delegation_environment, + reference, +) +from test_support.orchestration_research import _definitions # noqa: E402 +from test_support.versioning import assert_app_version_at_least # noqa: E402 + + +REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +FIX_DOC = os.path.join( + REPO_ROOT, "docs", "explanation", "fixes", "ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md", +) +ADMIN_DOC = os.path.join(REPO_ROOT, "docs", "admin", "orchestration.md") +LOGGING_DOC = os.path.join(REPO_ROOT, "docs", "reference", "logging-tags.md") + +LOG_EVENT = "functions_orchestration_external_sources.log_event" +REFUSAL_MESSAGE = "[ORCHESTRATION_EXTERNAL_SOURCES] A step's source was refused." +SOURCE_UNAVAILABLE = "result_external_source_unavailable" +CAPABILITY_UNAVAILABLE = "result_external_capability_unavailable" +# Raw identifiers from the fixture. None of them may reach a refusal event. +RAW_IDENTIFIERS = ( + "owner", "conversation-1", "external-run", "agent-1", "owner_m365", "m365", "Lookup", + "group-a", "intruder", +) + +PERSONAL_SELECTION = { + "name": "m365", "id": "owner_m365", "is_global": False, "is_group": False, "group_id": None, +} +GROUP_SELECTION = {"name": "Lookup", "id": "agent-1", "is_group": True, "group_id": "group-a"} + + +def production_catalog(world): + """Run the real resolve_agent_catalog over the fixture's agents.""" + module = types.ModuleType("functions_agent_catalog") + module.build_accessible_agent_catalog = Mock( + side_effect=lambda user_id, *, settings, user_groups: world.catalog(user_id, settings=settings), + ) + module.build_agent_catalog_key = _definitions( + "functions_agent_catalog.py", names={"build_agent_catalog_key"}, + )["build_agent_catalog_key"] + world.stack.enter_context(patch.dict(sys.modules, {"functions_agent_catalog": module})) + + +def production_provider(world, **overrides): + return world.provider(agent_catalog_reader=resolve_agent_catalog, **overrides) + + +def use_personal_m365(world): + """The reported agent: the user's personal m365 agent, picked in the composer.""" + world.services.add_agent("owner_m365", scope="personal", scope_id="owner", name="m365") + world.run["plan"]["steps"][0]["arguments"] = {"agent_name": "m365"} + world.agent_selector = "personal:owner:owner_m365" + world.run["seeds"] = {"agent": deepcopy(PERSONAL_SELECTION)} + + +def spy_point_reads(world, kind, scope): + """Record what the fixture's Cosmos point reads return to the code under test.""" + container = world.services.containers[kind, scope] + original = container.read_item.side_effect + returned = [] + + def read_item(**kwargs): + record = original(**kwargs) + returned.append(record) + return record + + container.read_item.side_effect = read_item + return returned + + +def run_step(world, current, source_type): + world.preflight(current) + current.preflight_session_acquisition( + source_type, producer=world.fixture.producer, selector=world.selected_integration(), + ) + + +def save(world, current, source_type): + catalog = world.admit(current) + assert len(catalog) == 1 + assert next(iter(catalog.values())).source_type == source_type + return world.persist(world.service(current, catalog), catalog).output("prepared") + + +def read(world, reader, output): + return world.service(reader).open_result(output, require_current_sources=True).read_value() + + +def refusal_events(log_event): + return [call for call in log_event.call_args_list if call.args and call.args[0] == REFUSAL_MESSAGE] + + +def only_refusal(log_event): + events = refusal_events(log_event) + assert len(events) == 1, events + assert not any(identifier in repr(events[0]) for identifier in RAW_IDENTIFIERS), events[0] + return events[0] + + +def expected_correlation(): + return workflow_log_context(conversation_id="conversation-1", run_id="external-run", step_id="gather") + + +def test_version_includes_agent_document_type_fix(): + assert_app_version_at_least("0.261.291") + + +def test_fake_point_reads_return_a_dict_subclass_like_the_sdk(): + services = DelegationServices() + services.add_agent("agent-1", scope="group", scope_id="group-a") + container = services.containers["agents", "group"] + + record = container.read_item(item="agent-1", partition_key="group-a") + assert isinstance(record, dict) + assert type(record) is CosmosDictLike + assert type(deepcopy(record)) is CosmosDictLike + + rows = container.query_items( + query="SELECT * FROM c WHERE c.group_id = @scope_id", + parameters=[{"name": "@scope_id", "value": "group-a"}], partition_key="group-a", + ) + assert rows + assert all(type(row) is dict for row in rows) + + +def test_the_pinned_sdk_point_read_type_behaves_like_the_fake(): + responses = pytest.importorskip("azure.cosmos._cosmos_responses") + utils = pytest.importorskip("azure.core.utils") + sdk_record = responses.CosmosDict({"id": "agent-1"}, response_headers=utils.CaseInsensitiveDict()) + for record in (sdk_record, CosmosDictLike({"id": "agent-1"})): + assert isinstance(record, dict) + assert type(record) is not dict + assert type(deepcopy(record)) is type(record) + assert type(dict(record)) is dict + assert dict(record) == {"id": "agent-1"} + + +@pytest.mark.parametrize("scope_type, scope_id", ( + ("personal", "actor"), ("group", "group-a"), ("global", "global"), +)) +def test_the_resolver_returns_a_plain_dict_for_every_scope(scope_type, scope_id): + with delegation_environment() as (helper, services): + services.add_agent("target-id", scope=scope_type, scope_id=scope_id) + resolved = helper.resolve_delegation_agent( + reference("target-id", scope_type, scope_id), user_id="actor", settings=services.settings, + ) + assert type(resolved) is dict + assert (resolved["id"], resolved["scope_type"], resolved["scope_id"]) == ("target-id", scope_type, scope_id) + # The document's own fields survive, including the ETag that conditional writes need. + assert resolved["_etag"] == '"stored-etag"' + + +@pytest.mark.parametrize("preference", (False, True)) +def test_the_reported_personal_agent_runs_and_stays_readable(preference): + """The incident: a personal m365 agent picked in the composer, read back from Cosmos.""" + with ExternalSourceWorld("agent_invoke") as world: + production_catalog(world) + use_personal_m365(world) + world.user_enable_agents = preference + reads = spy_point_reads(world, "agents", "personal") + + current = production_provider(world) + run_step(world, current, "agent") + output = save(world, current, "agent") + value = read(world, production_provider(world), output) + assert value == world.prepared + assert reads + assert all(type(record) is CosmosDictLike for record in reads) + + +@pytest.mark.parametrize("picked", (False, True)) +def test_a_group_agent_runs_and_stays_readable(picked): + with ExternalSourceWorld("agent_invoke") as world: + production_catalog(world) + if picked: + world.run["seeds"] = {"agent": deepcopy(GROUP_SELECTION)} + reads = spy_point_reads(world, "agents", "group") + + current = production_provider(world) + run_step(world, current, "agent") + output = save(world, current, "agent") + value = read(world, production_provider(world), output) + assert value == world.prepared + assert reads + assert all(type(record) is CosmosDictLike for record in reads) + + +def test_the_provider_accepts_a_dict_subclass_from_its_resolver(): + """Defence in depth: the provider doesn't depend on the resolver unwrapping the SDK type.""" + def resolver(agent, *, user_id, settings): + return CosmosDictLike({**agent, "name": "Lookup", "agent_type": "local", "is_enabled": True}) + + with ExternalSourceWorld("agent_invoke") as world: + current = world.provider(agent_resolver=resolver) + run_step(world, current, "agent") + output = save(world, current, "agent") + value = read(world, world.provider(agent_resolver=resolver), output) + assert value == world.prepared + + +@pytest.mark.parametrize("returned, check", ( + (["agent-1"], "integration_not_mapping"), + ("group:group-a:agent-1", "integration_not_mapping"), + (None, "integration_not_mapping"), + (CosmosDictLike({"id": "agent-2", "scope_type": "group", "scope_id": "group-a"}), "integration_reference_mismatch"), + ({"id": "agent-1", "scope_type": "group", "scope_id": "group-b"}, "integration_reference_mismatch"), +)) +def test_a_resolver_result_that_is_not_the_exact_agent_is_refused(returned, check): + with ExternalSourceWorld("agent_invoke") as world: + current = world.provider(agent_resolver=lambda agent, *, user_id, settings: deepcopy(returned)) + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError) as raised: + world.preflight(current) + assert raised.value.code == SOURCE_UNAVAILABLE + reason = only_refusal(log_event).kwargs["extra"]["reason"] + assert reason == check + + +def _revoke(world, revoke): + if revoke == "disabled": + world.services.records["agents", "group"]["group-a", "agent-1"]["is_enabled"] = False + elif revoke == "membership": + world.services.roles.clear() + elif revoke == "governance": + world.services.denied_features.add("governance_group_agents") + elif revoke == "scope": + world.settings["allow_group_agents"] = False + elif revoke == "owner": + world.services.add_agent("owner_m365", scope="personal", scope_id="owner", name="m365", user_id="intruder") + world.run["plan"]["steps"][0]["arguments"] = {"agent_name": "m365"} + world.agent_selector = "personal:owner:owner_m365" + elif revoke == "missing": + world.agent_selector = "group:group-a:agent-missing" + + +@pytest.mark.parametrize("revoke, check", ( + ("disabled", "integration_not_found"), + ("membership", "integration_access_denied"), + ("governance", "integration_access_denied"), + ("scope", "integration_access_denied"), + ("owner", "integration_access_denied"), + ("missing", "selection_not_in_catalog"), +)) +def test_agents_the_user_cannot_reach_are_still_refused_with_their_check(revoke, check): + with ExternalSourceWorld("agent_invoke") as world: + _revoke(world, revoke) + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError) as raised: + world.preflight(world.provider()) + assert raised.value.code == SOURCE_UNAVAILABLE + event = only_refusal(log_event) + assert event.kwargs["level"] == logging.WARNING + assert event.kwargs["extra"] == { + **expected_correlation(), "capability_id": "agent_invoke", + "stage": "external_source_preflight", "authority_reason": SOURCE_UNAVAILABLE, "reason": check, + } + + +def test_an_admission_refusal_logs_its_stage(): + with ExternalSourceWorld("agent_invoke") as world: + current = world.provider() + run_step(world, current, "agent") + _revoke(world, "disabled") + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError): + world.admit(current) + event = only_refusal(log_event) + assert event.kwargs["level"] == logging.WARNING + assert event.kwargs["extra"]["stage"] == "external_source_admission" + assert event.kwargs["extra"]["reason"] == "integration_not_found" + + +def test_a_read_refusal_logs_its_stage_at_information_level(): + with ExternalSourceWorld("agent_invoke") as world: + current = world.provider() + run_step(world, current, "agent") + output = save(world, current, "agent") + _revoke(world, "disabled") + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError): + read(world, world.provider(), output) + event = only_refusal(log_event) + assert event.kwargs["level"] == logging.INFO + assert event.kwargs["extra"] == { + **expected_correlation(), "capability_id": "agent_invoke", + "stage": "external_source_read", "authority_reason": SOURCE_UNAVAILABLE, + "reason": "integration_not_found", + } + + +def test_read_references_name_their_check(): + with ExternalSourceWorld("agent_invoke") as world: + _provider, catalog, output = world.save() + saved = next(iter(catalog.values())) + + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError): + world.provider().authorize( + saved, producer=world.fixture.producer, user_id="intruder", conversation_id="conversation-1", + ) + reason = only_refusal(log_event).kwargs["extra"]["reason"] + assert reason == "reference_binding_mismatch" + + # The catalog still has an agent, so the capability is offered, but not this one. + world.catalog_override = [{ + "id": "agent-2", "name": "Other", "scope_type": "group", "scope_id": "group-a", + "is_global": False, "is_group": True, "group_id": "group-a", + "catalog_key": "group:group-a:agent-2", + }] + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError): + read(world, world.provider(), output) + reason = only_refusal(log_event).kwargs["extra"]["reason"] + assert reason == "reference_not_in_catalog" + + +def test_capability_refusals_name_their_check(): + with ExternalSourceWorld("agent_invoke") as world: + world.settings["enable_semantic_kernel"] = False + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError) as raised: + world.preflight(world.provider()) + assert raised.value.code == CAPABILITY_UNAVAILABLE + reason = only_refusal(log_event).kwargs["extra"]["reason"] + assert reason == "required_setting_off" + + with ExternalSourceWorld("agent_invoke") as world: + world.settings["chat_orchestration_enabled_capabilities"] = "agent_invoke" + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError): + world.preflight(world.provider()) + reason = only_refusal(log_event).kwargs["extra"]["reason"] + assert reason == "capability_allowlist_invalid" + + with ExternalSourceWorld("agent_invoke") as world: + world.user_enable_agents = False + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError): + world.preflight(world.provider()) + reason = only_refusal(log_event).kwargs["extra"]["reason"] + assert reason == "capability_not_available" + + with ExternalSourceWorld("url_fetch") as world: + _provider, _catalog, output = world.save() + world.roles = ("User",) + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError): + read(world, world.provider(), output) + event = only_refusal(log_event) + assert event.kwargs["extra"]["capability_id"] == "url_fetch" + assert event.kwargs["extra"]["reason"] == "url_access_not_permitted" + + +def test_picked_agent_refusals_name_their_check(): + with ExternalSourceWorld("agent_invoke") as world: + production_catalog(world) + world.run["seeds"] = {"agent": {**GROUP_SELECTION, "id": "agent-removed"}} + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError) as raised: + world.preflight(production_provider(world)) + assert isinstance(raised.value.__cause__, CatalogResolutionError) + reason = only_refusal(log_event).kwargs["extra"]["reason"] + assert reason == "selected_agent_unavailable" + + with ExternalSourceWorld("agent_invoke") as world: + world.services.add_agent("agent-2", scope="group", scope_id="group-a", name="Other") + world.run["seeds"] = {"agent": deepcopy(GROUP_SELECTION)} + reader = Mock(side_effect=lambda user_id, *, seeds, settings: world.catalog(user_id, settings=settings)) + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError): + world.preflight(world.provider(agent_catalog_reader=reader)) + reason = only_refusal(log_event).kwargs["extra"]["reason"] + assert reason == "selected_agent_ambiguous" + + +def test_refusal_properties_survive_the_log_allowlist(): + with ExternalSourceWorld("agent_invoke") as world: + _revoke(world, "disabled") + with patch(LOG_EVENT) as log_event, pytest.raises(ResultUnavailableError): + world.preflight(world.provider()) + event = only_refusal(log_event) + properties = _build_logger_extra(event.args[0], event.kwargs["extra"]) + correlation = expected_correlation() + assert properties["sc_message"] == REFUSAL_MESSAGE + assert properties["sc_stage"] == "external_source_preflight" + assert properties["sc_reason"] == "integration_not_found" + assert properties["sc_authority_reason"] == SOURCE_UNAVAILABLE + assert properties["sc_capability_id"] == "agent_invoke" + assert properties["sc_run_id_hash"] == correlation["run_id_hash"] + assert properties["sc_step_id_hash"] == correlation["step_id_hash"] + assert properties["sc_conversation_id_hash"] == correlation["conversation_id_hash"] + + +def test_successful_steps_log_no_refusal(): + with ExternalSourceWorld("agent_invoke") as world, patch(LOG_EVENT) as log_event: + current = world.provider() + run_step(world, current, "agent") + output = save(world, current, "agent") + value = read(world, world.provider(), output) + assert value == world.prepared + assert refusal_events(log_event) == [] + + +def test_action_steps_are_unchanged_with_sdk_typed_reads(): + with ExternalSourceWorld("action_invoke") as world: + reads = spy_point_reads(world, "actions", "group") + current = world.provider() + run_step(world, current, "action") + output = save(world, current, "action") + value = read(world, world.provider(), output) + assert value == world.prepared + assert reads + assert all(type(record) is CosmosDictLike for record in reads) + + +def test_fix_admin_and_logging_docs_describe_the_fix(): + with open(FIX_DOC, encoding="utf-8") as handle: + fix_doc = handle.read() + with open(ADMIN_DOC, encoding="utf-8") as handle: + admin_doc = handle.read() + with open(LOGGING_DOC, encoding="utf-8") as handle: + logging_doc = handle.read() + assert "Fixed in version: **0.261.291**" in fix_doc + assert "#1699" in fix_doc + assert "result_external_source_unavailable" in fix_doc + assert "| Before 0.261.291, every Ask an agent step" in admin_doc + assert "ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX" in admin_doc + assert "- `[ORCHESTRATION_EXTERNAL_SOURCES]`" in logging_doc + assert f"| `{REFUSAL_MESSAGE}` |" in logging_doc + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"])) diff --git a/functional_tests/test_support/agent_delegation.py b/functional_tests/test_support/agent_delegation.py index 7a7751465..d5505bdf9 100644 --- a/functional_tests/test_support/agent_delegation.py +++ b/functional_tests/test_support/agent_delegation.py @@ -1,11 +1,14 @@ # agent_delegation.py """Isolated external-service seams for the real agent delegation contract. -Version: 0.261.093 +Version: 0.261.291 Implemented in: 0.261.093 +Point reads return the SDK's dictionary subclass in: 0.261.291 (#1699) The application helper is executed unchanged. Only Cosmos, settings, governance, -group membership, cache invalidation, and audit services are replaced. +group membership, cache invalidation, and audit services are replaced. Point reads +return ``CosmosDictLike``, as azure-cosmos returns ``CosmosDict``, so code that +requires exactly ``dict`` fails here as it does in production. """ import ast @@ -43,6 +46,15 @@ class MatchConditions: IfNotModified = object() +class CosmosDictLike(dict): + """Stands in for azure-cosmos ``CosmosDict``, the type of a document point read. + + The pinned azure-cosmos 4.9.0 returns ``CosmosDict`` from ``read_item``. It is a + ``dict`` subclass and ``copy.deepcopy`` keeps the subclass, so a check that requires + exactly ``dict`` refuses it. Query results are plain dictionaries. + """ + + def reference(agent_id="target-id", scope_type="personal", scope_id="actor"): return {"id": agent_id, "scope_type": scope_type, "scope_id": scope_id} @@ -141,7 +153,7 @@ def read_item(*, item, partition_key): record = self.records[kind, scope].get((partition_key, item)) if record is None: raise CosmosResourceNotFoundError() - return deepcopy(record) + return CosmosDictLike(deepcopy(record)) return read_item def query(self, kind, scope): From afedd4b373639f1b4fbb789dbdf1e3360aa96d7a Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Wed, 7 Oct 2026 15:30:38 -0400 Subject: [PATCH 2/2] Add release notes for the Ask an agent step fixes Adds the v0.261.291 entry for orchestrated Ask an agent steps (#1699) and the missing v0.261.289 entry for picked agents and the agents setting. Refs #1699 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md | 1 + docs/explanation/release_notes.md | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/docs/explanation/fixes/ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md b/docs/explanation/fixes/ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md index a1ea19815..0ba5aecfa 100644 --- a/docs/explanation/fixes/ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md +++ b/docs/explanation/fixes/ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md @@ -165,6 +165,7 @@ as it did in production. | `docs/admin/orchestration.md` | Troubleshooting row. | | `docs/explanation/features/ORCHESTRATION_EXTERNAL_SOURCE_ACCESS.md` | Resolver contract and refusal logging. | | `docs/reference/logging-tags.md` | New tag and event. | +| `docs/explanation/release_notes.md` | 0.261.291 entry, and the missing 0.261.289 entry. | No setting, deployment or data change is needed. diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 1502ad3b5..03e0545e3 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,16 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes). +### **(v0.261.291)** + +#### Bug Fixes + +* **Orchestrated Ask an Agent Steps Run Again** + * With Orchestrate on, every Ask an agent step failed with "This step's agent or action isn't available to you right now", in personal and shared conversations, while the same agent answered with Orchestrate off. + * Cosmos DB returns stored agents as a subclass of a dictionary, and the step's access check accepted only a plain dictionary. Stored agents are now read back as plain dictionaries, and the check accepts either while still requiring the exact agent the user can reach. No setting or data change is needed. + * When a step's web, linked-page, deep research, agent, action or memory source is refused, `[ORCHESTRATION_EXTERNAL_SOURCES] A step's source was refused.` now names the check that failed in `sc_reason`. + * (Ref: `functions_orchestration_external_sources.py`, `functions_agent_delegation.py` `_canonical_agent`, [#1699](https://github.com/microsoft/simplechat/issues/1699), [Orchestration Agent Document Type Fix](fixes/ORCHESTRATION_AGENT_DOCUMENT_TYPE_FIX.md)) + ### **(v0.261.290)** #### Bug Fixes @@ -12,6 +22,16 @@ For feature-focused and fix-focused drill-downs by version, see [Features by Ver * A functional test now flags any router link built from the page origin in the V2 source, and a UI test clicks each rail link. * (Ref: `Sidebar.tsx` `safeNavHref`, [#1698](https://github.com/microsoft/simplechat/issues/1698), [V2 Sidebar Navigation Links Fix](fixes/V2_SIDEBAR_NAV_LINKS_FIX.md)) +### **(v0.261.289)** + +#### Bug Fixes + +* **Picked Agents Pass the Agents Setting in Orchestrate** + * An Ask an agent step for an agent the user picked, or tagged with @, was refused when the user's own agents setting was off, the agents button on the classic chat page. V2 has no control for it, so many V2 users have it off. + * Picking an agent now counts as permission to use that agent, as it does in chat. Its scope, membership, governance and enabled state are still checked, and the setting still applies to agents the user didn't pick. + * These steps still failed at a later check until 0.261.291. + * (Ref: `functions_orchestration_external_sources.py` `_seeded_agent`, `test_orchestration_seeded_agent_preference_fix.py`, [Orchestration Selected Agent With Agents Turned Off Fix](fixes/ORCHESTRATION_SELECTED_AGENT_DISABLED_PREFERENCE_FIX.md)) + ### **(v0.261.288)** #### New Features