Skip to content

Interactive

InteractiveAgent class

Full source code
"""A small generalization of the default agent that puts the user in the loop.

There are three modes:
- human: commands issued by the user are executed immediately
- confirm: commands issued by the LM but not whitelisted are confirmed by the user
- yolo: commands issued by the LM are executed immediately without confirmation
"""

import re
import sys
from typing import Literal, NoReturn

from rich.console import Console
from rich.rule import Rule

from minisweagent.agents.default import AgentConfig, DefaultAgent
from minisweagent.agents.utils.prompt_user import _multiline_prompt, prompt_session
from minisweagent.exceptions import LimitsExceeded, Submitted, TimeExceeded, UserInterruption
from minisweagent.models.utils.content_string import get_content_string

console = Console(highlight=False)


class InteractiveAgentConfig(AgentConfig):
    mode: Literal["human", "confirm", "yolo"] = "confirm"
    """Whether to confirm actions."""
    whitelist_actions: list[str] = []
    """Never confirm actions that match these regular expressions."""
    confirm_exit: bool = True
    """If the agent wants to finish, do we ask for confirmation from user?"""


class InteractiveAgent(DefaultAgent):
    _MODE_COMMANDS_MAPPING = {"/u": "human", "/c": "confirm", "/y": "yolo"}

    def __init__(self, *args, config_class=InteractiveAgentConfig, **kwargs):
        super().__init__(*args, config_class=config_class, **kwargs)
        self.cost_last_confirmed = 0.0

    def _interrupt(self, content: str, *, itype: str = "UserInterruption") -> NoReturn:
        raise UserInterruption({"role": "user", "content": content, "extra": {"interrupt_type": itype}})

    def add_messages(self, *messages: dict) -> list[dict]:
        # Extend supermethod to print messages
        for msg in messages:
            role, content = msg.get("role") or msg.get("type", "unknown"), get_content_string(msg)
            if role == "assistant":
                console.print(
                    f"\n[red][bold]mini-swe-agent[/bold] (step [bold]{self.n_calls}[/bold], [bold]${self.cost:.2f}[/bold]):[/red]\n",
                    end="",
                    highlight=False,
                )
            else:
                console.print(f"\n[bold green]{role.capitalize()}[/bold green]:\n", end="", highlight=False)
            console.print(content, highlight=False, markup=False)
        return super().add_messages(*messages)

    def query(self) -> dict:
        # Extend supermethod to handle human mode
        if self.config.mode == "human":
            match command := self._prompt_and_handle_slash_commands("[bold yellow]>[/bold yellow] "):
                case "/y" | "/c":
                    pass
                case _:
                    msg = {
                        "role": "user",
                        "content": f"User command: \n```bash\n{command}\n```",
                        "extra": {"actions": [{"command": command}]},
                    }
                    self.add_messages(msg)
                    return msg
        try:
            with console.status("Waiting for the LM to respond..."):
                return super().query()
        except TimeExceeded:
            # A wall-clock limit can't be lifted by raising the step/cost limits
            # (the next query re-checks the clock and raises again), so prompting
            # would loop forever. Always stop cleanly instead.
            raise
        except LimitsExceeded:
            if not self._stdin_is_interactive():
                # No terminal to prompt for new limits -- e.g. an unattended
                # `--yolo` run, or any run with stdin redirected from /dev/null
                # (CI, a sandbox). Stop cleanly so the trajectory is saved with a
                # LimitsExceeded exit status, instead of crashing on EOFError when
                # reading input.
                raise
            console.print(
                f"Limits exceeded. Limits: {self.config.step_limit} steps, ${self.config.cost_limit}.\n"
                f"Current spend: {self.n_calls} steps, ${self.cost:.2f}."
            )
            self.config.step_limit = int(input("New step limit: "))
            self.config.cost_limit = float(input("New cost limit: "))
            return super().query()

    @staticmethod
    def _stdin_is_interactive() -> bool:
        """Whether an interactive terminal is available to prompt the user.

        Returns False for unattended runs (e.g. `--yolo` in CI, or inside a
        sandbox with stdin redirected from /dev/null), where calling ``input()``
        would raise ``EOFError`` and crash the run.
        """
        try:
            return sys.stdin is not None and sys.stdin.isatty()
        except (ValueError, OSError):
            return False

    def step(self) -> list[dict]:
        # Override the step method to handle user interruption
        try:
            console.print(Rule())
            return super().step()
        except KeyboardInterrupt:
            interruption_message = self._prompt_and_handle_slash_commands(
                "\n\n[bold yellow]Interrupted.[/bold yellow] "
                "[green]Type a comment/command[/green] (/h for available commands)"
                "\n[bold yellow]>[/bold yellow] "
            ).strip()
            if not interruption_message or interruption_message in self._MODE_COMMANDS_MAPPING:
                interruption_message = "Temporary interruption caught."
            self._interrupt(f"Interrupted by user: {interruption_message}")

    def execute_actions(self, message: dict) -> list[dict]:
        # Override to handle user confirmation and confirm_exit, with try/finally to preserve partial outputs
        actions = message.get("extra", {}).get("actions", [])
        commands = [action["command"] for action in actions]
        outputs = []
        try:
            self._ask_confirmation_or_interrupt(commands)
            for action in actions:
                outputs.append(self.env.execute(action))
        except Submitted as e:
            self._check_for_new_task_or_submit(e)
        finally:
            result = self.add_messages(
                *self.model.format_observation_messages(message, outputs, self.get_template_vars())
            )
        return result

    def _add_observation_messages(self, message: dict, outputs: list[dict]) -> list[dict]:
        return self.add_messages(*self.model.format_observation_messages(message, outputs, self.get_template_vars()))

    def _check_for_new_task_or_submit(self, e: Submitted) -> NoReturn:
        """Check if user wants to add a new task or submit."""
        if self.config.confirm_exit:
            message = (
                "[bold yellow]Agent wants to finish.[/bold yellow] "
                "[bold green]Type new task[/bold green] or [bold]Enter[/bold] to quit "
                "([bold]/h[/bold] for commands)\n"
                "[bold yellow]>[/bold yellow] "
            )
            user_input = self._prompt_and_handle_slash_commands(message).strip()
            if user_input == "/u":  # directly continue
                self._interrupt("Switched to human mode.")
            elif user_input in self._MODE_COMMANDS_MAPPING:  # ask again
                return self._check_for_new_task_or_submit(e)
            elif user_input:
                self._interrupt(f"The user added a new task: {user_input}", itype="UserNewTask")
        raise e

    def _should_ask_confirmation(self, action: str) -> bool:
        return self.config.mode == "confirm" and not any(re.match(r, action) for r in self.config.whitelist_actions)

    def _ask_confirmation_or_interrupt(self, commands: list[str]) -> None:
        if not any(self._should_ask_confirmation(c) for c in commands):
            return
        prompt = (
            f"[bold yellow]Execute {len(commands)} action(s)?[/] [green][bold]Enter[/] to confirm[/], "
            "[red]type [bold]comment[/] to reject[/], or [blue][bold]/h[/] to show available commands[/]\n"
            "[bold yellow]>[/bold yellow] "
        )
        match user_input := self._prompt_and_handle_slash_commands(prompt).strip():
            case "" | "/y":
                pass  # confirmed, do nothing
            case "/u":  # Skip execution action and get back to query
                self._interrupt("Commands not executed. Switching to human mode", itype="UserRejection")
            case _:
                self._interrupt(
                    f"Commands not executed. The user rejected your commands with the following message: {user_input}",
                    itype="UserRejection",
                )

    def _prompt_and_handle_slash_commands(self, prompt: str, *, _multiline: bool = False) -> str:
        """Prompts the user, takes care of /h (followed by requery) and sets the mode. Returns the user input."""
        console.print(prompt, end="")
        if _multiline:
            return _multiline_prompt()
        user_input = prompt_session.prompt("")
        if user_input == "/m":
            return self._prompt_and_handle_slash_commands(prompt, _multiline=True)
        if user_input == "/h":
            console.print(
                f"Current mode: [bold green]{self.config.mode}[/bold green]\n"
                f"[bold green]/y[/bold green] to switch to [bold yellow]yolo[/bold yellow] mode (execute LM commands without confirmation)\n"
                f"[bold green]/c[/bold green] to switch to [bold yellow]confirmation[/bold yellow] mode (ask for confirmation before executing LM commands)\n"
                f"[bold green]/u[/bold green] to switch to [bold yellow]human[/bold yellow] mode (execute commands issued by the user)\n"
                f"[bold green]/m[/bold green] to enter multiline comment",
            )
            return self._prompt_and_handle_slash_commands(prompt)
        if user_input in self._MODE_COMMANDS_MAPPING:
            if self.config.mode == self._MODE_COMMANDS_MAPPING[user_input]:
                return self._prompt_and_handle_slash_commands(
                    f"[bold red]Already in {self.config.mode} mode.[/bold red]\n{prompt}"
                )
            self.config.mode = self._MODE_COMMANDS_MAPPING[user_input]
            console.print(f"Switched to [bold green]{self.config.mode}[/bold green] mode.")
            return user_input
        return user_input

