fast-agent-mcp 0.4.7__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 (261) hide show
  1. fast_agent/__init__.py +183 -0
  2. fast_agent/acp/__init__.py +19 -0
  3. fast_agent/acp/acp_aware_mixin.py +304 -0
  4. fast_agent/acp/acp_context.py +437 -0
  5. fast_agent/acp/content_conversion.py +136 -0
  6. fast_agent/acp/filesystem_runtime.py +427 -0
  7. fast_agent/acp/permission_store.py +269 -0
  8. fast_agent/acp/server/__init__.py +5 -0
  9. fast_agent/acp/server/agent_acp_server.py +1472 -0
  10. fast_agent/acp/slash_commands.py +1050 -0
  11. fast_agent/acp/terminal_runtime.py +408 -0
  12. fast_agent/acp/tool_permission_adapter.py +125 -0
  13. fast_agent/acp/tool_permissions.py +474 -0
  14. fast_agent/acp/tool_progress.py +814 -0
  15. fast_agent/agents/__init__.py +85 -0
  16. fast_agent/agents/agent_types.py +64 -0
  17. fast_agent/agents/llm_agent.py +350 -0
  18. fast_agent/agents/llm_decorator.py +1139 -0
  19. fast_agent/agents/mcp_agent.py +1337 -0
  20. fast_agent/agents/tool_agent.py +271 -0
  21. fast_agent/agents/workflow/agents_as_tools_agent.py +849 -0
  22. fast_agent/agents/workflow/chain_agent.py +212 -0
  23. fast_agent/agents/workflow/evaluator_optimizer.py +380 -0
  24. fast_agent/agents/workflow/iterative_planner.py +652 -0
  25. fast_agent/agents/workflow/maker_agent.py +379 -0
  26. fast_agent/agents/workflow/orchestrator_models.py +218 -0
  27. fast_agent/agents/workflow/orchestrator_prompts.py +248 -0
  28. fast_agent/agents/workflow/parallel_agent.py +250 -0
  29. fast_agent/agents/workflow/router_agent.py +353 -0
  30. fast_agent/cli/__init__.py +0 -0
  31. fast_agent/cli/__main__.py +73 -0
  32. fast_agent/cli/commands/acp.py +159 -0
  33. fast_agent/cli/commands/auth.py +404 -0
  34. fast_agent/cli/commands/check_config.py +783 -0
  35. fast_agent/cli/commands/go.py +514 -0
  36. fast_agent/cli/commands/quickstart.py +557 -0
  37. fast_agent/cli/commands/serve.py +143 -0
  38. fast_agent/cli/commands/server_helpers.py +114 -0
  39. fast_agent/cli/commands/setup.py +174 -0
  40. fast_agent/cli/commands/url_parser.py +190 -0
  41. fast_agent/cli/constants.py +40 -0
  42. fast_agent/cli/main.py +115 -0
  43. fast_agent/cli/terminal.py +24 -0
  44. fast_agent/config.py +798 -0
  45. fast_agent/constants.py +41 -0
  46. fast_agent/context.py +279 -0
  47. fast_agent/context_dependent.py +50 -0
  48. fast_agent/core/__init__.py +92 -0
  49. fast_agent/core/agent_app.py +448 -0
  50. fast_agent/core/core_app.py +137 -0
  51. fast_agent/core/direct_decorators.py +784 -0
  52. fast_agent/core/direct_factory.py +620 -0
  53. fast_agent/core/error_handling.py +27 -0
  54. fast_agent/core/exceptions.py +90 -0
  55. fast_agent/core/executor/__init__.py +0 -0
  56. fast_agent/core/executor/executor.py +280 -0
  57. fast_agent/core/executor/task_registry.py +32 -0
  58. fast_agent/core/executor/workflow_signal.py +324 -0
  59. fast_agent/core/fastagent.py +1186 -0
  60. fast_agent/core/logging/__init__.py +5 -0
  61. fast_agent/core/logging/events.py +138 -0
  62. fast_agent/core/logging/json_serializer.py +164 -0
  63. fast_agent/core/logging/listeners.py +309 -0
  64. fast_agent/core/logging/logger.py +278 -0
  65. fast_agent/core/logging/transport.py +481 -0
  66. fast_agent/core/prompt.py +9 -0
  67. fast_agent/core/prompt_templates.py +183 -0
  68. fast_agent/core/validation.py +326 -0
  69. fast_agent/event_progress.py +62 -0
  70. fast_agent/history/history_exporter.py +49 -0
  71. fast_agent/human_input/__init__.py +47 -0
  72. fast_agent/human_input/elicitation_handler.py +123 -0
  73. fast_agent/human_input/elicitation_state.py +33 -0
  74. fast_agent/human_input/form_elements.py +59 -0
  75. fast_agent/human_input/form_fields.py +256 -0
  76. fast_agent/human_input/simple_form.py +113 -0
  77. fast_agent/human_input/types.py +40 -0
  78. fast_agent/interfaces.py +310 -0
  79. fast_agent/llm/__init__.py +9 -0
  80. fast_agent/llm/cancellation.py +22 -0
  81. fast_agent/llm/fastagent_llm.py +931 -0
  82. fast_agent/llm/internal/passthrough.py +161 -0
  83. fast_agent/llm/internal/playback.py +129 -0
  84. fast_agent/llm/internal/silent.py +41 -0
  85. fast_agent/llm/internal/slow.py +38 -0
  86. fast_agent/llm/memory.py +275 -0
  87. fast_agent/llm/model_database.py +490 -0
  88. fast_agent/llm/model_factory.py +388 -0
  89. fast_agent/llm/model_info.py +102 -0
  90. fast_agent/llm/prompt_utils.py +155 -0
  91. fast_agent/llm/provider/anthropic/anthropic_utils.py +84 -0
  92. fast_agent/llm/provider/anthropic/cache_planner.py +56 -0
  93. fast_agent/llm/provider/anthropic/llm_anthropic.py +796 -0
  94. fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +462 -0
  95. fast_agent/llm/provider/bedrock/bedrock_utils.py +218 -0
  96. fast_agent/llm/provider/bedrock/llm_bedrock.py +2207 -0
  97. fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +84 -0
  98. fast_agent/llm/provider/google/google_converter.py +466 -0
  99. fast_agent/llm/provider/google/llm_google_native.py +681 -0
  100. fast_agent/llm/provider/openai/llm_aliyun.py +31 -0
  101. fast_agent/llm/provider/openai/llm_azure.py +143 -0
  102. fast_agent/llm/provider/openai/llm_deepseek.py +76 -0
  103. fast_agent/llm/provider/openai/llm_generic.py +35 -0
  104. fast_agent/llm/provider/openai/llm_google_oai.py +32 -0
  105. fast_agent/llm/provider/openai/llm_groq.py +42 -0
  106. fast_agent/llm/provider/openai/llm_huggingface.py +85 -0
  107. fast_agent/llm/provider/openai/llm_openai.py +1195 -0
  108. fast_agent/llm/provider/openai/llm_openai_compatible.py +138 -0
  109. fast_agent/llm/provider/openai/llm_openrouter.py +45 -0
  110. fast_agent/llm/provider/openai/llm_tensorzero_openai.py +128 -0
  111. fast_agent/llm/provider/openai/llm_xai.py +38 -0
  112. fast_agent/llm/provider/openai/multipart_converter_openai.py +561 -0
  113. fast_agent/llm/provider/openai/openai_multipart.py +169 -0
  114. fast_agent/llm/provider/openai/openai_utils.py +67 -0
  115. fast_agent/llm/provider/openai/responses.py +133 -0
  116. fast_agent/llm/provider_key_manager.py +139 -0
  117. fast_agent/llm/provider_types.py +34 -0
  118. fast_agent/llm/request_params.py +61 -0
  119. fast_agent/llm/sampling_converter.py +98 -0
  120. fast_agent/llm/stream_types.py +9 -0
  121. fast_agent/llm/usage_tracking.py +445 -0
  122. fast_agent/mcp/__init__.py +56 -0
  123. fast_agent/mcp/common.py +26 -0
  124. fast_agent/mcp/elicitation_factory.py +84 -0
  125. fast_agent/mcp/elicitation_handlers.py +164 -0
  126. fast_agent/mcp/gen_client.py +83 -0
  127. fast_agent/mcp/helpers/__init__.py +36 -0
  128. fast_agent/mcp/helpers/content_helpers.py +352 -0
  129. fast_agent/mcp/helpers/server_config_helpers.py +25 -0
  130. fast_agent/mcp/hf_auth.py +147 -0
  131. fast_agent/mcp/interfaces.py +92 -0
  132. fast_agent/mcp/logger_textio.py +108 -0
  133. fast_agent/mcp/mcp_agent_client_session.py +411 -0
  134. fast_agent/mcp/mcp_aggregator.py +2175 -0
  135. fast_agent/mcp/mcp_connection_manager.py +723 -0
  136. fast_agent/mcp/mcp_content.py +262 -0
  137. fast_agent/mcp/mime_utils.py +108 -0
  138. fast_agent/mcp/oauth_client.py +509 -0
  139. fast_agent/mcp/prompt.py +159 -0
  140. fast_agent/mcp/prompt_message_extended.py +155 -0
  141. fast_agent/mcp/prompt_render.py +84 -0
  142. fast_agent/mcp/prompt_serialization.py +580 -0
  143. fast_agent/mcp/prompts/__init__.py +0 -0
  144. fast_agent/mcp/prompts/__main__.py +7 -0
  145. fast_agent/mcp/prompts/prompt_constants.py +18 -0
  146. fast_agent/mcp/prompts/prompt_helpers.py +238 -0
  147. fast_agent/mcp/prompts/prompt_load.py +186 -0
  148. fast_agent/mcp/prompts/prompt_server.py +552 -0
  149. fast_agent/mcp/prompts/prompt_template.py +438 -0
  150. fast_agent/mcp/resource_utils.py +215 -0
  151. fast_agent/mcp/sampling.py +200 -0
  152. fast_agent/mcp/server/__init__.py +4 -0
  153. fast_agent/mcp/server/agent_server.py +613 -0
  154. fast_agent/mcp/skybridge.py +44 -0
  155. fast_agent/mcp/sse_tracking.py +287 -0
  156. fast_agent/mcp/stdio_tracking_simple.py +59 -0
  157. fast_agent/mcp/streamable_http_tracking.py +309 -0
  158. fast_agent/mcp/tool_execution_handler.py +137 -0
  159. fast_agent/mcp/tool_permission_handler.py +88 -0
  160. fast_agent/mcp/transport_tracking.py +634 -0
  161. fast_agent/mcp/types.py +24 -0
  162. fast_agent/mcp/ui_agent.py +48 -0
  163. fast_agent/mcp/ui_mixin.py +209 -0
  164. fast_agent/mcp_server_registry.py +89 -0
  165. fast_agent/py.typed +0 -0
  166. fast_agent/resources/examples/data-analysis/analysis-campaign.py +189 -0
  167. fast_agent/resources/examples/data-analysis/analysis.py +68 -0
  168. fast_agent/resources/examples/data-analysis/fastagent.config.yaml +41 -0
  169. fast_agent/resources/examples/data-analysis/mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv +1471 -0
  170. fast_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
  171. fast_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +297 -0
  172. fast_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
  173. fast_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
  174. fast_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
  175. fast_agent/resources/examples/mcp/elicitations/forms_demo.py +107 -0
  176. fast_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
  177. fast_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
  178. fast_agent/resources/examples/mcp/elicitations/tool_call.py +21 -0
  179. fast_agent/resources/examples/mcp/state-transfer/agent_one.py +18 -0
  180. fast_agent/resources/examples/mcp/state-transfer/agent_two.py +18 -0
  181. fast_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +27 -0
  182. fast_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +15 -0
  183. fast_agent/resources/examples/researcher/fastagent.config.yaml +61 -0
  184. fast_agent/resources/examples/researcher/researcher-eval.py +53 -0
  185. fast_agent/resources/examples/researcher/researcher-imp.py +189 -0
  186. fast_agent/resources/examples/researcher/researcher.py +36 -0
  187. fast_agent/resources/examples/tensorzero/.env.sample +2 -0
  188. fast_agent/resources/examples/tensorzero/Makefile +31 -0
  189. fast_agent/resources/examples/tensorzero/README.md +56 -0
  190. fast_agent/resources/examples/tensorzero/agent.py +35 -0
  191. fast_agent/resources/examples/tensorzero/demo_images/clam.jpg +0 -0
  192. fast_agent/resources/examples/tensorzero/demo_images/crab.png +0 -0
  193. fast_agent/resources/examples/tensorzero/demo_images/shrimp.png +0 -0
  194. fast_agent/resources/examples/tensorzero/docker-compose.yml +105 -0
  195. fast_agent/resources/examples/tensorzero/fastagent.config.yaml +19 -0
  196. fast_agent/resources/examples/tensorzero/image_demo.py +67 -0
  197. fast_agent/resources/examples/tensorzero/mcp_server/Dockerfile +25 -0
  198. fast_agent/resources/examples/tensorzero/mcp_server/entrypoint.sh +35 -0
  199. fast_agent/resources/examples/tensorzero/mcp_server/mcp_server.py +31 -0
  200. fast_agent/resources/examples/tensorzero/mcp_server/pyproject.toml +11 -0
  201. fast_agent/resources/examples/tensorzero/simple_agent.py +25 -0
  202. fast_agent/resources/examples/tensorzero/tensorzero_config/system_schema.json +29 -0
  203. fast_agent/resources/examples/tensorzero/tensorzero_config/system_template.minijinja +11 -0
  204. fast_agent/resources/examples/tensorzero/tensorzero_config/tensorzero.toml +35 -0
  205. fast_agent/resources/examples/workflows/agents_as_tools_extended.py +73 -0
  206. fast_agent/resources/examples/workflows/agents_as_tools_simple.py +50 -0
  207. fast_agent/resources/examples/workflows/chaining.py +37 -0
  208. fast_agent/resources/examples/workflows/evaluator.py +77 -0
  209. fast_agent/resources/examples/workflows/fastagent.config.yaml +26 -0
  210. fast_agent/resources/examples/workflows/graded_report.md +89 -0
  211. fast_agent/resources/examples/workflows/human_input.py +28 -0
  212. fast_agent/resources/examples/workflows/maker.py +156 -0
  213. fast_agent/resources/examples/workflows/orchestrator.py +70 -0
  214. fast_agent/resources/examples/workflows/parallel.py +56 -0
  215. fast_agent/resources/examples/workflows/router.py +69 -0
  216. fast_agent/resources/examples/workflows/short_story.md +13 -0
  217. fast_agent/resources/examples/workflows/short_story.txt +19 -0
  218. fast_agent/resources/setup/.gitignore +30 -0
  219. fast_agent/resources/setup/agent.py +28 -0
  220. fast_agent/resources/setup/fastagent.config.yaml +65 -0
  221. fast_agent/resources/setup/fastagent.secrets.yaml.example +38 -0
  222. fast_agent/resources/setup/pyproject.toml.tmpl +23 -0
  223. fast_agent/skills/__init__.py +9 -0
  224. fast_agent/skills/registry.py +235 -0
  225. fast_agent/tools/elicitation.py +369 -0
  226. fast_agent/tools/shell_runtime.py +402 -0
  227. fast_agent/types/__init__.py +59 -0
  228. fast_agent/types/conversation_summary.py +294 -0
  229. fast_agent/types/llm_stop_reason.py +78 -0
  230. fast_agent/types/message_search.py +249 -0
  231. fast_agent/ui/__init__.py +38 -0
  232. fast_agent/ui/console.py +59 -0
  233. fast_agent/ui/console_display.py +1080 -0
  234. fast_agent/ui/elicitation_form.py +946 -0
  235. fast_agent/ui/elicitation_style.py +59 -0
  236. fast_agent/ui/enhanced_prompt.py +1400 -0
  237. fast_agent/ui/history_display.py +734 -0
  238. fast_agent/ui/interactive_prompt.py +1199 -0
  239. fast_agent/ui/markdown_helpers.py +104 -0
  240. fast_agent/ui/markdown_truncator.py +1004 -0
  241. fast_agent/ui/mcp_display.py +857 -0
  242. fast_agent/ui/mcp_ui_utils.py +235 -0
  243. fast_agent/ui/mermaid_utils.py +169 -0
  244. fast_agent/ui/message_primitives.py +50 -0
  245. fast_agent/ui/notification_tracker.py +205 -0
  246. fast_agent/ui/plain_text_truncator.py +68 -0
  247. fast_agent/ui/progress_display.py +10 -0
  248. fast_agent/ui/rich_progress.py +195 -0
  249. fast_agent/ui/streaming.py +774 -0
  250. fast_agent/ui/streaming_buffer.py +449 -0
  251. fast_agent/ui/tool_display.py +422 -0
  252. fast_agent/ui/usage_display.py +204 -0
  253. fast_agent/utils/__init__.py +5 -0
  254. fast_agent/utils/reasoning_stream_parser.py +77 -0
  255. fast_agent/utils/time.py +22 -0
  256. fast_agent/workflow_telemetry.py +261 -0
  257. fast_agent_mcp-0.4.7.dist-info/METADATA +788 -0
  258. fast_agent_mcp-0.4.7.dist-info/RECORD +261 -0
  259. fast_agent_mcp-0.4.7.dist-info/WHEEL +4 -0
  260. fast_agent_mcp-0.4.7.dist-info/entry_points.txt +7 -0
  261. fast_agent_mcp-0.4.7.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,734 @@
