mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-21 03:56:20 +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
|
### 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.
|
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:
|
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) == []
|
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):
|
def test_detect_from_config_buzz_via_channels_enabled(tmp_path):
|
||||||
cfg = tmp_path / "config.yaml"
|
cfg = tmp_path / "config.yaml"
|
||||||
cfg.write_text(
|
cfg.write_text(
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
## Service Startup Contracts
|
## 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
|
The root `PORT` value configures Docker's published nginx ingress only; local
|
||||||
orchestration pins Next.js to `3000`. Runtime commands launch from the already
|
orchestration pins Next.js to `3000`. Runtime commands launch from the already
|
||||||
synchronized environment with `uv run --no-sync`. Production Compose probes
|
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*$")
|
_SECTION_RE = re.compile(r"^([A-Za-z_][\w-]*)\s*:\s*$")
|
||||||
_INDENTED_SECTION_RE = re.compile(r"^\s+([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*$")
|
_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
|
# `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
|
# later one (` use: X`). Leading whitespace is optional because
|
||||||
# `yaml.safe_dump` (the setup wizard, config-upgrade.sh) writes list items
|
# `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:
|
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
|
inside = False
|
||||||
|
list_indent: int | None = None
|
||||||
|
field_indent: int | None = None
|
||||||
for raw in lines:
|
for raw in lines:
|
||||||
line = _strip_comment(raw)
|
line = _strip_comment(raw)
|
||||||
if not line.strip():
|
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)
|
sect_match = _SECTION_RE.match(line)
|
||||||
if sect_match:
|
if sect_match:
|
||||||
inside = sect_match.group(1) == "tools"
|
inside = sect_match.group(1) == "tools"
|
||||||
|
list_indent = None
|
||||||
|
field_indent = None
|
||||||
continue
|
continue
|
||||||
if not inside:
|
if not inside:
|
||||||
continue
|
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()
|
stripped = line.lstrip()
|
||||||
indent = len(line) - len(stripped)
|
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
|
inside = False
|
||||||
continue
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user