fix(agents): harden update_agent null-like args (#3237)

* fix(agents): harden update_agent null-like args

* docs: mention undefined null-like update args

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Eilen Shin
2026-06-04 07:10:59 +08:00
committed by GitHub
parent 3fddc24c5f
commit 28b1da2172
4 changed files with 101 additions and 9 deletions
@@ -678,6 +678,7 @@ SOUL.md or config.yaml — those write into a temporary sandbox/tool workspace a
Rules: Rules:
- Always pass the FULL replacement text for `soul` (no patch semantics). Start from your current SOUL above and apply the user's edits. - Always pass the FULL replacement text for `soul` (no patch semantics). Start from your current SOUL above and apply the user's edits.
- Only pass the fields that should change. Omit the others to preserve them. - Only pass the fields that should change. Omit the others to preserve them.
- Never pass literal strings like `"null"`, `"none"`, or `"undefined"` for unchanged fields.
- Pass `skills=[]` to disable all skills, or omit `skills` to keep the existing whitelist. - Pass `skills=[]` to disable all skills, or omit `skills` to keep the existing whitelist.
- After `update_agent` returns successfully, tell the user the change is persisted and will take effect on the next turn. - After `update_agent` returns successfully, tell the user the change is persisted and will take effect on the next turn.
</self_update> </self_update>
@@ -17,12 +17,13 @@ from __future__ import annotations
import logging import logging
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any from typing import Annotated, Any
import yaml import yaml
from langchain_core.messages import ToolMessage from langchain_core.messages import ToolMessage
from langchain_core.tools import tool from langchain_core.tools import tool
from langgraph.types import Command from langgraph.types import Command
from pydantic import BeforeValidator
from deerflow.config.agents_config import load_agent_config, validate_agent_name from deerflow.config.agents_config import load_agent_config, validate_agent_name
from deerflow.config.app_config import get_app_config from deerflow.config.app_config import get_app_config
@@ -32,6 +33,8 @@ from deerflow.tools.types import Runtime
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_NULLISH_STRINGS = frozenset({"null", "none", "undefined"})
def _stage_temp(path: Path, text: str) -> Path: def _stage_temp(path: Path, text: str) -> Path:
"""Write ``text`` into a sibling temp file and return its path. """Write ``text`` into a sibling temp file and return its path.
@@ -67,14 +70,26 @@ def _cleanup_temps(temps: list[Path]) -> None:
logger.debug("Failed to clean up temp file %s", tmp, exc_info=True) logger.debug("Failed to clean up temp file %s", tmp, exc_info=True)
def _is_nullish_string(value: object) -> bool:
return isinstance(value, str) and value.strip().lower() in _NULLISH_STRINGS
def _normalize_nullish_string(value: object) -> object:
return None if _is_nullish_string(value) else value
OptionalText = Annotated[str | None, BeforeValidator(_normalize_nullish_string)]
OptionalStringList = Annotated[list[str] | None, BeforeValidator(_normalize_nullish_string)]
@tool(parse_docstring=True) @tool(parse_docstring=True)
def update_agent( def update_agent(
runtime: Runtime, runtime: Runtime,
soul: str | None = None, soul: OptionalText = None,
description: str | None = None, description: OptionalText = None,
skills: list[str] | None = None, skills: OptionalStringList = None,
tool_groups: list[str] | None = None, tool_groups: OptionalStringList = None,
model: str | None = None, model: OptionalText = None,
) -> Command: ) -> Command:
"""Persist updates to the current custom agent's SOUL.md and config.yaml. """Persist updates to the current custom agent's SOUL.md and config.yaml.
@@ -86,7 +101,9 @@ def update_agent(
semantics, so always start from the current SOUL and apply your edits. semantics, so always start from the current SOUL and apply your edits.
Pass ``skills=[]`` to disable all skills for this agent. Omit ``skills`` Pass ``skills=[]`` to disable all skills for this agent. Omit ``skills``
entirely to keep the existing whitelist. entirely to keep the existing whitelist. Do not pass literal strings like
``"null"`` / ``"none"`` / ``"undefined"`` for unchanged fields; omit those
fields instead.
Args: Args:
soul: Optional full replacement SOUL.md content. soul: Optional full replacement SOUL.md content.
@@ -104,10 +121,10 @@ def update_agent(
agent_name_raw: str | None = runtime.context.get("agent_name") if runtime.context else None agent_name_raw: str | None = runtime.context.get("agent_name") if runtime.context else None
def _err(message: str) -> Command: def _err(message: str) -> Command:
return Command(update={"messages": [ToolMessage(content=f"Error: {message}", tool_call_id=tool_call_id)]}) return Command(update={"messages": [ToolMessage(content=f"Error: {message}", tool_call_id=tool_call_id, status="error")]})
if soul is None and description is None and skills is None and tool_groups is None and model is None: if soul is None and description is None and skills is None and tool_groups is None and model is None:
return _err("No fields provided. Pass at least one of: soul, description, skills, tool_groups, model.") return _err('No fields provided. Pass at least one of: soul, description, skills, tool_groups, model. Omit unchanged fields instead of passing null-like strings such as "null", "none", or "undefined".')
try: try:
agent_name = validate_agent_name(agent_name_raw) agent_name = validate_agent_name(agent_name_raw)
+1
View File
@@ -30,6 +30,7 @@ def test_build_self_update_section_present_for_custom_agent():
assert "<self_update>" in section assert "<self_update>" in section
assert "my-agent" in section assert "my-agent" in section
assert "update_agent" in section assert "update_agent" in section
assert '"null"' in section
def test_build_custom_mounts_section_returns_empty_when_no_mounts(monkeypatch): def test_build_custom_mounts_section_returns_empty_when_no_mounts(monkeypatch):
+73
View File
@@ -15,6 +15,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
import yaml import yaml
from langchain.tools import ToolRuntime
from deerflow.config.agents_config import AgentConfig from deerflow.config.agents_config import AgentConfig
from deerflow.tools.builtins.update_agent_tool import update_agent from deerflow.tools.builtins.update_agent_tool import update_agent
@@ -31,6 +32,18 @@ def _runtime(agent_name: str | None = "test-agent", tool_call_id: str = "call_1"
return _DummyRuntime(context={"agent_name": agent_name} if agent_name is not None else {}, tool_call_id=tool_call_id) return _DummyRuntime(context={"agent_name": agent_name} if agent_name is not None else {}, tool_call_id=tool_call_id)
def _tool_runtime(agent_name: str | None = "test-agent", tool_call_id: str = "call_1") -> ToolRuntime:
return ToolRuntime(
state={"sandbox": {"sandbox_id": "local"}, "thread_data": {}},
context={"agent_name": agent_name} if agent_name is not None else {},
config={"configurable": {"thread_id": "thread-1"}},
stream_writer=lambda _: None,
tools=[],
tool_call_id=tool_call_id,
store=None,
)
def _make_paths_mock(tmp_path: Path) -> MagicMock: def _make_paths_mock(tmp_path: Path) -> MagicMock:
paths = MagicMock() paths = MagicMock()
paths.base_dir = tmp_path paths.base_dir = tmp_path
@@ -115,6 +128,7 @@ def test_update_agent_requires_at_least_one_field(tmp_path, patched_paths):
msg = result.update["messages"][0] msg = result.update["messages"][0]
assert "No fields provided" in msg.content assert "No fields provided" in msg.content
assert msg.status == "error"
def test_update_agent_rejects_unknown_model(tmp_path, patched_paths, stub_app_config): def test_update_agent_rejects_unknown_model(tmp_path, patched_paths, stub_app_config):
@@ -141,6 +155,65 @@ def test_update_agent_accepts_known_model(tmp_path, patched_paths, stub_app_conf
assert "model" in result.update["messages"][0].content assert "model" in result.update["messages"][0].content
def test_update_agent_treats_nullish_optional_text_as_omitted(tmp_path, patched_paths):
"""Models sometimes pass literal "null" strings while trying to omit fields.
Treat those as omitted for optional text fields so they do not get persisted
as a model name or SOUL.md content and feed repeated update_agent retries.
"""
agent_dir = _seed_agent(tmp_path, description="old desc", soul="old soul")
result = update_agent.invoke(
{
"runtime": _tool_runtime(),
"soul": "null",
"description": "none",
"model": "undefined",
}
)
msg = result.update["messages"][0]
assert "No fields provided" in msg.content
assert msg.status == "error"
cfg = yaml.safe_load((agent_dir / "config.yaml").read_text())
assert cfg["description"] == "old desc"
assert "model" not in cfg
assert (agent_dir / "SOUL.md").read_text() == "old soul"
def test_update_agent_rejects_string_list_fields(tmp_path, patched_paths):
"""skills/tool_groups must be real arrays; string placeholders are invalid."""
agent_dir = _seed_agent(tmp_path, skills=["existing"])
assert update_agent.args_schema is not None
with pytest.raises(ValueError, match="skills"):
update_agent.args_schema.model_validate({"skills": "alpha,beta"})
cfg = yaml.safe_load((agent_dir / "config.yaml").read_text())
assert cfg["skills"] == ["existing"]
def test_update_agent_treats_nullish_string_list_fields_as_omitted(tmp_path, patched_paths):
agent_dir = _seed_agent(tmp_path, skills=["existing"])
result = update_agent.invoke(
{
"runtime": _tool_runtime(),
"skills": "null",
"tool_groups": "none",
}
)
msg = result.update["messages"][0]
assert "No fields provided" in msg.content
assert msg.status == "error"
cfg = yaml.safe_load((agent_dir / "config.yaml").read_text())
assert cfg["skills"] == ["existing"]
assert "tool_groups" not in cfg
# --- Partial update tests --- # --- Partial update tests ---