diff --git a/README.md b/README.md index 4783a710a..50379d2ff 100644 --- a/README.md +++ b/README.md @@ -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**. diff --git a/backend/README.md b/backend/README.md index e8b87af22..a7317b985 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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 diff --git a/backend/docs/TUI.md b/backend/docs/TUI.md index 481b114c7..693bf83d9 100644 --- a/backend/docs/TUI.md +++ b/backend/docs/TUI.md @@ -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. diff --git a/backend/packages/harness/deerflow/tui/cli.py b/backend/packages/harness/deerflow/tui/cli.py index b7c57ac69..60a4cd014 100644 --- a/backend/packages/harness/deerflow/tui/cli.py +++ b/backend/packages/harness/deerflow/tui/cli.py @@ -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() diff --git a/backend/tests/test_tui_cli.py b/backend/tests/test_tui_cli.py index 47740c9b8..9939e79e2 100644 --- a/backend/tests/test_tui_cli.py +++ b/backend/tests/test_tui_cli.py @@ -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" diff --git a/backend/tests/test_tui_cli_main.py b/backend/tests/test_tui_cli_main.py index efb5a6826..c6817b52b 100644 --- a/backend/tests/test_tui_cli_main.py +++ b/backend/tests/test_tui_cli_main.py @@ -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)