返回 JoyAI-Echo
question_cards.py
根目录 / echo_longvideo / Director_Agent / nanobot / session / question_cards.py
1 """Session persistence helpers for ask_user question cards."""
2
3 from __future__ import annotations
4
5 import json
6 import re
7 from datetime import datetime
8 from typing import Any
9
10 _BATCH_ID_RE = re.compile(
11 r"batch=([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})",
12 re.IGNORECASE,
13 )
14
15
16 def extract_batch_id_from_tool_result(content: str) -> str | None:
17 """Return the question batch uuid embedded in an ask_user tool result."""
18 if not content:
19 return None
20 match = _BATCH_ID_RE.search(content)
21 return match.group(1) if match else None
22
23
24 def wire_questions_snapshot(questions: list[dict[str, Any]]) -> list[dict[str, Any]]:
25 """Build a WebUI-friendly questions array with stable option labels."""
26 snapshot: list[dict[str, Any]] = []
27 for card in questions:
28 if not isinstance(card, dict):
29 continue
30 question = card.get("question")
31 if not isinstance(question, str) or not question.strip():
32 continue
33 options_raw = card.get("options")
34 if not isinstance(options_raw, list) or not options_raw:
35 continue
36 options: list[dict[str, str]] = []
37 for opt in options_raw:
38 if isinstance(opt, str) and opt.strip():
39 options.append({"label": opt.strip()})
40 elif isinstance(opt, dict) and isinstance(opt.get("label"), str) and opt["label"].strip():
41 options.append({"label": opt["label"].strip()})
42 if not options:
43 continue
44 entry: dict[str, Any] = {
45 "id": card.get("id") if isinstance(card.get("id"), str) else f"q-{len(snapshot)}",
46 "question": question.strip(),
47 "options": options,
48 "status": card.get("status") if isinstance(card.get("status"), str) else "pending",
49 }
50 if card.get("allow_custom") is True:
51 entry["allow_custom"] = True
52 if card.get("answered") is not None:
53 entry["answered"] = card["answered"]
54 snapshot.append(entry)
55 return snapshot
56
57
58 def _questions_for_arguments(questions: list[dict[str, Any]]) -> list[dict[str, Any]]:
59 """Build ask_user arguments payload from normalized question cards."""
60 out: list[dict[str, Any]] = []
61 for card in questions:
62 if not isinstance(card, dict):
63 continue
64 question = card.get("question")
65 if not isinstance(question, str) or not question.strip():
66 continue
67 options_raw = card.get("options")
68 if not isinstance(options_raw, list) or not options_raw:
69 continue
70 options: list[Any] = []
71 for opt in options_raw:
72 if isinstance(opt, str) and opt.strip():
73 options.append(opt.strip())
74 elif isinstance(opt, dict) and isinstance(opt.get("label"), str) and opt["label"].strip():
75 options.append(opt["label"].strip())
76 if not options:
77 continue
78 entry: dict[str, Any] = {
79 "id": card.get("id") if isinstance(card.get("id"), str) else f"q-{len(out)}",
80 "question": question.strip(),
81 "options": options,
82 }
83 if card.get("allow_custom") is True:
84 entry["allow_custom"] = True
85 if card.get("answered") is not None:
86 entry["answered"] = card["answered"]
87 if card.get("status") is not None:
88 entry["status"] = card["status"]
89 out.append(entry)
90 return out
91
92
93 def questions_display_text(
94 questions: list[dict[str, Any]],
95 *,
96 intro: str = "",
97 ) -> str:
98 """Flatten ask_user cards into plain assistant text for WebUI replay."""
99 parts: list[str] = []
100 intro = intro.strip()
101 if intro:
102 parts.append(intro)
103 for card in questions:
104 if not isinstance(card, dict):
105 continue
106 question = card.get("question")
107 if not isinstance(question, str) or not question.strip():
108 continue
109 text = question.strip()
110 if text not in parts:
111 parts.append(text)
112 return "\n".join(parts)
113
114
115 def build_ask_user_tool_call(
116 tool_call_id: str,
117 content: str,
118 questions: list[dict[str, Any]],
119 *,
120 question_batch_id: str | None = None,
121 ) -> dict[str, Any]:
122 """Build one OpenAI-style ask_user tool_call entry."""
123 # Intro text is ephemeral for the WebUI — only question cards are replayed.
124 payload: dict[str, Any] = {
125 "content": "",
126 "questions": _questions_for_arguments(questions),
127 }
128 if question_batch_id:
129 payload["question_batch_id"] = question_batch_id
130 arguments = json.dumps(payload, ensure_ascii=False)
131 return {
132 "id": tool_call_id,
133 "type": "function",
134 "function": {
135 "name": "ask_user",
136 "arguments": arguments,
137 },
138 }
139
140
141 def build_ask_user_session_messages(
142 *,
143 tool_call_id: str,
144 content: str,
145 questions: list[dict[str, Any]],
146 batch_id: str,
147 channel: str,
148 chat_id: str,
149 ) -> list[dict[str, Any]]:
150 """Return assistant + tool rows for one ask_user turn in fixed wire format."""
151 now = datetime.now().isoformat()
152 assistant = {
153 "role": "assistant",
154 "content": "",
155 "tool_calls": [
156 build_ask_user_tool_call(
157 tool_call_id,
158 content,
159 questions,
160 question_batch_id=batch_id,
161 )
162 ],
163 "question_batch_id": batch_id,
164 "questions": wire_questions_snapshot(questions),
165 "timestamp": now,
166 }
167 tool_result = {
168 "role": "tool",
169 "tool_call_id": tool_call_id,
170 "name": "ask_user",
171 "content": (
172 f"Question cards sent to {channel}:{chat_id} "
173 f"({len(questions)} card(s), batch={batch_id})"
174 ),
175 "timestamp": now,
176 }
177 return [assistant, tool_result]
178
179
180 def normalize_tool_calls(tool_calls: Any) -> list[dict[str, Any]] | None:
181 """Normalize persisted tool_calls to OpenAI wire shape."""
182 if not isinstance(tool_calls, list) or not tool_calls:
183 return None
184 normalized: list[dict[str, Any]] = []
185 for tc in tool_calls:
186 if not isinstance(tc, dict):
187 continue
188 tool_call_id = tc.get("id")
189 if not isinstance(tool_call_id, str) or not tool_call_id.strip():
190 continue
191 fn = tc.get("function")
192 if not isinstance(fn, dict):
193 continue
194 name = fn.get("name")
195 if not isinstance(name, str) or not name.strip():
196 continue
197 arguments = fn.get("arguments")
198 if isinstance(arguments, dict):
199 arguments = json.dumps(arguments, ensure_ascii=False)
200 elif not isinstance(arguments, str):
201 arguments = "{}"
202 normalized.append(
203 {
204 "id": tool_call_id.strip(),
205 "type": "function",
206 "function": {
207 "name": name.strip(),
208 "arguments": arguments,
209 },
210 }
211 )
212 return normalized or None
213
214
215 def questions_snapshot_from_ask_user_tool_calls(
216 tool_calls: list[dict[str, Any]],
217 ) -> list[dict[str, Any]]:
218 """Rebuild WebUI question cards from persisted ask_user tool_calls."""
219 for tc in tool_calls:
220 if not isinstance(tc, dict):
221 continue
222 fn = tc.get("function")
223 if not isinstance(fn, dict) or fn.get("name") != "ask_user":
224 continue
225 parsed = _parse_tool_arguments(fn.get("arguments"))
226 if not parsed:
227 continue
228 raw_questions = parsed.get("questions")
229 if not isinstance(raw_questions, list) or not raw_questions:
230 continue
231 snapshot = wire_questions_snapshot(raw_questions)
232 if snapshot:
233 return snapshot
234 return []
235
236
237 def sync_tool_calls_from_question_snapshot(message: dict[str, Any]) -> None:
238 """Mirror assistant ``questions`` back into ask_user tool_call arguments."""
239 if message.get("role") != "assistant":
240 return
241 snapshot = message.get("questions")
242 if not isinstance(snapshot, list) or not snapshot:
243 return
244 tool_calls = message.get("tool_calls")
245 if not isinstance(tool_calls, list):
246 return
247 args_questions = _questions_for_arguments(snapshot)
248 if not args_questions:
249 return
250 batch_id = message.get("question_batch_id")
251 for tc in tool_calls:
252 if not isinstance(tc, dict):
253 continue
254 fn = tc.get("function")
255 if not isinstance(fn, dict) or fn.get("name") != "ask_user":
256 continue
257 parsed = _parse_tool_arguments(fn.get("arguments")) or {}
258 payload: dict[str, Any] = {
259 "content": "",
260 "questions": args_questions,
261 }
262 if isinstance(batch_id, str) and batch_id.strip():
263 payload["question_batch_id"] = batch_id.strip()
264 elif isinstance(parsed.get("question_batch_id"), str):
265 payload["question_batch_id"] = parsed["question_batch_id"]
266 fn["arguments"] = json.dumps(payload, ensure_ascii=False)
267
268
269 def sync_message_question_snapshot(message: dict[str, Any]) -> None:
270 """Keep assistant ``questions`` in sync with ask_user tool_call arguments."""
271 if message.get("role") != "assistant":
272 return
273 tool_calls = message.get("tool_calls")
274 if not isinstance(tool_calls, list):
275 return
276 snapshot = questions_snapshot_from_ask_user_tool_calls(tool_calls)
277 if snapshot:
278 message["questions"] = snapshot
279
280
281 def normalize_persisted_message(message: dict[str, Any]) -> dict[str, Any]:
282 """Normalize one session row for stable WebUI replay."""
283 entry = dict(message)
284 role = entry.get("role")
285 content = entry.get("content")
286
287 if role == "assistant":
288 tool_calls = normalize_tool_calls(entry.get("tool_calls"))
289 if tool_calls:
290 entry["tool_calls"] = tool_calls
291 # ask_user tool_call arguments are authoritative; a stale native
292 # ``questions`` snapshot must not override a later card batch.
293 snapshot = questions_snapshot_from_ask_user_tool_calls(tool_calls)
294 if snapshot:
295 entry["questions"] = snapshot
296 entry["content"] = ""
297 else:
298 existing = entry.get("questions")
299 if isinstance(existing, list) and existing:
300 entry["questions"] = wire_questions_snapshot(existing)
301 entry["content"] = ""
302 elif content is None:
303 entry["content"] = ""
304 elif isinstance(content, str):
305 entry["content"] = content
306 else:
307 entry["content"] = ""
308 elif isinstance(entry.get("questions"), list) and entry["questions"]:
309 entry["questions"] = wire_questions_snapshot(entry["questions"])
310 entry["content"] = ""
311 for key in ("reasoning_content", "thinking_blocks", "extra_content", "provider_specific_fields"):
312 entry.pop(key, None)
313 elif role == "tool":
314 if isinstance(content, str):
315 entry["content"] = content
316 tool_call_id = entry.get("tool_call_id")
317 if isinstance(tool_call_id, str):
318 entry["tool_call_id"] = tool_call_id.strip()
319 name = entry.get("name")
320 if isinstance(name, str):
321 entry["name"] = name.strip()
322 elif role == "user" and isinstance(content, str):
323 from nanobot.session.agent_inject import visible_user_content
324
325 entry["content"] = visible_user_content(content)
326
327 return entry
328
329
330 def session_has_tool_turn(messages: list[dict[str, Any]], tool_call_id: str) -> bool:
331 """True when assistant+tool rows for *tool_call_id* are already persisted."""
332 for message in messages:
333 if message.get("role") != "tool":
334 continue
335 if message.get("tool_call_id") == tool_call_id:
336 return True
337 return False
338
339
340 def session_has_ask_user_turn(messages: list[dict[str, Any]], tool_call_id: str) -> bool:
341 """True when an ask_user turn for *tool_call_id* is already in session history."""
342 needle = tool_call_id.strip()
343 if not needle:
344 return False
345 if session_has_tool_turn(messages, needle):
346 return True
347 for message in messages:
348 if message.get("role") != "assistant":
349 continue
350 tool_calls = message.get("tool_calls")
351 if not isinstance(tool_calls, list):
352 continue
353 for tc in tool_calls:
354 if not isinstance(tc, dict):
355 continue
356 tc_id = tc.get("id")
357 fn = tc.get("function")
358 if (
359 isinstance(tc_id, str)
360 and tc_id.strip() == needle
361 and isinstance(fn, dict)
362 and fn.get("name") == "ask_user"
363 ):
364 return True
365 return False
366
367
368 def session_has_recent_ask_user_cards(messages: list[dict[str, Any]]) -> bool:
369 """True when trailing assistant rows already delivered ask_user question cards."""
370 for message in reversed(messages):
371 role = message.get("role")
372 if role == "user":
373 return False
374 if role != "assistant":
375 continue
376 questions = message.get("questions")
377 if isinstance(questions, list) and questions:
378 return True
379 tool_calls = message.get("tool_calls")
380 if isinstance(tool_calls, list) and questions_snapshot_from_ask_user_tool_calls(
381 tool_calls
382 ):
383 return True
384 return False
385
386
387 def _parse_tool_arguments(arguments: Any) -> dict[str, Any] | None:
388 if isinstance(arguments, dict):
389 return arguments
390 if isinstance(arguments, str):
391 try:
392 parsed = json.loads(arguments)
393 except json.JSONDecodeError:
394 return None
395 return parsed if isinstance(parsed, dict) else None
396 return None
397
398
399 def _batch_id_from_assistant(message: dict[str, Any]) -> str | None:
400 batch_id = message.get("question_batch_id")
401 if isinstance(batch_id, str) and batch_id.strip():
402 return batch_id.strip()
403 tool_calls = message.get("tool_calls")
404 if not isinstance(tool_calls, list):
405 return None
406 for tc in tool_calls:
407 if not isinstance(tc, dict):
408 continue
409 fn = tc.get("function")
410 if not isinstance(fn, dict) or fn.get("name") != "ask_user":
411 continue
412 parsed = _parse_tool_arguments(fn.get("arguments"))
413 if not parsed:
414 continue
415 args_batch = parsed.get("question_batch_id")
416 if isinstance(args_batch, str) and args_batch.strip():
417 return args_batch.strip()
418 return None
419
420
421 def find_tool_call_id_for_batch(
422 messages: list[dict[str, Any]],
423 question_batch_id: str,
424 ) -> str | None:
425 """Resolve tool_call_id from a question batch id via the tool result row."""
426 for message in reversed(messages):
427 if message.get("role") != "tool":
428 continue
429 content = message.get("content")
430 if not isinstance(content, str):
431 continue
432 if extract_batch_id_from_tool_result(content) != question_batch_id:
433 continue
434 tool_call_id = message.get("tool_call_id")
435 if isinstance(tool_call_id, str) and tool_call_id.strip():
436 return tool_call_id.strip()
437 return None
438
439
440 def _card_matches(card: dict[str, Any], card_id: str, index: int) -> bool:
441 stored_id = card.get("id")
442 if isinstance(stored_id, str) and stored_id == card_id:
443 return True
444 if card_id == f"q-{index}":
445 return True
446 return False
447
448
449 def _mark_card_answered(card: dict[str, Any], value: str) -> None:
450 card["answered"] = value
451 card["status"] = "answered"
452
453
454 def _update_cards_in_questions(
455 questions: list[Any],
456 card_id: str,
457 value: str,
458 ) -> bool:
459 for index, card in enumerate(questions):
460 if not isinstance(card, dict):
461 continue
462 if not _card_matches(card, card_id, index):
463 continue
464 _mark_card_answered(card, value)
465 return True
466 return False
467
468
469 def _update_assistant_tool_call_cards(
470 message: dict[str, Any],
471 *,
472 question_batch_id: str,
473 card_id: str,
474 value: str,
475 tool_call_id: str | None = None,
476 ) -> bool:
477 tool_calls = message.get("tool_calls")
478 if not isinstance(tool_calls, list):
479 return False
480 for tc in tool_calls:
481 if not isinstance(tc, dict):
482 continue
483 if tool_call_id and tc.get("id") != tool_call_id:
484 continue
485 fn = tc.get("function")
486 if not isinstance(fn, dict) or fn.get("name") != "ask_user":
487 continue
488 parsed = _parse_tool_arguments(fn.get("arguments"))
489 if not parsed:
490 continue
491 args_batch = parsed.get("question_batch_id")
492 if (
493 isinstance(args_batch, str)
494 and args_batch.strip()
495 and args_batch.strip() != question_batch_id
496 ):
497 continue
498 questions = parsed.get("questions")
499 if not isinstance(questions, list):
500 continue
501 if not _update_cards_in_questions(questions, card_id, value):
502 continue
503 parsed["question_batch_id"] = question_batch_id
504 fn["arguments"] = json.dumps(parsed, ensure_ascii=False)
505 message["question_batch_id"] = question_batch_id
506 sync_message_question_snapshot(message)
507 return True
508 return False
509
510
511 def update_ask_user_card_answer(
512 messages: list[dict[str, Any]],
513 question_batch_id: str,
514 card_id: str,
515 value: str,
516 ) -> bool:
517 """Update answered state on the ask_user assistant row for *question_batch_id*."""
518 tool_call_id = find_tool_call_id_for_batch(messages, question_batch_id)
519
520 for message in reversed(messages):
521 if message.get("role") != "assistant":
522 continue
523 batch_id = _batch_id_from_assistant(message)
524 if batch_id and batch_id != question_batch_id:
525 continue
526 if not batch_id:
527 tool_calls = message.get("tool_calls")
528 if not isinstance(tool_calls, list):
529 continue
530 ask_ids = [
531 tc.get("id")
532 for tc in tool_calls
533 if isinstance(tc, dict)
534 and isinstance(tc.get("function"), dict)
535 and tc["function"].get("name") == "ask_user"
536 and isinstance(tc.get("id"), str)
537 ]
538 if not ask_ids:
539 continue
540 if tool_call_id:
541 if tool_call_id not in ask_ids:
542 continue
543 else:
544 continue
545 if _update_assistant_tool_call_cards(
546 message,
547 question_batch_id=question_batch_id,
548 card_id=card_id,
549 value=value,
550 tool_call_id=tool_call_id,
551 ):
552 return True
553
554 for message in reversed(messages):
555 if message.get("role") != "assistant":
556 continue
557 if message.get("question_batch_id") != question_batch_id:
558 continue
559 questions = message.get("questions")
560 if not isinstance(questions, list):
561 continue
562 if _update_cards_in_questions(questions, card_id, value):
563 sync_tool_calls_from_question_snapshot(message)
564 return True
565
566 return False
567
568
569 def _card_is_pending(card: dict[str, Any]) -> bool:
570 if card.get("status") == "answered":
571 return False
572 return card.get("answered") is None
573
574
575 def _ensure_assistant_questions(message: dict[str, Any]) -> list[dict[str, Any]] | None:
576 questions = message.get("questions")
577 if isinstance(questions, list) and questions:
578 return questions
579 tool_calls = message.get("tool_calls")
580 if not isinstance(tool_calls, list):
581 return None
582 snapshot = questions_snapshot_from_ask_user_tool_calls(tool_calls)
583 if not snapshot:
584 return None
585 message["questions"] = snapshot
586 return snapshot
587
588
589 def _assistant_has_questions(message: dict[str, Any]) -> bool:
590 return _ensure_assistant_questions(message) is not None
591
592
593 def _card_accepts_reply(card: dict[str, Any], reply: str) -> bool:
594 options_raw = card.get("options")
595 if isinstance(options_raw, list):
596 for opt in options_raw:
597 if isinstance(opt, str) and opt.strip() == reply:
598 return True
599 if isinstance(opt, dict) and opt.get("label") == reply:
600 return True
601 if card.get("allow_custom") is True:
602 return True
603 return False
604
605
606 def _batch_accepts_reply(questions: list[Any], reply: str) -> bool:
607 pending = [
608 card
609 for card in questions
610 if isinstance(card, dict) and _card_is_pending(card)
611 ]
612 if not pending:
613 return False
614 return any(_card_accepts_reply(card, reply) for card in pending)
615
616
617 def session_has_user_reply(messages: list[dict[str, Any]], reply: str) -> bool:
618 """True when *reply* already exists as any user row in *messages*."""
619 needle = reply.strip()
620 if not needle:
621 return False
622 for message in messages:
623 if message.get("role") != "user":
624 continue
625 content = message.get("content")
626 if isinstance(content, str) and content.strip() == needle:
627 return True
628 return False
629
630
631 def session_recently_contains_user_reply(
632 messages: list[dict[str, Any]],
633 reply: str,
634 *,
635 tail: int = 6,
636 ) -> bool:
637 """True when an identical user reply already appears near the end of *messages*."""
638 if session_has_user_reply(messages, reply):
639 return True
640 needle = reply.strip()
641 if not needle:
642 return False
643 for message in messages[-tail:]:
644 if message.get("role") != "user":
645 continue
646 content = message.get("content")
647 if isinstance(content, str) and content.strip() == needle:
648 return True
649 return False
650
651
652 def session_has_user_reply_after_recent_ask_user_cards(
653 messages: list[dict[str, Any]],
654 ) -> bool:
655 """True when the user already replied after the latest ask_user card batch."""
656 card_index: int | None = None
657 for index in range(len(messages) - 1, -1, -1):
658 message = messages[index]
659 if message.get("role") != "assistant":
660 continue
661 if _assistant_has_questions(message):
662 card_index = index
663 break
664 if card_index is None:
665 return False
666 for message in messages[card_index + 1 :]:
667 if message.get("role") != "user":
668 continue
669 content = message.get("content")
670 if isinstance(content, str) and content.strip():
671 return True
672 return False
673
674
675 def should_drop_assistant_blurb_after_cards(messages: list[dict[str, Any]]) -> bool:
676 """Drop assistant text only while ask_user cards still await a user reply."""
677 if not session_has_recent_ask_user_cards(messages):
678 return False
679 return not session_has_user_reply_after_recent_ask_user_cards(messages)
680
681
682 def ensure_user_reply_for_batch(
683 messages: list[dict[str, Any]],
684 question_batch_id: str,
685 reply: str,
686 ) -> bool:
687 """Insert a user row for *reply* right after the ask_user batch when missing."""
688 reply = reply.strip()
689 if not reply:
690 return False
691
692 batch_index: int | None = None
693 for index, message in enumerate(messages):
694 if message.get("role") != "assistant":
695 continue
696 if _batch_id_from_assistant(message) == question_batch_id:
697 batch_index = index
698 break
699 if batch_index is None:
700 return False
701
702 if session_has_user_reply(messages, reply):
703 return False
704
705 insert_at = batch_index + 1
706 while insert_at < len(messages) and messages[insert_at].get("role") == "tool":
707 insert_at += 1
708
709 for index in range(insert_at, len(messages)):
710 role = messages[index].get("role")
711 if role == "assistant" and _assistant_has_questions(messages[index]):
712 break
713 if role != "user":
714 continue
715 content = messages[index].get("content")
716 if isinstance(content, str) and content.strip() == reply:
717 return False
718 break
719
720 messages.insert(
721 insert_at,
722 {
723 "role": "user",
724 "content": reply,
725 "timestamp": datetime.now().isoformat(),
726 },
727 )
728 return True
729
730
731 def apply_following_user_replies_to_question_cards(
732 messages: list[dict[str, Any]],
733 ) -> bool:
734 """Mark ask_user cards answered when a user reply matches a preceding batch."""
735 changed = False
736 for user_index, message in enumerate(messages):
737 if message.get("role") != "user":
738 continue
739 content = message.get("content")
740 if not isinstance(content, str) or not content.strip():
741 continue
742 reply = content.strip()
743
744 for assistant_index in range(user_index - 1, -1, -1):
745 assistant = messages[assistant_index]
746 if assistant.get("role") != "assistant":
747 continue
748 questions = _ensure_assistant_questions(assistant)
749 if not questions or not _batch_accepts_reply(questions, reply):
750 continue
751 marked = False
752 for card in questions:
753 if (
754 isinstance(card, dict)
755 and _card_is_pending(card)
756 and _card_accepts_reply(card, reply)
757 ):
758 _mark_card_answered(card, reply)
759 marked = True
760 if marked:
761 sync_tool_calls_from_question_snapshot(assistant)
762 changed = True
763 break
764 return changed
765
766
767 def attach_batch_id_to_assistant_tool_call(
768 messages: list[dict[str, Any]],
769 *,
770 tool_call_id: str,
771 question_batch_id: str,
772 ) -> None:
773 """Backfill question_batch_id onto the assistant ask_user row for a tool result."""
774 for message in reversed(messages):
775 if message.get("role") != "assistant":
776 continue
777 tool_calls = message.get("tool_calls")
778 if not isinstance(tool_calls, list):
779 continue
780 for tc in tool_calls:
781 if not isinstance(tc, dict) or tc.get("id") != tool_call_id:
782 continue
783 fn = tc.get("function")
784 if not isinstance(fn, dict) or fn.get("name") != "ask_user":
785 continue
786 message["question_batch_id"] = question_batch_id
787 parsed = _parse_tool_arguments(fn.get("arguments"))
788 if not isinstance(parsed, dict):
789 sync_message_question_snapshot(message)
790 return
791 parsed["question_batch_id"] = question_batch_id
792 raw_questions = parsed.get("questions")
793 if (not isinstance(raw_questions, list) or not raw_questions) and isinstance(
794 message.get("questions"), list
795 ):
796 parsed["questions"] = _questions_for_arguments(message["questions"])
797 fn["arguments"] = json.dumps(parsed, ensure_ascii=False)
798 sync_message_question_snapshot(message)
799 return
800
801
801 lines PYTHON