feat: OpenMesh 基础平台与 MD/PDF 转换技能
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled

- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理
- 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类)
- 技能: md-to-office (pandoc + wkhtmltopdf)
- 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/
- 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
2026-09-13 23:41:04 +08:00
commit 6f402ffcee
638 changed files with 154534 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from .registry import ToolRegistry, ToolSpec
__all__ = ["ToolRegistry", "ToolSpec"]

263
coworker/tools/ask.py Normal file
View File

@@ -0,0 +1,263 @@
"""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.
OPE-51 upgrades: options may be rich objects ({label, description, recommended, preview}) instead
of plain strings, and `questions` groups up to 4 questions into ONE call (rendered as a stepper —
one agent round-trip instead of several). Plain-string options and the singular `question` form
stay valid: old sessions and simple asks render exactly as before.
"""
from __future__ import annotations
import json
from aisuite.agents import ToolMetadata, tool
# How many questions one grouped call may carry (stepper chips get unreadable past this).
MAX_GROUPED_QUESTIONS = 4
# An option is a plain string OR a rich object. `label` is what the user picks (and what comes
# back as the answer); `description` renders under it; `recommended` adds the green tag (put the
# recommended option first); `preview` is monospace text shown in the side pane (code, config,
# ASCII mockups, SQL — any text; when ≥1 option has one the card switches to two-pane layout).
_OPTION_SCHEMA = {
"anyOf": [
{"type": "string"},
{
"type": "object",
"properties": {
"label": {"type": "string"},
"description": {"type": "string"},
"recommended": {"type": "boolean"},
"preview": {"type": "string"},
},
"required": ["label"],
},
]
}
# Explicit schema (same pattern as todo.py): the string-or-object option union and the nested
# `questions` array can't be auto-generated from the signature reliably.
_ASK_SCHEMA = {
"type": "function",
"function": {
"name": "ask_user",
"description": (
"Ask the user one or more questions and wait for their answer. Use for decisions or "
"information only the user can provide. Do not use it to ask permission for a "
"specific action you are about to take — propose the action instead; the approval "
"flow shows the user exactly what would run and does the asking. Group related "
f"questions (up to {MAX_GROUPED_QUESTIONS}) into one call via `questions` instead "
"of asking serially."
),
"parameters": {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The full question, in plain language (single-question form).",
},
"options": {
"type": "array",
"items": _OPTION_SCHEMA,
"description": (
"Optional quick-reply choices: plain strings, or objects with `label` "
"(required — this is the answer value), `description` (why/when to pick "
"it), `recommended` (green tag; list that option first), and `preview` "
"(monospace text — code, config, a mockup — shown in a side pane)."
),
},
"allow_text": {
"type": "boolean",
"description": (
"Keep a free-text answer available even when options exist (default true; "
"the \"Other / type your own\" escape). Set false only when the options "
"are exhaustive."
),
},
"multi": {
"type": "boolean",
"description": "Allow the user to pick more than one option.",
},
"header": {
"type": "string",
"description": "Short (≤ ~12 char) chip label for the card, e.g. \"Region\".",
},
"questions": {
"type": "array",
"maxItems": MAX_GROUPED_QUESTIONS,
"items": {
"type": "object",
"properties": {
"question": {"type": "string"},
"header": {
"type": "string",
"description": (
"Short (≤ ~12 char) label — names this step in the stepper "
"chips and keys its answer in the result."
),
},
"options": {"type": "array", "items": _OPTION_SCHEMA},
"allow_text": {"type": "boolean"},
"multi": {"type": "boolean"},
},
"required": ["question"],
},
"description": (
f"Grouped form: up to {MAX_GROUPED_QUESTIONS} questions asked in ONE "
"round-trip, rendered as a stepper. When set, the singular "
"question/options fields are ignored."
),
},
},
"required": [],
},
},
}
def ask_user_tool() -> object:
def ask_user(
question: str = "",
options: list | None = None,
allow_text: bool = True,
multi: bool = False,
header: str = "",
questions: list | None = None,
) -> 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.
Never use it to ask permission for a specific action you are about to take ("shall I
open a PR?") — propose the action instead: the approval flow shows the user the exact
command/arguments and does the asking, which is stronger consent than a chat yes.
Single form returns `{"answer": "..."}` — the chosen option label(s) or the typed text.
Grouped form (`questions`) returns `{"answers": {"<header or question>": "..."}}` — one
entry per question. 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",
}
wrapped = 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 — never to ask "
"permission for a specific action; propose the action and let the approval flow ask."
),
),
)
wrapped.__coworker_schema__ = _ASK_SCHEMA
return wrapped
def normalize_option(opt) -> dict:
"""One option in canonical dict form: {label, description, recommended, preview}. Plain
strings become {label: str, ...empty}. The label doubles as the answer value everywhere
(buttons, pills, resolutions), so it is always a non-empty-able str."""
if isinstance(opt, dict):
return {
"label": str(opt.get("label", "")),
"description": str(opt.get("description", "")),
"recommended": bool(opt.get("recommended", False)),
"preview": str(opt.get("preview", "")),
}
return {"label": str(opt), "description": "", "recommended": False, "preview": ""}
def option_label(opt) -> str:
"""The answer value / button text for a str-or-dict option."""
return str(opt.get("label", "")) if isinstance(opt, dict) else str(opt)
def normalize_questions(raw) -> list[dict]:
"""The grouped `questions` arg in canonical form (capped, blanks dropped). Each entry:
{question, header, options: [canonical option], allow_text, multi}."""
out: list[dict] = []
for entry in list(raw or [])[:MAX_GROUPED_QUESTIONS]:
if not isinstance(entry, dict):
continue
q = str(entry.get("question", "")).strip()
if not q:
continue
out.append(
{
"question": q,
"header": str(entry.get("header", "")),
"options": [normalize_option(o) for o in entry.get("options") or []],
"allow_text": bool(entry.get("allow_text", True)),
"multi": bool(entry.get("multi", False)),
}
)
return out
def question_item_fields(args: dict) -> dict | None:
"""`InboxStore.add_question` kwargs from raw ask_user args, or None when nothing was asked.
A grouped call surfaces its FIRST question as title/options too, so legacy surfaces (channel
mirrors, old persisted-item readers) degrade to a sensible single question."""
grouped = normalize_questions(args.get("questions"))
if grouped:
first = grouped[0]
return {
"title": first["question"],
"options": first["options"],
"allow_text": first["allow_text"],
"multi": first["multi"],
"header": first["header"],
"questions": grouped,
}
question = str(args.get("question", "")).strip()
if not question:
return None
return {
"title": question,
# Strings pass through untouched (simple asks keep rendering as today's pills);
# rich objects are canonicalized so downstream never meets a half-filled dict.
"options": [
o if isinstance(o, str) else normalize_option(o)
for o in args.get("options") or []
],
"allow_text": bool(args.get("allow_text", True)),
"multi": bool(args.get("multi", False)),
"header": str(args.get("header", "")),
"questions": [],
}
def answer_result(item_questions: list, resolution: str | None) -> dict:
"""Shape the ask_user tool result from an Inbox item's resolution string. Grouped items
resolve with a JSON object string keyed by header-or-question → `{"answers": {...}}`;
everything else returns the plain `{"answer": str}` shape."""
if item_questions:
try:
parsed = json.loads(resolution or "")
except (ValueError, TypeError):
parsed = None
if isinstance(parsed, dict):
return {"answers": {str(k): str(v) for k, v in parsed.items()}}
if resolution:
# Answered from a text-only surface (e.g. a mirrored channel): attribute the lone
# answer to the first question rather than losing it.
first = item_questions[0] if isinstance(item_questions[0], dict) else {}
key = str(first.get("header") or first.get("question") or "answer")
return {"answers": {key: str(resolution)}}
return {"answer": ""}
return {"answer": resolution or ""}

