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 --resume THREAD # resume a thread by id
deerflow --print "summarize this repo" # headless one-shot answer to stdout deerflow --print "summarize this repo" # headless one-shot answer to stdout
deerflow --json "hello" # headless newline-delimited StreamEvents 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**. 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 uv pip install 'deerflow-harness[tui]' # optional 'textual' dependency
deerflow # launch the TUI deerflow # launch the TUI
deerflow --print "summarize this repo" # headless one-shot 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 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 --resume THREAD` | Resume a thread by id |
| `deerflow --print "question"` | Headless one-shot answer to stdout | | `deerflow --print "question"` | Headless one-shot answer to stdout |
| `deerflow --json "question"` | Headless newline-delimited `StreamEvent`s | | `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 | | `echo "q" \| deerflow --print` | Read the message from stdin |
| `DEER_FLOW_TUI=1 deerflow` | Force the TUI via environment | | `DEER_FLOW_TUI=1 deerflow` | Force the TUI via environment |
If no TTY is available and no headless flag is given, `deerflow` prints guidance If no TTY is available and no headless flag is given, `deerflow` prints guidance
instead of hanging. 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 ## Surface
- **Header** — model, thread, project root, skill/tool counts. - **Header** — model, thread, project root, skill/tool counts.

View File

@ -30,9 +30,20 @@ class LaunchPlan:
thread_id: str | None = None thread_id: str | None = None
continue_recent: bool = False continue_recent: bool = False
forced_tui: bool = False forced_tui: bool = False
recursion_limit: int | None = None
reason: str = "" 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: def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
prog="deerflow", 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("--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("--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("--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 return parser
@ -85,29 +102,60 @@ def plan_launch(
env: dict[str, str], env: dict[str, str],
) -> LaunchPlan: ) -> LaunchPlan:
"""Decide what surface to launch. Pure: no I/O, no client construction.""" """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 positional = " ".join(args.message).strip() or None
resume = args.resume resume = args.resume
continue_recent = bool(args.continue_recent) 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: if args.print is not _UNSET:
message = args.print if isinstance(args.print, str) else None message = args.print if isinstance(args.print, str) else None
if message is None and stdin_isatty: if message is None and stdin_isatty:
return LaunchPlan(mode="headless-help", reason="--print needs a MESSAGE argument or piped stdin.") 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: if args.json is not _UNSET:
message = args.json if isinstance(args.json, str) else None message = args.json if isinstance(args.json, str) else None
if message is None and stdin_isatty: if message is None and stdin_isatty:
return LaunchPlan(mode="headless-help", reason="--json needs a MESSAGE argument or piped stdin.") 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 args.cli:
if positional: 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. # Mirror --print: a piped message or --continue is enough to run headless.
if continue_recent or not stdin_isatty: 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( return LaunchPlan(
mode="headless-help", mode="headless-help",
reason='--cli needs a message. Try: deerflow --print "your question".', 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 --resume THREAD resume a thread by id or title
deerflow --print "question" one-shot answer to stdout deerflow --print "question" one-shot answer to stdout
deerflow --json "question" stream newline-delimited JSON events 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 echo "question" | deerflow --print
""" """
@ -155,6 +205,12 @@ def _resolve_message(plan: LaunchPlan) -> str:
return plan.message or "" 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: def main(argv: Sequence[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv) argv = list(sys.argv[1:] if argv is None else argv)
plan = plan_launch( plan = plan_launch(
@ -195,7 +251,7 @@ def _run_print(plan: LaunchPlan) -> int:
return 2 return 2
session = _make_session() session = _make_session()
thread_id = session.resolve_thread(plan) 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) print(answer)
return 0 return 0
@ -207,7 +263,7 @@ def _run_json(plan: LaunchPlan) -> int:
return 2 return 2
session = _make_session() session = _make_session()
thread_id = session.resolve_thread(plan) 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} payload = {"type": event.type, "data": event.data}
sys.stdout.write(json.dumps(payload, ensure_ascii=False, default=str) + "\n") sys.stdout.write(json.dumps(payload, ensure_ascii=False, default=str) + "\n")
sys.stdout.flush() sys.stdout.flush()

View File

@ -1,5 +1,7 @@
"""Tests for TUI launch-mode planning + arg parsing (pure, no Textual).""" """Tests for TUI launch-mode planning + arg parsing (pure, no Textual)."""
import pytest
from deerflow.tui.cli import LaunchPlan, plan_launch from deerflow.tui.cli import LaunchPlan, plan_launch
@ -26,6 +28,28 @@ def test_print_with_message():
assert p.read_stdin is False 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(): def test_print_without_value_reads_stdin_when_piped():
p = plan(["--print"], stdin_tty=False) p = plan(["--print"], stdin_tty=False)
assert p.mode == "print" assert p.mode == "print"

View File

@ -2,22 +2,33 @@
import json import json
import pytest
from deerflow.client import StreamEvent from deerflow.client import StreamEvent
from deerflow.tui import cli from deerflow.tui import cli
class _FakeClient: class _FakeClient:
def __init__(self):
self.chat_kwargs = None
self.stream_kwargs = None
def chat(self, message, *, thread_id=None, **kwargs): def chat(self, message, *, thread_id=None, **kwargs):
self.chat_kwargs = kwargs
return f"answer:{message}" return f"answer:{message}"
def stream(self, message, *, thread_id=None, **kwargs): 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="messages-tuple", data={"type": "ai", "content": "hi", "id": "m1"})
yield StreamEvent(type="end", data={"usage": {"total_tokens": 1}}) yield StreamEvent(type="end", data={"usage": {"total_tokens": 1}})
class _FakeSession: class _FakeSession:
latest = None
def __init__(self): def __init__(self):
self.client = _FakeClient() self.client = _FakeClient()
type(self).latest = self
def resolve_thread(self, plan): def resolve_thread(self, plan):
return None return None
@ -30,6 +41,20 @@ def test_main_print_outputs_chat_answer(monkeypatch, capsys):
assert "answer:hello" in capsys.readouterr().out 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): def test_main_json_emits_ndjson_stream_events(monkeypatch, capsys):
monkeypatch.setattr(cli, "_make_session", _FakeSession) monkeypatch.setattr(cli, "_make_session", _FakeSession)
rc = cli.main(["--json", "hello"]) rc = cli.main(["--json", "hello"])
@ -40,6 +65,22 @@ def test_main_json_emits_ndjson_stream_events(monkeypatch, capsys):
assert payloads[-1]["type"] == "end" 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): 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. # On a TTY with no message and no piped stdin, --cli has nothing to run.
monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True) monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)