Merge branch 'pr/663' into development

frdel committed Aug 14, 2025 at 14:33 UTC 267c6d49fd1c776668baca378fb7bc578dd039f8
19 files changed +1000 -187
agent.py
+78 -29
@@ -14,10 +14,11 @@ import models
14 from python.helpers import extract_tools, files, errors, history, tokens
15 from python.helpers import dirty_json
16 from python.helpers.print_style import PrintStyle
17 +
18 from langchain_core.prompts import (
19 ChatPromptTemplate,
20 )
20 -from langchain_core.messages import HumanMessage, SystemMessage, BaseMessage
21 +from langchain_core.messages import SystemMessage, BaseMessage
22
23 import python.helpers.log as Log
24 from python.helpers.dirty_json import DirtyJson
@@ -25,6 +26,8 @@ from python.helpers.defer import DeferredTask
26 from typing import Callable
27 from python.helpers.localization import Localization
28 from python.helpers.extension import call_extensions
29 +from python.helpers.errors import RepairableException
30 +
31
32 class AgentContextType(Enum):
33 USER = "user"
@@ -89,7 +92,7 @@ class AgentContext:
92 @classmethod
93 def get_notification_manager(cls):
94 if cls._notification_manager is None:
92 - from python.helpers.notification import NotificationManager
95 + from python.helpers.notification import NotificationManager # type: ignore
96 cls._notification_manager = NotificationManager()
97 return cls._notification_manager
98
@@ -259,8 +262,6 @@ class InterventionException(Exception):
262
263
264 # killer exception class - not forwarded to LLM, cannot be fixed on its own, ends message loop
262 -class RepairableException(Exception):
263 - pass
265
266
267 class HandledException(Exception):
@@ -287,17 +288,13 @@ class Agent:
288 self.number = number
289 self.agent_name = f"A{self.number}"
290
290 - self.history = history.History(self)
291 + self.history = history.History(self) # type: ignore[abstract]
292 self.last_user_message: history.Message | None = None
293 self.intervention: UserMessage | None = None
293 - self.data = {} # free data object all the tools can use
294 -
294 + self.data: dict[str, Any] = {} # free data object all the tools can use
295
296 asyncio.run(self.call_extensions("agent_init"))
297
298 -
299 -
300 -
298 async def monologue(self):
299 while True:
300 try:
@@ -330,15 +327,31 @@ class Agent:
327 async def reasoning_callback(chunk: str, full: str):
328 if chunk == full:
329 printer.print("Reasoning: ") # start of reasoning
333 - printer.stream(chunk)
334 - await self.handle_reasoning_stream(full)
330 + # Pass chunk and full data to extensions for processing
331 + stream_data = {"chunk": chunk, "full": full}
332 + await self.call_extensions(
333 + "reasoning_stream_chunk", loop_data=self.loop_data, stream_data=stream_data
334 + )
335 + # Stream masked chunk after extensions processed it
336 + if stream_data.get("chunk"):
337 + printer.stream(stream_data["chunk"])
338 + # Use the potentially modified full text for downstream processing
339 + await self.handle_reasoning_stream(stream_data["full"])
340
341 async def stream_callback(chunk: str, full: str):
342 # output the agent response stream
343 if chunk == full:
344 printer.print("Response: ") # start of response
340 - printer.stream(chunk)
341 - await self.handle_response_stream(full)
345 + # Pass chunk and full data to extensions for processing
346 + stream_data = {"chunk": chunk, "full": full}
347 + await self.call_extensions(
348 + "response_stream_chunk", loop_data=self.loop_data, stream_data=stream_data
349 + )
350 + # Stream masked chunk after extensions processed it
351 + if stream_data.get("chunk"):
352 + printer.stream(stream_data["chunk"])
353 + # Use the potentially modified full text for downstream processing
354 + await self.handle_response_stream(stream_data["full"])
355
356 # call main LLM
357 agent_response, _reasoning = await self.call_chat_model(
@@ -347,6 +360,14 @@ class Agent:
360 reasoning_callback=reasoning_callback,
361 )
362
363 + # Notify extensions to finalize their stream filters
364 + await self.call_extensions(
365 + "reasoning_stream_end", loop_data=self.loop_data
366 + )
367 + await self.call_extensions(
368 + "response_stream_end", loop_data=self.loop_data
369 + )
370 +
371 await self.handle_intervention(agent_response)
372
373 if (
@@ -375,10 +396,11 @@ class Agent:
396 pass # intervention message has been handled in handle_intervention(), proceed with conversation loop
397 except RepairableException as e:
398 # Forward repairable errors to the LLM, maybe it can fix them
378 - error_message = errors.format_error(e)
379 - self.hist_add_warning(error_message)
380 - PrintStyle(font_color="red", padding=True).print(error_message)
381 - self.context.log.log(type="error", content=error_message)
399 + msg = {"message": errors.format_error(e)}
400 + await self.call_extensions("error_format", msg=msg)
401 + self.hist_add_warning(msg["message"])
402 + PrintStyle(font_color="red", padding=True).print(msg["message"])
403 + self.context.log.log(type="error", content=msg["message"])
404 except Exception as e:
405 # Other exception kill the loop
406 self.handle_critical_exception(e)
@@ -416,7 +438,7 @@ class Agent:
438 system_text = "\n\n".join(loop_data.system)
439
440 # join extras
419 - extras = history.Message(
441 + extras = history.Message( # type: ignore[abstract]
442 False,
443 content=self.read_prompt(
444 "agent.context.extras.md",
@@ -465,6 +487,8 @@ class Agent:
487 # Handling for general exceptions
488 error_text = errors.error_text(exception)
489 error_message = errors.format_error(exception)
490 +
491 + # Mask secrets in error messages
492 PrintStyle(font_color="red", padding=True).print(error_message)
493 self.context.log.log(
494 type="error",
@@ -472,10 +496,14 @@ class Agent:
496 content=error_message,
497 kvps={"text": error_text},
498 )
499 + PrintStyle(font_color="red", padding=True).print(
500 + f"{self.agent_name}: {error_text}"
501 + )
502 +
503 raise HandledException(exception) # Re-raise the exception to kill the loop
504
505 async def get_system_prompt(self, loop_data: LoopData) -> list[str]:
478 - system_prompt = []
506 + system_prompt: list[str] = []
507 await self.call_extensions(
508 "system_prompt", system_prompt=system_prompt, loop_data=loop_data
509 )
@@ -518,7 +546,14 @@ class Agent:
546 self, ai: bool, content: history.MessageContent, tokens: int = 0
547 ):
548 self.last_message = datetime.now(timezone.utc)
521 - return self.history.add_message(ai=ai, content=content, tokens=tokens)
549 + # Allow extensions to process content before adding to history
550 + content_data = {"content": content}
551 + try:
552 + asyncio.run(self.call_extensions("hist_add_before", content_data=content_data, ai=ai))
553 + except Exception as e:
554 + # If extension call fails, proceed without modification
555 + pass
556 + return self.history.add_message(ai=ai, content=content_data["content"], tokens=tokens)
557
558 def hist_add_user_message(self, message: UserMessage, intervention: bool = False):
559 self.history.new_topic() # user message starts a new topic in history
@@ -609,7 +644,6 @@ class Agent:
644 ):
645 model = self.get_utility_model()
646
612 -
647 # propagate stream to callback if set
648 async def stream_callback(chunk: str, total: str):
649 if callback:
@@ -647,7 +681,7 @@ class Agent:
681 return response, reasoning
682
683 async def rate_limiter_callback(
650 - self, message:str, key:str, total:int, limit:int
684 + self, message: str, key: str, total: int, limit: int
685 ):
686 # show the rate limit waiting in a progress bar, no need to spam the chat history
687 self.context.log.set_progress(message, True)
@@ -714,14 +748,29 @@ class Agent:
748
749 if tool:
750 await self.handle_intervention()
751 +
752 + # Allow extensions to preprocess tool arguments (e.g., unmask secrets)
753 + await self.call_extensions("tool_execute_before", tool_args=tool_args or {}, tool_name=tool_name)
754 +
755 + # Call tool hooks for compatibility
756 await tool.before_execution(**tool_args)
757 await self.handle_intervention()
758 +
759 response = await tool.execute(**tool_args)
760 await self.handle_intervention()
721 - await tool.after_execution(response)
761 +
762 + # Allow extensions to postprocess tool response (e.g., mask secrets)
763 + response_data = {"response": response}
764 + await self.call_extensions("tool_execute_after", response_data=response_data, tool_name=tool_name)
765 + processed_response = response_data["response"]
766 +
767 + # Store result to history
768 + self.hist_add_tool_result(tool_name, getattr(processed_response, "message", ""))
769 +
770 + await tool.after_execution(processed_response)
771 await self.handle_intervention()
723 - if response.break_loop:
724 - return response.message
772 + if processed_response.break_loop:
773 + return processed_response.message
774 else:
775 error_detail = (
776 f"Tool '{raw_tool_name}' not found or could not be initialized."
@@ -775,16 +824,16 @@ class Agent:
824 if self.config.profile:
825 try:
826 classes = extract_tools.load_classes_from_file(
778 - "agents/" + self.config.profile + "/tools/" + name + ".py", Tool
827 + "agents/" + self.config.profile + "/tools/" + name + ".py", Tool # type: ignore[arg-type]
828 )
780 - except Exception as e:
829 + except Exception:
830 pass
831
832 # try default tools
833 if not classes:
834 try:
835 classes = extract_tools.load_classes_from_file(
787 - "python/tools/" + name + ".py", Tool
836 + "python/tools/" + name + ".py", Tool # type: ignore[arg-type]
837 )
838 except Exception as e:
839 pass
prompts/agent.system.secrets.md new
+13
@@ -0,0 +1,13 @@
1 +# Available Secret Placeholders
2 +For safety, user secrets are masked and used as placeholders.
3 +Use these placeholders in tool calls, they will be automatically replaced with actual values.
4 +
5 +You have access to the following secrets:
6 +{{secrets}}
7 +
8 +
9 +## Important Guidelines:
10 +- Use the exact placeholder format: §§KEY_NAME§§ (double section sign markers)
11 +- Secret values may contain special characters that need escaping in JSON strings, keep in mind and sanitize in your code
12 +- Placeholders are case-sensitive and must match the exact key names
13 +
python/extensions/error_format/_10_mask_errors.py new
+17
@@ -0,0 +1,17 @@
1 +from python.helpers.extension import Extension
2 +from python.helpers.secrets import SecretsManager
3 +
4 +
5 +class MaskErrorSecrets(Extension):
6 +
7 + async def execute(self, **kwargs):
8 + # Get error data from kwargs
9 + msg = kwargs.get("msg")
10 + if not msg:
11 + return
12 +
13 + secrets_mgr = SecretsManager.get_instance()
14 +
15 + # Mask the error message
16 + if "message" in msg:
17 + msg["message"] = secrets_mgr.mask_values(msg["message"])
python/extensions/hist_add_before/_10_mask_content.py new
+32
@@ -0,0 +1,32 @@
1 +from python.helpers.extension import Extension
2 +
3 +
4 +class MaskHistoryContent(Extension):
5 +
6 + async def execute(self, **kwargs):
7 + # Get content data from kwargs
8 + content_data = kwargs.get("content_data")
9 + if not content_data:
10 + return
11 +
12 + try:
13 + from python.helpers.secrets import SecretsManager
14 + secrets_mgr = SecretsManager.get_instance()
15 +
16 + # Mask the content before adding to history
17 + content_data["content"] = self._mask_content(content_data["content"], secrets_mgr)
18 + except Exception as e:
19 + # If masking fails, proceed without masking
20 + pass
21 +
22 + def _mask_content(self, content, secrets_mgr):
23 + """Recursively mask secrets in message content."""
24 + if isinstance(content, str):
25 + return secrets_mgr.mask_values(content)
26 + elif isinstance(content, list):
27 + return [self._mask_content(item, secrets_mgr) for item in content]
28 + elif isinstance(content, dict):
29 + return {k: self._mask_content(v, secrets_mgr) for k, v in content.items()}
30 + else:
31 + # For other types, return as-is
32 + return content
python/extensions/reasoning_stream_chunk/_10_mask_stream.py new
+38
@@ -0,0 +1,38 @@
1 +from python.helpers.extension import Extension
2 +
3 +
4 +class MaskReasoningStreamChunk(Extension):
5 + async def execute(self, **kwargs):
6 + # Get stream data and agent from kwargs
7 + stream_data = kwargs.get("stream_data")
8 + agent = kwargs.get("agent")
9 + if not agent or not stream_data:
10 + return
11 +
12 + try:
13 + from python.helpers.secrets import SecretsManager
14 + secrets_mgr = SecretsManager.get_instance()
15 +
16 + # Initialize filter if not exists
17 + filter_key = "_reason_stream_filter"
18 + filter_instance = agent.get_data(filter_key)
19 + if not filter_instance:
20 + filter_instance = secrets_mgr.create_streaming_filter()
21 + agent.set_data(filter_key, filter_instance)
22 +
23 + # Process the chunk through the streaming filter
24 + processed_chunk = filter_instance.process_chunk(stream_data["chunk"])
25 +
26 + # Update the stream data with processed chunk
27 + stream_data["chunk"] = processed_chunk
28 +
29 + # Also mask the full text for consistency
30 + stream_data["full"] = secrets_mgr.mask_values(stream_data["full"])
31 +
32 + # Print the processed chunk (this is where printing should happen)
33 + if processed_chunk:
34 + from python.helpers.print_style import PrintStyle
35 + PrintStyle().stream(processed_chunk)
36 + except Exception as e:
37 + # If masking fails, proceed without masking
38 + pass
python/extensions/reasoning_stream_end/_10_mask_end.py new
+27
@@ -0,0 +1,27 @@
1 +from python.helpers.extension import Extension
2 +
3 +
4 +class MaskReasoningStreamEnd(Extension):
5 + async def execute(self, **kwargs):
6 + # Get agent and finalize the streaming filter
7 + agent = kwargs.get("agent")
8 + if not agent:
9 + return
10 +
11 + try:
12 + # Finalize the reasoning stream filter if it exists
13 + filter_key = "_reason_stream_filter"
14 + filter_instance = agent.get_data(filter_key)
15 + if filter_instance:
16 + tail = filter_instance.finalize()
17 +
18 + # Print any remaining masked content
19 + if tail:
20 + from python.helpers.print_style import PrintStyle
21 + PrintStyle().stream(tail)
22 +
23 + # Clean up the filter
24 + agent.set_data(filter_key, None)
25 + except Exception as e:
26 + # If masking fails, proceed without masking
27 + pass
python/extensions/response_stream_chunk/_10_mask_stream.py new
+41
@@ -0,0 +1,41 @@
1 +from python.helpers.extension import Extension
2 +from python.helpers.secrets import SecretsManager
3 +from agent import Agent, LoopData
4 +
5 +
6 +class MaskResponseStreamChunk(Extension):
7 +
8 + async def execute(self, **kwargs):
9 + # Get stream data and agent from kwargs
10 + stream_data = kwargs.get("stream_data")
11 + agent = kwargs.get("agent")
12 + if not agent or not stream_data:
13 + return
14 +
15 + try:
16 + from python.helpers.secrets import SecretsManager
17 + secrets_mgr = SecretsManager.get_instance()
18 +
19 + # Initialize filter if not exists
20 + filter_key = "_resp_stream_filter"
21 + filter_instance = agent.get_data(filter_key)
22 + if not filter_instance:
23 + filter_instance = secrets_mgr.create_streaming_filter()
24 + agent.set_data(filter_key, filter_instance)
25 +
26 + # Process the chunk through the streaming filter
27 + processed_chunk = filter_instance.process_chunk(stream_data["chunk"])
28 +
29 + # Update the stream data with processed chunk
30 + stream_data["chunk"] = processed_chunk
31 +
32 + # Also mask the full text for consistency
33 + stream_data["full"] = secrets_mgr.mask_values(stream_data["full"])
34 +
35 + # Print the processed chunk (this is where printing should happen)
36 + if processed_chunk:
37 + from python.helpers.print_style import PrintStyle
38 + PrintStyle().stream(processed_chunk)
39 + except Exception as e:
40 + # If masking fails, proceed without masking
41 + pass
python/extensions/response_stream_end/_10_mask_end.py new
+28
@@ -0,0 +1,28 @@
1 +from python.helpers.extension import Extension
2 +from python.helpers.secrets import SecretsManager
3 +
4 +
5 +class MaskResponseStreamEnd(Extension):
6 + async def execute(self, **kwargs):
7 + # Get agent and finalize the streaming filter
8 + agent = kwargs.get("agent")
9 + if not agent:
10 + return
11 +
12 + try:
13 + # Finalize the response stream filter if it exists
14 + filter_key = "_resp_stream_filter"
15 + filter_instance = agent.get_data(filter_key)
16 + if filter_instance:
17 + tail = filter_instance.finalize()
18 +
19 + # Print any remaining masked content
20 + if tail:
21 + from python.helpers.print_style import PrintStyle
22 + PrintStyle().stream(tail)
23 +
24 + # Clean up the filter
25 + agent.set_data(filter_key, None)
26 + except Exception as e:
27 + # If masking fails, proceed without masking
28 + pass
python/extensions/system_prompt/_10_system_prompt.py
+17 -3
@@ -11,11 +11,14 @@ class SystemPrompt(Extension):
11 main = get_main_prompt(self.agent)
12 tools = get_tools_prompt(self.agent)
13 mcp_tools = get_mcp_tools_prompt(self.agent)
14 + secrets_prompt = get_secrets_prompt(self.agent)
15
16 system_prompt.append(main)
17 system_prompt.append(tools)
18 if mcp_tools:
19 system_prompt.append(mcp_tools)
20 + if secrets_prompt:
21 + system_prompt.append(secrets_prompt)
22
23
24 def get_main_prompt(agent: Agent):
@@ -33,9 +36,20 @@ def get_mcp_tools_prompt(agent: Agent):
36 mcp_config = MCPConfig.get_instance()
37 if mcp_config.servers:
38 pre_progress = agent.context.log.progress
36 - agent.context.log.set_progress("Collecting MCP tools") # MCP might be initializing, better inform via progress bar
39 + agent.context.log.set_progress("Collecting MCP tools") # MCP might be initializing, better inform via progress bar
40 tools = MCPConfig.get_instance().get_tools_prompt()
38 - agent.context.log.set_progress(pre_progress) # return original progress
41 + agent.context.log.set_progress(pre_progress) # return original progress
42 return tools
43 return ""
41 -
44 +
45 +
46 +def get_secrets_prompt(agent: Agent):
47 + try:
48 + # Use lazy import to avoid circular dependencies
49 + from python.helpers.secrets import SecretsManager
50 + secrets_manager = SecretsManager.get_instance()
51 + secrets = secrets_manager.get_secrets_for_prompt()
52 + return agent.read_prompt("agent.system.secrets.md", secrets=secrets)
53 + except Exception as e:
54 + # If secrets module is not available or has issues, return empty string
55 + return ""
python/extensions/tool_execute_after/_10_mask_secrets.py new
+21
@@ -0,0 +1,21 @@
1 +from python.helpers.extension import Extension
2 +from python.helpers.secrets import SecretsManager
3 +
4 +
5 +class MaskToolSecrets(Extension):
6 +
7 + async def execute(self, **kwargs):
8 + # Get response data from kwargs
9 + response_data = kwargs.get("response_data")
10 + if not response_data:
11 + return
12 +
13 + secrets_mgr = SecretsManager.get_instance()
14 + response = response_data["response"]
15 +
16 + # Mask response message if it exists
17 + if hasattr(response, "message") and response.message:
18 + response.message = secrets_mgr.mask_values(response.message)
19 +
20 + # Update the response data
21 + response_data["response"] = response
python/extensions/tool_execute_before/_10_unmask_secrets.py new
+18
@@ -0,0 +1,18 @@
1 +from python.helpers.extension import Extension
2 +from python.helpers.secrets import SecretsManager
3 +
4 +
5 +class UnmaskToolSecrets(Extension):
6 +
7 + async def execute(self, **kwargs):
8 + # Get tool args from kwargs
9 + tool_args = kwargs.get("tool_args")
10 + if not tool_args:
11 + return
12 +
13 + secrets_mgr = SecretsManager.get_instance()
14 +
15 + # Unmask placeholders in args for actual tool execution
16 + for k, v in tool_args.items():
17 + if isinstance(v, str):
18 + tool_args[k] = secrets_mgr.replace_placeholders(v)
python/helpers/errors.py
+6 -1
@@ -46,7 +46,7 @@ def format_error(e: Exception, start_entries=6, end_entries=4):
46 error_message = ""
47 for line in reversed(lines):
48 # match both simple errors and module.path.Error patterns
49 - if re.match(r"[\w\.]+Error:", line):
49 + if re.match(r"[\w\.]+Error:\s*", line):
50 error_message = line
51 break
52
@@ -63,3 +63,8 @@ def format_error(e: Exception, start_entries=6, end_entries=4):
63 result = str(e)
64
65 return result
66 +
67 +
68 +class RepairableException(Exception):
69 + """An exception type indicating errors that can be surfaced to the LLM for potential self-repair."""
70 + pass
python/helpers/fasta2a_server.py
+1 -1
@@ -258,7 +258,7 @@ class DynamicA2AProxy:
258 # Atomic update of the app
259 self.app = new_app
260
261 - _PRINTER.print("[A2A] FastA2A server configured successfully")
261 + # _PRINTER.print("[A2A] FastA2A server configured successfully")
262
263 except Exception as e:
264 _PRINTER.print(f"[A2A] Failed to configure FastA2A server: {e}")
python/helpers/log.py
+76 -58
@@ -1,10 +1,15 @@
1 from dataclasses import dataclass, field
2 import json
3 -from typing import Any, Literal, Optional, Dict
3 +from typing import Any, Literal, Optional, Dict, TypeVar
4 +
5 +T = TypeVar("T")
6 import uuid
7 from collections import OrderedDict # Import OrderedDict
8 from python.helpers.strings import truncate_text_by_ratio
9 import copy
10 +from typing import TypeVar
11 +
12 +T = TypeVar("T")
13
14 Type = Literal[
15 "agent",
@@ -37,19 +42,23 @@ def _truncate_heading(text: str | None) -> str:
42 return ""
43 return truncate_text_by_ratio(str(text), HEADING_MAX_LEN, "...", ratio=1.0)
44
45 +
46 def _truncate_progress(text: str | None) -> str:
47 if text is None:
48 return ""
49 return truncate_text_by_ratio(str(text), PROGRESS_MAX_LEN, "...", ratio=1.0)
50
51 +
52 def _truncate_key(text: str) -> str:
53 return truncate_text_by_ratio(str(text), KEY_MAX_LEN, "...", ratio=1.0)
54
48 -def _truncate_value(val: Any) -> Any:
55 +
56 +def _truncate_value(val: T) -> T:
57 # If dict, recursively truncate each value
58 if isinstance(val, dict):
59 for k in list(val.keys()):
52 - val[k] = _truncate_value(val[k])
60 + val[_truncate_key(k)] = _truncate_value(val[k])
61 + del val[k]
62 return val
63 # If list or tuple, recursively truncate each item
64 if isinstance(val, list):
@@ -57,7 +66,7 @@ def _truncate_value(val: Any) -> Any:
66 val[i] = _truncate_value(val[i])
67 return val
68 if isinstance(val, tuple):
60 - return tuple(_truncate_value(x) for x in val)
69 + return tuple(_truncate_value(x) for x in val) # type: ignore
70
71 # Convert non-str values to json for consistent length measurement
72 if isinstance(val, str):
@@ -77,6 +86,7 @@ def _truncate_value(val: Any) -> Any:
86 truncated = truncate_text_by_ratio(raw, VALUE_MAX_LEN, replacement, ratio=0.3)
87 return truncated
88
89 +
90 def _truncate_content(text: str | None) -> str:
91 if text is None:
92 return ""
@@ -95,14 +105,35 @@ def _truncate_content(text: str | None) -> str:
105 removed = new_removed
106 return truncated
107
108 +
109 +def _mask_recursive(obj: T) -> T:
110 + """Recursively mask secrets in nested objects."""
111 + try:
112 + from python.helpers.secrets import SecretsManager
113 +
114 + secrets_mgr = SecretsManager.get_instance()
115 +
116 + if isinstance(obj, str):
117 + return secrets_mgr.mask_values(obj)
118 + elif isinstance(obj, dict):
119 + return {k: _mask_recursive(v) for k, v in obj.items()} # type: ignore
120 + elif isinstance(obj, list):
121 + return [_mask_recursive(item) for item in obj] # type: ignore
122 + else:
123 + return obj
124 + except Exception as _e:
125 + # If masking fails, return original object
126 + return obj
127 +
128 +
129 @dataclass
130 class LogItem:
131 log: "Log"
132 no: int
133 type: str
103 - heading: str
104 - content: str
105 - temp: bool
134 + heading: str = ""
135 + content: str = ""
136 + temp: bool = False
137 update_progress: Optional[ProgressUpdate] = "persistent"
138 kvps: Optional[OrderedDict] = None # Use OrderedDict for kvps
139 id: Optional[str] = None # Add id field
@@ -179,41 +210,26 @@ class Log:
210 id: Optional[str] = None, # Add id parameter
211 **kwargs,
212 ) -> LogItem:
182 - # Truncate heading and content
183 - heading = _truncate_heading(heading)
184 - content = _truncate_content(content)
185 -
186 - # Truncate kvps
187 - if kvps is not None:
188 - kvps = copy.deepcopy(kvps) # deep copy to avoid modifying the original kvps
189 - kvps = OrderedDict({
190 - _truncate_key(k): _truncate_value(v) for k, v in kvps.items()
191 - })
192 - # Apply truncation to kwargs merged into kvps later
193 - if kwargs is not None:
194 - kwargs = copy.deepcopy(kwargs) # deep copy to avoid modifying the original kwargs
195 - kwargs = { _truncate_key(k): _truncate_value(v) for k, v in (kwargs or {}).items() }
196 -
197 - # Ensure kvps is OrderedDict even if None
198 - if kvps is None:
199 - kvps = OrderedDict()
213
214 + # add a minimal item to the log
215 item = LogItem(
216 log=self,
217 no=len(self.logs),
218 type=type,
205 - heading=heading or "",
206 - content=content or "",
207 - kvps=OrderedDict({**(kvps or {}), **(kwargs or {})}),
208 - update_progress=(
209 - update_progress if update_progress is not None else "persistent"
210 - ),
211 - temp=temp if temp is not None else False,
212 - id=id, # Pass id to LogItem
219 )
220 self.logs.append(item)
215 - self.updates += [item.no]
216 - self._update_progress_from_item(item)
221 +
222 + # and update it (to have just one implementation)
223 + self._update_item(
224 + no=item.no,
225 + type=type,
226 + heading=heading,
227 + content=content,
228 + kvps=kvps,
229 + temp=temp,
230 + update_progress=update_progress,
231 + id=id,
232 + )
233 return item
234
235 def _update_item(
@@ -228,41 +244,44 @@ class Log:
244 **kwargs,
245 ):
246 item = self.logs[no]
231 - # Apply truncation where necessary
247 +
248 + # adjust all content before processing
249 + if heading is not None:
250 + heading = _mask_recursive(heading)
251 + heading = _truncate_heading(heading)
252 + item.heading = heading
253 + if content is not None:
254 + content = _mask_recursive(content)
255 + content = _truncate_content(content)
256 + item.content = content
257 + if kvps is not None:
258 + kvps = OrderedDict(copy.deepcopy(kvps))
259 + kvps = _mask_recursive(kvps)
260 + kvps = _truncate_value(kvps)
261 + item.kvps = kvps
262 + else:
263 + item.kvps = OrderedDict()
264 + if kwargs:
265 + kwargs = copy.deepcopy(kwargs)
266 + kwargs = _mask_recursive(kwargs)
267 + item.kvps.update(kwargs)
268 +
269 if type is not None:
270 item.type = type
271
272 if update_progress is not None:
273 item.update_progress = update_progress
274
238 - if heading is not None:
239 - item.heading = _truncate_heading(heading)
240 -
241 - if content is not None:
242 - item.content = _truncate_content(content)
243 -
244 - if kvps is not None:
245 - kvps = copy.deepcopy(kvps) # deep copy to avoid modifying the original kvps
246 - item.kvps = OrderedDict({
247 - _truncate_key(k): _truncate_value(v) for k, v in kvps.items()
248 - }) # Ensure order
249 -
275 if temp is not None:
276 item.temp = temp
277
253 - if kwargs:
254 - kwargs = copy.deepcopy(kwargs) # deep copy to avoid modifying the original kwargs
255 - if item.kvps is None:
256 - item.kvps = OrderedDict() # Ensure kvps is an OrderedDict
257 - for k, v in kwargs.items():
258 - item.kvps[_truncate_key(k)] = _truncate_value(v)
259 -
260 -
278 self.updates += [item.no]
279 self._update_progress_from_item(item)
280
281 def set_progress(self, progress: str, no: int = 0, active: bool = True):
265 - self.progress = _truncate_progress(progress)
282 + progress = _mask_recursive(progress)
283 + progress = _truncate_progress(progress)
284 + self.progress = progress
285 if not no:
286 no = len(self.logs)
287 self.progress_no = no
@@ -299,4 +318,3 @@ class Log:
318 item.heading,
319 (item.no if item.update_progress == "persistent" else -1),
320 )
302 -
python/helpers/print_style.py
+10
@@ -92,6 +92,16 @@ class PrintStyle:
92
93 def get(self, *args, sep=' ', **kwargs):
94 text = sep.join(map(str, args))
95 +
96 + # Automatically mask secrets in all print output
97 + try:
98 + from python.helpers.secrets import SecretsManager
99 + secrets_mgr = SecretsManager.get_instance()
100 + text = secrets_mgr.mask_values(text)
101 + except Exception:
102 + # If masking fails, proceed without masking to avoid breaking functionality
103 + pass
104 +
105 return text, self._get_styled_text(text), self._get_html_styled_text(text)
106
107 def print(self, *args, sep=' ', **kwargs):
python/helpers/secrets.py new
+464
@@ -0,0 +1,464 @@
1 +import re
2 +import threading
3 +import time
4 +import os
5 +from io import StringIO
6 +from dataclasses import dataclass
7 +from typing import Dict, Optional, List, Literal, Set
8 +from dotenv.parser import parse_stream
9 +from python.helpers.errors import RepairableException
10 +from python.helpers import files
11 +
12 +
13 +@dataclass
14 +class EnvLine:
15 + raw: str
16 + type: Literal["pair", "comment", "blank", "other"]
17 + key: Optional[str] = None
18 + value: Optional[str] = None
19 + key_part: Optional[str] = None # original left side including whitespace up to '='
20 + inline_comment: Optional[str] = None # preserves trailing inline comment including leading spaces and '#'
21 +
22 +
23 +class StreamingSecretsFilter:
24 + """Stateful streaming filter that masks secrets on the fly.
25 +
26 + - Replaces full secret values with placeholders §§KEY§§ when detected.
27 + - Holds the longest suffix of the current buffer that matches any secret prefix
28 + (with minimum trigger length of 3) to avoid leaking partial secrets across chunks.
29 + - On finalize(), any unresolved partial is masked with '***'.
30 + """
31 +
32 + def __init__(self, key_to_value: Dict[str, str], min_trigger: int = 3):
33 + self.min_trigger = max(1, int(min_trigger))
34 + # Map value -> key for placeholder construction
35 + self.value_to_key: Dict[str, str] = {
36 + v: k for k, v in key_to_value.items() if isinstance(v, str) and v
37 + }
38 + # Only keep non-empty values
39 + self.secret_values: List[str] = [v for v in self.value_to_key.keys() if v]
40 + # Precompute all prefixes for quick suffix matching
41 + self.prefixes: Set[str] = set()
42 + for v in self.secret_values:
43 + for i in range(self.min_trigger, len(v) + 1):
44 + self.prefixes.add(v[:i])
45 + self.max_len: int = max((len(v) for v in self.secret_values), default=0)
46 +
47 + # Internal buffer of pending text that is not safe to flush yet
48 + self.pending: str = ""
49 +
50 + def _replace_full_values(self, text: str) -> str:
51 + """Replace all full secret values with placeholders in the given text."""
52 + # Sort by length desc to avoid partial overlaps
53 + for val in sorted(self.secret_values, key=len, reverse=True):
54 + if not val:
55 + continue
56 + key = self.value_to_key.get(val, "")
57 + if key:
58 + text = text.replace(val, f"§§{key}§§")
59 + return text
60 +
61 + def _longest_suffix_prefix(self, text: str) -> int:
62 + """Return length of longest suffix of text that is a known secret prefix.
63 + Returns 0 if none found (or only shorter than min_trigger)."""
64 + max_check = min(len(text), self.max_len)
65 + for length in range(max_check, self.min_trigger - 1, -1):
66 + suffix = text[-length:]
67 + if suffix in self.prefixes:
68 + return length
69 + return 0
70 +
71 + def process_chunk(self, chunk: str) -> str:
72 + if not chunk:
73 + return ""
74 +
75 + self.pending += chunk
76 +
77 + # Replace any full secret occurrences first
78 + self.pending = self._replace_full_values(self.pending)
79 +
80 + # Determine the longest suffix that could still form a secret
81 + hold_len = self._longest_suffix_prefix(self.pending)
82 + if hold_len > 0:
83 + # Flush everything except the hold suffix
84 + emit = self.pending[:-hold_len]
85 + self.pending = self.pending[-hold_len:]
86 + else:
87 + # Safe to flush everything
88 + emit = self.pending
89 + self.pending = ""
90 +
91 + return emit
92 +
93 + def finalize(self) -> str:
94 + """Flush any remaining buffered text. If pending contains an unresolved partial
95 + (i.e., a prefix of a secret >= min_trigger), mask it with *** to avoid leaks."""
96 + if not self.pending:
97 + return ""
98 +
99 + hold_len = self._longest_suffix_prefix(self.pending)
100 + if hold_len > 0:
101 + safe = self.pending[:-hold_len]
102 + # Mask unresolved partial
103 + result = safe + "***"
104 + else:
105 + result = self.pending
106 + self.pending = ""
107 + return result
108 +
109 +
110 +class SecretsManager:
111 + SECRETS_FILE = "tmp/secrets.env"
112 + PLACEHOLDER_PATTERN = r"§§([A-Z_][A-Z0-9_]*)§§"
113 + MASK_VALUE = "***"
114 +
115 + _instance: Optional["SecretsManager"] = None
116 + _secrets_cache: Optional[Dict[str, str]] = None
117 + _last_raw_text: Optional[str] = None
118 +
119 + @classmethod
120 + def get_instance(cls) -> "SecretsManager":
121 + if cls._instance is None:
122 + cls._instance = cls()
123 + return cls._instance
124 +
125 + def __init__(self):
126 + self._lock = threading.RLock()
127 + # instance-level override for secrets file
128 + self._secrets_file_rel = self.SECRETS_FILE
129 +
130 +
131 + def set_secrets_file(self, relative_path: str):
132 + """Override the relative secrets file location (useful for tests)."""
133 + with self._lock:
134 + self._secrets_file_rel = relative_path
135 + self.clear_cache()
136 +
137 + def read_secrets_raw(self) -> str:
138 + """Read raw secrets file content from local filesystem (same system)."""
139 + try:
140 + content = files.read_file(self._secrets_file_rel)
141 + self._last_raw_text = content
142 + return content
143 + except Exception:
144 + self._last_raw_text = ""
145 + return ""
146 +
147 + def _write_secrets_raw(self, content: str):
148 + """Write raw secrets file content to local filesystem."""
149 + files.write_file(self._secrets_file_rel, content)
150 +
151 + def load_secrets(self) -> Dict[str, str]:
152 + """Load secrets from file, return key-value dict"""
153 + with self._lock:
154 + if self._secrets_cache is not None:
155 + return self._secrets_cache
156 +
157 + secrets: Dict[str, str] = {}
158 + try:
159 + content = self.read_secrets_raw()
160 + # keep raw snapshot for future save merge without reading again
161 + self._last_raw_text = content
162 + if content:
163 + secrets = self.parse_env_content(content)
164 + except Exception as e:
165 + # On unexpected failure, keep empty cache rather than crash
166 + secrets = {}
167 +
168 + self._secrets_cache = secrets
169 + return secrets
170 +
171 + def save_secrets(self, secrets_content: str):
172 + """Save secrets content to file and update cache"""
173 + with self._lock:
174 + # Ensure write to local filesystem (UTF-8)
175 + self._write_secrets_raw(secrets_content)
176 + # Update cache
177 + self._secrets_cache = self.parse_env_content(secrets_content)
178 + # Update raw snapshot
179 + self._last_raw_text = secrets_content
180 +
181 + def save_secrets_with_merge(self, submitted_content: str):
182 + """Merge submitted content with existing file preserving comments, order and supporting deletion.
183 + - Existing keys keep their value when submitted as MASK_VALUE (***).
184 + - Keys present in existing but omitted from submitted are deleted.
185 + - New keys with non-masked values are appended at the end.
186 + """
187 + with self._lock:
188 + # Prefer in-memory snapshot to avoid disk reads during save
189 + if self._last_raw_text is not None:
190 + existing_text = self._last_raw_text
191 + else:
192 + try:
193 + existing_text = self.read_secrets_raw()
194 + except Exception as e:
195 + # If read fails and submitted contains masked values, abort to avoid losing values/comments
196 + if self.MASK_VALUE in submitted_content:
197 + raise RepairableException(
198 + "Saving secrets failed because existing secrets could not be read to preserve masked values and comments. Please retry."
199 + ) from e
200 + # No masked values, safe to treat as new file
201 + existing_text = ""
202 + merged_lines = self._merge_env(existing_text, submitted_content)
203 + merged_text = self._serialize_env_lines(merged_lines)
204 + self.save_secrets(merged_text)
205 +
206 + def get_keys(self) -> List[str]:
207 + """Get list of secret keys"""
208 + secrets = self.load_secrets()
209 + return list(secrets.keys())
210 +
211 + def get_secrets_for_prompt(self) -> str:
212 + """Get formatted string of secret keys for system prompt"""
213 + content = self._last_raw_text or self.read_secrets_raw()
214 + if not content:
215 + return ""
216 +
217 + env_lines = self.parse_env_lines(content)
218 + return self._serialize_env_lines(env_lines, with_values=False, with_comments=True, with_blank=True, with_other=True)
219 +
220 +
221 +
222 + def create_streaming_filter(self) -> 'StreamingSecretsFilter':
223 + """Create a streaming-aware secrets filter snapshotting current secret values."""
224 + return StreamingSecretsFilter(self.load_secrets())
225 +
226 + def replace_placeholders(self, text: str) -> str:
227 + """Replace secret placeholders with actual values"""
228 + if not text:
229 + return text
230 +
231 + secrets = self.load_secrets()
232 +
233 + def replacer(match):
234 + key = match.group(1)
235 + if key in secrets:
236 + return secrets[key]
237 + else:
238 + # Try common variations for user convenience
239 + variations = self._get_key_variations(key)
240 + for variation in variations:
241 + if variation in secrets:
242 + return secrets[variation]
243 +
244 + # Show both the original key and available alternatives
245 + available_keys = ', '.join(secrets.keys())
246 + suggested_variations = [f"§§{var}§§" for var in variations if var in secrets]
247 +
248 + error_msg = f"Secret placeholder '§§{key}§§' not found in secrets store.\n"
249 + error_msg += f"Available secrets: {available_keys}"
250 +
251 + if suggested_variations:
252 + error_msg += f"\nDid you mean: {', '.join(suggested_variations)}?"
253 +
254 + raise RepairableException(error_msg)
255 +
256 + return re.sub(self.PLACEHOLDER_PATTERN, replacer, text)
257 +
258 + def _get_key_variations(self, key: str) -> List[str]:
259 + """Generate common variations of a key name for better UX"""
260 + variations = []
261 +
262 + # Common API key variations
263 + if key == "OPENAI_API_KEY":
264 + variations.extend(["API_KEY_OPENAI", "OPENAI_KEY", "OPENAI"])
265 + elif key == "API_KEY_OPENAI":
266 + variations.extend(["OPENAI_API_KEY", "OPENAI_KEY", "OPENAI"])
267 + elif key == "ANTHROPIC_API_KEY":
268 + variations.extend(["API_KEY_ANTHROPIC", "ANTHROPIC_KEY", "ANTHROPIC"])
269 + elif key == "API_KEY_ANTHROPIC":
270 + variations.extend(["ANTHROPIC_API_KEY", "ANTHROPIC_KEY", "ANTHROPIC"])
271 + elif key == "GOOGLE_API_KEY":
272 + variations.extend(["API_KEY_GOOGLE", "GOOGLE_KEY", "GOOGLE"])
273 + elif key == "API_KEY_GOOGLE":
274 + variations.extend(["GOOGLE_API_KEY", "GOOGLE_KEY", "GOOGLE"])
275 +
276 + # General pattern variations
277 + if "_API_KEY" in key:
278 + # Convert SERVICE_API_KEY to API_KEY_SERVICE
279 + service = key.replace("_API_KEY", "")
280 + variations.append(f"API_KEY_{service}")
281 + elif "API_KEY_" in key:
282 + # Convert API_KEY_SERVICE to SERVICE_API_KEY
283 + service = key.replace("API_KEY_", "")
284 + variations.append(f"{service}_API_KEY")
285 +
286 + return variations
287 +
288 + def mask_values(self, text: str) -> str:
289 + """Replace actual secret values with placeholders in text"""
290 + if not text:
291 + return text
292 +
293 + secrets = self.load_secrets()
294 + result = text
295 +
296 + # Sort by length (longest first) to avoid partial replacements
297 + for key, value in sorted(secrets.items(), key=lambda x: len(x[1]), reverse=True):
298 + if value and len(value.strip()) > 0:
299 + result = result.replace(value, f"§§{key}§§")
300 +
301 + return result
302 +
303 + def get_masked_content(self, content: str) -> str:
304 + """Get content with values masked for frontend display (preserves comments and unrecognized lines)"""
305 + if not content:
306 + return ""
307 +
308 + # Parse content for known keys using python-dotenv
309 + secrets_map = self.parse_env_content(content)
310 + env_lines = self.parse_env_lines(content)
311 + # Replace values with mask for keys present
312 + for ln in env_lines:
313 + if ln.type == "pair" and ln.key is not None:
314 + if ln.key in secrets_map and secrets_map[ln.key] != "":
315 + ln.value = self.MASK_VALUE
316 + return self._serialize_env_lines(env_lines)
317 +
318 + def parse_env_content(self, content: str) -> Dict[str, str]:
319 + """Parse .env format content into key-value dict using python-dotenv."""
320 + env: Dict[str, str] = {}
321 + for binding in parse_stream(StringIO(content)):
322 + if binding.key and not binding.error:
323 + env[binding.key] = binding.value or ""
324 + return env
325 +
326 + # Backward-compatible alias for callers using the old private method name
327 + def _parse_env_content(self, content: str) -> Dict[str, str]:
328 + return self.parse_env_content(content)
329 +
330 + def clear_cache(self):
331 + """Clear the secrets cache"""
332 + with self._lock:
333 + self._secrets_cache = None
334 +
335 + # ---------------- Internal helpers for parsing/merging ----------------
336 +
337 + def parse_env_lines(self, content: str) -> List[EnvLine]:
338 + """Parse env file into EnvLine objects using python-dotenv, preserving comments and order.
339 + We reconstruct key_part and inline_comment based on the original string.
340 + """
341 + lines: List[EnvLine] = []
342 + for binding in parse_stream(StringIO(content)):
343 + orig = getattr(binding, "original", None)
344 + raw = getattr(orig, "string", "") if orig is not None else ""
345 + if binding.key and not binding.error:
346 + # Determine key_part and inline_comment from original line
347 + line_text = raw.rstrip("\n")
348 + # Fallback to composed key_part if original not available
349 + if "=" in line_text:
350 + left, right = line_text.split("=", 1)
351 + key_part = left
352 + else:
353 + key_part = binding.key
354 + right = ""
355 + # Try to extract inline comment by scanning right side to comment start, respecting quotes
356 + in_single = False
357 + in_double = False
358 + esc = False
359 + comment_index = None
360 + for i, ch in enumerate(right):
361 + if esc:
362 + esc = False
363 + continue
364 + if ch == "\\":
365 + esc = True
366 + continue
367 + if ch == "'" and not in_double:
368 + in_single = not in_single
369 + continue
370 + if ch == '"' and not in_single:
371 + in_double = not in_double
372 + continue
373 + if ch == "#" and not in_single and not in_double:
374 + comment_index = i
375 + break
376 + inline_comment = None
377 + if comment_index is not None:
378 + inline_comment = right[comment_index:]
379 + lines.append(
380 + EnvLine(
381 + raw=line_text,
382 + type="pair",
383 + key=binding.key,
384 + value=binding.value or "",
385 + key_part=key_part,
386 + inline_comment=inline_comment,
387 + )
388 + )
389 + else:
390 + # Comment, blank, or other lines
391 + raw_line = raw.rstrip("\n")
392 + if raw_line.strip() == "":
393 + lines.append(EnvLine(raw=raw_line, type="blank"))
394 + elif raw_line.lstrip().startswith("#"):
395 + lines.append(EnvLine(raw=raw_line, type="comment"))
396 + else:
397 + lines.append(EnvLine(raw=raw_line, type="other"))
398 + return lines
399 +
400 + def _serialize_env_lines(self, lines: List[EnvLine], with_values=True, with_comments=True, with_blank=True, with_other=True) -> str:
401 + out: List[str] = []
402 + for ln in lines:
403 + if ln.type == "pair" and ln.key is not None:
404 + left = ln.key_part if ln.key_part is not None else ln.key
405 + val = ln.value if ln.value is not None else ""
406 + comment = ln.inline_comment or ""
407 + out.append(f"{left}={val if with_values else ""}{" " + comment if with_comments and comment else ""}")
408 + elif ln.type == "blank" and with_blank:
409 + out.append(ln.raw)
410 + elif ln.type == "comment" and with_comments:
411 + out.append(ln.raw)
412 + elif ln.type == "other" and with_other:
413 + out.append(ln.raw)
414 + return "\n".join(out)
415 +
416 + def _merge_env(self, existing_text: str, submitted_text: str) -> List[EnvLine]:
417 + """Merge using submitted content as the base to preserve its comments and structure.
418 + Behavior:
419 + - Iterate submitted lines in order and keep them (including comments/blanks/other).
420 + - For pair lines:
421 + - If key exists in existing and submitted value is MASK_VALUE (***), use existing value.
422 + - If key is new and value is MASK_VALUE, skip (ignore masked-only additions).
423 + - Otherwise, use submitted value as-is.
424 + - Keys present only in existing and not in submitted are deleted (not added).
425 + This preserves comments and arbitrary lines from the submitted content and persists them.
426 + """
427 + existing_lines = self.parse_env_lines(existing_text)
428 + submitted_lines = self.parse_env_lines(submitted_text)
429 +
430 + existing_pairs: Dict[str, EnvLine] = {
431 + ln.key: ln for ln in existing_lines if ln.type == "pair" and ln.key is not None
432 + }
433 +
434 + merged: List[EnvLine] = []
435 + for sub in submitted_lines:
436 + if sub.type != "pair" or sub.key is None:
437 + # Preserve submitted comments/blanks/other verbatim
438 + merged.append(sub)
439 + continue
440 +
441 + key = sub.key
442 + submitted_val = sub.value or ""
443 +
444 + if key in existing_pairs and submitted_val == self.MASK_VALUE:
445 + # Replace mask with existing value, keep submitted key formatting
446 + existing_val = existing_pairs[key].value or ""
447 + merged.append(
448 + EnvLine(
449 + raw=f"{(sub.key_part or key)}={existing_val}",
450 + type="pair",
451 + key=key,
452 + value=existing_val,
453 + key_part=sub.key_part or key,
454 + inline_comment=sub.inline_comment,
455 + )
456 + )
457 + elif key not in existing_pairs and submitted_val == self.MASK_VALUE:
458 + # Masked-only new key -> ignore
459 + continue
460 + else:
461 + # Use submitted value as-is
462 + merged.append(sub)
463 +
464 + return merged
python/helpers/settings.py
+40 -2
@@ -11,6 +11,7 @@ from python.helpers import runtime, whisper, defer, git
11 from . import files, dotenv
12 from python.helpers.print_style import PrintStyle
13 from python.helpers.providers import get_providers
14 +from python.helpers.secrets import SecretsManager
15
16
17 class Settings(TypedDict):
@@ -99,8 +100,8 @@ class Settings(TypedDict):
100 mcp_server_token: str
101
102 a2a_server_enabled: bool
102 -
103
104 + secrets: str
105
106 class PartialSettings(Settings, total=False):
107 pass
@@ -1044,6 +1045,34 @@ def convert_out(settings: Settings) -> SettingsOutput:
1045 "tab": "mcp",
1046 }
1047
1048 + # Secrets section
1049 + secrets_fields: list[SettingsField] = []
1050 +
1051 + secrets_manager = SecretsManager.get_instance()
1052 + current_secrets = ""
1053 + try:
1054 + current_secrets = secrets_manager.read_secrets_raw()
1055 + except Exception:
1056 + current_secrets = ""
1057 +
1058 + masked_secrets = secrets_manager.get_masked_content(current_secrets) if current_secrets else ""
1059 +
1060 + secrets_fields.append({
1061 + "id": "secrets",
1062 + "title": "Secrets Store",
1063 + "description": "Store secrets and credentials in .env format. Use placeholders like §§MY_SECRET§§ in agent responses. Values are shown as *** for security. To update a secret, enter the real value - existing secrets with *** will be preserved.",
1064 + "type": "textarea",
1065 + "value": masked_secrets,
1066 + })
1067 +
1068 + secrets_section: SettingsSection = {
1069 + "id": "secrets",
1070 + "title": "Secrets Management",
1071 + "description": "Manage secrets and credentials that agents can reference without exposing values in chat history.",
1072 + "fields": secrets_fields,
1073 + "tab": "external",
1074 + }
1075 +
1076 mcp_server_fields: list[SettingsField] = []
1077
1078 mcp_server_fields.append(
@@ -1165,6 +1194,7 @@ def convert_out(settings: Settings) -> SettingsOutput:
1194 speech_section,
1195 api_keys_section,
1196 auth_section,
1197 + secrets_section,
1198 mcp_client_section,
1199 mcp_server_section,
1200 a2a_section,
@@ -1204,11 +1234,17 @@ def convert_in(settings: dict) -> Settings:
1234 current[field["id"]] = _env_to_dict(field["value"])
1235 elif field["id"].startswith("api_key_"):
1236 current["api_keys"][field["id"]] = field["value"]
1237 + elif field["id"] == "secrets":
1238 + # Handle secrets separately - merge with existing preserving comments/order and support deletions
1239 + secrets_manager = SecretsManager.get_instance()
1240 + submitted_content = field["value"]
1241 + secrets_manager.save_secrets_with_merge(submitted_content)
1242 + secrets_manager.clear_cache() # Clear cache to reload secrets
1243 + current[field["id"]] = field["value"]
1244 else:
1245 current[field["id"]] = field["value"]
1246 return current
1247
1211 -
1248 def get_settings() -> Settings:
1249 global _settings
1250 if not _settings:
@@ -1298,6 +1334,7 @@ def _remove_sensitive_settings(settings: Settings):
1334 settings["rfc_password"] = ""
1335 settings["root_password"] = ""
1336 settings["mcp_server_token"] = ""
1337 + settings["secrets"] = ""
1338
1339
1340 def _write_sensitive_settings(settings: Settings):
@@ -1390,6 +1427,7 @@ def get_default_settings() -> Settings:
1427 mcp_server_enabled=False,
1428 mcp_server_token=create_auth_token(),
1429 a2a_server_enabled=False,
1430 + secrets="",
1431 )
1432
1433
python/tools/browser_agent.py
+58 -93
@@ -1,16 +1,15 @@
1 import asyncio
2 import time
3 -from typing import Optional
3 +from typing import Optional, cast
4 from agent import Agent, InterventionException
5 from pathlib import Path
6
7 -
8 -import models
7 from python.helpers.tool import Tool, Response
8 from python.helpers import files, defer, persist_chat, strings
11 -from python.helpers.browser_use import browser_use
9 +from python.helpers.browser_use import browser_use # type: ignore[attr-defined]
10 from python.helpers.print_style import PrintStyle
11 from python.helpers.playwright import ensure_playwright_binary
12 +from python.helpers.secrets import SecretsManager
13 from python.extensions.message_loop_start._10_iteration_no import get_iter_no
14 from pydantic import BaseModel
15 import uuid
@@ -28,6 +27,7 @@ class State:
27 self.browser_session: Optional[browser_use.BrowserSession] = None
28 self.task: Optional[defer.DeferredTask] = None
29 self.use_agent: Optional[browser_use.Agent] = None
30 + self.secrets_dict: Optional[dict[str, str]] = None
31 self.iter_no = 0
32
33 def __del__(self):
@@ -67,13 +67,13 @@ class State:
67 )
68 )
69
70 - await self.browser_session.start()
70 + await self.browser_session.start() if self.browser_session else None
71 # self.override_hooks()
72
73 # Add init script to the browser session
74 - if self.browser_session.browser_context:
74 + if self.browser_session and self.browser_session.browser_context:
75 js_override = files.get_abs_path("lib/browser/init_override.js")
76 - await self.browser_session.browser_context.add_init_script(path=js_override)
76 + await self.browser_session.browser_context.add_init_script(path=js_override) if self.browser_session else None
77
78 def start_task(self, task: str):
79 if self.task and self.task.is_alive():
@@ -84,7 +84,7 @@ class State:
84 )
85 if self.agent.context.task:
86 self.agent.context.task.add_child_task(self.task, terminate_thread=True)
87 - self.task.start_task(self._run_task, task)
87 + self.task.start_task(self._run_task, task) if self.task else None
88 return self.task
89
90 def kill_task(self):
@@ -97,7 +97,7 @@ class State:
97
98 loop = asyncio.new_event_loop()
99 asyncio.set_event_loop(loop)
100 - loop.run_until_complete(self.browser_session.close())
100 + loop.run_until_complete(self.browser_session.close()) if self.browser_session else None
101 loop.close()
102 except Exception as e:
103 PrintStyle().error(f"Error closing browser session: {e}")
@@ -128,6 +128,10 @@ class State:
128 model = self.agent.get_browser_model()
129
130 try:
131 +
132 + secrets_manager = SecretsManager.get_instance()
133 + secrets_dict = secrets_manager.load_secrets()
134 +
135 self.use_agent = browser_use.Agent(
136 task=task,
137 browser_session=self.browser_session,
@@ -138,7 +142,7 @@ class State:
142 ),
143 controller=controller,
144 enable_memory=False, # Disable memory to avoid state conflicts
141 - # available_file_paths=[],
145 + sensitive_data=cast(dict[str, str | dict[str, str]] | None, secrets_dict or {}), # Pass secrets
146 )
147 except Exception as e:
148 raise Exception(
@@ -153,39 +157,17 @@ class State:
157 raise InterventionException("Task cancelled")
158
159 # try:
156 - result = await self.use_agent.run(
157 - max_steps=50, on_step_start=hook, on_step_end=hook
158 - )
160 + result = None
161 + if self.use_agent:
162 + result = await self.use_agent.run(
163 + max_steps=50, on_step_start=hook, on_step_end=hook
164 + )
165 return result
160 - # finally:
161 - # # if self.browser_session:
162 - # # try:
163 - # # await self.browser_session.close()
164 - # # except Exception as e:
165 - # # PrintStyle().error(f"Error closing browser session in task cleanup: {e}")
166 - # # finally:
167 - # # self.browser_session = None
168 - # pass
169 -
170 - # def override_hooks(self):
171 - # def override_hook(func):
172 - # async def wrapper(*args, **kwargs):
173 - # await self.agent.wait_if_paused()
174 - # if self.iter_no != get_iter_no(self.agent):
175 - # raise InterventionException("Task cancelled")
176 - # return await func(*args, **kwargs)
177 -
178 - # return wrapper
179 -
180 - # if self.browser_session and hasattr(self.browser_session, "remove_highlights"):
181 - # self.browser_session.remove_highlights = override_hook(
182 - # self.browser_session.remove_highlights
183 - # )
166
167 async def get_page(self):
168 if self.use_agent and self.browser_session:
169 try:
188 - return await self.use_agent.browser_session.get_current_page()
170 + return await self.use_agent.browser_session.get_current_page() if self.use_agent.browser_session else None
171 except Exception:
172 # Browser session might be closed or invalid
173 return None
@@ -194,6 +176,8 @@ class State:
176 async def get_selector_map(self):
177 """Get the selector map for the current page state."""
178 if self.use_agent:
179 + await self.use_agent.browser_session.get_state_summary(cache_clickable_elements_hashes=True) if self.use_agent.browser_session else None
180 + return await self.use_agent.browser_session.get_selector_map() if self.use_agent.browser_session else None
181 await self.use_agent.browser_session.get_state_summary(
182 cache_clickable_elements_hashes=True
183 )
@@ -207,25 +191,25 @@ class BrowserAgent(Tool):
191 self.guid = str(uuid.uuid4())
192 reset = str(reset).lower().strip() == "true"
193 await self.prepare_state(reset=reset)
210 - task = self.state.start_task(message)
194 + task = self.state.start_task(message) if self.state else None
195
196 # wait for browser agent to finish and update progress with timeout
197 timeout_seconds = 300 # 5 minute timeout
198 start_time = time.time()
199
200 fail_counter = 0
217 - while not task.is_ready():
201 + while not task.is_ready() if task else False:
202 # Check for timeout to prevent infinite waiting
203 if time.time() - start_time > timeout_seconds:
204 PrintStyle().warning(
221 - f"Browser agent task timeout after {timeout_seconds} seconds, forcing completion"
205 + self._mask(f"Browser agent task timeout after {timeout_seconds} seconds, forcing completion")
206 )
207 break
208
209 await self.agent.handle_intervention()
210 await asyncio.sleep(1)
211 try:
228 - if task.is_ready(): # otherwise get_update hangs
212 + if task and task.is_ready(): # otherwise get_update hangs
213 break
214 try:
215 update = await asyncio.wait_for(self.get_update(), timeout=10)
@@ -233,42 +217,42 @@ class BrowserAgent(Tool):
217 except asyncio.TimeoutError:
218 fail_counter += 1
219 PrintStyle().warning(
236 - f"browser_agent.get_update timed out ({fail_counter}/3)"
220 + self._mask(f"browser_agent.get_update timed out ({fail_counter}/3)")
221 )
222 if fail_counter >= 3:
223 PrintStyle().warning(
240 - "3 consecutive browser_agent.get_update timeouts, breaking loop"
224 + self._mask("3 consecutive browser_agent.get_update timeouts, breaking loop")
225 )
226 break
227 continue
244 - log = update.get("log", get_use_agent_log(None))
245 - self.update_progress("\n".join(log))
228 + update_log = update.get("log", get_use_agent_log(None))
229 + self.update_progress("\n".join(update_log))
230 screenshot = update.get("screenshot", None)
231 if screenshot:
232 self.log.update(screenshot=screenshot)
233 except Exception as e:
250 - PrintStyle().error(f"Error getting update: {str(e)}")
234 + PrintStyle().error(self._mask(f"Error getting update: {str(e)}"))
235
252 - if not task.is_ready():
253 - PrintStyle().warning("browser_agent.get_update timed out, killing the task")
254 - self.state.kill_task()
236 + if task and not task.is_ready():
237 + PrintStyle().warning(self._mask("browser_agent.get_update timed out, killing the task"))
238 + self.state.kill_task() if self.state else None
239 return Response(
256 - message="Browser agent task timed out, not output provided.",
240 + message=self._mask("Browser agent task timed out, not output provided."),
241 break_loop=False,
242 )
243
244 # final progress update
261 - if self.state.use_agent:
262 - log = get_use_agent_log(self.state.use_agent)
263 - self.update_progress("\n".join(log))
245 + if self.state and self.state.use_agent:
246 + log_final = get_use_agent_log(self.state.use_agent)
247 + self.update_progress("\n".join(log_final))
248
249 # collect result with error handling
250 try:
267 - result = await task.result()
251 + result = await task.result() if task else None
252 except Exception as e:
269 - PrintStyle().error(f"Error getting browser agent task result: {str(e)}")
253 + PrintStyle().error(self._mask(f"Error getting browser agent task result: {str(e)}"))
254 # Return a timeout response if task.result() fails
271 - answer_text = f"Browser agent task failed to return result: {str(e)}"
255 + answer_text = self._mask(f"Browser agent task failed to return result: {str(e)}")
256 self.log.update(answer=answer_text)
257 return Response(message=answer_text, break_loop=False)
258 # finally:
@@ -277,7 +261,7 @@ class BrowserAgent(Tool):
261 # pass
262
263 # Check if task completed successfully
280 - if result.is_done():
264 + if result and result.is_done():
265 answer = result.final_result()
266 try:
267 if answer and isinstance(answer, str) and answer.strip():
@@ -295,13 +279,16 @@ class BrowserAgent(Tool):
279 )
280 else:
281 # Task hit max_steps without calling done()
298 - urls = result.urls()
282 + urls = result.urls() if result else []
283 current_url = urls[-1] if urls else "unknown"
284 answer_text = (
285 f"Task reached step limit without completion. Last page: {current_url}. "
286 f"The browser agent may need clearer instructions on when to finish."
287 )
288
289 + # Mask answer for logs and response
290 + answer_text = self._mask(answer_text)
291 +
292 # update the log (without screenshot path here, user can click)
293 self.log.update(answer=answer_text)
294
@@ -330,8 +317,8 @@ class BrowserAgent(Tool):
317
318 result = {}
319 agent = self.agent
333 - ua = self.state.use_agent
334 - page = await self.state.get_page()
320 + ua = self.state.use_agent if self.state else None
321 + page = await self.state.get_page() if self.state else None
322
323 if ua and page:
324 try:
@@ -340,36 +327,7 @@ class BrowserAgent(Tool):
327
328 # await agent.wait_if_paused() # no need here
329
343 - log = []
344 -
345 - # for message in ua.message_manager.get_messages():
346 - # if message.type == "system":
347 - # continue
348 - # if message.type == "ai":
349 - # try:
350 - # data = json.loads(message.content) # type: ignore
351 - # cs = data.get("current_state")
352 - # if cs:
353 - # log.append("AI:" + cs["memory"])
354 - # log.append("AI:" + cs["next_goal"])
355 - # except Exception:
356 - # pass
357 - # if message.type == "human":
358 - # content = str(message.content).strip()
359 - # part = content.split("\n", 1)[0].split(",", 1)[0]
360 - # if part:
361 - # if len(part) > 150:
362 - # part = part[:150] + "..."
363 - # log.append("FW:" + part)
364 -
365 - # for hist in ua.state.history.history:
366 - # for res in hist.result:
367 - # log.append(res.extracted_content)
368 - # log = ua.state.history.extracted_content()
369 - # short_log = []
370 - # for item in log:
371 - # first_line = str(item).split("\n", 1)[0][:200]
372 - # short_log.append(first_line)
330 + # Build short activity log
331 result["log"] = get_use_agent_log(ua)
332
333 path = files.get_abs_path(
@@ -382,7 +340,7 @@ class BrowserAgent(Tool):
340 await page.screenshot(path=path, full_page=False, timeout=3000)
341 result["screenshot"] = f"img://{path}&t={str(time.time())}"
342
385 - if self.state.task and not self.state.task.is_ready():
343 + if self.state and self.state.task and not self.state.task.is_ready():
344 await self.state.task.execute_inside(_get_update)
345
346 except Exception:
@@ -399,6 +357,7 @@ class BrowserAgent(Tool):
357 self.agent.set_data("_browser_agent_state", self.state)
358
359 def update_progress(self, text):
360 + text = self._mask(text)
361 short = text.split("\n")[-1]
362 if len(short) > 50:
363 short = short[:50] + "..."
@@ -407,6 +366,12 @@ class BrowserAgent(Tool):
366 self.log.update(progress=text)
367 self.agent.context.log.set_progress(progress)
368
369 + def _mask(self, text: str) -> str:
370 + try:
371 + return SecretsManager.get_instance().mask_values(text or "")
372 + except Exception as e:
373 + return text or ""
374 +
375 # def __del__(self):
376 # if self.state:
377 # self.state.kill_task()
@@ -421,7 +386,7 @@ def get_use_agent_log(use_agent: browser_use.Agent | None):
386 # final results
387 if item.is_done:
388 if item.success:
424 - short_log.append(f"✅ Done")
389 + short_log.append("✅ Done")
390 else:
391 short_log.append(
392 f"❌ Error: {item.error or item.extracted_content or 'Unknown error'}"
webui/public/secrets.svg new
+15
@@ -0,0 +1,15 @@
1 +<?xml version="1.0" encoding="utf-8" ?>
2 +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="1533" height="1142">
3 + <path fill="#383737" transform="scale(1.08416 1.08452)" d="M330.871 418.641C321.7 418.456 312.544 418.272 303.503 418.485C295.628 412.012 270.954 390.444 270.46 381.041C270.359 379.098 270.949 376.731 271.503 374.51C271.774 373.422 272.036 372.369 272.205 371.417C273.994 361.316 269.991 350.751 263.164 343.328C255.33 334.813 245.246 329.652 233.569 329.625C221.613 329.687 210.212 334.676 202.056 343.416C195.499 350.407 192.118 359.792 192.714 369.358C193.49 381.112 199.681 392.313 208.818 399.675C215.997 405.463 225.304 408.813 234.565 407.907C237.084 407.66 239.847 406.644 242.645 405.615C248.098 403.611 253.685 401.557 257.866 405.044C271.401 416.316 279.858 431.269 295.527 440.402C298.32 440.394 302.979 440.299 308.689 440.184C330.588 439.74 367.945 438.983 374.825 441.573C374.937 444.227 375.195 447.78 375.487 451.816C376.526 466.139 378.005 486.538 375.006 494.337C372.726 495.79 369.862 495.684 367.193 495.812C348.318 496.703 329.164 496.483 310.106 496.265C302.109 496.174 294.13 496.082 286.194 496.073C283.912 496.07 280.74 496.129 277.119 496.197C265.168 496.42 248.32 496.735 242.366 495.203C231.788 492.49 233.811 472.301 207.505 468.257C202.179 467.44 196.58 467.664 191.106 467.883C189.658 467.941 188.219 467.999 186.796 468.037C174.381 476.892 163.373 482.592 160.198 499.237C158.299 509.192 161.574 519.722 167.46 527.808C174.061 536.871 185.574 544.325 196.806 545.726C206.201 546.901 217.55 542.624 224.833 536.65C230.995 531.599 233.815 523.889 239.263 518.242C257.797 518.488 276.387 518.266 294.981 518.045C321.476 517.729 347.983 517.413 374.361 518.449C375.006 541.621 375.43 564.797 375.629 587.977C375.635 593.222 375.488 598.467 375.341 603.711C375.189 609.137 375.036 614.562 375.054 619.986C375.057 620.907 375.234 622.995 375.442 625.445C375.87 630.507 376.43 637.119 375.859 638.191C375.642 638.598 375.24 638.876 374.909 639.194C373.561 640.476 372.872 640.918 371.038 641.218C364.291 642.319 351.813 641.941 341.283 641.621C336.896 641.488 332.847 641.365 329.692 641.364C321.36 641.357 312.937 641.492 304.484 641.627C285.57 641.93 266.5 642.235 247.933 640.962C243.48 640.657 240.877 640.286 237.889 636.804C226.614 623.658 224.479 613.942 204.342 611.701C193.193 610.464 181.515 614.901 173.122 622.182C166.539 627.896 159.902 638.417 159.246 647.418C158.474 658.005 165.015 670.554 171.987 678.017C178.49 684.976 187.476 690.928 197.271 691.163C208.103 691.423 220.612 685.228 228.193 677.699C232.139 673.78 236.946 666.878 241.581 664.05C244.871 662.042 299.662 662.343 326.351 662.49C332.145 662.522 336.614 662.546 338.945 662.538C341.494 662.53 344.079 662.481 346.681 662.432C355.701 662.26 364.936 662.085 373.667 663.608C376.215 666.345 375.68 680.703 375.29 691.165C375.131 695.415 374.997 699.022 375.103 700.946C375.208 702.831 375.709 704.688 376.208 706.54C376.785 708.677 377.359 710.806 377.321 712.964C377.281 715.209 376.711 715.845 375.456 717.595C371.695 719.337 357.53 718.924 341.469 718.456C322.527 717.904 300.948 717.274 290.693 720.012C283.844 726.751 261.622 753.475 255.851 755.556C252.294 754.836 249 753.929 245.851 753.062C234.473 749.929 224.991 747.317 211.803 755.932C202.472 762.03 196.369 770.191 194.446 781.326C192.573 792.165 194.466 804.056 201.148 812.995C207.37 821.315 217.49 825.429 227.567 826.724C238.382 828.111 248.79 825.787 257.371 818.916C265.148 812.685 271.007 803.455 271.971 793.451C272.237 790.66 271.644 787.111 271.046 783.534C270.246 778.751 269.439 773.918 270.667 770.783C281.727 761.292 291.302 750.422 302.323 740.948C304.473 740.941 308.102 740.874 312.624 740.79C332.199 740.428 368.505 739.757 373.972 742.274C376.047 745.771 375.488 751.333 374.99 756.282C374.785 758.318 374.591 760.25 374.595 761.893L374.666 826.631C374.636 830.582 374.522 834.577 374.409 838.589C374.116 848.894 373.821 859.312 374.931 869.391C375.903 878.175 378.364 888.51 382.513 896.362C387.078 905.014 397.682 911.995 406.975 914.399C422.188 918.332 439.028 917.828 454.617 917.205C455.081 916.816 455.413 916.167 456.005 916.034C457.564 915.683 459.591 915.825 461.606 915.967C463.16 916.076 464.706 916.184 466.022 916.065C473.14 915.416 479.848 913.586 487.099 913.339C510.394 912.548 533.88 912.826 557.329 913.103C570.691 913.261 584.041 913.419 597.337 913.379L783.938 911.916L923.047 912.124C930.427 912.138 937.884 911.944 945.359 911.75C959.16 911.391 973.023 911.03 986.583 911.973C986.849 911.757 987.096 911.527 987.374 911.324C988.101 910.798 988.276 910.657 988.475 910.589C988.671 910.521 988.89 910.522 989.681 910.294C996.565 908.306 1004.93 900.436 1008.08 894.215C1011.77 886.946 1013.69 876.779 1014.47 868.67C1015.49 858.1 1015.25 847.082 1015 836.208C1014.9 831.686 1014.8 827.189 1014.79 822.76L1014.64 741.116C1048.7 739.835 1083.63 740.493 1117.73 741.235C1124.57 747.96 1142.73 764.137 1147.07 771.128C1148.38 773.249 1148.58 776.289 1148.64 778.697C1148.72 781.666 1148.64 784.439 1148.57 787.071C1148.29 797.576 1148.07 805.842 1157.45 815.425C1164.94 823.087 1176.47 828.12 1187.24 828.08C1198.98 828.036 1207.84 822.729 1215.73 814.515C1223.63 806.292 1227.49 794.975 1227.07 783.69L1227 782.227C1224.45 778.423 1223.55 776.448 1223.22 771.875C1216.47 762.224 1208.35 752.998 1196.19 750.563C1187.27 748.774 1179.72 750.545 1171.3 753.232L1171.19 753.266C1169.26 753.884 1167.22 754.535 1165.16 754.018C1161.14 753.007 1132.94 724.188 1127.25 719.093C1113.47 718.233 1099.5 718.445 1085.61 718.656C1081.1 718.724 1076.59 718.792 1072.11 718.824C1066.5 718.861 1060.89 718.857 1055.27 718.852C1041.7 718.841 1028.12 718.83 1014.58 719.411C1014.72 716.375 1014.64 710.813 1014.56 704.323C1014.35 688.958 1014.08 668.39 1016.31 663.82C1026.81 660.917 1164.94 662.017 1181.49 663.471C1187.82 672.662 1195.34 681.707 1204.85 687.729C1208.66 687.844 1211.37 688.184 1214.87 689.7C1215.93 690.164 1216.99 690.615 1218.04 691.096L1219.22 690.593C1233.77 684.508 1241.81 686.072 1251.97 671.592C1254.52 667.964 1255.99 663.749 1258.66 660.223C1258.67 659.239 1258.67 658.278 1258.68 657.339C1258.76 644.536 1258.82 635.76 1248.71 625.205C1240.78 616.924 1230.48 612.059 1218.92 611.997C1209.96 611.944 1197.51 615.608 1191.35 622.523C1186.41 628.064 1185.95 639.994 1177.32 641.201C1172.32 641.9 1166.9 641.744 1161.65 641.593C1159.69 641.536 1157.76 641.481 1155.89 641.47L1113.08 641.267C1087.77 641.329 1062.46 641.262 1037.15 641.068C1034.76 641.032 1032.35 641.142 1029.94 641.253C1026.79 641.398 1023.65 641.542 1020.55 641.36C1018.41 641.231 1016.76 641.033 1015.02 639.689C1014.29 633.544 1014.44 627.202 1014.59 620.932C1014.65 618.594 1014.7 616.267 1014.71 613.964L1014.62 570.418L1014.54 537.154C1014.52 535.366 1014.35 533.544 1014.18 531.72C1013.94 529.264 1013.71 526.804 1013.85 524.423C1013.99 522.179 1014.53 520.743 1015.89 518.922C1019.11 517.239 1174.42 516.483 1182.32 518.745C1192.94 531.127 1198.78 543.137 1217.34 544.36C1228.01 545.059 1238.06 540.662 1245.86 533.566C1248.72 530.959 1253.28 526.62 1254.74 523.062C1256.09 519.762 1256.42 517.654 1258.74 514.746C1258.88 501.292 1258.51 490.112 1248.51 479.653C1240.79 471.585 1229.96 467.603 1218.9 467.665C1214.5 467.687 1210.09 467.922 1205.69 468.112C1196.82 473.719 1190.74 479.706 1184.35 487.934C1184.05 489.728 1183.82 493.696 1182.3 494.725C1180.26 496.122 1177.48 496.113 1175.08 496.21C1162.37 496.718 1021.68 496.754 1019.77 496.139C1017.77 495.499 1016.13 494.593 1015.31 492.547C1013.56 488.159 1014.68 445.497 1016.61 441.851C1021.99 439.813 1029.23 440.03 1035.64 440.222C1037.5 440.277 1039.29 440.331 1040.94 440.327C1043.74 440.319 1048.52 440.391 1054.45 440.481C1078.3 440.842 1120.64 441.483 1126.72 438.294C1127.08 438.109 1127.43 437.936 1127.78 437.746C1141.09 430.433 1153.27 409.63 1163.63 403.793C1166.55 402.143 1170.75 403.069 1175.91 404.204C1186.45 406.525 1200.98 409.726 1216.76 393.573C1221.88 388.328 1222.53 381.638 1226.85 376.313C1227.39 363.535 1225.76 352.47 1216.81 342.51C1209.14 333.978 1198.71 329.599 1187.34 329.245C1176.42 328.905 1166.77 332.423 1158.84 340.009C1151.61 346.916 1148.22 355.413 1148.3 365.346C1148.35 371.709 1148.51 378.288 1149.5 384.572C1139.55 396.569 1129.62 407.496 1118.43 418.366L1051.63 418.423C1048.55 418.426 1045.38 418.505 1042.18 418.586C1033.51 418.804 1024.58 419.029 1016.35 417.734C1013.74 413.522 1014.28 373.199 1014.69 342.894C1014.87 328.851 1015.03 316.96 1014.84 311.809C1009.74 304.519 1003.42 298.39 997.436 291.846C984.162 277.33 970.243 263.566 956.766 249.258C948.87 240.876 941.734 231.805 933.904 223.356C908.213 195.638 881.436 168.295 854.297 141.995C850.93 138.729 843.842 138.645 839.273 138.46C827.568 137.987 815.686 138.299 803.834 138.612C796.649 138.801 789.474 138.99 782.356 139.003L647.696 139.061L505.998 138.879C497.865 138.847 489.715 138.764 481.557 138.681C458.97 138.452 436.324 138.223 413.828 139.047C403.201 139.436 391.585 145.282 384.577 153.253C377.012 161.861 376.053 176.673 375.094 187.777C374.316 196.802 374.793 228.35 375.351 265.335C376.341 330.897 377.59 413.542 372.567 418.079C358.867 419.205 344.852 418.923 330.871 418.641ZM432.802 893.621C424.581 892.876 416.363 892.131 408.08 892.147C406.542 890.936 404.982 889.717 403.542 888.391C399.764 884.905 397.046 879.474 396.582 874.379C395.171 858.818 395.507 842.52 395.838 826.468C395.981 819.506 396.124 812.59 396.123 805.801L396.127 671L396.136 327.041C396.12 309.303 396.216 291.551 396.312 273.795C396.488 241.161 396.664 208.513 396.149 175.921C397.722 173.522 399.035 170.981 400.793 168.697C403.493 165.18 406.948 161.936 411.469 161.273C465.957 160.289 520.546 160.539 575.108 160.789C600.659 160.906 626.204 161.023 651.731 161.013L765.326 161.004C771.607 161.005 777.908 160.949 784.216 160.892C797.905 160.769 811.626 160.645 825.244 161.114C827.506 161.194 834.351 161.181 835.897 162.886C838.218 165.439 837.749 192.667 837.265 220.756C836.809 247.212 836.34 274.432 838.177 282.518C840.25 291.62 845.919 302.101 852.414 308.756C858.936 315.437 868.339 320.731 877.49 322.763C885.931 324.639 895.535 324.382 904.645 324.138C907.552 324.06 910.41 323.984 913.162 323.978C921.544 323.963 929.953 323.853 938.369 323.743C954.341 323.534 970.34 323.325 986.23 323.762C989.045 323.837 990.644 324.27 992.535 326.351C993.796 354.199 993.474 382.385 993.154 410.47C993.006 423.436 992.858 436.381 992.867 449.262L992.818 693.359L992.898 819.901C992.896 824.197 993.02 829.474 993.153 835.156C993.492 849.622 993.892 866.715 992.451 876.933C991.59 883.062 985.704 888.921 980.861 892.204C959.043 894.089 937.156 893.907 915.273 893.725C911.656 893.695 908.039 893.665 904.422 893.645L810.07 893.234C804.939 893.221 799.725 893.109 794.485 892.997C783.97 892.772 773.354 892.545 763.113 893.106C761.32 893.203 759.4 893.626 757.451 894.054C753.94 894.826 750.337 895.618 747.223 894.573C745.888 894.126 745.924 893.419 745.358 892.257C747.006 887.671 766.122 876.783 770.995 873.407C803.221 851.097 836.056 828.699 863.801 800.817C887.596 776.894 909.146 747.859 921.62 716.362C924.231 709.77 925.707 703.084 927.899 696.399C929.273 692.206 931.248 688.207 932.362 683.934C936.255 668.985 939.188 651.125 940.351 635.73C940.943 627.911 940.848 619.925 940.752 612C940.718 609.114 940.683 606.236 940.682 603.377L940.713 549.332L940.647 403.895C938.468 401.655 936.095 399.547 933.829 397.39C926.485 397.329 920.228 396.012 913.127 394.196C872.413 393.551 815.196 374.488 778.269 357.141C775.849 356.004 770.32 353.154 763.363 349.569C743.218 339.186 711.107 322.637 707.901 323.665C701.494 325.728 692.63 332.524 686.355 336.117C678.318 340.655 670.152 344.972 661.872 349.063C616.531 371.926 569.963 386.856 519.665 394.072C508.777 395.632 498.199 397.386 487.165 397.448C485.005 399.498 482.676 401.398 480.422 403.347L480.422 551.444C480.422 557.953 480.392 564.36 480.361 570.675C479.998 646.919 479.699 709.693 533.38 774.539C542.262 785.267 551.338 795.682 561.634 805.09C581.743 823.467 602.277 840.598 624.507 856.404C632.271 861.927 640.154 867.588 648.302 872.528C657.011 877.804 669.352 881.701 676.126 889.478C677.465 891.011 677.483 891.303 677.324 893.22C674.136 895.939 665.498 894.74 658.568 893.778C655.643 893.372 653.023 893.009 651.245 893C607.822 892.765 564.099 892.606 520.681 893.274C511.672 893.412 502.673 893.788 493.675 894.164C481.062 894.691 468.45 895.219 455.81 895.09C448.103 895.009 440.451 894.315 432.802 893.621ZM975.139 298.319L973.424 301.668C967.895 302.164 962.173 302.041 956.502 301.92C953.822 301.863 951.153 301.806 948.521 301.814L900.441 302C889.704 302 873.164 300.383 865.578 291.947C856.057 281.374 857.568 252.506 858.631 232.198C858.902 227.02 859.144 222.398 859.166 218.778C859.192 214.645 859.075 210.422 858.957 206.175C858.735 198.16 858.51 190.06 859.241 182.32C861.256 182.483 862.661 182.788 864.517 183.583C867.08 191.533 884.609 205.597 891.179 212.106C899.889 220.731 973.986 295.169 975.139 298.319ZM855.596 779.134C831.85 803.844 741.059 873.849 709.978 883.61C709.58 883.689 709.187 883.822 708.78 883.853C699.89 884.502 613.717 822.968 601.738 813.185C584.991 799.5 568.332 784.883 554.192 768.454C522.533 731.673 506.276 684.035 502.64 636.062C501.964 627.164 502.103 618.029 502.24 608.979C502.295 605.363 502.35 601.762 502.352 598.193L502.414 526.111L502.308 459.986C502.302 456.724 502.233 453.408 502.164 450.069C501.989 441.614 501.812 433.017 502.635 424.813C502.909 422.1 503.165 420.253 505.596 418.728C510.925 415.388 552.531 409.94 561.961 407.704C605.445 397.39 646.159 379.557 685.777 359.293C691.251 356.496 699.731 353.659 704.092 349.717C704.49 349.355 704.865 348.975 705.25 348.604C716.727 348.08 737.21 359.408 752.701 367.975C757.464 370.609 761.755 372.982 765.167 374.67C793.199 388.531 822.884 398.314 852.931 406.74C859.537 408.592 866.404 408.636 872.829 410.66C879.766 412.847 887.251 414.124 894.494 414.884C900.534 415.52 908.284 415.017 913.869 417.257C916.021 418.114 917.289 418.94 918.005 421.238C919.901 427.336 919.296 599.47 918.606 619.045C918.103 633.234 916.158 648.633 914.015 662.702C910.538 685.494 903.371 707.565 892.797 728.054C882.85 746.741 870.323 763.934 855.596 779.134ZM1170.9 377.595C1170.49 371.965 1170.53 366.38 1170.58 360.742L1178.27 352.307L1187.21 350.119C1191.05 351.047 1194.94 351.781 1198.81 352.563C1200.77 354.498 1202.9 356.31 1204.95 358.161C1205.07 362.618 1204.92 367.114 1204.78 371.594C1204.72 373.297 1204.67 374.998 1204.63 376.693C1199.86 380.812 1195.5 384.112 1189.78 386.755C1181.16 386.185 1177.41 382.902 1170.9 377.595ZM216.48 377.396C216.244 371.904 216.135 366.579 216.525 361.087C219.78 358.422 222.215 355.373 224.81 352.099C230.508 351.962 236.059 351.984 241.742 352.426L250.368 360.768C250.38 361.624 250.405 362.47 250.429 363.306C250.606 369.456 250.767 375.055 246.158 380.003C245.857 380.326 245.535 380.626 245.223 380.94C244.703 381.466 244.186 381.996 243.643 382.5C238.93 386.847 234.572 386.658 228.792 386.406C228.34 386.387 227.878 386.366 227.407 386.348C223.85 383.207 220.301 380.219 216.48 377.396ZM1201.41 502.754C1203.11 499.909 1204.69 497.023 1206.26 494.102C1210.47 491.411 1214.64 489.008 1219.1 486.781C1225.01 487.748 1230.61 491.155 1235.49 494.504C1235.87 495.421 1236.27 496.302 1236.66 497.165C1238.39 501.008 1239.95 504.488 1239.08 509.099C1238.11 514.252 1233.79 518.308 1229.55 521.008L1223.99 524.521C1217.93 524.211 1213.08 521.94 1207.32 520.394C1204.39 514.397 1202.45 509.325 1201.41 502.754ZM181.486 504.504C186.249 495.216 189.79 491.699 199.298 487.47C205.781 489.838 213.647 493.157 216.926 499.679C218.704 503.214 218.875 507.553 217.502 511.251C216.468 514.035 214.701 516.695 213.167 519.236C205.847 523.332 202.408 524.697 193.93 522.294C191.848 521.702 190.236 521.268 188.041 521.313C186.819 518.798 185.628 516.404 184.879 513.695C183.74 510.65 182.454 507.601 181.486 504.504ZM1208.77 665.777C1201.87 660.493 1202.28 652.075 1201.3 644.294C1207.24 638.594 1210.01 635.253 1218.24 632.893C1221.26 632.253 1224.25 633.203 1227.07 634.25C1231.8 635.995 1235.5 639.164 1237.53 643.834C1239.4 648.164 1239.54 652.667 1237.65 657.011C1234.63 663.961 1229.9 666.104 1223.36 668.897C1218.94 669.52 1212.48 668.61 1208.77 665.777ZM187.532 638.84C188.233 637.876 188.901 636.957 189.465 636.097C195.963 633.455 202.233 633.11 208.876 635.668C213.033 637.272 216.669 640.056 218.248 644.325C220.24 649.709 217.447 656.287 215.2 661.578C214.892 662.305 214.593 663.007 214.319 663.678C208.813 667.311 203.454 670.66 196.512 668.981C192.078 667.912 188.315 664.995 185.36 661.655C183.946 659.087 182.531 654.784 182.061 651.845C181.343 647.357 184.708 642.727 187.532 638.84ZM1204.96 795.859C1200.78 801.603 1197.43 805.041 1190.47 807.114C1190.26 807.115 1190.05 807.116 1189.84 807.118C1187.01 807.14 1184.08 807.162 1181.37 806.278C1176.99 804.847 1172.97 802.014 1170.93 797.781C1168.94 793.672 1169.17 788.537 1170.63 784.3C1173.45 776.09 1179.5 772.966 1186.86 769.639C1195.32 772.515 1199.64 774.512 1204.85 782.174L1204.96 795.859ZM216.042 783.641C220.86 775.409 224.363 773.412 233.288 770.757C242.556 773.143 245.296 774.729 250.5 782.859L250.394 795.453C245.149 801.944 242.006 804.449 233.94 806.968C233.487 806.977 233.035 806.994 232.582 806.994C228.362 806.994 222.925 805.121 220.096 801.873C215.541 796.646 215.735 790.106 216.042 783.641Z"/>
4 + <path fill="#383737" transform="scale(1.08416 1.08452)" d="M632.487 241.671L530.725 241.698C525.121 241.721 519.472 241.67 513.807 241.619C500.781 241.502 487.669 241.385 474.828 242.158C471.315 244.296 468.152 245.706 467.259 250.054C466.689 252.846 466.848 255.851 468.43 258.308C470.41 261.388 474.077 262.917 477.559 263.478C486.518 264.917 500.26 264.497 512.367 264.126C517.28 263.975 521.923 263.833 525.869 263.827L636.513 263.654C641.07 263.667 645.63 263.694 650.192 263.722C663.96 263.807 677.739 263.891 691.49 263.54C694.884 260.495 700.637 256.333 700.778 251.26C700.818 249.705 700.149 248.492 699.44 247.206C699.359 247.059 699.277 246.911 699.196 246.762C695.233 243.735 689.776 242.842 684.906 242.303C672.733 240.954 659.882 241.214 647.267 241.47C642.285 241.571 637.339 241.671 632.487 241.671Z"/>
5 + <path fill="#383737" transform="scale(1.08416 1.08452)" d="M720.631 640.277C716.548 639.46 712.178 638.713 708.02 639.119C693.288 642.146 678.609 648.947 669.825 661.668C662.892 671.787 660.351 684.283 662.782 696.311C665.384 708.833 670.669 716.548 678.649 726.309L678.676 727.648C678.747 733.131 676.581 743.017 674.192 753.917C669.901 773.499 664.893 796.354 670.855 802.823C673.673 805.886 694.366 805.162 706.337 804.743C709.529 804.631 712.101 804.541 713.548 804.551C715.009 804.585 717.075 804.728 719.491 804.896C729.366 805.583 745.083 806.675 749.18 802.337C752.327 799.005 752.817 793.588 752.318 789.258C751.963 786.191 750.843 779.424 749.517 771.408C746.611 753.849 742.714 730.298 743.697 726.53C745.491 719.65 751.81 714.065 755.309 708.02C757.845 703.632 756.741 699.112 760.112 694.64L760.103 680.947C757.192 676.731 756.767 674.244 756.639 669.233C747.629 655.552 737.661 643.675 720.631 640.277ZM686.36 696.2C685.763 692.899 684.972 689.793 684.062 686.567C685.949 680.315 688.048 673.311 692.891 668.641C695.586 666.042 698.935 663.382 702.78 663.32C703.357 663.279 703.936 663.236 704.516 663.193C707.532 662.969 710.571 662.743 713.588 662.75C720.189 662.773 727.339 664.704 731.97 669.661C738.739 676.902 738.515 686.432 738.299 695.64L738.297 695.714C733.048 703.751 730.092 708.877 721.603 713.672C720.954 717.202 720.401 720.702 720.516 724.303C720.715 730.467 722.35 736.878 723.101 743.029C723.839 749.092 723.808 755.609 725.13 761.539C726.318 766.842 729.252 772.224 730.092 777.486C730.304 778.794 729.972 779.457 729.08 780.38C726.537 783.01 722.974 782.845 719.545 782.687C718.963 782.66 718.385 782.633 717.817 782.621C716.223 782.581 714.124 782.695 711.805 782.82C705.238 783.175 696.911 783.624 693.328 780.973C691.286 775.486 695.091 759.58 698.59 744.953C701.268 733.759 703.766 723.315 703.328 718.864C702.833 713.857 696.302 711.029 692.701 708.457C688.578 705.519 687.226 701.012 686.36 696.2Z"/>
6 + <path fill="white" fill-opacity="0.011764706" transform="scale(1.08416 1.08452)" d="M242.715 786.271C240.648 785.391 239.325 785.413 237.131 785.453L236.881 786.324C236.108 788.926 235.354 790.641 233.737 792.815C235.922 793.734 238.161 793.844 240.493 794.078C242.165 793.208 242.97 792.311 244.144 790.848C244.367 788.574 244.224 788.375 243.097 786.806C242.98 786.643 242.853 786.466 242.715 786.271Z"/>
7 + <path fill="white" fill-opacity="0.011764706" transform="scale(1.08416 1.08452)" d="M230.66 789.244C228.7 786.796 226.959 785.687 223.864 785.131L223.744 785.379C222.598 787.754 222.338 788.294 222.595 791.029C224.816 793.703 226.915 793.964 230.228 794.414C231.295 792.168 231.235 791.893 230.759 789.706C230.728 789.561 230.695 789.407 230.66 789.244Z"/>
8 + <path fill="white" fill-opacity="0.011764706" transform="scale(1.08416 1.08452)" d="M224.841 359.425L224.81 352.099C222.215 355.373 219.78 358.422 216.525 361.087L221.674 360.826L222.834 361.957L222.794 363.177C222.659 369.164 223.7 373.105 228.078 377.405C229.5 378.801 230.553 379.512 232.586 379.756C235.761 376.517 238.534 372.571 242.439 370.224L244.409 370.361C245.915 373.013 245.634 376.182 245.363 379.234C245.312 379.808 245.261 380.378 245.223 380.94C245.535 380.626 245.857 380.326 246.158 380.003C250.767 375.055 250.606 369.456 250.429 363.306C250.405 362.47 250.38 361.624 250.368 360.768L241.742 352.426L241.774 358.882C239.861 359.58 238.727 358.948 237.556 358.295C236.706 357.821 235.836 357.336 234.635 357.344C233.174 357.352 231.808 357.87 230.42 358.396C228.674 359.058 226.893 359.733 224.841 359.425Z"/>
9 + <path fill="white" fill-opacity="0.015686275" transform="scale(1.08416 1.08452)" d="M182.061 651.845C182.531 654.784 183.946 659.087 185.36 661.655C185.69 660.258 185.635 660.771 185.717 659.529C185.886 656.966 186.23 654.744 186.822 652.243C187.199 652.583 187.561 652.945 187.953 653.268C189.59 654.626 191.235 654.294 193.096 653.918C193.602 653.816 194.124 653.71 194.667 653.635C199.092 655.645 196.798 660.263 199.876 663.731C200.03 663.747 200.171 663.762 200.304 663.776C201.102 663.862 201.587 663.914 202.586 663.709C204.774 663.25 206.538 662.074 207.47 660.157C206.791 656.268 205.917 644.554 203.474 642.045C202.727 641.943 202.498 641.867 202.271 641.874C202.063 641.88 201.857 641.956 201.254 642.146C200.492 642.388 199.762 642.656 199.049 642.917C196.282 643.932 193.77 644.853 190.652 643.799C189.207 641.641 189.289 640.01 189.4 637.797C189.427 637.268 189.455 636.707 189.465 636.097C188.901 636.957 188.233 637.876 187.532 638.84C184.708 642.727 181.343 647.357 182.061 651.845Z"/>
10 + <path fill="white" fill-opacity="0.011764706" transform="scale(1.08416 1.08452)" d="M187.758 511.561C187.305 512.961 186.104 513.085 184.879 513.695C185.628 516.404 186.819 518.798 188.041 521.313C188.335 519.284 188.47 516.788 189.141 514.866C190.428 511.177 194.12 511.57 197.994 511.982C200.947 512.295 204.006 512.621 206.186 511.159C207.181 510.491 207.247 509.214 207.251 508.11C207.267 504 202.972 498.901 200.205 496.232L198.905 496.524C198.09 498.123 197.334 499.741 196.592 501.38C194.937 501.919 193.751 501.525 192.569 501.134C191.395 500.744 190.226 500.357 188.605 500.885C187.697 502.779 187.856 504.693 188.016 506.614C188.153 508.26 188.291 509.911 187.758 511.561Z"/>
11 + <path fill="#383737" fill-opacity="0.97254902" transform="scale(1.08416 1.08452)" d="M456.005 916.034C455.413 916.167 455.081 916.816 454.617 917.205C460.603 917.755 466.93 917.56 473.139 917.368C475.813 917.286 478.465 917.204 481.059 917.183C497.567 917.033 514.075 916.984 530.583 917.033L712.726 916.675C721.552 916.611 730.375 916.797 739.198 916.984C746.238 917.132 753.278 917.281 760.32 917.302L887.11 917.422C894.348 917.44 901.613 917.526 908.889 917.613C927.537 917.834 946.253 918.056 964.768 917.143C970.04 916.883 982.483 915.124 986.583 911.973C973.023 911.03 959.16 911.391 945.359 911.75C937.884 911.944 930.427 912.138 923.047 912.124L783.938 911.916L597.337 913.379C584.041 913.419 570.691 913.261 557.329 913.103C533.88 912.826 510.394 912.548 487.099 913.339C479.848 913.586 473.14 915.416 466.022 916.065C464.706 916.184 463.16 916.076 461.606 915.967C459.591 915.825 457.564 915.683 456.005 916.034Z"/>
12 + <path transform="scale(1.08416 1.08452)" d="M652.793 448.497C649.655 448.479 646.512 448.462 643.383 448.543C638.27 448.681 633.41 450.798 629.826 454.449C627.172 457.199 625.268 460.824 624.677 464.606C624.237 467.416 624.309 471.517 624.372 475.048C624.392 476.204 624.411 477.299 624.411 478.267L624.412 504.676L624.417 557.882L624.403 574.899C624.401 575.91 624.392 576.922 624.382 577.935C624.362 580.106 624.342 582.279 624.393 584.443C624.53 590.245 626.947 595.42 631.127 599.389C634.084 602.198 637.697 604.021 641.742 604.63C643.383 604.877 645.071 604.861 646.74 604.845C647.125 604.841 647.509 604.837 647.891 604.837L655.798 604.836L684.096 604.84L740.076 604.841L758.046 604.848C759.047 604.849 760.05 604.858 761.053 604.868C763.325 604.889 765.602 604.91 767.865 604.838C768.776 604.815 769.684 604.73 770.583 604.584C776.665 603.367 781.789 600.086 785.258 594.893C787.165 592.038 788.65 588.395 789.008 584.963C789.183 583.286 789.162 581.552 789.141 579.843C789.135 579.289 789.128 578.738 789.128 578.192L789.13 567.398L789.128 530.631L789.145 487.756L789.138 476.142C789.137 475.585 789.14 475.027 789.143 474.469C789.16 471.115 789.177 467.757 788.495 464.454C787.445 459.363 784.963 454.767 780.519 451.896C777.394 449.878 773.825 449.045 770.163 448.71C768.273 448.536 766.362 448.544 764.46 448.552C764.035 448.553 763.612 448.555 763.189 448.555L753.62 448.55L722.505 448.54L675.667 448.522L656.311 448.511C655.14 448.51 653.967 448.503 652.793 448.497ZM762.242 598.402L752.813 598.38L678.526 598.373L655.868 598.408C654.814 598.408 653.746 598.425 652.672 598.441C649.216 598.495 645.691 598.549 642.336 598.062C639.461 597.645 636.999 596.549 634.916 594.505C631.34 590.995 630.025 586.997 629.972 582.047L629.951 550.391L629.963 503.517L629.943 480.801C629.946 479.833 629.935 478.852 629.924 477.864C629.885 474.51 629.845 471.08 630.373 467.829C630.867 464.789 632.257 462.491 634.406 460.31C639.608 455.035 645.015 455.045 651.923 455.058L651.934 455.058C655.164 455.074 658.395 455.071 661.626 455.047C661.733 455.044 661.841 455.041 661.949 455.039C667.296 454.961 672.656 454.991 678.013 455.021C680.769 455.036 683.523 455.052 686.275 455.052L734.807 455.047L757.541 455.047C758.471 455.047 759.483 455.028 760.534 455.008C763.649 454.949 767.104 454.883 769.758 455.298C773.353 455.848 776.682 457.52 779.271 460.075C781.594 462.375 783.467 465.48 784.013 468.731C784.45 471.319 784.392 474.105 784.335 476.819C784.316 477.768 784.296 478.709 784.298 479.628L784.3 499.442L784.352 570.887L784.352 578.367C784.339 583.933 784.308 589.894 780.052 594.111C775.665 598.457 769.099 598.43 763.287 598.405C762.936 598.404 762.587 598.402 762.242 598.402Z"/>
13 + <path transform="scale(1.08416 1.08452)" d="M721.221 486.048C719.782 483.567 710.453 467.159 709.841 466.655C705.736 474.068 701.302 481.313 697.064 488.652L662.654 547.868C658.434 555.025 654.267 562.214 650.153 569.433L646.211 576.189C645.519 577.365 644.737 578.52 644.127 579.739L658.341 579.732C659.193 579.733 660.051 579.746 660.911 579.758C662.775 579.786 664.649 579.814 666.49 579.718C667.466 578.077 668.354 576.383 669.317 574.733C673.696 567.313 678.004 559.852 682.238 552.349L701.978 518.221C702.706 516.962 709.41 505.023 709.827 504.717L710.007 504.861L739.82 556.037L748.885 571.608C750.388 574.208 751.758 577.111 753.511 579.54C758.236 579.636 762.974 579.614 767.71 579.592C770.446 579.58 773.181 579.567 775.913 579.577C774.914 578.152 774.109 576.547 773.242 575.037L768.959 567.637L757.083 547.247L721.221 486.048Z"/>
14 + <path transform="scale(1.08416 1.08452)" d="M704.956 560.562C701.043 560.526 697.131 560.491 693.22 560.511C692.403 560.695 692.228 560.901 691.788 561.594C690.245 564.025 688.897 566.629 687.46 569.129L681.255 579.749L726.352 579.701C727.46 579.703 728.571 579.714 729.683 579.725C732.453 579.752 735.23 579.78 737.988 579.672C734.545 573.271 730.823 566.997 727.252 560.665C725.105 560.291 721.833 560.415 718.944 560.525C717.727 560.571 716.578 560.614 715.609 560.617C712.059 560.627 708.508 560.594 704.956 560.562Z"/>
15 +</svg>