mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-12 23:29:55 +00:00
OpenWorker: initial import
Imported from andrewyng/aisuite@1b4bbf303e (contents of its platform/ directory, hoisted to the repo root). Development history prior to this commit lives in that repository. Co-authored-by: Devika <devikaverma11@gmail.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
from .registry import ToolRegistry, ToolSpec
|
||||
|
||||
__all__ = ["ToolRegistry", "ToolSpec"]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""The `ask_user` tool — the agent asks the user a question and waits for the answer.
|
||||
|
||||
The general human-in-the-loop Q&A primitive, modelled on Claude Code's own AskUserQuestion: a
|
||||
question, optional quick-reply `options`, and (by default) an always-available free-text escape —
|
||||
plus `multi` for choose-several. Like `request_directory`, it's intercepted by the TurnEngine: the
|
||||
question becomes an Inbox item (answerable inline in the live session, or from the Inbox when the
|
||||
session runs unattended), the agent suspends until it's resolved, and the answer comes back as the
|
||||
tool result. The callable here is only a schema carrier + a safe fallback.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aisuite.agents import ToolMetadata, tool
|
||||
|
||||
|
||||
def ask_user_tool() -> object:
|
||||
def ask_user(
|
||||
question: str,
|
||||
options: list[str] | None = None,
|
||||
allow_text: bool = True,
|
||||
multi: bool = False,
|
||||
header: str = "",
|
||||
) -> dict:
|
||||
"""Ask the user a question and wait for their answer — use when you genuinely need a human
|
||||
decision or information you can't infer (a preference, a missing fact, a choice between real
|
||||
alternatives). Prefer this over guessing or stalling.
|
||||
|
||||
- `question`: the full question, in plain language.
|
||||
- `options`: optional quick-reply choices. Offer them when the answer is one of a few
|
||||
discrete alternatives; leave empty for an open-ended question.
|
||||
- `allow_text`: keep a free-text answer available even when you give options (the default;
|
||||
this is the "Other / type your own" escape). Set False only when the options are
|
||||
exhaustive and a typed answer would be meaningless.
|
||||
- `multi`: allow the user to pick more than one option.
|
||||
- `header`: a short (≤ ~12 char) label for the Inbox card chip, e.g. "Region".
|
||||
|
||||
Returns `{"answer": "..."}` — the chosen option(s) or the typed text. Don't ask what you can
|
||||
reasonably decide yourself; reserve this for choices that are actually the user's to make.
|
||||
"""
|
||||
# Real handling lives in the engine (it needs the out-of-band Inbox round-trip). This body
|
||||
# only runs if no question_asker is wired (e.g. a headless surface).
|
||||
return {
|
||||
"answer": "",
|
||||
"error": "asking the user isn't available in this surface",
|
||||
}
|
||||
|
||||
return tool(
|
||||
ask_user,
|
||||
metadata=ToolMetadata(
|
||||
category="interaction",
|
||||
risk_level="low",
|
||||
capabilities=["ask_user"],
|
||||
description=(
|
||||
"Ask the user a question (free-text or multiple-choice) and wait for their answer. "
|
||||
"Use for decisions or information only the user can provide."
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""The `request_directory` tool — the agent asks the user to grant access to a folder.
|
||||
|
||||
Unlike ordinary tools, this one is intercepted by the TurnEngine: it emits a DIRECTORY_REQUESTED
|
||||
event and waits for the user to pick/approve a folder out-of-band (the GUI surfaces a prompt),
|
||||
then the live session gains that root and the tool result tells the agent the outcome. The
|
||||
callable here is only a schema carrier + a safe fallback for surfaces without a requester.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aisuite.agents import ToolMetadata, tool
|
||||
|
||||
|
||||
def request_directory_tool() -> object:
|
||||
def request_directory(reason: str, path: str = "", writable: bool = False) -> dict:
|
||||
"""Ask the user for access to a directory when the task needs files outside the current
|
||||
ones (e.g. to read a project the user mentioned, or to save a deliverable somewhere
|
||||
specific). Explain why in `reason`; optionally suggest a `path` and whether you need
|
||||
`writable` access. The user picks/approves the folder; the result says whether it was
|
||||
granted. Do not use this to escape sandboxing — only to serve the user's request.
|
||||
"""
|
||||
# Real handling lives in the engine (it needs the out-of-band GUI round-trip). This body
|
||||
# only runs if no requester is wired (e.g. a headless surface).
|
||||
return {
|
||||
"granted": False,
|
||||
"error": "directory requests aren't available in this surface",
|
||||
}
|
||||
|
||||
return tool(
|
||||
request_directory,
|
||||
metadata=ToolMetadata(
|
||||
category="filesystem",
|
||||
risk_level="low",
|
||||
capabilities=["request_directory"],
|
||||
description=(
|
||||
"Ask the user to grant access to a directory (read-only or read-write) when the "
|
||||
"task needs files outside the directories you already have."
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Line-numbered file reading (`read_file`) — replaces the aisuite toolkit's reader.
|
||||
|
||||
The toolkit's `read_file` returns raw text (the agent can't cite path:line without
|
||||
counting) and raises outright on large files (the agent errors and guesses). This one
|
||||
returns `cat -n`-style numbered lines, windows big files instead of failing, and tells
|
||||
the agent how to continue reading. Read-only, workspace-scoped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
_DEFAULT_MAX_LINES = 2000
|
||||
_MAX_LINE_CHARS = 500
|
||||
|
||||
_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "read_file",
|
||||
"description": (
|
||||
"Read a text file, returning numbered lines (' 12\\ttext') so code can be "
|
||||
"referenced as path:line. Large files are windowed: pass start_line to continue "
|
||||
"where the previous read stopped. Read-only."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path, relative to the workspace.",
|
||||
},
|
||||
"start_line": {
|
||||
"type": "integer",
|
||||
"description": "First line to read, 1-based (default 1).",
|
||||
},
|
||||
"max_lines": {
|
||||
"type": "integer",
|
||||
"description": f"How many lines (default {_DEFAULT_MAX_LINES}).",
|
||||
},
|
||||
},
|
||||
"required": ["path"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def file_tools(workspace: str) -> list:
|
||||
root = Path(workspace).resolve()
|
||||
|
||||
def read_file(
|
||||
path: str,
|
||||
start_line: int = 1,
|
||||
max_lines: int = _DEFAULT_MAX_LINES,
|
||||
) -> dict[str, Any]:
|
||||
start = start_line if isinstance(start_line, int) and start_line > 0 else 1
|
||||
n = (
|
||||
max_lines
|
||||
if isinstance(max_lines, int) and max_lines > 0
|
||||
else _DEFAULT_MAX_LINES
|
||||
)
|
||||
n = min(n, _DEFAULT_MAX_LINES)
|
||||
target = (root / path).resolve()
|
||||
try:
|
||||
target.relative_to(root) # keep reads inside the workspace
|
||||
except ValueError:
|
||||
return {"error": "path escapes the workspace"}
|
||||
if not target.is_file():
|
||||
return {"error": f"not a file: {path}"}
|
||||
|
||||
selected: list[str] = []
|
||||
total = 0
|
||||
try:
|
||||
with open(target, "r", encoding="utf-8", errors="replace") as fh:
|
||||
for i, line in enumerate(fh, 1):
|
||||
total = i
|
||||
if i < start or len(selected) >= n:
|
||||
continue
|
||||
text = line.rstrip("\n")
|
||||
if len(text) > _MAX_LINE_CHARS:
|
||||
text = text[:_MAX_LINE_CHARS] + "… (line truncated)"
|
||||
selected.append(f"{i:>6}\t{text}")
|
||||
except OSError as exc:
|
||||
return {"error": f"read failed: {exc}"}
|
||||
|
||||
end = start + len(selected) - 1 if selected else start - 1
|
||||
result: dict[str, Any] = {
|
||||
"path": str(target.relative_to(root)),
|
||||
"start_line": start,
|
||||
"end_line": end,
|
||||
"total_lines": total,
|
||||
"content": "\n".join(selected),
|
||||
}
|
||||
if end < total:
|
||||
result["note"] = (
|
||||
f"showing lines {start}-{end} of {total}; "
|
||||
f"call again with start_line={end + 1} to continue"
|
||||
)
|
||||
return result
|
||||
|
||||
read_file.__name__ = "read_file"
|
||||
read_file.__doc__ = _SCHEMA["function"]["description"]
|
||||
read_file.__aisuite_tool_metadata__ = ai.ToolMetadata(
|
||||
name="read_file",
|
||||
category="filesystem",
|
||||
risk_level="low",
|
||||
capabilities=["read"],
|
||||
requires_approval=False,
|
||||
)
|
||||
read_file.__coworker_schema__ = _SCHEMA
|
||||
return [read_file]
|
||||
@@ -0,0 +1,90 @@
|
||||
"""`git_log` — recent commit history for context (read-only).
|
||||
|
||||
aisuite's git toolkit gives `git_status`/`git_diff`; this adds history so the agent can see how
|
||||
a file came to be the way it is before changing it. Read-only; no commit/push here (the prompt
|
||||
forbids those without explicit ask, and they'd go through run_shell anyway).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
_SEP = "\x1f"
|
||||
|
||||
_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "git_log",
|
||||
"description": (
|
||||
"Recent git commit history (hash, author, date, subject). Optionally scope to a path. "
|
||||
"Use it to understand how code evolved before editing. Read-only."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Optional file/dir to scope history to.",
|
||||
},
|
||||
"max_count": {
|
||||
"type": "integer",
|
||||
"description": "How many commits (default 20, max 200).",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def git_tools(workspace: str) -> list:
|
||||
root = str(Path(workspace).resolve())
|
||||
|
||||
def git_log(path: Optional[str] = None, max_count: int = 20) -> dict[str, Any]:
|
||||
n = max_count if isinstance(max_count, int) and max_count > 0 else 20
|
||||
n = min(n, 200)
|
||||
cmd = [
|
||||
"git",
|
||||
"-C",
|
||||
root,
|
||||
"log",
|
||||
f"-n{n}",
|
||||
f"--pretty=format:%h{_SEP}%an{_SEP}%ad{_SEP}%s",
|
||||
"--date=short",
|
||||
]
|
||||
if path:
|
||||
cmd += ["--", path]
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
except Exception as exc:
|
||||
return {"error": f"git log failed: {exc}"}
|
||||
if out.returncode != 0:
|
||||
return {"error": (out.stderr or "git log failed").strip()[:300]}
|
||||
commits = []
|
||||
for line in out.stdout.splitlines():
|
||||
parts = line.split(_SEP)
|
||||
if len(parts) == 4:
|
||||
commits.append(
|
||||
{
|
||||
"hash": parts[0],
|
||||
"author": parts[1],
|
||||
"date": parts[2],
|
||||
"subject": parts[3],
|
||||
}
|
||||
)
|
||||
return {"count": len(commits), "commits": commits}
|
||||
|
||||
git_log.__name__ = "git_log"
|
||||
git_log.__doc__ = _SCHEMA["function"]["description"]
|
||||
git_log.__aisuite_tool_metadata__ = ai.ToolMetadata(
|
||||
name="git_log",
|
||||
category="git",
|
||||
risk_level="low",
|
||||
capabilities=["git"],
|
||||
requires_approval=False,
|
||||
)
|
||||
git_log.__coworker_schema__ = _SCHEMA
|
||||
return [git_log]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""The `propose_plan` tool — the agent presents its plan and asks to start executing.
|
||||
|
||||
Registered only when the session starts in plan mode. Like `request_directory`, it is
|
||||
intercepted by the TurnEngine: it emits a PLAN_PROPOSED event and waits for the user's
|
||||
out-of-band decision. Approval flips the live PermissionEngine out of plan mode (same
|
||||
session, full exploration context kept); rejection returns the user's feedback so the
|
||||
agent can revise the plan. The callable here is only a schema carrier + a safe fallback
|
||||
for surfaces without an approver.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aisuite.agents import ToolMetadata, tool
|
||||
|
||||
|
||||
def propose_plan_tool() -> object:
|
||||
def propose_plan(plan: str) -> dict:
|
||||
"""Present your implementation plan to the user for approval. Use this once you
|
||||
have explored enough to commit to an approach: summarize what you'll change, in
|
||||
which files, and how you'll verify it. If approved, the session switches out of
|
||||
read-only plan mode and you implement the plan; if rejected, revise it using the
|
||||
feedback in the result. Don't start describing implementation steps as if you
|
||||
were doing them — propose first.
|
||||
"""
|
||||
# Real handling lives in the engine (it needs the out-of-band approval round-trip).
|
||||
# This body only runs if no approver is wired (e.g. a headless surface).
|
||||
return {
|
||||
"approved": False,
|
||||
"error": "plan approval isn't available in this surface",
|
||||
}
|
||||
|
||||
return tool(
|
||||
propose_plan,
|
||||
metadata=ToolMetadata(
|
||||
category="planning",
|
||||
risk_level="low",
|
||||
capabilities=["plan"],
|
||||
description=(
|
||||
"Present the implementation plan for user approval; approval exits "
|
||||
"read-only plan mode and starts execution."
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tool registry — wraps callables (incl. aisuite toolkit tools) into a registry the
|
||||
runtime owns: JSON schemas for the model, plus execution. Permission checks live in the
|
||||
PermissionEngine and are applied by the turn engine, not here.
|
||||
|
||||
Schema generation is reused from aisuite (`Tools`) so we don't reimplement
|
||||
docstring/type-hint → JSON-schema extraction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from aisuite.utils.tools import Tools
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolSpec:
|
||||
name: str
|
||||
schema: dict[str, Any] # OpenAI-format function tool schema
|
||||
func: Callable[..., Any]
|
||||
metadata: Any = None # aisuite ToolMetadata or None
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._tools: dict[str, ToolSpec] = {}
|
||||
|
||||
def register(
|
||||
self,
|
||||
func: Callable[..., Any],
|
||||
*,
|
||||
metadata: Any = None,
|
||||
schema: Optional[dict[str, Any]] = None,
|
||||
) -> ToolSpec:
|
||||
name = getattr(func, "__name__", None)
|
||||
if not name:
|
||||
raise ValueError("Tool function must have a __name__.")
|
||||
meta = metadata or getattr(func, "__aisuite_tool_metadata__", None)
|
||||
# Allow an explicit schema override (param or a `__coworker_schema__` attribute)
|
||||
# for tools whose signature can't be auto-converted to a valid JSON schema.
|
||||
resolved_schema = (
|
||||
schema or getattr(func, "__coworker_schema__", None) or _schema_for(func)
|
||||
)
|
||||
spec = ToolSpec(name=name, schema=resolved_schema, func=func, metadata=meta)
|
||||
self._tools[name] = spec
|
||||
return spec
|
||||
|
||||
def register_all(self, funcs: list[Callable[..., Any]]) -> None:
|
||||
for func in funcs:
|
||||
self.register(func)
|
||||
|
||||
def names(self) -> list[str]:
|
||||
return list(self._tools)
|
||||
|
||||
def get(self, name: str) -> Optional[ToolSpec]:
|
||||
return self._tools.get(name)
|
||||
|
||||
def schemas(self) -> list[dict[str, Any]]:
|
||||
return [spec.schema for spec in self._tools.values()]
|
||||
|
||||
def execute(self, name: str, arguments: Optional[dict[str, Any]] = None) -> Any:
|
||||
spec = self._tools.get(name)
|
||||
if spec is None:
|
||||
raise KeyError(f"Tool not registered: {name}")
|
||||
return spec.func(**(arguments or {}))
|
||||
|
||||
|
||||
def _schema_for(func: Callable[..., Any]) -> dict[str, Any]:
|
||||
"""Generate one OpenAI-format tool schema via aisuite's schema generator."""
|
||||
return Tools([func]).tools(format="openai")[0]
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Fast code search (`grep`) — ripgrep when available, a Python walk otherwise.
|
||||
|
||||
ripgrep respects `.gitignore`, so it skips `node_modules`/`target`/`dist` automatically; the
|
||||
fallback skips a hardcoded set of heavy dirs. Read-only, workspace-scoped. Returns file:line:text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
_IGNORE_DIRS = {
|
||||
".git",
|
||||
"node_modules",
|
||||
"target",
|
||||
"dist",
|
||||
"build",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
".next",
|
||||
".mypy_cache",
|
||||
".pytest_cache",
|
||||
".ruff_cache",
|
||||
".idea",
|
||||
}
|
||||
|
||||
_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "grep",
|
||||
"description": (
|
||||
"Search the workspace for a regular-expression pattern and return matching lines as "
|
||||
"file:line:text. Fast and .gitignore-aware (skips node_modules, build dirs, etc.). "
|
||||
"Prefer this over reading files blindly to locate code. Read-only."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Regular expression to search for.",
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Subdirectory to search (default: whole workspace).",
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "Optional filename glob filter, e.g. '*.py'.",
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Max matches (default 100, max 1000).",
|
||||
},
|
||||
},
|
||||
"required": ["pattern"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def search_tools(workspace: str) -> list:
|
||||
root = Path(workspace).resolve()
|
||||
|
||||
def grep(
|
||||
pattern: str,
|
||||
path: str = ".",
|
||||
glob: Optional[str] = None,
|
||||
max_results: int = 100,
|
||||
) -> dict[str, Any]:
|
||||
n = max_results if isinstance(max_results, int) and max_results > 0 else 100
|
||||
n = min(n, 1000)
|
||||
base = (root / (path or ".")).resolve()
|
||||
try:
|
||||
base.relative_to(root) # keep searches inside the workspace
|
||||
except ValueError:
|
||||
return {"error": "path escapes the workspace"}
|
||||
|
||||
rg = shutil.which("rg")
|
||||
if rg:
|
||||
cmd = [
|
||||
rg,
|
||||
"--line-number",
|
||||
"--no-heading",
|
||||
"--color=never",
|
||||
"--max-count",
|
||||
str(n),
|
||||
"-e",
|
||||
pattern,
|
||||
]
|
||||
if glob:
|
||||
cmd += ["--glob", glob]
|
||||
cmd.append(str(base))
|
||||
try:
|
||||
out = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
except Exception as exc:
|
||||
return {"error": f"grep failed: {exc}"}
|
||||
if out.returncode not in (0, 1): # 1 = no matches
|
||||
return {"error": (out.stderr or "ripgrep error").strip()[:300]}
|
||||
return {"engine": "ripgrep", **_parse_rg(out.stdout, root, n)}
|
||||
|
||||
return {"engine": "python", **_py_grep(root, base, pattern, glob, n)}
|
||||
|
||||
grep.__name__ = "grep"
|
||||
grep.__doc__ = _SCHEMA["function"]["description"]
|
||||
grep.__aisuite_tool_metadata__ = ai.ToolMetadata(
|
||||
name="grep",
|
||||
category="search",
|
||||
risk_level="low",
|
||||
capabilities=["search"],
|
||||
requires_approval=False,
|
||||
)
|
||||
grep.__coworker_schema__ = _SCHEMA
|
||||
return [grep]
|
||||
|
||||
|
||||
def _rel(path: str, root: Path) -> str:
|
||||
try:
|
||||
return str(Path(path).resolve().relative_to(root))
|
||||
except (ValueError, OSError):
|
||||
return path
|
||||
|
||||
|
||||
def _parse_rg(stdout: str, root: Path, n: int) -> dict[str, Any]:
|
||||
matches: list[dict[str, Any]] = []
|
||||
for line in stdout.splitlines():
|
||||
parts = line.split(":", 2)
|
||||
if len(parts) == 3:
|
||||
f, ln, txt = parts
|
||||
matches.append(
|
||||
{
|
||||
"file": _rel(f, root),
|
||||
"line": int(ln) if ln.isdigit() else 0,
|
||||
"text": txt[:300],
|
||||
}
|
||||
)
|
||||
if len(matches) >= n:
|
||||
break
|
||||
return {"count": len(matches), "matches": matches}
|
||||
|
||||
|
||||
def _py_grep(
|
||||
root: Path, base: Path, pattern: str, glob: Optional[str], n: int
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
rx = re.compile(pattern)
|
||||
except re.error as exc:
|
||||
return {"error": f"invalid regex: {exc}", "count": 0, "matches": []}
|
||||
matches: list[dict[str, Any]] = []
|
||||
for dirpath, dirs, files in os.walk(base):
|
||||
dirs[:] = [d for d in dirs if d not in _IGNORE_DIRS]
|
||||
for fn in files:
|
||||
if glob and not fnmatch.fnmatch(fn, glob):
|
||||
continue
|
||||
fp = Path(dirpath) / fn
|
||||
try:
|
||||
with open(fp, "r", encoding="utf-8", errors="ignore") as fh:
|
||||
for i, line in enumerate(fh, 1):
|
||||
if rx.search(line):
|
||||
matches.append(
|
||||
{
|
||||
"file": _rel(str(fp), root),
|
||||
"line": i,
|
||||
"text": line.rstrip()[:300],
|
||||
}
|
||||
)
|
||||
if len(matches) >= n:
|
||||
return {"count": len(matches), "matches": matches}
|
||||
except OSError:
|
||||
continue
|
||||
return {"count": len(matches), "matches": matches}
|
||||
@@ -0,0 +1,568 @@
|
||||
"""Persistent shell behind an `Executor` boundary.
|
||||
|
||||
`LocalExecutor` keeps one long-lived shell process, so `cd`, `export`, activated venvs,
|
||||
etc. persist across `run_shell` calls (unlike a per-call `subprocess.run`). The `Executor`
|
||||
interface is the hedge for a future `ContainerExecutor`/`VMExecutor` (sandboxing) without
|
||||
touching the engine.
|
||||
|
||||
The shell is OS-native: `/bin/bash` on POSIX, `powershell.exe` (`-Command -` REPL) on
|
||||
Windows. Each backend has its own marker/exit-code protocol and interrupt mechanism, but
|
||||
the `Executor` contract (and the parsed `{marker} {exit_code} {cwd}` trailer) is identical.
|
||||
|
||||
Safety here is permission-gating (high-risk tool → approval) + per-command timeout +
|
||||
best-effort non-interactive enforcement. A timed-out command is interrupted (SIGINT to the
|
||||
foreground child on POSIX, Ctrl-Break to the child group on Windows); the shell survives so
|
||||
session state is preserved.
|
||||
|
||||
Background tasks (`run_shell` with `run_in_background`) get their own detached process —
|
||||
NOT the persistent shell — so a dev server can run while the session keeps working. They
|
||||
are deliberately not killed by `close()` (which the timeout-recovery path calls); they end
|
||||
when they exit or via `shell_task_kill`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import queue
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
_IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
# Foreground timeout bounds: long enough for installs/builds/test runs by default, capped so
|
||||
# a model-requested timeout can't wedge the turn for more than ten minutes.
|
||||
_DEFAULT_TIMEOUT = 120.0
|
||||
_MAX_TIMEOUT = 600.0
|
||||
|
||||
# Env defaults that discourage commands from blocking on a prompt.
|
||||
_NONINTERACTIVE_ENV = {
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"DEBIAN_FRONTEND": "noninteractive",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
"PIP_NO_INPUT": "1",
|
||||
}
|
||||
|
||||
|
||||
class Executor(ABC):
|
||||
@abstractmethod
|
||||
def run(self, command: str, timeout: Optional[float] = None) -> dict[str, Any]: ...
|
||||
|
||||
def run_background(self, command: str) -> dict[str, Any]:
|
||||
return {"error": "background execution is not supported by this executor"}
|
||||
|
||||
def background_output(self, task_id: str) -> dict[str, Any]:
|
||||
return {"error": "background execution is not supported by this executor"}
|
||||
|
||||
def background_kill(self, task_id: str) -> dict[str, Any]:
|
||||
return {"error": "background execution is not supported by this executor"}
|
||||
|
||||
def interrupt(self) -> None: # pragma: no cover - default no-op
|
||||
pass
|
||||
|
||||
def close(self) -> None: # pragma: no cover - default no-op
|
||||
pass
|
||||
|
||||
|
||||
class _BackgroundTask:
|
||||
"""One detached background command: its own process (not the persistent shell), a
|
||||
reader thread draining output into a buffer, and an incremental-read cursor."""
|
||||
|
||||
def __init__(self, task_id: str, command: str, cwd: str, env: dict[str, str]):
|
||||
self.id = task_id
|
||||
self.command = command
|
||||
if _IS_WINDOWS:
|
||||
argv = ["powershell.exe", "-NoProfile", "-Command", command]
|
||||
spawn_kwargs: dict[str, Any] = {
|
||||
"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
}
|
||||
else:
|
||||
argv = ["/bin/bash", "-c", command]
|
||||
spawn_kwargs = {"start_new_session": True}
|
||||
self.proc = subprocess.Popen(
|
||||
argv,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=cwd,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=env,
|
||||
**spawn_kwargs,
|
||||
)
|
||||
self._lock = threading.Lock()
|
||||
self._lines: list[str] = []
|
||||
self._cursor = 0
|
||||
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
||||
self._reader.start()
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
assert self.proc.stdout is not None
|
||||
for line in self.proc.stdout:
|
||||
with self._lock:
|
||||
self._lines.append(line)
|
||||
|
||||
def read_new(self) -> str:
|
||||
with self._lock:
|
||||
new = "".join(self._lines[self._cursor :])
|
||||
self._cursor = len(self._lines)
|
||||
return new
|
||||
|
||||
def kill(self) -> None:
|
||||
if self.proc.poll() is not None:
|
||||
return
|
||||
if _IS_WINDOWS:
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(self.proc.pid)],
|
||||
capture_output=True,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
return
|
||||
try:
|
||||
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
class LocalExecutor(Executor):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
cwd: str | Path,
|
||||
env: Optional[dict[str, str]] = None,
|
||||
shell_path: Optional[str] = None,
|
||||
default_timeout: float = _DEFAULT_TIMEOUT,
|
||||
max_output_chars: int = 20_000,
|
||||
) -> None:
|
||||
self.cwd = str(Path(cwd).expanduser().resolve())
|
||||
self.default_timeout = default_timeout
|
||||
self.max_output_chars = max_output_chars
|
||||
self._marker = f"__COWORKER_DONE_{uuid.uuid4().hex}__"
|
||||
self._is_windows = _IS_WINDOWS
|
||||
self._bg_tasks: dict[str, _BackgroundTask] = {}
|
||||
self._bg_counter = 0
|
||||
|
||||
# Pick a native shell per-OS. POSIX drives bash line-by-line; Windows drives
|
||||
# PowerShell in `-Command -` mode, which is a true stdin REPL (executes
|
||||
# incrementally, and cwd/env persist across commands).
|
||||
if shell_path is None:
|
||||
shell_path = "powershell.exe" if self._is_windows else "/bin/bash"
|
||||
self._shell_path = shell_path
|
||||
self._env = {**os.environ, **_NONINTERACTIVE_ENV, **(env or {})}
|
||||
self._spawn()
|
||||
|
||||
def _spawn(self) -> None:
|
||||
"""Start (or restart) the shell process and its reader. Reused for self-healing:
|
||||
if a command times out and the shell is hard-closed, the next `run` respawns here
|
||||
in the last known `cwd` (in-shell env/vars are lost, but the session continues).
|
||||
"""
|
||||
if self._is_windows:
|
||||
argv = [
|
||||
self._shell_path,
|
||||
"-NoProfile",
|
||||
"-NoLogo",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"-",
|
||||
]
|
||||
# New process group so a timeout can deliver Ctrl-Break to the child (and only
|
||||
# the child), without signaling our own process.
|
||||
spawn_kwargs: dict[str, Any] = {
|
||||
"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
}
|
||||
else:
|
||||
argv = [self._shell_path]
|
||||
spawn_kwargs = {"start_new_session": True}
|
||||
|
||||
self._proc = subprocess.Popen(
|
||||
argv,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=self.cwd,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
env=self._env,
|
||||
**spawn_kwargs,
|
||||
)
|
||||
self._queue: "queue.Queue[Optional[str]]" = queue.Queue()
|
||||
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
||||
self._reader.start()
|
||||
|
||||
if self._is_windows and self._proc.stdin is not None:
|
||||
# Silence the REPL prompt so it never pollutes captured command output.
|
||||
self._proc.stdin.write("function prompt { '' }\n")
|
||||
self._proc.stdin.flush()
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
try:
|
||||
assert self._proc.stdout is not None
|
||||
for line in self._proc.stdout:
|
||||
self._queue.put(line)
|
||||
finally:
|
||||
self._queue.put(None) # EOF sentinel
|
||||
|
||||
def run(self, command: str, timeout: Optional[float] = None) -> dict[str, Any]:
|
||||
if self._proc.poll() is not None:
|
||||
# Shell exited (e.g. hard-closed after a prior command's timeout). Respawn so
|
||||
# the session self-heals rather than wedging every future command.
|
||||
self._spawn()
|
||||
if self._proc.stdin is None:
|
||||
return self._result(
|
||||
command, None, "", timed_out=False, error="shell not running"
|
||||
)
|
||||
|
||||
timeout = timeout or self.default_timeout
|
||||
# Run the command, then emit a marker line with exit code + cwd.
|
||||
self._proc.stdin.write(command + "\n")
|
||||
self._proc.stdin.write(self._trailer())
|
||||
self._proc.stdin.flush()
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
interrupted = False
|
||||
timed_out = False
|
||||
exit_code: Optional[int] = None
|
||||
lines: list[str] = []
|
||||
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
if self._is_windows:
|
||||
# PowerShell has no reliable "interrupt one command, keep the REPL"
|
||||
# primitive, so don't try to resync — kill the shell tree decisively.
|
||||
# The next run() respawns in the last cwd (session continues).
|
||||
timed_out = True
|
||||
self.close()
|
||||
break
|
||||
if not interrupted:
|
||||
# First deadline: interrupt the running command and keep reading
|
||||
# until ITS marker arrives, so the stream stays in sync for the
|
||||
# next command. SIGINT makes the command exit and the trailer
|
||||
# printf emit the marker.
|
||||
interrupted = True
|
||||
timed_out = True
|
||||
self._interrupt()
|
||||
deadline = time.monotonic() + 3.0 # grace to resync on the marker
|
||||
continue
|
||||
# Grace expired and still no marker: the shell is wedged. Hard-kill
|
||||
# so future commands don't desync (session state is lost).
|
||||
self.close()
|
||||
break
|
||||
try:
|
||||
item = self._queue.get(timeout=min(remaining, 0.5))
|
||||
except queue.Empty:
|
||||
continue
|
||||
if item is None:
|
||||
break # shell died
|
||||
if self._marker in item:
|
||||
exit_code = _parse_exit_code(item, self._marker)
|
||||
cwd = _parse_cwd(item, self._marker)
|
||||
if cwd:
|
||||
self.cwd = cwd
|
||||
break
|
||||
lines.append(item)
|
||||
|
||||
output = "".join(lines)
|
||||
truncated = len(output) > self.max_output_chars
|
||||
if truncated:
|
||||
# Keep the TAIL: builds and test runners put the verdict at the end.
|
||||
output = output[-self.max_output_chars :]
|
||||
return self._result(
|
||||
command, exit_code, output, timed_out=timed_out, truncated=truncated
|
||||
)
|
||||
|
||||
# -- background tasks ---------------------------------------------------------
|
||||
def run_background(self, command: str) -> dict[str, Any]:
|
||||
self._bg_counter += 1
|
||||
task_id = f"bg-{self._bg_counter}"
|
||||
try:
|
||||
task = _BackgroundTask(task_id, command, self.cwd, self._env)
|
||||
except OSError as exc:
|
||||
return {"error": f"failed to start background task: {exc}"}
|
||||
self._bg_tasks[task_id] = task
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"command": command,
|
||||
"status": "running",
|
||||
"note": "use shell_task_output to read its output, shell_task_kill to stop it",
|
||||
}
|
||||
|
||||
def background_output(self, task_id: str) -> dict[str, Any]:
|
||||
task = self._bg_tasks.get(task_id)
|
||||
if task is None:
|
||||
return {"error": f"unknown task: {task_id}"}
|
||||
output = task.read_new()
|
||||
truncated = len(output) > self.max_output_chars
|
||||
if truncated:
|
||||
output = output[-self.max_output_chars :]
|
||||
exit_code = task.proc.poll()
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "running" if exit_code is None else "exited",
|
||||
"exit_code": exit_code,
|
||||
"output": output,
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
def background_kill(self, task_id: str) -> dict[str, Any]:
|
||||
task = self._bg_tasks.get(task_id)
|
||||
if task is None:
|
||||
return {"error": f"unknown task: {task_id}"}
|
||||
task.kill()
|
||||
try:
|
||||
task.proc.wait(timeout=5)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "running" if task.proc.poll() is None else "killed",
|
||||
"exit_code": task.proc.poll(),
|
||||
}
|
||||
|
||||
def _trailer(self) -> str:
|
||||
"""Command appended after each user command. Emits one line `<marker> <exit> <cwd>`
|
||||
parsed by `_parse_exit_code` / `_parse_cwd`. Reads the exit status of the *preceding*
|
||||
command, so it must run as its own statement right after it."""
|
||||
if self._is_windows:
|
||||
# PowerShell: `$?` is the success bool; `$LASTEXITCODE` is the exit code of the
|
||||
# last native program. Success → 0; else the program's code, falling back to 1.
|
||||
return (
|
||||
f'"`n{self._marker} '
|
||||
f"$(if ($?) {{0}} else {{ if ($LASTEXITCODE) {{$LASTEXITCODE}} else {{1}} }}) "
|
||||
f'$($PWD.Path)"\n'
|
||||
)
|
||||
return f'printf "\\n%s %s %s\\n" "{self._marker}" "$?" "$PWD"\n'
|
||||
|
||||
def _interrupt(self) -> None:
|
||||
# Interrupt the running command, not the shell itself, so the session survives; the
|
||||
# queued trailer then emits the marker and the stream resyncs.
|
||||
if self._is_windows:
|
||||
# Ctrl-Break to the child's process group (best-effort). If the marker never
|
||||
# resyncs, run()'s grace timeout hard-closes the shell.
|
||||
try:
|
||||
self._proc.send_signal(signal.CTRL_BREAK_EVENT)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return
|
||||
try:
|
||||
found = subprocess.run(
|
||||
["pgrep", "-P", str(self._proc.pid)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
for pid in found.stdout.split():
|
||||
try:
|
||||
os.kill(int(pid), signal.SIGINT)
|
||||
except (ProcessLookupError, ValueError, OSError):
|
||||
pass
|
||||
except (FileNotFoundError, OSError):
|
||||
pass
|
||||
|
||||
def interrupt(self) -> None:
|
||||
self._interrupt()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._is_windows:
|
||||
# Kill the whole tree — a timed-out command may have spawned children that
|
||||
# `terminate()` (the shell only) would orphan. Then reap so `poll()` reliably
|
||||
# reports the exit, which the next run()'s respawn check depends on.
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(self._proc.pid)],
|
||||
capture_output=True,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
try:
|
||||
self._proc.wait(timeout=5)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
return
|
||||
try:
|
||||
self._proc.terminate()
|
||||
except (ProcessLookupError, OSError):
|
||||
pass
|
||||
|
||||
def _result(
|
||||
self, command, exit_code, output, *, timed_out, truncated=False, error=None
|
||||
):
|
||||
result = {
|
||||
"command": command,
|
||||
"cwd": self.cwd,
|
||||
"exit_code": exit_code,
|
||||
"output": output,
|
||||
"timed_out": timed_out,
|
||||
"truncated": truncated,
|
||||
}
|
||||
if error:
|
||||
result["error"] = error
|
||||
return result
|
||||
|
||||
|
||||
def _parse_exit_code(line: str, marker: str) -> Optional[int]:
|
||||
parts = line.strip().split()
|
||||
try:
|
||||
return int(parts[parts.index(marker) + 1])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def _parse_cwd(line: str, marker: str) -> Optional[str]:
|
||||
parts = line.strip().split()
|
||||
try:
|
||||
return " ".join(parts[parts.index(marker) + 2 :]) or None
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
_RUN_SHELL_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_shell",
|
||||
"description": (
|
||||
"Run a shell command in the persistent session (cwd and env persist across "
|
||||
"calls). Output longer than the limit keeps the END (where test/build verdicts "
|
||||
"are). Set run_in_background for long-running processes like dev servers, then "
|
||||
"poll with shell_task_output."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The command to run.",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Short human-readable summary of what the command does (e.g. "
|
||||
"'Install dependencies'), shown in approval prompts and logs."
|
||||
),
|
||||
},
|
||||
"timeout_seconds": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
f"Max seconds to wait (default {int(_DEFAULT_TIMEOUT)}, "
|
||||
f"max {int(_MAX_TIMEOUT)}). Ignored for background tasks."
|
||||
),
|
||||
},
|
||||
"run_in_background": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Run detached and return a task_id immediately instead of waiting. "
|
||||
"Use for servers, watchers, and very long builds."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_TASK_OUTPUT_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "shell_task_output",
|
||||
"description": (
|
||||
"Read NEW output (since the last read) from a background task started with "
|
||||
"run_shell run_in_background=true, plus its status and exit code."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "The task_id returned by run_shell.",
|
||||
}
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_TASK_KILL_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "shell_task_kill",
|
||||
"description": "Stop a background task started with run_shell run_in_background=true.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"type": "string",
|
||||
"description": "The task_id returned by run_shell.",
|
||||
}
|
||||
},
|
||||
"required": ["task_id"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def shell_tools(executor: Executor) -> list:
|
||||
"""Return the shell tools (`run_shell` + background-task helpers) bound to a
|
||||
persistent executor."""
|
||||
|
||||
def run_shell(
|
||||
command: str,
|
||||
description: Optional[str] = None,
|
||||
timeout_seconds: Optional[int] = None,
|
||||
run_in_background: bool = False,
|
||||
) -> dict:
|
||||
# `description` is not used here on purpose: it rides along in the call arguments
|
||||
# so approval prompts and the audit log can show intent, not just the raw command.
|
||||
if run_in_background:
|
||||
return executor.run_background(command)
|
||||
timeout = None
|
||||
if isinstance(timeout_seconds, (int, float)) and timeout_seconds > 0:
|
||||
timeout = min(float(timeout_seconds), _MAX_TIMEOUT)
|
||||
return executor.run(command, timeout=timeout)
|
||||
|
||||
def shell_task_output(task_id: str) -> dict:
|
||||
return executor.background_output(task_id)
|
||||
|
||||
def shell_task_kill(task_id: str) -> dict:
|
||||
return executor.background_kill(task_id)
|
||||
|
||||
wrapped_run = ai.tool(
|
||||
run_shell,
|
||||
metadata=ai.ToolMetadata(
|
||||
category="shell",
|
||||
risk_level="high",
|
||||
capabilities=["run_command"],
|
||||
requires_approval=True,
|
||||
),
|
||||
)
|
||||
wrapped_run.__coworker_schema__ = _RUN_SHELL_SCHEMA
|
||||
wrapped_output = ai.tool(
|
||||
shell_task_output,
|
||||
metadata=ai.ToolMetadata(
|
||||
category="shell",
|
||||
risk_level="low",
|
||||
capabilities=["run_command"],
|
||||
requires_approval=False,
|
||||
),
|
||||
)
|
||||
wrapped_output.__coworker_schema__ = _TASK_OUTPUT_SCHEMA
|
||||
wrapped_kill = ai.tool(
|
||||
shell_task_kill,
|
||||
metadata=ai.ToolMetadata(
|
||||
category="shell",
|
||||
risk_level="low",
|
||||
capabilities=["run_command"],
|
||||
requires_approval=False,
|
||||
),
|
||||
)
|
||||
wrapped_kill.__coworker_schema__ = _TASK_KILL_SCHEMA
|
||||
return [wrapped_run, wrapped_output, wrapped_kill]
|
||||
@@ -0,0 +1,138 @@
|
||||
"""The `explore` tool — a read-only research subagent with its own context window.
|
||||
|
||||
Broad questions ("where is retry logic handled?") burn the main session's context on
|
||||
dozens of file reads. `explore` spawns a child TurnEngine over the same workspace with
|
||||
read-only tools and a fresh context; only its final report returns to the caller.
|
||||
|
||||
The child runs in plan mode — the PermissionEngine hard-blocks writes/shell no matter
|
||||
what the child decides — with no approver, so it never needs an approval round-trip.
|
||||
That's what lets `explore` carry low-risk metadata, which in turn makes several explores
|
||||
in one assistant turn eligible for the engine's parallel execution. No recursion: the
|
||||
child registry has no `explore` tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
from ..engine import TurnEngine
|
||||
from ..events import EventType
|
||||
from ..permissions import Mode, PermissionEngine
|
||||
from ..tools import ToolRegistry
|
||||
from .files import file_tools
|
||||
from .git import git_tools
|
||||
from .search import search_tools
|
||||
|
||||
EXPLORER_INSTRUCTIONS = """You are a read-only code explorer working inside the user's workspace. \
|
||||
Answer the research task you're given by searching and reading the code (`grep`, `read_file`, \
|
||||
`list_files`, `git_log`, `git_status`, `git_diff`). You cannot write files or run commands.
|
||||
|
||||
Your final message is your report — it goes back to the agent that spawned you, not to the \
|
||||
user. Make it self-contained: answer the task directly, reference code as path:line, quote the \
|
||||
key snippets, and note anything surprising you found along the way. If you couldn't find \
|
||||
something, say what you searched so the caller doesn't repeat the same searches."""
|
||||
|
||||
_CHILD_MAX_ITERATIONS = 10
|
||||
|
||||
|
||||
def build_explorer_engine(
|
||||
*,
|
||||
workspace: str | Path,
|
||||
provider: Any,
|
||||
model: str,
|
||||
model_settings: Optional[dict[str, Any]] = None,
|
||||
max_iterations: int = _CHILD_MAX_ITERATIONS,
|
||||
) -> TurnEngine:
|
||||
"""A child engine with the Code agent's read-only tools and a fresh context."""
|
||||
ws = str(Path(workspace).resolve())
|
||||
registry = ToolRegistry()
|
||||
# Read-only slice of the Code agent's toolset, with the same toolkit replacements
|
||||
# (our grep for search_files, our windowed read_file for read_file/read_file_lines).
|
||||
replaced = {"search_files", "read_file", "read_file_lines"}
|
||||
registry.register_all(
|
||||
[
|
||||
t
|
||||
for t in ai.toolkits.files(root=ws) # no allow_write → list/read only
|
||||
if getattr(t, "__name__", "") not in replaced
|
||||
]
|
||||
)
|
||||
registry.register_all(file_tools(ws))
|
||||
registry.register_all(ai.toolkits.git(root=ws)) # git_status, git_diff
|
||||
registry.register_all(git_tools(ws)) # git_log
|
||||
registry.register_all(search_tools(ws)) # grep
|
||||
permissions = PermissionEngine(workspace_root=Path(ws), mode=Mode.PLAN)
|
||||
return TurnEngine(
|
||||
provider=provider,
|
||||
registry=registry,
|
||||
permissions=permissions,
|
||||
model=model,
|
||||
instructions=EXPLORER_INSTRUCTIONS,
|
||||
max_iterations=max_iterations,
|
||||
model_settings=model_settings,
|
||||
)
|
||||
|
||||
|
||||
def explorer_tools(
|
||||
*,
|
||||
workspace: str | Path,
|
||||
provider: Any,
|
||||
model: str,
|
||||
model_settings: Optional[dict[str, Any]] = None,
|
||||
) -> list:
|
||||
def explore(task: str) -> dict:
|
||||
"""Delegate a broad, read-only research task to a subagent with its own fresh
|
||||
context window. It searches and reads the workspace, then returns only its final
|
||||
report — the intermediate file reads never touch your context. Use it for
|
||||
multi-file questions ("where is X handled?", "how does the Y flow work?"); for a
|
||||
single known file, just read it yourself. Independent explore calls run in
|
||||
parallel when requested together. State the task precisely and say what the
|
||||
report should include.
|
||||
|
||||
Args:
|
||||
task (str): The research question, with any constraints and the expected
|
||||
shape of the report.
|
||||
"""
|
||||
engine = build_explorer_engine(
|
||||
workspace=workspace,
|
||||
provider=provider,
|
||||
model=model,
|
||||
model_settings=model_settings,
|
||||
)
|
||||
|
||||
async def _run() -> tuple[str, str]:
|
||||
report, status = "", "unknown"
|
||||
async for event in engine.run(task):
|
||||
if event.type == EventType.ASSISTANT_MESSAGE and event.data.get("text"):
|
||||
report = event.data["text"]
|
||||
elif event.type == EventType.TURN_END:
|
||||
status = event.data.get("status", "unknown")
|
||||
elif event.type == EventType.ERROR:
|
||||
return report, f"error: {event.data.get('error', '')}"
|
||||
return report, status
|
||||
|
||||
# Tools execute in a worker thread (no running loop), so asyncio.run is safe.
|
||||
report, status = asyncio.run(_run())
|
||||
if not report:
|
||||
return {"error": f"explorer produced no report (status: {status})"}
|
||||
result: dict[str, Any] = {"report": report}
|
||||
if status != "completed":
|
||||
result["note"] = (
|
||||
f"explorer stopped early ({status}); the report may be partial"
|
||||
)
|
||||
return result
|
||||
|
||||
return [
|
||||
ai.tool(
|
||||
explore,
|
||||
metadata=ai.ToolMetadata(
|
||||
category="search",
|
||||
risk_level="low",
|
||||
capabilities=["search"],
|
||||
requires_approval=False,
|
||||
),
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Todo / plan tool — a structured task list the agent maintains and the UI renders.
|
||||
|
||||
Most of the "organized agent" feel in interactive work. Low risk, auto-approved. The list
|
||||
is held in a `TodoList` the surface can read; `todo_write` replaces it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import aisuite as ai
|
||||
|
||||
_STATUSES = {"pending", "in_progress", "done"}
|
||||
|
||||
# Explicit schema — the array-of-objects shape can't be auto-generated reliably, and
|
||||
# providers reject a bare `list` annotation. Registered via `__coworker_schema__`.
|
||||
_TODO_SCHEMA = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "todo_write",
|
||||
"description": "Replace the task list. Provide the full list of items each call.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string"},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["pending", "in_progress", "done"],
|
||||
},
|
||||
},
|
||||
"required": ["content", "status"],
|
||||
},
|
||||
}
|
||||
},
|
||||
"required": ["items"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class TodoList:
|
||||
items: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def todo_tools(todo: TodoList) -> list:
|
||||
def todo_write(items: list) -> dict:
|
||||
"""Replace the task list. Each item is an object with `content` and a `status`
|
||||
of pending, in_progress, or done."""
|
||||
normalized = []
|
||||
for entry in items or []:
|
||||
if isinstance(entry, dict):
|
||||
status = entry.get("status", "pending")
|
||||
if status == "completed": # common model alias for our "done"
|
||||
status = "done"
|
||||
normalized.append(
|
||||
{
|
||||
"content": str(entry.get("content", "")),
|
||||
"status": status if status in _STATUSES else "pending",
|
||||
}
|
||||
)
|
||||
else:
|
||||
normalized.append({"content": str(entry), "status": "pending"})
|
||||
todo.items = normalized
|
||||
return {"count": len(normalized), "items": normalized}
|
||||
|
||||
wrapped = ai.tool(
|
||||
todo_write,
|
||||
metadata=ai.ToolMetadata(
|
||||
category="planning",
|
||||
risk_level="low",
|
||||
capabilities=["todo"],
|
||||
),
|
||||
)
|
||||
wrapped.__coworker_schema__ = _TODO_SCHEMA
|
||||
return [wrapped]
|
||||
Reference in New Issue
Block a user