2020from ms_agent .callbacks import Callback , callbacks_mapping
2121from ms_agent .knowledge_search import SirchmunkSearch
2222from ms_agent .llm .llm import LLM
23+ from ms_agent .llm .message_text import (append_text , flatten_message_text ,
24+ prepend_text )
2325from ms_agent .llm .utils import Message , ToolResult
2426from ms_agent .memory import Memory , get_memory_meta_safe , memory_mapping
2527from ms_agent .memory .memory_manager import SharedMemoryManager
4446 ErrorRaised , PlanEntry , PlanUpdated ,
4547 ReasoningDelta , ReasoningEnded ,
4648 ReasoningStarted , ToolCallCompleted ,
47- ToolCallStarted , TurnCompleted , UsageInfo )
49+ ToolCallComposing , ToolCallStarted ,
50+ TurnCompleted , UsageInfo )
4851from ms_agent .utils import (async_retry , is_retryable_error , read_history ,
4952 save_history )
5053from 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
0 commit comments