aip-agents-binary 0.5.20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (280) hide show
  1. aip_agents/__init__.py +65 -0
  2. aip_agents/a2a/__init__.py +19 -0
  3. aip_agents/a2a/server/__init__.py +10 -0
  4. aip_agents/a2a/server/base_executor.py +1086 -0
  5. aip_agents/a2a/server/google_adk_executor.py +198 -0
  6. aip_agents/a2a/server/langflow_executor.py +180 -0
  7. aip_agents/a2a/server/langgraph_executor.py +270 -0
  8. aip_agents/a2a/types.py +232 -0
  9. aip_agents/agent/__init__.py +27 -0
  10. aip_agents/agent/base_agent.py +970 -0
  11. aip_agents/agent/base_langgraph_agent.py +2942 -0
  12. aip_agents/agent/google_adk_agent.py +926 -0
  13. aip_agents/agent/google_adk_constants.py +6 -0
  14. aip_agents/agent/hitl/__init__.py +24 -0
  15. aip_agents/agent/hitl/config.py +28 -0
  16. aip_agents/agent/hitl/langgraph_hitl_mixin.py +515 -0
  17. aip_agents/agent/hitl/manager.py +532 -0
  18. aip_agents/agent/hitl/models.py +18 -0
  19. aip_agents/agent/hitl/prompt/__init__.py +9 -0
  20. aip_agents/agent/hitl/prompt/base.py +42 -0
  21. aip_agents/agent/hitl/prompt/deferred.py +73 -0
  22. aip_agents/agent/hitl/registry.py +149 -0
  23. aip_agents/agent/interface.py +138 -0
  24. aip_agents/agent/interfaces.py +65 -0
  25. aip_agents/agent/langflow_agent.py +464 -0
  26. aip_agents/agent/langgraph_memory_enhancer_agent.py +433 -0
  27. aip_agents/agent/langgraph_react_agent.py +2514 -0
  28. aip_agents/agent/system_instruction_context.py +34 -0
  29. aip_agents/clients/__init__.py +10 -0
  30. aip_agents/clients/langflow/__init__.py +10 -0
  31. aip_agents/clients/langflow/client.py +477 -0
  32. aip_agents/clients/langflow/types.py +18 -0
  33. aip_agents/constants.py +23 -0
  34. aip_agents/credentials/manager.py +132 -0
  35. aip_agents/examples/__init__.py +5 -0
  36. aip_agents/examples/compare_streaming_client.py +783 -0
  37. aip_agents/examples/compare_streaming_server.py +142 -0
  38. aip_agents/examples/demo_memory_recall.py +401 -0
  39. aip_agents/examples/hello_world_a2a_google_adk_client.py +49 -0
  40. aip_agents/examples/hello_world_a2a_google_adk_client_agent.py +48 -0
  41. aip_agents/examples/hello_world_a2a_google_adk_client_streaming.py +60 -0
  42. aip_agents/examples/hello_world_a2a_google_adk_server.py +79 -0
  43. aip_agents/examples/hello_world_a2a_langchain_client.py +39 -0
  44. aip_agents/examples/hello_world_a2a_langchain_client_agent.py +39 -0
  45. aip_agents/examples/hello_world_a2a_langchain_client_lm_invoker.py +37 -0
  46. aip_agents/examples/hello_world_a2a_langchain_client_streaming.py +41 -0
  47. aip_agents/examples/hello_world_a2a_langchain_reference_client_streaming.py +60 -0
  48. aip_agents/examples/hello_world_a2a_langchain_reference_server.py +105 -0
  49. aip_agents/examples/hello_world_a2a_langchain_server.py +79 -0
  50. aip_agents/examples/hello_world_a2a_langchain_server_lm_invoker.py +78 -0
  51. aip_agents/examples/hello_world_a2a_langflow_client.py +83 -0
  52. aip_agents/examples/hello_world_a2a_langflow_server.py +82 -0
  53. aip_agents/examples/hello_world_a2a_langgraph_artifact_client.py +73 -0
  54. aip_agents/examples/hello_world_a2a_langgraph_artifact_client_streaming.py +76 -0
  55. aip_agents/examples/hello_world_a2a_langgraph_artifact_server.py +92 -0
  56. aip_agents/examples/hello_world_a2a_langgraph_client.py +54 -0
  57. aip_agents/examples/hello_world_a2a_langgraph_client_agent.py +54 -0
  58. aip_agents/examples/hello_world_a2a_langgraph_client_agent_lm_invoker.py +32 -0
  59. aip_agents/examples/hello_world_a2a_langgraph_client_streaming.py +50 -0
  60. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_lm_invoker.py +44 -0
  61. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_tool_streaming.py +92 -0
  62. aip_agents/examples/hello_world_a2a_langgraph_server.py +84 -0
  63. aip_agents/examples/hello_world_a2a_langgraph_server_lm_invoker.py +79 -0
  64. aip_agents/examples/hello_world_a2a_langgraph_server_tool_streaming.py +132 -0
  65. aip_agents/examples/hello_world_a2a_mcp_langgraph.py +196 -0
  66. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_client.py +244 -0
  67. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_server.py +251 -0
  68. aip_agents/examples/hello_world_a2a_with_metadata_langchain_client.py +57 -0
  69. aip_agents/examples/hello_world_a2a_with_metadata_langchain_server_lm_invoker.py +80 -0
  70. aip_agents/examples/hello_world_google_adk.py +41 -0
  71. aip_agents/examples/hello_world_google_adk_mcp_http.py +34 -0
  72. aip_agents/examples/hello_world_google_adk_mcp_http_stream.py +40 -0
  73. aip_agents/examples/hello_world_google_adk_mcp_sse.py +44 -0
  74. aip_agents/examples/hello_world_google_adk_mcp_sse_stream.py +48 -0
  75. aip_agents/examples/hello_world_google_adk_mcp_stdio.py +44 -0
  76. aip_agents/examples/hello_world_google_adk_mcp_stdio_stream.py +48 -0
  77. aip_agents/examples/hello_world_google_adk_stream.py +44 -0
  78. aip_agents/examples/hello_world_langchain.py +28 -0
  79. aip_agents/examples/hello_world_langchain_lm_invoker.py +15 -0
  80. aip_agents/examples/hello_world_langchain_mcp_http.py +34 -0
  81. aip_agents/examples/hello_world_langchain_mcp_http_interactive.py +130 -0
  82. aip_agents/examples/hello_world_langchain_mcp_http_stream.py +42 -0
  83. aip_agents/examples/hello_world_langchain_mcp_multi_server.py +155 -0
  84. aip_agents/examples/hello_world_langchain_mcp_sse.py +34 -0
  85. aip_agents/examples/hello_world_langchain_mcp_sse_stream.py +40 -0
  86. aip_agents/examples/hello_world_langchain_mcp_stdio.py +30 -0
  87. aip_agents/examples/hello_world_langchain_mcp_stdio_stream.py +41 -0
  88. aip_agents/examples/hello_world_langchain_stream.py +36 -0
  89. aip_agents/examples/hello_world_langchain_stream_lm_invoker.py +39 -0
  90. aip_agents/examples/hello_world_langflow_agent.py +163 -0
  91. aip_agents/examples/hello_world_langgraph.py +39 -0
  92. aip_agents/examples/hello_world_langgraph_bosa_twitter.py +41 -0
  93. aip_agents/examples/hello_world_langgraph_mcp_http.py +31 -0
  94. aip_agents/examples/hello_world_langgraph_mcp_http_stream.py +34 -0
  95. aip_agents/examples/hello_world_langgraph_mcp_sse.py +35 -0
  96. aip_agents/examples/hello_world_langgraph_mcp_sse_stream.py +50 -0
  97. aip_agents/examples/hello_world_langgraph_mcp_stdio.py +35 -0
  98. aip_agents/examples/hello_world_langgraph_mcp_stdio_stream.py +50 -0
  99. aip_agents/examples/hello_world_langgraph_stream.py +43 -0
  100. aip_agents/examples/hello_world_langgraph_stream_lm_invoker.py +37 -0
  101. aip_agents/examples/hello_world_model_switch_cli.py +210 -0
  102. aip_agents/examples/hello_world_multi_agent_adk.py +75 -0
  103. aip_agents/examples/hello_world_multi_agent_langchain.py +54 -0
  104. aip_agents/examples/hello_world_multi_agent_langgraph.py +66 -0
  105. aip_agents/examples/hello_world_multi_agent_langgraph_lm_invoker.py +69 -0
  106. aip_agents/examples/hello_world_pii_logger.py +21 -0
  107. aip_agents/examples/hello_world_sentry.py +133 -0
  108. aip_agents/examples/hello_world_step_limits.py +273 -0
  109. aip_agents/examples/hello_world_stock_a2a_server.py +103 -0
  110. aip_agents/examples/hello_world_tool_output_client.py +46 -0
  111. aip_agents/examples/hello_world_tool_output_server.py +114 -0
  112. aip_agents/examples/hitl_demo.py +724 -0
  113. aip_agents/examples/mcp_configs/configs.py +63 -0
  114. aip_agents/examples/mcp_servers/common.py +76 -0
  115. aip_agents/examples/mcp_servers/mcp_name.py +29 -0
  116. aip_agents/examples/mcp_servers/mcp_server_http.py +19 -0
  117. aip_agents/examples/mcp_servers/mcp_server_sse.py +19 -0
  118. aip_agents/examples/mcp_servers/mcp_server_stdio.py +19 -0
  119. aip_agents/examples/mcp_servers/mcp_time.py +10 -0
  120. aip_agents/examples/pii_demo_langgraph_client.py +69 -0
  121. aip_agents/examples/pii_demo_langgraph_server.py +126 -0
  122. aip_agents/examples/pii_demo_multi_agent_client.py +80 -0
  123. aip_agents/examples/pii_demo_multi_agent_server.py +247 -0
  124. aip_agents/examples/todolist_planning_a2a_langchain_client.py +70 -0
  125. aip_agents/examples/todolist_planning_a2a_langgraph_server.py +88 -0
  126. aip_agents/examples/tools/__init__.py +27 -0
  127. aip_agents/examples/tools/adk_arithmetic_tools.py +36 -0
  128. aip_agents/examples/tools/adk_weather_tool.py +60 -0
  129. aip_agents/examples/tools/data_generator_tool.py +103 -0
  130. aip_agents/examples/tools/data_visualization_tool.py +312 -0
  131. aip_agents/examples/tools/image_artifact_tool.py +136 -0
  132. aip_agents/examples/tools/langchain_arithmetic_tools.py +26 -0
  133. aip_agents/examples/tools/langchain_currency_exchange_tool.py +88 -0
  134. aip_agents/examples/tools/langchain_graph_artifact_tool.py +172 -0
  135. aip_agents/examples/tools/langchain_weather_tool.py +48 -0
  136. aip_agents/examples/tools/langgraph_streaming_tool.py +130 -0
  137. aip_agents/examples/tools/mock_retrieval_tool.py +56 -0
  138. aip_agents/examples/tools/pii_demo_tools.py +189 -0
  139. aip_agents/examples/tools/random_chart_tool.py +142 -0
  140. aip_agents/examples/tools/serper_tool.py +202 -0
  141. aip_agents/examples/tools/stock_tools.py +82 -0
  142. aip_agents/examples/tools/table_generator_tool.py +167 -0
  143. aip_agents/examples/tools/time_tool.py +82 -0
  144. aip_agents/examples/tools/weather_forecast_tool.py +38 -0
  145. aip_agents/executor/agent_executor.py +473 -0
  146. aip_agents/executor/base.py +48 -0
  147. aip_agents/mcp/__init__.py +1 -0
  148. aip_agents/mcp/client/__init__.py +14 -0
  149. aip_agents/mcp/client/base_mcp_client.py +369 -0
  150. aip_agents/mcp/client/connection_manager.py +193 -0
  151. aip_agents/mcp/client/google_adk/__init__.py +11 -0
  152. aip_agents/mcp/client/google_adk/client.py +381 -0
  153. aip_agents/mcp/client/langchain/__init__.py +11 -0
  154. aip_agents/mcp/client/langchain/client.py +265 -0
  155. aip_agents/mcp/client/persistent_session.py +359 -0
  156. aip_agents/mcp/client/session_pool.py +351 -0
  157. aip_agents/mcp/client/transports.py +215 -0
  158. aip_agents/mcp/utils/__init__.py +7 -0
  159. aip_agents/mcp/utils/config_validator.py +139 -0
  160. aip_agents/memory/__init__.py +14 -0
  161. aip_agents/memory/adapters/__init__.py +10 -0
  162. aip_agents/memory/adapters/base_adapter.py +717 -0
  163. aip_agents/memory/adapters/mem0.py +84 -0
  164. aip_agents/memory/base.py +84 -0
  165. aip_agents/memory/constants.py +49 -0
  166. aip_agents/memory/factory.py +86 -0
  167. aip_agents/memory/guidance.py +20 -0
  168. aip_agents/memory/simple_memory.py +47 -0
  169. aip_agents/middleware/__init__.py +17 -0
  170. aip_agents/middleware/base.py +88 -0
  171. aip_agents/middleware/manager.py +128 -0
  172. aip_agents/middleware/todolist.py +274 -0
  173. aip_agents/schema/__init__.py +69 -0
  174. aip_agents/schema/a2a.py +56 -0
  175. aip_agents/schema/agent.py +111 -0
  176. aip_agents/schema/hitl.py +157 -0
  177. aip_agents/schema/langgraph.py +37 -0
  178. aip_agents/schema/model_id.py +97 -0
  179. aip_agents/schema/step_limit.py +108 -0
  180. aip_agents/schema/storage.py +40 -0
  181. aip_agents/sentry/__init__.py +11 -0
  182. aip_agents/sentry/sentry.py +151 -0
  183. aip_agents/storage/__init__.py +41 -0
  184. aip_agents/storage/base.py +85 -0
  185. aip_agents/storage/clients/__init__.py +12 -0
  186. aip_agents/storage/clients/minio_client.py +318 -0
  187. aip_agents/storage/config.py +62 -0
  188. aip_agents/storage/providers/__init__.py +15 -0
  189. aip_agents/storage/providers/base.py +106 -0
  190. aip_agents/storage/providers/memory.py +114 -0
  191. aip_agents/storage/providers/object_storage.py +214 -0
  192. aip_agents/tools/__init__.py +33 -0
  193. aip_agents/tools/bosa_tools.py +105 -0
  194. aip_agents/tools/browser_use/__init__.py +82 -0
  195. aip_agents/tools/browser_use/action_parser.py +103 -0
  196. aip_agents/tools/browser_use/browser_use_tool.py +1112 -0
  197. aip_agents/tools/browser_use/llm_config.py +120 -0
  198. aip_agents/tools/browser_use/minio_storage.py +198 -0
  199. aip_agents/tools/browser_use/schemas.py +119 -0
  200. aip_agents/tools/browser_use/session.py +76 -0
  201. aip_agents/tools/browser_use/session_errors.py +132 -0
  202. aip_agents/tools/browser_use/steel_session_recording.py +317 -0
  203. aip_agents/tools/browser_use/streaming.py +813 -0
  204. aip_agents/tools/browser_use/structured_data_parser.py +257 -0
  205. aip_agents/tools/browser_use/structured_data_recovery.py +204 -0
  206. aip_agents/tools/browser_use/types.py +78 -0
  207. aip_agents/tools/code_sandbox/__init__.py +26 -0
  208. aip_agents/tools/code_sandbox/constant.py +13 -0
  209. aip_agents/tools/code_sandbox/e2b_cloud_sandbox_extended.py +257 -0
  210. aip_agents/tools/code_sandbox/e2b_sandbox_tool.py +411 -0
  211. aip_agents/tools/constants.py +165 -0
  212. aip_agents/tools/document_loader/__init__.py +44 -0
  213. aip_agents/tools/document_loader/base_reader.py +302 -0
  214. aip_agents/tools/document_loader/docx_reader_tool.py +68 -0
  215. aip_agents/tools/document_loader/excel_reader_tool.py +171 -0
  216. aip_agents/tools/document_loader/pdf_reader_tool.py +79 -0
  217. aip_agents/tools/document_loader/pdf_splitter.py +169 -0
  218. aip_agents/tools/gl_connector/__init__.py +5 -0
  219. aip_agents/tools/gl_connector/tool.py +351 -0
  220. aip_agents/tools/memory_search/__init__.py +22 -0
  221. aip_agents/tools/memory_search/base.py +200 -0
  222. aip_agents/tools/memory_search/mem0.py +258 -0
  223. aip_agents/tools/memory_search/schema.py +48 -0
  224. aip_agents/tools/memory_search_tool.py +26 -0
  225. aip_agents/tools/time_tool.py +117 -0
  226. aip_agents/tools/tool_config_injector.py +300 -0
  227. aip_agents/tools/web_search/__init__.py +15 -0
  228. aip_agents/tools/web_search/serper_tool.py +187 -0
  229. aip_agents/types/__init__.py +70 -0
  230. aip_agents/types/a2a_events.py +13 -0
  231. aip_agents/utils/__init__.py +79 -0
  232. aip_agents/utils/a2a_connector.py +1757 -0
  233. aip_agents/utils/artifact_helpers.py +502 -0
  234. aip_agents/utils/constants.py +22 -0
  235. aip_agents/utils/datetime/__init__.py +34 -0
  236. aip_agents/utils/datetime/normalization.py +231 -0
  237. aip_agents/utils/datetime/timezone.py +206 -0
  238. aip_agents/utils/env_loader.py +27 -0
  239. aip_agents/utils/event_handler_registry.py +58 -0
  240. aip_agents/utils/file_prompt_utils.py +176 -0
  241. aip_agents/utils/final_response_builder.py +211 -0
  242. aip_agents/utils/formatter_llm_client.py +231 -0
  243. aip_agents/utils/langgraph/__init__.py +19 -0
  244. aip_agents/utils/langgraph/converter.py +128 -0
  245. aip_agents/utils/langgraph/tool_managers/__init__.py +15 -0
  246. aip_agents/utils/langgraph/tool_managers/a2a_tool_manager.py +99 -0
  247. aip_agents/utils/langgraph/tool_managers/base_tool_manager.py +66 -0
  248. aip_agents/utils/langgraph/tool_managers/delegation_tool_manager.py +1071 -0
  249. aip_agents/utils/langgraph/tool_output_management.py +967 -0
  250. aip_agents/utils/logger.py +195 -0
  251. aip_agents/utils/metadata/__init__.py +27 -0
  252. aip_agents/utils/metadata/activity_metadata_helper.py +407 -0
  253. aip_agents/utils/metadata/activity_narrative/__init__.py +35 -0
  254. aip_agents/utils/metadata/activity_narrative/builder.py +817 -0
  255. aip_agents/utils/metadata/activity_narrative/constants.py +51 -0
  256. aip_agents/utils/metadata/activity_narrative/context.py +49 -0
  257. aip_agents/utils/metadata/activity_narrative/formatters.py +230 -0
  258. aip_agents/utils/metadata/activity_narrative/utils.py +35 -0
  259. aip_agents/utils/metadata/schemas/__init__.py +16 -0
  260. aip_agents/utils/metadata/schemas/activity_schema.py +29 -0
  261. aip_agents/utils/metadata/schemas/thinking_schema.py +31 -0
  262. aip_agents/utils/metadata/thinking_metadata_helper.py +38 -0
  263. aip_agents/utils/metadata_helper.py +358 -0
  264. aip_agents/utils/name_preprocessor/__init__.py +17 -0
  265. aip_agents/utils/name_preprocessor/base_name_preprocessor.py +73 -0
  266. aip_agents/utils/name_preprocessor/google_name_preprocessor.py +100 -0
  267. aip_agents/utils/name_preprocessor/name_preprocessor.py +87 -0
  268. aip_agents/utils/name_preprocessor/openai_name_preprocessor.py +48 -0
  269. aip_agents/utils/pii/__init__.py +25 -0
  270. aip_agents/utils/pii/pii_handler.py +397 -0
  271. aip_agents/utils/pii/pii_helper.py +207 -0
  272. aip_agents/utils/pii/uuid_deanonymizer_mapping.py +195 -0
  273. aip_agents/utils/reference_helper.py +273 -0
  274. aip_agents/utils/sse_chunk_transformer.py +831 -0
  275. aip_agents/utils/step_limit_manager.py +265 -0
  276. aip_agents/utils/token_usage_helper.py +156 -0
  277. aip_agents_binary-0.5.20.dist-info/METADATA +681 -0
  278. aip_agents_binary-0.5.20.dist-info/RECORD +280 -0
  279. aip_agents_binary-0.5.20.dist-info/WHEEL +5 -0
  280. aip_agents_binary-0.5.20.dist-info/top_level.txt +1 -0
