Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
205 changes: 195 additions & 10 deletions clearwing/agent/tools/hunt/deep_agent.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
"""Deep agent mode tools: execute, read_file, write_file.
"""Deep agent mode tools: bounded source discovery and sandbox operations.

Replaces the constrained 9-tool hunter set with 3 primitives that give
the model full-shell access inside the sandbox container. The model
uses the same tools a human researcher would — gcc, gdb, strace, make,
etc. — all via ``execute()``.
Pairs repository-scoped search with sandbox tools for source reading,
compilation, debugging, and test execution.

Model-side reasoning is captured natively via rust-genai's
`capture_reasoning_content=True` on every chat request; the hunter
Expand All @@ -19,15 +17,20 @@
from __future__ import annotations

import difflib
import fnmatch
import logging
import os
import re
import shlex
from pathlib import Path

from pydantic import Field

from clearwing.llm import NativeToolSpec, ToolInputModel
from clearwing.reporting.safety import redact_text
from clearwing.sourcehunt.paths import resolve_repo_directory, resolve_repo_file

from .discovery import build_discovery_tools
from .pool_query import build_pool_query_tools
from .potentials import build_potential_tools
from .reporting import build_reporting_tools
Expand All @@ -44,7 +47,9 @@ class ExecuteInput(ToolInputModel):


class ReadFileInput(ToolInputModel):
path: str = Field(description="Absolute path in the container.")
path: str = Field(
description="Repository-relative path from a search hit, or /workspace/... path."
)
offset: int = Field(
default=0, description="Line offset (0-based, default 0). Or use start_line (1-based)."
)
Expand Down Expand Up @@ -91,6 +96,16 @@ class ReadFunctionInput(ToolInputModel):
name: str = Field(description="Exact function name to read (e.g. 'foo_bar_baz').")


class FindSourceInput(ToolInputModel):
query: str = Field(description="Filename or directory name substring, or glob pattern.")
path: str = Field(default=".", description="Repository-relative directory to search.")
kind: str = Field(default="both", description="Match files, directories, or both.")


_SEARCH_LIMIT = 40
_SHELL_SEARCH = re.compile(r"(?:^|[;&|]\s*)\s*(?:find|grep|rg)\b")


def _cap_output(text: str, label: str = "output") -> str:
safe = redact_text(text)
if len(safe) <= _OUTPUT_CAP:
Expand All @@ -113,16 +128,164 @@ def _matches(name: str, tokens: list[str]) -> bool:


