From ecd6974e3bb1a2085ae917ec0223f39ae99fc3ba Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:15:06 +0800 Subject: [PATCH 01/36] Fix tool_call_id loss in unified memory message round-trip --- ms_agent/memory/unified/orchestrator.py | 37 +++++++++++++------------ tests/memory/test_unified_memory.py | 25 +++++++++++++++++ 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/ms_agent/memory/unified/orchestrator.py b/ms_agent/memory/unified/orchestrator.py index 000a643be..789096a88 100644 --- a/ms_agent/memory/unified/orchestrator.py +++ b/ms_agent/memory/unified/orchestrator.py @@ -12,6 +12,9 @@ from ms_agent.llm.utils import Message from ms_agent.memory.base import Memory +# Single canonical deserializer, shared with ContextAssembler. A second local +# copy previously drifted and silently dropped `tool_call_id` / `name`. +from ms_agent.session.context_assembler import _dicts_to_messages from ms_agent.utils.logger import get_logger from .config import MemoryConfig from .protocols import MemoryBackend, MemoryEntry @@ -181,6 +184,15 @@ def _default_base_dir(mc: MemoryConfig, config: Any) -> None: def _messages_to_dicts(messages: List[Message]) -> List[Dict[str, Any]]: + """Serialize for ``backend.inject()`` — must be lossless. + + This round-trip (``run()``: messages -> dicts -> inject -> messages) runs on + EVERY turn, so any field dropped here is dropped from the live LLM context, + not just from storage. ``tool_call_id`` in particular is mandatory on the + wire for ``role='tool'`` rows: losing it makes OpenAI-compatible providers + reject the request with ``missing field 'tool_call_id'``. Keep this the + exact inverse of ``_dicts_to_messages`` below. + """ result: List[Dict[str, Any]] = [] for m in messages: if isinstance(m, dict): @@ -189,24 +201,15 @@ def _messages_to_dicts(messages: List[Message]) -> List[Dict[str, Any]]: d: Dict[str, Any] = {'role': m.role, 'content': m.content or ''} if m.tool_calls: d['tool_calls'] = m.tool_calls + if m.tool_call_id: + d['tool_call_id'] = m.tool_call_id + if m.name: + d['name'] = m.name + if m.reasoning_content: + d['reasoning_content'] = m.reasoning_content + if m.reasoning_signature: + d['reasoning_signature'] = m.reasoning_signature result.append(d) else: result.append({'role': 'user', 'content': str(m)}) return result - - -def _dicts_to_messages(dicts: List[Dict[str, Any]]) -> List[Message]: - result: List[Message] = [] - for d in dicts: - if isinstance(d, Message): - result.append(d) - elif isinstance(d, dict): - result.append( - Message( - role=d.get('role', 'user'), - content=d.get('content', ''), - tool_calls=d.get('tool_calls'), - )) - else: - result.append(Message(role='user', content=str(d))) - return result diff --git a/tests/memory/test_unified_memory.py b/tests/memory/test_unified_memory.py index 2063b5949..e8391d797 100644 --- a/tests/memory/test_unified_memory.py +++ b/tests/memory/test_unified_memory.py @@ -262,6 +262,31 @@ def test_dicts_to_messages_preserves_tool_fields(self): assert msgs[1].tool_call_id == "call_1" assert msgs[1].name == "search" + def test_memory_orchestrator_round_trip_preserves_tool_fields(self): + """The unified-memory round-trip runs on EVERY turn, in-memory, before + the LLM call — so a field dropped there is dropped from the live + request. This previously regressed independently of the assembler + (the test above imported the assembler's copy and never covered it), + and OpenAI-compatible providers answered with + ``missing field 'tool_call_id'``. + """ + from ms_agent.memory.unified.orchestrator import (_dicts_to_messages, + _messages_to_dicts) + from ms_agent.llm.utils import Message + + msgs = [ + Message(role="assistant", content="", + tool_calls=[{"id": "call_1", "type": "function"}]), + Message(role="tool", content="result", + tool_call_id="call_1", name="search"), + ] + out = _dicts_to_messages(_messages_to_dicts(msgs)) + assert out[1].tool_call_id == "call_1" + assert out[1].name == "search" + # And it must actually reach the wire: to_dict_clean() drops falsy + # values, so a lost id disappears from the payload entirely. + assert out[1].to_dict_clean()["tool_call_id"] == "call_1" + # ═══════════════════════════════════════════════════════════════════════ # 2. ViewStrategies From f8ce706129b49b1c77e0ad84afff3c345f27acfd Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:15:50 +0800 Subject: [PATCH 02/36] Pair tool results with pending calls when tool_call_id is missing in openai-compat transport --- ms_agent/llm/transport/openai_compat.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/ms_agent/llm/transport/openai_compat.py b/ms_agent/llm/transport/openai_compat.py index 3e7779c47..d25316419 100644 --- a/ms_agent/llm/transport/openai_compat.py +++ b/ms_agent/llm/transport/openai_compat.py @@ -171,6 +171,14 @@ def _format_input_message(self, cache_indice = max(cache_indices) if cache_indices else None openai_messages = [] + # Order-matched fallback for a tool row that reaches us without its id. + # OpenAI-compatible gateways reject such a message outright + # ("missing field `tool_call_id`"), killing the whole turn, so pair it + # with the preceding assistant turn's calls instead. The Anthropic + # transport has carried the same guard for a while; this keeps the two + # symmetric. Note `to_dict_clean()` omits falsy values, so a None/'' id + # disappears from the dict entirely rather than arriving as None. + pending_tool_ids: List[str] = [] for idx, message in enumerate(messages): if isinstance(message, Message): if isinstance(message.content, str): @@ -207,6 +215,22 @@ def _format_input_message(self, and not content): formatted_message['content'] = None + role = formatted_message.get('role') + if role == 'assistant': + pending_tool_ids = [ + tc.get('id') for tc in (formatted_message.get('tool_calls') + or []) if isinstance(tc, dict) + and tc.get('id') + ] + elif role == 'tool' and not formatted_message.get('tool_call_id'): + if pending_tool_ids: + formatted_message['tool_call_id'] = pending_tool_ids.pop(0) + else: + logger.warning( + 'tool message has no tool_call_id and no preceding ' + 'assistant tool_calls to match it against; the provider ' + 'will likely reject this request') + openai_messages.append(formatted_message) return openai_messages From a7b86ec557005db1be8f72d716a433bea22cb6ca Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:15:52 +0800 Subject: [PATCH 03/36] Seal errored rounds so resume consumes the next prompt instead of replaying --- ms_agent/agent/llm_agent.py | 65 +++++++++++++++++++++++-------- tests/agent/test_partial_round.py | 64 ++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 17 deletions(-) diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index 2dd2b905a..a7364b731 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -59,8 +59,15 @@ _INTERRUPTED_TOOL_RESULT = '[Interrupted: tool execution was cancelled]' -def build_partial_round_records(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: - """Turn an interrupted round's in-memory rows into protocol-valid log records. +def build_partial_round_records( + rows: List[Dict[str, Any]], + marker: str = 'interrupted') -> List[Dict[str, Any]]: + """Turn an unfinished round's in-memory rows into protocol-valid log records. + + ``marker`` is the boolean key stamped on every produced record, naming *why* + the round ended early: ``interrupted`` (user Stop / cancellation) or + ``errored`` (the turn raised). UIs badge these differently, so an API + failure must not be sealed as an interruption. ``rows`` are the ``_msg_to_dict`` serializations of ``messages[pre_step_len:]`` at cancellation time. Rules (each keeps replay valid on BOTH transports): @@ -79,7 +86,7 @@ def build_partial_round_records(rows: List[Dict[str, Any]]) -> List[Dict[str, An with no content and no kept calls gets the neutral placeholder content so the turn reads as closed (an empty assistant block would be rejected on Anthropic replay, and a dangling user row would be re-answered on resume). - - every row is flagged ``interrupted: true`` — an extra key that survives in + - every row is flagged ``: true`` — an extra key that survives in the log for UI replay but is filtered out of the LLM context rebuild. """ present_results = { @@ -89,7 +96,7 @@ def build_partial_round_records(rows: List[Dict[str, Any]]) -> List[Dict[str, An records: List[Dict[str, Any]] = [] for row in rows: rec = dict(row) - rec['interrupted'] = True + rec[marker] = True if rec.get('role') != 'assistant': records.append(rec) continue @@ -130,14 +137,14 @@ def build_partial_round_records(rows: List[Dict[str, Any]]) -> List[Dict[str, An 'tool_call_id': tc.get('id'), 'name': tc.get('tool_name', ''), 'is_error': True, - 'interrupted': True, + marker: True, }) if not records: records.append({ 'role': 'assistant', 'content': INTERRUPTED_PLACEHOLDER, 'content_placeholder': True, - 'interrupted': True, + marker: True, }) return records @@ -1980,16 +1987,24 @@ def _msg_to_dict(msg: Message) -> Dict[str, Any]: d['tokens'] = prompt_tokens + completion_tokens return d - def _persist_partial_round(self, messages: List[Message], - pre_step_len: int) -> None: - """Seal an interrupted round into the SessionLog (best-effort). - - Called from run_loop's cancellation handler, where the normal - round-boundary persistence can no longer run. Serializes the round's - in-memory rows and appends the protocol-repaired records built by - :func:`build_partial_round_records`. Synchronous file I/O only (safe - inside a cancelled task); never raises — sealing must not break the - cancellation unwind. + def _persist_partial_round(self, + messages: List[Message], + pre_step_len: int, + marker: str = 'interrupted') -> None: + """Seal an unfinished round into the SessionLog (best-effort). + + Called from run_loop's cancellation handler AND its exception handler, + where the normal round-boundary persistence can no longer run. + Serializes the round's in-memory rows and appends the protocol-repaired + records built by :func:`build_partial_round_records`. Synchronous file + I/O only (safe inside a cancelled task); never raises — sealing must not + break the unwind. + + Sealing is not cosmetic: without it the log tail stays on a ``user`` or + ``tool`` row, and the next resume rebuilds that same round and re-calls + the model instead of reading the user's new prompt (see run_loop's + ``load_cache`` guard). ``marker`` records why the round ended — + ``interrupted`` for Stop, ``errored`` for a raised turn. """ if self.session_log is None: return @@ -2018,7 +2033,7 @@ def _persist_partial_round(self, messages: List[Message], rows = [ self._msg_to_dict(msg) for msg in messages[pre_step_len:] ] - for record in build_partial_round_records(rows): + for record in build_partial_round_records(rows, marker=marker): self.session_log.append(record) except Exception: logger.warning('persist partial round failed', exc_info=True) @@ -2034,6 +2049,11 @@ async def run_loop(self, messages: Union[List[Message], str], Args: messages: Input prompt string or list of Message objects. """ + # Bound BEFORE the try so the exception handler can always read it: + # setup (agent/LLM construction, credential resolution) raises well + # before the round loop, and an unbound local there would turn a clean + # provider error into an UnboundLocalError. None = no round to seal. + pre_step_len: Optional[int] = None try: self.max_chat_round = getattr(self.config, 'max_chat_round', LLMAgent.DEFAULT_MAX_CHAT_ROUND) @@ -2266,6 +2286,17 @@ async def run_loop(self, messages: Union[List[Message], str], import traceback logger.warning(traceback.format_exc()) + # Seal the round FIRST. A raised turn leaves the log tail on a + # `user`/`tool` row, and run_loop's resume guard only skips the + # model call when the tail is `assistant` — so the next request + # rebuilds this same round, re-sends the identical failing context, + # and never reaches after_tool_call (which is what drains the queued + # prompt). The user sees the turn "loop" while every resend is + # silently discarded. Sealing closes the round, so the rebuilt agent + # blocks on read_prompt and consumes the new message instead. + if pre_step_len is not None: + self._persist_partial_round( + messages, pre_step_len, marker='errored') if self._event_sink is not None: # A run_loop turn-abort is non-recoverable (the turn produced no # usable assistant output); mark it so live == persisted/replay. diff --git a/tests/agent/test_partial_round.py b/tests/agent/test_partial_round.py index 75d17d478..b0281dff8 100644 --- a/tests/agent/test_partial_round.py +++ b/tests/agent/test_partial_round.py @@ -185,3 +185,67 @@ def test_reasoning_duration_finalized_when_cancelled_mid_reasoning(): import time dur = _persisted_reasoning_duration(time.monotonic() - 3.0, None) assert dur is not None and dur >= 2 + + +# --- sealing a round that RAISED (not a user Stop) ----------------------- + + +def test_errored_marker_replaces_interrupted(): + # run_loop's exception handler seals with marker='errored'. UIs badge + # `interrupted` as a user Stop, so an API failure must not borrow that key. + records = build_partial_round_records([], marker='errored') + assert records == [{ + 'role': 'assistant', + 'content': INTERRUPTED_PLACEHOLDER, + 'content_placeholder': True, + 'errored': True, + }] + assert 'interrupted' not in records[0] + + +def test_errored_marker_applies_to_synthesized_tool_results(): + records = build_partial_round_records( + [{'role': 'assistant', 'content': '', + 'tool_calls': [{'id': 'c1', 'arguments': '{}', 'tool_name': 'grep'}]}], + marker='errored') + assert [r['role'] for r in records] == ['assistant', 'tool'] + assert all(r.get('errored') is True for r in records) + assert not any('interrupted' in r for r in records) + + +def test_error_seal_closes_dangling_tool_tail(): + """The regression that made failed turns look like an infinite loop. + + A raised turn used to leave the log tail on a `tool` row. run_loop's resume + guard only skips the model call when the tail is `assistant`, so the next + request replayed the same round with the same failing context and never + drained the queued prompt — every resend was silently dropped. + """ + from ms_agent.agent.llm_agent import LLMAgent + + class _Log: + + def __init__(self, rows): + self.rows = list(rows) + + def append(self, rec): + self.rows.append(rec) + return len(self.rows) + + # Tail as persisted by the round that then blew up on the next LLM call. + stub = LLMAgent.__new__(LLMAgent) + stub.session_log = _Log([ + {'role': 'user', 'content': '搜一下今天的热点新闻'}, + {'role': 'assistant', 'content': '', + 'tool_calls': [{'id': 'c1', 'arguments': '{}', 'tool_name': 'search'}]}, + {'role': 'tool', 'content': 'results', 'tool_call_id': 'c1'}, + ]) + stub._reasoning_started_at = None + stub._last_reasoning_duration = None + assert stub.session_log.rows[-1]['role'] == 'tool' # the poisoned state + + # step() raised before yielding anything -> empty segment. + LLMAgent._persist_partial_round(stub, [], 0, marker='errored') + + assert stub.session_log.rows[-1]['role'] == 'assistant' + assert stub.session_log.rows[-1]['errored'] is True From 0686305ffe91df4158748a1f6909680924a7e6cf Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:16:18 +0800 Subject: [PATCH 04/36] Skip LLM call retries for non-retryable 4xx client errors --- ms_agent/agent/llm_agent.py | 9 +- ms_agent/utils/__init__.py | 2 +- ms_agent/utils/llm_utils.py | 61 ++++++++++++- tests/utils/test_retry_classification.py | 109 +++++++++++++++++++++++ 4 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 tests/utils/test_retry_classification.py diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index a7364b731..03d30a0fb 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -40,7 +40,8 @@ ReasoningDelta, ReasoningEnded, ReasoningStarted, ToolCallCompleted, ToolCallStarted, TurnCompleted, UsageInfo) -from ms_agent.utils import async_retry, read_history, save_history +from ms_agent.utils import (async_retry, is_retryable_error, read_history, + save_history) from ms_agent.utils.constants import DEFAULT_TAG, DEFAULT_USER from ms_agent.utils.logger import get_logger from ms_agent.utils.snapshot import take_snapshot @@ -1535,7 +1536,11 @@ def _append_task_notifications(self, messages.append(Message(role='user', content=body)) return messages - @async_retry(max_attempts=Agent.retry_count, delay=1.0) + # retry_if: a hard 4xx (bad payload, content filter, auth) is a verdict on + # the request, not a transient fault — retrying it 5× only adds ~40s of + # backoff before the same failure surfaces. + @async_retry( + max_attempts=Agent.retry_count, delay=1.0, retry_if=is_retryable_error) async def step( self, messages: List[Message] ) -> AsyncGenerator[List[Message], Any]: # type: ignore diff --git a/ms_agent/utils/__init__.py b/ms_agent/utils/__init__.py index 32655bd93..afa87e941 100644 --- a/ms_agent/utils/__init__.py +++ b/ms_agent/utils/__init__.py @@ -1,5 +1,5 @@ # Copyright (c) ModelScope Contributors. All rights reserved. -from .llm_utils import async_retry, retry +from .llm_utils import async_retry, is_retryable_error, retry from .logger import get_logger from .prompt import get_fact_retrieval_prompt from .utils import (assert_package_exist, enhance_error, read_history, diff --git a/ms_agent/utils/llm_utils.py b/ms_agent/utils/llm_utils.py index 4f800bd45..7d0611bc7 100644 --- a/ms_agent/utils/llm_utils.py +++ b/ms_agent/utils/llm_utils.py @@ -1,8 +1,10 @@ # Copyright (c) ModelScope Contributors. All rights reserved. import asyncio import functools +import re import time -from typing import Any, AsyncGenerator, Callable, Tuple, Type, TypeVar, Union +from typing import (Any, AsyncGenerator, Callable, Optional, Tuple, Type, + TypeVar, Union) from .logger import get_logger @@ -10,6 +12,47 @@ T = TypeVar('T') +# 4xx statuses that CAN succeed on a resend: request timeout, lock conflict, +# too-early, and rate limiting. Every other 4xx is a verdict on the request +# itself — resending the identical payload just buys the same rejection. +_RETRYABLE_CLIENT_STATUSES = frozenset({408, 409, 425, 429}) + +# Providers surface the status inconsistently: the openai/anthropic SDKs expose +# `status_code`, while gateways often only put it in the message +# ("APIError: <400> ...", "Error code: 400 - {...}"). +_STATUS_IN_TEXT = re.compile(r'<(\d{3})>|(?:error\s+)?code[:=]?\s*(\d{3})\b', + re.IGNORECASE) + + +def http_status_of(exc: BaseException) -> Optional[int]: + """Best-effort HTTP status for a provider exception, or None if unknown.""" + for attr in ('status_code', 'http_status', 'status'): + value = getattr(exc, attr, None) + if isinstance(value, int) and 100 <= value <= 599: + return value + match = _STATUS_IN_TEXT.search(str(exc)) + if match: + status = int(match.group(1) or match.group(2)) + if 100 <= status <= 599: + return status + return None + + +def is_retryable_error(exc: BaseException) -> bool: + """Whether resending the same request could plausibly succeed. + + Unknown/None status keeps the historical behaviour (retry), so transient + network faults are unaffected. Only an identifiable, non-transient 4xx + fails fast — previously a hard 400 (bad payload, content filter, missing + field) burned all five attempts plus 15s of backoff before surfacing. + """ + status = http_status_of(exc) + if status is None: + return True + if 400 <= status < 500: + return status in _RETRYABLE_CLIENT_STATUSES + return True + def retry(max_attempts: int = 3, delay: float = 1.0, @@ -54,8 +97,14 @@ def async_retry(max_attempts: int = 3, delay: float = 1.0, backoff_factor: float = 2.0, exceptions: Union[Type[Exception], Tuple[Type[Exception], - ...]] = Exception): - """Retry doing something""" + ...]] = Exception, + retry_if: Optional[Callable[[BaseException], bool]] = None): + """Retry doing something. + + ``retry_if`` short-circuits the loop for exceptions that cannot succeed on + a resend (see :func:`is_retryable_error`); the exception is still raised, + just without burning the remaining attempts and their backoff. + """ def decorator(func: Callable[..., T]) -> Callable[..., T]: @@ -73,6 +122,12 @@ async def wrapper(*args, **kwargs) -> AsyncGenerator[T, Any]: import traceback logger.warning(traceback.format_exc()) last_exception = e + if retry_if is not None and not retry_if(e): + logger.error( + f'{func.__name__} failed unrecoverably on attempt ' + f'{attempt}/{max_attempts}; not retrying. ' + f'Exception message: {e}') + break if attempt < max_attempts: logger.warning( f'Attempt {attempt}/{max_attempts} fails: {func.__name__}. ' diff --git a/tests/utils/test_retry_classification.py b/tests/utils/test_retry_classification.py new file mode 100644 index 000000000..79cc3c969 --- /dev/null +++ b/tests/utils/test_retry_classification.py @@ -0,0 +1,109 @@ +"""async_retry's ``retry_if`` gate: don't burn attempts on unrecoverable 4xx. + +A hard 400 (bad payload, missing field, content filter) is a verdict on the +request — resending the identical body five times only adds ~15s of backoff +before the same failure surfaces. Transient faults must keep retrying. +""" +import asyncio + +import pytest +from ms_agent.utils import async_retry, is_retryable_error +from ms_agent.utils.llm_utils import http_status_of + + +class _ApiError(Exception): + """Stand-in for a provider SDK error, with or without ``status_code``.""" + + def __init__(self, message, status_code=None): + super().__init__(message) + if status_code is not None: + self.status_code = status_code + + +@pytest.mark.parametrize( + 'exc, expected', + [ + # Real strings observed in production, where the status is only in text. + (_ApiError('APIError: <400> InternalError.Algo.DataInspectionFailed: ' + 'Output data may contain inappropriate content.'), 400), + (_ApiError("Error code: 400 - {'error': {'message': 'missing field " + "`tool_call_id`'}}"), 400), + # Structured SDK errors. + (_ApiError('boom', 429), 429), + (_ApiError('boom', 503), 503), + # Nothing status-like. + (ConnectionError('Connection reset by peer'), None), + ], +) +def test_http_status_extraction(exc, expected): + assert http_status_of(exc) == expected + + +@pytest.mark.parametrize('status', [400, 401, 403, 404, 422]) +def test_client_errors_are_not_retryable(status): + assert is_retryable_error(_ApiError('boom', status)) is False + + +@pytest.mark.parametrize('status', [408, 409, 425, 429, 500, 502, 503]) +def test_transient_and_server_errors_stay_retryable(status): + assert is_retryable_error(_ApiError('boom', status)) is True + + +@pytest.mark.parametrize( + 'exc', + [ConnectionError('reset'), + TimeoutError('timed out'), + Exception('something odd')]) +def test_unknown_errors_keep_retrying(exc): + # Conservative default: an unidentifiable failure behaves as before. + assert is_retryable_error(exc) is True + + +def _run(fn): + async def drive(): + async for _ in fn(): + pass + + with pytest.raises(Exception): + asyncio.run(drive()) + + +def test_unrecoverable_error_uses_exactly_one_attempt(): + calls = [] + + @async_retry(max_attempts=5, delay=10.0, retry_if=is_retryable_error) + async def boom(): + calls.append(1) + raise _ApiError('APIError: <400> DataInspectionFailed') + yield # pragma: no cover - makes this an async generator + + _run(boom) + # delay=10.0 would make a retrying implementation take >=10s; one attempt + # also proves no backoff was slept. + assert len(calls) == 1 + + +def test_transient_error_still_exhausts_attempts(): + calls = [] + + @async_retry(max_attempts=3, delay=0.01, retry_if=is_retryable_error) + async def flaky(): + calls.append(1) + raise ConnectionError('reset') + yield # pragma: no cover + + _run(flaky) + assert len(calls) == 3 + + +def test_without_retry_if_behaviour_is_unchanged(): + calls = [] + + @async_retry(max_attempts=3, delay=0.01) + async def boom(): + calls.append(1) + raise _ApiError('APIError: <400> DataInspectionFailed') + yield # pragma: no cover + + _run(boom) + assert len(calls) == 3 From 977d16b9f4fddb96557c19669f631e39be2f31fe Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:16:22 +0800 Subject: [PATCH 05/36] Dedupe identical per-round error records in SessionLog --- ms_agent/session/session_log.py | 13 ++++++++++ tests/memory/test_unified_memory.py | 37 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/ms_agent/session/session_log.py b/ms_agent/session/session_log.py index 84f922aab..8b8e4c6aa 100644 --- a/ms_agent/session/session_log.py +++ b/ms_agent/session/session_log.py @@ -103,7 +103,20 @@ def record_error(self, event: Dict[str, Any]) -> None: turn-level / API errors that must NOT go back to the model (tool-call errors stay as ordinary ``role="tool"`` messages instead). ``event`` typically carries ``message``, ``error_type``, ``recoverable``, ``round``. + + Idempotent per (round, message): a round that is retried or replayed + must not stack identical records. A wedged turn used to append one per + attempt, growing a log of dozens of copies of the same failure and + making replay unreadable. Dedup needs a ``round`` to key on; without one + every call is recorded (callers that omit it are opting out). """ + rnd = event.get('round') + if rnd is not None: + message = event.get('message') + for prior in self.get_errors(): + if (prior.get('round') == rnd + and prior.get('message') == message): + return seq = self._next_seq() record = { "_type": "error", diff --git a/tests/memory/test_unified_memory.py b/tests/memory/test_unified_memory.py index e8391d797..6f92cb347 100644 --- a/tests/memory/test_unified_memory.py +++ b/tests/memory/test_unified_memory.py @@ -1696,3 +1696,40 @@ def test_session_and_memory_pipeline(self): assert len(log.get_all_messages()) == 4 finally: loop.close() + + +class TestSessionLogErrorDedup: + """record_error is idempotent per (round, message). + + A wedged turn used to append one identical record per retry/replay attempt + (observed: five copies of the same 400, all round 15), making replay + unreadable. Callers that omit ``round`` opt out of dedup on purpose — they + have no round identity to key on. + """ + + def setup_method(self): + import tempfile + self.tmpdir = tempfile.mkdtemp() + + def test_same_round_same_message_recorded_once(self): + log = SessionLog(self.tmpdir, session_key="err_dedup") + for _ in range(5): + log.record_error({ + "message": "APIError: <400> boom", + "recoverable": False, + "round": 15, + }) + assert len(log.get_errors()) == 1 + + def test_round_and_message_changes_are_kept(self): + log = SessionLog(self.tmpdir, session_key="err_kept") + log.record_error({"message": "APIError: boom", "round": 15}) + log.record_error({"message": "APIError: boom", "round": 16}) + log.record_error({"message": "different failure", "round": 15}) + assert len(log.get_errors()) == 3 + + def test_callers_without_a_round_opt_out_of_dedup(self): + log = SessionLog(self.tmpdir, session_key="err_optout") + log.record_error({"message": "no round identity"}) + log.record_error({"message": "no round identity"}) + assert len(log.get_errors()) == 2 From 4b5f976039b88938b9f2162272dbea88065825ce Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 5 Aug 2026 01:16:24 +0800 Subject: [PATCH 06/36] Use native Windows shell semantics in the local code executor --- ms_agent/tools/code/local_code_executor.py | 19 +++++++- .../tools/test_local_code_executor_windows.py | 47 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/tools/test_local_code_executor_windows.py diff --git a/ms_agent/tools/code/local_code_executor.py b/ms_agent/tools/code/local_code_executor.py index 261859278..e46a6911f 100644 --- a/ms_agent/tools/code/local_code_executor.py +++ b/ms_agent/tools/code/local_code_executor.py @@ -332,6 +332,18 @@ def _build_env(self, field: str, inherit: bool = False) -> Dict[str, str]: 'HOME': os.environ.get('HOME', ''), 'LANG': os.environ.get('LANG', ''), } + if os.name == 'nt': + # ``create_subprocess_shell`` uses the native Windows command + # processor. Keep the non-secret OS/user variables that cmd + # and programs using temporary/profile directories require. + for key in ( + 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'TEMP', + 'TMP', 'TMPDIR', 'USERPROFILE', 'HOMEDRIVE', + 'HOMEPATH', 'USERNAME', 'APPDATA', 'LOCALAPPDATA', + 'PROGRAMDATA', 'OS', 'PROCESSOR_ARCHITECTURE'): + value = os.environ.get(key) + if value is not None: + env[key] = value if not self.tool_config or not hasattr(self.tool_config, field): return env @@ -589,7 +601,12 @@ async def call_tool(self, server_name: str, *, tool_name: str, indent=2) def _prepare_shell_command(self, command: str) -> str: - """Wrap composite shell input in ``sh -lc`` when needed (matches sandbox behavior).""" + """Use the native Windows shell; wrap composite POSIX input when needed.""" + if os.name == 'nt': + # asyncio.create_subprocess_shell delegates to cmd.exe on Windows; + # wrapping native syntax in a usually unavailable POSIX shell + # makes otherwise valid compound commands fail. + return command shell_meta = ('&&', '||', '|', ';', '>', '<', '`', '$(', 'cd ', 'export ') already_wrapped = command.lstrip().startswith( diff --git a/tests/tools/test_local_code_executor_windows.py b/tests/tools/test_local_code_executor_windows.py new file mode 100644 index 000000000..8ba819c6e --- /dev/null +++ b/tests/tools/test_local_code_executor_windows.py @@ -0,0 +1,47 @@ +import os +from unittest import mock + +from ms_agent.tools.code.local_code_executor import LocalCodeExecutionTool + + +def _bare_tool() -> LocalCodeExecutionTool: + """Build a unit-test instance without starting kernels or checking deps.""" + tool = LocalCodeExecutionTool.__new__(LocalCodeExecutionTool) + tool.tool_config = None + return tool + + +def test_composite_command_uses_native_windows_shell(): + command = 'cd work && echo ok > result.txt' + with mock.patch('ms_agent.tools.code.local_code_executor.os.name', 'nt'): + assert _bare_tool()._prepare_shell_command(command) == command + + +def test_sanitized_env_keeps_windows_runtime_variables(): + windows_env = { + 'PATH': r'C:\Windows\System32', + 'SYSTEMROOT': r'C:\Windows', + 'WINDIR': r'C:\Windows', + 'COMSPEC': r'C:\Windows\System32\cmd.exe', + 'PATHEXT': '.COM;.EXE;.BAT;.CMD', + 'TEMP': r'C:\Users\tester\AppData\Local\Temp', + 'TMP': r'C:\Users\tester\AppData\Local\Temp', + 'USERPROFILE': r'C:\Users\tester', + 'HOMEDRIVE': 'C:', + 'HOMEPATH': r'\Users\tester', + 'USERNAME': 'tester', + 'APPDATA': r'C:\Users\tester\AppData\Roaming', + 'LOCALAPPDATA': r'C:\Users\tester\AppData\Local', + 'SECRET_TOKEN': 'must-not-leak', + } + with mock.patch.dict(os.environ, windows_env, clear=True), mock.patch( + 'ms_agent.tools.code.local_code_executor.os.name', 'nt'): + env = _bare_tool()._build_env('shell_env', inherit=False) + + for key in ( + 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP', + 'USERPROFILE', 'HOMEDRIVE', 'HOMEPATH', 'USERNAME', 'APPDATA', + 'LOCALAPPDATA'): + assert env[key] == windows_env[key] + assert env['INHERITED_FROM_LOCAL'] == 'False' + assert 'SECRET_TOKEN' not in env From 0ae72247dc244601d6e2d7586a43428935bbd1cf Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Thu, 6 Aug 2026 16:35:56 +0800 Subject: [PATCH 07/36] Infer provider from model name only when no service is configured --- ms_agent/llm/router.py | 16 ++++++++++---- tests/llm/test_provider_layer.py | 38 ++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/ms_agent/llm/router.py b/ms_agent/llm/router.py index 1195fe1f4..5e46659cc 100644 --- a/ms_agent/llm/router.py +++ b/ms_agent/llm/router.py @@ -3,9 +3,9 @@ ``ProviderRouter.create(config)`` is the data-driven replacement for the hard-coded ``all_services_mapping`` factory. It resolves the spec (by service -name, then by model-name keywords, else a generic OpenAI-compatible fallback), -resolves credentials, builds the matching transport, and returns an -``LLMProvider``. +name; by model-name keywords only when no service is configured; else a generic +OpenAI-compatible fallback named after the service), resolves credentials, +builds the matching transport, and returns an ``LLMProvider``. ``LLMProvider`` is a drop-in for the legacy LLM instances on the agent hot path: it exposes ``.model``, ``.config`` and ``.generate(messages, tools, **kwargs)`` @@ -108,7 +108,15 @@ def create(self, config: DictConfig) -> LLMProvider: model = config.llm.model spec = self._registry.get(service) - if spec is None: + if spec is None and not service: + # Model-name inference only applies when the config names no + # service. A configured-but-unknown service is a custom provider: + # its credentials live under ``_api_key`` / + # ``_base_url``, so inferring a built-in spec from the + # model name would look them up under that vendor's name instead + # and silently fall back to that vendor's default endpoint (e.g. a + # private gateway serving a model named ``deepseek-*`` would be + # routed to api.deepseek.com). spec = self._registry.resolve_by_model(model) if spec is None: logger.info( diff --git a/tests/llm/test_provider_layer.py b/tests/llm/test_provider_layer.py index 9e05fd481..fd26003d3 100644 --- a/tests/llm/test_provider_layer.py +++ b/tests/llm/test_provider_layer.py @@ -146,6 +146,44 @@ def test_explicit_true_forces_router(self): self.assertIsInstance(obj, LLMProvider) +class TestCustomServiceRouting(unittest.TestCase): + """A configured-but-unknown service names a custom provider: it must not be + re-resolved to a built-in spec via the model name, or its credentials and + endpoint (stored under ``_*``) would be looked up under the wrong + provider name.""" + + @unittest.skipUnless(test_level() >= 0, 'skip test in current test level') + def test_custom_service_keeps_its_name_and_endpoint(self): + from ms_agent.llm.router import ProviderRouter + config = OmegaConf.create({ + 'llm': { + 'service': 'ms-test', + # Vendor keyword in the model name must not hijack the spec. + 'model': 'deepseek-v4-flash', + 'ms-test_api_key': 'sk-custom', + 'ms-test_base_url': 'https://gateway.invalid/compatible-mode/v1', + } + }) + provider = ProviderRouter().create(config) + self.assertEqual('ms-test', provider.spec.name) + self.assertEqual( + 'sk-custom', + CredentialResolver.resolve_api_key(provider.spec, config)) + self.assertEqual( + 'https://gateway.invalid/compatible-mode/v1', + CredentialResolver.resolve_base_url(provider.spec, config)) + + @unittest.skipUnless(test_level() >= 0, 'skip test in current test level') + def test_model_inference_still_applies_without_service(self): + from ms_agent.llm.router import ProviderRouter + config = OmegaConf.create( + {'llm': { + 'model': 'deepseek-v4-flash', + 'deepseek_api_key': 'sk-x' + }}) + self.assertEqual('deepseek', ProviderRouter().create(config).spec.name) + + class TestCredentialResolver(unittest.TestCase): @unittest.skipUnless(test_level() >= 0, 'skip test in current test level') From 738640e8941cd2f0954ba2de195a29c04c5becf3 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Mon, 10 Aug 2026 16:48:30 +0800 Subject: [PATCH 08/36] Ingest a round's memory before the blocking interactive input wait --- .gitignore | 2 ++ ms_agent/agent/llm_agent.py | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 534cc302a..0f46edaa3 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,5 @@ webui/work_dir/ .ms_agent_snapshots/ .ms_agent/ + +webui/frontend/.react-router \ No newline at end of file diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index 03d30a0fb..0276944be 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -2248,6 +2248,18 @@ async def run_loop(self, messages: Union[List[Message], str], for msg in messages[pre_step_len:step_end_len]: self.session_log.append(self._msg_to_dict(msg)) + # Ingest THIS round's memory here, for the same reason the + # SessionLog write above moved up: after_tool_call blocks on the + # next prompt in interactive mode. Running afterwards made + # 'add_after_step' mean "after the *next* step" — round N was + # only ingested once round N+1 arrived, a single-round session + # was never ingested at all (the run-loop tail that would have + # caught it is skipped when the input source raises EOF), and + # the message list it saw had the next user turn already + # appended. Before the block, `messages` is exactly this round. + await self.add_memory( + messages, add_type='add_after_step', **kwargs) + await self.after_tool_call(messages) self.runtime.round += 1 @@ -2258,9 +2270,6 @@ async def run_loop(self, messages: Union[List[Message], str], self.session_log.append(self._msg_to_dict(msg)) self.session_log.round = self.runtime.round - # save memory and history - await self.add_memory( - messages, add_type='add_after_step', **kwargs) self.save_history(messages) # +1 means the next round the assistant may give a conclusion From ed0175326e07312cb20ce7c8da99e968ffd31c8b Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Mon, 10 Aug 2026 16:48:31 +0800 Subject: [PATCH 09/36] Release the mem0 vector client on close and log ingestion failures --- .../memory/unified/backends/mem0_adapter.py | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/ms_agent/memory/unified/backends/mem0_adapter.py b/ms_agent/memory/unified/backends/mem0_adapter.py index 1798ea044..ffdc2c681 100644 --- a/ms_agent/memory/unified/backends/mem0_adapter.py +++ b/ms_agent/memory/unified/backends/mem0_adapter.py @@ -84,6 +84,18 @@ async def start(self, **kwargs: Any) -> None: self._mem0 = None async def close(self) -> None: + # Drop the vector client explicitly. Embedded stores (qdrant/chroma on a + # local path) hold an exclusive OS file lock, so merely releasing the + # reference leaves the store locked until GC gets around to it -- long + # enough that the next agent, or any other process on the same path, + # fails with "already accessed by another instance". + client = getattr(getattr(self._mem0, 'vector_store', None), 'client', + None) + if client is not None: + try: + client.close() + except Exception as e: # pragma: no cover - best-effort teardown + logger.debug(f'[mem0_backend] vector client close failed: {e}') self._mem0 = None # ── inject ─────────────────────────────────────────────────────── @@ -142,7 +154,14 @@ async def on_messages( return await _offload(self._mem0.add, convo, user_id=self._user_id) except Exception as e: - logger.warning(f'[mem0_backend] add failed: {e}') + # Deliberately non-fatal -- a memory write must never break the + # turn. But log at error with the exception type: this is the only + # trace a failed ingestion leaves, and the symptom it produces + # (conversation succeeds, memory stays empty forever) gives the + # user nothing to search for. + logger.error( + f'[mem0_backend] add failed, nothing was persisted for this ' + f'round: {type(e).__name__}: {e}') # ── Search ─────────────────────────────────────────────────────── From 4335a8b981f53693d21f797b02f93f370775a920 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Mon, 10 Aug 2026 21:57:16 +0800 Subject: [PATCH 10/36] Let a handler that supports it receive a round's parallel permission asks at once --- ms_agent/permission/enforcer.py | 48 +++++++++++--- ms_agent/permission/handler.py | 20 ++++++ tests/permission/test_parallel_permission.py | 67 ++++++++++++++++++-- 3 files changed, 122 insertions(+), 13 deletions(-) diff --git a/ms_agent/permission/enforcer.py b/ms_agent/permission/enforcer.py index 4cc44aff9..ca10b020b 100644 --- a/ms_agent/permission/enforcer.py +++ b/ms_agent/permission/enforcer.py @@ -40,10 +40,13 @@ def __init__( self._memory = memory or PermissionMemory() self._matcher = PermissionMatcher() # Parallel tool calls (asyncio.gather in ToolManager.parallel_call_tool) - # would otherwise invoke the interactive handler concurrently — N - # prompts fighting over one terminal deadlocks. Serialize asks with a - # lock created lazily per running loop (the per-turn TUI uses a fresh - # loop each turn, so a single init-time Lock would bind to the wrong one). + # reach the handler concurrently. Whether that is safe is the HANDLER's + # property, not a blanket rule: a terminal-bound one (CLI prompt / TUI + # menu) deadlocks with N prompts fighting over one stdin, while a + # request_id-keyed UI wants them all at once. Handlers opt in with + # ``supports_concurrent_asks``; everyone else is serialized with a lock + # created lazily per running loop (the per-turn TUI uses a fresh loop + # each turn, so a single init-time Lock would bind to the wrong one). self._ask_lock: asyncio.Lock | None = None self._ask_lock_loop = None @@ -54,13 +57,31 @@ def _ask_lock_for_loop(self) -> 'asyncio.Lock': self._ask_lock_loop = loop return self._ask_lock - async def _serialized_ask(self, **kwargs) -> PermissionResponse: + async def _ask_user(self, + *, + forced: bool = False, + **kwargs) -> PermissionResponse | None: + """Put one ask in front of the user, serialized unless the handler + declares it can service several at once. + + Returns ``None`` when a queued ask turned out to be unnecessary: while + it waited for the lock, an earlier ask in the same round was answered + with allow_session / allow_always covering this call too, so prompting + again would ask the user something they just answered. ``forced`` asks + (a SafetyGuard confirmation) skip that shortcut — memory must never + bypass a safety ask. + """ # ``call_id`` is a newer, optional kwarg (see check()). A handler that # predates it — or a lightweight test double — need not accept it; drop # it for such handlers so their fixed signature keeps working. if 'call_id' in kwargs and not self._handler_accepts('call_id'): kwargs.pop('call_id') + if getattr(self._handler, 'supports_concurrent_asks', False): + return await self._handler.ask(**kwargs) async with self._ask_lock_for_loop(): + if not forced and self._memory.matches(kwargs['tool_name'], + kwargs['tool_args']): + return None return await self._handler.ask(**kwargs) def _handler_accepts(self, param: str) -> bool: @@ -96,7 +117,8 @@ async def check( if force_decision and force_decision.action == 'ask': suggestions = generate_suggestions(tool_name, tool_args) - response = await self._serialized_ask( + response = await self._ask_user( + forced=True, tool_name=tool_name, tool_args=tool_args, context=force_decision.reason or '', @@ -126,9 +148,9 @@ async def check( reason='Allowed by remembered permission', ) - # 5. Ask user via handler (serialized against parallel tool calls) + # 5. Ask user via handler (serialized unless it opts into concurrency) suggestions = generate_suggestions(tool_name, tool_args) - response = await self._serialized_ask( + response = await self._ask_user( tool_name=tool_name, tool_args=tool_args, context='', @@ -140,10 +162,18 @@ async def check( def _process_response( self, - response: PermissionResponse, + response: PermissionResponse | None, tool_name: str, tool_args: dict[str, Any], ) -> PermissionDecision: + if response is None: + # The ask was skipped: memory started covering this call while it + # was queued behind another one (see _ask_user). + return PermissionDecision( + action='allow', + reason='Allowed by remembered permission', + ) + if response.action == PermissionAction.ALLOW_ONCE: return PermissionDecision( action='allow', reason='User allowed once') diff --git a/ms_agent/permission/handler.py b/ms_agent/permission/handler.py index a12590179..9e9802c57 100644 --- a/ms_agent/permission/handler.py +++ b/ms_agent/permission/handler.py @@ -34,6 +34,17 @@ class PermissionResponse: class PermissionHandler(Protocol): + """Confirmation UI for a tool call the policy can't decide on its own. + + Optional duck-typed attribute ``supports_concurrent_asks`` (default + ``False`` when absent) declares whether several asks may be in flight at + once. It is False for anything bound to the one terminal — N prompts + fighting over a single stdin/menu deadlock — so ``PermissionEnforcer`` + serializes those. A handler that keys pending asks by id and renders them + independently (``WebPermissionHandler``) sets it True, so a round's + parallel tool calls all surface for decision at the same time instead of + one-at-a-time behind whoever the user answers first. + """ async def ask( self, @@ -49,6 +60,9 @@ async def ask( class AutoPermissionHandler: """Always allows — used as fallback or in auto mode.""" + # Never blocks on anything, so it has no reason to be serialized. + supports_concurrent_asks = True + async def ask( self, tool_name: str, @@ -141,6 +155,12 @@ def emit(self, event: dict[str, Any]) -> None: class WebPermissionHandler: """Async handler that suspends on a Future until the frontend responds.""" + # Pending asks are keyed by request_id and each renders as its own card, so + # a round's parallel tool calls can all wait for a decision simultaneously. + # Serializing them instead would show one card at a time while the untouched + # siblings sat there looking like they were already running. + supports_concurrent_asks = True + def __init__( self, event_emitter: EventEmitter, diff --git a/tests/permission/test_parallel_permission.py b/tests/permission/test_parallel_permission.py index 4f48f3f78..add905087 100644 --- a/tests/permission/test_parallel_permission.py +++ b/tests/permission/test_parallel_permission.py @@ -1,7 +1,10 @@ # Copyright (c) ModelScope Contributors. All rights reserved. -"""Parallel tool calls (ToolManager.parallel_call_tool → asyncio.gather) must -not invoke the interactive permission handler concurrently: N prompts fighting -over one terminal deadlocks. The enforcer serializes asks.""" +"""Parallel tool calls (ToolManager.parallel_call_tool → asyncio.gather) reach +the permission handler concurrently. Whether that is safe is the handler's +property: a terminal-bound one deadlocks with N prompts fighting over one +stdin, so the enforcer serializes it; a handler that declares +``supports_concurrent_asks`` gets them all at once (a web UI renders each +pending ask as its own card).""" import asyncio import pytest @@ -45,9 +48,65 @@ async def test_parallel_asks_are_serialized(tmp_path): assert handler.max_in_flight == 1 +class _ConcurrentProbe(_ConcurrencyProbe): + """Same probe, but declaring it can service several asks at once.""" + supports_concurrent_asks = True + + +@pytest.mark.asyncio +async def test_concurrent_handler_asks_overlap(tmp_path): + """A handler that opts in sees the whole round's asks at once, instead of + one-at-a-time behind whichever the user answers first.""" + cfg = PermissionConfig.from_dict({'mode': 'restricted'}) + handler = _ConcurrentProbe() + enf = PermissionEnforcer( + config=cfg, handler=handler, + memory=PermissionMemory(project_path=str(tmp_path))) + + results = await asyncio.gather( + *[enf.check('some_tool', {'i': i}) for i in range(5)]) + + assert all(r.action == 'allow' for r in results) + assert handler.calls == 5 + assert handler.max_in_flight == 5 + + +class _AlwaysAllowOnce: + """Serialized handler whose FIRST answer is allow_always; later asks should + never reach it — memory now covers them.""" + + def __init__(self): + self.calls = 0 + + async def ask(self, tool_name, tool_args, context, suggestions=None, + call_id=''): + self.calls += 1 + await asyncio.sleep(0.01) + return PermissionResponse( + action=PermissionAction.ALLOW_ALWAYS, pattern=tool_name) + + +@pytest.mark.asyncio +async def test_queued_ask_skipped_once_memory_covers_it(tmp_path): + """A serialized ask waiting its turn re-checks memory before prompting: the + user already answered "always allow" for this exact pattern on the sibling + ahead of it in the queue.""" + cfg = PermissionConfig.from_dict({'mode': 'restricted'}) + handler = _AlwaysAllowOnce() + enf = PermissionEnforcer( + config=cfg, handler=handler, + memory=PermissionMemory(project_path=str(tmp_path))) + + results = await asyncio.gather( + *[enf.check('some_tool', {'i': i}) for i in range(4)]) + + assert all(r.action == 'allow' for r in results) + assert handler.calls == 1 # the other three were covered by memory + + class _CallIdCapture: """Handler that records the call_id it was asked with (and tolerates - handlers that don't accept it — see enforcer._serialized_ask).""" + handlers that don't accept it — see enforcer._ask_user).""" def __init__(self): self.seen = [] From c1654a57fb4bc4eda7f634c849b45814af9b95a9 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Mon, 10 Aug 2026 21:57:34 +0800 Subject: [PATCH 11/36] Report each parallel tool call's completion as it finishes, not after the batch --- ms_agent/agent/llm_agent.py | 91 ++++++++++++++++------------ ms_agent/tools/tool_manager.py | 30 ++++++++- ms_agent/tui/app.py | 8 ++- ms_agent/tui/permission.py | 34 +++++++++-- ms_agent/tui/renderer.py | 64 ++++++++++++++++--- tests/tui/test_permission_handler.py | 66 ++++++++++++++++++++ tests/tui/test_renderer.py | 39 ++++++++++++ 7 files changed, 275 insertions(+), 57 deletions(-) create mode 100644 tests/tui/test_permission_handler.py diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index d9f0d1a7e..1efe70886 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -678,23 +678,29 @@ async def parallel_tool_call(self, """ Execute multiple tool calls in parallel and append results to the message list. + Each result is turned into its tool message and ANNOUNCED + (``ToolCallCompleted``, plus ``PlanUpdated`` for todo tools) the moment + that call returns — not once the whole batch has. A round can contain + one call blocked on a human's approval next to calls that are already + finished, and those must be able to report themselves as finished. + The messages are still appended in call order, matching ``tool_calls``. + Args: messages (List[Message]): Current conversation history. Returns: List[Message]: Updated message list including tool responses. """ - tool_call_result = await self.tool_manager.parallel_call_tool( - messages[-1].tool_calls) - assert len(tool_call_result) == len(messages[-1].tool_calls) - for tool_call_result, tool_call_query in zip(tool_call_result, - messages[-1].tool_calls): - tool_call_result_format = ToolResult.from_raw(tool_call_result) + tool_calls = messages[-1].tool_calls + results: Dict[int, Message] = {} + + def _on_result(index: int, tool_call, raw, duration_s: float) -> None: + tool_call_result_format = ToolResult.from_raw(raw) _new_message = Message( role='tool', content=tool_call_result_format.text, - tool_call_id=tool_call_query['id'], - name=tool_call_query['tool_name'], + tool_call_id=tool_call['id'], + name=tool_call['tool_name'], resources=tool_call_result_format.resources, tool_detail=tool_call_result_format.tool_detail, hook_attachments=tool_call_result_format.hook_attachments, @@ -704,11 +710,42 @@ async def parallel_tool_call(self, if _new_message.tool_call_id is None: # If tool call id is None, add a random one _new_message.tool_call_id = str(uuid.uuid4())[:8] - tool_call_query['id'] = _new_message.tool_call_id - messages.append(_new_message) + tool_call['id'] = _new_message.tool_call_id + # This call's OWN wall clock. It used to be the batch's span + # attributed to every call in it, which over-reported every fast + # tool that shared a round with a slow one. + _new_message._duration_ms = int(duration_s * 1000) + results[index] = _new_message self.log_output(_new_message.content) + self._emit_tool_completed(_new_message, duration_s) + + await self.tool_manager.parallel_call_tool( + tool_calls, on_result=_on_result) + assert len(results) == len(tool_calls) + for index in range(len(tool_calls)): + messages.append(results[index]) return messages + def _emit_tool_completed(self, message: Message, duration_s: float) -> None: + """Report one finished tool call to the UI (and the plan it may carry).""" + if self._event_sink is None: + return + content = ( + message.content + if isinstance(message.content, str) else str(message.content)) + is_error = bool(getattr(message, 'is_error', False)) + self._event_sink.emit( + ToolCallCompleted( + call_id=str(getattr(message, 'tool_call_id', '') or ''), + name=str(getattr(message, 'name', '') or ''), + result=content or '', + error=(content or 'tool call failed') if is_error else None, + duration_s=round(duration_s, 3))) + # todo/split_task tool results drive the plan panel. + plan = self._extract_plan_from_tool_result(message) + if plan is not None: + self._event_sink.emit(PlanUpdated(entries=plan)) + def _select_permission_handler(self, mode: str): """Pick the PermissionHandler by mode + runtime environment. @@ -1727,37 +1764,11 @@ def _next_chunk(_g=_gen): name=str( tc.get('tool_name') or tc.get('name') or ''), arguments=tc.get('arguments'))) - _tool_start = len(messages) - _tool_t0 = time.monotonic() + # Each call stamps its own ``_duration_ms`` (persistence/replay) and + # emits its own ToolCallCompleted / PlanUpdated as it finishes — + # inside parallel_tool_call, not after the batch, so one call held + # up by an approval doesn't hold back its finished siblings. messages = await self.parallel_tool_call(messages) - # Batch wall-clock: exact for the common single-tool round; for - # parallel multi-tool rounds it attributes the batch span to each - # (they ran concurrently within it). Stamp for persistence/replay - # regardless of sink, and report it live via ToolCallCompleted. - _tool_ms = int((time.monotonic() - _tool_t0) * 1000) - for m in messages[_tool_start:]: - if getattr(m, 'role', None) == 'tool': - m._duration_ms = _tool_ms - if self._event_sink is not None: - for m in messages[_tool_start:]: - if getattr(m, 'role', None) == 'tool': - _content = ( - m.content - if isinstance(m.content, str) else str(m.content)) - _is_err = bool(getattr(m, 'is_error', False)) - self._event_sink.emit( - ToolCallCompleted( - call_id=str( - getattr(m, 'tool_call_id', '') or ''), - name=str(getattr(m, 'name', '') or ''), - result=_content or '', - error=(_content or 'tool call failed') - if _is_err else None, - duration_s=round(_tool_ms / 1000, 3))) - # todo/split_task tool results drive the plan panel. - _plan = self._extract_plan_from_tool_result(m) - if _plan is not None: - self._event_sink.emit(PlanUpdated(entries=_plan)) # usage # NOTE: token accounting must run BEFORE after_tool_call. The interactive diff --git a/ms_agent/tools/tool_manager.py b/ms_agent/tools/tool_manager.py index 5a4074ff4..dac7dcf6e 100644 --- a/ms_agent/tools/tool_manager.py +++ b/ms_agent/tools/tool_manager.py @@ -8,6 +8,7 @@ import math import os import sys +import time import uuid from copy import copy from types import TracebackType @@ -744,8 +745,33 @@ async def single_call_tool(self, tool_info: ToolCall): True, } - async def parallel_call_tool(self, tool_list: List[ToolCall]): - tasks = [self.single_call_tool(tool) for tool in tool_list] + async def parallel_call_tool( + self, + tool_list: List[ToolCall], + on_result: Optional[Callable[[int, ToolCall, Any, float], + None]] = None, + ): + """Run a round's tool calls concurrently, in call order in the result. + + ``on_result(index, tool_call, result, duration_s)`` — when given — fires + the moment THAT call returns, rather than after the whole batch. The + distinction matters as soon as one call can block for a long time: under + interactive permissions a call suspended on a human's approval used to + hold back every sibling's completion, so calls that were already done + (or needed no approval at all) still looked like they were running until + the human answered. It runs on the event loop between tool calls, so + keep it cheap and non-throwing — an exception propagates out of the + gather and fails the round. + """ + + async def _call(index: int, tool: ToolCall): + started = time.monotonic() + result = await self.single_call_tool(tool) + if on_result is not None: + on_result(index, tool, result, time.monotonic() - started) + return result + + tasks = [_call(i, tool) for i, tool in enumerate(tool_list)] result = await asyncio.gather(*tasks) return result diff --git a/ms_agent/tui/app.py b/ms_agent/tui/app.py index 9c5ceb83d..aa25d4800 100644 --- a/ms_agent/tui/app.py +++ b/ms_agent/tui/app.py @@ -131,7 +131,13 @@ def __init__( # interactive at runtime and get real confirmations. from ms_agent.tui.permission import TUIPermissionHandler self.agent.set_permission_handler( - TUIPermissionHandler(console=self.console, theme=self.theme)) + TUIPermissionHandler( + console=self.console, + theme=self.theme, + # Lets the menu hold the renderer's draws while it owns the + # terminal (a sibling tool finishing mid-menu must not print + # into it) — see RichEventSink.hold_output. + renderer=self.renderer)) # ('new', None) | ('resume', '<#|id>') | None, set by session commands. self._pending_switch: Optional[Tuple[str, Optional[str]]] = None diff --git a/ms_agent/tui/permission.py b/ms_agent/tui/permission.py index 3a0f1ee3a..a031199cc 100644 --- a/ms_agent/tui/permission.py +++ b/ms_agent/tui/permission.py @@ -3,14 +3,20 @@ Matches the pattern used by Claude Code / Qoder / hermes: a compact header for the tool call, then a selectable menu (``❯`` cursor, ↑/↓ + number keys, Enter) -instead of a "type a letter" prompt. The enforcer serializes asks and this runs -on the main event loop, so a prompt_toolkit menu composes without terminal -contention; a non-TTY fallback keeps it scriptable. +instead of a "type a letter" prompt. A non-TTY fallback keeps it scriptable. + +This handler does NOT declare ``supports_concurrent_asks``, so the enforcer +serializes its asks — one terminal, one menu at a time. That alone is no longer +enough for a quiet screen: the menu runs on the same event loop the renderer +draws from, and a sibling tool call approved a moment earlier can finish while +this menu is up. So ``ask()`` also holds the renderer's output for its duration +(``RichEventSink.hold_output``). """ from __future__ import annotations import asyncio import json +from contextlib import contextmanager from rich.console import Console from typing import Any, Optional @@ -26,11 +32,31 @@ class TUIPermissionHandler: def __init__(self, console: Optional[Console] = None, io: Any = None, - theme: Theme = DEFAULT_THEME) -> None: + theme: Theme = DEFAULT_THEME, + renderer: Any = None) -> None: self._console = console or Console() self._theme = theme + # The event renderer, so its draws can be held while this menu owns the + # terminal. A sibling tool call finishing mid-menu would otherwise print + # its result line straight through the prompt_toolkit app the user is + # reading (tool completions arrive per call now, so that overlap is + # reachable whenever one call is approved while another is still asked). + self._renderer = renderer + + @contextmanager + def _own_screen(self): + hold = getattr(self._renderer, 'hold_output', None) + if hold is None: + yield # no renderer wired (tests, embedders) — nothing to hold + return + with hold(): + yield async def ask(self, tool_name, tool_args, context, suggestions=None): + with self._own_screen(): + return await self._ask(tool_name, tool_args, context, suggestions) + + async def _ask(self, tool_name, tool_args, context, suggestions=None): from ms_agent.permission.handler import (PermissionAction, PermissionResponse) suggestion = suggestions[0] if suggestions else tool_name diff --git a/ms_agent/tui/renderer.py b/ms_agent/tui/renderer.py index cb2268c08..d491b6550 100644 --- a/ms_agent/tui/renderer.py +++ b/ms_agent/tui/renderer.py @@ -15,6 +15,7 @@ import json import re import time +from contextlib import contextmanager from rich.console import Console from rich.markdown import Markdown from rich.markup import escape @@ -71,6 +72,9 @@ def __init__(self, # add a blank line between sections (tools ↔ assistant) for breathing # room without spacing tightly-grouped tool lines apart. self._last_kind: Optional[str] = None + # Buffered draws while something else owns the screen (see hold_output). + # None = draw straight through. + self._held: Optional[list] = None # ── sink protocol ────────────────────────────────────────────────────── @@ -79,6 +83,44 @@ def emit(self, event: AgentEvent) -> None: if handler is not None: handler(event) + # ── screen ownership ─────────────────────────────────────────────────── + + @contextmanager + def hold_output(self): + """Buffer this sink's draws while a modal owns the terminal. + + A permission menu (``tui.select.select_async``) is a prompt_toolkit + Application rendered inline on the SAME event loop this sink draws from, + so any print landing mid-menu corrupts what the user is reading. Tool + completions now arrive as each call finishes rather than after the whole + round (``LLMAgent.parallel_tool_call``), which is exactly when that can + happen: approving one call lets the next one's menu open while the first + is still executing, and its result lands underneath. + + Events are still HANDLED immediately — only the drawing waits — so + durations and internal state stay measured at the true moment. + + Nested holds share the outermost buffer; the flush happens once, when + the screen is actually released. + """ + if self._held is not None: + yield # already held by an outer scope — it owns the flush + return + self._held = [] + try: + yield + finally: + held, self._held = self._held, None + for args, kwargs in held: + self.console.print(*args, **kwargs) + + def _print(self, *args, **kwargs) -> None: + """Draw now, or queue it if a modal currently owns the screen.""" + if self._held is not None: + self._held.append((args, kwargs)) + return + self.console.print(*args, **kwargs) + def finalize(self) -> None: """Tear down any in-flight Live region (call on error / turn abort).""" if self._live is not None: @@ -161,24 +203,26 @@ def _on_tool_call_started(self, ev) -> None: if self._last_kind != 'tool': # Blank before a new tool group (turn start or after text), but keep # consecutive tool lines tight together. - self.console.print() + self._print() self._tool_started[ev.call_id] = time.monotonic() header = escape(tool_header(ev.name, ev.arguments)) - self.console.print(f'[{self.theme.tool_bullet}]•[/] {header}') + self._print(f'[{self.theme.tool_bullet}]•[/] {header}') self._last_kind = 'tool' def _on_tool_call_completed(self, ev) -> None: - # Indented one-line summary: " └ 42 lines · 1.2s". + # Indented one-line summary: " └ 42 lines · 1.2s". Measured HERE even + # when the draw is held back for a permission menu — the elapsed time is + # the tool's, not the user's deliberation. start = self._tool_started.pop(ev.call_id, None) dur = f' · {time.monotonic() - start:.1f}s' if start else '' if ev.error: summary = escape(tool_summary(ev.result, ev.error)) - self.console.print( + self._print( f' [{self.theme.tool_error_border}]└[/] ' f'[{self.theme.tool_error_border}]{summary}[/][dim]{dur}[/]') else: summary = escape(tool_summary(ev.result)) - self.console.print(f' [dim]└ {summary}{dur}[/]') + self._print(f' [dim]└ {summary}{dur}[/]') # ── plan / notices / context / errors ────────────────────────────────── @@ -196,7 +240,7 @@ def _on_plan_updated(self, ev) -> None: e.get('content', '') if isinstance(e, dict) else getattr( e, 'content', '')) lines.append(f'{mark.get(status, "○")} {content}') - self.console.print( + self._print( Panel( '\n'.join(lines), title='plan', @@ -207,7 +251,7 @@ def _on_context_compacted(self, ev) -> None: detail = '' if ev.before_tokens and ev.after_tokens: detail = f' {ev.before_tokens}→{ev.after_tokens} tok' - self.console.print( + self._print( f'[{self.theme.notice_info}]· context compacted{detail} ·[/]') def _on_notice(self, ev) -> None: @@ -225,17 +269,17 @@ def _on_notice(self, ev) -> None: background_color='default') else: body = Text(ev.text) - self.console.print(Panel(body, border_style='dim', expand=False)) + self._print(Panel(body, border_style='dim', expand=False)) return style = { 'success': self.theme.notice_success, 'warning': self.theme.notice_warning }.get(ev.level, self.theme.notice_info) - self.console.print(f'[{style}]{ev.text}[/]') + self._print(f'[{style}]{ev.text}[/]') def _on_error(self, ev) -> None: self.finalize() - self.console.print( + self._print( Panel( f'[bold]{ev.message}[/]', title='error', diff --git a/tests/tui/test_permission_handler.py b/tests/tui/test_permission_handler.py new file mode 100644 index 000000000..7191f6c55 --- /dev/null +++ b/tests/tui/test_permission_handler.py @@ -0,0 +1,66 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""TUIPermissionHandler owns the terminal while its menu is up. + +The menu is a prompt_toolkit Application rendered inline on the SAME event loop +the renderer draws from, and tool completions now arrive as each call finishes +(LLMAgent.parallel_tool_call) rather than after the whole round — so approving +one call can open the next call's menu while the first is still executing, and +its result would land inside the menu the user is reading. +""" +from io import StringIO + +import pytest +from rich.console import Console + +from ms_agent.permission.handler import PermissionAction +from ms_agent.tui import permission as permission_mod +from ms_agent.tui.permission import TUIPermissionHandler +from ms_agent.tui.renderer import RichEventSink +from ms_agent.tui.state import TuiState +from ms_agent.ui.events import ToolCallCompleted, ToolCallStarted + + +def _renderer(): + console = Console(file=StringIO(), force_terminal=False, width=80) + return RichEventSink(console, TuiState()), console + + +@pytest.mark.asyncio +async def test_sibling_completion_does_not_print_into_the_menu(monkeypatch): + renderer, console = _renderer() + handler = TUIPermissionHandler(console=console, renderer=renderer) + renderer.emit( + ToolCallStarted( + call_id='c1', name='file_system---read_file', + arguments={'path': 'a.txt'})) + seen_during_menu = {} + + async def fake_menu(options, *, default=0, header=None): + # A sibling call finishes while the user is deciding. + renderer.emit( + ToolCallCompleted( + call_id='c1', name='file_system---read_file', result='aaa')) + seen_during_menu['out'] = console.file.getvalue() + return 0 # "Allow once" + + monkeypatch.setattr(permission_mod, 'select_async', fake_menu) + resp = await handler.ask('file_system---write_file', {'path': 'b.txt'}, '') + + assert resp.action == PermissionAction.ALLOW_ONCE + assert 'aaa' not in seen_during_menu['out'] # held while the menu was up + assert 'aaa' in console.file.getvalue() # drawn once the menu closed + + +@pytest.mark.asyncio +async def test_handler_without_a_renderer_still_works(monkeypatch): + """Embedders/tests may construct the handler bare — no renderer to hold.""" + console = Console(file=StringIO(), force_terminal=False, width=80) + handler = TUIPermissionHandler(console=console) + + async def fake_menu(options, *, default=0, header=None): + return 4 # "Deny" + + monkeypatch.setattr(permission_mod, 'select_async', fake_menu) + resp = await handler.ask('code_executor---shell', {'command': 'rm -rf /'}, + '') + assert resp.action == PermissionAction.DENY diff --git a/tests/tui/test_renderer.py b/tests/tui/test_renderer.py index 2413ee38d..80f6a3bd7 100644 --- a/tests/tui/test_renderer.py +++ b/tests/tui/test_renderer.py @@ -119,3 +119,42 @@ def test_unhandled_event_is_ignored(): def test_finalize_is_safe_without_live(): sink, _, _ = _sink() sink.finalize() # no active Live — must not raise + + +def test_hold_output_defers_draws_until_the_screen_is_released(): + """A permission menu owns the terminal while it is up; a sibling tool call + finishing mid-menu must not print into it. Tool completions arrive per call + now (LLMAgent.parallel_tool_call), so that overlap is reachable.""" + sink, console, _ = _sink() + sink.emit(ToolCallStarted(call_id='c1', name='file_system---read_file', + arguments={'path': 'a.txt'})) + with sink.hold_output(): + sink.emit(ToolCallCompleted(call_id='c1', + name='file_system---read_file', + result='aaa')) + sink.emit(PlanUpdated(entries=[PlanEntry('do X', 'completed')])) + held = _out(console) + assert 'aaa' not in held and 'do X' not in held + out = _out(console) + assert 'aaa' in out and 'do X' in out # flushed on release, in order + assert out.index('aaa') < out.index('do X') + + +def test_hold_output_flushes_even_if_the_menu_raises(): + sink, console, _ = _sink() + try: + with sink.hold_output(): + sink.emit(Notice(level='success', text='saved')) + raise KeyboardInterrupt # user hit Ctrl-C in the menu + except KeyboardInterrupt: + pass + assert 'saved' in _out(console) + + +def test_nested_holds_flush_once_at_the_outermost_release(): + sink, console, _ = _sink() + with sink.hold_output(): + with sink.hold_output(): + sink.emit(Notice(level='success', text='inner')) + assert 'inner' not in _out(console) # inner exit must NOT flush + assert 'inner' in _out(console) From 1100a380e977ef7d16bd2a664e2d02a191fda7c1 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Tue, 11 Aug 2026 02:11:32 +0800 Subject: [PATCH 12/36] Take memory ingestion off the turn's critical path The orchestrator now owns the write discipline around a backend: - schedule_add() runs the extraction-LLM + embedding cost (seconds) in a background task; flush_pending() is the teardown barrier so the last write is never dropped, and an inline fallback keeps writes when no loop is running. - retrieval/ingestion/flush serialize on one per-store asyncio lock (embedded qdrant underneath is lock-free single-client code). - a content-hash delta ledger (/ingest_state.json) makes each ingest send only messages the store has not seen; hashes are recorded only after a confirmed write, so a failed ingest retries naturally. - ingest_status reports the last outcome (state/count/error/pending) so a UI can show memory working instead of silence. Mem0Backend: per-turn retrieval cache (rounds 2..N of a tool-calling turn reuse round 1's search instead of paying an embedding round-trip each), on_messages returns the event count and propagates failures -- the orchestrator is the swallow-and-report layer now and needs the exception to keep failed messages un-marked for retry. --- .../memory/unified/backends/mem0_adapter.py | 83 +++--- ms_agent/memory/unified/config.py | 7 +- ms_agent/memory/unified/orchestrator.py | 265 +++++++++++++++++- tests/memory/test_backend_contracts.py | 13 + tests/memory/test_orchestrator_scheduling.py | 224 +++++++++++++++ tests/memory/test_unified_memory.py | 10 +- 6 files changed, 551 insertions(+), 51 deletions(-) create mode 100644 tests/memory/test_orchestrator_scheduling.py diff --git a/ms_agent/memory/unified/backends/mem0_adapter.py b/ms_agent/memory/unified/backends/mem0_adapter.py index 43f8008c5..4323763aa 100644 --- a/ms_agent/memory/unified/backends/mem0_adapter.py +++ b/ms_agent/memory/unified/backends/mem0_adapter.py @@ -67,8 +67,13 @@ def __init__(self, config: MemoryConfig) -> None: self._config = config self._mem0: Any = None # mem0.Memory instance self._user_id: str = config.user_id - self._snapshot: Optional[str] = None - self._snapshot_dirty = True + # Per-turn retrieval cache: one turn = one embedding + one vector + # search. The turn key is the latest user message — every round of a + # multi-round (tool-calling) turn injects with the same user message, + # so rounds 2..N reuse the round-1 results instead of paying another + # embedding round-trip each. Invalidated on writes/deletes. + self._turn_cache_key: Optional[str] = None + self._turn_cache_results: Optional[list] = None # ── Lifecycle ──────────────────────────────────────────────────── @@ -111,13 +116,21 @@ async def inject( if not query: return messages - try: - results = _result_list(await _offload(_mem0_search, self._mem0, - query, self._user_id)) - if not results: + turn_key = f'{self._user_id}\x1f{query}' + if turn_key == self._turn_cache_key \ + and self._turn_cache_results is not None: + results = self._turn_cache_results + else: + try: + results = _result_list( + await _offload(_mem0_search, self._mem0, query, + self._user_id)) + except Exception as e: + logger.debug(f'[mem0_backend] search failed: {e}') return messages - except Exception as e: - logger.debug(f'[mem0_backend] search failed: {e}') + self._turn_cache_key = turn_key + self._turn_cache_results = results + if not results: return messages formatted = self._format_results(results) @@ -139,31 +152,32 @@ async def on_messages( self, messages: List[Dict[str, Any]], **kwargs: Any, - ) -> None: + ) -> int: + """Ingest via mem0's fact extraction. Returns the number of memory + events mem0 produced (ADD/UPDATE/DELETE). Raises on failure — the + orchestrator owns the swallow-and-report policy, and needs the + exception to know the write did NOT land (so its delta ledger keeps + the messages for a retry instead of marking them ingested).""" if not self._mem0: - return - try: - # mem0 rejects non-chat fields and roles like `tool`; feed it the - # user/assistant text turns only. - convo = [ - { - 'role': m['role'], - 'content': m['content'] - } for m in messages - if m.get('role') in ('user', 'assistant') and m.get('content') - ] - if not convo: - return - await _offload(self._mem0.add, convo, user_id=self._user_id) - except Exception as e: - # Deliberately non-fatal -- a memory write must never break the - # turn. But log at error with the exception type: this is the only - # trace a failed ingestion leaves, and the symptom it produces - # (conversation succeeds, memory stays empty forever) gives the - # user nothing to search for. - logger.error( - f'[mem0_backend] add failed, nothing was persisted for this ' - f'round: {type(e).__name__}: {e}') + return 0 + # mem0 rejects non-chat fields and roles like `tool`; feed it the + # user/assistant text turns only. + convo = [ + { + 'role': m['role'], + 'content': m['content'] + } for m in messages + if m.get('role') in ('user', 'assistant') and m.get('content') + ] + if not convo: + return 0 + result = await _offload(self._mem0.add, convo, user_id=self._user_id) + # A write changes what retrieval should see. + self._turn_cache_key = None + self._turn_cache_results = None + if isinstance(result, dict): + return len(result.get('results') or []) + return len(result or []) # ── Search ─────────────────────────────────────────────────────── @@ -191,8 +205,9 @@ async def search( # ── Cache ──────────────────────────────────────────────────────── def invalidate(self) -> None: - self._snapshot = None - self._snapshot_dirty = True + # External edit (UI delete, another writer): next inject re-queries. + self._turn_cache_key = None + self._turn_cache_results = None # ── Internal helpers ───────────────────────────────────────────── diff --git a/ms_agent/memory/unified/config.py b/ms_agent/memory/unified/config.py index 7fa247e5b..5b57cde2e 100644 --- a/ms_agent/memory/unified/config.py +++ b/ms_agent/memory/unified/config.py @@ -26,6 +26,11 @@ class MemoryConfig: # LLM for extraction (reuses agent LLM if None) llm_config: Optional[Dict[str, Any]] = None + # Ingest every Nth completed turn (1 = every turn). Turns skipped by the + # interval are still covered later: the orchestrator's delta ledger sends + # everything not yet ingested on the next firing ingest. + ingest_interval: int = 1 + # Backend-specific options keyed by backend name backend_options: Dict[str, Any] = field(default_factory=dict) @@ -113,7 +118,7 @@ def from_dict_config(cls, cfg: DictConfig) -> 'MemoryConfig': if k in ns: flat[k] = ns[k] - for k in ('enabled', 'base_dir', 'llm_config'): + for k in ('enabled', 'base_dir', 'llm_config', 'ingest_interval'): if k in raw: flat[k] = raw[k] diff --git a/ms_agent/memory/unified/orchestrator.py b/ms_agent/memory/unified/orchestrator.py index 789096a88..691787306 100644 --- a/ms_agent/memory/unified/orchestrator.py +++ b/ms_agent/memory/unified/orchestrator.py @@ -4,11 +4,39 @@ prompt injection, retrieval strategies, or tool definitions. All of that lives inside the MemoryBackend implementation selected by configuration. +What it DOES own is the write/read discipline around the backend: + +* **Serialization** — retrieval (``run``), ingestion (``add``) and flush all + take one asyncio lock per *store* (keyed by ``base_dir``), because embedded + vector stores (mem0 + local qdrant) have no internal locking at all and + mem0 even fans out worker threads inside ``add``. Lock per store, not per + orchestrator: ``SharedMemoryManager`` may hand different orchestrator + instances the same directory. +* **Background ingestion** — ``schedule_add`` takes the extraction-LLM + + embedding cost (seconds) off the turn's critical path. Tasks are retained + for ``flush_pending`` so a teardown cannot silently drop the last write. + If no loop is running the write happens inline — slower, never lost. +* **Delta ledger** — every ingested message's content hash is remembered + (in memory + ``/ingest_state.json``), so each ingest sends only + the messages the store has not seen. Without this, every ingest re-sent + the whole conversation: cost O(rounds x history), and re-extraction of old + turns. Hashes are only recorded AFTER a successful backend write, so a + failed ingest retries naturally on the next turn (the analog of a + watermark that only advances on a confirmed write). +* **Status** — ``ingest_status`` reports the last ingest outcome so a UI can + show "memory updated / failed" instead of silence. + Registered as ``unified_memory`` in ``memory_mapping``. """ from __future__ import annotations -from typing import Any, Dict, List, Optional +import asyncio +import hashlib +import json +import os +import tempfile +import time +from typing import Any, Dict, List, Optional, Set from ms_agent.llm.utils import Message from ms_agent.memory.base import Memory @@ -22,6 +50,32 @@ logger = get_logger() +# One lock per storage directory. Never per orchestrator instance: two +# orchestrators over the same path (possible through SharedMemoryManager's +# llm-dependent cache key) must still serialize against each other. +_STORE_LOCKS: Dict[str, asyncio.Lock] = {} + + +def _store_lock(base_dir: str) -> asyncio.Lock: + key = os.path.abspath(str(base_dir or '.')) + lock = _STORE_LOCKS.get(key) + if lock is None: + lock = _STORE_LOCKS.setdefault(key, asyncio.Lock()) + return lock + + +# Only conversational text is ingested (mirrors what backends extract from); +# system prompts and tool payloads never become long-term memory rows. +_INGEST_ROLES = ('user', 'assistant') + +_LEDGER_FILE = 'ingest_state.json' +_LEDGER_MAX = 4096 + + +def _content_hash(msg: Dict[str, Any]) -> str: + raw = f"{msg.get('role', '')}\x1f{msg.get('content', '')}" + return hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16] + class MemoryOrchestrator(Memory): """Thin adapter between the ms-agent ``Memory`` ABC and a @@ -42,6 +96,17 @@ def __init__(self, config: Any) -> None: self.mem_config = self._parse_config(config) self._backend: Optional[MemoryBackend] = None self._started = False + # Background ingestion bookkeeping (see module docstring). + self._pending: Set[asyncio.Task] = set() + self._turns_since_ingest = 0 + self._ledger: Optional[Set[str]] = None # lazy-loaded from disk + self._ledger_order: List[str] = [] + self._status: Dict[str, Any] = { + 'state': 'idle', + 'at': None, + 'count': None, + 'error': None + } # ------------------------------------------------------------------ # Lazy backend construction @@ -71,21 +136,192 @@ async def run(self, messages: List[Message]) -> List[Message]: if not self.mem_config.enabled: return messages - backend = await self._ensure_started() - msg_dicts = _messages_to_dicts(messages) - injected = await backend.inject(msg_dicts) + # Retrieval must not overlap a write: the embedded stores underneath + # (qdrant local) are single-client, lock-free code. A scheduled ingest + # normally finishes while the user reads the previous answer, so this + # rarely actually waits. + async with _store_lock(self.mem_config.base_dir): + backend = await self._ensure_started() + msg_dicts = _messages_to_dicts(messages) + injected = await backend.inject(msg_dicts) return _dicts_to_messages(injected) # ------------------------------------------------------------------ - # Memory ABC -- add() + # Memory ABC -- add() / schedule_add() # ------------------------------------------------------------------ async def add(self, messages: List[Message], **kwargs: Any) -> None: - if not self.mem_config.enabled: + """Awaited ingestion (legacy callers / inline fallback).""" + if not self._should_ingest(): return - backend = await self._ensure_started() + await self._ingest(_messages_to_dicts(messages), **kwargs) + + def schedule_add(self, messages: List[Message], + **kwargs: Any) -> Optional[asyncio.Task]: + """Ingest in the background; returns the task (None when skipped or + run inline). The message list is snapshotted to dicts NOW — the + caller's list keeps mutating after this returns (the next user turn + is appended to it).""" + if not self._should_ingest(): + return None msg_dicts = _messages_to_dicts(messages) - await backend.on_messages(msg_dicts, **kwargs) + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + if loop is None: + # No loop to defer to — do the write inline rather than lose it. + asyncio.run(self._ingest(msg_dicts, **kwargs)) + return None + self._status.update(state='scheduled', error=None) + task = loop.create_task(self._ingest(msg_dicts, **kwargs)) + self._pending.add(task) + task.add_done_callback(self._pending.discard) + return task + + def _should_ingest(self) -> bool: + if not self.mem_config.enabled: + return False + interval = max(1, int(getattr(self.mem_config, 'ingest_interval', 1))) + self._turns_since_ingest += 1 + if self._turns_since_ingest < interval: + return False + self._turns_since_ingest = 0 + return True + + async def _ingest(self, msg_dicts: List[Dict[str, Any]], + **kwargs: Any) -> int: + """The single write path: locked, delta-only, status-reporting. + + Never raises — a memory write must not break anything above it; the + outcome lands in ``ingest_status`` instead. + """ + try: + async with _store_lock(self.mem_config.base_dir): + backend = await self._ensure_started() + delta = self._ledger_delta(msg_dicts) + if not delta: + self._set_status('ok', count=0) + return 0 + self._set_status('running') + result = await backend.on_messages(delta, **kwargs) + # Record hashes only after the backend accepted the write, so + # a failure leaves them un-marked and the next turn's delta + # carries them again (retry-by-construction). + self._ledger_mark(delta) + count = result if isinstance(result, int) else len(delta) + self._set_status('ok', count=count) + return count + except asyncio.CancelledError: + self._set_status('error', error='cancelled') + raise + except Exception as e: # noqa: BLE001 - reported via status + logger.error(f'[orchestrator] memory ingest failed, nothing was ' + f'persisted for this turn: {type(e).__name__}: {e}') + self._set_status('error', error=f'{type(e).__name__}: {e}') + return 0 + + def mark_ingested(self, messages: List[Message]) -> None: + """Advance the ledger WITHOUT ingesting (interrupted turns): a + half-finished answer must not be swept into the next turn's delta.""" + self._ledger_mark( + [ + m for m in _messages_to_dicts(messages) + if m.get('role') in _INGEST_ROLES and m.get('content') + ], + persist=True, + ) + + async def flush_pending(self, timeout: float = 15.0) -> None: + """Barrier: wait for scheduled ingests (teardown must not drop the + final write). Timeout guards against a wedged provider call.""" + pending = {t for t in self._pending if not t.done()} + if not pending: + return + done, still = await asyncio.wait(pending, timeout=timeout) + if still: + logger.warning( + f'[orchestrator] {len(still)} memory ingest(s) still running ' + f'after {timeout}s flush timeout') + + @property + def ingest_status(self) -> Dict[str, Any]: + status = dict(self._status) + status['pending'] = sum(1 for t in self._pending if not t.done()) + return status + + def _set_status(self, state: str, count: Optional[int] = None, + error: Optional[str] = None) -> None: + self._status.update( + state=state, + at=time.strftime('%Y-%m-%dT%H:%M:%S%z'), + count=count, + error=error) + + # ------------------------------------------------------------------ + # Ingest ledger (content hashes of already-ingested messages) + # ------------------------------------------------------------------ + + def _ledger_path(self) -> str: + return os.path.join(str(self.mem_config.base_dir), _LEDGER_FILE) + + def _ledger_load(self) -> Set[str]: + if self._ledger is None: + hashes: List[str] = [] + try: + with open(self._ledger_path(), encoding='utf-8') as fh: + hashes = list(json.load(fh).get('hashes') or []) + except (OSError, ValueError): + pass + self._ledger_order = hashes[-_LEDGER_MAX:] + self._ledger = set(self._ledger_order) + return self._ledger + + def _ledger_delta( + self, msg_dicts: List[Dict[str, + Any]]) -> List[Dict[str, Any]]: + """Conversational messages not yet ingested, in order. Repeated + identical texts dedup to their first occurrence — a repeat carries no + new fact, and it keeps the ledger content-addressed (stable across + context compression rewriting the list).""" + seen = self._ledger_load() + delta: List[Dict[str, Any]] = [] + batch: Set[str] = set() + for m in msg_dicts: + if m.get('role') not in _INGEST_ROLES or not m.get('content'): + continue + h = _content_hash(m) + if h in seen or h in batch: + continue + batch.add(h) + delta.append(m) + return delta + + def _ledger_mark(self, msg_dicts: List[Dict[str, Any]], + persist: bool = True) -> None: + seen = self._ledger_load() + added = False + for m in msg_dicts: + h = _content_hash(m) + if h not in seen: + seen.add(h) + self._ledger_order.append(h) + added = True + if not added or not persist: + return + if len(self._ledger_order) > _LEDGER_MAX: + for stale in self._ledger_order[:-_LEDGER_MAX]: + seen.discard(stale) + self._ledger_order = self._ledger_order[-_LEDGER_MAX:] + try: + os.makedirs(str(self.mem_config.base_dir), exist_ok=True) + fd, tmp = tempfile.mkstemp( + dir=str(self.mem_config.base_dir), suffix='.tmp') + with os.fdopen(fd, 'w', encoding='utf-8') as fh: + json.dump({'version': 1, 'hashes': self._ledger_order}, fh) + os.replace(tmp, self._ledger_path()) + except OSError as e: # pragma: no cover - bookkeeping never breaks + logger.warning(f'[orchestrator] ingest ledger write failed: {e}') # ------------------------------------------------------------------ # Flush (pre-compression) @@ -94,9 +330,10 @@ async def add(self, messages: List[Message], **kwargs: Any) -> None: async def flush(self, messages: List[Message]) -> None: if not self.mem_config.enabled: return - backend = await self._ensure_started() - msg_dicts = _messages_to_dicts(messages) - await backend.on_pre_compress(msg_dicts) + async with _store_lock(self.mem_config.base_dir): + backend = await self._ensure_started() + msg_dicts = _messages_to_dicts(messages) + await backend.on_pre_compress(msg_dicts) # ------------------------------------------------------------------ # Search @@ -148,8 +385,12 @@ def init_update_queue(self) -> None: # ------------------------------------------------------------------ async def close(self) -> None: + # Drain scheduled writes first — closing under a pending ingest would + # either lose the write or race the backend teardown. + await self.flush_pending() if self._backend is not None and self._started: - await self._backend.close() + async with _store_lock(self.mem_config.base_dir): + await self._backend.close() self._started = False # ------------------------------------------------------------------ diff --git a/tests/memory/test_backend_contracts.py b/tests/memory/test_backend_contracts.py index 919aa21c5..7cd30d697 100644 --- a/tests/memory/test_backend_contracts.py +++ b/tests/memory/test_backend_contracts.py @@ -220,6 +220,19 @@ def test_tool_schemas_valid(self, backend_setup): def test_on_messages_no_crash(self, backend_setup): name, backend, loop, tmp = backend_setup + if name == "mem0": + # mem0's ingestion calls a live extraction provider, and the + # adapter now PROPAGATES provider failures by design: the + # swallow-and-report layer moved up to MemoryOrchestrator._ingest, + # which needs the exception to keep failed messages in its delta + # ledger for a retry. A clean provider error is therefore a valid + # outcome here (see tests/memory/test_orchestrator_scheduling.py + # for the orchestrator-level never-raises guarantee). + try: + loop.run_until_complete(backend.on_messages(SAMPLE_TURN)) + except Exception: + pass + return loop.run_until_complete(backend.on_messages(SAMPLE_TURN)) def test_on_pre_compress_no_crash(self, backend_setup): diff --git a/tests/memory/test_orchestrator_scheduling.py b/tests/memory/test_orchestrator_scheduling.py new file mode 100644 index 000000000..01e70f929 --- /dev/null +++ b/tests/memory/test_orchestrator_scheduling.py @@ -0,0 +1,224 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""MemoryOrchestrator write discipline: background scheduling, per-store +serialization, the delta ledger, and status reporting. + +These guard the properties that keep ingestion off the turn's critical path +without corrupting the store or silently losing writes: + +* ingestion is delta-only (content hashes), and a hash is recorded ONLY + after the backend accepted the write — a failure retries by construction; +* retrieval and ingestion serialize on one per-store lock (embedded vector + stores underneath have no locking of their own); +* ``flush_pending`` is a real barrier, so teardown cannot drop the last write; +* ``mark_ingested`` advances the ledger without ingesting (interrupted turns). +""" +import asyncio +import json +import os + +import pytest + +from ms_agent.llm.utils import Message +from ms_agent.memory.unified.config import MemoryConfig +from ms_agent.memory.unified.orchestrator import MemoryOrchestrator + + +class RecordingBackend: + + def __init__(self): + self.batches = [] + self.closed = False + self.active = 0 + self.max_active = 0 + + async def start(self, **kwargs): + pass + + async def on_messages(self, messages, **kwargs): + self.active += 1 + self.max_active = max(self.max_active, self.active) + await asyncio.sleep(0.01) + self.active -= 1 + self.batches.append(list(messages)) + return len(messages) + + async def inject(self, messages): + self.active += 1 + self.max_active = max(self.max_active, self.active) + await asyncio.sleep(0.01) + self.active -= 1 + return messages + + async def on_pre_compress(self, messages): + pass + + async def close(self): + self.closed = True + + def invalidate(self): + pass + + +def _orch(tmp_path, backend, **cfg_kwargs): + orch = MemoryOrchestrator( + MemoryConfig( + base_dir=str(tmp_path), storage_backend='file', **cfg_kwargs)) + orch._backend = backend + orch._started = True + return orch + + +def _msgs(*contents, roles=None): + roles = roles or ['user', 'assistant'] * len(contents) + return [ + Message(role=r, content=c) for r, c in zip(roles, contents) + ] + + +def test_schedule_add_ingests_in_background_and_flush_waits(tmp_path): + + async def main(): + backend = RecordingBackend() + orch = _orch(tmp_path, backend) + task = orch.schedule_add(_msgs('hello', 'hi')) + assert task is not None and not backend.batches # not run inline + await orch.flush_pending() + assert len(backend.batches) == 1 + assert orch.ingest_status['state'] == 'ok' + assert orch.ingest_status['count'] == 2 + + asyncio.run(main()) + + +def test_system_and_tool_rows_never_reach_the_backend(tmp_path): + + async def main(): + backend = RecordingBackend() + orch = _orch(tmp_path, backend) + await orch.add([ + Message(role='system', content='prompt'), + Message(role='user', content='q'), + Message(role='tool', content='{"ok":true}', tool_call_id='1'), + Message(role='assistant', content='a'), + ]) + assert [m['role'] for m in backend.batches[0]] == ['user', 'assistant'] + + asyncio.run(main()) + + +def test_second_ingest_sends_only_the_delta(tmp_path): + + async def main(): + backend = RecordingBackend() + orch = _orch(tmp_path, backend) + history = _msgs('turn one', 'answer one') + await orch.add(history) + history += _msgs('turn two', 'answer two') + await orch.add(history) + assert [m['content'] for m in backend.batches[1]] == [ + 'turn two', 'answer two' + ] + + asyncio.run(main()) + + +def test_ledger_survives_a_process_restart(tmp_path): + + async def main(): + history = _msgs('turn one', 'answer one') + await _orch(tmp_path, RecordingBackend()).add(history) + assert os.path.exists(tmp_path / 'ingest_state.json') + with open(tmp_path / 'ingest_state.json') as fh: + assert len(json.load(fh)['hashes']) == 2 + + # A fresh orchestrator (new process) must not re-ingest old turns. + backend = RecordingBackend() + await _orch(tmp_path, backend).add(history) + assert backend.batches == [] + + asyncio.run(main()) + + +def test_failed_ingest_is_retried_on_the_next_turn(tmp_path): + + async def main(): + + class Failing(RecordingBackend): + + async def on_messages(self, messages, **kwargs): + raise RuntimeError('provider down') + + orch = _orch(tmp_path, Failing()) + await orch.add(_msgs('important fact', 'noted')) + assert orch.ingest_status['state'] == 'error' + assert 'provider down' in orch.ingest_status['error'] + + # Hashes were NOT recorded, so the same messages come back as delta. + backend = RecordingBackend() + orch._backend = backend + await orch.add(_msgs('important fact', 'noted')) + assert [m['content'] for m in backend.batches[0]] == [ + 'important fact', 'noted' + ] + + asyncio.run(main()) + + +def test_mark_ingested_skips_interrupted_content(tmp_path): + + async def main(): + backend = RecordingBackend() + orch = _orch(tmp_path, backend) + interrupted = _msgs('do something', 'half-finished ans') + orch.mark_ingested(interrupted) + await orch.add(interrupted + _msgs('next turn', 'done')) + assert [m['content'] for m in backend.batches[0]] == [ + 'next turn', 'done' + ] + + asyncio.run(main()) + + +def test_ingest_and_inject_serialize_on_the_store_lock(tmp_path): + + async def main(): + backend = RecordingBackend() + orch = _orch(tmp_path, backend) + msgs = _msgs('q', 'a') + orch.schedule_add(msgs) + await orch.run(msgs) # retrieval while the ingest task is pending + await orch.flush_pending() + assert backend.max_active == 1 # never overlapped + + asyncio.run(main()) + + +def test_ingest_interval_batches_turns(tmp_path): + + async def main(): + backend = RecordingBackend() + orch = _orch(tmp_path, backend, ingest_interval=2) + history = _msgs('turn one', 'answer one') + await orch.add(history) + assert backend.batches == [] # skipped: 1 of 2 + history += _msgs('turn two', 'answer two') + await orch.add(history) + # The firing ingest carries everything not yet ingested. + assert [m['content'] for m in backend.batches[0]] == [ + 'turn one', 'answer one', 'turn two', 'answer two' + ] + + asyncio.run(main()) + + +def test_close_drains_pending_then_closes_backend(tmp_path): + + async def main(): + backend = RecordingBackend() + orch = _orch(tmp_path, backend) + orch.schedule_add(_msgs('q', 'a')) + await orch.close() + assert len(backend.batches) == 1 # drained, not dropped + assert backend.closed + + asyncio.run(main()) diff --git a/tests/memory/test_unified_memory.py b/tests/memory/test_unified_memory.py index 6f92cb347..6ce4851bd 100644 --- a/tests/memory/test_unified_memory.py +++ b/tests/memory/test_unified_memory.py @@ -1601,11 +1601,13 @@ def test_on_messages_without_mem0(self): loop.close() def test_invalidate(self): - self.backend._snapshot = "cached" - self.backend._snapshot_dirty = False + # invalidate() must drop the per-turn retrieval cache so the next + # inject re-queries (an external edit changed what search should see). + self.backend._turn_cache_key = "user\x1fquery" + self.backend._turn_cache_results = [{"memory": "cached"}] self.backend.invalidate() - assert self.backend._snapshot is None - assert self.backend._snapshot_dirty is True + assert self.backend._turn_cache_key is None + assert self.backend._turn_cache_results is None def test_close_safe(self): loop = asyncio.new_event_loop() From 41cfac2e0342e3f14c768a8b54760c0e3742088b Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Tue, 11 Aug 2026 02:11:52 +0800 Subject: [PATCH 13/36] Ingest memory on closing rounds only, and give shared stores an owner-side close - add_memory(add_after_step) now fires only when a round closes the turn (assistant reply with no tool calls) and dispatches through the backend's schedule_add when available: tool rounds are intermediate state, and ingesting every round cost O(rounds x history) extraction calls where the closing ingest covers the whole turn. - an interrupted round advances the ingest ledger WITHOUT ingesting (mark_ingested): a half-finished answer is not durable conversational truth and must not be swept into the next turn's delta. - cleanup_tools drains scheduled ingestion (flush only -- memory instances are shared across agents of one store, so closing here would yank the store from a sibling agent); the new SharedMemoryManager.close_matching(base_dir) is the owner-of-last- resort that actually closes instances and releases the embedded store's exclusive file lock. --- ms_agent/agent/llm_agent.py | 62 ++++++++++++++++++++++++++----- ms_agent/memory/memory_manager.py | 30 +++++++++++++++ 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index 1efe70886..ca646b8cf 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -938,6 +938,19 @@ async def cleanup_tools(self): await self.mcp_runtime.stop() if self.tool_manager is not None: await self.tool_manager.cleanup() + # Drain scheduled memory ingestion so a teardown right after the last + # turn cannot lose its write. Flush only — memory instances are shared + # across agents of the same store (SharedMemoryManager), so CLOSING + # them here would yank the store out from under a sibling agent; the + # owner of the shared instance decides when to close (e.g. via + # SharedMemoryManager.close_matching). + for tool in self.memory_tools: + flush = getattr(tool, 'flush_pending', None) + if flush is not None: + try: + await flush(timeout=15) + except Exception as e: # noqa: BLE001 - cleanup is best-effort + logger.warning(f'memory flush on cleanup failed: {e}') @property def stream(self): @@ -1937,6 +1950,20 @@ async def add_memory(self, messages: List[Message], add_type, **kwargs): if not any(v is not None for v in [user_id, agent_id, run_id, memory_type]): continue + # Ingestion runs an extraction LLM + embeddings (seconds). + # Backends that support it take the write off the turn's + # critical path — schedule_add returns immediately and the + # write serializes against retrieval on the store's own lock. + # Others (legacy memory types) keep the awaited behaviour. + if add_type == 'add_after_step' and hasattr( + tool, 'schedule_add'): + tool.schedule_add( + messages, + user_id=user_id, + agent_id=agent_id, + run_id=run_id, + memory_type=memory_type) + continue await tool.add( messages, user_id=user_id, @@ -2250,6 +2277,17 @@ async def run_loop(self, messages: Union[List[Message], str], # plus synthesized interrupted tool results — before the # cancellation unwinds. Sync file I/O only; must re-raise. self._persist_partial_round(messages, pre_step_len) + # An interrupted round is never ingested into long-term + # memory: a half-finished answer is not durable + # conversational truth. Advance the ingest ledger past it + # (sync, in-memory + small file write) so the next turn's + # delta does not sweep the partial content in either. + for _mem_tool in self.memory_tools: + if hasattr(_mem_tool, 'mark_ingested'): + try: + _mem_tool.mark_ingested(messages) + except Exception: # noqa: E722 - never mask cancel + pass raise # Persist THIS round's step output (assistant + any tool @@ -2264,17 +2302,21 @@ async def run_loop(self, messages: Union[List[Message], str], for msg in messages[pre_step_len:step_end_len]: self.session_log.append(self._msg_to_dict(msg)) - # Ingest THIS round's memory here, for the same reason the - # SessionLog write above moved up: after_tool_call blocks on the - # next prompt in interactive mode. Running afterwards made - # 'add_after_step' mean "after the *next* step" — round N was + # Ingest memory here — before after_tool_call, which blocks on + # the next prompt in interactive mode (running afterwards made + # 'add_after_step' mean "after the *next* step": round N was # only ingested once round N+1 arrived, a single-round session - # was never ingested at all (the run-loop tail that would have - # caught it is skipped when the input source raises EOF), and - # the message list it saw had the next user turn already - # appended. Before the block, `messages` is exactly this round. - await self.add_memory( - messages, add_type='add_after_step', **kwargs) + # never at all, and the ingested list already carried the next + # user turn). Only on a CLOSING round — an assistant reply with + # no tool calls, i.e. the turn is complete. Tool rounds are + # intermediate state, not durable conversational truth, and + # ingesting every round made memory cost O(rounds x history): + # a 4-round tool-calling answer paid ~4 extraction-LLM calls + # where one covers it (the closing ingest sees the whole turn). + if (messages and messages[-1].role == 'assistant' + and not messages[-1].tool_calls): + await self.add_memory( + messages, add_type='add_after_step', **kwargs) await self.after_tool_call(messages) self.runtime.round += 1 diff --git a/ms_agent/memory/memory_manager.py b/ms_agent/memory/memory_manager.py index 14d7afb3e..77b1ce312 100644 --- a/ms_agent/memory/memory_manager.py +++ b/ms_agent/memory/memory_manager.py @@ -44,6 +44,36 @@ async def get_shared_memory(cls, config: DictConfig, return cls._instances[key] + @classmethod + async def close_matching(cls, base_dir: str) -> int: + """Close and drop every shared instance rooted at ``base_dir``. + + The owner-of-last-resort for embedded stores: closing an instance + releases its vector client (and with it the store's exclusive file + lock), which per-agent cleanup deliberately does NOT do — an + instance may be shared by several live agents, so only whoever knows + no agent still needs the store (e.g. a runtime registry evicting the + last session of a project) may call this. Returns how many instances + were closed.""" + target = os.path.abspath(os.path.expanduser(str(base_dir))) + closed = 0 + for key, mem in list(cls._instances.items()): + mem_cfg = getattr(mem, 'mem_config', None) + base = getattr(mem_cfg, 'base_dir', None) + if base is None or os.path.abspath(str(base)) != target: + continue + try: + close = getattr(mem, 'close', None) + if close is not None: + await close() + except Exception as e: # noqa: BLE001 - eviction is best-effort + logger.warning( + f'closing shared memory for {key} failed: {e}') + cls._instances.pop(key, None) + closed += 1 + logger.info(f'Closed shared memory instance: {key}') + return closed + @classmethod def clear_shared_memory(cls, config: DictConfig, mem_instance_type: str): """Clear shared memory instances. If config is provided, clear specific instance.""" From 313a2327596a51e9f45fda9940428945954f06af Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Tue, 11 Aug 2026 13:49:49 +0800 Subject: [PATCH 14/36] Make mem0 recall size configurable (recall_top_k) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The number of recalled memories injected per turn was hardcoded twice (search default 20, then a [:10] formatting slice). MemoryConfig gains recall_top_k (default 10, read from the unified_memory node) and the mem0 adapter threads it through search and formatting — consumers can now size recall to their context budget. --- ms_agent/memory/unified/backends/mem0_adapter.py | 16 +++++++++------- ms_agent/memory/unified/config.py | 7 ++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ms_agent/memory/unified/backends/mem0_adapter.py b/ms_agent/memory/unified/backends/mem0_adapter.py index 4323763aa..0d8c94410 100644 --- a/ms_agent/memory/unified/backends/mem0_adapter.py +++ b/ms_agent/memory/unified/backends/mem0_adapter.py @@ -46,12 +46,12 @@ def _result_list(results: Any) -> List[Dict[str, Any]]: return list(results or []) -def _mem0_search(m0: Any, query: str, user_id: str) -> Any: +def _mem0_search(m0: Any, query: str, user_id: str, top_k: int = 10) -> Any: """mem0 2.x moved entity params into ``filters=``; 1.x uses kwargs.""" try: - return m0.search(query, filters={'user_id': user_id}) + return m0.search(query, filters={'user_id': user_id}, top_k=top_k) except TypeError: - return m0.search(query, user_id=user_id) + return m0.search(query, user_id=user_id, limit=top_k) class Mem0Backend(BaseMemoryBackend): @@ -121,10 +121,11 @@ async def inject( and self._turn_cache_results is not None: results = self._turn_cache_results else: + top_k = max(1, int(getattr(self._config, 'recall_top_k', 10))) try: results = _result_list( await _offload(_mem0_search, self._mem0, query, - self._user_id)) + self._user_id, top_k)) except Exception as e: logger.debug(f'[mem0_backend] search failed: {e}') return messages @@ -133,7 +134,8 @@ async def inject( if not results: return messages - formatted = self._format_results(results) + formatted = self._format_results( + results, max(1, int(getattr(self._config, 'recall_top_k', 10)))) if not formatted: return messages @@ -220,9 +222,9 @@ def _extract_query(messages: List[Dict[str, Any]]) -> str: return '' @staticmethod - def _format_results(results: Any) -> str: + def _format_results(results: Any, top_k: int = 10) -> str: lines = [] - for r in _result_list(results)[:10]: + for r in _result_list(results)[:top_k]: text = r.get('memory', r.get('text', '')) if text: lines.append(f'- {text}') diff --git a/ms_agent/memory/unified/config.py b/ms_agent/memory/unified/config.py index 5b57cde2e..0640bb64b 100644 --- a/ms_agent/memory/unified/config.py +++ b/ms_agent/memory/unified/config.py @@ -31,6 +31,10 @@ class MemoryConfig: # everything not yet ingested on the next firing ingest. ingest_interval: int = 1 + # How many recalled memories retrieval-style backends (mem0) inject per + # turn. + recall_top_k: int = 10 + # Backend-specific options keyed by backend name backend_options: Dict[str, Any] = field(default_factory=dict) @@ -118,7 +122,8 @@ def from_dict_config(cls, cfg: DictConfig) -> 'MemoryConfig': if k in ns: flat[k] = ns[k] - for k in ('enabled', 'base_dir', 'llm_config', 'ingest_interval'): + for k in ('enabled', 'base_dir', 'llm_config', 'ingest_interval', + 'recall_top_k'): if k in raw: flat[k] = raw[k] From e2d284b7686cb3119a4e503c03acc4a377a3dd43 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Thu, 13 Aug 2026 15:27:09 +0800 Subject: [PATCH 15/36] Build the system prompt from live workspace files (SOUL/AGENTS/PROFILE) instead of scattered config fields, gated by personalization.enabled. When those files change mid-conversation the next user turn carries a durable naming them, so the model can tell a changed file from its own faulty memory. --- ms_agent/agent/agent.yaml | 46 +- ms_agent/agent/llm_agent.py | 287 ++++++++- ms_agent/personalization/profile.py | 11 +- ms_agent/personalization/settings.py | 9 +- ms_agent/prompting/builtin.py | 184 ++++++ ms_agent/prompting/workspace_files.py | 543 ++++++++++++++++++ ms_agent/session/session_log.py | 6 + .../session/strategies/summary_compactor.py | 6 +- ms_agent/skill/runtime.py | 35 +- tests/config/test_resolver.py | 11 +- tests/prompting/test_system_assembly.py | 138 +++++ tests/prompting/test_update_notice.py | 259 +++++++++ tests/prompting/test_workspace_files.py | 184 ++++++ tests/skill/test_prompt_tool_names.py | 28 + tests/skill/test_update_notice.py | 31 +- 15 files changed, 1699 insertions(+), 79 deletions(-) create mode 100644 ms_agent/prompting/builtin.py create mode 100644 ms_agent/prompting/workspace_files.py create mode 100644 tests/prompting/test_system_assembly.py create mode 100644 tests/prompting/test_update_notice.py create mode 100644 tests/prompting/test_workspace_files.py create mode 100644 tests/skill/test_prompt_tool_names.py diff --git a/ms_agent/agent/agent.yaml b/ms_agent/agent/agent.yaml index aeca4dcda..7144a213e 100644 --- a/ms_agent/agent/agent.yaml +++ b/ms_agent/agent/agent.yaml @@ -12,43 +12,15 @@ generation_config: enable_thinking: false prompt: - system: | - You are an assistant that helps me complete tasks. You need to follow these instructions: - - 1. Analyze whether my requirements need tool-calling. If no tools are needed, you can think directly and provide an answer. - - 2. I will give you many tools, some of which are similar. Please carefully analyze which tool you currently need to invoke. - * If tools need to be invoked, you must call at least one tool in each round until the requirement is completed. - * If you get any useful links or images from the tool calling, output them with your answer as well. - * Check carefully the tool result, what it contains, whether it has information you need. - - 3. You DO NOT have built-in geocode/coordinates/links. Do not output any fake geocode/coordinates/links. Always query geocode/coordinates/links from tools first! - - 4. If you need to complete coding tasks, you need to carefully analyze the original requirements, provide detailed requirement analysis, and then complete the code writing. - - 5. This conversation is NOT for demonstration or testing purposes. Answer it as accurately as you can. - - 6. Do not call tools carelessly. Show your thoughts **as detailed as possible**. - - 7. Respond in the same language the user uses. If the user switches, switch accordingly. - - For requests that require performing a specific task or retrieving information, using the following format: - ``` - The user needs to ... - I have analyzed this request in detail and broken it down into the following steps: - ... - ``` - If you have tools which may help you to solve problems, follow this format to answer: - ``` - The user needs to ... - I have analyzed this request in detail and broken it down into the following steps: - ... - First, I should use the [Tool Name] because [explain relevance]. The required input parameters are: ... - ... - I have carefully reviewed the tool's output. The result does/does not fully meet my expectations. Next, I need to ... - ``` - - **Important: Always respond in the same language the user is using.** + # Unset -> built-in base prompt (prompting/builtin.py BASE_AGENT_PROMPT). + # A value replaces that layer only; SOUL/AGENTS/PROFILE.md still apply. + # Always present, so test the value, not `hasattr`. + system: + +personalization: + # Opt in to the user's workspace files (SOUL/AGENTS/PROFILE.md). + # Default is false, so self-contained yamls stay unaffected. + enabled: true max_chat_round: 9999 diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index ca646b8cf..7ab37e209 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -26,6 +26,10 @@ from ms_agent.personalization.injector import PersonalizationInjector from ms_agent.personalization.profile import ProfileManager from ms_agent.personalization.types import PersonalizationConfig +from ms_agent.project.paths import global_home +from ms_agent.prompting import workspace_files +from ms_agent.prompting.builtin import (BASE_AGENT_PROMPT, LIVE_FILES_HINT, + MEMORY_TOOL_GUIDANCE) from ms_agent.rag.base import RAG from ms_agent.rag.utils import rag_mapping from ms_agent.session import ContextAssembler, SessionLog @@ -184,6 +188,8 @@ class LLMAgent(Agent): AGENT_NAME = 'LLMAgent' + # Deprecated: the base slot now falls back to prompting.builtin + # BASE_AGENT_PROMPT; kept only for external references. DEFAULT_SYSTEM = 'You are a helpful assistant.' DEFAULT_MAX_CHAT_ROUND = 20 @@ -240,6 +246,16 @@ def __init__( default_yaml = os.path.join( os.path.dirname(os.path.abspath(__file__)), 'agent.yaml') llm_config = Config.from_task(default_yaml) + # This implicit merge only borrows the default definition's + # plumbing (llm/tools/callbacks) for partial configs. Its + # environment contract (personalization.enabled) must NOT ride + # along: workspace files apply only when the default assistant is + # the *explicit* definition (bare run / tui / webui resolve), or + # when the caller's own config opts in. + if hasattr(llm_config, 'personalization'): + from omegaconf import open_dict + with open_dict(llm_config): + del llm_config['personalization'] config = OmegaConf.merge(llm_config, config) super().__init__(config, tag, trust_remote_code) self.callbacks: List[Callback] = [] @@ -265,6 +281,9 @@ def __init__( # Skill system (initialized in prepare_skills) self._skill_catalog = None self._skill_injector = None + # Conditional system-prompt segment; set by _register_memory_tool once + # the memory tool actually registered (never mutates config.prompt). + self._memory_guidance = '' self._rollback_messages: Optional[List[Message]] = None # Skill runtime (initialized in prepare_skills) @@ -299,6 +318,12 @@ def __init__( # Personalization (lazy-loaded in _build_personalization_section) self._profile_manager = ProfileManager() + # Per-source fingerprints of the hot-reloadable head files as of the + # last state the model was told about (None until initialized from the + # session sidecar or the first build). Drift against this baseline + # fires a durable update notice on the next user turn. + self._prompt_surface: Optional[Dict[str, str]] = None + async def prepare_skills(self): """Initialize the skill system from config.skills. @@ -377,17 +402,46 @@ async def prepare_skills(self): def _build_system_content(self) -> str: """Build the full system prompt content. - Assembly order: base prompt → personalization → skill injection. + Layering (docs: prompt-context design-final §2): + ① BASE — explicit ``prompt.system`` replaces the built-in base prompt + (and only this layer); + ② SOUL.md persona + ③④⑤ instructions/profile files — environment + layers, gated by ``personalization.enabled`` (schema default false; + the packaged default agent.yaml opts in); + ⑥ memory guidance — conditional, present only after the memory tool + registered (see _register_memory_tool); + ⑦ skill section — unchanged. Used by create_messages() and SkillRuntime.maybe_refresh_system_prompt(). """ - content = self.system or LLMAgent.DEFAULT_SYSTEM - - personalization = self._build_personalization_section() - if personalization: - content += '\n\n' + personalization + content = self.system or BASE_AGENT_PROMPT + + if self._personalization_enabled(): + soul = workspace_files.soul_content() + if soul: + content += '\n\n' + soul + personalization = self._build_personalization_section() + if personalization: + content += '\n\n' + personalization + if soul or personalization: + # Self-knowledge of the hot-reload contract; without it the + # model tends to tell users its prompt is a static snapshot. + # {home} resolves the logical ~/.ms_agent labels to the real + # directory so agent-side edits target the right files. + content += '\n\n' + LIVE_FILES_HINT.format( + home=str(global_home())) + + if self._memory_guidance: + content += '\n\n' + self._memory_guidance if self._skill_injector: - skill_section = self._skill_injector.build_skill_prompt_section() + # Through the runtime when present: in update_notice mode it pins + # the skill section to its session-start snapshot (byte-stable; + # changes go through in-conversation notices) while the rest of + # the head stays hot-reloadable. + if self._skill_runtime is not None: + skill_section = self._skill_runtime.build_skill_section() + else: + skill_section = self._skill_injector.build_skill_prompt_section() if skill_section: content += '\n\n' + skill_section @@ -1198,14 +1252,38 @@ async def create_messages( return messages + def _personalization_enabled(self) -> bool: + """Environment contract: does this definition accept workspace files? + + Schema default is **false** so self-contained yamls (task pipelines + like deep_research) stay byte-identical regardless of what lives in + the user's home. The packaged default agent.yaml — the general + assistant that bare CLI / TUI / WebUI all run — opts in explicitly. + """ + p_config = getattr(self.config, 'personalization', None) + if p_config is None: + return False + return bool(getattr(p_config, 'enabled', False)) + def _build_personalization_section(self) -> str: + """Sections ③④⑤: file-first with legacy-field fallback. + + The fallback criterion is "file strips to empty", NOT "file exists" — + ensure-materialized templates are comment-only and must not shadow a + legacy settings/project field before the user writes anything. + """ p_config = getattr(self.config, 'personalization', None) + legacy_global = (getattr(p_config, 'global_instruction', '') + or '') if p_config else '' + legacy_project = (getattr(p_config, 'project_instruction', '') + or '') if p_config else '' config = PersonalizationConfig( - global_instruction=(getattr(p_config, 'global_instruction', '') - or '') if p_config else '', - project_instruction=(getattr(p_config, 'project_instruction', '') - or '') if p_config else '', - user_profile=self._profile_manager.read(), + global_instruction=workspace_files.global_instructions_block( + legacy_fallback=legacy_global), + project_instruction=workspace_files.project_instructions_block( + getattr(self, 'output_dir', None), + legacy_fallback=legacy_project), + user_profile=workspace_files.profile_block(), ) return PersonalizationInjector.build(config) @@ -1272,8 +1350,7 @@ async def load_memory(self): async def _register_memory_tool(self, orchestrator): """Register the memory tool into ToolManager and inject prompt guidance.""" - from ms_agent.memory.unified.memory_tool import (MEMORY_USAGE_PROMPT, - MemoryTool) + from ms_agent.memory.unified.memory_tool import MemoryTool if not hasattr(orchestrator, 'get_tool_schemas'): return @@ -1304,16 +1381,11 @@ async def _register_memory_tool(self, orchestrator): await self.tool_manager.index_extra_tool(mem_tool) logger.info('[unified_memory] Memory tool registered') - # Inject usage guidance into system prompt - if hasattr(self.config, 'prompt') and hasattr(self.config.prompt, - 'system'): - current_prompt = self.config.prompt.system or '' - if 'Long-term Memory' not in current_prompt: - OmegaConf.update( - self.config, - 'prompt.system', - current_prompt + '\n\n' + MEMORY_USAGE_PROMPT, - merge=True) + # Register the usage guidance as an assembly segment (design-final §2 + # rule 3). The previous approach mutated config.prompt.system in + # place, which made the config object a hidden prompt writer and broke + # the "definition is read-only" contract. + self._memory_guidance = MEMORY_TOOL_GUIDANCE def _schedule_add_memory_after_task(self, messages, timestamp=None): @@ -1345,6 +1417,151 @@ async def prepare_knowledge_search(self): self.knowledge_search: SirchmunkSearch = SirchmunkSearch( self.config) + async def _attach_memory_recall(self, messages: List[Message]) -> None: + """Durably attach vector-memory recall to a NEW user turn. + + Runs exactly once per user turn, right before the turn is persisted: + the recall block becomes part of the message in the SessionLog (the + same mechanism skill update notices use), so it + - survives per-round context reassembly (the model's own history + keeps showing what it actually saw), and + - keeps the request a strict prefix-extension of the previous one + (maximal prefix-cache reuse — an ephemeral per-round attach + diverged at the previous user message and re-prefilled the whole + last turn). + Backends without ``recall_block`` (e.g. the file backend, whose + snapshot rides in the system prompt) are unaffected. + """ + if not self.memory_tools or not messages: + return + last = messages[-1] + if getattr(last, 'role', None) != 'user': + return + content = last.content + if not isinstance(content, str): + return + # The turn may already carry other blocks (skill + # update notice prefixed by the host, prompt-files update notice) — + # they must not suppress recall, and must not leak into the retrieval + # query. Idempotency is per-block: the backend's own marker. + query = workspace_files.REMINDER_BLOCK_RE.sub('', content).strip() + if not query: + return + for tool in self.memory_tools: + recall = getattr(tool, 'recall_block', None) + if recall is None: + continue + marker = getattr(tool, 'recall_marker', None) + if marker and marker in content: + return # this turn already carries a recall block + try: + block = await recall(query) + except Exception as e: + logger.warning(f'[memory] recall attach skipped: {e}') + continue + if block: + if block in content: + return # marker-less backend, identical block attached + last.content = f'{last.content}\n\n{block}' + return + + # ── prompt-files update notices (hot-reload perception) ────────────── + # + # The head hot-reloads silently (content compare each round). These + # helpers give the model the missing *event*: per-source fingerprints are + # tracked against what the model was last told, and drift is announced as + # a durable prefixed to the next user message — same + # delivery contract as skill update notices (part of the persisted turn, + # survives reassembly, prefix-cache friendly). Mid-turn edits are + # announced at the next turn boundary; the *content* still applies + # immediately through the per-round refresh. + + def _prompt_surface_sidecar(self) -> Optional[Path]: + if self.session_log is None: + return None + return self.session_log.directory / 'prompt_surface.json' + + def _current_prompt_surface(self) -> Dict[str, str]: + return workspace_files.head_source_fingerprints( + getattr(self, 'output_dir', None)) + + def _commit_prompt_surface(self, surface: Dict[str, str]) -> None: + """The model has now been told this state — persist it.""" + self._prompt_surface = surface + path = self._prompt_surface_sidecar() + if path is None: + return + try: + tmp = path.with_suffix('.json.tmp') + tmp.write_text( + json.dumps({ + 'version': 1, + 'sources': surface + }, + ensure_ascii=False, + indent=1), + encoding='utf-8') + tmp.replace(path) + except OSError as e: + logger.warning(f'[prompt-surface] sidecar save failed: {e}') + + def _load_prompt_surface(self) -> Optional[Dict[str, str]]: + path = self._prompt_surface_sidecar() + if path is None: + return None + try: + data = json.loads(path.read_text(encoding='utf-8')) + except (OSError, ValueError): + return None + sources = data.get('sources') + return sources if isinstance(sources, dict) else None + + def _init_prompt_surface(self) -> None: + """Session start (fresh first turn): begin tracking, announce nothing + — the head was just built from these very files.""" + if not self._personalization_enabled(): + return + self._commit_prompt_surface(self._current_prompt_surface()) + + def _attach_prompt_update_notice(self, messages: List[Message]): + """Prefix a durable update notice to a NEW user turn on drift. + + Returns a commit callable to invoke AFTER the turn is persisted (safe + over-notify: an interrupted turn re-fires the notice next time, never + silently drops it), or None when nothing was attached. + """ + if not self._personalization_enabled(): + return None + if not messages: + return None + last = messages[-1] + if getattr(last, 'role', None) != 'user' or not isinstance( + last.content, str): + return None + + baseline = self._prompt_surface + if baseline is None: + baseline = self._load_prompt_surface() + current = self._current_prompt_surface() + if baseline is None: + # Resumed session predating surface tracking: unknowable drift. + # Start tracking silently rather than spamming every legacy + # resume with a vague "may have changed". + self._commit_prompt_surface(current) + return None + + # A label missing from the baseline (schema growth) never fires. + changed = sorted(label for label, digest in current.items() + if label in baseline and baseline[label] != digest) + if not changed: + if current.keys() - baseline.keys(): + self._commit_prompt_surface(current) + return None + + notice = workspace_files.render_update_notice(changed) + last.content = f'{notice}\n\n{last.content}' + return lambda: self._commit_prompt_surface(current) + async def condense_memory(self, messages: List[Message]) -> List[Message]: """Inject long-term memory context into the message list. @@ -2221,6 +2438,13 @@ async def run_loop(self, messages: Union[List[Message], str], messages, submit, hook_event='UserPromptSubmit') await self.do_rag(messages) + # Durable recall attach BEFORE seeding: the block becomes part + # of this turn in the log (skill-notice style), so it survives + # context reassembly and keeps the prefix cache maximal. + await self._attach_memory_recall(messages) + # Head files were just read to build this head — baseline the + # surface so later turns can detect (and announce) drift. + self._init_prompt_surface() # Seed SessionLog with initial messages if self.session_log is not None: @@ -2319,6 +2543,16 @@ async def run_loop(self, messages: Union[List[Message], str], messages, add_type='add_after_step', **kwargs) await self.after_tool_call(messages) + # New user turn (interactive multi-turn): attach the durable + # augmentations BEFORE the slice below persists them — same + # semantics as the round-0 attach. Order: state notice first + # (prompt-files drift, prefixed), then recall (appended; its + # query strips reminder blocks so notices never pollute it). + commit_surface = None + if len(messages) > step_end_len: + commit_surface = self._attach_prompt_update_notice( + messages) + await self._attach_memory_recall(messages) self.runtime.round += 1 # Persist whatever after_tool_call appended (the next user @@ -2327,6 +2561,11 @@ async def run_loop(self, messages: Union[List[Message], str], for msg in messages[step_end_len:]: self.session_log.append(self._msg_to_dict(msg)) self.session_log.round = self.runtime.round + if commit_surface is not None: + # Only now is the notice durably part of the turn — an + # interrupted persist re-fires it next time (over-notify, + # never silent-drop). + commit_surface() self.save_history(messages) diff --git a/ms_agent/personalization/profile.py b/ms_agent/personalization/profile.py index 060d99dc1..2b73fc01b 100644 --- a/ms_agent/personalization/profile.py +++ b/ms_agent/personalization/profile.py @@ -13,8 +13,15 @@ class ProfileManager: into the system prompt's User Profile section. """ - def __init__(self, global_dir: str = '~/.ms_agent') -> None: - self._dir = Path(os.path.expanduser(global_dir)) + def __init__(self, global_dir: str | None = None) -> None: + # Default follows the runtime home (honors MS_AGENT_HOME) instead of a + # hard-coded '~/.ms_agent' — a no-arg ProfileManager used to read a + # different file than a UI writing to a redirected home (dead link). + if global_dir is None: + from ms_agent.project.paths import global_home + self._dir = global_home() + else: + self._dir = Path(os.path.expanduser(global_dir)) self._path = self._dir / PROFILE_FILENAME @property diff --git a/ms_agent/personalization/settings.py b/ms_agent/personalization/settings.py index adc9169bd..540e3e24a 100644 --- a/ms_agent/personalization/settings.py +++ b/ms_agent/personalization/settings.py @@ -18,8 +18,13 @@ class PersonalizationSettings: are preserved as-is during save. """ - def __init__(self, global_dir: str = '~/.ms_agent') -> None: - self._path = Path(os.path.expanduser(global_dir)) / SETTINGS_FILE + def __init__(self, global_dir: str | None = None) -> None: + # Follows MS_AGENT_HOME by default (see ProfileManager for rationale). + if global_dir is None: + from ms_agent.project.paths import global_home + self._path = global_home() / SETTINGS_FILE + else: + self._path = Path(os.path.expanduser(global_dir)) / SETTINGS_FILE def load(self) -> PersonalizationConfig: data = self._read_section() diff --git a/ms_agent/prompting/builtin.py b/ms_agent/prompting/builtin.py new file mode 100644 index 000000000..a3ed88ccc --- /dev/null +++ b/ms_agent/prompting/builtin.py @@ -0,0 +1,184 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Built-in prompt constants — the *definition* half of the system prompt. + +Layout (see docs: prompt-context design-final): + +- ``BASE_AGENT_PROMPT`` — the built-in base prompt of the general assistant. + It fills the base slot when a config does not set ``prompt.system``; an + explicit ``prompt.system`` replaces this layer (and only this layer). +- ``SOUL_TEMPLATE`` / ``AGENTS_TEMPLATE`` / ``PROFILE_TEMPLATE`` — default + templates materialized into ``~/.ms_agent/`` on first read (route B). + Guidance inside AGENTS/PROFILE templates lives in HTML comments so a + pristine template injects nothing ("seeded != injected"); SOUL's body is + the real default persona and injects as-is. +- ``MEMORY_TOOL_GUIDANCE`` — conditional segment, injected only after the + memory tool registers successfully (never baked into BASE). + +Precedent for constants-in-code rather than packaged prompt files: +deepagents ``BASE_AGENT_PROMPT``, hermes ``prompt_builder.py``, deer-flow +``SYSTEM_PROMPT_TEMPLATE``. Code constants ship with the wheel by +construction — no package-data risk. +""" +from __future__ import annotations + +#: Bump when a template below changes materially. The workspace sidecar +#: records the version + sha256 written, so untouched files upgrade silently +#: while user-edited files are left alone (see workspace_files.py). +TEMPLATE_VERSION = 1 + +BASE_AGENT_PROMPT = """\ +You are MS-Agent, a general-purpose assistant. You help with everyday work of +all kinds — research, writing, document and file handling, data analysis, +planning, and coding. Programming is one of your skills, not your only job. + +## How you work +- First decide whether the task needs tools. If you can answer reliably from + what you know and what the user gave you, just answer. +- When you use a tool, know why you chose it. After each call, read the result + carefully: check what it actually contains and whether it answers the need + before moving on. +- Never invent facts. Links, numbers, file paths, dates, and quotes must come + from tool results or from material the user provided. If you don't know, + say you don't know. +- Prefer doing over asking when the action is safe and easy to undo. Ask first + when it isn't, and ask everything you need in one round. +- Report outcomes honestly, including steps that failed or were skipped. +- Respond in the language the user is using; switch when they switch. + +## Safety +- Confirm with the user before actions that are hard to reverse or that leave + the machine: sending, publishing, deleting, paying, or overwriting user + files. +- The user's data is private. Never move it somewhere the user didn't intend. +- Never bypass permission or approval mechanisms, even when asked to hurry. +""" + +SOUL_TEMPLATE = """\ +--- +version: 1 +about: Personality and working attitude. Edit freely — this file is yours. +--- + +# Who You Are + +## Temperament +- **Direct.** Skip filler openers like "Great question!" — give the answer or + start the work. +- **Has judgment.** You may disagree and prefer things, with reasons. Don't + flatter, don't just agree. +- **Resourceful first.** Read the file, search, try once — then ask if truly + stuck. +- **Plain words.** Lead with the conclusion, then the detail. Avoid jargon + walls. + +## With your user +- You work for a real person on real tasks, not a demo audience. Assume + competence; don't oversell or coddle. +- Unsure means saying so. Never paper over a gap with a confident tone. +- You are a guest. Their files, schedule, and accounts belong to them. + +## Boundaries +- Private things stay private. +- Outward actions (sending, publishing, deleting) get confirmed first. +""" + +AGENTS_TEMPLATE = """\ +--- +version: 1 +about: Your standing instructions, applied to every session. Project AGENTS.md + adds per-project rules on top. +--- + + +""" + +PROFILE_TEMPLATE = """\ +--- +version: 1 +about: Who the user is. Filled by the user and the assistant together; only + uncommented content reaches the model. +--- + + +""" + +#: Conditional segment: injected by the assembler only when the memory tool +#: registered successfully (tool-less backends and disabled memory skip it). +#: Keep wording generic ("memory tools") — actual tool names are +#: backend-defined and must not be hard-coded here. +MEMORY_TOOL_GUIDANCE = """\ +## Long-term Memory + +You have memory tools available in this session, backed by a persistent +long-term memory. Use them proactively. + +**When to save:** +- The user explicitly states a preference (e.g. "I prefer ruff over flake8") +- The user shares important project context (tech stack, conventions, + deadlines) +- The user corrects you — save the correction to avoid repeating the mistake +- Key decisions are made during the conversation +- Recurring patterns you notice (coding style, communication preferences) + +**When NOT to save:** +- Transient information (today's weather, one-off questions) +- Information already present in your memory +- Conversation filler or greetings +- Sensitive credentials or secrets (API keys, passwords) + +**Division of labor:** durable user preferences belong in PROFILE.md; use +memory for facts learned while working. + +**Be conservative** — only save facts that will genuinely help in future +sessions. Quality over quantity. +""" + +#: Appended after the personalization layers when any of them injected +#: content. Gives the model correct self-knowledge of the hot-reload +#: mechanism: without it, models plausibly (and wrongly) tell users their +#: system prompt is a session-start snapshot that cannot pick up file edits. +LIVE_FILES_HINT = """\ +The persona, instructions and profile above come from workspace files \ +(SOUL.md, AGENTS.md, PROFILE.md) that stay live during the conversation: \ +edits apply from the next round, and this system prompt always shows the \ +current file content. When files change mid-conversation, a \ + at the start of a user turn lists which ones changed. \ +The ~/.ms_agent/... source labels are logical names — on this machine those \ +files actually live in {home}; project AGENTS.md files live in the project \ +directory.""" + +#: Filename -> template registry used by workspace_files.ensure logic. +HOME_FILE_TEMPLATES = { + 'SOUL.md': SOUL_TEMPLATE, + 'AGENTS.md': AGENTS_TEMPLATE, + 'PROFILE.md': PROFILE_TEMPLATE, +} diff --git a/ms_agent/prompting/workspace_files.py b/ms_agent/prompting/workspace_files.py new file mode 100644 index 000000000..4dc981944 --- /dev/null +++ b/ms_agent/prompting/workspace_files.py @@ -0,0 +1,543 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Workspace prompt files — the *environment* half of the system prompt. + +User-editable Markdown sources: + +- ``~/.ms_agent/SOUL.md`` persona (additive layer) +- ``~/.ms_agent/AGENTS.md`` global standing instructions +- ``~/.ms_agent/PROFILE.md`` who the user is +- ``/AGENTS.md`` project instructions (shared slot) +- ``/.ms_agent/AGENTS.md`` project instructions (private slot) + +Behavioral contract (docs: prompt-context design-final §2.1/§3/§5.4): + +- **Seeded != injected.** Templates keep guidance inside HTML comments; the + injection pipeline strips frontmatter + HTML comments and skips empty + results, so a pristine template contributes nothing. +- **Ensure-on-first-read** (lazy, entrance-agnostic) with a sha256 sidecar per + home file: pristine files upgrade silently on template bumps, user-edited + files are never overwritten, deleted files stay deleted. +- **Legacy PROFILE rebuild**: an old free-text ``profile.md`` is rebuilt once + into the new format (template header + old text as free region), with a + ``.bak`` and a non-pristine sidecar so upgrades never clobber user content. +- **mtime cache** so the per-round system-prompt rebuild does no repeat IO. +""" +from __future__ import annotations + +import hashlib +import json +import re +from pathlib import Path +from typing import Dict, Optional, Tuple + +from ms_agent.prompting.builtin import (HOME_FILE_TEMPLATES, TEMPLATE_VERSION) +from ms_agent.project.paths import global_home, local_internal_dir +from ms_agent.utils.logger import get_logger + +logger = get_logger() + +#: Per-file cap on injected characters (hermes-style context cap). +MAX_FILE_CHARS = 20_000 + +_FRONTMATTER_RE = re.compile(r'^\s*---\s*\n.*?\n---\s*\n?', re.DOTALL) +_HTML_COMMENT_RE = re.compile(r'', re.DOTALL) +_CALL_ME_RE = re.compile(r'^\s*[-*]\s*\**\s*Call me\s*\**\s*[::]\s*(.*)$', + re.IGNORECASE) + +#: name -> sidecar filename (records what the framework materialized). +_SIDECAR_NAMES = { + 'SOUL.md': '.soul.builtin', + 'AGENTS.md': '.agents.builtin', + 'PROFILE.md': '.profile.builtin', +} + +# (path -> (mtime_ns, size, text)) read cache; (path) set for truncate warns. +_read_cache: Dict[str, Tuple[int, int, str]] = {} +_warned_truncate: set = set() +_ensured_homes: set = set() + + +def reset_cache() -> None: + """Testing/tooling hook: forget cached reads and ensure state.""" + _read_cache.clear() + _warned_truncate.clear() + _ensured_homes.clear() + + +# ── strip pipeline ─────────────────────────────────────────────────────────── + + +def strip_frontmatter(text: str) -> str: + return _FRONTMATTER_RE.sub('', text, count=1) + + +def strip_html_comments(text: str) -> str: + return _HTML_COMMENT_RE.sub('', text) + + +def strip_for_injection(text: str) -> str: + """frontmatter → HTML comments → trim. Empty result means "inject nothing".""" + return strip_html_comments(strip_frontmatter(text)).strip() + + +def _escape_closing(body: str, tag: str) -> str: + """Keep user content from breaking out of its source-labelled wrapper.""" + return body.replace(f'', f'<\\/{tag}>') + + +def wrap_block(tag: str, source: str, body: str) -> str: + return f'<{tag} source="{source}">\n{_escape_closing(body, tag)}\n' + + +# ── cached raw reads ───────────────────────────────────────────────────────── + + +def _read_raw(path: Path) -> str: + """mtime-cached raw read; '' when missing/unreadable.""" + key = str(path) + try: + st = path.stat() + except OSError: + _read_cache.pop(key, None) + return '' + cached = _read_cache.get(key) + if cached and cached[0] == st.st_mtime_ns and cached[1] == st.st_size: + return cached[2] + try: + text = path.read_text(encoding='utf-8', errors='replace') + except OSError: + return '' + _read_cache[key] = (st.st_mtime_ns, st.st_size, text) + return text + + +def _capped(body: str, path: Path) -> str: + if len(body) <= MAX_FILE_CHARS: + return body + if str(path) not in _warned_truncate: + _warned_truncate.add(str(path)) + logger.warning( + f'[workspace_files] {path} exceeds {MAX_FILE_CHARS} chars; ' + f'truncating its injected content') + return body[:MAX_FILE_CHARS] + '\n\n[...truncated: file exceeds limit...]' + + +def _atomic_write(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + '.tmp') + tmp.write_text(text, encoding='utf-8') + tmp.replace(path) + + +# ── sidecar bookkeeping ────────────────────────────────────────────────────── + + +def _sha256(text: str) -> str: + return hashlib.sha256(text.encode('utf-8')).hexdigest() + + +def _sidecar_path(home: Path, name: str) -> Path: + return home / _SIDECAR_NAMES[name] + + +def _load_sidecar(home: Path, name: str) -> Optional[dict]: + try: + return json.loads(_sidecar_path(home, name).read_text('utf-8')) + except (OSError, json.JSONDecodeError, ValueError): + return None + + +def _save_sidecar(home: Path, name: str, data: dict) -> None: + try: + _atomic_write(_sidecar_path(home, name), json.dumps(data, indent=1)) + except OSError as e: # sidecar failures must never break the agent + logger.warning(f'[workspace_files] cannot write sidecar for {name}: {e}') + + +# ── ensure / rebuild (route B) ─────────────────────────────────────────────── + + +def _ensure_one(home: Path, name: str, template: str) -> None: + path = home / name + sidecar = _load_sidecar(home, name) + if not path.exists(): + if sidecar is not None: + return # user deleted it — respect the deletion, never re-seed + try: + _atomic_write(path, template) + except OSError as e: + logger.warning(f'[workspace_files] cannot materialize {name}: {e}') + return + _save_sidecar(home, name, { + 'template_version': TEMPLATE_VERSION, + 'sha256': _sha256(template), + 'pristine': True, + }) + logger.info(f'[workspace_files] materialized default {name} in {home}') + return + # Existing file: silent upgrade only when pristine (hash matches what we + # wrote) and the built-in template moved forward. + if (sidecar and sidecar.get('pristine') + and sidecar.get('template_version', 0) < TEMPLATE_VERSION + and _sha256(_read_raw(path)) == sidecar.get('sha256')): + try: + _atomic_write(path.with_name(name + '.bak'), _read_raw(path)) + _atomic_write(path, template) + except OSError as e: + logger.warning(f'[workspace_files] cannot upgrade {name}: {e}') + return + _save_sidecar(home, name, { + 'template_version': TEMPLATE_VERSION, + 'sha256': _sha256(template), + 'pristine': True, + }) + logger.info(f'[workspace_files] upgraded pristine {name} to ' + f'template v{TEMPLATE_VERSION}') + + +def _is_new_format(raw: str) -> bool: + """New-format files start with a frontmatter block carrying ``version:``.""" + if not raw.lstrip().startswith('---'): + return False + m = _FRONTMATTER_RE.match(raw.lstrip()) + return bool(m and re.search(r'^version\s*:', m.group(0), re.MULTILINE)) + + +def _rebuild_legacy_profile(home: Path) -> None: + """One-time rebuild of a legacy free-text profile into the new format. + + New file = template header (frontmatter + comment guidance) + the old text + verbatim as the free region. Old content is backed up; the sidecar is + written non-pristine so template upgrades can never clobber user text. + """ + target = home / 'PROFILE.md' + legacy = home / 'profile.md' + src = target if target.exists() else (legacy if legacy.exists() else None) + if src is None: + return + raw = _read_raw(src) + if _is_new_format(raw): + return + template = HOME_FILE_TEMPLATES['PROFILE.md'] + rebuilt = template.rstrip('\n') + '\n' + if raw.strip(): + rebuilt += '\n' + raw.strip() + '\n' + try: + _atomic_write(target.with_name('PROFILE.md.bak'), raw) + _atomic_write(target, rebuilt) + # On case-sensitive filesystems the legacy lowercase file is a distinct + # entry; drop it (its content lives in the .bak and in the new file). + # On case-insensitive filesystems (macOS/Windows default) they are the + # same file and os.replace() KEEPS the existing directory entry's case + # — fix the case with an explicit rename so the file really is + # PROFILE.md everywhere. + if legacy.exists(): + try: + same = legacy.samefile(target) + except OSError: + same = False + if not same: + legacy.unlink(missing_ok=True) + else: + try: + legacy.rename(target) # case-only rename + except OSError: + pass + except OSError as e: + # Read-only FS etc.: keep reading the legacy file in place — the strip + # pipeline treats plain text as free region, injection is unaffected. + logger.warning(f'[workspace_files] profile rebuild skipped: {e}') + return + _save_sidecar(home, 'PROFILE.md', { + 'template_version': TEMPLATE_VERSION, + 'sha256': _sha256(rebuilt), + 'pristine': False, + 'rebuilt_from': src.name, + }) + logger.info(f'[workspace_files] rebuilt legacy {src.name} -> PROFILE.md ' + f'(backup: PROFILE.md.bak)') + + +def ensure_home_files(home: Optional[Path] = None) -> None: + """Materialize missing home files + run the one-time PROFILE rebuild. + + Idempotent and cheap after the first call per home (keyed by path so tests + that redirect ``MS_AGENT_HOME`` re-ensure their own home). + """ + home = home or global_home() + key = str(home) + if key in _ensured_homes: + return + _rebuild_legacy_profile(home) + for name, template in HOME_FILE_TEMPLATES.items(): + _ensure_one(home, name, template) + _ensured_homes.add(key) + + +# ── PROFILE region model (R0 header / R1 managed / R2 free) ───────────────── + + +def _line_comment_flags(lines): + """Per-line flag: True when the line is entirely comment/blank inside a + ```` block (template guidance), i.e. carries no injectable text.""" + flags = [] + in_comment = False + for line in lines: + stripped_spans = _HTML_COMMENT_RE.sub('', line) + if in_comment: + if '-->' in line: + in_comment = False + rest = line.split('-->', 1)[1] + flags.append(not rest.strip()) + else: + flags.append(True) + continue + opens = line.count('') + if opens: + in_comment = True + before = line.split('\n') + wf.reset_cache() + assert wf.head_source_fingerprints(str(work)) == base + + # Real content changes the one fingerprint it belongs to. + with open(home / 'AGENTS.md', 'a', encoding='utf-8') as f: + f.write('\nAnswer in French.\n') + wf.reset_cache() + after = wf.head_source_fingerprints(str(work)) + changed = [k for k in base if after[k] != base[k]] + assert changed == ['~/.ms_agent/AGENTS.md'] + + # No project -> no project keys. + assert set(wf.head_source_fingerprints(None)) == { + '~/.ms_agent/SOUL.md', '~/.ms_agent/AGENTS.md', + '~/.ms_agent/PROFILE.md' + } + + +def test_render_update_notice_shape(home): + text = wf.render_update_notice( + ['~/.ms_agent/AGENTS.md', '/.ms_agent/AGENTS.md']) + assert text.startswith('') + assert text.endswith('') + assert wf.UPDATE_NOTICE_MARKER in text + assert '~/.ms_agent/AGENTS.md, /.ms_agent/AGENTS.md' in text + assert 'did not misremember' in text + + +# ── agent attach flow ──────────────────────────────────────────────────────── + + +def test_notice_fires_once_on_drift(home, tmp_path): + agent = _agent(tmp_path, personalization={'enabled': True}) + agent._init_prompt_surface() + + # No drift -> no notice. + messages = _user_turn() + assert agent._attach_prompt_update_notice(messages) is None + assert '' not in messages[-1].content + + # Drift -> prefixed notice naming the file; baseline moves only on commit. + with open(home / 'AGENTS.md', 'a', encoding='utf-8') as f: + f.write('\nAnswer in French.\n') + wf.reset_cache() + commit = agent._attach_prompt_update_notice(messages) + assert commit is not None + content = messages[-1].content + assert content.startswith('') + assert '~/.ms_agent/AGENTS.md' in content + assert content.rstrip().endswith('下一个问题') + + # Un-committed (turn failed to persist): the next turn re-fires. + retry = _user_turn('再问一次') + assert agent._attach_prompt_update_notice(retry) is not None + + # Committed: quiet from here on. + commit() + clean = _user_turn('第三问') + assert agent._attach_prompt_update_notice(clean) is None + assert clean[-1].content == '第三问' + + +def test_notice_disabled_without_personalization(home, tmp_path): + agent = _agent(tmp_path) # gate off + agent._init_prompt_surface() + (home / 'AGENTS.md').parent.mkdir(parents=True, exist_ok=True) + (home / 'AGENTS.md').write_text('New rules\n', encoding='utf-8') + wf.reset_cache() + messages = _user_turn() + assert agent._attach_prompt_update_notice(messages) is None + assert messages[-1].content == '下一个问题' + + +def test_sidecar_survives_process_restart(home, tmp_path): + from ms_agent.session.session_log import SessionLog + + session_dir = tmp_path / 'sess' + agent = _agent(tmp_path, personalization={'enabled': True}) + agent.session_log = SessionLog(session_dir, session_key='session_x') + agent._init_prompt_surface() + assert (session_dir / 'prompt_surface.json').exists() + + # "Restart": a fresh agent over the same session dir, file edited while + # the process was down. + with open(home / 'AGENTS.md', 'a', encoding='utf-8') as f: + f.write('\nEdited while offline.\n') + wf.reset_cache() + agent2 = _agent(tmp_path, personalization={'enabled': True}) + agent2.session_log = SessionLog(session_dir, session_key='session_x') + messages = _user_turn() + commit = agent2._attach_prompt_update_notice(messages) + assert commit is not None + assert '~/.ms_agent/AGENTS.md' in messages[-1].content + commit() + + # And a third agent sees no drift. + agent3 = _agent(tmp_path, personalization={'enabled': True}) + agent3.session_log = SessionLog(session_dir, session_key='session_x') + assert agent3._attach_prompt_update_notice(_user_turn()) is None + + +def test_legacy_session_without_sidecar_stays_silent(home, tmp_path): + """Unknowable drift (session predates tracking): start tracking quietly + instead of guessing.""" + agent = _agent(tmp_path, personalization={'enabled': True}) + messages = _user_turn() + assert agent._attach_prompt_update_notice(messages) is None + # ...but tracking has begun: real drift after this point does fire. + with open(home / 'AGENTS.md', 'a', encoding='utf-8') as f: + f.write('\nNow it changed.\n') + wf.reset_cache() + assert agent._attach_prompt_update_notice(_user_turn()) is not None + + +# ── coexistence: skill notice + prompt notice + recall ─────────────────────── + + +def test_all_three_attachments_coexist(home, tmp_path): + """A host-prefixed skill notice must not suppress the prompt-files notice + nor the recall attach; the recall query sees only the user's words.""" + agent = _agent(tmp_path, personalization={'enabled': True}) + agent._init_prompt_surface() + with open(home / 'AGENTS.md', 'a', encoding='utf-8') as f: + f.write('\nDrifted.\n') + wf.reset_cache() + + seen_queries = [] + + class FakeOrchestrator: + recall_marker = '- MEM:' + + async def recall_block(self, query): + seen_queries.append(query) + return '\n- MEM: F1\n' + + agent.memory_tools = [FakeOrchestrator()] + + skill_notice = ('\nSkill inventory updated. CURRENT ' + 'full list: ...\n') + messages = _user_turn(f'{skill_notice}\n\n查一下我的偏好') + + commit = agent._attach_prompt_update_notice(messages) + asyncio.run(agent._attach_memory_recall(messages)) + assert commit is not None + + content = messages[-1].content + # prompt-files notice first, then the host's skill notice, then the words, + # then recall — and the retrieval query carried none of the notices. + assert content.index(wf.UPDATE_NOTICE_MARKER) < content.index( + 'Skill inventory updated') + assert content.index('Skill inventory updated') < content.index('查一下我的偏好') + assert content.rstrip().endswith('') + assert '- MEM: F1' in content + assert seen_queries == ['查一下我的偏好'] + + # Idempotent per mechanism: a second recall attach is a no-op. + asyncio.run(agent._attach_memory_recall(messages)) + assert content == messages[-1].content + + +def test_recall_not_suppressed_by_skill_notice_alone(home, tmp_path): + """Regression: the old guard skipped recall whenever ANY + was present — a skill-notice turn lost its memories.""" + agent = _agent(tmp_path) + + class FakeOrchestrator: + recall_marker = '- MEM:' + + async def recall_block(self, query): + assert query == '我的主题偏好?' + return '\n- MEM: 深色主题\n' + + agent.memory_tools = [FakeOrchestrator()] + messages = [ + Message(role='system', content='S'), + Message( + role='user', + content=('\nSkill inventory updated.\n' + '\n\n我的主题偏好?')), + ] + asyncio.run(agent._attach_memory_recall(messages)) + assert '深色主题' in messages[-1].content + + +# ── static self-knowledge hint ─────────────────────────────────────────────── + + +def test_live_files_hint_present_iff_personalized_content(home, tmp_path): + wf.ensure_home_files() + agent = _agent(tmp_path, personalization={'enabled': True}) + # Default SOUL template has real content -> hint present, with the + # logical ~/.ms_agent labels resolved to the real home directory. + content = agent._build_system_content() + assert builtin.LIVE_FILES_HINT.format(home=str(home)) in content + assert str(home) in content + + # Gate off -> no hint. + agent2 = _agent(tmp_path) + assert 'stay live during the conversation' not in \ + agent2._build_system_content() diff --git a/tests/prompting/test_workspace_files.py b/tests/prompting/test_workspace_files.py new file mode 100644 index 000000000..cc4c187f6 --- /dev/null +++ b/tests/prompting/test_workspace_files.py @@ -0,0 +1,184 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Workspace prompt files: strip pipeline, ensure/sidecar, regions, rebuild.""" +import json +import os + +import pytest + +from ms_agent.prompting import builtin, workspace_files as wf + + +@pytest.fixture() +def home(tmp_path, monkeypatch): + monkeypatch.setenv('MS_AGENT_HOME', str(tmp_path)) + wf.reset_cache() + yield tmp_path + wf.reset_cache() + + +# ── strip pipeline ─────────────────────────────────────────────────────────── + + +def test_pristine_templates_strip_to_empty(): + assert wf.strip_for_injection(builtin.AGENTS_TEMPLATE) == '' + assert wf.strip_for_injection(builtin.PROFILE_TEMPLATE) == '' + + +def test_soul_template_is_real_content(): + body = wf.strip_for_injection(builtin.SOUL_TEMPLATE) + assert body.startswith('# Who You Are') + assert 'version:' not in body # frontmatter stripped + + +def test_escape_keeps_wrapper_intact(): + block = wf.wrap_block('instructions', 'x.md', 'evil body') + # exactly one real closing tag — the payload's copy is defused + assert block.count('') == 1 + assert '<\\/instructions>' in block + + +# ── ensure / sidecar / deletion ───────────────────────────────────────────── + + +def test_ensure_materializes_and_is_idempotent(home): + wf.ensure_home_files() + for name in ('SOUL.md', 'AGENTS.md', 'PROFILE.md'): + assert (home / name).exists(), name + sidecar = json.loads((home / '.soul.builtin').read_text()) + assert sidecar['pristine'] is True + assert sidecar['template_version'] == builtin.TEMPLATE_VERSION + + mtimes = {n: (home / n).stat().st_mtime_ns + for n in ('SOUL.md', 'AGENTS.md', 'PROFILE.md')} + wf.reset_cache() + wf.ensure_home_files() + for n, t in mtimes.items(): + assert (home / n).stat().st_mtime_ns == t, f'{n} rewritten' + + +def test_deleted_file_stays_deleted(home): + wf.ensure_home_files() + (home / 'SOUL.md').unlink() + wf.reset_cache() + wf.ensure_home_files() + assert not (home / 'SOUL.md').exists() + assert wf.soul_content() == '' + + +# ── injected blocks: file-first, stripped-empty falls back to legacy ──────── + + +def test_pristine_file_does_not_shadow_legacy_field(home): + block = wf.global_instructions_block(legacy_fallback='Be terse.') + assert 'legacy:settings.json' in block + assert 'Be terse.' in block + + +def test_user_content_wins_over_legacy_field(home): + wf.ensure_home_files() + path = home / 'AGENTS.md' + path.write_text(path.read_text() + '\nAlways answer in French.\n') + wf.reset_cache() + block = wf.global_instructions_block(legacy_fallback='Be terse.') + assert 'Always answer in French.' in block + assert 'Be terse.' not in block + assert '~/.ms_agent/AGENTS.md' in block + + +def test_project_slots_are_additive(home, tmp_path): + work = tmp_path / 'proj' + (work / '.ms_agent').mkdir(parents=True) + (work / 'AGENTS.md').write_text('shared rule\n') + (work / '.ms_agent' / 'AGENTS.md').write_text('private rule\n') + block = wf.project_instructions_block(str(work)) + assert 'shared rule' in block and 'private rule' in block + assert block.index('shared rule') < block.index('private rule') + assert 'source="AGENTS.md"' in block + assert 'source=".ms_agent/AGENTS.md"' in block + + +def test_truncation(home): + wf.ensure_home_files() + (home / 'AGENTS.md').write_text('x' * (wf.MAX_FILE_CHARS + 500)) + wf.reset_cache() + block = wf.global_instructions_block() + assert 'truncated' in block + assert len(block) < wf.MAX_FILE_CHARS + 300 + + +def test_hot_reload_on_change(home): + wf.ensure_home_files() + assert 'first version' not in wf.soul_content() + (home / 'SOUL.md').write_text('first version of the soul\n') + assert 'first version' in wf.soul_content() # mtime/size cache invalidated + + +# ── legacy PROFILE rebuild ─────────────────────────────────────────────────── + + +def test_legacy_profile_rebuilt_once(home): + (home / 'profile.md').write_text('I mainly do agent work.\n') + wf.ensure_home_files() + + target = home / 'PROFILE.md' + raw = target.read_text() + assert raw.lstrip().startswith('---') and 'version:' in raw + assert 'I mainly do agent work.' in raw + assert (home / 'PROFILE.md.bak').exists() + sidecar = json.loads((home / '.profile.builtin').read_text()) + assert sidecar['pristine'] is False # upgrades must never clobber it + + # injected content is exactly the old text (template header strips away) + block = wf.profile_block() + assert 'I mainly do agent work.' in block + assert 'source="~/.ms_agent/PROFILE.md"' in block + + # idempotent: run again, file unchanged + before = target.read_text() + wf.reset_cache() + wf.ensure_home_files() + assert target.read_text() == before + + +def test_new_format_profile_not_rebuilt(home): + wf.ensure_home_files() + target = home / 'PROFILE.md' + before = target.read_text() + wf.reset_cache() + wf.ensure_home_files() + assert target.read_text() == before + assert not (home / 'PROFILE.md.bak').exists() + + +# ── PROFILE region model / Call me line ───────────────────────────────────── + + +def test_call_me_roundtrip_on_template(): + t = builtin.PROFILE_TEMPLATE + assert wf.get_call_me(t) == '' # commented skeleton must not match + x = wf.set_call_me(t, 'Han Zhou') + assert wf.get_call_me(x) == 'Han Zhou' + r0, r1, r2 = wf.split_profile_regions(x) + assert '# About Me' in r1 and 'Call me: Han Zhou' in r1 + # free region editing keeps the managed line + y = wf.set_free_region(x, 'Mostly agent work.\n') + assert wf.get_call_me(y) == 'Han Zhou' + assert 'Mostly agent work.' in wf.get_free_region(y) + # clearing removes the line + z = wf.set_call_me(y, '') + assert wf.get_call_me(z) == '' + assert 'Mostly agent work.' in z + + +def test_regions_reconstruct_exactly(): + for text in (builtin.PROFILE_TEMPLATE, + wf.set_call_me(builtin.PROFILE_TEMPLATE, 'X'), + 'plain legacy text\nwith two lines\n'): + r0, r1, r2 = wf.split_profile_regions(text) + assert r0 + r1 + r2 == text + + +def test_plain_text_is_all_free_region(): + r0, r1, r2 = wf.split_profile_regions('just some intro text\n') + assert r0 == '' and r1 == '' + assert r2 == 'just some intro text\n' diff --git a/tests/skill/test_prompt_tool_names.py b/tests/skill/test_prompt_tool_names.py new file mode 100644 index 000000000..bd4e60e2b --- /dev/null +++ b/tests/skill/test_prompt_tool_names.py @@ -0,0 +1,28 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Regression tripwire: tool names referenced in injected prompt text must +exist in the skill toolset. If a tool is renamed, this fails before the model +starts calling a tool that no longer exists (P0 item 9).""" +import inspect + +from ms_agent.skill import skill_tools +from ms_agent.skill.prompt_injector import SkillPromptInjector + +REFERENCED = ('skills_list', 'skill_view') + + +def test_prompt_text_references_real_tool_names(): + prompt_text = (SkillPromptInjector.SKILL_SECTION_HEADER + + SkillPromptInjector.DISCOVERY_HINT) + source = inspect.getsource(skill_tools) + for name in REFERENCED: + assert name in prompt_text, f'{name} vanished from the prompt text' + assert f"'{name}'" in source, ( + f'{name} is referenced in the skill prompt text but no longer ' + f'appears in skill_tools.py — rename both together') + + +def test_manage_tool_still_dispatched(): + # skill_manage is not advertised in the header (manage is opt-in), but the + # dispatcher must keep accepting it while any doc/skill references it. + source = inspect.getsource(skill_tools) + assert "'skill_manage'" in source diff --git a/tests/skill/test_update_notice.py b/tests/skill/test_update_notice.py index 49bc9b3e2..b123c4e81 100644 --- a/tests/skill/test_update_notice.py +++ b/tests/skill/test_update_notice.py @@ -33,15 +33,36 @@ def __init__(self, role, content): class TestHeadGate: - def test_disabled_gate_keeps_head_untouched(self, tmp_path): + def test_notice_mode_pins_skill_section_but_head_still_refreshes( + self, tmp_path): + """Source-tiered refresh (2026-08): in update_notice mode the SKILL + section is frozen at its session snapshot (skill changes ride the + in-conversation notices), while instruction/persona layers keep + hot-reloading through the content compare.""" cat = _catalog(tmp_path) - rt = SkillRuntime(catalog=cat) - rt.set_system_content_builder(lambda: 'NEW HEAD') + injector = SkillPromptInjector(cat, update_notice=True) + rt = SkillRuntime(catalog=cat, injector=injector) rt.head_refresh_enabled = False - messages = [_Msg('system', 'OLD HEAD')] + frozen = rt.build_skill_section() + assert 'alpha' in frozen + # a skill change must NOT alter the pinned section... + rt.toggle('alpha', False) + assert rt.build_skill_section() == frozen + # ...while the live injector output did change underneath + assert injector.build_skill_prompt_section() != frozen + + instructions = {'text': 'OLD INSTRUCTIONS'} + rt.set_system_content_builder( + lambda: instructions['text'] + '\n\n' + rt.build_skill_section()) + messages = [_Msg('system', 'OLD INSTRUCTIONS\n\n' + frozen)] + # nothing changed -> zero churn (skill toggle above is invisible) assert rt.maybe_refresh_system_prompt(messages) is False - assert messages[0].content == 'OLD HEAD' + # an instruction-layer change (e.g. edited AGENTS.md) DOES apply + instructions['text'] = 'EDITED INSTRUCTIONS' + assert rt.maybe_refresh_system_prompt(messages) is True + assert messages[0].content.startswith('EDITED INSTRUCTIONS') + assert frozen in messages[0].content def test_enabled_gate_still_refreshes(self, tmp_path): cat = _catalog(tmp_path) From 2712ff733fa752fe8fb57f0c00ebfd100ea08e80 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Thu, 13 Aug 2026 15:27:43 +0800 Subject: [PATCH 16/36] Attach vector recall durably to each user turn and keep the file backend's MEMORY.md snapshot in step with edits made outside the agent. Also translates the memory tool descriptions and prompt headings to English. --- .../memory/unified/backends/file_based.py | 63 +++++++--- .../memory/unified/backends/mem0_adapter.py | 76 +++++++---- .../memory/unified/extraction/tool_based.py | 9 +- ms_agent/memory/unified/memory_tool.py | 25 +--- ms_agent/memory/unified/orchestrator.py | 23 +++- ms_agent/memory/unified/protocols.py | 6 + .../memory/unified/storage/file_storage.py | 29 ++++- tests/memory/test_mem0_inject_placement.py | 118 ++++++++++++++++++ .../memory/test_memory_snapshot_hot_reload.py | 102 +++++++++++++++ 9 files changed, 378 insertions(+), 73 deletions(-) create mode 100644 tests/memory/test_mem0_inject_placement.py create mode 100644 tests/memory/test_memory_snapshot_hot_reload.py diff --git a/ms_agent/memory/unified/backends/file_based.py b/ms_agent/memory/unified/backends/file_based.py index 1fb7ce300..843821610 100644 --- a/ms_agent/memory/unified/backends/file_based.py +++ b/ms_agent/memory/unified/backends/file_based.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import re from copy import deepcopy from typing import Any, Dict, List, Optional @@ -28,26 +29,37 @@ logger = get_logger() +#: The memory section this backend appends to the system prompt. Matched so a +#: block from an earlier round can be replaced instead of accumulating. +_LTM_BLOCK_RE = re.compile( + r'\n*.*?', re.DOTALL) + MEMORY_TOOL_DEF = { 'tool_name': 'memory', - 'description': ('管理长期记忆 (MEMORY.md)。用于跨会话记住用户偏好、项目上下文、' - '关键决策和纠错记录。支持 add(添加)、replace(替换)、remove(删除)操作。'), + 'description': + ('Manage long-term memory (MEMORY.md): remember user preferences, project ' + 'context, key decisions and corrections across sessions. Supports add, ' + 'replace and remove operations.'), 'parameters': { 'type': 'object', 'properties': { 'action': { 'type': 'string', 'enum': ['add', 'replace', 'remove'], - 'description': '操作类型:add=添加新条目,replace=替换已有条目,remove=删除条目', + 'description': + ('add = append a new entry, replace = replace an existing ' + 'entry, remove = delete an entry'), }, 'content': { 'type': 'string', - 'description': '要添加的内容 (add),或要匹配的旧内容 (replace/remove)', + 'description': + ('content to add (add), or the existing content to match ' + '(replace/remove)'), }, 'new_content': { 'type': 'string', - 'description': '替换后的新内容(仅 replace 时需要)', + 'description': 'the replacement content (replace only)', }, }, 'required': ['action', 'content'], @@ -56,7 +68,7 @@ MEMORY_READ_TOOL_DEF = { 'tool_name': 'memory_read', - 'description': '读取当前长期记忆 (MEMORY.md) 的完整内容', + 'description': 'Read the full content of long-term memory (MEMORY.md)', 'parameters': { 'type': 'object', 'properties': {}, @@ -89,6 +101,9 @@ def __init__(self, config: MemoryConfig) -> None: self._prompt_snapshot: Optional[str] = None self._snapshot_dirty = True + # The MEMORY.md content the cached snapshot was built from, for the + # external-edit check in _get_or_build_snapshot. + self._snapshot_md_source: Optional[str] = None # -- Lifecycle ---------------------------------------------------- @@ -292,21 +307,28 @@ def _build_extractor(self) -> ToolBasedExtractor | LLMMergeExtractor: return ToolBasedExtractor(self._config, self._llm) def _get_or_build_snapshot(self) -> str: - if self._prompt_snapshot is not None and not self._snapshot_dirty: + # The dirty flag only tracks OUR writes; MEMORY.md also changes under + # us (WebUI memory editor, hand edits). get_content() is mtime-cached, + # so comparing it against the snapshot's source is cheap and makes + # external edits live from the next round — same hot-reload contract + # as the workspace instruction files. + md_content = self._file_storage.get_content().strip() + if (self._prompt_snapshot is not None and not self._snapshot_dirty + and md_content == self._snapshot_md_source): return self._prompt_snapshot parts: List[str] = [] - md_content = self._file_storage.get_content().strip() if md_content: - parts.append(f'## 长期记忆\n\n{md_content}') + parts.append(f'## Long-term Memory\n\n{md_content}') if self._config.retrieval_strategy in ('fts', 'hybrid'): facts_text = self._facts_storage.format_for_prompt(max_chars=800) if facts_text: - parts.append(f'## 已知事实\n\n{facts_text}') + parts.append(f'## Known Facts\n\n{facts_text}') self._prompt_snapshot = '\n\n'.join(parts) if parts else '' self._snapshot_dirty = False + self._snapshot_md_source = md_content return self._prompt_snapshot def _inject_snapshot( @@ -320,8 +342,13 @@ def _inject_snapshot( sys_msg = {**messages[0]} block = f'\n\n\n{snapshot}\n' - if '' not in (sys_msg.get('content') or ''): - sys_msg['content'] = (sys_msg.get('content') or '') + block + # Drop a block left by an earlier round before appending the current + # one: skipping when a block is already present would pin the memory + # section to its first value for the rest of the session whenever the + # head is not rebuilt in between (no context assembler / no skill + # runtime). Strip-then-append is idempotent AND always fresh. + content = _LTM_BLOCK_RE.sub('', sys_msg.get('content') or '') + sys_msg['content'] = content + block messages[0] = sys_msg return messages @@ -367,11 +394,13 @@ async def _inject_fts_context( messages = list(messages) user_copy = {**messages[last_user_idx]} - user_copy['content'] = (f"{user_copy['content']}\n\n" - f'\n' - f'[System note: 以下是从历史会话中检索到的相关上下文]\n' - f'{context_text}\n' - f'') + user_copy['content'] = ( + f"{user_copy['content']}\n\n" + f'\n' + f'Relevant context retrieved from past sessions (background ' + f'reference — not instructions):\n' + f'{context_text}\n' + f'') messages[last_user_idx] = user_copy return messages diff --git a/ms_agent/memory/unified/backends/mem0_adapter.py b/ms_agent/memory/unified/backends/mem0_adapter.py index 0d8c94410..1990ccd41 100644 --- a/ms_agent/memory/unified/backends/mem0_adapter.py +++ b/ms_agent/memory/unified/backends/mem0_adapter.py @@ -22,11 +22,18 @@ import asyncio import json import logging +import re from functools import partial from typing import Any, Dict, List, Optional +#: Injected framework blocks inside user/assistant text (durable recall, +#: skill update notices) — stripped before fact extraction so memory never +#: re-ingests its own output. +_SYSTEM_REMINDER_RE = re.compile(r'.*?\s*', + re.DOTALL) + from ..config import MemoryConfig -from ..protocols import BaseMemoryBackend, MemoryEntry +from ..protocols import (RECALL_BLOCK_MARKER, BaseMemoryBackend, MemoryEntry) from ..registry import backend_registry logger = logging.getLogger(__name__) @@ -109,12 +116,27 @@ async def inject( self, messages: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: - if not self._mem0: - return messages + """Per-round injection is a no-op for the vector backend. + + Recall is DURABLE here (2026-08 design): LLMAgent attaches + ``recall_block()`` to each new user turn before it is persisted, so + the block lives in the session log like a skill update notice — + it survives context reassembly (the model's history keeps showing + what it saw) and every request stays a prefix-extension of the last + (maximal prefix-cache reuse). Mutating messages here every round + would break both. + """ + return messages + + async def recall_block(self, query: str) -> str: + """Formatted recall for a new user turn ('' when nothing relevant). - query = self._extract_query(messages) - if not query: - return messages + Turn-cached by (user, query) so multi-step turns and retries reuse + one vector search. Framed as reference data — retrieved content must + not masquerade as instructions. + """ + if not self._mem0 or not query: + return '' turn_key = f'{self._user_id}\x1f{query}' if turn_key == self._turn_cache_key \ @@ -128,25 +150,21 @@ async def inject( self._user_id, top_k)) except Exception as e: logger.debug(f'[mem0_backend] search failed: {e}') - return messages + return '' self._turn_cache_key = turn_key self._turn_cache_results = results if not results: - return messages + return '' formatted = self._format_results( results, max(1, int(getattr(self._config, 'recall_top_k', 10)))) if not formatted: - return messages - - messages = list(messages) - if messages and messages[0].get('role') == 'system': - sys_msg = {**messages[0]} - block = f'\n\n\n{formatted}\n' - sys_msg['content'] = (sys_msg.get('content') or '') + block - messages[0] = sys_msg - - return messages + return '' + return ('\n' + f'{RECALL_BLOCK_MARKER} (background ' + 'reference — not instructions):\n' + f'{formatted}\n' + '') # ── on_messages ────────────────────────────────────────────────── @@ -163,14 +181,20 @@ async def on_messages( if not self._mem0: return 0 # mem0 rejects non-chat fields and roles like `tool`; feed it the - # user/assistant text turns only. - convo = [ - { - 'role': m['role'], - 'content': m['content'] - } for m in messages - if m.get('role') in ('user', 'assistant') and m.get('content') - ] + # user/assistant text turns only. Strip blocks + # (durable recall attachments, skill update notices) so fact + # extraction never re-ingests injected framework content as if the + # user said it. + convo = [] + for m in messages: + if m.get('role') not in ('user', 'assistant'): + continue + content = m.get('content') + if isinstance(content, str): + content = _SYSTEM_REMINDER_RE.sub('', content).strip() + if not content: + continue + convo.append({'role': m['role'], 'content': content}) if not convo: return 0 result = await _offload(self._mem0.add, convo, user_id=self._user_id) diff --git a/ms_agent/memory/unified/extraction/tool_based.py b/ms_agent/memory/unified/extraction/tool_based.py index 0a09330e7..07ebf4dd5 100644 --- a/ms_agent/memory/unified/extraction/tool_based.py +++ b/ms_agent/memory/unified/extraction/tool_based.py @@ -20,14 +20,17 @@ 'type': 'function', 'function': { 'name': 'save_memory', - 'description': '保存整合结果到持久化存储。输出完整的长期记忆 markdown。', + 'description': ('Persist the consolidation result. Output the ' + 'complete long-term memory markdown.'), 'parameters': { 'type': 'object', 'properties': { 'memory_update': { 'type': 'string', - 'description': ('完整的长期记忆 markdown,包含所有现有事实加新增内容。' - '无变化则原样返回。'), + 'description': + ('The complete long-term memory markdown: all existing ' + 'facts plus additions. Return it unchanged when nothing ' + 'changed.'), } }, 'required': ['memory_update'], diff --git a/ms_agent/memory/unified/memory_tool.py b/ms_agent/memory/unified/memory_tool.py index 84cb32b6c..e1d5ff549 100644 --- a/ms_agent/memory/unified/memory_tool.py +++ b/ms_agent/memory/unified/memory_tool.py @@ -14,28 +14,13 @@ if TYPE_CHECKING: from .orchestrator import MemoryOrchestrator -SERVER_NAME = 'unified_memory' - -MEMORY_USAGE_PROMPT = """ -## Long-term Memory - -You have access to a persistent long-term memory system. Use the memory tools to proactively manage it during conversation. +from ms_agent.prompting.builtin import MEMORY_TOOL_GUIDANCE -**When to save:** -- User explicitly states a preference (e.g. "I prefer ruff over flake8") -- User shares important project context (tech stack, conventions, deadlines) -- User corrects you — save the correction to avoid repeating the mistake -- Key decisions are made during the conversation -- User's recurring patterns you notice (coding style, communication preferences) - -**When NOT to save:** -- Transient information (today's weather, one-off questions) -- Information already present in your memory -- Conversation filler or greetings -- Sensitive credentials or secrets (API keys, passwords) +SERVER_NAME = 'unified_memory' -**Be conservative** — only save facts that will genuinely help in future sessions. Quality over quantity. -""".strip() +#: Deprecated alias — the guidance text now lives in prompting.builtin and is +#: injected as an assembly segment by LLMAgent (never by mutating config). +MEMORY_USAGE_PROMPT = MEMORY_TOOL_GUIDANCE class MemoryTool(ToolBase): diff --git a/ms_agent/memory/unified/orchestrator.py b/ms_agent/memory/unified/orchestrator.py index 691787306..cddf496ff 100644 --- a/ms_agent/memory/unified/orchestrator.py +++ b/ms_agent/memory/unified/orchestrator.py @@ -45,7 +45,7 @@ from ms_agent.session.context_assembler import _dicts_to_messages from ms_agent.utils.logger import get_logger from .config import MemoryConfig -from .protocols import MemoryBackend, MemoryEntry +from .protocols import (RECALL_BLOCK_MARKER, MemoryBackend, MemoryEntry) from .registry import backend_registry logger = get_logger() @@ -146,6 +146,27 @@ async def run(self, messages: List[Message]) -> List[Message]: injected = await backend.inject(msg_dicts) return _dicts_to_messages(injected) + # ------------------------------------------------------------------ + # Durable recall (attached to the user turn by LLMAgent) + # ------------------------------------------------------------------ + + #: Attach-idempotency marker for LLMAgent (matches the first line every + #: recall_block() implementation renders). + recall_marker = RECALL_BLOCK_MARKER + + async def recall_block(self, query: str) -> str: + """Formatted recall for a NEW user turn; '' when the backend has no + per-query recall (file backend) or memory is disabled. Same store + lock as run() — retrieval must not overlap a write.""" + if not self.mem_config.enabled: + return '' + async with _store_lock(self.mem_config.base_dir): + backend = await self._ensure_started() + fn = getattr(backend, 'recall_block', None) + if fn is None: + return '' + return await fn(query) + # ------------------------------------------------------------------ # Memory ABC -- add() / schedule_add() # ------------------------------------------------------------------ diff --git a/ms_agent/memory/unified/protocols.py b/ms_agent/memory/unified/protocols.py index 81f81384f..9c5207eff 100644 --- a/ms_agent/memory/unified/protocols.py +++ b/ms_agent/memory/unified/protocols.py @@ -33,6 +33,12 @@ from typing import (Any, Callable, Dict, List, Optional, Protocol, runtime_checkable) +#: Stable first-line marker of a durable recall block (see +#: ``recall_block()`` implementations). LLMAgent uses it to keep the attach +#: idempotent per turn WITHOUT treating every on the +#: message (skill notices, prompt-files notices) as "already attached". +RECALL_BLOCK_MARKER = 'Relevant long-term memories for this request' + # =================================================================== # Layer 1 -- Data structures # =================================================================== diff --git a/ms_agent/memory/unified/storage/file_storage.py b/ms_agent/memory/unified/storage/file_storage.py index fb5a59963..618a000a6 100644 --- a/ms_agent/memory/unified/storage/file_storage.py +++ b/ms_agent/memory/unified/storage/file_storage.py @@ -37,6 +37,11 @@ def __init__(self, config: MemoryConfig): self.char_limit = config.char_limit self.security_scan = config.security_scan self._content_cache: Optional[str] = None + # (mtime_ns, size) of the file the cache was read from. External + # writers exist (the WebUI memory editor, hand edits) and MEMORY.md + # rides in the system prompt — a never-expiring cache made those + # edits invisible to a running session. + self._cache_stat: Optional[tuple] = None # ------------------------------------------------------------------ # MemoryStorage protocol @@ -168,13 +173,19 @@ def append_archive(self, content: str) -> None: # ------------------------------------------------------------------ def _read(self) -> str: - if self._content_cache is not None: + try: + st = self.memory_path.stat() + stat_key = (st.st_mtime_ns, st.st_size) + except OSError: + self._content_cache = None + self._cache_stat = None + return '' + if self._content_cache is not None and self._cache_stat == stat_key: return self._content_cache - if self.memory_path.exists(): - content = self.memory_path.read_text(encoding='utf-8') - self._content_cache = content - return content - return '' + content = self.memory_path.read_text(encoding='utf-8') + self._content_cache = content + self._cache_stat = stat_key + return content def _write(self, content: str) -> None: self.memory_path.parent.mkdir(parents=True, exist_ok=True) @@ -188,6 +199,12 @@ def _write(self, content: str) -> None: os.unlink(tmp) raise self._content_cache = content + try: + st = self.memory_path.stat() + self._cache_stat = (st.st_mtime_ns, st.st_size) + except OSError: + self._cache_stat = None def invalidate_cache(self) -> None: self._content_cache = None + self._cache_stat = None diff --git a/tests/memory/test_mem0_inject_placement.py b/tests/memory/test_mem0_inject_placement.py new file mode 100644 index 000000000..9ad063369 --- /dev/null +++ b/tests/memory/test_mem0_inject_placement.py @@ -0,0 +1,118 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Vector-memory recall is DURABLE: attached once to each new user turn +(skill-notice style) before the turn is persisted — NOT re-injected per round. + +Why: an ephemeral per-round attach made round N+1's history differ from what +round N actually sent (the previous user message lost its block), which broke +prefix-cache reuse at that point and made the model's own history inconsistent +with what it had seen. Durable attachment keeps every request a strict +prefix-extension of the previous one. +""" +import asyncio + +from ms_agent.llm.utils import Message +from ms_agent.memory.unified.backends import mem0_adapter +from ms_agent.memory.unified.config import MemoryConfig + + +def _backend(monkeypatch, tmp_path, results): + cfg = MemoryConfig( + enabled=True, + storage_backend='mem0', + base_dir=str(tmp_path), + user_id='u1', + agent_id='a1', + backend_options={'mem0': {}}, + ) + backend = mem0_adapter.Mem0Backend(cfg) + backend._mem0 = object() # skip start(); search is stubbed below + monkeypatch.setattr(mem0_adapter, '_mem0_search', + lambda m0, query, user_id, top_k=10: results) + return backend + + +def test_recall_block_wraps_and_frames(monkeypatch, tmp_path): + backend = _backend(monkeypatch, tmp_path, + [{'memory': '用户偏好深色主题。'}]) + block = asyncio.run(backend.recall_block('我的主题偏好?')) + assert block.startswith('') + assert block.endswith('') + assert '用户偏好深色主题。' in block + assert 'not instructions' in block # framed as reference data + + +def test_recall_block_empty_when_no_results(monkeypatch, tmp_path): + backend = _backend(monkeypatch, tmp_path, []) + assert asyncio.run(backend.recall_block('任意问题')) == '' + + +def test_inject_is_a_noop(monkeypatch, tmp_path): + """Per-round injection must not touch messages — recall is durable.""" + backend = _backend(monkeypatch, tmp_path, [{'memory': 'F1'}]) + msgs = [ + {'role': 'system', 'content': 'SYSTEM PROMPT'}, + {'role': 'user', 'content': '问题'}, + ] + out = asyncio.run(backend.inject([dict(m) for m in msgs])) + assert out == msgs + + +def test_ingestion_strips_injected_blocks(monkeypatch, tmp_path): + """Fact extraction must never re-ingest recall/notice blocks.""" + backend = _backend(monkeypatch, tmp_path, []) + captured = {} + + class FakeMem0: + def add(self, convo, user_id=None): + captured['convo'] = convo + return {'results': []} + + backend._mem0 = FakeMem0() + msgs = [{ + 'role': 'user', + 'content': ('直接回答。\n\n\nRelevant long-term ' + 'memories...\n- 旧记忆\n'), + }, { + 'role': 'assistant', + 'content': '好的。', + }] + asyncio.run(backend.on_messages(msgs)) + assert captured['convo'][0]['content'] == '直接回答。' + assert '旧记忆' not in str(captured['convo']) + + +def test_agent_attaches_recall_to_new_user_turn(monkeypatch, tmp_path): + """LLMAgent._attach_memory_recall: once per turn, user tail only.""" + from omegaconf import OmegaConf + + from ms_agent.agent.llm_agent import LLMAgent + + agent = LLMAgent(config=OmegaConf.create( + {'output_dir': str(tmp_path / 'w')})) + + class FakeOrchestrator: + async def recall_block(self, query): + assert query == '直接回答。' + return '\n- F1\n' + + agent.memory_tools = [FakeOrchestrator()] + messages = [ + Message(role='system', content='S'), + Message(role='user', content='直接回答。'), + ] + asyncio.run(agent._attach_memory_recall(messages)) + assert messages[1].content == ( + '直接回答。\n\n\n- F1\n') + # idempotent: a second call must not double-attach + asyncio.run(agent._attach_memory_recall(messages)) + assert messages[1].content.count('') == 1 + # non-user tail: untouched + messages.append(Message(role='assistant', content='A')) + asyncio.run(agent._attach_memory_recall(messages)) + assert messages[2].content == 'A' + + +def test_vector_backend_stays_tool_less(monkeypatch, tmp_path): + """No tools -> no memory-guidance segment (LLMAgent has_tools early-exit).""" + backend = _backend(monkeypatch, tmp_path, []) + assert backend.get_tool_schemas() == [] diff --git a/tests/memory/test_memory_snapshot_hot_reload.py b/tests/memory/test_memory_snapshot_hot_reload.py new file mode 100644 index 000000000..08da67e57 --- /dev/null +++ b/tests/memory/test_memory_snapshot_hot_reload.py @@ -0,0 +1,102 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""External edits to MEMORY.md (WebUI memory editor, hand edits) must reach a +running session: the snapshot in the system prompt rebuilds when the file +changes on disk, not only after the agent's own memory-tool writes.""" +import asyncio +import os + +from ms_agent.memory.unified.backends.file_based import FileBasedBackend +from ms_agent.memory.unified.config import MemoryConfig + + +def _backend(tmp_path): + cfg = MemoryConfig( + enabled=True, + storage_backend='file', + base_dir=str(tmp_path), + user_id='u1', + agent_id='a1', + ) + return FileBasedBackend(cfg) + + +def _memory_path(backend): + return backend._file_storage.memory_path + + +def _bump_mtime(path): + st = os.stat(path) + os.utime(path, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000)) + + +def test_snapshot_reflects_external_edit(tmp_path): + backend = _backend(tmp_path) + path = _memory_path(backend) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('规则A:发布前跑 make check。\n', encoding='utf-8') + + first = backend._get_or_build_snapshot() + assert '规则A' in first + + # Cached path: unchanged file -> identical snapshot object semantics. + assert backend._get_or_build_snapshot() == first + + # External edit (editor/UI) -> next build sees it without any dirty flag. + path.write_text( + '规则A:发布前跑 make check。\n临时约定:本周试验固定 seed=42。\n', + encoding='utf-8') + _bump_mtime(path) + second = backend._get_or_build_snapshot() + assert 'seed=42' in second + + +def test_snapshot_reflects_external_delete(tmp_path): + backend = _backend(tmp_path) + path = _memory_path(backend) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('规则A\n', encoding='utf-8') + assert '规则A' in backend._get_or_build_snapshot() + + path.unlink() + assert backend._get_or_build_snapshot() == '' + + +def test_inject_carries_external_edit(tmp_path): + """End-to-end through inject(): the system message shows the fresh file.""" + backend = _backend(tmp_path) + path = _memory_path(backend) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('旧内容\n', encoding='utf-8') + msgs = [{'role': 'system', 'content': 'S'}, {'role': 'user', 'content': 'q'}] + out = asyncio.run(backend.inject([dict(m) for m in msgs])) + assert '旧内容' in out[0]['content'] + + path.write_text('新内容\n', encoding='utf-8') + _bump_mtime(path) + out2 = asyncio.run(backend.inject([dict(m) for m in msgs])) + assert '新内容' in out2[0]['content'] + assert '旧内容' not in out2[0]['content'] + + +def test_inject_replaces_stale_block_instead_of_skipping(tmp_path): + """A block left on the head by an earlier round must be replaced, not kept: + otherwise the memory section freezes at its first value for the whole + session whenever the head is not rebuilt in between.""" + backend = _backend(tmp_path) + path = _memory_path(backend) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('第一版\n', encoding='utf-8') + + msgs = [{'role': 'system', 'content': 'S'}, {'role': 'user', 'content': 'q'}] + out = asyncio.run(backend.inject([dict(m) for m in msgs])) + assert '第一版' in out[0]['content'] + + # Same message objects carried into the next round (no head rebuild). + path.write_text('第二版\n', encoding='utf-8') + _bump_mtime(path) + out2 = asyncio.run(backend.inject(out)) + content = out2[0]['content'] + assert '第二版' in content + assert '第一版' not in content + assert content.count('') == 1 + assert content.startswith('S') From e66e382e250f73d810fa88da3b91a7cbb2aa5610 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Thu, 13 Aug 2026 15:28:27 +0800 Subject: [PATCH 17/36] Ship agent_hub default configs in the wheel and merge the project config patch once instead of twice. --- MANIFEST.in | 3 ++ ms_agent/agent/base.py | 23 +++++++----- ms_agent/agent_hub/_defaults.py | 12 +++++++ ms_agent/config/resolver.py | 13 +++++++ setup.py | 4 +++ tests/config/test_project_patch_once.py | 47 +++++++++++++++++++++++++ 6 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 tests/config/test_project_patch_once.py diff --git a/MANIFEST.in b/MANIFEST.in index b7ac745da..c95dca9bf 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,9 @@ include requirements.txt recursive-include requirements *.txt recursive-include ms_agent/ *.yaml +# agent_hub cross-framework conversion templates (markdown, not yaml) +recursive-include ms_agent/agent_hub/default_configs * + # Include projects recursive-include projects * diff --git a/ms_agent/agent/base.py b/ms_agent/agent/base.py index 954933da3..b576744cd 100644 --- a/ms_agent/agent/base.py +++ b/ms_agent/agent/base.py @@ -58,15 +58,20 @@ def __init__(self, # anchored to the project (the work dir), not the config file's # directory. This keeps running a shared/template config from picking up # (or scattering) overrides in that config's folder. - try: - from omegaconf import OmegaConf - - from ms_agent.config.resolver import ConfigResolver - patch = ConfigResolver()._load_project_patch(self.output_dir) - if patch is not None: - self.config = OmegaConf.merge(self.config, patch) - except Exception: - pass + # Skipped when ConfigResolver.resolve() already merged the patch (it + # marks the config): merging twice here re-applied the patch ON TOP of + # caller-side overrides, silently making the project patch the highest + # priority layer. + if not getattr(self.config, '_project_patch_applied', False): + try: + from omegaconf import OmegaConf + + from ms_agent.config.resolver import ConfigResolver + patch = ConfigResolver()._load_project_patch(self.output_dir) + if patch is not None: + self.config = OmegaConf.merge(self.config, patch) + except Exception: + pass @abstractmethod async def run( diff --git a/ms_agent/agent_hub/_defaults.py b/ms_agent/agent_hub/_defaults.py index 714162d3f..00ffd06f3 100644 --- a/ms_agent/agent_hub/_defaults.py +++ b/ms_agent/agent_hub/_defaults.py @@ -15,7 +15,19 @@ def get_defaults(framework: str) -> Dict[str, str]: """Read all files under ``defaults/{framework}/`` and return {rel_path: content}. Returns an empty dict if the framework directory doesn't exist or is empty. + + Raises: + RuntimeError: when the whole ``default_configs/`` directory is absent — + that is a packaging bug (templates not shipped in the wheel), not a + legitimate "this framework has no defaults" case, and silently + returning ``{}`` would degrade convert to a raw file copy. + (Guard modeled on openclaw's "Ensure templates are packaged".) """ + if not _DEFAULTS_DIR.is_dir(): + raise RuntimeError( + f'agent_hub default templates directory is missing: {_DEFAULTS_DIR}. ' + f'Ensure ms_agent/agent_hub/default_configs is packaged ' + f'(setup.py package_data / MANIFEST.in).') framework_dir = _DEFAULTS_DIR / framework if not framework_dir.is_dir(): return {} diff --git a/ms_agent/config/resolver.py b/ms_agent/config/resolver.py index 502c46620..57c2ff63f 100644 --- a/ms_agent/config/resolver.py +++ b/ms_agent/config/resolver.py @@ -146,6 +146,19 @@ def resolve( from ms_agent.config.config import Config merged = Config.fill_missing_fields(merged) + if effective_project_path: + # Mark that this resolve already merged the work-dir project patch + # so BaseAgent.__init__ doesn't merge it a second time. The double + # merge silently gave /.ms_agent/config.yaml priority over + # every caller-side override applied between resolve() and agent + # construction (e.g. the WebUI's shaping). + try: + from omegaconf import open_dict + with open_dict(merged): + merged._project_patch_applied = True + except Exception: + pass + return merged def resolve_mcp( diff --git a/setup.py b/setup.py index 0bb309db2..0b4f7aabc 100644 --- a/setup.py +++ b/setup.py @@ -273,6 +273,10 @@ def _build_and_copy_webui(self): 'projects/**/*', 'webui/backend/**/*', 'webui/frontend/dist/**/*', + # agent_hub conversion templates — without these in the wheel, + # get_defaults() returns {} and cross-framework convert + # silently degrades to a raw file copy. + 'agent_hub/default_configs/**/*', ], '': ['*.h', '*.cpp', '*.cu'], }, diff --git a/tests/config/test_project_patch_once.py b/tests/config/test_project_patch_once.py new file mode 100644 index 000000000..3cb055a9f --- /dev/null +++ b/tests/config/test_project_patch_once.py @@ -0,0 +1,47 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""The work-dir project patch must be merged exactly once (golden scenario 9). + +Before the fix, ConfigResolver.resolve() merged /.ms_agent/config.yaml +as layer 4 and BaseAgent.__init__ merged it AGAIN on top of every caller-side +override applied between resolve() and agent construction — silently making the +project patch the highest-priority layer (the WebUI's shaping lost to it). +""" +from omegaconf import OmegaConf + +from ms_agent.agent.llm_agent import LLMAgent +from ms_agent.config.resolver import ConfigResolver + + +def _project_with_patch(tmp_path, yaml_text): + project = tmp_path / 'proj' + (project / '.ms_agent').mkdir(parents=True) + (project / '.ms_agent' / 'config.yaml').write_text(yaml_text) + return project + + +def test_resolve_marks_patch_applied(tmp_path): + project = _project_with_patch(tmp_path, 'llm:\n model: patched-model\n') + resolver = ConfigResolver(global_dir=str(tmp_path / 'home')) + cfg = resolver.resolve(project_path=str(project)) + assert cfg._project_patch_applied is True + assert cfg.llm.model == 'patched-model' + + +def test_caller_override_survives_agent_init(tmp_path): + project = _project_with_patch(tmp_path, 'llm:\n model: patched-model\n') + resolver = ConfigResolver(global_dir=str(tmp_path / 'home')) + cfg = resolver.resolve(project_path=str(project)) + # caller-side shaping AFTER resolve (what the WebUI does) + OmegaConf.update(cfg, 'llm.model', 'shaped-model', merge=True) + OmegaConf.update(cfg, 'output_dir', str(project), merge=True) + agent = LLMAgent(config=cfg) + assert agent.config.llm.model == 'shaped-model' # not re-clobbered + + +def test_from_task_path_still_merges_patch(tmp_path): + """Configs that did NOT go through resolve() keep the old behavior.""" + project = _project_with_patch(tmp_path, 'llm:\n model: patched-model\n') + cfg = OmegaConf.create({'output_dir': str(project), + 'llm': {'model': 'yaml-model'}}) + agent = LLMAgent(config=cfg) + assert agent.config.llm.model == 'patched-model' From ec2816c9687b256f7a34f2a3cb047b93eb6f24c5 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Thu, 13 Aug 2026 16:22:30 +0800 Subject: [PATCH 18/36] Remove the memory section from the prompt when memory is cleared or its last entry deleted, instead of leaving the previous round's block in place. --- .../memory/unified/backends/file_based.py | 49 +++++++++++-------- .../memory/test_memory_snapshot_hot_reload.py | 45 +++++++++++++++++ 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/ms_agent/memory/unified/backends/file_based.py b/ms_agent/memory/unified/backends/file_based.py index 843821610..81f7d2fa3 100644 --- a/ms_agent/memory/unified/backends/file_based.py +++ b/ms_agent/memory/unified/backends/file_based.py @@ -101,9 +101,9 @@ def __init__(self, config: MemoryConfig) -> None: self._prompt_snapshot: Optional[str] = None self._snapshot_dirty = True - # The MEMORY.md content the cached snapshot was built from, for the - # external-edit check in _get_or_build_snapshot. - self._snapshot_md_source: Optional[str] = None + # (MEMORY.md text, facts text) the cached snapshot was built from — + # the external-edit / external-delete check in _get_or_build_snapshot. + self._snapshot_source: Optional[tuple] = None # -- Lifecycle ---------------------------------------------------- @@ -122,9 +122,11 @@ async def inject( self, messages: List[Dict[str, Any]], ) -> List[Dict[str, Any]]: - snapshot = self._get_or_build_snapshot() - if snapshot: - messages = self._inject_snapshot(messages, snapshot) + # Unconditional: an EMPTY snapshot must still run, otherwise the block + # a previous round left on the head survives every later round and + # deleted memories keep being shown (forgetting silently fails). + messages = self._inject_snapshot(messages, + self._get_or_build_snapshot()) if self._config.retrieval_strategy in ('fts', 'hybrid'): messages = await self._inject_fts_context(messages) @@ -313,22 +315,26 @@ def _get_or_build_snapshot(self) -> str: # external edits live from the next round — same hot-reload contract # as the workspace instruction files. md_content = self._file_storage.get_content().strip() + facts_text = '' + if self._config.retrieval_strategy in ('fts', 'hybrid'): + facts_text = self._facts_storage.format_for_prompt(max_chars=800) + # Both sources are compared, not just ours: an entry removed through + # the UI or by hand must disappear from the prompt exactly like one + # removed through the memory tool. + source = (md_content, facts_text) if (self._prompt_snapshot is not None and not self._snapshot_dirty - and md_content == self._snapshot_md_source): + and source == self._snapshot_source): return self._prompt_snapshot parts: List[str] = [] if md_content: parts.append(f'## Long-term Memory\n\n{md_content}') - - if self._config.retrieval_strategy in ('fts', 'hybrid'): - facts_text = self._facts_storage.format_for_prompt(max_chars=800) - if facts_text: - parts.append(f'## Known Facts\n\n{facts_text}') + if facts_text: + parts.append(f'## Known Facts\n\n{facts_text}') self._prompt_snapshot = '\n\n'.join(parts) if parts else '' self._snapshot_dirty = False - self._snapshot_md_source = md_content + self._snapshot_source = source return self._prompt_snapshot def _inject_snapshot( @@ -341,14 +347,17 @@ def _inject_snapshot( return messages sys_msg = {**messages[0]} - block = f'\n\n\n{snapshot}\n' - # Drop a block left by an earlier round before appending the current - # one: skipping when a block is already present would pin the memory - # section to its first value for the rest of the session whenever the - # head is not rebuilt in between (no context assembler / no skill - # runtime). Strip-then-append is idempotent AND always fresh. + # Strip first, then append the current snapshot. Two reasons: + # - keeping an existing block would pin the memory section to its + # first value whenever the head is not rebuilt in between (no + # context assembler / no skill runtime); + # - an EMPTY snapshot (everything deleted, memory cleared) must + # remove the section entirely — forgetting is a real state, not + # "nothing to update". content = _LTM_BLOCK_RE.sub('', sys_msg.get('content') or '') - sys_msg['content'] = content + block + if snapshot: + content += f'\n\n\n{snapshot}\n' + sys_msg['content'] = content messages[0] = sys_msg return messages diff --git a/tests/memory/test_memory_snapshot_hot_reload.py b/tests/memory/test_memory_snapshot_hot_reload.py index 08da67e57..9ae5ca8f0 100644 --- a/tests/memory/test_memory_snapshot_hot_reload.py +++ b/tests/memory/test_memory_snapshot_hot_reload.py @@ -100,3 +100,48 @@ def test_inject_replaces_stale_block_instead_of_skipping(tmp_path): assert '第一版' not in content assert content.count('') == 1 assert content.startswith('S') + + +def test_clearing_memory_removes_the_section(tmp_path): + """Forgetting is a state, not a no-op: when MEMORY.md is emptied, the + block a previous round put on the head must be REMOVED, not left behind + (an empty snapshot used to skip injection entirely).""" + backend = _backend(tmp_path) + path = _memory_path(backend) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text('用户偏好深色主题。\n', encoding='utf-8') + + msgs = [{'role': 'system', 'content': 'S'}, {'role': 'user', 'content': 'q'}] + out = asyncio.run(backend.inject([dict(m) for m in msgs])) + assert '深色主题' in out[0]['content'] + + # Cleared through the UI editor / by hand, head carried into next round. + path.write_text('', encoding='utf-8') + _bump_mtime(path) + out2 = asyncio.run(backend.inject(out)) + content = out2[0]['content'] + assert '深色主题' not in content + assert '' not in content + assert content == 'S' # head is back to exactly what it was + + +def test_memory_tool_remove_drops_the_entry_from_the_prompt(tmp_path): + """Same through the agent's own memory tool (add -> remove -> gone).""" + backend = _backend(tmp_path) + asyncio.run( + backend.handle_tool_call('memory', { + 'action': 'add', + 'content': '发布前跑 make check。' + })) + msgs = [{'role': 'system', 'content': 'S'}, {'role': 'user', 'content': 'q'}] + out = asyncio.run(backend.inject([dict(m) for m in msgs])) + assert 'make check' in out[0]['content'] + + asyncio.run( + backend.handle_tool_call('memory', { + 'action': 'remove', + 'content': '发布前跑 make check。' + })) + out2 = asyncio.run(backend.inject(out)) + assert 'make check' not in out2[0]['content'] + assert '' not in out2[0]['content'] From 67a07b1ad52664116563f0ae9940f5ac83af6d6f Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Thu, 13 Aug 2026 16:56:02 +0800 Subject: [PATCH 19/36] fix ut --- tests/prompting/test_workspace_files.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/prompting/test_workspace_files.py b/tests/prompting/test_workspace_files.py index cc4c187f6..dc6e4635c 100644 --- a/tests/prompting/test_workspace_files.py +++ b/tests/prompting/test_workspace_files.py @@ -156,13 +156,13 @@ def test_new_format_profile_not_rebuilt(home): def test_call_me_roundtrip_on_template(): t = builtin.PROFILE_TEMPLATE assert wf.get_call_me(t) == '' # commented skeleton must not match - x = wf.set_call_me(t, 'Han Zhou') - assert wf.get_call_me(x) == 'Han Zhou' + x = wf.set_call_me(t, 'Alice') + assert wf.get_call_me(x) == 'Alice' r0, r1, r2 = wf.split_profile_regions(x) - assert '# About Me' in r1 and 'Call me: Han Zhou' in r1 + assert '# About Me' in r1 and 'Call me: Alice' in r1 # free region editing keeps the managed line y = wf.set_free_region(x, 'Mostly agent work.\n') - assert wf.get_call_me(y) == 'Han Zhou' + assert wf.get_call_me(y) == 'Alice' assert 'Mostly agent work.' in wf.get_free_region(y) # clearing removes the line z = wf.set_call_me(y, '') From c715dfb0c25782ccb335fde3f4a6b769427f9e76 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Fri, 14 Aug 2026 15:47:45 +0800 Subject: [PATCH 20/36] Fix unified memory losing writes and ignoring config changes - an interrupt marks only its own round, and never messages a scheduled ingest still owns (both lost the write silently) - one shared instance per store, not per model, reconfigured in place - close() is terminal: a straggler can no longer reopen a released store - the store lock is per (loop, path), and search() takes it too - search() honours its limit; injected memories carry their date - memories are written in the language the user used --- ms_agent/agent/llm_agent.py | 8 +- ms_agent/memory/memory_manager.py | 42 +++- .../memory/unified/backends/mem0_adapter.py | 46 +++- ms_agent/memory/unified/orchestrator.py | 197 ++++++++++++--- tests/memory/test_orchestrator_teardown.py | 231 ++++++++++++++++++ .../memory/test_shared_memory_reconfigure.py | 198 +++++++++++++++ tests/memory/test_unified_memory.py | 101 ++++++++ 7 files changed, 779 insertions(+), 44 deletions(-) create mode 100644 tests/memory/test_orchestrator_teardown.py create mode 100644 tests/memory/test_shared_memory_reconfigure.py diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index ca646b8cf..7ddeaf203 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -2282,10 +2282,16 @@ async def run_loop(self, messages: Union[List[Message], str], # conversational truth. Advance the ingest ledger past it # (sync, in-memory + small file write) so the next turn's # delta does not sweep the partial content in either. + # THIS ROUND ONLY -- the same slice `_persist_partial_round` + # takes. Handing over the whole history would mark earlier + # rounds as ingested too, including one a background ingest + # is still writing (extraction takes seconds), which loses + # it: the write finds an empty delta, or fails and is denied + # its retry. for _mem_tool in self.memory_tools: if hasattr(_mem_tool, 'mark_ingested'): try: - _mem_tool.mark_ingested(messages) + _mem_tool.mark_ingested(messages[pre_step_len:]) except Exception: # noqa: E722 - never mask cancel pass raise diff --git a/ms_agent/memory/memory_manager.py b/ms_agent/memory/memory_manager.py index 77b1ce312..80eea4fba 100644 --- a/ms_agent/memory/memory_manager.py +++ b/ms_agent/memory/memory_manager.py @@ -17,7 +17,12 @@ class SharedMemoryManager: @classmethod async def get_shared_memory(cls, config: DictConfig, mem_instance_type: str) -> Memory: - """Get or create a shared memory instance based on configuration.""" + """Get or create the shared memory instance for this config's store. + + An existing instance is reconfigured in place when the incoming config + differs, so the caller always gets an instance that matches what it + asked for. + """ node = getattr(config.memory, mem_instance_type, OmegaConf.create({})) # unified_memory namespaces the user under `namespace.user_id`; # legacy memories keep a top-level `user_id`. Honor both. @@ -31,18 +36,37 @@ async def get_shared_memory(cls, config: DictConfig, path: str = getattr(node, 'path', None) or getattr( config, 'output_dir', None) or DEFAULT_OUTPUT_DIR path = os.path.abspath(os.path.expanduser(str(path))) - llm_str: str = getattr(config.llm, 'model', 'default_model') - key = f'{mem_instance_type}_{user_id}_{llm_str}_{path}' + # One instance per (memory type, user, store) — deliberately NOT + # keyed by the agent's model. Embedded stores (mem0 + local qdrant) + # take an exclusive file lock, so a second instance on the same path + # cannot open the store at all: keying by model meant that switching + # models with a store already open silently disabled memory for the + # new agent. A configuration change is handled by reconfiguring the + # live instance below, not by keeping two of them. + key = f'{mem_instance_type}_{user_id}_{path}' - if key not in cls._instances: + instance = cls._instances.get(key) + if instance is None: logger.info(f'Creating new shared memory instance for key: {key}') - cls._instances[key] = memory_mapping[mem_instance_type](config) - else: - logger.info( - f'Reusing existing shared memory instance for key: {key}') + instance = memory_mapping[mem_instance_type](config) + cls._instances[key] = instance + return instance - return cls._instances[key] + logger.info(f'Reusing existing shared memory instance for key: {key}') + # The cached instance was built from whatever config the FIRST agent + # had. Later agents may carry a changed one (the user edited the + # project's memory models, recall size, ...), and silently serving the + # old config is indistinguishable from "the setting does nothing". + reconfigure = getattr(instance, 'reconfigure', None) + if reconfigure is not None: + try: + await reconfigure(config) + except Exception as e: # noqa: BLE001 - never fail agent startup + logger.warning( + f'Reconfiguring shared memory {key} failed, keeping the ' + f'existing configuration: {e}') + return instance @classmethod async def close_matching(cls, base_dir: str) -> int: diff --git a/ms_agent/memory/unified/backends/mem0_adapter.py b/ms_agent/memory/unified/backends/mem0_adapter.py index 0d8c94410..0607249a1 100644 --- a/ms_agent/memory/unified/backends/mem0_adapter.py +++ b/ms_agent/memory/unified/backends/mem0_adapter.py @@ -46,6 +46,24 @@ def _result_list(results: Any) -> List[Dict[str, Any]]: return list(results or []) +# Extraction guidance we add to mem0's own, for one reason: retrieval. The +# store is queried with the user's next message, so a memory kept in a language +# the user does not write in has to survive a cross-lingual embedding hop — and +# mem0 2.x's hybrid search also runs a BM25 leg, where cross-language lexical +# overlap is simply zero. (Observed: Chinese questions returning nothing at all +# against English-worded memories.) +# +# mem0 2.x treats `custom_instructions` as an EXTRA, highest-priority section +# appended to its prompt — it does not replace the built-in one. (1.x's +# `custom_fact_extraction_prompt`, which did replace it wholesale, no longer +# exists in the config.) Overridable: an explicit `custom_instructions` in +# `backend_options.mem0` wins. +DEFAULT_CUSTOM_INSTRUCTIONS = ( + 'Write each memory in the SAME language and script the user used — do not ' + 'translate or transliterate it. Keep names, technical terms and product ' + 'names exactly as they appeared.') + + def _mem0_search(m0: Any, query: str, user_id: str, top_k: int = 10) -> Any: """mem0 2.x moved entity params into ``filters=``; 1.x uses kwargs.""" try: @@ -80,8 +98,10 @@ def __init__(self, config: MemoryConfig) -> None: async def start(self, **kwargs: Any) -> None: try: from mem0 import Memory - mem0_cfg = self._config.backend_options.get('mem0', {}) - self._mem0 = Memory.from_config(mem0_cfg) if mem0_cfg else Memory() + mem0_cfg = dict(self._config.backend_options.get('mem0', {}) or {}) + mem0_cfg.setdefault('custom_instructions', + DEFAULT_CUSTOM_INSTRUCTIONS) + self._mem0 = Memory.from_config(mem0_cfg) self._user_id = kwargs.get('user_id', self._config.user_id) logger.info('[mem0_backend] mem0 initialized') except Exception as e: @@ -142,7 +162,10 @@ async def inject( messages = list(messages) if messages and messages[0].get('role') == 'system': sys_msg = {**messages[0]} - block = f'\n\n\n{formatted}\n' + block = ('\n\n\n' + '(Each entry is dated. When two entries conflict, the ' + 'later one supersedes the earlier.)\n' + f'{formatted}\n') sys_msg['content'] = (sys_msg.get('content') or '') + block messages[0] = sys_msg @@ -192,7 +215,8 @@ async def search( return [] try: results = _result_list(await _offload(_mem0_search, self._mem0, - query, self._user_id)) + query, self._user_id, + max(1, int(limit)))) return [ MemoryEntry( id=r.get('id', ''), @@ -223,11 +247,21 @@ def _extract_query(messages: List[Dict[str, Any]]) -> str: @staticmethod def _format_results(results: Any, top_k: int = 10) -> str: + """One bullet per memory, stamped with the day it was written. + + mem0's extraction only ever ADDs (2.x has no update/delete pass), so a + superseded fact and the fact that replaced it both come back from the + same search. Undated, the model has nothing to prefer the newer one by; + dated, resolving the contradiction is at least possible at read time. + """ lines = [] for r in _result_list(results)[:top_k]: text = r.get('memory', r.get('text', '')) - if text: - lines.append(f'- {text}') + if not text: + continue + day = str(r.get('updated_at') or r.get('created_at') or '')[:10] + stamp = f'({day}) ' if len(day) == 10 else '' + lines.append(f'- {stamp}{text}') return '\n'.join(lines) diff --git a/ms_agent/memory/unified/orchestrator.py b/ms_agent/memory/unified/orchestrator.py index 691787306..5b4ddb219 100644 --- a/ms_agent/memory/unified/orchestrator.py +++ b/ms_agent/memory/unified/orchestrator.py @@ -6,12 +6,12 @@ What it DOES own is the write/read discipline around the backend: -* **Serialization** — retrieval (``run``), ingestion (``add``) and flush all - take one asyncio lock per *store* (keyed by ``base_dir``), because embedded +* **Serialization** — every access to the store (``run``, ``add``, ``search``, + ``flush``, teardown) takes one asyncio lock per *store*, because embedded vector stores (mem0 + local qdrant) have no internal locking at all and mem0 even fans out worker threads inside ``add``. Lock per store, not per - orchestrator: ``SharedMemoryManager`` may hand different orchestrator - instances the same directory. + orchestrator: a transient client (the WebUI's read path) may be on the same + directory. * **Background ingestion** — ``schedule_add`` takes the extraction-LLM + embedding cost (seconds) off the turn's critical path. Tasks are retained for ``flush_pending`` so a teardown cannot silently drop the last write. @@ -36,6 +36,7 @@ import os import tempfile import time +from dataclasses import fields from typing import Any, Dict, List, Optional, Set from ms_agent.llm.utils import Message @@ -50,18 +51,36 @@ logger = get_logger() -# One lock per storage directory. Never per orchestrator instance: two -# orchestrators over the same path (possible through SharedMemoryManager's -# llm-dependent cache key) must still serialize against each other. -_STORE_LOCKS: Dict[str, asyncio.Lock] = {} +# One lock per storage directory. Never per orchestrator instance: the HTTP +# read path and any other transient client over the same path must serialize +# against this orchestrator's writes. +# +# Keyed by (loop, path), not path alone: an asyncio.Lock binds itself to the +# loop that first has to *wait* on it, and raises for good once a second loop +# contends. A process that runs more than one loop over its lifetime -- the +# inline `asyncio.run` ingest path below, a test suite calling asyncio.run per +# case, a notebook -- would otherwise wedge on a lock belonging to a loop that +# is already closed. Same shape as PermissionEnforcer._ask_lock_for_loop. +# (Two loops alive at once over one store still get one lock each and would not +# exclude each other; that is inherent to any per-loop scheme, and no such +# caller exists -- embedded stores are single-client by construction.) +_STORE_LOCKS: Dict[str, Any] = {} # path -> (loop, lock) def _store_lock(base_dir: str) -> asyncio.Lock: - key = os.path.abspath(str(base_dir or '.')) - lock = _STORE_LOCKS.get(key) - if lock is None: - lock = _STORE_LOCKS.setdefault(key, asyncio.Lock()) - return lock + path = os.path.abspath(str(base_dir or '.')) + try: + loop = asyncio.get_running_loop() + except RuntimeError: # sync caller: nothing to serialize against yet + loop = None + entry = _STORE_LOCKS.get(path) + # Identity of the loop object, not its id(): an id is reused after the loop + # is collected, which would hand back a lock bound to the dead one. + if entry is None or entry[0] is not loop: + lock = asyncio.Lock() + _STORE_LOCKS[path] = (loop, lock) + return lock + return entry[1] # Only conversational text is ingested (mirrors what backends extract from); @@ -71,12 +90,36 @@ def _store_lock(base_dir: str) -> asyncio.Lock: _LEDGER_FILE = 'ingest_state.json' _LEDGER_MAX = 4096 +# Config fields the backend re-reads from ``mem_config`` on every call, so a +# change to them can be applied by mutating the live config object. Everything +# else decides which backend exists or which store it points at, and needs a +# teardown (see ``MemoryOrchestrator.reconfigure``). +_SOFT_FIELDS = ('recall_top_k', 'ingest_interval') + def _content_hash(msg: Dict[str, Any]) -> str: raw = f"{msg.get('role', '')}\x1f{msg.get('content', '')}" return hashlib.sha256(raw.encode('utf-8')).hexdigest()[:16] +def _ingestable_hashes(msg_dicts: List[Dict[str, Any]]) -> Set[str]: + return { + _content_hash(m) + for m in msg_dicts + if m.get('role') in _INGEST_ROLES and m.get('content') + } + + +def _changed_fields(old: MemoryConfig, new: MemoryConfig) -> Set[str]: + # Field-by-field rather than ``asdict``: that deep-copies every value, + # and ``llm_config`` / ``backend_options`` can hold anything. + return { + f.name + for f in fields(old) + if getattr(old, f.name, None) != getattr(new, f.name, None) + } + + class MemoryOrchestrator(Memory): """Thin adapter between the ms-agent ``Memory`` ABC and a ``MemoryBackend`` implementation. @@ -96,8 +139,13 @@ def __init__(self, config: Any) -> None: self.mem_config = self._parse_config(config) self._backend: Optional[MemoryBackend] = None self._started = False + # Retired by close(): a closed orchestrator never reopens its store. + self._closed = False # Background ingestion bookkeeping (see module docstring). self._pending: Set[asyncio.Task] = set() + # Hashes a scheduled ingest has claimed but not yet written. The ledger + # may not advance past them from anywhere else (see mark_ingested). + self._inflight: Set[str] = set() self._turns_since_ingest = 0 self._ledger: Optional[Set[str]] = None # lazy-loaded from disk self._ledger_order: List[str] = [] @@ -133,7 +181,7 @@ async def _ensure_started(self, **kwargs: Any) -> MemoryBackend: # ------------------------------------------------------------------ async def run(self, messages: List[Message]) -> List[Message]: - if not self.mem_config.enabled: + if not self.mem_config.enabled or self._closed: return messages # Retrieval must not overlap a write: the embedded stores underneath @@ -174,9 +222,16 @@ def schedule_add(self, messages: List[Message], asyncio.run(self._ingest(msg_dicts, **kwargs)) return None self._status.update(state='scheduled', error=None) + # Claim the messages NOW, synchronously. A task does not start until + # the loop gets around to it, and an interrupt landing in that gap + # would otherwise mark them as ingested before the write ever looked. + claimed = _ingestable_hashes(msg_dicts) + self._inflight |= claimed task = loop.create_task(self._ingest(msg_dicts, **kwargs)) self._pending.add(task) task.add_done_callback(self._pending.discard) + task.add_done_callback( + lambda _t: self._inflight.difference_update(claimed)) return task def _should_ingest(self) -> bool: @@ -196,6 +251,16 @@ async def _ingest(self, msg_dicts: List[Dict[str, Any]], Never raises — a memory write must not break anything above it; the outcome lands in ``ingest_status`` instead. """ + if self._closed: + # Scheduled before the teardown, woken after it. Writing now would + # reopen a store its owner has already released. + logger.debug('[orchestrator] ingest dropped: orchestrator closed') + return 0 + # Also claimed here, not only in schedule_add: `add()` reaches this + # directly. Claiming twice is harmless — both releases drop the same + # hashes. + claimed = _ingestable_hashes(msg_dicts) + self._inflight |= claimed try: async with _store_lock(self.mem_config.base_dir): backend = await self._ensure_started() @@ -220,17 +285,25 @@ async def _ingest(self, msg_dicts: List[Dict[str, Any]], f'persisted for this turn: {type(e).__name__}: {e}') self._set_status('error', error=f'{type(e).__name__}: {e}') return 0 + finally: + self._inflight -= claimed def mark_ingested(self, messages: List[Message]) -> None: """Advance the ledger WITHOUT ingesting (interrupted turns): a - half-finished answer must not be swept into the next turn's delta.""" - self._ledger_mark( - [ - m for m in _messages_to_dicts(messages) - if m.get('role') in _INGEST_ROLES and m.get('content') - ], - persist=True, - ) + half-finished answer must not be swept into the next turn's delta. + + Pass ONLY the interrupted round's own messages. Anything a scheduled + ingest has already claimed is skipped regardless: marking a message + that is still being written would either make that write a no-op (the + delta comes out empty) or, if it fails, deny it the retry the ledger + exists to guarantee — either way the memory is silently lost. + """ + dicts = [ + m for m in _messages_to_dicts(messages) + if m.get('role') in _INGEST_ROLES and m.get('content') + and _content_hash(m) not in self._inflight + ] + self._ledger_mark(dicts, persist=True) async def flush_pending(self, timeout: float = 15.0) -> None: """Barrier: wait for scheduled ingests (teardown must not drop the @@ -328,7 +401,7 @@ def _ledger_mark(self, msg_dicts: List[Dict[str, Any]], # ------------------------------------------------------------------ async def flush(self, messages: List[Message]) -> None: - if not self.mem_config.enabled: + if not self.mem_config.enabled or self._closed: return async with _store_lock(self.mem_config.base_dir): backend = await self._ensure_started() @@ -340,8 +413,14 @@ async def flush(self, messages: List[Message]) -> None: # ------------------------------------------------------------------ async def search(self, query: str, limit: int = 10) -> List[MemoryEntry]: - backend = await self._ensure_started() - return await backend.search(query, limit) + if self._closed: + return [] + # Under the store lock like every other access: an embedded store has + # no locking of its own, and this one used to be the single reader that + # could land in the middle of a background write. + async with _store_lock(self.mem_config.base_dir): + backend = await self._ensure_started() + return await backend.search(query, limit) # ------------------------------------------------------------------ # Tool interface (called by the agent's ToolManager) @@ -384,15 +463,77 @@ def init_update_queue(self) -> None: # Shutdown # ------------------------------------------------------------------ - async def close(self) -> None: - # Drain scheduled writes first — closing under a pending ingest would - # either lose the write or race the backend teardown. + async def _shutdown_backend(self, retire: bool = False) -> None: + """Drain scheduled writes, then release the backend (and with it the + store's file lock). ``retire`` additionally makes the orchestrator + refuse to ever reopen the store; ``reconfigure`` leaves it False, + because everyone holding this instance must keep working with it. + + Order matters. Draining comes FIRST: a write that was already + scheduled is part of what a teardown promises to persist. Retiring + comes right after, so a straggler past ``flush_pending``'s timeout + finds the door closed instead of going through ``_ensure_started``, + which would restart the backend and take the embedded store's file + lock again — after its owner believed it released. + + Stragglers are deliberately NOT cancelled: a backend write runs in a + worker thread (mem0 extraction + embedding), so cancelling only + abandons the await while the thread keeps writing — and we would then + close the client under it. Waiting on the store lock below is what + actually makes the teardown safe. + """ await self.flush_pending() + if retire: + self._closed = True if self._backend is not None and self._started: async with _store_lock(self.mem_config.base_dir): await self._backend.close() self._started = False + async def close(self) -> None: + await self._shutdown_backend(retire=True) + + # ------------------------------------------------------------------ + # Reconfiguration + # ------------------------------------------------------------------ + + async def reconfigure(self, config: Any) -> bool: + """Adopt ``config`` on this live instance. Returns True when the + backend had to be torn down, False for a no-op or an in-place update. + + Applied to the instance instead of by replacing it, because + ``SharedMemoryManager`` hands ONE instance per store to every agent + that asks: a replacement would leave earlier holders pointing at an + orchestrator whose store was closed under them, and two live + orchestrators over one embedded store is exactly the exclusive-lock + conflict that sharing exists to prevent. Mutating the shared object + updates every holder at once. + """ + new_cfg = self._parse_config(config) + changed = _changed_fields(self.mem_config, new_cfg) + if not changed: + return False + if not changed - set(_SOFT_FIELDS): + # The live backend holds a reference to this very MemoryConfig, so + # writing the fields through reaches it without a teardown. + for field in _SOFT_FIELDS: + setattr(self.mem_config, field, getattr(new_cfg, field)) + logger.info(f'[orchestrator] memory config updated in place: ' + f'{sorted(changed)}') + return False + logger.info(f'[orchestrator] memory config changed ' + f'({sorted(changed)}) -> rebuilding backend') + # Release the old store, but keep the instance usable — everyone + # holding it must keep working, now against the new configuration. + await self._shutdown_backend() + self._backend = None + if 'base_dir' in changed: + # A different store keeps a different ledger. + self._ledger = None + self._ledger_order = [] + self.mem_config = new_cfg + return True + # ------------------------------------------------------------------ # Config parsing # ------------------------------------------------------------------ diff --git a/tests/memory/test_orchestrator_teardown.py b/tests/memory/test_orchestrator_teardown.py new file mode 100644 index 000000000..d77b84fad --- /dev/null +++ b/tests/memory/test_orchestrator_teardown.py @@ -0,0 +1,231 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Interrupt / teardown hazards around the ingest ledger and the store lock. + +All four guard the same property from different sides: a memory that the +conversation produced is either written or still owed — never quietly dropped, +and never written into a store whose owner has let go of it. + +* an interrupt must not advance the ledger past a write that is still running + (the write then finds an empty delta, or fails and is denied its retry); +* the per-store lock must survive a process that runs more than one event loop; +* retrieval takes that lock too, so it cannot read a store mid-write; +* a closed orchestrator must not be reopened by a straggling ingest. +""" +import asyncio + +import pytest + +from ms_agent.llm.utils import Message +from ms_agent.memory.unified.config import MemoryConfig +from ms_agent.memory.unified.orchestrator import (MemoryOrchestrator, + _store_lock) + + +class SlowBackend: + """Records what it was actually asked to write, slowly enough to overlap.""" + + def __init__(self, delay: float = 0.05): + self.delay = delay + self.batches = [] + self.searches = [] + self.starts = 0 + self.closes = 0 + self.wrote_after_close = False + + async def start(self, **kwargs): + self.starts += 1 + + async def on_messages(self, messages, **kwargs): + await asyncio.sleep(self.delay) + if self.closes: + self.wrote_after_close = True + self.batches.append([m['content'] for m in messages]) + return len(messages) + + async def inject(self, messages): + return messages + + async def search(self, query, limit=10): + # Recorded on ENTRY: the question is whether retrieval reaches the + # store while a write holds it, not when it finishes. + self.searches.append(query) + await asyncio.sleep(self.delay) + return [] + + async def on_pre_compress(self, messages): + pass + + async def close(self): + self.closes += 1 + + def invalidate(self): + pass + + +def _orch(tmp_path, backend, **cfg): + orch = MemoryOrchestrator( + MemoryConfig(base_dir=str(tmp_path), storage_backend='file', **cfg)) + orch._backend = backend + orch._started = True + return orch + + +def _round(user, assistant): + return [ + Message(role='user', content=user), + Message(role='assistant', content=assistant) + ] + + +def test_interrupt_does_not_swallow_an_ingest_in_flight(tmp_path): + """The reported data loss. + + A round is being written in the background (extraction takes seconds) when + the user hits stop. The interrupt advanced the ledger over that round too, + so the write found an empty delta — the memory was neither stored nor still + owed. Reproduced here by handing ``mark_ingested`` the WHOLE history, which + is what the interrupt path used to pass: whatever a caller hands over, a + write already in flight owns its own messages until it finishes. + """ + backend = SlowBackend() + orch = _orch(tmp_path, backend) + history = _round('u1', 'a1') + + async def main(): + lock = _store_lock(str(tmp_path)) + await lock.acquire() # the scheduled ingest cannot reach its delta yet + try: + task = orch.schedule_add(history) + await asyncio.sleep(0) + orch.mark_ingested(history + _round('u2', 'half an answ')) + finally: + lock.release() + await task + + asyncio.run(main()) + assert backend.batches == [['u1', 'a1']] # the round still got written + + +def test_interrupt_marks_only_its_own_round(tmp_path): + """`mark_ingested` is fed one round, not the whole history: an earlier + round that was never ingested (a failed write, an interval skip) must stay + owed, not be written off by an unrelated interrupt.""" + backend = SlowBackend(delay=0) + orch = _orch(tmp_path, backend) + earlier = _round('u1', 'a1') + + async def main(): + orch.mark_ingested(_round('u2', 'half an answ')) + await orch.add(earlier) + + asyncio.run(main()) + assert backend.batches == [['u1', 'a1']] + + +def test_interrupt_still_seals_its_own_partial_round(tmp_path): + """...while the partial answer itself never reaches the store.""" + backend = SlowBackend(delay=0) + orch = _orch(tmp_path, backend) + partial = _round('u1', 'half an answ') + + async def main(): + orch.mark_ingested(partial) + await orch.add(partial) + + asyncio.run(main()) + assert backend.batches == [] + + +def test_store_lock_survives_a_second_event_loop(tmp_path): + """asyncio.Lock binds to the loop that first waits on it and refuses every + other one afterwards. The lock is per (loop, store) so a process that runs + several loops — the inline `asyncio.run` ingest path, a test suite — does + not wedge on a lock belonging to a loop that is already closed.""" + + async def contend(): + lock = _store_lock(str(tmp_path)) + await lock.acquire() + waiter = asyncio.create_task(_take(lock)) + await asyncio.sleep(0) # let it queue: this is what binds the loop + lock.release() + await waiter + + async def _take(lock): + async with lock: + pass + + asyncio.run(contend()) + asyncio.run(contend()) # RuntimeError: bound to a different event loop + + +def test_search_waits_for_a_write_to_finish(tmp_path): + """Retrieval used to be the one store access outside the lock.""" + backend = SlowBackend(delay=0.05) + orch = _orch(tmp_path, backend) + + async def main(): + lock = _store_lock(str(tmp_path)) + await lock.acquire() + task = asyncio.create_task(orch.search('who am i')) + await asyncio.sleep(0.02) + held = list(backend.searches) # must not have run yet + lock.release() + await task + return held, backend.searches + + during, after = asyncio.run(main()) + assert during == [] and after == ['who am i'] + + +def test_a_closed_orchestrator_never_reopens_the_store(tmp_path): + """`close()` releases an embedded store's file lock, so anything that + reopens it afterwards takes that lock behind the owner's back.""" + backend = SlowBackend(delay=0) + orch = _orch(tmp_path, backend) + + async def main(): + await orch.close() + await orch.add(_round('u1', 'a1')) + await orch.run(_round('u2', 'a2')) + return await orch.search('anything') + + found = asyncio.run(main()) + assert backend.starts == 0 and backend.closes == 1 + assert backend.batches == [] and found == [] + + +def test_close_still_drains_what_was_already_scheduled(tmp_path): + """Retiring must not cost the writes close() promised to persist — the + order is drain, then retire.""" + backend = SlowBackend(delay=0.02) + orch = _orch(tmp_path, backend) + + async def main(): + orch.schedule_add(_round('u1', 'a1')) + await orch.close() + + asyncio.run(main()) + assert backend.batches == [['u1', 'a1']] + assert backend.wrote_after_close is False + + +def test_reconfigure_keeps_the_instance_usable(tmp_path): + """The teardown a config change performs is not a retirement: every agent + sharing this instance must keep working, now on the new configuration.""" + backend = SlowBackend(delay=0) + orch = _orch(tmp_path, backend) + + async def main(): + await orch.reconfigure( + MemoryConfig( + base_dir=str(tmp_path), + storage_backend='file', + memory_path='OTHER.md')) + assert orch._closed is False + # A fresh backend is built on demand from the new config. + orch._backend, orch._started = backend, True + await orch.add(_round('u1', 'a1')) + + asyncio.run(main()) + assert backend.closes == 1 # old backend released + assert backend.batches == [['u1', 'a1']] # instance still writes diff --git a/tests/memory/test_shared_memory_reconfigure.py b/tests/memory/test_shared_memory_reconfigure.py new file mode 100644 index 000000000..1d8034966 --- /dev/null +++ b/tests/memory/test_shared_memory_reconfigure.py @@ -0,0 +1,198 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Sharing and reconfiguration of memory instances. + +``SharedMemoryManager`` hands one instance per store to every agent that asks +for it, which raises two questions these tests pin down: + +* what happens when a later agent's config differs from the one the instance + was built with — it must be adopted, otherwise editing a memory setting is + indistinguishable from the setting doing nothing; +* what happens when only the agent's model differs — the instance must still + be shared, because embedded vector stores take an exclusive file lock and a + second instance on the same path cannot open the store at all. +""" +import asyncio + +import pytest +from omegaconf import OmegaConf + +from ms_agent.memory.memory_manager import SharedMemoryManager +from ms_agent.memory.unified.config import MemoryConfig +from ms_agent.memory.unified.orchestrator import MemoryOrchestrator + + +class FakeBackend: + """Backend that keeps the config object it was constructed with, the way + every real backend does.""" + + def __init__(self, config): + self._config = config + self.closed = False + + async def start(self, **kwargs): + pass + + async def inject(self, messages): + return messages + + async def on_messages(self, messages, **kwargs): + return len(messages) + + async def on_pre_compress(self, messages): + pass + + async def close(self): + self.closed = True + + def invalidate(self): + pass + + +@pytest.fixture(autouse=True) +def _clean_instances(): + SharedMemoryManager._instances.clear() + yield + SharedMemoryManager._instances.clear() + + +def _cfg(tmp_path, *, model='m1', recall=10, backend='file', options=None): + node = { + 'storage': { + 'backend': backend + }, + 'namespace': { + 'user_id': 'p1' + }, + 'user_id': 'p1', + 'base_dir': str(tmp_path), + 'recall_top_k': recall, + } + if options is not None: + node['mem0'] = options + return OmegaConf.create({ + 'output_dir': str(tmp_path), + 'llm': { + 'model': model + }, + 'memory': { + 'unified_memory': node + }, + }) + + +def _orch(tmp_path, **cfg_kwargs): + """A live orchestrator, built the way an agent builds one.""" + orch = MemoryOrchestrator(_cfg(tmp_path, **cfg_kwargs)) + orch._backend = FakeBackend(orch.mem_config) + orch._started = True + return orch + + +def test_identical_config_is_a_noop(tmp_path): + orch = _orch(tmp_path) + backend = orch._backend + assert asyncio.run(orch.reconfigure(_cfg(tmp_path))) is False + assert backend.closed is False + assert orch._backend is backend + + +def test_recall_size_applies_without_tearing_the_store_down(tmp_path): + """The reported bug: a changed recall size must reach the LIVE backend. + + It is applied by writing through the shared MemoryConfig object rather + than rebinding it, because the backend holds a reference to that object — + rebinding would leave the backend reading the old numbers. + """ + orch = _orch(tmp_path, recall=10) + backend = orch._backend + + torn_down = asyncio.run(orch.reconfigure(_cfg(tmp_path, recall=3))) + + assert torn_down is False # no reason to close a store for a number + assert backend.closed is False + assert orch.mem_config.recall_top_k == 3 + assert backend._config.recall_top_k == 3 # what inject() actually reads + + +def test_store_affecting_change_rebuilds_the_backend(tmp_path): + orch = _orch(tmp_path, backend='mem0') + backend = orch._backend + + torn_down = asyncio.run( + orch.reconfigure( + _cfg( + tmp_path, + backend='mem0', + options={'embedder': { + 'provider': 'fastembed' + }}))) + + assert torn_down is True + assert backend.closed is True # store released, so its lock is too + assert orch._backend is None # next use builds from the new config + + +def test_switching_models_shares_one_instance(tmp_path): + """Two agents on one store, different models: one instance. + + Keying the cache by model used to hand the second agent its own instance, + which then could not open the (exclusively locked) store at all — memory + silently stopped working for whoever switched models. + """ + + async def main(): + first = await SharedMemoryManager.get_shared_memory( + _cfg(tmp_path, model='m1'), 'unified_memory') + second = await SharedMemoryManager.get_shared_memory( + _cfg(tmp_path, model='m2'), 'unified_memory') + return first, second + + first, second = asyncio.run(main()) + assert first is second + assert len(SharedMemoryManager._instances) == 1 + + +def test_manager_adopts_a_changed_recall_size(tmp_path): + + async def main(): + await SharedMemoryManager.get_shared_memory( + _cfg(tmp_path, recall=10), 'unified_memory') + return await SharedMemoryManager.get_shared_memory( + _cfg(tmp_path, recall=5), 'unified_memory') + + assert asyncio.run(main()).mem_config.recall_top_k == 5 + + +def test_different_stores_stay_separate(tmp_path): + + async def main(): + a = await SharedMemoryManager.get_shared_memory( + _cfg(tmp_path / 'a'), 'unified_memory') + b = await SharedMemoryManager.get_shared_memory( + _cfg(tmp_path / 'b'), 'unified_memory') + return a, b + + a, b = asyncio.run(main()) + assert a is not b + assert len(SharedMemoryManager._instances) == 2 + + +def test_reconfigure_failure_keeps_the_cached_instance(tmp_path): + """A broken incoming config must not take an agent's memory down with it: + the existing instance keeps serving its own configuration.""" + + async def main(): + instance = await SharedMemoryManager.get_shared_memory( + _cfg(tmp_path, recall=10), 'unified_memory') + + async def boom(_config): + raise RuntimeError('bad config') + + instance.reconfigure = boom + again = await SharedMemoryManager.get_shared_memory( + _cfg(tmp_path, recall=5), 'unified_memory') + return instance, again + + instance, again = asyncio.run(main()) + assert instance is again + assert again.mem_config.recall_top_k == 10 diff --git a/tests/memory/test_unified_memory.py b/tests/memory/test_unified_memory.py index 6ce4851bd..5d4d66fc9 100644 --- a/tests/memory/test_unified_memory.py +++ b/tests/memory/test_unified_memory.py @@ -1566,6 +1566,107 @@ def test_format_results_limits_to_10(self): lines = [l for l in formatted.split("\n") if l.strip()] assert len(lines) == 10 + def test_format_results_stamps_the_day(self): + # mem0 2.x only ever ADDs, so contradicting memories coexist; the date + # is the only thing the model can prefer the newer one by. + results = [ + {"memory": "Answers in Chinese", "updated_at": "2026-08-01T10:00:00Z"}, + {"memory": "Answers in English", "created_at": "2026-08-11T09:00:00Z"}, + ] + formatted = Mem0Backend._format_results(results) + assert "- (2026-08-01) Answers in Chinese" in formatted + assert "- (2026-08-11) Answers in English" in formatted + + def test_format_results_omits_unusable_timestamp(self): + formatted = Mem0Backend._format_results( + [{"memory": "Uses ruff", "updated_at": "n/a"}] + ) + assert formatted == "- Uses ruff" + + def _capture_mem0_config(self, monkeypatch): + """Run start() against a stand-in mem0 and return the config it got.""" + import sys + import types + + captured = {} + + class FakeMemory: + + @staticmethod + def from_config(cfg): + captured["cfg"] = cfg + return object() + + module = types.ModuleType("mem0") + module.Memory = FakeMemory + monkeypatch.setitem(sys.modules, "mem0", module) + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(self.backend.start()) + finally: + loop.close() + return captured["cfg"] + + def test_language_instructions_are_configured_by_default(self, monkeypatch): + """Memories are stored in the user's own language, because that is what + the store is later QUERIED in: a differently-worded memory has to + survive a cross-lingual embedding hop, and mem0's BM25 leg contributes + nothing across languages.""" + from ms_agent.memory.unified.backends.mem0_adapter import ( + DEFAULT_CUSTOM_INSTRUCTIONS, + ) + + cfg = self._capture_mem0_config(monkeypatch) + assert cfg["custom_instructions"] == DEFAULT_CUSTOM_INSTRUCTIONS + + def test_configured_instructions_win(self, monkeypatch): + self.backend._config.backend_options["mem0"] = { + "custom_instructions": "mine" + } + assert self._capture_mem0_config(monkeypatch)["custom_instructions"] == "mine" + + def test_mem0_appends_instructions_instead_of_replacing_its_prompt(self): + """The contract our default relies on. mem0 1.x's + `custom_fact_extraction_prompt` REPLACED the extraction prompt (losing + every built-in guideline); 2.x's `custom_instructions` is an extra + section. If that ever flips back, this fails instead of quietly + degrading extraction quality.""" + prompts = pytest.importorskip("mem0.configs.prompts") + from ms_agent.memory.unified.backends.mem0_adapter import ( + DEFAULT_CUSTOM_INSTRUCTIONS, + ) + + built = prompts.generate_additive_extraction_prompt( + existing_memories=[], + new_messages=[{"role": "user", "content": "hi"}], + custom_instructions=DEFAULT_CUSTOM_INSTRUCTIONS, + ) + for section in ("## Summary", "## Last k Messages", + "## Recently Extracted Memories", + "## Existing Memories", "## New Messages", + "## Observation Date", "## Current Date"): + assert section in built, f"built-in section {section} disappeared" + assert DEFAULT_CUSTOM_INSTRUCTIONS in built + # ...and the system prompt is mem0's own, untouched by us. + assert "Memory Extractor" in prompts.ADDITIVE_EXTRACTION_PROMPT + + def test_search_passes_limit_through(self): + # Dropping `limit` here silently capped every caller at mem0's default. + seen = {} + + class FakeMem0: + def search(self, query, filters=None, top_k=None, **kwargs): + seen["top_k"] = top_k + return {"results": [{"id": "1", "memory": "x"}]} + + self.backend._mem0 = FakeMem0() + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(self.backend.search("q", limit=3)) + finally: + loop.close() + assert seen["top_k"] == 3 + def test_inject_without_mem0_passthrough(self): loop = asyncio.new_event_loop() try: From 6b5fae5f94c2600d8e1d37eb19b4a4232305a853 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Fri, 14 Aug 2026 20:37:36 +0800 Subject: [PATCH 21/36] Retry once with thinking off when a model rejects the thinking parameters Thinking support is per-model with no naming rule, and an unsupported model may reject the whole request (DashScope returns 400) instead of ignoring the flag. So we ask, and on a refusal retry once with it off, remembering the model. --- ms_agent/llm/openai_llm.py | 9 +- ms_agent/llm/thinking.py | 101 ++++++++++++++++ ms_agent/llm/transport/openai_compat.py | 8 +- tests/llm/test_thinking_fallback.py | 153 ++++++++++++++++++++++++ 4 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 ms_agent/llm/thinking.py create mode 100644 tests/llm/test_thinking_fallback.py diff --git a/ms_agent/llm/openai_llm.py b/ms_agent/llm/openai_llm.py index 28af611bf..6b4145579 100644 --- a/ms_agent/llm/openai_llm.py +++ b/ms_agent/llm/openai_llm.py @@ -11,6 +11,7 @@ from typing import Any, Dict, Generator, Iterable, List, Optional from ms_agent.llm import LLM +from ms_agent.llm.thinking import create_with_thinking_fallback from ms_agent.llm.utils import Message, Tool, ToolCall from ms_agent.utils import (MAX_CONTINUE_RUNS, assert_package_exist, get_logger, retry) @@ -18,7 +19,6 @@ logger = get_logger() - class _DashScopeResponsesTransport(httpx.HTTPTransport): """Rewrite /v1/responses -> /v1/chat/completions for DashScope proxy. @@ -295,8 +295,11 @@ def _call_llm(self, if is_streaming and stream_options_config.get('include_usage', True): kwargs.setdefault('stream_options', {})['include_usage'] = True - return self.client.chat.completions.create( - model=self.model, messages=messages, tools=tools, **kwargs) + # Thinking is per-model and a refusal is a hard 400 (see llm/thinking.py). + return create_with_thinking_fallback( + lambda **kw: self.client.chat.completions.create( + model=self.model, messages=messages, tools=tools, **kw), + self.client, self.model, logger, **kwargs) @staticmethod def _extract_cache_info(usage_obj: Any) -> tuple: diff --git a/ms_agent/llm/thinking.py b/ms_agent/llm/thinking.py new file mode 100644 index 000000000..1adb1481a --- /dev/null +++ b/ms_agent/llm/thinking.py @@ -0,0 +1,101 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Fallback for models that refuse the "thinking" parameters. + +Whether a model supports thinking is a per-MODEL fact with no naming rule to +derive it from, and a provider that does not support it may REJECT the whole +request rather than ignore the flag (DashScope answers 400 +``InternalError.Algo.InvalidParameter: The thinking_budget parameter must be a +positive integer and not greater than 0``). Probing one provider's 122 chat +models turned up refusals from ``qwen-vl-*``, ``qwen3.5-ocr``, ``qwen3-8b``, +``qwen3-livetranslate-flash``, ``qwen3.5-omni-flash`` and ``deepseek-v3.1`` — +vision, OCR, omni, open-weight and non-Qwen alike, i.e. not predictable from the +name, and a moving target as vendors ship models. + +So the client does not try to predict it: it asks for thinking, and if the model +refuses, turns it off and retries once, remembering the model so a session pays +the extra round-trip at most once. Shared by every OpenAI-compatible caller +(``llm/openai_llm.py`` and the provider router's ``transport/openai_compat.py``) +— a blocklist maintained by hand was wrong twice before this existed. +""" +from __future__ import annotations + +from typing import Any, Dict + +THINKING_PARAM_KEYS = ('enable_thinking', 'thinking_budget', 'thinking') + +#: ``(base_url, model)`` pairs observed to refuse the thinking parameters. +MODELS_REFUSING_THINKING: set = set() + + +def model_key(client: Any, model: str) -> tuple: + return (str(getattr(client, 'base_url', '')), model) + + +def is_thinking_refusal(exc: Exception) -> bool: + """A 400 that names the thinking parameters — not any other bad request.""" + status = getattr(exc, 'status_code', None) + if status is not None and status != 400: + return False + text = str(exc).lower() + if status != 400 and '400' not in text: + return False + return any(k in text for k in THINKING_PARAM_KEYS) + + +def without_thinking(kwargs: Dict[str, Any]) -> Dict[str, Any]: + """``kwargs`` with thinking turned OFF explicitly, not merely removed. + + Dropping the flag is not enough: some models default it ON and then refuse + the call ("parameter.enable_thinking must be set to false for non-stream + call" — qwen3-8b). So the budget keys go away and ``enable_thinking`` is + pinned to False, inside ``extra_body`` when that is where it came from. + + Returns the SAME object when the request carried no thinking parameter at + all, so callers can tell "we never asked for thinking" (a 400 that is + somebody else's problem) from "we just turned it off" (worth a retry). + """ + extra = kwargs.get('extra_body') + in_extra = isinstance(extra, dict) and any(k in extra + for k in THINKING_PARAM_KEYS) + in_top = any(k in kwargs for k in THINKING_PARAM_KEYS) + if not in_extra and not in_top: + return kwargs + cleaned = dict(kwargs) + for key in THINKING_PARAM_KEYS: + cleaned.pop(key, None) + if in_extra: + new_extra = { + k: v + for k, v in extra.items() if k not in THINKING_PARAM_KEYS + } + new_extra['enable_thinking'] = False + cleaned['extra_body'] = new_extra + else: + cleaned['enable_thinking'] = False + return cleaned + + +def create_with_thinking_fallback(create, client, model: str, logger, + **kwargs) -> Any: + """Call ``create(**kwargs)``, retrying once with thinking off on a refusal. + + ``create`` must be the completions factory itself; it is called with the + (possibly cleaned) kwargs. Streaming is covered because the OpenAI client + performs the request — and raises — before it returns an iterator. + """ + key = model_key(client, model) + if key in MODELS_REFUSING_THINKING: + kwargs = without_thinking(kwargs) + try: + return create(**kwargs) + except Exception as e: + if not is_thinking_refusal(e): + raise + retry_kwargs = without_thinking(kwargs) + if retry_kwargs is kwargs: # we asked for no thinking; not our 400 + raise + MODELS_REFUSING_THINKING.add(key) + logger.warning( + f'{model} rejected the thinking parameters; retrying with ' + f'thinking off (it stays off for this model): {e}') + return create(**retry_kwargs) diff --git a/ms_agent/llm/transport/openai_compat.py b/ms_agent/llm/transport/openai_compat.py index 20bdf25ce..ea06def46 100644 --- a/ms_agent/llm/transport/openai_compat.py +++ b/ms_agent/llm/transport/openai_compat.py @@ -22,6 +22,7 @@ from copy import deepcopy from typing import Any, Dict, Generator, Iterable, List, Optional, Union +from ms_agent.llm.thinking import create_with_thinking_fallback from ms_agent.llm.transport.base import Transport from ms_agent.llm.utils import Message, Tool, ToolCall from ms_agent.utils import MAX_CONTINUE_RUNS, assert_package_exist, get_logger @@ -339,8 +340,11 @@ def _call_llm(self, stream_options_config = self.args.get('stream_options', {}) if is_streaming and stream_options_config.get('include_usage', True): kwargs.setdefault('stream_options', {})['include_usage'] = True - return self.client.chat.completions.create( - model=self.model, messages=messages, tools=tools, **kwargs) + # Thinking is per-model and a refusal is a hard 400 (see llm/thinking.py). + return create_with_thinking_fallback( + lambda **kw: self.client.chat.completions.create( + model=self.model, messages=messages, tools=tools, **kw), + self.client, self.model, logger, **kwargs) # ------------------------------------------------------------------ # # usage diff --git a/tests/llm/test_thinking_fallback.py b/tests/llm/test_thinking_fallback.py new file mode 100644 index 000000000..36fe3f4c5 --- /dev/null +++ b/tests/llm/test_thinking_fallback.py @@ -0,0 +1,153 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Thinking parameters are dropped and retried when a model refuses them. + +Support for "thinking" is per-model and unpredictable from the name — probing +one provider's 122 chat models turned up refusals across vision, OCR, omni, +open-weight and even non-Qwen families — and a refusal is a hard 400, not an +ignored flag. So the client does not try to know: it asks, and on a refusal +retries once with thinking off, remembering the model. +""" +import pytest + +from ms_agent.llm import openai_llm as O +from ms_agent.llm import thinking as T +from ms_agent.llm.transport import openai_compat as TC + + +class _Recorder: + """Stands in for ``client.chat.completions``; records every call and fails + the ones that ask for thinking.""" + + def __init__(self, refuse: str = 'thinking_budget must be positive'): + self.calls = [] + self.refuse = refuse + + def create(self, **kwargs): + self.calls.append(kwargs) + extra = kwargs.get('extra_body') or {} + asked = extra.get('enable_thinking') or kwargs.get('enable_thinking') + if asked and self.refuse: + raise RuntimeError(f'Error code: 400 - {self.refuse}') + return f'completion-{len(self.calls)}' + + +def _client(recorder): + ns = type('NS', (), {}) + client = ns() + client.chat = ns() + client.chat.completions = recorder + client.base_url = 'https://example.test/v1' + return client + + +def _llm(recorder, model='some-model'): + llm = O.OpenAI.__new__(O.OpenAI) # bypass __init__ (config/network) + llm.client = _client(recorder) + llm.model = model + llm.args = {} + llm._format_input_message = lambda m: m + return llm + + +@pytest.fixture(autouse=True) +def _clear_memo(): + T.MODELS_REFUSING_THINKING.clear() + yield + T.MODELS_REFUSING_THINKING.clear() + + +def test_refusal_is_retried_with_thinking_explicitly_off(): + rec = _Recorder() + out = _llm(rec)._call_llm([], None, extra_body={'enable_thinking': True}) + + assert out == 'completion-2' # the retry's result, not an exception + assert len(rec.calls) == 2 + # Explicitly OFF, not merely absent: some models default it on and then + # refuse the call ("must be set to false for non-stream call"). + assert rec.calls[1]['extra_body'] == {'enable_thinking': False} + + +def test_budget_keys_are_dropped_and_other_extras_kept(): + rec = _Recorder() + _llm(rec)._call_llm([], + None, + extra_body={ + 'enable_thinking': True, + 'thinking_budget': 512, + 'unrelated': 'keep me' + }) + retried = rec.calls[1]['extra_body'] + assert retried == {'enable_thinking': False, 'unrelated': 'keep me'} + + +def test_the_refusal_is_remembered_so_later_turns_cost_one_call(): + rec = _Recorder() + llm = _llm(rec) + llm._call_llm([], None, extra_body={'enable_thinking': True}) + assert len(rec.calls) == 2 + + llm._call_llm([], None, extra_body={'enable_thinking': True}) + assert len(rec.calls) == 3 # no failed attempt this time + assert rec.calls[2]['extra_body'] == {'enable_thinking': False} + + +def test_memo_is_per_model(): + rec = _Recorder() + _llm(rec, model='refuser')._call_llm([], + None, + extra_body={'enable_thinking': True}) + assert len(rec.calls) == 2 + # A different model on the same endpoint must still get its chance to think. + _llm(rec, model='thinker')._call_llm([], + None, + extra_body={'enable_thinking': True}) + assert len(rec.calls) == 4 + assert rec.calls[2]['extra_body'] == {'enable_thinking': True} + + +def test_unrelated_400_is_not_retried(): + rec = _Recorder(refuse='context length exceeded') + + class _Always(_Recorder): + + def create(self, **kwargs): + self.calls.append(kwargs) + raise RuntimeError('Error code: 400 - context length exceeded') + + rec = _Always() + with pytest.raises(RuntimeError, match='context length'): + _llm(rec)._call_llm([], None, extra_body={'enable_thinking': True}) + assert len(rec.calls) == 1 + + +def test_a_request_without_thinking_is_never_retried(): + """A 400 that merely mentions thinking, on a call that asked for none, is + somebody else's problem — retrying would hide it.""" + + class _Always(_Recorder): + + def create(self, **kwargs): + self.calls.append(kwargs) + raise RuntimeError('Error code: 400 - thinking is unsupported') + + rec = _Always() + with pytest.raises(RuntimeError): + _llm(rec)._call_llm([], None, temperature=0.5) + assert len(rec.calls) == 1 + assert not T.MODELS_REFUSING_THINKING + + +def test_the_router_transport_heals_too(): + """The WebUI does not go through llm/openai_llm.py at all — its provider + router uses transport/openai_compat.py. A fallback that only covered one of + them looked fine in unit tests and still failed in the browser.""" + rec = _Recorder() + tr = TC.OpenAICompatTransport.__new__(TC.OpenAICompatTransport) + tr.client = _client(rec) + tr.model = 'refuser' + tr.args = {} + tr._format_input_message = lambda m: m + + out = tr._call_llm([], None, extra_body={'enable_thinking': True}) + assert out == 'completion-2' + assert rec.calls[1]['extra_body'] == {'enable_thinking': False} From c9389bc80a524de8fb35f5ad05423c33a08d9d64 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Mon, 17 Aug 2026 20:42:30 +0800 Subject: [PATCH 22/36] Lower a single reasoning_effort knob onto each endpoint's own thinking dialect --- ms_agent/llm/openai_llm.py | 8 +- ms_agent/llm/thinking.py | 341 +++++++++++++++++-- ms_agent/llm/transport/anthropic_messages.py | 11 + ms_agent/llm/transport/openai_compat.py | 12 +- tests/llm/test_thinking_effort.py | 275 +++++++++++++++ 5 files changed, 623 insertions(+), 24 deletions(-) create mode 100644 tests/llm/test_thinking_effort.py diff --git a/ms_agent/llm/openai_llm.py b/ms_agent/llm/openai_llm.py index 6b4145579..de7163990 100644 --- a/ms_agent/llm/openai_llm.py +++ b/ms_agent/llm/openai_llm.py @@ -11,7 +11,7 @@ from typing import Any, Dict, Generator, Iterable, List, Optional from ms_agent.llm import LLM -from ms_agent.llm.thinking import create_with_thinking_fallback +from ms_agent.llm.thinking import apply_effort, create_with_thinking_fallback from ms_agent.llm.utils import Message, Tool, ToolCall from ms_agent.utils import (MAX_CONTINUE_RUNS, assert_package_exist, get_logger, retry) @@ -252,6 +252,12 @@ def generate(self, if not stream: args.pop('stream_options', None) + # Lower the canonical knob first: the Responses path below reads + # `reasoning_effort` straight into `reasoning.effort`, so it must see a + # real OpenAI tier rather than a canonical `auto`/`off`. + args = apply_effort( + args, base_url=str(getattr(self.client, 'base_url', ''))) + if self._use_responses_api: if stream: return self._responses_stream_generate(messages, tools, **args) diff --git a/ms_agent/llm/thinking.py b/ms_agent/llm/thinking.py index 1adb1481a..acc4f1131 100644 --- a/ms_agent/llm/thinking.py +++ b/ms_agent/llm/thinking.py @@ -1,32 +1,330 @@ # Copyright (c) ModelScope Contributors. All rights reserved. -"""Fallback for models that refuse the "thinking" parameters. - -Whether a model supports thinking is a per-MODEL fact with no naming rule to -derive it from, and a provider that does not support it may REJECT the whole -request rather than ignore the flag (DashScope answers 400 -``InternalError.Algo.InvalidParameter: The thinking_budget parameter must be a -positive integer and not greater than 0``). Probing one provider's 122 chat -models turned up refusals from ``qwen-vl-*``, ``qwen3.5-ocr``, ``qwen3-8b``, -``qwen3-livetranslate-flash``, ``qwen3.5-omni-flash`` and ``deepseek-v3.1`` — -vision, OCR, omni, open-weight and non-Qwen alike, i.e. not predictable from the -name, and a moving target as vendors ship models. - -So the client does not try to predict it: it asks for thinking, and if the model -refuses, turns it off and retries once, remembering the model so a session pays -the extra round-trip at most once. Shared by every OpenAI-compatible caller -(``llm/openai_llm.py`` and the provider router's ``transport/openai_compat.py``) -— a blocklist maintained by hand was wrong twice before this existed. +"""One semantic knob for "how hard should the model think", plus the fallback +for models that refuse to be asked at all. + +Every vendor spells thinking differently and none of them spells it the same way +for long. Probed against the official docs on 2026-08-17: + +=============== ================================== ================== ========= +endpoint modern field tiers default +=============== ================================== ================== ========= +OpenAI ``reasoning_effort`` none…xhigh ``none`` +DeepSeek ``reasoning_effort`` / low/high/max ON, high + ``thinking: {type}`` (medium→high) +Zhipu GLM 5.2+ ``reasoning_effort`` low/high/max ``max`` +Moonshot Kimi 3 ``reasoning_effort`` low/high/max ``max`` +MiniMax M3 ``thinking: {type}`` adaptive/disabled ON via + OpenAI, + OFF via + Anthropic +DashScope ``enable_thinking``, on/off per model + ``thinking_budget`` (1..32768) +ModelScope ``enable_thinking`` (gateway) / on/off per model + ``chat_template_kwargs`` +OpenRouter ``reasoning: {effort|max_tokens}`` low/medium/high inferred +Anthropic ``output_config.effort`` + low…max high + ``thinking: {type: adaptive}`` +=============== ================================== ================== ========= + +Three things follow from that table, and they are the whole design: + +1. ``reasoning_effort`` is the de-facto standard. It is the name callers use + here, so anyone who knows one vendor already knows this one — and it survives + the OpenAI SDK's signature filter, unlike an invented name. + +2. The on/off switch is being retired. GLM-5.3 and Kimi K3 cannot stop thinking + at all (GLM-5.3 *fails* the request if you send the old + ``thinking: {type: disabled}``), and Anthropic deprecated + ``enabled + budget_tokens`` in favour of an effort level. So "off" is modelled + as the weakest rung of the ladder, the way OpenAI models it with ``none``. + +3. Defaults are per-MODEL and they move. On DashScope alone, qwen3.5 and later + default thinking ON while qwen-plus/turbo/flash and qwen3-max default it OFF; + MiniMax M3 defaults it ON through the OpenAI-compatible API and OFF through + the Anthropic-compatible one — same model. Any table of defaults we wrote + would be wrong within a release. So we do not write one: ``auto`` sends + NOTHING and inherits whatever the vendor tuned, and the lowering table below + is consulted ONLY when a caller asked for a specific tier. A bug in it can + then only affect someone who explicitly configured thinking, who will see it + immediately — rather than silently changing every request. """ from __future__ import annotations -from typing import Any, Dict +from typing import Any, Dict, Optional, Tuple +from urllib.parse import urlparse + +#: Wire keys that carry a thinking request, in any vendor's spelling. Used both +#: to strip a request back down and to recognise a refusal. +THINKING_PARAM_KEYS = ('enable_thinking', 'thinking_budget', 'thinking', + 'reasoning_effort', 'reasoning') -THINKING_PARAM_KEYS = ('enable_thinking', 'thinking_budget', 'thinking') +#: The canonical knob callers set, in ``generation_config``. +EFFORT_KEY = 'reasoning_effort' + +#: Canonical ladder, weakest to strongest. ``auto`` is not a rung — it means +#: "no opinion", which is the default and is never sent anywhere. +EFFORT_TIERS = ('off', 'low', 'medium', 'high', 'max') + +#: Ranks for clamping a requested tier onto what an endpoint accepts. Gaps leave +#: room for future rungs (a ``minimal: 15``) without renumbering. +EFFORT_RANKS = {'off': 0, 'low': 20, 'medium': 30, 'high': 40, 'max': 70} + +_EFFORT_ALIASES = { + 'none': 'off', + 'disabled': 'off', + 'disable': 'off', + 'false': 'off', + 'no': 'off', + 'med': 'medium', + 'xhigh': 'max', + 'extrahigh': 'max', + 'maximum': 'max', + 'true': 'high', + 'on': 'high', + 'enabled': 'high', +} #: ``(base_url, model)`` pairs observed to refuse the thinking parameters. MODELS_REFUSING_THINKING: set = set() +# --------------------------------------------------------------------------- # +# Endpoint families +# --------------------------------------------------------------------------- # +# Keyed on the endpoint HOST, not the model name. Hosts are stable — a vendor +# ships new model names every few weeks but keeps the same API surface, and two +# earlier attempts at a model-name table were both wrong within days. + +#: host substring -> family name. +_HOST_FAMILIES = ( + ('dashscope.aliyuncs.com', 'dashscope'), + ('maas.aliyuncs.com', 'dashscope'), # ATokenPlan et al. speak DashScope + ('api-inference.modelscope.cn', 'modelscope'), + ('api.deepseek.com', 'deepseek'), + ('open.bigmodel.cn', 'zhipu'), + ('bigmodel.cn', 'zhipu'), + ('z.ai', 'zhipu'), + ('api.moonshot.cn', 'moonshot'), + ('platform.kimi.ai', 'moonshot'), + ('api.minimax', 'minimax'), + ('openrouter.ai', 'openrouter'), + ('api.openai.com', 'openai'), +) + + +def endpoint_family(base_url: str, protocol: str = '') -> str: + """Which dialect of "thinking" this endpoint speaks. + + ``anthropic`` wins over the host: a vendor's Anthropic-compatible gateway + (DeepSeek serves one at ``api.deepseek.com/anthropic``) takes Messages-API + shapes, not its own OpenAI ones. + """ + if (protocol or '').lower() == 'anthropic': + return 'anthropic' + host = (urlparse(base_url or '').hostname or str(base_url or '')).lower() + for needle, family in _HOST_FAMILIES: + if needle in host: + return family + return 'unknown' + + +#: Tiers each family actually accepts, weakest to strongest. A request outside +#: the set is clamped (see ``clamp_effort``). +_FAMILY_TIERS = { + # On/off only: the flag is a boolean, so every "how hard" collapses to "on". + 'dashscope': ('off', 'high'), + 'modelscope': ('off', 'high'), + 'minimax': ('off', 'high'), + 'anthropic': ('off', 'high'), + # Real ladders. deepseek/zhipu can be switched off, just not through the + # effort field (their tiers are low/high/max) — see lower_effort. + 'deepseek': ('off', 'low', 'high', 'max'), + 'zhipu': ('off', 'low', 'high', 'max'), + # Kimi K3 always thinks, so "off" is deliberately absent and clamps up to + # the floor tier rather than sending a switch the model does not have. + 'moonshot': ('low', 'high', 'max'), + 'openrouter': ('off', 'low', 'medium', 'high'), + 'openai': ('off', 'low', 'medium', 'high', 'max'), + 'unknown': ('off', 'low', 'medium', 'high', 'max'), +} + +#: Extra raw keys a family understands, surfaced to users as an example of what +#: they may add by hand. We never send these ourselves — their defaults are +#: vendor-tuned and would be one more thing to keep in sync. +FAMILY_EXTRA_HINTS = { + 'dashscope': 'thinking_budget (1-32768)', + 'modelscope': 'chat_template_kwargs', + 'openrouter': 'reasoning.max_tokens', + 'anthropic': 'thinking_budget', +} + + +def normalize_effort(raw: Any) -> Optional[str]: + """Free-form input -> a canonical tier, ``'auto'``, or ``None`` if garbage. + + Booleans are accepted because that is what the old ``enable_thinking`` + spelling used, and some config files carry it through as a YAML bool. + """ + if raw is None: + return 'auto' + if isinstance(raw, bool): + return 'high' if raw else 'off' + if not isinstance(raw, str): + return None + key = raw.strip().lower().replace('-', '').replace('_', '').replace(' ', '') + if key in ('', 'auto', 'default', 'inherit'): + return 'auto' + key = _EFFORT_ALIASES.get(key, key) + return key if key in EFFORT_TIERS else None + + +def clamp_effort(tier: str, supported: Tuple[str, ...]) -> str: + """Nearest tier the endpoint accepts, preferring the next STRONGER one. + + Asking for more than a model offers should cap at its ceiling rather than + fail; asking for less than it offers (``off`` on Kimi K3, which always + thinks) should land on its floor rather than be silently dropped. + """ + if tier in supported: + return tier + want = EFFORT_RANKS[tier] + ranked = sorted(supported, key=lambda t: EFFORT_RANKS[t]) + for candidate in ranked: + if EFFORT_RANKS[candidate] >= want: + return candidate + return ranked[-1] + + +def _merge_extra_body(params: Dict[str, Any], extra: Dict[str, Any]) -> None: + body = dict(params.get('extra_body') or {}) + body.update(extra) + params['extra_body'] = body + + +def lower_effort(tier: str, family: str) -> Dict[str, Any]: + """The wire parameters that express ``tier`` on ``family``. + + ``tier`` must already be clamped to what the family supports. + """ + params: Dict[str, Any] = {} + if family in ('dashscope', 'modelscope', 'anthropic'): + # Boolean switch. On the Anthropic transport this is read back out and + # turned into the Messages-API `thinking` block. + _merge_extra_body(params, {'enable_thinking': tier != 'off'}) + elif family == 'minimax': + # `adaptive` rather than `enabled`: M3 decides per request whether the + # reasoning is worth it, which is what "on" should mean for an agent. + _merge_extra_body( + params, + {'thinking': { + 'type': 'disabled' if tier == 'off' else 'adaptive' + }}) + elif family in ('deepseek', 'zhipu'): + if tier == 'off': + # Their `reasoning_effort` has no "none" rung; the shape that turns + # thinking off is the `thinking` object. + _merge_extra_body(params, {'thinking': {'type': 'disabled'}}) + else: + params[EFFORT_KEY] = tier + elif family == 'moonshot': + params[EFFORT_KEY] = tier # 'off' was clamped up to 'low' already + elif family == 'openrouter': + # OpenRouter's own unified object; `enabled: false` is how it says off. + _merge_extra_body( + params, {'reasoning': { + 'enabled': False + } if tier == 'off' else { + 'effort': tier + }}) + else: # openai, unknown + params[EFFORT_KEY] = 'none' if tier == 'off' else tier + return params + + +def auto_params(family: str) -> Dict[str, Any]: + """What ``auto`` sends. Almost always nothing — see the module docstring. + + Two endpoints get an explicit ``true`` anyway: + + * ``anthropic`` — our Messages transport has no way to say "no opinion": it + always writes a ``thinking`` block, and absent means ``disabled``. Claude + would then never think. + * ``dashscope`` — its older commercial hybrids (qwen-plus, qwen-turbo, + qwen-flash, qwen3-max) default thinking OFF, and those are exactly the + cheap models people leave selected. Newer qwen3.5+ default it on, where + the flag is a redundant no-op (probed: 258 vs 276 characters of reasoning + with and without it). + """ + if family in ('anthropic', 'dashscope'): + return {'extra_body': {'enable_thinking': True}} + return {} + + +def plan(effort: Any, *, base_url: str = '', protocol: str = '') -> dict: + """Resolve a canonical effort into a wire plan, without sending anything. + + Returns ``{'family', 'requested', 'effective', 'params', 'extra_hint'}``. + ``effective`` is the clamped tier, or ``'auto'``. Shared by the transports + and by the WebUI, so what the settings page shows is what actually ships. + """ + family = endpoint_family(base_url, protocol) + requested = normalize_effort(effort) + if requested is None: + requested = 'auto' + if requested == 'auto': + return { + 'family': family, + 'requested': 'auto', + 'effective': 'auto', + 'params': auto_params(family), + 'extra_hint': FAMILY_EXTRA_HINTS.get(family, ''), + } + effective = clamp_effort(requested, _FAMILY_TIERS.get(family, + _FAMILY_TIERS['unknown'])) + return { + 'family': family, + 'requested': requested, + 'effective': effective, + 'params': lower_effort(effective, family), + 'extra_hint': FAMILY_EXTRA_HINTS.get(family, ''), + } + + +def apply_effort(kwargs: Dict[str, Any], *, base_url: str, + protocol: str = '') -> Dict[str, Any]: + """Replace the canonical knob in ``kwargs`` with this endpoint's wire shape. + + Runs even when no knob was set, because "unset" IS ``auto`` and on two + endpoints auto has something to say (see :func:`auto_params`) — an absent + key must not mean a different thing from an explicit ``auto``. + + The canonical key is always removed, so a value like ``auto`` or ``off`` + never reaches a vendor that would reject it. Anything the caller set by hand + wins: raw wire keys already present are left exactly as they are, because + the raw form is the escape hatch and a user who reached for it means it. + """ + effort = kwargs.get(EFFORT_KEY, 'auto') + resolved = plan(effort, base_url=base_url, protocol=protocol) + if EFFORT_KEY not in kwargs and not resolved['params']: + return kwargs # nothing to say and nothing to strip + out = dict(kwargs) + out.pop(EFFORT_KEY, None) + existing_extra = out.get('extra_body') or {} + for key, value in resolved['params'].items(): + if key == 'extra_body': + for sub_key, sub_value in value.items(): + if sub_key not in existing_extra: + _merge_extra_body(out, {sub_key: sub_value}) + elif key not in out: + out[key] = value + return out + + +# --------------------------------------------------------------------------- # +# Refusal fallback +# --------------------------------------------------------------------------- # + + def model_key(client: Any, model: str) -> tuple: return (str(getattr(client, 'base_url', '')), model) @@ -47,8 +345,9 @@ def without_thinking(kwargs: Dict[str, Any]) -> Dict[str, Any]: Dropping the flag is not enough: some models default it ON and then refuse the call ("parameter.enable_thinking must be set to false for non-stream - call" — qwen3-8b). So the budget keys go away and ``enable_thinking`` is - pinned to False, inside ``extra_body`` when that is where it came from. + call" — qwen3-8b). So every other thinking spelling goes away and + ``enable_thinking`` is pinned to False, inside ``extra_body`` when that is + where it came from. Returns the SAME object when the request carried no thinking parameter at all, so callers can tell "we never asked for thinking" (a 400 that is diff --git a/ms_agent/llm/transport/anthropic_messages.py b/ms_agent/llm/transport/anthropic_messages.py index 6af1b2848..55035db95 100644 --- a/ms_agent/llm/transport/anthropic_messages.py +++ b/ms_agent/llm/transport/anthropic_messages.py @@ -15,6 +15,7 @@ import json from typing import Any, Dict, Generator, Iterator, List, Optional, Union +from ms_agent.llm.thinking import apply_effort from ms_agent.llm.transport.base import Transport from ms_agent.llm.utils import Message, Tool, ToolCall from ms_agent.utils import assert_package_exist @@ -169,6 +170,11 @@ def _call_llm(self, system = formatted_messages[0]['content'] formatted_messages = formatted_messages[1:] + # The canonical `reasoning_effort` becomes extra_body.enable_thinking on + # this protocol (see llm/thinking.py); resolve it before reading that + # flag, and so an unknown key never reaches the Messages API through the + # `params.update(kwargs)` below. + kwargs = apply_effort(kwargs, base_url='', protocol='anthropic') max_tokens = kwargs.pop('max_tokens', 16000) extra_body = kwargs.get('extra_body', {}) enable_thinking = extra_body.get('enable_thinking', False) @@ -204,6 +210,11 @@ def generate( args.update(kwargs) stream = args.pop('stream', False) + # Before the signature filter: `reasoning_effort` is not a Messages API + # parameter, so filtering first would drop the knob instead of lowering + # it into this protocol's `thinking` block. + args = apply_effort(args, base_url='', protocol='anthropic') + sig_params = inspect.signature(self.client.messages.create).parameters filtered_args = {k: v for k, v in args.items() if k in sig_params} diff --git a/ms_agent/llm/transport/openai_compat.py b/ms_agent/llm/transport/openai_compat.py index ea06def46..76b697614 100644 --- a/ms_agent/llm/transport/openai_compat.py +++ b/ms_agent/llm/transport/openai_compat.py @@ -22,7 +22,7 @@ from copy import deepcopy from typing import Any, Dict, Generator, Iterable, List, Optional, Union -from ms_agent.llm.thinking import create_with_thinking_fallback +from ms_agent.llm.thinking import apply_effort, create_with_thinking_fallback from ms_agent.llm.transport.base import Transport from ms_agent.llm.utils import Message, Tool, ToolCall from ms_agent.utils import MAX_CONTINUE_RUNS, assert_package_exist, get_logger @@ -249,6 +249,9 @@ def generate( args = self.args.copy() args.update(kwargs) stream = args.get('stream', False) + # Lower the canonical knob before the signature filter, so continuation + # calls reuse the resolved wire params rather than re-resolving. + args = apply_effort(args, base_url=self.base_url) args = {key: value for key, value in args.items() if key in parameters} # Format tools once and thread the formatted list through the @@ -340,7 +343,12 @@ def _call_llm(self, stream_options_config = self.args.get('stream_options', {}) if is_streaming and stream_options_config.get('include_usage', True): kwargs.setdefault('stream_options', {})['include_usage'] = True - # Thinking is per-model and a refusal is a hard 400 (see llm/thinking.py). + # `reasoning_effort` is the one knob callers set; each endpoint spells it + # differently, so lower it here — as late as possible, when the base_url + # that decides the spelling is known. Thinking is also per-model and a + # refusal is a hard 400. Both live in llm/thinking.py. + kwargs = apply_effort( + kwargs, base_url=str(getattr(self.client, 'base_url', ''))) return create_with_thinking_fallback( lambda **kw: self.client.chat.completions.create( model=self.model, messages=messages, tools=tools, **kw), diff --git a/tests/llm/test_thinking_effort.py b/tests/llm/test_thinking_effort.py new file mode 100644 index 000000000..485c5b851 --- /dev/null +++ b/tests/llm/test_thinking_effort.py @@ -0,0 +1,275 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""One knob (`reasoning_effort`) lowered onto each endpoint's own spelling. + +The tiers and wire shapes asserted here come from the vendors' own docs, checked +2026-08-17; the module docstring of ``ms_agent/llm/thinking.py`` has the table. +""" +import pytest + +from ms_agent.llm import thinking as T + + +# --------------------------------------------------------------------------- # +# Which dialect an endpoint speaks +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + 'base_url,expected', + [ + ('https://dashscope.aliyuncs.com/compatible-mode/v1', 'dashscope'), + # ATokenPlan and friends are Aliyun MaaS endpoints speaking DashScope. + ('https://token-plan.cn-beijing.maas.aliyuncs.com/compatible-mode/v1', + 'dashscope'), + ('https://api-inference.modelscope.cn/v1', 'modelscope'), + ('https://api.deepseek.com', 'deepseek'), + ('https://open.bigmodel.cn/api/paas/v4', 'zhipu'), + ('https://api.moonshot.cn/v1', 'moonshot'), + ('https://api.minimaxi.com/v1', 'minimax'), + ('https://openrouter.ai/api/v1', 'openrouter'), + ('https://api.openai.com/v1', 'openai'), + ('https://vllm.internal.corp:8000/v1', 'unknown'), + ], +) +def test_family_comes_from_the_host(base_url, expected): + assert T.endpoint_family(base_url) == expected + + +def test_anthropic_protocol_beats_the_host(): + """DeepSeek serves an Anthropic-compatible gateway on its own domain. The + body shape follows the protocol, not the vendor.""" + assert T.endpoint_family('https://api.deepseek.com/anthropic', + 'anthropic') == 'anthropic' + + +# --------------------------------------------------------------------------- # +# Canonical input +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize('raw,expected', [ + (None, 'auto'), + ('', 'auto'), + ('auto', 'auto'), + (' HIGH ', 'high'), + ('x-high', 'max'), + ('none', 'off'), + ('disabled', 'off'), + (True, 'high'), + (False, 'off'), + ('turbo', None), +]) +def test_effort_is_normalized_leniently(raw, expected): + assert T.normalize_effort(raw) == expected + + +def test_clamp_prefers_the_next_stronger_tier(): + # DeepSeek/Zhipu/Kimi expose low/high/max: a request for medium should not + # quietly become low. + assert T.clamp_effort('medium', ('low', 'high', 'max')) == 'high' + # Nothing at or above the request -> the ceiling, so "max" caps instead of + # failing. + assert T.clamp_effort('max', ('off', 'low', 'medium', 'high')) == 'high' + assert T.clamp_effort('low', ('low', 'high', 'max')) == 'low' + + +def test_off_clamps_up_on_models_that_cannot_stop_thinking(): + """Kimi K3 always thinks and GLM-5.3 fails the request if you send the old + disable shape. Asking for "off" there should land on the floor tier, not be + dropped.""" + assert T.plan('off', base_url='https://api.moonshot.cn/v1')['effective'] \ + == 'low' + + +# --------------------------------------------------------------------------- # +# Lowering +# --------------------------------------------------------------------------- # +def test_boolean_endpoints_get_a_boolean(): + got = T.plan('max', base_url='https://dashscope.aliyuncs.com/v1') + assert got['params'] == {'extra_body': {'enable_thinking': True}} + assert got['effective'] == 'high' # the ladder collapses to on/off here + + +def test_ladder_endpoints_get_a_tier(): + got = T.plan('max', base_url='https://open.bigmodel.cn/api/paas/v4') + assert got['params'] == {'reasoning_effort': 'max'} + + +def test_deepseek_off_uses_the_thinking_object_not_an_effort(): + """DeepSeek's reasoning_effort has no "none" rung — low/high/max only — so + the only way to switch thinking off is the `thinking` object.""" + got = T.plan('off', base_url='https://api.deepseek.com') + assert got['params'] == {'extra_body': {'thinking': {'type': 'disabled'}}} + + +def test_openai_off_is_the_none_tier(): + got = T.plan('off', base_url='https://api.openai.com/v1') + assert got['params'] == {'reasoning_effort': 'none'} + + +def test_minimax_on_means_adaptive(): + got = T.plan('high', base_url='https://api.minimaxi.com/v1') + assert got['params'] == { + 'extra_body': { + 'thinking': { + 'type': 'adaptive' + } + } + } + + +def test_openrouter_uses_its_own_unified_object(): + assert T.plan('low', base_url='https://openrouter.ai/api/v1')['params'] \ + == {'extra_body': {'reasoning': {'effort': 'low'}}} + assert T.plan('off', base_url='https://openrouter.ai/api/v1')['params'] \ + == {'extra_body': {'reasoning': {'enabled': False}}} + + +# --------------------------------------------------------------------------- # +# `auto` — the default, and the whole point +# --------------------------------------------------------------------------- # +def test_auto_sends_nothing_almost_everywhere(): + """Vendor defaults are per-model and they move (DashScope alone has qwen3.5+ + defaulting ON and qwen-plus defaulting OFF). Sending nothing inherits + whatever they tuned, which is the only thing that stays correct for free.""" + for base_url in ('https://api-inference.modelscope.cn/v1', + 'https://api.deepseek.com', + 'https://open.bigmodel.cn/api/paas/v4', + 'https://api.openai.com/v1', + 'https://vllm.internal.corp:8000/v1'): + assert T.plan('auto', base_url=base_url)['params'] == {} + + +def test_auto_is_explicit_only_where_silence_would_mean_off(): + # Anthropic: our Messages transport always writes a `thinking` block and + # absent means disabled, so Claude would never think. + assert T.plan(None, base_url='', protocol='anthropic')['params'] == { + 'extra_body': { + 'enable_thinking': True + } + } + # DashScope: qwen-plus/turbo/flash and qwen3-max default thinking OFF. + assert T.plan(None, base_url='https://dashscope.aliyuncs.com/v1')[ + 'params'] == { + 'extra_body': { + 'enable_thinking': True + } + } + + +# --------------------------------------------------------------------------- # +# apply_effort: what actually reaches the client +# --------------------------------------------------------------------------- # +def test_the_canonical_key_never_reaches_the_wire(): + """`auto` and `off` are ours, not any vendor's. Leaving the key in place + would send `reasoning_effort: "auto"` to an endpoint that validates it.""" + out = T.apply_effort({'reasoning_effort': 'auto', 'temperature': 0.3}, + base_url='https://api-inference.modelscope.cn/v1') + assert out == {'temperature': 0.3} + + +def test_a_hand_written_wire_value_wins_over_the_knob(): + """extra_body is the escape hatch; someone who reached for it meant it.""" + out = T.apply_effort( + { + 'reasoning_effort': 'high', + 'extra_body': { + 'enable_thinking': False + } + }, + base_url='https://dashscope.aliyuncs.com/v1') + assert out == {'extra_body': {'enable_thinking': False}} + + +def test_unrelated_extra_body_keys_survive_lowering(): + out = T.apply_effort( + { + 'reasoning_effort': 'high', + 'extra_body': { + 'thinking_budget': 2048 + } + }, + base_url='https://dashscope.aliyuncs.com/v1') + assert out == { + 'extra_body': { + 'thinking_budget': 2048, + 'enable_thinking': True + } + } + + +def test_requests_without_the_knob_are_untouched_where_auto_is_silent(): + kwargs = {'temperature': 0.3, 'extra_body': {'enable_thinking': True}} + assert T.apply_effort(kwargs, base_url='https://x/v1') is kwargs + + +def test_an_absent_knob_means_auto_not_nothing(): + """Unset has to behave exactly like an explicit `auto`, or the two endpoints + where auto speaks up would depend on whether a caller bothered to write the + key.""" + out = T.apply_effort({'temperature': 0.3}, + base_url='https://dashscope.aliyuncs.com/v1') + assert out == { + 'temperature': 0.3, + 'extra_body': { + 'enable_thinking': True + } + } + # ...and it still yields to a hand-written wire value. + kwargs = {'extra_body': {'enable_thinking': False}} + assert T.apply_effort( + kwargs, base_url='https://dashscope.aliyuncs.com/v1') == kwargs + + +def test_a_reasoning_effort_refusal_is_recognized(): + """The fallback used to look only for qwen-style names, so a 400 on the + modern field would not have been healed.""" + assert T.is_thinking_refusal( + RuntimeError('Error code: 400 - unknown parameter reasoning_effort')) + + +# --------------------------------------------------------------------------- # +# Through the real transports +# --------------------------------------------------------------------------- # +class _Recorder: + + def __init__(self): + self.calls = [] + + def create(self, **kwargs): + self.calls.append(kwargs) + return 'completion' + + +def _fake_client(recorder, base_url): + ns = type('NS', (), {}) + client = ns() + client.chat = ns() + client.chat.completions = recorder + client.base_url = base_url + return client + + +def test_transport_lowers_the_knob_onto_the_endpoint(): + from ms_agent.llm.transport import openai_compat as TC + + rec = _Recorder() + tr = TC.OpenAICompatTransport.__new__(TC.OpenAICompatTransport) + tr.client = _fake_client(rec, 'https://api.deepseek.com') + tr.model = 'deepseek-v4-pro' + tr.args = {} + tr._format_input_message = lambda m: m + + tr._call_llm([], None, reasoning_effort='medium') + # medium is not a DeepSeek tier; it clamps up to high rather than down. + assert rec.calls[0]['reasoning_effort'] == 'high' + assert 'extra_body' not in rec.calls[0] + + +def test_the_knob_is_not_an_anthropic_parameter(): + """Why the Anthropic transport has to lower BEFORE its signature filter: + `reasoning_effort` is not a Messages API argument, so filtering first would + silently drop the knob instead of turning it into a `thinking` block.""" + import inspect + + anthropic = pytest.importorskip('anthropic') + params = inspect.signature( + anthropic.Anthropic(api_key='x').messages.create).parameters + assert 'reasoning_effort' not in params + assert 'extra_body' in params # ...but the shape we lower into survives From e8a910cb26a472031ab4aa2787a571f4af37205d Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Mon, 17 Aug 2026 20:42:30 +0800 Subject: [PATCH 23/36] Send both thinking knobs on DashScope, where the switch and the effort do different jobs --- ms_agent/llm/thinking.py | 93 +++++++++++++++++++++++++---- tests/llm/test_thinking_effort.py | 97 ++++++++++++++++++++++++++++++- 2 files changed, 179 insertions(+), 11 deletions(-) diff --git a/ms_agent/llm/thinking.py b/ms_agent/llm/thinking.py index acc4f1131..2d4c7795c 100644 --- a/ms_agent/llm/thinking.py +++ b/ms_agent/llm/thinking.py @@ -17,8 +17,10 @@ OpenAI, OFF via Anthropic -DashScope ``enable_thinking``, on/off per model - ``thinking_budget`` (1..32768) +DashScope ``enable_thinking`` AND none…xhigh per model + ``reasoning_effort``; plus (no ``max``) + ``thinking_budget``, which the + effort field CANNOT travel with ModelScope ``enable_thinking`` (gateway) / on/off per model ``chat_template_kwargs`` OpenRouter ``reasoning: {effort|max_tokens}`` low/medium/high inferred @@ -131,8 +133,11 @@ def endpoint_family(base_url: str, protocol: str = '') -> str: #: Tiers each family actually accepts, weakest to strongest. A request outside #: the set is clamped (see ``clamp_effort``). _FAMILY_TIERS = { + # DashScope takes a real ladder — probed 2026-08-17 on qwen3.8-max, whose + # reasoning length is strictly monotonic in it: none 0, minimal 54, low 78, + # medium 152, high 222, xhigh 393 characters. + 'dashscope': ('off', 'low', 'medium', 'high', 'max'), # On/off only: the flag is a boolean, so every "how hard" collapses to "on". - 'dashscope': ('off', 'high'), 'modelscope': ('off', 'high'), 'minimax': ('off', 'high'), 'anthropic': ('off', 'high'), @@ -148,6 +153,17 @@ def endpoint_family(base_url: str, protocol: str = '') -> str: 'unknown': ('off', 'low', 'medium', 'high', 'max'), } +#: Canonical tier -> the string this family actually spells it with. Only for +#: the rungs whose names differ; everything else goes out as-is. +_FAMILY_WIRE_EFFORT = { + # DashScope's ceiling is `xhigh`; `max` is REJECTED (qwen3.7-plus answers + # 400 "'reasoning_effort' must be one of: 'none', 'minimal', 'low', + # 'medium', ..."), while `xhigh` is accepted by every qwen3.7/3.8 probed. + 'dashscope': { + 'max': 'xhigh' + }, +} + #: Extra raw keys a family understands, surfaced to users as an example of what #: they may add by hand. We never send these ourselves — their defaults are #: vendor-tuned and would be one more thing to keep in sync. @@ -158,6 +174,12 @@ def endpoint_family(base_url: str, protocol: str = '') -> str: 'anthropic': 'thinking_budget', } +#: Raw keys that cannot travel with our lowered ``reasoning_effort``. DashScope +#: rejects the pair outright ("'reasoning_effort' and 'thinking_budget' cannot +#: be set simultaneously") — and ``thinking_budget`` is precisely what we invite +#: people to add by hand above, so the two suggestions would collide. +_EFFORT_CONFLICTS = ('thinking_budget', ) + def normalize_effort(raw: Any) -> Optional[str]: """Free-form input -> a canonical tier, ``'auto'``, or ``None`` if garbage. @@ -207,7 +229,19 @@ def lower_effort(tier: str, family: str) -> Dict[str, Any]: ``tier`` must already be clamped to what the family supports. """ params: Dict[str, Any] = {} - if family in ('dashscope', 'modelscope', 'anthropic'): + if family == 'dashscope': + # BOTH knobs, because they do different jobs and only one of them is + # universal. `enable_thinking` is what actually turns thinking on for + # the models that default it off (qwen-plus: 0 characters of reasoning + # with `reasoning_effort: high` alone, 543 with the flag), while + # `reasoning_effort` is what sets the depth on the models that honour + # it (qwen3.8-max, and DeepSeek/GLM/Kimi served on this host). Models + # that only understand one of the two ignore the other. + _merge_extra_body(params, {'enable_thinking': tier != 'off'}) + if tier != 'off': + params[EFFORT_KEY] = _FAMILY_WIRE_EFFORT['dashscope'].get( + tier, tier) + elif family in ('modelscope', 'anthropic'): # Boolean switch. On the Anthropic transport this is read back out and # turned into the Messages-API `thinking` block. _merge_extra_body(params, {'enable_thinking': tier != 'off'}) @@ -260,12 +294,47 @@ def auto_params(family: str) -> Dict[str, Any]: return {} -def plan(effort: Any, *, base_url: str = '', protocol: str = '') -> dict: +def _drop_conflicts(params: Dict[str, Any], + existing: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Yield to whatever the caller wrote by hand. + + Two degrees of yielding, because the raw keys mean different things: + + * A **switch** key (``enable_thinking``, ``thinking``, ``reasoning``) means + the caller is driving thinking themselves, so we contribute NOTHING — + adding a tier next to their ``enable_thinking: false`` would ask for a + depth and a shutdown in the same request. + * ``thinking_budget`` is only a depth, so the switch may still go out; but + our effort must not, because DashScope rejects that exact pair + ("'reasoning_effort' and 'thinking_budget' cannot be set simultaneously") + — and ``thinking_budget`` is precisely what the settings hint invites + people to add by hand, so the two suggestions would collide. + """ + if not params or not isinstance(existing, dict): + return params + extra = existing.get('extra_body') + present = set(existing) | (set(extra) if isinstance(extra, dict) else set()) + switches = set(THINKING_PARAM_KEYS) - {EFFORT_KEY} - set(_EFFORT_CONFLICTS) + if present & switches: + return {} + if present.isdisjoint(_EFFORT_CONFLICTS): + return params + return {k: v for k, v in params.items() if k != EFFORT_KEY} + + +def plan(effort: Any, + *, + base_url: str = '', + protocol: str = '', + existing: Optional[Dict[str, Any]] = None) -> dict: """Resolve a canonical effort into a wire plan, without sending anything. Returns ``{'family', 'requested', 'effective', 'params', 'extra_hint'}``. - ``effective`` is the clamped tier, or ``'auto'``. Shared by the transports - and by the WebUI, so what the settings page shows is what actually ships. + ``effective`` is the clamped tier, or ``'auto'``. ``existing`` is the + request (or stored params) the plan will be merged into, so conflicting raw + keys are honoured here rather than discovered on the wire. Shared by the + transports and by the WebUI, so what the settings page shows is what + actually ships. """ family = endpoint_family(base_url, protocol) requested = normalize_effort(effort) @@ -276,7 +345,7 @@ def plan(effort: Any, *, base_url: str = '', protocol: str = '') -> dict: 'family': family, 'requested': 'auto', 'effective': 'auto', - 'params': auto_params(family), + 'params': _drop_conflicts(auto_params(family), existing), 'extra_hint': FAMILY_EXTRA_HINTS.get(family, ''), } effective = clamp_effort(requested, _FAMILY_TIERS.get(family, @@ -285,7 +354,7 @@ def plan(effort: Any, *, base_url: str = '', protocol: str = '') -> dict: 'family': family, 'requested': requested, 'effective': effective, - 'params': lower_effort(effective, family), + 'params': _drop_conflicts(lower_effort(effective, family), existing), 'extra_hint': FAMILY_EXTRA_HINTS.get(family, ''), } @@ -304,7 +373,11 @@ def apply_effort(kwargs: Dict[str, Any], *, base_url: str, the raw form is the escape hatch and a user who reached for it means it. """ effort = kwargs.get(EFFORT_KEY, 'auto') - resolved = plan(effort, base_url=base_url, protocol=protocol) + resolved = plan(effort, + base_url=base_url, + protocol=protocol, + existing={k: v + for k, v in kwargs.items() if k != EFFORT_KEY}) if EFFORT_KEY not in kwargs and not resolved['params']: return kwargs # nothing to say and nothing to strip out = dict(kwargs) diff --git a/tests/llm/test_thinking_effort.py b/tests/llm/test_thinking_effort.py index 485c5b851..9b61a0c74 100644 --- a/tests/llm/test_thinking_effort.py +++ b/tests/llm/test_thinking_effort.py @@ -81,11 +81,69 @@ def test_off_clamps_up_on_models_that_cannot_stop_thinking(): # Lowering # --------------------------------------------------------------------------- # def test_boolean_endpoints_get_a_boolean(): - got = T.plan('max', base_url='https://dashscope.aliyuncs.com/v1') + got = T.plan('max', base_url='https://api-inference.modelscope.cn/v1') assert got['params'] == {'extra_body': {'enable_thinking': True}} assert got['effective'] == 'high' # the ladder collapses to on/off here +def test_dashscope_gets_both_knobs_because_they_do_different_jobs(): + """Probed 2026-08-17: `reasoning_effort: high` ALONE leaves qwen-plus at + zero reasoning — only `enable_thinking` turns thinking on there — while + `reasoning_effort` is what actually sets depth on qwen3.8-max (none 0 → + low 78 → high 222 → xhigh 393 characters). Sending one without the other + silently loses half the control.""" + got = T.plan('low', base_url='https://dashscope.aliyuncs.com/v1') + assert got['params'] == { + 'extra_body': { + 'enable_thinking': True + }, + 'reasoning_effort': 'low', + } + + +def test_dashscope_max_is_spelled_xhigh(): + """`max` is rejected outright — qwen3.7-plus answers 400 listing the valid + set — while `xhigh` is accepted by every qwen3.7/3.8 probed.""" + got = T.plan('max', base_url='https://dashscope.aliyuncs.com/v1') + assert got['params']['reasoning_effort'] == 'xhigh' + assert got['effective'] == 'max' + + +def test_dashscope_off_stays_a_plain_boolean(): + """The models that refuse every effort value (qwen-vl-*) still accept + `enable_thinking: false`, so "off" must not go out as an effort tier.""" + got = T.plan('off', base_url='https://dashscope.aliyuncs.com/v1') + assert got['params'] == {'extra_body': {'enable_thinking': False}} + + +def test_a_hand_written_thinking_budget_suppresses_our_effort(): + """DashScope 400s on the pair ("'reasoning_effort' and 'thinking_budget' + cannot be set simultaneously") — and thinking_budget is exactly what the + settings hint invites people to add, so the two suggestions would collide. + The hand-written key wins; only our effort is dropped, not the switch.""" + out = T.apply_effort( + { + 'reasoning_effort': 'high', + 'extra_body': { + 'thinking_budget': 2048 + } + }, + base_url='https://dashscope.aliyuncs.com/v1') + assert out == { + 'extra_body': { + 'thinking_budget': 2048, + 'enable_thinking': True + } + } + # The preview the settings page renders has to agree with that. + shown = T.plan('high', + base_url='https://dashscope.aliyuncs.com/v1', + existing={'extra_body': { + 'thinking_budget': 2048 + }}) + assert 'reasoning_effort' not in shown['params'] + + def test_ladder_endpoints_get_a_tier(): got = T.plan('max', base_url='https://open.bigmodel.cn/api/paas/v4') assert got['params'] == {'reasoning_effort': 'max'} @@ -273,3 +331,40 @@ def test_the_knob_is_not_an_anthropic_parameter(): anthropic.Anthropic(api_key='x').messages.create).parameters assert 'reasoning_effort' not in params assert 'extra_body' in params # ...but the shape we lower into survives + + +def test_a_refused_tier_falls_all_the_way_back(): + """qwen-vl-max rejects every effort value (DashScope converts the tier into + a thinking_budget, which that model has no room for). The fallback has to + strip the tier as well as the switch, or the retry repeats the 400.""" + from ms_agent.llm.transport import openai_compat as TC + + class _Refuser(_Recorder): + + def create(self, **kwargs): + self.calls.append(kwargs) + asked = (kwargs.get('reasoning_effort') + or (kwargs.get('extra_body') or {}).get('enable_thinking')) + if asked: + raise RuntimeError( + 'Error code: 400 - The thinking_budget parameter must be a ' + 'positive integer and not greater than 0') + return 'completion' + + rec = _Refuser() + tr = TC.OpenAICompatTransport.__new__(TC.OpenAICompatTransport) + tr.client = _fake_client(rec, 'https://dashscope.aliyuncs.com/v1') + tr.model = 'qwen-vl-max' + tr.args = {} + tr._format_input_message = lambda m: m + + T.MODELS_REFUSING_THINKING.clear() + try: + out = tr._call_llm([], None, reasoning_effort='high') + finally: + T.MODELS_REFUSING_THINKING.clear() + + assert out == 'completion' + assert rec.calls[0]['reasoning_effort'] == 'high' + assert 'reasoning_effort' not in rec.calls[1] + assert rec.calls[1]['extra_body'] == {'enable_thinking': False} From 582f0acc997c121ac27f5d7b7699a920e62b6ba5 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Mon, 17 Aug 2026 22:39:37 +0800 Subject: [PATCH 24/36] Lower the thinking knob once per request, repair mandatory-thinking forwards, and read OpenRouter's reasoning field --- ms_agent/llm/thinking.py | 101 ++++++++++++++- ms_agent/llm/transport/anthropic_messages.py | 8 +- ms_agent/llm/transport/openai_compat.py | 35 +++-- tests/llm/test_thinking_effort.py | 127 +++++++++++++++++++ 4 files changed, 252 insertions(+), 19 deletions(-) diff --git a/ms_agent/llm/thinking.py b/ms_agent/llm/thinking.py index 2d4c7795c..49a00c328 100644 --- a/ms_agent/llm/thinking.py +++ b/ms_agent/llm/thinking.py @@ -89,6 +89,13 @@ #: ``(base_url, model)`` pairs observed to refuse the thinking parameters. MODELS_REFUSING_THINKING: set = set() +#: ``(base_url, model)`` pairs that refuse to STOP thinking. The opposite +#: complaint, and it needs the opposite repair — OpenRouter answers +#: "Reasoning is mandatory for this endpoint and cannot be disabled" for +#: x-ai/grok-4.5, and healing that by forcing thinking off (the other set's +#: repair) both misses the point and poisons every later request for the model. +MODELS_REQUIRING_THINKING: set = set() + # --------------------------------------------------------------------------- # # Endpoint families @@ -402,17 +409,87 @@ def model_key(client: Any, model: str) -> tuple: return (str(getattr(client, 'base_url', '')), model) -def is_thinking_refusal(exc: Exception) -> bool: - """A 400 that names the thinking parameters — not any other bad request.""" +def _is_bad_request(exc: Exception) -> bool: status = getattr(exc, 'status_code', None) if status is not None and status != 400: return False - text = str(exc).lower() - if status != 400 and '400' not in text: + return status == 400 or '400' in str(exc) + + +#: Phrases an endpoint uses to say thinking is not optional here. +_MANDATORY_MARKERS = ('mandatory', 'cannot be disabled', 'can not be disabled', + 'must be enabled', 'cannot be turned off') + + +def is_thinking_refusal(exc: Exception) -> bool: + """A 400 that names the thinking parameters — not any other bad request.""" + if not _is_bad_request(exc): return False + text = str(exc).lower() return any(k in text for k in THINKING_PARAM_KEYS) +def is_thinking_mandatory(exc: Exception) -> bool: + """A 400 complaining that thinking may not be switched OFF. + + Checked before :func:`is_thinking_refusal`, which it would otherwise match + (the message names ``reasoning``) and be repaired backwards. + """ + if not _is_bad_request(exc): + return False + text = str(exc).lower() + if not any(k in text for k in THINKING_PARAM_KEYS): + return False + return any(marker in text for marker in _MANDATORY_MARKERS) + + +def asks_to_disable(kwargs: Dict[str, Any]) -> bool: + """Whether this request is telling the model NOT to think. + + Every family spells "off" differently (see :func:`lower_effort`), and a + model that merely refuses to be switched off must still be allowed to + receive a positive tier — so the memo has to know which kind of request it + is looking at rather than blanking them all. + """ + extra = kwargs.get('extra_body') + extra = extra if isinstance(extra, dict) else {} + if extra.get('enable_thinking') is False or kwargs.get( + 'enable_thinking') is False: + return True + for source in (extra, kwargs): + thinking = source.get('thinking') + if isinstance(thinking, dict) and thinking.get('type') == 'disabled': + return True + reasoning = source.get('reasoning') + if isinstance(reasoning, dict) and reasoning.get('enabled') is False: + return True + return kwargs.get(EFFORT_KEY) in ('none', 'off') + + +def strip_thinking(kwargs: Dict[str, Any]) -> Dict[str, Any]: + """``kwargs`` with every thinking parameter REMOVED, saying nothing at all. + + The repair for an endpoint that insists on thinking: stop asking it to + stop. Returns the SAME object when there was nothing to strip. + """ + extra = kwargs.get('extra_body') + in_extra = isinstance(extra, dict) and any(k in extra + for k in THINKING_PARAM_KEYS) + if not in_extra and not any(k in kwargs for k in THINKING_PARAM_KEYS): + return kwargs + cleaned = {k: v for k, v in kwargs.items() if k not in THINKING_PARAM_KEYS} + if in_extra: + pruned = { + k: v + for k, v in extra.items() if k not in THINKING_PARAM_KEYS + } + if pruned: + cleaned['extra_body'] = pruned + else: + cleaned.pop('extra_body', None) + return cleaned + + def without_thinking(kwargs: Dict[str, Any]) -> Dict[str, Any]: """``kwargs`` with thinking turned OFF explicitly, not merely removed. @@ -458,9 +535,25 @@ def create_with_thinking_fallback(create, client, model: str, logger, key = model_key(client, model) if key in MODELS_REFUSING_THINKING: kwargs = without_thinking(kwargs) + elif key in MODELS_REQUIRING_THINKING and asks_to_disable(kwargs): + # Only the "off" request is doomed here; a positive tier still goes out + # normally, so this model is not blacklisted the way a refuser is. + kwargs = strip_thinking(kwargs) try: return create(**kwargs) except Exception as e: + # Order matters: "reasoning is mandatory" also names a thinking + # parameter, so refusal would claim it and repair it backwards. + if is_thinking_mandatory(e): + retry_kwargs = strip_thinking(kwargs) + if retry_kwargs is kwargs: + raise + MODELS_REQUIRING_THINKING.add(key) + logger.warning( + f'{model} does not allow thinking to be switched off; ' + f'retrying without any thinking parameter (it stays that way ' + f'for this model): {e}') + return create(**retry_kwargs) if not is_thinking_refusal(e): raise retry_kwargs = without_thinking(kwargs) diff --git a/ms_agent/llm/transport/anthropic_messages.py b/ms_agent/llm/transport/anthropic_messages.py index 55035db95..59caaedf0 100644 --- a/ms_agent/llm/transport/anthropic_messages.py +++ b/ms_agent/llm/transport/anthropic_messages.py @@ -170,11 +170,9 @@ def _call_llm(self, system = formatted_messages[0]['content'] formatted_messages = formatted_messages[1:] - # The canonical `reasoning_effort` becomes extra_body.enable_thinking on - # this protocol (see llm/thinking.py); resolve it before reading that - # flag, and so an unknown key never reaches the Messages API through the - # `params.update(kwargs)` below. - kwargs = apply_effort(kwargs, base_url='', protocol='anthropic') + # Already lowered in `generate()` — it has to happen before the + # signature filter there, and doing it twice is destructive (see the + # note in transport/openai_compat.py). max_tokens = kwargs.pop('max_tokens', 16000) extra_body = kwargs.get('extra_body', {}) enable_thinking = extra_body.get('enable_thinking', False) diff --git a/ms_agent/llm/transport/openai_compat.py b/ms_agent/llm/transport/openai_compat.py index 76b697614..baa369d43 100644 --- a/ms_agent/llm/transport/openai_compat.py +++ b/ms_agent/llm/transport/openai_compat.py @@ -29,6 +29,21 @@ logger = get_logger() +#: Field names carrying the model's reasoning, in preference order. Most +#: OpenAI-compatible vendors use ``reasoning_content`` (DashScope, ModelScope, +#: Zhipu, DeepSeek); OpenRouter normalizes everything it proxies into +#: ``reasoning`` instead, so reading only the first name made every model +#: routed through it look like it never thought. +_REASONING_FIELDS = ('reasoning_content', 'reasoning') + + +def _reasoning_of(delta_or_message: Any) -> str: + for field in _REASONING_FIELDS: + value = getattr(delta_or_message, field, None) + if value: + return value + return '' + class OpenAICompatTransport(Transport): # Fields forwarded to the API. Includes continue-gen flags (partial/prefix) @@ -249,9 +264,14 @@ def generate( args = self.args.copy() args.update(kwargs) stream = args.get('stream', False) - # Lower the canonical knob before the signature filter, so continuation - # calls reuse the resolved wire params rather than re-resolving. - args = apply_effort(args, base_url=self.base_url) + # NOT lowered here — `_call_llm` does it, exactly once per request. + # Lowering twice is destructive rather than idempotent: the canonical + # key and DashScope's wire key are both spelled `reasoning_effort`, so a + # second pass reads the `enable_thinking` the first pass just added as + # "the caller is driving thinking by hand" and stands down, deleting our + # own tier. `reasoning_effort` is a real OpenAI parameter, so it survives + # the filter below and reaches `_call_llm` intact; continuation calls + # re-lower from the same canonical value. args = {key: value for key, value in args.items() if key in parameters} # Format tools once and thread the formatted list through the @@ -519,8 +539,7 @@ def _stream_format_output_message(completion_chunk) -> Message: content = '' if completion_chunk.choices and completion_chunk.choices[0].delta: content = completion_chunk.choices[0].delta.content - reasoning_content = getattr(completion_chunk.choices[0].delta, - 'reasoning_content', '') + reasoning_content = _reasoning_of(completion_chunk.choices[0].delta) if completion_chunk.choices[0].delta.tool_calls: func = completion_chunk.choices[0].delta.tool_calls tool_calls = [ @@ -550,11 +569,7 @@ def _stream_format_output_message(completion_chunk) -> Message: @staticmethod def _format_output_message(completion) -> Message: content = completion.choices[0].message.content or '' - if hasattr(completion.choices[0].message, 'reasoning_content'): - reasoning_content = completion.choices[ - 0].message.reasoning_content or '' - else: - reasoning_content = '' + reasoning_content = _reasoning_of(completion.choices[0].message) tool_calls = None if completion.choices[0].message.tool_calls: tool_calls = [ diff --git a/tests/llm/test_thinking_effort.py b/tests/llm/test_thinking_effort.py index 9b61a0c74..12bb57345 100644 --- a/tests/llm/test_thinking_effort.py +++ b/tests/llm/test_thinking_effort.py @@ -368,3 +368,130 @@ def create(self, **kwargs): assert rec.calls[0]['reasoning_effort'] == 'high' assert 'reasoning_effort' not in rec.calls[1] assert rec.calls[1]['extra_body'] == {'enable_thinking': False} + + +# --------------------------------------------------------------------------- # +# Bugs the unit tests missed and a live matrix caught +# --------------------------------------------------------------------------- # +def test_lowering_twice_would_destroy_the_tier(): + """Documents WHY each transport lowers exactly once. + + The canonical key and DashScope's wire key are both `reasoning_effort`, so + the operation is not idempotent: a second pass reads the `enable_thinking` + the first pass added as "the caller is driving thinking by hand" and stands + down — deleting the tier we ourselves just set. Two call sites were doing + this, which silently reduced every DashScope request back to a bare switch. + """ + base = 'https://dashscope.aliyuncs.com/compatible-mode/v1' + once = T.apply_effort({'reasoning_effort': 'low'}, base_url=base) + assert once['reasoning_effort'] == 'low' + assert 'reasoning_effort' not in T.apply_effort(once, base_url=base) + + +def test_generate_sends_the_tier_all_the_way_to_the_client(): + """The end-to-end shape, through `generate()` and its signature filter — + the level the double-lowering bug lived at and a `_call_llm` test could not + see.""" + from ms_agent.llm.transport import openai_compat as TC + + class _SigRecorder(_Recorder): + """`generate()` filters kwargs against create()'s SIGNATURE, so a stub + taking bare **kwargs would drop every argument — including `stream` — + and quietly test nothing.""" + + def create(self, + *, + model=None, + messages=None, + tools=None, + stream=None, + max_tokens=None, + extra_body=None, + reasoning_effort=None, + **kw): + self.calls.append({ + 'extra_body': extra_body, + 'reasoning_effort': reasoning_effort, + 'stream': stream, + }) + return 'completion' + + rec = _SigRecorder() + tr = TC.OpenAICompatTransport.__new__(TC.OpenAICompatTransport) + tr.client = _fake_client( + rec, 'https://dashscope.aliyuncs.com/compatible-mode/v1') + tr.model = 'qwen3.8-max' + tr.args = {'reasoning_effort': 'max'} + tr.max_continue_runs = 1 + tr._strip_reasoning_tags = False + tr._format_input_message = lambda m: m + tr.format_tools = lambda t: None + + # Only what reached the client matters here; the stub cannot satisfy the + # response-shaping that follows. + try: + tr.generate([]) + except Exception: + pass + assert rec.calls[0]['reasoning_effort'] == 'xhigh' + assert rec.calls[0]['extra_body'] == {'enable_thinking': True} + + +def test_mandatory_thinking_is_repaired_forwards_not_backwards(): + """OpenRouter answers "Reasoning is mandatory for this endpoint and cannot + be disabled" for x-ai/grok-4.5. That names a thinking parameter, so the + refusal path used to claim it and "repair" it by forcing thinking OFF — + the exact opposite — and then remembered the model, degrading every later + request in the session.""" + from ms_agent.llm.transport import openai_compat as TC + + class _Mandatory(_Recorder): + + def create(self, **kwargs): + self.calls.append(kwargs) + reasoning = (kwargs.get('extra_body') or {}).get('reasoning') or {} + if reasoning.get('enabled') is False: + raise RuntimeError( + 'Error code: 400 - Reasoning is mandatory for this ' + 'endpoint and cannot be disabled.') + return 'completion' + + rec = _Mandatory() + tr = TC.OpenAICompatTransport.__new__(TC.OpenAICompatTransport) + tr.client = _fake_client(rec, 'https://openrouter.ai/api/v1') + tr.model = 'x-ai/grok-4.5' + tr.args = {} + tr._format_input_message = lambda m: m + + T.MODELS_REFUSING_THINKING.clear() + T.MODELS_REQUIRING_THINKING.clear() + try: + assert tr._call_llm([], None, reasoning_effort='off') == 'completion' + # Repaired by saying nothing, not by forcing the switch the other way. + assert 'extra_body' not in rec.calls[1] + assert 'reasoning_effort' not in rec.calls[1] + # ...and the model is not blacklisted, so a later tier still works. + assert T.model_key(tr.client, tr.model) not in T.MODELS_REFUSING_THINKING + tr._call_llm([], None, reasoning_effort='high') + assert rec.calls[2]['extra_body'] == {'reasoning': {'effort': 'high'}} + finally: + T.MODELS_REFUSING_THINKING.clear() + T.MODELS_REQUIRING_THINKING.clear() + + +def test_openrouter_style_reasoning_field_is_read(): + """OpenRouter normalizes every upstream's reasoning into `reasoning`, not + `reasoning_content`. Reading only the latter made every model proxied + through it look like it never thought.""" + from ms_agent.llm.transport.openai_compat import _reasoning_of + + ns = type('NS', (), {}) + delta = ns() + delta.reasoning = 'thought about it' + assert _reasoning_of(delta) == 'thought about it' + + both = ns() + both.reasoning_content = 'native' + both.reasoning = 'proxied' + assert _reasoning_of(both) == 'native' # the native field wins + assert _reasoning_of(ns()) == '' From 28934431998e721d284ecff204eb7f78529a7188 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Mon, 17 Aug 2026 23:19:00 +0800 Subject: [PATCH 25/36] Adopt the effort vocabulary the endpoints themselves report instead of a smaller invented one --- ms_agent/llm/thinking.py | 85 ++++++++++++++++++------------- tests/llm/test_thinking_effort.py | 28 +++++----- 2 files changed, 65 insertions(+), 48 deletions(-) diff --git a/ms_agent/llm/thinking.py b/ms_agent/llm/thinking.py index 49a00c328..ea5d0203f 100644 --- a/ms_agent/llm/thinking.py +++ b/ms_agent/llm/thinking.py @@ -65,11 +65,27 @@ #: Canonical ladder, weakest to strongest. ``auto`` is not a rung — it means #: "no opinion", which is the default and is never sent anywhere. -EFFORT_TIERS = ('off', 'low', 'medium', 'high', 'max') +#: +#: These are not our invention: every endpoint that VALIDATES the field reports +#: the same seven values (probed 2026-08-17 by sending a bogus one and reading +#: the error) — GLM-5.2 "none、minimal、low、medium、high、xhigh、max", +#: OpenRouter "max|xhigh|high|medium|low|minimal|none", DeepSeek the same list, +#: DashScope the same minus ``max``. Matching their vocabulary exactly means a +#: value the user types usually reaches the model untouched, instead of being +#: clamped onto a smaller set we made up. +EFFORT_TIERS = ('off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max') #: Ranks for clamping a requested tier onto what an endpoint accepts. Gaps leave -#: room for future rungs (a ``minimal: 15``) without renumbering. -EFFORT_RANKS = {'off': 0, 'low': 20, 'medium': 30, 'high': 40, 'max': 70} +#: room for rungs a vendor may add later without renumbering. +EFFORT_RANKS = { + 'off': 0, + 'minimal': 10, + 'low': 20, + 'medium': 30, + 'high': 40, + 'xhigh': 60, + 'max': 70, +} _EFFORT_ALIASES = { 'none': 'off', @@ -77,9 +93,9 @@ 'disable': 'off', 'false': 'off', 'no': 'off', + 'min': 'minimal', 'med': 'medium', - 'xhigh': 'max', - 'extrahigh': 'max', + 'extrahigh': 'xhigh', 'maximum': 'max', 'true': 'high', 'on': 'high', @@ -140,35 +156,25 @@ def endpoint_family(base_url: str, protocol: str = '') -> str: #: Tiers each family actually accepts, weakest to strongest. A request outside #: the set is clamped (see ``clamp_effort``). _FAMILY_TIERS = { - # DashScope takes a real ladder — probed 2026-08-17 on qwen3.8-max, whose - # reasoning length is strictly monotonic in it: none 0, minimal 54, low 78, - # medium 152, high 222, xhigh 393 characters. - 'dashscope': ('off', 'low', 'medium', 'high', 'max'), - # On/off only: the flag is a boolean, so every "how hard" collapses to "on". + # DashScope takes the ladder but REJECTS the top rung: qwen3.7-plus answers + # 400 for `max` while accepting `xhigh`, so `max` clamps down to it. The + # ladder is real there — qwen3.8-max reasoning is strictly monotonic in it + # (none 0, minimal 54, low 78, medium 152, high 222, xhigh 393 characters). + 'dashscope': ('off', 'minimal', 'low', 'medium', 'high', 'xhigh'), + # No effort field at all: the switch is a boolean, so every "how hard" + # collapses onto "on". 'modelscope': ('off', 'high'), 'minimax': ('off', 'high'), 'anthropic': ('off', 'high'), - # Real ladders. deepseek/zhipu can be switched off, just not through the - # effort field (their tiers are low/high/max) — see lower_effort. - 'deepseek': ('off', 'low', 'high', 'max'), - 'zhipu': ('off', 'low', 'high', 'max'), - # Kimi K3 always thinks, so "off" is deliberately absent and clamps up to - # the floor tier rather than sending a switch the model does not have. - 'moonshot': ('low', 'high', 'max'), - 'openrouter': ('off', 'low', 'medium', 'high'), - 'openai': ('off', 'low', 'medium', 'high', 'max'), - 'unknown': ('off', 'low', 'medium', 'high', 'max'), -} - -#: Canonical tier -> the string this family actually spells it with. Only for -#: the rungs whose names differ; everything else goes out as-is. -_FAMILY_WIRE_EFFORT = { - # DashScope's ceiling is `xhigh`; `max` is REJECTED (qwen3.7-plus answers - # 400 "'reasoning_effort' must be one of: 'none', 'minimal', 'low', - # 'medium', ..."), while `xhigh` is accepted by every qwen3.7/3.8 probed. - 'dashscope': { - 'max': 'xhigh' - }, + # Everyone else takes the whole vocabulary. Endpoints that do not validate + # it (glm-5.1, glm-5, every Kimi, MiniMax, ModelScope) ignore an unknown + # value rather than failing, so passing a tier through costs nothing. + 'deepseek': EFFORT_TIERS, + 'zhipu': EFFORT_TIERS, + 'moonshot': EFFORT_TIERS, + 'openrouter': EFFORT_TIERS, + 'openai': EFFORT_TIERS, + 'unknown': EFFORT_TIERS, } #: Extra raw keys a family understands, surfaced to users as an example of what @@ -246,8 +252,7 @@ def lower_effort(tier: str, family: str) -> Dict[str, Any]: # that only understand one of the two ignore the other. _merge_extra_body(params, {'enable_thinking': tier != 'off'}) if tier != 'off': - params[EFFORT_KEY] = _FAMILY_WIRE_EFFORT['dashscope'].get( - tier, tier) + params[EFFORT_KEY] = tier elif family in ('modelscope', 'anthropic'): # Boolean switch. On the Anthropic transport this is read back out and # turned into the Messages-API `thinking` block. @@ -262,13 +267,21 @@ def lower_effort(tier: str, family: str) -> Dict[str, Any]: }}) elif family in ('deepseek', 'zhipu'): if tier == 'off': - # Their `reasoning_effort` has no "none" rung; the shape that turns - # thinking off is the `thinking` object. + # NOT `reasoning_effort: none`, even though the newer models accept + # it: glm-5.1 and glm-5 do not validate the field and simply IGNORE + # it (probed 2026-08-17 — 896 and 986 characters of reasoning with + # `none` set). The `thinking` object is the only spelling every + # generation honours. If a model rejects it outright (GLM-5.3 no + # longer allows thinking to be disabled), the mandatory-thinking + # repair below strips the request rather than failing the turn. _merge_extra_body(params, {'thinking': {'type': 'disabled'}}) else: params[EFFORT_KEY] = tier elif family == 'moonshot': - params[EFFORT_KEY] = tier # 'off' was clamped up to 'low' already + # Kimi is the other way round: it honours `none` on both k3 and k2.6 + # (0 characters of reasoning), so the effort field alone covers the + # whole range and no second shape is needed. + params[EFFORT_KEY] = 'none' if tier == 'off' else tier elif family == 'openrouter': # OpenRouter's own unified object; `enabled: false` is how it says off. _merge_extra_body( diff --git a/tests/llm/test_thinking_effort.py b/tests/llm/test_thinking_effort.py index 12bb57345..3ca1fac8a 100644 --- a/tests/llm/test_thinking_effort.py +++ b/tests/llm/test_thinking_effort.py @@ -48,7 +48,7 @@ def test_anthropic_protocol_beats_the_host(): ('', 'auto'), ('auto', 'auto'), (' HIGH ', 'high'), - ('x-high', 'max'), + ('x-high', 'xhigh'), ('none', 'off'), ('disabled', 'off'), (True, 'high'), @@ -69,12 +69,14 @@ def test_clamp_prefers_the_next_stronger_tier(): assert T.clamp_effort('low', ('low', 'high', 'max')) == 'low' -def test_off_clamps_up_on_models_that_cannot_stop_thinking(): - """Kimi K3 always thinks and GLM-5.3 fails the request if you send the old - disable shape. Asking for "off" there should land on the floor tier, not be - dropped.""" - assert T.plan('off', base_url='https://api.moonshot.cn/v1')['effective'] \ - == 'low' +def test_kimi_can_be_switched_off_after_all(): + """The docs say Kimi K3 always thinks; the endpoint disagrees. Probed + 2026-08-17, `reasoning_effort: none` yields ZERO characters of reasoning on + both k3 and k2.6, so "off" goes out as a real value rather than being + clamped up to the floor tier.""" + got = T.plan('off', base_url='https://api.moonshot.cn/v1') + assert got['effective'] == 'off' + assert got['params'] == {'reasoning_effort': 'none'} # --------------------------------------------------------------------------- # @@ -101,12 +103,13 @@ def test_dashscope_gets_both_knobs_because_they_do_different_jobs(): } -def test_dashscope_max_is_spelled_xhigh(): +def test_dashscope_caps_at_xhigh_because_max_is_rejected(): """`max` is rejected outright — qwen3.7-plus answers 400 listing the valid - set — while `xhigh` is accepted by every qwen3.7/3.8 probed.""" + set — while `xhigh` is accepted by every qwen3.7/3.8 probed. DashScope is + the one endpoint whose vocabulary falls short of the full ladder.""" got = T.plan('max', base_url='https://dashscope.aliyuncs.com/v1') + assert got['effective'] == 'xhigh' assert got['params']['reasoning_effort'] == 'xhigh' - assert got['effective'] == 'max' def test_dashscope_off_stays_a_plain_boolean(): @@ -315,8 +318,9 @@ def test_transport_lowers_the_knob_onto_the_endpoint(): tr._format_input_message = lambda m: m tr._call_llm([], None, reasoning_effort='medium') - # medium is not a DeepSeek tier; it clamps up to high rather than down. - assert rec.calls[0]['reasoning_effort'] == 'high' + # DeepSeek reports the full vocabulary when handed a bogus value, `medium` + # included, so the tier reaches it untouched. + assert rec.calls[0]['reasoning_effort'] == 'medium' assert 'extra_body' not in rec.calls[0] From 1f26c86b30b725e83b283b7c35ff27734394cc9a Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Tue, 18 Aug 2026 02:28:01 +0800 Subject: [PATCH 26/36] Clamp a thinking tier downward, never upward, and record only what endpoints reject --- ms_agent/llm/thinking.py | 176 +++++++++++++++++++----------- tests/llm/test_thinking_effort.py | 44 +++++--- 2 files changed, 144 insertions(+), 76 deletions(-) diff --git a/ms_agent/llm/thinking.py b/ms_agent/llm/thinking.py index ea5d0203f..bd54fc59a 100644 --- a/ms_agent/llm/thinking.py +++ b/ms_agent/llm/thinking.py @@ -3,52 +3,73 @@ for models that refuse to be asked at all. Every vendor spells thinking differently and none of them spells it the same way -for long. Probed against the official docs on 2026-08-17: - -=============== ================================== ================== ========= -endpoint modern field tiers default -=============== ================================== ================== ========= -OpenAI ``reasoning_effort`` none…xhigh ``none`` -DeepSeek ``reasoning_effort`` / low/high/max ON, high - ``thinking: {type}`` (medium→high) -Zhipu GLM 5.2+ ``reasoning_effort`` low/high/max ``max`` -Moonshot Kimi 3 ``reasoning_effort`` low/high/max ``max`` -MiniMax M3 ``thinking: {type}`` adaptive/disabled ON via - OpenAI, - OFF via - Anthropic -DashScope ``enable_thinking`` AND none…xhigh per model - ``reasoning_effort``; plus (no ``max``) - ``thinking_budget``, which the - effort field CANNOT travel with -ModelScope ``enable_thinking`` (gateway) / on/off per model - ``chat_template_kwargs`` -OpenRouter ``reasoning: {effort|max_tokens}`` low/medium/high inferred -Anthropic ``output_config.effort`` + low…max high - ``thinking: {type: adaptive}`` -=============== ================================== ================== ========= - -Three things follow from that table, and they are the whole design: +for long. Per-model real tiers, from each vendor's own docs and cross-checked +against opencode's model catalog (both agree line for line), 2026-08-18: + +================= ================================== ==================== ======== +model / endpoint modern field DISTINCT tiers default +================= ================================== ==================== ======== +xAI grok-4.5 ``reasoning_effort`` low/medium/high high +xAI grok-4.6 ``reasoning_effort`` + xhigh high +DeepSeek v4-* ``reasoning_effort`` / high/max ON, high + ``thinking: {type}`` (+low; med/xhigh→high) +Zhipu glm-5.2 ``reasoning_effort`` high/max ``max`` +Zhipu glm-5.3 ``reasoning_effort`` low/high/max ``max`` +Zhipu glm-5/5.1 ``thinking: {type}`` only — (no effort field) ON +Moonshot kimi-k3 ``reasoning_effort`` low/high/max ``max`` +Moonshot kimi-k2.x ``thinking: {type}`` only — (no effort field) varies +MiniMax M3 ``thinking: {type}`` adaptive/disabled ON via + (+ ``reasoning_split`` for the (NOT a depth knob) OpenAI, + output format, not the depth) OFF via + Anthropic +DashScope ``enable_thinking`` AND, on low/medium/xhigh per model + qwen3.8-max ``reasoning_effort``; the two (high/max→xhigh, + CANNOT travel with minimal→low) + ``thinking_budget`` +DashScope other ``enable_thinking`` only — (no effort field) per model +ModelScope ``enable_thinking`` (gateway) / — (no effort field) per model + ``chat_template_kwargs`` +OpenRouter ``reasoning: {effort|max_tokens}`` per model, published inferred + in ``/api/v1/models`` +Anthropic ``output_config.effort`` + low…max high + ``thinking: {type: adaptive}`` +================= ================================== ==================== ======== + +Four things follow, and they are the whole design: 1. ``reasoning_effort`` is the de-facto standard. It is the name callers use here, so anyone who knows one vendor already knows this one — and it survives the OpenAI SDK's signature filter, unlike an invented name. -2. The on/off switch is being retired. GLM-5.3 and Kimi K3 cannot stop thinking - at all (GLM-5.3 *fails* the request if you send the old - ``thinking: {type: disabled}``), and Anthropic deprecated - ``enabled + budget_tokens`` in favour of an effort level. So "off" is modelled - as the weakest rung of the ladder, the way OpenAI models it with ``none``. - -3. Defaults are per-MODEL and they move. On DashScope alone, qwen3.5 and later +2. Defaults are per-MODEL and they move. On DashScope alone, qwen3.5 and later default thinking ON while qwen-plus/turbo/flash and qwen3-max default it OFF; - MiniMax M3 defaults it ON through the OpenAI-compatible API and OFF through - the Anthropic-compatible one — same model. Any table of defaults we wrote - would be wrong within a release. So we do not write one: ``auto`` sends + ``kimi-k2.6`` defaults OFF on Alibaba's deployment and ON on Moonshot's, same + name; MiniMax M3 defaults it ON through the OpenAI-compatible API and OFF + through the Anthropic-compatible one, same model. Any table of defaults we + wrote would be wrong within a release. So we do not write one: ``auto`` sends NOTHING and inherits whatever the vendor tuned, and the lowering table below is consulted ONLY when a caller asked for a specific tier. A bug in it can then only affect someone who explicitly configured thinking, who will see it immediately — rather than silently changing every request. + +3. **"Accepted" is not "distinct", and a wire enum is not a capability list.** + Two traps, both of which this module fell into once. OpenRouter's rejection + message lists its whole GATEWAY vocabulary, identically for every model, then + maps unsupported-but-valid tiers to the nearest one the model has — sending + ``max`` to grok-4.5 returns 200, not an error. And DeepSeek's ``/v1`` enum + went from five values to seven between 2026-07-27 and 2026-08-17 (``minimal`` + flipped from a hard 400 to accepted, and the declaration order changed), so a + vocabulary derived from probing has a measured shelf life of about three + weeks. The table below therefore records only what an endpoint REJECTS, and + leaves each vendor's documented aliasing to the vendor. + +4. **The tier is a request, not a promise.** Measured in billed reasoning tokens + (3 samples per cell, 2026-08-18), the effect ranges from crisp to absent: + glm-5.2 moves monotonically and treats ``minimal`` as off (0 tokens, 3/3); + glm-5.1 does not move at all (it has no effort field); grok-4.5 through + OpenRouter shows no trend across six tiers; qwen3.8-max is non-monotonic. + So this module translates the knob faithfully and does not pretend to know + what the model will do with it. """ from __future__ import annotations @@ -129,8 +150,9 @@ ('open.bigmodel.cn', 'zhipu'), ('bigmodel.cn', 'zhipu'), ('z.ai', 'zhipu'), - ('api.moonshot.cn', 'moonshot'), - ('platform.kimi.ai', 'moonshot'), + ('api.moonshot.cn', 'moonshot'), # CN + ('api.moonshot.ai', 'moonshot'), # international, per Moonshot's docs + ('api.kimi.com', 'moonshot'), ('api.minimax', 'minimax'), ('openrouter.ai', 'openrouter'), ('api.openai.com', 'openai'), @@ -155,18 +177,31 @@ def endpoint_family(base_url: str, protocol: str = '') -> str: #: Tiers each family actually accepts, weakest to strongest. A request outside #: the set is clamped (see ``clamp_effort``). +# What each family ACCEPTS without erroring — deliberately NOT "which tiers are +# distinct behaviours there". Those are two different questions and only the +# first one is ours: every vendor documents its own collapse (DashScope maps +# `high`/`max` onto `xhigh` and `minimal` onto `low`; Zhipu maps `low`/`medium` +# onto `high` and `xhigh` onto `max`; DeepSeek publishes a five-row table) and +# applies it per MODEL, which a host-keyed table cannot express and should not +# try to. So we only subtract values an endpoint is measured to REJECT, and let +# the vendor alias the rest. _FAMILY_TIERS = { - # DashScope takes the ladder but REJECTS the top rung: qwen3.7-plus answers - # 400 for `max` while accepting `xhigh`, so `max` clamps down to it. The - # ladder is real there — qwen3.8-max reasoning is strictly monotonic in it - # (none 0, minimal 54, low 78, medium 152, high 222, xhigh 393 characters). + # `max` is the single measured rejection on this host: qwen3.7-plus answers + # 400 for it while accepting none/minimal/low/medium/high/xhigh, and + # qwen3.8-max accepts all seven (each value probed individually, + # 2026-08-18). Excluding it protects the qwen3.7 family and costs the one + # model that does take it nothing, because DashScope documents `max` as an + # alias of `xhigh` — exactly where the downward clamp lands. The canonical + # set for qwen3.8-max, the only Qwen with a real effort field, is + # low / medium / xhigh. 'dashscope': ('off', 'minimal', 'low', 'medium', 'high', 'xhigh'), # No effort field at all: the switch is a boolean, so every "how hard" - # collapses onto "on". + # collapses onto "on". Corroborated for MiniMax M3 and the ModelScope-hosted + # Qwen3.5 family by opencode's model catalog, which lists them as `toggle`. 'modelscope': ('off', 'high'), 'minimax': ('off', 'high'), 'anthropic': ('off', 'high'), - # Everyone else takes the whole vocabulary. Endpoints that do not validate + # Everyone else accepts the whole vocabulary. Endpoints that do not validate # it (glm-5.1, glm-5, every Kimi, MiniMax, ModelScope) ignore an unknown # value rather than failing, so passing a tier through costs nothing. 'deepseek': EFFORT_TIERS, @@ -214,20 +249,32 @@ def normalize_effort(raw: Any) -> Optional[str]: def clamp_effort(tier: str, supported: Tuple[str, ...]) -> str: - """Nearest tier the endpoint accepts, preferring the next STRONGER one. - - Asking for more than a model offers should cap at its ceiling rather than - fail; asking for less than it offers (``off`` on Kimi K3, which always - thinks) should land on its floor rather than be silently dropped. + """Nearest tier the endpoint accepts, preferring the next WEAKER one. + + Direction matters, and the intuitive choice is the wrong one. An effort tier + is a quality FLOOR the caller is willing to pay for, not a ceiling — so + landing above the request spends money they did not ask for. A published + post-mortem of the opposite choice (zlxlabs/llm-compat#11, merged + 2026-08-05) describes exactly that: a request for the middle rung clamped + UP into a support set's interior gap, jumped three tiers, was billed at the + top tier, and said nothing louder than a log line. + + ``off`` is not treated as the bottom of the ladder: it is a different + request, so a thinking tier never collapses into it. On an endpoint that + only has a switch, the weakest thinking tier is "on"; on one that cannot + stop thinking at all (Kimi K3 per Moonshot's FAQ), ``off`` lands on the + weakest tier — which is also the remedy Zhipu prescribes for GLM-5.3 + ("change disabled to enabled and set reasoning_effort to low"). """ if tier in supported: return tier + thinking = [t for t in supported if t != 'off'] + if not thinking: # switch-only, and we were not asked to switch off + return supported[0] + ranked = sorted(thinking, key=lambda t: EFFORT_RANKS[t]) want = EFFORT_RANKS[tier] - ranked = sorted(supported, key=lambda t: EFFORT_RANKS[t]) - for candidate in ranked: - if EFFORT_RANKS[candidate] >= want: - return candidate - return ranked[-1] + weaker = [t for t in ranked if EFFORT_RANKS[t] <= want] + return weaker[-1] if weaker else ranked[0] def _merge_extra_body(params: Dict[str, Any], extra: Dict[str, Any]) -> None: @@ -243,13 +290,20 @@ def lower_effort(tier: str, family: str) -> Dict[str, Any]: """ params: Dict[str, Any] = {} if family == 'dashscope': - # BOTH knobs, because they do different jobs and only one of them is - # universal. `enable_thinking` is what actually turns thinking on for - # the models that default it off (qwen-plus: 0 characters of reasoning - # with `reasoning_effort: high` alone, 543 with the flag), while - # `reasoning_effort` is what sets the depth on the models that honour - # it (qwen3.8-max, and DeepSeek/GLM/Kimi served on this host). Models - # that only understand one of the two ignore the other. + # BOTH knobs, because they cover disjoint sets of models on this host. + # `enable_thinking` is the only lever for the many models that take no + # effort field (every Qwen except qwen3.8-max) and it is what turns + # thinking on for the ones that default it off (qwen-plus: 0 characters + # of reasoning from `reasoning_effort: high` alone, 543 with the flag — + # because qwen-plus does not support the effort field at all). Alibaba's + # own CLI and opencode both send the flag here for the same reason. + # Models that understand only one of the two ignore the other. + # + # Known imprecision, deliberate: DashScope also hosts GLM, DeepSeek and + # Kimi, whose switch dialect on this host is `thinking.enabled` / + # `thinking: {type}` rather than `enable_thinking`. Getting that right + # needs per-model branching on a host-keyed table; the flag is ignored + # rather than rejected there, so the cost is a no-op field, not an error. _merge_extra_body(params, {'enable_thinking': tier != 'off'}) if tier != 'off': params[EFFORT_KEY] = tier diff --git a/tests/llm/test_thinking_effort.py b/tests/llm/test_thinking_effort.py index 3ca1fac8a..3a394f99a 100644 --- a/tests/llm/test_thinking_effort.py +++ b/tests/llm/test_thinking_effort.py @@ -59,16 +59,25 @@ def test_effort_is_normalized_leniently(raw, expected): assert T.normalize_effort(raw) == expected -def test_clamp_prefers_the_next_stronger_tier(): - # DeepSeek/Zhipu/Kimi expose low/high/max: a request for medium should not - # quietly become low. - assert T.clamp_effort('medium', ('low', 'high', 'max')) == 'high' - # Nothing at or above the request -> the ceiling, so "max" caps instead of - # failing. +def test_clamp_prefers_the_next_weaker_tier(): + """An effort tier is a quality FLOOR, not a ceiling, so a clamp must never + land above the request — see zlxlabs/llm-compat#11, where clamping UP into a + support set's interior gap jumped three tiers and billed at the top one.""" + assert T.clamp_effort('medium', ('low', 'high', 'max')) == 'low' assert T.clamp_effort('max', ('off', 'low', 'medium', 'high')) == 'high' assert T.clamp_effort('low', ('low', 'high', 'max')) == 'low' +def test_a_thinking_tier_never_collapses_into_off(): + """`off` is a different request, not the bottom rung. Clamping downward + must not turn "think a little" into "do not think".""" + assert T.clamp_effort('minimal', ('off', 'high')) == 'high' + assert T.clamp_effort('low', ('off', 'high')) == 'high' + # ...and asking to switch off where that is impossible lands on the weakest + # thinking tier, which is also the remedy Zhipu prescribes for GLM-5.3. + assert T.clamp_effort('off', ('low', 'high', 'max')) == 'low' + + def test_kimi_can_be_switched_off_after_all(): """The docs say Kimi K3 always thinks; the endpoint disagrees. Probed 2026-08-17, `reasoning_effort: none` yields ZERO characters of reasoning on @@ -89,11 +98,11 @@ def test_boolean_endpoints_get_a_boolean(): def test_dashscope_gets_both_knobs_because_they_do_different_jobs(): - """Probed 2026-08-17: `reasoning_effort: high` ALONE leaves qwen-plus at - zero reasoning — only `enable_thinking` turns thinking on there — while - `reasoning_effort` is what actually sets depth on qwen3.8-max (none 0 → - low 78 → high 222 → xhigh 393 characters). Sending one without the other - silently loses half the control.""" + """They cover disjoint sets of models on this host. `reasoning_effort: high` + ALONE leaves qwen-plus at zero reasoning — it does not support the effort + field, so only `enable_thinking` reaches it (probed 2026-08-17: 0 characters + vs 543 with the flag) — while qwen3.8-max is the one Qwen model that does + take an effort tier. Sending one without the other loses half the models.""" got = T.plan('low', base_url='https://dashscope.aliyuncs.com/v1') assert got['params'] == { 'extra_body': { @@ -103,13 +112,18 @@ def test_dashscope_gets_both_knobs_because_they_do_different_jobs(): } -def test_dashscope_caps_at_xhigh_because_max_is_rejected(): - """`max` is rejected outright — qwen3.7-plus answers 400 listing the valid - set — while `xhigh` is accepted by every qwen3.7/3.8 probed. DashScope is - the one endpoint whose vocabulary falls short of the full ladder.""" +def test_dashscope_drops_only_the_value_it_measurably_rejects(): + """`max` is the one 400 on this host (qwen3.7-plus rejects it, qwen3.8-max + accepts all seven — each value probed individually 2026-08-18), and it is + documented as an alias of `xhigh`, which is where the downward clamp lands. + Every other rung reaches the endpoint untouched.""" got = T.plan('max', base_url='https://dashscope.aliyuncs.com/v1') assert got['effective'] == 'xhigh' assert got['params']['reasoning_effort'] == 'xhigh' + for rung in ('minimal', 'low', 'medium', 'high', 'xhigh'): + sent = T.plan(rung, base_url='https://dashscope.aliyuncs.com/v1') + assert sent['effective'] == rung + assert sent['params']['reasoning_effort'] == rung def test_dashscope_off_stays_a_plain_boolean(): From e2fa1627d3139f12b6b35ff45d7ca88e5653ed17 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Tue, 18 Aug 2026 12:18:29 +0800 Subject: [PATCH 27/36] Ask MiniMax to deliver reasoning in its own field, the only shape it reads back --- ms_agent/llm/thinking.py | 48 ++++++++++++++++++++++++------- tests/llm/test_thinking_effort.py | 29 ++++++++++++++----- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/ms_agent/llm/thinking.py b/ms_agent/llm/thinking.py index bd54fc59a..f5c530aac 100644 --- a/ms_agent/llm/thinking.py +++ b/ms_agent/llm/thinking.py @@ -349,6 +349,31 @@ def lower_effort(tier: str, family: str) -> Dict[str, Any]: return params +def output_format_params(family: str) -> Dict[str, Any]: + """Params about WHERE the reasoning is delivered, not how much of it to do. + + Separate from the effort ladder on purpose: this asks nothing about how hard + to think, so it applies even under ``auto``, where we deliberately say + nothing about depth. + + MiniMax is the only family that needs it. Its OpenAI-compatible endpoint + inlines the reasoning into the answer as ```` (its docs call + that the native format) and offers ``reasoning_split`` to deliver it in + ``reasoning_content`` instead — which its docs "strongly recommend", and + which is the only shape it reads back: probed 2026-08-18, replaying a + separate ``reasoning_content`` in NATIVE mode behaves exactly like + discarding the thinking, because the field is not part of that format. + Verified on M3, M2.7 and M2.5: no error, reasoning moves out of ``content``. + + Host-gated by construction — third-party hosts of the same weights reject + the parameter outright (NVIDIA NIM: "Unsupported parameter(s): + 'reasoning_split'"), and they are a different family here. + """ + if family == 'minimax': + return {'extra_body': {'reasoning_split': True}} + return {} + + def auto_params(family: str) -> Dict[str, Any]: """What ``auto`` sends. Almost always nothing — see the module docstring. @@ -415,20 +440,23 @@ def plan(effort: Any, if requested is None: requested = 'auto' if requested == 'auto': - return { - 'family': family, - 'requested': 'auto', - 'effective': 'auto', - 'params': _drop_conflicts(auto_params(family), existing), - 'extra_hint': FAMILY_EXTRA_HINTS.get(family, ''), - } - effective = clamp_effort(requested, _FAMILY_TIERS.get(family, - _FAMILY_TIERS['unknown'])) + effective, wire = 'auto', auto_params(family) + else: + effective = clamp_effort( + requested, _FAMILY_TIERS.get(family, _FAMILY_TIERS['unknown'])) + wire = lower_effort(effective, family) + # Where the reasoning is delivered is a separate question from how much of + # it to do, so it survives `auto` and rides along with every tier. + for key, value in output_format_params(family).items(): + if key == 'extra_body': + _merge_extra_body(wire, value) + else: + wire.setdefault(key, value) return { 'family': family, 'requested': requested, 'effective': effective, - 'params': _drop_conflicts(lower_effort(effective, family), existing), + 'params': _drop_conflicts(wire, existing), 'extra_hint': FAMILY_EXTRA_HINTS.get(family, ''), } diff --git a/tests/llm/test_thinking_effort.py b/tests/llm/test_thinking_effort.py index 3a394f99a..1a104700d 100644 --- a/tests/llm/test_thinking_effort.py +++ b/tests/llm/test_thinking_effort.py @@ -179,14 +179,29 @@ def test_openai_off_is_the_none_tier(): def test_minimax_on_means_adaptive(): + """`adaptive`, not `enabled` — MiniMax's enum is exactly + ``["disabled", "adaptive"]`` and `enabled` appears nowhere in its docs.""" got = T.plan('high', base_url='https://api.minimaxi.com/v1') - assert got['params'] == { - 'extra_body': { - 'thinking': { - 'type': 'adaptive' - } - } - } + assert got['params']['extra_body']['thinking'] == {'type': 'adaptive'} + + +def test_minimax_always_asks_for_the_reasoning_to_be_split_out(): + """Its native format inlines reasoning into the answer as `` + and does not read a separate `reasoning_content` back — so replaying one, as + we do, is a no-op there (probed 2026-08-18: identical to discarding it). + `reasoning_split` is the format its docs recommend and the only one where + our replay shape means anything. It says nothing about depth, so it rides + along with `auto` and with every tier — including `off`, where there is + simply no reasoning to split.""" + for effort in ('auto', 'off', 'low', 'max'): + got = T.plan(effort, base_url='https://api.minimaxi.com/v1') + assert got['params']['extra_body']['reasoning_split'] is True + + # Nobody else gets it: third-party hosts of the same weights reject the + # parameter outright, and they are a different family here. + for base in ('https://api-inference.modelscope.cn/v1', + 'https://openrouter.ai/api/v1'): + assert 'reasoning_split' not in str(T.plan('auto', base_url=base)) def test_openrouter_uses_its_own_unified_object(): From 5ddb09cd139db41f3cb90eb0a4d26063fc9e1125 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Tue, 18 Aug 2026 12:36:51 +0800 Subject: [PATCH 28/36] Offer only the tiers an endpoint actually has, not the whole ladder --- ms_agent/llm/thinking.py | 19 +++++++++++++++++++ tests/llm/test_thinking_effort.py | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/ms_agent/llm/thinking.py b/ms_agent/llm/thinking.py index f5c530aac..5b958004e 100644 --- a/ms_agent/llm/thinking.py +++ b/ms_agent/llm/thinking.py @@ -229,6 +229,25 @@ def endpoint_family(base_url: str, protocol: str = '') -> str: _EFFORT_CONFLICTS = ('thinking_budget', ) +def offered_tiers(family: str) -> Tuple[str, ...]: + """The tiers worth OFFERING for this endpoint, weakest to strongest. + + Not the same list as ``_FAMILY_TIERS``, which answers "what will this + endpoint accept" — a question about avoiding 400s. This one answers "what is + worth showing a person", and on a switch-only endpoint that is two entries, + not eight. Listing a ladder where none exists is the UI promising control + the model does not have. + """ + supported = _FAMILY_TIERS.get(family, _FAMILY_TIERS['unknown']) + thinking = [t for t in supported if t != 'off'] + if len(thinking) <= 1: # a switch, however many rungs we may send it + # `on` is an alias of the single thinking tier, and it is the honest + # word for a knob with two positions. + return ('auto', 'off', 'on') + return ('auto', ) + tuple( + sorted(supported, key=lambda t: EFFORT_RANKS[t])) + + def normalize_effort(raw: Any) -> Optional[str]: """Free-form input -> a canonical tier, ``'auto'``, or ``None`` if garbage. diff --git a/tests/llm/test_thinking_effort.py b/tests/llm/test_thinking_effort.py index 1a104700d..10cb7f5b7 100644 --- a/tests/llm/test_thinking_effort.py +++ b/tests/llm/test_thinking_effort.py @@ -528,3 +528,24 @@ def test_openrouter_style_reasoning_field_is_read(): both.reasoning = 'proxied' assert _reasoning_of(both) == 'native' # the native field wins assert _reasoning_of(ns()) == '' + + +def test_a_switch_only_endpoint_is_not_offered_a_ladder(): + """What we ACCEPT and what we OFFER are different questions. ModelScope's + gateway and MiniMax have a boolean, so listing eight rungs in the settings + dialog would promise control the model does not have.""" + assert T.offered_tiers('modelscope') == ('auto', 'off', 'on') + assert T.offered_tiers('minimax') == ('auto', 'off', 'on') + assert T.offered_tiers('anthropic') == ('auto', 'off', 'on') + # `on` is an alias of the single thinking tier, so it round-trips. + assert T.normalize_effort('on') == 'high' + assert T.plan('on', base_url='https://api-inference.modelscope.cn/v1')[ + 'params'] == {'extra_body': {'enable_thinking': True}} + + +def test_a_real_ladder_is_offered_in_full(): + assert T.offered_tiers('zhipu') == ('auto', 'off', 'minimal', 'low', + 'medium', 'high', 'xhigh', 'max') + # ...minus the rung DashScope rejects. + assert 'max' not in T.offered_tiers('dashscope') + assert 'xhigh' in T.offered_tiers('dashscope') From 34bddc0c6a75f810a342b6b7fbc3d01b45bcaa47 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 19 Aug 2026 00:36:33 +0800 Subject: [PATCH 29/36] Match a bare command against its own ` *` rule --- ms_agent/permission/matcher.py | 23 +++++++++++++++++- tests/permission/test_matcher.py | 40 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/ms_agent/permission/matcher.py b/ms_agent/permission/matcher.py index a1422591c..10b1d57b5 100644 --- a/ms_agent/permission/matcher.py +++ b/ms_agent/permission/matcher.py @@ -37,6 +37,27 @@ def _extract_content(tool_name: str, tool_args: dict[str, Any]) -> str | None: return str(val) if val is not None else None +def _with_bare_command_variants(content_pattern: str) -> str: + """Add an argument-less variant for every `` *`` alternative. + + ``curl *`` means "the curl command with any arguments" — and running it with + NONE is a case of that. fnmatch disagrees: it wants the space and at least + one character after it, so bare ``curl`` slipped past a rule written to gate + exactly that, and a remembered ``whoami *`` failed to match the very + ``whoami`` it was generated from. + + Only the space-star idiom of shell commands is extended. Path patterns end + in ``/*`` (``~/.ssh/*``) or ``=*`` (``dd if=*``) and are left alone — there + the trailing component is meaningful, not an optional argument list. + """ + alts = [a.strip() for a in content_pattern.split('|')] + out = list(alts) + for alt in alts: + if alt.endswith(' *'): + out.append(alt[:-2].rstrip()) + return '|'.join(p for p in out if p) + + class PermissionMatcher: """Wildcard matcher for permission rules, shared by both SafetyGuard and PermissionEnforcer.""" @@ -77,4 +98,4 @@ def match_with_content( if content is None: return False - return self.match(content_pattern, content) + return self.match(_with_bare_command_variants(content_pattern), content) diff --git a/tests/permission/test_matcher.py b/tests/permission/test_matcher.py index 6e8e64bf6..c1b3b73dc 100644 --- a/tests/permission/test_matcher.py +++ b/tests/permission/test_matcher.py @@ -82,3 +82,43 @@ def test_non_string_content_is_coerced(self, matcher): {'path': ['/tmp/a', '/tmp/b']}, ) assert isinstance(result, bool) + + +class TestBareCommandVariant: + """`` *`` means "that command with any arguments" — and with NONE is a + case of that. fnmatch wants the space plus a character, so bare ``curl`` + slipped past the very ask rule written to gate it, and a remembered + ``whoami *`` failed to match the ``whoami`` it was generated from.""" + + TOOL = 'code_executor---shell_executor' + + def _m(self, pattern: str, command: str) -> bool: + return PermissionMatcher().match_with_content( + f'{self.TOOL}:{pattern}', self.TOOL, {'command': command}) + + def test_argument_less_command_matches(self): + assert self._m('whoami *', 'whoami') + assert self._m('curl *', 'curl') + + def test_command_with_arguments_still_matches(self): + assert self._m('whoami *', 'whoami --version') + assert self._m('curl *', 'curl https://example.com') + + def test_does_not_match_a_longer_command_name(self): + assert not self._m('ls *', 'lsof') + + def test_applies_per_alternative(self): + assert self._m('ls *|cat *', 'cat') + assert not self._m('ls *|cat *', 'rm') + + def test_leaves_non_space_star_patterns_alone(self): + # `dd if=*` / `rm -rf /*`: the trailing component is meaningful, not an + # optional argument list, so the bare command must NOT match. + assert self._m('dd if=*', 'dd if=/dev/zero') + assert not self._m('dd if=*', 'dd') + assert not self._m('rm -rf /*', 'rm') + + def test_path_patterns_unaffected(self): + assert not PermissionMatcher().match_with_content( + 'file_system---read_file:~/.ssh/*', 'file_system---read_file', + {'path': '~/.ssh'}) From b39bd5f63631dc74f0d49512d8282fb925ffbc8d Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 19 Aug 2026 00:36:33 +0800 Subject: [PATCH 30/36] Confirm network commands instead of refusing them, and remember only the command the user approved --- ms_agent/permission/config.py | 35 ++++-- ms_agent/permission/enforcer.py | 69 +++++++++-- tests/permission/test_enforcer.py | 187 +++++++++++++++++++++++++++++- tests/permission/test_safety.py | 46 ++++++-- 4 files changed, 304 insertions(+), 33 deletions(-) diff --git a/ms_agent/permission/config.py b/ms_agent/permission/config.py index 251647f8c..dde6a17aa 100644 --- a/ms_agent/permission/config.py +++ b/ms_agent/permission/config.py @@ -88,7 +88,18 @@ def _expand_dirs(raw: list[str]) -> tuple[str, ...]: ) -_DEFAULT_BLACKLIST: tuple[str, ...] = ( +#: Nothing by default. A blacklist entry can never be overridden — not by the +#: mode, not by a whitelist, not by the user answering a prompt — so it is the +#: wrong tool for "risky, ask first". The network-egress commands below used to +#: live here and were simply unusable: an agent asked to run ``curl`` reported +#: that it had been blocked and there was no way for the user to permit it. +_DEFAULT_BLACKLIST: tuple[str, ...] = () + +#: Commands that must be CONFIRMED rather than refused. Unlike the mode-level +#: default these hold in every mode, including full-access: reaching the network +#: or another host is worth one deliberate click even from a user who has +#: otherwise waved the agent through. ``allow_network: true`` drops them. +_DEFAULT_ASK_RULES: tuple[str, ...] = ( 'code_executor---shell_executor:curl *', 'code_executor---shell_executor:wget *', 'code_executor---shell_executor:ssh *', @@ -105,7 +116,10 @@ class PermissionConfig: mode: Literal['auto', 'strict', 'interactive'] = 'auto' whitelist: tuple[str, ...] = () blacklist: tuple[str, ...] = _DEFAULT_BLACKLIST - ask_rules: tuple[str, ...] = () + # Defaulted here as well as in from_dict: a config with no ``permission`` + # section at all takes the early return below, and the network commands + # must still be confirmed there. + ask_rules: tuple[str, ...] = _DEFAULT_ASK_RULES safety: SafetyConfig = SafetyConfig() @classmethod @@ -119,18 +133,19 @@ def from_dict(cls, _MODE_ALIASES = {'restricted': 'interactive'} mode = _MODE_ALIASES.get(raw_mode, raw_mode) whitelist = tuple(d.get('whitelist', ())) - ask_rules = tuple(d.get('ask_rules', ())) + user_ask_rules = tuple(d.get('ask_rules', ())) user_blacklist = tuple(d.get('blacklist', ())) - # The default blacklist blocks network-egress shell commands - # (curl/wget/ssh/...). ``allow_network: true`` (or legacy - # ``no_default_blacklist``) opts out of that secure default; the - # user's own blacklist entries still apply. + # Network-egress shell commands (curl/wget/ssh/...) are confirmed, not + # refused. ``allow_network: true`` (or legacy ``no_default_blacklist``) + # opts out of that confirmation; the user's own rules still apply. allow_network = bool( d.get('allow_network', False) or d.get('no_default_blacklist', False)) - base_blacklist = () if allow_network else _DEFAULT_BLACKLIST - blacklist = base_blacklist + tuple( - p for p in user_blacklist if p not in base_blacklist) + base_ask = () if allow_network else _DEFAULT_ASK_RULES + ask_rules = base_ask + tuple( + p for p in user_ask_rules if p not in base_ask) + blacklist = _DEFAULT_BLACKLIST + tuple( + p for p in user_blacklist if p not in _DEFAULT_BLACKLIST) safety_raw = d.get('safety_rules', {}) # Merge directory configs from top level into safety config diff --git a/ms_agent/permission/enforcer.py b/ms_agent/permission/enforcer.py index ca10b020b..c8d59880e 100644 --- a/ms_agent/permission/enforcer.py +++ b/ms_agent/permission/enforcer.py @@ -14,7 +14,7 @@ from .config import PermissionConfig from .handler import (AutoPermissionHandler, PermissionAction, PermissionHandler, PermissionResponse) -from .matcher import PermissionMatcher +from .matcher import CONTENT_SEP, PermissionMatcher from .memory import PermissionMemory from .suggestions import generate_suggestions @@ -84,6 +84,15 @@ async def _ask_user(self, return None return await self._handler.ask(**kwargs) + def _can_ask_human(self) -> bool: + """Whether a real person can actually answer a prompt right now. + + ``AutoPermissionHandler`` is the stand-in used headlessly and it always + answers "allow", so treating it as an asker would turn every ask rule + into a no-op. + """ + return not isinstance(self._handler, AutoPermissionHandler) + def _handler_accepts(self, param: str) -> bool: try: sig = inspect.signature(self._handler.ask) @@ -127,19 +136,41 @@ async def check( ) return self._process_response(response, tool_name, tool_args) + # 1b. Ask rules → confirm, in EVERY mode. Until now this config existed + # but only the hook path consulted it, so an ask rule was silently + # inert on the ordinary route. It outranks the mode and the whitelist — + # that is the whole point of "ask even under full access" — but not the + # user's own remembered answer below, so consenting once still sticks. + ask_rule = next( + (p for p in self._config.ask_rules + if self._matcher.match_with_content(p, tool_name, tool_args)), + None, + ) + if ask_rule and not self._can_ask_human(): + # Headless (AutoPermissionHandler allows everything): there is + # nobody to confirm, and silently running the thing an ask rule was + # written to gate would be worse than refusing. + return PermissionDecision( + action='deny', + reason=(f'Ask rule matched: {ask_rule}; no interactive ' + 'handler is attached to confirm it'), + ) + # 2. Auto / strict mode → allow (safety handled by SafetyGuard + ask_resolver) - if self._config.mode in ('auto', 'strict'): + if self._config.mode in ('auto', 'strict') and not ask_rule: return PermissionDecision( action='allow', reason=f'{self._config.mode.capitalize()} mode') # 3. Whitelist → allow - for pattern in self._config.whitelist: - if self._matcher.match_with_content(pattern, tool_name, tool_args): - return PermissionDecision( - action='allow', - reason=f'Allowed by whitelist rule: {pattern}', - ) + if not ask_rule: + for pattern in self._config.whitelist: + if self._matcher.match_with_content(pattern, tool_name, + tool_args): + return PermissionDecision( + action='allow', + reason=f'Allowed by whitelist rule: {pattern}', + ) # 4. Memory (session + persistent) → allow if self._memory.matches(tool_name, tool_args): @@ -160,6 +191,24 @@ async def check( return self._process_response(response, tool_name, tool_args) + def _remember_pattern(self, response: PermissionResponse, tool_name: str, + tool_args: dict[str, Any]) -> str: + """What to remember when the caller named no pattern of its own. + + The bare tool name means "allow this TOOL" — for the shell that is + every future command, so approving ``ls -la`` once silently handed over + unrestricted shell access. Prefer instead the most specific generated + suggestion that is no broader than the tool itself (``:ls *``); + a suggestion that WIDENS the scope (``---*``) is not a fallback + anyone asked for. + """ + if response.pattern: + return response.pattern + for s in generate_suggestions(tool_name, tool_args): + if s == tool_name or s.startswith(f'{tool_name}{CONTENT_SEP}'): + return s + return tool_name + def _process_response( self, response: PermissionResponse | None, @@ -179,7 +228,7 @@ def _process_response( action='allow', reason='User allowed once') if response.action == PermissionAction.ALLOW_SESSION: - pattern = response.pattern or tool_name + pattern = self._remember_pattern(response, tool_name, tool_args) self._memory.add_session(pattern) return PermissionDecision( action='allow', @@ -187,7 +236,7 @@ def _process_response( ) if response.action == PermissionAction.ALLOW_ALWAYS: - pattern = response.pattern or tool_name + pattern = self._remember_pattern(response, tool_name, tool_args) self._memory.add(pattern, scope='project', source='user') return PermissionDecision( action='allow', diff --git a/tests/permission/test_enforcer.py b/tests/permission/test_enforcer.py index c9aa2805f..fb6e88dac 100644 --- a/tests/permission/test_enforcer.py +++ b/tests/permission/test_enforcer.py @@ -67,8 +67,15 @@ async def test_always_allows(self, auto_enforcer): assert 'Auto mode' in r.reason @pytest.mark.asyncio - async def test_blacklist_denies(self, auto_enforcer): - r = await auto_enforcer.check( + async def test_blacklist_denies(self): + # A blacklist entry outranks even auto mode. The list ships EMPTY now + # (network commands are ask rules, not refusals), so this states its own + # rule rather than leaning on a default. + config = PermissionConfig( + mode='auto', + blacklist=('code_executor---shell_executor:curl *', ), + ) + r = await PermissionEnforcer(config=config).check( 'code_executor---shell_executor', {'command': 'curl http://example.com'}, ) @@ -220,3 +227,179 @@ async def ask(self, tool_name, tool_args, context, suggestions=None): r = await enforcer.check('code_executor---shell_executor', {'command': 'rm -rf /'}) assert r.action == 'allow' assert r.updated_args == {'command': 'ls -la'} + + +class TestNetworkCommandsAsk: + """curl/wget/ssh/... used to sit in the DEFAULT BLACKLIST, which nothing can + override — so the agent reported "blocked" and the user had no way to permit + it, in any mode. They are ask rules now: confirmed, never silently refused.""" + + @pytest.mark.asyncio + async def test_curl_asks_in_interactive_mode(self, tmp_path): + class Probe: + asked = 0 + + async def ask(self, tool_name, tool_args, context, suggestions=None): + Probe.asked += 1 + return PermissionResponse(action=PermissionAction.ALLOW_ONCE) + + enforcer = PermissionEnforcer( + config=_interactive_config(), + handler=Probe(), + memory=PermissionMemory(project_path=tmp_path), + ) + r = await enforcer.check('code_executor---shell_executor', + {'command': 'curl --version'}) + assert r.action == 'allow' + assert Probe.asked == 1 + + @pytest.mark.asyncio + async def test_curl_still_asks_under_full_access(self, tmp_path): + """Reaching the network is worth one deliberate click even from a user + who waved the agent through everything else — the ask rule outranks the + mode AND the whitelist.""" + class Probe: + asked = 0 + + async def ask(self, tool_name, tool_args, context, suggestions=None): + Probe.asked += 1 + return PermissionResponse(action=PermissionAction.ALLOW_ONCE) + + config = PermissionConfig.from_dict({ + 'mode': 'auto', + 'whitelist': ['code_executor---shell_executor'], + }) + enforcer = PermissionEnforcer( + config=config, + handler=Probe(), + memory=PermissionMemory(project_path=tmp_path), + ) + r = await enforcer.check('code_executor---shell_executor', + {'command': 'curl https://example.com'}) + assert r.action == 'allow' + assert Probe.asked == 1 + + @pytest.mark.asyncio + async def test_ordinary_command_unaffected_in_auto_mode(self, tmp_path): + class Probe: + asked = 0 + + async def ask(self, tool_name, tool_args, context, suggestions=None): + Probe.asked += 1 + return PermissionResponse(action=PermissionAction.ALLOW_ONCE) + + enforcer = PermissionEnforcer( + config=PermissionConfig.from_dict({'mode': 'auto'}), + handler=Probe(), + memory=PermissionMemory(project_path=tmp_path), + ) + r = await enforcer.check('code_executor---shell_executor', + {'command': 'ls -la'}) + assert r.action == 'allow' + assert Probe.asked == 0 + + @pytest.mark.asyncio + async def test_curl_denied_when_nobody_can_be_asked(self, tmp_path): + """Headless: AutoPermissionHandler answers "allow" to everything, so + running the thing an ask rule exists to gate would be worse than + refusing.""" + enforcer = PermissionEnforcer( + config=PermissionConfig.from_dict({'mode': 'auto'}), + handler=AutoPermissionHandler(), + memory=PermissionMemory(project_path=tmp_path), + ) + r = await enforcer.check('code_executor---shell_executor', + {'command': 'curl https://example.com'}) + assert r.action == 'deny' + assert 'curl' in r.reason + + @pytest.mark.asyncio + async def test_allow_network_opts_out(self, tmp_path): + class Probe: + asked = 0 + + async def ask(self, tool_name, tool_args, context, suggestions=None): + Probe.asked += 1 + return PermissionResponse(action=PermissionAction.ALLOW_ONCE) + + config = PermissionConfig.from_dict({ + 'mode': 'auto', + 'allow_network': True, + }) + enforcer = PermissionEnforcer( + config=config, + handler=Probe(), + memory=PermissionMemory(project_path=tmp_path), + ) + r = await enforcer.check('code_executor---shell_executor', + {'command': 'curl https://example.com'}) + assert r.action == 'allow' + assert Probe.asked == 0 + + +class TestRememberedPatternBreadth: + """A caller that names no pattern used to have the BARE TOOL NAME + remembered — for the shell that is every future command, so approving + `ls -la` once handed over unrestricted shell access.""" + + @pytest.mark.asyncio + async def test_shell_remembers_the_command_not_the_whole_tool(self, tmp_path): + class PatternlessSession: + async def ask(self, tool_name, tool_args, context, suggestions=None): + return PermissionResponse(action=PermissionAction.ALLOW_SESSION) + + memory = PermissionMemory(project_path=tmp_path) + enforcer = PermissionEnforcer( + config=_interactive_config(), + handler=PatternlessSession(), + memory=memory, + ) + r = await enforcer.check('code_executor---shell_executor', + {'command': 'ls -la'}) + assert r.action == 'allow' + # Same command family: remembered. + assert memory.matches('code_executor---shell_executor', + {'command': 'ls /tmp'}) + # Including with no arguments at all. + assert memory.matches('code_executor---shell_executor', + {'command': 'ls'}) + # A different command is NOT covered by approving `ls`. + assert not memory.matches('code_executor---shell_executor', + {'command': 'rm -rf build'}) + + @pytest.mark.asyncio + async def test_argument_less_command_remembers_itself(self, tmp_path): + """Approving bare `whoami` must cover `whoami` — the remembered pattern + used not to match the very command it was generated from.""" + class PatternlessSession: + async def ask(self, tool_name, tool_args, context, suggestions=None): + return PermissionResponse(action=PermissionAction.ALLOW_SESSION) + + memory = PermissionMemory(project_path=tmp_path) + enforcer = PermissionEnforcer( + config=_interactive_config(), + handler=PatternlessSession(), + memory=memory, + ) + await enforcer.check('code_executor---shell_executor', + {'command': 'whoami'}) + assert memory.matches('code_executor---shell_executor', + {'command': 'whoami'}) + + @pytest.mark.asyncio + async def test_fallback_never_widens_past_the_tool(self, tmp_path): + """`web_search` suggests a server-wide `web_search---*`; a FALLBACK must + not be broader than the tool the user actually approved.""" + class PatternlessSession: + async def ask(self, tool_name, tool_args, context, suggestions=None): + return PermissionResponse(action=PermissionAction.ALLOW_SESSION) + + memory = PermissionMemory(project_path=tmp_path) + enforcer = PermissionEnforcer( + config=_interactive_config(), + handler=PatternlessSession(), + memory=memory, + ) + await enforcer.check('web_search---search', {'query': 'x'}) + assert memory.matches('web_search---search', {'query': 'y'}) + assert not memory.matches('web_search---fetch_page', {'url': 'z'}) diff --git a/tests/permission/test_safety.py b/tests/permission/test_safety.py index 4970fa37c..7be5d0af1 100644 --- a/tests/permission/test_safety.py +++ b/tests/permission/test_safety.py @@ -285,21 +285,45 @@ def test_relative_path_outside_workspace_root(self): assert r.action == 'deny' -class TestDefaultBlacklist: - """PermissionConfig includes default network command blacklist.""" +class TestDefaultNetworkRules: + """Network-egress commands are CONFIRMED by default, not refused. - def test_default_blacklist_contains_curl(self): + They used to be default BLACKLIST entries, which nothing can override — so + an agent asked to fetch a URL reported it had been blocked and the user had + no way to permit it, in any mode. They are ask rules now, and the blacklist + ships empty: it is the wrong tool for "risky, ask first".""" + + def test_curl_is_an_ask_rule_not_a_blacklist_entry(self): + from ms_agent.permission.config import PermissionConfig + config = PermissionConfig.from_dict({}) + assert any('curl' in p for p in config.ask_rules) + assert not any('curl' in p for p in config.blacklist) + + def test_wget_is_an_ask_rule(self): from ms_agent.permission.config import PermissionConfig - config = PermissionConfig() - assert any('curl' in p for p in config.blacklist) + config = PermissionConfig.from_dict({}) + assert any('wget' in p for p in config.ask_rules) - def test_default_blacklist_contains_wget(self): + def test_default_blacklist_is_empty(self): from ms_agent.permission.config import PermissionConfig - config = PermissionConfig() - assert any('wget' in p for p in config.blacklist) + assert PermissionConfig().blacklist == () - def test_user_blacklist_merged(self): + def test_user_blacklist_kept_alongside_default_ask_rules(self): from ms_agent.permission.config import PermissionConfig config = PermissionConfig.from_dict({'blacklist': ['custom---tool']}) - assert any('curl' in p for p in config.blacklist) - assert 'custom---tool' in config.blacklist + assert config.blacklist == ('custom---tool', ) + assert any('curl' in p for p in config.ask_rules) + + def test_user_ask_rules_merged_with_defaults(self): + from ms_agent.permission.config import PermissionConfig + config = PermissionConfig.from_dict({'ask_rules': ['custom---tool']}) + assert 'custom---tool' in config.ask_rules + assert any('curl' in p for p in config.ask_rules) + + def test_allow_network_drops_the_default_ask_rules(self): + from ms_agent.permission.config import PermissionConfig + config = PermissionConfig.from_dict({ + 'allow_network': True, + 'ask_rules': ['custom---tool'], + }) + assert config.ask_rules == ('custom---tool', ) From b2718396efe1c1c29153ff3ca5dacfe3296e1b00 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 19 Aug 2026 01:19:25 +0800 Subject: [PATCH 31/36] Stop a bare `*` in dangerous_removal_paths from making every path dangerous --- ms_agent/permission/path_validator.py | 61 +++++++++++++++---------- tests/permission/test_path_validator.py | 50 ++++++++++++++++++++ tests/permission/test_safety.py | 41 +++++++++++++++++ 3 files changed, 128 insertions(+), 24 deletions(-) diff --git a/ms_agent/permission/path_validator.py b/ms_agent/permission/path_validator.py index 52b2791ad..74f465f8c 100644 --- a/ms_agent/permission/path_validator.py +++ b/ms_agent/permission/path_validator.py @@ -14,6 +14,10 @@ _WINDOWS_DRIVE_ROOT = re.compile(r'^[A-Za-z]:/?$') _WINDOWS_DRIVE_CHILD = re.compile(r'^[A-Za-z]:/[^/]+$') _ROOT_CHILD = re.compile(r'^/[^/]+$') +#: A dangerous-removal entry that is nothing but separators and stars (``*``, +#: ``/*``). Meaningful as a literal argument, useless as a glob: fnmatch's ``*`` +#: crosses ``/``, so such an entry matches every path in existence. +_WILDCARD_ONLY = re.compile(r'^[/\\*]+$') @dataclass(frozen=True) @@ -182,35 +186,44 @@ def is_dangerous_removal_path( ``extra_patterns`` are configurable (``safety_rules.dangerous_removal_paths``): each is expanded (``~``) and matched against the normalized path both - literally and as an fnmatch glob (REVIEW P1-8).""" + literally and as an fnmatch glob (REVIEW P1-8). + + A wildcard-ONLY pattern (``*``, ``/*``) is compared literally and never as a + glob. Those entries exist to catch the user typing ``rm *`` — the argument + itself — but ``fnmatch(anything, '*')`` is unconditionally true, so as globs + they made EVERY path dangerous and no ``rm`` could run at all, not even + ``rm build/out.txt``. The literal comparison (plus the fixed checks below) + still catches what they were written for.""" import fnmatch - normalized = _CONSECUTIVE_SLASHES.sub('/', path) - if normalized.endswith('/') and len(normalized) > 1: - normalized = normalized.rstrip('/') + + def _norm(raw: str) -> str: + out = _CONSECUTIVE_SLASHES.sub('/', raw) + return out.rstrip('/') if out.endswith('/') and len(out) > 1 else out + + # Judge the argument both AS WRITTEN (`~`, `*` — what the user typed, and + # what the pattern list is phrased in) and EXPANDED (`/Users/me` — what rm + # would actually delete). Checking only the written form let `rm ~` through. + candidates = {_norm(path), _norm(os.path.expanduser(path))} for pat in extra_patterns or (): - pat_expanded = _CONSECUTIVE_SLASHES.sub('/', - os.path.expanduser(str(pat))) - if (normalized == pat_expanded.rstrip('/') - or fnmatch.fnmatch(normalized, pat_expanded)): + raw = _norm(str(pat)) + expanded = _norm(os.path.expanduser(str(pat))) + if candidates & {raw, expanded}: + return True + if _WILDCARD_ONLY.match(expanded): + continue + if any(fnmatch.fnmatch(c, expanded) for c in candidates): return True - - if normalized == '*': - return True - if normalized.endswith('/*') or normalized.endswith('\\*'): - return True - if normalized == '/': - return True home = os.path.expanduser('~').replace('\\', '/') - if normalized == home: - return True - - if _ROOT_CHILD.match(normalized): - return True - if _WINDOWS_DRIVE_ROOT.match(normalized): - return True - if _WINDOWS_DRIVE_CHILD.match(normalized): - return True + for normalized in candidates: + if normalized == '*' or normalized.endswith(('/*', '\\*')): + return True + if normalized == '/' or normalized == home: + return True + if (_ROOT_CHILD.match(normalized) + or _WINDOWS_DRIVE_ROOT.match(normalized) + or _WINDOWS_DRIVE_CHILD.match(normalized)): + return True return False diff --git a/tests/permission/test_path_validator.py b/tests/permission/test_path_validator.py index f81760066..bcefebf73 100644 --- a/tests/permission/test_path_validator.py +++ b/tests/permission/test_path_validator.py @@ -195,3 +195,53 @@ def test_relative_glob(self): def test_root_glob(self): assert get_glob_base_directory('/*') == '/' + + +class TestDangerousRemovalWithConfiguredPatterns: + """The same check as above, but WITH the patterns production actually + configures. The default list starts with ``*``, and fnmatch's ``*`` crosses + ``/`` — so every path came back "dangerous" and NO removal was possible: + `rm build/out.txt` was refused by the non-bypassable safety layer, in every + mode, with nothing the user could do about it. The tests above never caught + it because they pass no patterns at all.""" + + @staticmethod + def _patterns(): + from ms_agent.permission.config import SafetyConfig + return SafetyConfig().dangerous_removal_paths + + @pytest.mark.parametrize('path', [ + 'build/out.txt', + 'a.log', + './tmp/x', + 'dist/', + os.path.join(os.path.expanduser('~'), 'proj/build/x.o'), + '~/proj/build/x.o', + ]) + def test_ordinary_removals_are_allowed(self, path): + assert not is_dangerous_removal_path(path, self._patterns()) + + @pytest.mark.parametrize('path', [ + '*', + '/*', + '/', + '/etc', + '/usr', + 'build/*', + ]) + def test_dangerous_removals_still_refused(self, path): + assert is_dangerous_removal_path(path, self._patterns()) + + @pytest.mark.parametrize('path', ['~', '~/']) + def test_literal_home_refused(self, path): + """`rm ~` is judged on both the written form and the expanded one. It + used to be caught only by the match-everything accident above.""" + assert is_dangerous_removal_path(path, self._patterns()) + + def test_a_real_glob_pattern_still_globs(self): + """Only a separators-and-stars entry is literal-only; a pattern with an + actual path in it keeps working as a glob.""" + assert is_dangerous_removal_path('/opt/data/db', + ('/opt/data/*', )) + assert not is_dangerous_removal_path('/opt/other/db', + ('/opt/data/*', )) diff --git a/tests/permission/test_safety.py b/tests/permission/test_safety.py index 7be5d0af1..83f0a39c3 100644 --- a/tests/permission/test_safety.py +++ b/tests/permission/test_safety.py @@ -327,3 +327,44 @@ def test_allow_network_drops_the_default_ask_rules(self): 'ask_rules': ['custom---tool'], }) assert config.ask_rules == ('custom---tool', ) + + +class TestOrdinaryRemovalReachesTheAsk: + """`rm` must be CONFIRMED, not refused. SafetyGuard is the non-bypassable + inner layer, so a denial there cannot be overridden by the mode or by the + user answering a prompt — and the default `dangerous_removal_paths` list + made every single path "dangerous", so `rm build/out.txt` was flatly + refused and no removal was possible at all.""" + + @staticmethod + def _guard(tmp_path): + from ms_agent.permission.config import SafetyConfig + from ms_agent.permission.safety import SafetyGuard + return SafetyGuard( + SafetyConfig(), + allowed_dirs=[str(tmp_path)], + workspace_root=str(tmp_path), + ) + + @pytest.mark.parametrize('command', [ + 'rm probe.txt', + 'rm -rf build', + 'rm ./dist/bundle.js', + ]) + def test_ordinary_removal_allowed_through_to_the_ask(self, tmp_path, command): + d = self._guard(tmp_path).check('code_executor---shell_executor', + {'command': command}) + assert d.action == 'allow', d.reason + + @pytest.mark.parametrize('command', [ + 'rm -rf /', + 'rm -rf /*', + 'rm *', + 'rm ~', + 'rm -rf /etc', + 'rm /usr', + ]) + def test_dangerous_removal_still_refused(self, tmp_path, command): + d = self._guard(tmp_path).check('code_executor---shell_executor', + {'command': command}) + assert d.action == 'deny', d.reason From 3d0b0e0a0d3c7a0f074741284cc34a0b2d5e9326 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 19 Aug 2026 01:59:46 +0800 Subject: [PATCH 32/36] Test the remembered pattern through the allow_always path the UI actually uses --- tests/permission/test_enforcer.py | 81 ++++++++++++++++++++++--------- 1 file changed, 59 insertions(+), 22 deletions(-) diff --git a/tests/permission/test_enforcer.py b/tests/permission/test_enforcer.py index fb6e88dac..4840bea8e 100644 --- a/tests/permission/test_enforcer.py +++ b/tests/permission/test_enforcer.py @@ -336,49 +336,67 @@ async def ask(self, tool_name, tool_args, context, suggestions=None): assert r.action == 'allow' assert Probe.asked == 0 - class TestRememberedPatternBreadth: - """A caller that names no pattern used to have the BARE TOOL NAME - remembered — for the shell that is every future command, so approving - `ls -la` once handed over unrestricted shell access.""" + """`allow_always` remembering the approval at PROJECT scope is BY DESIGN — + the user asked for "从此放行". What was wrong is WHAT got remembered: with no + pattern supplied the bare TOOL NAME was stored, and for the shell that means + every future command, so approving `ls -la` once permanently released the + entire shell — which is what made it look like the whole project had been + switched to full access.""" + + class _PatternlessAlways: + """A UI that answers the ask without naming a pattern — what the WebUI + authorization card sends.""" + + async def ask(self, tool_name, tool_args, context, suggestions=None): + return PermissionResponse(action=PermissionAction.ALLOW_ALWAYS) @pytest.mark.asyncio async def test_shell_remembers_the_command_not_the_whole_tool(self, tmp_path): - class PatternlessSession: - async def ask(self, tool_name, tool_args, context, suggestions=None): - return PermissionResponse(action=PermissionAction.ALLOW_SESSION) - memory = PermissionMemory(project_path=tmp_path) enforcer = PermissionEnforcer( config=_interactive_config(), - handler=PatternlessSession(), + handler=self._PatternlessAlways(), memory=memory, ) r = await enforcer.check('code_executor---shell_executor', {'command': 'ls -la'}) assert r.action == 'allow' - # Same command family: remembered. + # The approved command, remembered — including with no arguments at all. assert memory.matches('code_executor---shell_executor', {'command': 'ls /tmp'}) - # Including with no arguments at all. assert memory.matches('code_executor---shell_executor', {'command': 'ls'}) - # A different command is NOT covered by approving `ls`. + # A DIFFERENT command is not covered by having approved `ls`. assert not memory.matches('code_executor---shell_executor', {'command': 'rm -rf build'}) + @pytest.mark.asyncio + async def test_the_narrow_pattern_persists_to_the_project(self, tmp_path): + """The approval outliving the conversation is the FEATURE; only its + reach across commands was ever too wide.""" + enforcer = PermissionEnforcer( + config=_interactive_config(), + handler=self._PatternlessAlways(), + memory=PermissionMemory(project_path=tmp_path), + ) + await enforcer.check('code_executor---shell_executor', + {'command': 'ls -la'}) + # A fresh memory — i.e. a new conversation — reads the same file back. + reloaded = PermissionMemory(project_path=tmp_path) + assert reloaded.matches('code_executor---shell_executor', + {'command': 'ls -la'}) + assert not reloaded.matches('code_executor---shell_executor', + {'command': 'curl https://example.com'}) + @pytest.mark.asyncio async def test_argument_less_command_remembers_itself(self, tmp_path): """Approving bare `whoami` must cover `whoami` — the remembered pattern used not to match the very command it was generated from.""" - class PatternlessSession: - async def ask(self, tool_name, tool_args, context, suggestions=None): - return PermissionResponse(action=PermissionAction.ALLOW_SESSION) - memory = PermissionMemory(project_path=tmp_path) enforcer = PermissionEnforcer( config=_interactive_config(), - handler=PatternlessSession(), + handler=self._PatternlessAlways(), memory=memory, ) await enforcer.check('code_executor---shell_executor', @@ -390,16 +408,35 @@ async def ask(self, tool_name, tool_args, context, suggestions=None): async def test_fallback_never_widens_past_the_tool(self, tmp_path): """`web_search` suggests a server-wide `web_search---*`; a FALLBACK must not be broader than the tool the user actually approved.""" - class PatternlessSession: - async def ask(self, tool_name, tool_args, context, suggestions=None): - return PermissionResponse(action=PermissionAction.ALLOW_SESSION) - memory = PermissionMemory(project_path=tmp_path) enforcer = PermissionEnforcer( config=_interactive_config(), - handler=PatternlessSession(), + handler=self._PatternlessAlways(), memory=memory, ) await enforcer.check('web_search---search', {'query': 'x'}) assert memory.matches('web_search---search', {'query': 'y'}) assert not memory.matches('web_search---fetch_page', {'url': 'z'}) + + @pytest.mark.asyncio + async def test_a_caller_supplied_pattern_still_wins(self, tmp_path): + """A UI that DOES put the suggestion list in front of the user (the TUI) + keeps deciding the breadth itself — the fallback only fills a gap.""" + + class Chooses: + async def ask(self, tool_name, tool_args, context, suggestions=None): + return PermissionResponse( + action=PermissionAction.ALLOW_ALWAYS, + pattern='code_executor---shell_executor', + ) + + memory = PermissionMemory(project_path=tmp_path) + enforcer = PermissionEnforcer( + config=_interactive_config(), + handler=Chooses(), + memory=memory, + ) + await enforcer.check('code_executor---shell_executor', + {'command': 'ls -la'}) + assert memory.matches('code_executor---shell_executor', + {'command': 'rm -rf build'}) From 20b4288a65865c4a81c7d59f6cb65cfe3edfd2ca Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 19 Aug 2026 03:07:48 +0800 Subject: [PATCH 33/36] Send attached images to the model as native image content instead of a text file listing --- ms_agent/agent/agent.yaml | 50 ++ ms_agent/agent/llm_agent.py | 55 +- ms_agent/callbacks/input_callback.py | 6 +- ms_agent/command/interactive.py | 33 +- ms_agent/hooks/context.py | 12 +- ms_agent/llm/message_text.py | 125 ++++ ms_agent/llm/multimodal.py | 607 ++++++++++++++++++ ms_agent/llm/openai_llm.py | 64 +- ms_agent/llm/router.py | 27 +- ms_agent/llm/transport/anthropic_messages.py | 131 +++- ms_agent/llm/transport/openai_compat.py | 68 +- ms_agent/llm/utils.py | 39 ++ ms_agent/llm/vision.py | 246 +++++++ ms_agent/memory/unified/orchestrator.py | 8 +- ms_agent/session/context_assembler.py | 5 + .../session/strategies/summary_compactor.py | 20 +- ms_agent/session/strategies/tool_pruner.py | 21 +- ms_agent/tools/filesystem_tool.py | 46 +- ms_agent/tools/image_reader_tool.py | 243 +++++++ ms_agent/tools/tool_manager.py | 13 + ms_agent/ui/input.py | 10 + tests/llm/test_message_text.py | 102 +++ tests/llm/test_vision_fallback.py | 300 +++++++++ 23 files changed, 2183 insertions(+), 48 deletions(-) create mode 100644 ms_agent/llm/message_text.py create mode 100644 ms_agent/llm/multimodal.py create mode 100644 ms_agent/llm/vision.py create mode 100644 ms_agent/tools/image_reader_tool.py create mode 100644 tests/llm/test_message_text.py create mode 100644 tests/llm/test_vision_fallback.py diff --git a/ms_agent/agent/agent.yaml b/ms_agent/agent/agent.yaml index 7144a213e..6ce749f45 100644 --- a/ms_agent/agent/agent.yaml +++ b/ms_agent/agent/agent.yaml @@ -4,6 +4,50 @@ llm: modelscope_api_key: modelscope_base_url: https://api-inference.modelscope.cn/v1 + # Whether THIS model may be shown image attachments. Left unset on purpose: + # it is read as a tri-state, so absent means "nobody has said", which falls + # back to the provider's declared capability and then to runtime learning + # (a model that rejects an image is remembered and never shown one again). + # A hard default either way is worse — false makes a capable model silently + # ignore attachments, true burns a 400 on every text-only model's first use. + # supports_vision: true + + # Image encoding, applied at the wire boundary (ms_agent/llm/multimodal.py). + vision: + enabled: true + # Long-edge cap. 2560 sits inside DashScope's recommended range and at + # Anthropic's high-resolution tier while cutting a 4K upload ~4x. NOT 1568 + # (Anthropic's standard tier): it downsamples rather than rejecting, so + # forcing that would throw away resolution the newer tier can use. + max_edge: 2560 + # Hard ceiling on the base64 STRING length — DashScope's 10 MB limit is + # expressed that way; 8 MB leaves headroom. + max_bytes: 8388608 + # OpenAI-family `detail`. 'low' is an explicit cost lever, not a default. + detail: auto + # GIF/BMP/TIFF/HEIC -> PNG first frame. DashScope's vision docs do not list + # GIF, so transcoding gives one answer that works on every provider. + transcode: true + # Keep only the most recent N images in context (0 = unlimited). + max_images: 0 + + # OPTIONAL escape hatch for a model that cannot see images at all: a + # separately configured VISION model that describes an image as text, which + # the main model then reasons over. Lossy by construction, so it is the + # fallback, never the preferred path — when the main model can see images the + # transports show it the real pixels and this is not used. + # + # Unset by default. The `image_reader` tool is registered ONLY when a + # `model` is present here, because a tool that can only ever fail is worse + # than no tool at all. Enabling it also needs `tools.image_reader` below. + # + # auxiliary: + # service: dashscope + # model: qwen3.8-max # must be a model that CAN see images + # api_key: # optional; falls back to env / provider spec + # base_url: # optional; same + # protocol: openai # optional; 'anthropic' for that wire format + generation_config: temperature: 0.3 top_k: 20 @@ -36,6 +80,12 @@ tools: - edit_file - grep - glob + # Ask a separately configured vision model to describe an image, for a main + # model that cannot see one. Needs `llm.vision.auxiliary.model` set above; + # without it the tool is not registered. `mcp: false` marks it a built-in — + # every `tools.` without that flag is treated as an MCP server. + # image_reader: + # mcp: false code_executor: mcp: false implementation: python_env diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index 702b9422b..0ab7b225a 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -20,6 +20,8 @@ from ms_agent.callbacks import Callback, callbacks_mapping from ms_agent.knowledge_search import SirchmunkSearch from ms_agent.llm.llm import LLM +from ms_agent.llm.message_text import (append_text, flatten_message_text, + prepend_text) from ms_agent.llm.utils import Message, ToolResult from ms_agent.memory import Memory, get_memory_meta_safe, memory_mapping from ms_agent.memory.memory_manager import SharedMemoryManager @@ -315,6 +317,13 @@ def __init__( # When None, the legacy sync console_io / input() path is used. self._input_source = kwargs.get('input_source', None) + # Attachments belonging to the FIRST user turn, parked between the + # interactive read in run_loop and create_messages (whose input is a + # bare string). Cleared as soon as create_messages consumes them, so a + # later turn can never inherit the first turn's images. Mid-conversation + # turns bypass this entirely — InputCallback builds their Message. + self._pending_attachments: List[Dict[str, Any]] = [] + # Personalization (lazy-loaded in _build_personalization_section) self._profile_manager = ProfileManager() @@ -665,7 +674,7 @@ async def on_task_begin(self, messages: List[Message]): self.log_output(f'Agent {self.tag} task beginning.') if self.resolve_enable_snapshots(self.config): _user_content = next( - ((getattr(m, 'content', '') or '')[:80] + (flatten_message_text(getattr(m, 'content', ''))[:80] for m in messages if getattr(m, 'role', '') == 'user'), '', ) @@ -759,6 +768,10 @@ def _on_result(index: int, tool_call, raw, duration_s: float) -> None: tool_detail=tool_call_result_format.tool_detail, hook_attachments=tool_call_result_format.hook_attachments, is_error=tool_call_result_format.is_error, + # Images the tool produced. Carried on the tool Message so the + # transports can put them in the IMAGE channel; the text channel + # keeps only the short status. + attachments=tool_call_result_format.attachments, ) if _new_message.tool_call_id is None: @@ -1245,8 +1258,17 @@ async def create_messages( ), f'inputs can be either a list or a string, but current is {type(messages)}' messages = [ Message(role='system', content=''), - Message(role='user', content=messages or self.query), + Message( + role='user', + content=messages or self.query, + # Attachments for the FIRST turn. The interactive read that + # produced this prompt happens in run_loop, which stashes + # them here — the string-in signature cannot carry them, and + # a session's first message is exactly when a user attaches + # something. + attachments=self._pending_attachments or []), ] + self._pending_attachments = [] messages[0].content = self._build_system_content() @@ -1437,8 +1459,11 @@ async def _attach_memory_recall(self, messages: List[Message]) -> None: last = messages[-1] if getattr(last, 'role', None) != 'user': return - content = last.content - if not isinstance(content, str): + # Read the text out of whatever shape the content is in, rather than + # bailing on a block list: a multimodal turn that silently got no memory + # recall is a far worse outcome than one whose query came from its text. + content = flatten_message_text(last.content) + if not content: return # The turn may already carry other blocks (skill # update notice prefixed by the host, prompt-files update notice) — @@ -1462,7 +1487,9 @@ async def _attach_memory_recall(self, messages: List[Message]) -> None: if block: if block in content: return # marker-less backend, identical block attached - last.content = f'{last.content}\n\n{block}' + # append_text keeps the shape: a str grows, a block list gains a + # trailing text block (concatenating onto a list would raise). + last.content = append_text(last.content, block) return # ── prompt-files update notices (hot-reload perception) ────────────── @@ -1535,8 +1562,7 @@ def _attach_prompt_update_notice(self, messages: List[Message]): if not messages: return None last = messages[-1] - if getattr(last, 'role', None) != 'user' or not isinstance( - last.content, str): + if getattr(last, 'role', None) != 'user': return None baseline = self._prompt_surface @@ -1559,7 +1585,10 @@ def _attach_prompt_update_notice(self, messages: List[Message]): return None notice = workspace_files.render_update_notice(changed) - last.content = f'{notice}\n\n{last.content}' + # Shape-preserving prepend; on a block list the notice becomes the first + # text block, which also matches the providers' label-before-payload + # preference. + last.content = prepend_text(last.content, notice) return lambda: self._commit_prompt_surface(current) async def condense_memory(self, messages: List[Message]) -> List[Message]: @@ -2222,6 +2251,12 @@ def _msg_to_dict(msg: Message) -> Dict[str, Any]: d: Dict[str, Any] = {'role': msg.role, 'content': msg.content or ''} if msg.tool_calls: d['tool_calls'] = msg.tool_calls + # Image refs must survive to disk: the SessionLog is the source of truth + # a resumed session rebuilds context from, so dropping them here means + # attached images vanish on reload (and on every context reassembly). + # They are references, not bytes — cheap to persist. + if getattr(msg, 'attachments', None): + d['attachments'] = msg.attachments if hasattr(msg, 'tool_call_id') and msg.tool_call_id: d['tool_call_id'] = msg.tool_call_id if hasattr(msg, 'name') and msg.name: @@ -2369,6 +2404,10 @@ async def run_loop(self, messages: Union[List[Message], str], await self.cleanup_tools() return messages = turn.text + # create_messages() below builds the user Message from + # this string, so hand the turn's attachments over + # out-of-band rather than widening that signature. + self._pending_attachments = turn.attachments else: # Non-interactive with no task: accept piped stdin as the # query; otherwise fail clearly instead of blocking input(). diff --git a/ms_agent/callbacks/input_callback.py b/ms_agent/callbacks/input_callback.py index 9b4df84b7..7eb8e84b9 100644 --- a/ms_agent/callbacks/input_callback.py +++ b/ms_agent/callbacks/input_callback.py @@ -58,4 +58,8 @@ async def after_tool_call(self, runtime: Runtime, messages: List[Message]): runtime.should_stop = True return runtime.should_stop = False - messages.append(Message(role='user', content=turn.text)) + messages.append( + Message( + role='user', + content=turn.text, + attachments=turn.attachments)) diff --git a/ms_agent/command/interactive.py b/ms_agent/command/interactive.py index c0b940819..ef12bf3c6 100644 --- a/ms_agent/command/interactive.py +++ b/ms_agent/command/interactive.py @@ -11,8 +11,8 @@ """ from __future__ import annotations -from dataclasses import dataclass -from typing import Any, List, Optional +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional from ms_agent.command.router import CommandRouter from ms_agent.command.types import CommandContext, CommandResultType @@ -28,6 +28,10 @@ class InteractiveTurn: action: str text: Optional[str] = None + #: Non-text parts the input source attached to this turn (images). Stays + #: separate from ``text`` all the way to ``Message.attachments`` — a CLI + #: never sets it, a WebUI composer does. + attachments: List[Dict[str, Any]] = field(default_factory=list) class InteractiveSession: @@ -49,6 +53,26 @@ def __init__(self, # When None, plain print() is used (CLI). self._event_sink = event_sink + def _take_attachments(self) -> List[Dict[str, Any]]: + """Non-text parts the input source queued for the prompt just read. + + Optional protocol method: a plain CLI/TUI has no attachments and does + not implement it, so this returns ``[]`` and nothing downstream changes. + Called immediately after ``read_prompt`` returns, so the attachments and + the text belong to the same submission — hence "take": the source hands + them over once and clears them. + """ + source = self._input_source + if source is None: + return [] + take = getattr(source, 'take_attachments', None) + if take is None: + return [] + try: + return list(take() or []) + except Exception: # an input source must never break the turn + return [] + async def run_turn( self, messages: Optional[List[Any]] = None, @@ -84,7 +108,10 @@ async def run_turn( if self._event_sink is not None: from ms_agent.ui.events import UserMessage self._event_sink.emit(UserMessage(text=query)) - return InteractiveTurn(action='submit', text=query) + return InteractiveTurn( + action='submit', + text=query, + attachments=self._take_attachments()) cmd_name, args = self._router.parse_input(query) ctx = CommandContext( diff --git a/ms_agent/hooks/context.py b/ms_agent/hooks/context.py index 0186eaf5d..2b752190d 100644 --- a/ms_agent/hooks/context.py +++ b/ms_agent/hooks/context.py @@ -100,8 +100,16 @@ def condense_hook_attachments_for_llm( def extract_latest_user_prompt(messages: list[Message]) -> str: + """The latest user turn's text, for hooks that inspect what was asked. + + A block list is reduced to its text rather than ``str()``-ed: a hook that + matches on the prompt would otherwise be handed a Python repr and silently + stop matching (and UserPromptSubmit echoes this value back into the + conversation on a block, so the repr would become visible). + """ + from ms_agent.llm.message_text import flatten_message_text + for msg in reversed(messages): if msg.role == 'user': - return msg.content if isinstance(msg.content, str) else str( - msg.content) + return flatten_message_text(msg.content) return '' diff --git a/ms_agent/llm/message_text.py b/ms_agent/llm/message_text.py new file mode 100644 index 000000000..0149e22cb --- /dev/null +++ b/ms_agent/llm/message_text.py @@ -0,0 +1,125 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""One way to read the text out of a message, whatever shape its content is in. + +``Message.content`` is typed ``Union[str, List[Dict[str, str]]]``, so a caller +may legitimately hand the framework a provider-shaped block list. About twenty +places read a user message's content expecting a string — memory extraction, +full-text indexing, session auto-naming, summary compaction, snapshot labels, +hook prompt extraction — and none of them fails loudly on a list: they store a +Python repr, or a guard skips the message entirely. Both are silent, and the +symptom shows up weeks later as a garbled memory row or a nonsense session name. + +Image attachments in this codebase ride on ``Message.attachments`` precisely so +that ``content`` stays a string and those call sites keep working untouched. This +module is the belt to that suspenders: the highest-value of those sites route +through it, so a block list arriving from anywhere degrades to "the text of it" +rather than to garbage. + +Mirrors hermes-agent's ``agent/message_content.py``, which exists for the same +reason. +""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, List + +#: Block kinds that carry no readable text. Listed rather than inferred so a new +#: modality is a deliberate edit here instead of silently stringifying its bytes. +_NON_TEXT_BLOCKS = frozenset({ + 'image', + 'image_url', + 'input_image', + 'audio', + 'input_audio', + 'video', + 'input_video', + 'file', + 'document', +}) + +#: Keys a text-bearing block may use, in preference order. ``content`` is last: +#: it is the most generic and the most likely to hold something structured. +_TEXT_KEYS = ('text', 'input_text', 'output_text', 'summary_text', 'content') + + +def _field(value: Any, key: str) -> Any: + if isinstance(value, Mapping): + return value.get(key) + return getattr(value, key, None) + + +def _block_text(block: Any) -> str: + if block is None: + return '' + if isinstance(block, str): + return block + kind = str(_field(block, 'type') or '').strip().lower() + if kind in _NON_TEXT_BLOCKS: + return '' + for key in _TEXT_KEYS: + value = _field(block, key) + if isinstance(value, str): + return value + return '' + + +def flatten_message_text(content: Any, *, sep: str = '\n') -> str: + """The readable text of ``content``, for any shape it can legitimately take. + + * ``str`` -> itself (the overwhelmingly common case, returned unchanged so + no caller's behaviour shifts); + * ``list`` of blocks -> the text blocks joined by ``sep``; image/audio/video + blocks contribute nothing rather than their base64; + * anything else -> its own text field if it has one, else ``str()``. + + Never raises, and never returns None — callers use the result in prompts, + hashes and filenames. + """ + if content is None: + return '' + if isinstance(content, str): + return content + if isinstance(content, (list, tuple)): + parts: List[str] = [_block_text(block) for block in content] + return sep.join(part for part in parts if part) + text = _block_text(content) + if text: + return text + try: + return str(content) + except Exception: + return '' + + +def append_text(content: Any, extra: str, *, sep: str = '\n\n') -> Any: + """``content`` with ``extra`` appended, preserving its shape. + + A string grows; a block list gains a trailing text block. Used where the + framework augments a user turn in place (memory recall, update notices) — + concatenating a string onto a list would raise, and replacing the list with + a string would drop whatever non-text blocks it carried. + """ + if not extra: + return content + if isinstance(content, str) or content is None: + base = content or '' + return f'{base}{sep}{extra}' if base else extra + if isinstance(content, (list, tuple)): + return [*content, {'type': 'text', 'text': extra}] + return f'{flatten_message_text(content)}{sep}{extra}' + + +def prepend_text(content: Any, extra: str, *, sep: str = '\n\n') -> Any: + """``content`` with ``extra`` in front, preserving its shape. + + A block list gets the text block FIRST, which also matches the providers' + preference for a short label ahead of the payload. + """ + if not extra: + return content + if isinstance(content, str) or content is None: + base = content or '' + return f'{extra}{sep}{base}' if base else extra + if isinstance(content, (list, tuple)): + return [{'type': 'text', 'text': extra}, *content] + return f'{extra}{sep}{flatten_message_text(content)}' diff --git a/ms_agent/llm/multimodal.py b/ms_agent/llm/multimodal.py new file mode 100644 index 000000000..505fd5582 --- /dev/null +++ b/ms_agent/llm/multimodal.py @@ -0,0 +1,607 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Image attachments: internal references in, provider-native blocks out. + +A user turn carries images on ``Message.attachments`` as REFERENCES:: + + {'type': 'image', 'path': 'user_files/a.png', + 'media_type': 'image/png', 'label': 'Image 1: a.png'} + +Nothing upstream of the wire ever holds the bytes. This module is the single +place that resolves a reference to actual pixels, and it does so at the last +possible moment — inside a transport's ``_format_input_message``. That timing is +the whole point: + +* **The right encoding is provider- and model-specific.** Anthropic auto-downsamples + above 1568 px (2576 px on its high-resolution tier) and takes GIF; DashScope's + vision docs list only JPEG/PNG/WebP and cap the base64 string at 10 MB. Baking + bytes into the SessionLog would freeze one provider's answer forever. +* **A session can change models mid-conversation.** With references, switching to a + text-only model degrades the same log to text placeholders, and switching back + makes the images visible again. With inlined base64 neither direction works. +* **Re-encoding is cheap and cacheable**, while re-writing a log is not. + +Ordering follows Anthropic's guidance (image-then-text reads best) and each image +is introduced by its own ``Image N: `` text block, which is what lets a +follow-up question say "the second image" and land on the right one. +""" +from __future__ import annotations + +import base64 +import io +import mimetypes +import os +from dataclasses import dataclass, field +from functools import lru_cache +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from ms_agent.utils import get_logger + +logger = get_logger() + +#: Media types every supported provider accepts. GIF is deliberately absent: +#: Anthropic and OpenAI take it, DashScope's vision documentation does not list +#: it, so it is transcoded (first frame) to PNG for a single cross-provider +#: answer — which also resolves "animations are unsupported, first frame is used". +SUPPORTED_MEDIA_TYPES = frozenset({'image/png', 'image/jpeg', 'image/webp'}) + +#: Transcoded to PNG rather than rejected. +TRANSCODE_MEDIA_TYPES = frozenset( + {'image/gif', 'image/bmp', 'image/tiff', 'image/heic', 'image/heif'}) + +#: Flat per-image token cost for the LOCAL context estimator. Not a billing +#: figure — the providers charge by patch count off the pixel dimensions +#: (Anthropic ``⌈w/28⌉ × ⌈h/28⌉``; DashScope reports the real number back as +#: ``prompt_tokens_details.image_tokens``). This exists so the estimator stops +#: measuring base64 character count, which over-counts by ~26x even for a 22 KB +#: image (measured: 282 real vs 7,293 estimated). +IMAGE_TOKEN_ESTIMATE = 800 + + +@dataclass(frozen=True) +class VisionOptions: + """Resolved ``llm.vision`` config plus the root relative paths resolve against.""" + + #: False => never send pixels; every image degrades to a text placeholder. + enabled: bool = True + #: Long-edge cap. 2560 sits inside all three vendors' safe range while + #: cutting a 4K upload ~4x. Deliberately NOT 1568: Anthropic downsamples + #: rather than rejecting, so forcing its standard-tier limit would throw + #: away resolution its high-resolution tier (2576 px) can use. + max_edge: int = 2560 + #: Hard ceiling on the base64 STRING length (DashScope's limit is expressed + #: that way). 8 MB leaves 2 MB of headroom under its 10 MB. + max_bytes: int = 8 * 1024 * 1024 + #: OpenAI-family ``detail``; 'low' is an explicit cost lever. + detail: str = 'auto' + #: Transcode GIF/BMP/TIFF/HEIC to PNG instead of skipping them. + transcode: bool = True + #: Keep only the most recent N images in context (0 = unlimited). + max_images: int = 0 + #: Directory workspace-relative ``path`` values resolve against. + workspace_root: str = '' + + @classmethod + def from_config(cls, + config: Any, + workspace_root: str = '') -> 'VisionOptions': + """Build from an agent ``config`` (``llm.vision`` block, all optional).""" + vision = None + llm = getattr(config, 'llm', None) + if llm is not None: + vision = getattr(llm, 'vision', None) + root = workspace_root or str(getattr(config, 'output_dir', '') or '') + + def pick(name: str, default): + if vision is None: + return default + value = getattr(vision, name, None) + return default if value is None else value + + return cls( + enabled=bool(pick('enabled', True)), + max_edge=int(pick('max_edge', cls.max_edge)), + max_bytes=int(pick('max_bytes', cls.max_bytes)), + detail=str(pick('detail', cls.detail)), + transcode=bool(pick('transcode', True)), + max_images=int(pick('max_images', 0)), + workspace_root=root, + ) + + +@dataclass(frozen=True) +class ImageRef: + """One normalized image attachment reference.""" + + path: str + media_type: str + label: str = '' + + @property + def filename(self) -> str: + return os.path.basename(self.path) or self.path + + +def _guess_media_type(path: str, declared: str = '') -> str: + if declared and declared.startswith('image/'): + return declared.lower() + guessed = mimetypes.guess_type(path)[0] or '' + return guessed.lower() if guessed.startswith('image/') else '' + + +def image_refs(attachments: Optional[Sequence[Dict[str, Any]]], + opts: Optional[VisionOptions] = None) -> List[ImageRef]: + """The image attachments of a message, in order, as normalized refs. + + Non-image entries and entries whose type this build cannot render are + dropped; ``max_images`` keeps the most RECENT ones (a later image is more + likely what the current question is about). + """ + if not attachments: + return [] + opts = opts or VisionOptions() + refs: List[ImageRef] = [] + for index, item in enumerate(attachments): + if not isinstance(item, dict) or item.get('type') != 'image': + continue + path = str(item.get('path') or '') + if not path: + continue + media_type = _guess_media_type(path, str(item.get('media_type') or '')) + if not media_type: + logger.warning( + '[vision] attachment %s has no recognizable image type; skipped', + path) + continue + label = str(item.get('label') or '') + refs.append(ImageRef(path=path, media_type=media_type, label=label)) + if opts.max_images and len(refs) > opts.max_images: + dropped = len(refs) - opts.max_images + logger.info( + '[vision] %d image(s) dropped from context (max_images=%d); ' + 'the most recent %d are kept', dropped, opts.max_images, + opts.max_images) + refs = refs[-opts.max_images:] + return refs + + +def _resolve_path(path: str, root: str) -> str: + if os.path.isabs(path): + return os.path.normpath(path) + if root: + return os.path.normpath(os.path.join(root, path)) + return os.path.normpath(path) + + +def _encode(data: bytes) -> str: + return base64.b64encode(data).decode('ascii') + + +def _pil(): + try: + from PIL import Image # noqa: F401 + return Image + except ImportError: # pragma: no cover - pillow is a base dependency + return None + + +#: Progressive JPEG quality ladder used only when a resize alone cannot get the +#: encoded size under budget. Mirrors opencode's approach. +_JPEG_QUALITIES = (85, 75, 60, 45) + + +def _shrink(raw: bytes, media_type: str, + opts: VisionOptions) -> Tuple[str, str]: + """Return ``(base64, media_type)`` within ``opts`` limits. + + Proactive rather than send-and-retry: Anthropic silently downsamples instead + of rejecting, so a reactive strategy would never fire there and we would + upload full-resolution images for nothing. + """ + Image = _pil() + encoded = _encode(raw) + needs_transcode = media_type not in SUPPORTED_MEDIA_TYPES + if Image is None: + if needs_transcode: + raise ValueError( + f'{media_type} needs transcoding but Pillow is unavailable') + return encoded, media_type + + with Image.open(io.BytesIO(raw)) as img: + # Animated source: the vendors only look at the first frame anyway. + try: + img.seek(0) + except (EOFError, ValueError): + pass + width, height = img.size + oversize = max(width, height) > opts.max_edge + if not needs_transcode and not oversize and len( + encoded) <= opts.max_bytes: + return encoded, media_type + + has_alpha = img.mode in ('RGBA', 'LA') or (img.mode == 'P' and + 'transparency' in img.info) + frame = img.convert('RGBA' if has_alpha else 'RGB') + if oversize: + scale = opts.max_edge / float(max(width, height)) + frame = frame.resize( + (max(1, round(width * scale)), max(1, round(height * scale))), + Image.LANCZOS) + + # PNG first when it is likely to both fit and matter: transparency must + # not be flattened, and the images users attach to a chat are mostly + # screenshots/diagrams/text, where JPEG ringing is exactly what makes + # small type unreadable — the one thing the model is being asked to + # read. For a large photo PNG would be huge and pointless, so only try + # it under ~2 MP; the JPEG ladder below is the fallback either way. + pixels = frame.size[0] * frame.size[1] + prefer_png = has_alpha or pixels <= 2_000_000 + candidates: List[Tuple[str, str]] = [] + if prefer_png: + buf = io.BytesIO() + frame.save(buf, format='PNG', optimize=True) + candidates.append((_encode(buf.getvalue()), 'image/png')) + for quality in _JPEG_QUALITIES: + buf = io.BytesIO() + frame.convert('RGB').save( + buf, format='JPEG', quality=quality, optimize=True) + candidates.append((_encode(buf.getvalue()), 'image/jpeg')) + if not prefer_png: + # Lossless last resort for a big image the ladder could not fit. + buf = io.BytesIO() + frame.save(buf, format='PNG', optimize=True) + candidates.append((_encode(buf.getvalue()), 'image/png')) + + for encoded_candidate, candidate_type in candidates: + if len(encoded_candidate) <= opts.max_bytes: + return encoded_candidate, candidate_type + + # Still too big at the lowest quality: halve the edge and recurse once + # per step until it fits or the image is degenerate. + edge = max(frame.size) + while edge > 64: + edge = int(edge * 0.6) + scale = edge / float(max(frame.size)) + small = frame.convert('RGB').resize( + (max(1, round(frame.size[0] * scale)), + max(1, round(frame.size[1] * scale))), Image.LANCZOS) + buf = io.BytesIO() + small.save(buf, format='JPEG', quality=60, optimize=True) + encoded_candidate = _encode(buf.getvalue()) + if len(encoded_candidate) <= opts.max_bytes: + return encoded_candidate, 'image/jpeg' + raise ValueError( + f'cannot bring image under {opts.max_bytes} base64 bytes') + + +@lru_cache(maxsize=64) +def _load_cached(abs_path: str, mtime: float, size: int, media_type: str, + max_edge: int, max_bytes: int, + transcode: bool) -> Tuple[str, str]: + """``(base64, media_type)``, memoized on the file identity + encode params. + + The whole history is re-sent every round, so without this the same image is + re-read and re-encoded on every single request of a conversation. + """ + with open(abs_path, 'rb') as handle: + raw = handle.read() + if media_type in TRANSCODE_MEDIA_TYPES and not transcode: + raise ValueError(f'{media_type} is not accepted and transcode is off') + opts = VisionOptions( + max_edge=max_edge, max_bytes=max_bytes, transcode=transcode) + return _shrink(raw, media_type, opts) + + +def load_image(ref: ImageRef, + opts: VisionOptions) -> Optional[Tuple[str, str]]: + """``(base64, media_type)`` for one ref, or None when it cannot be sent. + + Never raises: a missing file or an un-encodable image must degrade to a text + placeholder, not kill the turn. + """ + abs_path = _resolve_path(ref.path, opts.workspace_root) + try: + stat = os.stat(abs_path) + except OSError as exc: + logger.warning('[vision] cannot read %s: %s', abs_path, exc) + return None + try: + return _load_cached(abs_path, stat.st_mtime, stat.st_size, + ref.media_type, opts.max_edge, opts.max_bytes, + opts.transcode) + except Exception as exc: # encode/transcode failure + logger.warning('[vision] cannot encode %s: %s', abs_path, exc) + return None + + +def placeholder_for(ref: ImageRef, reason: str = '') -> str: + """The text a model sees in place of an image it cannot be shown. + + Written for the model to be able to explain itself: a user who asks "what's + in this picture" must get an answer that says why it cannot see it and what + to do, not a silent non-answer. + """ + head = ref.label or f'Image: {ref.filename}' + body = (f'[{head} — not shown as an image. {reason} ' + f'The file is in the workspace at "{ref.path}".]') + return body + + +#: Reason strings, kept here so the wording is identical across transports. +REASON_DISABLED = ( + 'Image understanding is not enabled for the current model. ' + 'Tell the user they can turn on "image understanding" for ' + 'this model in Settings → Models, or switch to a model that ' + 'supports it.') +REASON_UNREADABLE = ('The file could not be read or decoded as an image.') + + +def _label_block(ref: ImageRef, index: int) -> Dict[str, str]: + """The ``Image N: `` introducer. + + Anthropic's own guidance: label each image with a short text block so it can + be referred to by name in this turn and in later ones. The ordinal carries + "the second image"; the filename carries "the chart one". + """ + return { + 'type': 'text', + 'text': ref.label or f'Image {index}: {ref.filename}', + } + + +def _degrade(text: str, refs: Sequence[ImageRef], reason: str) -> str: + """Fold every image into the text turn as placeholders.""" + notes = [placeholder_for(ref, reason) for ref in refs] + joined = '\n'.join(notes) + return f'{joined}\n\n{text}' if text else joined + + +def openai_content(text: Any, + attachments: Optional[Sequence[Dict[str, Any]]], + opts: VisionOptions, + vision_supported: bool = True) -> Any: + """Content for an OpenAI-compatible (Chat Completions) user message. + + Returns a plain string when there is nothing to attach — keeping the + overwhelmingly common text-only request byte-identical to before, which also + means prefix caching is unaffected. + """ + refs = image_refs(attachments, opts) + if not refs: + return text + if not (opts.enabled and vision_supported): + return _degrade( + text if isinstance(text, str) else '', refs, REASON_DISABLED) + + blocks: List[Dict[str, Any]] = [] + unreadable: List[ImageRef] = [] + for index, ref in enumerate(refs, start=1): + loaded = load_image(ref, opts) + if loaded is None: + unreadable.append(ref) + continue + encoded, media_type = loaded + blocks.append(_label_block(ref, index)) + image_url: Dict[str, Any] = { + 'url': f'data:{media_type};base64,{encoded}' + } + if opts.detail and opts.detail != 'auto': + image_url['detail'] = opts.detail + blocks.append({'type': 'image_url', 'image_url': image_url}) + + if not blocks: # every image failed to load + return _degrade( + text if isinstance(text, str) else '', unreadable, + REASON_UNREADABLE) + + tail = text if isinstance(text, str) else '' + if unreadable: + tail = _degrade(tail, unreadable, REASON_UNREADABLE) + if tail: + blocks.append({'type': 'text', 'text': tail}) + return blocks + + +def anthropic_content(text: Any, + attachments: Optional[Sequence[Dict[str, Any]]], + opts: VisionOptions, + vision_supported: bool = True) -> Any: + """Content blocks for an Anthropic Messages user message. + + Same contract as :func:`openai_content`; only the block shape differs + (``{'type':'image','source':{'type':'base64',...}}``). + """ + refs = image_refs(attachments, opts) + if not refs: + return text + if not (opts.enabled and vision_supported): + return _degrade( + text if isinstance(text, str) else '', refs, REASON_DISABLED) + + blocks: List[Dict[str, Any]] = [] + unreadable: List[ImageRef] = [] + for index, ref in enumerate(refs, start=1): + loaded = load_image(ref, opts) + if loaded is None: + unreadable.append(ref) + continue + encoded, media_type = loaded + blocks.append(_label_block(ref, index)) + blocks.append({ + 'type': 'image', + 'source': { + 'type': 'base64', + 'media_type': media_type, + 'data': encoded, + }, + }) + + if not blocks: + return _degrade( + text if isinstance(text, str) else '', unreadable, + REASON_UNREADABLE) + + tail = text if isinstance(text, str) else '' + if unreadable: + tail = _degrade(tail, unreadable, REASON_UNREADABLE) + if tail: + blocks.append({'type': 'text', 'text': tail}) + return blocks + + +def has_image_blocks(content: Any) -> bool: + """True when already-built content carries a provider-native image block. + + Used by the refusal fallback to know whether THIS request actually shipped + pixels — the only reliable signal, since a provider's rejection text may not + mention images at all (measured on DashScope: "Unexpected item type in + content", no mention of image/multimodal/vision). + """ + if not isinstance(content, list): + return False + for item in content: + if not isinstance(item, dict): + continue + if item.get('type') in ('image_url', 'image', 'input_image'): + return True + return False + + +#: What the model is told in place of an image the endpoint just refused. +#: Deliberately as informative as the proactive placeholder: the user asked +#: about a picture, so a bare "not available" makes the model reply "please +#: upload the image" — which is both wrong (it WAS uploaded) and unactionable. +#: Measured before this text existed, qwen3.7-max answered exactly that. +REASON_REFUSED = ( + 'not visible: this model rejected image input. The file was uploaded and is ' + 'in the workspace under the name shown above. Tell the user this model ' + 'cannot view images, and that they can enable "image understanding" for it ' + 'in Settings → Models or switch to a model that supports vision.') + + +def strip_image_blocks(content: Any) -> Any: + """``content`` with image blocks replaced by an explanatory text marker. + + The retry after a refusal must still say WHAT was dropped and WHY, or the + model answers a question about an image it was never told about. The + preceding ``Image N: `` label block survives, so the marker only + has to supply the reason and the remedy. + """ + if not isinstance(content, list): + return content + texts: List[str] = [] + for item in content: + if not isinstance(item, dict): + continue + if item.get('type') == 'text': + value = str(item.get('text') or '') + if value: + texts.append(value) + elif item.get('type') in ('image_url', 'image', 'input_image'): + texts.append(f'[{REASON_REFUSED}]') + return '\n'.join(texts) + + +def estimate_content_tokens(content: Any, text_estimator) -> int: + """Token estimate for possibly-multimodal content. + + ``text_estimator`` scores a string. Image blocks get a flat + :data:`IMAGE_TOKEN_ESTIMATE` each instead of having their base64 measured as + text — the bug this exists to prevent inflates a single 2 MiB PNG to + ~699k tokens against a ~108k budget, which re-fires compaction every round. + """ + if content is None: + return 0 + if isinstance(content, str): + return text_estimator(content) + if not isinstance(content, list): + return text_estimator(str(content)) + total = 0 + for item in content: + if not isinstance(item, dict): + total += text_estimator(str(item)) + continue + kind = item.get('type') + if kind in ('image_url', 'image', 'input_image'): + total += IMAGE_TOKEN_ESTIMATE + elif kind == 'text': + total += text_estimator(str(item.get('text') or '')) + else: + # Unknown block: measure its text-ish payload, never its raw bytes. + total += text_estimator(str(item.get('text') or '')) + return total + + +#: Introduces images hoisted out of a tool result into their own user turn. +#: +#: Why hoist at all: the Chat Completions SCHEMA restricts a ``role: "tool"`` +#: message to text. OpenAI's own generated types say +#: ``Union[str, Iterable[ChatCompletionContentPartTextParam]]`` for a tool +#: message, versus the wider union (text | image_url | input_audio | file) for a +#: user message. The Responses API is different — its ``function_call_output`` +#: does allow image content — which is why AI-SDK-based clients report "OpenAI +#: supports media in tool results"; they are on that API, we are on this one. +#: +#: Measured (2026-08) against five OpenAI-compatible providers — DashScope, +#: ModelScope, OpenRouter, Kimi, MiniMax — inline image parts in a tool message +#: were accepted and read correctly by all five, i.e. they are more permissive +#: than the schema. Hoisting is kept anyway because it is valid under BOTH the +#: schema and every provider tested, whereas inline is valid only under the +#: latter; real OpenAI (the one endpoint whose schema forbids it) was not +#: testable here. Same reasoning as any other spec-vs-practice split: prefer the +#: form that cannot be wrong. +#: +#: The Anthropic transport does NOT hoist — that protocol allows image blocks +#: inside ``tool_result``, so there the image stays attached to the call that +#: produced it. Mirrors opencode's SYNTHETIC_ATTACHMENT_PROMPT. +TOOL_MEDIA_PROMPT = 'Images returned by the tool call above:' + + +def openai_tool_media_message( + attachments: Sequence[Dict[str, Any]], + opts: VisionOptions, + vision_supported: bool = True) -> Optional[Dict[str, Any]]: + """A synthetic user message carrying a tool result's images, or None. + + Returns None when there is nothing to show — no images, images disabled, or + none of them could be loaded — so the caller appends nothing and the tool's + own text stands on its own. + """ + refs = image_refs(attachments, opts) + if not refs or not (opts.enabled and vision_supported): + return None + content = openai_content( + TOOL_MEDIA_PROMPT, attachments, opts, vision_supported=True) + if not isinstance(content, list): + return None # every image failed to load; the tool text already says so + return {'role': 'user', 'content': content} + + +def anthropic_tool_result_blocks( + attachments: Sequence[Dict[str, Any]], + opts: VisionOptions, + vision_supported: bool = True) -> List[Dict[str, Any]]: + """Image blocks to nest INSIDE an Anthropic ``tool_result``. + + Anthropic allows image blocks in tool_result content, so the image can stay + attached to the call that produced it — strictly better than hoisting, since + the association survives without relying on message order. + """ + refs = image_refs(attachments, opts) + if not refs or not (opts.enabled and vision_supported): + return [] + blocks: List[Dict[str, Any]] = [] + for index, ref in enumerate(refs, start=1): + loaded = load_image(ref, opts) + if loaded is None: + continue + encoded, media_type = loaded + blocks.append(_label_block(ref, index)) + blocks.append({ + 'type': 'image', + 'source': { + 'type': 'base64', + 'media_type': media_type, + 'data': encoded, + }, + }) + return blocks diff --git a/ms_agent/llm/openai_llm.py b/ms_agent/llm/openai_llm.py index de7163990..15c15d2ca 100644 --- a/ms_agent/llm/openai_llm.py +++ b/ms_agent/llm/openai_llm.py @@ -10,9 +10,10 @@ ChatCompletionMessageToolCall, Function) from typing import Any, Dict, Generator, Iterable, List, Optional -from ms_agent.llm import LLM +from ms_agent.llm import LLM, multimodal from ms_agent.llm.thinking import apply_effort, create_with_thinking_fallback from ms_agent.llm.utils import Message, Tool, ToolCall +from ms_agent.llm.vision import create_with_vision_fallback from ms_agent.utils import (MAX_CONTINUE_RUNS, assert_package_exist, get_logger, retry) from ms_agent.utils.constants import get_service_config @@ -97,6 +98,20 @@ def __init__( float(_read_timeout), connect=float(_connect_timeout)), ) self.base_url = base_url or '' + + # Image attachments (legacy non-router path). Resolution mirrors the + # router's: an explicit per-model switch wins, else the service's + # declared capability, else runtime learning from a refusal. + from ms_agent.llm.spec import get_registry + from ms_agent.llm.vision import resolve_supports_vision + self._vision = multimodal.VisionOptions.from_config(config) + _service = getattr(config.llm, 'service', None) + self._vision_supported = resolve_supports_vision( + config, + spec=get_registry().get(_service) + or get_registry().resolve_by_model(self.model), + model=self.model, + base_url=self.base_url) self.args: Dict = OmegaConf.to_container( getattr(config, 'generation_config', DictConfig({}))) @@ -302,10 +317,23 @@ def _call_llm(self, kwargs.setdefault('stream_options', {})['include_usage'] = True # Thinking is per-model and a refusal is a hard 400 (see llm/thinking.py). - return create_with_thinking_fallback( - lambda **kw: self.client.chat.completions.create( - model=self.model, messages=messages, tools=tools, **kw), - self.client, self.model, logger, **kwargs) + # Image content is a per-model hard 400 on text-only models; retry once + # with the images folded into text and remember the model. Composes with + # the thinking fallback (each retries for its own reason). + sent_images = any( + multimodal.has_image_blocks(m.get('content')) + for m in messages if isinstance(m, dict)) + return create_with_vision_fallback( + lambda messages, **kw: create_with_thinking_fallback( + lambda **kw2: self.client.chat.completions.create( + model=self.model, messages=messages, tools=tools, **kw2), + self.client, self.model, logger, **kw), + base_url=getattr(self.client, 'base_url', ''), + model=self.model, + messages=messages, + sent_images=sent_images, + logger_=logger, + **kwargs) @staticmethod def _extract_cache_info(usage_obj: Any) -> tuple: @@ -1039,6 +1067,9 @@ def _format_input_message(self, openai_messages = [] for idx, message in enumerate(messages): + # Read image refs BEFORE to_dict_clean(), which strips them. + attachments = (message.attachments if isinstance(message, Message) + else message.get('attachments')) or [] if isinstance(message, Message): # Only strip string content, keep list content as-is for multimodal if isinstance(message.content, str): @@ -1046,12 +1077,26 @@ def _format_input_message(self, message = message.to_dict_clean() else: message = dict(message) + message.pop('attachments', None) content = message.get('content', '') # Only strip string content, multimodal content (list) should be kept as-is if isinstance(content, str): content = content.strip() + # Image refs -> native image_url blocks. A turn with no attachments + # returns the same plain string, so text-only requests are unchanged. + # Not for a tool message: the Chat Completions SCHEMA allows + # only text parts in `role: "tool"`, so its images go to the + # synthetic user turn appended after it (below). Five compatible + # providers were measured to accept inline image parts here anyway, + # but hoisting is valid under the schema AND under all of them — see + # multimodal.TOOL_MEDIA_PROMPT for the full measurement. + if attachments and message.get('role') != 'tool': + content = multimodal.openai_content( + content, attachments, self._vision, + vision_supported=self._vision_supported) + # Apply prefix cache structured content transformation # Only for string content, multimodal content is already structured if cache_indice is not None and idx == cache_indice: @@ -1084,4 +1129,13 @@ def _format_input_message(self, openai_messages.append(formatted_message) + # Tool-result images: same hoist as OpenAICompatTransport (the + # Chat Completions schema restricts a tool message to text parts). + if attachments and message.get('role') == 'tool': + media = multimodal.openai_tool_media_message( + attachments, self._vision, + vision_supported=self._vision_supported) + if media is not None: + openai_messages.append(media) + return openai_messages diff --git a/ms_agent/llm/router.py b/ms_agent/llm/router.py index 54a38954f..c20a2fac1 100644 --- a/ms_agent/llm/router.py +++ b/ms_agent/llm/router.py @@ -31,8 +31,13 @@ logger = get_logger() -def _build_transport(spec: ProviderSpec, model: str, api_key: Optional[str], - base_url: str, gen_config: dict) -> Transport: +def _build_transport(spec: ProviderSpec, + model: str, + api_key: Optional[str], + base_url: str, + gen_config: dict, + vision: Optional['VisionOptions'] = None, + vision_supported: bool = True) -> Transport: if spec.transport == TRANSPORT_ANTHROPIC_MESSAGES: from .transport.anthropic_messages import AnthropicMessagesTransport return AnthropicMessagesTransport( @@ -40,6 +45,8 @@ def _build_transport(spec: ProviderSpec, model: str, api_key: Optional[str], api_key=api_key, base_url=base_url, generation_config=gen_config, + vision=vision, + vision_supported=vision_supported, ) if spec.transport == TRANSPORT_OPENAI_COMPAT: from .transport.openai_compat import OpenAICompatTransport @@ -51,6 +58,8 @@ def _build_transport(spec: ProviderSpec, model: str, api_key: Optional[str], continue_gen_mode=spec.continue_gen_mode, continue_gen_stop=spec.continue_gen_stop, strip_reasoning_tags=spec.strip_reasoning_tags, + vision=vision, + vision_supported=vision_supported, ) raise ValueError(f'Unknown transport: {spec.transport}') @@ -154,6 +163,16 @@ def create(self, config: DictConfig) -> LLMProvider: getattr(config, 'generation_config', DictConfig({}))) gen_config = {**spec.default_generation_config, **(gen_config or {})} - transport = _build_transport(spec, model, api_key, base_url, - gen_config) + # Image attachments: encode options + whether this model may be shown + # pixels. Resolved here (the one place that has spec, model and base_url + # together) rather than inside the transports. + from .multimodal import VisionOptions + from .vision import resolve_supports_vision + vision = VisionOptions.from_config(config) + vision_supported = resolve_supports_vision( + config, spec=spec, model=model, base_url=base_url) + + transport = _build_transport(spec, model, api_key, base_url, gen_config, + vision=vision, + vision_supported=vision_supported) return LLMProvider(config=config, spec=spec, transport=transport) diff --git a/ms_agent/llm/transport/anthropic_messages.py b/ms_agent/llm/transport/anthropic_messages.py index 59caaedf0..ba26bba55 100644 --- a/ms_agent/llm/transport/anthropic_messages.py +++ b/ms_agent/llm/transport/anthropic_messages.py @@ -15,9 +15,11 @@ import json from typing import Any, Dict, Generator, Iterator, List, Optional, Union +from ms_agent.llm import multimodal from ms_agent.llm.thinking import apply_effort from ms_agent.llm.transport.base import Transport from ms_agent.llm.utils import Message, Tool, ToolCall +from ms_agent.llm.vision import create_with_vision_fallback from ms_agent.utils import assert_package_exist @@ -29,6 +31,8 @@ def __init__( api_key: Optional[str], base_url: str, generation_config: Optional[Dict] = None, + vision: Optional['multimodal.VisionOptions'] = None, + vision_supported: bool = True, ): assert_package_exist('anthropic', 'anthropic') import anthropic @@ -36,6 +40,11 @@ def __init__( if not api_key: raise ValueError('Anthropic API key is required.') + # See OpenAICompatTransport for why these are explicit params rather + # than generation_config keys. + self._vision = vision or multimodal.VisionOptions() + self._vision_supported = bool(vision_supported) + self.model = model self.client = anthropic.Anthropic(api_key=api_key, base_url=base_url) self.args: Dict = dict(generation_config or {}) @@ -84,6 +93,55 @@ def _as_tool_input(value: Any) -> Any: return {} return value if value is not None else {} + @staticmethod + def _blocks_from_structured(content: List[Any]) -> List[Dict[str, Any]]: + """Translate an OpenAI-shaped content array to Anthropic blocks. + + Only reachable when a caller hands us structured content directly; our + own attachment path builds Anthropic blocks natively. Unknown block + kinds degrade to their text payload rather than being dropped silently. + """ + out: List[Dict[str, Any]] = [] + for item in content: + if not isinstance(item, dict): + out.append({'type': 'text', 'text': str(item)}) + continue + kind = item.get('type') + if kind == 'text': + text = str(item.get('text') or '') + if text: + out.append({'type': 'text', 'text': text}) + elif kind == 'image': # already Anthropic-shaped + out.append(item) + elif kind in ('image_url', 'input_image'): + url = item.get('image_url') + url = url.get('url') if isinstance(url, dict) else url + url = str(url or '') + if url.startswith('data:') and ',' in url: + header, data = url.split(',', 1) + media_type = header[5:].split(';')[0] or 'image/png' + out.append({ + 'type': 'image', + 'source': { + 'type': 'base64', + 'media_type': media_type, + 'data': data, + }, + }) + elif url: + out.append({ + 'type': 'image', + 'source': { + 'type': 'url', + 'url': url + }, + }) + else: + text = str(item.get('text') or '') + if text: + out.append({'type': 'text', 'text': text}) + return out + def _format_input_message(self, messages: List[Message]) -> List[Dict[str, Any]]: formatted_messages = [] @@ -107,7 +165,26 @@ def _format_input_message(self, if signature: thinking_block['signature'] = signature content.append(thinking_block) - if msg.content: + attachments = getattr(msg, 'attachments', None) or [] + if attachments and msg.role == 'user': + # Image refs -> native image blocks (image-then-text, each + # introduced by its own "Image N: " label). + built = multimodal.anthropic_content( + msg.content if isinstance(msg.content, str) else '', + attachments, + self._vision, + vision_supported=self._vision_supported) + if isinstance(built, list): + content.extend(built) + elif built: + content.append({'type': 'text', 'text': str(built)}) + elif isinstance(msg.content, list): + # Already-structured content (an image_url list handed in by an + # SDK caller, or our own blocks on a replayed turn). Passing it + # to _as_text would JSON-serialize the whole array into ONE text + # block, silently destroying every image; convert instead. + content.extend(self._blocks_from_structured(msg.content)) + elif msg.content: content.append({ 'type': 'text', 'text': self._as_text(msg.content) @@ -130,10 +207,24 @@ def _format_input_message(self, if msg.role == 'tool': tool_use_id = msg.tool_call_id or (pending_tool_ids.pop(0) if pending_tool_ids else '') + # This protocol DOES allow image blocks inside tool_result, so a + # tool's images stay attached to the call that produced them — + # better than the hoist the OpenAI transports are forced into, + # because the association survives regardless of message order. + result_content: Any = self._as_text(msg.content) + image_blocks = multimodal.anthropic_tool_result_blocks( + attachments, + self._vision, + vision_supported=self._vision_supported) + if image_blocks: + text = result_content + result_content = [*image_blocks] + if text: + result_content.append({'type': 'text', 'text': text}) result_block = { 'type': 'tool_result', 'tool_use_id': tool_use_id, - 'content': self._as_text(msg.content), + 'content': result_content, } # Anthropic requires ALL tool_results for one assistant turn's # tool_use blocks in the SINGLE user message immediately after it. @@ -193,9 +284,39 @@ def _call_llm(self, params['tools'] = tools params.update(kwargs) - if stream: - return self.client.messages.stream(**params) - return self.client.messages.create(**params) + # Same per-model hard-400 hazard as the OpenAI-family transports: a model + # that cannot accept images rejects the whole request rather than + # ignoring the blocks. Retry once with the images folded into text and + # remember the model. Symmetric with OpenAICompatTransport so behaviour + # does not depend on which protocol a gateway happens to speak. + sent_images = any( + multimodal.has_image_blocks(m.get('content')) + for m in formatted_messages if isinstance(m, dict)) + + def _create(messages, **kw): + call = dict(kw) + call['messages'] = messages + # `model` is a named parameter of create_with_vision_fallback (it + # keys the per-model refusal memo), so it is consumed there rather + # than forwarded — the API call has to name it again itself. + call['model'] = self.model + if stream: + return self.client.messages.stream(**call) + return self.client.messages.create(**call) + + # Everything except `model` and `messages`, which the wrapper takes as + # named arguments; leaving either in `params` would collide with them. + rest = { + k: v + for k, v in params.items() if k not in ('model', 'messages') + } + return create_with_vision_fallback( + _create, + base_url=getattr(self.client, 'base_url', ''), + model=self.model, + messages=params['messages'], + sent_images=sent_images, + **rest) def generate( self, diff --git a/ms_agent/llm/transport/openai_compat.py b/ms_agent/llm/transport/openai_compat.py index baa369d43..d8af7325e 100644 --- a/ms_agent/llm/transport/openai_compat.py +++ b/ms_agent/llm/transport/openai_compat.py @@ -22,9 +22,11 @@ from copy import deepcopy from typing import Any, Dict, Generator, Iterable, List, Optional, Union +from ms_agent.llm import multimodal from ms_agent.llm.thinking import apply_effort, create_with_thinking_fallback from ms_agent.llm.transport.base import Transport from ms_agent.llm.utils import Message, Tool, ToolCall +from ms_agent.llm.vision import create_with_vision_fallback from ms_agent.utils import MAX_CONTINUE_RUNS, assert_package_exist, get_logger logger = get_logger() @@ -65,10 +67,21 @@ def __init__( continue_gen_stop: Optional[List[str]] = None, max_continue_runs: Optional[int] = None, strip_reasoning_tags: bool = False, + vision: Optional['multimodal.VisionOptions'] = None, + vision_supported: bool = True, ): assert_package_exist('openai') import openai + # Image-attachment handling. Passed explicitly rather than via + # generation_config because that dict is forwarded wholesale as API + # kwargs (`self._call_llm(..., **args)`), so a private key in it would + # be sent to the endpoint and rejected. + self._vision = vision or multimodal.VisionOptions() + # Whether THIS model accepts images. Resolved by the caller from the + # per-model capability flag; False degrades attachments to text. + self._vision_supported = bool(vision_supported) + self.model = model self.base_url = self._normalize_base_url(base_url) self.client = openai.OpenAI(api_key=api_key, base_url=self.base_url) @@ -196,17 +209,36 @@ def _format_input_message(self, # disappears from the dict entirely rather than arriving as None. pending_tool_ids: List[str] = [] for idx, message in enumerate(messages): + # Image refs must be read BEFORE to_dict_clean(), which strips them + # (they are this method's input, never wire output). + attachments = (message.attachments if isinstance(message, Message) + else message.get('attachments')) or [] if isinstance(message, Message): if isinstance(message.content, str): message.content = message.content.strip() message = message.to_dict_clean() else: message = dict(message) + message.pop('attachments', None) content = message.get('content', '') if isinstance(content, str): content = content.strip() + # Expand image refs into native blocks. Text-only turns come back + # as the same plain string, so nothing changes for them (prefix + # caching included). + # Not for a tool message: the Chat Completions SCHEMA allows + # only text parts in `role: "tool"`, so its images go to the + # synthetic user turn appended after it (below). Five compatible + # providers were measured to accept inline image parts here anyway, + # but hoisting is valid under the schema AND under all of them — see + # multimodal.TOOL_MEDIA_PROMPT for the full measurement. + if attachments and message.get('role') != 'tool': + content = multimodal.openai_content( + content, attachments, self._vision, + vision_supported=self._vision_supported) + if cache_indice is not None and idx == cache_indice: content = self._to_structured_content( content, @@ -247,6 +279,17 @@ def _format_input_message(self, 'will likely reject this request') openai_messages.append(formatted_message) + + # A tool result's images ride on a synthetic user turn right + # after it, because the Chat Completions schema restricts a tool + # message to text parts (see multimodal.TOOL_MEDIA_PROMPT). + if attachments and role == 'tool': + media = multimodal.openai_tool_media_message( + attachments, + self._vision, + vision_supported=self._vision_supported) + if media is not None: + openai_messages.append(media) return openai_messages # ------------------------------------------------------------------ # @@ -369,10 +412,27 @@ def _call_llm(self, # refusal is a hard 400. Both live in llm/thinking.py. kwargs = apply_effort( kwargs, base_url=str(getattr(self.client, 'base_url', ''))) - return create_with_thinking_fallback( - lambda **kw: self.client.chat.completions.create( - model=self.model, messages=messages, tools=tools, **kw), - self.client, self.model, logger, **kwargs) + + # Image content is the other per-model hard-400: a text-only model + # rejects the whole request rather than ignoring the image blocks, which + # would make it unusable the moment a user attaches a file. Retry once + # with the images folded into text, and remember the model. Wrapped + # OUTSIDE the thinking fallback so the two compose: a request can be + # retried for thinking and, independently, for images. + sent_images = any( + multimodal.has_image_blocks(m.get('content')) + for m in messages if isinstance(m, dict)) + return create_with_vision_fallback( + lambda messages, **kw: create_with_thinking_fallback( + lambda **kw2: self.client.chat.completions.create( + model=self.model, messages=messages, tools=tools, **kw2), + self.client, self.model, logger, **kw), + base_url=getattr(self.client, 'base_url', ''), + model=self.model, + messages=messages, + sent_images=sent_images, + logger_=logger, + **kwargs) # ------------------------------------------------------------------ # # usage diff --git a/ms_agent/llm/utils.py b/ms_agent/llm/utils.py index ec568d976..09a04a444 100644 --- a/ms_agent/llm/utils.py +++ b/ms_agent/llm/utils.py @@ -107,6 +107,30 @@ class Message: # the model provider (the model still sees the failure via ``content``). is_error: bool = False + # Non-text parts riding alongside ``content`` — today only images. Ordered: + # entry i is "Image i+1" to the model, and the UI must show its chips in the + # same order or "the second image" points at the wrong one. + # + # Each entry is a REFERENCE, not bytes: + # {'type': 'image', 'path': 'user_files/a.png', + # 'media_type': 'image/png', 'label': 'Image 1: a.png'} + # + # Deliberately NOT folded into ``content``: this framework has ~40 call + # sites that read a user message's ``content`` expecting a string (memory + # extraction, full-text indexing, session auto-naming, summary compaction, + # snapshot labels, hook prompt extraction). None of them wants to know how + # many images there are, and none of them would crash loudly if handed a + # block list — they would silently store a Python repr or skip the turn. + # Keeping ``content`` a str keeps all of them correct for free; the + # transports expand these refs into provider-native blocks at the wire. + # + # Storing a reference rather than base64 is what lets the same log be + # re-encoded per provider (Anthropic 1568px/2576px tiers, DashScope not + # accepting GIF) and lets a session survive a model switch: swap to a + # text-only model and the refs degrade to text placeholders; swap back and + # the images are visible again. + attachments: List[Dict[str, Any]] = field(default_factory=list) + def to_dict(self): return asdict(self) @@ -139,6 +163,13 @@ def to_dict_clean(self): 'searching_detail', 'search_result', '_responses_output_items', + # Image refs are the transports' input, never wire output: each + # provider adapter reads ``message.attachments`` BEFORE calling this + # and folds them into provider-native content blocks. Leaving them + # in would ship a bare {'type':'image','path':...} to the endpoint. + # This entry is load-bearing: to_dict_clean() keeps every truthy + # field not listed here, so omitting it leaks the refs. + 'attachments', ] return { key: value @@ -161,6 +192,12 @@ class ToolResult: tool_detail: Optional[str] = None hook_attachments: List[Any] = field(default_factory=list) is_error: bool = False + #: Non-text parts the tool produced (images), same reference shape as + #: ``Message.attachments``. ``text`` still carries a short human/model + #: readable status; these carry the pixels. Splitting them is the point: a + #: tool that put base64 into ``text`` was writing into the one channel a + #: model cannot decode. + attachments: List[Dict[str, Any]] = field(default_factory=list) @staticmethod def from_raw(raw): @@ -177,6 +214,7 @@ def from_raw(raw): tool_detail=None if td is None else str(td), hook_attachments=raw.get('hook_attachments', []), is_error=bool(raw.get('is_error', False)), + attachments=raw.get('attachments', []) or [], extra={ k: v for k, v in raw.items() if k not in [ @@ -186,6 +224,7 @@ def from_raw(raw): 'tool_detail', 'hook_attachments', 'is_error', + 'attachments', ] }) raise TypeError('tool_call_result must be str or dict') diff --git a/ms_agent/llm/vision.py b/ms_agent/llm/vision.py new file mode 100644 index 000000000..5c3636f65 --- /dev/null +++ b/ms_agent/llm/vision.py @@ -0,0 +1,246 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Which models can be shown an image, and what to do when we guess wrong. + +Two halves: + +**Resolution** — whether to attach pixels at all, decided per model, strongest +signal first: + +1. an explicit per-model setting (the "image understanding" switch in the model + form) — the user's own statement, always wins; +2. the provider's declared ``vision`` capability — a coarse default for models + nobody has classified yet; +3. a model observed to REFUSE images earlier in this process — vetoes both of + the above, because a refusal is ground truth. + +**Self-healing** — a provider that cannot see images rejects the whole request +with a hard 400, which would otherwise make such a model unusable the moment a +user attaches a file. So the request is retried once with the images replaced by +text, and the model is remembered so a session pays that round-trip at most once. + +The refusal detector deliberately does **no keyword matching**. Measured against +DashScope (2026-08), a text-only model given an ``image_url`` block answers:: + + <400> InternalError.Algo.InvalidParameter: The provided messages input is + invalid. The error info is [Unexpected item type in content.] + +— which names neither "image" nor "multimodal" nor "vision". Any keyword list +built from a vendor's current phrasing is a guess that goes stale. What we *do* +know for certain is whether the request we just sent carried image blocks; that +fact plus a 400 is the attribution. Mirrors ``llm/thinking.py``, which exists +because a hand-maintained model blocklist was wrong twice before it. +""" +from __future__ import annotations + +from typing import Any, List, Optional, Set, Tuple + +from ms_agent.llm import multimodal +from ms_agent.utils import get_logger + +logger = get_logger() + +#: ``(base_url, model)`` pairs observed to reject image content. +MODELS_REFUSING_IMAGES: Set[Tuple[str, str]] = set() + +#: Callables notified the first time a model is learned to refuse images. +#: A host (the WebUI) registers one so the discovery can be written back to +#: wherever the user configured the model — otherwise the knowledge dies with +#: the process and every restart pays the same rejected request again, while the +#: "image understanding" switch keeps claiming the model supports it. +_OBSERVERS: List[Any] = [] + + +def register_refusal_observer(fn) -> None: + """Register ``fn(base_url, model)``, called once per newly-learned refusal. + + Idempotent per callable, so repeated setup (a WebUI reload) cannot stack + duplicate write-backs. Observer exceptions are swallowed: learning that a + model refuses images must never be able to fail the turn that discovered it. + """ + if fn not in _OBSERVERS: + _OBSERVERS.append(fn) + + +def model_key(base_url: Any, model: str) -> Tuple[str, str]: + return (str(base_url or ''), str(model or '')) + + +def note_refusal(base_url: Any, model: str) -> None: + key = model_key(base_url, model) + first_time = key not in MODELS_REFUSING_IMAGES + MODELS_REFUSING_IMAGES.add(key) + if not first_time: + return + for observer in list(_OBSERVERS): + try: + observer(key[0], key[1]) + except Exception as exc: # never fail the turn over bookkeeping + logger.warning('[vision] refusal observer failed: %s', exc) + + +def known_refuser(base_url: Any, model: str) -> bool: + return model_key(base_url, model) in MODELS_REFUSING_IMAGES + + +def _status_of(exc: Exception) -> Optional[int]: + status = getattr(exc, 'status_code', None) + if status is None: + response = getattr(exc, 'response', None) + status = getattr(response, 'status_code', None) + try: + return int(status) if status is not None else None + except (TypeError, ValueError): + return None + + +def is_image_refusal(exc: Exception, sent_images: bool) -> bool: + """True when a 400 is attributable to the images in THIS request. + + ``sent_images`` is the whole detector: we know what we put on the wire, and + guessing the vendor's wording does not work (see the module docstring). + + This is deliberately a WIDE net — it says "worth one retry", not "definitely + the images". Measured across seven providers, a 400 on an image-carrying + request also covers model-not-found ("Model id ... has no provider + supported" on ModelScope), auth failures and content filters. The + discrimination therefore happens in ``create_with_vision_fallback``, which + only blacklists the model when the image-less retry actually SUCCEEDS; a 400 + that persists without images is re-raised untouched and teaches us nothing. + + So the cost of a false positive is exactly one extra round-trip, and it can + never mask the real error or wrongly disable images on a capable model. + """ + if not sent_images: + return False # a 400 with no images in it is somebody else's problem + status = _status_of(exc) + if status is not None: + return status == 400 + # Some SDK wrappers lose the status; fall back to the textual marker. + return '400' in str(exc) + + +def strip_images_from_messages(messages: Any) -> Tuple[Any, bool]: + """``(messages, changed)`` with every image block folded back into text. + + Operates on the already-formatted provider payload, so it works for both the + OpenAI ``image_url`` shape and the Anthropic ``image``/``source`` shape. + """ + if not isinstance(messages, list): + return messages, False + changed = False + out = [] + for message in messages: + if not isinstance(message, dict): + out.append(message) + continue + content = message.get('content') + if multimodal.has_image_blocks(content): + message = { + **message, 'content': multimodal.strip_image_blocks(content) + } + changed = True + out.append(message) + return out, changed + + +def create_with_vision_fallback(create, + *, + base_url: Any, + model: str, + messages: Any, + sent_images: bool, + logger_=None, + **kwargs) -> Any: + """Call ``create(messages=..., **kwargs)``, retrying once without images. + + ``create`` must accept ``messages`` as a keyword so the retry can hand it a + rewritten list. Streaming is covered: the client performs the request — and + raises — before it returns an iterator. + """ + log = logger_ or logger + if sent_images and known_refuser(base_url, model): + messages, _ = strip_images_from_messages(messages) + sent_images = False + try: + return create(messages=messages, **kwargs) + except Exception as exc: + if not is_image_refusal(exc, sent_images): + raise + retry_messages, changed = strip_images_from_messages(messages) + if not changed: + raise + log.warning( + '%s returned 400 on a request carrying images; retrying once with ' + 'the images replaced by text: %s', model, exc) + try: + result = create(messages=retry_messages, **kwargs) + except Exception: + # Removing the images did NOT help, so they were not the cause — + # this was a model-not-found / auth / content-filter 400 that merely + # happened to ride on a turn with an attachment. Re-raise the + # ORIGINAL error (it describes the real problem) and, crucially, do + # not blacklist the model: marking a vision-capable model as + # image-refusing here would silently stop sending it images for the + # rest of the process. Measured on ModelScope, whose "Model id ... + # has no provider supported" is exactly this shape. + raise exc from None + # The image-less retry succeeded, so the images were the problem. THIS + # is the only sound moment to remember it — the status code alone cannot + # tell an image refusal from any other 400. + note_refusal(base_url, model) + log.warning( + 'images stay off for %s for the rest of this process (the ' + 'image-less retry succeeded)', model) + return result + + +def resolve_supports_vision(config: Any, + spec: Any = None, + model: str = '', + base_url: Any = '') -> bool: + """Whether to attach pixels for this model. See the module docstring. + + ``config.llm.supports_vision`` is the explicit per-model switch. It is + read as a TRI-STATE: absent/None means "nobody has said", which falls + through to the provider capability and then to runtime learning. That is + better than a hard default in either direction — a hard ``False`` would make + a capable model silently ignore attachments until someone ticks a box, and a + hard ``True`` would make every text-only model burn a 400 on first use. + """ + if model and known_refuser(base_url, model): + return False # observed truth beats every declaration + + llm = getattr(config, 'llm', None) if config is not None else None + explicit = None + if llm is not None: + for name in ('supports_vision', 'vision_supported'): + value = getattr(llm, name, None) + if value is not None: + explicit = value + break + if explicit is not None: + return _as_bool(explicit) + + if spec is not None: + caps = getattr(spec, 'capabilities', None) + if caps is not None: + try: + from ms_agent.llm.types import ProviderCapability + return bool(caps.supports(ProviderCapability.VISION)) + except Exception: + pass + return False + + +def _as_bool(value: Any) -> bool: + """Tolerate a YAML/JSON boolean written as a string. + + ``supports_vision: "false"`` is a common enough mistake that treating it as + truthy (which bare ``bool()`` does) would silently enable images on a model + the user just tried to turn them off for. + """ + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in ('1', 'true', 'yes', 'on', 'y') + return bool(value) diff --git a/ms_agent/memory/unified/orchestrator.py b/ms_agent/memory/unified/orchestrator.py index b18f282b1..c48d0e2a8 100644 --- a/ms_agent/memory/unified/orchestrator.py +++ b/ms_agent/memory/unified/orchestrator.py @@ -46,7 +46,7 @@ from ms_agent.session.context_assembler import _dicts_to_messages from ms_agent.utils.logger import get_logger from .config import MemoryConfig -from .protocols import (RECALL_BLOCK_MARKER, MemoryBackend, MemoryEntry) +from .protocols import RECALL_BLOCK_MARKER, MemoryBackend, MemoryEntry from .registry import backend_registry logger = get_logger() @@ -617,6 +617,12 @@ def _messages_to_dicts(messages: List[Message]) -> List[Dict[str, Any]]: d['reasoning_content'] = m.reasoning_content if m.reasoning_signature: d['reasoning_signature'] = m.reasoning_signature + # Image refs: this round-trip is the live LLM context, so a field + # dropped here is dropped from what the model sees this turn — not + # merely from storage (see the docstring above). Must mirror + # ``_dicts_to_messages``. + if getattr(m, 'attachments', None): + d['attachments'] = m.attachments result.append(d) else: result.append({'role': 'user', 'content': str(m)}) diff --git a/ms_agent/session/context_assembler.py b/ms_agent/session/context_assembler.py index 2f813777e..347f5dd1d 100644 --- a/ms_agent/session/context_assembler.py +++ b/ms_agent/session/context_assembler.py @@ -214,6 +214,11 @@ def _dicts_to_messages(dicts: List[Dict[str, Any]]) -> List[Message]: # replays them — required for its thinking-mode tool follow-ups. reasoning_content=d.get('reasoning_content', '') or '', reasoning_signature=d.get('reasoning_signature', '') or '', + # Image refs attached to a user turn. This runs on EVERY + # round (the live context is reassembled from the log), so + # omitting them here would make images visible on the turn + # they were sent and invisible from the next round on. + attachments=d.get('attachments') or [], )) else: result.append(Message(role='user', content=str(d))) diff --git a/ms_agent/session/strategies/summary_compactor.py b/ms_agent/session/strategies/summary_compactor.py index 622f46f79..ab9a61190 100644 --- a/ms_agent/session/strategies/summary_compactor.py +++ b/ms_agent/session/strategies/summary_compactor.py @@ -9,6 +9,8 @@ import json from typing import Any, Dict, List, Optional, Tuple +from ms_agent.llm import multimodal +from ms_agent.llm.message_text import flatten_message_text from ms_agent.utils.logger import get_logger logger = get_logger() @@ -41,13 +43,16 @@ def _estimate_tokens(text: str) -> int: def _estimate_message_tokens(msg: Dict[str, Any]) -> int: - """Heuristic token count from message body (no API usage fields).""" + """Heuristic token count from message body (no API usage fields). + + Image blocks are charged a flat per-image cost rather than having their + base64 measured as text — see the twin in ``tool_pruner`` for the numbers and + for why over-counting here does not self-correct. + """ total = 0 content = msg.get('content', '') if content: - if not isinstance(content, str): - content = json.dumps(content, ensure_ascii=False) - total += _estimate_tokens(content) + total += multimodal.estimate_content_tokens(content, _estimate_tokens) tc = msg.get('tool_calls') if tc: total += _estimate_tokens(json.dumps(tc)) @@ -158,8 +163,11 @@ def _generate_summary(self, messages: List[Dict[str, Any]], conv_parts: List[str] = [] for msg in messages: role = msg.get('role', '?').upper() - content = msg.get('content', '') - if isinstance(content, str) and content: + # Reduce a block list to its text instead of skipping the message: + # a turn dropped here is invisible to the summary that decides what + # survives compaction. + content = flatten_message_text(msg.get('content', '')) + if content: conv_parts.append(f'{role}: {content[:char_limit]}') conversation = '\n'.join(conv_parts) diff --git a/ms_agent/session/strategies/tool_pruner.py b/ms_agent/session/strategies/tool_pruner.py index e1b48ca92..b4abe1cc9 100644 --- a/ms_agent/session/strategies/tool_pruner.py +++ b/ms_agent/session/strategies/tool_pruner.py @@ -13,6 +13,7 @@ import json from typing import Any, Dict, List, Optional, Tuple +from ms_agent.llm import multimodal from ms_agent.utils.logger import get_logger logger = get_logger() @@ -25,13 +26,25 @@ def _estimate_tokens(text: str) -> int: def _estimate_message_tokens(msg: Dict[str, Any]) -> int: - """Heuristic token count from message body (no API usage fields).""" + """Heuristic token count from message body (no API usage fields). + + Structured content is walked block-by-block, and an image block is charged a + flat per-image cost instead of having its payload measured as text. The + providers bill images by pixel dimensions (Anthropic ``⌈w/28⌉ × ⌈h/28⌉``; + DashScope returns the real figure as ``prompt_tokens_details.image_tokens``), + which has nothing to do with how long the base64 happens to be. + + Measured on DashScope with a 900x320 PNG: 282 real image tokens versus 7,293 + from counting base64 characters — a 26x over-count on a 22 KB image. A 2 MiB + PNG estimates at ~699k tokens against a ~108k usable budget, which does not + merely mis-trigger compaction once: the offending message is the last visible + one, so the compactor re-appends it, the estimate never drops, and every + subsequent round pays for another summary LLM call. + """ total = 0 content = msg.get('content', '') if content: - if not isinstance(content, str): - content = json.dumps(content, ensure_ascii=False) - total += _estimate_tokens(content) + total += multimodal.estimate_content_tokens(content, _estimate_tokens) tool_calls = msg.get('tool_calls') if tool_calls: total += _estimate_tokens(json.dumps(tool_calls)) diff --git a/ms_agent/tools/filesystem_tool.py b/ms_agent/tools/filesystem_tool.py index 4848618e6..f83719888 100644 --- a/ms_agent/tools/filesystem_tool.py +++ b/ms_agent/tools/filesystem_tool.py @@ -194,7 +194,10 @@ async def _get_tools_inner(self): ('Read the content of one or more files.\n\n' '- `paths`: list of relative file paths to read (preferred).\n' '- `path`: single relative file path (alias when the model passes one file).\n' - '- For image files (png/jpg/jpeg/gif/webp), returns base64-encoded content.\n' + '- Image files (png/jpg/jpeg/gif/webp) are returned AS IMAGES, attached to\n' + ' the result — look at them directly. They are not readable as text, and\n' + ' `offset`/`limit` do not apply. If you cannot see an attached image, the\n' + ' current model has image understanding disabled.\n' '- `offset`: line number to start reading from (1-based). ' 'Only effective when paths has exactly one element. Omit to read from the beginning.\n' '- `limit`: number of lines to read. ' @@ -820,6 +823,10 @@ async def read_file(self, return await self._read_files_abbreviated(paths) results = {} + # Structured image references collected while walking the paths. Returned + # alongside the text so the transports can put the pixels in the image + # channel instead of stringifying them into the text one. + image_refs: list = [] use_line_range = len(paths) == 1 and (offset is not None or limit is not None) @@ -836,13 +843,36 @@ async def read_file(self, # --- Image files --- if ext in self.IMAGE_EXTENSIONS: - with open(target_path_real, 'rb') as f: - raw = f.read() + # Two channels, and the bytes belong in the other one. This + # dict is JSON-serialized into the tool message's TEXT + # content, so returning base64 here put the image where a + # model cannot decode it: tens of thousands of tokens of + # literal characters, zero comprehension, and an inflated + # context estimate that re-fires compaction every round + # (see session/strategies/tool_pruner). + # + # So: a short status in the text, and a structured reference + # collected below into ``attachments``, which the transports + # expand into a real image block. The model genuinely sees + # the file it asked to read. media_type = f'image/{ext}' if ext != 'jpg' else 'image/jpeg' + size = os.path.getsize(target_path_real) + image_refs.append({ + 'type': 'image', + 'path': path, + 'media_type': media_type, + 'label': f'Image: {os.path.basename(path)}', + }) results[path] = { 'type': 'image', 'media_type': media_type, - 'base64': base64.b64encode(raw).decode('ascii'), + 'bytes': size, + 'shown_as_image': True, + 'message': + (f'This {media_type} image ({size} bytes) is attached to ' + 'this result as an image, so look at it directly; it ' + 'cannot be read as text. If you cannot see it, the ' + 'current model has image understanding disabled.'), } continue @@ -909,7 +939,13 @@ async def read_file(self, results[path] = f'Read file <{path}> failed: FileNotFound' except Exception as e: results[path] = f'Read file <{path}> failed, error: ' + str(e) - return json.dumps(results, indent=2, ensure_ascii=False) + text = json.dumps(results, indent=2, ensure_ascii=False) + if image_refs: + # Dict form so the agent's ToolResult picks up ``attachments`` and + # carries the pixels to the image channel. A read with no images + # returns the same plain string as before, so nothing else moves. + return {'result': text, 'attachments': image_refs} + return text async def _read_files_abbreviated(self, paths: list[str]) -> str: results = {} diff --git a/ms_agent/tools/image_reader_tool.py b/ms_agent/tools/image_reader_tool.py new file mode 100644 index 000000000..e6cda7a6d --- /dev/null +++ b/ms_agent/tools/image_reader_tool.py @@ -0,0 +1,243 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""``image_reader``: let a text-only model ask a vision model about an image. + +The main conversation model may have no image understanding at all — measured +across seven providers, roughly a third of configured models are in that class, +and four of them accept an image block with HTTP 200 and simply cannot see it. +For those, an attached image degrades to a path and the answer is "I can't view +images", which is honest but useless. + +This tool closes that gap without changing the main model: it sends the image to +a SEPARATELY configured vision model and returns that model's description as +text. The main model then reasons over the text. Lossy by construction — a +description is not the pixels — so it is the fallback, never the preferred path: +when the main model can see images, the transports show it the real thing and +this tool should not be needed. + +Configuration (all under ``llm.vision.auxiliary``; absent ⇒ the tool is not +registered, so nothing changes for anyone who has not opted in):: + + llm: + vision: + auxiliary: + service: dashscope # provider id / SDK service name + model: qwen3.8-max # a model that CAN see images + api_key: ... # optional; falls back to the env/spec + base_url: ... # optional; same + protocol: openai # optional; 'anthropic' for that wire format + +Modelled on hermes-agent's ``vision_analyze``. +""" +from __future__ import annotations + +import json +import os +from typing import Any, Dict, List, Optional + +from ms_agent.llm.utils import Tool +from ms_agent.tools.base import ToolBase +from ms_agent.utils import get_logger + +logger = get_logger() + +#: Asked of the auxiliary model when the caller has no specific question. Aims at +#: a description another model can reason over rather than prose for a human: +#: verbatim text first, because that is what callers most often actually need. +DEFAULT_PROMPT = ( + 'Describe this image for another AI model that cannot see it. Start with ' + 'every piece of text in the image, transcribed verbatim. Then describe the ' + 'layout, the objects, their colours and any relationships that matter. Be ' + 'specific and factual; do not speculate about intent.') + + +def _fail(error: str) -> str: + """A failed ``image_reader`` result, in the same JSON shape as a success.""" + return json.dumps({'ok': False, 'error': error}, ensure_ascii=False) + + +def auxiliary_config(config: Any) -> Optional[Dict[str, Any]]: + """The ``llm.vision.auxiliary`` block as a plain dict, or None if unset. + + Returning None is the opt-in switch: no auxiliary model configured means the + tool is never registered, so a user who has not asked for this pays nothing — + not a tool definition in the prompt, not a stray dependency. + """ + llm = getattr(config, 'llm', None) + vision = getattr(llm, 'vision', None) if llm is not None else None + aux = getattr(vision, 'auxiliary', None) if vision is not None else None + if aux is None: + return None + model = getattr(aux, 'model', None) + if not model: + return None + out: Dict[str, Any] = {'model': str(model)} + for key in ('service', 'api_key', 'base_url', 'protocol'): + value = getattr(aux, key, None) + if value: + out[key] = str(value) + return out + + +class ImageReaderTool(ToolBase): + """One tool: ``image_reader(path, question=None)``.""" + + server_name = 'image_reader' + + def __init__(self, config, **kwargs): + super().__init__(config) + self.exclude_func(getattr(config.tools, 'image_reader', None)) + self._aux = auxiliary_config(config) or {} + self._llm = None # built lazily: no cost unless the tool is called + + async def connect(self) -> None: + if not self._aux: + logger.warning( + '[image_reader] no llm.vision.auxiliary.model configured; the ' + 'tool will report that it is unavailable') + + async def cleanup(self) -> None: + self._llm = None + + async def _get_tools_inner(self): + return { + 'image_reader': [ + Tool( + tool_name='image_reader', + server_name='image_reader', + description= + ('Look at an image file and get a text description of it, ' + 'produced by a vision model.\n\n' + 'Use this ONLY when you cannot see an image yourself. ' + 'Images the user attached to the conversation are shown ' + 'to you directly when this model supports it — asking ' + 'this tool about them instead would give you a lossy ' + 'second-hand description.\n\n' + 'Typical use: the user attached an image but you cannot ' + 'see it, or you need to inspect an image file in the ' + 'workspace that was never attached.'), + parameters={ + 'type': 'object', + 'properties': { + 'path': { + 'type': + 'string', + 'description': + ('Workspace-relative path of the image ' + '(png/jpg/jpeg/gif/webp).'), + }, + 'question': { + 'type': + 'string', + 'description': + ('What you need to know about the image. Omit ' + 'for a full general description.'), + }, + }, + 'required': ['path'], + 'additionalProperties': False, + }) + ] + } + + def _build_llm(self): + """Construct the auxiliary vision LLM (once).""" + if self._llm is not None: + return self._llm + from omegaconf import OmegaConf + + from ms_agent.llm import LLM + + aux = self._aux + service = aux.get('service') or 'openai' + llm_cfg: Dict[str, Any] = { + 'service': service, + 'model': aux['model'], + # The auxiliary model is chosen BECAUSE it can see images, so state + # that outright rather than letting the resolver guess. + 'supports_vision': True, + 'use_provider_router': True, + } + if aux.get('protocol'): + llm_cfg['protocol'] = aux['protocol'] + if aux.get('api_key'): + llm_cfg[f'{service}_api_key'] = aux['api_key'] + if aux.get('base_url'): + llm_cfg[f'{service}_base_url'] = aux['base_url'] + cfg = OmegaConf.create({ + 'llm': llm_cfg, + 'generation_config': { + 'stream': False + }, + # So the attachment's relative path resolves against the same + # workspace the caller is talking about. + 'output_dir': self.output_dir, + }) + self._llm = LLM.from_config(cfg) + return self._llm + + async def call_tool(self, server_name: str, *, tool_name: str, + tool_args: dict) -> str: + # Same dispatch shape as FileSystemTool: the tool name IS the method. + return await getattr(self, tool_name)(**(tool_args or {})) + + async def image_reader(self, + path: str = '', + question: Optional[str] = None) -> str: + """Describe the image at ``path`` using the auxiliary vision model.""" + if not self._aux: + return _fail( + 'No auxiliary vision model is configured ' + '(llm.vision.auxiliary.model), so this image cannot be ' + 'described. Tell the user to configure one, enable image ' + 'understanding for the current model, or switch to a model ' + 'that supports images.') + if not path: + return _fail('path is required') + + from ms_agent.llm import multimodal + from ms_agent.llm.utils import Message, collect_response + + opts = multimodal.VisionOptions.from_config( + self.config, workspace_root=self.output_dir) + refs = multimodal.image_refs([{'type': 'image', 'path': path}], opts) + if not refs: + return _fail(f'{path!r} is not a readable image type (expected ' + 'png/jpg/jpeg/gif/webp).') + # Resolve to bytes here rather than trusting the path to exist later, so + # a missing file is one clear error instead of a provider-side failure. + if multimodal.load_image(refs[0], opts) is None: + return _fail(f'cannot read or decode the image at {path!r}') + + prompt = (question or '').strip() or DEFAULT_PROMPT + attachment = { + 'type': 'image', + 'path': path, + 'media_type': refs[0].media_type, + 'label': f'Image: {os.path.basename(path)}', + } + try: + llm = self._build_llm() + response = collect_response( + llm.generate([ + Message( + role='user', content=prompt, attachments=[attachment]) + ])) + description = (getattr(response, 'content', '') or '').strip() + except Exception as exc: + logger.warning('[image_reader] %s failed: %s', + self._aux.get('model'), exc) + return _fail(f'{type(exc).__name__}: {exc}') + + if not description: + return _fail('the vision model returned nothing') + return json.dumps( + { + 'ok': True, + 'path': path, + 'model': self._aux.get('model'), + # Named so the reader cannot mistake it for having seen the + # image: it is one model's account of another's pixels. + 'description_from_vision_model': description, + }, + ensure_ascii=False, + indent=2) diff --git a/ms_agent/tools/tool_manager.py b/ms_agent/tools/tool_manager.py index dac7dcf6e..39a0d942a 100644 --- a/ms_agent/tools/tool_manager.py +++ b/ms_agent/tools/tool_manager.py @@ -20,6 +20,7 @@ from ms_agent.tools.code import CodeExecutionTool, LocalCodeExecutionTool from ms_agent.tools.filesystem_tool import FileSystemTool from ms_agent.tools.image_generator import ImageGenerator +from ms_agent.tools.image_reader_tool import ImageReaderTool try: from ms_agent.tools.mcp_client import MCPClient @@ -144,6 +145,18 @@ def __init__( if hasattr(config, 'tools') and hasattr(config.tools, 'video_generator'): self.extra_tools.append(VideoGenerator(config)) + # image_reader is registered ONLY when an auxiliary vision model is + # configured: without one the tool can do nothing, and an always-present + # tool the model may call and always fail is worse than no tool at all. + if _tool_on(config, 'image_reader'): + from ms_agent.tools.image_reader_tool import auxiliary_config + + if auxiliary_config(config): + self.extra_tools.append(ImageReaderTool(config)) + else: + logger.info( + 'tools.image_reader is enabled but ' + 'llm.vision.auxiliary.model is unset; not registering it') if _tool_on(config, 'file_system'): self.extra_tools.append( FileSystemTool( diff --git a/ms_agent/ui/input.py b/ms_agent/ui/input.py index a44b0c653..a9a0ae9ea 100644 --- a/ms_agent/ui/input.py +++ b/ms_agent/ui/input.py @@ -34,6 +34,16 @@ class InputSource(Protocol): async def read_prompt(self, prompt: str = '>>> ') -> str: ... + # Optional: sources that can carry non-text parts (a WebUI composer with + # image attachments) also implement + # + # def take_attachments(self) -> list[dict]: ... + # + # returning the parts belonging to the prompt just read and clearing them. + # It is intentionally NOT part of this Protocol's required surface so every + # existing text-only source stays conformant; ``InteractiveSession`` + # feature-detects it. + class StdinInputSource: """Default input source: blocking ``input()`` off the event loop. diff --git a/tests/llm/test_message_text.py b/tests/llm/test_message_text.py new file mode 100644 index 000000000..ec4d1e357 --- /dev/null +++ b/tests/llm/test_message_text.py @@ -0,0 +1,102 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""flatten_message_text and the shape-preserving mutators. + +Exists because a block list reaching a str-assuming call site never crashes — it +stores a Python repr or a guard skips the message. Both are silent, and two of +those sites (WebUI session auto-naming, TUI session naming) PERSIST what they +compute, so the garbage becomes permanent and user-visible. +""" +import unittest + +from ms_agent.llm.message_text import (append_text, flatten_message_text, + prepend_text) + +BLOCKS = [ + {'type': 'text', 'text': 'Image 1: a.png'}, + {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,AAAA'}}, + {'type': 'text', 'text': 'what is in it'}, +] + + +class TestFlatten(unittest.TestCase): + + def test_string_passes_through_unchanged(self): + # The common case must be byte-identical, so no existing behaviour moves. + for value in ('hello', '', ' spaced ', 'multi\nline'): + self.assertEqual(flatten_message_text(value), value) + + def test_none_is_empty_not_the_word_none(self): + self.assertEqual(flatten_message_text(None), '') + + def test_text_blocks_joined_images_dropped(self): + out = flatten_message_text(BLOCKS) + self.assertEqual(out, 'Image 1: a.png\nwhat is in it') + self.assertNotIn('base64', out) + self.assertNotIn('AAAA', out) + + def test_every_non_text_modality_contributes_nothing(self): + for kind in ('image', 'image_url', 'input_image', 'audio', + 'input_audio', 'video', 'input_video', 'file', 'document'): + self.assertEqual( + flatten_message_text([{'type': kind, 'data': 'x' * 100}]), '', + f'{kind} must not leak its payload') + + def test_anthropic_shaped_image_block(self): + anthropic = [ + {'type': 'text', 'text': 'look'}, + {'type': 'image', 'source': {'type': 'base64', 'data': 'ZZZZ'}}, + ] + self.assertEqual(flatten_message_text(anthropic), 'look') + + def test_bare_strings_inside_a_list(self): + self.assertEqual(flatten_message_text(['a', 'b']), 'a\nb') + + def test_unknown_block_falls_back_to_its_text_field(self): + self.assertEqual( + flatten_message_text([{'type': 'weird', 'text': 'still text'}]), + 'still text') + + def test_custom_separator(self): + self.assertEqual(flatten_message_text(BLOCKS, sep=' | '), + 'Image 1: a.png | what is in it') + + def test_never_raises_on_junk(self): + for junk in (123, 4.5, True, object(), {'no': 'type'}): + self.assertIsInstance(flatten_message_text(junk), str) + + +class TestShapePreservingMutators(unittest.TestCase): + """The framework augments a user turn in place (memory recall, update + notices). Concatenating a string onto a list raises; replacing the list with + a string silently drops the images.""" + + def test_append_to_string(self): + self.assertEqual(append_text('base', 'extra'), 'base\n\nextra') + self.assertEqual(append_text('', 'extra'), 'extra') + + def test_append_to_blocks_adds_a_trailing_text_block(self): + out = append_text(BLOCKS, 'recalled memory') + self.assertIsInstance(out, list) + self.assertEqual(len(out), len(BLOCKS) + 1) + self.assertEqual(out[-1], {'type': 'text', 'text': 'recalled memory'}) + # The image block survives untouched — the whole point. + self.assertEqual(out[1], BLOCKS[1]) + + def test_prepend_to_blocks_puts_text_first(self): + out = prepend_text(BLOCKS, 'NOTICE') + self.assertEqual(out[0], {'type': 'text', 'text': 'NOTICE'}) + self.assertEqual(out[1:], BLOCKS) + + def test_empty_extra_is_a_no_op_on_both(self): + self.assertEqual(append_text(BLOCKS, ''), BLOCKS) + self.assertEqual(prepend_text('x', ''), 'x') + + def test_originals_are_not_mutated(self): + original = list(BLOCKS) + append_text(BLOCKS, 'a') + prepend_text(BLOCKS, 'b') + self.assertEqual(BLOCKS, original) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/llm/test_vision_fallback.py b/tests/llm/test_vision_fallback.py new file mode 100644 index 000000000..fed0130b3 --- /dev/null +++ b/tests/llm/test_vision_fallback.py @@ -0,0 +1,300 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Image-refusal attribution and the one-shot fallback. + +The behaviour under test was shaped by a seven-provider sweep (2026-08): +DashScope is the ONLY provider that hard-400s on image content, and its message +("Unexpected item type in content") names neither image nor multimodal nor +vision — so keyword matching cannot work. Meanwhile a 400 on an image-carrying +request also covers model-not-found and auth, so the status code alone cannot +decide either. Hence: retry wide, blacklist only on a retry that SUCCEEDS. +""" +import unittest + +from ms_agent.llm import vision as V + + +class _Boom(Exception): + + def __init__(self, status=400, msg='bad request'): + super().__init__(msg) + self.status_code = status + + +IMG_MESSAGES = [{ + 'role': 'user', + 'content': [ + {'type': 'text', 'text': 'Image 1: a.png'}, + {'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,AA'}}, + {'type': 'text', 'text': 'what is this'}, + ], +}] + + +class TestIsImageRefusal(unittest.TestCase): + + def test_requires_images_on_the_wire(self): + # A 400 with no images in the request is somebody else's problem. + self.assertFalse(V.is_image_refusal(_Boom(400), sent_images=False)) + self.assertTrue(V.is_image_refusal(_Boom(400), sent_images=True)) + + def test_only_400(self): + for status in (401, 404, 429, 500, 503): + self.assertFalse( + V.is_image_refusal(_Boom(status), sent_images=True), + f'{status} must not be attributed to images') + + def test_status_from_nested_response(self): + + class Wrapped(Exception): + + class response: # noqa: N801 + status_code = 400 + + self.assertTrue(V.is_image_refusal(Wrapped(), sent_images=True)) + + def test_falls_back_to_text_when_status_is_lost(self): + self.assertTrue( + V.is_image_refusal(Exception('Error code: 400 - oops'), + sent_images=True)) + self.assertFalse( + V.is_image_refusal(Exception('some transport hiccup'), + sent_images=True)) + + +class TestStripImages(unittest.TestCase): + + def test_replaces_image_blocks_and_keeps_labels(self): + out, changed = V.strip_images_from_messages(IMG_MESSAGES) + self.assertTrue(changed) + body = out[0]['content'] + self.assertIsInstance(body, str) + self.assertIn('Image 1: a.png', body) # the label survives + self.assertIn('what is this', body) # so does the question + self.assertNotIn('base64', body) # the pixels do not + # The reason and the remedy are both present, so the model can explain + # itself instead of answering "please upload the image". + self.assertIn('Settings', body) + + def test_text_only_is_untouched(self): + msgs = [{'role': 'user', 'content': 'plain'}] + out, changed = V.strip_images_from_messages(msgs) + self.assertFalse(changed) + self.assertEqual(out, msgs) + + +class TestCreateWithVisionFallback(unittest.TestCase): + + def setUp(self): + V.MODELS_REFUSING_IMAGES.clear() + + def test_happy_path_is_a_passthrough(self): + calls = [] + + def create(messages, **kw): + calls.append(messages) + return 'ok' + + got = V.create_with_vision_fallback( + create, base_url='u', model='m', messages=IMG_MESSAGES, + sent_images=True) + self.assertEqual(got, 'ok') + self.assertEqual(len(calls), 1) + self.assertFalse(V.MODELS_REFUSING_IMAGES) + + def test_image_refusal_retries_without_images_and_remembers(self): + seen = [] + + def create(messages, **kw): + seen.append(messages) + if len(seen) == 1: + raise _Boom(400, 'Unexpected item type in content.') + return 'recovered' + + got = V.create_with_vision_fallback( + create, base_url='u', model='m', messages=IMG_MESSAGES, + sent_images=True) + self.assertEqual(got, 'recovered') + self.assertEqual(len(seen), 2) + self.assertIsInstance(seen[1][0]['content'], str) + self.assertIn(('u', 'm'), V.MODELS_REFUSING_IMAGES) + + def test_unrelated_400_does_not_blacklist_and_reraises_the_original(self): + """Regression: ModelScope answers "Model id ... has no provider + supported" with a 400. Attributing that to images wasted a round-trip + AND permanently stopped sending images to a model whose real problem was + that it did not exist.""" + original = _Boom(400, 'Model id : X , has no provider supported') + + def create(messages, **kw): + raise original + + with self.assertRaises(_Boom) as ctx: + V.create_with_vision_fallback( + create, base_url='u', model='m', messages=IMG_MESSAGES, + sent_images=True) + self.assertIs(ctx.exception, original) # the real error, not the retry's + self.assertFalse(V.MODELS_REFUSING_IMAGES) + + def test_known_refuser_skips_the_doomed_first_attempt(self): + V.note_refusal('u', 'm') + seen = [] + + def create(messages, **kw): + seen.append(messages) + return 'ok' + + V.create_with_vision_fallback( + create, base_url='u', model='m', messages=IMG_MESSAGES, + sent_images=True) + self.assertEqual(len(seen), 1) + self.assertIsInstance(seen[0][0]['content'], str) + + def test_non_image_error_propagates_untouched(self): + + def create(messages, **kw): + raise _Boom(429, 'rate limited') + + with self.assertRaises(_Boom): + V.create_with_vision_fallback( + create, base_url='u', model='m', messages=IMG_MESSAGES, + sent_images=True) + self.assertFalse(V.MODELS_REFUSING_IMAGES) + + +class TestResolveSupportsVision(unittest.TestCase): + + def setUp(self): + V.MODELS_REFUSING_IMAGES.clear() + + def test_explicit_switch_wins(self): + from omegaconf import OmegaConf + on = OmegaConf.create({'llm': {'supports_vision': True}}) + off = OmegaConf.create({'llm': {'supports_vision': False}}) + self.assertTrue(V.resolve_supports_vision(on)) + self.assertFalse(V.resolve_supports_vision(off)) + + def test_quoted_false_is_honoured(self): + """`supports_vision: "false"` is a common YAML slip; bare bool() would + read it as ON, i.e. exactly the opposite of what was asked.""" + from omegaconf import OmegaConf + cfg = OmegaConf.create({'llm': {'supports_vision': 'false'}}) + self.assertFalse(V.resolve_supports_vision(cfg)) + cfg = OmegaConf.create({'llm': {'supports_vision': 'yes'}}) + self.assertTrue(V.resolve_supports_vision(cfg)) + + def test_observed_refusal_overrides_an_explicit_yes(self): + from omegaconf import OmegaConf + cfg = OmegaConf.create({'llm': {'supports_vision': True}}) + V.note_refusal('u', 'm') + self.assertFalse( + V.resolve_supports_vision(cfg, model='m', base_url='u')) + + def test_unset_falls_through_to_provider_capability(self): + from omegaconf import OmegaConf + from ms_agent.llm.spec import get_registry + cfg = OmegaConf.create({'llm': {'model': 'x'}}) + # dashscope declares vision; anthropic does too; a spec-less provider + # yields False rather than guessing. + self.assertTrue( + V.resolve_supports_vision(cfg, spec=get_registry().get('dashscope'))) + self.assertFalse(V.resolve_supports_vision(cfg, spec=None)) + + +class TestTransportWiring(unittest.TestCase): + """The wrapper's named arguments must not collide with the API params. + + ``create_with_vision_fallback`` takes ``model`` and ``messages`` as named + arguments and forwards everything else to the factory. A transport that also + leaves those keys in the dict it splats raises + ``TypeError: got multiple values for keyword argument 'model'`` on EVERY + call — a total outage of that transport, not a vision-only edge case. It + reached a real endpoint before it was caught, so it is pinned here for both + transport families. + """ + + def setUp(self): + V.MODELS_REFUSING_IMAGES.clear() + + def _call(self, transport_params): + """Drive the wrapper the way a transport does and return the API kwargs.""" + seen = {} + + def factory(messages, **kw): + seen.update(kw) + seen['messages'] = messages + # Mimic a real client: it needs `model` named in the call. + assert 'model' in seen, 'the API call was made without a model' + return 'ok' + + params = dict(transport_params) + rest = { + k: v + for k, v in params.items() if k not in ('model', 'messages') + } + out = V.create_with_vision_fallback( + lambda messages, **kw: factory( + messages, model=params['model'], **kw), + base_url='https://example/v1', + model=params['model'], + messages=params['messages'], + sent_images=False, + **rest) + return out, seen + + def test_anthropic_shaped_params_do_not_collide(self): + out, seen = self._call({ + 'model': 'claude-x', + 'messages': IMG_MESSAGES, + 'max_tokens': 1024, + 'thinking': { + 'type': 'disabled', + 'budget_tokens': 1024 + }, + 'system': 'be brief', + }) + self.assertEqual(out, 'ok') + # model survives to the API call, and the other params are untouched. + self.assertEqual(seen['model'], 'claude-x') + self.assertEqual(seen['max_tokens'], 1024) + self.assertEqual(seen['system'], 'be brief') + self.assertEqual(seen['messages'], IMG_MESSAGES) + + def test_real_anthropic_transport_builds_a_valid_call(self): + """End-to-end through AnthropicMessagesTransport._call_llm itself.""" + from ms_agent.llm.transport import anthropic_messages as AM + from ms_agent.llm.utils import Message + + calls = [] + + class _Messages: + + def create(self, **kw): + calls.append(kw) + return 'created' + + def stream(self, **kw): + calls.append(kw) + return 'streamed' + + class _Client: + base_url = 'https://api.deepseek.com/anthropic' + messages = _Messages() + + transport = AM.AnthropicMessagesTransport.__new__( + AM.AnthropicMessagesTransport) + transport.client = _Client() + transport.model = 'deepseek-v4-pro' + transport.vision = None + transport.vision_supported = False + + out = transport._call_llm( + [Message(role='user', content='hi')], tools=None, stream=False) + + self.assertEqual(out, 'created') + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0]['model'], 'deepseek-v4-pro') + self.assertEqual(calls[0]['messages'][0]['role'], 'user') + + +if __name__ == '__main__': + unittest.main() From e614df64f25440803f849bbd8c08cee7b71a5941 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 19 Aug 2026 04:48:39 +0800 Subject: [PATCH 34/36] Mark earlier image descriptions as another model's reliable history so a vision-disabled model neither claims nor disowns them --- ms_agent/llm/multimodal.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/ms_agent/llm/multimodal.py b/ms_agent/llm/multimodal.py index 505fd5582..aa89384c3 100644 --- a/ms_agent/llm/multimodal.py +++ b/ms_agent/llm/multimodal.py @@ -327,11 +327,19 @@ def placeholder_for(ref: ImageRef, reason: str = '') -> str: #: Reason strings, kept here so the wording is identical across transports. +#: Both spell out that any earlier image descriptions in the conversation came +#: from a model that could see the pictures. Without that sentence, a model +#: switched in mid-session sees "not shown" placeholders NEXT TO confident +#: assistant answers about the same images, resolves the contradiction as "so I +#: did see them after all", and claims present-tense sight (measured on +#: qwen3.7-max: it answered 能看到 and repeated its predecessor's reading). REASON_DISABLED = ( - 'Image understanding is not enabled for the current model. ' - 'Tell the user they can turn on "image understanding" for ' - 'this model in Settings → Models, or switch to a model that ' - 'supports it.') + 'Image understanding is not enabled for the current model, so you cannot ' + 'see this image now. Earlier replies in this conversation that describe ' + 'it were written while a vision-capable model was active: treat them as ' + 'reliable history, but do not claim to see the image yourself. Tell the ' + 'user they can turn on "image understanding" for this model in ' + 'Settings → Models, or switch to a model that supports it.') REASON_UNREADABLE = ('The file could not be read or decoded as an image.') @@ -473,9 +481,11 @@ def has_image_blocks(content: Any) -> bool: #: Measured before this text existed, qwen3.7-max answered exactly that. REASON_REFUSED = ( 'not visible: this model rejected image input. The file was uploaded and is ' - 'in the workspace under the name shown above. Tell the user this model ' - 'cannot view images, and that they can enable "image understanding" for it ' - 'in Settings → Models or switch to a model that supports vision.') + 'in the workspace under the name shown above. Any earlier replies that ' + 'describe this image came from a model that could see it. Tell the user ' + 'this model cannot view images, and that they can enable "image ' + 'understanding" for it in Settings → Models or switch to a model that ' + 'supports vision.') def strip_image_blocks(content: Any) -> Any: From 89a4c571bf3535ba4aadd5e7de09c5bd33024a68 Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Wed, 19 Aug 2026 12:54:39 +0800 Subject: [PATCH 35/36] Simulate mem0's absence explicitly so the test stops depending on leftover credentials in the environment --- tests/memory/test_unified_memory.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/memory/test_unified_memory.py b/tests/memory/test_unified_memory.py index 5d4d66fc9..d459ca870 100644 --- a/tests/memory/test_unified_memory.py +++ b/tests/memory/test_unified_memory.py @@ -1677,9 +1677,18 @@ def test_inject_without_mem0_passthrough(self): loop.close() def test_start_without_mem0_package(self): + # Simulate the absence for real: `sys.modules['mem0'] = None` makes + # `from mem0 import Memory` raise ImportError regardless of what is + # installed. Without this the test only passed by luck — on a machine + # WITH mem0, the outcome depended on whether `Memory.from_config` + # happened to find working credentials that earlier LLM-backed tests + # had exported into os.environ, which made it order-flaky. + import sys + from unittest.mock import patch loop = asyncio.new_event_loop() try: - loop.run_until_complete(self.backend.start()) + with patch.dict(sys.modules, {'mem0': None}): + loop.run_until_complete(self.backend.start()) assert self.backend._mem0 is None finally: loop.close() From f3a1c6b61302ef7aef06d1d990d6c32a99f03abd Mon Sep 17 00:00:00 2001 From: alcholiclg Date: Thu, 20 Aug 2026 15:49:32 +0800 Subject: [PATCH 36/36] Resolve image support from the per-model switch, and retry refusals that arrive mid-stream - images go out only when the model's own switch says so; a provider's declared vision capability no longer implies it - a 400 delivered on the first streamed chunk is repaired like an eager one - thinking refusals are repaired on the Anthropic and Responses paths too - a tool call the model is still writing is reported instead of nothing at all - an unreadable managed MCP config is logged instead of silently yielding none --- ms_agent/agent/llm_agent.py | 40 ++++- ms_agent/llm/anthropic_llm.py | 23 ++- ms_agent/llm/multimodal.py | 39 +++-- ms_agent/llm/openai_llm.py | 31 +++- ms_agent/llm/stream_retry.py | 64 ++++++++ ms_agent/llm/thinking.py | 28 +++- ms_agent/llm/transport/anthropic_messages.py | 28 +++- ms_agent/llm/transport/openai_compat.py | 9 +- ms_agent/llm/vision.py | 146 ++++++++++++------- ms_agent/tui/managed_config.py | 13 +- ms_agent/ui/events.py | 22 +++ tests/llm/test_thinking_effort.py | 87 +++++++++++ tests/llm/test_vision_fallback.py | 145 +++++++++++++++++- 13 files changed, 583 insertions(+), 92 deletions(-) create mode 100644 ms_agent/llm/stream_retry.py diff --git a/ms_agent/agent/llm_agent.py b/ms_agent/agent/llm_agent.py index 0ab7b225a..2733eaa1f 100644 --- a/ms_agent/agent/llm_agent.py +++ b/ms_agent/agent/llm_agent.py @@ -46,7 +46,8 @@ ErrorRaised, PlanEntry, PlanUpdated, ReasoningDelta, ReasoningEnded, ReasoningStarted, ToolCallCompleted, - ToolCallStarted, TurnCompleted, UsageInfo) + ToolCallComposing, ToolCallStarted, + TurnCompleted, UsageInfo) from ms_agent.utils import (async_retry, is_retryable_error, read_history, save_history) from ms_agent.utils.constants import DEFAULT_TAG, DEFAULT_USER @@ -1135,6 +1136,38 @@ def _emit_content_end(self) -> None: else: sys.stdout.write('\n') + #: Bytes of tool-call arguments between two ``ToolCallComposing`` events. + #: Small enough that a multi-file write reports progress several times a + #: second, large enough that a short call emits once and stops. + _COMPOSING_STEP = 256 + + def _emit_tool_composing(self, message, announced: Dict[int, int]) -> None: + """Report tool calls the model is still writing. + + Streaming hands us the assistant message repeatedly, with each tool + call's ``arguments`` growing chunk by chunk. Nothing has run yet — this + is purely so the UI can say "preparing write_file…" instead of showing + nothing at all while a large call is transmitted. + + Silent for a UI-less run (no event sink), and throttled so short calls + emit once rather than once per chunk. + """ + if self._event_sink is None: + return + for index, call in enumerate(getattr(message, 'tool_calls', None) or []): + if not isinstance(call, dict): + continue + name = str(call.get('tool_name') or '') + if not name: + continue # the name always precedes the arguments; wait for it + size = len(str(call.get('arguments') or '')) + last = announced.get(index) + if last is not None and size - last < self._COMPOSING_STEP: + continue + announced[index] = size + self._event_sink.emit( + ToolCallComposing(index=index, name=name, arguments_len=size)) + @staticmethod def _extract_plan_from_tool_result(msg): """Parse a todo / split_task tool result into a list of PlanEntry, or @@ -1909,6 +1942,10 @@ async def step( _response_message = None _printed_reasoning_header = False _printed_reasoning_footer = False + # index -> arguments length already announced, so a long tool + # call reports progress instead of going silent (see + # ui.events.ToolCallComposing). + _composing: Dict[int, int] = {} _gen = self.llm.generate(messages, tools=tools) _loop = asyncio.get_running_loop() _NO_MORE = object() @@ -1957,6 +1994,7 @@ def _next_chunk(_g=_gen): _printed_reasoning_footer = True self._emit_content(new_content) _content = _response_message.content + self._emit_tool_composing(_response_message, _composing) messages[-1] = _response_message yield messages finally: diff --git a/ms_agent/llm/anthropic_llm.py b/ms_agent/llm/anthropic_llm.py index 11f7ab8bc..728f99016 100644 --- a/ms_agent/llm/anthropic_llm.py +++ b/ms_agent/llm/anthropic_llm.py @@ -5,10 +5,13 @@ from typing import Any, Dict, Generator, Iterator, List, Optional, Union from ms_agent.llm import LLM +from ms_agent.llm.thinking import create_with_thinking_fallback from ms_agent.llm.utils import Message, Tool, ToolCall -from ms_agent.utils import assert_package_exist, retry +from ms_agent.utils import assert_package_exist, get_logger, retry from ms_agent.utils.constants import get_service_config +logger = get_logger() + class _SSEEventInjector(httpx.SyncByteStream): """Injects SSE ``event:`` lines into DashScope's streaming response. @@ -278,10 +281,20 @@ def _call_llm(self, kwargs['extra_body'] = extra_body params.update(kwargs) - if stream: - return self.client.messages.stream(**params) - else: - return self.client.messages.create(**params) + def _send(**call): + call.setdefault('model', self.model) + if stream: + return self.client.messages.stream(**call) + return self.client.messages.create(**call) + + # This legacy engine owned no repair at all: a model that cannot think + # rejected the `thinking` block with a hard 400 and the error went + # straight to the caller, while the transport port of this same engine + # recovered. Bring it in line — `model` is passed through `_send` so it + # cannot collide with the wrapper's own named argument. + rest = {k: v for k, v in params.items() if k != 'model'} + return create_with_thinking_fallback(_send, self.client, self.model, + logger, **rest) @retry(max_attempts=LLM.retry_count, delay=3.0) def generate(self, diff --git a/ms_agent/llm/multimodal.py b/ms_agent/llm/multimodal.py index aa89384c3..391b722a8 100644 --- a/ms_agent/llm/multimodal.py +++ b/ms_agent/llm/multimodal.py @@ -333,13 +333,27 @@ def placeholder_for(ref: ImageRef, reason: str = '') -> str: #: assistant answers about the same images, resolves the contradiction as "so I #: did see them after all", and claims present-tense sight (measured on #: qwen3.7-max: it answered 能看到 and repeated its predecessor's reading). +#: The shared middle sentence: what to do about earlier descriptions. +_HISTORY_NOTE = ( + 'Earlier replies in this conversation that describe it were written while ' + 'a vision-capable model was active: treat them as reliable history, but do ' + 'not claim to see the image yourself.') + +#: The switch is off (the default). The remedy is to turn it on. REASON_DISABLED = ( 'Image understanding is not enabled for the current model, so you cannot ' - 'see this image now. Earlier replies in this conversation that describe ' - 'it were written while a vision-capable model was active: treat them as ' - 'reliable history, but do not claim to see the image yourself. Tell the ' - 'user they can turn on "image understanding" for this model in ' - 'Settings → Models, or switch to a model that supports it.') + f'see this image now. {_HISTORY_NOTE} Tell the user they can turn on ' + '"image understanding" for this model in Settings → Models, or switch to a ' + 'model that supports it.') + +#: The switch is ON but the endpoint rejected the image. Telling this user to +#: "enable image understanding" would point at a box they already ticked, so +#: this wording names the real situation and offers the remedy that is left. +REASON_REJECTED = ( + 'This model rejected image input, so you cannot see this image even though ' + f'image understanding is enabled for it. {_HISTORY_NOTE} Tell the user this ' + 'model cannot accept images and that they should switch to one that can.') + REASON_UNREADABLE = ('The file could not be read or decoded as an image.') @@ -366,19 +380,25 @@ def _degrade(text: str, refs: Sequence[ImageRef], reason: str) -> str: def openai_content(text: Any, attachments: Optional[Sequence[Dict[str, Any]]], opts: VisionOptions, - vision_supported: bool = True) -> Any: + vision_supported: bool = True, + disabled_reason: str = REASON_DISABLED) -> Any: """Content for an OpenAI-compatible (Chat Completions) user message. Returns a plain string when there is nothing to attach — keeping the overwhelmingly common text-only request byte-identical to before, which also means prefix caching is unaffected. + + ``disabled_reason`` lets the caller say WHY the pixels are absent: the + default blames the switch, and a transport that knows the endpoint rejected + this model's images passes :data:`REASON_REJECTED` instead, so the model + never tells a user to enable something they already enabled. """ refs = image_refs(attachments, opts) if not refs: return text if not (opts.enabled and vision_supported): return _degrade( - text if isinstance(text, str) else '', refs, REASON_DISABLED) + text if isinstance(text, str) else '', refs, disabled_reason) blocks: List[Dict[str, Any]] = [] unreadable: List[ImageRef] = [] @@ -412,7 +432,8 @@ def openai_content(text: Any, def anthropic_content(text: Any, attachments: Optional[Sequence[Dict[str, Any]]], opts: VisionOptions, - vision_supported: bool = True) -> Any: + vision_supported: bool = True, + disabled_reason: str = REASON_DISABLED) -> Any: """Content blocks for an Anthropic Messages user message. Same contract as :func:`openai_content`; only the block shape differs @@ -423,7 +444,7 @@ def anthropic_content(text: Any, return text if not (opts.enabled and vision_supported): return _degrade( - text if isinstance(text, str) else '', refs, REASON_DISABLED) + text if isinstance(text, str) else '', refs, disabled_reason) blocks: List[Dict[str, Any]] = [] unreadable: List[ImageRef] = [] diff --git a/ms_agent/llm/openai_llm.py b/ms_agent/llm/openai_llm.py index 15c15d2ca..bd9112963 100644 --- a/ms_agent/llm/openai_llm.py +++ b/ms_agent/llm/openai_llm.py @@ -14,6 +14,7 @@ from ms_agent.llm.thinking import apply_effort, create_with_thinking_fallback from ms_agent.llm.utils import Message, Tool, ToolCall from ms_agent.llm.vision import create_with_vision_fallback +from ms_agent.llm.vision import disabled_reason as vision_disabled_reason from ms_agent.utils import (MAX_CONTINUE_RUNS, assert_package_exist, get_logger, retry) from ms_agent.utils.constants import get_service_config @@ -907,9 +908,16 @@ def _responses_generate(self, if resp_tools: kwargs['tools'] = resp_tools - response = self._responses_client.responses.create( - model=self.model, - input=input_items, + # Same per-model hard-400 as the Chat Completions branch: a model that + # cannot think rejects the reasoning parameters outright. This branch + # used to lower the knob (`apply_effort`) without owning the repair, so + # the refusal reached the caller raw. + response = create_with_thinking_fallback( + lambda **kw: self._responses_client.responses.create( + model=self.model, input=input_items, **kw), + self._responses_client, + self.model, + logger, **kwargs, ) text = getattr(response, 'output_text', '') or '' @@ -959,9 +967,12 @@ def _responses_stream_generate(self, if resp_tools: kwargs['tools'] = resp_tools - stream = self._responses_client.responses.create( - model=self.model, - input=input_items, + stream = create_with_thinking_fallback( + lambda **kw: self._responses_client.responses.create( + model=self.model, input=input_items, **kw), + self._responses_client, + self.model, + logger, stream=True, **kwargs, ) @@ -1094,8 +1105,12 @@ def _format_input_message(self, # multimodal.TOOL_MEDIA_PROMPT for the full measurement. if attachments and message.get('role') != 'tool': content = multimodal.openai_content( - content, attachments, self._vision, - vision_supported=self._vision_supported) + content, + attachments, + self._vision, + vision_supported=self._vision_supported, + disabled_reason=vision_disabled_reason( + self.base_url, self.model)) # Apply prefix cache structured content transformation # Only for string content, multimodal content is already structured diff --git a/ms_agent/llm/stream_retry.py b/ms_agent/llm/stream_retry.py new file mode 100644 index 000000000..538124d6b --- /dev/null +++ b/ms_agent/llm/stream_retry.py @@ -0,0 +1,64 @@ +# Copyright (c) ModelScope Contributors. All rights reserved. +"""Retry a request whose rejection arrives *after* the HTTP response starts. + +``llm/thinking.py`` and ``llm/vision.py`` both repair a request the endpoint +refuses (drop the thinking parameters / drop the image blocks) and try once +more. Both used to guard only the ``create(...)`` call, on the assumption +spelled out in their docstrings: "the client performs the request — and raises — +before it returns an iterator". + +That holds for the OpenAI Python SDK, which does issue the HTTP request eagerly. +It does **not** hold for gateways that answer 200 and then put the error in the +stream. Measured on an Aliyun-family endpoint:: + + APIError: <400> InternalError.Algo.InvalidParameter: The thinking_budget + parameter must be a positive integer and not greater than 0 + +arrives while the first chunk is being read, i.e. *outside* the ``try`` — so +neither fallback saw it, nothing was retried, nothing was remembered, and the +raw provider error reached the user. + +The window this module reopens is deliberately narrow: only the FIRST advance of +the stream is guarded. Until then nothing has been handed to the caller, so +replacing the stream wholesale is invisible and safe. Once a single chunk has +been delivered the turn is already partly rendered, and silently restarting it +would duplicate or contradict what the user has seen — so a later failure is +re-raised untouched. +""" +from __future__ import annotations + +from typing import Any, Callable, Iterator + + +def retry_on_first_chunk(result: Any, repair: Callable[[BaseException], + Any]) -> Any: + """Guard the first advance of ``result`` with ``repair``. + + ``result`` is whatever the provider client returned. Non-iterators (a + non-streaming response object, Anthropic's stream *manager*) are handed back + untouched — there is no first chunk to guard, and their errors already + surface eagerly. + + ``repair(exc)`` is the same callable the eager path uses: it either returns + a replacement result or re-raises. Its replacement is streamed in full, so + the caller cannot tell which attempt produced the data. + """ + if not hasattr(result, '__next__'): + return result + + def _guarded() -> Iterator[Any]: + source = result + try: + first = next(source) + except StopIteration: + return + except Exception as exc: # noqa: BLE001 — handed to the same repair + replacement = repair(exc) + if replacement is not None: + yield from replacement + return + # Past this point the caller has seen output; a failure now is real. + yield first + yield from source + + return _guarded() diff --git a/ms_agent/llm/thinking.py b/ms_agent/llm/thinking.py index 5b958004e..753249e78 100644 --- a/ms_agent/llm/thinking.py +++ b/ms_agent/llm/thinking.py @@ -643,9 +643,15 @@ def create_with_thinking_fallback(create, client, model: str, logger, """Call ``create(**kwargs)``, retrying once with thinking off on a refusal. ``create`` must be the completions factory itself; it is called with the - (possibly cleaned) kwargs. Streaming is covered because the OpenAI client - performs the request — and raises — before it returns an iterator. + (possibly cleaned) kwargs. + + Streaming is covered in BOTH shapes: clients that issue the request eagerly + raise out of ``create`` itself, and gateways that answer 200 before + rejecting the parameters raise on the first chunk — see + ``llm/stream_retry.py`` for why only that first chunk is guarded. """ + from ms_agent.llm.stream_retry import retry_on_first_chunk + key = model_key(client, model) if key in MODELS_REFUSING_THINKING: kwargs = without_thinking(kwargs) @@ -653,15 +659,15 @@ def create_with_thinking_fallback(create, client, model: str, logger, # Only the "off" request is doomed here; a positive tier still goes out # normally, so this model is not blacklisted the way a refuser is. kwargs = strip_thinking(kwargs) - try: - return create(**kwargs) - except Exception as e: + + def _repair(e: BaseException) -> Any: + """Repair-and-retry, shared by the eager and first-chunk paths.""" # Order matters: "reasoning is mandatory" also names a thinking # parameter, so refusal would claim it and repair it backwards. if is_thinking_mandatory(e): retry_kwargs = strip_thinking(kwargs) if retry_kwargs is kwargs: - raise + raise e MODELS_REQUIRING_THINKING.add(key) logger.warning( f'{model} does not allow thinking to be switched off; ' @@ -669,12 +675,18 @@ def create_with_thinking_fallback(create, client, model: str, logger, f'for this model): {e}') return create(**retry_kwargs) if not is_thinking_refusal(e): - raise + raise e retry_kwargs = without_thinking(kwargs) if retry_kwargs is kwargs: # we asked for no thinking; not our 400 - raise + raise e MODELS_REFUSING_THINKING.add(key) logger.warning( f'{model} rejected the thinking parameters; retrying with ' f'thinking off (it stays off for this model): {e}') return create(**retry_kwargs) + + try: + result = create(**kwargs) + except Exception as e: + return _repair(e) + return retry_on_first_chunk(result, _repair) diff --git a/ms_agent/llm/transport/anthropic_messages.py b/ms_agent/llm/transport/anthropic_messages.py index ba26bba55..66231c4f3 100644 --- a/ms_agent/llm/transport/anthropic_messages.py +++ b/ms_agent/llm/transport/anthropic_messages.py @@ -16,11 +16,14 @@ from typing import Any, Dict, Generator, Iterator, List, Optional, Union from ms_agent.llm import multimodal -from ms_agent.llm.thinking import apply_effort +from ms_agent.llm.thinking import apply_effort, create_with_thinking_fallback from ms_agent.llm.transport.base import Transport from ms_agent.llm.utils import Message, Tool, ToolCall from ms_agent.llm.vision import create_with_vision_fallback -from ms_agent.utils import assert_package_exist +from ms_agent.llm.vision import disabled_reason as vision_disabled_reason +from ms_agent.utils import assert_package_exist, get_logger + +logger = get_logger() class AnthropicMessagesTransport(Transport): @@ -173,7 +176,9 @@ def _format_input_message(self, msg.content if isinstance(msg.content, str) else '', attachments, self._vision, - vision_supported=self._vision_supported) + vision_supported=self._vision_supported, + disabled_reason=vision_disabled_reason( + getattr(self.client, 'base_url', ''), self.model)) if isinstance(built, list): content.extend(built) elif built: @@ -293,7 +298,7 @@ def _call_llm(self, multimodal.has_image_blocks(m.get('content')) for m in formatted_messages if isinstance(m, dict)) - def _create(messages, **kw): + def _send(messages, **kw): call = dict(kw) call['messages'] = messages # `model` is a named parameter of create_with_vision_fallback (it @@ -304,6 +309,20 @@ def _create(messages, **kw): return self.client.messages.stream(**call) return self.client.messages.create(**call) + # Thinking is the OTHER per-model hard-400, and this transport used to + # be the one family without the repair: a Messages-API gateway fronting + # a model that cannot think rejected the `thinking` block outright and + # the error went straight to the user. Nested INSIDE the vision + # fallback, exactly as in the OpenAI-family transports, so the two + # retries compose instead of masking each other. + def _create(messages, **kw): + return create_with_thinking_fallback( + lambda **kw2: _send(messages, **kw2), + self.client, + self.model, + logger, + **kw) + # Everything except `model` and `messages`, which the wrapper takes as # named arguments; leaving either in `params` would collide with them. rest = { @@ -316,6 +335,7 @@ def _create(messages, **kw): model=self.model, messages=params['messages'], sent_images=sent_images, + logger_=logger, **rest) def generate( diff --git a/ms_agent/llm/transport/openai_compat.py b/ms_agent/llm/transport/openai_compat.py index d8af7325e..6cfe62f9b 100644 --- a/ms_agent/llm/transport/openai_compat.py +++ b/ms_agent/llm/transport/openai_compat.py @@ -27,6 +27,7 @@ from ms_agent.llm.transport.base import Transport from ms_agent.llm.utils import Message, Tool, ToolCall from ms_agent.llm.vision import create_with_vision_fallback +from ms_agent.llm.vision import disabled_reason as vision_disabled_reason from ms_agent.utils import MAX_CONTINUE_RUNS, assert_package_exist, get_logger logger = get_logger() @@ -236,8 +237,12 @@ def _format_input_message(self, # multimodal.TOOL_MEDIA_PROMPT for the full measurement. if attachments and message.get('role') != 'tool': content = multimodal.openai_content( - content, attachments, self._vision, - vision_supported=self._vision_supported) + content, + attachments, + self._vision, + vision_supported=self._vision_supported, + disabled_reason=vision_disabled_reason( + getattr(self.client, 'base_url', ''), self.model)) if cache_indice is not None and idx == cache_indice: content = self._to_structured_content( diff --git a/ms_agent/llm/vision.py b/ms_agent/llm/vision.py index 5c3636f65..6a1f4985d 100644 --- a/ms_agent/llm/vision.py +++ b/ms_agent/llm/vision.py @@ -3,15 +3,29 @@ Two halves: -**Resolution** — whether to attach pixels at all, decided per model, strongest -signal first: +**Resolution** — whether to attach pixels at all. Two states only, and the +default is OFF: 1. an explicit per-model setting (the "image understanding" switch in the model - form) — the user's own statement, always wins; -2. the provider's declared ``vision`` capability — a coarse default for models - nobody has classified yet; -3. a model observed to REFUSE images earlier in this process — vetoes both of - the above, because a refusal is ground truth. + form) — the user's own statement, and the only thing that turns images ON; +2. a model observed to REFUSE images earlier in this process vetoes it, because + a refusal is ground truth. + +Deliberately NOT consulted: the provider's declared ``vision`` capability. +Vision is a property of the MODEL, not of the endpoint — ModelScope serves +``Qwen3-VL-8B-Instruct`` and the text-only ``Qwen3-235B-A22B`` through one +provider entry, so a provider-level flag says yes to both. It used to be the +middle tier here, and because nine of ten registry entries declare ``vision`` +it made "nobody has said" mean "send images", i.e. the switch's OFF position +described a state the runtime never actually used. ``ProviderCapability.VISION`` +still exists and is still correct about what the *protocol* accepts; it is just +not evidence about a particular model's eyesight. + +Whether a model can really see is therefore the user's call. There is no +probing: a model that accepts image blocks with HTTP 200 and cannot read them +(measured: zhipu glm-5.x, MiniMax-M2.7, and ModelScope's Qwen3-235B-A22B, which +answered with an invented string) is indistinguishable at runtime from one that +can. **Self-healing** — a provider that cannot see images rejects the whole request with a hard 400, which would otherwise make such a model unusable the moment a @@ -42,11 +56,12 @@ #: ``(base_url, model)`` pairs observed to reject image content. MODELS_REFUSING_IMAGES: Set[Tuple[str, str]] = set() -#: Callables notified the first time a model is learned to refuse images. -#: A host (the WebUI) registers one so the discovery can be written back to -#: wherever the user configured the model — otherwise the knowledge dies with -#: the process and every restart pays the same rejected request again, while the -#: "image understanding" switch keeps claiming the model supports it. +#: Callables notified the first time a model is learned to refuse images, so a +#: host (the WebUI) can TELL THE USER. Deliberately not a write-back hook: the +#: switch is the user's statement about their own model, and silently rewriting +#: it would both contradict them and hide the reason. The memo below keeps the +#: session from paying the failed round-trip twice; making it permanent is the +#: user's decision to make in the model form. _OBSERVERS: List[Any] = [] @@ -54,7 +69,7 @@ def register_refusal_observer(fn) -> None: """Register ``fn(base_url, model)``, called once per newly-learned refusal. Idempotent per callable, so repeated setup (a WebUI reload) cannot stack - duplicate write-backs. Observer exceptions are swallowed: learning that a + duplicate notifications. Observer exceptions are swallowed: learning that a model refuses images must never be able to fail the turn that discovered it. """ if fn not in _OBSERVERS: @@ -82,6 +97,18 @@ def known_refuser(base_url: Any, model: str) -> bool: return model_key(base_url, model) in MODELS_REFUSING_IMAGES +def disabled_reason(base_url: Any = '', model: str = '') -> str: + """Why this turn's images are text placeholders, for the model to relay. + + A model whose switch is off should be told to turn it on; a model whose + switch is ON but whose endpoint rejected the images must NOT be, or it + sends the user back to a box they already ticked. + """ + if model and known_refuser(base_url, model): + return multimodal.REASON_REJECTED + return multimodal.REASON_DISABLED + + def _status_of(exc: Exception) -> Optional[int]: status = getattr(exc, 'status_code', None) if status is None: @@ -154,21 +181,58 @@ def create_with_vision_fallback(create, """Call ``create(messages=..., **kwargs)``, retrying once without images. ``create`` must accept ``messages`` as a keyword so the retry can hand it a - rewritten list. Streaming is covered: the client performs the request — and - raises — before it returns an iterator. + rewritten list. + + Streaming is covered in BOTH shapes: clients that issue the request eagerly + raise out of ``create`` itself, and gateways that answer 200 before + rejecting the image blocks raise on the first chunk (see + ``llm/stream_retry.py``). """ + from ms_agent.llm.stream_retry import retry_on_first_chunk + log = logger_ or logger if sent_images and known_refuser(base_url, model): messages, _ = strip_images_from_messages(messages) sent_images = False - try: - return create(messages=messages, **kwargs) - except Exception as exc: + + def _remember() -> None: + note_refusal(base_url, model) + log.warning( + 'images stay off for %s for the rest of this process (the ' + 'image-less retry succeeded)', model) + + def _confirm(result: Any, original: BaseException) -> Any: + """Blacklist only once the image-less attempt actually produces output. + + For a non-streaming call "returned" already means "succeeded". For a + stream it does not: the replacement can still fail on its own first + chunk, and treating that as proof would blacklist a model whose real + problem was something else entirely. + """ + if not hasattr(result, '__next__'): + _remember() + return result + + def _guarded(): + try: + first = next(result) + except StopIteration: + _remember() # empty, but the endpoint accepted it + return + except Exception: + raise original from None # the images were not the cause + _remember() + yield first + yield from result + + return _guarded() + + def _repair(exc: BaseException) -> Any: if not is_image_refusal(exc, sent_images): - raise + raise exc retry_messages, changed = strip_images_from_messages(messages) if not changed: - raise + raise exc log.warning( '%s returned 400 on a request carrying images; retrying once with ' 'the images replaced by text: %s', model, exc) @@ -184,14 +248,13 @@ def create_with_vision_fallback(create, # rest of the process. Measured on ModelScope, whose "Model id ... # has no provider supported" is exactly this shape. raise exc from None - # The image-less retry succeeded, so the images were the problem. THIS - # is the only sound moment to remember it — the status code alone cannot - # tell an image refusal from any other 400. - note_refusal(base_url, model) - log.warning( - 'images stay off for %s for the rest of this process (the ' - 'image-less retry succeeded)', model) - return result + return _confirm(result, exc) + + try: + result = create(messages=messages, **kwargs) + except Exception as exc: + return _repair(exc) + return retry_on_first_chunk(result, _repair) def resolve_supports_vision(config: Any, @@ -200,35 +263,20 @@ def resolve_supports_vision(config: Any, base_url: Any = '') -> bool: """Whether to attach pixels for this model. See the module docstring. - ``config.llm.supports_vision`` is the explicit per-model switch. It is - read as a TRI-STATE: absent/None means "nobody has said", which falls - through to the provider capability and then to runtime learning. That is - better than a hard default in either direction — a hard ``False`` would make - a capable model silently ignore attachments until someone ticks a box, and a - hard ``True`` would make every text-only model burn a 400 on first use. + ``config.llm.supports_vision`` is the explicit per-model switch and the only + thing that turns images on; unset means OFF. ``spec`` is accepted and ignored + (kept so existing callers need no edit): a provider's declared ``vision`` + capability describes the protocol, not the model behind it. """ if model and known_refuser(base_url, model): - return False # observed truth beats every declaration + return False # observed truth beats the switch llm = getattr(config, 'llm', None) if config is not None else None - explicit = None if llm is not None: for name in ('supports_vision', 'vision_supported'): value = getattr(llm, name, None) if value is not None: - explicit = value - break - if explicit is not None: - return _as_bool(explicit) - - if spec is not None: - caps = getattr(spec, 'capabilities', None) - if caps is not None: - try: - from ms_agent.llm.types import ProviderCapability - return bool(caps.supports(ProviderCapability.VISION)) - except Exception: - pass + return _as_bool(value) return False diff --git a/ms_agent/tui/managed_config.py b/ms_agent/tui/managed_config.py index 35de5c6fb..112759801 100644 --- a/ms_agent/tui/managed_config.py +++ b/ms_agent/tui/managed_config.py @@ -82,7 +82,18 @@ def resolve_mcp_config( for k, v in entry.items() if k not in _MCP_META } except Exception: - pass + # Everything above is one try, so a single malformed mcp.json silently + # became "this agent has no MCP servers at all" — indistinguishable + # from "none configured", and the hardest possible shape to diagnose + # from the outside. Still non-fatal (a broken file must not stop the + # agent), but no longer invisible. + from ms_agent.utils import get_logger + get_logger().warning( + 'could not read the managed MCP configuration (global=%s, ' + 'project=%s); continuing with no MCP servers from it', + global_home, + work_dir, + exc_info=True) # Explicit --mcp-server-file wins last (same-name replace). if explicit_file and os.path.isfile(explicit_file): try: diff --git a/ms_agent/ui/events.py b/ms_agent/ui/events.py index 5aeff78e5..79845143c 100644 --- a/ms_agent/ui/events.py +++ b/ms_agent/ui/events.py @@ -135,6 +135,28 @@ class ReasoningEnded(AgentEvent): # ── tools ───────────────────────────────────────────────────────────────── +@dataclass(frozen=True) +class ToolCallComposing(AgentEvent): + """The model is still WRITING a tool call; nothing runs yet. + + Between the last ``content_delta`` and the first ``tool_call_started`` the + model streams the call's arguments, and until this event existed that window + produced no events at all. It is imperceptible for a small call and very + visible for a large one: measured at ~67 s of blank UI for one round that + wrote five long files, because every file's whole body travels inside the + arguments. + + The tool NAME arrives before its arguments do, so this can say what is being + prepared. ``arguments_len`` is the bytes accumulated so far — enough to show + progress, and deliberately not the payload itself, which is often huge and + is delivered in full by ``tool_call_started`` anyway. + """ + EVENT_TYPE: ClassVar[str] = 'tool_call_composing' + index: int = 0 + name: str = '' + arguments_len: int = 0 + + @dataclass(frozen=True) class ToolCallStarted(AgentEvent): """A tool call is about to execute.""" diff --git a/tests/llm/test_thinking_effort.py b/tests/llm/test_thinking_effort.py index 10cb7f5b7..db105bf1d 100644 --- a/tests/llm/test_thinking_effort.py +++ b/tests/llm/test_thinking_effort.py @@ -549,3 +549,90 @@ def test_a_real_ladder_is_offered_in_full(): # ...minus the rung DashScope rejects. assert 'max' not in T.offered_tiers('dashscope') assert 'xhigh' in T.offered_tiers('dashscope') + + +# --------------------------------------------------------------------------- # +# Rejections that arrive on the first chunk, not out of create() +# --------------------------------------------------------------------------- # +class _Boom400(Exception): + """An Aliyun-family gateway answering 200 and then rejecting the params.""" + + MSG = ('<400> InternalError.Algo.InvalidParameter: The thinking_budget ' + 'parameter must be a positive integer and not greater than 0') + + def __init__(self, msg=MSG): + super().__init__(msg) + self.status_code = 400 + + +class _Log: + + def warning(self, *a, **k): + pass + + +def _streaming_factory(reject_thinking=True): + """Returns fine, fails only while the first chunk is read.""" + seen = [] + + def create(**kw): + extra = kw.get('extra_body') or {} + asking = bool(extra.get('thinking_budget')) and not T.asks_to_disable(kw) + seen.append(asking) + + def gen(): + if asking and reject_thinking: + raise _Boom400() + yield 'chunk-1' + yield 'chunk-2' + + return gen() + + return create, seen + + +def test_stream_time_thinking_refusal_is_repaired(): + """Regression: `thinking_budget` rejected mid-stream used to bypass the + fallback completely — no retry, no memo, raw 400 shown to the user.""" + T.MODELS_REFUSING_THINKING.clear() + create, seen = _streaming_factory() + stream = T.create_with_thinking_fallback( + create, client=None, model='stream-model', logger=_Log(), + extra_body={'thinking_budget': 4096}) + assert list(stream) == ['chunk-1', 'chunk-2'] + assert seen == [True, False] # asked, then repaired + assert any(k[1] == 'stream-model' for k in T.MODELS_REFUSING_THINKING) + T.MODELS_REFUSING_THINKING.clear() + + +def test_stream_failure_after_first_chunk_is_not_retried(): + calls = [] + + def create(**kw): + calls.append(kw) + + def gen(): + yield 'chunk-1' + raise _Boom400() + + return gen() + + T.MODELS_REFUSING_THINKING.clear() + stream = T.create_with_thinking_fallback( + create, client=None, model='late-model', logger=_Log(), + extra_body={'thinking_budget': 4096}) + got = [] + with pytest.raises(_Boom400): + for item in stream: + got.append(item) + assert got == ['chunk-1'] + assert len(calls) == 1 # already rendered; no restart + T.MODELS_REFUSING_THINKING.clear() + + +def test_non_stream_result_is_passed_through_untouched(): + def create(**kw): + return 'completion' + + assert T.create_with_thinking_fallback( + create, client=None, model='plain', logger=_Log()) == 'completion' diff --git a/tests/llm/test_vision_fallback.py b/tests/llm/test_vision_fallback.py index fed0130b3..b487ce4a9 100644 --- a/tests/llm/test_vision_fallback.py +++ b/tests/llm/test_vision_fallback.py @@ -189,16 +189,151 @@ def test_observed_refusal_overrides_an_explicit_yes(self): self.assertFalse( V.resolve_supports_vision(cfg, model='m', base_url='u')) - def test_unset_falls_through_to_provider_capability(self): + def test_unset_is_off_even_when_the_provider_declares_vision(self): + """Two states, default OFF — the provider's capability is NOT evidence. + + Nine of ten registry entries declare ``vision``, so consulting the spec + made "nobody has said" mean "send images" and the switch's OFF position + describe a state the runtime never used. Vision is a property of the + model (ModelScope serves Qwen3-VL and the text-only Qwen3-235B through + one provider entry), so only the per-model switch turns it on. + """ from omegaconf import OmegaConf from ms_agent.llm.spec import get_registry cfg = OmegaConf.create({'llm': {'model': 'x'}}) - # dashscope declares vision; anthropic does too; a spec-less provider - # yields False rather than guessing. - self.assertTrue( - V.resolve_supports_vision(cfg, spec=get_registry().get('dashscope'))) + for provider in ('dashscope', 'modelscope', 'kimi', 'openai'): + spec = get_registry().get(provider) + self.assertFalse( + V.resolve_supports_vision(cfg, spec=spec), + f'{provider}: unset must stay OFF regardless of its caps') self.assertFalse(V.resolve_supports_vision(cfg, spec=None)) + def test_only_the_switch_turns_images_on(self): + from omegaconf import OmegaConf + from ms_agent.llm.spec import get_registry + spec = get_registry().get('dashscope') + on = OmegaConf.create({'llm': {'supports_vision': True}}) + self.assertTrue(V.resolve_supports_vision(on, spec=spec)) + + +class TestDisabledReason(unittest.TestCase): + """Which explanation the model is handed when the pixels are absent.""" + + def setUp(self): + V.MODELS_REFUSING_IMAGES.clear() + + def tearDown(self): + V.MODELS_REFUSING_IMAGES.clear() + + def test_switch_off_points_at_the_switch(self): + reason = V.disabled_reason('u', 'm') + self.assertIn('Settings', reason) + self.assertNotIn('rejected image input', reason) + + def test_endpoint_refusal_does_not_point_at_the_switch(self): + """Regression: telling a user who already enabled the switch to enable + it is the single most confusing thing this feature can say.""" + V.note_refusal('u', 'm') + reason = V.disabled_reason('u', 'm') + self.assertIn('rejected image input', reason) + self.assertNotIn('Settings → Models', reason) + + +class TestStreamTimeRefusal(unittest.TestCase): + """A 400 that arrives on the FIRST CHUNK, not out of ``create()``. + + Aliyun-family gateways answer 200 and then put the rejection in the stream. + Guarding only ``create()`` let that error bypass the retry entirely: no + repair, no blacklist, raw provider error to the user. + """ + + def setUp(self): + V.MODELS_REFUSING_IMAGES.clear() + + def tearDown(self): + V.MODELS_REFUSING_IMAGES.clear() + + @staticmethod + def _streaming_create(reject_images: bool = True): + """A client that returns fine and only fails while being consumed.""" + seen = [] + + def create(messages, **kw): + has_img = any( + V.multimodal.has_image_blocks(m.get('content')) + for m in messages if isinstance(m, dict)) + seen.append(has_img) + + def gen(): + if has_img and reject_images: + raise _Boom(400, 'Unexpected item type in content.') + yield 'chunk-1' + yield 'chunk-2' + + return gen() + + return create, seen + + def test_first_chunk_refusal_is_repaired_and_remembered(self): + create, seen = self._streaming_create() + stream = V.create_with_vision_fallback( + create, base_url='u', model='m', messages=IMG_MESSAGES, + sent_images=True) + self.assertEqual(list(stream), ['chunk-1', 'chunk-2']) + self.assertEqual(seen, [True, False]) # with images, then without + self.assertIn(('u', 'm'), V.MODELS_REFUSING_IMAGES) + + def test_unrelated_stream_error_reraises_and_does_not_blacklist(self): + """The retry fails too -> the images were not the cause.""" + original = _Boom(400, 'Model id : X , has no provider supported') + + def create(messages, **kw): + def gen(): + raise original + yield # pragma: no cover + return gen() + + stream = V.create_with_vision_fallback( + create, base_url='u', model='m', messages=IMG_MESSAGES, + sent_images=True) + with self.assertRaises(_Boom) as ctx: + list(stream) + self.assertIs(ctx.exception, original) + self.assertFalse(V.MODELS_REFUSING_IMAGES) + + def test_failure_after_the_first_chunk_is_not_retried(self): + """Output already reached the user; restarting would duplicate it.""" + calls = [] + + def create(messages, **kw): + calls.append(1) + + def gen(): + yield 'chunk-1' + raise _Boom(400, 'Unexpected item type in content.') + + return gen() + + stream = V.create_with_vision_fallback( + create, base_url='u', model='m', messages=IMG_MESSAGES, + sent_images=True) + got = [] + with self.assertRaises(_Boom): + for item in stream: + got.append(item) + self.assertEqual(got, ['chunk-1']) + self.assertEqual(len(calls), 1) # no retry + self.assertFalse(V.MODELS_REFUSING_IMAGES) + + def test_non_streaming_result_is_untouched(self): + def create(messages, **kw): + return 'plain-response' + + self.assertEqual( + V.create_with_vision_fallback( + create, base_url='u', model='m', messages=IMG_MESSAGES, + sent_images=True), 'plain-response') + class TestTransportWiring(unittest.TestCase): """The wrapper's named arguments must not collide with the API params.