revert: "read-to-open"

This reverts commit c48d2e6080.
This commit is contained in:
can1357
2026-04-24 22:33:52 +02:00
parent 99a21b95ba
commit d82377cda8
44 changed files with 442 additions and 300 deletions
+1
View File
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
### Added
- Added a non-mutating chunk edit `read: true` operation for inspecting chunk content without relying on edit failures or delete previews.
+2 -3
View File
@@ -5,7 +5,7 @@ import { type Effort, THINKING_EFFORTS } from "@oh-my-pi/pi-ai";
import { APP_NAME, CONFIG_DIR_NAME, logger } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { parseEffort } from "../thinking";
import { BUILTIN_TOOLS, resolveToolAlias } from "../tools";
import { BUILTIN_TOOLS } from "../tools";
export type Mode = "text" | "json" | "rpc" | "acp";
@@ -129,8 +129,7 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
.map(s => s.trim().toLowerCase())
.filter(Boolean);
const validTools: string[] = [];
for (const rawName of toolNames) {
const name = resolveToolAlias(rawName);
for (const name of toolNames) {
if (name in BUILTIN_TOOLS) {
validTools.push(name);
} else {
+2 -2
View File
@@ -11,7 +11,7 @@ import { formatChunkedRead, resolveAnchorStyle } from "../edit/modes/chunk";
import { getLanguageFromPath } from "../modes/theme/theme";
import type { ToolSession } from "../tools";
import { parseReadUrlTarget } from "../tools/fetch";
import { OpenTool } from "../tools/open";
import { ReadTool } from "../tools/read";
export interface ReadCommandArgs {
path: string;
@@ -34,7 +34,7 @@ export async function runReadCommand(cmd: ReadCommandArgs): Promise<void> {
const parsedUrlTarget = parseReadUrlTarget(cmd.path, cmd.sel);
if (parsedUrlTarget) {
const settings = await Settings.init({ cwd });
const tool = new OpenTool(createCliReadSession(cwd, settings));
const tool = new ReadTool(createCliReadSession(cwd, settings));
const result = await tool.execute("cli-read", { path: cmd.path, sel: cmd.sel });
const text = result.content.find((content): content is { type: "text"; text: string } => content.type === "text");
console.log(text?.text ?? "");
@@ -155,8 +155,8 @@ const EMPTY_MODEL_TAGS_RECORD: ModelTagsSettings = {};
export const DEFAULT_BASH_INTERCEPTOR_RULES: BashInterceptorRule[] = [
{
pattern: "^\\s*(cat|head|tail|less|more)\\s+",
tool: "open",
message: "Use the `open` tool instead of cat/head/tail. It provides better context and handles binary files.",
tool: "read",
message: "Use the `read` tool instead of cat/head/tail. It provides better context and handles binary files.",
},
{
pattern: "^\\s*(grep|rg|ripgrep|ag|ack)\\s+",
+2 -2
View File
@@ -164,14 +164,14 @@ export class CursorExecHandlers implements ICursorExecHandlers {
async read(args: Parameters<NonNullable<ICursorExecHandlers["read"]>>[0]) {
const toolCallId = decodeToolCallId(args.toolCallId);
const toolResultMessage = await executeTool(this.options, "open", toolCallId, { path: args.path });
const toolResultMessage = await executeTool(this.options, "read", toolCallId, { path: args.path });
return toolResultMessage;
}
async ls(args: Parameters<NonNullable<ICursorExecHandlers["ls"]>>[0]) {
const toolCallId = decodeToolCallId(args.toolCallId);
// Redirect ls to read tool, which handles directories
const toolResultMessage = await executeTool(this.options, "open", toolCallId, { path: args.path });
const toolResultMessage = await executeTool(this.options, "read", toolCallId, { path: args.path });
return toolResultMessage;
}
@@ -31,6 +31,10 @@ import { expandApplyPatchToEntries, expandApplyPatchToPreviewEntries } from "./m
import type { Operation, PatchEditEntry } from "./modes/patch";
import type { PerFileDiffPreview } from "./streaming";
// ═══════════════════════════════════════════════════════════════════════════
// LSP Batching
// ═══════════════════════════════════════════════════════════════════════════
export { getLspBatchRequest, type LspBatchRequest };
// ═══════════════════════════════════════════════════════════════════════════
@@ -394,8 +394,7 @@
function formatToolCall(name, args) {
switch (name) {
case 'read': // legacy alias
case 'open': {
case 'read': {
const path = shortenPath(String(args.path || args.file_path || ''));
const offset = args.offset;
const limit = args.limit;
@@ -810,7 +809,7 @@
const filePath = str(args.file_path == null ? args.path : args.file_path);
let pathHtml = pathDisplay(filePath, args.offset, args.limit);
if (args.sel) pathHtml += '<span class="line-numbers">:' + escapeHtml(String(args.sel)) + '</span>';
let html = toolHead('open', pathHtml);
let html = toolHead('read', pathHtml);
if (result) {
html += ctx.renderResultImages();
const output = ctx.getResultText();
@@ -48,8 +48,8 @@ import type {
FindToolInput,
GrepToolDetails,
GrepToolInput,
OpenToolDetails,
OpenToolInput,
ReadToolDetails,
ReadToolInput,
WriteToolInput,
} from "../../tools";
import type { TodoItem } from "../../tools/todo-write";
@@ -668,9 +668,9 @@ export interface BashToolCallEvent extends ToolCallEventBase {
input: BashToolInput;
}
export interface OpenToolCallEvent extends ToolCallEventBase {
toolName: "open";
input: OpenToolInput;
export interface ReadToolCallEvent extends ToolCallEventBase {
toolName: "read";
input: ReadToolInput;
}
export interface EditToolCallEvent extends ToolCallEventBase {
@@ -701,7 +701,7 @@ export interface CustomToolCallEvent extends ToolCallEventBase {
/** Fired before a tool executes. Can block. */
export type ToolCallEvent =
| BashToolCallEvent
| OpenToolCallEvent
| ReadToolCallEvent
| EditToolCallEvent
| WriteToolCallEvent
| GrepToolCallEvent
@@ -721,9 +721,9 @@ export interface BashToolResultEvent extends ToolResultEventBase {
details: BashToolDetails | undefined;
}
export interface OpenToolResultEvent extends ToolResultEventBase {
toolName: "open";
details: OpenToolDetails | undefined;
export interface ReadToolResultEvent extends ToolResultEventBase {
toolName: "read";
details: ReadToolDetails | undefined;
}
export interface EditToolResultEvent extends ToolResultEventBase {
@@ -754,7 +754,7 @@ export interface CustomToolResultEvent extends ToolResultEventBase {
/** Fired after a tool executes. Can modify result. */
export type ToolResultEvent =
| BashToolResultEvent
| OpenToolResultEvent
| ReadToolResultEvent
| EditToolResultEvent
| WriteToolResultEvent
| GrepToolResultEvent
@@ -782,7 +782,7 @@ export type ToolResultEvent =
* CustomToolCallEvent.toolName is `string` which overlaps with all literals.
*/
export function isToolCallEventType(toolName: "bash", event: ToolCallEvent): event is BashToolCallEvent;
export function isToolCallEventType(toolName: "open", event: ToolCallEvent): event is OpenToolCallEvent;
export function isToolCallEventType(toolName: "read", event: ToolCallEvent): event is ReadToolCallEvent;
export function isToolCallEventType(toolName: "edit", event: ToolCallEvent): event is EditToolCallEvent;
export function isToolCallEventType(toolName: "write", event: ToolCallEvent): event is WriteToolCallEvent;
export function isToolCallEventType(toolName: "grep", event: ToolCallEvent): event is GrepToolCallEvent;
@@ -21,7 +21,7 @@ import type {
SessionEntry,
SessionManager,
} from "../../session/session-manager";
import type { BashToolDetails, FindToolDetails, GrepToolDetails, OpenToolDetails } from "../../tools";
import type { BashToolDetails, FindToolDetails, GrepToolDetails, ReadToolDetails } from "../../tools";
import type { TodoItem } from "../../tools/todo-write";
// Re-export for backward compatibility
@@ -477,9 +477,9 @@ export interface BashToolResultEvent extends ToolResultEventBase {
}
/** Tool result event for read tool */
export interface OpenToolResultEvent extends ToolResultEventBase {
toolName: "open";
details: OpenToolDetails | undefined;
export interface ReadToolResultEvent extends ToolResultEventBase {
toolName: "read";
details: ReadToolDetails | undefined;
}
/** Tool result event for edit tool */
@@ -519,7 +519,7 @@ export interface CustomToolResultEvent extends ToolResultEventBase {
*/
export type ToolResultEvent =
| BashToolResultEvent
| OpenToolResultEvent
| ReadToolResultEvent
| EditToolResultEvent
| WriteToolResultEvent
| GrepToolResultEvent
+1 -1
View File
@@ -539,7 +539,7 @@ function shouldPersistResponseItemForMemories(message: AgentMessage): boolean {
}
if (role !== "toolResult") return false;
const toolName = (message as { toolName?: string }).toolName;
if (toolName === "bash" || toolName === "python" || toolName === "open" || toolName === "grep") {
if (toolName === "bash" || toolName === "python" || toolName === "read" || toolName === "grep") {
const text = extractMessageText(message);
return text.length > 0 && text.length <= 32_000;
}
@@ -94,8 +94,7 @@ const ACP_TEXT_LIMIT = 4_000;
export function mapToolKind(toolName: string): ToolKind {
switch (toolName) {
case "read": // legacy alias
case "open":
case "read":
return "read";
case "write":
case "edit":
@@ -342,8 +342,7 @@ export class SessionObserverOverlayComponent extends Container {
#formatToolArgs(toolName: string, args: Record<string, unknown>): string {
// Show the most relevant arg for common tools
switch (toolName) {
case "read": // legacy alias
case "open":
case "read":
return args.path ? `path: ${args.path}` : "";
case "write":
return args.path ? `path: ${args.path}` : "";
@@ -637,8 +637,7 @@ class TreeList implements Component {
#formatToolCall(name: string, args: Record<string, unknown>): string {
switch (name) {
case "read": // legacy alias
case "open": {
case "read": {
const path = shortenPath(String(args.path || args.file_path || ""));
const offset = args.offset as number | undefined;
const limit = args.limit as number | undefined;
@@ -212,7 +212,7 @@ export class EventController {
for (const content of this.ctx.streamingMessage.content) {
if (content.type !== "toolCall") continue;
if (content.name === "open" || content.name === "read") {
if (content.name === "read") {
this.#trackReadToolCall(content.id, content.arguments);
const component = this.ctx.pendingTools.get(content.id);
if (component) {
@@ -309,7 +309,7 @@ export class EventController {
async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
this.#updateWorkingMessageFromIntent(event.intent);
if (!this.ctx.pendingTools.has(event.toolCallId)) {
if (event.toolName === "open" || event.toolName === "read") {
if (event.toolName === "read") {
this.#trackReadToolCall(event.toolCallId, event.args);
const component = this.ctx.pendingTools.get(event.toolCallId);
if (component) {
@@ -366,7 +366,7 @@ export class EventController {
}
async #handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>): Promise<void> {
if (event.toolName === "open" || event.toolName === "read") {
if (event.toolName === "read") {
if (this.#inlineReadToolImages(event.toolCallId, event.result)) {
const component = this.ctx.pendingTools.get(event.toolCallId);
if (component) {
@@ -254,7 +254,7 @@ export class UiHelpers {
continue;
}
if (content.name === "open" || content.name === "read") {
if (content.name === "read") {
if (hasErrorStop && errorMessage) {
if (!readGroup) {
readGroup = new ReadToolGroupComponent({
@@ -315,7 +315,7 @@ export class UiHelpers {
}
}
} else if (message.role === "toolResult") {
if (message.toolName === "open" || message.toolName === "read") {
if (message.toolName === "read") {
const assistantComponent = readToolCallAssistantComponents.get(message.toolCallId);
const images: ImageContent[] = message.content.filter(
(content): content is ImageContent => content.type === "image",
@@ -180,14 +180,14 @@ If the task may involve external systems, SaaS APIs, chat, tickets, databases, d
{{#ifAny (includes tools "python") (includes tools "bash")}}
### Tool priority
1. Use specialized tools first{{#ifAny (includes tools "open") (includes tools "grep") (includes tools "find") (includes tools "edit") (includes tools "lsp")}}: {{#has tools "open"}}`open`, {{/has}}{{#has tools "grep"}}`grep`, {{/has}}{{#has tools "find"}}`find`, {{/has}}{{#has tools "edit"}}`edit`, {{/has}}{{#has tools "lsp"}}`lsp`{{/has}}{{/ifAny}}
1. Use specialized tools first{{#ifAny (includes tools "read") (includes tools "grep") (includes tools "find") (includes tools "edit") (includes tools "lsp")}}: {{#has tools "read"}}`read`, {{/has}}{{#has tools "grep"}}`grep`, {{/has}}{{#has tools "find"}}`find`, {{/has}}{{#has tools "edit"}}`edit`, {{/has}}{{#has tools "lsp"}}`lsp`{{/has}}{{/ifAny}}
2. Python: logic, loops, processing, display
3. Bash: simple one-liners only
You **MUST NOT** use Python or Bash when a specialized tool exists.
{{/ifAny}}
{{#ifAny (includes tools "open") (includes tools "write") (includes tools "grep") (includes tools "find") (includes tools "edit")}}
{{#has tools "open"}}- Use `open`, not `cat`/`head`/`tail`/`curl`/`wget`.{{/has}}
{{#ifAny (includes tools "read") (includes tools "write") (includes tools "grep") (includes tools "find") (includes tools "edit")}}
{{#has tools "read"}}- Use `read`, not `cat`.{{/has}}
{{#has tools "write"}}- Use `write`, not shell redirection.{{/has}}
{{#has tools "grep"}}- Use `grep`, not shell regex search.{{/has}}
{{#has tools "find"}}- Use `find`, not shell file globbing.{{/has}}
@@ -232,7 +232,7 @@ Match commands to the host shell: linux/bash and macos/zsh use Unix commands; wi
### Search before you read
{{#has tools "grep"}}- Use `grep` to locate targets.{{/has}}
{{#has tools "find"}}- Use `find` to map structure.{{/has}}
{{#has tools "open"}}- Use `open` with offset or limit rather than whole-file reads when practical.{{/has}}
{{#has tools "read"}}- Use `read` with offset or limit rather than whole-file reads when practical.{{/has}}
{{#has tools "task"}}- Use `task` for investigate+edit when available.{{/has}}
- Do not read a file hoping to find the right thing.
@@ -33,13 +33,13 @@ You **MUST NOT** use bash for file operations where specialized tools exist:
|Instead of (WRONG)|Use (CORRECT)|
|---|---|
|`cat file`, `head -n N file`|`open(path="file", limit=N)`|
|`cat -n file \|sed -n '50,150p'`|`open(path="file", offset=50, limit=100)`|
|`cat file`, `head -n N file`|`read(path="file", limit=N)`|
|`cat -n file \|sed -n '50,150p'`|`read(path="file", offset=50, limit=100)`|
{{#if hasGrep}}|`grep -A 20 'pat' file`|`grep(pattern="pat", path="file", post=20)`|
|`grep -rn 'pat' dir/`|`grep(pattern="pat", path="dir/")`|
|`rg 'pattern' dir/`|`grep(pattern="pattern", path="dir/")`|{{/if}}
{{#if hasFind}}|`find dir -name '*.ts'`|`find(pattern="dir/**/*.ts")`|{{/if}}
|`ls dir/`|`open(path="dir/")`|
|`ls dir/`|`read(path="dir/")`|
|`cat <<'EOF' > file`|`write(path="file", content="…")`|
|`sed -i 's/old/new/' file`|`edit(path="file", edits=[…])`|
{{#if hasAstEdit}}|`sed -i 's/oldFn(/newFn(/' src/*.ts`|`ast_edit({ops:[{pat:"oldFn($$$A)", out:"newFn($$$A)"}], path:"src/"})`|{{/if}}
@@ -1,7 +1,7 @@
Navigates, clicks, types, scrolls, drags, queries DOM content, and captures screenshots.
<instruction>
- For fetching static web content (articles, docs, issues/PRs, JSON, PDFs, feeds), prefer the `open` tool with a URL — it returns clean reader-mode text without spinning up a browser. Use this tool only when you need JS execution, authentication, or interactive actions.
- For fetching static web content (articles, docs, issues/PRs, JSON, PDFs, feeds), prefer the `read` tool with a URL — it returns clean reader-mode text without spinning up a browser. Use this tool only when you need JS execution, authentication, or interactive actions.
- `"open"` starts a headless session (or implicitly on first action); `"goto"` navigates to `url`; `"close"` releases the browser
- `"observe"` captures a numbered accessibility snapshot — prefer `click_id`/`type_id`/`fill_id` using returned `element_id` values; flags: `include_all`, `viewport_only`
- `"click"`, `"type"`, `"fill"`, `"press"`, `"scroll"`, `"drag"` for selector-based interactions — prefer ARIA/text selectors (`p-aria/[name="Sign in"]`, `p-text/Continue`) over brittle CSS
@@ -1,5 +1,5 @@
Edits files via syntax-aware chunks. Use `open(path="file.ts")` to read and discover chunks before editing.
- `open` is the canonical read path for chunk source and `sel="?"` tree listings.
Edits files via syntax-aware chunks. Use `read(path="file.ts")` to read and discover chunks before editing.
- `read` is the canonical read path for chunk source and `sel="?"` tree listings.
- `read:true` is a non-mutating convenience for an already-known selector.
- `write` rewrites the entire targeted region — best for most edits.
- `insert` adds content before/after a chunk.
@@ -8,10 +8,10 @@ Edits files via syntax-aware chunks. Use `open(path="file.ts")` to read and disc
Call format: `{"edits": [{"path": "file:chunk#ID~", "write": "new body"}, …]}`
<rules>
- **MUST** inspect first with `open`. Never invent chunk paths or IDs. Copy them from the latest `open` output or edit response.
- **MUST** inspect first with `read`. Never invent chunk paths or IDs. Copy them from the latest `read` output or edit response.
- `path` format: `file:selector` — e.g. `src/app.ts:fn_foo#ABCD~`. Append `~` for body, `^` for head, or nothing for the whole chunk. Include `#ID` for `write`/`delete`.
- To inspect a known chunk through this tool, use `{"path":"file:chunk#ID","read":true}`. `read:true` is non-mutating, cannot be mixed with write operations in the same entry, and is not a replacement for `open(path="file", sel="?")` when discovering targets.
- If the exact chunk path is unclear, run `open(path="file", sel="?")` and copy a selector from that listing.
- To inspect a known chunk through this tool, use `{"path":"file:chunk#ID","read":true}`. `read:true` is non-mutating, cannot be mixed with write operations in the same entry, and is not a replacement for `read(path="file", sel="?")` when discovering targets.
- If the exact chunk path is unclear, run `read(path="file", sel="?")` and copy a selector from that listing.
{{#if chunkAutoIndent}}
- Use `\t` for indentation in `content`. Write content at indent-level 0 — the tool re-indents it to match the chunk's position in the file. For example, to replace `~` of a method, write the body starting at column 0:
```
@@ -38,7 +38,7 @@ Call format: `{"edits": [{"path": "file:chunk#ID~", "write": "new body"}, …]}`
- Unsuffixed `write` on a leaf chunk uses your content verbatim after normal replacement; it is not a body-region rewrite. Include the exact indentation and punctuation the leaf needs in the file.
- `^` head writes and `~` body writes use the same base-indent model: write content at column 0 relative to the target region, and the tool applies the chunk's file indentation.
- `write` and `delete` require the current ID. `prepend`/`append` do not.
- **IDs change after every edit.** The edit response always carries the new IDs — use those for the next call or run `open(path="file", sel="?")` to refresh. Never reuse an ID from before the latest edit.
- **IDs change after every edit.** The edit response always carries the new IDs — use those for the next call or run `read(path="file", sel="?")` to refresh. Never reuse an ID from before the latest edit.
- Same-file edit batches are transactional: if any operation in that file fails, no changes from that file's batch are saved. Multi-file edit calls run per file, so a later file error does not roll back earlier files that already succeeded.
</rules>
@@ -47,17 +47,17 @@ You **MUST** use the narrowest region that covers your change. Putting without a
**`put` is total, not surgical.** The `content` you supply becomes the *complete* new content for the targeted region. Everything in the original region that you omit from `content` is deleted. Before using `put` on any chunk's `~`, verify the chunk does not contain children you intend to keep. If a chunk spans hundreds of lines and your change touches only a few, target a specific child chunk — not the parent.
**Group chunks (`stmts_*`, `imports_*`, `decls_*`) are containers.** They hold many sibling items (test functions, import statements, declarations). `put` on a group chunk's `~` overwrites **all** of its children. To edit one item inside a group, target that item's own chunk path. If no child chunk exists, use the specific child's chunk selector from `open` output — do not `put` the parent group.
**Group chunks (`stmts_*`, `imports_*`, `decls_*`) are containers.** They hold many sibling items (test functions, import statements, declarations). `put` on a group chunk's `~` overwrites **all** of its children. To edit one item inside a group, target that item's own chunk path. If no child chunk exists, use the specific child's chunk selector from `read` output — do not `put` the parent group.
</critical>
<regions>
In `open` or `read:true` output, lines marked `^` between the line number and `|` are **head** lines (doc comments, attributes/decorators, signature). Lines without `^` are **body** lines. Use this to decide which region to target:
In `read` or `read:true` output, lines marked `^` between the line number and `|` are **head** lines (doc comments, attributes/decorators, signature). Lines without `^` are **body** lines. Use this to decide which region to target:
- `fn_foo#ID~` — **body only (the default choice for most edits).** Head lines (`^`) are preserved automatically — doc comments, attributes, and signature stay untouched. On code leaf chunks, this is rejected because there is no safe body boundary.
- `fn_foo#ID^` — head only (decorators, attributes, doc comments, signature, opening delimiter). Body stays untouched.
- `fn_foo#ID` — entire chunk including leading trivia. **You must include doc comments and attributes in `content`; omitting them deletes them.**
- `chunk~` + `append`/`prepend` inserts *inside* the container. `chunk` + `append`/`prepend` inserts *outside*. Appending to a container without `~` emits a warning because it lands after the closing delimiter, not before it.
**Note on leading trivia:** whether a decorator/doc comment belongs to `^` depends on the parser. In Rust and Python, attributes and decorators are attached to the function chunk, so `^` covers them. In TypeScript/JavaScript, a `@decorator` + `/** jsdoc */` block immediately above a method often surfaces as a **separate sibling chunk** (shown as `chunk#ID` in the `?` listing) rather than as part of the function's `^`. JSDoc directly above a plain function is more likely to be absorbed into that function's `^`. If you need to rewrite a decorated member, run `open(path="file", sel="?")` and check for a sibling `chunk#ID` directly above your target.
**Note on leading trivia:** whether a decorator/doc comment belongs to `^` depends on the parser. In Rust and Python, attributes and decorators are attached to the function chunk, so `^` covers them. In TypeScript/JavaScript, a `@decorator` + `/** jsdoc */` block immediately above a method often surfaces as a **separate sibling chunk** (shown as `chunk#ID` in the `?` listing) rather than as part of the function's `^`. JSDoc directly above a plain function is more likely to be absorbed into that function's `^`. If you need to rewrite a decorated member, run `read(path="file", sel="?")` and check for a sibling `chunk#ID` directly above your target.
**Python notes:** Python docstrings are body lines, not head lines. A `~` body write on a function that has a docstring deletes the docstring unless you include the docstring in `content`. Python enum members and nested functions/closures are often opaque inside their parent chunk and may not appear as addressable child chunks; rewrite the parent container body. Python decorated class/function `^` writes and Python `^` deletes are rejected because indentation-sensitive bodies can become attached to the wrong block while still parsing.
@@ -76,7 +76,7 @@ Each edit entry has `path` (`file:selector`) plus **exactly one** operation fiel
</ops>
<examples>
Given this `open` output for `counter.rs`:
Given this `read` output for `counter.rs`:
```
| counter.rs·62L·rust·#ZRPW
|
@@ -158,7 +158,7 @@ Given this `open` output for `counter.rs`:
61 |}
```
**Understanding `^` markers in `open` output:** Lines marked with `^` between the line number and `|` (e.g. ` 3^|`) are **head** lines — doc comments, attributes, and the signature. Lines without `^` (e.g. ` 7 |`) are **body** lines. `~` replaces body lines only, keeping head lines intact.
**Understanding `^` markers in `read` output:** Lines marked with `^` between the line number and `|` (e.g. ` 3^|`) are **head** lines — doc comments, attributes, and the signature. Lines without `^` (e.g. ` 7 |`) are **body** lines. `~` replaces body lines only, keeping head lines intact.
**Put body** (`~` — the common case):
```
@@ -21,7 +21,8 @@ Searches files using powerful regex matching.
</output>
<critical>
- You **MUST** use Grep when searching for content.
- You **MUST NOT** invoke `grep` or `rg` via Bash.
- If the search is open-ended, requiring multiple rounds, you **MUST** use Task tool with explore subagent instead.
- You **MUST** use the built-in Grep tool for any content search. Do **NOT** shell out to `grep`, `rg`, `ripgrep`, `ag`, `ack`, `git grep`, `awk`, `sed`-for-search, or any other CLI search via Bash — even for a single match, even "just to check quickly", even piped through other commands.
- Bash `grep`/`rg` returns raw text without chunk paths, loses `.gitignore` semantics, bypasses result limits, and wastes tokens. The Grep tool is faster, structured, and already wired into the workspace — there is no scenario where Bash search is preferable.
- If you catch yourself typing `grep`, `rg`, or `| grep` in a Bash command, stop and re-issue the search through the Grep tool instead.
- If the search is open-ended, requiring multiple rounds, you **MUST** use the Task tool with the explore subagent instead of chaining Grep calls yourself.
</critical>
@@ -36,4 +36,4 @@ Interacts with Language Server Protocol servers for code intelligence.
- You **MUST** use `lsp` for symbol-aware operations (rename, find references, go to definition/implementation, code actions) whenever a language server is available — it is safer and more accurate than text-based alternatives.
- You **MUST NOT** perform cross-file renames with `ast_edit`, `sed`, `rsed`, or manual edits when `lsp` `rename` can do it. Text-based renames miss shadowing, re-exports, and usages in other files.
- Prefer `lsp` `code_actions` for imports, quick-fixes, and refactors the language server already knows how to apply.
</critical>
</critical>
@@ -1,10 +1,10 @@
Reads files using syntax-aware chunks. Also inspects directories, archives, SQLite databases, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), **and URLs**.
<instruction>
The chunk-aware `open` variant returns AST-scoped chunks with current checksum IDs for structural editing, and otherwise behaves like `open` for non-code content.
The chunk-aware `read` variant returns AST-scoped chunks with current checksum IDs for structural editing, and otherwise behaves like `open` for non-code content.
- You **MUST** parallelize calls when exploring related files
- For URLs, `open` fetches the page and returns clean extracted text/markdown by default (reader-mode). It handles HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs, etc. You **SHOULD** reach for `open` — not a browser/puppeteer tool — for fetching and inspecting web content.
- For URLs, `read` fetches the page and returns clean extracted text/markdown by default (reader-mode). It handles HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs, etc. You **SHOULD** reach for `read` — not a browser/puppeteer tool — for fetching and inspecting web content.
## Parameters
- `path` — file path or URL; may include `:selector` suffix (required)
@@ -28,7 +28,7 @@ Max {{DEFAULT_MAX_LINES}} lines per call.
# Chunks
Each anchor `@full.chunk.path#CCCC` (with `-` prefixes for nesting depth) in the output identifies a chunk. Use `full.chunk.path#CCCC` as-is to read truncated chunks.
If you need a canonical target list, run `open(path="file", sel="?")`. That listing shows chunk paths with IDs and is the safest structural discovery mode. Summary lines in this listing are orientation hints; follow a selector with `open(path="file", sel="chunk#ID")` or use `raw` when you need exact source.
If you need a canonical target list, run `read(path="file", sel="?")`. That listing shows chunk paths with IDs and is the safest structural discovery mode. Summary lines in this listing are orientation hints; follow a selector with `read(path="file", sel="chunk#ID")` or use `raw` when you need exact source.
Line numbers in the gutter are absolute file line numbers.
{{#if chunkAutoIndent}}
@@ -60,15 +60,15 @@ When used against a SQLite database (`.sqlite`, `.sqlite3`, `.db`, `.db3`), retu
- `file.db?q=SELECT …` — read-only SELECT query
# URLs
Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom feeds, JSON endpoints, PDFs at URLs, and similar text-based resources. Returns clean reader-mode text/markdown — no browser required. Use `sel="raw"` for untouched HTML; `timeout` to override the default request timeout. You **SHOULD** prefer `open` over a browser/puppeteer tool for fetching URL content; only use a browser when the page requires JS execution, authentication, or interactive actions (clicks, forms, scrolling).
Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom feeds, JSON endpoints, PDFs at URLs, and similar text-based resources. Returns clean reader-mode text/markdown — no browser required. Use `sel="raw"` for untouched HTML; `timeout` to override the default request timeout. You **SHOULD** prefer `read` over a browser/puppeteer tool for fetching URL content; only use a browser when the page requires JS execution, authentication, or interactive actions (clicks, forms, scrolling).
</instruction>
<critical>
- You **MUST** `open` before editing — never invent chunk names or IDs.
- Chunk names are truncated (e.g., `handleRequest` becomes `fn_handleRequ`). Always copy chunk paths from `open` or `?` output — never construct them from source identifiers.
- You **MUST** use `open` (never bash `cat`/`head`/`tail`/`less`/`more`/`ls`/`tar`/`unzip`/`curl`/`wget`) for all file, directory, archive, and URL reads.
- You **MUST NOT** reach for a browser/puppeteer tool to fetch static web content — `open` handles HTML, PDFs, JSON, feeds, and docs directly. Reserve browser tools for JS-heavy pages or interactive flows.
- You **MUST** `read` before editing — never invent chunk names or IDs.
- Chunk names are truncated (e.g., `handleRequest` becomes `fn_handleRequ`). Always copy chunk paths from `read` or `?` output — never construct them from source identifiers.
- You **MUST** use `read` (never bash `cat`/`head`/`tail`/`less`/`more`/`ls`/`tar`/`unzip`/`curl`/`wget`) for all file, directory, archive, and URL reads.
- You **MUST NOT** reach for a browser/puppeteer tool to fetch static web content — `read` handles HTML, PDFs, JSON, feeds, and docs directly. Reserve browser tools for JS-heavy pages or interactive flows.
- You **MUST** always include the `path` parameter; never call with `{}`.
- For specific line ranges, use `sel`: `open(path="file", sel="L50-L150")` — not `cat -n file | sed`.
- For specific line ranges, use `sel`: `read(path="file", sel="L50-L150")` — not `cat -n file | sed`.
- You **MAY** use `sel` with URL reads; the tool paginates cached fetched output.
</critical>
@@ -1,11 +1,9 @@
Opens and reads the content at the specified path or URL.
Reads the content at the specified path or URL.
<instruction>
The `open` tool is multi-purpose and far more capable than its name might suggest — it inspects files, directories, archives, SQLite databases, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), **and URLs**.
(Previously named `read`. The old name is still accepted as an alias in agent configs and tool lists, but the canonical name is `open`.)
- You **MUST** parallelize calls when exploring related files
- For URLs, `open` fetches the page and returns clean extracted text/markdown by default (reader-mode). It handles HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs, etc. You **SHOULD** reach for `open` — not a browser/puppeteer tool — for fetching and inspecting web content.
The `read` tool is multi-purpose and more capable than it looks — inspects files, directories, archives, SQLite databases, images, documents (PDF/DOCX/PPTX/XLSX/RTF/EPUB/ipynb), **and URLs**.
- You **MUST** parallelize reads when exploring related files
- For URLs, `read` fetches the page and returns clean extracted text/markdown by default (reader-mode). It handles HTML pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom, JSON endpoints, PDFs, etc. You **SHOULD** reach for `read` — not a browser/puppeteer tool — for fetching and inspecting web content.
## Parameters
- `path` — file path or URL (required)
@@ -49,13 +47,13 @@ For `.sqlite`, `.sqlite3`, `.db`, `.db3`:
- `file.db?q=SELECT …` — read-only SELECT query
# URLs
Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom feeds, JSON endpoints, PDFs at URLs, and similar text-based resources. Returns clean reader-mode text/markdown — no browser required. Use `sel="raw"` for untouched HTML; `timeout` to override the default request timeout. You **SHOULD** prefer `open` over a browser/puppeteer tool for fetching URL content; only use a browser when the page requires JS execution, authentication, or interactive actions (clicks, forms, scrolling).
Extracts content from web pages, GitHub issues/PRs, Stack Overflow, Wikipedia, Reddit, NPM, arXiv, RSS/Atom feeds, JSON endpoints, PDFs at URLs, and similar text-based resources. Returns clean reader-mode text/markdown — no browser required. Use `sel="raw"` for untouched HTML; `timeout` to override the default request timeout. You **SHOULD** prefer `read` over a browser/puppeteer tool for fetching URL content; only use a browser when the page requires JS execution, authentication, or interactive actions (clicks, forms, scrolling).
</instruction>
<critical>
- You **MUST** use `open` (never bash `cat`/`head`/`tail`/`less`/`more`/`ls`/`tar`/`unzip`/`curl`/`wget`) for all file, directory, archive, and URL reads.
- You **MUST NOT** reach for a browser/puppeteer tool to fetch static web content — `open` handles HTML, PDFs, JSON, feeds, and docs directly. Reserve browser tools for JS-heavy pages or interactive flows.
- You **MUST** use `read` (never bash `cat`/`head`/`tail`/`less`/`more`/`ls`/`tar`/`unzip`/`curl`/`wget`) for all file, directory, archive, and URL reads.
- You **MUST NOT** reach for a browser/puppeteer tool to fetch static web content — `read` handles HTML, PDFs, JSON, feeds, and docs directly. Reserve browser tools for JS-heavy pages or interactive flows.
- You **MUST** always include the `path` parameter; never call with `{}`.
- For specific line ranges, use `sel`: `open(path="file", sel="L50-L150")` — not `cat -n file | sed`.
- For specific line ranges, use `sel`: `read(path="file", sel="L50-L150")` — not `cat -n file | sed`.
- You **MAY** use `sel` with URL reads; the tool paginates cached fetched output.
</critical>
+4 -6
View File
@@ -116,11 +116,10 @@ import {
isSearchProviderPreference,
type LspStartupServerInfo,
loadSshTool,
OpenTool,
PythonTool,
ReadTool,
ResolveTool,
renderSearchToolBm25Description,
resolveToolAlias,
setPreferredImageProvider,
setPreferredSearchProvider,
type Tool,
@@ -268,8 +267,8 @@ export {
GrepTool,
HIDDEN_TOOLS,
loadSshTool,
OpenTool as ReadTool,
PythonTool,
ReadTool,
ResolveTool,
type ToolSession,
WriteTool,
@@ -1354,9 +1353,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
const toolNamesFromRegistry = Array.from(toolRegistry.keys());
const requestedToolNames =
(options.toolNames
? [...new Set(options.toolNames.map(name => resolveToolAlias(name.toLowerCase())))]
: undefined) ?? toolNamesFromRegistry;
(options.toolNames ? [...new Set(options.toolNames.map(name => name.toLowerCase()))] : undefined) ??
toolNamesFromRegistry;
const normalizedRequested = requestedToolNames.filter(name => toolRegistry.has(name));
const includeExitPlanMode = requestedToolNames.includes("exit_plan_mode");
const mcpDiscoveryEnabled = settings.get("mcp.discoveryMode") ?? false;
@@ -18,7 +18,7 @@ export interface PruneConfig {
export const DEFAULT_PRUNE_CONFIG: PruneConfig = {
protectTokens: 40_000,
minimumSavings: 20_000,
protectedTools: ["skill", "open", "read"],
protectedTools: ["skill", "read"],
};
export interface PruneResult {
@@ -44,8 +44,7 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
if (!path) continue;
switch (block.name) {
case "read": // legacy alias
case "open":
case "read":
fileOps.read.add(path);
break;
case "write":
+2 -2
View File
@@ -561,7 +561,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
toolNames = Array.from(tools.keys());
} else {
// Use defaults
toolNames = ["open", "bash", "python", "edit", "write"]; // TODO: Why?
toolNames = ["read", "bash", "python", "edit", "write"]; // TODO: Why?
}
}
@@ -573,7 +573,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
}));
// Filter skills to only include those with read tool
const hasRead = tools?.has("open");
const hasRead = tools?.has("read");
const filteredSkills = hasRead ? skills : [];
const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [
+1 -1
View File
@@ -567,7 +567,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
}
const planModeState = this.session.getPlanModeState?.();
const planModeTools = ["open", "grep", "find", "ls", "lsp", "web_search"];
const planModeTools = ["read", "grep", "find", "ls", "lsp", "web_search"];
const effectiveAgent: typeof agent = planModeState?.enabled
? {
...agent,
@@ -6,7 +6,6 @@
* the specialized tools instead.
*/
import { type BashInterceptorRule, DEFAULT_BASH_INTERCEPTOR_RULES } from "../config/settings-schema";
import { resolveToolAlias } from "./index";
export interface InterceptionResult {
/** If true, the bash command should be blocked */
@@ -51,7 +50,7 @@ export function checkBashInterception(
for (const { rule, regex } of compiled) {
// Only block if the suggested tool is actually available
if (!availableTools.some(name => resolveToolAlias(name) === rule.tool)) {
if (!availableTools.includes(rule.tool)) {
continue;
}
+1 -1
View File
@@ -1189,7 +1189,7 @@ async function materializeReadUrlCacheEntry(
}
async function persistReadUrlArtifact(session: ToolSession, output: string): Promise<string | undefined> {
const { path: artifactPath, id } = (await session.allocateOutputArtifact?.("open")) ?? {};
const { path: artifactPath, id } = (await session.allocateOutputArtifact?.("read")) ?? {};
if (!artifactPath) return undefined;
await Bun.write(artifactPath, output);
return id;
+4 -20
View File
@@ -43,10 +43,10 @@ import {
import { GrepTool } from "./grep";
import { InspectImageTool } from "./inspect-image";
import { NotebookTool } from "./notebook";
import { OpenTool } from "./open";
import { wrapToolWithMetaNotice } from "./output-meta";
import { PollTool } from "./poll-tool";
import { PythonTool } from "./python";
import { ReadTool } from "./read";
import { RenderMermaidTool } from "./render-mermaid";
import { createReportToolIssueTool, isAutoQaEnabled } from "./report-tool-issue";
import { ResolveTool } from "./resolve";
@@ -82,9 +82,9 @@ export * from "./gh";
export * from "./grep";
export * from "./inspect-image";
export * from "./notebook";
export * from "./open";
export * from "./poll-tool";
export * from "./python";
export * from "./read";
export * from "./render-mermaid";
export * from "./report-tool-issue";
export * from "./resolve";
@@ -230,7 +230,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
grep: s => new GrepTool(s),
lsp: LspTool.createIf,
notebook: s => new NotebookTool(s),
open: s => new OpenTool(s),
read: s => new ReadTool(s),
inspect_image: s => new InspectImageTool(s),
browser: s => new BrowserTool(s),
checkpoint: CheckpointTool.createIf,
@@ -244,20 +244,6 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
write: s => new WriteTool(s),
};
/**
* Legacy tool-name aliases. Accepted wherever users supply tool names
* (agent `tools:` fields, `--tools` flags, settings, etc.) and normalized
* to the canonical name before lookup.
*/
export const TOOL_ALIASES: Record<string, string> = {
read: "open",
};
/** Resolve a possibly-aliased tool name to its canonical registry key. */
export function resolveToolAlias(name: string): string {
return TOOL_ALIASES[name] ?? name;
}
export const HIDDEN_TOOLS: Record<string, ToolFactory> = {
submit_result: s => new SubmitResultTool(s),
report_finding: () => reportFindingTool,
@@ -305,9 +291,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
const includeSubmitResult = session.requireSubmitResultTool === true;
const enableLsp = session.enableLsp ?? true;
const requestedTools =
toolNames && toolNames.length > 0
? [...new Set(toolNames.map(name => resolveToolAlias(name.toLowerCase())))]
: undefined;
toolNames && toolNames.length > 0 ? [...new Set(toolNames.map(name => name.toLowerCase()))] : undefined;
if (requestedTools && !requestedTools.includes("exit_plan_mode")) {
requestedTools.push("exit_plan_mode");
}
+2 -4
View File
@@ -590,8 +590,7 @@ function formatStatusEvent(event: PythonStatusEvent, theme: Theme): string {
// Build description based on common fields
switch (op) {
case "read": // legacy alias
case "open":
case "read":
parts.push(`${data.chars} chars`);
if (data.path) parts.push(`from ${shortenPath(String(data.path))}`);
break;
@@ -795,8 +794,7 @@ function formatStatusEventExpanded(event: PythonStatusEvent, theme: Theme): stri
case "git_branch":
if (data.branches) addItems(data.branches as unknown[], b => String(b));
break;
case "read": // legacy alias
case "open":
case "read":
case "cat":
case "head":
case "tail":
@@ -21,8 +21,8 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
import { parseInternalUrl } from "../internal-urls/parse";
import type { InternalUrl } from "../internal-urls/types";
import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
import openDescription from "../prompts/tools/open.md" with { type: "text" };
import openChunkDescription from "../prompts/tools/open-chunk.md" with { type: "text" };
import readDescription from "../prompts/tools/read.md" with { type: "text" };
import readChunkDescription from "../prompts/tools/read-chunk.md" with { type: "text" };
import type { ToolSession } from "../sdk";
import {
DEFAULT_MAX_BYTES,
@@ -71,7 +71,7 @@ import {
import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
import { toolResult } from "./tool-result";
const PROSE_LANGUAGES = new Set(["text", "log", "restructuredtext"]);
const PROSE_LANGUAGES = new Set(["markdown", "text", "log", "asciidoc", "restructuredtext"]);
function isProseLanguage(language: string | undefined): boolean {
return language !== undefined && PROSE_LANGUAGES.has(language);
@@ -368,15 +368,15 @@ function prependSuffixResolutionNotice(text: string, suffixResolution?: { from:
return text ? `${notice}\n${text}` : notice;
}
const openSchema = Type.Object({
const readSchema = Type.Object({
path: Type.String({ description: "Path or URL to read" }),
sel: Type.Optional(Type.String({ description: "Selector: chunk path, L10-L50, or raw" })),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds", default: 20 })),
});
export type OpenToolInput = Static<typeof openSchema>;
export type ReadToolInput = Static<typeof readSchema>;
export interface OpenToolDetails {
export interface ReadToolDetails {
kind?: "file" | "url";
truncation?: TruncationResult;
isDirectory?: boolean;
@@ -391,7 +391,7 @@ export interface OpenToolDetails {
meta?: OutputMeta;
}
type OpenParams = OpenToolInput;
type ReadParams = ReadToolInput;
/** Parsed representation of the `sel` parameter. */
type ParsedSelector =
@@ -465,11 +465,11 @@ function parseSqliteSelectorInput(selector: string | undefined): { subPath: stri
* Reads files with support for images, converted documents (via markit), and text.
* Directories return a formatted listing with modification times.
*/
export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
readonly name = "open";
export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
readonly name = "read";
readonly label = "Read";
readonly description: string;
readonly parameters = openSchema;
readonly parameters = readSchema;
readonly nonAbortable = true;
readonly strict = true;
@@ -487,11 +487,11 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
this.#inspectImageEnabled = session.settings.get("inspect_image.enabled");
this.description =
resolveEditMode(session) === "chunk"
? prompt.render(openChunkDescription, {
? prompt.render(readChunkDescription, {
anchorStyle: resolveAnchorStyle(session.settings),
chunkAutoIndent: resolveChunkAutoIndent(),
})
: prompt.render(openDescription, {
: prompt.render(readDescription, {
DEFAULT_LIMIT: String(this.#defaultLimit),
DEFAULT_MAX_LINES: String(DEFAULT_MAX_LINES),
IS_HASHLINE_MODE: displayMode.hashLines,
@@ -593,14 +593,14 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
offset: number | undefined,
limit: number | undefined,
options: {
details?: OpenToolDetails;
details?: ReadToolDetails;
sourcePath?: string;
sourceUrl?: string;
sourceInternal?: string;
entityLabel: string;
ignoreResultLimits?: boolean;
},
): AgentToolResult<OpenToolDetails> {
): AgentToolResult<ReadToolDetails> {
const displayMode = resolveFileDisplayMode(this.session);
const details = options.details ?? {};
const allLines = text.split("\n");
@@ -701,9 +701,9 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
archivePath: string,
subPath: string,
limit: number | undefined,
details: OpenToolDetails,
details: ReadToolDetails,
signal?: AbortSignal,
): Promise<AgentToolResult<OpenToolDetails>> {
): Promise<AgentToolResult<ReadToolDetails>> {
const DEFAULT_LIMIT = 500;
const effectiveLimit = limit ?? DEFAULT_LIMIT;
const entries = archive.listDirectory(subPath);
@@ -727,8 +727,8 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
const output = results.length > 0 ? results.join("\n") : "(empty archive directory)";
const text = prependSuffixResolutionNotice(output, details.suffixResolution);
const truncation = truncateHead(text, { maxLines: Number.MAX_SAFE_INTEGER });
const directoryDetails: OpenToolDetails = { ...details, isDirectory: true };
const resultBuilder = toolResult<OpenToolDetails>(directoryDetails).text(truncation.content);
const directoryDetails: ReadToolDetails = { ...details, isDirectory: true };
const resultBuilder = toolResult<ReadToolDetails>(directoryDetails).text(truncation.content);
resultBuilder.sourcePath(archivePath).limits({ resultLimit: limitMeta.resultLimit?.reached });
if (truncation.truncated) {
directoryDetails.truncation = truncation;
@@ -743,12 +743,12 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
limit: number | undefined,
resolvedArchivePath: ResolvedArchiveReadPath,
signal?: AbortSignal,
): Promise<AgentToolResult<OpenToolDetails>> {
): Promise<AgentToolResult<ReadToolDetails>> {
throwIfAborted(signal);
const archive = await openArchive(resolvedArchivePath.absolutePath);
throwIfAborted(signal);
const details: OpenToolDetails = {
const details: ReadToolDetails = {
resolvedPath: resolvedArchivePath.absolutePath,
suffixResolution: resolvedArchivePath.suffixResolution,
};
@@ -772,7 +772,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
const entry = await archive.readFile(resolvedArchivePath.archiveSubPath);
const text = decodeUtf8Text(entry.bytes);
if (text === null) {
return toolResult<OpenToolDetails>(details)
return toolResult<ReadToolDetails>(details)
.text(
prependSuffixResolutionNotice(
`[Cannot read binary archive entry '${entry.path}' (${formatBytes(entry.size)})]`,
@@ -799,14 +799,14 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
sel: string | undefined,
resolvedSqlitePath: ResolvedSqliteReadPath,
signal?: AbortSignal,
): Promise<AgentToolResult<OpenToolDetails>> {
): Promise<AgentToolResult<ReadToolDetails>> {
throwIfAborted(signal);
const selectorInput = sel
? parseSqliteSelectorInput(sel)
: { subPath: resolvedSqlitePath.sqliteSubPath, queryString: resolvedSqlitePath.queryString };
const selector = parseSqliteSelector(selectorInput.subPath, selectorInput.queryString);
const details: OpenToolDetails = {
const details: ReadToolDetails = {
resolvedPath: resolvedSqlitePath.absolutePath,
suffixResolution: resolvedSqlitePath.suffixResolution,
};
@@ -826,7 +826,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
);
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
details.truncation = truncation.truncated ? truncation : undefined;
const resultBuilder = toolResult<OpenToolDetails>(details)
const resultBuilder = toolResult<ReadToolDetails>(details)
.text(truncation.content)
.sourcePath(resolvedSqlitePath.absolutePath)
.limits({ resultLimit: listLimit.meta.resultLimit?.reached });
@@ -845,7 +845,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
const remaining = sampleRows.totalCount - sampleRows.rows.length;
output += `\n[${remaining} more rows; use sel="${selector.table}?limit=20&offset=${sampleRows.rows.length}" to continue]`;
}
return toolResult<OpenToolDetails>(details)
return toolResult<ReadToolDetails>(details)
.text(prependSuffixResolutionNotice(output, resolvedSqlitePath.suffixResolution))
.sourcePath(resolvedSqlitePath.absolutePath)
.done();
@@ -857,7 +857,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
? getRowByKey(db, selector.table, lookup, selector.key)
: getRowByRowId(db, selector.table, selector.key);
if (!row) {
return toolResult<OpenToolDetails>(details)
return toolResult<ReadToolDetails>(details)
.text(
prependSuffixResolutionNotice(
`No row found in table '${selector.table}' for key '${selector.key}'.`,
@@ -867,14 +867,14 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
.sourcePath(resolvedSqlitePath.absolutePath)
.done();
}
return toolResult<OpenToolDetails>(details)
return toolResult<ReadToolDetails>(details)
.text(prependSuffixResolutionNotice(renderRow(row), resolvedSqlitePath.suffixResolution))
.sourcePath(resolvedSqlitePath.absolutePath)
.done();
}
case "query": {
const page = queryRows(db, selector.table, selector);
return toolResult<OpenToolDetails>(details)
return toolResult<ReadToolDetails>(details)
.text(
prependSuffixResolutionNotice(
renderTable(page.columns, page.rows, {
@@ -892,7 +892,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
}
case "raw": {
const result = executeReadQuery(db, selector.sql);
return toolResult<OpenToolDetails>(details)
return toolResult<ReadToolDetails>(details)
.text(
prependSuffixResolutionNotice(
renderTable(result.columns, result.rows, {
@@ -923,11 +923,11 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
async execute(
_toolCallId: string,
params: OpenParams,
params: ReadParams,
signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<OpenToolDetails>,
_onUpdate?: AgentToolUpdateCallback<ReadToolDetails>,
_toolContext?: AgentToolContext,
): Promise<AgentToolResult<OpenToolDetails>> {
): Promise<AgentToolResult<ReadToolDetails>> {
let { path: readPath, sel, timeout } = params;
if (readPath.startsWith("file://")) {
readPath = expandPath(readPath);
@@ -1071,7 +1071,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
if (suffixResolution) {
text = prependSuffixResolutionNotice(text, suffixResolution);
}
return toolResult<OpenToolDetails>({
return toolResult<ReadToolDetails>({
resolvedPath: absolutePath,
suffixResolution,
chunk: chunkResult.chunk,
@@ -1083,7 +1083,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
// Read the file based on type
let content: Array<TextContent | ImageContent>;
let details: OpenToolDetails = {};
let details: ReadToolDetails = {};
let sourcePath: string | undefined;
let truncationInfo:
| { result: TruncationResult; options: { direction: "head"; startLine?: number; totalFileLines?: number } }
@@ -1178,7 +1178,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
if (suffixResolution) {
text = prependSuffixResolutionNotice(text, suffixResolution);
}
return toolResult<OpenToolDetails>({
return toolResult<ReadToolDetails>({
resolvedPath: absolutePath,
suffixResolution,
chunk: chunkResult.chunk,
@@ -1222,7 +1222,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
totalFileLines === 0
? "The file is empty."
: `Use sel=L1 to read from the start, or sel=L${totalFileLines} to read the last line.`;
return toolResult<OpenToolDetails>({ resolvedPath: absolutePath, suffixResolution })
return toolResult<ReadToolDetails>({ resolvedPath: absolutePath, suffixResolution })
.text(`Line ${startLineDisplay} is beyond end of file (${totalFileLines} lines total). ${suggestion}`)
.done();
}
@@ -1328,7 +1328,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
* Handle internal URLs (agent://, artifact://, memory://, skill://, rule://, local://, mcp://).
* Supports pagination via offset/limit but rejects them when query extraction is used.
*/
async #handleInternalUrl(url: string, offset?: number, limit?: number): Promise<AgentToolResult<OpenToolDetails>> {
async #handleInternalUrl(url: string, offset?: number, limit?: number): Promise<AgentToolResult<ReadToolDetails>> {
const internalRouter = this.session.internalRouter!;
// Check if URL has query extraction (agent:// only).
@@ -1355,7 +1355,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
// Resolve the internal URL
const resource = await internalRouter.resolve(url);
const details: OpenToolDetails = { resolvedPath: resource.sourcePath };
const details: ReadToolDetails = { resolvedPath: resource.sourcePath };
// If extraction was used, return directly (no pagination)
if (hasExtraction) {
@@ -1376,7 +1376,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
absolutePath: string,
limit: number | undefined,
signal?: AbortSignal,
): Promise<AgentToolResult<OpenToolDetails>> {
): Promise<AgentToolResult<ReadToolDetails>> {
const DEFAULT_LIMIT = 500;
const effectiveLimit = limit ?? DEFAULT_LIMIT;
@@ -1425,7 +1425,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
const output = results.join("\n");
const truncation = truncateHead(output, { maxLines: Number.MAX_SAFE_INTEGER });
const details: OpenToolDetails = {
const details: ReadToolDetails = {
isDirectory: true,
};
@@ -1445,7 +1445,7 @@ export class OpenTool implements AgentTool<typeof openSchema, OpenToolDetails> {
// TUI Renderer
// =============================================================================
interface OpenRenderArgs {
interface ReadRenderArgs {
path?: string;
file_path?: string;
sel?: string;
@@ -1456,8 +1456,8 @@ interface OpenRenderArgs {
raw?: boolean;
}
export const openToolRenderer = {
renderCall(args: OpenRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
export const readToolRenderer = {
renderCall(args: ReadRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
if (isReadableUrlPath(args.file_path || args.path || "")) {
return renderReadUrlCall(args, _options, uiTheme);
}
@@ -1479,10 +1479,10 @@ export const openToolRenderer = {
},
renderResult(
result: { content: Array<{ type: string; text?: string }>; details?: OpenToolDetails },
result: { content: Array<{ type: string; text?: string }>; details?: ReadToolDetails },
_options: RenderResultOptions,
uiTheme: Theme,
args?: OpenRenderArgs,
args?: ReadRenderArgs,
): Component {
const urlDetails = result.details as ReadUrlToolDetails | undefined;
if (urlDetails?.kind === "url" || isReadableUrlPath(args?.file_path || args?.path || "")) {
+2 -2
View File
@@ -21,8 +21,8 @@ import { ghRunWatchToolRenderer } from "./gh-renderer";
import { grepToolRenderer } from "./grep";
import { inspectImageToolRenderer } from "./inspect-image-renderer";
import { notebookToolRenderer } from "./notebook";
import { openToolRenderer } from "./open";
import { pythonToolRenderer } from "./python";
import { readToolRenderer } from "./read";
import { resolveToolRenderer } from "./resolve";
import { searchToolBm25Renderer } from "./search-tool-bm25";
import { sshToolRenderer } from "./ssh";
@@ -57,7 +57,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
lsp: lspToolRenderer as ToolRenderer,
notebook: notebookToolRenderer as ToolRenderer,
inspect_image: inspectImageToolRenderer as ToolRenderer,
read: openToolRenderer as ToolRenderer,
read: readToolRenderer as ToolRenderer,
resolve: resolveToolRenderer as ToolRenderer,
search_tool_bm25: searchToolBm25Renderer as ToolRenderer,
ssh: sshToolRenderer as ToolRenderer,
+3 -3
View File
@@ -215,17 +215,17 @@ describe("parseArgs", () => {
test("parses --no-tools with explicit --tools flags", () => {
const result = parseArgs(["--no-tools", "--tools", "read,bash"]);
expect(result.noTools).toBe(true);
expect(result.tools).toEqual(["open", "bash"]);
expect(result.tools).toEqual(["read", "bash"]);
});
test("lowercases tool names passed to --tools", () => {
const result = parseArgs(["--tools", "Read,Grep"]);
expect(result.tools).toEqual(["open", "grep"]);
expect(result.tools).toEqual(["read", "grep"]);
});
test("parses --tools=value with equals syntax", () => {
const result = parseArgs(["--tools=read,bash"]);
expect(result.tools).toEqual(["open", "bash"]);
expect(result.tools).toEqual(["read", "bash"]);
});
test("parses --tools=value with single tool", () => {
@@ -5,7 +5,7 @@ import * as path from "node:path";
import { processFileArguments } from "@oh-my-pi/pi-coding-agent/cli/file-processor";
import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { OpenTool } from "@oh-my-pi/pi-coding-agent/tools/open";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
// 1x1 red PNG image as base64 (smallest valid PNG)
const TINY_PNG_BASE64 =
@@ -71,7 +71,7 @@ describe("blockImages setting", () => {
const imagePath = path.join(testDir, "test.png");
fs.writeFileSync(imagePath, Buffer.from(TINY_PNG_BASE64, "base64"));
const tool = new OpenTool(
const tool = new ReadTool(
createTestToolSession(testDir, Settings.isolated({ "inspect_image.enabled": false })),
);
const result = await tool.execute("test-1", { path: imagePath });
@@ -87,7 +87,7 @@ describe("blockImages setting", () => {
const textPath = path.join(testDir, "test.txt");
fs.writeFileSync(textPath, "Hello, world!");
const tool = new OpenTool(createTestToolSession(testDir));
const tool = new ReadTool(createTestToolSession(testDir));
const result = await tool.execute("test-2", { path: textPath });
expect(result.content).toHaveLength(1);
@@ -105,7 +105,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
await session.activateDiscoveredMCPTools(["mcp_slack_post_message"]);
expect(session.getActiveToolNames()).toEqual(
expect.arrayContaining(["open", "search_tool_bm25", "mcp_github_create_issue", "mcp_slack_post_message"]),
expect.arrayContaining(["read", "search_tool_bm25", "mcp_github_create_issue", "mcp_slack_post_message"]),
);
expect(session.getSelectedMCPToolNames()).toEqual(["mcp_github_create_issue", "mcp_slack_post_message"]);
});
@@ -127,7 +127,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
slashCommands: [],
enableMCP: false,
enableLsp: false,
toolNames: ["open", "search_tool_bm25"],
toolNames: ["read", "search_tool_bm25"],
customTools: [
createMcpCustomTool("mcp_github_create_issue", "github", "create_issue"),
createMcpCustomTool("mcp_slack_post_message", "slack", "post_message"),
@@ -136,7 +136,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
try {
expect(session.getSelectedMCPToolNames()).toEqual(["mcp_github_create_issue"]);
expect(session.getActiveToolNames()).toEqual(
expect.arrayContaining(["open", "search_tool_bm25", "mcp_github_create_issue"]),
expect.arrayContaining(["read", "search_tool_bm25", "mcp_github_create_issue"]),
);
expect(session.getActiveToolNames()).not.toContain("mcp_slack_post_message");
} finally {
@@ -158,7 +158,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
slashCommands: [],
enableMCP: false,
enableLsp: false,
toolNames: ["open", "search_tool_bm25"],
toolNames: ["read", "search_tool_bm25"],
customTools: [createMcpCustomTool("mcp_github_create_issue", "github", "create_issue")],
});
@@ -185,7 +185,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
slashCommands: [],
enableMCP: false,
enableLsp: false,
toolNames: ["open", "search_tool_bm25"],
toolNames: ["read", "search_tool_bm25"],
customTools: [
createMcpCustomTool("mcp_github_create_issue", "github", "create_issue"),
createMcpCustomTool("mcp_slack_post_message", "slack", "post_message"),
@@ -221,7 +221,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
slashCommands: [],
enableMCP: false,
enableLsp: false,
toolNames: ["open", "search_tool_bm25"],
toolNames: ["read", "search_tool_bm25"],
customTools: [
createMcpCustomTool("mcp_github_create_issue", "github", "create_issue"),
createMcpCustomTool("mcp_slack_post_message", "slack", "post_message"),
@@ -232,7 +232,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
expect(resumedSession.serviceTier).toBe("priority");
expect(resumedSession.getSelectedMCPToolNames()).toEqual(["mcp_slack_post_message"]);
expect(resumedSession.getActiveToolNames()).toEqual(
expect.arrayContaining(["open", "search_tool_bm25", "mcp_slack_post_message"]),
expect.arrayContaining(["read", "search_tool_bm25", "mcp_slack_post_message"]),
);
expect(resumedSession.systemPrompt).toContain("mcp_slack_post_message");
expect(fs.readFileSync(sessionFile!, "utf8")).toBe(persistedBeforeResume);
@@ -274,7 +274,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
slashCommands: [],
enableMCP: false,
enableLsp: false,
toolNames: ["open", "search_tool_bm25"],
toolNames: ["read", "search_tool_bm25"],
customTools: [
createMcpCustomTool("mcp_github_create_issue", "github", "create_issue"),
createMcpCustomTool("mcp_slack_post_message", "slack", "post_message"),
@@ -285,7 +285,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
expect(session.serviceTier).toBe("priority");
expect(session.getSelectedMCPToolNames()).toEqual(["mcp_github_create_issue"]);
expect(session.getActiveToolNames()).toEqual(
expect.arrayContaining(["open", "search_tool_bm25", "mcp_github_create_issue"]),
expect.arrayContaining(["read", "search_tool_bm25", "mcp_github_create_issue"]),
);
expect(session.sessionManager.buildSessionContext().hasPersistedMCPToolSelection).toBe(false);
expect(fs.readFileSync(sessionFile!, "utf8")).toBe(persistedBeforeResume);
@@ -310,13 +310,13 @@ describe("createAgentSession MCP discovery prompt gating", () => {
slashCommands: [],
enableMCP: false,
enableLsp: false,
toolNames: ["open", "search_tool_bm25", "mcp_github_create_issue"],
toolNames: ["read", "search_tool_bm25", "mcp_github_create_issue"],
customTools: [
createMcpCustomTool("mcp_github_create_issue", "github", "create_issue"),
createMcpCustomTool("mcp_slack_post_message", "slack", "post_message"),
],
});
await firstSession.setActiveToolsByName(["open", "search_tool_bm25"]);
await firstSession.setActiveToolsByName(["read", "search_tool_bm25"]);
expect(firstSession.getSelectedMCPToolNames()).toEqual([]);
const sessionFile = firstSession.sessionFile;
expect(sessionFile).toBeDefined();
@@ -337,7 +337,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
slashCommands: [],
enableMCP: false,
enableLsp: false,
toolNames: ["open", "search_tool_bm25", "mcp_github_create_issue"],
toolNames: ["read", "search_tool_bm25", "mcp_github_create_issue"],
customTools: [
createMcpCustomTool("mcp_github_create_issue", "github", "create_issue"),
createMcpCustomTool("mcp_slack_post_message", "slack", "post_message"),
@@ -345,7 +345,7 @@ describe("createAgentSession MCP discovery prompt gating", () => {
});
try {
expect(resumedSession.getSelectedMCPToolNames()).toEqual([]);
expect(resumedSession.getActiveToolNames()).toEqual(expect.arrayContaining(["open", "search_tool_bm25"]));
expect(resumedSession.getActiveToolNames()).toEqual(expect.arrayContaining(["read", "search_tool_bm25"]));
expect(resumedSession.getActiveToolNames()).not.toContain("mcp_github_create_issue");
} finally {
await resumedSession.dispose();
@@ -104,7 +104,7 @@ describe("createAgentSession defaultInactive tool activation", () => {
try {
expect(session.getActiveToolNames()).toEqual(
expect.arrayContaining(["open", "default_active_tool", "default_inactive_tool"]),
expect.arrayContaining(["read", "default_active_tool", "default_inactive_tool"]),
);
expect(session.systemPrompt).toContain("default_inactive_tool");
} finally {
+6 -6
View File
@@ -14,9 +14,9 @@ import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash";
import { CancelJobTool } from "@oh-my-pi/pi-coding-agent/tools/cancel-job";
import { FindTool } from "@oh-my-pi/pi-coding-agent/tools/find";
import { GrepTool } from "@oh-my-pi/pi-coding-agent/tools/grep";
import { OpenTool } from "@oh-my-pi/pi-coding-agent/tools/open";
import { wrapToolWithMetaNotice } from "@oh-my-pi/pi-coding-agent/tools/output-meta";
import { PollTool } from "@oh-my-pi/pi-coding-agent/tools/poll-tool";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
import { WriteTool } from "@oh-my-pi/pi-coding-agent/tools/write";
import * as markitUtils from "@oh-my-pi/pi-coding-agent/utils/markit";
import { $which, Snowflake } from "@oh-my-pi/pi-utils";
@@ -216,7 +216,7 @@ function createTestToolContext(toolNames: string[]): AgentToolContext {
describe("Coding Agent Tools", () => {
let testDir: string;
let session: ToolSession;
let readTool: OpenTool;
let readTool: ReadTool;
let writeTool: WriteTool;
let editTool: EditTool;
let bashTool: BashTool;
@@ -235,7 +235,7 @@ describe("Coding Agent Tools", () => {
// Create tools for this test directory
session = createTestToolSession(testDir);
readTool = wrapToolWithMetaNotice(new OpenTool(session));
readTool = wrapToolWithMetaNotice(new ReadTool(session));
writeTool = wrapToolWithMetaNotice(new WriteTool(session));
editTool = wrapToolWithMetaNotice(new EditTool(session));
bashTool = wrapToolWithMetaNotice(new BashTool(session));
@@ -519,7 +519,7 @@ describe("Coding Agent Tools", () => {
fs.writeFileSync(testFile, pngBuffer);
const legacyReadTool = wrapToolWithMetaNotice(
new OpenTool(createTestToolSession(testDir, Settings.isolated({ "inspect_image.enabled": false }))),
new ReadTool(createTestToolSession(testDir, Settings.isolated({ "inspect_image.enabled": false }))),
);
const result = await legacyReadTool.execute("test-call-img-1", { path: testFile });
@@ -543,7 +543,7 @@ describe("Coding Agent Tools", () => {
fs.writeFileSync(testFile, pngBuffer);
const inspectModeReadTool = wrapToolWithMetaNotice(
new OpenTool(createTestToolSession(testDir, Settings.isolated({ "inspect_image.enabled": true }))),
new ReadTool(createTestToolSession(testDir, Settings.isolated({ "inspect_image.enabled": true }))),
);
const result = await inspectModeReadTool.execute("test-call-img-guidance", { path: testFile });
const output = getTextOutput(result);
@@ -820,7 +820,7 @@ function b() {
undefined,
createTestToolContext(["read"]),
),
).rejects.toThrow(/Use the `open` tool instead of cat\/head\/tail/);
).rejects.toThrow(/Use the `read` tool instead of cat\/head\/tail/);
});
it("should allow an explicit empty interceptor pattern list", async () => {
@@ -4,7 +4,7 @@ import * as os from "node:os";
import * as path from "node:path";
import { type SettingPath, Settings } from "@oh-my-pi/pi-coding-agent/config/settings";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
import { OpenTool } from "@oh-my-pi/pi-coding-agent/tools/open";
import { ReadTool } from "@oh-my-pi/pi-coding-agent/tools/read";
import * as imageResize from "@oh-my-pi/pi-coding-agent/utils/image-resize";
import * as toolsManager from "@oh-my-pi/pi-coding-agent/utils/tools-manager";
import * as scrapers from "@oh-my-pi/pi-coding-agent/web/scrapers/types";
@@ -59,7 +59,7 @@ describe("read tool URL selector shorthands", () => {
it("supports embedded raw selectors in URL paths", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const pageUrl = "https://example.com/embedded-raw";
const loadPageSpy = vi.spyOn(scrapers, "loadPage").mockImplementation(async requestedUrl => {
if (requestedUrl !== pageUrl) {
@@ -85,7 +85,7 @@ describe("read tool URL selector shorthands", () => {
it("supports embedded line selectors in URL paths", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const pageUrl = "https://example.com/embedded-lines";
const loadPageSpy = vi.spyOn(scrapers, "loadPage").mockImplementation(async requestedUrl => {
if (requestedUrl !== pageUrl) {
@@ -152,7 +152,7 @@ describe("read tool URL handling", () => {
it("returns an image content block when fetching image URLs", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const imageBytes = new Uint8Array([137, 80, 78, 71]);
vi.spyOn(scrapers, "loadPage").mockResolvedValue({
ok: true,
@@ -196,7 +196,7 @@ describe("read tool URL handling", () => {
it("resizes fetched images before emitting image content blocks", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const resizeSpy = vi.spyOn(imageResize, "resizeImage").mockResolvedValue({
buffer: new Uint8Array([1, 2, 3]),
mimeType: "image/jpeg",
@@ -242,7 +242,7 @@ describe("read tool URL handling", () => {
it("keeps markit extracted text for image responses", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const extractedText = "Converted image text content that is definitely longer than fifty characters.";
vi.spyOn(imageResize, "resizeImage").mockResolvedValue({
buffer: new Uint8Array([1, 2, 3]),
@@ -286,7 +286,7 @@ describe("read tool URL handling", () => {
});
it("falls back to text-only output for unsupported image MIME types", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const fetchBinarySpy = vi.spyOn(scraperUtils, "fetchBinary");
vi.spyOn(scrapers, "loadPage").mockResolvedValue({
ok: true,
@@ -309,7 +309,7 @@ describe("read tool URL handling", () => {
it("uses binary conversion fallback for unsupported image MIME when extension is convertible", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const convertedText = "Converted image text from markit fallback with sufficient length to pass threshold.";
const fetchBinarySpy = vi.spyOn(scraperUtils, "fetchBinary").mockResolvedValue({
ok: true,
@@ -342,7 +342,7 @@ describe("read tool URL handling", () => {
it("does not treat text/html at .png paths as inline images", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
vi.spyOn(scraperUtils, "fetchBinary").mockResolvedValue({ ok: false, error: "not an image" });
vi.spyOn(scrapers, "loadPage").mockResolvedValue({
ok: true,
@@ -364,7 +364,7 @@ describe("read tool URL handling", () => {
it("falls back to textual output when inline image refetch fails", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const convertSpy = vi.spyOn(scraperUtils, "convertWithMarkit");
vi.spyOn(scrapers, "loadPage").mockResolvedValue({
ok: true,
@@ -390,7 +390,7 @@ describe("read tool URL handling", () => {
});
it("falls back to text-only output when image payload bytes are invalid", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
vi.spyOn(scrapers, "loadPage").mockResolvedValue({
ok: true,
status: 200,
@@ -431,7 +431,7 @@ describe("read tool URL handling", () => {
});
it("prefers rendered page content over site-wide llms.txt for deep pages", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const pageUrl = "https://bun.com/reference/bun/UnixSocketOptions";
const pageHtml = "<html><body><main><h1>UnixSocketOptions</h1><p>Page-specific docs.</p></main></body></html>";
const renderedMarkdown = `# UnixSocketOptions\n\n${"Page-specific API docs. ".repeat(8)}`;
@@ -493,7 +493,7 @@ describe("read tool URL handling", () => {
it("uses section-scoped llms.txt fallback without requesting the site-wide file", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const pageUrl = "https://example.com/docs/reference/widget";
const pageHtml = "<html><body><nav>Docs</nav><main><h1>Widget</h1></main></body></html>";
const lowQualityRender = `${"Please enable JavaScript to view this page.\n".repeat(6)}${"navigation\n".repeat(4)}`;
@@ -574,7 +574,7 @@ describe("read tool URL handling", () => {
it("prefers Parallel extract before other HTML renderers when configured", async () => {
process.env.PARALLEL_API_KEY = "test-parallel-key";
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const pageUrl = "https://example.com/parallel-page";
const pageHtml = "<html><body><main><h1>Parallel Page</h1></main></body></html>";
const ensureToolSpy = vi.spyOn(toolsManager, "ensureTool");
@@ -649,7 +649,7 @@ describe("read tool URL handling", () => {
it("reuses cached output for repeated plain URL reads", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const pageUrl = "https://example.com/repeated-read-cache";
const loadPageSpy = vi.spyOn(scrapers, "loadPage").mockResolvedValue({
ok: true,
@@ -673,7 +673,7 @@ describe("read tool URL handling", () => {
it("supports offset and limit for URL reads using cached output", async () => {
const session = createSession();
const tool = new OpenTool(session);
const tool = new ReadTool(session);
const pageUrl = "https://example.com/offset-test";
const loadPageSpy = vi.spyOn(scrapers, "loadPage").mockResolvedValue({
ok: true,
@@ -702,6 +702,6 @@ describe("read tool URL handling", () => {
expect(pagedText?.text).toContain("Line 2");
expect(pagedText?.text).not.toContain("Line 3");
expect(loadPageSpy).not.toHaveBeenCalled();
expect(fs.readdirSync(path.join(testDir, "session")).some(file => file.endsWith(".open.log"))).toBe(true);
expect(fs.readdirSync(path.join(testDir, "session")).some(file => file.endsWith(".read.log"))).toBe(true);
});
});
@@ -55,7 +55,7 @@ describe("createTools", () => {
// Core tools should always be present
expect(names).toContain("python");
expect(names).toContain("bash");
expect(names).toContain("open");
expect(names).toContain("read");
expect(names).toContain("edit");
expect(names).toContain("write");
expect(names).toContain("grep");
@@ -130,7 +130,7 @@ describe("createTools", () => {
const tools = await createTools(session, ["read", "lsp", "write"]);
const names = tools.map(t => t.name);
expect(names).toEqual(["open", "write", "exit_plan_mode"]);
expect(names).toEqual(["read", "write", "exit_plan_mode"]);
});
it("excludes lsp tool when disabled", async () => {
@@ -146,7 +146,7 @@ describe("createTools", () => {
const tools = await createTools(session, ["read", "write"]);
const names = tools.map(t => t.name);
expect(names).toEqual(["open", "write", "exit_plan_mode"]);
expect(names).toEqual(["read", "write", "exit_plan_mode"]);
});
it("ignores vim as an unknown requested tool even when vim edit mode is active", async () => {
@@ -158,7 +158,7 @@ describe("createTools", () => {
const tools = await createTools(session, ["read", "vim"]);
const names = tools.map(t => t.name);
expect(names).toEqual(["open", "exit_plan_mode"]);
expect(names).toEqual(["read", "exit_plan_mode"]);
});
it("lowercases requested tool subset", async () => {
@@ -166,7 +166,7 @@ describe("createTools", () => {
const tools = await createTools(session, ["Read", "Write"]);
const names = tools.map(t => t.name);
expect(names).toEqual(["open", "write", "exit_plan_mode"]);
expect(names).toEqual(["read", "write", "exit_plan_mode"]);
});
it("includes hidden tools when explicitly requested", async () => {
@@ -80,7 +80,7 @@ describe("tool path root alias", () => {
it("reads cwd when path is slash", async () => {
const tools = await createTools(createTestSession(tempDir));
const tool = tools.find(entry => entry.name === "open");
const tool = tools.find(entry => entry.name === "read");
expect(tool).toBeDefined();
if (!tool) throw new Error("Missing read tool");
@@ -5,7 +5,7 @@ import * as os from "node:os";
import * as path from "node:path";
import "../../src/tools/renderers";
import { Settings } from "../../src/config/settings";
import { OpenTool } from "../../src/tools/open";
import { ReadTool } from "../../src/tools/read";
import { parseSqlitePathCandidates, parseSqliteSelector, renderTable } from "../../src/tools/sqlite-reader";
import { WriteTool } from "../../src/tools/write";
@@ -13,7 +13,7 @@ type ToolTextResult = {
content: Array<{ type: string; text?: string }>;
};
type SessionLike = ConstructorParameters<typeof OpenTool>[0];
type SessionLike = ConstructorParameters<typeof ReadTool>[0];
function getText(result: ToolTextResult): string {
return result.content
@@ -150,7 +150,7 @@ describe("SQLite tool support", () => {
let sqlitePath: string;
let sqliteDbPath: string;
let invalidDbPath: string;
let readTool: OpenTool;
let readTool: ReadTool;
let writeTool: WriteTool;
let originalEditVariant: string | undefined;
@@ -167,7 +167,7 @@ describe("SQLite tool support", () => {
await Bun.write(invalidDbPath, "not sqlite\nstill text\n");
const session = createSession(tmpDir);
readTool = new OpenTool(session);
readTool = new ReadTool(session);
writeTool = new WriteTool(session);
});
+244 -79
View File
@@ -34,12 +34,12 @@ from omp_rpc import ( # noqa: E402
MessageUpdateEvent,
RpcClient,
RpcError,
RpcProcessExitError,
RpcNotification,
RpcProcessExitError,
TodoAutoClearEvent,
TodoItem,
TodoReminderEvent,
TodoPhase,
TodoReminderEvent,
ToolExecutionEndEvent,
ToolExecutionStartEvent,
ToolExecutionUpdateEvent,
@@ -151,8 +151,9 @@ TODOS = [
"Summarize what was awkward, impossible, ambiguous, or under-documented with concrete examples spanning all fixtures.",
]
TS_FIXTURE = textwrap.dedent(
"""\
TS_FIXTURE = (
textwrap.dedent(
"""\
function sealed(_target: Function): void {}
function trace(_label: string) {
@@ -302,10 +303,13 @@ TS_FIXTURE = textwrap.dedent(
}
}
"""
).strip() + "\n"
).strip()
+ "\n"
)
RUST_FIXTURE = textwrap.dedent(
"""\
RUST_FIXTURE = (
textwrap.dedent(
"""\
use std::collections::HashMap;
use std::fmt;
@@ -480,10 +484,13 @@ RUST_FIXTURE = textwrap.dedent(
}
}
"""
).strip() + "\n"
).strip()
+ "\n"
)
GO_FIXTURE = textwrap.dedent(
"""\
GO_FIXTURE = (
textwrap.dedent(
"""\
package main
import (
@@ -604,12 +611,14 @@ GO_FIXTURE = textwrap.dedent(
fmt.Println(sink.Snapshot())
}
"""
).strip() + "\n"
).strip()
+ "\n"
)
PYTHON_FIXTURE = textwrap.dedent(
"""\
PYTHON_FIXTURE = (
textwrap.dedent(
"""\
from __future__ import annotations
from dataclasses import dataclass, field
@@ -684,10 +693,13 @@ PYTHON_FIXTURE = textwrap.dedent(
def write_report(lines: Iterable[str], target: Path) -> None:
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
"""
).strip() + "\n"
).strip()
+ "\n"
)
MARKDOWN_FIXTURE = textwrap.dedent(
"""\
MARKDOWN_FIXTURE = (
textwrap.dedent(
"""\
---
title: Tooling Evaluation Notes
owner: Fixtures Team
@@ -734,7 +746,9 @@ MARKDOWN_FIXTURE = textwrap.dedent(
2. List insertions should not collapse into one paragraph.
3. Deleting this section should not damage the fenced blocks above.
"""
).strip() + "\n"
).strip()
+ "\n"
)
REFERENCE_FILES = {
"PROMPT.md": PROMPT + "\n",
@@ -775,8 +789,13 @@ def build_fixture_prompt() -> str:
f"- `{fixture_file}` ({FIXTURE_DESCRIPTIONS.get(language, language)})"
for language, fixture_file in FIXTURES
]
surface = "Test surface (exercise every file in this workspace):\n" + "\n".join(lines)
return PROMPT.format(FIXTURE_SURFACE=surface) + "\n\nExercise every fixture in one session; do not skip any file type."
surface = "Test surface (exercise every file in this workspace):\n" + "\n".join(
lines
)
return (
PROMPT.format(FIXTURE_SURFACE=surface)
+ "\n\nExercise every fixture in one session; do not skip any file type."
)
@dataclass
@@ -827,7 +846,7 @@ class ModelProgress:
todo_items: dict[str, tuple[str, str]] = field(default_factory=dict)
TOOL_WHITELIST = ("open", "edit", "todo_write", "report_tool_issue")
TOOL_WHITELIST = ("read", "edit", "todo_write", "report_tool_issue")
MODEL_LABEL_WIDTH = 30
STATUS_WIDTH = 7
TOKENS_WIDTH = 9
@@ -868,14 +887,20 @@ def format_count(value: int | None) -> str:
return str(value)
def extract_usage_tokens(message: dict[str, Any]) -> tuple[int | None, int | None, int | None]:
def extract_usage_tokens(
message: dict[str, Any],
) -> tuple[int | None, int | None, int | None]:
usage = message.get("usage")
if not isinstance(usage, dict):
return None, None, None
token_input = usage.get("input")
token_output = usage.get("output")
token_total = usage.get("totalTokens")
if not isinstance(token_total, int) and isinstance(token_input, int) and isinstance(token_output, int):
if (
not isinstance(token_total, int)
and isinstance(token_input, int)
and isinstance(token_output, int)
):
token_total = token_input + token_output
return (
token_input if isinstance(token_input, int) else None,
@@ -884,7 +909,9 @@ def extract_usage_tokens(message: dict[str, Any]) -> tuple[int | None, int | Non
)
def build_todo_state_from_phases(phases: tuple[TodoPhase, ...]) -> tuple[list[str], dict[str, tuple[str, str]]]:
def build_todo_state_from_phases(
phases: tuple[TodoPhase, ...],
) -> tuple[list[str], dict[str, tuple[str, str]]]:
order: list[str] = []
items: dict[str, tuple[str, str]] = {}
for phase in phases:
@@ -904,7 +931,9 @@ def seed_todo_state(todos: list[str]) -> tuple[list[str], dict[str, tuple[str, s
return order, items
def summarize_todo_state(order: list[str], items: dict[str, tuple[str, str]]) -> tuple[int, int, str | None]:
def summarize_todo_state(
order: list[str], items: dict[str, tuple[str, str]]
) -> tuple[int, int, str | None]:
if not order:
return 0, 0, None
completed = 0
@@ -956,7 +985,15 @@ def apply_todo_ops(progress: ModelProgress, args: Any) -> None:
task_id = raw_task.get("id")
if not isinstance(task_id, str) or not task_id:
task_id = f"task-{task_index}"
tasks.append(TodoItem(id=task_id, content=content, status=status, notes=None, details=None))
tasks.append(
TodoItem(
id=task_id,
content=content,
status=status,
notes=None,
details=None,
)
)
phase_id = raw_phase.get("id")
name = raw_phase.get("name")
if not isinstance(name, str) or not name:
@@ -964,14 +1001,24 @@ def apply_todo_ops(progress: ModelProgress, args: Any) -> None:
if not isinstance(phase_id, str) or not phase_id:
phase_id = f"phase-{phase_index}"
phases.append(TodoPhase(id=phase_id, name=name, tasks=tuple(tasks)))
progress.todo_order, progress.todo_items = build_todo_state_from_phases(tuple(phases))
progress.todo_order, progress.todo_items = build_todo_state_from_phases(
tuple(phases)
)
elif op == "update":
task_id = raw_op.get("id")
if not isinstance(task_id, str) or task_id not in progress.todo_items:
continue
content, status = progress.todo_items[task_id]
next_content = raw_op.get("content") if isinstance(raw_op.get("content"), str) else content
next_status = raw_op.get("status") if isinstance(raw_op.get("status"), str) else status
next_content = (
raw_op.get("content")
if isinstance(raw_op.get("content"), str)
else content
)
next_status = (
raw_op.get("status")
if isinstance(raw_op.get("status"), str)
else status
)
progress.todo_items[task_id] = (next_content, next_status)
elif op == "add_task":
phase = raw_op.get("phase")
@@ -980,13 +1027,21 @@ def apply_todo_ops(progress: ModelProgress, args: Any) -> None:
continue
task_id = task_payload.get("id")
if not isinstance(task_id, str) or not task_id:
task_id = raw_op.get("id") if isinstance(raw_op.get("id"), str) else f"task-{len(progress.todo_order) + 1}"
task_id = (
raw_op.get("id")
if isinstance(raw_op.get("id"), str)
else f"task-{len(progress.todo_order) + 1}"
)
content = task_payload.get("content")
status = task_payload.get("status")
if not isinstance(content, str) or not isinstance(status, str):
continue
if task_id not in progress.todo_items:
insert_after = raw_op.get("after") if isinstance(raw_op.get("after"), str) else None
insert_after = (
raw_op.get("after")
if isinstance(raw_op.get("after"), str)
else None
)
if insert_after in progress.todo_order:
index = progress.todo_order.index(insert_after) + 1
progress.todo_order.insert(index, task_id)
@@ -998,7 +1053,9 @@ def apply_todo_ops(progress: ModelProgress, args: Any) -> None:
if not isinstance(task_id, str):
continue
progress.todo_items.pop(task_id, None)
progress.todo_order = [candidate for candidate in progress.todo_order if candidate != task_id]
progress.todo_order = [
candidate for candidate in progress.todo_order if candidate != task_id
]
elif op == "add_phase":
raw_tasks = raw_op.get("tasks")
if not isinstance(raw_tasks, list):
@@ -1016,21 +1073,30 @@ def apply_todo_ops(progress: ModelProgress, args: Any) -> None:
progress.todo_order.append(task_id)
progress.todo_items[task_id] = (content, status)
progress.todo_completed, progress.todo_total, progress.todo_current = summarize_todo_state(
progress.todo_order, progress.todo_items
progress.todo_completed, progress.todo_total, progress.todo_current = (
summarize_todo_state(progress.todo_order, progress.todo_items)
)
class ProgressPrinter:
def __init__(self, runs: list[tuple[str, str]], *, stream: TextIO | None = None, interactive: bool | None = None) -> None:
def __init__(
self,
runs: list[tuple[str, str]],
*,
stream: TextIO | None = None,
interactive: bool | None = None,
) -> None:
self._lock = threading.Lock()
self._stream = sys.stdout if stream is None else stream
self._interactive = self._stream.isatty() if interactive is None else interactive
self._console = Console(file=self._stream, force_terminal=self._interactive, soft_wrap=False)
self._interactive = (
self._stream.isatty() if interactive is None else interactive
)
self._console = Console(
file=self._stream, force_terminal=self._interactive, soft_wrap=False
)
self._model_order = [run_id for run_id, _ in runs]
self._states = {
run_id: ModelProgress(model=run_id, label=label)
for run_id, label in runs
run_id: ModelProgress(model=run_id, label=label) for run_id, label in runs
}
self._fixtures_dir: str | None = None
self._results_dir: str | None = None
@@ -1062,8 +1128,8 @@ class ProgressPrinter:
with self._lock:
progress = self._states[model]
progress.todo_order, progress.todo_items = seed_todo_state(todos)
progress.todo_completed, progress.todo_total, progress.todo_current = summarize_todo_state(
progress.todo_order, progress.todo_items
progress.todo_completed, progress.todo_total, progress.todo_current = (
summarize_todo_state(progress.todo_order, progress.todo_items)
)
progress.last_activity = f"seeded {progress.todo_total} todos"
self._refresh_locked()
@@ -1077,7 +1143,9 @@ class ProgressPrinter:
def mark_turn_end(self, model: str, turns: int) -> None:
self._mutate_model(model, turns=turns)
def note_tool_start(self, model: str, tool_name: str, intent: str | None, tool_calls: int, args: Any) -> None:
def note_tool_start(
self, model: str, tool_name: str, intent: str | None, tool_calls: int, args: Any
) -> None:
with self._lock:
progress = self._states[model]
progress.status = "run"
@@ -1096,9 +1164,11 @@ class ProgressPrinter:
with self._lock:
progress = self._states[model]
progress.todo_order = [task.id for task in todos]
progress.todo_items = {task.id: (task.content, task.status) for task in todos}
progress.todo_completed, progress.todo_total, progress.todo_current = summarize_todo_state(
progress.todo_order, progress.todo_items
progress.todo_items = {
task.id: (task.content, task.status) for task in todos
}
progress.todo_completed, progress.todo_total, progress.todo_current = (
summarize_todo_state(progress.todo_order, progress.todo_items)
)
self._refresh_locked()
@@ -1127,7 +1197,13 @@ class ProgressPrinter:
progress.last_activity = progress.last_text or "drafting"
self._refresh_locked()
def note_usage(self, model: str, token_input: int | None, token_output: int | None, token_total: int | None) -> None:
def note_usage(
self,
model: str,
token_input: int | None,
token_output: int | None,
token_total: int | None,
) -> None:
self._mutate_model(
model,
token_input=token_input,
@@ -1136,10 +1212,17 @@ class ProgressPrinter:
)
def mark_completed(self, model: str, duration_seconds: float) -> None:
self._mutate_model(model, status="done", duration_seconds=duration_seconds, last_activity="completed")
self._mutate_model(
model,
status="done",
duration_seconds=duration_seconds,
last_activity="completed",
)
def mark_failed(self, model: str, error: str) -> None:
self._mutate_model(model, status="failed", error=error, last_activity=truncate_text(error, 72))
self._mutate_model(
model, status="failed", error=error, last_activity=truncate_text(error, 72)
)
def finish(self, message: str) -> None:
with self._lock:
@@ -1168,7 +1251,11 @@ class ProgressPrinter:
def _build_renderable_locked(self) -> Group:
done = sum(1 for state in self._states.values() if state.status == "done")
failed = sum(1 for state in self._states.values() if state.status == "failed")
active = sum(1 for state in self._states.values() if state.status not in {"pending", "done", "failed"})
active = sum(
1
for state in self._states.values()
if state.status not in {"pending", "done", "failed"}
)
summary = Text()
summary.append(f"done {done}/{len(self._states)}", style="bold green")
@@ -1215,7 +1302,11 @@ class ProgressPrinter:
)
if self._final_message:
footer = Panel(self._final_message, border_style="green" if failed == 0 else "red", box=box.ROUNDED)
footer = Panel(
self._final_message,
border_style="green" if failed == 0 else "red",
box=box.ROUNDED,
)
return Group(header, table, footer)
return Group(header, table)
@@ -1244,7 +1335,11 @@ class ProgressPrinter:
@staticmethod
def _model_text(state: ModelProgress) -> Text:
text = Text(truncate_text(state.label, 34), style="bold")
activity = state.error if state.status == "failed" and state.error else state.last_activity
activity = (
state.error
if state.status == "failed" and state.error
else state.last_activity
)
if activity and activity not in {"waiting", "completed"}:
text.append("\n")
text.append(truncate_text(activity, 34), style="dim")
@@ -1294,7 +1389,14 @@ def serialize_notification(notification: Any) -> dict[str, Any]:
class ModelRunRecorder:
def __init__(self, run_id: str, model: str, fixture: str, printer: ProgressPrinter, jsonl_path: Path) -> None:
def __init__(
self,
run_id: str,
model: str,
fixture: str,
printer: ProgressPrinter,
jsonl_path: Path,
) -> None:
self.run_id = run_id
self.model = model
self.fixture = fixture
@@ -1344,7 +1446,9 @@ class ModelRunRecorder:
def record_tool_execution_start(self, event: ToolExecutionStartEvent) -> None:
self._touch()
self.tool_calls += 1
self.printer.note_tool_start(self.run_id, event.tool_name, event.intent, self.tool_calls, event.args)
self.printer.note_tool_start(
self.run_id, event.tool_name, event.intent, self.tool_calls, event.args
)
def record_tool_execution_update(self, _event: ToolExecutionUpdateEvent) -> None:
self._touch()
@@ -1376,7 +1480,9 @@ class ModelRunRecorder:
self._consumed_assistant_messages += 1
token_input, token_output, token_total = extract_usage_tokens(message)
if token_total is not None and (self.token_total is None or token_total >= self.token_total):
if token_total is not None and (
self.token_total is None or token_total >= self.token_total
):
self.token_input = token_input
self.token_output = token_output
self.token_total = token_total
@@ -1390,7 +1496,11 @@ class ModelRunRecorder:
if not isinstance(message, dict) or message.get("role") != "assistant":
continue
text = assistant_text(message)
if assistant_count >= self._consumed_assistant_messages and isinstance(text, str) and text.strip():
if (
assistant_count >= self._consumed_assistant_messages
and isinstance(text, str)
and text.strip()
):
self.review_sections.append(text.strip())
assistant_count += 1
@@ -1402,7 +1512,9 @@ class ModelRunRecorder:
self.token_input = token_input
self.token_output = token_output
self.token_total = token_total
self.printer.note_usage(self.run_id, token_input, token_output, token_total)
self.printer.note_usage(
self.run_id, token_input, token_output, token_total
)
break
def record_message_update(self, event: MessageUpdateEvent) -> None:
@@ -1411,11 +1523,15 @@ class ModelRunRecorder:
partial = assistant_event.get("partial")
if isinstance(partial, dict):
token_input, token_output, token_total = extract_usage_tokens(partial)
if token_total is not None and (self.token_total is None or token_total >= self.token_total):
if token_total is not None and (
self.token_total is None or token_total >= self.token_total
):
self.token_input = token_input
self.token_output = token_output
self.token_total = token_total
self.printer.note_usage(self.run_id, token_input, token_output, token_total)
self.printer.note_usage(
self.run_id, token_input, token_output, token_total
)
delta_type = assistant_event.get("type")
delta = assistant_event.get("delta")
@@ -1430,10 +1546,16 @@ class ModelRunRecorder:
def record_todo_reminder(self, event: TodoReminderEvent) -> None:
self._touch()
self.todo_completed = sum(1 for task in event.todos if task.status == "completed")
self.todo_completed = sum(
1 for task in event.todos if task.status == "completed"
)
self.todo_total = len(event.todos)
in_progress = next((task.content for task in event.todos if task.status == "in_progress"), None)
pending = next((task.content for task in event.todos if task.status == "pending"), None)
in_progress = next(
(task.content for task in event.todos if task.status == "in_progress"), None
)
pending = next(
(task.content for task in event.todos if task.status == "pending"), None
)
self.todo_current = in_progress or pending
self.printer.note_todo_reminder(self.run_id, event.todos)
@@ -1445,7 +1567,9 @@ class ModelRunRecorder:
def sync_final_todos(self, phases: tuple[TodoPhase, ...]) -> None:
order, items = build_todo_state_from_phases(phases)
self.todo_completed, self.todo_total, self.todo_current = summarize_todo_state(order, items)
self.todo_completed, self.todo_total, self.todo_current = summarize_todo_state(
order, items
)
flattened = tuple(task for phase in phases for task in phase.tasks)
self.printer.note_todo_reminder(self.run_id, flattened)
@@ -1469,7 +1593,6 @@ class ModelRunRecorder:
handle.write(json.dumps(payload) + "\n")
def run_model_sync(
*,
model: str,
@@ -1488,7 +1611,10 @@ def run_model_sync(
review_slug = slugify(shorten_model_name(model))
review_path = results_dir / f"review_{review_slug}.md"
jsonl_path = Path(tempfile.gettempdir()) / f"rate-edit-tool-{results_dir.name}-{model_slug}.jsonl"
jsonl_path = (
Path(tempfile.gettempdir())
/ f"rate-edit-tool-{results_dir.name}-{model_slug}.jsonl"
)
jsonl_path.unlink(missing_ok=True)
jsonl_path.touch()
recorder = ModelRunRecorder(
@@ -1551,14 +1677,23 @@ def run_model_sync(
try:
client.wait_for_idle(timeout=min(remaining, 60.0))
if recorder.auto_retry_active:
time.sleep(min(max(recorder.auto_retry_delay_ms / 1000.0, 0.2), 2.0))
time.sleep(
min(
max(recorder.auto_retry_delay_ms / 1000.0, 0.2), 2.0
)
)
continue
if recorder.agent_ended:
grace = min(0.5, max(deadline - time.monotonic(), 0.0))
if grace > 0:
time.sleep(grace)
if recorder.auto_retry_active:
time.sleep(min(max(recorder.auto_retry_delay_ms / 1000.0, 0.2), 2.0))
time.sleep(
min(
max(recorder.auto_retry_delay_ms / 1000.0, 0.2),
2.0,
)
)
continue
return
if recorder.is_effectively_complete(quiet_seconds=2.0):
@@ -1586,11 +1721,18 @@ def run_model_sync(
stats = client.get_session_stats()
todo_phases = client.get_todos()
recorder.sync_final_todos(todo_phases)
if (recorder.token_total is None or recorder.token_total <= 0) and stats.tokens.total > 0:
if (
recorder.token_total is None or recorder.token_total <= 0
) and stats.tokens.total > 0:
recorder.token_input = stats.tokens.input
recorder.token_output = stats.tokens.output
recorder.token_total = stats.tokens.total
printer.note_usage(run_id, recorder.token_input, recorder.token_output, recorder.token_total)
printer.note_usage(
run_id,
recorder.token_input,
recorder.token_output,
recorder.token_total,
)
review_path.write_text(review_markdown)
provider, model_id = model.split("/", 1)
session_state = {
@@ -1599,7 +1741,9 @@ def run_model_sync(
}
status = "ok"
except Exception as error: # noqa: BLE001
error_message = f"{type(error).__name__}: {error}" if str(error) else type(error).__name__
error_message = (
f"{type(error).__name__}: {error}" if str(error) else type(error).__name__
)
printer.mark_failed(run_id, error_message)
status = "failed"
@@ -1632,6 +1776,7 @@ def run_model_sync(
session_state=session_state,
)
def build_oracle_review_prompt(sources: list[tuple[str, str, str]]) -> str:
review_sections: list[str] = []
for model, fixture, review_path in sorted(sources):
@@ -1659,8 +1804,9 @@ def build_oracle_review_prompt(sources: list[tuple[str, str, str]]) -> str:
return ORACLE_REVIEW_PROMPT.replace("{{REVIEWS}}", review_payload)
def oracle_sources_from_results(results: list[ModelResult]) -> list[tuple[str, str, str]]:
def oracle_sources_from_results(
results: list[ModelResult],
) -> list[tuple[str, str, str]]:
return [(r.model, r.fixture, r.review_path) for r in results if r.review_path]
@@ -1679,6 +1825,7 @@ def oracle_sources_from_dir(results_dir: Path) -> list[tuple[str, str, str]]:
sources.append((model, fixture, str(path)))
return sources
def run_oracle_review_sync(
*,
model: str,
@@ -1715,18 +1862,32 @@ def run_oracle_review_sync(
(results_dir / "oracle_synthesis.md").write_text(synthesis + "\n", encoding="utf-8")
return synthesis
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run OpenRouter fixture evaluations through omp RPC mode.")
parser = argparse.ArgumentParser(
description="Run OpenRouter fixture evaluations through omp RPC mode."
)
parser.add_argument("--omp-bin", default=os.environ.get("OMP_BIN"))
parser.add_argument("--fixtures-dir", default=os.path.expanduser("~/tmp/fixtures"))
parser.add_argument("--results-dir")
parser.add_argument("--timeout", type=float, default=900.0, help="Per run timeout in seconds.")
parser.add_argument("--model", dest="models", action="append", help="Repeat to limit execution to specific models.")
parser.add_argument("--oracle-model", default=ORACLE_MODEL, help="Model used to synthesize findings across all reviews.")
parser.add_argument(
"--rerun-oracle",
dest="rerun_oracle",
help="Skip fixture runs and only synthesize against review_*.md files in this existing results dir.",
"--timeout", type=float, default=900.0, help="Per run timeout in seconds."
)
parser.add_argument(
"--model",
dest="models",
action="append",
help="Repeat to limit execution to specific models.",
)
parser.add_argument(
"--oracle-model",
default=ORACLE_MODEL,
help="Model used to synthesize findings across all reviews.",
)
parser.add_argument(
"--rerun-oracle",
dest="rerun_oracle",
help="Skip fixture runs and only synthesize against review_*.md files in this existing results dir.",
)
return parser.parse_args()
@@ -1766,7 +1927,11 @@ async def run_all(args: argparse.Namespace) -> int:
timestamp = time.strftime("%Y%m%d-%H%M%S")
tmp_root = Path(tempfile.gettempdir())
results_dir = Path(args.results_dir) if args.results_dir else tmp_root / f"omp-fixture-runs-{timestamp}"
results_dir = (
Path(args.results_dir)
if args.results_dir
else tmp_root / f"omp-fixture-runs-{timestamp}"
)
results_dir.mkdir(parents=True, exist_ok=True)
workspace_root = tmp_root / f"rate-edit-tool-workspaces-{timestamp}"
workspace_root.mkdir(parents=True, exist_ok=True)