def build_deep_agent_tools(ctx: HunterContext) -> list[NativeToolSpec]: # noqa: C901
"""Build the deep agent tool set: execute, read_file, write_file,
"""Build the deep agent tool set: bounded search, execute, read_file, write_file,
plus the shared reporting + findings-pool tools.
"""
# Deep hunters read source via read_file/execute (cat/sed/grep), not the
# constrained read_source_file that populates ctx.files_read. Mark the
# context so the reporting guard doesn't reject every trace step for a
# file it never saw a read_source_file call for.
ctx.agent_mode = "deep"
discovery_grep = next(tool for tool in build_discovery_tools(ctx) if tool.name == "grep_source")

def grep_source(pattern: str, path: str = ".", file_glob: str = "", **_: object) -> dict:
matches = discovery_grep.handler(
pattern=pattern, path=path, file_glob=file_glob, max_results=_SEARCH_LIMIT + 1
)
if matches and "error" in matches[0]:
return {
"status": "error",
"error": matches[0]["error"],
"matches": [],
"truncated": False,
}
truncated = len(matches) > _SEARCH_LIMIT
visible = [
{**match, "matched_text": str(match.get("matched_text", ""))[:240]}
for match in matches[:_SEARCH_LIMIT]
]
return {
"status": "truncated" if truncated else "matches" if matches else "no_matches",
"pattern": pattern,
"scope": path,
"matches": visible,
"count": min(len(matches), _SEARCH_LIMIT),
"truncated": truncated,
"next_action": "Read a relevant hit with read_file; refine the query if truncated or empty.",
}

def find_source(query: str, path: str = ".", kind: str = "both", **_: object) -> dict: # noqa: C901
if kind not in {"file", "directory", "both"}:
return {
"status": "error",
"error": "kind must be file, directory, or both",
"matches": [],
"truncated": False,
}
if not query.strip():
return {
"status": "error",
"error": "query must not be empty",
"matches": [],
"truncated": False,
}
base = resolve_repo_directory(ctx.repo_path, path)
if base is None:
return {
"status": "error",
"error": "path is outside the repository or is not a directory",
"matches": [],
"truncated": False,
}
root = Path(ctx.repo_path).resolve()
has_glob = any(char in query for char in "*?[]")
needle = query.lower()
matches: list[dict[str, str]] = []

if ctx.sandbox is not None:
relative_base = base.relative_to(root).as_posix()
container_base = "/workspace" if relative_base == "." else f"/workspace/{relative_base}"
name_pattern = query if has_glob else f"*{query}*"
type_filter = {
"file": "-type f",
"directory": "-type d",
"both": r"\( -type f -o -type d \)",
}[kind]
command = (
f"find {shlex.quote(container_base)} -path '*/.git' -prune -o "
f"\\( -iname {shlex.quote(name_pattern)} -a {type_filter} \\) -print "
"| while IFS= read -r match; do "
'if [ -d "$match" ]; then printf "directory\\t%s\\n" "$match"; '
'else printf "file\\t%s\\n" "$match"; fi; done '
f"| head -n {_SEARCH_LIMIT + 1}"
)
result = ctx.sandbox.exec(command, timeout=30)
if result.timed_out:
return {
"status": "error",
"error": "repository path search timed out",
"matches": [],
"truncated": False,
}
if result.stderr and not result.stdout:
return {
"status": "error",
"error": _cap_output(result.stderr.strip(), "find error"),
"matches": [],
"truncated": False,
}
for row in result.stdout.splitlines():
entry_type, separator, absolute = row.partition("\t")
if not separator or entry_type not in {"file", "directory"}:
continue
if not absolute.startswith("/workspace/"):
continue
matches.append(
{"path": redact_text(absolute.removeprefix("/workspace/")), "type": entry_type}
)
else:

def wanted(name: str) -> bool:
return fnmatch.fnmatch(name.lower(), needle) if has_glob else needle in name.lower()

for directory, dirnames, filenames in os.walk(base, followlinks=False):
dirnames[:] = sorted(
name
for name in dirnames
if name != ".git"
and resolve_repo_directory(
ctx.repo_path, (Path(directory) / name).relative_to(root).as_posix()
)
is not None
)
if kind in {"directory", "both"}:
for name in dirnames:
if wanted(name):
relative = (Path(directory) / name).relative_to(root).as_posix()
matches.append({"path": redact_text(relative), "type": "directory"})
if len(matches) > _SEARCH_LIMIT:
break
if len(matches) > _SEARCH_LIMIT:
break
if kind in {"file", "both"}:
for name in sorted(filenames):
candidate = Path(directory) / name
relative = candidate.relative_to(root).as_posix()
if wanted(name) and resolve_repo_file(ctx.repo_path, relative) is not None:
matches.append({"path": redact_text(relative), "type": "file"})
if len(matches) > _SEARCH_LIMIT:
break
if len(matches) > _SEARCH_LIMIT:
break
truncated = len(matches) > _SEARCH_LIMIT
return {
"status": "truncated" if truncated else "matches" if matches else "no_matches",
"query": query,
"scope": path,
"matches": matches[:_SEARCH_LIMIT],
"count": min(len(matches), _SEARCH_LIMIT),
"truncated": truncated,
"next_action": "Read a relevant file with read_file; refine the query if truncated or empty.",
}

def execute(command: str, timeout: int = 300, **_: object) -> dict:
if _SHELL_SEARCH.search(command):
return {
"error": (
"Shell search blocked. Use grep_source for repository "
"content or find_source for repository paths, then read_file a hit."
)
}
if ctx.sandbox is None:
return {"error": "no sandbox available"}
result = ctx.sandbox.exec(command, timeout=timeout)
Expand Down Expand Up @@ -315,19 +478,41 @@ def read_function(name: str, **_: object) -> dict:
)

return [
NativeToolSpec(
name="grep_source",
description=(
"Search source content inside the repository. Returns at most 40 file:line "
"matches and an explicit no_matches or truncated state. Read a relevant hit "
"with read_file; repeated results call for a different approach or a finish."
),
schema=discovery_grep.schema,
handler=grep_source,
),
NativeToolSpec(
name="find_source",
description=(
"Find repository files or directories by name substring or glob. Returns at "
"most 40 labeled paths and an explicit no_matches or truncated state. "
"Read a relevant file with read_file."
),
schema=FindSourceInput.model_json_schema(),
handler=find_source,
),
NativeToolSpec(
name="execute",
description=(
"Run a shell command inside the sandbox container. "
"Use for compilation, debugging, running tests, etc."
"Run a shell command inside the sandbox container for compilation, "
"debugging, and tests. Use grep_source for source content and "
"find_source for paths instead of shell grep/find searches."
),
schema=ExecuteInput.model_json_schema(),
handler=execute,
),
NativeToolSpec(
name="read_file",
description=(
"Read lines from a file in the container. "
"Read lines from a relevant grep_source or find_source hit. "
"Repository-relative paths resolve inside /workspace. "
"Parameters: path (required), offset (line offset, default 0), "
"limit (max lines, default 2000). No other parameters exist."
),
Expand Down
42 changes: 33 additions & 9 deletions clearwing/agent/tools/hunt/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import logging
import os
import re
import shlex
from pathlib import Path

from pydantic import Field
Expand Down Expand Up @@ -109,7 +110,7 @@ def _container_path(rel_path: str) -> str:
return f"/workspace/{rel_path}".replace("//", "/")


def _parse_rg_output(stdout: str, default_file: str = "") -> list[dict]:
def _parse_rg_output(stdout: str, default_file: str = "", max_results: int = 100) -> list[dict]:
"""Turn ripgrep's `--no-heading --line-number` output into match dicts."""
matches: list[dict] = []
for line in stdout.splitlines():
Expand All @@ -133,7 +134,7 @@ def _parse_rg_output(stdout: str, default_file: str = "") -> list[dict]:
"matched_text": _safe_source_output(text.rstrip(), "grep match"),
}
)
if len(matches) >= 100:
if len(matches) >= max_results:
break
return matches

