Skip to content

feat: add interactive mode for agent loop#364

Merged
0xallam merged 1 commit into
mainfrom
feat/interactive-agent-loop
Mar 14, 2026
Merged

feat: add interactive mode for agent loop#364
0xallam merged 1 commit into
mainfrom
feat/interactive-agent-loop

Conversation

@0xallam

@0xallam 0xallam commented Mar 14, 2026

Copy link
Copy Markdown
Member

Summary

  • Re-architects the agent loop to support an interactive (chat-like) mode where text-only responses
    pause execution and wait for user input, while tool-call responses continue looping autonomously
  • Non-interactive mode (CLI, SaaS scans) is completely unchanged — every change is gated behind
    an interactive flag that defaults to False
  • Foundation for upcoming chat interface in the web app

Changes

New interactive flag on LLMConfig — flows through to system prompt (Jinja conditional),
LLM message preparation, and the agent loop.

Agent loop behavior (base_agent.py):

  • _process_iteration() now returns None for text-only responses (no tool calls)
  • In interactive mode: None → enter waiting state (pause for user input)
  • In non-interactive mode: None is falsy → loop continues as before (zero regression)

System prompt (system_prompt.jinja) — conditional sections:

  • Interactive: explicitly tells the model that text-only = pause, tool call = keep working
  • Autonomous: existing behavior preserved in {% else %} blocks

<meta>Continue the task.</meta> injection (llm.py) — skipped in interactive mode so
the agent doesn't auto-continue after text responses.

Configurable waiting_timeout on AgentState:

  • Root agent (interactive): 0 = wait indefinitely for user
  • Sub-agents (interactive): 300s auto-resume so they don't get stuck
  • Non-interactive: 600s default unchanged

Sub-agents inherit interactive from parent's LLMConfig via agents_graph_actions.py.

TUI sets interactive=True; CLI remains interactive=False.

@greptile-apps

greptile-apps Bot commented Mar 14, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR re-architects the agent loop to support an interactive (chat-like) mode by introducing an interactive flag on LLMConfig that flows through the system prompt, message preparation, and agent loop logic. Non-interactive behaviour (CLI/SaaS) is entirely unchanged — every new code path is gated behind the flag, which defaults to False.

Key design decisions are sound:

  • _process_iteration returning None (text-only, no tool call) vs False (empty/no response) gives interactive mode a clean signal to pause without touching non-interactive code paths.
  • waiting_timeout=0 for the root interactive agent and 300s for interactive sub-agents provides a sensible two-tier timeout policy.
  • Removing the <meta>Continue the task.</meta> injection in interactive mode prevents the agent from auto-looping after presenting results to the user.
  • Sub-agents correctly inherit interactive from their parent's llm_config in agents_graph_actions.py.

Minor items noted:

  • A pre-existing dead-code condition in _execute_actions (both branches of an inner if unconditionally return True) was carried over; it can be removed.
  • The tri-valued return type of _process_iteration (True/False/None) is undocumented; a short docstring would help future maintainers.
  • Using 0 as a sentinel for "wait indefinitely" in waiting_timeout is implicit; None would be more expressive.

Confidence Score: 4/5

  • Safe to merge — non-interactive paths are entirely unchanged and the interactive feature is cleanly gated behind a flag.
  • All changes are correctly guarded by self.interactive / not self.interactive. The None sentinel return from _process_iteration is falsy and therefore backward-compatible with non-interactive mode. The only issues found are a pre-existing dead-code condition, an undocumented tri-valued return type, and a minor sentinel readability concern — none affect correctness.
  • strix/agents/base_agent.py — dead-code condition at lines 441-443 and undocumented return semantics in _process_iteration.

Important Files Changed

Filename Overview
strix/agents/base_agent.py Core agent loop changes are well-structured; interactive flag correctly gates all new behaviour. One pre-existing dead-code block (both branches of inner if in _execute_actions return True) was carried over unchanged. New text_response path in _enter_waiting_state is clean.
strix/agents/state.py Adds configurable waiting_timeout with 0 meaning "wait indefinitely". has_waiting_timeout guard for waiting_timeout == 0 is correct. Clean, minimal change.
strix/llm/llm.py Passes interactive flag to the Jinja template and correctly suppresses the <meta>Continue the task.</meta> injection in interactive mode. Change is minimal and correct.
strix/llm/config.py Adds interactive: bool = False parameter to LLMConfig. Simple, correct addition with a safe default.
strix/tools/agents_graph/agents_graph_actions.py Inherits interactive from parent's llm_config and sets sub-agent waiting_timeout=300 (vs root's 0 or non-interactive's 600). Replaces the removed non_interactive config key cleanly.
strix/agents/StrixAgent/system_prompt.jinja Conditional {% if interactive %} blocks correctly adjust the agent's communication rules. Minor tension between "every message MUST contain a tool call" and the listed exceptions, but this is intentional to teach the model that text-only == pause.
strix/interface/tui.py Adds interactive=True to LLMConfig in _build_agent_config. One-line, correct change.
strix/interface/cli.py Removes the now-redundant non_interactive: True from agent_config. Non-interactive behaviour is now expressed through LLMConfig.interactive defaulting to False.

