mirror of
https://github.com/mcp-use/mcp-use.git
synced 2026-09-14 18:06:01 +08:00
efa7fe78b2
* 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>
367 lines
16 KiB
Python
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")
|