Skip to content

Commit cbda540

Browse files
authored
Merge pull request #661 from davidhadas/wrap_mcp_init
fix mcp never failing till timeout bug
2 parents 362e9f3 + 9459c41 commit cbda540

7 files changed

Lines changed: 499 additions & 12 deletions

File tree

a2a/git_issue_agent/a2a_agent.py

Lines changed: 63 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
Module for A2A Agent.
33
"""
44

5+
import asyncio
56
import logging
67
import os
78
import sys
9+
import threading
810
import traceback
911

1012
import uvicorn
11-
from crewai_tools import MCPServerAdapter
1213
from crewai_tools.adapters.tool_collection import ToolCollection
1314

1415

@@ -32,7 +33,8 @@
3233

3334
from git_issue_agent.config import settings, Settings
3435
from git_issue_agent.event import Event
35-
from git_issue_agent.main import GitIssueAgent
36+
from git_issue_agent.main import GitIssueAgent, TaskCancelled
37+
from git_issue_agent.mcp_connect import mcp_tools_session
3638

3739
logger = logging.getLogger(__name__)
3840
logging.basicConfig(level=settings.LOG_LEVEL, stream=sys.stdout, format="%(levelname)s: %(message)s")
@@ -115,11 +117,26 @@ class GithubExecutor(AgentExecutor):
115117
A class to handle research execution for A2A Agent.
116118
"""
117119

118-
async def _run_agent(self, messages: dict, settings: Settings, event_emitter: Event, toolkit: ToolCollection):
120+
def __init__(self):
121+
# Per-request cooperative-cancel flags, keyed by task id. CrewAI runs its
122+
# crew synchronously in a worker thread (kickoff_async -> asyncio.to_thread),
123+
# so cancelling the awaiting coroutine cannot stop the work; instead the
124+
# crew checks this Event between steps and stops itself.
125+
self._cancel_events: dict[str, threading.Event] = {}
126+
127+
async def _run_agent(
128+
self,
129+
messages: dict,
130+
settings: Settings,
131+
event_emitter: Event,
132+
toolkit: ToolCollection,
133+
cancel_event: threading.Event,
134+
):
119135
git_issue_agent = GitIssueAgent(
120136
config=settings,
121137
eventer=event_emitter,
122138
mcp_toolkit=toolkit,
139+
cancel_event=cancel_event,
123140
)
124141
result = await git_issue_agent.execute(messages)
125142
await event_emitter.emit_event(result, True)
@@ -163,6 +180,11 @@ async def execute(self, context: RequestContext, event_queue: EventQueue):
163180
}
164181
)
165182

183+
# Register a cooperative-cancel flag for this task so cancel()/disconnect
184+
# can stop the (thread-bound) crew run.
185+
cancel_event = threading.Event()
186+
self._cancel_events[task.id] = cancel_event
187+
166188
# Hook up MCP tools
167189
try:
168190
if settings.MCP_URL:
@@ -173,7 +195,13 @@ async def execute(self, context: RequestContext, event_queue: EventQueue):
173195
"transport": "streamable-http",
174196
"headers": headers,
175197
}
176-
with MCPServerAdapter(server_params, connect_timeout=settings.MCP_TIMEOUT) as mcp_tools:
198+
# mcp_tools_session fails fast if the connection errors out, while
199+
# still allowing up to MCP_TIMEOUT for a slow (e.g. OAuth) connect.
200+
async with mcp_tools_session(
201+
server_params,
202+
connect_timeout=settings.MCP_TIMEOUT,
203+
poll_interval=settings.MCP_POLL_INTERVAL,
204+
) as mcp_tools:
177205
# Keep only search and list issue-related tools.
178206
issue_tools = [
179207
tool
@@ -187,21 +215,47 @@ async def execute(self, context: RequestContext, event_queue: EventQueue):
187215
"No issue-related tools found from the GitHub MCP server. "
188216
"Ensure your PAT scopes allow issue access and the server is reachable."
189217
)
190-
await self._run_agent(messages, settings, event_emitter, issue_tools)
218+
# Tool output is bounded inside GitIssueAgent (wrap_tool_output).
219+
await self._run_agent(messages, settings, event_emitter, issue_tools, cancel_event)
191220
else:
192-
await self._run_agent(messages, settings, event_emitter, None)
193-
221+
await self._run_agent(messages, settings, event_emitter, None, cancel_event)
222+
223+
except (asyncio.CancelledError, TaskCancelled):
224+
# A2A raises CancelledError in this task on cancel/disconnect; the crew
225+
# may also surface TaskCancelled after observing the flag. Signal the
226+
# worker thread to stop and report the task as cancelled.
227+
cancel_event.set()
228+
logging.info("Task %s cancelled; stopping crew run", task.id)
229+
try:
230+
await task_updater.cancel()
231+
except Exception: # noqa: BLE001 - best-effort status on an already-torn-down queue
232+
pass
233+
raise
194234
except Exception as e:
195235
traceback.print_exc()
196236
await event_emitter.emit_event(
197237
f"I'm sorry I was unable to fulfill your request. I encountered the following exception: {str(e)}", True
198238
)
239+
finally:
240+
self._cancel_events.pop(task.id, None)
199241

