mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 21:26:39 +00:00
* feat(examples): add opt-in fetched-content screening extension * fix(examples): screen sync and async results through supported middleware hooks * fix(examples): preserve empty message IDs when applying screening warnings * fix(examples): include screening policy in assembly identity * feat(examples): load fetched-content screening through the extension entry Package the screening example as a plugins: extension instead of an operator-configured middleware. install() validates the private config and, once enabled, contributes one TOOL_VISIBLE middleware for lead agents and subagents. The package imports only deerflow_extension_api. Extension tool wrappers are observational, so detection and the warning use two supported contracts: the tool wrapper classifies the visible result and records flagged tool-call IDs in the run's task store, and before_model replaces the matching messages of the latest tool step by ID. The excerpt sent to Jev is now the redacted, sanitized text the model sees. Runs without a task store send nothing. Unexpected local errors surface as isolation diagnostics; provider errors fail open. Tests load the package through load_extensions() and the host isolation wrapper and run real lead/subagent graphs with a task store bound as the Gateway worker binds it. The README records what the example exercised in the extension API and the gaps it found. * fix(examples): classify each tool message of a screened result on its own A Command result was classified as one concatenated excerpt and the flag landed on its first text message, so a benign first result could take the warning while an injected later result reached the model unmarked. Each text message now gets its own bounded excerpt and Jev request, for at most eight messages per tool call. The requests share one client and run concurrently in a task group, each under its own deadline, so one slow or failed request cannot hide another message's flag. Every flagged message's tool-call ID is recorded. The message cap joins the declared policy, and the README describes the per-message requests and data sharing. --------- Co-authored-by: Totoro-qaq <279883115+Totoro-qaq@users.noreply.github.com>
116 lines
4.9 KiB
Python
116 lines
4.9 KiB
Python
"""Screening policy identity through the host's assembly descriptor.
|
|
|
|
The middleware reaches the descriptor the way a ``plugins:`` entry does: loaded
|
|
by the extension loader and wrapped by host isolation, which the descriptor
|
|
unwraps to read the extension's own policy declaration.
|
|
"""
|
|
|
|
import json
|
|
from dataclasses import asdict
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from deerflow_extension_api import AgentBuildContext, AgentScope, Placement
|
|
|
|
from deerflow.agents.assembly_descriptor import build_assembly_descriptor
|
|
from deerflow.extensions.anchors import outermost
|
|
from deerflow.extensions.injection import inject_middlewares
|
|
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
|
|
|
EXAMPLE = Path(__file__).resolve().parents[2] / "examples/deerflow-extension-jev-screening"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def example_path(monkeypatch):
|
|
monkeypatch.syspath_prepend(str(EXAMPLE))
|
|
|
|
|
|
def contributed(**options):
|
|
extensions, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_jev_screening:install", config={"enabled": True, **options})])
|
|
assert [d for d in diagnostics if d.level == "error"] == []
|
|
stack, _provenance, construction = inject_middlewares([], {Placement.TOOL_VISIBLE: outermost()}, AgentScope.LEAD, AgentBuildContext(scope=AgentScope.LEAD), extensions)
|
|
assert construction == []
|
|
return stack
|
|
|
|
|
|
def assembly(**options):
|
|
return build_assembly_descriptor(
|
|
namespace="screening-policy-test",
|
|
agent_name="test",
|
|
requested_model="fixed-model",
|
|
effective_model="fixed-model",
|
|
model_config=SimpleNamespace(model="fixed-model"),
|
|
thinking_enabled=False,
|
|
reasoning_effort=None,
|
|
rendered_base_prompt="Fixed synthetic base prompt.",
|
|
tools=[],
|
|
middlewares=contributed(**options),
|
|
deferred_names=frozenset(),
|
|
enabled_skills=[],
|
|
effective_policies={},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"options",
|
|
[
|
|
{"enabled": False},
|
|
{"endpoint": "https://other.example.test/private-endpoint"},
|
|
{"model": "another-model"},
|
|
{"threshold": 0.7},
|
|
{"max_excerpt_chars": 2000},
|
|
{"timeout_seconds": 1.0},
|
|
{"api_key_env": "ALTERNATE_SCREENING_KEY"},
|
|
],
|
|
)
|
|
def test_each_screening_option_changes_the_real_assembly_fingerprint(options):
|
|
assert assembly(**options).fingerprint != assembly().fingerprint
|
|
|
|
|
|
@pytest.mark.parametrize("field", ["_INSTRUCTION", "_CRITERIA", "_MARKER"])
|
|
def test_screening_text_changes_move_the_assembly_fingerprint(monkeypatch, field):
|
|
from deerflow_extension_jev_screening import screener
|
|
|
|
before = assembly().fingerprint
|
|
original = getattr(screener, field)
|
|
changed = {**original, "true": "Synthetic changed criterion."} if isinstance(original, dict) else original + " Synthetic changed text."
|
|
monkeypatch.setattr(screener, field, changed)
|
|
assert assembly().fingerprint != before
|
|
|
|
|
|
def test_policy_is_stable_json_safe_and_does_not_expose_endpoint_prompt_or_keys(monkeypatch):
|
|
from deerflow_extension_jev_screening import screener
|
|
|
|
endpoint = "https://private.example.test/tenant-canary"
|
|
instruction = "Synthetic private screening question canary."
|
|
criteria = {"true": "Synthetic private positive canary.", "false": "Synthetic private negative canary."}
|
|
marker = "Synthetic private marker canary."
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", "secret-value-canary-A")
|
|
monkeypatch.setattr(screener, "_INSTRUCTION", instruction)
|
|
monkeypatch.setattr(screener, "_CRITERIA", criteria)
|
|
monkeypatch.setattr(screener, "_MARKER", marker)
|
|
first = assembly(endpoint=endpoint)
|
|
(entry,) = first.middlewares
|
|
assert entry.extension, "the descriptor must attribute the middleware to its extension"
|
|
declared = entry.policy_parameters
|
|
assert "probed" not in declared
|
|
assert declared["enabled"] is True
|
|
assert declared["api_key_env"] == "TYPESAFE_API_KEY"
|
|
assert declared["max_screened_messages"] == 8
|
|
for name in ("endpoint_sha256", "question_sha256", "marker_sha256"):
|
|
assert len(declared[name]) == 64 and all(c in "0123456789abcdef" for c in declared[name])
|
|
serialized = json.dumps(asdict(first), sort_keys=True, allow_nan=False)
|
|
for private in (endpoint, instruction, marker, *criteria.values(), "secret-value-canary-A"):
|
|
assert private not in serialized
|
|
assert "endpoint" not in declared
|
|
assert assembly(endpoint=endpoint).fingerprint == first.fingerprint
|
|
monkeypatch.setenv("TYPESAFE_API_KEY", "secret-value-canary-B")
|
|
rotated = assembly(endpoint=endpoint)
|
|
assert rotated.fingerprint == first.fingerprint
|
|
assert "secret-value-canary-B" not in json.dumps(asdict(rotated))
|
|
monkeypatch.delenv("TYPESAFE_API_KEY")
|
|
assert assembly(endpoint=endpoint).fingerprint == first.fingerprint
|
|
monkeypatch.setattr(screener, "_CRITERIA", dict(reversed(list(criteria.items()))))
|
|
assert assembly(endpoint=endpoint).fingerprint == first.fingerprint
|