Expand All @@ -143,6 +144,7 @@ def _grep_python_fallback(
rel_dir: str,
pattern: str,
file_glob: str,
max_results: int = 100,
) -> list[dict]:
"""Pure-Python fallback when no sandbox is attached (test mode)."""
try:
Expand Down Expand Up @@ -186,7 +188,7 @@ def _grep_python_fallback(
"matched_text": _safe_source_output(line.rstrip(), "grep match"),
}
)
if len(matches) >= 100:
if len(matches) >= max_results:
return matches
except OSError:
continue
Expand Down Expand Up @@ -292,7 +294,13 @@ def list_source_tree(dir_path: str = ".", max_depth: int = 2) -> list[str]:
return out
return out

def grep_source(pattern: str, path: str = ".", file_glob: str = "") -> list[dict]:
def grep_source(
pattern: str,
path: str = ".",
file_glob: str = "",
*,
max_results: int = 100,
) -> list[dict]:
"""ripgrep-style search for a pattern. Returns up to 100 matches.

Args:
Expand Down Expand Up @@ -320,15 +328,31 @@ def grep_source(pattern: str, path: str = ".", file_glob: str = "") -> list[dict
if ctx.sandbox is not None:
# Run rg inside the sandbox so we don't depend on the host having it
target = _container_path(rel)
argv = ["rg", "--no-heading", "--line-number", "--with-filename", "--max-count", "100"]
argv = ["rg", "--no-heading", "--line-number", "--with-filename"]
if file_glob and not path_is_file:
argv += ["-g", file_glob]
argv += [pattern, target]
result = ctx.sandbox.exec(argv, timeout=30)
return _parse_rg_output(result.stdout, default_file=rel if path_is_file else "")
argv += ["--", pattern, target]
if max_results == 100:
# Preserve the constrained hunter's existing command shape.
argv[4:4] = ["--max-count", "100"]
result = ctx.sandbox.exec(argv, timeout=30)
else:
# Deep mode asks for one extra match to distinguish a complete
# result from a truncated one. Bound output across all files.
command = f"{shlex.join(argv)} | head -n {max_results}"
result = ctx.sandbox.exec(command, timeout=30)
if result.timed_out:
return [{"error": "repository content search timed out"}]
if result.stderr and not result.stdout:
return [{"error": _safe_source_output(result.stderr.strip(), "grep error")}]
return _parse_rg_output(
result.stdout,
default_file=rel if path_is_file else "",
max_results=max_results,
)
else:
# Fallback: use Python re on the host file tree (slower but works in tests)
return _grep_python_fallback(ctx.repo_path, rel, pattern, file_glob)
return _grep_python_fallback(ctx.repo_path, rel, pattern, file_glob, max_results)

def find_callers(symbol: str) -> list[dict]:
"""Find files/lines that reference a symbol. Wraps grep_source.
Expand Down
Loading
Loading