From a03ef331f98833c090f1f4377ed242e0aa7a16bd Mon Sep 17 00:00:00 2001 From: qinxuye Date: Sun, 12 Jul 2026 23:00:49 +0800 Subject: [PATCH 1/4] enh: optimize routed agent context usage --- src/xagent/core/agent/pattern/auto/auto.py | 19 +- src/xagent/core/agent/pattern/react/react.py | 69 +++++- src/xagent/core/agent/runtime.py | 46 ++++ .../core/model/chat/basic/openrouter.py | 133 ++++++++++-- src/xagent/core/model/chat/basic/router.py | 204 +++++++++++++++++- .../core/tools/adapters/vibe/browser_use.py | 13 +- .../adapters/vibe/workspace_file_tool.py | 6 +- .../core/tools/core/workspace_file_tool.py | 6 +- src/xagent/core/workspace.py | 6 +- tests/core/agent/test_browser_tools.py | 14 ++ tests/core/agent/test_react.py | 42 ++++ tests/core/agent/test_runtime.py | 34 ++- .../core/model/chat/basic/test_openrouter.py | 177 +++++++++++++++ tests/core/model/chat/basic/test_router.py | 29 +++ .../tools/test_workspace_file_operations.py | 3 +- 15 files changed, 754 insertions(+), 47 deletions(-) diff --git a/src/xagent/core/agent/pattern/auto/auto.py b/src/xagent/core/agent/pattern/auto/auto.py index cce142ae2..a3ab63a11 100644 --- a/src/xagent/core/agent/pattern/auto/auto.py +++ b/src/xagent/core/agent/pattern/auto/auto.py @@ -24,7 +24,12 @@ normalize_response_language_label, output_language_policy, ) -from ...runtime import LLMCallInterrupted, PatternRuntime +from ...runtime import ( + LLMCallInterrupted, + PatternRuntime, + prepare_llm_for_context, + resolved_llm_metadata, +) from ..base import ( REQUIRED_TOOL_CALL_FAILURE_REASON, AgentPattern, @@ -754,6 +759,11 @@ async def _decide( # Re-derive the request-scoped language before routing so stale metadata # cannot bias the current decision prompt. self._clear_response_language(context) + call_llm = await prepare_llm_for_context( + llm=llm, + messages=context.get_messages_for_llm(), + context=context, + ) await runtime.compact_context_if_needed( context=context, llm=compact_llm, @@ -783,7 +793,10 @@ async def _decide( content=retry_feedback, section_title="Auto routing retry feedback", ) - metadata: dict[str, Any] = {"phase": "auto_decision"} + metadata: dict[str, Any] = { + "phase": "auto_decision", + **resolved_llm_metadata(call_llm), + } if attempt: metadata["attempt"] = attempt + 1 await runtime.on_llm_start( @@ -806,7 +819,7 @@ async def _decide( ) try: response = await runtime.run_streaming_llm_call( - llm, + call_llm, messages=messages, tools=decision_tools, tool_choice="required", diff --git a/src/xagent/core/agent/pattern/react/react.py b/src/xagent/core/agent/pattern/react/react.py index 6789ce539..269183cb8 100644 --- a/src/xagent/core/agent/pattern/react/react.py +++ b/src/xagent/core/agent/pattern/react/react.py @@ -47,7 +47,12 @@ ) from ...language import final_answer_language_rule from ...result import tool_result_succeeded, unwrap_final_answer_content -from ...runtime import LLMCallInterrupted, PatternRuntime +from ...runtime import ( + LLMCallInterrupted, + PatternRuntime, + prepare_llm_for_context, + resolved_llm_metadata, +) from ..base import AgentPattern, PatternResult, truncate_prompt_preview from ..final_answer_stream import ReActFinalAnswerStreamer @@ -364,6 +369,21 @@ async def _run_tool_calling_loop( if interrupted is not None: return interrupted + route_messages = self._messages_for_llm( + context, + has_tools=bool(tool_schemas), + force_final_answer=force_final_answer_now, + tool_names=self._schema_tool_names(tool_schemas), + ) + call_llm = await prepare_llm_for_context( + llm=llm, + messages=route_messages, + context=context, + ) + llm_metadata = { + "iteration": iteration, + **resolved_llm_metadata(call_llm), + } await runtime.compact_context_if_needed( context=context, llm=compact_llm, @@ -381,7 +401,7 @@ async def _run_tool_calling_loop( context=context, messages=messages, tools=tool_schemas or None, - metadata={"iteration": iteration}, + metadata=llm_metadata, ) answer_streamer: ReActFinalAnswerStreamer | None = None try: @@ -400,12 +420,12 @@ async def _run_tool_calling_loop( if tool_schemas: answer_streamer = ReActFinalAnswerStreamer(runtime) response = await runtime.run_streaming_llm_call( - llm, + call_llm, on_chunk=answer_streamer.handle_chunk, **llm_kwargs, ) else: - response = await runtime.stream_final_answer(llm, **llm_kwargs) + response = await runtime.stream_final_answer(call_llm, **llm_kwargs) except LLMCallInterrupted: if answer_streamer is not None: await answer_streamer.fail("interrupted during LLM stream") @@ -428,7 +448,7 @@ async def _run_tool_calling_loop( normalized, force_final_answer=force_final_answer_now, ) - end_metadata: dict[str, Any] = {"iteration": iteration} + end_metadata: dict[str, Any] = dict(llm_metadata) if requires_protocol_retry: end_metadata.update( success=False, @@ -1085,8 +1105,8 @@ def _coerce_arguments(self, arguments: Any) -> dict[str, Any]: def _build_tool_schema(self, tool: Any) -> dict[str, Any]: name = self._tool_name(tool) - description = self._tool_description(tool) - schema = self._tool_json_schema(tool) + description = self._compact_tool_description(self._tool_description(tool)) + schema = self._compact_tool_json_schema(self._tool_json_schema(tool)) return { "type": "function", "function": { @@ -1096,6 +1116,33 @@ def _build_tool_schema(self, tool: Any) -> dict[str, Any]: }, } + def _compact_tool_description(self, description: str) -> str: + """Collapse formatting whitespace without changing description content.""" + return " ".join(description.split()) + + def _compact_tool_json_schema( + self, value: Any, *, preserve_mapping_keys: bool = False + ) -> Any: + """Remove presentation-only Pydantic metadata from provider schemas.""" + if isinstance(value, list): + return [self._compact_tool_json_schema(item) for item in value] + if not isinstance(value, dict): + return value + + compacted: dict[str, Any] = {} + for key, item in value.items(): + if key == "title" and not preserve_mapping_keys: + continue + if key == "description" and isinstance(item, str): + compacted[key] = self._compact_tool_description(item) + else: + compacted[key] = self._compact_tool_json_schema( + item, + preserve_mapping_keys=key + in {"properties", "patternProperties", "$defs", "definitions"}, + ) + return compacted + def _builtin_tool_schemas(self) -> list[dict[str, Any]]: return [ { @@ -1680,9 +1727,15 @@ async def _run_repeated_tool_decision( return None messages = self._messages_for_repeated_tool_decision(context, metadata) + call_llm = await prepare_llm_for_context( + llm=llm, + messages=messages, + context=context, + ) decision_tools = [self._react_decision_tool_schema()] llm_metadata = { "phase": REPEATED_TOOL_DECISION_REQUESTED_STATUS, + **resolved_llm_metadata(call_llm), **metadata, } await runtime.on_llm_start( @@ -1693,7 +1746,7 @@ async def _run_repeated_tool_decision( ) try: response = await runtime.run_streaming_llm_call( - llm, + call_llm, messages=messages, tools=decision_tools, tool_choice="required", diff --git a/src/xagent/core/agent/runtime.py b/src/xagent/core/agent/runtime.py index e05dcf85e..7e6eb6533 100644 --- a/src/xagent/core/agent/runtime.py +++ b/src/xagent/core/agent/runtime.py @@ -7,6 +7,7 @@ from typing import Any, Callable from uuid import uuid4 +from ...config import get_compact_threshold_ratio from ..agent.trace import ( TraceAction, TraceCategory, @@ -27,6 +28,51 @@ class LLMCallInterrupted(Exception): """Raised when an active LLM call is cancelled by an execution interrupt.""" +async def prepare_llm_for_context( + *, + llm: Any, + messages: list[dict[str, Any]], + context: Any, +) -> Any: + """Resolve virtual models before compaction and apply their context window. + + Normal models are returned unchanged. RouterLLM exposes ``prepare_for_call`` + and returns a one-call wrapper for the concrete xrouter selection, ensuring + the selected model is reused after compaction instead of routing twice. + """ + prepared = llm + prepare = getattr(llm, "prepare_for_call", None) + if callable(prepare): + prepared = prepare(messages) + if inspect.isawaitable(prepared): + prepared = await prepared + + context_window = getattr(prepared, "context_window", None) + compact_config = getattr(context, "compact_config", None) + if ( + isinstance(context_window, int) + and context_window > 0 + and compact_config is not None + ): + compact_config.threshold = max( + 1, int(context_window * get_compact_threshold_ratio()) + ) + + return prepared + + +def resolved_llm_metadata(llm: Any) -> dict[str, Any]: + """Trace metadata for an already-resolved virtual model.""" + metadata: dict[str, Any] = {} + model_name = getattr(llm, "model_name", None) + context_window = getattr(llm, "context_window", None) + if isinstance(model_name, str) and model_name: + metadata["selected_model"] = model_name + if isinstance(context_window, int) and context_window > 0: + metadata["context_window"] = context_window + return metadata + + @dataclass class PatternRuntime: """Thin runtime services shared by execution patterns. diff --git a/src/xagent/core/model/chat/basic/openrouter.py b/src/xagent/core/model/chat/basic/openrouter.py index db6fd7fc1..f90b73932 100644 --- a/src/xagent/core/model/chat/basic/openrouter.py +++ b/src/xagent/core/model/chat/basic/openrouter.py @@ -1,3 +1,4 @@ +import logging from typing import Any, AsyncIterator, Dict, List, Optional from .....config import get_openrouter_official_providers_only @@ -9,7 +10,10 @@ ) from .openai import OpenAILLM +logger = logging.getLogger(__name__) + OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" +_DEEPSEEK_FUNCTION_PREFIX_ERROR = "function call should not be used with prefix" _OPENROUTER_OFFICIAL_PROVIDERS_BY_AUTHOR: dict[str, tuple[str, ...]] = { "anthropic": ("anthropic",), @@ -31,6 +35,25 @@ def _openrouter_model_author(model_name: str) -> str: return "" +def _strip_assistant_tool_call_prefixes( + messages: List[Dict[str, Any]], +) -> tuple[List[Dict[str, Any]], bool]: + """Remove text prefixes from historical assistant function-call messages.""" + sanitized: List[Dict[str, Any]] = [] + changed = False + for message in messages: + sanitized_message = dict(message) + if ( + sanitized_message.get("role") == "assistant" + and sanitized_message.get("tool_calls") + and str(sanitized_message.get("content") or "").strip() + ): + sanitized_message["content"] = "" + changed = True + sanitized.append(sanitized_message) + return sanitized, changed + + class OpenRouterLLM(OpenAILLM): """OpenRouter client using the OpenAI SDK with OpenRouter-specific options.""" @@ -60,9 +83,24 @@ def __init__( def _uses_deepseek_tool_protocol(self) -> bool: return _openrouter_model_author(self._model_name) == "deepseek" + def _is_official_openrouter_client(self) -> bool: + return self.base_url.rstrip("/") == OPENROUTER_BASE_URL + + def _should_retry_deepseek_function_prefix( + self, + exc: Exception, + messages: List[Dict[str, Any]], + ) -> bool: + if _openrouter_model_author(self._model_name) != "deepseek": + return False + if _DEEPSEEK_FUNCTION_PREFIX_ERROR not in str(exc).lower(): + return False + _, changed = _strip_assistant_tool_call_prefixes(messages) + return changed + async def chat( self, - messages: List[Dict[str, str]], + messages: List[Dict[str, Any]], temperature: Optional[float] = None, max_tokens: Optional[int] = None, tools: Optional[List[Dict[str, Any]]] = None, @@ -72,8 +110,84 @@ async def chat( output_config: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> Any: - response = await super().chat( - messages=messages, + try: + response = await super().chat( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking=thinking, + output_config=output_config, + **kwargs, + ) + except RuntimeError as exc: + if not self._should_retry_deepseek_function_prefix(exc, messages): + raise + + sanitized_messages, _ = _strip_assistant_tool_call_prefixes(messages) + logger.info( + "OpenRouter DeepSeek rejected function-call history with an " + "assistant prefix; retrying once without tool-call prefixes" + ) + response = await super().chat( + messages=sanitized_messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking=thinking, + output_config=output_config, + **kwargs, + ) + + if not self._uses_deepseek_tool_protocol: + return response + return normalize_deepseek_response(response, tools=tools) + + async def _stream_chat_with_prefix_retry( + self, + messages: List[Dict[str, Any]], + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[str | Dict[str, Any]] = None, + response_format: Optional[Dict[str, Any]] = None, + thinking: Optional[Dict[str, Any]] = None, + output_config: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> AsyncIterator[StreamChunk]: + has_yielded = False + try: + async for chunk in super().stream_chat( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking=thinking, + output_config=output_config, + **kwargs, + ): + has_yielded = True + yield chunk + return + except RuntimeError as exc: + if has_yielded or not self._should_retry_deepseek_function_prefix( + exc, messages + ): + raise + + sanitized_messages, _ = _strip_assistant_tool_call_prefixes(messages) + logger.info( + "OpenRouter DeepSeek rejected streaming function-call history with an " + "assistant prefix; retrying once without tool-call prefixes" + ) + async for chunk in super().stream_chat( + messages=sanitized_messages, temperature=temperature, max_tokens=max_tokens, tools=tools, @@ -82,14 +196,12 @@ async def chat( thinking=thinking, output_config=output_config, **kwargs, - ) - if not self._uses_deepseek_tool_protocol: - return response - return normalize_deepseek_response(response, tools=tools) + ): + yield chunk async def stream_chat( self, - messages: List[Dict[str, str]], + messages: List[Dict[str, Any]], temperature: Optional[float] = None, max_tokens: Optional[int] = None, tools: Optional[List[Dict[str, Any]]] = None, @@ -99,7 +211,7 @@ async def stream_chat( output_config: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> AsyncIterator[StreamChunk]: - stream = super().stream_chat( + stream = self._stream_chat_with_prefix_retry( messages=messages, temperature=temperature, max_tokens=max_tokens, @@ -117,9 +229,6 @@ async def stream_chat( async for chunk in adapt_deepseek_stream(stream, tools=tools): yield chunk - def _is_official_openrouter_client(self) -> bool: - return self.base_url.rstrip("/") == OPENROUTER_BASE_URL - def _prepare_extra_body(self, extra_body: Dict[str, Any]) -> Dict[str, Any]: if ( not get_openrouter_official_providers_only() diff --git a/src/xagent/core/model/chat/basic/router.py b/src/xagent/core/model/chat/basic/router.py index 513bd6772..2b6a792e9 100644 --- a/src/xagent/core/model/chat/basic/router.py +++ b/src/xagent/core/model/chat/basic/router.py @@ -297,9 +297,8 @@ async def chat( output_config: dict[str, Any] | None = None, **kwargs: Any, ) -> str | dict[str, Any]: - llm = await self._resolve(messages) - return await self._run_non_streaming_with_provider_retry( - llm.chat, + prepared = await self.prepare_for_call(messages) + return await prepared.chat( messages, temperature=temperature, max_tokens=max_tokens, @@ -308,7 +307,7 @@ async def chat( response_format=response_format, thinking=thinking, output_config=output_config, - kwargs=kwargs, + **kwargs, ) async def vision_chat( @@ -323,9 +322,8 @@ async def vision_chat( output_config: dict[str, Any] | None = None, **kwargs: Any, ) -> str | dict[str, Any]: - llm = await self._resolve(messages) - return await self._run_non_streaming_with_provider_retry( - llm.vision_chat, + prepared = await self.prepare_for_call(messages) + return await prepared.vision_chat( messages, temperature=temperature, max_tokens=max_tokens, @@ -334,7 +332,7 @@ async def vision_chat( response_format=response_format, thinking=thinking, output_config=output_config, - kwargs=kwargs, + **kwargs, ) async def stream_chat( @@ -349,7 +347,34 @@ async def stream_chat( output_config: dict[str, Any] | None = None, **kwargs: Any, ) -> AsyncIterator[StreamChunk]: - llm = await self._resolve(messages) + prepared = await self.prepare_for_call(messages) + async for chunk in prepared.stream_chat( + messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking=thinking, + output_config=output_config, + **kwargs, + ): + yield chunk + + async def _run_streaming_with_provider_retry( + self, + llm: BaseLLM, + messages: list[dict[str, Any]], + *, + temperature: float | None, + max_tokens: int | None, + tools: list[dict[str, Any]] | None, + tool_choice: str | dict[str, Any] | None, + response_format: dict[str, Any] | None, + thinking: dict[str, Any] | None, + output_config: dict[str, Any] | None, + kwargs: dict[str, Any], + ) -> AsyncIterator[StreamChunk]: has_yielded = False current_tool_choice = tool_choice current_thinking = thinking @@ -394,7 +419,35 @@ async def stream_chat( current_thinking = next_thinking # ---- Routing ------------------------------------------------------------ + async def prepare_for_call(self, messages: list[dict[str, Any]]) -> BaseLLM: + """Resolve one xrouter decision into a reusable per-call LLM. + + The returned wrapper keeps RouterLLM's compatibility retries without + routing a second time, and carries the selected model's context window + from xrouter's model profile catalog. + """ + model_id, downstream = await self._resolve_route(messages) + context_window = self.context_window + if not context_window: + context_window = await asyncio.to_thread( + self._profile_context_window, model_id + ) + if not context_window: + context_window = getattr(downstream, "context_window", None) + return _ResolvedRouterLLM( + router=self, + downstream=downstream, + selected_model=model_id, + context_window=context_window, + ) + async def _resolve(self, messages: list[dict[str, Any]]) -> BaseLLM: + _, downstream = await self._resolve_route(messages) + return downstream + + async def _resolve_route( + self, messages: list[dict[str, Any]] + ) -> tuple[str, BaseLLM]: # Route on the agent's current goal (the user's request, or a DAG step's # objective) rather than the scaffolded sub-prompt this particular LLM # call happens to carry. @@ -405,7 +458,7 @@ async def _resolve(self, messages: list[dict[str, Any]]) -> BaseLLM: logger.info("xrouter selected %s -> openrouter", model_id) if self._downstream_resolver is not None: # Reuse the user-configured OpenRouter model (credentials + base_url). - return self._downstream_resolver(model_id) + return model_id, self._downstream_resolver(model_id) # Fallback when no downstream resolver was injected: an OpenAI-compatible # client using this model's own OpenRouter credentials (or the ambient # OPENAI_BASE_URL / OPENAI_API_KEY env when those are unset). @@ -423,7 +476,22 @@ async def _resolve(self, messages: list[dict[str, Any]]) -> BaseLLM: timeout=self.timeout, abilities=self._abilities, ) - return create_base_llm(config) + return model_id, create_base_llm(config) + + @staticmethod + def _profile_context_window(model_id: str) -> int | None: + """Read context length from xrouter's already-loaded model catalog.""" + try: + profile = _get_service().profiles.get(model_id) + value = getattr(profile, "context_length", None) + except Exception as exc: # noqa: BLE001 - metadata is best effort + logger.warning( + "Could not resolve xrouter context window for %s: %s", + model_id, + exc, + ) + return None + return value if isinstance(value, int) and value > 0 else None async def _select_model(self, prompt: str) -> str: # The decision loads/embeds in-process and is CPU-bound, so run it in a @@ -474,3 +542,117 @@ def _extract_prompt(messages: list[dict[str, Any]]) -> str: return "\n".join( m["content"] for m in messages if isinstance(m.get("content"), str) ) + + +class _ResolvedRouterLLM(BaseLLM): + """A concrete xrouter selection reused for one logical LLM call.""" + + def __init__( + self, + *, + router: RouterLLM, + downstream: BaseLLM, + selected_model: str, + context_window: int | None, + ) -> None: + self._router = router + self._downstream = downstream + self._selected_model = selected_model + self.context_window = context_window + self._model_id = getattr(downstream, "model_id", None) or None + + @property + def abilities(self) -> List[str]: + return self._router.abilities + + @property + def model_name(self) -> str: + return self._selected_model + + @property + def supports_thinking_mode(self) -> bool: + return self._router.supports_thinking_mode + + @property + def supports_json_schema_response_format(self) -> bool: + return self._downstream.supports_json_schema_response_format + + @property + def supports_json_object_response_format(self) -> bool: + return self._downstream.supports_json_object_response_format + + async def chat( + self, + messages: list[dict[str, str]], + temperature: float | None = None, + max_tokens: int | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: dict[str, Any] | None = None, + thinking: dict[str, Any] | None = None, + output_config: dict[str, Any] | None = None, + **kwargs: Any, + ) -> str | dict[str, Any]: + return await self._router._run_non_streaming_with_provider_retry( + self._downstream.chat, + messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking=thinking, + output_config=output_config, + kwargs=kwargs, + ) + + async def vision_chat( + self, + messages: list[dict[str, Any]], + temperature: float | None = None, + max_tokens: int | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: dict[str, Any] | None = None, + thinking: dict[str, Any] | None = None, + output_config: dict[str, Any] | None = None, + **kwargs: Any, + ) -> str | dict[str, Any]: + return await self._router._run_non_streaming_with_provider_retry( + self._downstream.vision_chat, + messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking=thinking, + output_config=output_config, + kwargs=kwargs, + ) + + async def stream_chat( + self, + messages: list[dict[str, str]], + temperature: float | None = None, + max_tokens: int | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: str | dict[str, Any] | None = None, + response_format: dict[str, Any] | None = None, + thinking: dict[str, Any] | None = None, + output_config: dict[str, Any] | None = None, + **kwargs: Any, + ) -> AsyncIterator[StreamChunk]: + async for chunk in self._router._run_streaming_with_provider_retry( + self._downstream, + messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking=thinking, + output_config=output_config, + kwargs=kwargs, + ): + yield chunk diff --git a/src/xagent/core/tools/adapters/vibe/browser_use.py b/src/xagent/core/tools/adapters/vibe/browser_use.py index c1102f682..fe793cd8b 100644 --- a/src/xagent/core/tools/adapters/vibe/browser_use.py +++ b/src/xagent/core/tools/adapters/vibe/browser_use.py @@ -1118,7 +1118,10 @@ async def run_json_async(self, args: Mapping[str, Any]) -> Any: def create_browser_tools( - task_id: Optional[str] = None, workspace: Optional["TaskWorkspace"] = None + task_id: Optional[str] = None, + workspace: Optional["TaskWorkspace"] = None, + *, + include_debug_tools: bool = False, ) -> list: """ Create all browser automation tools for a task. @@ -1126,11 +1129,13 @@ def create_browser_tools( Args: task_id: Optional task ID for session tracking workspace: Optional workspace for saving screenshots + include_debug_tools: Include browser session diagnostics. Disabled for + normal agent runs so debug schemas do not consume model context. Returns: List of browser tool instances """ - return [ + tools = [ BrowserNavigateTool(task_id=task_id, workspace=workspace), BrowserClickTool(task_id=task_id), BrowserFillTool(task_id=task_id), @@ -1141,5 +1146,7 @@ def create_browser_tools( BrowserSelectOptionTool(task_id=task_id), BrowserWaitForSelectorTool(task_id=task_id), BrowserCloseTool(task_id=task_id), - BrowserListSessionsTool(), # Debug tool (no task_id needed) ] + if include_debug_tools: + tools.append(BrowserListSessionsTool()) + return tools diff --git a/src/xagent/core/tools/adapters/vibe/workspace_file_tool.py b/src/xagent/core/tools/adapters/vibe/workspace_file_tool.py index b900089de..e0b68f1ff 100644 --- a/src/xagent/core/tools/adapters/vibe/workspace_file_tool.py +++ b/src/xagent/core/tools/adapters/vibe/workspace_file_tool.py @@ -8,7 +8,7 @@ import logging from typing import TYPE_CHECKING, Any, Dict, List -from .....core.workspace import TaskWorkspace +from .....core.workspace import DEFAULT_USER_FILE_LIST_LIMIT, TaskWorkspace from ...core.workspace_file_tool import FileInfo, WorkspaceFileOperations from .base import ToolCategory from .function import FunctionTool @@ -149,14 +149,14 @@ def get_workspace_output_files(self) -> Dict[str, Any]: def list_all_user_files( self, include_workspace_files: bool = True, - limit: int = 1000, + limit: int = DEFAULT_USER_FILE_LIST_LIMIT, offset: int = 0, ) -> Dict[str, Any]: """List all user files across all workspaces and uploaded files. Args: include_workspace_files: Whether to include current workspace files - limit: Maximum number of files to return (default: 1000) + limit: Maximum number of files to return (default: 50) offset: Number of files to skip for pagination (default: 0) Returns: diff --git a/src/xagent/core/tools/core/workspace_file_tool.py b/src/xagent/core/tools/core/workspace_file_tool.py index 719b51511..4198cf946 100644 --- a/src/xagent/core/tools/core/workspace_file_tool.py +++ b/src/xagent/core/tools/core/workspace_file_tool.py @@ -17,7 +17,7 @@ from pydantic import BaseModel from ...file_ref import build_workspace_file_ref, safe_asset_filename -from ...workspace import TaskWorkspace +from ...workspace import DEFAULT_USER_FILE_LIST_LIMIT, TaskWorkspace from .document_parser import DocumentCapabilities, DocumentParseArgs, parse_document from .file_tool import ( EditOperation, @@ -666,14 +666,14 @@ def get_workspace_output_files(self) -> Dict[str, Any]: def list_all_user_files( self, include_workspace_files: bool = True, - limit: int = 1000, + limit: int = DEFAULT_USER_FILE_LIST_LIMIT, offset: int = 0, ) -> Dict[str, Any]: """List all user files across all workspaces and uploaded files. Args: include_workspace_files: Whether to include current workspace files - limit: Maximum number of files to return (default: 1000) + limit: Maximum number of files to return (default: 50) offset: Number of files to skip for pagination (default: 0) Returns: diff --git a/src/xagent/core/workspace.py b/src/xagent/core/workspace.py index 7101f97d4..95a122345 100644 --- a/src/xagent/core/workspace.py +++ b/src/xagent/core/workspace.py @@ -23,6 +23,8 @@ logger = logging.getLogger(__name__) +DEFAULT_USER_FILE_LIST_LIMIT = 50 + # Context variable for auto-registration mode _auto_register = contextvars.ContextVar("_auto_register", default=False) @@ -1239,14 +1241,14 @@ def get_file_id_from_path(self, file_path: str) -> Optional[str]: def list_all_user_files( self, include_workspace_files: bool = True, - limit: int = 1000, + limit: int = DEFAULT_USER_FILE_LIST_LIMIT, offset: int = 0, ) -> Dict[str, Any]: """List all user files across all workspaces and uploaded files. Args: include_workspace_files: Whether to include current workspace files - limit: Maximum number of files to return (default: 1000) + limit: Maximum number of files to return (default: 50) offset: Number of files to skip for pagination (default: 0) Returns: diff --git a/tests/core/agent/test_browser_tools.py b/tests/core/agent/test_browser_tools.py index 246a005ee..64f0e07e5 100644 --- a/tests/core/agent/test_browser_tools.py +++ b/tests/core/agent/test_browser_tools.py @@ -12,6 +12,20 @@ from xagent.core.workspace import TaskWorkspace +def test_browser_debug_tools_are_opt_in() -> None: + default_names = {tool.name for tool in create_browser_tools(task_id="task")} + debug_names = { + tool.name + for tool in create_browser_tools( + task_id="task", + include_debug_tools=True, + ) + } + + assert "browser_list_sessions" not in default_names + assert "browser_list_sessions" in debug_names + + @pytest.mark.asyncio async def test_browser_task_session_mixin_defaults_to_no_task() -> None: tool = BrowserTaskSessionMixin() diff --git a/tests/core/agent/test_react.py b/tests/core/agent/test_react.py index 5b7a2cec3..6608beb70 100644 --- a/tests/core/agent/test_react.py +++ b/tests/core/agent/test_react.py @@ -2405,6 +2405,48 @@ async def test_react_pattern_supports_legacy_args_type_schema() -> None: assert parameters["required"] == ["value"] +def test_react_compacts_provider_tool_schema_without_losing_named_fields() -> None: + class CompactSchemaArgs: + @staticmethod + def model_json_schema() -> dict[str, Any]: + return { + "title": "CompactSchemaArgs", + "type": "object", + "properties": { + "title": { + "title": "Title", + "type": "string", + "description": "First line.\n\n Second line.", + "default": "draft", + } + }, + "required": ["title"], + } + + class CompactSchemaTool: + def __init__(self) -> None: + class Metadata: + name = "compact_schema" + description = "Compact\n\n redundant whitespace." + + self.metadata = Metadata() + + def args_type(self) -> type[CompactSchemaArgs]: + return CompactSchemaArgs + + schema = ReActPattern()._build_tool_schema(CompactSchemaTool()) + + assert schema["function"]["description"] == "Compact redundant whitespace." + parameters = schema["function"]["parameters"] + assert "title" not in parameters + assert parameters["properties"]["title"] == { + "type": "string", + "description": "First line. Second line.", + "default": "draft", + } + assert parameters["required"] == ["title"] + + @pytest.mark.asyncio async def test_react_pattern_injects_v1_memory_and_skill_context() -> None: llm = FakeLLM( diff --git a/tests/core/agent/test_runtime.py b/tests/core/agent/test_runtime.py index 31a657765..8160f8f63 100644 --- a/tests/core/agent/test_runtime.py +++ b/tests/core/agent/test_runtime.py @@ -10,7 +10,11 @@ ToolCallStringFieldStreamer, _JsonStringFieldReader, ) -from xagent.core.agent.runtime import LLMCallInterrupted +from xagent.core.agent.runtime import ( + LLMCallInterrupted, + prepare_llm_for_context, + resolved_llm_metadata, +) from xagent.core.model.chat.types import ChunkType, StreamChunk @@ -24,6 +28,34 @@ async def chat(self, **_: Any) -> str: return "never" +@pytest.mark.asyncio +async def test_prepare_llm_for_context_uses_resolved_model_window(monkeypatch) -> None: + monkeypatch.delenv("XAGENT_COMPACT_THRESHOLD_RATIO", raising=False) + + class PreparedLLM: + model_name = "deepseek/deepseek-v4-flash" + context_window = 1_048_576 + + class VirtualLLM: + async def prepare_for_call(self, messages: list[dict[str, Any]]) -> Any: + assert messages[-1]["content"] == "make a podcast" + return PreparedLLM() + + context = ExecutionContext() + prepared = await prepare_llm_for_context( + llm=VirtualLLM(), + messages=[{"role": "user", "content": "make a podcast"}], + context=context, + ) + + assert isinstance(prepared, PreparedLLM) + assert context.compact_config.threshold == 786_432 + assert resolved_llm_metadata(prepared) == { + "selected_model": "deepseek/deepseek-v4-flash", + "context_window": 1_048_576, + } + + class CancelledLLM: async def chat(self, **_: Any) -> str: raise asyncio.CancelledError diff --git a/tests/core/model/chat/basic/test_openrouter.py b/tests/core/model/chat/basic/test_openrouter.py index 60bc84a31..095937437 100644 --- a/tests/core/model/chat/basic/test_openrouter.py +++ b/tests/core/model/chat/basic/test_openrouter.py @@ -2,6 +2,8 @@ from types import SimpleNamespace +import httpx +import openai import pytest from xagent.core.model.chat.basic.openrouter import OpenRouterLLM @@ -67,6 +69,69 @@ async def test_openrouter_deepseek_rejects_serialized_tool_protocol_content( assert error["code"] == "serialized_tool_call_content" +def _deepseek_function_prefix_error() -> openai.BadRequestError: + return openai.BadRequestError( + "Error code: 400 - {'error': {'message': 'Provider returned error'}}", + response=httpx.Response( + 400, + request=httpx.Request( + "POST", "https://openrouter.ai/api/v1/chat/completions" + ), + ), + body={ + "error": { + "message": "Provider returned error", + "code": 400, + "metadata": { + "provider_name": "DeepSeek", + "raw": ( + '{"error":{"message":' + '"Function call should not be used with prefix"}}' + ), + }, + } + }, + ) + + +def _unrelated_bad_request() -> openai.BadRequestError: + return openai.BadRequestError( + "Error code: 400 - {'error': {'message': 'Unrelated invalid request'}}", + response=httpx.Response( + 400, + request=httpx.Request( + "POST", "https://openrouter.ai/api/v1/chat/completions" + ), + ), + body={"error": {"message": "Unrelated invalid request", "code": 400}}, + ) + + +def _tool_call_history() -> list[dict]: + return [ + {"role": "user", "content": "Generate music"}, + { + "role": "assistant", + "content": "I will generate the music first.", + "tool_calls": [ + { + "id": "call_music", + "type": "function", + "function": { + "name": "generate_music", + "arguments": '{"prompt":"intro"}', + }, + } + ], + }, + { + "role": "tool", + "tool_call_id": "call_music", + "content": '{"success":true}', + }, + ] + + @pytest.mark.asyncio async def test_openrouter_official_provider_pinning_disabled_by_default( mock_chat_completion, mocker, monkeypatch @@ -180,6 +245,118 @@ async def test_openrouter_provider_override_is_preserved( } +@pytest.mark.asyncio +async def test_openrouter_deepseek_retries_function_call_without_assistant_prefix( + mock_chat_completion, mocker, monkeypatch +): + monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.side_effect = [ + _deepseek_function_prefix_error(), + mock_chat_completion, + ] + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + llm = OpenRouterLLM( + model_name="deepseek/deepseek-v4-flash", + api_key="test-key", + ) + messages = _tool_call_history() + + result = await llm.chat(messages) + + assert result["content"] == "Hello World" + assert mock_client.chat.completions.create.await_count == 2 + first_messages = mock_client.chat.completions.create.call_args_list[0].kwargs[ + "messages" + ] + retry_messages = mock_client.chat.completions.create.call_args_list[1].kwargs[ + "messages" + ] + assert first_messages[1]["content"] == "I will generate the music first." + assert retry_messages[1]["content"] == "" + assert retry_messages[1]["tool_calls"] == messages[1]["tool_calls"] + assert messages[1]["content"] == "I will generate the music first." + + +@pytest.mark.asyncio +async def test_openrouter_deepseek_stream_retries_prefix_error_before_first_chunk( + mocker, monkeypatch +): + monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") + + async def empty_stream(): + if False: + yield None + + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.side_effect = [ + _deepseek_function_prefix_error(), + empty_stream(), + ] + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + llm = OpenRouterLLM( + model_name="deepseek/deepseek-v4-flash", + api_key="test-key", + ) + + chunks = [chunk async for chunk in llm.stream_chat(_tool_call_history())] + + assert chunks == [] + assert mock_client.chat.completions.create.await_count == 2 + retry_messages = mock_client.chat.completions.create.call_args_list[1].kwargs[ + "messages" + ] + assert retry_messages[1]["content"] == "" + + +@pytest.mark.asyncio +async def test_openrouter_does_not_retry_unrelated_bad_request(mocker, monkeypatch): + monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.side_effect = _unrelated_bad_request() + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + llm = OpenRouterLLM( + model_name="deepseek/deepseek-v4-flash", + api_key="test-key", + ) + + with pytest.raises(RuntimeError, match="Unrelated invalid request"): + await llm.chat(_tool_call_history()) + + assert mock_client.chat.completions.create.await_count == 1 + + +@pytest.mark.asyncio +async def test_openrouter_non_deepseek_does_not_retry_function_prefix_error( + mocker, monkeypatch +): + monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.side_effect = _deepseek_function_prefix_error() + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + llm = OpenRouterLLM( + model_name="openai/gpt-5.5", + api_key="test-key", + ) + + with pytest.raises(RuntimeError, match="Function call should not be used"): + await llm.chat(_tool_call_history()) + + assert mock_client.chat.completions.create.await_count == 1 + + @pytest.mark.asyncio async def test_openrouter_stream_deepseek_uses_official_provider(mocker, monkeypatch): """Streaming calls should carry the same OpenRouter provider routing.""" diff --git a/tests/core/model/chat/basic/test_router.py b/tests/core/model/chat/basic/test_router.py index 523359ab0..2920941fe 100644 --- a/tests/core/model/chat/basic/test_router.py +++ b/tests/core/model/chat/basic/test_router.py @@ -129,6 +129,35 @@ async def _select_glm(_prompt: str) -> str: return "z-ai/glm-5.2" +@pytest.mark.asyncio +async def test_prepare_for_call_reuses_route_and_exposes_profile_context_window( + monkeypatch, +): + downstream = _ScriptedChatLLM([]) + router = RouterLLM(downstream_resolver=lambda _model_id: downstream) + selected: list[str] = [] + + async def select_model(prompt: str) -> str: + selected.append(prompt) + return "deepseek/deepseek-v4-flash" + + monkeypatch.setattr(router, "_select_model", select_model) + monkeypatch.setattr( + router, + "_profile_context_window", + lambda _model_id: 1_048_576, + ) + + prepared = await router.prepare_for_call( + [{"role": "user", "content": "make a podcast"}] + ) + + assert prepared.model_name == "deepseek/deepseek-v4-flash" + assert prepared.context_window == 1_048_576 + assert await prepared.chat([{"role": "user", "content": "continue"}]) == "ok" + assert selected == ["make a podcast"] + + @pytest.mark.asyncio async def test_router_chat_relaxes_required_tool_choice_on_openrouter_endpoint_error( monkeypatch, diff --git a/tests/core/tools/test_workspace_file_operations.py b/tests/core/tools/test_workspace_file_operations.py index 4fcef1a8e..4e0cf69a1 100644 --- a/tests/core/tools/test_workspace_file_operations.py +++ b/tests/core/tools/test_workspace_file_operations.py @@ -8,7 +8,7 @@ import pytest from xagent.core.tools.core.workspace_file_tool import WorkspaceFileOperations -from xagent.core.workspace import TaskWorkspace +from xagent.core.workspace import DEFAULT_USER_FILE_LIST_LIMIT, TaskWorkspace class TestWorkspaceFileOperations: @@ -320,6 +320,7 @@ def test_list_all_user_files_test_workspace(self, tmp_path): assert result["success"] is True assert result["user_id"] is None # No user_id for test workspace + assert result["limit"] == DEFAULT_USER_FILE_LIST_LIMIT # Files will only include workspace files if include_workspace_files=True assert len(result["files"]) == 0 # Default is include_workspace_files=False From 939f623780828c0b47e3be34bd8ac7262ea784f0 Mon Sep 17 00:00:00 2001 From: qinxuye Date: Sun, 12 Jul 2026 23:12:31 +0800 Subject: [PATCH 2/4] fix: guard optional router context window --- src/xagent/core/model/chat/basic/router.py | 2 +- tests/core/model/chat/basic/test_router.py | 23 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/xagent/core/model/chat/basic/router.py b/src/xagent/core/model/chat/basic/router.py index 2b6a792e9..f77f47438 100644 --- a/src/xagent/core/model/chat/basic/router.py +++ b/src/xagent/core/model/chat/basic/router.py @@ -427,7 +427,7 @@ async def prepare_for_call(self, messages: list[dict[str, Any]]) -> BaseLLM: from xrouter's model profile catalog. """ model_id, downstream = await self._resolve_route(messages) - context_window = self.context_window + context_window = getattr(self, "context_window", None) if not context_window: context_window = await asyncio.to_thread( self._profile_context_window, model_id diff --git a/tests/core/model/chat/basic/test_router.py b/tests/core/model/chat/basic/test_router.py index 2920941fe..c4c38ee2e 100644 --- a/tests/core/model/chat/basic/test_router.py +++ b/tests/core/model/chat/basic/test_router.py @@ -158,6 +158,29 @@ async def select_model(prompt: str) -> str: assert selected == ["make a podcast"] +@pytest.mark.asyncio +async def test_prepare_for_call_handles_missing_router_context_window(monkeypatch): + downstream = _ScriptedChatLLM([]) + router = RouterLLM(downstream_resolver=lambda _model_id: downstream) + + async def select_model(_prompt: str) -> str: + return "deepseek/deepseek-v4-flash" + + monkeypatch.delattr(BaseLLM, "context_window") + monkeypatch.setattr(router, "_select_model", select_model) + monkeypatch.setattr( + router, + "_profile_context_window", + lambda _model_id: 1_048_576, + ) + + prepared = await router.prepare_for_call( + [{"role": "user", "content": "make a podcast"}] + ) + + assert prepared.context_window == 1_048_576 + + @pytest.mark.asyncio async def test_router_chat_relaxes_required_tool_choice_on_openrouter_endpoint_error( monkeypatch, From ba748c81ea20eef4bf4a4b584e26f4c9b1e1de95 Mon Sep 17 00:00:00 2001 From: qinxuye Date: Sun, 12 Jul 2026 23:38:24 +0800 Subject: [PATCH 3/4] fix: expose resolved router metadata --- src/xagent/core/model/chat/basic/router.py | 9 ++++++++- tests/core/model/chat/basic/test_router.py | 8 +++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/xagent/core/model/chat/basic/router.py b/src/xagent/core/model/chat/basic/router.py index f77f47438..ce30aef61 100644 --- a/src/xagent/core/model/chat/basic/router.py +++ b/src/xagent/core/model/chat/basic/router.py @@ -559,7 +559,14 @@ def __init__( self._downstream = downstream self._selected_model = selected_model self.context_window = context_window - self._model_id = getattr(downstream, "model_id", None) or None + + @property + def model_id(self) -> str: + return self._downstream.model_id + + @property + def timeout(self) -> float: + return self._router.timeout @property def abilities(self) -> List[str]: diff --git a/tests/core/model/chat/basic/test_router.py b/tests/core/model/chat/basic/test_router.py index c4c38ee2e..38bcd2656 100644 --- a/tests/core/model/chat/basic/test_router.py +++ b/tests/core/model/chat/basic/test_router.py @@ -134,7 +134,11 @@ async def test_prepare_for_call_reuses_route_and_exposes_profile_context_window( monkeypatch, ): downstream = _ScriptedChatLLM([]) - router = RouterLLM(downstream_resolver=lambda _model_id: downstream) + downstream._model_id = "configured-openrouter-model" + router = RouterLLM( + timeout=42.0, + downstream_resolver=lambda _model_id: downstream, + ) selected: list[str] = [] async def select_model(prompt: str) -> str: @@ -153,6 +157,8 @@ async def select_model(prompt: str) -> str: ) assert prepared.model_name == "deepseek/deepseek-v4-flash" + assert prepared.model_id == "configured-openrouter-model" + assert prepared.timeout == 42.0 assert prepared.context_window == 1_048_576 assert await prepared.chat([{"role": "user", "content": "continue"}]) == "ok" assert selected == ["make a podcast"] From 67eb1fb2712de25f15ff7c8443687a75c79d6861 Mon Sep 17 00:00:00 2001 From: qinxuye Date: Mon, 13 Jul 2026 01:46:30 +0800 Subject: [PATCH 4/4] fix: preserve routed call invariants --- src/xagent/core/agent/pattern/react/react.py | 26 +++++-- src/xagent/core/agent/runtime.py | 6 +- .../core/model/chat/basic/openrouter.py | 24 +++--- src/xagent/core/model/chat/basic/router.py | 4 - tests/core/agent/test_react.py | 76 +++++++++++++++++-- tests/core/agent/test_runtime.py | 24 ++++++ .../core/model/chat/basic/test_openrouter.py | 52 +++++++++++++ tests/core/model/chat/basic/test_router.py | 15 ++++ .../core/model/test_router_provider_config.py | 15 ++-- .../tools/test_workspace_file_operations.py | 64 ++++++++++++++++ 10 files changed, 271 insertions(+), 35 deletions(-) diff --git a/src/xagent/core/agent/pattern/react/react.py b/src/xagent/core/agent/pattern/react/react.py index 269183cb8..4664c037d 100644 --- a/src/xagent/core/agent/pattern/react/react.py +++ b/src/xagent/core/agent/pattern/react/react.py @@ -468,7 +468,7 @@ async def _run_tool_calling_loop( answer_streamer, ) = await self._retry_tool_protocol_response( context=context, - llm=llm, + llm=call_llm, runtime=runtime, iteration=iteration, tool_schemas=tool_schemas, @@ -701,6 +701,7 @@ async def _retry_tool_protocol_response( metadata = { "iteration": iteration, "phase": "tool_protocol_retry", + **resolved_llm_metadata(llm), } await runtime.checkpoint( "tool_protocol_retry", @@ -1117,11 +1118,20 @@ def _build_tool_schema(self, tool: Any) -> dict[str, Any]: } def _compact_tool_description(self, description: str) -> str: - """Collapse formatting whitespace without changing description content.""" - return " ".join(description.split()) + """Trim redundant whitespace while preserving instructional structure.""" + compacted_lines: list[str] = [] + for line in description.splitlines(): + compacted = " ".join(line.split()) + if compacted: + compacted_lines.append(compacted) + elif compacted_lines and compacted_lines[-1]: + compacted_lines.append("") + while compacted_lines and not compacted_lines[-1]: + compacted_lines.pop() + return "\n".join(compacted_lines) def _compact_tool_json_schema( - self, value: Any, *, preserve_mapping_keys: bool = False + self, value: Any, *, named_schema_mapping: bool = False ) -> Any: """Remove presentation-only Pydantic metadata from provider schemas.""" if isinstance(value, list): @@ -1131,14 +1141,16 @@ def _compact_tool_json_schema( compacted: dict[str, Any] = {} for key, item in value.items(): - if key == "title" and not preserve_mapping_keys: + if key == "title" and not named_schema_mapping: continue - if key == "description" and isinstance(item, str): + if named_schema_mapping: + compacted[key] = self._compact_tool_json_schema(item) + elif key == "description" and isinstance(item, str): compacted[key] = self._compact_tool_description(item) else: compacted[key] = self._compact_tool_json_schema( item, - preserve_mapping_keys=key + named_schema_mapping=key in {"properties", "patternProperties", "$defs", "definitions"}, ) return compacted diff --git a/src/xagent/core/agent/runtime.py b/src/xagent/core/agent/runtime.py index 7e6eb6533..d8882df39 100644 --- a/src/xagent/core/agent/runtime.py +++ b/src/xagent/core/agent/runtime.py @@ -49,8 +49,12 @@ async def prepare_llm_for_context( context_window = getattr(prepared, "context_window", None) compact_config = getattr(context, "compact_config", None) + # Fixed-model thresholds are initialized once by AgentRunner and restored + # verbatim from checkpoints. Recompute only when a virtual model resolves to + # a concrete per-call wrapper whose window was unavailable at task start. if ( - isinstance(context_window, int) + prepared is not llm + and isinstance(context_window, int) and context_window > 0 and compact_config is not None ): diff --git a/src/xagent/core/model/chat/basic/openrouter.py b/src/xagent/core/model/chat/basic/openrouter.py index f90b73932..fab6c893d 100644 --- a/src/xagent/core/model/chat/basic/openrouter.py +++ b/src/xagent/core/model/chat/basic/openrouter.py @@ -86,17 +86,17 @@ def _uses_deepseek_tool_protocol(self) -> bool: def _is_official_openrouter_client(self) -> bool: return self.base_url.rstrip("/") == OPENROUTER_BASE_URL - def _should_retry_deepseek_function_prefix( + def _deepseek_function_prefix_retry_messages( self, exc: Exception, messages: List[Dict[str, Any]], - ) -> bool: + ) -> List[Dict[str, Any]] | None: if _openrouter_model_author(self._model_name) != "deepseek": - return False + return None if _DEEPSEEK_FUNCTION_PREFIX_ERROR not in str(exc).lower(): - return False - _, changed = _strip_assistant_tool_call_prefixes(messages) - return changed + return None + sanitized, changed = _strip_assistant_tool_call_prefixes(messages) + return sanitized if changed else None async def chat( self, @@ -123,10 +123,12 @@ async def chat( **kwargs, ) except RuntimeError as exc: - if not self._should_retry_deepseek_function_prefix(exc, messages): + sanitized_messages = self._deepseek_function_prefix_retry_messages( + exc, messages + ) + if sanitized_messages is None: raise - sanitized_messages, _ = _strip_assistant_tool_call_prefixes(messages) logger.info( "OpenRouter DeepSeek rejected function-call history with an " "assistant prefix; retrying once without tool-call prefixes" @@ -176,12 +178,12 @@ async def _stream_chat_with_prefix_retry( yield chunk return except RuntimeError as exc: - if has_yielded or not self._should_retry_deepseek_function_prefix( + sanitized_messages = self._deepseek_function_prefix_retry_messages( exc, messages - ): + ) + if has_yielded or sanitized_messages is None: raise - sanitized_messages, _ = _strip_assistant_tool_call_prefixes(messages) logger.info( "OpenRouter DeepSeek rejected streaming function-call history with an " "assistant prefix; retrying once without tool-call prefixes" diff --git a/src/xagent/core/model/chat/basic/router.py b/src/xagent/core/model/chat/basic/router.py index ce30aef61..48f271e73 100644 --- a/src/xagent/core/model/chat/basic/router.py +++ b/src/xagent/core/model/chat/basic/router.py @@ -441,10 +441,6 @@ async def prepare_for_call(self, messages: list[dict[str, Any]]) -> BaseLLM: context_window=context_window, ) - async def _resolve(self, messages: list[dict[str, Any]]) -> BaseLLM: - _, downstream = await self._resolve_route(messages) - return downstream - async def _resolve_route( self, messages: list[dict[str, Any]] ) -> tuple[str, BaseLLM]: diff --git a/tests/core/agent/test_react.py b/tests/core/agent/test_react.py index 6608beb70..7ead9e77d 100644 --- a/tests/core/agent/test_react.py +++ b/tests/core/agent/test_react.py @@ -17,6 +17,7 @@ ReActReasoningMode, ToolCallRecord, ) +from xagent.core.model.chat.basic.router import RouterLLM from xagent.core.model.chat.tool_protocol import ( ToolProtocolViolation, tool_protocol_error_response, @@ -355,7 +356,11 @@ def __init__( async def chat(self, **kwargs: Any) -> Any: raise AssertionError("invalid tool protocol path should stay streaming") - async def stream_chat(self, **kwargs: Any) -> Any: + async def stream_chat( + self, messages: list[dict[str, Any]] | None = None, **kwargs: Any + ) -> Any: + if messages is not None: + kwargs["messages"] = messages self.stream_calls.append(kwargs) call_index = len(self.stream_calls) - 1 if call_index == 0: @@ -1130,6 +1135,47 @@ async def test_react_closes_invalid_initial_final_answer_stream_before_retry() - assert outbound.events[2]["error"] == "invalid tool protocol, retrying" +@pytest.mark.asyncio +async def test_react_reuses_resolved_route_for_tool_protocol_retry() -> None: + downstream = StreamingInvalidToolProtocolFinalAnswerLLM( + partial_final_before_work_tool=True + ) + selected_models: list[str] = [] + router = RouterLLM(downstream_resolver=lambda _model_id: downstream) + router.context_window = 1_048_576 + + async def select_model(_prompt: str) -> str: + selected = f"test/model-{len(selected_models) + 1}" + selected_models.append(selected) + return selected + + router._select_model = select_model # type: ignore[assignment] + pattern = ReActPattern(max_iterations=3, finalize_after_tool_result=True) + context = ExecutionContext(system_prompt="You are helpful.", execution_id="task-1") + context.add_user_message("Find an audio clip") + tracer = TraceEventRecorder() + runtime = PatternRuntime(execution_id="task-1", tracer=tracer) + + result = await pattern.run( + context=context, + tools=[FakeTool()], + llm=router, + runtime=runtime, + ) + + assert result["success"] is True + assert selected_models == ["test/model-1", "test/model-2"] + retry_starts = [ + event + for event in tracer.events + if event["event_type"] == "action_start_llm" + and event["data"].get("phase") == "tool_protocol_retry" + ] + assert len(retry_starts) == 1 + assert retry_starts[0]["data"]["selected_model"] == "test/model-2" + assert retry_starts[0]["data"]["context_window"] == 1_048_576 + + @pytest.mark.asyncio async def test_react_closes_invalid_retry_final_answer_stream_before_failure() -> None: llm = StreamingInvalidToolProtocolFinalAnswerLLM( @@ -2418,16 +2464,26 @@ def model_json_schema() -> dict[str, Any]: "type": "string", "description": "First line.\n\n Second line.", "default": "draft", - } + }, + "properties": { + "title": "Properties", + "type": "object", + "properties": { + "value": { + "title": "Value", + "type": "integer", + } + }, + }, }, - "required": ["title"], + "required": ["title", "properties"], } class CompactSchemaTool: def __init__(self) -> None: class Metadata: name = "compact_schema" - description = "Compact\n\n redundant whitespace." + description = "Compact\n\n - first step\n - second step." self.metadata = Metadata() @@ -2436,15 +2492,21 @@ def args_type(self) -> type[CompactSchemaArgs]: schema = ReActPattern()._build_tool_schema(CompactSchemaTool()) - assert schema["function"]["description"] == "Compact redundant whitespace." + assert schema["function"]["description"] == ( + "Compact\n\n- first step\n- second step." + ) parameters = schema["function"]["parameters"] assert "title" not in parameters assert parameters["properties"]["title"] == { "type": "string", - "description": "First line. Second line.", + "description": "First line.\n\nSecond line.", "default": "draft", } - assert parameters["required"] == ["title"] + assert parameters["properties"]["properties"] == { + "type": "object", + "properties": {"value": {"type": "integer"}}, + } + assert parameters["required"] == ["title", "properties"] @pytest.mark.asyncio diff --git a/tests/core/agent/test_runtime.py b/tests/core/agent/test_runtime.py index 8160f8f63..083dc0202 100644 --- a/tests/core/agent/test_runtime.py +++ b/tests/core/agent/test_runtime.py @@ -56,6 +56,30 @@ async def prepare_for_call(self, messages: list[dict[str, Any]]) -> Any: } +@pytest.mark.asyncio +@pytest.mark.parametrize("context_window", [None, 128_000]) +async def test_prepare_llm_for_context_preserves_plain_llm_threshold( + context_window: int | None, +) -> None: + class PlainLLM: + pass + + llm = PlainLLM() + if context_window is not None: + llm.context_window = context_window + context = ExecutionContext() + context.compact_config.threshold = 12_345 + + prepared = await prepare_llm_for_context( + llm=llm, + messages=[{"role": "user", "content": "continue"}], + context=context, + ) + + assert prepared is llm + assert context.compact_config.threshold == 12_345 + + class CancelledLLM: async def chat(self, **_: Any) -> str: raise asyncio.CancelledError diff --git a/tests/core/model/chat/basic/test_openrouter.py b/tests/core/model/chat/basic/test_openrouter.py index 095937437..99c287d7c 100644 --- a/tests/core/model/chat/basic/test_openrouter.py +++ b/tests/core/model/chat/basic/test_openrouter.py @@ -6,6 +6,7 @@ import openai import pytest +from xagent.core.model.chat.basic import openrouter as openrouter_module from xagent.core.model.chat.basic.openrouter import OpenRouterLLM from xagent.core.model.chat.tool_protocol import get_tool_protocol_error @@ -264,11 +265,13 @@ async def test_openrouter_deepseek_retries_function_call_without_assistant_prefi api_key="test-key", ) messages = _tool_call_history() + strip_spy = mocker.spy(openrouter_module, "_strip_assistant_tool_call_prefixes") result = await llm.chat(messages) assert result["content"] == "Hello World" assert mock_client.chat.completions.create.await_count == 2 + assert strip_spy.call_count == 1 first_messages = mock_client.chat.completions.create.call_args_list[0].kwargs[ "messages" ] @@ -281,6 +284,55 @@ async def test_openrouter_deepseek_retries_function_call_without_assistant_prefi assert messages[1]["content"] == "I will generate the music first." +@pytest.mark.asyncio +async def test_openrouter_deepseek_propagates_sanitized_retry_failure( + mocker, monkeypatch +): + monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.side_effect = [ + _deepseek_function_prefix_error(), + _deepseek_function_prefix_error(), + ] + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + llm = OpenRouterLLM( + model_name="deepseek/deepseek-v4-flash", + api_key="test-key", + ) + + with pytest.raises(RuntimeError, match="Function call should not be used"): + await llm.chat(_tool_call_history()) + + assert mock_client.chat.completions.create.await_count == 2 + + +@pytest.mark.asyncio +async def test_openrouter_deepseek_does_not_retry_whitespace_only_prefix( + mocker, monkeypatch +): + monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.side_effect = _deepseek_function_prefix_error() + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + llm = OpenRouterLLM( + model_name="deepseek/deepseek-v4-flash", + api_key="test-key", + ) + messages = _tool_call_history() + messages[1]["content"] = " " + + with pytest.raises(RuntimeError, match="Function call should not be used"): + await llm.chat(messages) + + assert mock_client.chat.completions.create.await_count == 1 + + @pytest.mark.asyncio async def test_openrouter_deepseek_stream_retries_prefix_error_before_first_chunk( mocker, monkeypatch diff --git a/tests/core/model/chat/basic/test_router.py b/tests/core/model/chat/basic/test_router.py index 38bcd2656..ae0fbef52 100644 --- a/tests/core/model/chat/basic/test_router.py +++ b/tests/core/model/chat/basic/test_router.py @@ -187,6 +187,21 @@ async def select_model(_prompt: str) -> str: assert prepared.context_window == 1_048_576 +def test_profile_context_window_returns_none_when_catalog_lookup_fails( + monkeypatch, caplog +): + def fail_service_lookup(): + raise RuntimeError("profile catalog unavailable") + + monkeypatch.setattr( + "xagent.core.model.chat.basic.router._get_service", + fail_service_lookup, + ) + + assert RouterLLM._profile_context_window("test/model") is None + assert "Could not resolve xrouter context window for test/model" in caplog.text + + @pytest.mark.asyncio async def test_router_chat_relaxes_required_tool_choice_on_openrouter_endpoint_error( monkeypatch, diff --git a/tests/core/model/test_router_provider_config.py b/tests/core/model/test_router_provider_config.py index 33faa6ea2..7fa5033b3 100644 --- a/tests/core/model/test_router_provider_config.py +++ b/tests/core/model/test_router_provider_config.py @@ -193,9 +193,11 @@ async def fake_select(_prompt: str) -> str: llm._select_model = fake_select # type: ignore[assignment] - result = await llm._resolve([{"role": "user", "content": "hi"}]) + llm.context_window = 128_000 + result = await llm.prepare_for_call([{"role": "user", "content": "hi"}]) assert seen["slug"] == "anthropic/claude-opus-4.8" - assert result == "DOWNSTREAM_LLM" + assert result.model_name == "anthropic/claude-opus-4.8" + assert result._downstream == "DOWNSTREAM_LLM" async def test_router_retries_chat_without_thinking_for_tool_choice_error(): @@ -340,10 +342,12 @@ async def fake_select(_prompt: str) -> str: llm._select_model = fake_select # type: ignore[assignment] - result = await llm._resolve([{"role": "user", "content": "hi"}]) + llm.context_window = 128_000 + result = await llm.prepare_for_call([{"role": "user", "content": "hi"}]) config = seen["config"] - assert result == "FALLBACK_LLM" + assert result.model_name == "deepseek/deepseek-v4-flash" + assert result._downstream == "FALLBACK_LLM" assert config.model_provider == "openrouter" assert config.model_name == "deepseek/deepseek-v4-flash" assert config.base_url == "https://openrouter.ai/api/v1" @@ -379,10 +383,11 @@ async def fake_select(prompt: str) -> str: return "openai/gpt-5.5" llm = RouterLLM(model_name="auto", downstream_resolver=lambda s: "DOWNSTREAM") + llm.context_window = 128_000 llm._select_model = fake_select # type: ignore[assignment] with goal_scope("你好"): - await llm._resolve( + await llm.prepare_for_call( [ { "role": "user", diff --git a/tests/core/tools/test_workspace_file_operations.py b/tests/core/tools/test_workspace_file_operations.py index 4e0cf69a1..4de05cf34 100644 --- a/tests/core/tools/test_workspace_file_operations.py +++ b/tests/core/tools/test_workspace_file_operations.py @@ -6,9 +6,15 @@ """ import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker from xagent.core.tools.core.workspace_file_tool import WorkspaceFileOperations from xagent.core.workspace import DEFAULT_USER_FILE_LIST_LIMIT, TaskWorkspace +from xagent.web.models import Base +from xagent.web.models.task import Task +from xagent.web.models.uploaded_file import UploadedFile +from xagent.web.models.user import User class TestWorkspaceFileOperations: @@ -370,6 +376,64 @@ def test_list_all_user_files_pagination(self, tmp_path): assert result_offset["limit"] == 2 assert result_offset["offset"] == 2 + def test_list_all_user_files_db_pagination(self, tmp_path): + """Test real uploaded-file pagination for a resolvable task workspace.""" + engine = create_engine( + "sqlite:///:memory:", connect_args={"check_same_thread": False} + ) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + Base.metadata.create_all(bind=engine) + db = SessionLocal() + try: + user = User(username="pagination-user", password_hash="hash") + db.add(user) + db.flush() + task = Task(id=790, user_id=user.id, title="Pagination task") + db.add(task) + db.flush() + + for index in range(DEFAULT_USER_FILE_LIST_LIMIT + 5): + path = tmp_path / "uploads" / f"file-{index:02d}.txt" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(str(index), encoding="utf-8") + db.add( + UploadedFile( + user_id=user.id, + task_id=task.id, + filename=path.name, + storage_path=str(path), + mime_type="text/plain", + file_size=path.stat().st_size, + ) + ) + db.commit() + + workspace = TaskWorkspace( + id="web_task_790", + base_dir=str(tmp_path / "workspaces"), + ) + workspace.db_session = db + ops = WorkspaceFileOperations(workspace) + + first_page = ops.list_all_user_files(include_workspace_files=False) + second_page = ops.list_all_user_files( + include_workspace_files=False, + offset=DEFAULT_USER_FILE_LIST_LIMIT, + ) + + first_ids = {file_info["file_id"] for file_info in first_page["files"]} + second_ids = {file_info["file_id"] for file_info in second_page["files"]} + assert len(first_ids) == DEFAULT_USER_FILE_LIST_LIMIT + assert len(second_ids) == 5 + assert first_ids.isdisjoint(second_ids) + assert first_page["total_count"] == DEFAULT_USER_FILE_LIST_LIMIT + 5 + assert second_page["total_count"] == DEFAULT_USER_FILE_LIST_LIMIT + 5 + assert first_page["limit"] == DEFAULT_USER_FILE_LIST_LIMIT + assert second_page["offset"] == DEFAULT_USER_FILE_LIST_LIMIT + finally: + db.close() + engine.dispose() + def test_list_all_user_files_exclude_workspace(self, tmp_path): """Test list_all_user_files can exclude workspace files.""" workspace = TaskWorkspace("test_exclude", str(tmp_path))