diff --git a/README.md b/README.md index 6155dabaf..21b2833cd 100644 --- a/README.md +++ b/README.md @@ -1470,6 +1470,9 @@ This is the difference between a chatbot with tool access and an agent with an a ### Agentic Browser Control +Browser dependency auto-detection accepts `name`, `group`, and `use` in any +order within a tool entry, with either indented or indentless YAML lists. + Reading a page is not the same as *using* one. Alongside the read-only `web_fetch` and `web_capture` tools, DeerFlow ships an optional agentic browser tool group that keeps a live, per-conversation browser session so the agent can actually operate a page — navigate, read the interactive elements, click, type, submit forms, and follow multi-step flows on JavaScript-heavy sites. Each action returns a fresh snapshot of the page's interactive elements, each addressed by a stable `[ref]` number, so the agent acts on what it just observed instead of guessing selectors. Outbound URLs are SSRF-screened by default. It is powered by Playwright and shipped as an optional extra so the core install stays lean: diff --git a/backend/tests/test_detect_uv_extras.py b/backend/tests/test_detect_uv_extras.py index 668192e92..1da032b74 100644 --- a/backend/tests/test_detect_uv_extras.py +++ b/backend/tests/test_detect_uv_extras.py @@ -173,6 +173,70 @@ def test_detect_from_config_ignores_commented_browser_tool(tmp_path): assert detect.detect_from_config(cfg) == [] +@pytest.mark.parametrize("indent", ["", " "]) +@pytest.mark.parametrize("first_key", ["use", "group"]) +def test_detect_browser_name_after_other_fields(tmp_path, indent, first_key): + """A tool mapping's field order must not change its required extras.""" + fields = { + "use": "deerflow.community.browser_automation.tools:browser_navigate_tool", + "group": "browser", + } + second_key = "group" if first_key == "use" else "use" + cfg = tmp_path / "config.yaml" + cfg.write_text( + "tools:\n" + f"{indent}- name: web_fetch\n" + f"{indent} group: web\n" + f"{indent}- {first_key}: {fields[first_key]}\n" + f"{indent} {second_key}: {fields[second_key]}\n" + f"{indent} # The name does not have to be the first field.\n" + f"{indent} name: 'browser_navigate' # enable browser\n", + encoding="utf-8", + ) + assert detect.detect_from_config(cfg) == ["browser"] + + +@pytest.mark.parametrize("indent", ["", " "]) +@pytest.mark.parametrize( + "other_name", + [ + " options:\n name: browser_navigate\n", + " options:\n - name: browser_navigate\n", + " description: |\n name: browser_navigate\n", + " # name: browser_navigate\n", + ], + ids=["nested-mapping", "nested-list", "block-scalar", "comment"], +) +def test_detect_browser_ignores_names_outside_tool_fields(tmp_path, indent, other_name): + """Nested or commented names must not install an unrelated optional extra.""" + cfg = tmp_path / "config.yaml" + nested_lines = "".join(f"{indent}{line}\n" for line in other_name.splitlines()) + cfg.write_text(f"tools:\n{indent}- name: web_fetch\n{nested_lines}", encoding="utf-8") + assert detect.detect_from_config(cfg) == [] + + +@pytest.mark.parametrize("indent", ["", " "]) +def test_detect_browser_name_after_nested_block(tmp_path, indent): + """A nested block must not reset tracking of the tool's direct fields.""" + cfg = tmp_path / "config.yaml" + cfg.write_text( + f"tools:\n{indent}- group: browser\n{indent} options:\n{indent} x: 1\n{indent} name: browser_navigate\n", + encoding="utf-8", + ) + assert detect.detect_from_config(cfg) == ["browser"] + + +@pytest.mark.parametrize("indent", ["", " "]) +def test_detect_browser_stops_at_following_section(tmp_path, indent): + """A later section's item name is not a tool name.""" + cfg = tmp_path / "config.yaml" + cfg.write_text( + f"tools:\n{indent}- group: web\n{indent} name: web_fetch\nmodels:\n{indent}- use: provider:Model\n{indent} name: browser_navigate\n", + encoding="utf-8", + ) + assert detect.detect_from_config(cfg) == [] + + def test_detect_from_config_buzz_via_channels_enabled(tmp_path): cfg = tmp_path / "config.yaml" cfg.write_text( diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index c080a489e..b244388f3 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -1,5 +1,11 @@ ## Service Startup Contracts +Optional browser dependency detection reads the top-level `tools:` sequence +without requiring `name` to be its first mapping key. Both indented and +indentless lists are supported; nested option names and block-scalar text +must not enable the browser extra. Keep the detector standard-library-only +because it runs before dependency synchronization. + The root `PORT` value configures Docker's published nginx ingress only; local orchestration pins Next.js to `3000`. Runtime commands launch from the already synchronized environment with `uv run --no-sync`. Production Compose probes diff --git a/scripts/detect_uv_extras.py b/scripts/detect_uv_extras.py index 7f71b9f14..eb7941411 100755 --- a/scripts/detect_uv_extras.py +++ b/scripts/detect_uv_extras.py @@ -79,7 +79,7 @@ def find_config_file() -> Path | None: _SECTION_RE = re.compile(r"^([A-Za-z_][\w-]*)\s*:\s*$") _INDENTED_SECTION_RE = re.compile(r"^\s+([A-Za-z_][\w-]*)\s*:\s*$") _KEY_RE = re.compile(r"^\s+([A-Za-z_][\w-]*)\s*:\s*(\S.*?)\s*$") -_LIST_ITEM_NAME_RE = re.compile(r"^\s*-\s+name\s*:\s*(\S.*?)\s*$") +_LIST_ITEM_KEY_RE = re.compile(r"^(\s*)-\s+([A-Za-z_][\w-]*)\s*:\s*(.*?)\s*$") # `use:` on a models list item, whether it is the first key (`- use: X`) or a # later one (` use: X`). Leading whitespace is optional because # `yaml.safe_dump` (the setup wizard, config-upgrade.sh) writes list items @@ -242,8 +242,15 @@ def nested_section_value(lines: list[str], section_path: str, key: str) -> str | def tools_include_name(lines: list[str], tool_name: str) -> bool: - """Return True when the top-level tools list has an active item name.""" + """Return True when the top-level tools list has an active item name. + + The first list item fixes the list indent, and each item's first key column + identifies its direct fields. The name may appear anywhere in the mapping; + deeper-nested names are ignored in both indented and indentless lists. + """ inside = False + list_indent: int | None = None + field_indent: int | None = None for raw in lines: line = _strip_comment(raw) if not line.strip(): @@ -251,19 +258,34 @@ def tools_include_name(lines: list[str], tool_name: str) -> bool: sect_match = _SECTION_RE.match(line) if sect_match: inside = sect_match.group(1) == "tools" + list_indent = None + field_indent = None continue if not inside: continue - name_match = _LIST_ITEM_NAME_RE.match(line) - if name_match: - if _unquote(name_match.group(1).strip()) == tool_name: - return True - continue stripped = line.lstrip() indent = len(line) - len(stripped) - if indent == 0: + item_match = _LIST_ITEM_KEY_RE.match(line) + if item_match: + indent = item_match.end(1) + if list_indent is None: + list_indent = indent + if indent != list_indent: + continue + # The first mapping key need not be name. Its column determines + # which continuation keys belong to the tool rather than options. + field_indent = item_match.start(2) + key, value = item_match.group(2, 3) + elif indent == 0: inside = False continue + else: + key_match = _KEY_RE.match(line) + if indent != field_indent or key_match is None: + continue + key, value = key_match.group(1, 2) + if key == "name" and _unquote(value.strip()) == tool_name: + return True return False