mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
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:
parent
15a9a87fbd
commit
42629c8ac6
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user