mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
Imported from andrewyng/aisuite@1b4bbf303e (contents of its platform/ directory, hoisted to the repo root). Development history prior to this commit lives in that repository. Co-authored-by: Devika <devikaverma11@gmail.com>
139 lines
5.4 KiB
Python
139 lines
5.4 KiB
Python
"""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,
|
|
),
|
|
)
|
|
]
|