Skip to content

Commit 1846332

Browse files
authored
feat: harden unified memory and unify provider-aware thinking controls (#943)
1 parent 901a19f commit 1846332

43 files changed

Lines changed: 5461 additions & 171 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ms_agent/agent/agent.yaml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,50 @@ llm:
44
modelscope_api_key:
55
modelscope_base_url: https://api-inference.modelscope.cn/v1
66

7+
# Whether THIS model may be shown image attachments. Left unset on purpose:
8+
# it is read as a tri-state, so absent means "nobody has said", which falls
9+
# back to the provider's declared capability and then to runtime learning
10+
# (a model that rejects an image is remembered and never shown one again).
11+
# A hard default either way is worse — false makes a capable model silently
12+
# ignore attachments, true burns a 400 on every text-only model's first use.
13+
# supports_vision: true
14+
15+
# Image encoding, applied at the wire boundary (ms_agent/llm/multimodal.py).
16+
vision:
17+
enabled: true
18+
# Long-edge cap. 2560 sits inside DashScope's recommended range and at
19+
# Anthropic's high-resolution tier while cutting a 4K upload ~4x. NOT 1568
20+
# (Anthropic's standard tier): it downsamples rather than rejecting, so
21+
# forcing that would throw away resolution the newer tier can use.
22+
max_edge: 2560
23+
# Hard ceiling on the base64 STRING length — DashScope's 10 MB limit is
24+
# expressed that way; 8 MB leaves headroom.
25+
max_bytes: 8388608
26+
# OpenAI-family `detail`. 'low' is an explicit cost lever, not a default.
27+
detail: auto
28+
# GIF/BMP/TIFF/HEIC -> PNG first frame. DashScope's vision docs do not list
29+
# GIF, so transcoding gives one answer that works on every provider.
30+
transcode: true
31+
# Keep only the most recent N images in context (0 = unlimited).
32+
max_images: 0
33+
34+
# OPTIONAL escape hatch for a model that cannot see images at all: a
35+
# separately configured VISION model that describes an image as text, which
36+
# the main model then reasons over. Lossy by construction, so it is the
37+
# fallback, never the preferred path — when the main model can see images the
38+
# transports show it the real pixels and this is not used.
39+
#
40+
# Unset by default. The `image_reader` tool is registered ONLY when a
41+
# `model` is present here, because a tool that can only ever fail is worse
42+
# than no tool at all. Enabling it also needs `tools.image_reader` below.
43+
#
44+
# auxiliary:
45+
# service: dashscope
46+
# model: qwen3.8-max # must be a model that CAN see images
47+
# api_key: # optional; falls back to env / provider spec
48+
# base_url: # optional; same
49+
# protocol: openai # optional; 'anthropic' for that wire format
50+
751
generation_config:
852
temperature: 0.3
953
top_k: 20
@@ -36,6 +80,12 @@ tools:
3680
- edit_file
3781
- grep
3882
- glob
83+
# Ask a separately configured vision model to describe an image, for a main
84+
# model that cannot see one. Needs `llm.vision.auxiliary.model` set above;
85+
# without it the tool is not registered. `mcp: false` marks it a built-in —
86+
# every `tools.<name>` without that flag is treated as an MCP server.
87+
# image_reader:
88+
# mcp: false
3989
code_executor:
4090
mcp: false
4191
implementation: python_env

ms_agent/agent/llm_agent.py

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
from ms_agent.callbacks import Callback, callbacks_mapping
2121
from ms_agent.knowledge_search import SirchmunkSearch
2222
from ms_agent.llm.llm import LLM
23+
from ms_agent.llm.message_text import (append_text, flatten_message_text,
24+
prepend_text)
2325
from ms_agent.llm.utils import Message, ToolResult
2426
from ms_agent.memory import Memory, get_memory_meta_safe, memory_mapping
2527
from ms_agent.memory.memory_manager import SharedMemoryManager
@@ -44,7 +46,8 @@
4446
ErrorRaised, PlanEntry, PlanUpdated,
4547
ReasoningDelta, ReasoningEnded,
4648
ReasoningStarted, ToolCallCompleted,
47-
ToolCallStarted, TurnCompleted, UsageInfo)
49+
ToolCallComposing, ToolCallStarted,
50+
TurnCompleted, UsageInfo)
4851
from ms_agent.utils import (async_retry, is_retryable_error, read_history,
4952
save_history)
5053
from ms_agent.utils.constants import DEFAULT_TAG, DEFAULT_USER
@@ -315,6 +318,13 @@ def __init__(
315318
# When None, the legacy sync console_io / input() path is used.
316319
self._input_source = kwargs.get('input_source', None)
317320

321+
# Attachments belonging to the FIRST user turn, parked between the
322+
# interactive read in run_loop and create_messages (whose input is a
323+
# bare string). Cleared as soon as create_messages consumes them, so a
324+
# later turn can never inherit the first turn's images. Mid-conversation
325+
# turns bypass this entirely — InputCallback builds their Message.
326+
self._pending_attachments: List[Dict[str, Any]] = []
327+
318328
# Personalization (lazy-loaded in _build_personalization_section)
319329
self._profile_manager = ProfileManager()
320330

@@ -665,7 +675,7 @@ async def on_task_begin(self, messages: List[Message]):
665675
self.log_output(f'Agent {self.tag} task beginning.')
666676
if self.resolve_enable_snapshots(self.config):
667677
_user_content = next(
668-
((getattr(m, 'content', '') or '')[:80]
678+
(flatten_message_text(getattr(m, 'content', ''))[:80]
669679
for m in messages if getattr(m, 'role', '') == 'user'),
670680
'',
671681
)
@@ -759,6 +769,10 @@ def _on_result(index: int, tool_call, raw, duration_s: float) -> None:
759769
tool_detail=tool_call_result_format.tool_detail,
760770
hook_attachments=tool_call_result_format.hook_attachments,
761771
is_error=tool_call_result_format.is_error,
772+
# Images the tool produced. Carried on the tool Message so the
773+
# transports can put them in the IMAGE channel; the text channel
774+
# keeps only the short status.
775+
attachments=tool_call_result_format.attachments,
762776
)
763777

764778
if _new_message.tool_call_id is None:
@@ -1122,6 +1136,38 @@ def _emit_content_end(self) -> None:
11221136
else:
11231137
sys.stdout.write('\n')
11241138

1139+
#: Bytes of tool-call arguments between two ``ToolCallComposing`` events.
1140+
#: Small enough that a multi-file write reports progress several times a
1141+
#: second, large enough that a short call emits once and stops.
1142+
_COMPOSING_STEP = 256
1143+
1144+
def _emit_tool_composing(self, message, announced: Dict[int, int]) -> None:
1145+
"""Report tool calls the model is still writing.
1146+
1147+
Streaming hands us the assistant message repeatedly, with each tool
1148+
call's ``arguments`` growing chunk by chunk. Nothing has run yet — this
1149+
is purely so the UI can say "preparing write_file…" instead of showing
1150+
nothing at all while a large call is transmitted.
1151+
1152+
Silent for a UI-less run (no event sink), and throttled so short calls
1153+
emit once rather than once per chunk.
1154+
"""
1155+
if self._event_sink is None:
1156+
return
1157+
for index, call in enumerate(getattr(message, 'tool_calls', None) or []):
1158+
if not isinstance(call, dict):
1159+
continue
1160+
name = str(call.get('tool_name') or '')
1161+
if not name:
1162+
continue # the name always precedes the arguments; wait for it
1163+
size = len(str(call.get('arguments') or ''))
1164+
last = announced.get(index)
1165+
if last is not None and size - last < self._COMPOSING_STEP:
1166+
continue
1167+
announced[index] = size
1168+
self._event_sink.emit(
1169+
ToolCallComposing(index=index, name=name, arguments_len=size))
1170+
11251171
@staticmethod
11261172
def _extract_plan_from_tool_result(msg):
11271173
"""Parse a todo / split_task tool result into a list of PlanEntry, or
@@ -1245,8 +1291,17 @@ async def create_messages(
12451291
), f'inputs can be either a list or a string, but current is {type(messages)}'
12461292
messages = [
12471293
Message(role='system', content=''),
1248-
Message(role='user', content=messages or self.query),
1294+
Message(
1295+
role='user',
1296+
content=messages or self.query,
1297+
# Attachments for the FIRST turn. The interactive read that
1298+
# produced this prompt happens in run_loop, which stashes
1299+
# them here — the string-in signature cannot carry them, and
1300+
# a session's first message is exactly when a user attaches
1301+
# something.
1302+
attachments=self._pending_attachments or []),
12491303
]
1304+
self._pending_attachments = []
12501305

12511306
messages[0].content = self._build_system_content()
12521307

@@ -1437,8 +1492,11 @@ async def _attach_memory_recall(self, messages: List[Message]) -> None:
14371492
last = messages[-1]
14381493
if getattr(last, 'role', None) != 'user':
14391494
return
1440-
content = last.content
1441-
if not isinstance(content, str):
1495+
# Read the text out of whatever shape the content is in, rather than
1496+
# bailing on a block list: a multimodal turn that silently got no memory
1497+
# recall is a far worse outcome than one whose query came from its text.
1498+
content = flatten_message_text(last.content)
1499+
if not content:
14421500
return
14431501
# The turn may already carry other <system-reminder> blocks (skill
14441502
# update notice prefixed by the host, prompt-files update notice) —
@@ -1462,7 +1520,9 @@ async def _attach_memory_recall(self, messages: List[Message]) -> None:
14621520
if block:
14631521
if block in content:
14641522
return # marker-less backend, identical block attached
1465-
last.content = f'{last.content}\n\n{block}'
1523+
# append_text keeps the shape: a str grows, a block list gains a
1524+
# trailing text block (concatenating onto a list would raise).
1525+
last.content = append_text(last.content, block)
14661526
return
14671527

14681528
# ── prompt-files update notices (hot-reload perception) ──────────────
@@ -1535,8 +1595,7 @@ def _attach_prompt_update_notice(self, messages: List[Message]):
15351595
if not messages:
15361596
return None
15371597
last = messages[-1]
1538-
if getattr(last, 'role', None) != 'user' or not isinstance(
1539-
last.content, str):
1598+
if getattr(last, 'role', None) != 'user':
15401599
return None
15411600

15421601
baseline = self._prompt_surface
@@ -1559,7 +1618,10 @@ def _attach_prompt_update_notice(self, messages: List[Message]):
15591618
return None
15601619

15611620
notice = workspace_files.render_update_notice(changed)
1562-
last.content = f'{notice}\n\n{last.content}'
1621+
# Shape-preserving prepend; on a block list the notice becomes the first
1622+
# text block, which also matches the providers' label-before-payload
1623+
# preference.
1624+
last.content = prepend_text(last.content, notice)
15631625
return lambda: self._commit_prompt_surface(current)
15641626

15651627
async def condense_memory(self, messages: List[Message]) -> List[Message]:
@@ -1880,6 +1942,10 @@ async def step(
18801942
_response_message = None
18811943
_printed_reasoning_header = False
18821944
_printed_reasoning_footer = False
1945+
# index -> arguments length already announced, so a long tool
1946+
# call reports progress instead of going silent (see
1947+
# ui.events.ToolCallComposing).
1948+
_composing: Dict[int, int] = {}
18831949
_gen = self.llm.generate(messages, tools=tools)
18841950
_loop = asyncio.get_running_loop()
18851951
_NO_MORE = object()
@@ -1928,6 +1994,7 @@ def _next_chunk(_g=_gen):
19281994
_printed_reasoning_footer = True
19291995
self._emit_content(new_content)
19301996
_content = _response_message.content
1997+
self._emit_tool_composing(_response_message, _composing)
19311998
messages[-1] = _response_message
19321999
yield messages
19332000
finally:
@@ -2222,6 +2289,12 @@ def _msg_to_dict(msg: Message) -> Dict[str, Any]:
22222289
d: Dict[str, Any] = {'role': msg.role, 'content': msg.content or ''}
22232290
if msg.tool_calls:
22242291
d['tool_calls'] = msg.tool_calls
2292+
# Image refs must survive to disk: the SessionLog is the source of truth
2293+
# a resumed session rebuilds context from, so dropping them here means
2294+
# attached images vanish on reload (and on every context reassembly).
2295+
# They are references, not bytes — cheap to persist.
2296+
if getattr(msg, 'attachments', None):
2297+
d['attachments'] = msg.attachments
22252298
if hasattr(msg, 'tool_call_id') and msg.tool_call_id:
22262299
d['tool_call_id'] = msg.tool_call_id
22272300
if hasattr(msg, 'name') and msg.name:
@@ -2369,6 +2442,10 @@ async def run_loop(self, messages: Union[List[Message], str],
23692442
await self.cleanup_tools()
23702443
return
23712444
messages = turn.text
2445+
# create_messages() below builds the user Message from
2446+
# this string, so hand the turn's attachments over
2447+
# out-of-band rather than widening that signature.
2448+
self._pending_attachments = turn.attachments
23722449
else:
23732450
# Non-interactive with no task: accept piped stdin as the
23742451
# query; otherwise fail clearly instead of blocking input().
@@ -2506,10 +2583,16 @@ async def run_loop(self, messages: Union[List[Message], str],
25062583
# conversational truth. Advance the ingest ledger past it
25072584
# (sync, in-memory + small file write) so the next turn's
25082585
# delta does not sweep the partial content in either.
2586+
# THIS ROUND ONLY -- the same slice `_persist_partial_round`
2587+
# takes. Handing over the whole history would mark earlier
2588+
# rounds as ingested too, including one a background ingest
2589+
# is still writing (extraction takes seconds), which loses
2590+
# it: the write finds an empty delta, or fails and is denied
2591+
# its retry.
25092592
for _mem_tool in self.memory_tools:
25102593
if hasattr(_mem_tool, 'mark_ingested'):
25112594
try:
2512-
_mem_tool.mark_ingested(messages)
2595+
_mem_tool.mark_ingested(messages[pre_step_len:])
25132596
except Exception: # noqa: E722 - never mask cancel
25142597
pass
25152598
raise

ms_agent/callbacks/input_callback.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,8 @@ async def after_tool_call(self, runtime: Runtime, messages: List[Message]):
5858
runtime.should_stop = True
5959
return
6060
runtime.should_stop = False
61-
messages.append(Message(role='user', content=turn.text))
61+
messages.append(
62+
Message(
63+
role='user',
64+
content=turn.text,
65+
attachments=turn.attachments))

ms_agent/command/interactive.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
"""
1212
from __future__ import annotations
1313

14-
from dataclasses import dataclass
15-
from typing import Any, List, Optional
14+
from dataclasses import dataclass, field
15+
from typing import Any, Dict, List, Optional
1616

1717
from ms_agent.command.router import CommandRouter
1818
from ms_agent.command.types import CommandContext, CommandResultType
@@ -28,6 +28,10 @@ class InteractiveTurn:
2828

2929
action: str
3030
text: Optional[str] = None
31+
#: Non-text parts the input source attached to this turn (images). Stays
32+
#: separate from ``text`` all the way to ``Message.attachments`` — a CLI
33+
#: never sets it, a WebUI composer does.
34+
attachments: List[Dict[str, Any]] = field(default_factory=list)
3135

3236

3337
class InteractiveSession:
@@ -49,6 +53,26 @@ def __init__(self,
4953
# When None, plain print() is used (CLI).
5054
self._event_sink = event_sink
5155

56+
def _take_attachments(self) -> List[Dict[str, Any]]:
57+
"""Non-text parts the input source queued for the prompt just read.
58+
59+
Optional protocol method: a plain CLI/TUI has no attachments and does
60+
not implement it, so this returns ``[]`` and nothing downstream changes.
61+
Called immediately after ``read_prompt`` returns, so the attachments and
62+
the text belong to the same submission — hence "take": the source hands
63+
them over once and clears them.
64+
"""
65+
source = self._input_source
66+
if source is None:
67+
return []
68+
take = getattr(source, 'take_attachments', None)
69+
if take is None:
70+
return []
71+
try:
72+
return list(take() or [])
73+
except Exception: # an input source must never break the turn
74+
return []
75+
5276
async def run_turn(
5377
self,
5478
messages: Optional[List[Any]] = None,
@@ -84,7 +108,10 @@ async def run_turn(
84108
if self._event_sink is not None:
85109
from ms_agent.ui.events import UserMessage
86110
self._event_sink.emit(UserMessage(text=query))
87-
return InteractiveTurn(action='submit', text=query)
111+
return InteractiveTurn(
112+
action='submit',
113+
text=query,
114+
attachments=self._take_attachments())
88115

89116
cmd_name, args = self._router.parse_input(query)
90117
ctx = CommandContext(

ms_agent/hooks/context.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,16 @@ def condense_hook_attachments_for_llm(
100100

101101

102102
def extract_latest_user_prompt(messages: list[Message]) -> str:
103+
"""The latest user turn's text, for hooks that inspect what was asked.
104+
105+
A block list is reduced to its text rather than ``str()``-ed: a hook that
106+
matches on the prompt would otherwise be handed a Python repr and silently
107+
stop matching (and UserPromptSubmit echoes this value back into the
108+
conversation on a block, so the repr would become visible).
109+
"""
110+
from ms_agent.llm.message_text import flatten_message_text
111+
103112
for msg in reversed(messages):
104113
if msg.role == 'user':
105-
return msg.content if isinstance(msg.content, str) else str(
106-
msg.content)
114+
return flatten_message_text(msg.content)
107115
return ''

0 commit comments

Comments
 (0)