Skip to content

Commit fa5ab17

Browse files
committed
Workaround for the MCP server connection cleanup issue
1 parent 934f3e8 commit fa5ab17

4 files changed

Lines changed: 594 additions & 0 deletions

File tree

lightspeed_stack_providers/providers/inline/agents/lightspeed_inline_agent/agents.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
)
3232

3333
from .config import LightspeedAgentsImplConfig
34+
from .responses.openai_responses import LightspeedOpenAIResponsesImpl
3435

3536
logger = get_logger(name=__name__, category="agents")
3637

@@ -63,6 +64,31 @@ def __init__(
6364
)
6465
self.config = config
6566

67+
async def initialize(self) -> None:
68+
"""
69+
Initialize the agent implementation.
70+
71+
This method uses LightspeedOpenAIResponsesImpl as part of the workaround for MCP server
72+
connection cleanup issue. The issue is resolved in Llama Stack by
73+
https://github.com/llamastack/llama-stack/pull/4758, but we need to use Llama Stack 0.4.3
74+
and have backported the PR fix.
75+
76+
TODO: Remove this workaround once we upgrade to a Llama Stack version that includes PR #4758.
77+
"""
78+
await super().initialize()
79+
self.openai_responses_impl = LightspeedOpenAIResponsesImpl(
80+
inference_api=self.inference_api,
81+
tool_groups_api=self.tool_groups_api,
82+
tool_runtime_api=self.tool_runtime_api,
83+
responses_store=self.responses_store,
84+
vector_io_api=self.vector_io_api,
85+
safety_api=self.safety_api,
86+
conversations_api=self.conversations_api,
87+
prompts_api=self.prompts_api,
88+
files_api=self.files_api,
89+
vector_stores_config=self.config.vector_stores_config,
90+
)
91+
6692
async def create_openai_response(
6793
self,
6894
input: str | list[OpenAIResponseInput],

lightspeed_stack_providers/providers/inline/agents/lightspeed_inline_agent/responses/__init__.py

Whitespace-only changes.
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
"""
2+
Workaround for MCP server connection cleanup issue.
3+
4+
This file provides a custom implementation to handle MCP session cleanup properly.
5+
The issue is resolved in Llama Stack by https://github.com/llamastack/llama-stack/pull/4758,
6+
but we need to use Llama Stack 0.4.3 and have backported the PR fix to this code.
7+
8+
TODO: Remove this workaround once we upgrade to a Llama Stack version that includes PR #4758.
9+
"""
10+
11+
import time
12+
import uuid
13+
from typing import AsyncIterator
14+
15+
from llama_stack.log import get_logger
16+
from llama_stack.providers.inline.agents.meta_reference.responses.openai_responses import (
17+
OpenAIResponsesImpl,
18+
)
19+
from llama_stack.providers.inline.agents.meta_reference.responses.streaming import (
20+
StreamingResponseOrchestrator,
21+
)
22+
from llama_stack.providers.inline.agents.meta_reference.responses.tool_executor import (
23+
ToolExecutor,
24+
)
25+
from llama_stack.providers.inline.agents.meta_reference.responses.types import (
26+
ChatCompletionContext,
27+
)
28+
from llama_stack.providers.inline.agents.meta_reference.responses.utils import (
29+
convert_response_text_to_chat_response_format,
30+
)
31+
from llama_stack.providers.utils.tools.mcp import MCPSessionManager
32+
from llama_stack_api import (
33+
ConversationItem,
34+
OpenAIResponseInput,
35+
OpenAIResponseInputTool,
36+
OpenAIResponseInputToolChoice,
37+
OpenAIResponseObjectStream,
38+
OpenAIResponsePrompt,
39+
OpenAIResponseText,
40+
OpenAISystemMessageParam,
41+
)
42+
from llama_stack_api.agents import ResponseItemInclude
43+
44+
logger = get_logger(name=__name__, category="agents")
45+
46+
47+
class MyMCPSessionManager(MCPSessionManager):
48+
async def __aenter__(self):
49+
"""Enter the async context manager."""
50+
return self
51+
52+
async def __aexit__(self, exc_type, exc_val, exc_tb):
53+
"""Exit the async context manager and cleanup all sessions."""
54+
await super().close_all()
55+
return False
56+
57+
58+
class LightspeedOpenAIResponsesImpl(OpenAIResponsesImpl):
59+
async def _create_streaming_response(
60+
self,
61+
input: str | list[OpenAIResponseInput],
62+
model: str,
63+
instructions: str | None = None,
64+
previous_response_id: str | None = None,
65+
conversation: str | None = None,
66+
prompt: OpenAIResponsePrompt | None = None,
67+
store: bool | None = True,
68+
temperature: float | None = None,
69+
text: OpenAIResponseText | None = None,
70+
tools: list[OpenAIResponseInputTool] | None = None,
71+
tool_choice: OpenAIResponseInputToolChoice | None = None,
72+
max_infer_iters: int | None = 10,
73+
guardrail_ids: list[str] | None = None,
74+
parallel_tool_calls: bool | None = True,
75+
max_tool_calls: int | None = None,
76+
metadata: dict[str, str] | None = None,
77+
include: list[ResponseItemInclude] | None = None,
78+
) -> AsyncIterator[OpenAIResponseObjectStream]:
79+
logger.info("LightspeedOpenAIResponsesImpl._create_streaming_response")
80+
# These should never be None when called from create_openai_response (which sets defaults)
81+
# but we assert here to help mypy understand the types
82+
assert text is not None, "text must not be None"
83+
assert max_infer_iters is not None, "max_infer_iters must not be None"
84+
85+
# Input preprocessing
86+
all_input, messages, tool_context = (
87+
await self._process_input_with_previous_response(
88+
input, tools, previous_response_id, conversation
89+
)
90+
)
91+
92+
if instructions:
93+
messages.insert(0, OpenAISystemMessageParam(content=instructions))
94+
95+
# Prepend reusable prompt (if provided)
96+
await self._prepend_prompt(messages, prompt)
97+
98+
# Structured outputs
99+
response_format = await convert_response_text_to_chat_response_format(text)
100+
101+
ctx = ChatCompletionContext(
102+
model=model,
103+
messages=messages,
104+
response_tools=tools,
105+
tool_choice=tool_choice,
106+
temperature=temperature,
107+
response_format=response_format,
108+
tool_context=tool_context,
109+
inputs=all_input,
110+
)
111+
112+
# Create orchestrator and delegate streaming logic
113+
response_id = f"resp_{uuid.uuid4()}"
114+
created_at = int(time.time())
115+
116+
# Create a per-request MCP session manager for session reuse (fix for #4452)
117+
# This avoids redundant tools/list calls when making multiple MCP tool invocations
118+
async with MyMCPSessionManager() as mcp_session_manager:
119+
120+
# Create a per-request ToolExecutor with the session manager
121+
request_tool_executor = ToolExecutor(
122+
tool_groups_api=self.tool_groups_api,
123+
tool_runtime_api=self.tool_runtime_api,
124+
vector_io_api=self.vector_io_api,
125+
vector_stores_config=self.tool_executor.vector_stores_config,
126+
mcp_session_manager=mcp_session_manager,
127+
)
128+
129+
orchestrator = StreamingResponseOrchestrator(
130+
inference_api=self.inference_api,
131+
ctx=ctx,
132+
response_id=response_id,
133+
created_at=created_at,
134+
prompt=prompt,
135+
text=text,
136+
max_infer_iters=max_infer_iters,
137+
parallel_tool_calls=parallel_tool_calls,
138+
tool_executor=request_tool_executor,
139+
safety_api=self.safety_api,
140+
guardrail_ids=guardrail_ids,
141+
instructions=instructions,
142+
max_tool_calls=max_tool_calls,
143+
metadata=metadata,
144+
include=include,
145+
)
146+
147+
# Stream the response
148+
final_response = None
149+
failed_response = None
150+
151+
# Type as ConversationItem to avoid list invariance issues
152+
output_items: list[ConversationItem] = []
153+
async for stream_chunk in orchestrator.create_response():
154+
match stream_chunk.type:
155+
case "response.completed" | "response.incomplete":
156+
final_response = stream_chunk.response
157+
case "response.failed":
158+
failed_response = stream_chunk.response
159+
case "response.output_item.done":
160+
item = stream_chunk.item
161+
output_items.append(item)
162+
case _:
163+
pass # Other event types
164+
165+
# Store and sync before yielding terminal events
166+
# This ensures the storage/syncing happens even if the consumer breaks after
167+
# receiving the event
168+
if (
169+
stream_chunk.type in {"response.completed", "response.incomplete"}
170+
and final_response
171+
and failed_response is None
172+
):
173+
messages_to_store = list(
174+
filter(
175+
lambda x: not isinstance(x, OpenAISystemMessageParam),
176+
orchestrator.final_messages,
177+
)
178+
)
179+
if store:
180+
# TODO: we really should work off of output_items instead of
181+
# "final_messages"
182+
await self._store_response(
183+
response=final_response,
184+
input=all_input,
185+
messages=messages_to_store,
186+
)
187+
188+
if conversation:
189+
await self._sync_response_to_conversation(
190+
conversation, input, output_items
191+
)
192+
await self.responses_store.store_conversation_messages(
193+
conversation, messages_to_store
194+
)
195+
196+
yield stream_chunk

0 commit comments

Comments
 (0)