mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-07 21:38:53 +00:00
294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""Command-line entry point and launch-mode planning for the DeerFlow TUI.
|
|
|
|
``plan_launch`` is a pure decision function (fully unit-tested): given argv, TTY
|
|
state and the environment, it decides whether to open the terminal UI or run a
|
|
headless one-shot. ``main`` wires that decision to the embedded ``DeerFlowClient``
|
|
and lazily imports the Textual app only when actually launching the UI, so the
|
|
``deerflow`` console script still runs headless commands without Textual present.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
from typing import Literal
|
|
|
|
_UNSET = object()
|
|
|
|
Mode = Literal["tui", "print", "json", "headless-help"]
|
|
|
|
|
|
@dataclass
|
|
class LaunchPlan:
|
|
mode: Mode
|
|
message: str | None = None
|
|
read_stdin: bool = False
|
|
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",
|
|
description="DeerFlow terminal workbench — a TUI over the embedded DeerFlow harness.",
|
|
add_help=True,
|
|
)
|
|
parser.add_argument("message", nargs="*", help="initial prompt for the TUI, or message in --cli mode")
|
|
parser.add_argument(
|
|
"--print",
|
|
dest="print",
|
|
nargs="?",
|
|
const=None,
|
|
default=_UNSET,
|
|
metavar="MESSAGE",
|
|
help="headless one-shot: print the final answer and exit (reads stdin if no MESSAGE)",
|
|
)
|
|
parser.add_argument(
|
|
"--json",
|
|
dest="json",
|
|
nargs="?",
|
|
const=None,
|
|
default=_UNSET,
|
|
metavar="MESSAGE",
|
|
help="headless streaming: emit newline-delimited JSON StreamEvents and exit",
|
|
)
|
|
parser.add_argument("--tui", action="store_true", help="force the terminal UI (error if unavailable)")
|
|
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
|
|
|
|
|
|
def _strip_chat(argv: Sequence[str]) -> list[str]:
|
|
"""Accept an optional leading ``chat`` subcommand as an alias for the default surface."""
|
|
argv = list(argv)
|
|
if argv and argv[0] == "chat":
|
|
return argv[1:]
|
|
return argv
|
|
|
|
|
|
def _truthy(value: object) -> bool:
|
|
return isinstance(value, str) and value.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
def plan_launch(
|
|
argv: Sequence[str],
|
|
*,
|
|
stdin_isatty: bool,
|
|
stdout_isatty: bool,
|
|
env: dict[str, str],
|
|
) -> LaunchPlan:
|
|
"""Decide what surface to launch. Pure: no I/O, no client construction."""
|
|
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,
|
|
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,
|
|
recursion_limit=args.recursion_limit,
|
|
)
|
|
|
|
if args.cli:
|
|
if positional:
|
|
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,
|
|
recursion_limit=args.recursion_limit,
|
|
)
|
|
return LaunchPlan(
|
|
mode="headless-help",
|
|
reason='--cli needs a message. Try: deerflow --print "your question".',
|
|
)
|
|
|
|
forced_tui = bool(args.tui)
|
|
if forced_tui or _truthy(env.get("DEER_FLOW_TUI")) or (stdin_isatty and stdout_isatty):
|
|
return LaunchPlan(
|
|
mode="tui",
|
|
message=positional,
|
|
thread_id=resume,
|
|
continue_recent=continue_recent,
|
|
forced_tui=forced_tui,
|
|
)
|
|
|
|
return LaunchPlan(
|
|
mode="headless-help",
|
|
message=positional,
|
|
thread_id=resume,
|
|
continue_recent=continue_recent,
|
|
reason="No interactive terminal detected. Use --print MESSAGE for one-shot output, or --tui to force the UI.",
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Runtime dispatch (not unit-tested here; covered by smoke + integration).
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
_HEADLESS_HELP = """\
|
|
deerflow — DeerFlow terminal workbench
|
|
|
|
deerflow launch the terminal UI (TTY required)
|
|
deerflow --tui force the terminal UI
|
|
deerflow --continue resume the most recent thread in the UI
|
|
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
|
|
"""
|
|
|
|
|
|
def _resolve_message(plan: LaunchPlan) -> str:
|
|
if plan.read_stdin:
|
|
return sys.stdin.read().strip()
|
|
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(
|
|
argv,
|
|
stdin_isatty=sys.stdin.isatty(),
|
|
stdout_isatty=sys.stdout.isatty(),
|
|
env=dict(os.environ),
|
|
)
|
|
|
|
if plan.mode == "headless-help":
|
|
if plan.reason:
|
|
print(plan.reason, file=sys.stderr)
|
|
print(_HEADLESS_HELP, file=sys.stderr)
|
|
return 0 if not plan.reason else 2
|
|
|
|
if plan.mode == "print":
|
|
return _run_print(plan)
|
|
|
|
if plan.mode == "json":
|
|
return _run_json(plan)
|
|
|
|
return _run_tui(plan)
|
|
|
|
|
|
def _make_session():
|
|
# Imported lazily so the pure planning path never imports the heavy harness.
|
|
# Headless one-shots never use the threads_meta writer, so skip persistence
|
|
# (no background loop / engine / connection pool just to discard it).
|
|
from .session import open_session
|
|
|
|
return open_session(persistence=False)
|
|
|
|
|
|
def _run_print(plan: LaunchPlan) -> int:
|
|
message = _resolve_message(plan)
|
|
if not message:
|
|
print("No message provided.", file=sys.stderr)
|
|
return 2
|
|
session = _make_session()
|
|
thread_id = session.resolve_thread(plan)
|
|
answer = session.client.chat(message, thread_id=thread_id, **_run_overrides(plan))
|
|
print(answer)
|
|
return 0
|
|
|
|
|
|
def _run_json(plan: LaunchPlan) -> int:
|
|
message = _resolve_message(plan)
|
|
if not message:
|
|
print("No message provided.", file=sys.stderr)
|
|
return 2
|
|
session = _make_session()
|
|
thread_id = session.resolve_thread(plan)
|
|
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()
|
|
return 0
|
|
|
|
|
|
def _run_tui(plan: LaunchPlan) -> int:
|
|
try:
|
|
# Absolute import (not `from .app`) so the harness import-boundary check,
|
|
# which records relative module names verbatim, doesn't mistake the sibling
|
|
# `deerflow.tui.app` module for the forbidden top-level `app` package.
|
|
from deerflow.tui.app import run_tui
|
|
except ModuleNotFoundError as exc: # textual missing
|
|
if getattr(exc, "name", "") == "textual" or "textual" in str(exc):
|
|
msg = "The terminal UI needs the optional 'textual' dependency.\nInstall it with: uv pip install 'deerflow-harness[tui]' (or: pip install textual)\n"
|
|
if plan.forced_tui:
|
|
print(msg, file=sys.stderr)
|
|
return 1
|
|
print(msg + "\nFalling back to headless help:\n", file=sys.stderr)
|
|
print(_HEADLESS_HELP, file=sys.stderr)
|
|
return 0
|
|
raise
|
|
return run_tui(plan)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|