200242
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
201243
"""
202-
Not implemented
244+
Signal the running crew for this task to stop, and report cancellation.
245+
246+
The crew runs in a worker thread, so we flip its cooperative-cancel flag;
247+
it stops at the next step boundary rather than mid-LLM-call.
203248
"""
204-
raise Exception("cancel not supported")
249+
task = context.current_task
250+
task_id = task.id if task else None
251+
cancel_event = self._cancel_events.get(task_id) if task_id else None
252+
if cancel_event is not None:
253+
cancel_event.set()
254+
logging.info("Cancellation requested for task %s", task_id)
255+
256+
if task_id:
257+
task_updater = TaskUpdater(event_queue, task_id, task.context_id)
258+
await task_updater.cancel()
205259

206260

207261
def run():

a2a/git_issue_agent/git_issue_agent/agents.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55

66

77
class GitAgents:
8-
def __init__(self, config: Settings, issue_tools):
8+
def __init__(self, config: Settings, issue_tools, step_callback=None):
99
self.llm = CrewLLM(config)
10+
# step_callback is invoked by CrewAI after each agent step; we use it to
11+
# cooperatively stop a run on cancellation.
12+
self.step_callback = step_callback
1013

1114
###################
1215
# Pre-requisite validator
@@ -33,6 +36,7 @@ def __init__(self, config: Settings, issue_tools):
3336
tasks=[self.prereq_identifier_task],
3437
process=Process.sequential,
3538
verbose=True,
39+
step_callback=step_callback,
3640
)
3741

3842
###################
@@ -49,6 +53,10 @@ def __init__(self, config: Settings, issue_tools):
4953
verbose=True,
5054
llm=self.llm.llm,
5155
inject_date=True,
56+
# max_iter is the backstop for the context-overflow fallback: even if a
57+
# tool result slips past the size caps (tool_limits.py) and triggers
58+
# CrewAI's summarize-and-retry loop, the run terminates after this many
59+
# iterations with a partial answer rather than looping unbounded.
5260
max_iter=6,
5361
max_retry_limit=3,
5462
respect_context_window=True,
@@ -75,4 +83,5 @@ def __init__(self, config: Settings, issue_tools):
7583
tasks=[self.issue_query_task],
7684
process=Process.sequential,
7785
verbose=True,
86+
step_callback=step_callback,
7887
)

a2a/git_issue_agent/git_issue_agent/config.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ class Settings(BaseSettings):
3030
os.getenv("MCP_URL", "https://api.githubcopilot.com/mcp/"), description="Endpoint for an option MCP server"
3131
)
3232
MCP_TIMEOUT: int = Field(os.getenv("MCP_TIMEOUT", 600), description="Timeout in seconds for MCP server connection")
33+
MCP_POLL_INTERVAL: float = Field(
34+
os.getenv("MCP_POLL_INTERVAL", 1.0),
35+
description="How often (seconds) to poll the MCP connection state so failures surface fast",
36+
gt=0,
37+
)
38+
MAX_ISSUES: int = Field(
39+
os.getenv("MAX_ISSUES", 30),
40+
description="Max number of items kept from an MCP issue result set before it reaches the LLM",
41+
gt=0,
42+
)
43+
MAX_TOOL_CHARS: int = Field(
44+
os.getenv("MAX_TOOL_CHARS", 24000),
45+
description="Hard character budget for any single tool observation fed to the LLM",
46+
gt=0,
47+
)
3348

3449
# auth variables for token validation
3550
ISSUER: Optional[str] = Field(os.getenv("ISSUER", None), description="The issuer for incoming JWT tokens")

a2a/git_issue_agent/git_issue_agent/main.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,24 @@
22
import logging
33
import re
44
import sys
5+
import threading
56

67
from crewai_tools.adapters.tool_collection import ToolCollection
78