Comments Outside Diff (3)

  1. strix/agents/base_agent.py, line 441-443 (link)

    Redundant condition — both branches return True

    The inner if condition not self.interactive and self.state.parent_id is None never influences behaviour because both its True and False branches unconditionally return True. The condition is dead code and can be removed entirely.

    Simplify to:

  2. strix/agents/base_agent.py, line 367-376 (link)

    None vs False return values carry different semantics now

    _process_iteration now uses three distinct return values with different meanings:

    • True → agent should finish
    • False → no content / empty response (loop should continue)
    • None → text-only response (interactive: pause; non-interactive: continue)

    This is implicit and easy to misread — the type annotation bool | None doesn't communicate the intent. Consider adding a brief docstring or a named sentinel to clarify, for example:

    async def _process_iteration(self, tracer: Optional["Tracer"]) -> bool | None:
        """
        Returns:
            True  – agent signalled finish (tool called agent_finish/finish_scan)
            False – empty or no response; corrective message added
            None  – text-only response with no tool call (pause in interactive mode)
        """

    Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

  3. strix/agents/state.py, line 119-121 (link)

    waiting_timeout == 0 sentinel is implicit

    Using 0 to mean "wait indefinitely" is a common Unix convention, but it's invisible to readers of AgentState. If someone passes waiting_timeout=0 by accident (e.g. from a misconfigured value), the agent will silently wait forever with no timeout. Consider using None (or a named constant like WAIT_INDEFINITELY = 0) to make the intent explicit at call sites:

    waiting_timeout: int | None = 600

    and in has_waiting_timeout:

    if self.waiting_timeout is None:
        return False

    This also allows future type checkers to catch mistaken 0 assignments more easily.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: strix/agents/base_agent.py
Line: 441-443

Comment:
**Redundant condition — both branches return `True`**

The inner `if` condition `not self.interactive and self.state.parent_id is None` never influences behaviour because both its `True` and `False` branches unconditionally `return True`. The condition is dead code and can be removed entirely.

```suggestion
            if not self.interactive and self.state.parent_id is None:
                return True
            return True
```

Simplify to:

```suggestion
            return True
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: strix/agents/base_agent.py
Line: 367-376

Comment:
**`None` vs `False` return values carry different semantics now**

`_process_iteration` now uses three distinct return values with different meanings:
- `True` → agent should finish
- `False` → no content / empty response (loop should continue)
- `None` → text-only response (interactive: pause; non-interactive: continue)

This is implicit and easy to misread — the type annotation `bool | None` doesn't communicate the intent. Consider adding a brief docstring or a named sentinel to clarify, for example:

```python
async def _process_iteration(self, tracer: Optional["Tracer"]) -> bool | None:
    """
    Returns:
        True  – agent signalled finish (tool called agent_finish/finish_scan)
        False – empty or no response; corrective message added
        None  – text-only response with no tool call (pause in interactive mode)
    """
```

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: strix/agents/state.py
Line: 119-121

Comment:
**`waiting_timeout == 0` sentinel is implicit**

Using `0` to mean "wait indefinitely" is a common Unix convention, but it's invisible to readers of `AgentState`. If someone passes `waiting_timeout=0` by accident (e.g. from a misconfigured value), the agent will silently wait forever with no timeout. Consider using `None` (or a named constant like `WAIT_INDEFINITELY = 0`) to make the intent explicit at call sites:

```python
waiting_timeout: int | None = 600
```

and in `has_waiting_timeout`:

```python
if self.waiting_timeout is None:
    return False
```

This also allows future type checkers to catch mistaken `0` assignments more easily.

How can I resolve this? If you propose a fix, please make it concise.

Last reviewed commit: 7b09838

@0xallam 0xallam force-pushed the feat/interactive-agent-loop branch from 9021407 to d554389 Compare March 14, 2026 18:35
Re-architects the agent loop to support interactive (chat-like) mode
where text-only responses pause execution and wait for user input,
while tool-call responses continue looping autonomously.

- Add `interactive` flag to LLMConfig (default False, no regression)
- Add configurable `waiting_timeout` to AgentState (0 = disabled)
- _process_iteration returns None for text-only → agent_loop pauses
- Conditional system prompt: interactive allows natural text responses
- Skip <meta>Continue the task.</meta> injection in interactive mode
- Sub-agents inherit interactive from parent (300s auto-resume timeout)
- Root interactive agents wait indefinitely for user input (timeout=0)
- TUI sets interactive=True; CLI unchanged (non_interactive=True)
@0xallam 0xallam force-pushed the feat/interactive-agent-loop branch from d554389 to 7b09838 Compare March 14, 2026 18:40
@0xallam

0xallam commented Mar 14, 2026

Copy link
Copy Markdown
Member Author

@greptileai review again

@0xallam 0xallam merged commit 1404864 into main Mar 14, 2026
2 checks passed
@0xallam 0xallam deleted the feat/interactive-agent-loop branch March 14, 2026 18:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant