feat(tools): improve coding workflow recovery

This commit is contained in:
Xubin Ren
2026-05-21 00:58:05 +08:00
parent 3e154bb5cf
commit 480ca28a2d
9 changed files with 571 additions and 32 deletions
+110 -4
View File
@@ -25,22 +25,39 @@ class _SessionPoll:
output: str
done: bool
exit_code: int | None
elapsed_s: float = 0.0
timed_out: bool = False
terminated: bool = False
stdin_closed: bool = False
truncated_chars: int = 0
@dataclass(slots=True)
class ExecSessionInfo:
session_id: str
command: str
cwd: str
elapsed_s: float
idle_s: float
remaining_s: float
returncode: int | None
class _ExecSession:
def __init__(
self,
*,
session_id: str,
process: asyncio.subprocess.Process,
command: str,
cwd: str,
timeout: int,
) -> None:
self.session_id = session_id
self.process = process
self.command = command
self.cwd = cwd
self.started_at = time.monotonic()
self.deadline = time.monotonic() + timeout
self.last_access = time.monotonic()
self._chunks: list[str] = []
@@ -122,6 +139,7 @@ class _ExecSession:
output=output,
done=self.process.returncode is not None,
exit_code=self.process.returncode,
elapsed_s=max(0.0, time.monotonic() - self.started_at),
timed_out=self._timed_out,
terminated=terminated,
stdin_closed=stdin_closed,
@@ -161,7 +179,13 @@ class ExecSessionManager:
raise RuntimeError(f"maximum exec sessions reached ({self.max_sessions})")
process = await self._spawn(command, cwd, env, shell_program, login)
session_id = uuid.uuid4().hex[:12]
session = _ExecSession(session_id=session_id, process=process, timeout=timeout)
session = _ExecSession(
session_id=session_id,
process=process,
command=command,
cwd=cwd,
timeout=timeout,
)
self._sessions[session_id] = session
poll = await session.poll(yield_time_ms, max_output_chars)
@@ -186,7 +210,7 @@ class ExecSessionManager:
if session is None:
raise KeyError(session_id)
if chars is not None:
if chars:
error = await session.write(chars)
if error:
raise RuntimeError(error)
@@ -209,13 +233,29 @@ class ExecSessionManager:
self._sessions.pop(session_id, None)
return poll
async def list(self) -> list[ExecSessionInfo]:
async with self._lock:
await self._cleanup_locked()
now = time.monotonic()
return [
ExecSessionInfo(
session_id=session_id,
command=session.command,
cwd=session.cwd,
elapsed_s=max(0.0, now - session.started_at),
idle_s=max(0.0, now - session.last_access),
remaining_s=max(0.0, session.deadline - now),
returncode=session.process.returncode,
)
for session_id, session in sorted(self._sessions.items())
]
async def _cleanup_locked(self) -> None:
now = time.monotonic()
stale = [
session_id
for session_id, session in self._sessions.items()
if session.process.returncode is not None
or now - session.last_access > self.idle_timeout
if now - session.last_access > self.idle_timeout
]
for session_id in stale:
session = self._sessions.pop(session_id)
@@ -291,6 +331,7 @@ def format_session_poll(session_id: str, poll: _SessionPoll) -> str:
parts.append(f"Exit code: {poll.exit_code}")
else:
parts.append(f"Process running. session_id: {session_id}")
parts.append(f"Elapsed: {poll.elapsed_s:.1f}s")
return "\n".join(parts) if parts else "(no output yet)"
@@ -407,3 +448,68 @@ class WriteStdinTool(Tool):
return f"Error: exec session not found: {session_id}"
except Exception as exc:
return f"Error writing to exec session: {exc}"
@tool_parameters(tool_parameters_schema())
class ListExecSessionsTool(Tool):
"""List active exec sessions."""
_scopes = {"core", "subagent"}
config_key = "exec"
@classmethod
def config_cls(cls):
from nanobot.agent.tools.shell import ExecToolConfig
return ExecToolConfig
@classmethod
def enabled(cls, ctx: Any) -> bool:
return ctx.config.exec.enable
def __init__(
self,
*,
manager: ExecSessionManager | None = None,
) -> None:
self._manager = manager or DEFAULT_EXEC_SESSION_MANAGER
@classmethod
def create(cls, ctx: Any) -> Tool:
return cls()
@property
def name(self) -> str:
return "list_exec_sessions"
@property
def description(self) -> str:
return (
"List active long-running exec sessions, including session_id, cwd, "
"elapsed time, idle time, remaining timeout, and command preview. "
"Use this to recover a session_id before polling with write_stdin."
)
@property
def read_only(self) -> bool:
return True
async def execute(self, **kwargs: Any) -> str:
try:
sessions = await self._manager.list()
if not sessions:
return "No active exec sessions."
lines = []
for info in sessions:
command = " ".join(info.command.split())
if len(command) > 120:
command = command[:119] + "..."
status = "exited" if info.returncode is not None else "running"
lines.append(
f"{info.session_id} | {status} | elapsed={info.elapsed_s:.1f}s "
f"| idle={info.idle_s:.1f}s | remaining={info.remaining_s:.1f}s "
f"| cwd={info.cwd} | {command}"
)
return "\n".join(lines)
except Exception as exc:
return f"Error listing exec sessions: {exc}"
+165 -1
View File
@@ -1,4 +1,4 @@
"""Search tools: grep."""
"""Search tools: file discovery and grep."""
from __future__ import annotations
@@ -12,6 +12,7 @@ from typing import Any, Iterable, TypeVar
from nanobot.agent.tools.filesystem import ListDirTool, _FsTool
_DEFAULT_HEAD_LIMIT = 250
_DEFAULT_FILE_HEAD_LIMIT = 200
T = TypeVar("T")
_TYPE_GLOB_MAP = {
"py": ("*.py", "*.pyi"),
@@ -88,6 +89,14 @@ def _matches_type(name: str, file_type: str | None) -> bool:
return any(fnmatch.fnmatch(name.lower(), pattern.lower()) for pattern in patterns)
def _matches_query(rel_path: str, query: str | None) -> bool:
if not query:
return True
haystack = rel_path.lower()
terms = [part for part in query.lower().split() if part]
return all(term in haystack for term in terms)
class _SearchTool(_FsTool):
_IGNORE_DIRS = set(ListDirTool._IGNORE_DIRS)
@@ -109,6 +118,161 @@ class _SearchTool(_FsTool):
yield current / filename
class FindFilesTool(_SearchTool):
"""Find files by path fragment, glob, or type."""
_scopes = {"core", "subagent"}
@property
def name(self) -> str:
return "find_files"
@property
def description(self) -> str:
return (
"Find files by path fragment, glob, or file type. "
"Use this before read_file when you need to locate files. "
"Returns workspace-relative paths and skips common dependency/build directories."
)
@property
def read_only(self) -> bool:
return True
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Directory or file to search in (default '.')",
},
"query": {
"type": "string",
"description": (
"Optional case-insensitive path fragment search. "
"Whitespace-separated terms must all be present."
),
},
"glob": {
"type": "string",
"description": "Optional file filter, e.g. '*.py' or 'tests/**/test_*.py'",
},
"type": {
"type": "string",
"description": "Optional file type shorthand, e.g. 'py', 'ts', 'md', 'json'",
},
"include_dirs": {
"type": "boolean",
"description": "Include matching directories as well as files (default false)",
},
"sort": {
"type": "string",
"enum": ["path", "modified"],
"description": "Sort by path or most recently modified first (default path)",
},
"head_limit": {
"type": "integer",
"description": "Maximum number of paths to return (default 200, 0 for all, max 1000)",
"minimum": 0,
"maximum": 1000,
},
"offset": {
"type": "integer",
"description": "Skip the first N results before applying head_limit",
"minimum": 0,
"maximum": 100000,
},
},
}
def _iter_paths(self, root: Path, *, include_dirs: bool) -> Iterable[Path]:
if root.is_file():
yield root
return
if include_dirs:
yield root
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if d not in self._IGNORE_DIRS)
current = Path(dirpath)
if include_dirs and current != root:
yield current
for filename in sorted(filenames):
yield current / filename
async def execute(
self,
path: str = ".",
query: str | None = None,
glob: str | None = None,
type: str | None = None,
include_dirs: bool = False,
sort: str = "path",
head_limit: int | None = None,
offset: int = 0,
**kwargs: Any,
) -> str:
try:
target = self._resolve(path or ".")
if not target.exists():
return f"Error: Path not found: {path}"
if not (target.is_dir() or target.is_file()):
return f"Error: Unsupported path: {path}"
if sort not in {"path", "modified"}:
return "Error: sort must be 'path' or 'modified'"
limit = (
_DEFAULT_FILE_HEAD_LIMIT
if head_limit is None
else None if head_limit == 0 else head_limit
)
root = target if target.is_dir() else target.parent
matches: list[tuple[str, float]] = []
for candidate in self._iter_paths(target, include_dirs=include_dirs):
if candidate.is_dir() and not include_dirs:
continue
rel_path = candidate.relative_to(root).as_posix()
display_path = self._display_path(candidate, root)
name = candidate.name
if glob and not _match_glob(rel_path, name, glob):
continue
if candidate.is_file() and not _matches_type(name, type):
continue
if candidate.is_dir() and type:
continue
if not _matches_query(display_path, query):
continue
try:
mtime = candidate.stat().st_mtime
except OSError:
mtime = 0.0
suffix = "/" if candidate.is_dir() else ""
matches.append((display_path + suffix, mtime))
if sort == "modified":
matches.sort(key=lambda item: (-item[1], item[0]))
else:
matches.sort(key=lambda item: item[0])
paths = [item[0] for item in matches]
paged, truncated = _paginate(paths, limit, offset)
if not paged:
return "No files found"
result = "\n".join(paged)
note = _pagination_note(limit, offset, truncated)
if note:
result += "\n\n" + note
return result
except PermissionError as e:
return f"Error: {e}"
except Exception as e:
return f"Error finding files: {e}"
class GrepTool(_SearchTool):
"""Search file contents using a regex-like pattern."""
_scopes = {"core", "subagent"}