feat: added strict-mode fallback for OpenAI tool calls with all_strict
- Added `toolStrictMode` support with `all_strict`/`none`/`mixed` options to OpenAI compatibility. - Fixed OpenAI-completion strict-mode flows by capturing failed HTTP responses and retrying once as non-strict. - Fixed completion error reporting by surfacing captured status, headers, and JSON `type`/`param`/`code` details. - Improved strict-schema enforcement with WeakMap memoization and circular-schema detection in sanitization. - Fixed OpenRouter provider lookup by resolving fallback model IDs for suffix and date variants in registry resolution. - Refactored benchmark tooling and added async RPC error-window tracking for scheduled run execution.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { logger, Snowflake } from "@oh-my-pi/pi-utils";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
|
||||
const DELIVERY_RETRY_BASE_MS = 500;
|
||||
const DELIVERY_RETRY_MAX_MS = 30_000;
|
||||
|
||||
@@ -79,7 +79,7 @@ export function createAnalyzeFileTool(options: {
|
||||
});
|
||||
const taskParams: TaskParams = {
|
||||
agent: "quick_task",
|
||||
schema: analyzeFileOutputSchema,
|
||||
schema: JSON.stringify(analyzeFileOutputSchema),
|
||||
tasks,
|
||||
};
|
||||
return taskTool.execute(toolCallId, taskParams, signal, onUpdate);
|
||||
|
||||
@@ -51,6 +51,7 @@ const TRAILING_CANONICAL_MARKERS = [
|
||||
"xhigh",
|
||||
"free",
|
||||
"exacto",
|
||||
"nitro",
|
||||
"original",
|
||||
"optimized",
|
||||
"nvfp4",
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { isRecord, logger } from "@oh-my-pi/pi-utils";
|
||||
import { type Static, Type } from "@sinclair/typebox";
|
||||
import { type ConfigError, ConfigFile } from "../config";
|
||||
import { parseModelString } from "../config/model-resolver";
|
||||
import { parseModelString, resolveProviderModelReference } from "../config/model-resolver";
|
||||
import { isValidThemeColor, type ThemeColor } from "../modes/theme/theme";
|
||||
import type { AuthStorage, OAuthCredential } from "../session/auth-storage";
|
||||
import {
|
||||
@@ -160,6 +160,7 @@ const OpenAICompatSchema = Type.Object({
|
||||
vercelGatewayRouting: Type.Optional(VercelGatewayRoutingSchema),
|
||||
extraBody: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
||||
supportsStrictMode: Type.Optional(Type.Boolean()),
|
||||
toolStrictMode: Type.Optional(Type.Union([Type.Literal("all_strict"), Type.Literal("none")])),
|
||||
});
|
||||
|
||||
const EffortSchema = Type.Union([
|
||||
@@ -1871,7 +1872,7 @@ export class ModelRegistry {
|
||||
* Find a model by provider and ID.
|
||||
*/
|
||||
find(provider: string, modelId: string): Model<Api> | undefined {
|
||||
return this.#models.find(m => m.provider === provider && m.id === modelId);
|
||||
return resolveProviderModelReference(provider, modelId, this.#models);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -62,6 +62,101 @@ export function formatModelSelectorValue(selector: string, thinkingLevel: Thinki
|
||||
return thinkingLevel && thinkingLevel !== ThinkingLevel.Inherit ? `${selector}:${thinkingLevel}` : selector;
|
||||
}
|
||||
|
||||
function getOpenRouterRouteSuffix(modelId: string): { baseId: string; suffix: string } | undefined {
|
||||
const colonIdx = modelId.lastIndexOf(":");
|
||||
if (colonIdx === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const suffix = modelId.slice(colonIdx + 1).trim();
|
||||
if (!suffix || parseThinkingLevel(suffix)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { baseId: modelId.slice(0, colonIdx), suffix };
|
||||
}
|
||||
|
||||
function stripOpenRouterDateSuffix(modelId: string): string | undefined {
|
||||
const stripped = modelId.replace(/-\d{8}(?=$|:)/i, "");
|
||||
return stripped !== modelId ? stripped : undefined;
|
||||
}
|
||||
|
||||
function getOpenRouterFallbackModelIds(modelId: string): string[] {
|
||||
const orderedCandidates: string[] = [];
|
||||
const queue = [modelId];
|
||||
const seen = new Set<string>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const candidate = queue.shift();
|
||||
if (!candidate || seen.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(candidate);
|
||||
orderedCandidates.push(candidate);
|
||||
|
||||
const routedSuffix = getOpenRouterRouteSuffix(candidate);
|
||||
if (routedSuffix) {
|
||||
queue.push(routedSuffix.baseId);
|
||||
}
|
||||
|
||||
const strippedDate = stripOpenRouterDateSuffix(candidate);
|
||||
if (strippedDate) {
|
||||
queue.push(strippedDate);
|
||||
}
|
||||
}
|
||||
|
||||
return orderedCandidates;
|
||||
}
|
||||
|
||||
function cloneModelWithRequestedId(model: Model<Api>, requestedId: string): Model<Api> {
|
||||
return {
|
||||
...model,
|
||||
id: requestedId,
|
||||
...(model.name === model.id ? { name: requestedId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderModelReference(
|
||||
provider: string,
|
||||
modelId: string,
|
||||
availableModels: readonly Model<Api>[],
|
||||
): Model<Api> | undefined {
|
||||
const normalizedProvider = provider.trim().toLowerCase();
|
||||
const normalizedModelId = modelId.trim().toLowerCase();
|
||||
if (!normalizedProvider || !normalizedModelId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const exactMatches = availableModels.filter(
|
||||
model => model.provider.toLowerCase() === normalizedProvider && model.id.toLowerCase() === normalizedModelId,
|
||||
);
|
||||
if (exactMatches.length === 1) {
|
||||
return exactMatches[0];
|
||||
}
|
||||
if (exactMatches.length > 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (normalizedProvider !== "openrouter") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const fallbackId of getOpenRouterFallbackModelIds(modelId).slice(1)) {
|
||||
const baseMatches = availableModels.filter(
|
||||
model =>
|
||||
model.provider.toLowerCase() === normalizedProvider && model.id.toLowerCase() === fallbackId.toLowerCase(),
|
||||
);
|
||||
if (baseMatches.length === 1) {
|
||||
return cloneModelWithRequestedId(baseMatches[0], modelId);
|
||||
}
|
||||
if (baseMatches.length > 1) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface ModelMatchPreferences {
|
||||
/** Most-recently-used model keys (provider/modelId) to prefer when ambiguous. */
|
||||
usageOrder?: string[];
|
||||
@@ -171,17 +266,7 @@ export function findExactModelReferenceMatch(
|
||||
const provider = trimmedReference.substring(0, slashIndex).trim();
|
||||
const modelId = trimmedReference.substring(slashIndex + 1).trim();
|
||||
if (provider && modelId) {
|
||||
const providerMatches = availableModels.filter(
|
||||
model =>
|
||||
model.provider.toLowerCase() === provider.toLowerCase() &&
|
||||
model.id.toLowerCase() === modelId.toLowerCase(),
|
||||
);
|
||||
if (providerMatches.length === 1) {
|
||||
return providerMatches[0];
|
||||
}
|
||||
if (providerMatches.length > 1) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveProviderModelReference(provider, modelId, availableModels);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
@@ -853,10 +938,8 @@ export function resolveCliModel(options: {
|
||||
let exact: (typeof availableModels)[number] | undefined;
|
||||
if (slashIdx !== -1) {
|
||||
const prefix = lower.substring(0, slashIdx);
|
||||
const suffix = lower.substring(slashIdx + 1);
|
||||
exact = availableModels.find(
|
||||
model => model.provider.toLowerCase() === prefix && model.id.toLowerCase() === suffix,
|
||||
);
|
||||
const suffix = trimmedModel.substring(slashIdx + 1);
|
||||
exact = resolveProviderModelReference(prefix, suffix, availableModels);
|
||||
}
|
||||
if (!exact && !trimmedModel.includes(":")) {
|
||||
const canonicalMatch = modelRegistry.resolveCanonicalModel?.(trimmedModel, { availableOnly: false });
|
||||
@@ -905,6 +988,19 @@ export function resolveCliModel(options: {
|
||||
}
|
||||
}
|
||||
|
||||
if (provider) {
|
||||
const exactProviderMatch = resolveProviderModelReference(provider, pattern, availableModels);
|
||||
if (exactProviderMatch) {
|
||||
return {
|
||||
model: exactProviderMatch,
|
||||
selector: formatModelString(exactProviderMatch),
|
||||
warning: undefined,
|
||||
thinkingLevel: undefined,
|
||||
error: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = provider ? availableModels.filter(model => model.provider === provider) : availableModels;
|
||||
const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, preferences, {
|
||||
allowInvalidThinkingSelectorFallback: false,
|
||||
|
||||
@@ -602,7 +602,9 @@ export async function waitForProjectLoaded(client: LspClient, signal?: AbortSign
|
||||
if (signal?.aborted) return;
|
||||
await Promise.race([
|
||||
client.projectLoaded,
|
||||
...(signal ? [new Promise<void>(resolve => signal.addEventListener("abort", () => resolve(), { once: true }))] : []),
|
||||
...(signal
|
||||
? [new Promise<void>(resolve => signal.addEventListener("abort", () => resolve(), { once: true }))]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
sendRequest,
|
||||
setIdleTimeout,
|
||||
syncContent,
|
||||
waitForProjectLoaded,
|
||||
WARMUP_TIMEOUT_MS,
|
||||
waitForProjectLoaded,
|
||||
} from "./client";
|
||||
import { getLinterClient } from "./clients";
|
||||
import { getServersForFile, type LspConfig, loadConfig } from "./config";
|
||||
|
||||
@@ -315,7 +315,8 @@ export class ToolExecutionComponent extends Container {
|
||||
*/
|
||||
#updateSpinnerAnimation(): void {
|
||||
// Spinner for: task tool with partial result, or edit/write while args streaming
|
||||
const isStreamingArgs = !this.#argsComplete && (this.#toolName === "edit" || this.#toolName === "write" || this.#toolName === "vim");
|
||||
const isStreamingArgs =
|
||||
!this.#argsComplete && (this.#toolName === "edit" || this.#toolName === "write" || this.#toolName === "vim");
|
||||
const isBackgroundAsyncTask =
|
||||
this.#toolName === "task" &&
|
||||
(this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
Edits files via syntax-aware chunks. Run `read(path="file.ts")` first.
|
||||
|
||||
- `write` rewrites the entire targeted region — best for most edits.
|
||||
- `replace` does surgical find-and-replace within a chunk — use when making small changes to a large chunk, or batching multiple substitutions.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Subagents lack your conversation history. Every decision, file content, and user
|
||||
- `.description`: UI display only — subagent never sees it
|
||||
- `.assignment`: Complete self-contained instructions. One-liners PROHIBITED; missing acceptance criteria = too vague.
|
||||
- `context`: Shared background prepended to every assignment. Session-specific info only.
|
||||
- `schema`: JTD schema for expected output. Format lives here — **MUST NOT** be duplicated in assignments.
|
||||
- `schema`: JSON-encoded JTD schema for expected output. Format lives here — **MUST NOT** be duplicated in assignments.
|
||||
- `tasks`: Tasks to execute in parallel.
|
||||
- `isolated`: Run in isolated environment; returns patches. Use when tasks edit overlapping files.
|
||||
</parameters>
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
Stateful single-buffer Vim-style editor.
|
||||
Stateful Vim-style editor with multi-buffer support.
|
||||
|
||||
Use this for surgical text edits when motions and compact viewport feedback are more efficient than rewriting full regions.
|
||||
Every call requires `file` — the path to edit. The buffer is loaded automatically on first use.
|
||||
|
||||
Actions:
|
||||
- `open`: load a file into the buffer (auto-saves any previous buffer)
|
||||
- `kbd`: run Vim key sequences, then optionally insert literal text
|
||||
- `{"file": "path/to/file.py"}` — view file (loads buffer if needed)
|
||||
- `{"file": "path/to/file.py", "kbd": ["…"], "insert": "…"}` — edit file
|
||||
|
||||
How `kbd` + `insert` work together:
|
||||
- `kbd` runs Vim key sequences (motions, commands, operators)
|
||||
- `insert` is **raw text** (with real `\n` newlines in JSON) that gets typed into the buffer
|
||||
- For `insert` to work, the last `kbd` entry **MUST** leave the buffer in INSERT mode (via `i`, `o`, `O`, `a`, `A`, `cc`, `C`, `s`, `S`, etc.)
|
||||
- After the call, the tool auto-exits INSERT mode and auto-saves to disk
|
||||
- Set `pause: true` to skip auto-save and stay in the current mode
|
||||
|
||||
Rules:
|
||||
- One active buffer at a time; `open` replaces it
|
||||
- Each `kbd` call auto-saves to disk (unless `pause: true`)
|
||||
- `kbd` array entries run in order; every non-final entry must leave NORMAL mode — if an entry enters INSERT mode, end it with `<Esc>` or merge into one string
|
||||
- `insert` is **raw text** (newlines = real `\n` in JSON), NOT Vim key syntax; the buffer must already be in INSERT mode (via `i`, `o`, `O`, `a`, `A`, `cc`, etc.)
|
||||
- After `insert`, the tool exits INSERT mode and saves automatically (unless `pause: true`)
|
||||
- `pause: true` keeps the current mode active and skips auto-save; use it for multi-step edits
|
||||
- Use `:e!` to reload from disk and discard unsaved changes
|
||||
- Each non-final `kbd` entry must end in NORMAL mode — use `<Esc>` or merge into one string
|
||||
- To recover from mistakes: `{"file": "f.py", "kbd": ["u"]}` to undo, or `{"file": "f.py", "kbd": [":e!<CR>"]}` to reload from disk
|
||||
|
||||
Supported Vim subset: motions (`h/j/k/l`, `w/b/e`, `0/$`, `gg/G`, `{/}`, `f/t`), counts, `.` repeat, insert commands (`i/a/o/O/I/A/cc/C/s/S`), visual mode (`v/V`), operators (`d/c/y/p`), text objects (`iw/aw/i"/a"/i(/a(`), undo/redo (`u`/`<C-r>`), search (`/pattern<CR>`, `n/N`), ex commands (`:s`, `:%s`, `:e`, `:e!`, ranged `:d`).
|
||||
Supported: motions (`h/j/k/l`, `w/b/e`, `0/$`, `gg/G`, `{/}`, `f/t`), counts, `.` repeat, insert (`i/a/o/O/I/A/cc/C/s/S`), visual (`v/V`), operators (`d/c/y/p`), text objects (`iw/aw/i"/a"/i(/a(`), undo/redo (`u`/`<C-r>`), search (`/pattern<CR>`, `n/N`), ex (`:s`, `:%s`, `:e`, `:e!`, ranged `:d`).
|
||||
|
||||
Special keys: `<Esc>` or `<Escape>`, `<CR>` or `<Enter>`, `<BS>`, `<Tab>`, `<C-d>`, `<C-u>`, `<C-r>`, `<C-w>`, `<C-o>`.
|
||||
Special keys: `<Esc>`, `<Escape>`, `<CR>`, `<Enter>`, `<BS>`, `<Tab>`, `<C-d>`, `<C-u>`, `<C-r>`, `<C-w>`, `<C-o>`.
|
||||
|
||||
Examples:
|
||||
- Open file: `{"open":"src/app.ts"}`
|
||||
- Open at line: `{"open":"src/app.ts", "line":42}`
|
||||
- Rename word: `{"kbd":["42G", "ciwnewName<Esc>"]}`
|
||||
- Replace line with multi-line text: `{"kbd":["5G", "cc"], "insert":" if b == 0:\n return None"}`
|
||||
- Add lines below: `{"kbd":["3G", "o"], "insert":"def multiply(a, b):\n return a * b"}`
|
||||
- Global substitution: `{"kbd":[":%s/oldName/newName/g<CR>"]}`
|
||||
- Search and delete: `{"kbd":["/TODO<CR>", "dd"]}`
|
||||
- Delete range of lines: `{"kbd":[":3,5d<CR>"]}`
|
||||
- `{"file": "src/app.ts"}` — view file
|
||||
- `{"file": "src/app.ts", "kbd": ["3G", "ciwnewName<Esc>"]}` — rename word on line 3
|
||||
- `{"file": "src/app.ts", "kbd": ["5G", "cc"], "insert": " if b == 0:\n return None"}` — replace line 5
|
||||
- `{"file": "src/app.ts", "kbd": ["3G", "o"], "insert": "def multiply(a, b):\n return a * b"}` — insert after line 3
|
||||
- `{"file": "src/app.ts", "kbd": [":%s/oldName/newName/g<CR>"]}` — find and replace
|
||||
- `{"file": "src/app.ts", "kbd": ["/TODO<CR>", "dd"]}` — search and delete
|
||||
- `{"file": "src/app.ts", "kbd": [":3,5d<CR>"]}` — delete line range
|
||||
|
||||
@@ -82,9 +82,9 @@ const createTaskSchema = (options: { isolationEnabled: boolean }) => {
|
||||
}),
|
||||
),
|
||||
schema: Type.Optional(
|
||||
Type.Record(Type.String(), Type.Unknown(), {
|
||||
Type.String({
|
||||
description:
|
||||
"JTD schema defining expected response structure. Use typed properties. Output format belongs here — never in context or assignment.",
|
||||
"JSON-encoded JTD schema defining expected response structure. Output format belongs here — never in context or assignment.",
|
||||
}),
|
||||
),
|
||||
tasks: Type.Array(taskItemSchema, {
|
||||
|
||||
@@ -162,9 +162,7 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
|
||||
const stat = await Bun.file(searchPath).stat();
|
||||
isDirectory = stat.isDirectory();
|
||||
} catch {
|
||||
const hint = scopePath.includes(",")
|
||||
? ` (comma-separated paths must each exist relative to cwd)`
|
||||
: "";
|
||||
const hint = scopePath.includes(",") ? ` (comma-separated paths must each exist relative to cwd)` : "";
|
||||
throw new ToolError(`Path not found: ${scopePath}${hint}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,10 +23,8 @@ import { SearchTool } from "../web/search";
|
||||
import { AskTool } from "./ask";
|
||||
import { AstEditTool } from "./ast-edit";
|
||||
import { AstGrepTool } from "./ast-grep";
|
||||
import { PollTool } from "./poll-tool";
|
||||
import { BashTool } from "./bash";
|
||||
import { BrowserTool } from "./browser";
|
||||
|
||||
import { CalculatorTool } from "./calculator";
|
||||
import { CancelJobTool } from "./cancel-job";
|
||||
import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
|
||||
@@ -48,6 +46,7 @@ import { GrepTool } from "./grep";
|
||||
import { InspectImageTool } from "./inspect-image";
|
||||
import { NotebookTool } from "./notebook";
|
||||
import { wrapToolWithMetaNotice } from "./output-meta";
|
||||
import { PollTool } from "./poll-tool";
|
||||
import { PythonTool } from "./python";
|
||||
import { ReadTool } from "./read";
|
||||
import { RenderMermaidTool } from "./render-mermaid";
|
||||
@@ -73,7 +72,6 @@ export * from "../web/search";
|
||||
export * from "./ask";
|
||||
export * from "./ast-edit";
|
||||
export * from "./ast-grep";
|
||||
export * from "./poll-tool";
|
||||
export * from "./bash";
|
||||
export * from "./browser";
|
||||
export * from "./calculator";
|
||||
@@ -87,6 +85,7 @@ export * from "./gh";
|
||||
export * from "./grep";
|
||||
export * from "./inspect-image";
|
||||
export * from "./notebook";
|
||||
export * from "./poll-tool";
|
||||
export * from "./python";
|
||||
export * from "./read";
|
||||
export * from "./render-mermaid";
|
||||
|
||||
@@ -57,7 +57,7 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
|
||||
readonly label = "Submit Result";
|
||||
readonly description =
|
||||
"Finish the task with structured JSON output. Call exactly once at the end of the task.\n\n" +
|
||||
"Pass `result: { data: <your output> }` for success, or `result: { error: \"message\" }` for failure.\n" +
|
||||
'Pass `result: { data: <your output> }` for success, or `result: { error: "message" }` for failure.\n' +
|
||||
"The `data`/`error` wrapper is required — do not put your output directly in `result`.";
|
||||
readonly parameters: TSchema;
|
||||
strict = true;
|
||||
@@ -173,7 +173,7 @@ export class SubmitResultTool implements AgentTool<TSchema, SubmitResultDetails>
|
||||
}
|
||||
if (errorMessage === undefined && data === undefined) {
|
||||
throw new Error(
|
||||
"result must contain either `data` or `error`. Use `{result: {data: <your output>}}` for success or `{result: {error: \"message\"}}` for failure.",
|
||||
'result must contain either `data` or `error`. Use `{result: {data: <your output>}}` for success or `{result: {error: "message"}}` for failure.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,24 +37,27 @@ const INTERNAL_URL_PREFIX = /^(agent|artifact|skill|rule|local|mcp):\/\//;
|
||||
const utf8Decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
const vimSchema = Type.Object({
|
||||
open: Type.Optional(Type.String({ description: "File path to open" })),
|
||||
line: Type.Optional(Type.Number({ description: "1-indexed line to place cursor on open" })),
|
||||
col: Type.Optional(Type.Number({ description: "1-indexed column to place cursor on open" })),
|
||||
kbd: Type.Optional(Type.Array(Type.String(), { description: "Vim key sequences to execute" })),
|
||||
insert: Type.Optional(Type.String({ description: "Raw text to insert literally while in INSERT mode" })),
|
||||
pause: Type.Optional(Type.Boolean({ description: "Return an intermediate snapshot without forcing a mode exit" })),
|
||||
file: Type.String({ description: "File path to edit." }),
|
||||
kbd: Type.Optional(
|
||||
Type.Array(Type.String(), {
|
||||
description: "Vim key sequences to execute against the buffer. Null when just viewing the file.",
|
||||
}),
|
||||
),
|
||||
insert: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Raw text to type into the buffer. kbd must leave INSERT mode active first (e.g. via o, O, i, cc). Null when not inserting.",
|
||||
}),
|
||||
),
|
||||
pause: Type.Optional(
|
||||
Type.Boolean({
|
||||
description: "If true, skip auto-save and keep current mode. Null or false for normal auto-save.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
type VimParams = Static<typeof vimSchema>;
|
||||
|
||||
function isOpenParams(params: VimParams): boolean {
|
||||
return params.open !== undefined;
|
||||
}
|
||||
|
||||
function isKbdParams(params: VimParams): boolean {
|
||||
return params.kbd! !== undefined;
|
||||
}
|
||||
|
||||
function fingerprintEqual(left: VimFingerprint | null, right: VimFingerprint | null): boolean {
|
||||
if (left === null || right === null) {
|
||||
return left === right;
|
||||
@@ -216,7 +219,7 @@ export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
|
||||
readonly parameters = vimSchema;
|
||||
readonly concurrency = "exclusive";
|
||||
|
||||
#engine: VimEngine | null = null;
|
||||
#engines = new Map<string, VimEngine>();
|
||||
#writethrough: WritethroughCallback;
|
||||
|
||||
constructor(private readonly session: ToolSession) {
|
||||
@@ -329,125 +332,117 @@ export class VimTool implements AgentTool<typeof vimSchema, VimToolDetails> {
|
||||
_context?: AgentToolContext,
|
||||
): Promise<AgentToolResult<VimToolDetails>> {
|
||||
return untilAborted(signal, async () => {
|
||||
if (isOpenParams(params)) {
|
||||
// Auto-save previous buffer before opening new file
|
||||
if (this.#engine?.buffer.modified) {
|
||||
await this.#saveBuffer(this.#engine.buffer);
|
||||
}
|
||||
this.#engine = null;
|
||||
const loaded = await this.#loadBuffer(params.open!);
|
||||
const engine = new VimEngine(new VimBuffer(loaded), {
|
||||
// Resolve file path and get-or-create engine for this buffer
|
||||
const { absolutePath } = normalizeTargetPath(params.file, this.session.cwd);
|
||||
let engine = this.#engines.get(absolutePath);
|
||||
let isNewBuffer = false;
|
||||
if (!engine) {
|
||||
const loaded = await this.#loadBuffer(params.file);
|
||||
engine = new VimEngine(new VimBuffer(loaded), {
|
||||
beforeMutate: buffer => this.#beforeMutate(buffer),
|
||||
loadBuffer: path => this.#loadBuffer(path),
|
||||
saveBuffer: (buffer, options) => this.#saveBuffer(buffer, options),
|
||||
});
|
||||
if (params.line || params.col) {
|
||||
engine.setCursor(Math.max(0, (params.line ?? 1) - 1), Math.max(0, (params.col ?? 1) - 1));
|
||||
}
|
||||
engine.viewportStart = params.line ? Math.max(1, params.line - 20) : 1;
|
||||
engine.statusMessage = `Opened ${engine.buffer.displayPath}`;
|
||||
this.#engine = engine;
|
||||
return this.#renderFromEngine(
|
||||
engine,
|
||||
VIM_OPEN_VIEWPORT_LINES,
|
||||
params.line ? Math.max(1, params.line - 20) : 1,
|
||||
);
|
||||
engine.viewportStart = 1;
|
||||
this.#engines.set(absolutePath, engine);
|
||||
isNewBuffer = true;
|
||||
}
|
||||
|
||||
if (isKbdParams(params)) {
|
||||
if (!this.#engine) {
|
||||
throw new ToolError("No active vim buffer. Open a file first.");
|
||||
const sequences = Array.isArray(params.kbd) ? params.kbd : undefined;
|
||||
if (!sequences) {
|
||||
// No kbd — just show the file viewport
|
||||
if (isNewBuffer) {
|
||||
engine.statusMessage = `Opened ${engine.buffer.displayPath}`;
|
||||
}
|
||||
const engine = this.#engine;
|
||||
const sequences = params.kbd!;
|
||||
const commandText = sequences.join(" ");
|
||||
const tokenGroups = splitTokensBySequence(sequences);
|
||||
const beforeText = serializeBufferText(engine.buffer);
|
||||
return this.#renderFromEngine(engine, VIM_OPEN_VIEWPORT_LINES, engine.viewportStart);
|
||||
}
|
||||
|
||||
if (this.session.getPlanModeState?.()?.enabled) {
|
||||
if (params.insert !== undefined) {
|
||||
throw new ToolError("Plan mode: vim is read-only; insert payloads are not allowed.");
|
||||
}
|
||||
const preview = engine.clone({
|
||||
beforeMutate: async () => {
|
||||
throw new VimInputError(
|
||||
"Plan mode: vim is read-only; only navigation, search, open, and close are allowed.",
|
||||
);
|
||||
},
|
||||
saveBuffer: async () => {
|
||||
throw new VimInputError("Plan mode: :w is not allowed.");
|
||||
},
|
||||
});
|
||||
await executeKeySequences(preview, tokenGroups, commandText);
|
||||
// Execute kbd sequences
|
||||
const commandText = sequences.join(" ");
|
||||
const tokenGroups = splitTokensBySequence(sequences);
|
||||
const beforeText = serializeBufferText(engine.buffer);
|
||||
|
||||
if (this.session.getPlanModeState?.()?.enabled) {
|
||||
if (params.insert !== undefined) {
|
||||
throw new ToolError("Plan mode: vim is read-only; insert payloads are not allowed.");
|
||||
}
|
||||
const preview = engine.clone({
|
||||
beforeMutate: async () => {
|
||||
throw new VimInputError(
|
||||
"Plan mode: vim is read-only; only navigation, search, open, and close are allowed.",
|
||||
);
|
||||
},
|
||||
saveBuffer: async () => {
|
||||
throw new VimInputError("Plan mode: :w is not allowed.");
|
||||
},
|
||||
});
|
||||
await executeKeySequences(preview, tokenGroups, commandText);
|
||||
}
|
||||
|
||||
try {
|
||||
const FRAME_INTERVAL_MS = 16; // ~60fps
|
||||
let lastUpdateTime = 0;
|
||||
try {
|
||||
const FRAME_INTERVAL_MS = 16; // ~60fps
|
||||
let lastUpdateTime = 0;
|
||||
|
||||
const emitUpdate = onUpdate
|
||||
? async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastUpdateTime < FRAME_INTERVAL_MS) {
|
||||
return; // throttle: skip if too soon
|
||||
}
|
||||
onUpdate(this.#renderFromEngine(engine, VIM_DEFAULT_VIEWPORT_LINES, engine.viewportStart));
|
||||
lastUpdateTime = Date.now();
|
||||
await Bun.sleep(FRAME_INTERVAL_MS); // real delay for terminal to render
|
||||
const emitUpdate = onUpdate
|
||||
? async () => {
|
||||
const now = Date.now();
|
||||
if (now - lastUpdateTime < FRAME_INTERVAL_MS) {
|
||||
return; // throttle: skip if too soon
|
||||
}
|
||||
: undefined;
|
||||
onUpdate(this.#renderFromEngine(engine, VIM_DEFAULT_VIEWPORT_LINES, engine.viewportStart));
|
||||
lastUpdateTime = Date.now();
|
||||
await Bun.sleep(FRAME_INTERVAL_MS); // real delay for terminal to render
|
||||
}
|
||||
: undefined;
|
||||
|
||||
await executeKeySequences(engine, tokenGroups, commandText, emitUpdate);
|
||||
await executeKeySequences(engine, tokenGroups, commandText, emitUpdate);
|
||||
|
||||
if (!engine.closed && params.insert !== undefined) {
|
||||
await engine.applyLiteralInsert(params.insert, params.pause !== true);
|
||||
await emitUpdate?.();
|
||||
}
|
||||
if (!engine.closed && params.insert !== undefined) {
|
||||
await engine.applyLiteralInsert(params.insert, params.pause !== true);
|
||||
await emitUpdate?.();
|
||||
}
|
||||
|
||||
if (params.pause === true && !engine.closed && engine.getPendingInput()) {
|
||||
engine.statusMessage = engine.statusMessage ?? `Paused in ${engine.getPublicMode()} mode`;
|
||||
if (params.pause === true && !engine.closed && engine.getPendingInput()) {
|
||||
engine.statusMessage = engine.statusMessage ?? `Paused in ${engine.getPublicMode()} mode`;
|
||||
}
|
||||
} catch (error) {
|
||||
this.#throwWithSnapshot(engine, error);
|
||||
}
|
||||
|
||||
if (beforeText !== serializeBufferText(engine.buffer)) {
|
||||
engine.centerViewportOnCursor();
|
||||
}
|
||||
|
||||
// Auto-save when buffer was modified
|
||||
if (!engine.closed && engine.buffer.modified && params.pause !== true) {
|
||||
try {
|
||||
const result = await this.#saveBuffer(engine.buffer);
|
||||
engine.buffer.markSaved(result.loaded);
|
||||
engine.diagnostics = result.diagnostics;
|
||||
if (beforeText !== serializeBufferText(engine.buffer)) {
|
||||
engine.centerViewportOnCursor();
|
||||
}
|
||||
} catch (error) {
|
||||
this.#throwWithSnapshot(engine, error);
|
||||
}
|
||||
|
||||
if (beforeText !== serializeBufferText(engine.buffer)) {
|
||||
engine.centerViewportOnCursor();
|
||||
}
|
||||
|
||||
// Auto-save when buffer was modified
|
||||
if (!engine.closed && engine.buffer.modified && params.pause !== true) {
|
||||
try {
|
||||
const result = await this.#saveBuffer(engine.buffer);
|
||||
engine.buffer.markSaved(result.loaded);
|
||||
engine.diagnostics = result.diagnostics;
|
||||
if (beforeText !== serializeBufferText(engine.buffer)) {
|
||||
engine.centerViewportOnCursor();
|
||||
}
|
||||
} catch (error) {
|
||||
this.#throwWithSnapshot(engine, error);
|
||||
}
|
||||
}
|
||||
|
||||
const afterText = serializeBufferText(engine.buffer);
|
||||
const modelDiff = buildModelDiff(beforeText, afterText);
|
||||
|
||||
const result = this.#renderFromEngine(
|
||||
engine,
|
||||
VIM_DEFAULT_VIEWPORT_LINES,
|
||||
engine.viewportStart,
|
||||
engine.closed,
|
||||
undefined,
|
||||
undefined,
|
||||
modelDiff,
|
||||
);
|
||||
if (engine.closed) {
|
||||
this.#engine = null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new ToolError("Invalid vim parameters");
|
||||
const afterText = serializeBufferText(engine.buffer);
|
||||
const modelDiff = buildModelDiff(beforeText, afterText);
|
||||
|
||||
const result = this.#renderFromEngine(
|
||||
engine,
|
||||
VIM_DEFAULT_VIEWPORT_LINES,
|
||||
engine.viewportStart,
|
||||
engine.closed,
|
||||
undefined,
|
||||
undefined,
|
||||
modelDiff,
|
||||
);
|
||||
if (engine.closed) {
|
||||
this.#engines.delete(absolutePath);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -535,7 +530,7 @@ function getInsertForDisplay(args: VimRenderArgs): string | undefined {
|
||||
}
|
||||
|
||||
interface VimRenderArgs {
|
||||
open?: string;
|
||||
file?: string;
|
||||
kbd?: string[];
|
||||
insert?: string;
|
||||
pause?: boolean;
|
||||
@@ -544,8 +539,8 @@ interface VimRenderArgs {
|
||||
|
||||
export const vimToolRenderer = {
|
||||
renderCall(args: VimRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
|
||||
if (args.open) {
|
||||
return renderText(`${uiTheme.bold("Vim")} open ${args.open}`);
|
||||
if (args.file && !args.kbd) {
|
||||
return renderText(`${uiTheme.bold("Vim")} open ${args.file}`);
|
||||
}
|
||||
|
||||
// Build a description of the streaming args for the header
|
||||
|
||||
Reference in New Issue
Block a user