@@ -0,0 +1,817 @@
1
+ """Activity narrative builder utilities for tool and delegate messaging.
2
+
3
+ Authors:
4
+ Raymond Christopher (raymond.christopher@gdplabs.id)
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from collections.abc import Callable
12
+ from typing import Any
13
+
14
+ from gllm_inference.schema import Message
15
+
16
+ from aip_agents.schema.hitl import ApprovalDecisionType, HitlMetadata
17
+ from aip_agents.utils.formatter_llm_client import (
18
+ FormatterInvocationError,
19
+ FormatterInvokerUnavailableError,
20
+ get_formatter_llm_client,
21
+ )
22
+ from aip_agents.utils.logger import get_logger
23
+ from aip_agents.utils.metadata.activity_narrative.constants import (
24
+ DELEGATE_PREFIX,
25
+ HITL_DECISION_MESSAGES,
26
+ HITL_PENDING_DESCRIPTION,
27
+ HITL_PENDING_TITLE,
28
+ OUTPUT_EXCERPT_MAX_CHARS,
29
+ SYSTEM_PROMPT,
30
+ )
31
+ from aip_agents.utils.metadata.activity_narrative.context import ActivityContext, ActivityPhase
32
+ from aip_agents.utils.metadata.activity_narrative.formatters import (
33
+ ArgsFormatter,
34
+ OutputFormatter,
35
+ SensitiveInfoFilter,
36
+ )
37
+ from aip_agents.utils.metadata.activity_narrative.utils import _format_tool_or_subagent_name
38
+
39
+ logger = get_logger(__name__)
40
+ _formatter_llm_client = get_formatter_llm_client()
41
+
42
+
43
+ class ActivityNarrativeBuilder:
44
+ """Generate structured activity payloads via formatter LLM.
45
+
46
+ High-level flow:
47
+ 1. Gather raw metadata about a tool/delegate event and normalize it into an ``ActivityContext``.
48
+ 2. Sanitize arguments and outputs so no sensitive values reach downstream renderers or the formatter model.
49
+ 3. Prompt the shared formatter with phase-specific instructions (e.g., describe intent on start, summarize results on end).
50
+ 4. If the formatter responds with usable heading/body text, surface it; otherwise fall back to deterministic templates
51
+ built from the sanitized context.
52
+
53
+ This approach keeps SSE activity cards readable when the formatter is healthy while still providing sensible copy when
54
+ the formatter is unavailable or returns low-quality text.
55
+ """
56
+
57
+ _PHASE_PROMPTS: dict[ActivityPhase, dict[str, str]] = {
58
+ ActivityPhase.TOOL_START: {
59
+ "heading": (
60
+ "Return heading_text describing the tool action such as 'Executing Time Tool' or "
61
+ "'Searching for flight status'. Keep it short, friendly, and avoid colon-separated detail."
62
+ ),
63
+ "body": (
64
+ "Summarize what the tool is about to do using the arguments. "
65
+ "The first sentence must start with 'Trying to'. Use at most two sentences."
66
+ ),
67
+ },
68
+ ActivityPhase.TOOL_END: {
69
+ "heading": (
70
+ "Return heading_text in the form '<subject> completed'. Do not add colons or extra punctuation."
71
+ ),
72
+ "body": (
73
+ "Summarize the observed outcome using the outputs or errors. "
74
+ "Only mention errors when one is provided; never state that no errors occurred. "
75
+ "The first sentence must start with 'Reporting'. Keep it concise."
76
+ ),
77
+ },
78
+ ActivityPhase.DELEGATE_START: {
79
+ "heading": (
80
+ "Return heading_text describing the delegated agent, e.g., 'Delegating to Research Agent'. "
81
+ "Keep it friendly and avoid punctuation beyond spaces."
82
+ ),
83
+ "body": (
84
+ "Explain the work being handed off using the arguments or task description. "
85
+ "Start the first sentence with 'Investigating' or 'Researching' and describe the sub-agent's goal."
86
+ ),
87
+ },
88
+ ActivityPhase.DELEGATE_END: {
89
+ "heading": (
90
+ "Return heading_text describing that the delegate finished, such as 'Compiling results from Research Agent'."
91
+ ),
92
+ "body": (
93
+ "Summarize the delegate's outcome using the outputs. "
94
+ "Only mention errors when one is provided; never state that no errors occurred. "
95
+ "The first sentence should start with 'Reporting' or 'Returning'."
96
+ ),
97
+ },
98
+ }
99
+
100
+ def __init__(self) -> None:
101
+ """Initialize the activity narrative builder."""
102
+ self._filter = SensitiveInfoFilter()
103
+ self._args_formatter = ArgsFormatter()
104
+ self._output_formatter = OutputFormatter()
105
+
106
+ # ------------------------------------------------------------------
107
+ # Public API
108
+ # ------------------------------------------------------------------
109
+ def build_payload(self, metadata: dict[str, Any] | None) -> dict[str, Any] | None:
110
+ """Build enriched payload for the provided metadata.
111
+
112
+ Args:
113
+ metadata: The metadata dictionary containing tool_info, hitl, and other context.
114
+
115
+ Returns:
116
+ Dictionary payload with a rendered message, or None when not available.
117
+ """
118
+ if not isinstance(metadata, dict):
119
+ logger.info("activity narrative skipping non-dict metadata (type=%s)", type(metadata))
120
+ return None
121
+
122
+ step_id = metadata.get("step_id") if isinstance(metadata.get("step_id"), str) else None
123
+
124
+ context = self._build_context(metadata)
125
+ if context is None:
126
+ logger.info("activity narrative context unavailable; falling back to templates (step_id=%s)", step_id)
127
+ return None
128
+
129
+ if context.phase in {ActivityPhase.HITL_PENDING, ActivityPhase.HITL_RESOLVED}:
130
+ message = self._build_hitl_message(context)
131
+ logger.info(
132
+ "activity narrative generated HITL message step_id=%s phase=%s subject=%s",
133
+ context.step_id,
134
+ context.phase.value,
135
+ context.subject_name,
136
+ )
137
+ return {"message": message}
138
+
139
+ heading_text, body_text = self._generate_llm_texts(context)
140
+ message = self._assemble_message(context, heading_text, body_text)
141
+ logger.info(
142
+ "activity narrative message ready step_id=%s phase=%s chars=%s",
143
+ context.step_id,
144
+ context.phase.value,
145
+ len(message),
146
+ )
147
+ return {"message": message}
148
+
149
+ # ------------------------------------------------------------------
150
+ # Context extraction
151
+ # ------------------------------------------------------------------
152
+ def _build_context(self, metadata: dict[str, Any]) -> ActivityContext | None:
153
+ """Build ActivityContext from metadata dictionary.
154
+
155
+ Args:
156
+ metadata: Metadata dictionary containing tool_info, hitl, and other context.
157
+
158
+ Returns:
159
+ ActivityContext instance with extracted and formatted information, or None if invalid.
160
+ """
161
+ tool_info = self._ensure_dict(self._extract_value(metadata, "tool_info"))
162
+ hitl_payload = self._extract_value(metadata, "hitl")
163
+ hitl_metadata = self._parse_hitl_metadata(hitl_payload)
164
+ hitl_decision = self._to_decision(hitl_metadata)
165
+
166
+ phase = self._resolve_phase(tool_info, hitl_decision)
167
+ args = self._extract_args(tool_info)
168
+ output = self._extract_output(tool_info)
169
+ sanitizer = self._extract_activity_sanitizer(tool_info)
170
+ sanitized_args, sanitized_output = self._filter.sanitize(args, output, sanitizer)
171
+ sanitized_args = self._ensure_dict(sanitized_args)
172
+
173
+ arguments_excerpt = self._args_formatter.format(sanitized_args)
174
+ output_excerpt = self._output_formatter.format(sanitized_output)
175
+ error_excerpt = self._extract_error_excerpt(tool_info, metadata)
176
+ is_delegate = self._is_delegate_tool(tool_info)
177
+ subject_name = self._resolve_subject_name(tool_info, is_delegate)
178
+ agent_name = self._extract_agent_name(metadata)
179
+ step_id_value = self._extract_value(metadata, "step_id")
180
+ step_id = step_id_value if isinstance(step_id_value, str) and step_id_value.strip() else None
181
+ default_heading = self._default_heading(phase, subject_name, is_delegate)
182
+
183
+ context = ActivityContext(
184
+ phase=phase,
185
+ agent_name=agent_name,
186
+ step_id=step_id,
187
+ subject_name=subject_name,
188
+ sanitized_args=sanitized_args,
189
+ sanitized_output=sanitized_output,
190
+ arguments_excerpt=arguments_excerpt,
191
+ output_excerpt=output_excerpt,
192
+ error_excerpt=error_excerpt,
193
+ is_delegate=is_delegate,
194
+ hitl_metadata=hitl_metadata,
195
+ hitl_decision=hitl_decision,
196
+ default_heading=default_heading,
197
+ )
198
+ return context
199
+
200
+ def _extract_value(self, metadata: dict[str, Any], key: str) -> Any:
201
+ """Extract value from metadata by key name or enum value.
202
+
203
+ Args:
204
+ metadata: Metadata dictionary.
205
+ key: Key name or enum value to look up.
206
+
207
+ Returns:
208
+ Extracted value, or None if not found.
209
+ """
210
+ if key in metadata:
211
+ return metadata[key]
212
+ for meta_key, value in metadata.items():
213
+ if getattr(meta_key, "value", None) == key:
214
+ return value
215
+ return None
216
+
217
+ def _ensure_dict(self, value: Any) -> dict[str, Any] | None:
218
+ """Ensure value is a dictionary.
219
+
220
+ Args:
221
+ value: Value to check.
222
+
223
+ Returns:
224
+ Value if it's a dict, None otherwise.
225
+ """
226
+ return value if isinstance(value, dict) else None
227
+
228
+ def _resolve_phase(
229
+ self, tool_info: dict[str, Any] | None, hitl_decision: ApprovalDecisionType | None
230
+ ) -> ActivityPhase:
231
+ """Determine activity phase from tool info and HITL decision.
232
+
233
+ Args:
234
+ tool_info: Tool information dictionary.
235
+ hitl_decision: HITL approval decision type.
236
+
237
+ Returns:
238
+ ActivityPhase enum value indicating the current phase.
239
+ """
240
+ if hitl_decision == ApprovalDecisionType.PENDING:
241
+ return ActivityPhase.HITL_PENDING
242
+ if hitl_decision and hitl_decision != ApprovalDecisionType.PENDING:
243
+ return ActivityPhase.HITL_RESOLVED
244
+
245
+ has_output = isinstance(tool_info, dict) and "output" in tool_info
246
+ is_delegate = self._is_delegate_tool(tool_info)
247
+ if is_delegate:
248
+ return ActivityPhase.DELEGATE_END if has_output else ActivityPhase.DELEGATE_START
249
+ return ActivityPhase.TOOL_END if has_output else ActivityPhase.TOOL_START
250
+
251
+ def _is_delegate_tool(self, tool_info: dict[str, Any] | None) -> bool:
252
+ """Check if tool is a delegation tool.
253
+
254
+ Args:
255
+ tool_info: Tool information dictionary.
256
+
257
+ Returns:
258
+ True if tool is a delegation tool, False otherwise.
259
+ """
260
+ if not isinstance(tool_info, dict):
261
+ return False
262
+ tool_instance = tool_info.get("tool_instance")
263
+ metadata = getattr(tool_instance, "metadata", None)
264
+ if isinstance(metadata, dict) and metadata.get("is_delegation_tool"):
265
+ return True
266
+ names = self._collect_tool_names(tool_info)
267
+ return any(isinstance(name, str) and name.startswith(DELEGATE_PREFIX) for name in names)
268
+
269
+ def _extract_args(self, tool_info: dict[str, Any] | None) -> dict[str, Any] | None:
270
+ """Extract tool arguments from tool info.
271
+
272
+ Args:
273
+ tool_info: Tool information dictionary.
274
+
275
+ Returns:
276
+ Arguments dictionary, or None if not found.
277
+ """
278
+ if not isinstance(tool_info, dict):
279
+ return None
280
+ if isinstance(tool_info.get("args"), dict):
281
+ return tool_info["args"]
282
+ calls = tool_info.get("tool_calls")
283
+ if isinstance(calls, list):
284
+ for call in calls:
285
+ if isinstance(call, dict) and isinstance(call.get("args"), dict):
286
+ return call["args"]
287
+ return None
288
+
289
+ def _extract_output(self, tool_info: dict[str, Any] | None) -> Any:
290
+ """Extract tool output from tool info.
291
+
292
+ Args:
293
+ tool_info: Tool information dictionary.
294
+
295
+ Returns:
296
+ Tool output value, or None if not found.
297
+ """
298
+ if not isinstance(tool_info, dict):
299
+ return None
300
+ return tool_info.get("output")
301
+
302
+ def _extract_activity_sanitizer(
303
+ self, tool_info: dict[str, Any] | None
304
+ ) -> Callable[[dict[str, Any] | None, Any | None], dict[str, Any]] | None:
305
+ """Extract activity sanitizer function from tool instance.
306
+
307
+ Args:
308
+ tool_info: Tool information dictionary.
309
+
310
+ Returns:
311
+ Sanitizer callable if found, None otherwise.
312
+ """
313
+ if not isinstance(tool_info, dict):
314
+ return None
315
+ tool_instance = tool_info.get("tool_instance")
316
+ sanitizer = getattr(tool_instance, "activity_sanitizer", None)
317
+ return sanitizer if callable(sanitizer) else None
318
+
319
+ def _extract_agent_name(self, metadata: dict[str, Any]) -> str | None:
320
+ """Extract and prettify agent name from metadata.
321
+
322
+ Args:
323
+ metadata: Metadata dictionary.
324
+
325
+ Returns:
326
+ Prettified agent name, or None if not found.
327
+ """
328
+ candidate: str | None = None
329
+ for key in ("agent_display_name", "agent_label", "agent_name", "agent_id", "memory_user_id"):
330
+ value = self._extract_value(metadata, key)
331
+ if isinstance(value, str) and value.strip():
332
+ candidate = value.strip()
333
+ if key in ("agent_display_name", "agent_label"):
334
+ break
335
+ if not candidate:
336
+ return None
337
+ return _format_tool_or_subagent_name(candidate)
338
+
339
+ def _extract_error_excerpt(self, tool_info: dict[str, Any] | None, metadata: dict[str, Any]) -> str | None:
340
+ """Extract error message excerpt from tool info or metadata.
341
+
342
+ Args:
343
+ tool_info: Tool information dictionary.
344
+ metadata: Metadata dictionary.
345
+
346
+ Returns:
347
+ Error message string, or None if no error found.
348
+ """
349
+ error_payload = None
350
+ if isinstance(tool_info, dict):
351
+ error_payload = tool_info.get("error")
352
+ if not error_payload:
353
+ error_payload = self._extract_value(metadata, "error")
354
+ if isinstance(error_payload, dict):
355
+ message = error_payload.get("message") or error_payload.get("detail")
356
+ if isinstance(message, str) and message.strip():
357
+ return message.strip()
358
+ try:
359
+ return json.dumps(error_payload, ensure_ascii=False)
360
+ except Exception:
361
+ return str(error_payload)
362
+ if isinstance(error_payload, str) and error_payload.strip():
363
+ return error_payload.strip()
364
+ return None
365
+
366
+ def _resolve_subject_name(self, tool_info: dict[str, Any] | None, is_delegate: bool) -> str | None:
367
+ """Resolve subject name (tool or agent) from tool info.
368
+
369
+ Args:
370
+ tool_info: Tool information dictionary.
371
+ is_delegate: Whether this is a delegation tool.
372
+
373
+ Returns:
374
+ Formatted subject name, or None if not found.
375
+ """
376
+ if not isinstance(tool_info, dict):
377
+ return None
378
+ tool_instance = tool_info.get("tool_instance")
379
+ metadata = getattr(tool_instance, "metadata", None)
380
+ if isinstance(metadata, dict):
381
+ delegated = metadata.get("delegated_agent_name")
382
+ if isinstance(delegated, str) and delegated.strip():
383
+ return delegated
384
+ names = self._collect_tool_names(tool_info)
385
+ if not names:
386
+ return None
387
+ formatted = (
388
+ [_format_tool_or_subagent_name(name, remove_delegate_prefix=True) for name in names]
389
+ if is_delegate
390
+ else [_format_tool_or_subagent_name(name) for name in names]
391
+ )
392
+ if len(formatted) == 1:
393
+ return formatted[0]
394
+ return ", ".join(formatted)
395
+
396
+ def _collect_tool_names(self, tool_info: dict[str, Any]) -> list[str]:
397
+ """Collect tool names from tool info.
398
+
399
+ Args:
400
+ tool_info: Tool information dictionary.
401
+
402
+ Returns:
403
+ List of tool names found.
404
+ """
405
+ names: list[str] = []
406
+ if "name" in tool_info and isinstance(tool_info["name"], str):
407
+ names.append(tool_info["name"])
408
+ calls = tool_info.get("tool_calls")
409
+ if isinstance(calls, list):
410
+ for call in calls:
411
+ if isinstance(call, dict) and isinstance(call.get("name"), str):
412
+ names.append(call["name"])
413
+ return names
414
+
415
+ def _default_heading(self, phase: ActivityPhase, subject_name: str | None, is_delegate: bool) -> str:
416
+ """Return a deterministic heading used when the LLM omits one.
417
+
418
+ Args:
419
+ phase: Current activity phase indicating start/end/delegate state.
420
+ subject_name: Friendly tool or delegate name, when available.
421
+ is_delegate: Whether the subject represents a delegated agent.
422
+
423
+ Returns:
424
+ Heading string normalized for markdown display.
425
+ """
426
+ subject = subject_name or ("delegated task" if is_delegate else "agent task")
427
+ subject = subject.strip().rstrip(".")
428
+ templates: dict[ActivityPhase, str] = {
429
+ ActivityPhase.TOOL_START: "Executing {subject}",
430
+ ActivityPhase.TOOL_END: "{subject} completed",
431
+ ActivityPhase.DELEGATE_START: "Delegating to {subject}",
432
+ ActivityPhase.DELEGATE_END: "Compiling results from {subject}",
433
+ }
434
+ template = templates.get(phase)
435
+ if template:
436
+ heading = template.format(subject=subject)
437
+ elif subject_name:
438
+ heading = subject_name.strip()
439
+ else:
440
+ heading = "Delegated task" if is_delegate else "Agent update"
441
+ heading = re.sub(r"\s+", " ", heading).strip()
442
+ return heading.rstrip(":.")
443
+
444
+ def _parse_hitl_metadata(self, payload: Any) -> HitlMetadata | None:
445
+ """Parse HITL metadata from payload.
446
+
447
+ Args:
448
+ payload: HITL metadata payload (dict, HitlMetadata, or other).
449
+
450
+ Returns:
451
+ HitlMetadata instance if valid, None otherwise.
452
+ """
453
+ if isinstance(payload, HitlMetadata):
454
+ return payload
455
+ if isinstance(payload, dict):
456
+ try:
457
+ return HitlMetadata.model_validate(payload) # type: ignore[attr-defined]
458
+ except Exception:
459
+ try:
460
+ return HitlMetadata(**payload)
461
+ except Exception:
462
+ return None
463
+ return None
464
+
465
+ def _to_decision(self, metadata: HitlMetadata | None) -> ApprovalDecisionType | None:
466
+ """Convert HITL metadata decision to ApprovalDecisionType.
467
+
468
+ Args:
469
+ metadata: HITL metadata instance.
470
+
471
+ Returns:
472
+ ApprovalDecisionType enum value, or None if invalid.
473
+ """
474
+ if metadata and metadata.decision:
475
+ try:
476
+ return ApprovalDecisionType(metadata.decision)
477
+ except Exception:
478
+ return None
479
+ return None
480
+
481
+ def _build_context_summary(self, context: ActivityContext) -> str | None:
482
+ """Build summary from context excerpts or sanitized data.
483
+
484
+ Args:
485
+ context: Activity context.
486
+
487
+ Returns:
488
+ Summary string, or None if no data available.
489
+ """
490
+ if context.arguments_excerpt:
491
+ return context.arguments_excerpt
492
+ if context.output_excerpt:
493
+ return context.output_excerpt
494
+ if context.sanitized_args:
495
+ return self._truncate_json(context.sanitized_args)
496
+ if context.sanitized_output:
497
+ return self._truncate_json(context.sanitized_output)
498
+ return None
499
+
500
+ def _truncate_json(self, value: Any, limit: int = OUTPUT_EXCERPT_MAX_CHARS) -> str | None:
501
+ """Truncate JSON-serialized value to limit with ellipsis.
502
+
503
+ Args:
504
+ value: Value to serialize and truncate.
505
+ limit: Maximum length (default 400).
506
+
507
+ Returns:
508
+ Truncated JSON string, or None if empty.
509
+ """
510
+ try:
511
+ serialized = json.dumps(value, ensure_ascii=False)
512
+ except Exception:
513
+ serialized = str(value)
514
+ serialized = serialized.strip()
515
+ if not serialized:
516
+ return None
517
+ if len(serialized) <= limit:
518
+ return serialized
519
+ return serialized[: limit - 1].rstrip() + "…"
520
+
521
+ def _build_hitl_message(self, context: ActivityContext) -> str:
522
+ """Build HITL message based on phase and decision.
523
+
524
+ Args:
525
+ context: Activity context with HITL information.
526
+
527
+ Returns:
528
+ Formatted HITL message string.
529
+ """
530
+ if context.phase == ActivityPhase.HITL_PENDING:
531
+ detail = HITL_PENDING_DESCRIPTION
532
+ summary = context.subject_name or self._build_context_summary(context)
533
+ if summary:
534
+ detail = f"{detail} Request: {summary}."
535
+ return f"{HITL_PENDING_TITLE}\n\n{detail}"
536
+
537
+ title, desc = HITL_DECISION_MESSAGES.get(
538
+ context.hitl_decision or ApprovalDecisionType.SKIPPED,
539
+ HITL_DECISION_MESSAGES[ApprovalDecisionType.SKIPPED],
540
+ )
541
+ message = f"{title}\n\n{desc}"
542
+ extra = self._build_context_summary(context)
543
+ if extra:
544
+ message = f"{message}\n\nContext: {extra}"
545
+ return message
546
+
547
+ def _invoke_formatter(self, context: ActivityContext) -> Any | None:
548
+ """Invoke the formatter LLM and return the raw response.
549
+
550
+ Args:
551
+ context: Prepared activity context containing prompt payload inputs.
552
+
553
+ Returns:
554
+ Formatter client response object or None when the invoker is unavailable.
555
+ """
556
+ prompt_payload = self._build_prompt_payload(context)
557
+ user_prompt = self._build_user_prompt(prompt_payload)
558
+ messages = [
559
+ Message.system(SYSTEM_PROMPT),
560
+ Message.user(user_prompt),
561
+ ]
562
+ try:
563
+ raw_response = _formatter_llm_client.invoke_blocking(messages=messages)
564
+ return raw_response
565
+ except FormatterInvokerUnavailableError:
566
+ logger.warning(
567
+ "activity narrative formatter unavailable; skipping narrative (step_id=%s phase=%s subject=%s)",
568
+ context.step_id,
569
+ context.phase.value,
570
+ context.subject_name,
571
+ )
572
+ return None
573
+ except FormatterInvocationError as exc: # pragma: no cover - defensive
574
+ logger.warning(
575
+ "activity narrative LLM invocation failed step_id=%s phase=%s: %s",
576
+ context.step_id,
577
+ context.phase.value,
578
+ exc,
579
+ )
580
+ return None
581
+
582
+ def _generate_llm_texts(self, context: ActivityContext) -> tuple[str | None, str]:
583
+ """Return heading/body text strings produced by the formatter model.
584
+
585
+ Args:
586
+ context: Fully constructed activity context for the event.
587
+
588
+ Returns:
589
+ A tuple of (heading_text, body_text) where heading_text may be None when
590
+ the formatter is unavailable and body_text always contains a fallback.
591
+ """
592
+ fallback_body = self._build_context_summary(context) or "No additional detail provided."
593
+ raw_response = self._invoke_formatter(context)
594
+ if raw_response is None:
595
+ logger.info("activity narrative LLM unavailable step_id=%s; using fallback", context.step_id)
596
+ return None, fallback_body
597
+
598
+ response_text = self._extract_response_text(raw_response)
599
+ logger.debug(
600
+ "activity narrative LLM raw response step_id=%s preview=%s",
601
+ context.step_id,
602
+ response_text[:160] + ("…" if len(response_text) > 160 else ""),
603
+ )
604
+ heading_text, body_text = self._extract_payload_texts(response_text, context)
605
+
606
+ if not body_text:
607
+ body_text = self._clean_llm_body(response_text)
608
+ if not body_text:
609
+ body_text = fallback_body
610
+ return heading_text, body_text
611
+
612
+ def _extract_payload_texts(self, response_text: str, context: ActivityContext) -> tuple[str | None, str | None]:
613
+ """Extract heading/body strings from an embedded JSON block.
614
+
615
+ Args:
616
+ response_text: Raw formatter response serialized as text.
617
+ context: Activity context used for logging.
618
+
619
+ Returns:
620
+ Tuple of sanitized heading and body text strings, or (None, None) when parsing fails.
621
+ """
622
+ payload = self._parse_embedded_json(response_text)
623
+ if not isinstance(payload, dict):
624
+ return None, None
625
+ heading_text = self._sanitize_heading_text(payload.get("heading_text"))
626
+ body_candidate = payload.get("body_text")
627
+ body_text = self._clean_llm_body(body_candidate) if body_candidate is not None else None
628
+ heading_present = bool(heading_text)
629
+ body_present = bool(body_text)
630
+ logger.debug(
631
+ "activity narrative parsed JSON step_id=%s heading_present=%s body_present=%s",
632
+ context.step_id,
633
+ heading_present,
634
+ body_present,
635
+ )
636
+ return heading_text, body_text
637
+
638
+ def _parse_embedded_json(self, response_text: str) -> dict[str, Any] | None:
639
+ """Return a dict parsed from the first {...} block in the response text.
640
+
641
+ Args:
642
+ response_text: Formatter output that may contain an embedded JSON payload.
643
+
644
+ Returns:
645
+ Parsed dictionary if JSON is found and valid, otherwise None.
646
+ """
647
+ start = response_text.find("{")
648
+ end = response_text.rfind("}")
649
+ if start == -1 or end == -1 or end <= start:
650
+ return None
651
+ candidate = response_text[start : end + 1]
652
+ try:
653
+ parsed_payload = json.loads(candidate)
654
+ except Exception:
655
+ return None
656
+ return parsed_payload if isinstance(parsed_payload, dict) else None
657
+
658
+ def _build_prompt_payload(self, context: ActivityContext) -> dict[str, Any]:
659
+ """Build prompt payload for LLM invocation.
660
+
661
+ Args:
662
+ context: Activity context.
663
+
664
+ Returns:
665
+ Prompt payload dictionary.
666
+ """
667
+ payload = {
668
+ "phase": context.phase.value,
669
+ "agent": context.agent_name,
670
+ "subject": context.subject_name,
671
+ "arguments": context.sanitized_args,
672
+ "output": context.sanitized_output,
673
+ "arguments_excerpt": context.arguments_excerpt,
674
+ "output_excerpt": context.output_excerpt,
675
+ "error": context.error_excerpt,
676
+ "is_delegate": context.is_delegate,
677
+ }
678
+ if context.hitl_metadata:
679
+ payload["hitl"] = context.hitl_metadata.model_dump(mode="json", exclude_none=True)
680
+ instructions = self._phase_prompt(context.phase)
681
+ return {
682
+ "context": payload,
683
+ "heading_instruction": instructions["heading"],
684
+ "body_instruction": instructions["body"],
685
+ }
686
+
687
+ def _phase_prompt(self, phase: ActivityPhase) -> dict[str, str]:
688
+ """Return heading/body instructions for the given phase.
689
+
690
+ Args:
691
+ phase: Activity phase to build prompt instructions for.
692
+
693
+ Returns:
694
+ Dictionary with "heading" and "body" instruction strings.
695
+ """
696
+ return self._PHASE_PROMPTS.get(
697
+ phase,
698
+ {
699
+ "heading": "Return heading_text describing this update in five words or fewer.",
700
+ "body": "Return body_text summarizing the activity in one or two sentences.",
701
+ },
702
+ )
703
+
704
+ def _build_user_prompt(self, payload: dict[str, Any]) -> str:
705
+ """Compose the user-facing prompt string for the formatter model.
706
+
707
+ Args:
708
+ payload: Prompt payload containing context plus heading/body instructions.
709
+
710
+ Returns:
711
+ Fully formatted string that will be sent as the user message.
712
+ """
713
+ heading_instruction = payload.get("heading_instruction") or "Provide heading_text describing this update."
714
+ body_instruction = payload.get("body_instruction") or "Provide sentence_text summarizing this update."
715
+ context_payload = payload.get("context") or {}
716
+ arguments_excerpt = context_payload.get("arguments_excerpt") or "Not provided."
717
+ output_excerpt = context_payload.get("output_excerpt") or "Not provided."
718
+ context_json = json.dumps(context_payload, ensure_ascii=False)
719
+ return (
720
+ "Return valid JSON with keys 'heading_text' and 'body_text'. "
721
+ "Do not include any other keys.\n"
722
+ f"Heading instruction: {heading_instruction}\n"
723
+ f"Body instruction: {body_instruction}\n"
724
+ "Constraints: start each text with a verb phrase, avoid 'I' or 'We'.\n"
725
+ f"Arguments excerpt: {arguments_excerpt}\n"
726
+ f"Output excerpt: {output_excerpt}\n"
727
+ f"Context JSON: {context_json}"
728
+ )
729
+
730
+ def _clean_llm_body(self, response: Any) -> str | None:
731
+ """Normalize the LLM response (or snippet) into a plain body string.
732
+
733
+ Args:
734
+ response: Formatter response object, dictionary, or string snippet.
735
+
736
+ Returns:
737
+ Cleaned body text or None when the response lacks usable content.
738
+ """
739
+ text = self._extract_response_text(response)
740
+ if not text:
741
+ return None
742
+ lines = [line.strip() for line in text.splitlines() if line.strip()]
743
+ body_lines: list[str] = []
744
+ for line in lines:
745
+ if line.startswith("#"):
746
+ continue
747
+ if line.startswith("**") and line.endswith("**"):
748
+ continue
749
+ body_lines.append(line)
750
+ body = "\n".join(body_lines).strip()
751
+ return body or None
752
+
753
+ def _sanitize_heading_text(self, heading: Any | None) -> str | None:
754
+ """Normalize heading text extracted from the formatter JSON payload.
755
+
756
+ Args:
757
+ heading: Raw heading value returned by the formatter.
758
+
759
+ Returns:
760
+ Sanitized heading string or None when the value is unusable.
761
+ """
762
+ if not isinstance(heading, str):
763
+ return None
764
+ text = heading.replace("\n", " ").strip()
765
+ text = re.sub(r"\s+", " ", text)
766
+ return text.rstrip(" .:;-")
767
+
768
+ def _extract_response_text(self, response: Any) -> str:
769
+ """Extract a usable text payload from various LLM client response shapes.
770
+
771
+ Args:
772
+ response: Formatter response object/dict/string produced by the client.
773
+
774
+ Returns:
775
+ Stripped string representation suitable for downstream parsing.
776
+ """
777
+ if hasattr(response, "output_text"):
778
+ text = getattr(response, "output_text")
779
+ elif isinstance(response, dict) and isinstance(response.get("output_text"), str):
780
+ text = response["output_text"]
781
+ else:
782
+ text = response if isinstance(response, str) else str(response or "")
783
+ return text.strip()
784
+
785
+ def _assemble_message(
786
+ self,
787
+ context: ActivityContext,
788
+ heading_text: str | None = None,
789
+ body_text: str | None = None,
790
+ ) -> str:
791
+ """Combine heading/body strings with deterministic fallbacks.
792
+
793
+ Args:
794
+ context: Activity context associated with the event.
795
+ heading_text: Optional heading supplied by the formatter.
796
+ body_text: Optional body text supplied by the formatter.
797
+
798
+ Returns:
799
+ Markdown-formatted message ready for dashboards.
800
+ """
801
+ heading = (
802
+ heading_text
803
+ or context.default_heading
804
+ or self._default_heading(context.phase, context.subject_name, context.is_delegate)
805
+ )
806
+ if heading:
807
+ heading = heading.strip()
808
+ else:
809
+ heading = "Delegated task"
810
+ if not context.is_delegate:
811
+ heading = "Agent update"
812
+ heading = re.sub(r"\s+", " ", heading).rstrip(".")
813
+
814
+ body = body_text.strip() if body_text else (self._build_context_summary(context) or "")
815
+ if not body:
816
+ body = "No additional detail provided."
817
+ return f"**{heading}**\n\n{body}"