See also

  • This agent subclass builds on top of the default agent, make sure to read that first.
  • This class powers the mini command line tool, see usage for more details.

minisweagent.agents.interactive

A small generalization of the default agent that puts the user in the loop.

There are three modes: - human: commands issued by the user are executed immediately - confirm: commands issued by the LM but not whitelisted are confirmed by the user - yolo: commands issued by the LM are executed immediately without confirmation

console module-attribute

console = Console(highlight=False)

InteractiveAgentConfig

Bases: AgentConfig

mode class-attribute instance-attribute

mode: Literal['human', 'confirm', 'yolo'] = 'confirm'

Whether to confirm actions.

whitelist_actions class-attribute instance-attribute

whitelist_actions: list[str] = []

Never confirm actions that match these regular expressions.

confirm_exit class-attribute instance-attribute

confirm_exit: bool = True

If the agent wants to finish, do we ask for confirmation from user?

InteractiveAgent

InteractiveAgent(
    *args, config_class=InteractiveAgentConfig, **kwargs
)

Bases: DefaultAgent

Source code in src/minisweagent/agents/interactive.py
36
37
38
def __init__(self, *args, config_class=InteractiveAgentConfig, **kwargs):
    super().__init__(*args, config_class=config_class, **kwargs)
    self.cost_last_confirmed = 0.0