1
+ """Display helpers for agent conversation history."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Mapping, Sequence
7
+ from shutil import get_terminal_size
8
+ from typing import TYPE_CHECKING
9
+
10
+ from rich import print as rich_print
11
+ from rich.text import Text
12
+
13
+ from fast_agent.constants import FAST_AGENT_TIMING, FAST_AGENT_TOOL_TIMING
14
+ from fast_agent.mcp.helpers.content_helpers import get_text
15
+ from fast_agent.types.conversation_summary import ConversationSummary
16
+
17
+ if TYPE_CHECKING: # pragma: no cover - typing only
18
+ from rich.console import Console
19
+
20
+ from fast_agent.llm.usage_tracking import UsageAccumulator
21
+ from fast_agent.types import PromptMessageExtended
22
+
23
+
24
+ NON_TEXT_MARKER = "^"
25
+ TIMELINE_WIDTH = 20
26
+ SUMMARY_COUNT = 8
27
+ ROLE_COLUMN_WIDTH = 17
28
+
29
+
30
+ def _normalize_text(value: str | None) -> str:
31
+ return "" if not value else " ".join(value.split())
32
+
33
+
34
+ class Colours:
35
+ """Central colour palette for history display output."""
36
+
37
+ USER = "blue"
38
+ ASSISTANT = "green"
39
+ TOOL = "magenta"
40
+ TOOL_ERROR = "red"
41
+ HEADER = USER
42
+ TIMELINE_EMPTY = "dim default"
43
+ CONTEXT_SAFE = "green"
44
+ CONTEXT_CAUTION = "yellow"
45
+ CONTEXT_ALERT = "bright_red"
46
+ TOOL_DETAIL = "dim magenta"
47
+
48
+
49
+ def _char_count(value: str | None) -> int:
50
+ return len(_normalize_text(value))
51
+
52
+
53
+ def _format_tool_detail(prefix: str, names: Sequence[str]) -> Text:
54
+ detail = Text(prefix, style=Colours.TOOL_DETAIL)
55
+ if names:
56
+ detail.append(", ".join(names), style=Colours.TOOL_DETAIL)
57
+ return detail
58
+
59
+
60
+ def _ensure_text(value: object | None) -> Text:
61
+ """Coerce various value types into a Rich Text instance."""
62
+
63
+ if isinstance(value, Text):
64
+ return value.copy()
65
+ if value is None:
66
+ return Text("")
67
+ if isinstance(value, str):
68
+ return Text(value)
69
+ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, Text)):
70
+ return Text(", ".join(str(item) for item in value if item))
71
+ return Text(str(value))
72
+
73
+
74
+ def _truncate_text_segment(segment: Text, width: int) -> Text:
75
+ if width <= 0 or segment.cell_len == 0:
76
+ return Text("")
77
+ if segment.cell_len <= width:
78
+ return segment.copy()
79
+ truncated = segment.copy()
80
+ truncated.truncate(width, overflow="ellipsis")
81
+ return truncated
82
+
83
+
84
+ def _compose_summary_text(
85
+ preview: Text,
86
+ detail: Text | None,
87
+ *,
88
+ include_non_text: bool,
89
+ max_width: int | None,
90
+ ) -> Text:
91
+ marker_component = Text()
92
+ if include_non_text:
93
+ marker_component.append(" ")
94
+ marker_component.append(NON_TEXT_MARKER, style="dim")
95
+
96
+ if max_width is None:
97
+ combined = Text()
98
+ combined.append_text(preview)
99
+ if detail and detail.cell_len > 0:
100
+ if combined.cell_len > 0:
101
+ combined.append(" ")
102
+ combined.append_text(detail)
103
+ combined.append_text(marker_component)
104
+ return combined
105
+
106
+ width_available = max_width
107
+ if width_available <= 0:
108
+ return Text("")
109
+
110
+ if marker_component.cell_len > width_available:
111
+ marker_component = Text("")
112
+ marker_width = marker_component.cell_len
113
+ width_after_marker = max(0, width_available - marker_width)
114
+
115
+ preview_len = preview.cell_len
116
+ detail_component = detail.copy() if detail else Text("")
117
+ detail_len = detail_component.cell_len
118
+ detail_plain = detail_component.plain
119
+
120
+ preview_allow = min(preview_len, width_after_marker)
121
+ detail_allow = 0
122
+ if detail_len > 0 and width_after_marker > 0:
123
+ detail_allow = min(detail_len, max(0, width_after_marker - preview_allow))
124
+
125
+ if width_after_marker > 0:
126
+ min_detail_allow = 1
127
+ for prefix in ("tool→", "result→"):
128
+ if detail_plain.startswith(prefix):
129
+ min_detail_allow = min(detail_len, len(prefix))
130
+ break
131
+ else:
132
+ min_detail_allow = 0
133
+ if detail_allow < min_detail_allow:
134
+ needed = min_detail_allow - detail_allow
135
+ reduction = min(preview_allow, needed)
136
+ preview_allow -= reduction
137
+ detail_allow += reduction
138
+
139
+ preview_allow = max(0, preview_allow)
140
+ detail_allow = max(0, min(detail_allow, detail_len))
141
+
142
+ space = 1 if preview_allow > 0 and detail_allow > 0 else 0
143
+ total = preview_allow + detail_allow + space
144
+ if total > width_after_marker:
145
+ overflow = total - width_after_marker
146
+ reduction = min(preview_allow, overflow)
147
+ preview_allow -= reduction
148
+ overflow -= reduction
149
+ if overflow > 0:
150
+ detail_allow = max(0, detail_allow - overflow)
151
+
152
+ preview_allow = max(0, preview_allow)
153
+ detail_allow = max(0, min(detail_allow, detail_len))
154
+ else:
155
+ preview_allow = min(preview_len, width_after_marker)
156
+ detail_allow = 0
157
+
158
+ preview_segment = _truncate_text_segment(preview, preview_allow)
159
+ detail_segment = (
160
+ _truncate_text_segment(detail_component, detail_allow) if detail_allow > 0 else Text("")
161
+ )
162
+
163
+ combined = Text()
164
+ combined.append_text(preview_segment)
165
+ if preview_segment.cell_len > 0 and detail_segment.cell_len > 0:
166
+ combined.append(" ")
167
+ combined.append_text(detail_segment)
168
+
169
+ if marker_component.cell_len > 0:
170
+ if combined.cell_len + marker_component.cell_len <= max_width:
171
+ combined.append_text(marker_component)
172
+
173
+ return combined
174
+
175
+
176
+ def _preview_text(value: str | None, limit: int = 80) -> str:
177
+ normalized = _normalize_text(value)
178
+ if not normalized:
179
+ return "<no text>"
180
+ if len(normalized) <= limit:
181
+ return normalized
182
+ return normalized[: limit - 1] + "…"
183
+
184
+
185
+ def _has_non_text_content(message: PromptMessageExtended) -> bool:
186
+ for block in getattr(message, "content", []) or []:
187
+ block_type = getattr(block, "type", None)
188
+ if block_type and block_type != "text":
189
+ return True
190
+ return False
191
+
192
+
193
+ def _extract_tool_result_summary(result, *, limit: int = 80) -> tuple[str, int, bool]:
194
+ preview: str | None = None
195
+ total_chars = 0
196
+ saw_non_text = False
197
+
198
+ for block in getattr(result, "content", []) or []:
199
+ text = get_text(block)
200
+ if text:
201
+ normalized = _normalize_text(text)
202
+ if preview is None:
203
+ preview = _preview_text(normalized, limit=limit)
204
+ total_chars += len(normalized)
205
+ else:
206
+ saw_non_text = True
207
+
208
+ if preview is not None:
209
+ return preview, total_chars, saw_non_text
210
+ return f"{NON_TEXT_MARKER} non-text tool result", 0, True
211
+
212
+
213
+ def format_chars(value: int) -> str:
214
+ if value <= 0:
215
+ return "—"
216
+ if value >= 1_000_000:
217
+ return f"{value / 1_000_000:.1f}M"
218
+ if value >= 10_000:
219
+ return f"{value / 1_000:.1f}k"
220
+ return str(value)
221
+
222
+
223
+ def _extract_timing_ms(message: PromptMessageExtended) -> float | None:
224
+ """Extract timing duration in milliseconds from message channels."""
225
+ channels = getattr(message, "channels", None)
226
+ if not channels:
227
+ return None
228
+
229
+ timing_blocks = channels.get(FAST_AGENT_TIMING, [])
230
+ if not timing_blocks:
231
+ return None
232
+
233
+ timing_text = get_text(timing_blocks[0])
234
+ if not timing_text:
235
+ return None
236
+
237
+ try:
238
+ timing_data = json.loads(timing_text)
239
+ return timing_data.get("duration_ms")
240
+ except (json.JSONDecodeError, AttributeError, KeyError):
241
+ return None
242
+
243
+
244
+ def _extract_tool_timings(message: PromptMessageExtended) -> dict[str, dict[str, float | str | None]]:
245
+ """Extract tool timing data from message channels.
246
+
247
+ Returns a dict mapping tool_id to timing info:
248
+ {
249
+ "tool_id": {
250
+ "timing_ms": 123.45,
251
+ "transport_channel": "post-sse"
252
+ }
253
+ }
254
+
255
+ Handles backward compatibility with old format where values were just floats.
256
+ """
257
+ channels = getattr(message, "channels", None)
258
+ if not channels:
259
+ return {}
260
+
261
+ timing_blocks = channels.get(FAST_AGENT_TOOL_TIMING, [])
262
+ if not timing_blocks:
263
+ return {}
264
+
265
+ timing_text = get_text(timing_blocks[0])
266
+ if not timing_text:
267
+ return {}
268
+
269
+ try:
270
+ raw_data = json.loads(timing_text)
271
+ # Normalize to new format for backward compatibility
272
+ normalized = {}
273
+ for tool_id, value in raw_data.items():
274
+ if isinstance(value, dict):
275
+ # New format - already has timing_ms and transport_channel
276
+ normalized[tool_id] = value
277
+ else:
278
+ # Old format - value is just a float (timing in ms)
279
+ normalized[tool_id] = {
280
+ "timing_ms": value,
281
+ "transport_channel": None
282
+ }
283
+ return normalized
284
+ except (json.JSONDecodeError, TypeError):
285
+ return {}
286
+
287
+
288
+ def format_time(value: float | None) -> str:
289
+ """Format timing value for display."""
290
+ if value is None:
291
+ return "-"
292
+ if value < 1000:
293
+ return f"{value:.0f}ms"
294
+ return f"{value / 1000:.1f}s"
295
+
296
+
297
+ def _build_history_rows(history: Sequence[PromptMessageExtended]) -> list[dict]:
298
+ rows: list[dict] = []
299
+ call_name_lookup: dict[str, str] = {}
300
+
301
+ for message in history:
302
+ role_raw = getattr(message, "role", "assistant")
303
+ role_value = getattr(role_raw, "value", role_raw)
304
+ role = str(role_value).lower() if role_value else "assistant"
305
+
306
+ text = ""
307
+ if hasattr(message, "first_text"):
308
+ try:
309
+ text = message.first_text() or ""
310
+ except Exception: # pragma: no cover - defensive
311
+ text = ""
312
+ normalized_text = _normalize_text(text)
313
+ chars = len(normalized_text)
314
+ preview = _preview_text(text)
315
+ non_text = _has_non_text_content(message) or chars == 0
316
+
317
+ # Extract timing data
318
+ timing_ms = _extract_timing_ms(message)
319
+ tool_timings = _extract_tool_timings(message)
320
+
321
+ tool_calls: Mapping[str, object] | None = getattr(message, "tool_calls", None)
322
+ tool_results: Mapping[str, object] | None = getattr(message, "tool_results", None)
323
+
324
+ detail_sections: list[Text] = []
325
+ row_non_text = non_text
326
+ has_tool_request = False
327
+ hide_in_summary = False
328
+ timeline_role = role
329
+ include_in_timeline = True
330
+ result_rows: list[dict] = []
331
+ tool_result_total_chars = 0
332
+ tool_result_has_non_text = False
333
+ tool_result_has_error = False
334
+
335
+ if tool_calls:
336
+ names: list[str] = []
337
+ for call_id, call in tool_calls.items():
338
+ params = getattr(call, "params", None)
339
+ name = getattr(params, "name", None) or getattr(call, "name", None) or call_id
340
+ call_name_lookup[call_id] = name
341
+ names.append(name)
342
+ if names:
343
+ detail_sections.append(_format_tool_detail("tool→", names))
344
+ row_non_text = row_non_text and chars == 0 # treat call as activity
345
+ has_tool_request = True
346
+ if not normalized_text and tool_calls:
347
+ preview = "(issuing tool request)"
348
+
349
+ if tool_results:
350
+ result_names: list[str] = []
351
+ for call_id, result in tool_results.items():
352
+ tool_name = call_name_lookup.get(call_id, call_id)
353
+ result_names.append(tool_name)
354
+ summary, result_chars, result_non_text = _extract_tool_result_summary(result)
355
+ tool_result_total_chars += result_chars
356
+ tool_result_has_non_text = tool_result_has_non_text or result_non_text
357
+ detail = _format_tool_detail("result→", [tool_name])
358
+ is_error = getattr(result, "isError", False)
359
+ tool_result_has_error = tool_result_has_error or is_error
360
+ # Get timing info for this specific tool call
361
+ tool_timing_info = tool_timings.get(call_id)
362
+ timing_ms = tool_timing_info.get("timing_ms") if tool_timing_info else None
363
+ transport_channel = tool_timing_info.get("transport_channel") if tool_timing_info else None
364
+ result_rows.append(
365
+ {
366
+ "role": "tool",
367
+ "timeline_role": "tool",
368
+ "chars": result_chars,
369
+ "preview": summary,
370
+ "details": detail,
371
+ "non_text": result_non_text,
372
+ "has_tool_request": False,
373
+ "hide_summary": False,
374
+ "include_in_timeline": False,
375
+ "is_error": is_error,
376
+ "timing_ms": timing_ms,
377
+ "transport_channel": transport_channel,
378
+ }
379
+ )
380
+ if role == "user":
381
+ timeline_role = "tool"
382
+ hide_in_summary = True
383
+ if result_names:
384
+ detail_sections.append(_format_tool_detail("result→", result_names))
385
+
386
+ if detail_sections:
387
+ if len(detail_sections) == 1:
388
+ details: Text | None = detail_sections[0]
389
+ else:
390
+ details = Text()
391
+ for index, section in enumerate(detail_sections):
392
+ if index > 0:
393
+ details.append(" ")
394
+ details.append_text(section)
395
+ else:
396
+ details = None
397
+
398
+ row_chars = chars
399
+ if timeline_role == "tool" and tool_result_total_chars > 0:
400
+ row_chars = tool_result_total_chars
401
+ row_non_text = row_non_text or tool_result_has_non_text
402
+ row_is_error = tool_result_has_error
403
+
404
+ rows.append(
405
+ {
406
+ "role": role,
407
+ "timeline_role": timeline_role,
408
+ "chars": row_chars,
409
+ "preview": preview,
410
+ "details": details,
411
+ "non_text": row_non_text,
412
+ "has_tool_request": has_tool_request,
413
+ "hide_summary": hide_in_summary,
414
+ "include_in_timeline": include_in_timeline,
415
+ "is_error": row_is_error,
416
+ "timing_ms": timing_ms,
417
+ }
418
+ )
419
+ rows.extend(result_rows)
420
+
421
+ return rows
422
+
423
+
424
+ def _aggregate_timeline_entries(rows: Sequence[dict]) -> list[dict]:
425
+ return [
426
+ {
427
+ "role": row.get("timeline_role", row["role"]),
428
+ "chars": row["chars"],
429
+ "non_text": row["non_text"],
430
+ "is_error": row.get("is_error", False),
431
+ }
432
+ for row in rows
433
+ if row.get("include_in_timeline", True)
434
+ ]
435
+
436
+
437
+ def _get_role_color(role: str, *, is_error: bool = False) -> str:
438
+ """Get the display color for a role, accounting for error states."""
439
+ color_map = {"user": Colours.USER, "assistant": Colours.ASSISTANT, "tool": Colours.TOOL}
440
+
441
+ if role == "tool" and is_error:
442
+ return Colours.TOOL_ERROR
443
+
444
+ return color_map.get(role, "white")
445
+
446
+
447
+ def _shade_block(chars: int, *, non_text: bool, color: str) -> Text:
448
+ if non_text:
449
+ return Text(NON_TEXT_MARKER, style=f"bold {color}")
450
+ if chars <= 0:
451
+ return Text("·", style="dim")
452
+ if chars < 50:
453
+ return Text("░", style=f"dim {color}")
454
+ if chars < 200:
455
+ return Text("▒", style=f"dim {color}")
456
+ if chars < 500:
457
+ return Text("▒", style=color)
458
+ if chars < 2000:
459
+ return Text("▓", style=color)
460
+ return Text("█", style=f"bold {color}")
461
+
462
+
463
+ def _build_history_bar(entries: Sequence[dict], width: int = TIMELINE_WIDTH) -> tuple[Text, Text]:
464
+ recent = list(entries[-width:])
465
+ bar = Text(" history |", style="dim")
466
+ for entry in recent:
467
+ color = _get_role_color(entry["role"], is_error=entry.get("is_error", False))
468
+ bar.append_text(
469
+ _shade_block(entry["chars"], non_text=entry.get("non_text", False), color=color)
470
+ )
471
+ remaining = width - len(recent)
472
+ if remaining > 0:
473
+ bar.append("░" * remaining, style=Colours.TIMELINE_EMPTY)
474
+ bar.append("|", style="dim")
475
+
476
+ detail = Text(f"{len(entries)} turns", style="dim")
477
+ return bar, detail
478
+
479
+
480
+ def _build_context_bar_line(
481
+ current: int,
482
+ window: int | None,
483
+ width: int = TIMELINE_WIDTH,
484
+ ) -> tuple[Text, Text]:
485
+ bar = Text(" context |", style="dim")
486
+
487
+ if not window or window <= 0:
488
+ bar.append("░" * width, style=Colours.TIMELINE_EMPTY)
489
+ bar.append("|", style="dim")
490
+ detail = Text(f"{format_chars(current)} tokens (unknown window)", style="dim")
491
+ return bar, detail
492
+
493
+ percent = current / window if window else 0.0
494
+ filled = min(width, int(round(min(percent, 1.0) * width)))
495
+
496
+ def color_for(pct: float) -> str:
497
+ if pct >= 0.9:
498
+ return Colours.CONTEXT_ALERT
499
+ if pct >= 0.7:
500
+ return Colours.CONTEXT_CAUTION
501
+ return Colours.CONTEXT_SAFE
502
+
503
+ color = color_for(percent)
504
+ if filled > 0:
505
+ bar.append("█" * filled, style=color)
506
+ if filled < width:
507
+ bar.append("░" * (width - filled), style=Colours.TIMELINE_EMPTY)
508
+ bar.append("|", style="dim")
509
+ bar.append(f" {percent * 100:5.1f}%", style="dim")
510
+ if percent > 1.0:
511
+ bar.append(f" +{(percent - 1) * 100:.0f}%", style="bold bright_red")
512
+
513
+ detail = Text(f"{format_chars(current)} / {format_chars(window)} →", style="dim")
514
+ return bar, detail
515
+
516
+
517
+ def _render_header_line(agent_name: str, *, console: Console | None, printer) -> None:
518
+ header = Text()
519
+ header.append("▎", style=Colours.HEADER)
520
+ header.append("●", style=f"dim {Colours.HEADER}")
521
+ header.append(" [ 1] ", style=Colours.HEADER)
522
+ header.append(str(agent_name), style=f"bold {Colours.USER}")
523
+
524
+ line = Text()
525
+ line.append_text(header)
526
+ line.append(" ")
527
+
528
+ try:
529
+ total_width = console.width if console else get_terminal_size().columns
530
+ except Exception:
531
+ total_width = 80
532
+
533
+ separator_width = max(1, total_width - line.cell_len)
534
+ line.append("─" * separator_width, style="dim")
535
+
536
+ printer("")
537
+ printer(line)
538
+ printer("")
539
+
540
+
541
+ def _render_statistics(
542
+ summary: ConversationSummary,
543
+ *,
544
+ console: Console | None,
545
+ printer,
546
+ ) -> None:
547
+ """Render compact conversation statistics section."""
548
+
549
+ # Format timing values
550
+ llm_time = (
551
+ format_time(summary.total_elapsed_time_ms) if summary.total_elapsed_time_ms > 0 else "-"
552
+ )
553
+ runtime = format_time(summary.conversation_span_ms) if summary.conversation_span_ms > 0 else "-"
554
+
555
+ # Build compact statistics lines
556
+ stats_lines = []
557
+
558
+ if summary.total_elapsed_time_ms > 0 or summary.conversation_span_ms > 0:
559
+ timing_line = Text(" ", style="dim")
560
+ timing_line.append("LLM Time: ", style="dim")
561
+ timing_line.append(llm_time, style="default")
562
+ timing_line.append(" • ", style="dim")
563
+ timing_line.append("Runtime: ", style="dim")
564
+ timing_line.append(runtime, style="default")
565
+ stats_lines.append(timing_line)
566
+
567
+ tool_counts = Text(" ", style="dim")
568
+ tool_counts.append("Tool Calls: ", style="dim")
569
+ tool_counts.append(str(summary.tool_calls), style="default")
570
+ if summary.tool_calls > 0:
571
+ tool_counts.append(
572
+ f" (successes: {summary.tool_successes}, errors: {summary.tool_errors})", style="dim"
573
+ )
574
+ stats_lines.append(tool_counts)
575
+
576
+ # Tool Usage Breakdown (if tools were used)
577
+ if summary.tool_calls > 0 and summary.tool_call_map:
578
+ # Get top tools sorted by count
579
+ sorted_tools = sorted(summary.tool_call_map.items(), key=lambda x: x[1], reverse=True)
580
+
581
+ # Show compact breakdown
582
+ tool_details = Text(" ", style="dim")
583
+ tool_details.append("Tools: ", style="dim")
584
+
585
+ tool_parts = []
586
+ for tool_name, count in sorted_tools[:5]: # Show max 5 tools
587
+ tool_parts.append(f"{tool_name} ({count})")
588
+
589
+ tool_details.append(", ".join(tool_parts), style=Colours.TOOL_DETAIL)
590
+ stats_lines.append(tool_details)
591
+
592
+ # Print all statistics lines
593
+ for line in stats_lines:
594
+ printer(line)
595
+
596
+ printer("")
597
+
598
+
599
+ def display_history_overview(
600
+ agent_name: str,
601
+ history: Sequence[PromptMessageExtended],
602
+ usage_accumulator: "UsageAccumulator" | None = None,
603
+ *,
604
+ console: Console | None = None,
605
+ ) -> None:
606
+ if not history:
607
+ printer = console.print if console else rich_print
608
+ printer("[dim]No conversation history yet[/dim]")
609
+ return
610
+
611
+ printer = console.print if console else rich_print
612
+
613
+ # Create conversation summary for statistics
614
+ summary = ConversationSummary(messages=list(history))
615
+
616
+ rows = _build_history_rows(history)
617
+ timeline_entries = _aggregate_timeline_entries(rows)
618
+
619
+ history_bar, history_detail = _build_history_bar(timeline_entries)
620
+ if usage_accumulator:
621
+ current_tokens = getattr(usage_accumulator, "current_context_tokens", 0)
622
+ window = getattr(usage_accumulator, "context_window_size", None)
623
+ else:
624
+ current_tokens = 0
625
+ window = None
626
+ context_bar, context_detail = _build_context_bar_line(current_tokens, window)
627
+
628
+ _render_header_line(agent_name, console=console, printer=printer)
629
+
630
+ # Render conversation statistics
631
+ _render_statistics(summary, console=console, printer=printer)
632
+
633
+ gap = Text(" ")
634
+ combined_line = Text()
635
+ combined_line.append_text(history_bar)
636
+ combined_line.append_text(gap)
637
+ combined_line.append_text(context_bar)
638
+ printer(combined_line)
639
+
640
+ history_label_len = len(" history |")
641
+ context_label_len = len(" context |")
642
+
643
+ history_available = history_bar.cell_len - history_label_len
644
+ context_available = context_bar.cell_len - context_label_len
645
+
646
+ detail_line = Text()
647
+ detail_line.append(" " * history_label_len, style="dim")
648
+ detail_line.append_text(history_detail)
649
+ if history_available > history_detail.cell_len:
650
+ detail_line.append(" " * (history_available - history_detail.cell_len), style="dim")
651
+ detail_line.append_text(gap)
652
+ detail_line.append(" " * context_label_len, style="dim")
653
+ detail_line.append_text(context_detail)
654
+ if context_available > context_detail.cell_len:
655
+ detail_line.append(" " * (context_available - context_detail.cell_len), style="dim")
656
+ printer(detail_line)
657
+
658
+ printer("")
659
+ printer(
660
+ Text(" " + "─" * (history_bar.cell_len + context_bar.cell_len + gap.cell_len), style="dim")
661
+ )
662
+
663
+ summary_candidates = [row for row in rows if not row.get("hide_summary")]
664
+ summary_rows = summary_candidates[-SUMMARY_COUNT:]
665
+ start_index = len(summary_candidates) - len(summary_rows) + 1
666
+
667
+ role_arrows = {"user": "▶", "assistant": "◀", "tool": "▶"}
668
+ role_labels = {"user": "user", "assistant": "assistant", "tool": "tool result"}
669
+
670
+ try:
671
+ total_width = console.width if console else get_terminal_size().columns
672
+ except Exception:
673
+ total_width = 80
674
+
675
+ # Responsive column layout based on terminal width
676
+ show_time = total_width >= 60
677
+ show_chars = total_width >= 50
678
+
679
+ header_line = Text(" ")
680
+ header_line.append(" #", style="dim")
681
+ header_line.append(" ", style="dim")
682
+ header_line.append(f" {'Role':<{ROLE_COLUMN_WIDTH}}", style="dim")
683
+ if show_time:
684
+ header_line.append(f" {'Time':>7}", style="dim")
685
+ if show_chars:
686
+ header_line.append(f" {'Chars':>7}", style="dim")
687
+ header_line.append(" ", style="dim")
688
+ header_line.append("Summary", style="dim")
689
+ printer(header_line)
690
+
691
+ for offset, row in enumerate(summary_rows):
692
+ role = row["role"]
693
+ color = _get_role_color(role, is_error=row.get("is_error", False))
694
+ arrow = role_arrows.get(role, "▶")
695
+ label = role_labels.get(role, role)
696
+ if role == "assistant" and row.get("has_tool_request"):
697
+ label = f"{label}*"
698
+ chars = row["chars"]
699
+ block = _shade_block(chars, non_text=row.get("non_text", False), color=color)
700
+
701
+ details = row.get("details")
702
+ preview_value = row["preview"]
703
+ preview_text = _ensure_text(preview_value)
704
+ detail_text = _ensure_text(details) if details else Text("")
705
+ if detail_text.cell_len == 0:
706
+ detail_text = None
707
+
708
+ timing_ms = row.get("timing_ms")
709
+ timing_str = format_time(timing_ms)
710
+
711
+ line = Text(" ")
712
+ line.append(f"{start_index + offset:>2}", style="dim")
713
+ line.append(" ")
714
+ line.append_text(block)
715
+ line.append(" ")
716
+ line.append(arrow, style=color)
717
+ line.append(" ")
718
+ line.append(f"{label:<{ROLE_COLUMN_WIDTH}}", style=color)
719
+ if show_time:
720
+ line.append(f" {timing_str:>7}", style="dim")
721
+ if show_chars:
722
+ line.append(f" {format_chars(chars):>7}", style="dim")
723
+ line.append(" ")
724
+ summary_width = max(0, total_width - line.cell_len)
725
+ summary_text = _compose_summary_text(
726
+ preview_text,
727
+ detail_text,
728
+ include_non_text=row.get("non_text", False),
729
+ max_width=summary_width,
730
+ )
731
+ line.append_text(summary_text)
732
+ printer(line)
733
+
734
+ printer("")