diff --git a/CLAUDE.md b/CLAUDE.md index ea7c55a8d..da3a0fd5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,7 +55,7 @@ The project uses a `src/smolagents/` layout. All source code is under `src/smola - `prompts/` - YAML prompt templates for different agent types ### Beyond Python (BP) Extensions -- `bp_cli.py` - Interactive CLI (`bpsa`): REPL and one-shot modes with CodeAgent, slash commands, token tracking +- `bp_cli.py` - Interactive CLI (`bpsa`): REPL and one-shot modes with CodeAgent, slash commands, token tracking, shell escapes (`!`, `!!`, `!!!`), `/alias`, `/redo`, auto-save - `bp_tools.py` - Extended tool library: file I/O, source code analysis, OS commands, multi-language support (24+ languages including Pascal, PHP, C++, Java, Go, Rust) - `bp_thinkers.py` - `Thinker` agent with multi-step reasoning (thoughts/plans/code sections) - `bp_executors.py` - `LocalExecExecutor` for direct Python execution via `exec()` diff --git a/CLI.md b/CLI.md index d0558ce05..0016e0384 100644 --- a/CLI.md +++ b/CLI.md @@ -108,6 +108,14 @@ Use `prompt_toolkit` for: - Ctrl+C handling (cancel current input, not exit) - Autocomplete (slash commands) +### Shell Escapes + +| Command | Description | +|---------|-------------| +| `!` | Run an OS command directly (agent does not see the output) | +| `!!` | Run an OS command; output is appended to the next prompt sent to the agent | +| `!!!` | Run an OS command and immediately send the output to the agent for analysis | + ### Step Display - Show intermediate code/thoughts as they happen (streaming) @@ -126,12 +134,14 @@ Use `prompt_toolkit` for: | `/compression-max-uncompressed-steps ` | Change max_uncompressed_steps | | `/compression-model ` | Switch compression model | | `/exit` | Exit the REPL | -| `/file ` | Load a file's content as the prompt | | `/help` | Show available commands and brief descriptions | | `/load-instructions` | Load agent instruction files into next prompt | | `/plan [on\|off\|N]` | Toggle or set planning interval (default: 22) | | `/pwd` | Show current working directory | -| `/run ` | Execute a Python script | +| `/repeat ` | Run the same prompt N times, each on a fresh agent with current context | +| `/repeat-prompt ` | Run a prompt file N times, each on a fresh agent with current context | +| `/run-prompt ` | Load a file's content as the prompt | +| `/run-py ` | Execute a Python script | | `/save ` | Save the last answer to a file | | `/save-step ` | Save full content of step N to a file | | `/session-load ` | Load a session from a JSON file | @@ -141,8 +151,8 @@ Use `prompt_toolkit` for: | `/show-stats` | Show session statistics (token usage, time) | | `/show-step ` | Show full content of a specific step | | `/show-steps` | Show one-line summary of all memory steps | -| `/steps ` | Change max_steps for the agent | -| `/tools` | List all loaded tools | +| `/set-max-steps ` | Change max_steps for the agent | +| `/show-tools` | List all loaded tools | | `/undo-steps [N]` | Remove last N steps from memory (default: 1) | | `/verbose` | Toggle verbose output | diff --git a/README.md b/README.md index c4a262c20..bb180dbad 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,23 @@ $ bpsa --load-instructions # Load CLAUDE.md, AGENTS.md, etc. at startup $ bpsa --browser # Enable Playwright browser integration ``` -The REPL supports command history, tab completion for slash commands, and multi-line input via Alt+Enter. Use `/session-save ` and `/session-load ` to persist and restore sessions across restarts. +The REPL supports command history, tab completion for slash commands, and multi-line input via Alt+Enter. Use `/session-save ` and `/session-load ` to persist and restore sessions across restarts. You can also launch `ad-infinitum` from within the REPL via `!ad-infinitum ...`. Type `/help` to see all available commands. + +#### Shell commands from the REPL + +| Prefix | Description | +|--------|-------------| +| `!` | Run an OS command directly (agent does not see the output) | +| `!!` | Run an OS command with streaming output; output is appended to the next prompt sent to the agent | +| `!!!` | Run an OS command and immediately send the output to the agent for analysis | + +#### Aliases + +Define command aliases with `/alias ` (e.g., `/alias gs !!git status`). Aliases are saved to `~/.bpsa_aliases` and persist across sessions. Use `/alias` to list all and `/alias -d ` to delete. + +#### Auto-save + +Sessions are automatically saved every 5 turns to `~/.bpsa_autosave.json`. Configure the interval with the `BPSA_AUTOSAVE_INTERVAL` environment variable (set to 0 to disable). ## CLI (`ad-infinitum`) diff --git a/src/smolagents/agents.py b/src/smolagents/agents.py index b8f104a5a..ce34a6c36 100644 --- a/src/smolagents/agents.py +++ b/src/smolagents/agents.py @@ -687,19 +687,23 @@ def _finalize_step(self, memory_step: ActionStep | PlanningStep): def _handle_max_steps_reached(self, task: str) -> Any: action_step_start_time = time.time() - final_answer = self.provide_final_answer(task) + # BP: replaced forced LLM final answer with a static message to save tokens + # final_answer = self.provide_final_answer(task) + message = f"Max steps reached ({self.max_steps}/{self.max_steps}) - should I continue?" final_memory_step = ActionStep( step_number=self.step_number, error=AgentMaxStepsError("Reached max steps.", self.logger), timing=Timing(start_time=action_step_start_time, end_time=time.time()), - token_usage=final_answer.token_usage, + # token_usage=final_answer.token_usage, actionstep_id=self._next_actionstep_id, ) self._next_actionstep_id += 1 - final_memory_step.action_output = final_answer.content + # final_memory_step.action_output = final_answer.content + final_memory_step.action_output = message self._finalize_step(final_memory_step) self.memory.steps.append(final_memory_step) - return final_answer.content + # return final_answer.content + return message def _generate_planning_step( self, task, is_first_step: bool, step: int @@ -1431,6 +1435,7 @@ def initialize_system_prompt(self) -> str: "tools": self.tools, "managed_agents": self.managed_agents, "custom_instructions": self.instructions, + "model_id": getattr(self.model, 'model_id', 'unknown') or 'unknown', }, ) return system_prompt @@ -1810,6 +1815,7 @@ def initialize_system_prompt(self) -> str: "custom_instructions": self.instructions, "code_block_opening_tag": self.code_block_tags[0], "code_block_closing_tag": self.code_block_tags[1], + "model_id": getattr(self.model, 'model_id', 'unknown') or 'unknown', }, ) return system_prompt diff --git a/src/smolagents/bp_ad_infinitum.py b/src/smolagents/bp_ad_infinitum.py index 6cdbe7754..7caae22ca 100644 --- a/src/smolagents/bp_ad_infinitum.py +++ b/src/smolagents/bp_ad_infinitum.py @@ -29,7 +29,7 @@ BPSA_PLAN_INTERVAL - Planning interval (default: None = off) BPSA_MAX_STEPS - Max steps per agent run (default: 200) BPSA_COOLDOWN - Seconds to wait between cycles (default: 0) - BPSA_INJECT_FOLDER - Inject directory tree (default: false, true = cwd, or a path) + BPSA_INJECT_FOLDER - Inject directory tree (default: true = cwd, false = off, or a path) """ import glob @@ -177,19 +177,8 @@ def get_int_env(name: str, default: int) -> int: def inject_tree(folder: str) -> str: """Generate directory tree string to append to task prompts.""" - from smolagents.bp_tools import list_directory_tree - tree = list_directory_tree(folder_path=folder, add_function_signatures=True) - return ( - "\nThis is the result of list_directory_tree:\n\n" - + tree - + "\n\n" - "The contents of is VERY important to you. " - "From , you can get a general view/current state of the project:\n" - "* From the md files, if they exist, you can find the existing section titles " - "and have a general idea of the md file contents.\n" - "* For source code files, if they exist, you can find class and method names " - "so you can also develop a general idea of their contents.\n" - ) + from smolagents.bp_tools import inject_tree as _inject_tree + return _inject_tree(folder) def run_script(task: TaskItem) -> subprocess.CompletedProcess: @@ -368,9 +357,9 @@ def main(): max_steps = get_int_env("BPSA_MAX_STEPS", 200) cooldown = get_int_env("BPSA_COOLDOWN", 0) tree_folder_raw = get_env("BPSA_INJECT_FOLDER") - if tree_folder_raw is None or tree_folder_raw.lower() == "false": + if tree_folder_raw is not None and tree_folder_raw.lower() == "false": tree_folder = None - elif tree_folder_raw.lower() == "true": + elif tree_folder_raw is None or tree_folder_raw.lower() == "true": tree_folder = os.getcwd() else: tree_folder = tree_folder_raw diff --git a/src/smolagents/bp_cli.py b/src/smolagents/bp_cli.py index 72307129f..0f4e917b5 100644 --- a/src/smolagents/bp_cli.py +++ b/src/smolagents/bp_cli.py @@ -24,6 +24,7 @@ import time from dotenv import load_dotenv +from smolagents.utils import truncate_content from rich.console import Console from rich.markdown import Markdown from rich.panel import Panel @@ -445,14 +446,54 @@ def print_banner(model_id: str, server_model: str, tool_count: int): console.print("[dim]Type /help for commands, /verbose to toggle verbosity, /exit to quit.[/]\n") +def _run_shell_streaming(shell_cmd: str) -> str: + """Run a shell command, streaming output to the terminal and returning the full output.""" + output_lines = [] + try: + proc = subprocess.Popen(shell_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + for line in proc.stdout: + print(line, end="") + output_lines.append(line) + proc.wait() + except KeyboardInterrupt: + proc.kill() + proc.wait() + console.print("\n[dim]Command interrupted.[/]") + return "".join(output_lines) + + +ALIASES_FILE = os.path.expanduser("~/.bpsa_aliases") + + +def _load_aliases() -> dict: + """Load aliases from ~/.bpsa_aliases.""" + aliases = {} + if os.path.isfile(ALIASES_FILE): + with open(ALIASES_FILE) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#"): + parts = line.split(None, 1) + if len(parts) == 2: + aliases[parts[0]] = parts[1] + return aliases + + +def _save_aliases(aliases: dict): + """Save aliases to ~/.bpsa_aliases.""" + with open(ALIASES_FILE, "w") as f: + for name, value in sorted(aliases.items()): + f.write(f"{name} {value}\n") + + SLASH_COMMANDS = [ - "/auto-approve", "/cd", "/clear", "/compress", "/compression", + "/alias", "/auto-approve", "/cd", "/clear", "/compress", "/compression", "/compression-keep-recent-steps", "/compression-max-uncompressed-steps", - "/compression-model", "/exit", "/file", "/help", - "/load-instructions", "/plan", "/pwd", "/run", "/save", + "/compression-model", "/exit", "/help", + "/load-instructions", "/plan", "/pwd", "/redo", "/repeat", "/repeat-prompt", "/run-prompt", "/run-py", "/save", "/session-load", "/session-save", "/show-compression-stats", "/show-memory-stats", "/show-stats", - "/save-step", "/show-step", "/show-steps", "/steps", "/tools", "/undo-steps", "/verbose", + "/save-step", "/set-max-steps", "/show-step", "/show-steps", "/show-tools", "/undo-steps", "/verbose", ] @@ -460,6 +501,10 @@ def print_help(): table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2)) table.add_column("Command", style="bold cyan") table.add_column("Description") + table.add_row("!", "Run an OS command directly (agent does not see the output)") + table.add_row("!!", "Run an OS command; output is appended to the next prompt sent to the agent") + table.add_row("!!!", "Run an OS command and immediately send the output to the agent for analysis") + table.add_row("/alias ", "Define alias (saved to ~/.bpsa_aliases). No args=list, -d =delete") table.add_row("/auto-approve \[on|off]", "Toggle or set auto-approve for tag execution") table.add_row("/cd ", "Change working directory") table.add_row("/clear", "Clear screen, reset agent and conversation history") @@ -469,12 +514,15 @@ def print_help(): table.add_row("/compression-max-uncompressed-steps ", "Change max_uncompressed_steps") table.add_row("/compression-model ", "Switch compression model") table.add_row("/exit", "Exit the REPL") - table.add_row("/file ", "Load a file's content as the prompt") table.add_row("/help", "Show this help message") table.add_row("/load-instructions", "Load agent instruction files into next prompt") table.add_row("/plan \[on|off|N]", "Toggle or set planning interval (default: 22)") table.add_row("/pwd", "Show current working directory") - table.add_row("/run ", "Execute a Python script in the agent's executor") + table.add_row("/redo", "Re-run the last prompt (undo last steps and run again)") + table.add_row("/repeat ", "Run the same prompt N times, each on a fresh agent with current context") + table.add_row("/repeat-prompt ", "Run a prompt file N times, each on a fresh agent with current context") + table.add_row("/run-prompt ", "Load a file's content as the prompt") + table.add_row("/run-py ", "Execute a Python script in the agent's executor") table.add_row("/save ", "Save the last answer to a file") table.add_row("/save-step ", "Save full content of step N to a file") table.add_row("/session-load ", "Load a session from a JSON file") @@ -484,8 +532,8 @@ def print_help(): table.add_row("/show-step ", "Show full content of a specific step") table.add_row("/show-steps", "Show one-line summary of all memory steps") table.add_row("/show-stats", "Show session statistics") - table.add_row("/steps ", "Change max_steps for the agent") - table.add_row("/tools", "List all loaded tools") + table.add_row("/set-max-steps ", "Change max_steps for the agent") + table.add_row("/show-tools", "List all loaded tools") table.add_row("/undo-steps \[N]", "Remove last N steps from memory (default: 1)") table.add_row("/verbose", "Toggle verbose output") console.print(table) @@ -616,7 +664,7 @@ def load_file_as_prompt(args: str) -> str | None: """Load a file's content to use as a prompt.""" filepath = args.strip() if not filepath: - console.print("[yellow]Usage: /file [/]") + console.print("[yellow]Usage: /run-prompt [/]") return None filepath = os.path.expanduser(filepath) if not os.path.isabs(filepath): @@ -639,7 +687,7 @@ def change_steps(agent, args: str): args = args.strip() if not args: console.print(f"[cyan]Current max_steps: {agent.max_steps}[/]") - console.print("[dim]Usage: /steps [/]") + console.print("[dim]Usage: /set-max-steps [/]") return try: n = int(args) @@ -649,14 +697,14 @@ def change_steps(agent, args: str): agent.max_steps = n console.print(f"[green]max_steps set to {n}[/]") except ValueError: - console.print("[red]Invalid number. Usage: /steps [/]") + console.print("[red]Invalid number. Usage: /set-max-steps [/]") def run_script(agent, args: str): """Execute a Python script in the agent's executor.""" filepath = args.strip() if not filepath: - console.print("[yellow]Usage: /run [/]") + console.print("[yellow]Usage: /run-py [/]") return filepath = os.path.expanduser(filepath) if not os.path.isabs(filepath): @@ -1273,6 +1321,79 @@ def cmd_undo(agent, args: str): console.print(f"[yellow]Only {actual} of {n} requested steps were removable (protected system prompt steps).[/]") +def cmd_repeat(agent, model, n, prompt_text, session_stats, verbose, instructions, first_turn, browser_enabled): + """Run a prompt N times, each on a fresh agent with snapshotted context.""" + from smolagents.bp_session import load_session_from_dict, save_session_to_dict + from smolagents.monitoring import LogLevel + + from smolagents.bp_tools import inject_tree + + # Snapshot current agent state (in-memory, not to file) + snapshot = save_session_to_dict(agent, session_stats) + + # Save current working directory + original_folder = os.getcwd() + + # Resolve BPSA_INJECT_FOLDER (default: true = cwd) + tree_folder_raw = get_env("BPSA_INJECT_FOLDER") + if tree_folder_raw is not None and tree_folder_raw.lower() == "false": + tree_folder = None + elif tree_folder_raw is None or tree_folder_raw.lower() == "true": + tree_folder = original_folder + else: + tree_folder = tree_folder_raw + + completed = 0 + errors = 0 + + for i in range(1, n + 1): + console.print(Rule(f"[bold cyan] Cycle {i}/{n} [/]", style="cyan")) + try: + # Restore working directory + os.chdir(original_folder) + + # Create fresh agent and restore snapshot + cycle_agent = build_agent(model, approval_callback=interactive_approval_callback, browser_enabled=browser_enabled) + load_session_from_dict(snapshot, cycle_agent) + + # Prepare prompt (prepend instructions on first_turn only) + task_text = prepend_instructions(prompt_text, instructions) if first_turn else prompt_text + + # Inject directory tree with function signatures if configured + if tree_folder: + task_text += inject_tree(tree_folder) + + # Run + _spinner.start() + start_time = time.time() + if verbose: + cycle_agent.logger.level = LogLevel.INFO + else: + cycle_agent.logger.level = LogLevel.ERROR + result = cycle_agent.run(task_text, reset=False) + _spinner.stop() + elapsed = time.time() - start_time + + completed += 1 + console.print() + console.print(Markdown(str(result))) + console.print() + console.print(f"[dim]Cycle {i}/{n} completed in {elapsed:.1f}s[/]") + + except KeyboardInterrupt: + _spinner.stop() + console.print(f"\n[yellow]Interrupted at cycle {i}/{n}.[/]") + break + except Exception as e: + _spinner.stop() + errors += 1 + console.print(f"\n[bold red]Cycle {i}/{n} error:[/] {e}") + + # Summary + console.print(Rule(style="cyan")) + console.print(f"[bold]Repeat summary:[/] {completed} completed, {errors} errors out of {n} cycles") + + def _shutdown_browser(agent): """Shut down the browser manager if one exists on the agent.""" manager = getattr(agent, "_browser_manager", None) @@ -1391,6 +1512,11 @@ def get_input(): return "" last_answer = None + last_prompt = None + pending_shell_outputs = [] + aliases = _load_aliases() + autosave_interval = int(get_env("BPSA_AUTOSAVE_INTERVAL", default="5")) + autosave_file = os.path.expanduser("~/.bpsa_autosave.json") session_stats = { "turns": 0, "total_time": 0.0, @@ -1410,6 +1536,41 @@ def get_input(): if not text: continue + # Expand aliases: check if first word matches an alias + first_word = text.split(None, 1)[0] if text else "" + if first_word in aliases: + rest = text[len(first_word):].lstrip() + text = aliases[first_word] + (" " + rest if rest else "") + + # Handle !!! shell escape: run OS command and immediately send to agent + if text.startswith("!!!"): + shell_cmd = text[3:].strip() + if shell_cmd: + output = _run_shell_streaming(shell_cmd) + shell_context = f"\n{shell_cmd}\n\n{truncate_content(output)}\n" + text = f"Analyze the output of the command above.\n{shell_context}" + # Fall through to agent run below + else: + continue + + # Handle !! shell escape: run OS command, output appended to next prompt + elif text.startswith("!!"): + shell_cmd = text[2:].strip() + if shell_cmd: + output = _run_shell_streaming(shell_cmd) + pending_shell_outputs.append((shell_cmd, truncate_content(output))) + continue + + # Handle ! shell escape: run OS command directly (agent doesn't see it) + if text.startswith("!"): + shell_cmd = text[1:].strip() + if shell_cmd: + try: + subprocess.run(shell_cmd, shell=True) + except KeyboardInterrupt: + console.print("\n[dim]Command interrupted.[/]") + continue + # Handle slash commands if text.startswith("/"): cmd_parts = text.split(maxsplit=1) @@ -1426,6 +1587,39 @@ def get_input(): elif cmd == "/help": print_help() continue + elif cmd == "/alias": + args = cmd_args.strip() + if not args: + if aliases: + for name, value in sorted(aliases.items()): + console.print(f" [cyan]{name}[/] = {value}") + else: + console.print("[dim]No aliases defined. Usage: /alias [/]") + elif args.startswith("-d "): + alias_name = args[3:].strip() + if alias_name in aliases: + del aliases[alias_name] + _save_aliases(aliases) + console.print(f"[cyan]Alias '{alias_name}' deleted.[/]") + else: + console.print(f"[yellow]Alias '{alias_name}' not found.[/]") + else: + parts = args.split(None, 1) + if len(parts) < 2: + console.print("[yellow]Usage: /alias or /alias -d [/]") + else: + aliases[parts[0]] = parts[1] + _save_aliases(aliases) + console.print(f"[cyan]{parts[0]}[/] = {parts[1]}") + continue + elif cmd == "/redo": + if last_prompt is None: + console.print("[yellow]No previous prompt to redo.[/]") + continue + # Undo the steps from the last turn, then re-run + cmd_undo(agent, "") + text = last_prompt + # Fall through to agent run below elif cmd == "/clear": _shutdown_browser(agent) agent = build_agent(model, approval_callback=interactive_approval_callback, browser_enabled=browser_enabled) @@ -1440,7 +1634,7 @@ def get_input(): console.clear() print_banner(model_id, server_model, count_tools(agent)) continue - elif cmd == "/tools": + elif cmd == "/show-tools": print_tools(agent) continue elif cmd == "/verbose": @@ -1454,17 +1648,17 @@ def get_input(): elif cmd == "/show-stats": print_stats(session_stats) continue - elif cmd == "/file": + elif cmd == "/run-prompt": file_content = load_file_as_prompt(cmd_args) if file_content: text = file_content # Fall through to agent run else: continue - elif cmd == "/steps": + elif cmd == "/set-max-steps": change_steps(agent, cmd_args) continue - elif cmd == "/run": + elif cmd == "/run-py": run_script(agent, cmd_args) continue elif cmd == "/cd": @@ -1537,6 +1731,37 @@ def get_input(): elif cmd == "/undo-steps": cmd_undo(agent, cmd_args) continue + elif cmd == "/repeat": + parts = cmd_args.strip().split(None, 1) + if len(parts) < 2: + console.print("[yellow]Usage: /repeat [/]") + continue + try: + repeat_n = int(parts[0]) + if repeat_n < 1: + raise ValueError + except ValueError: + console.print("[red]N must be a positive integer. Usage: /repeat [/]") + continue + cmd_repeat(agent, model, repeat_n, parts[1], session_stats, verbose, instructions, first_turn, browser_enabled) + continue + elif cmd == "/repeat-prompt": + parts = cmd_args.strip().split(None, 1) + if len(parts) < 2: + console.print("[yellow]Usage: /repeat-prompt [/]") + continue + try: + repeat_n = int(parts[0]) + if repeat_n < 1: + raise ValueError + except ValueError: + console.print("[red]N must be a positive integer. Usage: /repeat-prompt [/]") + continue + file_content = load_file_as_prompt(parts[1]) + if file_content is None: + continue + cmd_repeat(agent, model, repeat_n, file_content, session_stats, verbose, instructions, first_turn, browser_enabled) + continue elif cmd == "/session-save": cmd_session_save(agent, session_stats, cmd_args) continue @@ -1580,8 +1805,16 @@ def get_input(): else: agent.logger.level = LogLevel.ERROR _spinner.start() + last_prompt = text task_text = prepend_instructions(text, instructions) if first_turn else text first_turn = False + if pending_shell_outputs: + shell_context = "\n".join( + f"\n{cmd}\n\n{out}\n" + for cmd, out in pending_shell_outputs + ) + task_text = task_text + "\n" + shell_context + pending_shell_outputs.clear() result = agent.run(task_text, reset=False) _spinner.stop() elapsed = time.time() - start_time @@ -1605,6 +1838,15 @@ def get_input(): print_turn_summary(turn_num, elapsed, turn_input, turn_output, agent) console.print() + # Auto-save session periodically + if autosave_interval > 0 and session_stats["turns"] % autosave_interval == 0: + try: + from smolagents.bp_session import save_session + save_session(autosave_file, agent, session_stats) + console.print(f"[dim]Auto-saved session to {autosave_file}[/]") + except Exception: + pass + except KeyboardInterrupt: _spinner.stop() console.print("\n[yellow]Interrupted.[/]") diff --git a/src/smolagents/bp_session.py b/src/smolagents/bp_session.py index e4a3f02ae..c55d0e8d2 100644 --- a/src/smolagents/bp_session.py +++ b/src/smolagents/bp_session.py @@ -268,6 +268,78 @@ def deserialize_step(data: dict) -> MemoryStep: # --------------------------------------------------------------------------- +def save_session_to_dict(agent, session_stats: dict) -> dict: + """Snapshot agent state to an in-memory dict. + + Same serialization as save_session() but returns a dict instead of writing to a file. + + Args: + agent: The CodeAgent instance whose state to save. + session_stats: Session statistics dict (turns, time, tokens). + + Returns: + Serialized session payload as a dict. + """ + steps = [serialize_step(step) for step in agent.memory.steps] + + return { + "version": SESSION_VERSION, + "saved_at": datetime.now(timezone.utc).isoformat(), + "agent_state": { + "system_prompt": agent.memory.system_prompt.system_prompt, + "next_actionstep_id": agent._next_actionstep_id, + "last_plan_step": agent._last_plan_step, + }, + "session_stats": dict(session_stats), + "monitor_state": { + "total_input_token_count": agent.monitor.total_input_token_count, + "total_output_token_count": agent.monitor.total_output_token_count, + }, + "steps": steps, + } + + +def load_session_from_dict(payload: dict, agent) -> dict: + """Restore agent state from an in-memory dict. + + Same deserialization as load_session() but reads from a dict instead of a file. + + Args: + payload: Serialized session payload (as returned by save_session_to_dict). + agent: The CodeAgent instance to restore into. + + Returns: + Restored session_stats dict. + + Raises: + ValueError: If the payload version is unsupported. + """ + version = payload.get("version") + if version != SESSION_VERSION: + raise ValueError(f"Unsupported session version: {version} (expected {SESSION_VERSION})") + + # Restore agent memory + agent_state = payload.get("agent_state", {}) + agent.memory.system_prompt = SystemPromptStep(system_prompt=agent_state.get("system_prompt", "")) + agent.memory.steps = [deserialize_step(s) for s in payload.get("steps", [])] + + # Restore agent counters + agent._next_actionstep_id = agent_state.get("next_actionstep_id", 1) + agent._last_plan_step = agent_state.get("last_plan_step", 0) + + # Restore monitor token counts + monitor_state = payload.get("monitor_state", {}) + agent.monitor.total_input_token_count = monitor_state.get("total_input_token_count", 0) + agent.monitor.total_output_token_count = monitor_state.get("total_output_token_count", 0) + + return payload.get("session_stats", { + "turns": 0, + "total_time": 0.0, + "total_input_tokens": 0, + "total_output_tokens": 0, + }) + + def save_session(filepath: str, agent, session_stats: dict) -> int: """Save an entire agent session to a JSON file. diff --git a/src/smolagents/bp_thinkers.py b/src/smolagents/bp_thinkers.py index 94f518acc..f11426539 100644 --- a/src/smolagents/bp_thinkers.py +++ b/src/smolagents/bp_thinkers.py @@ -28,7 +28,7 @@ source_code_to_string, string_to_source_code, run_os_command, replace_on_file, replace_on_file_with_files, get_file_size, load_string_from_file, save_string_to_file, append_string_to_file, - list_directory_tree, search_in_files, get_file_info, list_directory, + list_directory_tree, inject_tree, search_in_files, get_file_info, list_directory, extract_function_signatures, compare_files, count_lines_of_code, mkdir, delete_file, delete_directory, compare_folders, read_first_n_lines, read_last_n_lines, delete_lines_from_file] @@ -1062,14 +1062,7 @@ def run_agent_cycles( try: local_prompt = task_str if list_directory_tree_in_folder is not None: - local_prompt += "\nThis is the result of list_directory_tree:\n\n" + \ - list_directory_tree(folder_path=list_directory_tree_in_folder, add_function_signatures=add_function_signatures) + \ - "\n\n" + \ -""" -The contents of is VERY important to you. From , you can get a general view/current state of the project: -* From the md files, if they exist, you can find the existing section titles and have a general idea of the md file contets. -* For source code files, if they exist, you can find class and method names so you can also develop a general idea of their contents. -""" + local_prompt += inject_tree(list_directory_tree_in_folder) # restore the original folder os.chdir(original_folder) local_agent = get_default_thinker_agent( diff --git a/src/smolagents/bp_tools.py b/src/smolagents/bp_tools.py index aea7ad5c3..ceeda07e6 100644 --- a/src/smolagents/bp_tools.py +++ b/src/smolagents/bp_tools.py @@ -1677,6 +1677,27 @@ def add_tree_lines(current_path, prefix="", depth=0): return "\n".join(lines) + +@tool +def inject_tree(folder: str) -> str: + """Generate directory tree string with function signatures to append to task prompts. + + Args: + folder: path to the folder to generate the tree from. + """ + tree = list_directory_tree(folder_path=folder, add_function_signatures=True) + return ( + "\nThis is the result of list_directory_tree:\n\n" + + tree + + "\n\n" + "The contents of is VERY important to you. " + "From , you can get a general view/current state of the project:\n" + "* From the md files, if they exist, you can find the existing section titles " + "and have a general idea of the md file contents.\n" + "* For source code files, if they exist, you can find class and method names " + "so you can also develop a general idea of their contents.\n" + ) + @tool def search_in_files(folder_path: str, search_pattern: str, file_extensions: tuple = None, case_sensitive: bool = False, max_results: int = 50) -> str: diff --git a/src/smolagents/prompts/code_agent.yaml b/src/smolagents/prompts/code_agent.yaml index 718cdb7ba..e64b11ff2 100644 --- a/src/smolagents/prompts/code_agent.yaml +++ b/src/smolagents/prompts/code_agent.yaml @@ -151,6 +151,8 @@ system_prompt: |- 17. In python, do not use global nor globals() as they are not available in this environment. 18. Do not use the assertion command for testing. Print the result instead of raising an exception. 19. When reading files, try to not read more than 50 lines at once, unless strictly necessary. This is to avoid overloading your context window. + 20. When making git commits (via run_os_command or any other means), always include the following co-authorship trailer at the end of the commit message: + Co-Authored-By: BPSA using {{model_id}} Any final output that you would like to give such as "my name is Assistant" should be done via a python code block with final_answer("my name is Assistant").