89
from git_issue_agent.agents import GitAgents
910
from git_issue_agent.config import Settings, settings
1011
from git_issue_agent.data_types import IssueSearchInfo
1112
from git_issue_agent.event import Event
13+
from git_issue_agent.tool_limits import wrap_tool_output
1214

1315
logger = logging.getLogger(__name__)
1416
logging.basicConfig(level=settings.LOG_LEVEL, stream=sys.stdout, format="%(levelname)s: %(message)s")
1517

1618

19+
class TaskCancelled(Exception):
20+
"""Raised to unwind a CrewAI run once cancellation has been requested."""
21+
22+
1723
def _parse_prereq_from_raw(raw: str) -> IssueSearchInfo:
1824
"""Parse IssueSearchInfo from raw LLM text when instructor/pydantic parsing fails.
1925
@@ -41,11 +47,38 @@ def __init__(
4147
eventer: Event = None,
4248
mcp_toolkit: ToolCollection = None,
4349
logger=None,
50+
cancel_event: threading.Event = None,
4451
):
45-
self.agents = GitAgents(config, mcp_toolkit)
52+
self.cancel_event = cancel_event or threading.Event()
53+
self._truncation_notes: list[str] = []
54+
# Bound each MCP tool's output so a large result set can't overflow the
55+
# model context window (which triggers CrewAI's slow summarize fallback).
56+
if mcp_toolkit:
57+
mcp_toolkit = [
58+
wrap_tool_output(
59+
tool,
60+
max_items=config.MAX_ISSUES,
61+
max_chars=config.MAX_TOOL_CHARS,
62+
on_truncate=self.add_truncation_note,
63+
)
64+
for tool in mcp_toolkit
65+
]
66+
# step_callback runs on the CrewAI worker thread after each agent step;
67+
# raising here propagates out of the crew loop and stops it promptly.
68+
self.agents = GitAgents(config, mcp_toolkit, step_callback=self._on_step)
4669
self.eventer = eventer
4770
self.logger = logger or logging.getLogger(__name__)
4871

72+
def _on_step(self, _step) -> None:
73+
"""CrewAI step callback: abort the run if cancellation was requested."""
74+
if self.cancel_event.is_set():
75+
raise TaskCancelled()
76+
77+
def _check_cancelled(self) -> None:
78+
"""Abort between crews (e.g. prereq -> main) if cancellation was requested."""
79+
if self.cancel_event.is_set():
80+
raise TaskCancelled()
81+
4982
async def _send_event(self, message: str, final: bool = False):
5083
logger.info(message)
5184
if self.eventer:
@@ -82,10 +115,26 @@ async def _get_prereq_output(self, query: str) -> IssueSearchInfo:
82115
raw = self.agents.prereq_identifier_task.output.raw
83116
self.logger.info(f"Prereq raw output: {raw}")
84117
return _parse_prereq_from_raw(raw)
118+
except TaskCancelled:
119+
# Never swallow cancellation as a "prereq failed" fallback.
120+
raise
85121
except Exception as e:
86122
self.logger.warning(f"Prereq crew failed: {e}")
87123
return IssueSearchInfo()
88124

125+
def add_truncation_note(self, tool_name: str, kept: int, total: int) -> None:
126+
"""Record that a tool result was trimmed, for a user-facing note.
127+
128+
Used as the ``on_truncate`` callback for wrap_tool_output. ``kept == -1``
129+
marks a raw character-budget cut where item counts are unknown.
130+
"""
131+
if kept == -1:
132+
note = "Some tool output was truncated to fit the model context window."
133+
else:
134+
note = f"Results were limited to the first {kept} of {total} items."
135+
if note not in self._truncation_notes:
136+
self._truncation_notes.append(note)
137+
89138
async def execute(self, user_input):
90139
query = self.extract_user_input(user_input)
91140
await self._send_event("🧐 Evaluating requirements...")
@@ -98,6 +147,7 @@ async def execute(self, user_input):
98147
if not repo_id_task_output.owner:
99148
return "When supplying a repository name, you must also provide an owner of the repo."
100149

150+
self._check_cancelled()
101151
await self._send_event("🔎 Searching for issues...")
102152
await self.agents.crew.kickoff_async(
103153
inputs={
@@ -107,4 +157,7 @@ async def execute(self, user_input):
107157
"issues": repo_id_task_output.issue_numbers,
108158
}
109159
)
110-
return self.agents.issue_query_task.output.raw
160+
answer = self.agents.issue_query_task.output.raw
161+
if self._truncation_notes:
162+
answer = f"{answer}\n\n_Note: {' '.join(self._truncation_notes)}_"
163+
return answer

0 commit comments

Comments
 (0)