View File

@@ -0,0 +1,45 @@
"""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, primary: 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. Set `primary=true` only when the granted folder should become the
session's main workspace (the project the whole conversation is about) — allowed once,
and only while the session is still running on its scratch directory. 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."
),
),
)

126
coworker/tools/files.py Normal file
View File

@@ -0,0 +1,126 @@
"""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, Optional
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, roots: Optional[list] = None) -> list:
"""Windowed read_file rooted at `workspace`. With `roots` (RootDir list), absolute
paths inside ANY root also resolve — multi-root sessions (universal scratch) address
their scratch/extra dirs by the absolute paths the roots context advertises."""
root = Path(workspace).resolve()
extra_roots = [Path(str(r.path)).resolve() for r in (roots or [])]
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()
home = root
try:
target.relative_to(root) # keep reads inside the workspace
except ValueError:
for r in extra_roots:
try:
target.relative_to(r)
home = r
break
except ValueError:
continue
else:
return {"error": "path escapes the session's directories"}
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(home)) if home == root else str(target),
"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
coworker/tools/git.py Normal file
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
coworker/tools/plan.py Normal file
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."
),
),
)

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]

196
coworker/tools/search.py Normal file
View File

@@ -0,0 +1,196 @@
"""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
# Per-OS application data directories. These are not build noise: on macOS 14+ merely
# *descending* into ~/Library/Application Support (other apps' containers) trips the App
# Data TCC protection and macOS shows "would like to access data from other apps" — an
# alarming prompt the user never asked for, reachable whenever the workspace is a home
# directory. Never traversed; a workspace under one of these is still searched normally,
# because the guard matches directory NAMES encountered during a walk.
OS_DATA_DIRS = {
"Library", # macOS
"AppData", # Windows
"Application Data", # Windows (legacy junction)
}
_IGNORE_DIRS = {
".git",
"node_modules",
"target",
"dist",
"build",
".venv",
"venv",
"__pycache__",
".next",
".mypy_cache",
".pytest_cache",
".ruff_cache",
".idea",
} | OS_DATA_DIRS
_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]
# Do not rely solely on a workspace's .gitignore: the Python fallback
# always omits these generated/dependency directories too. Exclusions come
# last because ripgrep resolves conflicting globs with the later one winning.
for ignored in sorted(_IGNORE_DIRS):
cmd += ["--glob", f"!**/{ignored}/**"]
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}

599
coworker/tools/shell.py Normal file
View File

@@ -0,0 +1,599 @@
"""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
# Set by interrupt_now() (user Stop) — run()'s read loop treats it like an
# early deadline, so the in-flight foreground command dies within one tick.
self._abort = threading.Event()
# 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 {})}
# Managed pinned tools (toolchain.install) land under one stable bin dir; putting
# it on PATH up front — even before anything is installed there — means a tool the
# user approves mid-session works in THIS shell immediately, by name, no respawn.
# Appended last: the user's own copies always win.
from .. import toolchain
path = self._env.get("PATH", "")
managed_bin = str(toolchain.bin_dir())
if managed_bin not in path.split(os.pathsep):
self._env["PATH"] = f"{path}{os.pathsep}{managed_bin}" if path else managed_bin
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
self._abort.clear()
# 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
aborted = False
exit_code: Optional[int] = None
lines: list[str] = []
while True:
if self._abort.is_set():
# User Stop: reuse the deadline path this tick (interrupt-and-resync on
# POSIX, decisive shell kill on Windows) instead of waiting out the timeout.
aborted = True
deadline = time.monotonic()
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,
error="interrupted by user" if aborted else None,
)
def interrupt_now(self) -> None:
"""User Stop: make an in-flight foreground `run()` bail on its next read tick
(≤0.5s). Thread-safe; a no-op when nothing is running. Background tasks are
left alone — they're explicitly fire-and-forget."""
self._abort.set()
# -- 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
coworker/tools/subagent.py Normal file
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,
),
)
]

