Files
Andrew Khadder efa7fe78b2 Canary (#1719)
* fix(cli): update EnvVariable type to allow null values for sensitive rows (#1717)

- Modified the `value` property in the `EnvVariable` interface to accept `null`, indicating write-only sensitive rows where the stored value is withheld by the API.
- Adjusted the `printEnvVar` function to handle cases where `value` is `null`, ensuring proper display of sensitive information.

* chore: enter canary prerelease mode

* chore(typescript): version packages (canary)

* feat(server): add outgoing notification logging (#1674)

* chore(typescript): version packages (canary)

* fix(server): mount RFC 8414 canonical well-known paths for path-suffix issuers (#1684)

* fix(server): mount RFC 8414 canonical well-known paths for path-suffix issuers

Construct upstream OAuth/OIDC metadata URLs per RFC 8414 and OIDC
Discovery, and mount canonical path-suffixed routes on the MCP server
when the upstream issuer includes a path component.

Fixes #1576

Co-authored-by: Andrew Khadder <khandrew1@users.noreply.github.com>

* fix(server): mount OIDC discovery at root only; stop leaking error detail

Remove the unreachable, malformed OIDC path-suffix local mount (OIDC
Discovery appends the well-known segment to the issuer, so there is no
canonical form under the openid-configuration prefix, and no client flow
reaches a path-suffixed local route). Keep the RFC 8414 OAuth path-suffix
mount. Return a generic error_description to clients and log the raw
exception server-side instead of echoing it.

* style: apply prettier formatting

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Andrew Khadder <khandrew1@users.noreply.github.com>

* chore(typescript): version packages (canary)

* fix(server/oauth): broker upstream OAuth callback through /oauth/callback (#1728)

In proxy mode the server was forwarding each MCP client's redirect_uri
straight to the upstream provider, which forced every client's callback
(Claude, ChatGPT, the inspector, ...) to be pre-registered on the
provider. Instead, /authorize now sends the upstream the server's own
/oauth/callback and packs the client's redirect_uri + state into the
upstream `state` (base64url JSON, stateless). A new GET /oauth/callback
restores them and forwards the code (or upstream error) to the client's
original callback. /token rewrites redirect_uri to the brokered callback
so it matches the authorize request (RFC 6749 §4.1.3). PKCE passes
through untouched.

Also: jwksVerifier now rejects opaque (non-JWT) access tokens with an
actionable message pointing at the missing `audience`, instead of jose's
cryptic "Invalid Compact JWS".

Only `<server-domain>/oauth/callback` needs registering upstream now.

Closes MCP-2170

* fix(inspector): route localhost MCP servers through client-side chat (MCP-2419) (#1736)

* fix(inspector): route localhost MCP servers through client-side chat (MCP-2419)

In hosted mode the Chat tab streams through the managed cloud backend
(chatApiUrl), which connects to the MCP server server-side and cannot reach a
user's localhost server. The request 502s and, lacking CORS headers, surfaces
in the browser as an opaque "Failed to fetch" / CORS error.

Loopback server URLs now fall back to client-side (in-browser) chat streaming,
which already holds a direct session to the localhost server. The configure-key
empty state shows an explanatory notice telling the user the managed key can't
reach their machine and that a personal API key is needed — only in hosted mode;
the local inspector is unchanged.

The localhost check is extracted into a shared serverUrl util (with IPv6 [::1]
bracket handling) plus unit tests, and e2e tests cover the hosted+localhost
fallback, the no-cloud-call guarantee, and the notice's presence/absence.

* fix(inspector): address PR review on MCP-2419 localhost chat fix

- serverUrl: detect the full 127.0.0.0/8 loopback range (not just
  127.0.0.1), anchored so "127.example.com" is not a false positive
- serverUrl tests: cover 127.0.0.0/8 and near-loopback negatives
- e2e: stub the hosted `/api/auth/get-session` endpoint in
  enableHostedChatMode so HostedUserMenu never hits the real cloud
  backend (avoids CI flakiness/slowness)
- changeset: fix grammar

* style(inspector): prettier formatting for e2e test helpers

* chore(typescript): version packages (canary)

* fix(cli): use authoritative GitHub repo-access check for deploy (MCP-2467) (#1737)

The `mcp-use deploy` pre-flight repo-access check listed an installation's
repos and matched the target locally. That only inspected the first page, so
repos on later pages were wrongly reported inaccessible (MCP-2467); fully
paginating it (the prior behavior) hung on very large orgs.

Replace it with a single authoritative question per installation via the new
backend `GET /github/installations/:installationId/repos/:owner/:repo/access`
endpoint (a `repos.get` probe). The installation whose account matches the repo
owner is checked first, falling back to the others, preserving the
across-installations fix from MCP-2358 without paginating.

* chore(typescript): version packages (canary)

* chore(deps): bump esbuild from 0.27.1 to 0.28.1 in /libraries/typescript (#1734)

* chore(deps): bump esbuild from 0.27.1 to 0.28.1 in /libraries/typescript

Bumps [esbuild](https://github.com/evanw/esbuild) from 0.27.1 to 0.28.1.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2025.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.27.1...v0.28.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: add changeset for dependabot updates

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* chore(typescript): version packages (canary)

* fix(typescript): silence dev widget logs when project has no widgets (#1730)

* chore(typescript): version packages (canary)

* feat : inspector add scroll-to-bottom button (#1538)

* inspector: add scroll-to-bottom button

* fix(inspector): center chat scroll button

---------

Co-authored-by: Andrew Khadder <andrew@manufact.com>

* chore(typescript): version packages (canary)

* fix(cli): cleanly unsubscribe on Ctrl+C (SIGINT) (#1687)

* fix(cli): add SIGINT handler to cleanly unsubscribe from resource

Addresses #1686

* chore: add cli changeset

---------

Co-authored-by: Andrew Khadder <andrew@manufact.com>

* chore(typescript): version packages (canary)

* fix(inspector): update emulate oauth callback (#1743)

* refactor: centralize screenshot handling and add REPL screenshot support (#1592)

* refactor: centralize screenshot handling and add REPL screenshot support

* chore: fix knip checks

* chore: drop knip version ignore

---------

Co-authored-by: Andrew Khadder <andrew@manufact.com>

* chore(typescript): version packages (canary)

* fix(api): update base URL for remote API calls to manufact.com (#1745)

* chore(config): add Langfuse Cloud API endpoints to exclusion list in lychee.toml

- Updated the `lychee.toml` configuration file to include Langfuse Cloud API endpoints in the exclusion list, preventing 403 errors from bot protection during CI processes. This change ensures valid defaults for API interactions.

* fix(workflow): remove unnecessary flag from pnpm install command in CI configuration

- Updated the `bump-mcp-use-reusable.yml` workflow file to remove the `--config.dangerously-allow-all-builds=true` flag from the `pnpm install` command, streamlining the installation process during CI runs.

* fix(api): update base URL for remote API calls to manufact.com

- Changed the default base URL from 'https://cloud.mcp-use.com' to 'https://cloud.manufact.com' across multiple files, including MCPAgent and RemoteAgent initializations, ensuring consistency in API endpoint references.

* chore(typescript): version packages (canary)

* feat(cli): add support for custom Dockerfile path in deployment options (#1746)

* chore(config): add Langfuse Cloud API endpoints to exclusion list in lychee.toml

- Updated the `lychee.toml` configuration file to include Langfuse Cloud API endpoints in the exclusion list, preventing 403 errors from bot protection during CI processes. This change ensures valid defaults for API interactions.

* fix(workflow): remove unnecessary flag from pnpm install command in CI configuration

- Updated the `bump-mcp-use-reusable.yml` workflow file to remove the `--config.dangerously-allow-all-builds=true` flag from the `pnpm install` command, streamlining the installation process during CI runs.

* feat(cli): add support for custom Dockerfile path in deployment options

- Introduced a new CLI option `--dockerfile` to specify a non-default Dockerfile path relative to the project root.
- Updated the `DeployOptions` interface to include the `dockerfile` property.
- Enhanced deployment command output to display the specified Dockerfile path, improving user feedback during deployment processes.

* refactor(cli): improve console log formatting for Dockerfile output

- Reformatted the console log statement for the Dockerfile option in the deploy command to enhance readability by breaking it into multiple lines. This change aims to maintain consistent code style and improve maintainability.

* chore(typescript): version packages (canary)

* Remove obsolete OAuth proxy callback changeset

* chore(typescript): version packages (canary)

* docs: update canary changelogs

* fix(cli): allow empty env var updates (#1750)

* chore(config): add Langfuse Cloud API endpoints to exclusion list in lychee.toml

- Updated the `lychee.toml` configuration file to include Langfuse Cloud API endpoints in the exclusion list, preventing 403 errors from bot protection during CI processes. This change ensures valid defaults for API interactions.

* fix(workflow): remove unnecessary flag from pnpm install command in CI configuration

- Updated the `bump-mcp-use-reusable.yml` workflow file to remove the `--config.dangerously-allow-all-builds=true` flag from the `pnpm install` command, streamlining the installation process during CI runs.

* fix(cli): allow empty env var updates

* Add changeset for empty env value updates

---------

Co-authored-by: Enrico <2827496+tonxxd@users.noreply.github.com>

* chore(typescript): version packages (canary)

* chore(deps): bump tar from 7.5.11 to 7.5.16 in /libraries/typescript (#1744)

* chore(config): add Langfuse Cloud API endpoints to exclusion list in lychee.toml

- Updated the `lychee.toml` configuration file to include Langfuse Cloud API endpoints in the exclusion list, preventing 403 errors from bot protection during CI processes. This change ensures valid defaults for API interactions.

* fix(workflow): remove unnecessary flag from pnpm install command in CI configuration

- Updated the `bump-mcp-use-reusable.yml` workflow file to remove the `--config.dangerously-allow-all-builds=true` flag from the `pnpm install` command, streamlining the installation process during CI runs.

* chore(deps): bump tar from 7.5.11 to 7.5.16 in /libraries/typescript

Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.11 to 7.5.16.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.11...v7.5.16)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.16
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* chore: add changeset for dependabot updates

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Enrico Toniato <2827496+tonxxd@users.noreply.github.com>
Co-authored-by: Enrico <2827496+tonxxd@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

* chore(typescript): version packages (canary)

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Enrico Toniato <2827496+tonxxd@users.noreply.github.com>
Co-authored-by: Enrico Toniato <2827496+tonxxd@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Dhruv Tiwari <144548881+dhruvtiwari6@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Andrew Khadder <khandrew1@users.noreply.github.com>
Co-authored-by: Pietro Zullo <62951181+pietrozullo@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: kalpchaniyara <kalpchaniyara.2006@gmail.com>
Co-authored-by: Neha Prasad <nehaa9863pd@gmail.com>
Co-authored-by: Arpit Maurya <arpitbabu802@gmail.com>
Co-authored-by: 落尘 <cuidong111@gmail.com>
2026-06-16 09:41:20 -07:00

367 lines
16 KiB
Python

"""
Remote agent implementation for executing agents via API.
"""
import json
import os
from collections.abc import AsyncGenerator
from typing import Any, TypeVar
from uuid import UUID
import httpx
from langchain_core.messages import BaseMessage
from pydantic import BaseModel
from mcp_use.logging import logger
T = TypeVar("T", bound=BaseModel)
# API endpoint constants
API_CHATS_ENDPOINT = "/api/v1/chats/get-or-create"
API_CHAT_STREAM_ENDPOINT = "/api/v1/chats/{chat_id}/stream"
API_CHAT_DELETE_ENDPOINT = "/api/v1/chats/{chat_id}"
UUID_ERROR_MESSAGE = """A UUID is a 36 character string of the format xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx \n
Example: 123e4567-e89b-12d3-a456-426614174000
To generate a UUID, you can use the following command:
import uuid
# Generate a random UUID
my_uuid = uuid.uuid4()
print(my_uuid)
"""
class RemoteAgent:
"""Agent that executes remotely via API."""
def __init__(
self,
agent_id: str,
chat_id: str | None = None,
api_key: str | None = None,
base_url: str = "https://cloud.manufact.com",
):
"""Initialize remote agent.
Args:
agent_id: The ID of the remote agent to execute
chat_id: The ID of the chat session to use. If None, a new chat session will be created.
api_key: API key for authentication. If None, will check MCP_USE_API_KEY env var
base_url: Base URL for the remote API
"""
if chat_id is not None:
try:
chat_id = str(UUID(chat_id))
except ValueError as e:
raise ValueError(
f"Invalid chat ID: {chat_id}, make sure to provide a valid UUID.\n{UUID_ERROR_MESSAGE}"
) from e
self.agent_id = agent_id
self.chat_id = chat_id
self._session_established = False
self.base_url = base_url
# Handle API key validation
if api_key is None:
api_key = os.getenv("MCP_USE_API_KEY")
if not api_key:
raise ValueError(
"API key is required for remote execution. "
"Please provide it as a parameter or set the MCP_USE_API_KEY environment variable. "
"You can get an API key from https://cloud.manufact.com"
)
self.api_key = api_key
# Configure client with reasonable timeouts for agent execution
self._client = httpx.AsyncClient(
timeout=httpx.Timeout(
connect=10.0, # 10 seconds to establish connection
read=300.0, # 5 minutes to read response (agents can take time)
write=10.0, # 10 seconds to send request
pool=10.0, # 10 seconds to get connection from pool
)
)
def _pydantic_to_json_schema(self, model_class: type[T]) -> dict[str, Any]:
"""Convert a Pydantic model to JSON schema for API transmission.
Args:
model_class: The Pydantic model class to convert
Returns:
JSON schema representation of the model
"""
return model_class.model_json_schema()
def _parse_structured_response(self, response_data: Any, output_schema: type[T]) -> T:
"""Parse the API response into the structured output format.
Args:
response_data: Raw response data from the API
output_schema: The Pydantic model to parse into
Returns:
Parsed structured output
"""
# Handle different response formats
if isinstance(response_data, dict):
if "result" in response_data:
outer_result = response_data["result"]
# Check if this is a nested result structure (agent execution response)
if isinstance(outer_result, dict) and "result" in outer_result:
# Extract the actual structured output from the nested result
result_data = outer_result["result"]
else:
# Use the outer result directly
result_data = outer_result
else:
result_data = response_data
elif isinstance(response_data, str):
try:
result_data = json.loads(response_data)
except json.JSONDecodeError:
# If it's not valid JSON, try to create the model from the string content
result_data = {"content": response_data}
else:
result_data = response_data
# Parse into the Pydantic model
try:
logger.info(f"🔍 Attempting to validate result_data against {output_schema.__name__}")
logger.info(f"🔍 Result data type: {type(result_data)}")
logger.info(f"🔍 Result data: {result_data}")
return output_schema.model_validate(result_data)
except Exception as e:
logger.warning(f"❌ Failed to parse structured output: {e}")
logger.warning(f"🔍 Validation error details: {type(e).__name__}: {str(e)}")
logger.warning(f"🔍 Result data that failed validation: {result_data}")
# Fallback: try to parse it as raw content if the model has a content field
if hasattr(output_schema, "model_fields") and "content" in output_schema.model_fields:
logger.info("🔄 Attempting fallback with content field")
try:
fallback_result = output_schema.model_validate({"content": str(result_data)})
logger.info("✅ Fallback parsing succeeded")
return fallback_result
except Exception as fallback_e:
logger.error(f"❌ Fallback parsing also failed: {fallback_e}")
raise
raise
async def _upsert_chat_session(self) -> str:
"""Create or resume a persistent chat session for the agent via upsert.
Returns:
The chat session ID
"""
chat_payload = {
"id": self.chat_id, # Include chat_id for resuming or None for creating
"title": f"Remote Agent Session - {self.agent_id}",
"agent_id": self.agent_id,
"type": "agent_execution",
}
headers = {"Content-Type": "application/json", "x-api-key": self.api_key}
chat_url = f"{self.base_url}{API_CHATS_ENDPOINT}"
logger.info(f"📝 [{self.chat_id}] Upserting chat session for agent {self.agent_id}")
try:
chat_response = await self._client.post(chat_url, json=chat_payload, headers=headers)
chat_response.raise_for_status()
chat_data = chat_response.json()
chat_id = chat_data["id"]
if chat_response.status_code == 201:
logger.info(f"✅ [{self.chat_id}] New chat session created")
else:
logger.info(f"✅ [{self.chat_id}] Resumed chat session")
return chat_id
except httpx.HTTPStatusError as e:
status_code = e.response.status_code
response_text = e.response.text
if status_code == 404:
raise RuntimeError(
f"Agent not found: Agent '{self.agent_id}' does not exist or you don't have access to it. "
"Please verify the agent ID and ensure it exists in your account."
) from e
else:
raise RuntimeError(f"Failed to create chat session: {status_code} - {response_text}") from e
except Exception as e:
raise RuntimeError(f"Failed to create chat session: {str(e)}") from e
async def stream(
self,
query: str,
max_steps: int | None = None,
external_history: list[BaseMessage] | None = None,
output_schema: type[T] | None = None,
) -> AsyncGenerator[str, None]:
"""Stream the execution of a query on the remote agent using HTTP streaming."""
if external_history is not None:
logger.warning("External history is not yet supported for remote execution")
if not self._session_established:
logger.info(f"🔧 [{self.chat_id}] Establishing chat session for agent {self.agent_id}")
self.chat_id = await self._upsert_chat_session()
self._session_established = True
chat_id = self.chat_id
stream_url = f"{self.base_url}{API_CHAT_STREAM_ENDPOINT.format(chat_id=chat_id)}"
# Prepare the request payload
request_payload = {"messages": [{"role": "user", "content": query}], "max_steps": max_steps or 30}
if output_schema is not None:
request_payload["output_schema"] = self._pydantic_to_json_schema(output_schema)
headers = {"Content-Type": "application/json", "x-api-key": self.api_key, "Accept": "text/event-stream"}
try:
logger.info(f"🌐 [{self.chat_id}] Connecting to HTTP stream for agent {self.agent_id}")
async with self._client.stream("POST", stream_url, headers=headers, json=request_payload) as response:
logger.info(f"✅ [{self.chat_id}] HTTP stream connection established")
if response.status_code != 200:
error_text = await response.aread()
raise RuntimeError(f"Failed to stream from remote agent: {error_text.decode()}")
# Read the streaming response line by line
try:
async for line in response.aiter_lines():
if line:
yield line
except UnicodeDecodeError as e:
logger.error(f"❌ [{self.chat_id}] UTF-8 decoding error at position {e.start}: {e.reason}")
logger.error(f"❌ [{self.chat_id}] Error occurred while reading stream for agent {self.agent_id}")
# Try to read raw bytes and decode with error handling
logger.info(f"🔄 [{self.chat_id}] Attempting to read raw bytes with error handling...")
logger.info(f"✅ [{self.chat_id}] Agent execution stream completed")
except httpx.HTTPStatusError as e:
status_code = e.response.status_code
response_text = e.response.text
if status_code == 404:
raise RuntimeError(f"Chat or agent not found: {response_text}") from e
else:
raise RuntimeError(f"Failed to stream from remote agent: {status_code} - {response_text}") from e
except Exception as e:
logger.error(f"❌ [{self.chat_id}] An error occurred during HTTP streaming: {e}")
raise RuntimeError(f"Failed to stream from remote agent: {str(e)}") from e
async def run(
self,
query: str,
max_steps: int | None = None,
external_history: list[BaseMessage] | None = None,
output_schema: type[T] | None = None,
) -> str | T:
"""
Executes the agent and returns the final result.
This method uses HTTP streaming to avoid timeouts for long-running tasks.
It consumes the entire stream and returns only the final result.
"""
final_result = None
steps_taken = 0
finished = False
try:
# Consume the ENTIRE stream to ensure proper execution
async for event in self.stream(query, max_steps, external_history, output_schema):
logger.debug(f"[{self.chat_id}] Processing stream event: {event}...")
# Parse AI SDK format events to extract final result
# The events follow the AI SDK streaming protocol
if event.startswith("0:"): # Text event
try:
text_data = json.loads(event[2:]) # Remove "0:" prefix
# Normal text accumulation
if final_result is None:
final_result = ""
final_result += text_data
result_preview = final_result[:200] if len(final_result) > 200 else final_result
logger.debug(f"Accumulated text result: {result_preview}...")
except json.JSONDecodeError:
logger.warning(f"Failed to parse text event: {event[:100]}")
continue
elif event.startswith("9:"): # Tool call event
steps_taken += 1
logger.debug(f"Tool call executed, total steps: {steps_taken}")
elif event.startswith("d:"): # Finish event
logger.debug("Received finish event, marking as finished")
finished = True
# Continue consuming to ensure stream cleanup
elif event.startswith("3:"): # Error event
try:
error_data = json.loads(event[2:])
error_msg = error_data if isinstance(error_data, str) else json.dumps(error_data)
raise RuntimeError(f"Agent execution failed: {error_msg}")
except json.JSONDecodeError as e:
raise RuntimeError("Agent execution failed with unknown error") from e
elif event.startswith("f:"): # Structured final event
try:
structured_data = json.loads(event[2:]) # Remove "f:" prefix
logger.info(f"📋 [{self.chat_id}] Received structured final event")
# Replace accumulated text with structured output
final_result = structured_data
logger.info(f"📋 [{self.chat_id}] Replaced accumulated text with structured output")
except json.JSONDecodeError:
logger.warning(f"Failed to parse structured final event: {event[:100]}")
continue
# Log completion of stream consumption
logger.info(f"Stream consumption complete. Finished: {finished}, Steps taken: {steps_taken}")
if final_result is None:
logger.warning(f"No final result captured from stream (structured output: {output_schema is not None})")
final_result = "" # Return empty string instead of error message
# For structured output, try to parse the result
if output_schema:
logger.info(f"🔍 Attempting structured output parsing for schema: {output_schema.__name__}")
logger.info(f"🔍 Raw final result type: {type(final_result)}")
logger.info(f"🔍 Raw final result length: {len(str(final_result)) if final_result else 0}")
logger.info(f"🔍 Raw final result preview: {str(final_result)[:500] if final_result else 'None'}...")
if isinstance(final_result, str) and final_result:
try:
# Try to parse as JSON first
parsed_result = json.loads(final_result)
logger.info("✅ Successfully parsed structured result as JSON")
return self._parse_structured_response(parsed_result, output_schema)
except json.JSONDecodeError as e:
logger.warning(f"❌ Could not parse result as JSON: {e}")
logger.warning(f"🔍 Raw string content: {final_result[:1000]}...")
# Try to parse directly
return self._parse_structured_response({"content": final_result}, output_schema)
else:
logger.warning(f"❌ Final result is empty or not string: {final_result}")
# Try to parse the result directly
return self._parse_structured_response(final_result, output_schema)
# Regular string output
return final_result if isinstance(final_result, str) else str(final_result)
except RuntimeError:
raise
except Exception as e:
logger.error(f"Error executing agent: {e}")
raise RuntimeError(f"Failed to execute agent: {str(e)}") from e
async def close(self) -> None:
"""Close the HTTP client."""
await self._client.aclose()
logger.info("🔌 Remote agent client closed")