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.
- fast_agent/__init__.py +183 -0
- fast_agent/acp/__init__.py +19 -0
- fast_agent/acp/acp_aware_mixin.py +304 -0
- fast_agent/acp/acp_context.py +437 -0
- fast_agent/acp/content_conversion.py +136 -0
- fast_agent/acp/filesystem_runtime.py +427 -0
- fast_agent/acp/permission_store.py +269 -0
- fast_agent/acp/server/__init__.py +5 -0
- fast_agent/acp/server/agent_acp_server.py +1472 -0
- fast_agent/acp/slash_commands.py +1050 -0
- fast_agent/acp/terminal_runtime.py +408 -0
- fast_agent/acp/tool_permission_adapter.py +125 -0
- fast_agent/acp/tool_permissions.py +474 -0
- fast_agent/acp/tool_progress.py +814 -0
- fast_agent/agents/__init__.py +85 -0
- fast_agent/agents/agent_types.py +64 -0
- fast_agent/agents/llm_agent.py +350 -0
- fast_agent/agents/llm_decorator.py +1139 -0
- fast_agent/agents/mcp_agent.py +1337 -0
- fast_agent/agents/tool_agent.py +271 -0
- fast_agent/agents/workflow/agents_as_tools_agent.py +849 -0
- fast_agent/agents/workflow/chain_agent.py +212 -0
- fast_agent/agents/workflow/evaluator_optimizer.py +380 -0
- fast_agent/agents/workflow/iterative_planner.py +652 -0
- fast_agent/agents/workflow/maker_agent.py +379 -0
- fast_agent/agents/workflow/orchestrator_models.py +218 -0
- fast_agent/agents/workflow/orchestrator_prompts.py +248 -0
- fast_agent/agents/workflow/parallel_agent.py +250 -0
- fast_agent/agents/workflow/router_agent.py +353 -0
- fast_agent/cli/__init__.py +0 -0
- fast_agent/cli/__main__.py +73 -0
- fast_agent/cli/commands/acp.py +159 -0
- fast_agent/cli/commands/auth.py +404 -0
- fast_agent/cli/commands/check_config.py +783 -0
- fast_agent/cli/commands/go.py +514 -0
- fast_agent/cli/commands/quickstart.py +557 -0
- fast_agent/cli/commands/serve.py +143 -0
- fast_agent/cli/commands/server_helpers.py +114 -0
- fast_agent/cli/commands/setup.py +174 -0
- fast_agent/cli/commands/url_parser.py +190 -0
- fast_agent/cli/constants.py +40 -0
- fast_agent/cli/main.py +115 -0
- fast_agent/cli/terminal.py +24 -0
- fast_agent/config.py +798 -0
- fast_agent/constants.py +41 -0
- fast_agent/context.py +279 -0
- fast_agent/context_dependent.py +50 -0
- fast_agent/core/__init__.py +92 -0
- fast_agent/core/agent_app.py +448 -0
- fast_agent/core/core_app.py +137 -0
- fast_agent/core/direct_decorators.py +784 -0
- fast_agent/core/direct_factory.py +620 -0
- fast_agent/core/error_handling.py +27 -0
- fast_agent/core/exceptions.py +90 -0
- fast_agent/core/executor/__init__.py +0 -0
- fast_agent/core/executor/executor.py +280 -0
- fast_agent/core/executor/task_registry.py +32 -0
- fast_agent/core/executor/workflow_signal.py +324 -0
- fast_agent/core/fastagent.py +1186 -0
- fast_agent/core/logging/__init__.py +5 -0
- fast_agent/core/logging/events.py +138 -0
- fast_agent/core/logging/json_serializer.py +164 -0
- fast_agent/core/logging/listeners.py +309 -0
- fast_agent/core/logging/logger.py +278 -0
- fast_agent/core/logging/transport.py +481 -0
- fast_agent/core/prompt.py +9 -0
- fast_agent/core/prompt_templates.py +183 -0
- fast_agent/core/validation.py +326 -0
- fast_agent/event_progress.py +62 -0
- fast_agent/history/history_exporter.py +49 -0
- fast_agent/human_input/__init__.py +47 -0
- fast_agent/human_input/elicitation_handler.py +123 -0
- fast_agent/human_input/elicitation_state.py +33 -0
- fast_agent/human_input/form_elements.py +59 -0
- fast_agent/human_input/form_fields.py +256 -0
- fast_agent/human_input/simple_form.py +113 -0
- fast_agent/human_input/types.py +40 -0
- fast_agent/interfaces.py +310 -0
- fast_agent/llm/__init__.py +9 -0
- fast_agent/llm/cancellation.py +22 -0
- fast_agent/llm/fastagent_llm.py +931 -0
- fast_agent/llm/internal/passthrough.py +161 -0
- fast_agent/llm/internal/playback.py +129 -0
- fast_agent/llm/internal/silent.py +41 -0
- fast_agent/llm/internal/slow.py +38 -0
- fast_agent/llm/memory.py +275 -0
- fast_agent/llm/model_database.py +490 -0
- fast_agent/llm/model_factory.py +388 -0
- fast_agent/llm/model_info.py +102 -0
- fast_agent/llm/prompt_utils.py +155 -0
- fast_agent/llm/provider/anthropic/anthropic_utils.py +84 -0
- fast_agent/llm/provider/anthropic/cache_planner.py +56 -0
- fast_agent/llm/provider/anthropic/llm_anthropic.py +796 -0
- fast_agent/llm/provider/anthropic/multipart_converter_anthropic.py +462 -0
- fast_agent/llm/provider/bedrock/bedrock_utils.py +218 -0
- fast_agent/llm/provider/bedrock/llm_bedrock.py +2207 -0
- fast_agent/llm/provider/bedrock/multipart_converter_bedrock.py +84 -0
- fast_agent/llm/provider/google/google_converter.py +466 -0
- fast_agent/llm/provider/google/llm_google_native.py +681 -0
- fast_agent/llm/provider/openai/llm_aliyun.py +31 -0
- fast_agent/llm/provider/openai/llm_azure.py +143 -0
- fast_agent/llm/provider/openai/llm_deepseek.py +76 -0
- fast_agent/llm/provider/openai/llm_generic.py +35 -0
- fast_agent/llm/provider/openai/llm_google_oai.py +32 -0
- fast_agent/llm/provider/openai/llm_groq.py +42 -0
- fast_agent/llm/provider/openai/llm_huggingface.py +85 -0
- fast_agent/llm/provider/openai/llm_openai.py +1195 -0
- fast_agent/llm/provider/openai/llm_openai_compatible.py +138 -0
- fast_agent/llm/provider/openai/llm_openrouter.py +45 -0
- fast_agent/llm/provider/openai/llm_tensorzero_openai.py +128 -0
- fast_agent/llm/provider/openai/llm_xai.py +38 -0
- fast_agent/llm/provider/openai/multipart_converter_openai.py +561 -0
- fast_agent/llm/provider/openai/openai_multipart.py +169 -0
- fast_agent/llm/provider/openai/openai_utils.py +67 -0
- fast_agent/llm/provider/openai/responses.py +133 -0
- fast_agent/llm/provider_key_manager.py +139 -0
- fast_agent/llm/provider_types.py +34 -0
- fast_agent/llm/request_params.py +61 -0
- fast_agent/llm/sampling_converter.py +98 -0
- fast_agent/llm/stream_types.py +9 -0
- fast_agent/llm/usage_tracking.py +445 -0
- fast_agent/mcp/__init__.py +56 -0
- fast_agent/mcp/common.py +26 -0
- fast_agent/mcp/elicitation_factory.py +84 -0
- fast_agent/mcp/elicitation_handlers.py +164 -0
- fast_agent/mcp/gen_client.py +83 -0
- fast_agent/mcp/helpers/__init__.py +36 -0
- fast_agent/mcp/helpers/content_helpers.py +352 -0
- fast_agent/mcp/helpers/server_config_helpers.py +25 -0
- fast_agent/mcp/hf_auth.py +147 -0
- fast_agent/mcp/interfaces.py +92 -0
- fast_agent/mcp/logger_textio.py +108 -0
- fast_agent/mcp/mcp_agent_client_session.py +411 -0
- fast_agent/mcp/mcp_aggregator.py +2175 -0
- fast_agent/mcp/mcp_connection_manager.py +723 -0
- fast_agent/mcp/mcp_content.py +262 -0
- fast_agent/mcp/mime_utils.py +108 -0
- fast_agent/mcp/oauth_client.py +509 -0
- fast_agent/mcp/prompt.py +159 -0
- fast_agent/mcp/prompt_message_extended.py +155 -0
- fast_agent/mcp/prompt_render.py +84 -0
- fast_agent/mcp/prompt_serialization.py +580 -0
- fast_agent/mcp/prompts/__init__.py +0 -0
- fast_agent/mcp/prompts/__main__.py +7 -0
- fast_agent/mcp/prompts/prompt_constants.py +18 -0
- fast_agent/mcp/prompts/prompt_helpers.py +238 -0
- fast_agent/mcp/prompts/prompt_load.py +186 -0
- fast_agent/mcp/prompts/prompt_server.py +552 -0
- fast_agent/mcp/prompts/prompt_template.py +438 -0
- fast_agent/mcp/resource_utils.py +215 -0
- fast_agent/mcp/sampling.py +200 -0
- fast_agent/mcp/server/__init__.py +4 -0
- fast_agent/mcp/server/agent_server.py +613 -0
- fast_agent/mcp/skybridge.py +44 -0
- fast_agent/mcp/sse_tracking.py +287 -0
- fast_agent/mcp/stdio_tracking_simple.py +59 -0
- fast_agent/mcp/streamable_http_tracking.py +309 -0
- fast_agent/mcp/tool_execution_handler.py +137 -0
- fast_agent/mcp/tool_permission_handler.py +88 -0
- fast_agent/mcp/transport_tracking.py +634 -0
- fast_agent/mcp/types.py +24 -0
- fast_agent/mcp/ui_agent.py +48 -0
- fast_agent/mcp/ui_mixin.py +209 -0
- fast_agent/mcp_server_registry.py +89 -0
- fast_agent/py.typed +0 -0
- fast_agent/resources/examples/data-analysis/analysis-campaign.py +189 -0
- fast_agent/resources/examples/data-analysis/analysis.py +68 -0
- fast_agent/resources/examples/data-analysis/fastagent.config.yaml +41 -0
- fast_agent/resources/examples/data-analysis/mount-point/WA_Fn-UseC_-HR-Employee-Attrition.csv +1471 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +297 -0
- fast_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
- fast_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
- fast_agent/resources/examples/mcp/elicitations/forms_demo.py +107 -0
- fast_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
- fast_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
- fast_agent/resources/examples/mcp/elicitations/tool_call.py +21 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_one.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/agent_two.py +18 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +27 -0
- fast_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +15 -0
- fast_agent/resources/examples/researcher/fastagent.config.yaml +61 -0
- fast_agent/resources/examples/researcher/researcher-eval.py +53 -0
- fast_agent/resources/examples/researcher/researcher-imp.py +189 -0
- fast_agent/resources/examples/researcher/researcher.py +36 -0
- fast_agent/resources/examples/tensorzero/.env.sample +2 -0
- fast_agent/resources/examples/tensorzero/Makefile +31 -0
- fast_agent/resources/examples/tensorzero/README.md +56 -0
- fast_agent/resources/examples/tensorzero/agent.py +35 -0
- fast_agent/resources/examples/tensorzero/demo_images/clam.jpg +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/crab.png +0 -0
- fast_agent/resources/examples/tensorzero/demo_images/shrimp.png +0 -0
- fast_agent/resources/examples/tensorzero/docker-compose.yml +105 -0
- fast_agent/resources/examples/tensorzero/fastagent.config.yaml +19 -0
- fast_agent/resources/examples/tensorzero/image_demo.py +67 -0
- fast_agent/resources/examples/tensorzero/mcp_server/Dockerfile +25 -0
- fast_agent/resources/examples/tensorzero/mcp_server/entrypoint.sh +35 -0
- fast_agent/resources/examples/tensorzero/mcp_server/mcp_server.py +31 -0
- fast_agent/resources/examples/tensorzero/mcp_server/pyproject.toml +11 -0
- fast_agent/resources/examples/tensorzero/simple_agent.py +25 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_schema.json +29 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/system_template.minijinja +11 -0
- fast_agent/resources/examples/tensorzero/tensorzero_config/tensorzero.toml +35 -0
- fast_agent/resources/examples/workflows/agents_as_tools_extended.py +73 -0
- fast_agent/resources/examples/workflows/agents_as_tools_simple.py +50 -0
- fast_agent/resources/examples/workflows/chaining.py +37 -0
- fast_agent/resources/examples/workflows/evaluator.py +77 -0
- fast_agent/resources/examples/workflows/fastagent.config.yaml +26 -0
- fast_agent/resources/examples/workflows/graded_report.md +89 -0
- fast_agent/resources/examples/workflows/human_input.py +28 -0
- fast_agent/resources/examples/workflows/maker.py +156 -0
- fast_agent/resources/examples/workflows/orchestrator.py +70 -0
- fast_agent/resources/examples/workflows/parallel.py +56 -0
- fast_agent/resources/examples/workflows/router.py +69 -0
- fast_agent/resources/examples/workflows/short_story.md +13 -0
- fast_agent/resources/examples/workflows/short_story.txt +19 -0
- fast_agent/resources/setup/.gitignore +30 -0
- fast_agent/resources/setup/agent.py +28 -0
- fast_agent/resources/setup/fastagent.config.yaml +65 -0
- fast_agent/resources/setup/fastagent.secrets.yaml.example +38 -0
- fast_agent/resources/setup/pyproject.toml.tmpl +23 -0
- fast_agent/skills/__init__.py +9 -0
- fast_agent/skills/registry.py +235 -0
- fast_agent/tools/elicitation.py +369 -0
- fast_agent/tools/shell_runtime.py +402 -0
- fast_agent/types/__init__.py +59 -0
- fast_agent/types/conversation_summary.py +294 -0
- fast_agent/types/llm_stop_reason.py +78 -0
- fast_agent/types/message_search.py +249 -0
- fast_agent/ui/__init__.py +38 -0
- fast_agent/ui/console.py +59 -0
- fast_agent/ui/console_display.py +1080 -0
- fast_agent/ui/elicitation_form.py +946 -0
- fast_agent/ui/elicitation_style.py +59 -0
- fast_agent/ui/enhanced_prompt.py +1400 -0
- fast_agent/ui/history_display.py +734 -0
- fast_agent/ui/interactive_prompt.py +1199 -0
- fast_agent/ui/markdown_helpers.py +104 -0
- fast_agent/ui/markdown_truncator.py +1004 -0
- fast_agent/ui/mcp_display.py +857 -0
- fast_agent/ui/mcp_ui_utils.py +235 -0
- fast_agent/ui/mermaid_utils.py +169 -0
- fast_agent/ui/message_primitives.py +50 -0
- fast_agent/ui/notification_tracker.py +205 -0
- fast_agent/ui/plain_text_truncator.py +68 -0
- fast_agent/ui/progress_display.py +10 -0
- fast_agent/ui/rich_progress.py +195 -0
- fast_agent/ui/streaming.py +774 -0
- fast_agent/ui/streaming_buffer.py +449 -0
- fast_agent/ui/tool_display.py +422 -0
- fast_agent/ui/usage_display.py +204 -0
- fast_agent/utils/__init__.py +5 -0
- fast_agent/utils/reasoning_stream_parser.py +77 -0
- fast_agent/utils/time.py +22 -0
- fast_agent/workflow_telemetry.py +261 -0
- fast_agent_mcp-0.4.7.dist-info/METADATA +788 -0
- fast_agent_mcp-0.4.7.dist-info/RECORD +261 -0
- fast_agent_mcp-0.4.7.dist-info/WHEEL +4 -0
- fast_agent_mcp-0.4.7.dist-info/entry_points.txt +7 -0
- fast_agent_mcp-0.4.7.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Transports for the Logger module for MCP Agent, including:
|
|
3
|
+
- Local + optional remote event transport
|
|
4
|
+
- Async event bus
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import traceback
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
import aiohttp
|
|
15
|
+
from opentelemetry import trace
|
|
16
|
+
from rich import print
|
|
17
|
+
from rich.json import JSON
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from fast_agent.config import LoggerSettings
|
|
21
|
+
from fast_agent.core.logging.events import Event, EventFilter
|
|
22
|
+
from fast_agent.core.logging.json_serializer import JSONSerializer
|
|
23
|
+
from fast_agent.core.logging.listeners import EventListener, LifecycleAwareListener
|
|
24
|
+
from fast_agent.ui.console import console
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EventTransport(Protocol):
|
|
28
|
+
"""
|
|
29
|
+
Pluggable interface for sending events to a remote or external system
|
|
30
|
+
(Kafka, RabbitMQ, REST, etc.).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
async def send_event(self, event: Event) -> None:
|
|
34
|
+
"""
|
|
35
|
+
Send an event to the external system.
|
|
36
|
+
Args:
|
|
37
|
+
event: Event to send.
|
|
38
|
+
"""
|
|
39
|
+
...
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FilteredEventTransport(EventTransport, ABC):
|
|
43
|
+
"""
|
|
44
|
+
Event transport that filters events based on a filter before sending.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, event_filter: EventFilter | None = None) -> None:
|
|
48
|
+
self.filter = event_filter
|
|
49
|
+
|
|
50
|
+
async def send_event(self, event: Event) -> None:
|
|
51
|
+
if not self.filter or self.filter.matches(event):
|
|
52
|
+
await self.send_matched_event(event)
|
|
53
|
+
|
|
54
|
+
@abstractmethod
|
|
55
|
+
async def send_matched_event(self, event: Event):
|
|
56
|
+
"""Send an event to the external system."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class NoOpTransport(FilteredEventTransport):
|
|
60
|
+
"""Default transport that does nothing (purely local)."""
|
|
61
|
+
|
|
62
|
+
async def send_matched_event(self, event) -> None:
|
|
63
|
+
"""Do nothing."""
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ConsoleTransport(FilteredEventTransport):
|
|
68
|
+
"""Simple transport that prints events to console."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, event_filter: EventFilter | None = None) -> None:
|
|
71
|
+
super().__init__(event_filter=event_filter)
|
|
72
|
+
# Use shared console instances
|
|
73
|
+
self._serializer = JSONSerializer()
|
|
74
|
+
self.log_level_styles: dict[str, str] = {
|
|
75
|
+
"info": "bold green",
|
|
76
|
+
"debug": "dim white",
|
|
77
|
+
"warning": "bold yellow",
|
|
78
|
+
"error": "bold red",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async def send_matched_event(self, event: Event) -> None:
|
|
82
|
+
# Map log levels to styles
|
|
83
|
+
style = self.log_level_styles.get(event.type, "white")
|
|
84
|
+
|
|
85
|
+
# Use the appropriate console based on event type
|
|
86
|
+
# output_console = error_console if event.type == "error" else console
|
|
87
|
+
output_console = console
|
|
88
|
+
|
|
89
|
+
# Create namespace without None
|
|
90
|
+
namespace = event.namespace
|
|
91
|
+
if event.name:
|
|
92
|
+
namespace = f"{namespace}.{event.name}"
|
|
93
|
+
|
|
94
|
+
log_text = Text.assemble(
|
|
95
|
+
(f"[{event.type.upper()}] ", style),
|
|
96
|
+
(f"{event.timestamp.replace(microsecond=0).isoformat()} ", "cyan"),
|
|
97
|
+
(f"{namespace} ", "magenta"),
|
|
98
|
+
(f"- {event.message}", "white"),
|
|
99
|
+
)
|
|
100
|
+
output_console.print(log_text)
|
|
101
|
+
|
|
102
|
+
# Print additional data as JSON if available
|
|
103
|
+
if event.data:
|
|
104
|
+
serialized_data = self._serializer(event.data)
|
|
105
|
+
output_console.print(JSON.from_data(serialized_data))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class FileTransport(FilteredEventTransport):
|
|
109
|
+
"""Transport that writes events to a file with proper formatting."""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
filepath: str | Path,
|
|
114
|
+
event_filter: EventFilter | None = None,
|
|
115
|
+
mode: str = "a",
|
|
116
|
+
encoding: str = "utf-8",
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Initialize FileTransport.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
filepath: Path to the log file. If relative, the current working directory will be used
|
|
122
|
+
event_filter: Optional filter for events
|
|
123
|
+
mode: File open mode ('a' for append, 'w' for write)
|
|
124
|
+
encoding: File encoding to use
|
|
125
|
+
"""
|
|
126
|
+
super().__init__(event_filter=event_filter)
|
|
127
|
+
self.filepath = Path(filepath)
|
|
128
|
+
self.mode = mode
|
|
129
|
+
self.encoding = encoding
|
|
130
|
+
self._serializer = JSONSerializer()
|
|
131
|
+
|
|
132
|
+
# Create directory if it doesn't exist
|
|
133
|
+
self.filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
async def send_matched_event(self, event: Event) -> None:
|
|
136
|
+
"""Write matched event to log file asynchronously.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
event: Event to write to file
|
|
140
|
+
"""
|
|
141
|
+
# Format the log entry
|
|
142
|
+
namespace = event.namespace
|
|
143
|
+
if event.name:
|
|
144
|
+
namespace = f"{namespace}.{event.name}"
|
|
145
|
+
|
|
146
|
+
log_entry = {
|
|
147
|
+
"level": event.type.upper(),
|
|
148
|
+
"timestamp": event.timestamp.isoformat(),
|
|
149
|
+
"namespace": namespace,
|
|
150
|
+
"message": event.message,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
# Add event data if present
|
|
154
|
+
if event.data:
|
|
155
|
+
log_entry["data"] = self._serializer(event.data)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
with open(self.filepath, mode=self.mode, encoding=self.encoding) as f:
|
|
159
|
+
# Write the log entry as compact JSON (JSONL format)
|
|
160
|
+
f.write(json.dumps(log_entry, separators=(",", ":")) + "\n")
|
|
161
|
+
f.flush() # Ensure writing to disk
|
|
162
|
+
except IOError as e:
|
|
163
|
+
# Log error without recursion
|
|
164
|
+
print(f"Error writing to log file {self.filepath}: {e}")
|
|
165
|
+
|
|
166
|
+
async def close(self) -> None:
|
|
167
|
+
"""Clean up resources if needed."""
|
|
168
|
+
pass # File handles are automatically closed after each write
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def is_closed(self) -> bool:
|
|
172
|
+
"""Check if transport is closed."""
|
|
173
|
+
return False # Since we open/close per write
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class HTTPTransport(FilteredEventTransport):
|
|
177
|
+
"""
|
|
178
|
+
Sends events to an HTTP endpoint in batches.
|
|
179
|
+
Useful for sending to remote logging services like Elasticsearch, etc.
|
|
180
|
+
"""
|
|
181
|
+
|
|
182
|
+
def __init__(
|
|
183
|
+
self,
|
|
184
|
+
endpoint: str,
|
|
185
|
+
headers: dict[str, str] = None,
|
|
186
|
+
batch_size: int = 100,
|
|
187
|
+
timeout: float = 5.0,
|
|
188
|
+
event_filter: EventFilter | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
super().__init__(event_filter=event_filter)
|
|
191
|
+
self.endpoint = endpoint
|
|
192
|
+
self.headers = headers or {}
|
|
193
|
+
self.batch_size = batch_size
|
|
194
|
+
self.timeout = timeout
|
|
195
|
+
|
|
196
|
+
self.batch: list[Event] = []
|
|
197
|
+
self.lock = asyncio.Lock()
|
|
198
|
+
self._session: aiohttp.ClientSession | None = None
|
|
199
|
+
self._serializer = JSONSerializer()
|
|
200
|
+
|
|
201
|
+
async def start(self) -> None:
|
|
202
|
+
"""Initialize HTTP session."""
|
|
203
|
+
if not self._session:
|
|
204
|
+
self._session = aiohttp.ClientSession(
|
|
205
|
+
headers=self.headers, timeout=aiohttp.ClientTimeout(total=self.timeout)
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
async def stop(self) -> None:
|
|
209
|
+
"""Close HTTP session and flush any remaining events."""
|
|
210
|
+
if self.batch:
|
|
211
|
+
await self._flush()
|
|
212
|
+
if self._session:
|
|
213
|
+
await self._session.close()
|
|
214
|
+
self._session = None
|
|
215
|
+
|
|
216
|
+
async def send_matched_event(self, event: Event) -> None:
|
|
217
|
+
"""Add event to batch, flush if batch is full."""
|
|
218
|
+
async with self.lock:
|
|
219
|
+
self.batch.append(event)
|
|
220
|
+
if len(self.batch) >= self.batch_size:
|
|
221
|
+
await self._flush()
|
|
222
|
+
|
|
223
|
+
async def _flush(self) -> None:
|
|
224
|
+
"""Send batch of events to HTTP endpoint."""
|
|
225
|
+
if not self.batch:
|
|
226
|
+
return
|
|
227
|
+
|
|
228
|
+
if not self._session:
|
|
229
|
+
await self.start()
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
# Convert events to JSON-serializable dicts
|
|
233
|
+
events_data = [
|
|
234
|
+
{
|
|
235
|
+
"timestamp": event.timestamp.isoformat(),
|
|
236
|
+
"type": event.type,
|
|
237
|
+
"name": event.name,
|
|
238
|
+
"namespace": event.namespace,
|
|
239
|
+
"message": event.message,
|
|
240
|
+
"data": self._serializer(event.data),
|
|
241
|
+
"trace_id": event.trace_id,
|
|
242
|
+
"span_id": event.span_id,
|
|
243
|
+
"context": event.context.dict() if event.context else None,
|
|
244
|
+
}
|
|
245
|
+
for event in self.batch
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
async with self._session.post(self.endpoint, json=events_data) as response:
|
|
249
|
+
if response.status >= 400:
|
|
250
|
+
text = await response.text()
|
|
251
|
+
print(
|
|
252
|
+
f"Error sending log events to {self.endpoint}. "
|
|
253
|
+
f"Status: {response.status}, Response: {text}"
|
|
254
|
+
)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
print(f"Error sending log events to {self.endpoint}: {e}")
|
|
257
|
+
finally:
|
|
258
|
+
self.batch.clear()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class AsyncEventBus:
|
|
262
|
+
"""
|
|
263
|
+
Async event bus with local in-process listeners + optional remote transport.
|
|
264
|
+
Also injects distributed tracing (trace_id, span_id) if there's a current span.
|
|
265
|
+
"""
|
|
266
|
+
|
|
267
|
+
_instance = None
|
|
268
|
+
|
|
269
|
+
def __init__(self, transport: EventTransport | None = None) -> None:
|
|
270
|
+
self.transport: EventTransport = transport or NoOpTransport()
|
|
271
|
+
self.listeners: dict[str, EventListener] = {}
|
|
272
|
+
self._queue: asyncio.Queue | None = None
|
|
273
|
+
self._task: asyncio.Task | None = None
|
|
274
|
+
self._running = False
|
|
275
|
+
|
|
276
|
+
@classmethod
|
|
277
|
+
def get(cls, transport: EventTransport | None = None) -> "AsyncEventBus":
|
|
278
|
+
"""Get the singleton instance of the event bus."""
|
|
279
|
+
if cls._instance is None:
|
|
280
|
+
cls._instance = cls(transport=transport)
|
|
281
|
+
elif transport is not None:
|
|
282
|
+
# Update transport if provided
|
|
283
|
+
cls._instance.transport = transport
|
|
284
|
+
return cls._instance
|
|
285
|
+
|
|
286
|
+
@classmethod
|
|
287
|
+
def reset(cls) -> None:
|
|
288
|
+
"""
|
|
289
|
+
Reset the singleton instance.
|
|
290
|
+
This is primarily useful for testing scenarios where you need to ensure
|
|
291
|
+
a clean state between tests.
|
|
292
|
+
"""
|
|
293
|
+
if cls._instance:
|
|
294
|
+
# Signal shutdown
|
|
295
|
+
cls._instance._running = False
|
|
296
|
+
|
|
297
|
+
# Clear the singleton instance
|
|
298
|
+
cls._instance = None
|
|
299
|
+
|
|
300
|
+
async def start(self) -> None:
|
|
301
|
+
"""Start the event bus and all lifecycle-aware listeners."""
|
|
302
|
+
if self._running:
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
try:
|
|
306
|
+
asyncio.get_running_loop()
|
|
307
|
+
except RuntimeError:
|
|
308
|
+
loop = asyncio.new_event_loop()
|
|
309
|
+
asyncio.set_event_loop(loop)
|
|
310
|
+
|
|
311
|
+
self._queue = asyncio.Queue()
|
|
312
|
+
|
|
313
|
+
# Start each lifecycle-aware listener
|
|
314
|
+
for listener in self.listeners.values():
|
|
315
|
+
if isinstance(listener, LifecycleAwareListener):
|
|
316
|
+
await listener.start()
|
|
317
|
+
|
|
318
|
+
# Start processing
|
|
319
|
+
self._running = True
|
|
320
|
+
self._task = asyncio.create_task(self._process_events())
|
|
321
|
+
|
|
322
|
+
async def stop(self) -> None:
|
|
323
|
+
"""Stop the event bus and all lifecycle-aware listeners."""
|
|
324
|
+
if not self._running:
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
# Signal processing to stop
|
|
328
|
+
self._running = False
|
|
329
|
+
|
|
330
|
+
# Try to process remaining items with a timeout
|
|
331
|
+
if not self._queue.empty():
|
|
332
|
+
try:
|
|
333
|
+
# Give some time for remaining items to be processed
|
|
334
|
+
await asyncio.wait_for(self._queue.join(), timeout=5.0)
|
|
335
|
+
except asyncio.TimeoutError:
|
|
336
|
+
# If we timeout, drain the queue to prevent deadlock
|
|
337
|
+
while not self._queue.empty():
|
|
338
|
+
try:
|
|
339
|
+
self._queue.get_nowait()
|
|
340
|
+
self._queue.task_done()
|
|
341
|
+
except asyncio.QueueEmpty:
|
|
342
|
+
break
|
|
343
|
+
except Exception as e:
|
|
344
|
+
print(f"Error during queue cleanup: {e}")
|
|
345
|
+
self._queue = None
|
|
346
|
+
|
|
347
|
+
# Cancel and wait for task with timeout
|
|
348
|
+
if self._task and not self._task.done():
|
|
349
|
+
self._task.cancel()
|
|
350
|
+
try:
|
|
351
|
+
# Wait for task to complete with timeout
|
|
352
|
+
await asyncio.wait_for(self._task, timeout=5.0)
|
|
353
|
+
except (asyncio.CancelledError, asyncio.TimeoutError):
|
|
354
|
+
pass # Task was cancelled or timed out
|
|
355
|
+
except Exception as e:
|
|
356
|
+
print(f"Error cancelling process task: {e}")
|
|
357
|
+
self._task = None
|
|
358
|
+
|
|
359
|
+
# Stop each lifecycle-aware listener
|
|
360
|
+
for listener in self.listeners.values():
|
|
361
|
+
if isinstance(listener, LifecycleAwareListener):
|
|
362
|
+
try:
|
|
363
|
+
await asyncio.wait_for(listener.stop(), timeout=3.0)
|
|
364
|
+
except asyncio.TimeoutError:
|
|
365
|
+
print(f"Timeout stopping listener: {listener}")
|
|
366
|
+
except Exception as e:
|
|
367
|
+
print(f"Error stopping listener: {e}")
|
|
368
|
+
|
|
369
|
+
async def emit(self, event: Event) -> None:
|
|
370
|
+
"""Emit an event to all listeners and transport."""
|
|
371
|
+
if not self._running:
|
|
372
|
+
return
|
|
373
|
+
|
|
374
|
+
# Inject current tracing info if available
|
|
375
|
+
span = trace.get_current_span()
|
|
376
|
+
if span.is_recording():
|
|
377
|
+
ctx = span.get_span_context()
|
|
378
|
+
event.trace_id = f"{ctx.trace_id:032x}"
|
|
379
|
+
event.span_id = f"{ctx.span_id:016x}"
|
|
380
|
+
|
|
381
|
+
# Forward to transport first (immediate processing)
|
|
382
|
+
try:
|
|
383
|
+
await self.transport.send_event(event)
|
|
384
|
+
except Exception as e:
|
|
385
|
+
print(f"Error in transport.send_event: {e}")
|
|
386
|
+
|
|
387
|
+
# Then queue for listeners
|
|
388
|
+
await self._queue.put(event)
|
|
389
|
+
|
|
390
|
+
def add_listener(self, name: str, listener: EventListener) -> None:
|
|
391
|
+
"""Add a listener to the event bus."""
|
|
392
|
+
self.listeners[name] = listener
|
|
393
|
+
|
|
394
|
+
def remove_listener(self, name: str) -> None:
|
|
395
|
+
"""Remove a listener from the event bus."""
|
|
396
|
+
self.listeners.pop(name, None)
|
|
397
|
+
|
|
398
|
+
async def _process_events(self) -> None:
|
|
399
|
+
"""Process events from the queue until stopped."""
|
|
400
|
+
while self._running:
|
|
401
|
+
event = None
|
|
402
|
+
try:
|
|
403
|
+
# Use wait_for with a timeout to allow checking running state
|
|
404
|
+
try:
|
|
405
|
+
event = await asyncio.wait_for(self._queue.get(), timeout=0.1)
|
|
406
|
+
except asyncio.TimeoutError:
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
# Process the event through all listeners
|
|
410
|
+
tasks = []
|
|
411
|
+
for listener in self.listeners.values():
|
|
412
|
+
try:
|
|
413
|
+
tasks.append(listener.handle_event(event))
|
|
414
|
+
except Exception as e:
|
|
415
|
+
print(f"Error creating listener task: {e}")
|
|
416
|
+
|
|
417
|
+
if tasks:
|
|
418
|
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
419
|
+
for r in results:
|
|
420
|
+
if isinstance(r, Exception):
|
|
421
|
+
print(f"Error in listener: {r}")
|
|
422
|
+
print(
|
|
423
|
+
f"Stacktrace: {''.join(traceback.format_exception(type(r), r, r.__traceback__))}"
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
except asyncio.CancelledError:
|
|
427
|
+
# TODO -- added _queue assertion; is that necessary?
|
|
428
|
+
if event is not None and self._queue is not None:
|
|
429
|
+
self._queue.task_done()
|
|
430
|
+
raise
|
|
431
|
+
except Exception as e:
|
|
432
|
+
print(f"Error in event processing loop: {e}")
|
|
433
|
+
# Mark task done for this event
|
|
434
|
+
if event is not None and self._queue is not None:
|
|
435
|
+
self._queue.task_done()
|
|
436
|
+
|
|
437
|
+
# Process remaining events in queue
|
|
438
|
+
if self._queue:
|
|
439
|
+
while not self._queue.empty():
|
|
440
|
+
try:
|
|
441
|
+
event = self._queue.get_nowait()
|
|
442
|
+
tasks = []
|
|
443
|
+
for listener in self.listeners.values():
|
|
444
|
+
try:
|
|
445
|
+
tasks.append(listener.handle_event(event))
|
|
446
|
+
except Exception:
|
|
447
|
+
pass
|
|
448
|
+
if tasks:
|
|
449
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
450
|
+
self._queue.task_done()
|
|
451
|
+
except asyncio.QueueEmpty:
|
|
452
|
+
break
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def create_transport(
|
|
456
|
+
settings: LoggerSettings, event_filter: EventFilter | None = None
|
|
457
|
+
) -> EventTransport:
|
|
458
|
+
"""Create event transport based on settings."""
|
|
459
|
+
if settings.type == "none":
|
|
460
|
+
return NoOpTransport(event_filter=event_filter)
|
|
461
|
+
elif settings.type == "console":
|
|
462
|
+
return ConsoleTransport(event_filter=event_filter)
|
|
463
|
+
elif settings.type == "file":
|
|
464
|
+
if not settings.path:
|
|
465
|
+
raise ValueError("File path required for file transport")
|
|
466
|
+
return FileTransport(
|
|
467
|
+
filepath=settings.path,
|
|
468
|
+
event_filter=event_filter,
|
|
469
|
+
)
|
|
470
|
+
elif settings.type == "http":
|
|
471
|
+
if not settings.http_endpoint:
|
|
472
|
+
raise ValueError("HTTP endpoint required for HTTP transport")
|
|
473
|
+
return HTTPTransport(
|
|
474
|
+
endpoint=settings.http_endpoint,
|
|
475
|
+
headers=settings.http_headers,
|
|
476
|
+
batch_size=settings.batch_size,
|
|
477
|
+
timeout=settings.http_timeout,
|
|
478
|
+
event_filter=event_filter,
|
|
479
|
+
)
|
|
480
|
+
else:
|
|
481
|
+
raise ValueError(f"Unsupported transport type: {settings.type}")
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helpers for applying template variables to system prompts after initial bootstrap.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import platform
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, Mapping, MutableMapping
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from fast_agent.skills import SkillManifest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def apply_template_variables(
|
|
17
|
+
template: str | None, variables: Mapping[str, str | None] | None
|
|
18
|
+
) -> str | None:
|
|
19
|
+
"""
|
|
20
|
+
Apply a mapping of template variables to the provided template string.
|
|
21
|
+
|
|
22
|
+
This helper intentionally performs no work when either the template or variables
|
|
23
|
+
are empty so callers can safely execute it during both the initial and late
|
|
24
|
+
initialization passes without accidentally stripping placeholders too early.
|
|
25
|
+
|
|
26
|
+
Supports both simple variable substitution and file template patterns:
|
|
27
|
+
- {{variable}} - Simple variable replacement
|
|
28
|
+
- {{file:relative/path}} - Reads file contents (relative to workspaceRoot, errors if missing)
|
|
29
|
+
- {{file_silent:relative/path}} - Reads file contents (relative to workspaceRoot, empty if missing)
|
|
30
|
+
"""
|
|
31
|
+
if not template or not variables:
|
|
32
|
+
return template
|
|
33
|
+
|
|
34
|
+
resolved = template
|
|
35
|
+
|
|
36
|
+
# Get workspaceRoot for file resolution
|
|
37
|
+
workspace_root = variables.get("workspaceRoot")
|
|
38
|
+
|
|
39
|
+
# Apply {{file:...}} templates (relative paths required, resolved from workspaceRoot)
|
|
40
|
+
file_pattern = re.compile(r"\{\{file:([^}]+)\}\}")
|
|
41
|
+
|
|
42
|
+
def replace_file(match):
|
|
43
|
+
file_path_str = match.group(1).strip()
|
|
44
|
+
file_path = Path(file_path_str).expanduser()
|
|
45
|
+
|
|
46
|
+
# Enforce relative paths
|
|
47
|
+
if file_path.is_absolute():
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"File template paths must be relative, got absolute path: {file_path_str}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Resolve against workspaceRoot if available
|
|
53
|
+
if workspace_root:
|
|
54
|
+
resolved_path = (Path(workspace_root) / file_path).resolve()
|
|
55
|
+
else:
|
|
56
|
+
resolved_path = file_path.resolve()
|
|
57
|
+
|
|
58
|
+
return resolved_path.read_text(encoding="utf-8")
|
|
59
|
+
|
|
60
|
+
resolved = file_pattern.sub(replace_file, resolved)
|
|
61
|
+
|
|
62
|
+
# Apply {{file_silent:...}} templates (missing files become empty strings)
|
|
63
|
+
file_silent_pattern = re.compile(r"\{\{file_silent:([^}]+)\}\}")
|
|
64
|
+
|
|
65
|
+
def replace_file_silent(match):
|
|
66
|
+
file_path_str = match.group(1).strip()
|
|
67
|
+
file_path = Path(file_path_str).expanduser()
|
|
68
|
+
|
|
69
|
+
# Enforce relative paths
|
|
70
|
+
if file_path.is_absolute():
|
|
71
|
+
raise ValueError(
|
|
72
|
+
f"File template paths must be relative, got absolute path: {file_path_str}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Resolve against workspaceRoot if available
|
|
76
|
+
if workspace_root:
|
|
77
|
+
resolved_path = (Path(workspace_root) / file_path).resolve()
|
|
78
|
+
else:
|
|
79
|
+
resolved_path = file_path.resolve()
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
return resolved_path.read_text(encoding="utf-8")
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
return ""
|
|
85
|
+
|
|
86
|
+
resolved = file_silent_pattern.sub(replace_file_silent, resolved)
|
|
87
|
+
|
|
88
|
+
# Apply simple variable substitutions
|
|
89
|
+
for key, value in variables.items():
|
|
90
|
+
if value is None:
|
|
91
|
+
continue
|
|
92
|
+
placeholder = f"{{{{{key}}}}}"
|
|
93
|
+
if placeholder in resolved:
|
|
94
|
+
resolved = resolved.replace(placeholder, value)
|
|
95
|
+
|
|
96
|
+
return resolved
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def load_skills_for_context(
|
|
100
|
+
workspace_root: str | None, skills_directory_override: str | None = None
|
|
101
|
+
) -> list["SkillManifest"]:
|
|
102
|
+
"""
|
|
103
|
+
Load skill manifests from the workspace root or override directory.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
workspace_root: The workspace root directory
|
|
107
|
+
skills_directory_override: Optional override for skills directory (relative to workspace_root)
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
List of SkillManifest objects
|
|
111
|
+
"""
|
|
112
|
+
from fast_agent.skills.registry import SkillRegistry
|
|
113
|
+
|
|
114
|
+
if not workspace_root:
|
|
115
|
+
return []
|
|
116
|
+
|
|
117
|
+
base_dir = Path(workspace_root)
|
|
118
|
+
|
|
119
|
+
# If override is provided, treat it as relative to workspace_root
|
|
120
|
+
override_dir = None
|
|
121
|
+
if skills_directory_override:
|
|
122
|
+
override_path = Path(skills_directory_override)
|
|
123
|
+
# If it's absolute, use as-is; otherwise make relative to workspace_root
|
|
124
|
+
if override_path.is_absolute():
|
|
125
|
+
override_dir = override_path
|
|
126
|
+
else:
|
|
127
|
+
override_dir = base_dir / override_path
|
|
128
|
+
|
|
129
|
+
registry = SkillRegistry(base_dir=base_dir, override_directory=override_dir)
|
|
130
|
+
return registry.load_manifests()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def enrich_with_environment_context(
|
|
134
|
+
context: MutableMapping[str, str],
|
|
135
|
+
cwd: str | None,
|
|
136
|
+
client_info: Mapping[str, str] | None,
|
|
137
|
+
skills_directory_override: str | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""
|
|
140
|
+
Populate the provided context mapping with environment details used for template replacement.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
context: The context mapping to populate
|
|
144
|
+
cwd: The current working directory (workspace root)
|
|
145
|
+
client_info: Client information mapping
|
|
146
|
+
skills_directory_override: Optional override for skills directory
|
|
147
|
+
"""
|
|
148
|
+
if cwd:
|
|
149
|
+
context["workspaceRoot"] = cwd
|
|
150
|
+
|
|
151
|
+
server_platform = platform.platform()
|
|
152
|
+
python_version = platform.python_version()
|
|
153
|
+
|
|
154
|
+
# Provide individual placeholders for automation
|
|
155
|
+
if server_platform:
|
|
156
|
+
context["hostPlatform"] = server_platform
|
|
157
|
+
context["pythonVer"] = python_version
|
|
158
|
+
|
|
159
|
+
# Load and format agent skills
|
|
160
|
+
if cwd:
|
|
161
|
+
from fast_agent.skills.registry import format_skills_for_prompt
|
|
162
|
+
|
|
163
|
+
skill_manifests = load_skills_for_context(cwd, skills_directory_override)
|
|
164
|
+
skills_text = format_skills_for_prompt(skill_manifests)
|
|
165
|
+
context["agentSkills"] = skills_text
|
|
166
|
+
|
|
167
|
+
env_lines: list[str] = []
|
|
168
|
+
if cwd:
|
|
169
|
+
env_lines.append(f"Workspace root: {cwd}")
|
|
170
|
+
if client_info:
|
|
171
|
+
display_name = client_info.get("title") or client_info.get("name")
|
|
172
|
+
version = client_info.get("version")
|
|
173
|
+
if display_name:
|
|
174
|
+
if version and version != "unknown":
|
|
175
|
+
env_lines.append(f"Client: {display_name} {version}")
|
|
176
|
+
else:
|
|
177
|
+
env_lines.append(f"Client: {display_name}")
|
|
178
|
+
if server_platform:
|
|
179
|
+
env_lines.append(f"Host platform: {server_platform}")
|
|
180
|
+
|
|
181
|
+
if env_lines:
|
|
182
|
+
formatted = "Environment:\n- " + "\n- ".join(env_lines)
|
|
183
|
+
context["env"] = formatted
|