mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-25 05:56:18 +00:00
fix(agents): distinguish undeclared and empty skill allowed-tools (#5669)
* fix(agents): distinguish undeclared and empty skill allowed-tools * fix(ci): reduce guidance and isolate PostgreSQL test mocks --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
61a99a1e8a
commit
e1352bcdc0
@ -1,18 +1,17 @@
|
|||||||
### Agent System
|
### Agent System
|
||||||
|
|
||||||
**Lead Agent** (`packages/harness/deerflow/agents/lead_agent/agent.py`):
|
**Lead Agent** (`packages/harness/deerflow/agents/lead_agent/agent.py`):
|
||||||
- Entry point: `make_lead_agent(config: RunnableConfig)` registered in `langgraph.json`.
|
- `make_lead_agent(config: RunnableConfig)` is the published `langgraph.json`
|
||||||
Its signature and bare-graph return type are a published ABI: LangGraph Server calls it
|
entry point; preserve its signature and bare-graph return type.
|
||||||
directly, so neither may change.
|
- Gateway calls `assemble_lead_agent(config, *, app_config=None)` for a
|
||||||
- `assemble_lead_agent(config, *, app_config=None) -> LeadAgentAssembly(graph, descriptor)`
|
`LeadAgentAssembly(graph, descriptor)`; `make_lead_agent` returns `.graph`.
|
||||||
is the richer entry point the Gateway uses; `make_lead_agent` is a thin wrapper returning
|
`assembly_descriptor.py::build_assembly_descriptor()` records the model after
|
||||||
`.graph`. The descriptor is built by
|
runtime overrides, rendered prompt hash, authorized tools and middleware order.
|
||||||
`deerflow/agents/assembly_descriptor.py::build_assembly_descriptor()` and captures what
|
Consumers must unwrap via `runtime/runs/worker.py::_agent_graph` to also accept
|
||||||
only the factory knows — the model resolved after runtime overrides, the rendered prompt
|
third-party factories returning bare graphs.
|
||||||
hash, the tool list left by authorization, and the composed middleware stack in order.
|
The hashed skill catalog preserves `allowed_tools=None` (legacy allow-all),
|
||||||
Consumers of a factory result must unwrap `.graph` defensively (see
|
`()` (explicit allow-none for business tools), and non-empty allowlists as
|
||||||
`runtime/runs/worker.py::_agent_graph`), because a third-party factory still returns a
|
distinct policy states; allowlist ordering does not affect the fingerprint.
|
||||||
bare graph.
|
|
||||||
- Dynamic model selection via `create_chat_model()` with thinking/vision support
|
- Dynamic model selection via `create_chat_model()` with thinking/vision support
|
||||||
- Tools loaded via `get_available_tools()` - combines sandbox, built-in, MCP, community, and subagent tools
|
- Tools loaded via `get_available_tools()` - combines sandbox, built-in, MCP, community, and subagent tools
|
||||||
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions
|
- System prompt generated by `apply_prompt_template()` with skills, memory, and subagent instructions
|
||||||
|
|||||||
@ -467,7 +467,8 @@ def build_assembly_descriptor(
|
|||||||
{
|
{
|
||||||
"name": str(getattr(skill, "name", "")),
|
"name": str(getattr(skill, "name", "")),
|
||||||
"description": str(getattr(skill, "description", "")),
|
"description": str(getattr(skill, "description", "")),
|
||||||
"allowed_tools": sorted(str(item) for item in (getattr(skill, "allowed_tools", None) or ())),
|
# None preserves legacy allow-all; an empty declaration allows no business tools.
|
||||||
|
"allowed_tools": None if (allowed_tools := getattr(skill, "allowed_tools", None)) is None else sorted(str(item) for item in allowed_tools),
|
||||||
"content_hash": _skill_content_hash(skill),
|
"content_hash": _skill_content_hash(skill),
|
||||||
"secrets_autonomous": bool(getattr(skill, "secrets_autonomous", True)),
|
"secrets_autonomous": bool(getattr(skill, "secrets_autonomous", True)),
|
||||||
"required_secrets": sorted(f"{getattr(requirement, 'name', '')}:{bool(getattr(requirement, 'optional', False))}" for requirement in (getattr(skill, "required_secrets", None) or ())),
|
"required_secrets": sorted(f"{getattr(requirement, 'name', '')}:{bool(getattr(requirement, 'optional', False))}" for requirement in (getattr(skill, "required_secrets", None) or ())),
|
||||||
|
|||||||
@ -662,7 +662,7 @@ class TestSkillCatalogHashesContent:
|
|||||||
allowed-tools are untouched."""
|
allowed-tools are untouched."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _skill(skill_dir: Path, *, required_secrets=(), secrets_autonomous=True):
|
def _skill(skill_dir: Path, *, allowed_tools=None, required_secrets=(), secrets_autonomous=True):
|
||||||
from deerflow.skills.types import Skill, SkillCategory
|
from deerflow.skills.types import Skill, SkillCategory
|
||||||
|
|
||||||
skill_file = skill_dir / "SKILL.md"
|
skill_file = skill_dir / "SKILL.md"
|
||||||
@ -674,6 +674,7 @@ class TestSkillCatalogHashesContent:
|
|||||||
skill_file=skill_file,
|
skill_file=skill_file,
|
||||||
relative_path=Path(skill_dir.name),
|
relative_path=Path(skill_dir.name),
|
||||||
category=SkillCategory.CUSTOM,
|
category=SkillCategory.CUSTOM,
|
||||||
|
allowed_tools=allowed_tools,
|
||||||
required_secrets=required_secrets,
|
required_secrets=required_secrets,
|
||||||
secrets_autonomous=secrets_autonomous,
|
secrets_autonomous=secrets_autonomous,
|
||||||
)
|
)
|
||||||
@ -719,6 +720,17 @@ class TestSkillCatalogHashesContent:
|
|||||||
with_secret = self._skill(Path("/nonexistent/skill-b"), required_secrets=(SecretRequirement(name="API_KEY"),))
|
with_secret = self._skill(Path("/nonexistent/skill-b"), required_secrets=(SecretRequirement(name="API_KEY"),))
|
||||||
assert self._build([no_secrets]).fingerprint != self._build([with_secret]).fingerprint
|
assert self._build([no_secrets]).fingerprint != self._build([with_secret]).fingerprint
|
||||||
|
|
||||||
|
def test_allowed_tools_declaration_states_have_distinct_fingerprints(self, tmp_path):
|
||||||
|
descriptors = [self._build([self._skill(tmp_path, allowed_tools=allowed_tools)]) for allowed_tools in (None, (), ("bash",))]
|
||||||
|
|
||||||
|
assert len({descriptor.fingerprint for descriptor in descriptors}) == 3
|
||||||
|
|
||||||
|
def test_allowed_tools_order_does_not_change_the_fingerprint(self, tmp_path):
|
||||||
|
before = self._build([self._skill(tmp_path, allowed_tools=("bash", "read_file"))])
|
||||||
|
reordered = self._build([self._skill(tmp_path, allowed_tools=("read_file", "bash"))])
|
||||||
|
|
||||||
|
assert before.fingerprint == reordered.fingerprint
|
||||||
|
|
||||||
def test_a_missing_skill_file_is_undescribable_not_fatal(self, tmp_path):
|
def test_a_missing_skill_file_is_undescribable_not_fatal(self, tmp_path):
|
||||||
skill = self._skill(tmp_path / "missing-skill")
|
skill = self._skill(tmp_path / "missing-skill")
|
||||||
descriptor = self._build([skill])
|
descriptor = self._build([skill])
|
||||||
|
|||||||
@ -55,7 +55,7 @@ def test_postgres_engine_kwargs_allow_command_timeout_opt_out() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_configured_command_timeout_ends_stalled_command() -> None:
|
async def test_configured_command_timeout_ends_stalled_command(monkeypatch) -> None:
|
||||||
config = DatabaseConfig(
|
config = DatabaseConfig(
|
||||||
backend="postgres",
|
backend="postgres",
|
||||||
postgres_url="postgresql://user:password@localhost/deerflow",
|
postgres_url="postgresql://user:password@localhost/deerflow",
|
||||||
@ -79,8 +79,9 @@ async def test_configured_command_timeout_ends_stalled_command() -> None:
|
|||||||
|
|
||||||
bootstrap_schema = AsyncMock()
|
bootstrap_schema = AsyncMock()
|
||||||
|
|
||||||
|
# Patch one key: patch.dict restores all of sys.modules and races with background imports.
|
||||||
|
monkeypatch.setitem(sys.modules, "asyncpg", ModuleType("asyncpg"))
|
||||||
with (
|
with (
|
||||||
patch.dict(sys.modules, {"asyncpg": ModuleType("asyncpg")}),
|
|
||||||
patch.object(engine_mod, "create_async_engine", side_effect=_create_engine),
|
patch.object(engine_mod, "create_async_engine", side_effect=_create_engine),
|
||||||
patch.object(engine_mod, "async_sessionmaker", return_value=MagicMock()),
|
patch.object(engine_mod, "async_sessionmaker", return_value=MagicMock()),
|
||||||
patch("deerflow.persistence.bootstrap.bootstrap_schema", new=bootstrap_schema),
|
patch("deerflow.persistence.bootstrap.bootstrap_schema", new=bootstrap_schema),
|
||||||
@ -101,7 +102,7 @@ async def test_configured_command_timeout_ends_stalled_command() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_init_engine_from_config_preserves_longer_command_timeout_override() -> None:
|
async def test_init_engine_from_config_preserves_longer_command_timeout_override(monkeypatch) -> None:
|
||||||
config = DatabaseConfig(
|
config = DatabaseConfig(
|
||||||
backend="postgres",
|
backend="postgres",
|
||||||
postgres_url="postgresql://user:password@localhost/deerflow",
|
postgres_url="postgresql://user:password@localhost/deerflow",
|
||||||
@ -112,8 +113,8 @@ async def test_init_engine_from_config_preserves_longer_command_timeout_override
|
|||||||
mock_engine.dispose = AsyncMock()
|
mock_engine.dispose = AsyncMock()
|
||||||
bootstrap_schema = AsyncMock()
|
bootstrap_schema = AsyncMock()
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "asyncpg", ModuleType("asyncpg"))
|
||||||
with (
|
with (
|
||||||
patch.dict(sys.modules, {"asyncpg": ModuleType("asyncpg")}),
|
|
||||||
patch.object(engine_mod, "create_async_engine", return_value=mock_engine) as create_engine,
|
patch.object(engine_mod, "create_async_engine", return_value=mock_engine) as create_engine,
|
||||||
patch.object(engine_mod, "async_sessionmaker", return_value=MagicMock()),
|
patch.object(engine_mod, "async_sessionmaker", return_value=MagicMock()),
|
||||||
patch("deerflow.persistence.bootstrap.bootstrap_schema", new=bootstrap_schema),
|
patch("deerflow.persistence.bootstrap.bootstrap_schema", new=bootstrap_schema),
|
||||||
@ -129,14 +130,14 @@ async def test_init_engine_from_config_preserves_longer_command_timeout_override
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_init_engine_postgres_uses_hardened_kwargs() -> None:
|
async def test_init_engine_postgres_uses_hardened_kwargs(monkeypatch) -> None:
|
||||||
url = "postgresql+asyncpg://user:password@localhost/deerflow"
|
url = "postgresql+asyncpg://user:password@localhost/deerflow"
|
||||||
mock_engine = MagicMock()
|
mock_engine = MagicMock()
|
||||||
mock_engine.dispose = AsyncMock()
|
mock_engine.dispose = AsyncMock()
|
||||||
bootstrap_schema = AsyncMock()
|
bootstrap_schema = AsyncMock()
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "asyncpg", ModuleType("asyncpg"))
|
||||||
with (
|
with (
|
||||||
patch.dict(sys.modules, {"asyncpg": ModuleType("asyncpg")}),
|
|
||||||
patch.object(engine_mod, "create_async_engine", return_value=mock_engine) as create_engine,
|
patch.object(engine_mod, "create_async_engine", return_value=mock_engine) as create_engine,
|
||||||
patch.object(engine_mod, "async_sessionmaker", return_value=MagicMock()),
|
patch.object(engine_mod, "async_sessionmaker", return_value=MagicMock()),
|
||||||
patch("deerflow.persistence.bootstrap.bootstrap_schema", new=bootstrap_schema),
|
patch("deerflow.persistence.bootstrap.bootstrap_schema", new=bootstrap_schema),
|
||||||
@ -151,7 +152,7 @@ async def test_init_engine_postgres_uses_hardened_kwargs() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_init_engine_postgres_retry_uses_hardened_kwargs() -> None:
|
async def test_init_engine_postgres_retry_uses_hardened_kwargs(monkeypatch) -> None:
|
||||||
url = "postgresql+asyncpg://user:password@localhost/deerflow"
|
url = "postgresql+asyncpg://user:password@localhost/deerflow"
|
||||||
initial_engine = MagicMock()
|
initial_engine = MagicMock()
|
||||||
initial_engine.dispose = AsyncMock()
|
initial_engine.dispose = AsyncMock()
|
||||||
@ -160,8 +161,8 @@ async def test_init_engine_postgres_retry_uses_hardened_kwargs() -> None:
|
|||||||
bootstrap_schema = AsyncMock(side_effect=[Exception("database does not exist"), None])
|
bootstrap_schema = AsyncMock(side_effect=[Exception("database does not exist"), None])
|
||||||
auto_create = AsyncMock()
|
auto_create = AsyncMock()
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "asyncpg", ModuleType("asyncpg"))
|
||||||
with (
|
with (
|
||||||
patch.dict(sys.modules, {"asyncpg": ModuleType("asyncpg")}),
|
|
||||||
patch.object(engine_mod, "create_async_engine", side_effect=[initial_engine, retry_engine]) as create_engine,
|
patch.object(engine_mod, "create_async_engine", side_effect=[initial_engine, retry_engine]) as create_engine,
|
||||||
patch.object(engine_mod, "async_sessionmaker", return_value=MagicMock()),
|
patch.object(engine_mod, "async_sessionmaker", return_value=MagicMock()),
|
||||||
patch.object(engine_mod, "_auto_create_postgres_db", new=auto_create),
|
patch.object(engine_mod, "_auto_create_postgres_db", new=auto_create),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user