Files
openworker/coworker/catalog.py
T
Rohit C Prasad 2c9fb7490b Toolset dedup: one windowed reader, sleep_until only, browser trim/rename
files capability adopts the numbered read_file; per-turn Now: line feeds absolute scheduling.
browser_get_text/browser_read_url dropped; browser_snapshot renamed browser_read_page.
2026-08-20 23:06:36 -07:00

190 lines
6.4 KiB
Python

"""Vetted tool catalog — the stable ``id → capability`` layer a persona references.
A *capability* bundles a group of tools (the existing ``tools/`` factories) behind a stable
id, plus what session context it needs (``requires``) and the risk classes it can produce
(``risk``, used by the Phase 2 install-consent screen). ``expand(ids, context)`` turns a
persona's ``tools:`` list into concrete callables, skipping capabilities whose context
prerequisites aren't met (e.g. no shell without an executor) — matching the per-agent
factories that used to assemble tools by hand.
The catalog is **platform-owned and closed**: third parties get breadth from us adding
vetted capabilities here and from MCP, never by adding entries. MCP tools are *not* in the
catalog (see ``PERMISSIONS-AND-INBOX.md``).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Callable
import aisuite as ai
from .agents.base import AgentContext
from .risk import RiskClass
from .tools.files import file_tools
from .tools.git import git_tools
from .tools.search import search_tools
from .tools.shell import shell_tools
from .tools.todo import todo_tools
# Context prerequisites a capability may require, mapped to a predicate over AgentContext.
_REQUIREMENTS: dict[str, Callable[[AgentContext], bool]] = {
"workspace": lambda c: c.workspace is not None,
"executor": lambda c: c.executor is not None,
"todo": lambda c: c.todo is not None,
}
@dataclass(frozen=True)
class Capability:
id: str
name: str # human label (consent screen)
description: str
build: Callable[[AgentContext], list]
requires: tuple[str, ...] = ()
risk: tuple[RiskClass, ...] = (RiskClass.READ,)
def available(self, context: AgentContext) -> bool:
return all(_REQUIREMENTS[r](context) for r in self.requires)
# -- capability builders --------------------------------------------------------
# These reproduce, exactly, what the Code and Cowork agent factories assembled by hand.
def _code_files(context: AgentContext) -> list:
"""Repo-oriented files: line-numbered/windowed `read_file`. Our `grep` and windowed
`read_file` replace aisuite's slower `search_files` / `read_file`/`read_file_lines`.
Multi-root aware (universal scratch): with session roots, writes/reads reach the
scratch and granted dirs too; the workspace stays the relative-path anchor.
"""
ws = str(context.workspace)
replaced = {"search_files", "read_file", "read_file_lines"}
file_kwargs = (
{"roots": context.roots} if context.roots else {"root": ws, "allow_write": True}
)
files = [
t
for t in ai.toolkits.files(**file_kwargs)
if getattr(t, "__name__", "") not in replaced
]
return [*files, *file_tools(ws, roots=context.roots)]
def _files(context: AgentContext) -> list:
"""Knowledge-work files: multi-root aware (reads/writes across the session's roots).
One reader everywhere (owner ruling 2026-08-20): the windowed, line-numbered
`read_file` replaces aisuite's `read_file`/`read_file_lines`, and our `grep`
replaces the slow `search_files` — same set Code uses.
"""
ws = str(context.workspace)
file_kwargs = (
{"roots": context.roots} if context.roots else {"root": ws, "allow_write": True}
)
replaced = {"search_files", "read_file", "read_file_lines"}
files = [
t
for t in ai.toolkits.files(**file_kwargs)
if getattr(t, "__name__", "") not in replaced
]
return [*files, *file_tools(ws, roots=context.roots)]
def _git(context: AgentContext) -> list:
ws = str(context.workspace)
return [*ai.toolkits.git(root=ws), *git_tools(ws)] # git_status, git_diff, git_log
def _search(context: AgentContext) -> list:
return search_tools(str(context.workspace)) # grep (ripgrep, .gitignore-aware)
def _shell(context: AgentContext) -> list:
return shell_tools(context.executor) # run_shell + background task tools
def _todo(context: AgentContext) -> list:
return todo_tools(context.todo) # todo_write (drives the Progress panel)
_CAPS: list[Capability] = [
Capability(
id="code_files",
name="Code files",
description="Read & edit files in a single repo workspace (line-numbered reads).",
build=_code_files,
requires=("workspace",),
risk=(RiskClass.READ, RiskClass.WRITE_LOCAL),
),
Capability(
id="files",
name="Files",
description="Read & edit files across the session's workspace folders.",
build=_files,
requires=("workspace",),
risk=(RiskClass.READ, RiskClass.WRITE_LOCAL),
),
Capability(
id="git",
name="Git",
description="Inspect git state and history (status, diff, log).",
build=_git,
requires=("workspace",),
risk=(RiskClass.READ,),
),
Capability(
id="search",
name="Search",
description="Fast code/content search (grep).",
build=_search,
requires=("workspace",),
risk=(RiskClass.READ,),
),
Capability(
id="shell",
name="Shell",
description="Run shell commands in a persistent session.",
build=_shell,
requires=("executor",),
risk=(RiskClass.EXEC,),
),
Capability(
id="todo",
name="Task list",
description="Maintain a visible task/progress list.",
build=_todo,
requires=("todo",),
risk=(RiskClass.READ,),
),
]
CATALOG: dict[str, Capability] = {c.id: c for c in _CAPS}
def capability(cap_id: str) -> Capability:
cap = CATALOG.get(cap_id)
if cap is None:
raise KeyError(f"Unknown capability id: {cap_id!r}")
return cap
def expand(ids: list[str], context: AgentContext) -> list:
"""Expand a persona's ``tools:`` id list into concrete tool callables for this context.
Capabilities whose context prerequisites aren't met are skipped (no shell without an
executor, no files without a workspace) — exactly like the old hand-written factories.
"""
tools: list = []
for cap_id in ids:
cap = capability(cap_id)
if cap.available(context):
tools.extend(cap.build(context))
return tools
def risk_summary(ids: list[str]) -> set[RiskClass]:
"""The union of risk classes a tool list can produce — for the install-consent screen."""
out: set[RiskClass] = set()
for cap_id in ids:
out.update(capability(cap_id).risk)
return out