mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-24 21:58:38 +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 --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**.
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user