main
py 1,601 lines 59.2 KB
Raw
1 import asyncio, json, random, re, string, threading, time
2
3 from collections import OrderedDict
4 from dataclasses import dataclass, field
5 from datetime import datetime
6 from typing import Any, Awaitable, Coroutine, Dict, Literal
7 from enum import Enum
8 import models
9
10 from helpers import (
11 extract_tools,
12 files,
13 errors,
14 history,
15 tokens,
16 context as context_helper,
17 dirty_json,
18 subagents,
19 )
20 from helpers import extension
21 from helpers.print_style import PrintStyle
22
23 from langchain_core.prompts import (
24 ChatPromptTemplate,
25 )
26 from langchain_core.messages import SystemMessage, BaseMessage
27
28 import helpers.log as Log
29 from helpers.dirty_json import DirtyJson
30 from helpers.defer import DeferredTask
31 from typing import Callable
32 from helpers.localization import Localization
33 from helpers import extension
34 from helpers.errors import RepairableException, InterventionException, HandledException
35 from helpers.llm_result import (
36 LLMResult,
37 RESPONSE_METADATA_KEY,
38 function_call_output_item,
39 metadata_from_llm_result,
40 result_from_metadata,
41 )
42 from helpers.litellm_transport import ResponsesTransport
43 from helpers.responses_tools import build_responses_function_tools, original_tool_name
44
45 _RESPONSE_STREAM_UPDATE_CHARS = 128
46 _RESPONSE_STREAM_UPDATE_SECONDS = 0.05
47
48
49 class AgentContextType(Enum):
50 USER = "user"
51 TASK = "task"
52 BACKGROUND = "background"
53
54
55 class AgentContext:
56
57 _contexts: dict[str, "AgentContext"] = {}
58 _contexts_lock = threading.RLock()
59 _counter: int = 0
60 _notification_manager = None
61
62 @extension.extensible
63 def __init__(
64 self,
65 config: "AgentConfig",
66 id: str | None = None,
67 name: str | None = None,
68 agent0: "Agent|None" = None,
69 log: Log.Log | None = None,
70 paused: bool = False,
71 streaming_agent: "Agent|None" = None,
72 created_at: datetime | None = None,
73 type: AgentContextType = AgentContextType.USER,
74 last_message: datetime | None = None,
75 data: dict | None = None,
76 output_data: dict | None = None,
77 set_current: bool = False,
78 ):
79 # initialize context
80 self.id = id or AgentContext.generate_id()
81 existing = None
82 with AgentContext._contexts_lock:
83 existing = AgentContext._contexts.get(self.id, None)
84 if existing:
85 AgentContext._contexts.pop(self.id, None)
86 AgentContext._contexts[self.id] = self
87 if existing and existing.task:
88 existing.task.kill()
89 if set_current:
90 AgentContext.set_current(self.id)
91
92 # initialize state
93 self.name = name
94 self.config = config
95 self.data = data or {}
96 self.output_data = output_data or {}
97 self.log = log or Log.Log()
98 self.log.context = self
99 self.paused = paused
100 self.streaming_agent = streaming_agent
101 self.task: DeferredTask | None = None
102 self.created_at = created_at or Localization.get().now()
103 self.type = type
104 AgentContext._counter += 1
105 self.no = AgentContext._counter
106 self.last_message = last_message or Localization.get().now()
107
108 # initialize agent at last (context is complete now)
109 self.agent0 = agent0 or Agent(0, self.config, self)
110
111 @staticmethod
112 def get(id: str):
113 with AgentContext._contexts_lock:
114 return AgentContext._contexts.get(id, None)
115
116 @staticmethod
117 def use(id: str):
118 context = AgentContext.get(id)
119 if context:
120 AgentContext.set_current(id)
121 else:
122 AgentContext.set_current("")
123 return context
124
125 @staticmethod
126 def current():
127 ctxid = context_helper.get_context_data("agent_context_id", "")
128 if not ctxid:
129 return None
130 return AgentContext.get(ctxid)
131
132 @staticmethod
133 def set_current(ctxid: str):
134 context_helper.set_context_data("agent_context_id", ctxid)
135
136 @staticmethod
137 def first():
138 with AgentContext._contexts_lock:
139 if not AgentContext._contexts:
140 return None
141 return list(AgentContext._contexts.values())[0]
142
143 @staticmethod
144 def all():
145 with AgentContext._contexts_lock:
146 return list(AgentContext._contexts.values())
147
148 @staticmethod
149 def generate_id():
150 def generate_short_id():
151 return "".join(random.choices(string.ascii_letters + string.digits, k=8))
152
153 while True:
154 short_id = generate_short_id()
155 with AgentContext._contexts_lock:
156 if short_id not in AgentContext._contexts:
157 return short_id
158
159 @classmethod
160 def get_notification_manager(cls):
161 if cls._notification_manager is None:
162 from helpers.notification import NotificationManager # type: ignore
163
164 cls._notification_manager = NotificationManager()
165 return cls._notification_manager
166
167 @staticmethod
168 @extension.extensible
169 def remove(id: str):
170 with AgentContext._contexts_lock:
171 context = AgentContext._contexts.pop(id, None)
172 if context and context.task:
173 context.task.kill()
174 return context
175
176 def get_data(self, key: str, recursive: bool = True):
177 # recursive is not used now, prepared for context hierarchy
178 return self.data.get(key, None)
179
180 def set_data(self, key: str, value: Any, recursive: bool = True):
181 # recursive is not used now, prepared for context hierarchy
182 self.data[key] = value
183
184 def get_output_data(self, key: str, recursive: bool = True):
185 # recursive is not used now, prepared for context hierarchy
186 return self.output_data.get(key, None)
187
188 def set_output_data(self, key: str, value: Any, recursive: bool = True):
189 # recursive is not used now, prepared for context hierarchy
190 self.output_data[key] = value
191
192 # @extension.extensible
193 def output(self):
194 return {
195 "id": self.id,
196 "name": self.name,
197 "created_at": (
198 Localization.get().serialize_datetime(self.created_at)
199 if self.created_at
200 else Localization.get().serialize_datetime(datetime.fromtimestamp(0))
201 ),
202 "no": self.no,
203 "log_guid": self.log.guid,
204 "log_version": len(self.log.updates),
205 "log_length": len(self.log.logs),
206 "paused": self.paused,
207 "last_message": (
208 Localization.get().serialize_datetime(self.last_message)
209 if self.last_message
210 else Localization.get().serialize_datetime(datetime.fromtimestamp(0))
211 ),
212 "type": self.type.value,
213 "running": self.is_running(),
214 **self.output_data,
215 }
216
217 @staticmethod
218 def log_to_all(
219 type: Log.Type,
220 heading: str | None = None,
221 content: str | None = None,
222 kvps: dict | None = None,
223 update_progress: Log.ProgressUpdate | None = None,
224 id: str | None = None, # Add id parameter
225 **kwargs,
226 ) -> list[Log.LogItem]:
227 items: list[Log.LogItem] = []
228 for context in AgentContext.all():
229 items.append(
230 context.log.log(
231 type, heading, content, kvps, update_progress, id, **kwargs
232 )
233 )
234 return items
235
236 @extension.extensible
237 def kill_process(self):
238 if self.task:
239 self.task.kill()
240
241 @extension.extensible
242 def reset(self):
243 self.kill_process()
244 self.log.reset()
245 self.agent0 = Agent(0, self.config, self)
246 self.streaming_agent = None
247 self.paused = False
248
249 @extension.extensible
250 def nudge(self):
251 self.kill_process()
252 self.paused = False
253 self.task = self.communicate(UserMessage(self.agent0.read_prompt("fw.msg_nudge.md")))
254 return self.task
255
256 @extension.extensible
257 def get_agent(self):
258 return self.streaming_agent or self.agent0
259
260 def is_running(self) -> bool:
261 return (self.task and self.task.is_alive()) or False
262
263 @extension.extensible
264 def communicate(self, msg: "UserMessage", broadcast_level: int = 1):
265 self.paused = False # unpause if paused
266
267 current_agent = self.get_agent()
268
269 if self.task and self.task.is_alive():
270 # set intervention messages to agent(s):
271 intervention_agent = current_agent
272 while intervention_agent and broadcast_level != 0:
273 intervention_agent.intervention = msg
274 broadcast_level -= 1
275 intervention_agent = intervention_agent.data.get(
276 Agent.DATA_NAME_SUPERIOR, None
277 )
278 else:
279 self.task = self.run_task(self._process_chain, current_agent, msg)
280
281 return self.task
282
283 @extension.extensible
284 def run_task(
285 self, func: Callable[..., Coroutine[Any, Any, Any]], *args: Any, **kwargs: Any
286 ):
287 if not self.task:
288 self.task = DeferredTask(
289 thread_name=self.__class__.__name__,
290 )
291 self.task.start_task(func, *args, **kwargs)
292 return self.task
293
294 # this wrapper ensures that superior agents are called back if the chat was loaded from file and original callstack is gone
295 @extension.extensible
296 async def _process_chain(self, agent: "Agent", msg: "UserMessage|str", user=True):
297 try:
298 msg_template = (
299 agent.hist_add_user_message(msg) # type: ignore
300 if user
301 else agent.hist_add_tool_result(
302 tool_name="call_subordinate", tool_result=msg # type: ignore
303 )
304 )
305 response = await agent.monologue() # type: ignore
306 superior = agent.data.get(Agent.DATA_NAME_SUPERIOR, None)
307 if superior:
308 response = await self._process_chain(superior, response, False) # type: ignore
309
310 # call end of process extensions
311 await extension.call_extensions_async("process_chain_end", agent=self.get_agent(), data={})
312
313 return response
314 except Exception as e:
315 await self.handle_exception("process_chain", e)
316
317 @extension.extensible
318 async def handle_exception(self, location: str, exception: Exception):
319 if exception:
320 raise exception # exception handling is done by extensions
321
322
323 @dataclass
324 class AgentConfig:
325 mcp_servers: str
326 profile: str = ""
327 knowledge_subdirs: list[str] = field(default_factory=lambda: ["default", "custom"])
328 additional: Dict[str, Any] = field(default_factory=dict)
329
330
331 @dataclass
332 class UserMessage:
333 message: str
334 attachments: list[str] = field(default_factory=list[str])
335 system_message: list[str] = field(default_factory=list[str])
336 id: str = ""
337
338
339 class LoopData:
340 def __init__(self, **kwargs):
341 self.iteration = -1
342 self.system = []
343 self.user_message: history.Message | None = None
344 self.history_output: list[history.OutputMessage] = []
345 self.protocol_temporary: OrderedDict[str, history.MessageContent] = OrderedDict()
346 self.protocol_persistent: OrderedDict[str, history.MessageContent] = OrderedDict()
347 self.extras_temporary: OrderedDict[str, history.MessageContent] = OrderedDict()
348 self.extras_persistent: OrderedDict[str, history.MessageContent] = OrderedDict()
349 self.last_response = ""
350 self.params_temporary: dict = {}
351 self.params_persistent: dict = {}
352 self.current_tool = None
353
354 # override values with kwargs
355 for key, value in kwargs.items():
356 setattr(self, key, value)
357
358
359 class Agent:
360
361 DATA_NAME_SUPERIOR = "_superior"
362 DATA_NAME_SUBORDINATE = "_subordinate"
363 DATA_NAME_CTX_WINDOW = "ctx_window"
364 DATA_NAME_RESPONSES_STATE = "responses_state"
365 DATA_NAME_RESPONSES_TOOL_NAME_MAP = "responses_tool_name_map"
366 DATA_NAME_RESPONSES_COMPUTER_SESSION = "responses_computer_session_id"
367
368 @extension.extensible
369 def __init__(
370 self, number: int, config: AgentConfig, context: AgentContext | None = None
371 ):
372
373 # agent config
374 self.config = config
375
376 # agent context
377 self.context = context or AgentContext(config=config, agent0=self)
378
379 # non-config vars
380 self.number = number
381 self.agent_name = f"A{self.number}"
382
383 self.history = history.History(self) # type: ignore[abstract]
384 self.last_user_message: history.Message | None = None
385 self.intervention: UserMessage | None = None
386 self.data: dict[str, Any] = {} # free data object all the tools can use
387
388 extension.call_extensions_sync("agent_init", self)
389
390 @extension.extensible
391 async def monologue(self):
392 while True:
393 try:
394 # loop data dictionary to pass to extensions
395 self.loop_data = LoopData(user_message=self.last_user_message)
396 # call monologue_start extensions
397 await extension.call_extensions_async(
398 "monologue_start", self, loop_data=self.loop_data
399 )
400
401 printer = PrintStyle(italic=True, font_color="#b3ffd9", padding=False)
402
403 # let the agent run message loop until he stops it with a response tool
404 while True:
405
406 self.context.streaming_agent = self # mark self as current streamer
407 self.loop_data.iteration += 1
408 self.loop_data.params_temporary = {} # clear temporary params
409 last_response_stream_full = ""
410 last_response_stream_chars = 0
411 last_response_stream_at = time.monotonic()
412 response_stream_pending = False
413
414 # call message_loop_start extensions
415 await extension.call_extensions_async(
416 "message_loop_start", self, loop_data=self.loop_data
417 )
418 await self.handle_intervention()
419
420 try:
421 # prepare LLM chain (model, system, history)
422 prompt = await self.prepare_prompt(loop_data=self.loop_data)
423
424 # call before_main_llm_call extensions
425 await extension.call_extensions_async(
426 "before_main_llm_call", self, loop_data=self.loop_data
427 )
428 await self.handle_intervention()
429
430
431 async def reasoning_callback(chunk: str, full: str):
432 await self.handle_intervention()
433 if chunk == full:
434 printer.print("Reasoning: ") # start of reasoning
435 # Pass chunk and full data to extensions for processing
436 stream_data = {"chunk": chunk, "full": full}
437 await extension.call_extensions_async(
438 "reasoning_stream_chunk",
439 self,
440 loop_data=self.loop_data,
441 stream_data=stream_data,
442 )
443 # Stream masked chunk after extensions processed it
444 if stream_data.get("chunk"):
445 printer.stream(stream_data["chunk"])
446 # Use the potentially modified full text for downstream processing
447 await self.handle_reasoning_stream(stream_data["full"])
448
449 async def stream_callback(chunk: str, full: str):
450 nonlocal last_response_stream_full, last_response_stream_chars
451 nonlocal last_response_stream_at, response_stream_pending
452 await self.handle_intervention()
453 # output the agent response stream
454 if chunk == full:
455 printer.print("Response: ") # start of response
456 # Pass chunk and full data to extensions for processing
457 stream_data = {"chunk": chunk, "full": full}
458 tool_request = extract_tools.extract_tool_request(full)
459 if tool_request is not None:
460 try:
461 await self.validate_tool_request(tool_request)
462 except Exception:
463 pass
464 else:
465 await self.handle_response_stream(full)
466 response_stream_pending = False
467 return full.strip()
468
469 await extension.call_extensions_async(
470 "response_stream_chunk",
471 self,
472 loop_data=self.loop_data,
473 stream_data=stream_data,
474 )
475 # Stream masked chunk after extensions processed it
476 if stream_data.get("chunk"):
477 printer.stream(stream_data["chunk"])
478 last_response_stream_full = stream_data["full"]
479 response_stream_pending = True
480 now = time.monotonic()
481 if (
482 len(full) - last_response_stream_chars
483 >= _RESPONSE_STREAM_UPDATE_CHARS
484 or now - last_response_stream_at
485 >= _RESPONSE_STREAM_UPDATE_SECONDS
486 ):
487 await self.handle_response_stream(last_response_stream_full)
488 last_response_stream_chars = len(full)
489 last_response_stream_at = time.monotonic()
490 response_stream_pending = False
491
492 # call main LLM
493 llm_result = await self.call_chat_model_turn(
494 messages=prompt,
495 response_callback=stream_callback,
496 reasoning_callback=reasoning_callback,
497 )
498 agent_response = llm_result.response
499 await self.handle_intervention(agent_response)
500
501 if response_stream_pending:
502 await self.handle_response_stream(last_response_stream_full)
503
504 # Notify extensions to finalize their stream filters
505 await extension.call_extensions_async(
506 "reasoning_stream_end", self, loop_data=self.loop_data
507 )
508 await self.handle_intervention(agent_response)
509
510 await extension.call_extensions_async(
511 "response_stream_end", self, loop_data=self.loop_data
512 )
513
514 await self.handle_intervention(agent_response)
515
516 result_data = {"llm_result": llm_result}
517 await extension.call_extensions_async(
518 "message_loop_result",
519 self,
520 loop_data=self.loop_data,
521 result_data=result_data,
522 )
523 if result_data.get("skip_default_processing"):
524 continue
525
526 agent_response = llm_result.response
527 log_item = self.loop_data.params_temporary.get("log_item_generating")
528 assistant_message = self.hist_add_ai_response(
529 agent_response,
530 id=log_item.id if log_item else "",
531 llm_result=llm_result,
532 )
533 self._remember_llm_result_state(llm_result, assistant_message)
534 tools_result = await self.process_llm_result_tools(llm_result)
535 if tools_result: # final response of message loop available
536 return tools_result # break the execution if the task is done
537
538 # exceptions inside message loop:
539 except Exception as e:
540 await self.handle_exception("message_loop", e)
541
542 finally:
543 # call message_loop_end extensions
544 if self.context.task and self.context.task.is_alive(): # don't call extensions post mortem
545 await extension.call_extensions_async(
546 "message_loop_end", self, loop_data=self.loop_data
547 )
548
549
550
551 # exceptions outside message loop:
552 except Exception as e:
553 await self.handle_exception("monologue", e)
554 finally:
555 self.context.streaming_agent = None # unset current streamer
556 # call monologue_end extensions
557 if self.context.task and self.context.task.is_alive(): # don't call extensions post mortem
558 await extension.call_extensions_async(
559 "monologue_end", self, loop_data=self.loop_data
560 ) # type: ignore
561
562 @extension.extensible
563 async def prepare_prompt(self, loop_data: LoopData) -> list[BaseMessage]:
564 self.context.log.set_progress("Building prompt")
565
566 # call extensions before setting prompts
567 await extension.call_extensions_async(
568 "message_loop_prompts_before", self, loop_data=loop_data
569 )
570
571 # set system prompt and message history
572 loop_data.system = await self.get_system_prompt(self.loop_data)
573 loop_data.history_output = self.history.output()
574
575 # and allow extensions to edit them
576 await extension.call_extensions_async(
577 "message_loop_prompts_after", self, loop_data=loop_data
578 )
579
580 # concatenate system prompt and remove JSON fence markers from examples
581 system_text = files.remove_code_fences(
582 "\n\n".join(loop_data.system), language="json"
583 )
584
585 # join protocol and extras
586 protocol = self._build_context_message(
587 "agent.context.protocol.md",
588 "protocol",
589 {**loop_data.protocol_persistent, **loop_data.protocol_temporary},
590 include_empty=False,
591 )
592 extras = self._build_context_message(
593 "agent.context.extras.md",
594 "extras",
595 {**loop_data.extras_persistent, **loop_data.extras_temporary},
596 include_empty=True,
597 )
598 loop_data.protocol_temporary.clear()
599 loop_data.extras_temporary.clear()
600
601 # convert protocol + history + extras to LLM format
602 history_langchain: list[BaseMessage] = history.output_langchain(
603 protocol + loop_data.history_output + extras
604 )
605
606 # build full prompt from system prompt, protocol, message history and extras
607 full_prompt: list[BaseMessage] = [
608 SystemMessage(content=system_text),
609 *history_langchain,
610 ]
611 full_text = ChatPromptTemplate.from_messages(full_prompt).format()
612
613 # store as last context window content
614 self.set_data(
615 Agent.DATA_NAME_CTX_WINDOW,
616 {
617 "text": full_text,
618 "tokens": tokens.approximate_prompt_tokens(full_text),
619 },
620 )
621
622 return full_prompt
623
624 def _build_context_message(
625 self,
626 prompt_file: str,
627 variable_name: str,
628 values: dict[str, history.MessageContent],
629 include_empty: bool,
630 ) -> list[history.OutputMessage]:
631 if not include_empty and not values:
632 return []
633
634 return history.Message( # type: ignore[abstract]
635 False,
636 content=self.read_prompt(
637 prompt_file,
638 **{variable_name: dirty_json.stringify(values, separators=(",", ":"))},
639 ),
640 ).output()
641
642 @extension.extensible
643 async def handle_exception(self, location: str, exception: Exception):
644 if exception:
645 raise exception # exception handling is done by extensions
646
647 # exception_data = {"exception": exception}
648 # await self.call_extensions(
649 # "message_loop_exception", exception_data=exception_data
650 # )
651
652 # # If extensions cleared the exception, continue.
653 # if not exception_data.get("exception"):
654 # return
655
656 # # Backwards-compatible fallback (should normally be handled by _90 extension).
657 # exception = exception_data["exception"]
658 # if isinstance(exception, HandledException):
659 # raise exception
660 # elif isinstance(exception, asyncio.CancelledError):
661 # PrintStyle(font_color="white", background_color="red", padding=True).print(
662 # f"Context {self.context.id} terminated during message loop"
663 # )
664 # raise HandledException(exception)
665
666 # else:
667 # error_text = errors.error_text(exception)
668 # error_message = errors.format_error(exception)
669
670 # # Mask secrets in error messages
671 # PrintStyle(font_color="red", padding=True).print(error_message)
672 # self.context.log.log(
673 # type="error",
674 # content=error_message,
675 # )
676 # PrintStyle(font_color="red", padding=True).print(
677 # f"{self.agent_name}: {error_text}"
678 # )
679
680 # raise HandledException(exception) # Re-raise the exception to kill the loop
681
682 @extension.extensible
683 async def get_system_prompt(self, loop_data: LoopData) -> list[str]:
684 system_prompt: list[str] = []
685 await extension.call_extensions_async(
686 "system_prompt", self, system_prompt=system_prompt, loop_data=loop_data
687 )
688 return system_prompt
689
690 @extension.extensible
691 def parse_prompt(self, _prompt_file: str, **kwargs):
692 dirs = subagents.get_paths(self, "prompts")
693
694 prompt = files.parse_file(
695 _prompt_file, _directories=dirs, _agent=self, **kwargs
696 )
697 return prompt
698
699 @extension.extensible
700 def read_prompt(self, file: str, **kwargs) -> str:
701 dirs = subagents.get_paths(self, "prompts")
702
703 prompt = files.read_prompt_file(file, _directories=dirs, _agent=self, **kwargs)
704 if files.is_full_json_template(prompt):
705 prompt = files.remove_code_fences(prompt)
706 return prompt
707
708 def get_data(self, field: str):
709 return self.data.get(field, None)
710
711 def set_data(self, field: str, value):
712 self.data[field] = value
713
714 @extension.extensible
715 def hist_add_message(
716 self,
717 ai: bool,
718 content: history.MessageContent,
719 tokens: int = 0,
720 id: str = "",
721 metadata: dict[str, Any] | None = None,
722 ):
723 self.last_message = Localization.get().now()
724 # Allow extensions to process content before adding to history
725 content_data = {"content": content}
726 extension.call_extensions_sync(
727 "hist_add_before", self, content_data=content_data, ai=ai
728 )
729 return self.history.add_message(
730 ai=ai,
731 content=content_data["content"],
732 tokens=tokens,
733 id=id,
734 metadata=metadata,
735 )
736
737 @extension.extensible
738 def hist_add_user_message(self, message: UserMessage, intervention: bool = False):
739 self.history.new_topic() # user message starts a new topic in history
740
741 # load message template based on intervention
742 if intervention:
743 content = self.parse_prompt(
744 "fw.intervention.md",
745 message=message.message,
746 attachments=message.attachments,
747 system_message=message.system_message,
748 )
749 else:
750 content = self.parse_prompt(
751 "fw.user_message.md",
752 message=message.message,
753 attachments=message.attachments,
754 system_message=message.system_message,
755 )
756
757 # remove empty parts from template
758 if isinstance(content, dict):
759 content = {k: v for k, v in content.items() if v}
760
761 # add to history
762 msg = self.hist_add_message(False, content=content, id=message.id) # type: ignore
763 self.last_user_message = msg
764 return msg
765
766 @extension.extensible
767 def hist_add_ai_response(
768 self, message: str, id: str = "", llm_result: LLMResult | None = None
769 ):
770 self.loop_data.last_response = message
771 content = self.parse_prompt("fw.ai_response.md", message=message)
772 return self.hist_add_message(
773 True,
774 content=content,
775 id=id,
776 metadata=metadata_from_llm_result(llm_result),
777 )
778
779 @extension.extensible
780 def hist_add_warning(self, message: history.MessageContent, id: str = ""):
781 content = self.parse_prompt("fw.warning.md", message=message)
782 return self.hist_add_message(False, content=content, id=id)
783
784 @extension.extensible
785 def hist_add_tool_result(self, tool_name: str, tool_result: str, **kwargs):
786 msg_id = kwargs.pop("id", "")
787 responses_item = kwargs.pop("_responses_output_item", None) or kwargs.pop(
788 "responses_item", None
789 )
790 metadata = (
791 {
792 RESPONSE_METADATA_KEY: {
793 "input_items": [responses_item],
794 "output_items": [],
795 "mode": "responses",
796 "state": "provider",
797 }
798 }
799 if isinstance(responses_item, dict)
800 else None
801 )
802 data = {
803 "tool_name": tool_name,
804 "tool_result": tool_result,
805 **kwargs,
806 }
807 extension.call_extensions_sync("hist_add_tool_result", self, data=data)
808 return self.hist_add_message(False, content=data, id=msg_id, metadata=metadata)
809
810 def concat_messages(
811 self, messages
812 ): # TODO add param for message range, topic, history
813 return self.history.output_text(human_label="user", ai_label="assistant")
814
815 @extension.extensible
816 def get_chat_model(self):
817 return None
818
819 @extension.extensible
820 def get_utility_model(self):
821 return None
822
823 @extension.extensible
824 def get_embedding_model(self):
825 return None
826
827 @extension.extensible
828 async def call_utility_model(
829 self,
830 system: str,
831 message: str,
832 callback: Callable[[str], Awaitable[None]] | None = None,
833 background: bool = False,
834 ):
835 model = self.get_utility_model()
836
837 # call extensions
838 call_data = {
839 "model": model,
840 "system": system,
841 "message": message,
842 "callback": callback,
843 "background": background,
844 }
845 await extension.call_extensions_async(
846 "util_model_call_before", self, call_data=call_data
847 )
848
849 # propagate stream to callback if set
850 async def stream_callback(chunk: str, total: str):
851 if call_data["callback"]:
852 await call_data["callback"](chunk)
853
854 response, _reasoning = await call_data["model"].unified_call(
855 system_message=call_data["system"],
856 user_message=call_data["message"],
857 response_callback=stream_callback if call_data["callback"] else None,
858 rate_limiter_callback=(
859 self.rate_limiter_callback if not call_data["background"] else None
860 ),
861 )
862
863 await extension.call_extensions_async(
864 "util_model_call_after", self, call_data=call_data, response=response
865 )
866
867 return response
868
869 @extension.extensible
870 async def call_chat_model(
871 self,
872 messages: list[BaseMessage],
873 response_callback: Callable[[str, str], Awaitable[str | None]] | None = None,
874 reasoning_callback: Callable[[str, str], Awaitable[None]] | None = None,
875 background: bool = False,
876 explicit_caching: bool = True,
877 ):
878 response = ""
879
880 # model class
881 model = self.get_chat_model()
882
883 # call extensions before
884 call_data = {
885 "model": model,
886 "messages": messages,
887 "response_callback": response_callback,
888 "reasoning_callback": reasoning_callback,
889 "background": background,
890 "explicit_caching": explicit_caching,
891 }
892 await extension.call_extensions_async(
893 "chat_model_call_before", self, call_data=call_data
894 )
895
896 # call model
897 response, reasoning = await call_data["model"].unified_call(
898 messages=call_data["messages"],
899 reasoning_callback=call_data["reasoning_callback"],
900 response_callback=call_data["response_callback"],
901 rate_limiter_callback=(
902 self.rate_limiter_callback if not call_data["background"] else None
903 ),
904 explicit_caching=call_data["explicit_caching"],
905 )
906
907 await extension.call_extensions_async(
908 "chat_model_call_after", self, call_data=call_data, response=response, reasoning=reasoning
909 )
910
911 return response, reasoning
912
913 @extension.extensible
914 async def call_chat_model_turn(
915 self,
916 messages: list[BaseMessage],
917 response_callback: Callable[[str, str], Awaitable[str | None]] | None = None,
918 reasoning_callback: Callable[[str, str], Awaitable[None]] | None = None,
919 background: bool = False,
920 explicit_caching: bool = True,
921 ) -> LLMResult:
922 model = self.get_chat_model()
923 model_kwargs = getattr(model, "kwargs", {}) if model else {}
924 if isinstance(model_kwargs, dict) and model_kwargs.get("responses_delete_on_chat_delete") is False:
925 self.set_data("responses_delete_on_chat_delete", False)
926 response_tools, name_map = build_responses_function_tools(self)
927 self.set_data(Agent.DATA_NAME_RESPONSES_TOOL_NAME_MAP, name_map)
928
929 call_data = {
930 "model": model,
931 "messages": messages,
932 "response_callback": response_callback,
933 "reasoning_callback": reasoning_callback,
934 "background": background,
935 "explicit_caching": explicit_caching,
936 "a0_responses_function_tools": response_tools,
937 }
938
939 previous_state = self._responses_state_for_model(model)
940 if previous_state:
941 history_counter = int(previous_state.get("history_counter", 0) or 0)
942 call_data["previous_response_id"] = previous_state.get("response_id", "")
943 call_data["responses_input_items"] = self._responses_input_items_since(
944 model,
945 history_counter,
946 )
947 call_data["responses_local_input_items"] = self._responses_prompt_input_items(
948 model,
949 messages,
950 )
951
952 await extension.call_extensions_async(
953 "chat_model_call_before", self, call_data=call_data
954 )
955
956 turn_kwargs = {
957 "a0_responses_function_tools": call_data.get(
958 "a0_responses_function_tools"
959 ),
960 "responses_local_input_items": call_data.get(
961 "responses_local_input_items"
962 ),
963 }
964 for key in (
965 "responses_builtin_tools",
966 "responses_state",
967 "previous_response_id",
968 "responses_input_items",
969 ):
970 if call_data.get(key) is not None:
971 turn_kwargs[key] = call_data.get(key)
972
973 llm_result = await call_data["model"].unified_turn(
974 messages=call_data["messages"],
975 reasoning_callback=call_data["reasoning_callback"],
976 response_callback=call_data["response_callback"],
977 rate_limiter_callback=(
978 self.rate_limiter_callback if not call_data["background"] else None
979 ),
980 explicit_caching=call_data["explicit_caching"],
981 **turn_kwargs,
982 )
983
984 downgraded = llm_result.capability.get("builtin_tool_downgrades")
985 if downgraded:
986 self.context.log.log(
987 type="info",
988 heading="Responses capability downgrade",
989 content=(
990 "Provider rejected Responses built-in tool(s); omitted: "
991 + ", ".join(str(item) for item in downgraded)
992 ),
993 )
994
995 await extension.call_extensions_async(
996 "chat_model_call_after",
997 self,
998 call_data=call_data,
999 response=llm_result.response,
1000 reasoning=llm_result.reasoning,
1001 )
1002
1003 return llm_result
1004
1005 def _responses_state_for_model(self, model: Any) -> dict[str, Any]:
1006 state = self.get_data(Agent.DATA_NAME_RESPONSES_STATE)
1007 if not isinstance(state, dict):
1008 return {}
1009 provider_model_key = str(getattr(model, "model_name", "") or "")
1010 if state.get("provider_model_key") != provider_model_key:
1011 return {}
1012 if not state.get("response_id"):
1013 return {}
1014 return state
1015
1016 def _responses_input_items_since(
1017 self, model: Any, sequence: int
1018 ) -> list[dict[str, Any]]:
1019 items: list[dict[str, Any]] = []
1020 for message in self.history.messages_since(sequence):
1021 items.extend(self._responses_input_items_for_message(model, message))
1022 return items
1023
1024 def _responses_input_items_for_message(
1025 self, model: Any, message: history.Message
1026 ) -> list[dict[str, Any]]:
1027 result = result_from_metadata(message.metadata)
1028 if result:
1029 if message.ai and result.output_items:
1030 return [item.to_dict() for item in result.output_items]
1031 if not message.ai and result.input_items:
1032 return [dict(item) for item in result.input_items]
1033
1034 output = message.output()
1035 langchain_messages = history.output_langchain(output)
1036 if hasattr(model, "_convert_messages"):
1037 converted = model._convert_messages(langchain_messages)
1038 return ResponsesTransport.input_from_messages(converted)
1039 return []
1040
1041 def _responses_prompt_input_items(
1042 self, model: Any, messages: list[BaseMessage]
1043 ) -> list[dict[str, Any]]:
1044 if not hasattr(model, "_convert_messages"):
1045 return []
1046 converted = model._convert_messages(messages)
1047 return ResponsesTransport.input_from_messages(converted)
1048
1049 def _remember_llm_result_state(
1050 self, llm_result: LLMResult, history_message: history.Message
1051 ) -> None:
1052 if not llm_result.response_id:
1053 return
1054 current = self.get_data(Agent.DATA_NAME_RESPONSES_STATE)
1055 response_ids = []
1056 if isinstance(current, dict) and isinstance(current.get("response_ids"), list):
1057 response_ids = [str(item) for item in current["response_ids"] if item]
1058 if llm_result.response_id not in response_ids:
1059 response_ids.append(llm_result.response_id)
1060 self.set_data(
1061 Agent.DATA_NAME_RESPONSES_STATE,
1062 {
1063 "response_id": llm_result.response_id,
1064 "previous_response_id": llm_result.previous_response_id,
1065 "provider_model_key": llm_result.provider_model_key,
1066 "history_counter": history_message.sequence,
1067 "response_ids": response_ids,
1068 },
1069 )
1070
1071 @extension.extensible
1072 async def rate_limiter_callback(
1073 self, message: str, key: str, total: int, limit: int
1074 ):
1075 # show the rate limit waiting in a progress bar, no need to spam the chat history
1076 self.context.log.set_progress(message, True)
1077 return False
1078
1079 @extension.extensible
1080 async def handle_intervention(self, progress: str = ""):
1081 await self.wait_if_paused()
1082 if (
1083 self.intervention
1084 ): # if there is an intervention message, but not yet processed
1085 msg = self.intervention
1086 self.intervention = None # reset the intervention message
1087 # If a tool was running, save its progress to history
1088 last_tool = self.loop_data.current_tool
1089 if last_tool:
1090 tool_progress = last_tool.progress.strip()
1091 if tool_progress:
1092 self.hist_add_tool_result(last_tool.name, tool_progress)
1093 last_tool.set_progress(None)
1094 if progress.strip():
1095 self.hist_add_ai_response(progress)
1096 # append the intervention message
1097 self.hist_add_user_message(msg, intervention=True)
1098 raise InterventionException(msg)
1099
1100 async def wait_if_paused(self):
1101 while self.context.paused:
1102 await asyncio.sleep(0.1)
1103
1104 async def process_llm_result_tools(self, llm_result: LLMResult):
1105 await self._log_response_builtin_items(llm_result)
1106 if llm_result.function_calls:
1107 for function_call in llm_result.function_calls:
1108 name_map = self.get_data(Agent.DATA_NAME_RESPONSES_TOOL_NAME_MAP)
1109 tool_name = original_tool_name(function_call.name, name_map)
1110 response_item_factory = lambda response, call=function_call: function_call_output_item(
1111 call.call_id,
1112 response.message,
1113 )
1114 result = await self._execute_tool_request(
1115 tool_name=tool_name,
1116 tool_args=function_call.arguments,
1117 message=llm_result.response,
1118 raw_tool_name=tool_name,
1119 responses_item_factory=response_item_factory,
1120 )
1121 if result:
1122 return result
1123 return None
1124 if llm_result.builtin_items and not llm_result.response:
1125 return None
1126 message = llm_result.response
1127 if not message and llm_result.reasoning:
1128 if (
1129 extract_tools.extract_tool_request(llm_result.reasoning) is not None
1130 or extract_tools.is_misformatted_tool_request(llm_result.reasoning)
1131 ):
1132 message = llm_result.reasoning
1133 if (
1134 llm_result.mode == "responses"
1135 and isinstance(message, str)
1136 and bool(message.strip())
1137 and extract_tools.extract_tool_request(message) is None
1138 and not extract_tools.is_misformatted_tool_request(message)
1139 ):
1140 return await self._execute_tool_request(
1141 tool_name="response",
1142 tool_args={"text": message},
1143 message=message,
1144 )
1145 return await self.process_tools(message)
1146
1147 async def _execute_tool_request(
1148 self,
1149 tool_name: str,
1150 tool_args: dict,
1151 message: str,
1152 raw_tool_name: str = "",
1153 responses_item_factory: Callable[[Any], dict[str, Any]] | None = None,
1154 ):
1155 raw_tool_name = raw_tool_name or tool_name
1156 tool_method = None
1157 tool = None
1158
1159 try:
1160 import helpers.mcp_handler as mcp_helper
1161
1162 mcp_tool_candidate = mcp_helper.MCPConfig.get_instance().get_tool(
1163 self, tool_name
1164 )
1165 if mcp_tool_candidate:
1166 tool = mcp_tool_candidate
1167 except ImportError:
1168 PrintStyle(
1169 background_color="black", font_color="yellow", padding=True
1170 ).print("MCP helper module not found. Skipping MCP tool lookup.")
1171 except Exception as e:
1172 PrintStyle(background_color="black", font_color="red", padding=True).print(
1173 f"Failed to get MCP tool '{tool_name}': {e}"
1174 )
1175
1176 if not tool:
1177 tool = self.get_tool(
1178 name=tool_name,
1179 method=tool_method,
1180 args=tool_args,
1181 message=message,
1182 loop_data=self.loop_data,
1183 )
1184
1185 if not tool:
1186 error_detail = (
1187 f"Tool '{raw_tool_name}' not found or could not be initialized."
1188 )
1189 wmsg = self.hist_add_warning(error_detail)
1190 PrintStyle(font_color="red", padding=True).print(error_detail)
1191 self.context.log.log(
1192 type="warning",
1193 content=f"{self.agent_name}: {error_detail}",
1194 id=wmsg.id,
1195 )
1196 return None
1197
1198 self.loop_data.current_tool = tool # type: ignore
1199 try:
1200 await self.handle_intervention()
1201
1202 await tool.before_execution(**tool_args)
1203 await self.handle_intervention()
1204
1205 await extension.call_extensions_async(
1206 "tool_execute_before",
1207 self,
1208 tool_args=tool_args or {},
1209 tool_name=tool_name,
1210 )
1211
1212 response = await tool.execute(**tool_args)
1213 await self.handle_intervention()
1214
1215 await extension.call_extensions_async(
1216 "tool_execute_after",
1217 self,
1218 response=response,
1219 tool_name=tool_name,
1220 )
1221
1222 if responses_item_factory:
1223 response.additional = {
1224 **(response.additional or {}),
1225 "_responses_output_item": responses_item_factory(response),
1226 }
1227
1228 await tool.after_execution(response)
1229 await self.handle_intervention()
1230
1231 if response.break_loop:
1232 self._clear_responses_pending_state()
1233 return response.message
1234 finally:
1235 self.loop_data.current_tool = None
1236 return None
1237
1238 async def _log_response_builtin_items(self, llm_result: LLMResult) -> None:
1239 for item in llm_result.builtin_items:
1240 if item.type == "computer_call":
1241 await self._handle_responses_computer_call(item.data)
1242 continue
1243 if item.type == "mcp_approval_request":
1244 self._handle_responses_mcp_approval_request(item.data)
1245 continue
1246 self.context.log.log(
1247 type="info",
1248 heading=f"Responses tool item: {item.type}",
1249 content=json.dumps(item.data, ensure_ascii=False, default=str),
1250 )
1251
1252 async def _handle_responses_computer_call(self, item: dict[str, Any]) -> None:
1253 safety_checks = item.get("pending_safety_checks") or item.get("safety_checks")
1254 if safety_checks:
1255 message = (
1256 "Responses computer_call requested safety-check acknowledgement. "
1257 "Agent Zero requires explicit user acknowledgement before executing it."
1258 )
1259 output_item = {
1260 "type": "computer_call_output",
1261 "call_id": str(item.get("call_id") or item.get("id") or ""),
1262 "output": {"type": "input_text", "text": message},
1263 }
1264 self.hist_add_tool_result(
1265 "computer_call",
1266 message,
1267 responses_item=output_item,
1268 )
1269 self.context.log.log(type="warning", content=message)
1270 return
1271
1272 args = self._computer_call_args(item)
1273 if not args:
1274 message = "Responses computer_call action is unsupported by Agent Zero."
1275 output_item = {
1276 "type": "computer_call_output",
1277 "call_id": str(item.get("call_id") or item.get("id") or ""),
1278 "output": {"type": "input_text", "text": message},
1279 }
1280 self.hist_add_tool_result(
1281 "computer_call",
1282 message,
1283 responses_item=output_item,
1284 )
1285 self.context.log.log(type="warning", content=message)
1286 return
1287
1288 if args.get("action") != "start_session" and not args.get("session_id"):
1289 session_id = str(
1290 self.get_data(Agent.DATA_NAME_RESPONSES_COMPUTER_SESSION) or ""
1291 )
1292 if session_id:
1293 args["session_id"] = session_id
1294
1295 response_item_factory = lambda response: self._computer_call_output_item(
1296 item,
1297 response,
1298 )
1299 result = await self._execute_tool_request(
1300 tool_name="computer_use_remote",
1301 tool_args=args,
1302 message=json.dumps(item, ensure_ascii=False, default=str),
1303 raw_tool_name="computer_call",
1304 responses_item_factory=response_item_factory,
1305 )
1306 _ = result
1307
1308 def _handle_responses_mcp_approval_request(self, item: dict[str, Any]) -> None:
1309 request_id = str(
1310 item.get("approval_request_id") or item.get("id") or item.get("call_id") or ""
1311 )
1312 message = (
1313 "Responses MCP approval request received. Agent Zero denied it because "
1314 "provider-hosted MCP approval requires explicit user approval."
1315 )
1316 output_item = {
1317 "type": "mcp_approval_response",
1318 "approval_request_id": request_id,
1319 "approve": False,
1320 }
1321 self.hist_add_tool_result(
1322 "mcp_approval_request",
1323 message,
1324 responses_item=output_item,
1325 )
1326 self.context.log.log(
1327 type="warning",
1328 heading="Responses MCP approval required",
1329 content=message,
1330 )
1331
1332 def _computer_call_args(self, item: dict[str, Any]) -> dict[str, Any]:
1333 action = item.get("action")
1334 action_data = dict(action) if isinstance(action, dict) else {}
1335 action_type = str(
1336 action_data.get("type")
1337 or action_data.get("action")
1338 or item.get("action_type")
1339 or ""
1340 ).strip().lower()
1341 args: dict[str, Any] = {}
1342
1343 if action_type in {"screenshot", "capture"}:
1344 args["action"] = "capture"
1345 elif action_type in {"move", "mousemove"}:
1346 args.update({"action": "move", "x": action_data.get("x"), "y": action_data.get("y")})
1347 elif action_type in {"click", "double_click"}:
1348 args.update(
1349 {
1350 "action": "click",
1351 "x": action_data.get("x"),
1352 "y": action_data.get("y"),
1353 "button": action_data.get("button", "left"),
1354 "count": 2 if action_type == "double_click" else action_data.get("count", 1),
1355 }
1356 )
1357 elif action_type == "scroll":
1358 args.update(
1359 {
1360 "action": "scroll",
1361 "dx": action_data.get("dx", action_data.get("scroll_x", 0)),
1362 "dy": action_data.get("dy", action_data.get("scroll_y", 0)),
1363 }
1364 )
1365 elif action_type in {"keypress", "key"}:
1366 args.update(
1367 {
1368 "action": "key",
1369 "keys": action_data.get("keys") or action_data.get("key"),
1370 }
1371 )
1372 elif action_type in {"type", "input_text"}:
1373 args.update({"action": "type", "text": action_data.get("text", "")})
1374 else:
1375 return {}
1376
1377 session_id = item.get("session_id") or action_data.get("session_id")
1378 if session_id:
1379 args["session_id"] = session_id
1380 return args
1381
1382 def _computer_call_output_item(
1383 self, source_item: dict[str, Any], response: Any
1384 ) -> dict[str, Any]:
1385 output: dict[str, Any] = {
1386 "type": "input_text",
1387 "text": str(getattr(response, "message", "") or ""),
1388 }
1389 additional = getattr(response, "additional", None)
1390 raw_content = additional.get("raw_content") if isinstance(additional, dict) else None
1391 if isinstance(raw_content, list):
1392 for content in raw_content:
1393 if not isinstance(content, dict):
1394 continue
1395 if content.get("type") != "image_url":
1396 continue
1397 image_url = content.get("image_url")
1398 url = image_url.get("url") if isinstance(image_url, dict) else image_url
1399 if url:
1400 output = {"type": "input_image", "image_url": url}
1401 break
1402
1403 session_id_match = re_search_session_id(str(getattr(response, "message", "") or ""))
1404 if session_id_match:
1405 self.set_data(Agent.DATA_NAME_RESPONSES_COMPUTER_SESSION, session_id_match)
1406
1407 return {
1408 "type": "computer_call_output",
1409 "call_id": str(source_item.get("call_id") or source_item.get("id") or ""),
1410 "output": output,
1411 }
1412
1413 def _clear_responses_pending_state(self) -> None:
1414 state = self.get_data(Agent.DATA_NAME_RESPONSES_STATE)
1415 if isinstance(state, dict):
1416 state = dict(state)
1417 state.pop("response_id", None)
1418 state.pop("previous_response_id", None)
1419 self.set_data(Agent.DATA_NAME_RESPONSES_STATE, state)
1420
1421 @extension.extensible
1422 async def process_tools(self, msg: str):
1423 # search for tool usage requests in agent message
1424 tool_request = extract_tools.extract_tool_request(msg)
1425
1426 raw_tool_name = ""
1427 tool_args = {}
1428
1429 # Only validate when extraction produced an object; None means no JSON tool
1430 # block was found - the misformat warning path below handles that.
1431 if tool_request is not None:
1432 try:
1433 await self.validate_tool_request(tool_request)
1434 raw_tool_name, tool_args = extract_tools.normalize_tool_request(
1435 tool_request
1436 )
1437 except ValueError:
1438 tool_request = None # treat structural validation errors as misformat
1439
1440 if tool_request is not None:
1441 tool_name = raw_tool_name # Initialize tool_name with raw_tool_name
1442 tool_method = None # Initialize tool_method
1443
1444 tool = None # Initialize tool to None
1445
1446 # Try getting tool from MCP first
1447 try:
1448 import helpers.mcp_handler as mcp_helper
1449
1450 mcp_tool_candidate = mcp_helper.MCPConfig.get_instance().get_tool(
1451 self, tool_name
1452 )
1453 if mcp_tool_candidate:
1454 tool = mcp_tool_candidate
1455 except ImportError:
1456 PrintStyle(
1457 background_color="black", font_color="yellow", padding=True
1458 ).print("MCP helper module not found. Skipping MCP tool lookup.")
1459 except Exception as e:
1460 PrintStyle(
1461 background_color="black", font_color="red", padding=True
1462 ).print(f"Failed to get MCP tool '{tool_name}': {e}")
1463
1464 # Fallback to local get_tool if MCP tool was not found or MCP lookup failed
1465 if not tool:
1466 tool = self.get_tool(
1467 name=tool_name,
1468 method=tool_method,
1469 args=tool_args,
1470 message=msg,
1471 loop_data=self.loop_data,
1472 )
1473
1474 if tool:
1475 tool.args = tool_args
1476 self.loop_data.current_tool = tool # type: ignore
1477 try:
1478 await self.handle_intervention()
1479
1480 # Call tool hooks for compatibility
1481 await tool.before_execution(**tool_args)
1482 await self.handle_intervention()
1483
1484 # Allow extensions to preprocess tool arguments
1485 await extension.call_extensions_async(
1486 "tool_execute_before",
1487 self,
1488 tool_args=tool_args or {},
1489 tool_name=tool_name,
1490 )
1491
1492 response = await tool.execute(**tool_args)
1493 await self.handle_intervention()
1494
1495 # Allow extensions to postprocess tool response
1496 await extension.call_extensions_async(
1497 "tool_execute_after",
1498 self,
1499 response=response,
1500 tool_name=tool_name,
1501 )
1502
1503 await tool.after_execution(response)
1504 await self.handle_intervention()
1505
1506 if response.break_loop:
1507 return response.message
1508 finally:
1509 self.loop_data.current_tool = None
1510 else:
1511 error_detail = (
1512 f"Tool '{raw_tool_name}' not found or could not be initialized."
1513 )
1514 wmsg = self.hist_add_warning(error_detail)
1515 PrintStyle(font_color="red", padding=True).print(error_detail)
1516 self.context.log.log(
1517 type="warning", content=f"{self.agent_name}: {error_detail}", id=wmsg.id
1518 )
1519 else:
1520 warning_msg_misformat = self.read_prompt("fw.msg_misformat.md")
1521 wmsg = self.hist_add_warning(warning_msg_misformat)
1522 PrintStyle(font_color="red", padding=True).print(warning_msg_misformat)
1523 self.context.log.log(
1524 type="warning",
1525 content=f"{self.agent_name}: Message misformat, no valid tool request found.",
1526 id=wmsg.id,
1527 )
1528
1529 @extension.extensible
1530 async def validate_tool_request(self, tool_request: Any):
1531 extract_tools.normalize_tool_request(tool_request)
1532
1533
1534
1535 async def handle_reasoning_stream(self, stream: str):
1536 await self.handle_intervention()
1537 await extension.call_extensions_async(
1538 "reasoning_stream",
1539 self,
1540 loop_data=self.loop_data,
1541 text=stream,
1542 )
1543
1544 async def handle_response_stream(self, stream: str):
1545 await self.handle_intervention()
1546 try:
1547 if len(stream) < 25:
1548 return # no reason to try
1549 response = DirtyJson.parse_string(stream)
1550 if isinstance(response, dict):
1551 await extension.call_extensions_async(
1552 "response_stream",
1553 self,
1554 loop_data=self.loop_data,
1555 text=stream,
1556 parsed=response,
1557 )
1558
1559 except Exception as e:
1560 pass
1561
1562 @extension.extensible
1563 def get_tool(
1564 self,
1565 name: str,
1566 method: str | None,
1567 args: dict,
1568 message: str,
1569 loop_data: LoopData | None,
1570 **kwargs,
1571 ):
1572 from tools.unknown import Unknown
1573 from helpers.tool import Tool
1574
1575 classes = []
1576
1577 # search for tools in agent's folder hierarchy
1578 paths = subagents.get_paths(self, "tools", name + ".py")
1579
1580 for path in paths:
1581 try:
1582 classes = extract_tools.load_classes_from_file(path, Tool) # type: ignore[arg-type]
1583 break
1584 except Exception:
1585 continue
1586
1587 tool_class = classes[0] if classes else Unknown
1588 return tool_class(
1589 agent=self,
1590 name=name,
1591 method=method,
1592 args=args,
1593 message=message,
1594 loop_data=loop_data,
1595 **kwargs,
1596 )
1597
1598
1599 def re_search_session_id(text: str) -> str:
1600 match = re.search(r"session_id=([A-Za-z0-9_.:-]+)", text or "")
1601 return match.group(1) if match else ""