mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-07 21:38:53 +00:00
fix(cli): allow overriding agent recursion limit (#4615)
This commit is contained in:
parent
bf2cb19ce7
commit
11bb8ddcd9
@ -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**.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user