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:
Rohit C Prasad
2026-07-21 11:09:41 -07:00
co-authored by Devika
commit 2b45018ffa
413 changed files with 93539 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
from .registry import ToolRegistry, ToolSpec
__all__ = ["ToolRegistry", "ToolSpec"]
+58
View File
@@ -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."
),
),
)
+40
View File
@@ -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."
),
),
)
+113
View File
@@ -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]
+90
View 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]
+43
View File
@@ -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."
),
),
)
+71
View File
@@ -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]
+179
View File
@@ -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}
+568
View File
@@ -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]
+138
View File
@@ -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,
),
)
]
+81
View File
@@ -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]