diff --git a/README.md b/README.md
index 4d5122c2e..75d39eb42 100644
--- a/README.md
+++ b/README.md
@@ -16,15 +16,17 @@ limitations under the License.
# BPSA - Beyond Python Smolagents
**BPSA - Beyond Python Smolagents** is a fork of the original [smolagents](https://github.com/huggingface/smolagents) that extends its original abilities:
+* 💻 **Interactive CLI ([`bpsa`](#cli-bpsa)):** Multi-turn REPL with slash commands, command history, tab completion, session stats, and auto-approve mode.
+* 🔄 **Infinite runtime CLI ([`ad-infinitum`](#cli-ad-infinitum)):** Allows agents to **run ad infinitum** via autonomous looping.
* 🗜️ **Context compression**: Automatic LLM-based summarization of older memory steps to manage context window size during long-running tasks.
+* 🌐 **Browser integration:** Control a headed Chromium browser from agent code blocks via Playwright (`--browser` flag).
* ⚡ Execute Python code **natively** via `exec` for unrestricted processing.
-* 🔄 **Infinite runtime:** The thinkers allow agents to **run ad infinitum**.
* 🔄 Code in multiple languages beyond Python (Pascal, PHP, C++, Java and more).
* 🛠️ Lots of new tools that help agents to compile, test, and debug source code in various computing languages.
* 👥 Collaborate across multiple agents to solve complex problems.
* 🔍 Tools that help agents to research and write technical documentation.
* 📚 Generate and update documentation including READMEs for existing codebases.
-* 🌐 **Browser integration:** Control a headed Chromium browser from agent code blocks via Playwright (`--browser` flag).
+
## Installation
To get started with Beyond Python Smolagents, follow these steps:
@@ -76,6 +78,92 @@ bpsa --browser # Enable Playwright browser integration
The REPL supports command history, tab completion for slash commands, and multi-line input via Alt+Enter.
+
+## CLI (`ad-infinitum`)
+
+`ad-infinitum` is a dedicated CLI for autonomous, looping agent execution. It loads tasks from a folder of `.md` files (or a single file) and runs them repeatedly.
+
+### How It Works
+
+Each cycle iterates through all tasks in order.
+
+### Task Folder Convention
+
+```
+tasks/
++-- _preamble.md (optional) prepended to ALL tasks
++-- 01-scaffold.md task 1
++-- 02-implement.md task 2
++-- 03-test.md task 3
++-- _postamble.md (optional) appended to ALL tasks
+```
+
+- Files starting with `_` are **modifiers**, not tasks
+- `_preamble.md` is prepended to every task (e.g., project context, coding standards)
+- `_postamble.md` is appended to every task (e.g., "commit when done", "call final_answer with a summary")
+- All other `.md` files are tasks, loaded in **alphabetical order**
+- Numbering prefixes (`01-`, `02-`) give natural sequencing
+
+### Usage
+
+```bash
+ad-infinitum ../tasks/ # Run all .md files from a folder
+ad-infinitum ../single-task.md # Run a single task file
+ad-infinitum ../tasks/ -c 5 # Run 5 cycles
+ad-infinitum ../tasks/ --cycles 0 # Run ad infinitum
+```
+
+| Flag | Description |
+|---|---|
+| `-c`, `--cycles` | Number of cycles, 0 = infinite (overrides `BPSA_CYCLES`) |
+
+### Environment Variables
+
+`ad-infinitum` uses the same `BPSA_*` environment variables as `bpsa`, plus these additional ones:
+
+| Variable | Default | Description |
+|---|---|---|
+| `BPSA_CYCLES` | `1` | Number of cycles (0 = infinite) |
+| `BPSA_MAX_STEPS` | `200` | Max steps per agent run |
+| `BPSA_PLAN_INTERVAL` | off | Planning interval (e.g., `22`) |
+| `BPSA_COOLDOWN` | `0` | Seconds to wait between cycles |
+| `BPSA_INJECT_FOLDER` | `false` | Inject directory tree (`false`, `true` = cwd, or a path) |
+
+When `BPSA_INJECT_FOLDER` is set to `true`, a fresh `list_directory_tree` snapshot of the current working directory is appended to each task prompt, so the agent can "see" the current project structure (files, class/method signatures, section titles). You can also pass a specific folder path instead of `true`.
+
+Example `.env` file:
+```
+BPSA_SERVER_MODEL=OpenAIServerModel
+BPSA_API_ENDPOINT=https://api.poe.com/v1
+BPSA_KEY_VALUE=your_api_key
+BPSA_MODEL_ID=Gemini-2.5-Flash
+BPSA_CYCLES=3
+BPSA_INJECT_FOLDER=true
+BPSA_MAX_STEPS=200
+BPSA_COOLDOWN=5
+```
+
+### Execution Model
+
+With 3 task files and `BPSA_CYCLES=2`:
+
+```
+Cycle 1/2:
+ Task 1/3: 01-scaffold.md (fresh agent)
+ Task 2/3: 02-implement.md (fresh agent, sees files from task 1)
+ Task 3/3: 03-test.md (fresh agent, sees files from tasks 1-2)
+Cycle 2/2:
+ Task 1/3: 01-scaffold.md (fresh agent, sees evolved project)
+ Task 2/3: 02-implement.md (fresh agent)
+ Task 3/3: 03-test.md (fresh agent)
+```
+
+### Graceful Shutdown
+
+- **Single Ctrl+C**: Finishes the current task, then stops
+- **Double Ctrl+C**: Aborts immediately
+
+
## The Thinkers
There are 2 main functions that you can easily call:
* [fast_solver](https://github.com/joaopauloschuler/beyond-python-smolagents?tab=readme-ov-file#the-fast_solver) : A multi-agent parallel problem-solving approach that generates 3 independent solutions using different AI models, then synthesizes them into an optimized final solution. Think of it as automated "brainstorming → best-of-breed synthesis" that leverages diverse AI perspectives for higher quality outcomes.
diff --git a/pyproject.toml b/pyproject.toml
index 4174c9070..8478c05b4 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -149,3 +149,4 @@ lines-after-imports = 2
smolagent = "smolagents.cli:main"
bpsa = "smolagents.bp_cli:main"
webagent = "smolagents.vision_web_browser:main"
+ad-infinitum = "smolagents.bp_ad_infinitum:main"
diff --git a/src/smolagents/agents.py b/src/smolagents/agents.py
index 3865cb58c..2b1245562 100644
--- a/src/smolagents/agents.py
+++ b/src/smolagents/agents.py
@@ -1918,7 +1918,9 @@ def _step_stream(
#v1.19 compatibility
model_output_for_parsing = model_output_for_parsing.replace('','```py').replace('','```')
# this is for backward compatibility
+ skip_next_approval = False
if not('```py' in model_output_for_parsing) and not('```' in model_output_for_parsing):
+ skip_next_approval = True # system-generated reminders, no approval needed
model_output_for_parsing = model_output_for_parsing + """
```py
@@ -2031,7 +2033,10 @@ def _step_stream(
memory_step.tool_calls = [tool_call]
### Execute action ###
- if not self._request_approval("runcode", code_action):
+ if skip_next_approval:
+ # there is no need to request for approval
+ pass
+ elif not self._request_approval("runcode", code_action):
raise AgentExecutionRejected("User rejected runcode execution.", self.logger)
self.logger.log_code(title="Executing code with "+str(len(code_action))+" chars:", content=code_action, level=LogLevel.INFO)
code_action = self.replace_include_tags(code_action, saved_files)
diff --git a/src/smolagents/bp_ad_infinitum.py b/src/smolagents/bp_ad_infinitum.py
new file mode 100644
index 000000000..edb646cc9
--- /dev/null
+++ b/src/smolagents/bp_ad_infinitum.py
@@ -0,0 +1,321 @@
+
+#!/usr/bin/env python
+# coding=utf-8
+
+"""
+Ad-Infinitum CLI for Beyond Python SmolAgents.
+
+Autonomous agent cycling: loads tasks from a folder of .md files (or a single file)
+and runs them repeatedly with a fresh agent per task.
+
+Folder convention:
+ tasks/
+ +-- _preamble.md (optional) prepended to ALL tasks
+ +-- 01-scaffold.md task 1
+ +-- 02-implement.md task 2
+ +-- 03-test.md task 3
+ +-- _postamble.md (optional) appended to ALL tasks
+
+Files starting with '_' are modifiers, not tasks. All other .md files are
+loaded in alphabetical order. Each becomes one element in the task array.
+
+Environment variables (same BPSA_* as bpsa, plus):
+ BPSA_CYCLES - Number of cycles, 0 = infinite (default: 1)
+ 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)
+"""
+
+import glob
+import os
+import signal
+import sys
+import time
+
+from rich.console import Console
+from rich.panel import Panel
+from rich.rule import Rule
+
+from smolagents.bp_cli import get_env
+
+console = Console()
+
+# Graceful shutdown flag
+_stop_requested = False
+
+
+def _signal_handler(signum, frame):
+ global _stop_requested
+ if _stop_requested:
+ console.print("\n[bold red]Double Ctrl+C: aborting immediately.[/]")
+ sys.exit(1)
+ _stop_requested = True
+ console.print("\n[yellow]Ctrl+C received. Will stop after current task finishes.[/]")
+
+
+def fail(msg: str):
+ console.print(f"[bold red]Error:[/] {msg}")
+ sys.exit(1)
+
+
+def load_tasks(path: str) -> list[str]:
+ """Load tasks from a folder of .md files or a single file.
+
+ Folder mode:
+ - _preamble.md and _postamble.md are optional wrappers
+ - All other *.md files are tasks, sorted alphabetically
+ - Each task = preamble + file content + postamble
+
+ File mode:
+ - Returns a single-element list with the file content.
+ """
+ if os.path.isdir(path):
+ all_md = sorted(glob.glob(os.path.join(path, "*.md")))
+ if not all_md:
+ fail(f"No .md files found in {path}")
+
+ preamble = ""
+ postamble = ""
+ task_files = []
+
+ for f in all_md:
+ basename = os.path.basename(f)
+ if basename == "_preamble.md":
+ with open(f, "r", encoding="utf-8") as fh:
+ preamble = fh.read().strip() + "\n\n"
+ console.print(f" [green]Preamble:[/] {basename}")
+ elif basename == "_postamble.md":
+ with open(f, "r", encoding="utf-8") as fh:
+ postamble = "\n\n" + fh.read().strip()
+ console.print(f" [green]Postamble:[/] {basename}")
+ elif not basename.startswith("_"):
+ task_files.append(f)
+
+ if not task_files:
+ fail(f"No task .md files found in {path} (files starting with '_' are modifiers, not tasks)")
+
+ tasks = []
+ for f in task_files:
+ with open(f, "r", encoding="utf-8") as fh:
+ content = fh.read().strip()
+ tasks.append(preamble + content + postamble)
+ console.print(f" [cyan]Task:[/] {os.path.basename(f)}")
+
+ return tasks
+
+ elif os.path.isfile(path):
+ with open(path, "r", encoding="utf-8") as fh:
+ content = fh.read().strip()
+ if not content:
+ fail(f"File is empty: {path}")
+ console.print(f" [cyan]Task:[/] {os.path.basename(path)}")
+ return [content]
+
+ else:
+ fail(f"Path not found: {path}")
+
+
+
+def get_int_env(name: str, default: int) -> int:
+ val = get_env(name)
+ if val is None:
+ return default
+ try:
+ return int(val)
+ except ValueError:
+ console.print(f"[yellow]Warning: Invalid integer for {name}='{val}', using default: {default}[/]")
+ return default
+
+
+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"
+ )
+
+
+def print_banner(config: dict):
+ cycles_str = str(config["cycles"]) if config["cycles"] > 0 else "infinite"
+ plan_str = str(config["plan_interval"]) if config["plan_interval"] else "off"
+ tree_str = config["tree_folder"] if config["tree_folder"] else "off"
+
+ console.print(
+ Panel.fit(
+ f"[bold]AD-INFINITUM[/] - Autonomous Agent Cycles\n"
+ f"Model: [cyan]{config['model_id']}[/] ({config['server_model']})\n"
+ f"Tasks: [green]{config['task_count']}[/] | "
+ f"Cycles: [green]{cycles_str}[/] | "
+ f"Steps/run: [green]{config['max_steps']}[/]\n"
+ f"Planning: {plan_str} | "
+ f"Inject folder: {tree_str} | "
+ f"Cooldown: {config['cooldown']}s",
+ border_style="blue",
+ )
+ )
+ console.print(
+ Panel.fit(
+ "[bold red]EXTREME SECURITY RISK[/]\n"
+ "Running autonomously with full system access.\n"
+ "Only run inside a securely isolated environment.\n"
+ "[bold]USE AT YOUR OWN RISK.[/]",
+ border_style="red",
+ )
+ )
+ console.print("[dim]Press Ctrl+C to stop after current task. Double Ctrl+C to abort.[/]\n")
+
+
+def run_loop(model, tasks, cycles, max_steps, plan_interval, tree_folder, cooldown):
+ """Core autonomous loop: cycles x tasks, fresh agent per task."""
+ from smolagents.bp_cli import build_agent
+
+ original_dir = os.getcwd()
+ total_start = time.time()
+ cycle = 0
+ total_tasks_run = 0
+
+ while cycles == 0 or cycle < cycles:
+ cycle += 1
+ cycle_label = f"{cycle}" if cycles > 0 else f"{cycle}"
+ cycle_limit = f"/{cycles}" if cycles > 0 else ""
+
+ console.print(Rule(f"[bold]Cycle {cycle_label}{cycle_limit}[/]", style="blue"))
+
+ for task_idx, task_text in enumerate(tasks):
+ if _stop_requested:
+ break
+
+ os.chdir(original_dir)
+
+ # Inject directory tree if configured
+ prompt = task_text
+ if tree_folder:
+ prompt += inject_tree(tree_folder)
+
+ task_label = f"Task {task_idx + 1}/{len(tasks)}"
+ console.print(f"[dim]{task_label} starting...[/]")
+
+ agent = build_agent(model)
+ if plan_interval:
+ agent.planning_interval = plan_interval
+
+ task_start = time.time()
+ try:
+ agent.run(prompt, reset=True)
+ elapsed = time.time() - task_start
+ total_tasks_run += 1
+
+ # Get token usage
+ try:
+ usage = agent.monitor.get_total_token_counts()
+ in_tok, out_tok = usage.input_tokens, usage.output_tokens
+ except Exception:
+ in_tok, out_tok = 0, 0
+
+ console.print(
+ f"[green]OK[/] {task_label} | {elapsed:.1f}s | "
+ f"In: {in_tok:,} | Out: {out_tok:,}"
+ )
+ except KeyboardInterrupt:
+ console.print(f"[yellow]{task_label} interrupted.[/]")
+ break
+ except Exception as e:
+ elapsed = time.time() - task_start
+ total_tasks_run += 1
+ console.print(f"[red]FAIL[/] {task_label} | {elapsed:.1f}s | {e}")
+
+ if _stop_requested:
+ console.print(f"\n[yellow]Stopped after cycle {cycle}.[/]")
+ break
+
+ # Cooldown between cycles
+ if cooldown > 0 and (cycles == 0 or cycle < cycles):
+ console.print(f"[dim]Cooldown: {cooldown}s...[/]")
+ time.sleep(cooldown)
+
+ # Session summary
+ total_elapsed = time.time() - total_start
+ os.chdir(original_dir)
+ console.print()
+ console.print(Rule("[bold]Session Summary[/]", style="green"))
+ console.print(f" Cycles completed: [green]{cycle}[/]")
+ console.print(f" Tasks run: [green]{total_tasks_run}[/]")
+ console.print(f" Total time: [green]{total_elapsed:.1f}s[/]")
+
+
+def main():
+ import argparse
+
+ parser = argparse.ArgumentParser(
+ prog="ad-infinitum",
+ description="Ad-Infinitum: Autonomous agent cycling for Beyond Python SmolAgents",
+ )
+ parser.add_argument(
+ "task_source",
+ help="Folder of .md task files or a single .md file",
+ )
+ parser.add_argument(
+ "-c", "--cycles",
+ type=int,
+ default=None,
+ help="Number of cycles, 0 = infinite (overrides BPSA_CYCLES, default: 1)",
+ )
+ args = parser.parse_args()
+
+ # Install Ctrl+C handler
+ signal.signal(signal.SIGINT, _signal_handler)
+
+ # Load .env
+ from smolagents.bp_cli import try_load_dotenv, check_required_env, build_model
+ try_load_dotenv()
+ check_required_env()
+
+ # Read config from env
+ cycles = args.cycles if args.cycles is not None else get_int_env("BPSA_CYCLES", 1)
+ plan_interval_val = get_env("BPSA_PLAN_INTERVAL")
+ plan_interval = int(plan_interval_val) if plan_interval_val else None
+ 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":
+ tree_folder = None
+ elif tree_folder_raw.lower() == "true":
+ tree_folder = os.getcwd()
+ else:
+ tree_folder = tree_folder_raw
+
+ # Load tasks
+ console.print("[dim]Loading tasks...[/]")
+ tasks = load_tasks(args.task_source)
+
+ config = {
+ "model_id": get_env("BPSA_MODEL_ID"),
+ "server_model": get_env("BPSA_SERVER_MODEL", "OpenAIServerModel"),
+ "task_count": len(tasks),
+ "cycles": cycles,
+ "max_steps": max_steps,
+ "plan_interval": plan_interval,
+ "tree_folder": tree_folder,
+ "cooldown": cooldown,
+ }
+ print_banner(config)
+
+ # Build model (reused across all cycles)
+ model = build_model()
+
+ # Run the loop
+ run_loop(model, tasks, cycles, max_steps, plan_interval, tree_folder, cooldown)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/smolagents/bp_cli.py b/src/smolagents/bp_cli.py
index c8f987f16..ec99c1254 100644
--- a/src/smolagents/bp_cli.py
+++ b/src/smolagents/bp_cli.py
@@ -343,9 +343,9 @@ def load_agent_instructions() -> str | None:
if os.path.isfile(filepath):
try:
with open(filepath, "r") as f:
- content = '' + f.read().strip() + ''
+ content = f.read().strip()
if content:
- instructions.append(f"# Content from {filename}\n\n{content}")
+ instructions.append(f"# Content from {filename}\n\n{content}")
console.print(f" [green]Loaded:[/] {filename}")
except OSError:
pass
@@ -442,7 +442,7 @@ def print_banner(model_id: str, server_model: str, tool_count: int):
"/compression-model", "/exit", "/file", "/help",
"/load-instructions", "/plan", "/pwd", "/run", "/save",
"/show-compression-stats", "/show-memory-stats", "/show-stats",
- "/show-step", "/show-steps", "/steps", "/tools", "/verbose",
+ "/show-step", "/show-steps", "/steps", "/tools", "/undo-steps", "/verbose",
]
@@ -473,6 +473,7 @@ def print_help():
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("/undo-steps \[N]", "Remove last N steps from memory (default: 1)")
table.add_row("/verbose", "Toggle verbose output")
console.print(table)
console.print()
@@ -504,7 +505,6 @@ def _getch():
return key.decode('utf-8', errors='ignore').lower()
except ImportError:
# Unix/Linux/Mac
- import sys
import tty
import termios
fd = sys.stdin.fileno()
@@ -657,7 +657,7 @@ def run_script(agent, args: str):
console.print(f"[dim]Running {filepath}...[/]")
result = subprocess.run(
[sys.executable, filepath],
- capture_output=True, text=True, timeout=300,
+ capture_output=True, text=True, timeout=3600,
)
if result.stdout:
console.print(result.stdout)
@@ -668,7 +668,7 @@ def run_script(agent, args: str):
else:
console.print(f"[yellow]Script finished (exit code {result.returncode})[/]")
except subprocess.TimeoutExpired:
- console.print("[red]Script timed out (300s limit).[/]")
+ console.print("[red]Script timed out (3600s limit).[/]")
except OSError as e:
console.print(f"[red]Error running script: {e}[/]")
@@ -688,16 +688,30 @@ def change_directory(args: str):
except OSError as e:
console.print(f"[red]Error: {e}[/]")
+def _get_compression_config(agent):
+ """Get compression config from agent, or print a warning and return None."""
+ config = getattr(agent, "compression_config", None)
+ if config is None:
+ console.print("[yellow]No compression config set on this agent.[/]")
+ return config
+
+
+def _get_compressor(agent):
+ """Get compressor from agent, or print a warning and return None."""
+ compressor = getattr(agent, "compressor", None)
+ if compressor is None:
+ console.print("[yellow]No compressor configured on this agent.[/]")
+ return compressor
+
+
def cmd_compression_stats(agent):
"""Show current compression config and stats."""
from smolagents.bp_compression import CompressedHistoryStep, estimate_step_tokens
- compressor = getattr(agent, "compressor", None)
- config = getattr(agent, "compression_config", None)
-
+ compressor = _get_compressor(agent)
+ config = _get_compression_config(agent)
if config is None:
- console.print("[yellow]No compression config set on this agent.[/]")
return
console.print(Rule("[bold]Compression Configuration", style="blue"))
@@ -783,9 +797,8 @@ def cmd_compress(agent, args: str):
"""Force immediate compression, or compress a specific step."""
from smolagents.bp_compression import CompressedHistoryStep
- compressor = getattr(agent, "compressor", None)
+ compressor = _get_compressor(agent)
if compressor is None:
- console.print("[yellow]No compressor configured on this agent.[/]")
return
args = args.strip()
@@ -859,9 +872,8 @@ def cmd_compress(agent, args: str):
def cmd_compression_toggle(agent, args: str):
"""Toggle compression on/off."""
- config = getattr(agent, "compression_config", None)
+ config = _get_compression_config(agent)
if config is None:
- console.print("[yellow]No compression config set on this agent.[/]")
return
arg = args.strip().lower()
@@ -879,9 +891,8 @@ def cmd_compression_toggle(agent, args: str):
def cmd_compression_keep_recent(agent, args: str):
"""Change keep_recent_steps."""
- config = getattr(agent, "compression_config", None)
+ config = _get_compression_config(agent)
if config is None:
- console.print("[yellow]No compression config set on this agent.[/]")
return
args = args.strip()
if not args:
@@ -900,9 +911,8 @@ def cmd_compression_keep_recent(agent, args: str):
def cmd_compression_max_uncompressed(agent, args: str):
"""Change max_uncompressed_steps."""
- config = getattr(agent, "compression_config", None)
+ config = _get_compression_config(agent)
if config is None:
- console.print("[yellow]No compression config set on this agent.[/]")
return
args = args.strip()
if not args:
@@ -921,9 +931,8 @@ def cmd_compression_max_uncompressed(agent, args: str):
def cmd_compression_model(agent, args: str):
"""Switch compression model."""
- config = getattr(agent, "compression_config", None)
+ config = _get_compression_config(agent)
if config is None:
- console.print("[yellow]No compression config set on this agent.[/]")
return
args = args.strip()
if not args:
@@ -1073,6 +1082,62 @@ def cmd_show_steps(agent):
console.print()
+def cmd_undo(agent, args: str):
+ """Remove the last N steps from agent memory. Default N=1."""
+ from smolagents.memory import SystemPromptStep
+
+ steps = agent.memory.steps
+ if not steps:
+ console.print("[yellow]No steps in memory.[/]")
+ return
+
+ args = args.strip()
+ if args:
+ try:
+ n = int(args)
+ if n < 1:
+ console.print("[red]N must be at least 1.[/]")
+ return
+ except ValueError:
+ console.print("[red]Invalid number. Usage: /undo [N][/]")
+ return
+ else:
+ n = 1
+
+ # Count removable steps from the end (skip SystemPromptStep)
+ removable = 0
+ for step in reversed(steps):
+ if isinstance(step, SystemPromptStep):
+ break
+ removable += 1
+
+ if removable == 0:
+ console.print("[yellow]No removable steps (only system prompt steps remain).[/]")
+ return
+
+ actual = min(n, removable)
+ removed = steps[-actual:]
+ agent.memory.steps = steps[:-actual]
+
+ for step in removed:
+ type_name = type(step).__name__
+ preview = ""
+ if hasattr(step, "model_output") and step.model_output:
+ preview = str(step.model_output).replace("\n", " ")[:60]
+ elif hasattr(step, "summary"):
+ preview = step.summary.replace("\n", " ")[:60]
+ elif hasattr(step, "plan"):
+ preview = step.plan.replace("\n", " ")[:60]
+ if preview:
+ console.print(f" [red]Removed:[/] {type_name} - {preview}...")
+ else:
+ console.print(f" [red]Removed:[/] {type_name}")
+
+ console.print(f"[green]Undone {actual} step{'s' if actual != 1 else ''}. {len(agent.memory.steps)} steps remain.[/]")
+ if actual < n:
+ console.print(f"[yellow]Only {actual} of {n} requested steps were removable (protected system prompt steps).[/]")
+
+
def _shutdown_browser(agent):
"""Shut down the browser manager if one exists on the agent."""
manager = getattr(agent, "_browser_manager", None)
@@ -1331,6 +1396,9 @@ def get_input():
elif cmd == "/show-steps":
cmd_show_steps(agent)
continue
+ elif cmd == "/undo-steps":
+ cmd_undo(agent, cmd_args)
+ continue
elif cmd == "/auto-approve":
arg = cmd_args.strip().lower()
if arg == "on":
diff --git a/src/smolagents/bp_thinkers.py b/src/smolagents/bp_thinkers.py
index ba70c603e..94f518acc 100644
--- a/src/smolagents/bp_thinkers.py
+++ b/src/smolagents/bp_thinkers.py
@@ -1050,7 +1050,7 @@ def run_agent_cycles(
):
# Convert string to list if needed, maintaining backward compatibility
if isinstance(task_str, str):
- task_list = [task_str] * cycles_cnt
+ task_list = [task_str]
else:
task_list = list(task_str)