fix(cli): allow overriding agent recursion limit (#4615)

This commit is contained in:
Xinmin Zeng 2026-08-04 08:33:10 +08:00 committed by GitHub
parent bf2cb19ce7
commit 11bb8ddcd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 138 additions and 7 deletions

View File

@ -1148,6 +1148,7 @@ deerflow --continue # resume the most recent thread
deerflow --resume THREAD # resume a thread by id
deerflow --print "summarize this repo" # headless one-shot answer to stdout
deerflow --json "hello" # headless newline-delimited StreamEvents
deerflow --recursion-limit 250 --print "task" # override the headless agent-loop limit
```
A keyboard-driven chat surface with a streaming transcript (Markdown-rendered answers), compact tool-activity cards, a `/` slash-command palette, display-only `/clear`, `/goal` goal management, `/model` and `/threads` pickers, input history, and `Esc` / `Ctrl+C` interrupt. `/clear` removes rows from the current terminal display without deleting the thread or its persisted conversation; `/new` and `/clear` ask you to wait during an active run instead of resetting in-flight display state. Sessions opened in the TUI also appear in the Web UI sidebar — it writes the shared thread store under the local default user, so terminal and web stay in sync **without running the Gateway**.

View File

@ -215,6 +215,7 @@ no services required:
uv pip install 'deerflow-harness[tui]' # optional 'textual' dependency
deerflow # launch the TUI
deerflow --print "summarize this repo" # headless one-shot
deerflow --recursion-limit 250 --print "run a longer task"
```
Sessions opened in the TUI appear in the Web UI sidebar (it writes the shared

View File

@ -27,12 +27,20 @@ Launch modes:
| `deerflow --resume THREAD` | Resume a thread by id |
| `deerflow --print "question"` | Headless one-shot answer to stdout |
| `deerflow --json "question"` | Headless newline-delimited `StreamEvent`s |
| `deerflow --recursion-limit 250 --print "question"` | Set the headless agent-loop super-step limit |
| `echo "q" \| deerflow --print` | Read the message from stdin |
| `DEER_FLOW_TUI=1 deerflow` | Force the TUI via environment |
If no TTY is available and no headless flag is given, `deerflow` prints guidance
instead of hanging.
Headless runs use a recursion limit of `100` by default. Pass a positive
`--recursion-limit` when a longer agent loop is expected. This is a LangGraph
super-step budget, so it can include model and tool-execution steps rather than
mapping one-to-one to conversational turns. The `max_recursion_limit` setting
is a Gateway safety ceiling for client-supplied values; it is not the default
for trusted embedded CLI runs.
## Surface
- **Header** — model, thread, project root, skill/tool counts.

View File

@ -30,9 +30,20 @@ class LaunchPlan:
thread_id: str | None = None
continue_recent: bool = False
forced_tui: bool = False
recursion_limit: int | None = None
reason: str = ""
def _positive_int(value: str) -> int:
try:
parsed = int(value)
except ValueError as exc:
raise argparse.ArgumentTypeError("must be a positive integer") from exc
if parsed <= 0:
raise argparse.ArgumentTypeError("must be a positive integer")
return parsed
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="deerflow",
@ -62,6 +73,12 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--cli", action="store_true", help="force headless/classic mode for one invocation")
parser.add_argument("--continue", dest="continue_recent", action="store_true", help="resume the most recent thread")
parser.add_argument("--resume", dest="resume", metavar="THREAD", default=None, help="resume a thread by id or title")
parser.add_argument(
"--recursion-limit",
type=_positive_int,
metavar="N",
help="headless agent-loop super-step limit (default: 100)",
)
return parser
@ -85,29 +102,60 @@ def plan_launch(
env: dict[str, str],
) -> LaunchPlan:
"""Decide what surface to launch. Pure: no I/O, no client construction."""
args = build_parser().parse_args(_strip_chat(argv))
parser = build_parser()
args = parser.parse_args(_strip_chat(argv))
positional = " ".join(args.message).strip() or None
resume = args.resume
continue_recent = bool(args.continue_recent)
headless_requested = args.print is not _UNSET or args.json is not _UNSET or args.cli
if args.recursion_limit is not None and not headless_requested:
parser.error("--recursion-limit requires --print, --json, or --cli")
if args.print is not _UNSET:
message = args.print if isinstance(args.print, str) else None
if message is None and stdin_isatty:
return LaunchPlan(mode="headless-help", reason="--print needs a MESSAGE argument or piped stdin.")
return LaunchPlan(mode="print", message=message, read_stdin=message is None, thread_id=resume, continue_recent=continue_recent)
return LaunchPlan(
mode="print",
message=message,
read_stdin=message is None,
thread_id=resume,
continue_recent=continue_recent,
recursion_limit=args.recursion_limit,
)
if args.json is not _UNSET:
message = args.json if isinstance(args.json, str) else None
if message is None and stdin_isatty:
return LaunchPlan(mode="headless-help", reason="--json needs a MESSAGE argument or piped stdin.")
return LaunchPlan(mode="json", message=message, read_stdin=message is None, thread_id=resume, continue_recent=continue_recent)
return LaunchPlan(
mode="json",
message=message,
read_stdin=message is None,
thread_id=resume,
continue_recent=continue_recent,
recursion_limit=args.recursion_limit,
)
if args.cli:
if positional:
return LaunchPlan(mode="print", message=positional, thread_id=resume, continue_recent=continue_recent)
return LaunchPlan(
mode="print",
message=positional,
thread_id=resume,
continue_recent=continue_recent,
recursion_limit=args.recursion_limit,
)
# Mirror --print: a piped message or --continue is enough to run headless.
if continue_recent or not stdin_isatty:
return LaunchPlan(mode="print", message=None, read_stdin=True, thread_id=resume, continue_recent=continue_recent)
return LaunchPlan(
mode="print",
message=None,
read_stdin=True,
thread_id=resume,
continue_recent=continue_recent,
recursion_limit=args.recursion_limit,
)
return LaunchPlan(
mode="headless-help",
reason='--cli needs a message. Try: deerflow --print "your question".',
@ -145,6 +193,8 @@ deerflow — DeerFlow terminal workbench
deerflow --resume THREAD resume a thread by id or title
deerflow --print "question" one-shot answer to stdout
deerflow --json "question" stream newline-delimited JSON events
deerflow --recursion-limit N --print "question"
set the headless agent-loop super-step limit
echo "question" | deerflow --print
"""
@ -155,6 +205,12 @@ def _resolve_message(plan: LaunchPlan) -> str:
return plan.message or ""
def _run_overrides(plan: LaunchPlan) -> dict[str, int]:
if plan.recursion_limit is None:
return {}
return {"recursion_limit": plan.recursion_limit}
def main(argv: Sequence[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
plan = plan_launch(
@ -195,7 +251,7 @@ def _run_print(plan: LaunchPlan) -> int:
return 2
session = _make_session()
thread_id = session.resolve_thread(plan)
answer = session.client.chat(message, thread_id=thread_id)
answer = session.client.chat(message, thread_id=thread_id, **_run_overrides(plan))
print(answer)
return 0
@ -207,7 +263,7 @@ def _run_json(plan: LaunchPlan) -> int:
return 2
session = _make_session()
thread_id = session.resolve_thread(plan)
for event in session.client.stream(message, thread_id=thread_id):
for event in session.client.stream(message, thread_id=thread_id, **_run_overrides(plan)):
payload = {"type": event.type, "data": event.data}
sys.stdout.write(json.dumps(payload, ensure_ascii=False, default=str) + "\n")
sys.stdout.flush()

View File

@ -1,5 +1,7 @@
"""Tests for TUI launch-mode planning + arg parsing (pure, no Textual)."""
import pytest
from deerflow.tui.cli import LaunchPlan, plan_launch
@ -26,6 +28,28 @@ def test_print_with_message():
assert p.read_stdin is False
@pytest.mark.parametrize("mode", ["--print", "--json"])
def test_headless_recursion_limit(mode):
p = plan(["--recursion-limit", "250", mode, "hello"])
assert p.recursion_limit == 250
def test_headless_recursion_limit_is_optional():
p = plan(["--print", "hello"])
assert p.recursion_limit is None
@pytest.mark.parametrize("value", ["0", "-1", "not-an-integer"])
def test_headless_recursion_limit_must_be_positive(value):
with pytest.raises(SystemExit):
plan(["--recursion-limit", value, "--print", "hello"])
def test_recursion_limit_is_rejected_for_tui_mode():
with pytest.raises(SystemExit):
plan(["--recursion-limit", "250"])
def test_print_without_value_reads_stdin_when_piped():
p = plan(["--print"], stdin_tty=False)
assert p.mode == "print"

View File

@ -2,22 +2,33 @@
import json
import pytest
from deerflow.client import StreamEvent
from deerflow.tui import cli
class _FakeClient:
def __init__(self):
self.chat_kwargs = None
self.stream_kwargs = None
def chat(self, message, *, thread_id=None, **kwargs):
self.chat_kwargs = kwargs
return f"answer:{message}"
def stream(self, message, *, thread_id=None, **kwargs):
self.stream_kwargs = kwargs
yield StreamEvent(type="messages-tuple", data={"type": "ai", "content": "hi", "id": "m1"})
yield StreamEvent(type="end", data={"usage": {"total_tokens": 1}})
class _FakeSession:
latest = None
def __init__(self):
self.client = _FakeClient()
type(self).latest = self
def resolve_thread(self, plan):
return None
@ -30,6 +41,20 @@ def test_main_print_outputs_chat_answer(monkeypatch, capsys):
assert "answer:hello" in capsys.readouterr().out
def test_main_print_passes_explicit_recursion_limit(monkeypatch, capsys):
monkeypatch.setattr(cli, "_make_session", _FakeSession)
rc = cli.main(["--recursion-limit", "250", "--print", "hello"])
assert rc == 0
assert _FakeSession.latest.client.chat_kwargs == {"recursion_limit": 250}
def test_main_print_omits_default_recursion_limit(monkeypatch, capsys):
monkeypatch.setattr(cli, "_make_session", _FakeSession)
rc = cli.main(["--print", "hello"])
assert rc == 0
assert _FakeSession.latest.client.chat_kwargs == {}
def test_main_json_emits_ndjson_stream_events(monkeypatch, capsys):
monkeypatch.setattr(cli, "_make_session", _FakeSession)
rc = cli.main(["--json", "hello"])
@ -40,6 +65,22 @@ def test_main_json_emits_ndjson_stream_events(monkeypatch, capsys):
assert payloads[-1]["type"] == "end"
def test_main_json_passes_explicit_recursion_limit(monkeypatch, capsys):
monkeypatch.setattr(cli, "_make_session", _FakeSession)
rc = cli.main(["--recursion-limit", "250", "--json", "hello"])
assert rc == 0
assert _FakeSession.latest.client.stream_kwargs == {"recursion_limit": 250}
def test_invalid_recursion_limit_fails_before_session_creation(monkeypatch):
def fail_if_called():
raise AssertionError("session should not be created for invalid CLI input")
monkeypatch.setattr(cli, "_make_session", fail_if_called)
with pytest.raises(SystemExit):
cli.main(["--recursion-limit", "0", "--print", "hello"])
def test_main_headless_help_returns_2_and_prints_usage(monkeypatch, capsys):
# On a TTY with no message and no piped stdin, --cli has nothing to run.
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)