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,634 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from threading import Lock
7
+ from typing import Literal
8
+
9
+ from mcp.types import (
10
+ JSONRPCError,
11
+ JSONRPCMessage,
12
+ JSONRPCNotification,
13
+ JSONRPCRequest,
14
+ JSONRPCResponse,
15
+ RequestId,
16
+ )
17
+ from pydantic import BaseModel, ConfigDict
18
+
19
+ ChannelName = Literal["post-json", "post-sse", "get", "resumption", "stdio"]
20
+ EventType = Literal["message", "connect", "disconnect", "keepalive", "error"]
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class ChannelEvent:
25
+ """Event emitted by the tracking transport indicating channel activity."""
26
+
27
+ channel: ChannelName
28
+ event_type: EventType
29
+ message: JSONRPCMessage | None = None
30
+ raw_event: str | None = None
31
+ detail: str | None = None
32
+ status_code: int | None = None
33
+
34
+
35
+ @dataclass
36
+ class ModeStats:
37
+ messages: int = 0
38
+ request: int = 0
39
+ notification: int = 0
40
+ response: int = 0
41
+ last_summary: str | None = None
42
+ last_at: datetime | None = None
43
+
44
+
45
+ def _summarise_message(message: JSONRPCMessage) -> str:
46
+ root = message.root
47
+ if isinstance(root, JSONRPCRequest):
48
+ method = root.method or ""
49
+ return f"request {method}"
50
+ if isinstance(root, JSONRPCNotification):
51
+ method = root.method or ""
52
+ return f"notify {method}"
53
+ if isinstance(root, JSONRPCResponse):
54
+ return "response"
55
+ if isinstance(root, JSONRPCError):
56
+ code = getattr(root.error, "code", None)
57
+ return f"error {code}" if code is not None else "error"
58
+ return "message"
59
+
60
+
61
+ class ChannelSnapshot(BaseModel):
62
+ """Snapshot of aggregated activity for a single transport channel."""
63
+
64
+ model_config = ConfigDict(arbitrary_types_allowed=True)
65
+
66
+ message_count: int = 0
67
+ mode: str | None = None
68
+ mode_counts: dict[str, int] | None = None
69
+ last_message_summary: str | None = None
70
+ last_message_at: datetime | None = None
71
+ connected: bool | None = None
72
+ state: str | None = None
73
+ last_event: str | None = None
74
+ last_event_at: datetime | None = None
75
+ ping_count: int | None = None
76
+ ping_last_at: datetime | None = None
77
+ last_error: str | None = None
78
+ connect_at: datetime | None = None
79
+ disconnect_at: datetime | None = None
80
+ last_status_code: int | None = None
81
+ request_count: int = 0
82
+ response_count: int = 0
83
+ notification_count: int = 0
84
+ activity_buckets: list[str] | None = None
85
+ activity_bucket_seconds: int | None = None
86
+ activity_bucket_count: int | None = None
87
+
88
+
89
+ class TransportSnapshot(BaseModel):
90
+ """Collection of channel snapshots for a transport."""
91
+
92
+ model_config = ConfigDict(arbitrary_types_allowed=True)
93
+
94
+ post: ChannelSnapshot | None = None
95
+ post_json: ChannelSnapshot | None = None
96
+ post_sse: ChannelSnapshot | None = None
97
+ get: ChannelSnapshot | None = None
98
+ resumption: ChannelSnapshot | None = None
99
+ stdio: ChannelSnapshot | None = None
100
+ activity_bucket_seconds: int | None = None
101
+ activity_bucket_count: int | None = None
102
+
103
+
104
+ class TransportChannelMetrics:
105
+ """Aggregates low-level channel events into user-visible metrics."""
106
+
107
+ def __init__(
108
+ self,
109
+ bucket_seconds: int | None = None,
110
+ bucket_count: int | None = None,
111
+ ) -> None:
112
+ self._lock = Lock()
113
+
114
+ self._post_modes: set[str] = set()
115
+ self._post_count = 0
116
+ self._post_request_count = 0
117
+ self._post_response_count = 0
118
+ self._post_notification_count = 0
119
+ self._post_last_summary: str | None = None
120
+ self._post_last_at: datetime | None = None
121
+ self._post_mode_stats: dict[str, ModeStats] = {
122
+ "json": ModeStats(),
123
+ "sse": ModeStats(),
124
+ }
125
+
126
+ self._get_connected = False
127
+ self._get_had_connection = False
128
+ self._get_connect_at: datetime | None = None
129
+ self._get_disconnect_at: datetime | None = None
130
+ self._get_last_summary: str | None = None
131
+ self._get_last_at: datetime | None = None
132
+ self._get_last_event: str | None = None
133
+ self._get_last_event_at: datetime | None = None
134
+ self._get_last_error: str | None = None
135
+ self._get_last_status_code: int | None = None
136
+ self._get_message_count = 0
137
+ self._get_request_count = 0
138
+ self._get_response_count = 0
139
+ self._get_notification_count = 0
140
+ self._get_ping_count = 0
141
+ self._get_last_ping_at: datetime | None = None
142
+
143
+ self._resumption_count = 0
144
+ self._resumption_last_summary: str | None = None
145
+ self._resumption_last_at: datetime | None = None
146
+ self._resumption_request_count = 0
147
+ self._resumption_response_count = 0
148
+ self._resumption_notification_count = 0
149
+
150
+ self._stdio_connected = False
151
+ self._stdio_had_connection = False
152
+ self._stdio_connect_at: datetime | None = None
153
+ self._stdio_disconnect_at: datetime | None = None
154
+ self._stdio_count = 0
155
+ self._stdio_last_summary: str | None = None
156
+ self._stdio_last_at: datetime | None = None
157
+ self._stdio_last_event: str | None = None
158
+ self._stdio_last_event_at: datetime | None = None
159
+ self._stdio_last_error: str | None = None
160
+ self._stdio_request_count = 0
161
+ self._stdio_response_count = 0
162
+ self._stdio_notification_count = 0
163
+
164
+ self._response_channel_by_id: dict[RequestId, ChannelName] = {}
165
+
166
+ try:
167
+ seconds = 30 if bucket_seconds is None else int(bucket_seconds)
168
+ except (TypeError, ValueError):
169
+ seconds = 30
170
+ if seconds <= 0:
171
+ seconds = 30
172
+
173
+ try:
174
+ count = 20 if bucket_count is None else int(bucket_count)
175
+ except (TypeError, ValueError):
176
+ count = 20
177
+ if count <= 0:
178
+ count = 20
179
+
180
+ self._history_bucket_seconds = seconds
181
+ self._history_bucket_count = count
182
+ self._history_priority = {
183
+ "error": 5,
184
+ "disabled": 4,
185
+ "request": 4,
186
+ "response": 3,
187
+ "notification": 2,
188
+ "ping": 2,
189
+ "none": 1,
190
+ }
191
+ self._history: dict[str, deque[tuple[int, str]]] = {
192
+ "post-json": deque(maxlen=self._history_bucket_count),
193
+ "post-sse": deque(maxlen=self._history_bucket_count),
194
+ "get": deque(maxlen=self._history_bucket_count),
195
+ "resumption": deque(maxlen=self._history_bucket_count),
196
+ "stdio": deque(maxlen=self._history_bucket_count),
197
+ }
198
+
199
+ def record_event(self, event: ChannelEvent) -> None:
200
+ now = datetime.now(timezone.utc)
201
+ with self._lock:
202
+ if event.channel in ("post-json", "post-sse"):
203
+ self._handle_post_event(event, now)
204
+ elif event.channel == "get":
205
+ self._handle_get_event(event, now)
206
+ elif event.channel == "resumption":
207
+ self._handle_resumption_event(event, now)
208
+ elif event.channel == "stdio":
209
+ self._handle_stdio_event(event, now)
210
+
211
+ def _handle_post_event(self, event: ChannelEvent, now: datetime) -> None:
212
+ mode = "json" if event.channel == "post-json" else "sse"
213
+ if event.event_type == "message" and event.message is not None:
214
+ self._post_modes.add(mode)
215
+ self._post_count += 1
216
+
217
+ mode_stats = self._post_mode_stats[mode]
218
+ mode_stats.messages += 1
219
+
220
+ classification = self._tally_message_counts("post", event.message, now, sub_mode=mode)
221
+
222
+ summary = "ping" if classification == "ping" else _summarise_message(event.message)
223
+ mode_stats.last_summary = summary
224
+ mode_stats.last_at = now
225
+ self._post_last_summary = summary
226
+ self._post_last_at = now
227
+
228
+ self._record_response_channel(event)
229
+ self._record_history(event.channel, classification, now)
230
+ elif event.event_type == "error":
231
+ self._record_history(event.channel, "error", now)
232
+
233
+ def _handle_get_event(self, event: ChannelEvent, now: datetime) -> None:
234
+ if event.event_type == "connect":
235
+ self._get_connected = True
236
+ self._get_had_connection = True
237
+ self._get_connect_at = now
238
+ self._get_last_event = "connect"
239
+ self._get_last_event_at = now
240
+ self._get_last_error = None
241
+ self._get_last_status_code = None
242
+ elif event.event_type == "disconnect":
243
+ self._get_connected = False
244
+ self._get_disconnect_at = now
245
+ self._get_last_event = "disconnect"
246
+ self._get_last_event_at = now
247
+ elif event.event_type == "keepalive":
248
+ self._register_ping(now)
249
+ self._get_last_event = event.raw_event or "keepalive"
250
+ self._get_last_event_at = now
251
+ self._record_history("get", "ping", now)
252
+ elif event.event_type == "message" and event.message is not None:
253
+ self._get_message_count += 1
254
+ classification = self._tally_message_counts("get", event.message, now)
255
+ summary = "ping" if classification == "ping" else _summarise_message(event.message)
256
+ self._get_last_summary = summary
257
+ self._get_last_at = now
258
+ self._get_last_event = "ping" if classification == "ping" else "message"
259
+ self._get_last_event_at = now
260
+
261
+ self._record_response_channel(event)
262
+ self._record_history("get", classification, now)
263
+ elif event.event_type == "error":
264
+ self._get_last_status_code = event.status_code
265
+ self._get_last_error = event.detail
266
+ self._get_last_event = "error"
267
+ self._get_last_event_at = now
268
+ # Record 405 as "disabled" in timeline, not "error"
269
+ timeline_state = "disabled" if event.status_code == 405 else "error"
270
+ self._record_history("get", timeline_state, now)
271
+
272
+ def _handle_resumption_event(self, event: ChannelEvent, now: datetime) -> None:
273
+ if event.event_type == "message" and event.message is not None:
274
+ self._resumption_count += 1
275
+ classification = self._tally_message_counts("resumption", event.message, now)
276
+ summary = "ping" if classification == "ping" else _summarise_message(event.message)
277
+ self._resumption_last_summary = summary
278
+ self._resumption_last_at = now
279
+
280
+ self._record_response_channel(event)
281
+ self._record_history("resumption", classification, now)
282
+ elif event.event_type == "error":
283
+ self._record_history("resumption", "error", now)
284
+
285
+ def _handle_stdio_event(self, event: ChannelEvent, now: datetime) -> None:
286
+ if event.event_type == "connect":
287
+ self._stdio_connected = True
288
+ self._stdio_had_connection = True
289
+ self._stdio_connect_at = now
290
+ self._stdio_last_event = "connect"
291
+ self._stdio_last_event_at = now
292
+ self._stdio_last_error = None
293
+ elif event.event_type == "disconnect":
294
+ self._stdio_connected = False
295
+ self._stdio_disconnect_at = now
296
+ self._stdio_last_event = "disconnect"
297
+ self._stdio_last_event_at = now
298
+ elif event.event_type == "message":
299
+ self._stdio_count += 1
300
+
301
+ # Handle synthetic events (from ServerStats) vs real message events
302
+ if event.message is not None:
303
+ # Real message event with JSON-RPC content
304
+ classification = self._tally_message_counts("stdio", event.message, now)
305
+ summary = "ping" if classification == "ping" else _summarise_message(event.message)
306
+ self._record_response_channel(event)
307
+ else:
308
+ # Synthetic event from MCP operation activity
309
+ classification = "request" # MCP operations are always requests from client perspective
310
+ self._stdio_request_count += 1
311
+ summary = event.detail or "request"
312
+
313
+ self._stdio_last_summary = summary
314
+ self._stdio_last_at = now
315
+ self._stdio_last_event = "message"
316
+ self._stdio_last_event_at = now
317
+ self._record_history("stdio", classification, now)
318
+ elif event.event_type == "error":
319
+ self._stdio_last_error = event.detail
320
+ self._stdio_last_event = "error"
321
+ self._stdio_last_event_at = now
322
+ self._record_history("stdio", "error", now)
323
+
324
+ def _record_response_channel(self, event: ChannelEvent) -> None:
325
+ if event.message is None:
326
+ return
327
+ root = event.message.root
328
+ request_id: RequestId | None = None
329
+ if isinstance(root, (JSONRPCResponse, JSONRPCError, JSONRPCRequest)):
330
+ request_id = getattr(root, "id", None)
331
+ if request_id is None:
332
+ return
333
+ self._response_channel_by_id[request_id] = event.channel
334
+
335
+ def consume_response_channel(self, request_id: RequestId | None) -> ChannelName | None:
336
+ if request_id is None:
337
+ return None
338
+ with self._lock:
339
+ return self._response_channel_by_id.pop(request_id, None)
340
+
341
+ def _tally_message_counts(
342
+ self,
343
+ channel_key: str,
344
+ message: JSONRPCMessage,
345
+ timestamp: datetime,
346
+ *,
347
+ sub_mode: str | None = None,
348
+ ) -> str:
349
+ classification = self._classify_message(message)
350
+
351
+ if channel_key == "post":
352
+ if classification == "request":
353
+ self._post_request_count += 1
354
+ elif classification == "notification":
355
+ self._post_notification_count += 1
356
+ elif classification == "response":
357
+ self._post_response_count += 1
358
+
359
+ if sub_mode:
360
+ stats = self._post_mode_stats[sub_mode]
361
+ if classification in {"request", "notification", "response"}:
362
+ setattr(stats, classification, getattr(stats, classification) + 1)
363
+ elif channel_key == "get":
364
+ if classification == "ping":
365
+ self._register_ping(timestamp)
366
+ elif classification == "request":
367
+ self._get_request_count += 1
368
+ elif classification == "notification":
369
+ self._get_notification_count += 1
370
+ elif classification == "response":
371
+ self._get_response_count += 1
372
+ elif channel_key == "resumption":
373
+ if classification == "request":
374
+ self._resumption_request_count += 1
375
+ elif classification == "notification":
376
+ self._resumption_notification_count += 1
377
+ elif classification == "response":
378
+ self._resumption_response_count += 1
379
+ elif channel_key == "stdio":
380
+ if classification == "request":
381
+ self._stdio_request_count += 1
382
+ elif classification == "notification":
383
+ self._stdio_notification_count += 1
384
+ elif classification == "response":
385
+ self._stdio_response_count += 1
386
+
387
+ return classification
388
+
389
+ def _register_ping(self, timestamp: datetime) -> None:
390
+ self._get_ping_count += 1
391
+ self._get_last_ping_at = timestamp
392
+
393
+ def _classify_message(self, message: JSONRPCMessage | None) -> str:
394
+ if message is None:
395
+ return "none"
396
+ root = message.root
397
+ method = getattr(root, "method", "")
398
+ method_lower = method.lower() if isinstance(method, str) else ""
399
+
400
+ if isinstance(root, JSONRPCRequest):
401
+ if self._is_ping_method(method_lower):
402
+ return "ping"
403
+ return "request"
404
+ if isinstance(root, JSONRPCNotification):
405
+ if self._is_ping_method(method_lower):
406
+ return "ping"
407
+ return "notification"
408
+ if isinstance(root, (JSONRPCResponse, JSONRPCError)):
409
+ return "response"
410
+ return "none"
411
+
412
+ @staticmethod
413
+ def _is_ping_method(method: str) -> bool:
414
+ if not method:
415
+ return False
416
+ return (
417
+ method == "ping"
418
+ or method.endswith("/ping")
419
+ or method.endswith(".ping")
420
+ )
421
+
422
+ def _record_history(self, channel: str, state: str, timestamp: datetime) -> None:
423
+ if state in {"none", ""}:
424
+ return
425
+ history = self._history.get(channel)
426
+ if history is None:
427
+ return
428
+
429
+ bucket = int(timestamp.timestamp() // self._history_bucket_seconds)
430
+ if history and history[-1][0] == bucket:
431
+ existing = history[-1][1]
432
+ if self._history_priority.get(state, 0) >= self._history_priority.get(existing, 0):
433
+ history[-1] = (bucket, state)
434
+ return
435
+
436
+ while history and bucket - history[0][0] >= self._history_bucket_count:
437
+ history.popleft()
438
+
439
+ history.append((bucket, state))
440
+
441
+ def _build_activity_buckets(self, key: str, now: datetime) -> list[str]:
442
+ history = self._history.get(key)
443
+ if not history:
444
+ return ["none"] * self._history_bucket_count
445
+
446
+ history_map = {bucket: state for bucket, state in history}
447
+ current_bucket = int(now.timestamp() // self._history_bucket_seconds)
448
+ buckets: list[str] = []
449
+ for offset in range(self._history_bucket_count - 1, -1, -1):
450
+ bucket_index = current_bucket - offset
451
+ buckets.append(history_map.get(bucket_index, "none"))
452
+ return buckets
453
+
454
+ def _merge_activity_buckets(self, keys: list[str], now: datetime) -> list[str] | None:
455
+ sequences = [self._build_activity_buckets(key, now) for key in keys if key in self._history]
456
+ if not sequences:
457
+ return None
458
+
459
+ merged: list[str] = []
460
+ for idx in range(self._history_bucket_count):
461
+ best_state = "none"
462
+ best_priority = 0
463
+ for seq in sequences:
464
+ state = seq[idx]
465
+ priority = self._history_priority.get(state, 0)
466
+ if priority > best_priority:
467
+ best_state = state
468
+ best_priority = priority
469
+ merged.append(best_state)
470
+
471
+ if all(state == "none" for state in merged):
472
+ return None
473
+ return merged
474
+
475
+ def _build_post_mode_snapshot(self, mode: str, now: datetime) -> ChannelSnapshot | None:
476
+ stats = self._post_mode_stats[mode]
477
+ if stats.messages == 0:
478
+ return None
479
+ return ChannelSnapshot(
480
+ message_count=stats.messages,
481
+ mode=mode,
482
+ request_count=stats.request,
483
+ response_count=stats.response,
484
+ notification_count=stats.notification,
485
+ last_message_summary=stats.last_summary,
486
+ last_message_at=stats.last_at,
487
+ activity_buckets=self._build_activity_buckets(f"post-{mode}", now),
488
+ activity_bucket_seconds=self._history_bucket_seconds,
489
+ activity_bucket_count=self._history_bucket_count,
490
+ )
491
+
492
+ def snapshot(self) -> TransportSnapshot:
493
+ with self._lock:
494
+ if (
495
+ not self._post_count
496
+ and not self._get_message_count
497
+ and not self._get_ping_count
498
+ and not self._resumption_count
499
+ and not self._stdio_count
500
+ and not self._get_connected
501
+ and not self._stdio_connected
502
+ ):
503
+ return TransportSnapshot()
504
+
505
+ now = datetime.now(timezone.utc)
506
+
507
+ post_mode_counts = {
508
+ mode: stats.messages
509
+ for mode, stats in self._post_mode_stats.items()
510
+ if stats.messages
511
+ }
512
+ post_snapshot = None
513
+ if self._post_count:
514
+ if len(self._post_modes) == 0:
515
+ mode = None
516
+ elif len(self._post_modes) == 1:
517
+ mode = next(iter(self._post_modes))
518
+ else:
519
+ mode = "mixed"
520
+ post_snapshot = ChannelSnapshot(
521
+ message_count=self._post_count,
522
+ mode=mode,
523
+ mode_counts=post_mode_counts or None,
524
+ last_message_summary=self._post_last_summary,
525
+ last_message_at=self._post_last_at,
526
+ request_count=self._post_request_count,
527
+ response_count=self._post_response_count,
528
+ notification_count=self._post_notification_count,
529
+ activity_buckets=self._merge_activity_buckets(["post-json", "post-sse"], now),
530
+ activity_bucket_seconds=self._history_bucket_seconds,
531
+ activity_bucket_count=self._history_bucket_count,
532
+ )
533
+
534
+ post_json_snapshot = self._build_post_mode_snapshot("json", now)
535
+ post_sse_snapshot = self._build_post_mode_snapshot("sse", now)
536
+
537
+ get_snapshot = None
538
+ if (
539
+ self._get_message_count
540
+ or self._get_ping_count
541
+ or self._get_connected
542
+ or self._get_disconnect_at
543
+ or self._get_last_error
544
+ ):
545
+ if self._get_connected:
546
+ state = "open"
547
+ elif self._get_last_error is not None:
548
+ state = "disabled" if self._get_last_status_code == 405 else "error"
549
+ elif self._get_had_connection:
550
+ state = "off"
551
+ else:
552
+ state = "idle"
553
+
554
+ get_snapshot = ChannelSnapshot(
555
+ connected=self._get_connected,
556
+ state=state,
557
+ connect_at=self._get_connect_at,
558
+ disconnect_at=self._get_disconnect_at,
559
+ message_count=self._get_message_count,
560
+ last_message_summary=self._get_last_summary,
561
+ last_message_at=self._get_last_at,
562
+ ping_count=self._get_ping_count,
563
+ ping_last_at=self._get_last_ping_at,
564
+ last_error=self._get_last_error,
565
+ last_event=self._get_last_event,
566
+ last_event_at=self._get_last_event_at,
567
+ last_status_code=self._get_last_status_code,
568
+ request_count=self._get_request_count,
569
+ response_count=self._get_response_count,
570
+ notification_count=self._get_notification_count,
571
+ activity_buckets=self._build_activity_buckets("get", now),
572
+ activity_bucket_seconds=self._history_bucket_seconds,
573
+ activity_bucket_count=self._history_bucket_count,
574
+ )
575
+
576
+ resumption_snapshot = None
577
+ if self._resumption_count:
578
+ resumption_snapshot = ChannelSnapshot(
579
+ message_count=self._resumption_count,
580
+ last_message_summary=self._resumption_last_summary,
581
+ last_message_at=self._resumption_last_at,
582
+ request_count=self._resumption_request_count,
583
+ response_count=self._resumption_response_count,
584
+ notification_count=self._resumption_notification_count,
585
+ activity_buckets=self._build_activity_buckets("resumption", now),
586
+ activity_bucket_seconds=self._history_bucket_seconds,
587
+ activity_bucket_count=self._history_bucket_count,
588
+ )
589
+
590
+ stdio_snapshot = None
591
+ if (
592
+ self._stdio_count
593
+ or self._stdio_connected
594
+ or self._stdio_disconnect_at
595
+ or self._stdio_last_error
596
+ ):
597
+ if self._stdio_connected:
598
+ state = "open"
599
+ elif self._stdio_last_error is not None:
600
+ state = "error"
601
+ elif self._stdio_had_connection:
602
+ state = "off"
603
+ else:
604
+ state = "idle"
605
+
606
+ stdio_snapshot = ChannelSnapshot(
607
+ connected=self._stdio_connected,
608
+ state=state,
609
+ connect_at=self._stdio_connect_at,
610
+ disconnect_at=self._stdio_disconnect_at,
611
+ message_count=self._stdio_count,
612
+ last_message_summary=self._stdio_last_summary,
613
+ last_message_at=self._stdio_last_at,
614
+ last_error=self._stdio_last_error,
615
+ last_event=self._stdio_last_event,
616
+ last_event_at=self._stdio_last_event_at,
617
+ request_count=self._stdio_request_count,
618
+ response_count=self._stdio_response_count,
619
+ notification_count=self._stdio_notification_count,
620
+ activity_buckets=self._build_activity_buckets("stdio", now),
621
+ activity_bucket_seconds=self._history_bucket_seconds,
622
+ activity_bucket_count=self._history_bucket_count,
623
+ )
624
+
625
+ return TransportSnapshot(
626
+ post=post_snapshot,
627
+ post_json=post_json_snapshot,
628
+ post_sse=post_sse_snapshot,
629
+ get=get_snapshot,
630
+ resumption=resumption_snapshot,
631
+ stdio=stdio_snapshot,
632
+ activity_bucket_seconds=self._history_bucket_seconds,
633
+ activity_bucket_count=self._history_bucket_count,
634
+ )
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
4
+
5
+ from fast_agent.interfaces import AgentProtocol
6
+
7
+ if TYPE_CHECKING:
8
+ from fast_agent.context import Context
9
+ from fast_agent.mcp.mcp_aggregator import MCPAggregator
10
+ from fast_agent.ui.console_display import ConsoleDisplay
11
+
12
+
13
+ @runtime_checkable
14
+ class McpAgentProtocol(AgentProtocol, Protocol):
15
+ """Agent protocol with MCP-specific surface area."""
16
+
17
+ @property
18
+ def aggregator(self) -> MCPAggregator: ...
19
+
20
+ @property
21
+ def display(self) -> "ConsoleDisplay": ...
22
+
23
+ @property
24
+ def context(self) -> "Context | None": ...