Skip to content

Python: [Python] structured_outputs_transform reuses ChatHistory across calls (prompt pollution) #14483

Description

Description

structured_outputs_transform creates a single ChatHistory in the factory and closes over it. Each output_transform call add_messages into that same history, so prior orchestration outputs leak into later structured-output prompts and the history grows without bound (lengths 2→3→4…).

Environment

  • semantic-kernel==1.44.1 (Python)
  • Python 3.13
  • Repro uses a mocked chat service (no API keys)

Minimal repro

import asyncio
from typing import Any
from pydantic import BaseModel, PrivateAttr
from semantic_kernel.agents.orchestration.tools import structured_outputs_transform
from semantic_kernel.connectors.ai.chat_completion_client_base import ChatCompletionClientBase
from semantic_kernel.connectors.ai.prompt_execution_settings import PromptExecutionSettings
from semantic_kernel.contents import ChatMessageContent
from semantic_kernel.contents.utils.author_role import AuthorRole

class Out(BaseModel):
    answer: str

class FakeSettings(PromptExecutionSettings):
    response_format: Any = None

class FakeService(ChatCompletionClientBase):
    _calls: list = PrivateAttr(default_factory=list)
    def __init__(self) -> None:
        super().__init__(service_id="fake", ai_model_id="fake")
    def get_prompt_execution_settings_class(self):
        return FakeSettings
    async def get_chat_message_contents(self, chat_history, settings, **kwargs):
        self._calls.append(len(chat_history.messages))
        return [ChatMessageContent(role=AuthorRole.ASSISTANT, content='{"answer":"x"}')]
    async def get_chat_message_content(self, chat_history, settings, **kwargs):
        self._calls.append(len(chat_history.messages))
        return ChatMessageContent(role=AuthorRole.ASSISTANT, content='{"answer":"x"}')

async def main():
    svc = FakeService()
    transform = structured_outputs_transform(Out, svc)
    await transform(ChatMessageContent(role=AuthorRole.USER, content="first"))
    await transform(ChatMessageContent(role=AuthorRole.USER, content="second"))
    await transform(ChatMessageContent(role=AuthorRole.USER, content="third"))
    print(svc._calls)  # ACTUAL: [2, 3, 4]  EXPECTED: [2, 2, 2]

asyncio.run(main())

Expected

Each transform invocation uses a fresh history (system + current message only), so independent orchestration steps do not pollute each other.

Actual

Shared history grows across calls; prior outputs leak into later structured-output prompts.

Root cause (pointer)

semantic_kernel/agents/orchestration/tools.py — structured_outputs_transform: chat_history = ChatHistory(...) created once in the factory and mutated inside output_transform.

Suggested fix direction

Construct a fresh ChatHistory (with the system message) inside output_transform on every call.

Activity

  1. changed the title [-][Python] structured_outputs_transform reuses ChatHistory across calls (prompt pollution)[/-] [+]Python: [Python] structured_outputs_transform reuses ChatHistory across calls (prompt pollution)[/+] on Sep 25, 2026
  2. jiangjiang248 commented on Sep 26, 2026

    @jiangjiang248

    I reproduced the history leak with two calls to the same structured_outputs_transform. I am submitting a focused fix that creates a fresh ChatHistory for each invocation, with a regression test covering isolation across calls. The targeted orchestration tools tests pass locally.

  3. michaelkillgta commented on Sep 28, 2026

    @michaelkillgta
    Author

    Thanks jiangjiang248 for #14487 — I'll leave this one with your PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    pythonPull requests for the Python Semantic Kerneltriage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions