mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-10 22:18:59 +00:00
fix(sandbox): make tool descriptions optional (#4878)
* fix(sandbox): make tool descriptions optional * fix(sandbox): address optional description review * test(sandbox): pin optional description contracts
This commit is contained in:
parent
45adb8fbb5
commit
cdc886ae85
@ -93,6 +93,7 @@
|
||||
- Detection: `is_local_sandbox()` accepts both `sandbox_id == "local"` (legacy / no-thread) and `sandbox_id.startswith("local:")` (per-thread)
|
||||
|
||||
**Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`):
|
||||
- Every sandbox tool keeps a model-visible `description` field for a human-readable progress label, but the field is optional and defaults to an empty string. Tool execution must depend only on its operational arguments; the frontend supplies localized fallback labels when a provider omits `description`.
|
||||
- `bash` - Execute commands with path translation and error handling. For `LocalSandbox` (host bash), output on POSIX and Windows is captured through bounded pipe-drain threads and stdin is `/dev/null`; Windows capture decodes with the platform text encoding and applies universal-newline translation, matching the former `subprocess.run(..., text=True)` behavior for locale-code-page output, Python UTF-8 Mode, CRLF, and bare CR. That translation is Windows-only so the pre-existing POSIX output contract remains byte-decoded without newline rewriting. On POSIX, a backgrounded long-lived process (`server &`) returns immediately instead of blocking the turn on an inherited pipe, while unredirected background output is drained without growing anonymous temp files. Commands that read stdin get immediate EOF. The command runs in its own process group with a wall-clock timeout (`sandbox.bash_command_timeout`, default 600s); on timeout the whole POSIX process group or Windows process tree is killed and the agent gets a notice telling it to background long-lived processes. The bash tool description itself also instructs the model to background long-lived processes (e.g. servers) up front so it doesn't waste the turn waiting on a foreground server. See `LocalSandbox.execute_command`, its platform runners, and `bash_tool`'s docstring.
|
||||
- `ls` - Directory listing (tree format, max 2 levels)
|
||||
- `glob` - Find files or directories below a root directory with bounded results
|
||||
|
||||
@ -1852,7 +1852,7 @@ def _lark_cli_env_from_runtime(runtime: Runtime, command: str, *, sandbox_paths:
|
||||
|
||||
|
||||
@tool("bash", parse_docstring=True)
|
||||
def bash_tool(runtime: Runtime, description: str, command: str) -> str:
|
||||
def bash_tool(runtime: Runtime, command: str, description: str = "") -> str:
|
||||
"""Execute a bash command in a Linux environment.
|
||||
|
||||
|
||||
@ -1865,8 +1865,8 @@ def bash_tool(runtime: Runtime, description: str, command: str) -> str:
|
||||
it is killed at the command timeout.
|
||||
|
||||
Args:
|
||||
description: Explain why you are running this command in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
command: The bash command to execute. Always use absolute paths for files and directories.
|
||||
description: Optional short explanation of this command shown in the UI.
|
||||
"""
|
||||
try:
|
||||
sandbox = ensure_sandbox_initialized(runtime)
|
||||
@ -1928,20 +1928,20 @@ def bash_tool(runtime: Runtime, description: str, command: str) -> str:
|
||||
return f"Error: Unexpected error executing command: {_sanitize_error(e, runtime)}"
|
||||
|
||||
|
||||
async def _bash_tool_async(runtime: Runtime, description: str, command: str) -> str:
|
||||
return await _run_sync_tool_after_async_sandbox_init(bash_tool.func, runtime, description, command)
|
||||
async def _bash_tool_async(runtime: Runtime, command: str, description: str = "") -> str:
|
||||
return await _run_sync_tool_after_async_sandbox_init(bash_tool.func, runtime, command, description)
|
||||
|
||||
|
||||
bash_tool.coroutine = _bash_tool_async
|
||||
|
||||
|
||||
@tool("ls", parse_docstring=True)
|
||||
def ls_tool(runtime: Runtime, description: str, path: str) -> str:
|
||||
def ls_tool(runtime: Runtime, path: str, description: str = "") -> str:
|
||||
"""List the contents of a directory up to 2 levels deep in tree format.
|
||||
|
||||
Args:
|
||||
description: Explain why you are listing this directory in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
path: The **absolute** path to the directory to list.
|
||||
description: Optional short explanation of this listing shown in the UI.
|
||||
"""
|
||||
try:
|
||||
user_id = resolve_runtime_user_id(runtime)
|
||||
@ -1996,8 +1996,8 @@ def ls_tool(runtime: Runtime, description: str, path: str) -> str:
|
||||
return f"Error: Unexpected error listing directory: {_sanitize_error(e, runtime)}"
|
||||
|
||||
|
||||
async def _ls_tool_async(runtime: Runtime, description: str, path: str) -> str:
|
||||
return await _run_sync_tool_after_async_sandbox_init(ls_tool.func, runtime, description, path)
|
||||
async def _ls_tool_async(runtime: Runtime, path: str, description: str = "") -> str:
|
||||
return await _run_sync_tool_after_async_sandbox_init(ls_tool.func, runtime, path, description)
|
||||
|
||||
|
||||
ls_tool.coroutine = _ls_tool_async
|
||||
@ -2006,18 +2006,18 @@ ls_tool.coroutine = _ls_tool_async
|
||||
@tool("glob", parse_docstring=True)
|
||||
def glob_tool(
|
||||
runtime: Runtime,
|
||||
description: str,
|
||||
pattern: str,
|
||||
path: str,
|
||||
description: str = "",
|
||||
include_dirs: bool = False,
|
||||
max_results: int = _DEFAULT_GLOB_MAX_RESULTS,
|
||||
) -> str:
|
||||
"""Find files or directories that match a glob pattern under a root directory.
|
||||
|
||||
Args:
|
||||
description: Explain why you are searching for these paths in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
pattern: The glob pattern to match relative to the root path, for example `**/*.py`.
|
||||
path: The **absolute** root directory to search under.
|
||||
description: Optional short explanation of this search shown in the UI.
|
||||
include_dirs: Whether matching directories should also be returned. Default is False.
|
||||
max_results: Maximum number of paths to return. Default is 200.
|
||||
"""
|
||||
@ -2063,18 +2063,18 @@ def glob_tool(
|
||||
|
||||
async def _glob_tool_async(
|
||||
runtime: Runtime,
|
||||
description: str,
|
||||
pattern: str,
|
||||
path: str,
|
||||
description: str = "",
|
||||
include_dirs: bool = False,
|
||||
max_results: int = _DEFAULT_GLOB_MAX_RESULTS,
|
||||
) -> str:
|
||||
return await _run_sync_tool_after_async_sandbox_init(
|
||||
glob_tool.func,
|
||||
runtime,
|
||||
description,
|
||||
pattern,
|
||||
path,
|
||||
description,
|
||||
include_dirs,
|
||||
max_results,
|
||||
)
|
||||
@ -2086,9 +2086,9 @@ glob_tool.coroutine = _glob_tool_async
|
||||
@tool("grep", parse_docstring=True)
|
||||
def grep_tool(
|
||||
runtime: Runtime,
|
||||
description: str,
|
||||
pattern: str,
|
||||
path: str,
|
||||
description: str = "",
|
||||
glob: str | None = None,
|
||||
literal: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
@ -2097,9 +2097,9 @@ def grep_tool(
|
||||
"""Search for matching lines inside a text file or files under a root directory.
|
||||
|
||||
Args:
|
||||
description: Explain why you are searching file contents in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
pattern: The string or regex pattern to search for.
|
||||
path: The **absolute** file or root directory to search.
|
||||
description: Optional short explanation of this search shown in the UI.
|
||||
glob: Optional glob filter for candidate files, for example `**/*.py`.
|
||||
literal: Whether to treat `pattern` as a plain string. Default is False.
|
||||
case_sensitive: Whether matching is case-sensitive. Default is False.
|
||||
@ -2164,9 +2164,9 @@ def grep_tool(
|
||||
|
||||
async def _grep_tool_async(
|
||||
runtime: Runtime,
|
||||
description: str,
|
||||
pattern: str,
|
||||
path: str,
|
||||
description: str = "",
|
||||
glob: str | None = None,
|
||||
literal: bool = False,
|
||||
case_sensitive: bool = False,
|
||||
@ -2175,9 +2175,9 @@ async def _grep_tool_async(
|
||||
return await _run_sync_tool_after_async_sandbox_init(
|
||||
grep_tool.func,
|
||||
runtime,
|
||||
description,
|
||||
pattern,
|
||||
path,
|
||||
description,
|
||||
glob,
|
||||
literal,
|
||||
case_sensitive,
|
||||
@ -2219,16 +2219,16 @@ def read_current_file_content(runtime: Runtime | None, path: str) -> str:
|
||||
@tool("read_file", parse_docstring=True)
|
||||
def read_file_tool(
|
||||
runtime: Runtime,
|
||||
description: str,
|
||||
path: str,
|
||||
description: str = "",
|
||||
start_line: int | None = None,
|
||||
end_line: int | None = None,
|
||||
) -> str:
|
||||
"""Read the contents of a text file. Use this to examine source code, configuration files, logs, or any text-based file.
|
||||
|
||||
Args:
|
||||
description: Explain why you are reading this file in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
path: The **absolute** path to the file to read.
|
||||
description: Optional short explanation of this read shown in the UI.
|
||||
start_line: Optional starting line number (1-indexed, inclusive). Omit to start at the first line.
|
||||
end_line: Optional ending line number (1-indexed, inclusive). Omit to read through the last line.
|
||||
"""
|
||||
@ -2283,12 +2283,12 @@ def read_file_tool(
|
||||
|
||||
async def _read_file_tool_async(
|
||||
runtime: Runtime,
|
||||
description: str,
|
||||
path: str,
|
||||
description: str = "",
|
||||
start_line: int | None = None,
|
||||
end_line: int | None = None,
|
||||
) -> str:
|
||||
return await _run_sync_tool_after_async_sandbox_init(read_file_tool.func, runtime, description, path, start_line, end_line)
|
||||
return await _run_sync_tool_after_async_sandbox_init(read_file_tool.func, runtime, path, description, start_line, end_line)
|
||||
|
||||
|
||||
read_file_tool.coroutine = _read_file_tool_async
|
||||
@ -2314,9 +2314,9 @@ def _effective_write_file_max_bytes() -> int:
|
||||
@tool("write_file", parse_docstring=True)
|
||||
def write_file_tool(
|
||||
runtime: Runtime,
|
||||
description: str,
|
||||
path: str,
|
||||
content: str,
|
||||
description: str = "",
|
||||
append: bool = False,
|
||||
) -> str:
|
||||
"""Write text content to a file. By default this overwrites the target file; set append=True to add content to the end without replacing existing content.
|
||||
@ -2348,9 +2348,9 @@ def write_file_tool(
|
||||
(0 disables the guard entirely). Raising it risks streaming timeouts.
|
||||
|
||||
Args:
|
||||
description: Explain why you are writing to this file in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
path: The **absolute** path to the file to write to. ALWAYS PROVIDE THIS PARAMETER SECOND.
|
||||
content: The content to write to the file. ALWAYS PROVIDE THIS PARAMETER THIRD.
|
||||
path: The **absolute** path to the file to write to.
|
||||
content: The content to write to the file.
|
||||
description: Optional short explanation of this write shown in the UI.
|
||||
append: Whether to append content to the end of the file instead of overwriting it. Defaults to False.
|
||||
"""
|
||||
if not append:
|
||||
@ -2399,12 +2399,12 @@ def write_file_tool(
|
||||
|
||||
async def _write_file_tool_async(
|
||||
runtime: Runtime,
|
||||
description: str,
|
||||
path: str,
|
||||
content: str,
|
||||
description: str = "",
|
||||
append: bool = False,
|
||||
) -> str:
|
||||
return await _run_sync_tool_after_async_sandbox_init(write_file_tool.func, runtime, description, path, content, append)
|
||||
return await _run_sync_tool_after_async_sandbox_init(write_file_tool.func, runtime, path, content, description, append)
|
||||
|
||||
|
||||
write_file_tool.coroutine = _write_file_tool_async
|
||||
@ -2413,10 +2413,10 @@ write_file_tool.coroutine = _write_file_tool_async
|
||||
@tool("str_replace", parse_docstring=True)
|
||||
def str_replace_tool(
|
||||
runtime: Runtime,
|
||||
description: str,
|
||||
path: str,
|
||||
old_str: str,
|
||||
new_str: str,
|
||||
description: str = "",
|
||||
replace_all: bool = False,
|
||||
) -> str:
|
||||
"""Replace a substring in a file with another substring.
|
||||
@ -2426,10 +2426,10 @@ def str_replace_tool(
|
||||
version with read_file first; any write invalidates earlier reads.
|
||||
|
||||
Args:
|
||||
description: Explain why you are replacing the substring in short words. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
path: The **absolute** path to the file to replace the substring in. ALWAYS PROVIDE THIS PARAMETER SECOND.
|
||||
old_str: The substring to replace. ALWAYS PROVIDE THIS PARAMETER THIRD.
|
||||
new_str: The new substring. ALWAYS PROVIDE THIS PARAMETER FOURTH.
|
||||
path: The **absolute** path to the file to replace the substring in.
|
||||
old_str: The substring to replace.
|
||||
new_str: The new substring.
|
||||
description: Optional short explanation of this replacement shown in the UI.
|
||||
replace_all: Whether to replace all occurrences of the substring. If False, only the first occurrence will be replaced. Default is False.
|
||||
"""
|
||||
try:
|
||||
@ -2468,19 +2468,19 @@ def str_replace_tool(
|
||||
|
||||
async def _str_replace_tool_async(
|
||||
runtime: Runtime,
|
||||
description: str,
|
||||
path: str,
|
||||
old_str: str,
|
||||
new_str: str,
|
||||
description: str = "",
|
||||
replace_all: bool = False,
|
||||
) -> str:
|
||||
return await _run_sync_tool_after_async_sandbox_init(
|
||||
str_replace_tool.func,
|
||||
runtime,
|
||||
description,
|
||||
path,
|
||||
old_str,
|
||||
new_str,
|
||||
description,
|
||||
replace_all,
|
||||
)
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
- `setup_agent` - Bootstrap-only: persist a brand-new custom agent's `SOUL.md` and `config.yaml`. Bound only when `is_bootstrap=True`.
|
||||
- `update_agent` - Custom-agent-only: persist self-updates to the current agent's `SOUL.md` / `config.yaml` from inside a normal chat (partial update + atomic write). Bound when `agent_name` is set and `is_bootstrap=False`.
|
||||
4. **Subagent tool** (if enabled):
|
||||
- `task` - Delegate to subagent (description, prompt, subagent_type, optional acceptance_criteria). Subagent reports are self-reports: the docstring directs the lead to expect `[rN]` receipt citations and verifiable handles while `verification.receipts_enabled` (and explicitly qualifies that disabled receipts mean no citations and no citation verdict), to read the delegation ledger's citation cross-check as execution evidence only, and to attach `acceptance_criteria` for objectively checkable outcomes (canonical forms `file:<path> exists|non-empty`, `file_written:<path>`, `tests_passed:<command>`); criteria are handed to the executor and appended to the subagent's task message as untrusted data (see `subagents/report_contract.py`).
|
||||
- `task` - Delegate to subagent (`prompt`, `subagent_type`, optional `acceptance_criteria`, and an optional model-visible `description` used only as a short progress label). Execution never depends on `description`; lifecycle display falls back to `prompt` when a provider omits it. Subagent reports are self-reports: the docstring directs the lead to expect `[rN]` receipt citations and verifiable handles while `verification.receipts_enabled` (and explicitly qualifies that disabled receipts mean no citations and no citation verdict), to read the delegation ledger's citation cross-check as execution evidence only, and to attach `acceptance_criteria` for objectively checkable outcomes (canonical forms `file:<path> exists|non-empty`, `file_written:<path>`, `tests_passed:<command>`); criteria are handed to the executor and appended to the subagent's task message as untrusted data (see `subagents/report_contract.py`).
|
||||
Polling safety timeouts carry the latest published tool receipts into the terminal task metadata before requesting background cancellation.
|
||||
- `batch_task`, `batch_status`, `cancel_batch` - Explicit durable batch submission/progress/cancellation. Added only while the startup SQL-backed batch submitter is installed; large results stay in the owner-scoped API/JSONL export rather than the lead context.
|
||||
- Direct `create_deerflow_agent` integrations receive cloned tools bound to their explicit `SubagentRuntime`. The bound `task` forwards that runtime's exact execution controller and optional caller-owned `AppConfig` into registry/model/tool resolution and `SubagentExecutor`; bound batch tools use the same config snapshot and resolve only that runtime's submitter before falling back to no other application's active worker. Keep the original tool name/schema unchanged so model contracts and user-tool deduplication remain stable.
|
||||
|
||||
@ -568,12 +568,12 @@ def _task_result_command(
|
||||
@tool("task", parse_docstring=True)
|
||||
async def task_tool(
|
||||
runtime: Runtime,
|
||||
description: str,
|
||||
prompt: str,
|
||||
subagent_type: str,
|
||||
tool_call_id: Annotated[str, InjectedToolCallId],
|
||||
*,
|
||||
acceptance_criteria: list[str] | None = None,
|
||||
description: str = "",
|
||||
) -> str | Command:
|
||||
"""Delegate a bounded task to a specialized subagent in its own context.
|
||||
|
||||
@ -631,9 +631,8 @@ async def task_tool(
|
||||
on a load-bearing claim, spot-check its verifiable handle yourself.
|
||||
|
||||
Args:
|
||||
description: A short (3-5 word) description of the task for logging/display. ALWAYS PROVIDE THIS PARAMETER FIRST.
|
||||
prompt: The task description for the subagent. Be specific and clear about what needs to be done. ALWAYS PROVIDE THIS PARAMETER SECOND.
|
||||
subagent_type: The type of subagent to use. ALWAYS PROVIDE THIS PARAMETER THIRD.
|
||||
prompt: The task description for the subagent. Be specific and clear about what needs to be done.
|
||||
subagent_type: The type of subagent to use.
|
||||
acceptance_criteria: Optional list of completion requirements, handed to
|
||||
the subagent as untrusted data appended to its task input (never as
|
||||
system-prompt authority) and addressed one by one in its final
|
||||
@ -644,6 +643,7 @@ async def task_tool(
|
||||
decidable. Example for a report-writing delegation:
|
||||
["file:../outputs/report.md non-empty"]. Omit for open-ended
|
||||
exploration where no crisp acceptance condition exists.
|
||||
description: Optional short (3-5 word) description of the task for logging/display.
|
||||
"""
|
||||
runtime_app_config = _get_runtime_app_config(runtime)
|
||||
metadata: dict = runtime.config.get("metadata", {}) if runtime is not None else {}
|
||||
@ -827,7 +827,7 @@ async def task_tool(
|
||||
{
|
||||
"type": "task_started",
|
||||
"task_id": tool_call_id,
|
||||
"description": description,
|
||||
"description": description or prompt,
|
||||
"model_name": effective_model,
|
||||
},
|
||||
writer=writer,
|
||||
|
||||
@ -1008,7 +1008,7 @@ class TestBashToolInjectsActiveSecrets:
|
||||
patch.object(tools_mod, "is_local_sandbox", return_value=False),
|
||||
patch.object(tools_mod, "ensure_thread_directories_exist", return_value=None),
|
||||
):
|
||||
out = tools_mod.bash_tool.func(runtime, "run skill", "echo hi")
|
||||
out = tools_mod.bash_tool.func(runtime=runtime, command="echo hi", description="run skill")
|
||||
return out, captured
|
||||
|
||||
def test_active_secret_forwarded_as_env(self):
|
||||
@ -1049,7 +1049,7 @@ class TestBashToolInjectsActiveSecrets:
|
||||
patch.object(tools_mod, "_apply_cwd_prefix", side_effect=lambda command, td: command),
|
||||
patch("deerflow.config.app_config.get_app_config", return_value=fake_cfg),
|
||||
):
|
||||
out = tools_mod.bash_tool.func(runtime, "run local skill", "echo hi")
|
||||
out = tools_mod.bash_tool.func(runtime=runtime, command="echo hi", description="run local skill")
|
||||
|
||||
assert out == "done"
|
||||
assert captured["command"] == "echo hi"
|
||||
|
||||
@ -715,7 +715,6 @@ def test_task_tool_emits_running_and_completed_events(monkeypatch):
|
||||
|
||||
output = _run_task_tool(
|
||||
runtime=runtime,
|
||||
description="运行子任务",
|
||||
prompt="collect diagnostics",
|
||||
subagent_type="general-purpose",
|
||||
tool_call_id="tc-123",
|
||||
@ -741,6 +740,7 @@ def test_task_tool_emits_running_and_completed_events(monkeypatch):
|
||||
assert polled_execution_ids == ["execution-456", "execution-456"]
|
||||
assert cleaned_execution_ids == ["execution-456"]
|
||||
assert {event["task_id"] for event in events} == {"tc-123"}
|
||||
assert events[0]["description"] == "collect diagnostics"
|
||||
assert events[0]["model_name"] == "ark-model"
|
||||
assert events[-1]["result"] == "all done"
|
||||
|
||||
|
||||
@ -13,7 +13,9 @@ Pydantic's serialization expectations aligned with reality.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import warnings
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from langchain.tools import ToolRuntime
|
||||
@ -66,6 +68,85 @@ _TOOL_CASES = [
|
||||
(update_agent, {}),
|
||||
]
|
||||
|
||||
_SANDBOX_TOOL_CASES = [
|
||||
(bash_tool, {"command": "ls"}, ("command",), ("command", "description")),
|
||||
(ls_tool, {"path": "/tmp"}, ("path",), ("path", "description")),
|
||||
(
|
||||
glob_tool,
|
||||
{"pattern": "*.py", "path": "/tmp"},
|
||||
("pattern", "path"),
|
||||
("pattern", "path", "description", "include_dirs", "max_results"),
|
||||
),
|
||||
(
|
||||
grep_tool,
|
||||
{"pattern": "x", "path": "/tmp"},
|
||||
("pattern", "path"),
|
||||
("pattern", "path", "description", "glob", "literal", "case_sensitive", "max_results"),
|
||||
),
|
||||
(
|
||||
read_file_tool,
|
||||
{"path": "/tmp/x"},
|
||||
("path",),
|
||||
("path", "description", "start_line", "end_line"),
|
||||
),
|
||||
(
|
||||
write_file_tool,
|
||||
{"path": "/tmp/x", "content": "hi"},
|
||||
("path", "content"),
|
||||
("path", "content", "description", "append"),
|
||||
),
|
||||
(
|
||||
str_replace_tool,
|
||||
{"path": "/tmp/x", "old_str": "a", "new_str": "b"},
|
||||
("path", "old_str", "new_str"),
|
||||
("path", "old_str", "new_str", "description", "replace_all"),
|
||||
),
|
||||
]
|
||||
|
||||
_SANDBOX_TOOL_FORWARDING_CASES = [
|
||||
(bash_tool, {"command": "command", "description": "description"}, ("command", "description")),
|
||||
(ls_tool, {"path": "/path", "description": "description"}, ("/path", "description")),
|
||||
(
|
||||
glob_tool,
|
||||
{"pattern": "*.py", "path": "/path", "description": "description", "include_dirs": True, "max_results": 7},
|
||||
("*.py", "/path", "description", True, 7),
|
||||
),
|
||||
(
|
||||
grep_tool,
|
||||
{
|
||||
"pattern": "needle",
|
||||
"path": "/path",
|
||||
"description": "description",
|
||||
"glob": "*.py",
|
||||
"literal": True,
|
||||
"case_sensitive": True,
|
||||
"max_results": 7,
|
||||
},
|
||||
("needle", "/path", "description", "*.py", True, True, 7),
|
||||
),
|
||||
(
|
||||
read_file_tool,
|
||||
{"path": "/path", "description": "description", "start_line": 2, "end_line": 3},
|
||||
("/path", "description", 2, 3),
|
||||
),
|
||||
(
|
||||
write_file_tool,
|
||||
{"path": "/path", "content": "content", "description": "description", "append": True},
|
||||
("/path", "content", "description", True),
|
||||
),
|
||||
(
|
||||
str_replace_tool,
|
||||
{
|
||||
"path": "/path",
|
||||
"old_str": "old",
|
||||
"new_str": "new",
|
||||
"description": "description",
|
||||
"replace_all": True,
|
||||
},
|
||||
("/path", "old", "new", "description", True),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_obj", "extra_args"),
|
||||
@ -104,6 +185,67 @@ def test_write_file_append_is_discoverable_in_tool_schema() -> None:
|
||||
assert "append" in append_field.description
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("tool_obj", "operational_args", "required_args", "property_args"),
|
||||
_SANDBOX_TOOL_CASES,
|
||||
ids=[case[0].name for case in _SANDBOX_TOOL_CASES],
|
||||
)
|
||||
def test_sandbox_tool_description_is_optional_but_discoverable(tool_obj, operational_args, required_args, property_args) -> None:
|
||||
"""Provider tool calls may omit UI-only descriptions without blocking execution."""
|
||||
parameters = convert_to_openai_tool(tool_obj)["function"]["parameters"]
|
||||
|
||||
assert parameters["required"] == list(required_args)
|
||||
assert list(parameters["properties"]) == list(property_args)
|
||||
assert parameters["properties"]["description"]["description"]
|
||||
|
||||
validated = tool_obj.tool_call_schema.model_validate(operational_args)
|
||||
assert validated.description == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("tool_obj", "call_args", "forwarded_args"),
|
||||
_SANDBOX_TOOL_FORWARDING_CASES,
|
||||
ids=[case[0].name for case in _SANDBOX_TOOL_FORWARDING_CASES],
|
||||
)
|
||||
async def test_sandbox_tool_sync_async_signatures_and_forwarding_stay_aligned(
|
||||
monkeypatch,
|
||||
tool_obj,
|
||||
call_args,
|
||||
forwarded_args,
|
||||
) -> None:
|
||||
"""Async wrappers must keep both their public signature and positional forwarding aligned."""
|
||||
assert tool_obj.func is not None
|
||||
assert tool_obj.coroutine is not None
|
||||
assert list(inspect.signature(tool_obj.func).parameters) == list(inspect.signature(tool_obj.coroutine).parameters)
|
||||
|
||||
run_sync_tool = AsyncMock(return_value="forwarded")
|
||||
monkeypatch.setattr("deerflow.sandbox.tools._run_sync_tool_after_async_sandbox_init", run_sync_tool)
|
||||
runtime = object()
|
||||
|
||||
assert await tool_obj.coroutine(runtime=runtime, **call_args) == "forwarded"
|
||||
run_sync_tool.assert_awaited_once_with(tool_obj.func, runtime, *forwarded_args)
|
||||
|
||||
run_sync_tool.reset_mock()
|
||||
call_args_without_description = {key: value for key, value in call_args.items() if key != "description"}
|
||||
forwarded_without_description = tuple("" if value == "description" else value for value in forwarded_args)
|
||||
|
||||
assert await tool_obj.coroutine(runtime=runtime, **call_args_without_description) == "forwarded"
|
||||
run_sync_tool.assert_awaited_once_with(tool_obj.func, runtime, *forwarded_without_description)
|
||||
|
||||
|
||||
def test_task_tool_description_is_optional_but_discoverable() -> None:
|
||||
"""Subagent execution may not depend on its UI-only progress label."""
|
||||
parameters = convert_to_openai_tool(task_tool)["function"]["parameters"]
|
||||
|
||||
assert parameters["required"] == ["prompt", "subagent_type"]
|
||||
assert list(parameters["properties"]) == ["prompt", "subagent_type", "acceptance_criteria", "description"]
|
||||
assert parameters["properties"]["description"]["description"]
|
||||
|
||||
validated = task_tool.tool_call_schema.model_validate({"prompt": "go", "subagent_type": "general-purpose"})
|
||||
assert validated.description == ""
|
||||
|
||||
|
||||
def test_list_uploaded_files_model_schema_excludes_injected_runtime() -> None:
|
||||
"""The model-facing schema must not expose ToolRuntime internals."""
|
||||
parameters = convert_to_openai_tool(list_uploaded_files)["function"]["parameters"]
|
||||
|
||||
@ -107,7 +107,7 @@ Edit-and-rerun is deliberately latest-turn-only. `core/messages/utils.ts::getLat
|
||||
- **Streaming Markdown rendering** is owned by `core/streamdown`: Streamdown's `animated` / `isAnimating` API handles incremental word animation, while the shared `streamdownRenderingPlugins` config registers the named code-highlighting and Mermaid plugins required by Streamdown 2.5. Keep wrappers and derived configs wired to that shared object; do not reintroduce a rehype plugin that wraps every word, because reparsing a growing block remounts old words and replays their animation.
|
||||
- Citation links in message and artifact Markdown must derive their `citation:` label from the full `ReactNode` children tree, since Streamdown may provide element or array children during streaming rather than a plain string.
|
||||
- **Environment validation** uses `@t3-oss/env-nextjs` with Zod schemas (`src/env.js`). Skip with `SKIP_ENV_VALIDATION=1`
|
||||
- **Subtask step history and runtime metadata** (`core/tasks/`) — the subtask card shows a subagent's full step timeline (#3779): its assistant reasoning turns interleaved with the tools it ran. `Subtask.steps[]` is accumulated live from `task_running` events (appended via `mergeSteps`, not overwritten) and backfilled on expand for historical runs by `fetchSubtaskSteps`, which pages the events endpoint scoped to one task (GET `/runs/{runId}/events?event_types=subagent.step&task_id=…&after_seq=…`) until a short page, so the run-wide limit can't truncate the timeline. `task_started` carries the effective `model_name`; `task_running` carries a cumulative usage snapshot after each completed LLM call. `core/tasks/lifecycle.ts` normalizes these additive events, and `computeNextSubtask` keeps the largest cumulative total so replayed or late SSE frames cannot double-count or roll the folded card backward. Terminal ToolMessage metadata (`subagent_model_name` / `subagent_token_usage`) restores the same values from normal history after reload; no per-card event fetch is needed. `core/tasks/steps.ts` is the pure step model: `messageToStep` (live), `eventsToSteps` (reload), `mergeSteps` (dedup by `message_index`), and `stepsForDisplay` (what the card renders — keeps tool steps + AI steps with text, drops the trailing final-answer AI step when completed since it's shown as `result`). `core/tasks/context.tsx`'s `useUpdateSubtask` applies updates against a `tasksRef` mirroring the latest state (not a closure snapshot), so a late-resolving `fetchSubtaskSteps` backfill merges into current state instead of clobbering SSE steps or sibling subtasks that arrived meanwhile. The owning `run_id` is carried onto history content messages in `buildVisibleHistoryMessages` so the card can resolve the events endpoint.
|
||||
- **Subtask step history and runtime metadata** (`core/tasks/`) — the subtask card shows a subagent's full step timeline (#3779): its assistant reasoning turns interleaved with the tools it ran. The task tool's model-visible `description` is an optional progress label; `MessageList` uses the required `prompt` (then the localized generic subtask label) when a provider omits it, so a valid task call never renders a blank card title. `Subtask.steps[]` is accumulated live from `task_running` events (appended via `mergeSteps`, not overwritten) and backfilled on expand for historical runs by `fetchSubtaskSteps`, which pages the events endpoint scoped to one task (GET `/runs/{runId}/events?event_types=subagent.step&task_id=…&after_seq=…`) until a short page, so the run-wide limit can't truncate the timeline. `task_started` carries the effective `model_name`; `task_running` carries a cumulative usage snapshot after each completed LLM call. `core/tasks/lifecycle.ts` normalizes these additive events, and `computeNextSubtask` keeps the largest cumulative total so replayed or late SSE frames cannot double-count or roll the folded card backward. Terminal ToolMessage metadata (`subagent_model_name` / `subagent_token_usage`) restores the same values from normal history after reload; no per-card event fetch is needed. `core/tasks/steps.ts` is the pure step model: `messageToStep` (live), `eventsToSteps` (reload), `mergeSteps` (dedup by `message_index`), and `stepsForDisplay` (what the card renders — keeps tool steps + AI steps with text, drops the trailing final-answer AI step when completed since it's shown as `result`). `core/tasks/context.tsx`'s `useUpdateSubtask` applies updates against a `tasksRef` mirroring the latest state (not a closure snapshot), so a late-resolving `fetchSubtaskSteps` backfill merges into current state instead of clobbering SSE steps or sibling subtasks that arrived meanwhile. The owning `run_id` is carried onto history content messages in `buildVisibleHistoryMessages` so the card can resolve the events endpoint.
|
||||
|
||||
### Interaction Ownership
|
||||
|
||||
|
||||
@ -74,6 +74,7 @@ import {
|
||||
} from "@/core/sidecar";
|
||||
import type { Subtask } from "@/core/tasks";
|
||||
import { useUpdateSubtask } from "@/core/tasks/context";
|
||||
import { resolveSubtaskDescription } from "@/core/tasks/presentation";
|
||||
import {
|
||||
derivePendingSubtaskStatus,
|
||||
parseSubtaskResult,
|
||||
@ -1312,7 +1313,11 @@ export function MessageList({
|
||||
const task: Subtask = {
|
||||
id: taskId,
|
||||
subagent_type: toolCall.args.subagent_type,
|
||||
description: toolCall.args.description,
|
||||
description: resolveSubtaskDescription(
|
||||
toolCall.args.description,
|
||||
toolCall.args.prompt,
|
||||
t.subtasks.subtask,
|
||||
),
|
||||
prompt: toolCall.args.prompt,
|
||||
status,
|
||||
...(status === "failed"
|
||||
|
||||
@ -1,6 +1,21 @@
|
||||
import { formatTokenCount, type TokenUsage } from "@/core/messages/usage";
|
||||
import type { Model } from "@/core/models/types";
|
||||
|
||||
/** Resolve the card label when a provider omits the task tool's optional description. */
|
||||
export function resolveSubtaskDescription(
|
||||
description: unknown,
|
||||
prompt: unknown,
|
||||
fallback: string,
|
||||
): string {
|
||||
if (typeof description === "string" && description.trim()) {
|
||||
return description.trim();
|
||||
}
|
||||
if (typeof prompt === "string" && prompt.trim()) {
|
||||
return prompt.trim();
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Return the user-facing label for a configured subagent model. */
|
||||
export function resolveSubtaskModelLabel(
|
||||
modelName: string | undefined,
|
||||
|
||||
@ -2,7 +2,6 @@ import { expect, test } from "@playwright/test";
|
||||
|
||||
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
|
||||
|
||||
const STOPPED_TASK_DESCRIPTION = "Research stopped reload regression";
|
||||
const STOPPED_TASK_PROMPT =
|
||||
"Investigate why the stopped subtask card should not remain running after reload.";
|
||||
|
||||
@ -29,7 +28,6 @@ const stoppedSubtaskMessages = [
|
||||
name: "task",
|
||||
args: {
|
||||
subagent_type: "general-purpose",
|
||||
description: STOPPED_TASK_DESCRIPTION,
|
||||
prompt: STOPPED_TASK_PROMPT,
|
||||
},
|
||||
type: "tool_call",
|
||||
@ -57,7 +55,7 @@ test.describe("Subtask card", () => {
|
||||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||||
await page.reload();
|
||||
|
||||
await expect(page.getByText(STOPPED_TASK_DESCRIPTION)).toBeVisible({
|
||||
await expect(page.getByText(STOPPED_TASK_PROMPT)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByText("Subtask failed")).toBeVisible();
|
||||
|
||||
@ -2,9 +2,24 @@ import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
import {
|
||||
formatSubtaskTokenUsage,
|
||||
resolveSubtaskDescription,
|
||||
resolveSubtaskModelLabel,
|
||||
} from "@/core/tasks/presentation";
|
||||
|
||||
describe("resolveSubtaskDescription", () => {
|
||||
it("prefers the short label and falls back to the required prompt", () => {
|
||||
expect(
|
||||
resolveSubtaskDescription(" Research auth ", "long prompt", "Subtask"),
|
||||
).toBe("Research auth");
|
||||
expect(resolveSubtaskDescription("", " Investigate auth ", "Subtask")).toBe(
|
||||
"Investigate auth",
|
||||
);
|
||||
expect(resolveSubtaskDescription(undefined, undefined, "Subtask")).toBe(
|
||||
"Subtask",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSubtaskModelLabel", () => {
|
||||
it("prefers the configured display name and falls back to the model identifier", () => {
|
||||
expect(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user