87
coworker/tools/todo.py Normal file
View File

@@ -0,0 +1,87 @@
"""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__`.
#
# The parameter is `todos`, NOT `items`: a top-level argument key named "items" shadows
# minijinja's `.items()` map method in at least one hosted chat template (Together's
# GLM-5.2, 2026-07-21 — "object is not callable"), 400-ing every request that replays
# the call. Any key name that isn't a minijinja map method is safe; never rename back.
_TODO_SCHEMA = {
"type": "function",
"function": {
"name": "todo_write",
"description": "Replace the task list. Provide the full list of todos each call.",
"parameters": {
"type": "object",
"properties": {
"todos": {
"type": "array",
"items": {
"type": "object",
"properties": {
"content": {"type": "string"},
"status": {
"type": "string",
"enum": ["pending", "in_progress", "done"],
},
},
"required": ["content", "status"],
},
}
},
"required": ["todos"],
},
},
}
@dataclass
class TodoList:
items: list[dict] = field(default_factory=list)
def todo_tools(todo: TodoList) -> list:
def todo_write(todos: list = None, items: list = None) -> dict:
"""Replace the task list. Each todo is an object with `content` and a `status`
of pending, in_progress, or done."""
# `items` stays accepted (models that free-style the old name; queued replays).
normalized = []
for entry in (todos if todos is not None else 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), "todos": normalized}
wrapped = ai.tool(
todo_write,
metadata=ai.ToolMetadata(
category="planning",
risk_level="low",
capabilities=["todo"],
),
)
wrapped.__coworker_schema__ = _TODO_SCHEMA
return [wrapped]

52
coworker/tools/toolreq.py Normal file
View File

@@ -0,0 +1,52 @@
"""The `request_tool` tool — the agent asks the user for a CLI it needs but can't find.
Sibling of `request_directory`: the TurnEngine intercepts it, emits TOOL_REQUESTED, and the
user decides out-of-band (install the pinned build, or skip and let the run continue
degraded). The callable here is only a schema carrier + the fallback for surfaces with no
requester wired.
This exists because of a specific failure mode (OPE-85): with gitleaks absent, a security
review silently dropped its git-history secret scan — the check didn't fail, it vanished
from the report. A missing tool must become a visible decision, never an invisible gap.
"""
from __future__ import annotations
from aisuite.agents import ToolMetadata, tool
def request_tool_tool() -> object:
def request_tool(name: str, reason: str) -> dict:
"""Ask the user to install one of the PINNED catalog tools you need but can't find
on this machine. The catalog is a small closed set — currently `gitleaks`,
`trivy`, `osv-scanner` — installed at a pinned, checksum-verified version.
For ANY other missing CLI (semgrep, jq, kubectl, …) do NOT use this tool: install
it yourself with the shell (brew/pip/…), which goes through the normal command
approval, or proceed without it.
Keep `reason` to ONE sentence: which check needs the tool. The prompt the user
sees already explains what the install is (pinned version, publisher, checksum)
and what happens if they decline — don't restate any of that in `reason`.
Use this INSTEAD of quietly skipping a check. If the user declines, carry on with a
fallback (e.g. reading git history yourself instead of running gitleaks) and state
plainly in your report which checks were degraded and why.
"""
return {
"installed": False,
"error": "tool requests aren't available in this surface",
}
return tool(
request_tool,
metadata=ToolMetadata(
category="system",
risk_level="low",
capabilities=["request_tool"],
description=(
"Ask the user to install a missing command-line tool, rather than silently "
"skipping the check that needs it."
),
),
)