fix: detect browser extra regardless of tool field order (#5456)

* fix: detect browser extra regardless of tool field order

* test: address browser extra detection review feedback
This commit is contained in:
liunianxuxie 2026-09-16 18:29:27 +08:00 committed by GitHub
parent 15a9a87fbd
commit 42629c8ac6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 103 additions and 8 deletions

View File

@ -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:

View File

@ -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(

View File

@ -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

View File

@ -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