Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 49 additions & 10 deletions Server/src/services/custom_tool_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,14 @@ async def execute_tool(
message=f"Tool '{tool_name}' not found for project {project_id}",
)

# Custom tools are frequently long-running (captures, image comparison,
# batch imports), so the hub's default 30s command budget times them out
# while they are still working. Give them a generous default; callers can
# still override with an explicit timeout_seconds (or timeoutSeconds) parameter, up to 3600.
# The hub reads timeout_seconds first, so a default added beside a caller's timeoutSeconds would win.
if "timeout_seconds" not in params and "timeoutSeconds" not in params:
params["timeout_seconds"] = 300

response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
Expand Down Expand Up @@ -216,20 +224,45 @@ async def _poll_until_complete(
if time.time() > deadline:
return MCPResponse(
success=False,
message=f"Timeout waiting for {tool_name} to complete",
message=(
f"Timeout waiting for {tool_name} to complete. The operation may "
"still be running in Unity — do not assume it finished or failed; "
"verify its effects before continuing."
),
data=self._safe_response(response),
# No retry hint: the operation may still be running, and resubmitting it could run it twice.
)

await asyncio.sleep(poll_interval)
await asyncio.sleep(min(poll_interval, max(0.0, deadline - time.time())))

# A poll inherits the tool's parameters, including its long command budget (300 s by default):
# left as is, one status poll could wait far past this polling deadline. Give each poll what is
# left of the budget instead. The hub still waits at least its 30 s default for any command,
# so the wait itself is also cut off at the deadline here. On stdio the poll runs in a worker
# thread that a cancelled wait cannot stop, so it is given the same budget (budget_s) and
# ends by itself, releasing the connection.
remaining = max(0.0, deadline - time.time())
if remaining <= 0:
continue
poll_params.pop("timeoutSeconds", None)
poll_params["timeout_seconds"] = max(1.0, remaining)

try:
response = await send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
tool_name,
poll_params,
user_id=user_id,
response = await asyncio.wait_for(
send_with_unity_instance(
async_send_command_with_retry,
unity_instance,
tool_name,
poll_params,
user_id=user_id,
budget_s=remaining,
),
timeout=remaining,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
)
except asyncio.TimeoutError:
# The deadline passed mid-poll: keep the last pending response and let the
# check above report the timeout.
continue
except Exception as exc: # pragma: no cover - network/domain reload variability
logger.debug(f"Polling {tool_name} failed, will retry: {exc}")
# Back off modestly but stay responsive.
Expand Down Expand Up @@ -275,12 +308,18 @@ def _normalize_response(self, response) -> MCPResponse:
if isinstance(response, MCPResponse):
return response
if isinstance(response, dict):
data = response.get(
"data", response) if "data" not in response else response["data"]
hint = response.get("hint")
if isinstance(data, dict) and data.get("reason") == "unity_timeout":
# The tool may have run: its effects are to be checked, not the tool resubmitted.
hint = None
return MCPResponse(
success=response.get("success", True),
message=response.get("message"),
error=response.get("error"),
data=response.get(
"data", response) if "data" not in response else response["data"],
data=data,
hint=hint,
)

success = True
Expand Down
Loading