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>
1113 lines
50 KiB
Python
1113 lines
50 KiB
Python
"""
|
||
MCP: Main integration module with customizable system prompt.
|
||
|
||
This module provides the main MCPAgent class that integrates all components
|
||
to provide a simple interface for using MCP tools with different LLMs.
|
||
|
||
LangChain 1.0.0 Migration:
|
||
- The agent uses create_agent() from langchain.agents which returns a CompiledStateGraph
|
||
- New methods: astream_simplified() and run_v2() leverage the built-in astream() from
|
||
CompiledStateGraph which handles the agent loop internally
|
||
- Legacy methods: stream() and run() use manual step-by-step execution for backward compatibility
|
||
"""
|
||
|
||
import logging
|
||
import time
|
||
from collections.abc import AsyncGenerator, AsyncIterator
|
||
from typing import Any, TypeVar, cast
|
||
|
||
from langchain.agents import create_agent
|
||
from langchain.agents.middleware import ModelCallLimitMiddleware
|
||
from langchain_core.agents import AgentAction
|
||
from langchain_core.globals import set_debug
|
||
from langchain_core.language_models import BaseLanguageModel
|
||
from langchain_core.messages import (
|
||
AIMessage,
|
||
BaseMessage,
|
||
HumanMessage,
|
||
SystemMessage,
|
||
ToolMessage,
|
||
)
|
||
from langchain_core.runnables.schema import StreamEvent
|
||
from langchain_core.tools import BaseTool
|
||
from pydantic import BaseModel
|
||
|
||
from mcp_use.agents.adapters.langchain_adapter import LangChainAdapter
|
||
from mcp_use.agents.display import log_agent_step, log_agent_stream
|
||
from mcp_use.agents.managers.base import BaseServerManager
|
||
from mcp_use.agents.managers.server_manager import ServerManager
|
||
from mcp_use.agents.middleware import tool_error_handler
|
||
|
||
# Import observability manager
|
||
from mcp_use.agents.observability import ObservabilityManager
|
||
from mcp_use.agents.prompts.system_prompt_builder import create_system_message
|
||
from mcp_use.agents.prompts.templates import (
|
||
DEFAULT_SYSTEM_PROMPT_TEMPLATE,
|
||
SERVER_MANAGER_SYSTEM_PROMPT_TEMPLATE,
|
||
)
|
||
from mcp_use.agents.remote import RemoteAgent
|
||
from mcp_use.client import MCPClient
|
||
from mcp_use.client.connectors.base import BaseConnector
|
||
from mcp_use.logging import logger
|
||
from mcp_use.telemetry.telemetry import Telemetry
|
||
from mcp_use.telemetry.utils import extract_model_info, track_agent_execution_from_agent
|
||
|
||
set_debug(logger.level == logging.DEBUG)
|
||
|
||
# Type variable for structured output
|
||
T = TypeVar("T", bound=BaseModel)
|
||
QueryInput = str | HumanMessage
|
||
|
||
|
||
class MCPAgent:
|
||
"""Main class for using MCP tools with various LLM providers.
|
||
|
||
This class provides a unified interface for using MCP tools with different LLM providers
|
||
through LangChain's agent framework, with customizable system prompts and conversation memory.
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
llm: BaseLanguageModel | None = None,
|
||
client: MCPClient | None = None,
|
||
connectors: list[BaseConnector] | None = None,
|
||
max_steps: int = 5,
|
||
auto_initialize: bool = False,
|
||
memory_enabled: bool = True,
|
||
system_prompt: str | None = None,
|
||
system_prompt_template: (str | None) = None, # User can still override the template
|
||
additional_instructions: str | None = None,
|
||
disallowed_tools: list[str] | None = None,
|
||
tools_used_names: list[str] | None = None,
|
||
use_server_manager: bool = False,
|
||
server_manager: BaseServerManager | None = None,
|
||
verbose: bool = False,
|
||
pretty_print: bool = False,
|
||
agent_id: str | None = None,
|
||
api_key: str | None = None,
|
||
base_url: str = "https://cloud.manufact.com",
|
||
callbacks: list | None = None,
|
||
chat_id: str | None = None,
|
||
retry_on_error: bool = True,
|
||
):
|
||
"""Initialize a new MCPAgent instance.
|
||
|
||
Args:
|
||
llm: The LangChain LLM to use. Not required if agent_id is provided for remote execution.
|
||
client: The MCPClient to use. If provided, connector is ignored.
|
||
connectors: A list of MCP connectors to use if client is not provided.
|
||
max_steps: The maximum number of steps to take.
|
||
auto_initialize: Whether to automatically initialize the agent when run is called.
|
||
memory_enabled: Whether to maintain conversation history for context.
|
||
system_prompt: Complete system prompt to use (overrides template if provided).
|
||
system_prompt_template: Template for system prompt with {tool_descriptions} placeholder.
|
||
additional_instructions: Extra instructions to append to the system prompt.
|
||
disallowed_tools: List of tool names that should not be available to the agent.
|
||
use_server_manager: Whether to use server manager mode instead of exposing all tools.
|
||
pretty_print: Whether to pretty print the output.
|
||
agent_id: Remote agent ID for remote execution. If provided, creates a remote agent.
|
||
api_key: API key for remote execution. If None, checks MCP_USE_API_KEY env var.
|
||
base_url: Base URL for remote API calls.
|
||
callbacks: List of LangChain callbacks to use. If None and Langfuse is configured, uses langfuse_handler.
|
||
retry_on_error: Whether to enable automatic error handling for tool calls. When True, tool errors
|
||
(including validation errors) are caught and returned as messages to the LLM, allowing it to
|
||
retry with corrected input. When False, errors will halt execution immediately. Default: True.
|
||
"""
|
||
# Handle remote execution
|
||
if agent_id is not None:
|
||
self._remote_agent = RemoteAgent(agent_id=agent_id, api_key=api_key, base_url=base_url, chat_id=chat_id)
|
||
self._is_remote = True
|
||
return
|
||
|
||
self._is_remote = False
|
||
self._remote_agent = None
|
||
|
||
# Validate requirements for local execution
|
||
if llm is None:
|
||
raise ValueError("llm is required for local execution. For remote execution, provide agent_id instead.")
|
||
|
||
self.llm = llm
|
||
self.client = client
|
||
self.connectors = connectors or []
|
||
self.max_steps = max_steps
|
||
# Recursion limit for langchain
|
||
self.recursion_limit = self.max_steps * 2
|
||
self.auto_initialize = auto_initialize
|
||
self.memory_enabled = memory_enabled
|
||
self._initialized = False
|
||
self._conversation_history: list[BaseMessage] = []
|
||
self.disallowed_tools = disallowed_tools or []
|
||
self.tools_used_names = tools_used_names or []
|
||
self.use_server_manager = use_server_manager
|
||
self.server_manager = server_manager
|
||
self.verbose = verbose
|
||
self.pretty_print = pretty_print
|
||
self.retry_on_error = retry_on_error
|
||
# System prompt configuration
|
||
self.system_prompt = system_prompt # User-provided full prompt override
|
||
# User can provide a template override, otherwise use the imported default
|
||
self.system_prompt_template_override = system_prompt_template
|
||
self.additional_instructions = additional_instructions
|
||
|
||
# Set up observability callbacks using the ObservabilityManager
|
||
self.observability_manager = ObservabilityManager(custom_callbacks=callbacks)
|
||
self.callbacks = self.observability_manager.get_callbacks()
|
||
|
||
# Either client or connector must be provided
|
||
if not client and len(self.connectors) == 0:
|
||
raise ValueError("Either client or connector must be provided")
|
||
|
||
# Create the adapter for tool conversion
|
||
self.adapter = LangChainAdapter(disallowed_tools=self.disallowed_tools)
|
||
self.adapter._record_telemetry = False
|
||
|
||
# Initialize telemetry
|
||
self.telemetry = Telemetry()
|
||
|
||
if self.use_server_manager and self.server_manager is None:
|
||
if not self.client:
|
||
raise ValueError("Client must be provided when using server manager")
|
||
self.server_manager = ServerManager(self.client, self.adapter)
|
||
|
||
# State tracking - initialize _tools as empty list
|
||
self._agent_executor = None
|
||
self._system_message: SystemMessage | None = None
|
||
self._tools: list[BaseTool] = []
|
||
|
||
# Track model info for telemetry
|
||
self._model_provider, self._model_name = extract_model_info(self.llm)
|
||
|
||
async def initialize(self) -> None:
|
||
"""Initialize the MCP client and agent."""
|
||
logger.info("🚀 Initializing MCP agent and connecting to services...")
|
||
# If using server manager, initialize it
|
||
if self.use_server_manager and self.server_manager:
|
||
await self.server_manager.initialize()
|
||
# Get server management tools
|
||
management_tools = self.server_manager.tools
|
||
self._tools = management_tools
|
||
logger.info(f"🔧 Server manager mode active with {len(management_tools)} management tools")
|
||
|
||
# Create the system message based on available tools
|
||
await self._create_system_message_from_tools(self._tools)
|
||
else:
|
||
# Standard initialization - if using client, get or create sessions
|
||
if self.client:
|
||
# Disable telemetry for the client
|
||
self.client._record_telemetry = False
|
||
# First try to get existing sessions
|
||
self._sessions = self.client.get_all_active_sessions()
|
||
logger.info(f"🔌 Found {len(self._sessions)} existing sessions")
|
||
|
||
# If no active sessions exist, create new ones
|
||
if not self._sessions:
|
||
logger.info("🔄 No active sessions found, creating new ones...")
|
||
self._sessions = await self.client.create_all_sessions()
|
||
self.connectors = [session.connector for session in self._sessions.values()]
|
||
logger.info(f"✅ Created {len(self._sessions)} new sessions")
|
||
|
||
# Create LangChain tools directly from the client using the adapter
|
||
await self.adapter.create_all(self.client)
|
||
self._tools = self.adapter.tools + self.adapter.resources + self.adapter.prompts
|
||
logger.info(
|
||
f"🛠️ Created {len(self._tools)} LangChain tools from client: "
|
||
f"{len(self.adapter.tools)} tools, {len(self.adapter.resources)} resources, "
|
||
f"{len(self.adapter.prompts)} prompts"
|
||
)
|
||
else:
|
||
# Using direct connector - only establish connection
|
||
# LangChainAdapter will handle initialization
|
||
connectors_to_use = self.connectors
|
||
logger.info(f"🔗 Connecting to {len(connectors_to_use)} direct connectors...")
|
||
for connector in connectors_to_use:
|
||
# Disable telemetry for the connector
|
||
connector._record_telemetry = False
|
||
if not hasattr(connector, "client_session") or connector.client_session is None:
|
||
await connector.connect()
|
||
|
||
# Create LangChain tools using the adapter with connectors
|
||
await self.adapter._create_tools_from_connectors(connectors_to_use)
|
||
await self.adapter._create_resources_from_connectors(connectors_to_use)
|
||
await self.adapter._create_prompts_from_connectors(connectors_to_use)
|
||
self._tools = self.adapter.tools + self.adapter.resources + self.adapter.prompts
|
||
logger.info(
|
||
f"🛠️ Created {len(self._tools)} LangChain tools from connectors: "
|
||
f"{len(self.adapter.tools)} tools, {len(self.adapter.resources)} resources, "
|
||
f"{len(self.adapter.prompts)} prompts"
|
||
)
|
||
|
||
# Get all tools for system message generation
|
||
all_tools = self._tools
|
||
logger.info(f"🧰 Found {len(all_tools)} tools across all connectors")
|
||
|
||
# Create the system message based on available tools
|
||
await self._create_system_message_from_tools(all_tools)
|
||
|
||
# Create the agent
|
||
self._agent_executor = self._create_agent()
|
||
self._initialized = True
|
||
logger.info("✨ Agent initialization complete")
|
||
|
||
def _ensure_human_message(self, query: QueryInput) -> HumanMessage:
|
||
"""Return the provided query as a HumanMessage."""
|
||
if isinstance(query, HumanMessage):
|
||
return query
|
||
if isinstance(query, str):
|
||
return HumanMessage(content=query)
|
||
raise TypeError("query must be a string or HumanMessage")
|
||
|
||
def _message_text(self, message: HumanMessage) -> str:
|
||
"""Extract readable text from a HumanMessage for logging/telemetry."""
|
||
content = message.content
|
||
if isinstance(content, str):
|
||
return content
|
||
|
||
if isinstance(content, list):
|
||
text_parts: list[str] = []
|
||
for part in content:
|
||
if isinstance(part, dict):
|
||
if part.get("type") == "text" and "text" in part:
|
||
text_parts.append(str(part.get("text") or ""))
|
||
if text_parts:
|
||
return " ".join(text_parts)
|
||
return "[non-text content]"
|
||
|
||
return str(content)
|
||
|
||
def _message_preview(self, message: HumanMessage, limit: int = 50) -> str:
|
||
"""Create a short preview of the query for logs."""
|
||
text = self._message_text(message)
|
||
text = text.replace("\n", " ")
|
||
return text[:limit] + ("..." if len(text) > limit else "")
|
||
|
||
def _normalize_output(self, value: object) -> str:
|
||
"""Normalize model outputs into a plain text string."""
|
||
try:
|
||
if isinstance(value, str):
|
||
return value
|
||
|
||
# LangChain messages may have .content which is str or list-like
|
||
content = getattr(value, "content", None)
|
||
if content is not None:
|
||
return self._normalize_output(content)
|
||
|
||
if isinstance(value, list):
|
||
parts: list[str] = []
|
||
for item in value:
|
||
if isinstance(item, dict):
|
||
# Cast to dict[str, Any] since isinstance doesn't narrow the key/value types
|
||
item_dict = cast(dict[str, Any], item)
|
||
if "text" in item_dict and isinstance(item_dict["text"], str):
|
||
parts.append(item_dict["text"])
|
||
elif "content" in item_dict:
|
||
parts.append(self._normalize_output(item_dict["content"]))
|
||
else:
|
||
# Fallback to str for unknown shapes
|
||
parts.append(str(item))
|
||
else:
|
||
# recurse on .content or str
|
||
part_content = getattr(item, "text", None)
|
||
if isinstance(part_content, str):
|
||
parts.append(part_content)
|
||
else:
|
||
parts.append(self._normalize_output(getattr(item, "content", item)))
|
||
return "".join(parts)
|
||
|
||
return str(value)
|
||
|
||
except Exception:
|
||
return str(value)
|
||
|
||
async def _create_system_message_from_tools(self, tools: list[BaseTool]) -> None:
|
||
"""Create the system message based on provided tools using the builder."""
|
||
# Use the override if provided, otherwise use the imported default
|
||
default_template = self.system_prompt_template_override or DEFAULT_SYSTEM_PROMPT_TEMPLATE
|
||
# Server manager template is now also imported
|
||
server_template = SERVER_MANAGER_SYSTEM_PROMPT_TEMPLATE
|
||
|
||
# Delegate creation to the imported function
|
||
self._system_message = create_system_message(
|
||
tools=tools,
|
||
system_prompt_template=default_template,
|
||
server_manager_template=server_template, # Pass the imported template
|
||
use_server_manager=self.use_server_manager,
|
||
disallowed_tools=self.disallowed_tools,
|
||
user_provided_prompt=self.system_prompt,
|
||
additional_instructions=self.additional_instructions,
|
||
)
|
||
|
||
# Update conversation history if memory is enabled
|
||
# Note: The system message should not be included in the conversation history,
|
||
# as it will be automatically added using the create_tool_calling_agent function with the prompt parameter
|
||
if self.memory_enabled:
|
||
self._conversation_history = [
|
||
msg for msg in self._conversation_history if not isinstance(msg, SystemMessage)
|
||
]
|
||
|
||
def _create_agent(self):
|
||
"""Create the LangChain agent with the configured system message.
|
||
|
||
Returns:
|
||
An initialized AgentExecutor.
|
||
"""
|
||
logger.debug(f"Creating new agent with {len(self._tools)} tools")
|
||
|
||
# Use SystemMessage directly or create a default one
|
||
system_prompt: SystemMessage | str = self._system_message or "You are a helpful assistant"
|
||
|
||
tool_names = [tool.name for tool in self._tools]
|
||
logger.info(f"🧠 Agent ready with tools: {', '.join(tool_names)}")
|
||
|
||
# Create middleware stack
|
||
middleware = []
|
||
|
||
# Add tool error handler if retry_on_error is enabled
|
||
if self.retry_on_error:
|
||
middleware.append(tool_error_handler)
|
||
logger.debug("Tool error handler middleware enabled (retry_on_error=True)")
|
||
|
||
# Always add model call limit middleware
|
||
middleware.append(ModelCallLimitMiddleware(run_limit=self.max_steps))
|
||
|
||
# Use the standard create_agent with middleware
|
||
# Type assertion: self.llm is guaranteed to be non-None for local execution
|
||
from langchain_core.language_models.chat_models import BaseChatModel
|
||
|
||
# Cast to BaseChatModel to satisfy type checker
|
||
llm_model = self.llm
|
||
assert isinstance(llm_model, BaseChatModel), "LLM must be a BaseChatModel instance"
|
||
|
||
agent = create_agent(
|
||
model=llm_model,
|
||
tools=self._tools,
|
||
system_prompt=system_prompt,
|
||
middleware=middleware,
|
||
debug=self.verbose,
|
||
).with_config({"recursion_limit": self.recursion_limit})
|
||
|
||
logger.debug(
|
||
f"Created agent with max_steps={self.max_steps} (via ModelCallLimitMiddleware) "
|
||
f"and {len(self.callbacks)} callbacks"
|
||
)
|
||
return agent
|
||
|
||
def get_conversation_history(self) -> list[BaseMessage]:
|
||
"""Get the current conversation history.
|
||
|
||
Returns:
|
||
The list of conversation messages.
|
||
"""
|
||
return self._conversation_history
|
||
|
||
def clear_conversation_history(self) -> None:
|
||
"""Clear the conversation history."""
|
||
self._conversation_history = []
|
||
|
||
def add_to_history(self, message: BaseMessage) -> None:
|
||
"""Add a message to the conversation history.
|
||
|
||
Args:
|
||
message: The message to add.
|
||
"""
|
||
if self.memory_enabled:
|
||
self._conversation_history.append(message)
|
||
|
||
def get_system_message(self) -> SystemMessage | None:
|
||
"""Get the current system message.
|
||
|
||
Returns:
|
||
The current system message, or None if not set.
|
||
"""
|
||
return self._system_message
|
||
|
||
def set_system_message(self, message: str) -> None:
|
||
"""Set a new system message.
|
||
|
||
Args:
|
||
message: The new system message content.
|
||
"""
|
||
self._system_message = SystemMessage(content=message)
|
||
|
||
# Recreate the agent with the new system message if initialized
|
||
if self._initialized and self._tools:
|
||
self._agent_executor = self._create_agent()
|
||
logger.debug("Agent recreated with new system message")
|
||
|
||
def set_disallowed_tools(self, disallowed_tools: list[str]) -> None:
|
||
"""Set the list of tools that should not be available to the agent.
|
||
|
||
This will take effect the next time the agent is initialized.
|
||
|
||
Args:
|
||
disallowed_tools: List of tool names that should not be available.
|
||
"""
|
||
self.disallowed_tools = disallowed_tools
|
||
self.adapter.disallowed_tools = disallowed_tools
|
||
|
||
# If the agent is already initialized, we need to reinitialize it
|
||
# to apply the changes to the available tools
|
||
if self._initialized:
|
||
logger.debug("Agent already initialized. Changes will take effect on next initialization.")
|
||
# We don't automatically reinitialize here as it could be disruptive
|
||
# to ongoing operations. The user can call initialize() explicitly if needed.
|
||
|
||
def get_disallowed_tools(self) -> list[str]:
|
||
"""Get the list of tools that are not available to the agent.
|
||
|
||
Returns:
|
||
List of tool names that are not available.
|
||
"""
|
||
return self.disallowed_tools
|
||
|
||
async def _consume_and_return(
|
||
self,
|
||
generator: AsyncGenerator[str | T, None],
|
||
) -> tuple[str | T, int]:
|
||
"""Consume the stream generator and return the final result.
|
||
|
||
This is used by the run() method with the astream implementation.
|
||
|
||
Args:
|
||
generator: The async generator from astream.
|
||
|
||
Returns:
|
||
A tuple of (final_result, steps_taken). final_result can be a string
|
||
for regular output or a Pydantic model instance for structured output.
|
||
"""
|
||
final_result = ""
|
||
steps_taken = 0
|
||
async for item in generator:
|
||
# The last item yielded is always the final result
|
||
final_result = item
|
||
# Count steps as the number of tools used during execution
|
||
steps_taken = len(self.tools_used_names)
|
||
return final_result, steps_taken
|
||
|
||
async def run(
|
||
self,
|
||
query: QueryInput,
|
||
max_steps: int | None = None,
|
||
manage_connector: bool = True,
|
||
external_history: list[BaseMessage] | None = None,
|
||
output_schema: type[T] | None = None,
|
||
) -> str | T:
|
||
"""Run a query using LangChain 1.0.0's agent and return the final result.
|
||
|
||
Args:
|
||
query: The query to run. Accepts a plain string or a ``HumanMessage`` when
|
||
you need to include richer content (e.g., multi-part messages or files).
|
||
max_steps: Optional maximum number of steps to take.
|
||
manage_connector: Whether to handle the connector lifecycle internally.
|
||
external_history: Optional external history to use instead of the
|
||
internal conversation history.
|
||
output_schema: Optional Pydantic BaseModel class for structured output.
|
||
|
||
Returns:
|
||
The result of running the query as a string, or if output_schema is provided,
|
||
an instance of the specified Pydantic model.
|
||
|
||
Example:
|
||
```python
|
||
# Regular usage
|
||
result = await agent.run("What's the weather like?")
|
||
|
||
# Structured output usage
|
||
from pydantic import BaseModel, Field
|
||
|
||
class WeatherInfo(BaseModel):
|
||
temperature: float = Field(description="Temperature in Celsius")
|
||
condition: str = Field(description="Weather condition")
|
||
|
||
weather: WeatherInfo = await agent.run(
|
||
"What's the weather like?",
|
||
output_schema=WeatherInfo
|
||
)
|
||
```
|
||
"""
|
||
# Delegate to remote agent if in remote mode
|
||
if self._is_remote and self._remote_agent:
|
||
query_str: str = query if isinstance(query, str) else self._message_text(query)
|
||
result = await self._remote_agent.run(query_str, max_steps, external_history, output_schema)
|
||
return result
|
||
|
||
success = True
|
||
start_time = time.time()
|
||
|
||
human_query = self._ensure_human_message(query)
|
||
|
||
generator = self.stream(
|
||
human_query,
|
||
max_steps,
|
||
manage_connector,
|
||
external_history,
|
||
track_execution=False,
|
||
output_schema=output_schema,
|
||
)
|
||
error = None
|
||
result = None
|
||
steps_taken = 0
|
||
try:
|
||
result, steps_taken = await self._consume_and_return(generator)
|
||
|
||
except Exception as e:
|
||
success = False
|
||
error = str(e)
|
||
logger.error(f"❌ Error during agent execution: {e}")
|
||
raise
|
||
finally:
|
||
track_agent_execution_from_agent(
|
||
self,
|
||
execution_method="run",
|
||
query=self._message_text(human_query),
|
||
success=success,
|
||
execution_time_ms=int((time.time() - start_time) * 1000),
|
||
max_steps_used=max_steps,
|
||
manage_connector=manage_connector,
|
||
external_history_used=external_history is not None,
|
||
steps_taken=steps_taken,
|
||
response=str(self._normalize_output(result)),
|
||
error_type=error,
|
||
)
|
||
return result
|
||
|
||
async def _attempt_structured_output(
|
||
self,
|
||
raw_result: str,
|
||
structured_llm,
|
||
output_schema: type[T],
|
||
schema_description: str,
|
||
) -> T:
|
||
"""Attempt to create structured output from raw result with validation."""
|
||
format_prompt = f"""
|
||
Please format the following information according to the specified schema.
|
||
Extract and structure the relevant information from the content below.
|
||
|
||
Required schema fields:
|
||
{schema_description}
|
||
|
||
Content to format:
|
||
{raw_result}
|
||
|
||
Please provide the information in the requested structured format.
|
||
If any required information is missing, you must indicate this clearly.
|
||
"""
|
||
|
||
structured_result = await structured_llm.ainvoke(format_prompt)
|
||
|
||
try:
|
||
for field_name, field_info in output_schema.model_fields.items():
|
||
required = not hasattr(field_info, "default") or field_info.default is None
|
||
if required:
|
||
value = getattr(structured_result, field_name, None)
|
||
if value is None or (isinstance(value, str) and not value.strip()):
|
||
raise ValueError(f"Required field '{field_name}' is missing or empty")
|
||
if isinstance(value, list) and len(value) == 0:
|
||
raise ValueError(f"Required field '{field_name}' is an empty list")
|
||
except Exception as e:
|
||
logger.debug(f"Validation details: {e}")
|
||
raise # Re-raise to trigger retry logic
|
||
|
||
return structured_result
|
||
|
||
def _enhance_query_with_schema(self, query: str, output_schema: type[T]) -> str:
|
||
"""Enhance the query with schema information to make the agent aware of required fields."""
|
||
schema_fields = []
|
||
|
||
try:
|
||
for field_name, field_info in output_schema.model_fields.items():
|
||
description = getattr(field_info, "description", "") or field_name
|
||
required = not hasattr(field_info, "default") or field_info.default is None
|
||
schema_fields.append(f"- {field_name}: {description} {'(required)' if required else '(optional)'}")
|
||
|
||
schema_description = "\n".join(schema_fields)
|
||
except Exception as e:
|
||
logger.warning(f"Could not extract schema details: {e}")
|
||
schema_description = f"Schema: {output_schema.__name__}"
|
||
|
||
# Enhance the query with schema awareness
|
||
enhanced_query = f"""
|
||
{query}
|
||
|
||
IMPORTANT: Your response must include sufficient information to populate the following structured output:
|
||
|
||
{schema_description}
|
||
|
||
Make sure you gather ALL the required information during your task execution.
|
||
If any required information is missing, continue working to find it.
|
||
"""
|
||
|
||
return enhanced_query
|
||
|
||
async def stream(
|
||
self,
|
||
query: QueryInput,
|
||
max_steps: int | None = None,
|
||
manage_connector: bool = True,
|
||
external_history: list[BaseMessage] | None = None,
|
||
track_execution: bool = True,
|
||
output_schema: type[T] | None = None,
|
||
) -> AsyncGenerator[tuple[AgentAction, str] | str | T, None]:
|
||
"""Async generator using LangChain 1.0.0's create_agent and astream.
|
||
|
||
This method leverages the LangChain 1.0.0 API where create_agent returns
|
||
a CompiledStateGraph that handles the agent loop internally via astream.
|
||
|
||
**Tool Updates with Server Manager:**
|
||
When using server_manager mode, this method handles dynamic tool updates:
|
||
- **Before execution:** Updates are applied immediately to the new stream
|
||
- **During execution:** When tools change, we wait for a "safe restart point"
|
||
(after tool results complete), then interrupt the stream, recreate the agent
|
||
with new tools, and resume execution with accumulated messages.
|
||
- **Safe restart points:** Only restart after tool results to ensure message
|
||
pairs (tool_use + tool_result) are complete, satisfying LLM API requirements.
|
||
- **Max restarts:** Limited to 3 restarts to prevent infinite loops
|
||
|
||
This interrupt-and-restart approach ensures that tools added mid-execution
|
||
(e.g., via connect_to_mcp_server) are immediately available to the agent,
|
||
maintaining the same behavior as the legacy implementation while respecting
|
||
API constraints.
|
||
|
||
Args:
|
||
query: The query to run. Accepts a plain string or a ``HumanMessage`` when
|
||
you need to include richer content (e.g., multi-part messages or files).
|
||
manage_connector: Whether to handle the connector lifecycle internally.
|
||
external_history: Optional external history to use instead of the
|
||
internal conversation history.
|
||
output_schema: Optional Pydantic BaseModel class for structured output.
|
||
|
||
Yields:
|
||
Intermediate steps and final result from the agent execution.
|
||
"""
|
||
# Delegate to remote agent if in remote mode
|
||
if self._is_remote and self._remote_agent:
|
||
query_str: str = query if isinstance(query, str) else self._message_text(query)
|
||
async for item in self._remote_agent.stream(query_str, max_steps, external_history, output_schema):
|
||
yield item
|
||
return
|
||
|
||
human_query = self._ensure_human_message(query)
|
||
initialized_here = False
|
||
start_time = time.time()
|
||
success = False
|
||
final_output = None
|
||
steps_taken = 0
|
||
|
||
try:
|
||
# 1. Initialize if needed
|
||
if manage_connector and not self._initialized:
|
||
await self.initialize()
|
||
initialized_here = True
|
||
elif not self._initialized and self.auto_initialize:
|
||
await self.initialize()
|
||
initialized_here = True
|
||
|
||
if not self._agent_executor:
|
||
raise RuntimeError("MCP agent failed to initialize")
|
||
|
||
# Check for tool updates before starting execution (if using server manager)
|
||
if self.use_server_manager and self.server_manager:
|
||
current_tools = self.server_manager.tools
|
||
current_tool_names = {tool.name for tool in current_tools}
|
||
existing_tool_names = {tool.name for tool in self._tools}
|
||
|
||
if current_tool_names != existing_tool_names:
|
||
logger.info(
|
||
f"🔄 Tools changed before execution, updating agent. New tools: {', '.join(current_tool_names)}"
|
||
)
|
||
self._tools = current_tools
|
||
# Regenerate system message with ALL current tools
|
||
await self._create_system_message_from_tools(self._tools)
|
||
# Recreate the agent executor with the new tools and system message
|
||
self._agent_executor = self._create_agent()
|
||
|
||
# 2. Build inputs for the agent
|
||
history_to_use = external_history if external_history is not None else self._conversation_history
|
||
|
||
# Convert messages to format expected by LangChain agent
|
||
langchain_history = []
|
||
for msg in history_to_use:
|
||
if isinstance(msg, HumanMessage | AIMessage | ToolMessage):
|
||
langchain_history.append(msg)
|
||
|
||
inputs = {"messages": [*langchain_history, human_query]}
|
||
|
||
display_query = self._message_preview(human_query)
|
||
logger.info(f"💬 Received query: '{display_query}'")
|
||
logger.info("🏁 Starting agent execution")
|
||
|
||
# 3. Stream using the built-in astream from CompiledStateGraph
|
||
# The agent graph handles the loop internally
|
||
# With dynamic tool reload: if tools change mid-execution, we interrupt and restart
|
||
max_restarts = 3 # Prevent infinite restart loops
|
||
restart_count = 0
|
||
accumulated_messages = list(langchain_history) + [human_query]
|
||
pending_tool_calls = {} # Map tool_call_id -> AgentAction
|
||
|
||
while restart_count <= max_restarts:
|
||
# Update inputs with accumulated messages
|
||
inputs = {"messages": accumulated_messages}
|
||
should_restart = False
|
||
|
||
async for chunk in self._agent_executor.astream(
|
||
inputs,
|
||
stream_mode="updates", # Get updates as they happen
|
||
config={
|
||
"callbacks": self.callbacks,
|
||
"recursion_limit": self.recursion_limit,
|
||
},
|
||
):
|
||
# chunk is a dict with node names as keys
|
||
# The agent node will have 'messages' with the AI response
|
||
# The tools node will have 'messages' with tool calls and results
|
||
|
||
for node_name, node_output in chunk.items():
|
||
logger.debug(f"📦 Node '{node_name}' output: {node_output}")
|
||
|
||
# Extract messages from the node output and accumulate them
|
||
if node_output is not None and "messages" in node_output:
|
||
messages = node_output["messages"]
|
||
if not isinstance(messages, list):
|
||
messages = [messages]
|
||
|
||
# Add new messages to accumulated messages for potential restart
|
||
for msg in messages:
|
||
if msg not in accumulated_messages:
|
||
accumulated_messages.append(msg)
|
||
for message in messages:
|
||
# Track tool calls
|
||
if hasattr(message, "tool_calls") and message.tool_calls:
|
||
# Extract text content from message for the log
|
||
log_text = ""
|
||
if hasattr(message, "content"):
|
||
if isinstance(message.content, str):
|
||
log_text = message.content
|
||
elif isinstance(message.content, list):
|
||
# Extract text blocks from content array
|
||
text_parts = [
|
||
(block.get("text", "") if isinstance(block, dict) else str(block))
|
||
for block in message.content
|
||
if isinstance(block, dict) and block.get("type") == "text"
|
||
]
|
||
log_text = "\n".join(text_parts)
|
||
|
||
for tool_call in message.tool_calls:
|
||
tool_name = tool_call.get("name", "unknown")
|
||
tool_input = tool_call.get("args", {})
|
||
tool_call_id = tool_call.get("id")
|
||
|
||
action = AgentAction(
|
||
tool=tool_name,
|
||
tool_input=tool_input,
|
||
log=log_text,
|
||
)
|
||
if tool_call_id:
|
||
pending_tool_calls[tool_call_id] = action
|
||
|
||
self.tools_used_names.append(tool_name)
|
||
steps_taken += 1
|
||
|
||
tool_input_str = str(tool_input)
|
||
if len(tool_input_str) > 100:
|
||
tool_input_str = tool_input_str[:97] + "..."
|
||
|
||
# Track tool results and yield AgentStep
|
||
if isinstance(message, ToolMessage):
|
||
observation = message.content
|
||
tool_call_id = message.tool_call_id
|
||
|
||
if tool_call_id and tool_call_id in pending_tool_calls:
|
||
action = pending_tool_calls.pop(tool_call_id)
|
||
item = (action, str(observation))
|
||
log_agent_step(item, pretty_print=self.pretty_print)
|
||
yield item
|
||
|
||
observation_str = str(observation)
|
||
if len(observation_str) > 100:
|
||
observation_str = observation_str[:97] + "..."
|
||
observation_str = observation_str.replace("\n", " ")
|
||
|
||
# --- Check for tool updates after tool results (safe restart point) ---
|
||
if self.use_server_manager and self.server_manager:
|
||
current_tools = self.server_manager.tools
|
||
current_tool_names = {tool.name for tool in current_tools}
|
||
existing_tool_names = {tool.name for tool in self._tools}
|
||
|
||
if current_tool_names != existing_tool_names:
|
||
logger.info(
|
||
f"🔄 Tools changed during execution. "
|
||
f"New tools: {', '.join(current_tool_names)}"
|
||
)
|
||
self._tools = current_tools
|
||
# Regenerate system message with ALL current tools
|
||
await self._create_system_message_from_tools(self._tools)
|
||
# Recreate the agent executor with the new tools and system message
|
||
self._agent_executor = self._create_agent()
|
||
|
||
# Set restart flag - safe to restart now after tool results
|
||
should_restart = True
|
||
restart_count += 1
|
||
logger.info(
|
||
f"🔃 Restarting execution with updated tools "
|
||
f"(restart {restart_count}/{max_restarts})"
|
||
)
|
||
break # Break out of the message loop
|
||
|
||
# Track final AI message (without tool calls = final response)
|
||
if isinstance(message, AIMessage) and not message.tool_calls:
|
||
final_output = self._normalize_output(message.content)
|
||
logger.info("✅ Agent finished with output")
|
||
|
||
# Break out of node loop if restarting
|
||
if should_restart:
|
||
break
|
||
|
||
# Break out of chunk loop if restarting
|
||
if should_restart:
|
||
break
|
||
|
||
# Check if we should restart or if execution completed
|
||
if not should_restart:
|
||
# Execution completed successfully without tool changes
|
||
break
|
||
|
||
# If we've hit max restarts, log warning and continue
|
||
if restart_count > max_restarts:
|
||
logger.warning(f"⚠️ Max restarts ({max_restarts}) reached. Continuing with current tools.")
|
||
break
|
||
|
||
# 4. Update conversation history (store full transcript including tool exchange)
|
||
if self.memory_enabled and external_history is None:
|
||
self._conversation_history = [msg for msg in accumulated_messages if not isinstance(msg, SystemMessage)]
|
||
|
||
# 5. Handle structured output if requested
|
||
if output_schema and final_output:
|
||
try:
|
||
logger.info("🔧 Attempting structured output...")
|
||
structured_llm = self.llm.with_structured_output(output_schema)
|
||
|
||
# Get schema description
|
||
schema_fields = []
|
||
for field_name, field_info in output_schema.model_fields.items():
|
||
description = getattr(field_info, "description", "") or field_name
|
||
required = not hasattr(field_info, "default") or field_info.default is None
|
||
schema_fields.append(
|
||
f"- {field_name}: {description} " + ("(required)" if required else "(optional)")
|
||
)
|
||
schema_description = "\n".join(schema_fields)
|
||
|
||
structured_result = await self._attempt_structured_output(
|
||
final_output, structured_llm, output_schema, schema_description
|
||
)
|
||
|
||
if self.memory_enabled and external_history is None:
|
||
self.add_to_history(AIMessage(content=f"Structured result: {structured_result}"))
|
||
|
||
logger.info("✅ Structured output successful")
|
||
success = True
|
||
yield structured_result
|
||
return
|
||
except Exception as e:
|
||
logger.error(f"❌ Structured output failed: {e}")
|
||
raise RuntimeError(f"Failed to generate structured output: {str(e)}") from e
|
||
|
||
# 6. Yield final result
|
||
logger.info(f"🎉 Agent execution complete in {time.time() - start_time:.2f} seconds")
|
||
success = True
|
||
yield final_output or "No output generated"
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Error running query: {e}")
|
||
if initialized_here and manage_connector:
|
||
logger.info("🧹 Cleaning up resources after error")
|
||
await self.close()
|
||
raise
|
||
|
||
finally:
|
||
execution_time_ms = int((time.time() - start_time) * 1000)
|
||
|
||
if track_execution:
|
||
track_agent_execution_from_agent(
|
||
self,
|
||
execution_method="stream",
|
||
query=self._message_text(human_query),
|
||
success=success,
|
||
execution_time_ms=execution_time_ms,
|
||
max_steps_used=max_steps,
|
||
manage_connector=manage_connector,
|
||
external_history_used=external_history is not None,
|
||
steps_taken=steps_taken,
|
||
response=final_output,
|
||
error_type=None if success else "execution_error",
|
||
)
|
||
|
||
# Clean up if necessary
|
||
if manage_connector and not self.client and initialized_here:
|
||
logger.info("🧹 Closing agent after stream completion")
|
||
await self.close()
|
||
|
||
async def _generate_response_chunks_async(
|
||
self,
|
||
query: QueryInput,
|
||
max_steps: int | None = None,
|
||
manage_connector: bool = True,
|
||
external_history: list[BaseMessage] | None = None,
|
||
) -> AsyncIterator[StreamEvent]:
|
||
"""Internal async generator yielding response chunks.
|
||
|
||
The implementation purposefully keeps the logic compact:
|
||
1. Ensure the agent is initialised (optionally handling connector
|
||
lifecycle).
|
||
2. Forward the *same* inputs we use for ``run`` to LangChain's
|
||
``AgentExecutor.astream``.
|
||
3. Diff the growing ``output`` field coming from LangChain and yield
|
||
only the new part so the caller receives *incremental* chunks.
|
||
4. Persist conversation history when memory is enabled.
|
||
"""
|
||
|
||
# 1. Initialise on-demand ------------------------------------------------
|
||
initialised_here = False
|
||
if (manage_connector and not self._initialized) or (not self._initialized and self.auto_initialize):
|
||
await self.initialize()
|
||
initialised_here = True
|
||
|
||
if not self._agent_executor:
|
||
raise RuntimeError("MCP agent failed to initialise – call initialise() first?")
|
||
|
||
# 2. Configure max steps -------------------------------------------------
|
||
self.max_steps = max_steps or self.max_steps
|
||
|
||
# 3. Build inputs --------------------------------------------------------
|
||
human_query = self._ensure_human_message(query)
|
||
history_to_use = external_history if external_history is not None else self._conversation_history
|
||
langchain_history: list[BaseMessage] = [msg for msg in history_to_use if not isinstance(msg, SystemMessage)]
|
||
inputs = {"messages": [*langchain_history, human_query]}
|
||
|
||
# 4. Stream & collect response chunks ------------------------------------
|
||
recursion_limit = self.max_steps * 2
|
||
# Collect AI message content from streaming chunks
|
||
turn_messages = []
|
||
|
||
async for event in self._agent_executor.astream_events(
|
||
inputs,
|
||
config={
|
||
"callbacks": self.callbacks,
|
||
"recursion_limit": recursion_limit,
|
||
},
|
||
):
|
||
event_type = event.get("event")
|
||
if event_type == "on_chat_model_end":
|
||
# This contains the AIMessage
|
||
ai_message: AIMessage = event.get("data", {}).get("output")
|
||
turn_messages.append(ai_message)
|
||
if event_type == "on_tool_end":
|
||
# This contains the ToolMessage
|
||
tool_message: ToolMessage = event.get("data", {}).get("output")
|
||
turn_messages.append(tool_message)
|
||
|
||
yield event
|
||
|
||
# 5. Update conversation history with both messages ---------------------
|
||
# If external_history is provided, treat it as per-call input (do not mutate internal memory).
|
||
persist_to_memory = self.memory_enabled and external_history is None
|
||
if persist_to_memory:
|
||
# Add human message first
|
||
self.add_to_history(self._ensure_human_message(query))
|
||
for message in turn_messages:
|
||
self.add_to_history(message)
|
||
|
||
# 6. House-keeping -------------------------------------------------------
|
||
# Restrict agent cleanup in _generate_response_chunks_async to only occur
|
||
# when the agent was initialized in this generator and is not client-managed
|
||
# and the user does want us to manage the connection.
|
||
if not self.client and initialised_here and manage_connector:
|
||
logger.info("🧹 Closing agent after generator completion")
|
||
await self.close()
|
||
|
||
async def stream_events(
|
||
self,
|
||
query: QueryInput,
|
||
max_steps: int | None = None,
|
||
manage_connector: bool = True,
|
||
external_history: list[BaseMessage] | None = None,
|
||
) -> AsyncIterator[StreamEvent]:
|
||
"""Asynchronous streaming interface.
|
||
|
||
Example::
|
||
|
||
async for chunk in agent.stream_events("hello"):
|
||
print(chunk)
|
||
"""
|
||
start_time = time.time()
|
||
success = False
|
||
chunk_count = 0
|
||
human_query = self._ensure_human_message(query)
|
||
|
||
try:
|
||
async for chunk in self._generate_response_chunks_async(
|
||
query=human_query,
|
||
max_steps=max_steps,
|
||
manage_connector=manage_connector,
|
||
external_history=external_history,
|
||
):
|
||
log_agent_stream(chunk, pretty_print=self.pretty_print)
|
||
chunk_count += 1
|
||
yield chunk
|
||
success = True
|
||
finally:
|
||
execution_time_ms = int((time.time() - start_time) * 1000)
|
||
|
||
track_agent_execution_from_agent(
|
||
self,
|
||
execution_method="stream_events",
|
||
query=self._message_text(human_query),
|
||
success=success,
|
||
execution_time_ms=execution_time_ms,
|
||
max_steps_used=max_steps,
|
||
manage_connector=manage_connector,
|
||
external_history_used=external_history is not None,
|
||
response="[STREAMED RESPONSE]",
|
||
error_type=None if success else "streaming_error",
|
||
)
|
||
|
||
async def close(self) -> None:
|
||
"""Close the MCP connection with improved error handling."""
|
||
# Delegate to remote agent if in remote mode
|
||
if self._is_remote and self._remote_agent:
|
||
await self._remote_agent.close()
|
||
return
|
||
|
||
logger.info("🔌 Closing agent and cleaning up resources...")
|
||
try:
|
||
# Clean up the agent first
|
||
self._agent_executor = None
|
||
self._tools = []
|
||
|
||
# If using client with session, close the session through client
|
||
if self.client:
|
||
logger.info("🔄 Closing sessions through client")
|
||
await self.client.close_all_sessions()
|
||
if hasattr(self, "_sessions"):
|
||
self._sessions = {}
|
||
# If using direct connector, disconnect
|
||
elif self.connectors:
|
||
for connector in self.connectors:
|
||
logger.info("🔄 Disconnecting connector")
|
||
await connector.disconnect()
|
||
|
||
# Clear adapter tool cache
|
||
if hasattr(self.adapter, "_connector_tool_map"):
|
||
self.adapter._connector_tool_map = {}
|
||
|
||
self._initialized = False
|
||
logger.info("👋 Agent closed successfully")
|
||
|
||
except Exception as e:
|
||
logger.error(f"❌ Error during agent closure: {e}")
|
||
# Still try to clean up references even if there was an error
|
||
self._agent_executor = None
|
||
if hasattr(self, "_tools"):
|
||
self._tools = []
|
||
if hasattr(self, "_sessions"):
|
||
self._sessions = {}
|
||
self._initialized = False
|