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:
JasonH 2026-09-22 11:36:28 +08:00 committed by GitHub
parent 61a99a1e8a
commit e1352bcdc0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 35 additions and 22 deletions

View File

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

View File

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

View File

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

View File

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