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)