diff --git a/README.md b/README.md index e7d97c4e0..7d257af76 100644 --- a/README.md +++ b/README.md @@ -893,6 +893,8 @@ DeerFlow doesn't just *talk* about doing things. It has its own computer. Each task gets its own execution environment with a full filesystem view — skills, workspace, uploads, outputs. The agent reads, writes, and edits files. It can view images and, when configured safely, execute shell commands. +The built-in `grep` tool searches either one text file or all matching text files below a directory, so an agent can search an uploaded document directly without first broadening the request to the entire uploads directory. + Image bytes loaded for a vision-model call are transient: DeerFlow removes the hidden base64 message after the model consumes it so later checkpoints do not keep duplicating that payload. After each run, DeerFlow records a workspace change summary for the run-owned `workspace` and `outputs` directories. The Web UI shows a compact "files changed" badge on the assistant turn; opening it reveals created, modified, and deleted files with text diffs when safe to display. Uploads are excluded because they are user inputs, not agent-generated changes. Large, binary, or sensitive-looking files are shown as metadata only. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 3747c920e..2f1fd8ec0 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -536,7 +536,7 @@ copying a raw checkpoint because delta state is not self-contained in one tuple. ### Sandbox System (`packages/harness/deerflow/sandbox/`) -**Interface**: Abstract `Sandbox` with `execute_command(command, env=None)`, `read_file`, `write_file`, `list_dir`. The optional `env` injects per-call environment variables (request-scoped secrets — see Request-Scoped Secrets below); `LocalSandbox` merges it via `subprocess.run(env=...)` and `AioSandbox` routes env-bearing commands through the `bash.exec(env=...)` API on a fresh session. +**Interface**: Abstract `Sandbox` with `execute_command(command, env=None)`, `read_file`, `write_file`, `list_dir`, `glob`, and `grep`. `grep` accepts either one text file or a directory tree. The optional `env` injects per-call environment variables (request-scoped secrets — see Request-Scoped Secrets below); `LocalSandbox` merges it via `subprocess.run(env=...)` and `AioSandbox` routes env-bearing commands through the `bash.exec(env=...)` API on a fresh session. **Provider Pattern**: `SandboxProvider` with `acquire`, `acquire_async`, `get`, `release` lifecycle. Async agent/tool paths call async sandbox lifecycle hooks so Docker sandbox creation, discovery, cross-process locking, readiness polling, and release stay off the event loop. **Environment policy** (`sandbox/env_policy.py`): `execute_command` no longer inherits the full `os.environ`. `build_sandbox_env()` scrubs secret-looking names (`*KEY*`/`*SECRET*`/`*TOKEN*`/`*PASS*`/`*CREDENTIAL*`) from the inherited environment before layering injected request secrets on top, so platform credentials (e.g. `OPENAI_API_KEY`) never leak into skill subprocesses. Benign vars (`PATH`, `HOME`, `LANG`, `VIRTUAL_ENV`, ...) are preserved. **Implementations**: @@ -606,6 +606,8 @@ copying a raw checkpoint because delta state is not self-contained in one tuple. **Sandbox Tools** (in `packages/harness/deerflow/sandbox/tools.py`): - `bash` - Execute commands with path translation and error handling. For `LocalSandbox` (host bash), POSIX output is captured through bounded pipe-drain threads and stdin is `/dev/null`, so 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 group 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` / `_run_posix_command` 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 +- `grep` - Search one text file or recursively search a directory, with optional glob filtering and bounded line-level results - `read_file` - Read file contents with optional line range - `write_file` - Write/append to files, creates directories; overwrites by default and exposes the `append` argument in the model-facing schema for end-of-file writes; subject to the read-before-write gate when `read_before_write.enabled` (see Middleware Chain) - `str_replace` - Substring replacement (single or all occurrences); same-path serialization is scoped to `(sandbox.id, path)` so isolated sandboxes do not contend on identical virtual paths inside one process; subject to the read-before-write gate when `read_before_write.enabled` (see Middleware Chain) diff --git a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py index 5ed2b4bd3..a3be91863 100644 --- a/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py +++ b/backend/packages/harness/deerflow/community/aio_sandbox/aio_sandbox.py @@ -422,41 +422,45 @@ class AioSandbox(Sandbox): # (caught by grep_tool's except re.error handler) rather than a # generic remote API error. _re.compile(regex_source, 0 if case_sensitive else _re.IGNORECASE) - regex = regex_source if case_sensitive else f"(?i){regex_source}" - - if glob is not None: - find_result = self._client.file.find_files(path=path, glob=glob) - candidate_paths = find_result.data.files if find_result.data and find_result.data.files else [] - else: - list_result = self._client.file.list_path(path=path, recursive=True, show_hidden=False) - entries = list_result.data.files if list_result.data and list_result.data.files else [] - candidate_paths = [entry.path for entry in entries if not entry.is_directory] + total_cap = max(max_results * 4, max_results + 50) + result = self._client.file.grep_files( + path=path, + pattern=pattern, + case_insensitive=not case_sensitive, + fixed_strings=literal, + max_results=total_cap, + max_file_size="1M", + recursive=True, + ) + data = result.data + provider_matches = data.matches if data and data.matches else [] + root = path.rstrip("/") or "/" + root_prefix = root if root == "/" else f"{root}/" matches: list[GrepMatch] = [] - truncated = False - - for file_path in candidate_paths: + truncated = bool(data and data.truncated) + for match in provider_matches: + file_path = match.file if should_ignore_path(file_path): continue - - search_result = self._client.file.search_in_file(file=file_path, regex=regex) - data = search_result.data - if data is None: + if file_path == root: + rel_path = file_path.rsplit("/", 1)[-1] + elif file_path.startswith(root_prefix): + rel_path = file_path[len(root_prefix) :] + else: continue - - line_numbers = data.line_numbers or [] - matched_lines = data.matches or [] - for line_number, line in zip(line_numbers, matched_lines): - matches.append( - GrepMatch( - path=file_path, - line_number=line_number if isinstance(line_number, int) else 0, - line=truncate_line(line), - ) + if glob is not None and not path_matches(glob, rel_path): + continue + matches.append( + GrepMatch( + path=file_path, + line_number=match.line_number, + line=truncate_line(match.line_content), ) - if len(matches) >= max_results: - truncated = True - return matches, truncated + ) + if len(matches) >= max_results: + truncated = True + break return matches, truncated diff --git a/backend/packages/harness/deerflow/community/boxlite/box.py b/backend/packages/harness/deerflow/community/boxlite/box.py index 2f60dc1ee..534d7e7dd 100644 --- a/backend/packages/harness/deerflow/community/boxlite/box.py +++ b/backend/packages/harness/deerflow/community/boxlite/box.py @@ -325,11 +325,11 @@ class BoxliteBox(Sandbox): re.compile(pattern, 0 if case_sensitive else re.IGNORECASE) resolved = self._resolve_path(path) - # busybox+GNU-portable flags: -r recursive (also prints the filename), - # -n line numbers, -I skip binary, -E/-F regex vs fixed. --include and -m - # are omitted for busybox portability; glob-scoping and the result cap are - # applied in Python below. - flags = ["-r", "-n", "-I"] + # busybox+GNU-portable flags: -r recursive, -H always print the filename + # (including when path is a single file), -n line numbers, -I skip + # binary, -E/-F regex vs fixed. --include and -m are omitted for busybox + # portability; glob-scoping and the result cap are applied in Python. + flags = ["-r", "-H", "-n", "-I"] if not case_sensitive: flags.append("-i") flags.append("-F" if literal else "-E") diff --git a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py index c069ba993..e8607975a 100644 --- a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py +++ b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py @@ -476,8 +476,8 @@ class E2BSandbox(Sandbox): # ``--include`` flag above only pre-filtered by basename. if file_path != root and not file_path.startswith(root_prefix): continue - rel_path = file_path[len(root) :].lstrip("/") - if not rel_path or not path_matches(glob, rel_path): + rel_path = file_path.rsplit("/", 1)[-1] if file_path == root else file_path[len(root) :].lstrip("/") + if not path_matches(glob, rel_path): continue matches.append( GrepMatch( diff --git a/backend/packages/harness/deerflow/community/tenki/sandbox.py b/backend/packages/harness/deerflow/community/tenki/sandbox.py index 995a7fa62..c227a7cc7 100644 --- a/backend/packages/harness/deerflow/community/tenki/sandbox.py +++ b/backend/packages/harness/deerflow/community/tenki/sandbox.py @@ -448,8 +448,8 @@ class TenkiSandbox(Sandbox): # helper, same relative-to-root semantics as glob() above. if file_path != root and not file_path.startswith(root_prefix): continue - rel_path = file_path[len(root) :].lstrip("/") - if not rel_path or not path_matches(glob, rel_path): + rel_path = posixpath.basename(file_path) if file_path == root else file_path[len(root) :].lstrip("/") + if not path_matches(glob, rel_path): continue matches.append(GrepMatch(path=self._virtual_path(file_path), line_number=line_number, line=truncate_line(line_text))) if len(matches) >= max_results: diff --git a/backend/packages/harness/deerflow/sandbox/sandbox.py b/backend/packages/harness/deerflow/sandbox/sandbox.py index cad0168d7..5147a9ac2 100644 --- a/backend/packages/harness/deerflow/sandbox/sandbox.py +++ b/backend/packages/harness/deerflow/sandbox/sandbox.py @@ -161,7 +161,7 @@ class Sandbox(ABC): case_sensitive: bool = False, max_results: int = 100, ) -> tuple[list[GrepMatch], bool]: - """Search for matches inside text files under a directory.""" + """Search for matches inside a text file or files under a directory.""" pass @abstractmethod diff --git a/backend/packages/harness/deerflow/sandbox/search.py b/backend/packages/harness/deerflow/sandbox/search.py index b4b7b9608..83d7f39a8 100644 --- a/backend/packages/harness/deerflow/sandbox/search.py +++ b/backend/packages/harness/deerflow/sandbox/search.py @@ -169,7 +169,8 @@ def find_grep_matches( if not root.exists(): raise FileNotFoundError(root) - if not root.is_dir(): + root_is_file = root.is_file() + if not root_is_file and not root.is_dir(): raise NotADirectoryError(root) regex_source = re.escape(pattern) if literal else pattern @@ -179,44 +180,47 @@ def find_grep_matches( # Skip lines longer than this to prevent ReDoS on minified / no-newline files. _max_line_chars = line_summary_length * 10 - for current_root, dirs, files in os.walk(root): - dirs[:] = [name for name in dirs if not should_ignore_name(name)] - rel_dir = Path(current_root).relative_to(root) + def candidate_files(): + if root_is_file: + yield root, root.name + return - for name in files: - if should_ignore_name(name): + for current_root, dirs, files in os.walk(root): + dirs[:] = [name for name in dirs if not should_ignore_name(name)] + rel_dir = Path(current_root).relative_to(root) + for name in files: + if should_ignore_name(name): + continue + yield Path(current_root) / name, (rel_dir / name).as_posix() + + for candidate_path, rel_path in candidate_files(): + if glob_pattern is not None and not path_matches(glob_pattern, rel_path): + continue + + try: + if not root_is_file and candidate_path.is_symlink(): continue - - candidate_path = Path(current_root) / name - rel_path = (rel_dir / name).as_posix() - - if glob_pattern is not None and not path_matches(glob_pattern, rel_path): + file_path = candidate_path.resolve() + if not root_is_file and not file_path.is_relative_to(root): continue - - try: - if candidate_path.is_symlink(): - continue - file_path = candidate_path.resolve() - if not file_path.is_relative_to(root): - continue - if file_path.stat().st_size > max_file_size or is_binary_file(file_path): - continue - with file_path.open(encoding="utf-8", errors="replace") as handle: - for line_number, line in enumerate(handle, start=1): - if len(line) > _max_line_chars: - continue - if regex.search(line): - matches.append( - GrepMatch( - path=str(file_path), - line_number=line_number, - line=truncate_line(line, line_summary_length), - ) + if file_path.stat().st_size > max_file_size or is_binary_file(file_path): + continue + with file_path.open(encoding="utf-8", errors="replace") as handle: + for line_number, line in enumerate(handle, start=1): + if len(line) > _max_line_chars: + continue + if regex.search(line): + matches.append( + GrepMatch( + path=str(file_path), + line_number=line_number, + line=truncate_line(line, line_summary_length), ) - if len(matches) >= max_results: - truncated = True - return matches, truncated - except OSError: - continue + ) + if len(matches) >= max_results: + truncated = True + return matches, truncated + except OSError: + continue return matches, truncated diff --git a/backend/packages/harness/deerflow/sandbox/tools.py b/backend/packages/harness/deerflow/sandbox/tools.py index 48db29f7b..85c4f7b17 100644 --- a/backend/packages/harness/deerflow/sandbox/tools.py +++ b/backend/packages/harness/deerflow/sandbox/tools.py @@ -1993,12 +1993,12 @@ def grep_tool( case_sensitive: bool = False, max_results: int = _DEFAULT_GREP_MAX_RESULTS, ) -> str: - """Search for matching lines inside text files under a root directory. + """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** root directory to search under. + path: The **absolute** file or root directory to search. 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. diff --git a/backend/packages/harness/pyproject.toml b/backend/packages/harness/pyproject.toml index 59ed07530..5dafec0ad 100644 --- a/backend/packages/harness/pyproject.toml +++ b/backend/packages/harness/pyproject.toml @@ -5,7 +5,7 @@ description = "DeerFlow agent harness framework" requires-python = ">=3.12" dependencies = [ "agent-client-protocol>=0.4.0", - "agent-sandbox>=0.0.19", + "agent-sandbox>=0.0.30", "croniter>=6.0.0", "dotenv>=0.9.9", "exa-py>=1.0.0", diff --git a/backend/tests/test_boxlite_provider.py b/backend/tests/test_boxlite_provider.py index f163285dd..c151dd83c 100644 --- a/backend/tests/test_boxlite_provider.py +++ b/backend/tests/test_boxlite_provider.py @@ -165,6 +165,17 @@ def test_execute_command_forwards_timeout_to_sdk_and_loop_runner() -> None: assert run_timeouts == [5] +def test_grep_always_prints_filename_for_single_file_paths() -> None: + fake = _FakeBox(name="box-id") + box = BoxliteBox("box-id", box=fake, run=_fake_run) + + box.grep("/mnt/user-data/uploads/report.md", "needle") + + grep_commands = [argv[0][2] for argv in fake._exec_history if argv[0][:2] == ("sh", "-lc") and argv[0][2].startswith("grep ")] + assert grep_commands + assert "-H" in grep_commands[-1].split() + + def test_execute_command_invalidates_box_on_terminal_transport_error() -> None: invalidated: list[tuple[str, str]] = [] diff --git a/backend/tests/test_e2b_sandbox_provider.py b/backend/tests/test_e2b_sandbox_provider.py index b654dffe4..c438ab476 100644 --- a/backend/tests/test_e2b_sandbox_provider.py +++ b/backend/tests/test_e2b_sandbox_provider.py @@ -2072,6 +2072,18 @@ def test_grep_without_glob_is_unaffected(): assert truncated is False +def test_grep_single_file_path_with_matching_glob(): + """A basename glob must also apply when the search root is one file.""" + raw_stdout = "/home/user/uploads/report.md:2:needle here\n" + client = FakeClient(commands=FakeCommandsAPI([SimpleNamespace(stdout=raw_stdout, stderr="", exit_code=0)])) + sb = _make_sandbox(client) + + matches, truncated = sb.grep("/mnt/user-data/uploads/report.md", "needle", glob="*.md") + + assert [m.path for m in matches] == ["/home/user/uploads/report.md"] + assert truncated is False + + # ────────────────────────────────────────────────────────────────────────────── # Capacity enforcement tests (#4339) # ────────────────────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_sandbox_search_tools.py b/backend/tests/test_sandbox_search_tools.py index e3438c823..1acbacefd 100644 --- a/backend/tests/test_sandbox_search_tools.py +++ b/backend/tests/test_sandbox_search_tools.py @@ -101,6 +101,26 @@ def test_grep_tool_filters_by_glob_and_skips_binary_files(tmp_path, monkeypatch) assert str(workspace) not in result +def test_grep_tool_accepts_single_file_path(tmp_path, monkeypatch) -> None: + runtime = _make_runtime(tmp_path) + uploads = tmp_path / "uploads" + report = uploads / "report.md" + report.write_text("Revenue grew 20%\n", encoding="utf-8") + + monkeypatch.setattr("deerflow.sandbox.tools.ensure_sandbox_initialized", lambda runtime: LocalSandbox(id="local")) + + result = grep_tool.func( + runtime=runtime, + description="find revenue in the uploaded report", + pattern="Revenue", + path="/mnt/user-data/uploads/report.md", + ) + + assert "/mnt/user-data/uploads/report.md:1: Revenue grew 20%" in result + assert "Path is not a directory" not in result + assert str(uploads) not in result + + def test_grep_tool_truncates_results(tmp_path, monkeypatch) -> None: runtime = _make_runtime(tmp_path) workspace = tmp_path / "workspace" @@ -260,24 +280,20 @@ def test_aio_sandbox_grep_parses_json(monkeypatch) -> None: sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080") monkeypatch.setattr( sandbox._client.file, - "list_path", + "grep_files", lambda **kwargs: SimpleNamespace( data=SimpleNamespace( - files=[ + matches=[ SimpleNamespace( - name="app.py", - path="/mnt/user-data/workspace/app.py", - is_directory=False, + file="/mnt/user-data/workspace/app.py", + line_number=7, + line_content="TODO = True", ) - ] + ], + truncated=False, ) ), ) - monkeypatch.setattr( - sandbox._client.file, - "search_in_file", - lambda **kwargs: SimpleNamespace(data=SimpleNamespace(line_numbers=[7], matches=["TODO = True"])), - ) matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO") @@ -285,6 +301,37 @@ def test_aio_sandbox_grep_parses_json(monkeypatch) -> None: assert truncated is False +def test_aio_sandbox_grep_accepts_single_file_path(monkeypatch) -> None: + with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"): + sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080") + monkeypatch.setattr( + sandbox._client.file, + "grep_files", + lambda **kwargs: SimpleNamespace( + data=SimpleNamespace( + matches=[ + SimpleNamespace( + file="/mnt/user-data/uploads/report.md", + line_number=3, + line_content="Revenue grew 20%", + ) + ], + truncated=False, + ) + ), + ) + monkeypatch.setattr( + sandbox._client.file, + "list_path", + lambda **kwargs: (_ for _ in ()).throw(AssertionError("single-file grep must not list the path as a directory")), + ) + + matches, truncated = sandbox.grep("/mnt/user-data/uploads/report.md", "Revenue") + + assert matches == [GrepMatch(path="/mnt/user-data/uploads/report.md", line_number=3, line="Revenue grew 20%")] + assert truncated is False + + def test_find_glob_matches_raises_not_a_directory(tmp_path) -> None: file_path = tmp_path / "file.txt" file_path.write_text("x\n", encoding="utf-8") @@ -296,15 +343,14 @@ def test_find_glob_matches_raises_not_a_directory(tmp_path) -> None: pass -def test_find_grep_matches_raises_not_a_directory(tmp_path) -> None: +def test_find_grep_matches_accepts_single_file(tmp_path) -> None: file_path = tmp_path / "file.txt" file_path.write_text("TODO\n", encoding="utf-8") - try: - find_grep_matches(file_path, "TODO") - assert False, "Expected NotADirectoryError" - except NotADirectoryError: - pass + matches, truncated = find_grep_matches(file_path, "TODO") + + assert matches == [GrepMatch(path=str(file_path), line_number=1, line="TODO")] + assert truncated is False def test_find_grep_matches_skips_symlink_outside_root(tmp_path) -> None: @@ -367,29 +413,30 @@ def test_aio_sandbox_glob_include_dirs_enforces_root_boundary(monkeypatch) -> No assert truncated is False -def test_aio_sandbox_grep_skips_mismatched_line_number_payloads(monkeypatch) -> None: +def test_aio_sandbox_grep_drops_matches_outside_requested_root(monkeypatch) -> None: with patch("deerflow.community.aio_sandbox.aio_sandbox.AioSandboxClient"): sandbox = AioSandbox(id="test-sandbox", base_url="http://localhost:8080") monkeypatch.setattr( sandbox._client.file, - "list_path", + "grep_files", lambda **kwargs: SimpleNamespace( data=SimpleNamespace( - files=[ + matches=[ SimpleNamespace( - name="app.py", - path="/mnt/user-data/workspace/app.py", - is_directory=False, - ) - ] + file="/mnt/user-data/workspace/app.py", + line_number=7, + line_content="TODO = True", + ), + SimpleNamespace( + file="/mnt/user-data/workspace-sibling/leak.py", + line_number=9, + line_content="TODO = False", + ), + ], + truncated=False, ) ), ) - monkeypatch.setattr( - sandbox._client.file, - "search_in_file", - lambda **kwargs: SimpleNamespace(data=SimpleNamespace(line_numbers=[7], matches=["TODO = True", "extra"])), - ) matches, truncated = sandbox.grep("/mnt/user-data/workspace", "TODO") diff --git a/backend/tests/test_tenki_provider.py b/backend/tests/test_tenki_provider.py index 2fe8ba93b..77547f302 100644 --- a/backend/tests/test_tenki_provider.py +++ b/backend/tests/test_tenki_provider.py @@ -543,6 +543,16 @@ def test_grep_passes_capital_h_so_single_file_matches_parse() -> None: assert grep_scripts and "-H" in shlex.split(grep_scripts[0]) +def test_grep_single_file_path_with_matching_glob() -> None: + box = TenkiSandbox("sb", _FakeSandbox()) + box.write_file("/mnt/user-data/workspace/a.txt", "needle here\n") + + matches, truncated = box.grep("/mnt/user-data/workspace/a.txt", "needle", glob="*.txt") + + assert [m.path for m in matches] == ["/mnt/user-data/workspace/a.txt"] + assert truncated is False + + def _grep_script(fake: _FakeSandbox) -> list[str]: scripts = [c["argv"][2] for c in fake.exec_calls if c["argv"][:2] == ("sh", "-lc") and c["argv"][2].startswith("grep ")] assert scripts, "no grep command was issued" diff --git a/backend/uv.lock b/backend/uv.lock index bc81bd7ee..de87f4a56 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -952,7 +952,7 @@ tui = [ [package.metadata] requires-dist = [ { name = "agent-client-protocol", specifier = ">=0.4.0" }, - { name = "agent-sandbox", specifier = ">=0.0.19" }, + { name = "agent-sandbox", specifier = ">=0.0.30" }, { name = "aiosqlite", specifier = ">=0.19" }, { name = "alembic", specifier = ">=1.13" }, { name = "asyncpg", marker = "extra == 'postgres'", specifier = ">=0.29" },