main
py 600 lines 23.8 KB
Raw
1 import asyncio
2 import errno
3 from dataclasses import dataclass
4 import re
5 import shlex
6 import time
7
8 from helpers.tool import Tool, Response
9 from helpers import files, rfc_exchange, projects, runtime, secrets, settings
10 from helpers.print_style import PrintStyle
11 from helpers.strings import truncate_text as truncate_text_string
12 from helpers.messages import truncate_text as truncate_text_agent
13 from helpers import plugins
14
15 from plugins._code_execution.helpers.shell_local import LocalInteractiveSession
16 from plugins._code_execution.helpers.shell_ssh import SSHInteractiveSession
17
18
19 def _is_closed_pty_error(exc: BaseException) -> bool:
20 if isinstance(exc, RuntimeError):
21 message = str(exc)
22 if "TTYSpawn PTY is closed" in message or "TTYSpawn process has exited" in message:
23 return True
24 if isinstance(exc, OSError) and exc.errno in (errno.EBADF, errno.EIO, errno.EINVAL):
25 return True
26 cause = getattr(exc, "__cause__", None)
27 if cause and cause is not exc:
28 return _is_closed_pty_error(cause)
29 return False
30
31
32 def _group_multiline_command(command: str, powershell: bool = False) -> str:
33 body = command.rstrip("\n")
34 if "\n" not in body:
35 return body
36 opener = ". {" if powershell else "{"
37 return f"{opener}\n{body}\n}}"
38
39
40 @dataclass
41 class ShellWrap:
42 id: int
43 session: LocalInteractiveSession | SSHInteractiveSession
44 running: bool
45
46
47 @dataclass
48 class State:
49 ssh_enabled: bool
50 shells: dict[int, ShellWrap]
51
52
53 class CodeExecution(Tool):
54
55 async def execute(self, **kwargs) -> Response:
56
57 await self.agent.handle_intervention() # wait for intervention and handle it, if paused
58
59 runtime_arg = self.args.get("runtime", "").lower().strip()
60 session = int(self.args.get("session", 0))
61 self.allow_running = bool(self.args.get("allow_running", False))
62 reset = bool(self.args.get("reset", False) or runtime_arg == "reset")
63
64 cfg = _get_config(self.agent)
65
66 if runtime_arg == "python":
67 response = await self.execute_python_code(
68 cfg, code=self.args["code"], session=session, reset=reset
69 )
70 elif runtime_arg == "nodejs":
71 response = await self.execute_nodejs_code(
72 cfg, code=self.args["code"], session=session, reset=reset
73 )
74 elif runtime_arg == "terminal":
75 response = await self.execute_terminal_command(
76 cfg, command=self.args["code"], session=session, reset=reset
77 )
78 elif runtime_arg == "output":
79 response = await self.get_terminal_output(
80 cfg, session=session, timeouts=cfg["output_timeouts"]
81 )
82 elif runtime_arg == "reset":
83 response = await self.reset_terminal(cfg, session=session)
84 else:
85 response = self.agent.read_prompt(
86 "fw.code.runtime_wrong.md", runtime=runtime_arg
87 )
88
89 if not response:
90 response = self.agent.read_prompt(
91 "fw.code.info.md", info=self.agent.read_prompt("fw.code.no_output.md")
92 )
93 return Response(message=response, break_loop=False)
94
95 def get_log_object(self):
96 import uuid
97 return self.agent.context.log.log(
98 type="code_exe",
99 heading=self.get_heading(),
100 content="",
101 kvps=self.args,
102 id=str(uuid.uuid4()),
103 )
104
105 def get_heading(self, text: str = ""):
106 if not text:
107 text = f"{self.name} - {self.args['runtime'] if 'runtime' in self.args else 'unknown'}"
108 session = self.args.get("session", None)
109 session_text = f"[{session}] " if session or session == 0 else ""
110 return f"icon://terminal {session_text}{truncate_text_string(text, 200)}"
111
112 async def after_execution(self, response, **kwargs):
113 self.agent.hist_add_tool_result(self.name, response.message, id=self.log.id if self.log else "", **(response.additional or {}))
114
115 async def prepare_state(self, cfg: dict, reset=False, session: int | None = None):
116 self.state: State | None = self.agent.get_data("_cet_state")
117 ssh_enabled = cfg["ssh_enabled"]
118
119 # always reset state when ssh_enabled changes
120 if not self.state or self.state.ssh_enabled != ssh_enabled:
121 shells: dict[int, ShellWrap] = {}
122 else:
123 shells = self.state.shells.copy()
124
125 # Only reset the specified session if provided
126 if reset and session is not None and session in shells:
127 await shells[session].session.close()
128 del shells[session]
129 elif reset and not session:
130 # Close all sessions if full reset requested
131 for s in list(shells.keys()):
132 await shells[s].session.close()
133 shells = {}
134
135 # initialize local or remote interactive shell interface for session if needed
136 if session is not None and session not in shells:
137 cwd = await self.ensure_cwd()
138 if ssh_enabled:
139 ssh_pass = await _resolve_ssh_pass(cfg["ssh_pass"])
140 shell = SSHInteractiveSession(
141 self.agent.context.log,
142 cfg["ssh_addr"],
143 cfg["ssh_port"],
144 cfg["ssh_user"],
145 ssh_pass,
146 cwd=cwd,
147 )
148 else:
149 shell = LocalInteractiveSession(cwd=cwd)
150
151 shells[session] = ShellWrap(id=session, session=shell, running=False)
152 await shell.connect()
153
154 self.state = State(shells=shells, ssh_enabled=ssh_enabled)
155 self.agent.set_data("_cet_state", self.state)
156 return self.state
157
158 async def execute_python_code(self, cfg: dict, session: int, code: str, reset: bool = False):
159 escaped_code = shlex.quote(code)
160 command = f"ipython -c {escaped_code}"
161 prefix = "python> " + self.format_command_for_output(code) + "\n\n"
162 return await self.terminal_session(cfg, session, command, reset, prefix)
163
164 async def execute_nodejs_code(self, cfg: dict, session: int, code: str, reset: bool = False):
165 escaped_code = shlex.quote(code)
166 command = f"node /exe/node_eval.js {escaped_code}"
167 prefix = "node> " + self.format_command_for_output(code) + "\n\n"
168 return await self.terminal_session(cfg, session, command, reset, prefix)
169
170 async def execute_terminal_command(
171 self, cfg: dict, session: int, command: str, reset: bool = False
172 ):
173 prefix = (
174 ("bash>" if not runtime.is_windows() or cfg["ssh_enabled"] else "PS>")
175 + self.format_command_for_output(command)
176 + "\n\n"
177 )
178 command = _group_multiline_command(
179 command, powershell=runtime.is_windows() and not cfg["ssh_enabled"]
180 )
181 return await self.terminal_session(cfg, session, command, reset, prefix)
182
183 async def terminal_session(
184 self, cfg: dict, session: int, command: str, reset: bool = False, prefix: str = "", timeouts: dict | None = None
185 ):
186 self.state = await self.prepare_state(cfg, reset=reset, session=session)
187
188 await self.agent.handle_intervention() # wait for intervention and handle it, if paused
189
190 # Check if session is running and handle it
191 if not self.allow_running:
192 if response := await self.handle_running_session(cfg, session):
193 return response
194
195 # A strict-mode command can terminate the persistent shell itself.
196 # Recreate such a session lazily before accepting the next command.
197 if self.state.shells[session].session.is_terminated():
198 await self.prepare_state(cfg, reset=True, session=session)
199
200 # try again on lost connection
201 for i in range(2):
202 try:
203 self.state.shells[session].running = True
204 await self.state.shells[session].session.send_command(command)
205
206 locl = (
207 " (local)"
208 if isinstance(self.state.shells[session].session, LocalInteractiveSession)
209 else (
210 " (remote)"
211 if isinstance(self.state.shells[session].session, SSHInteractiveSession)
212 else " (unknown)"
213 )
214 )
215
216 PrintStyle(
217 background_color="white", font_color="#1B4F72", bold=True
218 ).print(f"{self.agent.agent_name} code execution output{locl}")
219 return await self.get_terminal_output(
220 cfg,
221 session=session,
222 prefix=prefix,
223 timeouts=(timeouts or cfg["code_exec_timeouts"]),
224 )
225
226 except Exception as e:
227 if _is_closed_pty_error(e) and i == 0:
228 PrintStyle.warning(f"Terminal session {session} was closed; resetting and retrying once.")
229 await self.prepare_state(cfg, reset=True, session=session)
230 continue
231 PrintStyle.error(str(e))
232 raise
233
234 def format_command_for_output(self, command: str):
235 short_cmd = command[:250]
236 short_cmd = " ".join(short_cmd.split())
237 short_cmd = secrets.get_secrets_manager(self.agent.context).mask_values(short_cmd)
238 short_cmd = truncate_text_string(short_cmd, 100)
239 return f"{short_cmd}"
240
241 async def get_terminal_output(
242 self,
243 cfg: dict,
244 session=0,
245 reset_full_output=True,
246 first_output_timeout=30,
247 between_output_timeout=15,
248 dialog_timeout=5,
249 max_exec_timeout=240,
250 sleep_time=0.5,
251 prefix="",
252 timeouts: dict | None = None,
253 ):
254 self.state = await self.prepare_state(cfg, session=session)
255
256 # Override timeouts if a dict is provided
257 if timeouts:
258 first_output_timeout = timeouts.get("first_output_timeout", first_output_timeout)
259 between_output_timeout = timeouts.get("between_output_timeout", between_output_timeout)
260 dialog_timeout = timeouts.get("dialog_timeout", dialog_timeout)
261 max_exec_timeout = timeouts.get("max_exec_timeout", max_exec_timeout)
262
263 prompt_patterns = cfg["prompt_patterns"]
264 dialog_patterns = cfg["dialog_patterns"]
265
266 start_time = time.time()
267 last_output_time = start_time
268 full_output = ""
269 truncated_output = ""
270 got_output = False
271
272 # if prefix, log right away
273 if prefix:
274 self.log.update(content=prefix)
275
276 while True:
277 await asyncio.sleep(sleep_time)
278 try:
279 full_output, partial_output = await self.state.shells[session].session.read_output(
280 timeout=1, reset_full_output=reset_full_output
281 )
282 except Exception as e:
283 if _is_closed_pty_error(e):
284 await self.prepare_state(cfg, reset=True, session=session)
285 self.mark_session_idle(session)
286 sysinfo = "Terminal session was closed and has been reset. Please run the command again."
287 response = self.agent.read_prompt("fw.code.info.md", info=sysinfo)
288 self.log.update(content=prefix + response)
289 return response
290 raise
291 reset_full_output = False # only reset once
292
293 await self.agent.handle_intervention()
294
295 now = time.time()
296 if partial_output:
297 PrintStyle(font_color="#85C1E9").stream(partial_output)
298 truncated_output = self.fix_full_output(full_output)
299 await self.set_progress(truncated_output)
300 heading = self.get_heading_from_output(truncated_output, 0)
301 self.log.update(content=prefix + truncated_output, heading=heading)
302 last_output_time = now
303 got_output = True
304
305 # ``set -e``, ``exit``, or a lost SSH channel can end the managed
306 # shell without ever producing another prompt. Treat that process
307 # or channel termination as a definitive command end.
308 shell = self.state.shells[session].session
309 if shell.is_terminated():
310 exit_code = shell.get_exit_code()
311 status = f" with exit code {exit_code}" if exit_code is not None else ""
312 sysinfo = self.agent.read_prompt(
313 "fw.code.shell_exit.md", status=status
314 )
315 response = self.agent.read_prompt("fw.code.info.md", info=sysinfo)
316 if truncated_output:
317 response = truncated_output + "\n\n" + response
318 PrintStyle.warning(sysinfo)
319 heading = self.get_heading_from_output(truncated_output, 0, True)
320 self.log.update(content=prefix + response, heading=heading)
321 self.mark_session_idle(session)
322 return response
323
324 if partial_output:
325 # Check for shell prompt at the end of output
326 last_lines = (
327 truncated_output.splitlines()[-3:] if truncated_output else []
328 )
329 last_lines.reverse()
330 for idx, line in enumerate(last_lines):
331 line = line.strip()
332 line = line if len(line) <= 500 else line[:250] + line[-250:] # only check start and end on long lines
333 for pat in prompt_patterns:
334 if pat.search(line):
335 PrintStyle.info(
336 "Detected shell prompt, returning output early."
337 )
338 last_lines.reverse()
339 heading = self.get_heading_from_output(
340 "\n".join(last_lines), idx + 1, True
341 )
342 self.log.update(heading=heading)
343 self.mark_session_idle(session)
344 return truncated_output
345
346 # Check for max execution time
347 if now - start_time > max_exec_timeout:
348 sysinfo = self.agent.read_prompt(
349 "fw.code.max_time.md", timeout=max_exec_timeout
350 )
351 response = self.agent.read_prompt("fw.code.info.md", info=sysinfo)
352 if truncated_output:
353 response = truncated_output + "\n\n" + response
354 PrintStyle.warning(sysinfo)
355 heading = self.get_heading_from_output(truncated_output, 0)
356 self.log.update(content=prefix + response, heading=heading)
357 return response
358
359 # Waiting for first output
360 if not got_output:
361 if now - start_time > first_output_timeout:
362 sysinfo = self.agent.read_prompt(
363 "fw.code.no_out_time.md", timeout=first_output_timeout
364 )
365 response = self.agent.read_prompt("fw.code.info.md", info=sysinfo)
366 PrintStyle.warning(sysinfo)
367 self.log.update(content=prefix + response)
368 return response
369 else:
370 # Waiting for more output after first output
371 if now - last_output_time > between_output_timeout:
372 sysinfo = self.agent.read_prompt(
373 "fw.code.pause_time.md", timeout=between_output_timeout
374 )
375 response = self.agent.read_prompt("fw.code.info.md", info=sysinfo)
376 if truncated_output:
377 response = truncated_output + "\n\n" + response
378 PrintStyle.warning(sysinfo)
379 heading = self.get_heading_from_output(truncated_output, 0)
380 self.log.update(content=prefix + response, heading=heading)
381 return response
382
383 # potential dialog detection
384 if now - last_output_time > dialog_timeout:
385 last_lines = (
386 truncated_output.splitlines()[-2:] if truncated_output else []
387 )
388 for line in last_lines:
389 for pat in dialog_patterns:
390 if pat.search(line.strip()):
391 PrintStyle.info(
392 "Detected dialog prompt, returning output early."
393 )
394
395 sysinfo = self.agent.read_prompt(
396 "fw.code.pause_dialog.md", timeout=dialog_timeout
397 )
398 response = self.agent.read_prompt(
399 "fw.code.info.md", info=sysinfo
400 )
401 if truncated_output:
402 response = truncated_output + "\n\n" + response
403 PrintStyle.warning(sysinfo)
404 heading = self.get_heading_from_output(
405 truncated_output, 0
406 )
407 self.log.update(
408 content=prefix + response, heading=heading
409 )
410 return response
411
412 async def handle_running_session(
413 self,
414 cfg: dict,
415 session=0,
416 reset_full_output=True,
417 prefix=""
418 ):
419 if not self.state or session not in self.state.shells:
420 return None
421 if not self.state.shells[session].running:
422 return None
423
424 prompt_patterns = cfg["prompt_patterns"]
425 dialog_patterns = cfg["dialog_patterns"]
426
427 try:
428 full_output, _ = await self.state.shells[session].session.read_output(
429 timeout=1, reset_full_output=reset_full_output
430 )
431 except Exception as e:
432 if _is_closed_pty_error(e):
433 await self.prepare_state(cfg, reset=True, session=session)
434 self.mark_session_idle(session)
435 return None
436 raise
437 truncated_output = self.fix_full_output(full_output)
438 await self.set_progress(truncated_output)
439 heading = self.get_heading_from_output(truncated_output, 0)
440
441 if self.state.shells[session].session.is_terminated():
442 self.mark_session_idle(session)
443 return None
444
445 last_lines = (
446 truncated_output.splitlines()[-3:] if truncated_output else []
447 )
448 last_lines.reverse()
449 for line in last_lines:
450 for pat in prompt_patterns:
451 if pat.search(line.strip()):
452 PrintStyle.info(
453 "Detected shell prompt, returning output early."
454 )
455 self.mark_session_idle(session)
456 return None
457
458 has_dialog = False
459 for line in last_lines:
460 for pat in dialog_patterns:
461 if pat.search(line.strip()):
462 has_dialog = True
463 break
464 if has_dialog:
465 break
466
467 if has_dialog:
468 sys_info = self.agent.read_prompt("fw.code.pause_dialog.md", timeout=1)
469 else:
470 sys_info = self.agent.read_prompt("fw.code.running.md", session=session)
471
472 response = self.agent.read_prompt("fw.code.info.md", info=sys_info)
473 if truncated_output:
474 response = truncated_output + "\n\n" + response
475 PrintStyle(font_color="#FFA500", bold=True).print(response)
476 self.log.update(content=prefix + response, heading=heading)
477 return response
478
479 def mark_session_idle(self, session: int = 0):
480 if self.state and session in self.state.shells:
481 self.state.shells[session].running = False
482
483 async def reset_terminal(self, cfg: dict, session=0, reason: str | None = None):
484 if reason:
485 PrintStyle(font_color="#FFA500", bold=True).print(
486 f"Resetting terminal session {session}... Reason: {reason}"
487 )
488 else:
489 PrintStyle(font_color="#FFA500", bold=True).print(
490 f"Resetting terminal session {session}..."
491 )
492
493 await self.prepare_state(cfg, reset=True, session=session)
494 response = self.agent.read_prompt(
495 "fw.code.info.md", info=self.agent.read_prompt("fw.code.reset.md")
496 )
497 self.log.update(content=response)
498 return response
499
500 def get_heading_from_output(self, output: str, skip_lines=0, done=False):
501 done_icon = " icon://done_all" if done else ""
502
503 if not output:
504 return self.get_heading() + done_icon
505
506 lines = output.splitlines()
507 for i in range(len(lines) - skip_lines - 1, -1, -1):
508 line = lines[i].strip()
509 if not line:
510 continue
511 return self.get_heading(line) + done_icon
512
513 return self.get_heading() + done_icon
514
515 def fix_full_output(self, output: str):
516 output = re.sub(r"(?<!\\)\\x[0-9A-Fa-f]{2}", "", output)
517 output = truncate_text_agent(agent=self.agent, output=output, threshold=1000000)
518 return output
519
520 async def ensure_cwd(self) -> str | None:
521 project_name = projects.get_context_project_name(self.agent.context)
522 if project_name:
523 path = projects.get_project_folder(project_name)
524 else:
525 set = settings.get_settings()
526 path = set.get("workdir_path")
527
528 if not path:
529 return None
530
531 normalized = files.normalize_a0_path(path)
532 await runtime.call_development_function(make_dir, normalized)
533 return normalized
534
535
536 # ------------------------------------------------------------------
537 # Internal
538 # ------------------------------------------------------------------
539
540 def _resolve_ssh_enabled(raw_value) -> bool:
541 val = str(raw_value).strip().lower()
542 if val == "auto":
543 return not runtime.is_dockerized()
544 return val in ("true", "1", "yes", "on")
545
546
547 def _resolve_ssh_addr(cfg_addr: str) -> str:
548 if cfg_addr:
549 return cfg_addr
550 set = settings.get_settings()
551 host = set.get("rfc_url", "localhost")
552 if "//" in host:
553 host = host.split("//")[1]
554 if ":" in host:
555 host = host.split(":")[0]
556 if host.endswith("/"):
557 host = host.rstrip("/")
558 return host or "localhost"
559
560
561 async def _resolve_ssh_pass(cfg_pass: str) -> str:
562 if cfg_pass:
563 return cfg_pass
564 return await rfc_exchange.get_root_password()
565
566
567 def _parse_patterns(raw, flags=0) -> list[re.Pattern]:
568 lines = [str(p) for p in raw] if isinstance(raw, list) else str(raw).splitlines()
569 return [re.compile(p.strip(), flags) for p in lines if p.strip()]
570
571
572 _TIMEOUT_KEYS = ("first_output_timeout", "between_output_timeout", "max_exec_timeout", "dialog_timeout")
573
574
575 def _parse_timeouts(cfg: dict, prefix: str, defaults: tuple[int, ...]) -> dict:
576 return {
577 key: int(cfg.get(f"{prefix}_{key}", default))
578 for key, default in zip(_TIMEOUT_KEYS, defaults)
579 }
580
581
582 def _get_config(agent) -> dict:
583 cfg = plugins.get_plugin_config("_code_execution", agent=agent) or {}
584
585 return {
586 "ssh_enabled": _resolve_ssh_enabled(cfg.get("ssh_enabled", "auto")),
587 "ssh_addr": _resolve_ssh_addr(str(cfg.get("ssh_addr", ""))),
588 "ssh_port": int(cfg.get("ssh_port", 55022)),
589 "ssh_user": str(cfg.get("ssh_user", "root")),
590 "ssh_pass": str(cfg.get("ssh_pass", "")),
591 "code_exec_timeouts": _parse_timeouts(cfg, "code_exec", (30, 15, 240, 5)),
592 "output_timeouts": _parse_timeouts(cfg, "output", (120, 60, 600, 5)),
593 "prompt_patterns": _parse_patterns(cfg.get("prompt_patterns", "")),
594 "dialog_patterns": _parse_patterns(cfg.get("dialog_patterns", ""), re.IGNORECASE),
595 }
596
597
598 def make_dir(path: str):
599 import os
600 os.makedirs(path, exist_ok=True)