feat: OpenMesh 基础平台与 MD/PDF 转换技能
- 后端: 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:
3
coworker/tools/__init__.py
Normal file
3
coworker/tools/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .registry import ToolRegistry, ToolSpec
|
||||
|
||||
__all__ = ["ToolRegistry", "ToolSpec"]
|
||||
263
coworker/tools/ask.py
Normal file
263
coworker/tools/ask.py
Normal 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 ""}
|
||||
45
coworker/tools/directories.py
Normal file
45
coworker/tools/directories.py
Normal 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
126
coworker/tools/files.py
Normal 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
90
coworker/tools/git.py
Normal 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
43
coworker/tools/plan.py
Normal 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
coworker/tools/registry.py
Normal file
71
coworker/tools/registry.py
Normal 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
196
coworker/tools/search.py
Normal 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
599
coworker/tools/shell.py
Normal 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
138
coworker/tools/subagent.py
Normal 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
87
coworker/tools/todo.py
Normal 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
52
coworker/tools/toolreq.py
Normal 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."
|
||||
),
|
||||
),
|
||||
)
|
||||
Reference in New Issue
Block a user