cost_last_confirmed instance-attribute

cost_last_confirmed = 0.0

add_messages

add_messages(*messages: dict) -> list[dict]
Source code in src/minisweagent/agents/interactive.py
43
44
45
46
47
48
49
50
51
52
53
54
55
56
def add_messages(self, *messages: dict) -> list[dict]:
    # Extend supermethod to print messages
    for msg in messages:
        role, content = msg.get("role") or msg.get("type", "unknown"), get_content_string(msg)
        if role == "assistant":
            console.print(
                f"\n[red][bold]mini-swe-agent[/bold] (step [bold]{self.n_calls}[/bold], [bold]${self.cost:.2f}[/bold]):[/red]\n",
                end="",
                highlight=False,
            )
        else:
            console.print(f"\n[bold green]{role.capitalize()}[/bold green]:\n", end="", highlight=False)
        console.print(content, highlight=False, markup=False)
    return super().add_messages(*messages)

query

query() -> dict
Source code in src/minisweagent/agents/interactive.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
def query(self) -> dict:
    # Extend supermethod to handle human mode
    if self.config.mode == "human":
        match command := self._prompt_and_handle_slash_commands("[bold yellow]>[/bold yellow] "):
            case "/y" | "/c":
                pass
            case _:
                msg = {
                    "role": "user",
                    "content": f"User command: \n```bash\n{command}\n```",
                    "extra": {"actions": [{"command": command}]},
                }
                self.add_messages(msg)
                return msg
    try:
        with console.status("Waiting for the LM to respond..."):
            return super().query()
    except TimeExceeded:
        # A wall-clock limit can't be lifted by raising the step/cost limits
        # (the next query re-checks the clock and raises again), so prompting
        # would loop forever. Always stop cleanly instead.
        raise
    except LimitsExceeded:
        if not self._stdin_is_interactive():
            # No terminal to prompt for new limits -- e.g. an unattended
            # `--yolo` run, or any run with stdin redirected from /dev/null
            # (CI, a sandbox). Stop cleanly so the trajectory is saved with a
            # LimitsExceeded exit status, instead of crashing on EOFError when
            # reading input.
            raise
        console.print(
            f"Limits exceeded. Limits: {self.config.step_limit} steps, ${self.config.cost_limit}.\n"
            f"Current spend: {self.n_calls} steps, ${self.cost:.2f}."
        )
        self.config.step_limit = int(input("New step limit: "))
        self.config.cost_limit = float(input("New cost limit: "))
        return super().query()

step

step() -> list[dict]
Source code in src/minisweagent/agents/interactive.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def step(self) -> list[dict]:
    # Override the step method to handle user interruption
    try:
        console.print(Rule())
        return super().step()
    except KeyboardInterrupt:
        interruption_message = self._prompt_and_handle_slash_commands(
            "\n\n[bold yellow]Interrupted.[/bold yellow] "
            "[green]Type a comment/command[/green] (/h for available commands)"
            "\n[bold yellow]>[/bold yellow] "
        ).strip()
        if not interruption_message or interruption_message in self._MODE_COMMANDS_MAPPING:
            interruption_message = "Temporary interruption caught."
        self._interrupt(f"Interrupted by user: {interruption_message}")

execute_actions

execute_actions(message: dict) -> list[dict]
Source code in src/minisweagent/agents/interactive.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def execute_actions(self, message: dict) -> list[dict]:
    # Override to handle user confirmation and confirm_exit, with try/finally to preserve partial outputs
    actions = message.get("extra", {}).get("actions", [])
    commands = [action["command"] for action in actions]
    outputs = []
    try:
        self._ask_confirmation_or_interrupt(commands)
        for action in actions:
            outputs.append(self.env.execute(action))
    except Submitted as e:
        self._check_for_new_task_or_submit(e)
    finally:
        result = self.add_messages(
            *self.model.format_observation_messages(message, outputs, self.get_template_vars())
        )
    return result