From 2594fea2f1944940b7173265ba4e3847fb4101ab Mon Sep 17 00:00:00 2001 From: minpeter Date: Mon, 23 Feb 2026 15:35:11 +0900 Subject: [PATCH 01/12] feat: add configurable friendli reasoning mode Replace /think with /reasoning-mode and add per-model Friendli reasoning controls so on/interleaved/preserved behavior can be tuned by model metadata. --- src/agent.ts | 222 ++++++++++++-- .../aliases-and-tool-fallback.test.ts | 46 ++- src/commands/model.ts | 30 +- src/commands/reasoning-mode.ts | 55 ++++ src/commands/render.test.ts | 47 +++ src/commands/render.ts | 44 ++- src/commands/think.ts | 14 - src/entrypoints/cli.ts | 169 ++++++++--- src/entrypoints/headless.ts | 36 ++- src/friendli-models.ts | 77 +++++ src/friendli-reasoning.test.ts | 147 +++++++++ src/friendli-reasoning.ts | 280 ++++++++++++++++++ src/reasoning-mode.test.ts | 24 ++ src/reasoning-mode.ts | 40 +++ 14 files changed, 1095 insertions(+), 136 deletions(-) create mode 100644 src/commands/reasoning-mode.ts create mode 100644 src/commands/render.test.ts delete mode 100644 src/commands/think.ts create mode 100644 src/friendli-models.ts create mode 100644 src/friendli-reasoning.test.ts create mode 100644 src/friendli-reasoning.ts create mode 100644 src/reasoning-mode.test.ts create mode 100644 src/reasoning-mode.ts diff --git a/src/agent.ts b/src/agent.ts index 4075eb2..e65753e 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -1,16 +1,23 @@ import { createAnthropic } from "@ai-sdk/anthropic"; +import type { ProviderOptions as AiProviderOptions } from "@ai-sdk/provider-utils"; import { createFriendli } from "@friendliai/ai-provider"; import type { ModelMessage } from "ai"; -import { stepCountIs, streamText, wrapLanguageModel } from "ai"; +import { generateText, stepCountIs, streamText, wrapLanguageModel } from "ai"; import { getEnvironmentContext } from "./context/environment-context"; import { loadSkillsMetadata } from "./context/skills"; import { SYSTEM_PROMPT } from "./context/system-prompt"; import { env } from "./env"; +import { + applyFriendliInterleavedField, + buildFriendliChatTemplateKwargs, + getFriendliSelectableReasoningModes, +} from "./friendli-reasoning"; import { buildMiddlewares } from "./middleware"; import { buildTodoContinuationPrompt, getIncompleteTodos, } from "./middleware/todo-continuation"; +import { DEFAULT_REASONING_MODE, type ReasoningMode } from "./reasoning-mode"; import { DEFAULT_TOOL_FALLBACK_MODE, LEGACY_ENABLED_TOOL_FALLBACK_MODE, @@ -18,11 +25,18 @@ import { } from "./tool-fallback-mode"; import { tools } from "./tools"; -export const DEFAULT_MODEL_ID = "MiniMaxAI/MiniMax-M2.5"; +export const DEFAULT_MODEL_ID = "zai-org/GLM-5"; export const DEFAULT_ANTHROPIC_MODEL_ID = "claude-sonnet-4-6"; const OUTPUT_TOKEN_MAX = 64_000; +const TRANSLATION_MAX_OUTPUT_TOKENS = 4000; + +const NON_ASCII_PATTERN = /[^\\x00-]/; + +const TRANSLATION_SYSTEM_PROMPT = + "Translate the user message to clear English so the original intent is preserved. Return only the translated text and nothing else."; type CoreStreamResult = ReturnType; +type ProviderOptions = AiProviderOptions | undefined; export interface AgentStreamOptions { abortSignal?: AbortSignal; @@ -58,9 +72,15 @@ interface CreateAgentOptions { enableThinking?: boolean; instructions?: string; provider?: ProviderType; + reasoningMode?: ReasoningMode; toolFallbackMode?: ToolFallbackMode; } +interface TranslationInputResult { + text: string; + translated: boolean; +} + const getModel = (modelId: string, provider: ProviderType) => { if (provider === "anthropic") { if (!anthropic) { @@ -81,11 +101,29 @@ const getModel = (modelId: string, provider: ProviderType) => { const ANTHROPIC_THINKING_BUDGET_TOKENS = 10_000; const ANTHROPIC_MAX_OUTPUT_TOKENS = 64_000; +const ANTHROPIC_SELECTABLE_REASONING_MODES: ReasoningMode[] = ["off", "on"]; -const createAgent = (modelId: string, options: CreateAgentOptions = {}) => { - const provider = options.provider ?? "friendli"; - const thinkingEnabled = options.enableThinking ?? false; - const model = getModel(modelId, provider); +const isNonEnglishText = (input: string): boolean => { + return NON_ASCII_PATTERN.test(input); +}; + +const isAnthropicWithReasoning = ( + modelId: string, + provider: ProviderType, + reasoningMode: ReasoningMode +): boolean => { + const thinkingEnabled = reasoningMode !== "off"; + return ( + provider === "anthropic" && thinkingEnabled && !modelId.includes("opus") + ); +}; + +const getProviderOptions = ( + modelId: string, + provider: ProviderType, + reasoningMode: ReasoningMode +): { options: ProviderOptions; maxOutputTokens: number } => { + const thinkingEnabled = reasoningMode !== "off"; const getAnthropicProviderOptions = () => { if (!thinkingEnabled) { @@ -96,6 +134,7 @@ const createAgent = (modelId: string, options: CreateAgentOptions = {}) => { if (isOpus) { return { anthropic: { effort: "high" } }; } + return { anthropic: { thinking: { @@ -106,46 +145,89 @@ const createAgent = (modelId: string, options: CreateAgentOptions = {}) => { }; }; - const getProviderOptions = () => { - if (provider === "anthropic") { - return getAnthropicProviderOptions(); - } + if (provider === "anthropic") { return { - friendli: { - chat_template_kwargs: { - enable_thinking: thinkingEnabled, - thinking: thinkingEnabled, - }, - }, + options: getAnthropicProviderOptions(), + maxOutputTokens: isAnthropicWithReasoning( + modelId, + provider, + reasoningMode + ) + ? ANTHROPIC_MAX_OUTPUT_TOKENS - ANTHROPIC_THINKING_BUDGET_TOKENS + : OUTPUT_TOKEN_MAX, }; - }; + } + + const chatTemplateKwargs = buildFriendliChatTemplateKwargs( + modelId, + reasoningMode + ); - const providerOptions = getProviderOptions(); + return { + options: chatTemplateKwargs + ? { + friendli: { + chat_template_kwargs: chatTemplateKwargs, + }, + } + : undefined, + maxOutputTokens: OUTPUT_TOKEN_MAX, + }; +}; - // Anthropic with thinking: maxOutputTokens + thinkingBudget must be <= 64000 - const isAnthropicWithThinking = - provider === "anthropic" && thinkingEnabled && !modelId.includes("opus"); - const maxOutputTokens = isAnthropicWithThinking - ? ANTHROPIC_MAX_OUTPUT_TOKENS - ANTHROPIC_THINKING_BUDGET_TOKENS - : OUTPUT_TOKEN_MAX; +const createBaseModel = ( + modelId: string, + provider: ProviderType, + toolFallbackMode: ToolFallbackMode, + reasoningMode: ReasoningMode +) => { + const model = getModel(modelId, provider); + const { options, maxOutputTokens } = getProviderOptions( + modelId, + provider, + reasoningMode + ); const wrappedModel = wrapLanguageModel({ model, middleware: buildMiddlewares({ - toolFallbackMode: options.toolFallbackMode ?? DEFAULT_TOOL_FALLBACK_MODE, + toolFallbackMode, }), }); + return { wrappedModel, providerOptions: options, maxOutputTokens }; +}; + +const createAgent = (modelId: string, options: CreateAgentOptions = {}) => { + const provider = options.provider ?? "friendli"; + const reasoningMode = + options.reasoningMode ?? + (options.enableThinking ? "on" : DEFAULT_REASONING_MODE); + const toolFallbackMode = + options.toolFallbackMode ?? DEFAULT_TOOL_FALLBACK_MODE; + + const { wrappedModel, providerOptions, maxOutputTokens } = createBaseModel( + modelId, + provider, + toolFallbackMode, + reasoningMode + ); + return { stream: ({ messages, abortSignal, }: { messages: ModelMessage[] } & AgentStreamOptions) => { + const preparedMessages = + provider === "friendli" + ? applyFriendliInterleavedField(messages, modelId, reasoningMode) + : messages; + return streamText({ model: wrappedModel, system: options.instructions ?? SYSTEM_PROMPT, tools, - messages, + messages: preparedMessages, maxOutputTokens, providerOptions, // stepCountIs(n) replaces the deprecated maxSteps option. @@ -158,6 +240,35 @@ const createAgent = (modelId: string, options: CreateAgentOptions = {}) => { }; }; +const translateToEnglish = async ( + text: string, + modelId: string, + provider: ProviderType, + toolFallbackMode: ToolFallbackMode +): Promise => { + const { wrappedModel, providerOptions } = createBaseModel( + modelId, + provider, + toolFallbackMode, + "off" + ); + + const result = await generateText({ + model: wrappedModel, + system: TRANSLATION_SYSTEM_PROMPT, + messages: [ + { + role: "user", + content: text, + }, + ], + maxOutputTokens: TRANSLATION_MAX_OUTPUT_TOKENS, + providerOptions, + }); + + return result.text.trim(); +}; + export type ModelType = "serverless" | "dedicated"; class AgentManager { @@ -165,8 +276,9 @@ class AgentManager { private modelType: ModelType = "serverless"; private provider: ProviderType = "friendli"; private headlessMode = false; - private thinkingEnabled = false; + private reasoningMode: ReasoningMode = DEFAULT_REASONING_MODE; private toolFallbackMode: ToolFallbackMode = DEFAULT_TOOL_FALLBACK_MODE; + private userInputTranslationEnabled = false; getModelId(): string { return this.modelId; @@ -205,12 +317,27 @@ class AgentManager { return this.headlessMode; } + setReasoningMode(mode: ReasoningMode): void { + this.reasoningMode = mode; + } + + getReasoningMode(): ReasoningMode { + return this.reasoningMode; + } + + getSelectableReasoningModes(): ReasoningMode[] { + if (this.provider === "friendli") { + return getFriendliSelectableReasoningModes(this.modelId); + } + return [...ANTHROPIC_SELECTABLE_REASONING_MODES]; + } + setThinkingEnabled(enabled: boolean): void { - this.thinkingEnabled = enabled; + this.reasoningMode = enabled ? "on" : DEFAULT_REASONING_MODE; } isThinkingEnabled(): boolean { - return this.thinkingEnabled; + return this.reasoningMode !== DEFAULT_REASONING_MODE; } getToolFallbackMode(): ToolFallbackMode { @@ -231,6 +358,37 @@ class AgentManager { return this.toolFallbackMode !== DEFAULT_TOOL_FALLBACK_MODE; } + setUserInputTranslationEnabled(enabled: boolean): void { + this.userInputTranslationEnabled = enabled; + } + + isUserInputTranslationEnabled(): boolean { + return this.userInputTranslationEnabled; + } + + async preprocessUserInput(input: string): Promise { + if (!(this.userInputTranslationEnabled && isNonEnglishText(input))) { + return { text: input, translated: false }; + } + + try { + const translated = await translateToEnglish( + input, + this.modelId, + this.provider, + this.toolFallbackMode + ); + + if (!translated || translated === input.trim()) { + return { text: input, translated: false }; + } + + return { text: translated, translated: true }; + } catch { + return { text: input, translated: false }; + } + } + async getInstructions(): Promise { let instructions = SYSTEM_PROMPT + getEnvironmentContext(); @@ -241,7 +399,9 @@ class AgentManager { const incompleteTodos = await getIncompleteTodos(); if (incompleteTodos.length > 0) { - instructions += `\n\n${buildTodoContinuationPrompt(incompleteTodos)}`; + instructions += ` + +${buildTodoContinuationPrompt(incompleteTodos)}`; } return instructions; @@ -257,7 +417,7 @@ class AgentManager { ): Promise { const agent = createAgent(this.modelId, { instructions: await this.getInstructions(), - enableThinking: this.thinkingEnabled, + reasoningMode: this.reasoningMode, toolFallbackMode: this.toolFallbackMode, provider: this.provider, }); diff --git a/src/commands/aliases-and-tool-fallback.test.ts b/src/commands/aliases-and-tool-fallback.test.ts index b210eef..01cafe6 100644 --- a/src/commands/aliases-and-tool-fallback.test.ts +++ b/src/commands/aliases-and-tool-fallback.test.ts @@ -7,7 +7,8 @@ import { type ToolFallbackMode, } from "../tool-fallback-mode"; import { createHelpCommand } from "./help"; -import { executeCommand, registerCommand } from "./index"; +import { executeCommand, getCommands, registerCommand } from "./index"; +import { createReasoningModeCommand } from "./reasoning-mode"; import { createToolFallbackCommand } from "./tool-fallback"; import type { Command } from "./types"; @@ -85,6 +86,49 @@ describe("Skill command prefixes", () => { }); }); +describe("Reasoning mode command", () => { + let originalMode: ReturnType; + let originalProvider: ReturnType; + let originalModelId: ReturnType; + + beforeEach(() => { + originalMode = agentManager.getReasoningMode(); + originalProvider = agentManager.getProvider(); + originalModelId = agentManager.getModelId(); + agentManager.setProvider("friendli"); + agentManager.setModelId("MiniMaxAI/MiniMax-M2.5"); + agentManager.setReasoningMode("on"); + }); + + afterEach(() => { + agentManager.setProvider(originalProvider); + agentManager.setModelId(originalModelId); + agentManager.setReasoningMode(originalMode); + }); + + it("supports /think alias for /reasoning-mode", async () => { + if (!getCommands().has("reasoning-mode")) { + registerCommand(createReasoningModeCommand()); + } + + const result = await executeCommand("/think interleaved"); + expect(result).not.toBeNull(); + expect(result?.success).toBe(true); + expect(agentManager.getReasoningMode()).toBe("interleaved"); + }); + + it("rejects unsupported mode for current model", async () => { + if (!getCommands().has("reasoning-mode")) { + registerCommand(createReasoningModeCommand()); + } + + const result = await executeCommand("/reasoning-mode preserved"); + expect(result).not.toBeNull(); + expect(result?.success).toBe(false); + expect(result?.message).toContain("not supported"); + }); +}); + describe("Tool fallback command", () => { let originalMode: ToolFallbackMode; diff --git a/src/commands/model.ts b/src/commands/model.ts index b50c9e1..542c21a 100644 --- a/src/commands/model.ts +++ b/src/commands/model.ts @@ -1,5 +1,7 @@ import type { ProviderType } from "../agent"; import { ANTHROPIC_MODELS, agentManager } from "../agent"; +import type { FriendliReasoningModelConfig } from "../friendli-models"; +import { FRIENDLI_MODELS } from "../friendli-models"; import { colorize } from "../interaction/colors"; import type { Command, CommandResult } from "./types"; @@ -7,36 +9,10 @@ export interface ModelInfo { id: string; name?: string; provider: ProviderType; + reasoning?: FriendliReasoningModelConfig | null; type?: "serverless" | "dedicated"; } -const FRIENDLI_MODELS: readonly ModelInfo[] = [ - { - id: "MiniMaxAI/MiniMax-M2.5", - name: "MiniMax M2.5", - provider: "friendli", - type: "serverless", - }, - { - id: "MiniMaxAI/MiniMax-M2.1", - name: "MiniMax M2.1", - provider: "friendli", - type: "serverless", - }, - { - id: "zai-org/GLM-5", - name: "GLM 5", - provider: "friendli", - type: "serverless", - }, - { - id: "zai-org/GLM-4.7", - name: "GLM 4.7", - provider: "friendli", - type: "serverless", - }, -] as const; - function getAnthropicModels(): ModelInfo[] { return ANTHROPIC_MODELS.map((m) => ({ id: m.id, diff --git a/src/commands/reasoning-mode.ts b/src/commands/reasoning-mode.ts new file mode 100644 index 0000000..31ff6c6 --- /dev/null +++ b/src/commands/reasoning-mode.ts @@ -0,0 +1,55 @@ +import { agentManager } from "../agent"; +import { parseReasoningMode, REASONING_MODES } from "../reasoning-mode"; +import type { Command } from "./types"; + +const REASONING_MODE_USAGE = REASONING_MODES.join("|"); + +export const createReasoningModeCommand = (): Command => ({ + name: "reasoning-mode", + aliases: ["think"], + description: "Set reasoning mode (off/on/interleaved/preserved)", + argumentSuggestions: [...REASONING_MODES], + execute: ({ args }) => { + const selectableModes = agentManager.getSelectableReasoningModes(); + const selectableLabel = selectableModes.join("|"); + + if (args.length === 0) { + const currentMode = agentManager.getReasoningMode(); + return { + success: true, + message: `Reasoning mode: ${currentMode}\nSelectable: ${selectableLabel}\nUsage: /reasoning-mode <${REASONING_MODE_USAGE}>`, + }; + } + + const rawMode = args[0] ?? ""; + const mode = parseReasoningMode(rawMode); + if (!mode) { + return { + success: false, + message: `Invalid mode: ${rawMode}. Use one of: ${REASONING_MODE_USAGE}`, + }; + } + + if (!selectableModes.includes(mode)) { + return { + success: false, + message: `Mode ${mode} is not supported for current model. Selectable: ${selectableLabel}`, + }; + } + + const currentMode = agentManager.getReasoningMode(); + if (mode === currentMode) { + return { + success: true, + message: `Already using reasoning mode: ${mode}`, + }; + } + + agentManager.setReasoningMode(mode); + + return { + success: true, + message: `Reasoning mode set to: ${mode}`, + }; + }, +}); diff --git a/src/commands/render.test.ts b/src/commands/render.test.ts new file mode 100644 index 0000000..ba14d71 --- /dev/null +++ b/src/commands/render.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import type { ModelMessage } from "ai"; +import { + appendNextUserPromptSentinel, + NEXT_USER_PROMPT_SENTINEL, +} from "./render"; + +describe("render prompt shaping", () => { + it("appends the sentinel as the last user message", () => { + const messages: ModelMessage[] = [ + { + role: "user", + content: "existing prompt", + }, + { + role: "assistant", + content: "existing response", + }, + ]; + + const withSentinel = appendNextUserPromptSentinel(messages); + + expect(withSentinel).toHaveLength(3); + expect(withSentinel.at(-1)).toEqual({ + role: "user", + content: NEXT_USER_PROMPT_SENTINEL, + }); + expect(NEXT_USER_PROMPT_SENTINEL).toBe( + NEXT_USER_PROMPT_SENTINEL.toUpperCase() + ); + }); + + it("does not mutate the original message array", () => { + const messages: ModelMessage[] = [ + { + role: "assistant", + content: "final answer", + }, + ]; + + const withSentinel = appendNextUserPromptSentinel(messages); + + expect(messages).toHaveLength(1); + expect(withSentinel).not.toBe(messages); + expect(withSentinel).toHaveLength(2); + }); +}); diff --git a/src/commands/render.ts b/src/commands/render.ts index 235ae1a..77b7cb4 100644 --- a/src/commands/render.ts +++ b/src/commands/render.ts @@ -3,9 +3,14 @@ import type { ModelMessage, ToolSet } from "ai"; import { generateText, wrapLanguageModel } from "ai"; import type { ModelType } from "../agent"; import { env } from "../env"; +import { + applyFriendliInterleavedField, + buildFriendliChatTemplateKwargs, +} from "../friendli-reasoning"; import { colors } from "../interaction/colors"; import { Spinner } from "../interaction/spinner"; import { buildMiddlewares } from "../middleware"; +import type { ReasoningMode } from "../reasoning-mode"; import type { ToolFallbackMode } from "../tool-fallback-mode"; import type { Command, CommandResult } from "./types"; @@ -14,11 +19,26 @@ interface RenderData { messages: ModelMessage[]; model: string; modelType: ModelType; - thinkingEnabled: boolean; + reasoningMode: ReasoningMode; toolFallbackMode: ToolFallbackMode; tools: ToolSet; } +export const NEXT_USER_PROMPT_SENTINEL = + "THE NEXT USER PROMPT IS LOCATED HERE."; + +export const appendNextUserPromptSentinel = ( + messages: ModelMessage[] +): ModelMessage[] => { + return [ + ...messages, + { + role: "user", + content: NEXT_USER_PROMPT_SENTINEL, + }, + ]; +}; + /** * Render chat prompt to raw text. * @@ -37,7 +57,7 @@ async function renderChatPrompt({ instructions, tools, messages, - thinkingEnabled, + reasoningMode, toolFallbackMode, }: RenderData): Promise { const isDedicated = modelType === "dedicated"; @@ -45,6 +65,17 @@ async function renderChatPrompt({ ? "https://api.friendli.ai/dedicated/v1" : "https://api.friendli.ai/serverless/v1"; + const chatTemplateKwargs = buildFriendliChatTemplateKwargs( + model, + reasoningMode + ); + const messagesWithSentinel = appendNextUserPromptSentinel(messages); + const preparedMessages = applyFriendliInterleavedField( + messagesWithSentinel, + model, + reasoningMode + ); + let capturedText = ""; const customFetch = Object.assign( @@ -70,10 +101,9 @@ async function renderChatPrompt({ }, body: JSON.stringify({ ...bodyWithoutToolChoice, - chat_template_kwargs: { - enable_thinking: thinkingEnabled, - thinking: thinkingEnabled, - }, + ...(chatTemplateKwargs + ? { chat_template_kwargs: chatTemplateKwargs } + : {}), }), }); @@ -127,7 +157,7 @@ async function renderChatPrompt({ }), system: instructions, tools, - messages, + messages: preparedMessages, }); return capturedText; diff --git a/src/commands/think.ts b/src/commands/think.ts deleted file mode 100644 index f558b4b..0000000 --- a/src/commands/think.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { agentManager } from "../agent"; -import { createToggleCommand } from "./factories/create-toggle-command"; -import type { Command } from "./types"; - -export const createThinkCommand = (): Command => - createToggleCommand({ - name: "think", - description: "Toggle reasoning mode on/off", - getter: () => agentManager.isThinkingEnabled(), - setter: (value) => agentManager.setThinkingEnabled(value), - featureName: "Reasoning", - enabledMessage: "Reasoning enabled", - disabledMessage: "Reasoning disabled", - }); diff --git a/src/entrypoints/cli.ts b/src/entrypoints/cli.ts index 55e1e2d..22873f5 100644 --- a/src/entrypoints/cli.ts +++ b/src/entrypoints/cli.ts @@ -42,9 +42,10 @@ import { getAvailableModels, type ModelInfo, } from "../commands/model"; +import { createReasoningModeCommand } from "../commands/reasoning-mode"; import { createRenderCommand } from "../commands/render"; -import { createThinkCommand } from "../commands/think"; import { createToolFallbackCommand } from "../commands/tool-fallback"; +import { createTranslateCommand } from "../commands/translate"; import { MessageHistory } from "../context/message-history"; import { getSessionId, initializeSession } from "../context/session"; import { toPromptsCommandName } from "../context/skill-command-prefix"; @@ -61,6 +62,11 @@ import { buildTodoContinuationUserMessage, getIncompleteTodos, } from "../middleware/todo-continuation"; +import { + DEFAULT_REASONING_MODE, + parseReasoningMode, + type ReasoningMode, +} from "../reasoning-mode"; import { DEFAULT_TOOL_FALLBACK_MODE, LEGACY_ENABLED_TOOL_FALLBACK_MODE, @@ -559,7 +565,9 @@ interface CliUi { currentProvider: ProviderType, initialFilter?: string ) => Promise; - showThinkSelector: (currentEnabled: boolean) => Promise<"on" | "off" | null>; + showReasoningModeSelector: ( + currentMode: ReasoningMode + ) => Promise; showToolFallbackSelector: ( currentMode: ToolFallbackMode ) => Promise; @@ -719,11 +727,19 @@ const createCliUi = (skills: SkillInfo[]): CliUi => { clearPromptInput(); }; - const showThinkSelector = async ( - currentEnabled: boolean - ): Promise<"on" | "off" | null> => { + const showReasoningModeSelector = async ( + currentMode: ReasoningMode + ): Promise => { clearStatus(); + const selectableModes = agentManager.getSelectableReasoningModes(); + const descriptions: Record = { + off: "Disable reasoning mode", + on: "Enable reasoning if supported", + interleaved: "Enable interleaved reasoning field mode", + preserved: "Enable preserved interleaved reasoning mode", + }; + const selectorContainer = new Container(); selectorContainer.addChild( new Text(style(ANSI_DIM, "Select reasoning execution"), 1, 0) @@ -731,22 +747,17 @@ const createCliUi = (skills: SkillInfo[]): CliUi => { selectorContainer.addChild(new Spacer(1)); const selectList = new SelectList( - [ - { - value: "on", - label: "on", - description: "Enable model reasoning", - }, - { - value: "off", - label: "off", - description: "Disable model reasoning", - }, - ], + selectableModes.map((mode) => ({ + value: mode, + label: buildCurrentIndicatorLabel(mode, currentMode === mode), + description: descriptions[mode], + })), 2, editorTheme.selectList ); - selectList.setSelectedIndex(currentEnabled ? 0 : 1); + + const selectedIndex = selectableModes.indexOf(currentMode); + selectList.setSelectedIndex(selectedIndex >= 0 ? selectedIndex : 0); selectorContainer.addChild(selectList); statusContainer.addChild(selectorContainer); @@ -762,7 +773,7 @@ const createCliUi = (skills: SkillInfo[]): CliUi => { tui.requestRender(); }; - const finish = (value: "on" | "off" | null): void => { + const finish = (value: ReasoningMode | null): void => { if (done) { return; } @@ -777,7 +788,8 @@ const createCliUi = (skills: SkillInfo[]): CliUi => { }); selectList.onSelect = (item) => { - finish(item.value === "off" ? "off" : "on"); + const selectedMode = parseReasoningMode(item.value); + finish(selectedMode ?? DEFAULT_REASONING_MODE); }; selectList.onCancel = () => { finish(null); @@ -1125,7 +1137,7 @@ const createCliUi = (skills: SkillInfo[]): CliUi => { showLoader, showModelSelector, showToolFallbackSelector, - showThinkSelector, + showReasoningModeSelector, clearStatus, dispose, }; @@ -1138,14 +1150,15 @@ registerCommand( instructions: await agentManager.getInstructions(), tools: agentManager.getTools(), messages: messageHistory.toModelMessages(), - thinkingEnabled: agentManager.isThinkingEnabled(), + reasoningMode: agentManager.getReasoningMode(), toolFallbackMode: agentManager.getToolFallbackMode(), })) ); registerCommand(createModelCommand()); registerCommand(createClearCommand()); -registerCommand(createThinkCommand()); +registerCommand(createReasoningModeCommand()); registerCommand(createToolFallbackCommand()); +registerCommand(createTranslateCommand()); const parseProviderArg = ( providerArg: string | undefined @@ -1197,22 +1210,65 @@ const parseToolFallbackCliOption = ( }; }; +const parseReasoningCliOption = ( + args: string[], + index: number +): { consumedArgs: number; mode: ReasoningMode } | null => { + const arg = args[index]; + if (arg === "--think") { + return { consumedArgs: 0, mode: "on" }; + } + + if (arg !== "--reasoning-mode") { + return null; + } + + const candidate = args[index + 1]; + if (candidate && !candidate.startsWith("--")) { + const parsedMode = parseReasoningMode(candidate); + return { + consumedArgs: 1, + mode: parsedMode ?? DEFAULT_REASONING_MODE, + }; + } + + return { + consumedArgs: 0, + mode: DEFAULT_REASONING_MODE, + }; +}; + +const parseTranslateCliOption = (arg: string): boolean | null => { + if (arg === "--translate") { + return true; + } + if (arg === "--no-translate") { + return false; + } + return null; +}; + const parseCliArgs = (): { - thinking: boolean; + reasoningMode: ReasoningMode; toolFallbackMode: ToolFallbackMode; model: string | null; provider: ProviderType | null; + translateUserPrompts: boolean; } => { const args = process.argv.slice(2); - let thinking = false; + let reasoningMode: ReasoningMode = DEFAULT_REASONING_MODE; let toolFallbackMode: ToolFallbackMode = DEFAULT_TOOL_FALLBACK_MODE; let model: string | null = null; let provider: ProviderType | null = null; + let translateUserPrompts = false; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; - if (arg === "--think") { - thinking = true; + + const reasoningOption = parseReasoningCliOption(args, i); + if (reasoningOption) { + reasoningMode = reasoningOption.mode; + i += reasoningOption.consumedArgs; continue; } @@ -1232,16 +1288,35 @@ const parseCliArgs = (): { if (arg === "--provider" && i + 1 < args.length) { provider = parseProviderArg(args[i + 1]) ?? provider; i += 1; + continue; + } + + const translateOption = parseTranslateCliOption(arg); + if (translateOption !== null) { + translateUserPrompts = translateOption; } } - return { thinking, toolFallbackMode, model, provider }; + return { + reasoningMode, + toolFallbackMode, + model, + provider, + translateUserPrompts, + }; }; const setupAgent = (): void => { - const { thinking, toolFallbackMode, model, provider } = parseCliArgs(); - agentManager.setThinkingEnabled(thinking); + const { + reasoningMode, + toolFallbackMode, + model, + provider, + translateUserPrompts, + } = parseCliArgs(); + agentManager.setReasoningMode(reasoningMode); agentManager.setToolFallbackMode(toolFallbackMode); + agentManager.setUserInputTranslationEnabled(translateUserPrompts); if (provider) { agentManager.setProvider(provider); } @@ -1422,21 +1497,27 @@ const resolveToolFallbackCommandInput = async ( return `/tool-fallback ${selected}`; }; -const resolveThinkCommandInput = async ( +const resolveReasoningModeCommandInput = async ( ui: CliUi, commandInput: string, parsed: ReturnType ): Promise => { - if (parsed?.name !== "think" || parsed.args.length > 0) { + if ( + !parsed || + (parsed.name !== "think" && parsed.name !== "reasoning-mode") || + parsed.args.length > 0 + ) { return commandInput; } - const selected = await ui.showThinkSelector(agentManager.isThinkingEnabled()); + const selected = await ui.showReasoningModeSelector( + agentManager.getReasoningMode() + ); if (!selected) { return null; } - return `/think ${selected}`; + return `/reasoning-mode ${selected}`; }; const handleCommand = async (ui: CliUi, input: string): Promise => { @@ -1462,15 +1543,15 @@ const handleCommand = async (ui: CliUi, input: string): Promise => { } commandInput = toolFallbackCommandInput; - const thinkCommandInput = await resolveThinkCommandInput( + const reasoningModeCommandInput = await resolveReasoningModeCommandInput( ui, commandInput, initialParsed ); - if (!thinkCommandInput) { + if (!reasoningModeCommandInput) { return true; } - commandInput = thinkCommandInput; + commandInput = reasoningModeCommandInput; const parsed = parseCommand(commandInput); const resolvedCommandName = parsed @@ -1478,7 +1559,7 @@ const handleCommand = async (ui: CliUi, input: string): Promise => { : null; const isNativeCommand = resolvedCommandName === "clear" || - resolvedCommandName === "think" || + resolvedCommandName === "reasoning-mode" || resolvedCommandName === "tool-fallback"; if (!isNativeCommand) { @@ -1532,8 +1613,10 @@ const processInput = async (ui: CliUi, input: string): Promise => { return await handleCommand(ui, trimmed); } + const preparedUserInput = await agentManager.preprocessUserInput(trimmed); + addUserMessage(ui.chatContainer, ui.markdownTheme, trimmed); - messageHistory.addUserMessage(trimmed); + messageHistory.addUserMessage(preparedUserInput.text); ui.tui.requestRender(); await handleAgentResponse(ui); return true; @@ -1574,22 +1657,22 @@ const run = async (): Promise => { } } finally { ui.dispose(); - cleanupTmuxSession(); + cleanupExecutionResources(); setSpinnerOutputEnabled(true); } }; -const cleanupTmuxSession = (): void => { +const cleanupExecutionResources = (): void => { cleanup(); }; const exitWithCleanup = (code: number): never => { - cleanupTmuxSession(); + cleanupExecutionResources(); process.exit(code); }; process.once("exit", () => { - cleanupTmuxSession(); + cleanupExecutionResources(); }); process.once("SIGTERM", () => { diff --git a/src/entrypoints/headless.ts b/src/entrypoints/headless.ts index 22f2eeb..a6a134d 100644 --- a/src/entrypoints/headless.ts +++ b/src/entrypoints/headless.ts @@ -3,7 +3,6 @@ import { agentManager, DEFAULT_MODEL_ID } from "../agent"; import { MessageHistory } from "../context/message-history"; import { setSessionId } from "../context/session"; -import { env } from "../env"; import { MANUAL_TOOL_LOOP_MAX_STEPS, shouldContinueManualToolLoop, @@ -12,6 +11,11 @@ import { buildTodoContinuationUserMessage, getIncompleteTodos, } from "../middleware/todo-continuation"; +import { + DEFAULT_REASONING_MODE, + parseReasoningMode, + type ReasoningMode, +} from "../reasoning-mode"; import { DEFAULT_TOOL_FALLBACK_MODE, LEGACY_ENABLED_TOOL_FALLBACK_MODE, @@ -69,17 +73,17 @@ type TrajectoryEvent = const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; -const cleanupTmuxSession = (): void => { +const cleanupExecutionResources = (): void => { cleanup(); }; const exitWithCleanup = (code: number): never => { - cleanupTmuxSession(); + cleanupExecutionResources(); process.exit(code); }; process.once("exit", () => { - cleanupTmuxSession(); + cleanupExecutionResources(); }); process.once("SIGINT", () => { @@ -157,13 +161,13 @@ const parseToolFallbackCliOption = ( const parseArgs = (): { prompt: string; model?: string; - thinking: boolean; + reasoningMode: ReasoningMode; toolFallbackMode: ToolFallbackMode; } => { const args = process.argv.slice(2); let prompt = ""; let model: string | undefined; - let thinking = false; + let reasoningMode: ReasoningMode = DEFAULT_REASONING_MODE; let toolFallbackMode: ToolFallbackMode = DEFAULT_TOOL_FALLBACK_MODE; for (let i = 0; i < args.length; i++) { @@ -176,7 +180,14 @@ const parseArgs = (): { model = args[i + 1] || undefined; i++; } else if (arg === "--think") { - thinking = true; + reasoningMode = "on"; + } else if (arg === "--reasoning-mode") { + const candidate = args[i + 1]; + if (candidate && !candidate.startsWith("--")) { + const parsedMode = parseReasoningMode(candidate); + reasoningMode = parsedMode ?? DEFAULT_REASONING_MODE; + i++; + } } else { const toolFallbackOption = parseToolFallbackCliOption(args, i); if (toolFallbackOption) { @@ -188,12 +199,12 @@ const parseArgs = (): { if (!prompt) { console.error( - "Usage: bun run src/entrypoints/headless.ts -p [-m ] [--think] [--tool-fallback [mode]] [--tool-fallback-mode ]" + "Usage: bun run src/entrypoints/headless.ts -p [-m ] [--think] [--reasoning-mode ] [--tool-fallback [mode]] [--tool-fallback-mode ]" ); process.exit(1); } - return { prompt, model, thinking, toolFallbackMode }; + return { prompt, model, reasoningMode, toolFallbackMode }; }; const extractToolOutput = ( @@ -481,16 +492,15 @@ const processAgentResponse = async ( }; const run = async (): Promise => { - // Initialize required tools (ripgrep, tmux) await initializeTools(); - const { prompt, model, thinking, toolFallbackMode } = parseArgs(); + const { prompt, model, reasoningMode, toolFallbackMode } = parseArgs(); setSessionId(sessionId); agentManager.setHeadlessMode(true); agentManager.setModelId(model || DEFAULT_MODEL_ID); - agentManager.setThinkingEnabled(thinking); + agentManager.setReasoningMode(reasoningMode); agentManager.setToolFallbackMode(toolFallbackMode); const messageHistory = new MessageHistory(); @@ -547,7 +557,7 @@ const run = async (): Promise => { exitWithCleanup(1); } - cleanupTmuxSession(); + cleanupExecutionResources(); const elapsed = ((Date.now() - startTime) / 1000).toFixed(2); console.error(`[headless] Completed in ${elapsed}s`); }; diff --git a/src/friendli-models.ts b/src/friendli-models.ts new file mode 100644 index 0000000..5a027ce --- /dev/null +++ b/src/friendli-models.ts @@ -0,0 +1,77 @@ +export interface FriendliReasoningToggleOnValueConfig { + preserved_toggle: boolean; + reasoning_toggle: boolean; +} + +export interface FriendliReasoningModelConfig { + interleaved_field: string | null; + on_value: FriendliReasoningToggleOnValueConfig; + preserved_toggle: string | null; + reasoning_toggle: string | null; +} + +export interface FriendliModelInfo { + id: string; + name?: string; + provider: "friendli"; + reasoning: FriendliReasoningModelConfig | null; + type?: "serverless" | "dedicated"; +} + +const DEFAULT_FRIENDLI_REASONING: FriendliReasoningModelConfig = { + reasoning_toggle: "enable_thinking", + preserved_toggle: "clear_thinking", + interleaved_field: "reasoning_content", + on_value: { + reasoning_toggle: true, + preserved_toggle: false, + }, +}; + +export const FRIENDLI_MODELS: readonly FriendliModelInfo[] = [ + { + id: "MiniMaxAI/MiniMax-M2.5", + name: "MiniMax M2.5", + provider: "friendli", + type: "serverless", + reasoning: { + ...DEFAULT_FRIENDLI_REASONING, + reasoning_toggle: null, + preserved_toggle: null, + }, + }, + { + id: "MiniMaxAI/MiniMax-M2.1", + name: "MiniMax M2.1", + provider: "friendli", + type: "serverless", + reasoning: { + ...DEFAULT_FRIENDLI_REASONING, + reasoning_toggle: null, + }, + }, + { + id: "zai-org/GLM-5", + name: "GLM 5", + provider: "friendli", + type: "serverless", + reasoning: { + ...DEFAULT_FRIENDLI_REASONING, + }, + }, + { + id: "zai-org/GLM-4.7", + name: "GLM 4.7", + provider: "friendli", + type: "serverless", + reasoning: { + ...DEFAULT_FRIENDLI_REASONING, + }, + }, +] as const; + +export const getFriendliModelById = ( + modelId: string +): FriendliModelInfo | undefined => { + return FRIENDLI_MODELS.find((model) => model.id === modelId); +}; diff --git a/src/friendli-reasoning.test.ts b/src/friendli-reasoning.test.ts new file mode 100644 index 0000000..717dcfc --- /dev/null +++ b/src/friendli-reasoning.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "bun:test"; +import type { ModelMessage } from "ai"; +import { + applyFriendliInterleavedField, + buildFriendliChatTemplateKwargs, + getFriendliSelectableReasoningModes, + resolveFriendliReasoningMode, +} from "./friendli-reasoning"; + +describe("friendli reasoning config", () => { + it("builds chat_template_kwargs with configurable boolean polarity", () => { + expect(buildFriendliChatTemplateKwargs("zai-org/GLM-5", "off")).toEqual({ + enable_thinking: false, + clear_thinking: true, + }); + + expect(buildFriendliChatTemplateKwargs("zai-org/GLM-5", "on")).toEqual({ + enable_thinking: true, + clear_thinking: true, + }); + + expect( + buildFriendliChatTemplateKwargs("zai-org/GLM-5", "preserved") + ).toEqual({ + enable_thinking: true, + clear_thinking: false, + }); + }); + + it("falls back to supported modes when requested mode is unavailable", () => { + expect(resolveFriendliReasoningMode("unknown-model", "preserved")).toBe( + "preserved" + ); + expect( + resolveFriendliReasoningMode("MiniMaxAI/MiniMax-M2.5", "interleaved") + ).toBe("interleaved"); + expect(resolveFriendliReasoningMode("MiniMaxAI/MiniMax-M2.5", "off")).toBe( + "on" + ); + expect( + resolveFriendliReasoningMode("MiniMaxAI/MiniMax-M2.5", "preserved") + ).toBe("interleaved"); + }); + + it("injects interleaved reasoning field for assistant messages", () => { + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "internal thinking" }, + { type: "text", text: "final answer" }, + ], + } as ModelMessage, + { + role: "user", + content: "next prompt", + }, + ]; + + const mapped = applyFriendliInterleavedField( + messages, + "zai-org/GLM-5", + "interleaved" + ); + + expect((mapped[0] as Record).reasoning_content).toBe( + "internal thinking" + ); + expect((mapped[1] as Record).reasoning_content).toBe( + undefined + ); + }); + + it("enforces MiniMax M2.5 semantics", () => { + expect( + getFriendliSelectableReasoningModes("MiniMaxAI/MiniMax-M2.5") + ).toEqual(["on", "interleaved"]); + + expect( + buildFriendliChatTemplateKwargs("MiniMaxAI/MiniMax-M2.5", "on") + ).toBe(undefined); + expect( + buildFriendliChatTemplateKwargs("MiniMaxAI/MiniMax-M2.5", "interleaved") + ).toBe(undefined); + + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "chain" }, + { type: "text", text: "answer" }, + ], + reasoning_content: "stale reasoning", + } as ModelMessage, + ]; + + const onMapped = applyFriendliInterleavedField( + messages, + "MiniMaxAI/MiniMax-M2.5", + "on" + ); + expect((onMapped[0] as Record).reasoning_content).toBe( + undefined + ); + expect(Array.isArray(onMapped[0]?.content)).toBe(true); + expect((onMapped[0]?.content as Array<{ type: string }>)[0]?.type).toBe( + "text" + ); + + const offMapped = applyFriendliInterleavedField( + messages, + "MiniMaxAI/MiniMax-M2.5", + "off" + ); + expect((offMapped[0] as Record).reasoning_content).toBe( + undefined + ); + + const interleavedMapped = applyFriendliInterleavedField( + messages, + "MiniMaxAI/MiniMax-M2.5", + "interleaved" + ); + expect( + (interleavedMapped[0] as Record).reasoning_content + ).toBe("chain"); + }); + + it("strips reasoning parts in non-interleaved modes", () => { + const messages: ModelMessage[] = [ + { + role: "assistant", + content: [{ type: "reasoning", text: "only-think" }], + } as ModelMessage, + ]; + + const mapped = applyFriendliInterleavedField( + messages, + "zai-org/GLM-5", + "on" + ); + expect(mapped[0]?.content).toBe(""); + expect((mapped[0] as Record).reasoning_content).toBe( + undefined + ); + }); +}); diff --git a/src/friendli-reasoning.ts b/src/friendli-reasoning.ts new file mode 100644 index 0000000..96da69b --- /dev/null +++ b/src/friendli-reasoning.ts @@ -0,0 +1,280 @@ +import type { ModelMessage } from "ai"; +import { + type FriendliReasoningModelConfig, + getFriendliModelById, +} from "./friendli-models"; +import { DEFAULT_REASONING_MODE, type ReasoningMode } from "./reasoning-mode"; + +export type FriendliReasoningConfig = FriendliReasoningModelConfig; + +const DEFAULT_FRIENDLI_REASONING_CONFIG: FriendliReasoningConfig = { + reasoning_toggle: "enable_thinking", + preserved_toggle: "clear_thinking", + interleaved_field: "reasoning_content", + on_value: { + reasoning_toggle: true, + preserved_toggle: false, + }, +}; + +const resolveFriendliReasoningCapability = (modelId: string) => { + const model = getFriendliModelById(modelId); + const hasReasoningSupport = model ? model.reasoning !== null : true; + const config = model?.reasoning ?? DEFAULT_FRIENDLI_REASONING_CONFIG; + const alwaysReasoning = + hasReasoningSupport && config.reasoning_toggle === null; + + return { + config, + hasReasoningSupport, + alwaysReasoning, + }; +}; + +export const getFriendliReasoningConfig = ( + modelId: string +): FriendliReasoningConfig => { + const { config } = resolveFriendliReasoningCapability(modelId); + + return { + ...config, + on_value: { + ...config.on_value, + }, + }; +}; + +export const resolveFriendliReasoningMode = ( + modelId: string, + requestedMode: ReasoningMode +): ReasoningMode => { + const { config, hasReasoningSupport, alwaysReasoning } = + resolveFriendliReasoningCapability(modelId); + const supportsOn = hasReasoningSupport; + const supportsInterleaved = + hasReasoningSupport && config.interleaved_field !== null; + const supportsPreserved = + supportsInterleaved && config.preserved_toggle !== null; + + if (requestedMode === "off") { + return alwaysReasoning ? "on" : "off"; + } + + if (requestedMode === "on") { + return supportsOn ? "on" : DEFAULT_REASONING_MODE; + } + + if (requestedMode === "interleaved") { + if (supportsInterleaved) { + return "interleaved"; + } + return supportsOn ? "on" : DEFAULT_REASONING_MODE; + } + + if (supportsPreserved) { + return "preserved"; + } + + if (supportsInterleaved) { + return "interleaved"; + } + + return supportsOn ? "on" : DEFAULT_REASONING_MODE; +}; + +export const getFriendliSelectableReasoningModes = ( + modelId: string +): ReasoningMode[] => { + const { config, hasReasoningSupport, alwaysReasoning } = + resolveFriendliReasoningCapability(modelId); + const selectable: ReasoningMode[] = []; + + if (!alwaysReasoning) { + selectable.push("off"); + } + + if (hasReasoningSupport) { + selectable.push("on"); + } + + if (hasReasoningSupport && config.interleaved_field !== null) { + selectable.push("interleaved"); + } + + if ( + config.interleaved_field !== null && + config.preserved_toggle !== null && + !alwaysReasoning + ) { + selectable.push("preserved"); + } + + if (selectable.length === 0) { + return [DEFAULT_REASONING_MODE]; + } + + return selectable; +}; + +const setToggleValue = ( + toggleValues: Record, + key: string | null, + onValue: boolean, + enabled: boolean +): void => { + if (!key) { + return; + } + + toggleValues[key] = enabled ? onValue : !onValue; +}; + +export const buildFriendliChatTemplateKwargs = ( + modelId: string, + requestedMode: ReasoningMode +): Record | undefined => { + const config = getFriendliReasoningConfig(modelId); + const mode = resolveFriendliReasoningMode(modelId, requestedMode); + const toggles: Record = {}; + + setToggleValue( + toggles, + config.reasoning_toggle, + config.on_value.reasoning_toggle, + mode !== "off" + ); + setToggleValue( + toggles, + config.preserved_toggle, + config.on_value.preserved_toggle, + mode === "preserved" + ); + + if (Object.keys(toggles).length === 0) { + return undefined; + } + + return toggles; +}; + +const extractReasoningTextFromPart = (part: unknown): string | null => { + if (typeof part !== "object" || part === null || !("type" in part)) { + return null; + } + + const type = (part as { type: unknown }).type; + if (type !== "reasoning") { + return null; + } + + if ("text" in part && typeof (part as { text: unknown }).text === "string") { + return (part as { text: string }).text; + } + + if ( + "reasoning" in part && + typeof (part as { reasoning: unknown }).reasoning === "string" + ) { + return (part as { reasoning: string }).reasoning; + } + + return null; +}; + +const getReasoningTextFromAssistantMessage = ( + message: ModelMessage +): string | null => { + if (message.role !== "assistant" || !Array.isArray(message.content)) { + return null; + } + + const chunks: string[] = []; + + for (const part of message.content) { + const reasoningText = extractReasoningTextFromPart(part); + if (reasoningText) { + chunks.push(reasoningText); + } + } + + if (chunks.length === 0) { + return null; + } + + return chunks.join(""); +}; + +const removeReasoningPartsFromAssistantMessage = ( + message: ModelMessage +): ModelMessage => { + if (message.role !== "assistant" || !Array.isArray(message.content)) { + return message; + } + + const filteredContent = message.content.filter((part) => { + return !( + typeof part === "object" && + part !== null && + "type" in part && + (part as { type: unknown }).type === "reasoning" + ); + }); + + if (filteredContent.length === message.content.length) { + return message; + } + + if (filteredContent.length === 0) { + return { + ...message, + content: "", + }; + } + + return { + ...message, + content: filteredContent, + }; +}; + +export const applyFriendliInterleavedField = ( + messages: ModelMessage[], + modelId: string, + requestedMode: ReasoningMode +): ModelMessage[] => { + const config = getFriendliReasoningConfig(modelId); + const mode = resolveFriendliReasoningMode(modelId, requestedMode); + + const interleavedField = config.interleaved_field; + if (!interleavedField) { + return messages; + } + + return messages.map((message) => { + if (mode !== "interleaved" && mode !== "preserved") { + const cleanedMessage = removeReasoningPartsFromAssistantMessage(message); + + if ( + typeof cleanedMessage === "object" && + cleanedMessage !== null && + interleavedField in cleanedMessage + ) { + const { [interleavedField]: _omitted, ...rest } = + cleanedMessage as Record; + return rest as ModelMessage; + } + + return cleanedMessage; + } + + const reasoningText = getReasoningTextFromAssistantMessage(message); + if (!reasoningText) { + return message; + } + + return { + ...message, + [interleavedField]: reasoningText, + }; + }); +}; diff --git a/src/reasoning-mode.test.ts b/src/reasoning-mode.test.ts new file mode 100644 index 0000000..b09c767 --- /dev/null +++ b/src/reasoning-mode.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "bun:test"; +import { parseReasoningMode } from "./reasoning-mode"; + +describe("parseReasoningMode", () => { + it("parses canonical reasoning modes", () => { + expect(parseReasoningMode("off")).toBe("off"); + expect(parseReasoningMode("on")).toBe("on"); + expect(parseReasoningMode("interleaved")).toBe("interleaved"); + expect(parseReasoningMode("preserved")).toBe("preserved"); + }); + + it("parses compatibility aliases", () => { + expect(parseReasoningMode("enable")).toBe("on"); + expect(parseReasoningMode("true")).toBe("on"); + expect(parseReasoningMode("disable")).toBe("off"); + expect(parseReasoningMode("false")).toBe("off"); + expect(parseReasoningMode("interleave")).toBe("interleaved"); + expect(parseReasoningMode("preserve")).toBe("preserved"); + }); + + it("returns null for unknown values", () => { + expect(parseReasoningMode("something-else")).toBeNull(); + }); +}); diff --git a/src/reasoning-mode.ts b/src/reasoning-mode.ts new file mode 100644 index 0000000..fe4ea11 --- /dev/null +++ b/src/reasoning-mode.ts @@ -0,0 +1,40 @@ +export const REASONING_MODES = [ + "off", + "on", + "interleaved", + "preserved", +] as const; + +export type ReasoningMode = (typeof REASONING_MODES)[number]; + +export const DEFAULT_REASONING_MODE: ReasoningMode = "off"; + +const isReasoningMode = (value: string): value is ReasoningMode => { + return REASONING_MODES.includes(value as ReasoningMode); +}; + +export const parseReasoningMode = (rawValue: string): ReasoningMode | null => { + const value = rawValue.toLowerCase(); + + if (isReasoningMode(value)) { + return value; + } + + if (value === "enable" || value === "true") { + return "on"; + } + + if (value === "disable" || value === "false") { + return "off"; + } + + if (value === "interleave") { + return "interleaved"; + } + + if (value === "preserve") { + return "preserved"; + } + + return null; +}; From d022c4dbb43cd31fbd4200f55806f704a0887c80 Mon Sep 17 00:00:00 2001 From: minpeter Date: Mon, 23 Feb 2026 15:40:03 +0900 Subject: [PATCH 02/12] feat(tools): centralize tool descriptions and hashline utilities Replace grouped tool system-context files with per-tool description text sources and wire all tool definitions to import those descriptions. Move hashline logic into shared tools utilities so read/edit paths consume the same implementation. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- src/context/system-prompt.ts | 36 +- src/tools/AGENTS.md | 44 ++ src/tools/execute/shell-execute.ts | 5 +- src/tools/execute/shell-execute.txt | 27 + src/tools/execute/shell-interact.ts | 10 +- src/tools/execute/shell-interact.txt | 15 + src/tools/execute/system-context.txt | 118 ----- src/tools/explore/glob-files.txt | 8 + src/tools/explore/glob.ts | 75 ++- src/tools/explore/grep-files.txt | 8 + src/tools/explore/grep.ts | 5 +- src/tools/explore/read-file.test.ts | 81 ++- src/tools/explore/read-file.ts | 46 +- src/tools/explore/read-file.txt | 23 + src/tools/explore/safety-utils.ts | 244 +++++---- src/tools/explore/system-context.txt | 37 -- src/tools/modify/AGENTS.md | 38 ++ src/tools/modify/delete-file.ts | 7 +- src/tools/modify/delete-file.txt | 9 + src/tools/modify/edit-file.test.ts | 516 ++++++------------- src/tools/modify/edit-file.ts | 574 ++++++++-------------- src/tools/modify/edit-file.txt | 36 ++ src/tools/modify/system-context.txt | 47 -- src/tools/modify/write-file.ts | 6 +- src/tools/modify/write-file.txt | 9 + src/tools/planning/load-skill.ts | 4 +- src/tools/planning/load-skill.txt | 6 + src/tools/planning/system-context.txt | 56 --- src/tools/planning/todo-write.ts | 7 +- src/tools/planning/todo-write.txt | 7 + src/tools/utils/hashline/hashline.test.ts | 142 ++++++ src/tools/utils/hashline/hashline.ts | 519 +++++++++++++++++++ 32 files changed, 1600 insertions(+), 1165 deletions(-) create mode 100644 src/tools/AGENTS.md create mode 100644 src/tools/execute/shell-execute.txt create mode 100644 src/tools/execute/shell-interact.txt delete mode 100644 src/tools/execute/system-context.txt create mode 100644 src/tools/explore/glob-files.txt create mode 100644 src/tools/explore/grep-files.txt create mode 100644 src/tools/explore/read-file.txt delete mode 100644 src/tools/explore/system-context.txt create mode 100644 src/tools/modify/AGENTS.md create mode 100644 src/tools/modify/delete-file.txt create mode 100644 src/tools/modify/edit-file.txt delete mode 100644 src/tools/modify/system-context.txt create mode 100644 src/tools/modify/write-file.txt create mode 100644 src/tools/planning/load-skill.txt delete mode 100644 src/tools/planning/system-context.txt create mode 100644 src/tools/planning/todo-write.txt create mode 100644 src/tools/utils/hashline/hashline.test.ts create mode 100644 src/tools/utils/hashline/hashline.ts diff --git a/src/context/system-prompt.ts b/src/context/system-prompt.ts index beda747..2fea1ab 100644 --- a/src/context/system-prompt.ts +++ b/src/context/system-prompt.ts @@ -1,7 +1,13 @@ -import SHELL_TOOLS_CONTEXT from "../tools/execute/system-context.txt"; -import EXPLORE_TOOLS_CONTEXT from "../tools/explore/system-context.txt"; -import MODIFY_TOOLS_CONTEXT from "../tools/modify/system-context.txt"; -import PLANNING_TOOLS_CONTEXT from "../tools/planning/system-context.txt"; +import SHELL_EXECUTE_CONTEXT from "../tools/execute/shell-execute.txt"; +import SHELL_INTERACT_CONTEXT from "../tools/execute/shell-interact.txt"; +import GLOB_FILES_CONTEXT from "../tools/explore/glob-files.txt"; +import GREP_FILES_CONTEXT from "../tools/explore/grep-files.txt"; +import READ_FILE_CONTEXT from "../tools/explore/read-file.txt"; +import DELETE_FILE_CONTEXT from "../tools/modify/delete-file.txt"; +import EDIT_FILE_CONTEXT from "../tools/modify/edit-file.txt"; +import WRITE_FILE_CONTEXT from "../tools/modify/write-file.txt"; +import LOAD_SKILL_CONTEXT from "../tools/planning/load-skill.txt"; +import TODO_WRITE_CONTEXT from "../tools/planning/todo-write.txt"; export const SYSTEM_PROMPT = `You are an expert software engineer assistant. @@ -9,13 +15,25 @@ Your goal is to help users accomplish coding tasks efficiently and correctly. --- -${EXPLORE_TOOLS_CONTEXT} +${READ_FILE_CONTEXT} -${MODIFY_TOOLS_CONTEXT} +${GLOB_FILES_CONTEXT} -${SHELL_TOOLS_CONTEXT} +${GREP_FILES_CONTEXT} -${PLANNING_TOOLS_CONTEXT} +${EDIT_FILE_CONTEXT} + +${WRITE_FILE_CONTEXT} + +${DELETE_FILE_CONTEXT} + +${SHELL_EXECUTE_CONTEXT} + +${SHELL_INTERACT_CONTEXT} + +${LOAD_SKILL_CONTEXT} + +${TODO_WRITE_CONTEXT} --- @@ -61,7 +79,7 @@ ${PLANNING_TOOLS_CONTEXT} - **Read before write**: Always verify file contents before modifying - **Test your changes**: Run tests or verify functionality after modifications - **Handle errors gracefully**: Check command outputs and handle failures appropriately -- **Be precise**: Use exact string matching in edit_file to avoid unintended changes +- **Be precise**: Use read_file hashline anchors (LINE#HASH) with edit_file for deterministic edits --- diff --git a/src/tools/AGENTS.md b/src/tools/AGENTS.md new file mode 100644 index 0000000..4f85566 --- /dev/null +++ b/src/tools/AGENTS.md @@ -0,0 +1,44 @@ +# TOOLS SUBSYSTEM KNOWLEDGE BASE + +**Generated:** 2026-02-23 14:40 KST +**Scope:** `src/tools/**` + +## OVERVIEW +`src/tools/` implements all callable tools and maps stable public tool keys to concrete executors. + +## STRUCTURE +```text +src/tools/ +|- index.ts # Public tool registry keys +|- execute/ # Shell execution and process management +|- explore/ # Read, glob, grep, and safety checks +|- modify/ # Deterministic file mutation operations +| `- AGENTS.md # High-risk mutation constraints +`- planning/ # Skill loading and todo state updates +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Add or rename a tool key | `src/tools/index.ts` | Keep key names stable for model/tool contracts | +| Shell execution semantics | `src/tools/execute/shell-execute.ts` | Timeout, background handling, non-interactive wrapping | +| File read/search behavior | `src/tools/explore/read-file.ts` | Enforces limits and safe read semantics | +| Deterministic file editing | `src/tools/modify/edit-file.ts` | Hashline-native edits | +| Todo persistence semantics | `src/tools/planning/todo-write.ts` | Status accounting and validation | +| Skill content resolution | `src/tools/planning/load-skill.ts` | Local/project/bundled skill lookup behavior | + +## CONVENTIONS +- `index.ts` is the single source of truth for exported tool key names (`shell_execute`, `read_file`, `edit_file`, etc.). +- Keep per-tool description files (`.txt`) synchronized with implementation and prompt wiring. +- Prefer dedicated `explore/modify` tools for file operations; shell is for build/test/git/system operations. +- `modify/edit-file.ts` is hashline-first: preserve `expected_file_hash` checks and deterministic anchors. +- New tool behavior must ship with colocated tests under the same subdirectory. + +## ANTI-PATTERNS +- Introducing file reads/writes through shell commands when dedicated tools already exist. +- Changing tool key strings in `index.ts` without coordinated updates to callers and tests. +- Bypassing stale-safety checks in edit flows (`expected_file_hash`, anchored operations). +- Expanding planning behavior without updating validation and summary outputs in tests. + +## NOTES +- `src/tools/modify/AGENTS.md` is the source of truth for hashline/legacy-edit constraints. diff --git a/src/tools/execute/shell-execute.ts b/src/tools/execute/shell-execute.ts index a776a42..d7e4d3d 100644 --- a/src/tools/execute/shell-execute.ts +++ b/src/tools/execute/shell-execute.ts @@ -2,6 +2,7 @@ import { tool } from "ai"; import { z } from "zod"; import { formatBackgroundMessage, formatTimeoutMessage } from "./format-utils"; import { executeCommand as pmExecuteCommand } from "./process-manager"; +import SHELL_EXECUTE_DESCRIPTION from "./shell-execute.txt"; const DEFAULT_TIMEOUT_MS = 120_000; @@ -43,9 +44,7 @@ export async function executeCommand( } export const shellExecuteTool = tool({ - description: - "Execute shell commands (git, npm, build, tests). " + - "120s timeout (configurable). Returns exit code and combined stdout/stderr output.", + description: SHELL_EXECUTE_DESCRIPTION, inputSchema: z.object({ command: z.string().describe("Shell command to execute"), diff --git a/src/tools/execute/shell-execute.txt b/src/tools/execute/shell-execute.txt new file mode 100644 index 0000000..2defc79 --- /dev/null +++ b/src/tools/execute/shell-execute.txt @@ -0,0 +1,27 @@ +### shell_execute +**Purpose**: Run shell commands with configurable timeout (default: 120s) + +**When to use**: +- Package management: `npm install`, `pip install`, `cargo build` +- Version control: `git status`, `git commit`, `git push` +- Build systems: `make`, `npm run build`, `docker build` +- Running tests: `npm test`, `pytest`, `cargo test` +- OS-level only: `chmod`, `ln -s`, `chown` + +**Timeout guidance**: +- Quick commands: default 120s +- Build/test/install: use larger timeout (`300000`-`600000` ms) + +**Non-interactive behavior**: +- Wrapper applies non-interactive flags/env when possible (`CI=true`, `DEBIAN_FRONTEND=noninteractive`, `GIT_TERMINAL_PROMPT=0`) + +**Background commands**: +- Commands ending with `&` run in background +- Always verify startup with follow-up checks (`sleep`, health check, logs) + +**Notes**: +- Large outputs are truncated automatically +- Background commands (`&`) should be verified with follow-up checks + +**Do not use shell for file ops**: +- Use dedicated tools for read/write/edit/delete/search/glob diff --git a/src/tools/execute/shell-interact.ts b/src/tools/execute/shell-interact.ts index fafd8d3..f177eda 100644 --- a/src/tools/execute/shell-interact.ts +++ b/src/tools/execute/shell-interact.ts @@ -1,5 +1,6 @@ import { tool } from "ai"; import { z } from "zod"; +import SHELL_INTERACT_DESCRIPTION from "./shell-interact.txt"; const SPECIAL_KEYS: Record = { enter: "Enter", @@ -105,13 +106,13 @@ export interface InteractResult { } const CTRL_C_GUIDANCE = [ - "No persistent terminal session exists. Each shell_execute command runs independently.", + "No retained terminal context exists. Each shell_execute command runs independently.", "To interrupt a long-running command, wait for its timeout (default: 120s) or use shell_execute to kill the process by PID:", ' shell_execute({ command: "kill -SIGINT " })', ].join("\n"); const GENERIC_GUIDANCE = [ - "No persistent terminal session exists. Each shell_execute command runs independently.", + "No retained terminal context exists. Each shell_execute command runs independently.", "To run a command, use shell_execute directly:", ' shell_execute({ command: "your command here" })', ].join("\n"); @@ -121,10 +122,7 @@ function hasCtrlC(parsedKeys: string[]): boolean { } export const shellInteractTool = tool({ - description: - "Guidance tool: no persistent terminal session exists. " + - "Use shell_execute to run commands directly. " + - "This tool returns guidance on how to interact with processes using shell_execute.", + description: SHELL_INTERACT_DESCRIPTION, inputSchema: z.object({ keystrokes: z diff --git a/src/tools/execute/shell-interact.txt b/src/tools/execute/shell-interact.txt new file mode 100644 index 0000000..cc061d8 --- /dev/null +++ b/src/tools/execute/shell-interact.txt @@ -0,0 +1,15 @@ +### shell_interact +**Purpose**: Return guidance for interactive process handling + +**When to use**: +- Prompt response guidance (`y`, `n`, ``) +- Recovering from long-running interactive workflows + +**Important constraint**: +- This tool does not control a persistent terminal session +- It returns instructions only; it does not execute commands + +**Notes**: +- No terminal session is retained between calls +- Use `shell_execute` for actual command execution +- For interruption, use timeout handling or explicit `shell_execute` kill commands diff --git a/src/tools/execute/system-context.txt b/src/tools/execute/system-context.txt deleted file mode 100644 index 52b64b5..0000000 --- a/src/tools/execute/system-context.txt +++ /dev/null @@ -1,118 +0,0 @@ -## Shell Tools - -Use shell for: (a) package install/build/test/git, (b) OS-level operations not possible with file tools (chmod, symlink). -For file read/write/search, ALWAYS use dedicated tools instead. - -### shell_execute -**Purpose**: Run shell commands with configurable timeout (default: 120s) - -**When to use**: -- Package management: `npm install`, `pip install`, `cargo build` -- Version control: `git status`, `git commit`, `git push` -- Build systems: `make`, `npm run build`, `docker build` -- Running tests: `npm test`, `pytest`, `cargo test` -- OS-level only: `chmod`, `ln -s`, `chown` (use file tools for mkdir - write_file auto-creates parent dirs) - -**Timeout guidelines**: -- Quick commands (git status, whoami, pwd): default 120s is fine -- Package install (npm install, pip install): use `timeout_ms: 120000` (2 min) -- Build commands (npm run build, cargo build): use `timeout_ms: 300000` (5 min) -- Test suites (npm test, pytest): use `timeout_ms: 300000` (5 min) -- Model downloads, large operations: use `timeout_ms: 600000` (10 min) - -**On timeout**: -- `[TIMEOUT]`: Process may still be running. Check output and process state before deciding next steps. - -- Large outputs are automatically truncated. Full output saved to temp file. - -**Automatic non-interactive mode**: -Commands are automatically wrapped with non-interactive flags when possible: -- apt/apt-get: `DEBIAN_FRONTEND=noninteractive` + `-y` flag -- git clone/fetch/pull/push: `GIT_TERMINAL_PROMPT=0` + SSH BatchMode -- npm/yarn/pnpm/bun install: `CI=true` environment -- pip install: `PIP_NO_INPUT=1` -- yum/dnf: `-y` flag, pacman: `--noconfirm` flag -- brew: `NONINTERACTIVE=1`, terraform: `-auto-approve` flag - -**Background processes**: -- Commands ending with `&` run in background -- ALWAYS verify after starting: - ``` - shell_execute("nohup python app.py > server.log 2>&1 &") - shell_execute("sleep 2") # Give it time to start - shell_execute("curl http://localhost:5000/health") # Verify - ``` -- If verification fails: - - Check logs: `read_file("server.log")` - - Check process: `shell_execute("ps aux | grep app.py")` - - Check port: `shell_execute("lsof -i :5000")` - -### shell_interact -**Purpose**: Guidance tool — returns instructions on how to interact with processes using shell_execute - -**When to use**: -- Responding to prompts (y/n questions, config conflicts) -- Recovering from or interrupting long-running commands when guidance is requested -- Navigating interactive programs (vim, less, pagers) via follow-up shell_execute steps - -**Key points**: -- Input schema is retained for compatibility: `keystrokes` (string), `timeout_ms` (number, optional) -- No retained terminal context exists; all commands are run per `shell_execute` - -**Common responses**: -``` -shell_interact("") -# Each `shell_execute` command runs independently. To interrupt a long-running command, wait for its timeout (default: 120s) or use shell_execute to kill the process by PID: shell_execute({ command: 'kill -SIGINT ' }) - -shell_interact("y") -# Commands are not connected between calls. To run a command, use shell_execute directly: shell_execute({ command: 'your command here' }) - -shell_interact("n") -# Commands are not connected between calls. To run a command, use shell_execute directly: shell_execute({ command: 'your command here' }) -``` - -### Tool Selection Rules (STRICT) - -**NEVER use shell for these - use dedicated tools**: - -| Task | Forbidden Shell | Required Tool | -|------|-----------------|---------------| -| Read file contents | `cat`, `head`, `tail` | `read_file` | -| Modify file contents | `sed`, `awk`, `perl -i` | `edit_file` | -| Create/overwrite file | `echo >`, `cat < list[i].mtime.getTime()) { + insertAt = i; + break; + } + } + + list.splice(insertAt, 0, item); + if (list.length > MAX_RESULTS) { + list.pop(); + } +} + const inputSchema = z.object({ pattern: z.string().describe("Glob pattern (e.g., '**/*.py', 'docs/*.md')"), path: z @@ -34,9 +51,34 @@ export async function executeGlob({ const searchDir = path ? resolve(path) : process.cwd(); const glob = new Bun.Glob(pattern); - const filesWithMtime: FileWithMtime[] = []; + const topFilesByMtime: FileWithMtime[] = []; + let totalMatches = 0; + let skippedUnreadable = 0; + + const ignoreFilter = respect_git_ignore + ? await getIgnoreFilter(searchDir) + : null; - const ignoreFilter = respect_git_ignore ? await getIgnoreFilter() : null; + const pending = new Set>(); + + const enqueueStat = (absolutePath: string): void => { + const task = stat(absolutePath) + .then((stats) => { + totalMatches += 1; + insertTopByMtime(topFilesByMtime, { + path: absolutePath, + mtime: stats.mtime, + }); + }) + .catch(() => { + skippedUnreadable += 1; + }) + .finally(() => { + pending.delete(task); + }); + + pending.add(task); + }; for await (const file of glob.scan({ cwd: searchDir, @@ -48,31 +90,26 @@ export async function executeGlob({ } const absolutePath = join(searchDir, file); - try { - const stats = await stat(absolutePath); - filesWithMtime.push({ - path: absolutePath, - mtime: stats.mtime, - }); - } catch { - // Ignore files that cannot be accessed + enqueueStat(absolutePath); + + if (pending.size >= STAT_CONCURRENCY) { + await Promise.race(pending); } } - filesWithMtime.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + await Promise.all(pending); - const truncated = filesWithMtime.length > MAX_RESULTS; - const displayFiles = truncated - ? filesWithMtime.slice(0, MAX_RESULTS) - : filesWithMtime; + const truncated = totalMatches > MAX_RESULTS; + const displayFiles = topFilesByMtime; const output = [ - filesWithMtime.length > 0 ? "OK - glob" : "OK - glob (no matches)", + totalMatches > 0 ? "OK - glob" : "OK - glob (no matches)", `pattern: "${pattern}"`, `path: ${path ?? "."}`, `respect_git_ignore: ${respect_git_ignore}`, - `file_count: ${filesWithMtime.length}`, + `file_count: ${totalMatches}`, `truncated: ${truncated}`, + `skipped_unreadable: ${skippedUnreadable}`, "sorted_by: mtime desc", "", ]; @@ -92,9 +129,7 @@ export async function executeGlob({ } export const globTool = tool({ - description: - "Find files by pattern (e.g., '**/*.ts', 'src/**/*.json'). " + - "Returns paths sorted by modification time (newest first).", + description: GLOB_FILES_DESCRIPTION, inputSchema, execute: executeGlob, }); diff --git a/src/tools/explore/grep-files.txt b/src/tools/explore/grep-files.txt new file mode 100644 index 0000000..80c5af1 --- /dev/null +++ b/src/tools/explore/grep-files.txt @@ -0,0 +1,8 @@ +### grep_files +**Purpose**: Search file contents by regex or literal pattern + +**When to use**: +- Finding function/variable usages +- Locating specific code patterns and messages + +**DO NOT use shell**: Prefer this over `grep`, `rg`, `ag` diff --git a/src/tools/explore/grep.ts b/src/tools/explore/grep.ts index 17448e3..951b4d3 100644 --- a/src/tools/explore/grep.ts +++ b/src/tools/explore/grep.ts @@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path"; import { tool } from "ai"; import { z } from "zod"; import { ensureTool } from "../../utils/tools-manager"; +import GREP_FILES_DESCRIPTION from "./grep-files.txt"; import { formatBlock } from "./safety-utils"; const MAX_MATCHES = 20_000; @@ -204,9 +205,7 @@ export async function executeGrep({ } export const grepTool = tool({ - description: - "Search file contents (regex or literal). " + - "Returns file:line:content format. Use result line numbers with read_file(around_line).", + description: GREP_FILES_DESCRIPTION, inputSchema, execute: executeGrep, }); diff --git a/src/tools/explore/read-file.test.ts b/src/tools/explore/read-file.test.ts index 1d94055..913f3fd 100644 --- a/src/tools/explore/read-file.test.ts +++ b/src/tools/explore/read-file.test.ts @@ -5,6 +5,15 @@ import { join } from "node:path"; import { executeReadFile } from "./read-file"; const ISO_DATE_PATTERN = /\d{4}-\d{2}-\d{2}T/; +const FILE_HASH_PATTERN = /file_hash:\s+[0-9a-f]{8}/; +const LINE_TAG_PATTERN = /\s\d+#(?:[ZPMQVRWSNKTXJBYH]{2})\s\|/; +const HASHLINE_ALPHABET = "[ZPMQVRWSNKTXJBYH]{2}"; +const REGEX_ESCAPE_PATTERN = /[.*+?^${}()|[\]\\]/g; + +function buildTaggedLinePattern(lineNumber: number, text: string): RegExp { + const escaped = text.replaceAll(REGEX_ESCAPE_PATTERN, "\\$&"); + return new RegExp(`\\s${lineNumber}#${HASHLINE_ALPHABET}\\s\\|\\s${escaped}`); +} describe("executeReadFile", () => { let tempDir: string; @@ -30,11 +39,12 @@ describe("executeReadFile", () => { expect(result).toContain(`path: ${testFile}`); expect(result).toContain("bytes:"); expect(result).toContain("lines: 3"); + expect(result).toMatch(FILE_HASH_PATTERN); expect(result).toContain("range: L1-L3"); expect(result).toContain("======== basic.txt L1-L3 ========"); - expect(result).toContain(" 1 | line1"); - expect(result).toContain(" 2 | line2"); - expect(result).toContain(" 3 | line3"); + expect(result).toMatch(buildTaggedLinePattern(1, "line1")); + expect(result).toMatch(buildTaggedLinePattern(2, "line2")); + expect(result).toMatch(buildTaggedLinePattern(3, "line3")); expect(result).toContain("======== end ========"); }); @@ -57,11 +67,11 @@ describe("executeReadFile", () => { const result = await executeReadFile({ path: testFile, offset: 2 }); expect(result).toContain("range: L3-L5"); - expect(result).toContain(" 3 | c"); - expect(result).toContain(" 4 | d"); - expect(result).toContain(" 5 | e"); - expect(result).not.toContain(" 1 | a"); - expect(result).not.toContain(" 2 | b"); + expect(result).toMatch(buildTaggedLinePattern(3, "c")); + expect(result).toMatch(buildTaggedLinePattern(4, "d")); + expect(result).toMatch(buildTaggedLinePattern(5, "e")); + expect(result).not.toMatch(buildTaggedLinePattern(1, "a")); + expect(result).not.toMatch(buildTaggedLinePattern(2, "b")); }); it("respects limit parameter", async () => { @@ -72,8 +82,8 @@ describe("executeReadFile", () => { expect(result).toContain("range: L1-L2"); expect(result).toContain("returned: 2"); - expect(result).toContain(" 1 | a"); - expect(result).toContain(" 2 | b"); + expect(result).toMatch(buildTaggedLinePattern(1, "a")); + expect(result).toMatch(buildTaggedLinePattern(2, "b")); }); it("combines offset and limit", async () => { @@ -87,9 +97,9 @@ describe("executeReadFile", () => { }); expect(result).toContain("range: L4-L6"); - expect(result).toContain(" 4 | 4"); - expect(result).toContain(" 5 | 5"); - expect(result).toContain(" 6 | 6"); + expect(result).toMatch(buildTaggedLinePattern(4, "4")); + expect(result).toMatch(buildTaggedLinePattern(5, "5")); + expect(result).toMatch(buildTaggedLinePattern(6, "6")); }); }); @@ -174,6 +184,7 @@ describe("executeReadFile", () => { const result = await executeReadFile({ path: testFile }); expect(result).toContain("truncated: false"); + expect(result).toMatch(LINE_TAG_PATTERN); }); }); @@ -183,5 +194,49 @@ describe("executeReadFile", () => { executeReadFile({ path: join(tempDir, "nonexistent.txt") }) ).rejects.toThrow(); }); + + it("rejects negative offset", async () => { + const testFile = join(tempDir, "invalid-offset.txt"); + writeFileSync(testFile, "a\nb\n"); + + await expect( + executeReadFile({ + path: testFile, + offset: -1, + }) + ).rejects.toThrow(); + }); + + it("rejects non-positive limit", async () => { + const testFile = join(tempDir, "invalid-limit.txt"); + writeFileSync(testFile, "a\nb\n"); + + await expect( + executeReadFile({ + path: testFile, + limit: 0, + }) + ).rejects.toThrow(); + }); + + it("rejects binary content even with text extension", async () => { + const testFile = join(tempDir, "binary.txt"); + writeFileSync(testFile, Buffer.from([0x00, 0x61, 0x62, 0x63])); + + await expect(executeReadFile({ path: testFile })).rejects.toThrow( + "binary" + ); + }); + + it("allows text files with .lock extension", async () => { + const testFile = join(tempDir, "plain.lock"); + writeFileSync(testFile, "line1\nline2"); + + const result = await executeReadFile({ path: testFile }); + + expect(result).toContain("OK - read file"); + expect(result).toMatch(buildTaggedLinePattern(1, "line1")); + expect(result).toMatch(buildTaggedLinePattern(2, "line2")); + }); }); }); diff --git a/src/tools/explore/read-file.ts b/src/tools/explore/read-file.ts index 63d1e25..c6eb3a2 100644 --- a/src/tools/explore/read-file.ts +++ b/src/tools/explore/read-file.ts @@ -2,27 +2,41 @@ import { stat } from "node:fs/promises"; import { basename } from "node:path"; import { tool } from "ai"; import { z } from "zod"; +import READ_FILE_DESCRIPTION from "./read-file.txt"; import { formatBlock, safeReadFileEnhanced } from "./safety-utils"; const inputSchema = z.object({ path: z.string().describe("File path (absolute or relative)"), offset: z .number() + .int() + .min(0) .optional() .describe( "Start line (0-based, default: 0). Use around_line for smarter reading." ), - limit: z.number().optional().describe("Max lines to read (default: 2000)"), + limit: z + .number() + .int() + .min(1) + .optional() + .describe("Max lines to read (default: 2000)"), around_line: z .number() + .int() + .min(1) .optional() .describe("Read around this line (1-based). Combines with before/after."), before: z .number() + .int() + .min(0) .optional() .describe("Lines before around_line (default: 5)"), after: z .number() + .int() + .min(0) .optional() .describe("Lines after around_line (default: 10)"), }); @@ -37,7 +51,8 @@ export async function executeReadFile({ before, after, }: ReadFileInput): Promise { - const result = await safeReadFileEnhanced(path, { + const parsedInput = inputSchema.parse({ + path, offset, limit, around_line, @@ -45,12 +60,29 @@ export async function executeReadFile({ after, }); + const result = await safeReadFileEnhanced(path, { + offset: parsedInput.offset, + limit: parsedInput.limit, + around_line: parsedInput.around_line, + before: parsedInput.before, + after: parsedInput.after, + }); + let mtime = ""; try { const stats = await stat(path); mtime = stats.mtime.toISOString(); - } catch { - mtime = "unknown"; + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + typeof (error as NodeJS.ErrnoException).code === "string" + ) { + mtime = `unknown(${(error as NodeJS.ErrnoException).code})`; + } else { + mtime = "unknown(error)"; + } } const fileName = basename(path); @@ -62,6 +94,7 @@ export async function executeReadFile({ `bytes: ${result.bytes}`, `last_modified: ${mtime}`, `lines: ${result.totalLines} (returned: ${result.endLine1 - result.startLine1 + 1})`, + `file_hash: ${result.fileHash}`, `range: ${rangeStr}`, `truncated: ${result.truncated}`, "", @@ -72,10 +105,7 @@ export async function executeReadFile({ } export const readFileTool = tool({ - description: - "Read file contents with line numbers. " + - "ALWAYS read before editing. " + - "Use around_line for smart reading (grep_files result → read around match).", + description: READ_FILE_DESCRIPTION, inputSchema, execute: executeReadFile, }); diff --git a/src/tools/explore/read-file.txt b/src/tools/explore/read-file.txt new file mode 100644 index 0000000..acf9c65 --- /dev/null +++ b/src/tools/explore/read-file.txt @@ -0,0 +1,23 @@ +### read_file +**Purpose**: Read file contents with hashline anchors (`LINE#HASH`) + +**When to use**: +- ALWAYS read a file before editing it +- Inspecting data files to confirm real structure +- Capturing `LINE#HASH` and `file_hash` for `edit_file` + +**Parameters**: +- `path` (required): file path +- `offset` (optional, 0-based, min 0): start line index +- `limit` (optional, min 1): max lines to return +- `around_line` (optional, 1-based, min 1): center line for windowed read +- `before` / `after` (optional, min 0): context window around `around_line` + +**Output fields**: +- `file_hash`: stale-check token for `edit_file.expected_file_hash` +- `range`: returned line range (`Lx-Ly`) +- `truncated`: whether partial content was returned + +**Guidelines**: +- Prefer `around_line` after `grep_files` hits +- Re-run `read_file` before retrying stale hashline edits diff --git a/src/tools/explore/safety-utils.ts b/src/tools/explore/safety-utils.ts index fd11f76..7fe8f05 100644 --- a/src/tools/explore/safety-utils.ts +++ b/src/tools/explore/safety-utils.ts @@ -1,45 +1,19 @@ -import { readFile, stat } from "node:fs/promises"; +import { open, readFile, stat } from "node:fs/promises"; import { isAbsolute, join, relative } from "node:path"; import ignore, { type Ignore } from "ignore"; +import { + computeFileHash, + formatHashlineNumberedLines, +} from "../utils/hashline/hashline"; -const MAX_FILE_SIZE = 1024 * 1024; // 1MB -const MAX_LINES = 2000; -const BINARY_EXTENSIONS = new Set([ - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".ico", - ".svg", - ".bmp", - ".mp3", - ".mp4", - ".wav", - ".avi", - ".mov", - ".mkv", - ".pdf", - ".zip", - ".tar", - ".gz", - ".rar", - ".7z", - ".exe", - ".dll", - ".so", - ".dylib", - ".woff", - ".woff2", - ".ttf", - ".eot", - ".otf", - ".db", - ".sqlite", - ".lock", -]); - -const DEFAULT_IGNORE_PATTERNS = [ +const FILE_READ_POLICY = { + maxFileSizeBytes: 1024 * 1024, // 1MB + maxLinesPerRead: 2000, + binarySampleBytes: 4096, + nonPrintableThreshold: 0.3, +} as const; + +const DEFAULT_IGNORED_DIRECTORIES = [ "node_modules", ".git", ".next", @@ -52,37 +26,86 @@ const DEFAULT_IGNORE_PATTERNS = [ ".vercel", ".output", "__pycache__", - "*.pyc", ".venv", "venv", - ".env.local", - ".env.*.local", ]; -let cachedIgnore: Ignore | null = null; +const DEFAULT_IGNORED_FILE_PATTERNS = ["*.pyc", ".env.local", ".env.*.local"]; + +const ignoreCache = new Map(); + +function buildDefaultIgnorePatterns(): string[] { + return [...DEFAULT_IGNORED_DIRECTORIES, ...DEFAULT_IGNORED_FILE_PATTERNS]; +} -export async function getIgnoreFilter(): Promise { - if (cachedIgnore) { - return cachedIgnore; +export async function getIgnoreFilter( + baseDir = process.cwd() +): Promise { + const cacheKey = baseDir; + const cached = ignoreCache.get(cacheKey); + if (cached) { + return cached; } - const ig = ignore().add(DEFAULT_IGNORE_PATTERNS); + const ig = ignore().add(buildDefaultIgnorePatterns()); try { - const gitignorePath = join(process.cwd(), ".gitignore"); + const gitignorePath = join(baseDir, ".gitignore"); const gitignoreContent = await readFile(gitignorePath, "utf-8"); ig.add(gitignoreContent); - } catch { - // .gitignore doesn't exist, use default patterns only + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + // .gitignore doesn't exist, use default patterns only + } else { + throw error; + } } - cachedIgnore = ig; + ignoreCache.set(cacheKey, ig); return ig; } -function isBinaryFile(path: string): boolean { - const ext = path.toLowerCase().slice(path.lastIndexOf(".")); - return BINARY_EXTENSIONS.has(ext); +async function isLikelyBinaryFile( + filePath: string, + fileSize: number +): Promise { + if (fileSize === 0) { + return false; + } + + const sampleSize = Math.min(FILE_READ_POLICY.binarySampleBytes, fileSize); + const handle = await open(filePath, "r"); + try { + const bytes = Buffer.alloc(sampleSize); + const result = await handle.read(bytes, 0, sampleSize, 0); + if (result.bytesRead === 0) { + return false; + } + + let nonPrintableCount = 0; + for (let i = 0; i < result.bytesRead; i++) { + const value = bytes[i]; + if (value === 0) { + return true; + } + + if (value < 9 || (value > 13 && value < 32)) { + nonPrintableCount++; + } + } + + return ( + nonPrintableCount / result.bytesRead > + FILE_READ_POLICY.nonPrintableThreshold + ); + } finally { + await handle.close(); + } } interface FileCheckResult { @@ -90,6 +113,16 @@ interface FileCheckResult { reason?: string; } +interface FileReadGuardContext { + filePath: string; + ig: Ignore; + pathForIgnoreCheck: string | null; +} + +type FileReadGuard = ( + context: FileReadGuardContext +) => FileCheckResult | null | Promise; + function getPathForIgnoreCheck(filePath: string, cwd: string): string | null { if (isAbsolute(filePath)) { const relativePath = relative(cwd, filePath); @@ -101,34 +134,73 @@ function getPathForIgnoreCheck(filePath: string, cwd: string): string | null { return filePath; } -async function checkFileReadable(filePath: string): Promise { - const ig = await getIgnoreFilter(); - const pathForIgnoreCheck = getPathForIgnoreCheck(filePath, process.cwd()); - - if (pathForIgnoreCheck && ig.ignores(pathForIgnoreCheck)) { - return { - allowed: false, - reason: `File '${filePath}' is excluded by .gitignore. Use glob_files with respect_git_ignore: false if you need to access it.`, - }; +const checkIgnoreGuard: FileReadGuard = ({ + filePath, + ig, + pathForIgnoreCheck, +}) => { + if (!(pathForIgnoreCheck && ig.ignores(pathForIgnoreCheck))) { + return null; } - if (isBinaryFile(filePath)) { - return { - allowed: false, - reason: `File '${filePath}' is binary. read_file only supports text files. Use appropriate tools for binary content.`, - }; - } + return { + allowed: false, + reason: `File '${filePath}' is excluded by .gitignore. Use glob_files with respect_git_ignore: false if you need to access it.`, + }; +}; +const checkFileStatGuards: FileReadGuard = async ({ filePath }) => { try { const stats = await stat(filePath); - if (stats.size > MAX_FILE_SIZE) { + + if (stats.size > FILE_READ_POLICY.maxFileSizeBytes) { + return { + allowed: false, + reason: `File too large (${Math.round(stats.size / 1024)}KB > ${FILE_READ_POLICY.maxFileSizeBytes / 1024}KB): ${filePath}`, + }; + } + + if (await isLikelyBinaryFile(filePath, stats.size)) { + return { + allowed: false, + reason: `File '${filePath}' is binary. read_file only supports text files. Use appropriate tools for binary content.`, + }; + } + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code !== "ENOENT" + ) { return { allowed: false, - reason: `File too large (${Math.round(stats.size / 1024)}KB > ${MAX_FILE_SIZE / 1024}KB): ${filePath}`, + reason: `Unable to inspect file metadata for '${filePath}'.`, }; } - } catch { - // stat failed - let the actual read operation handle this + } + + return null; +}; + +const FILE_READ_GUARDS: FileReadGuard[] = [ + checkIgnoreGuard, + checkFileStatGuards, +]; + +async function checkFileReadable(filePath: string): Promise { + const ig = await getIgnoreFilter(); + const context: FileReadGuardContext = { + filePath, + ig, + pathForIgnoreCheck: getPathForIgnoreCheck(filePath, process.cwd()), + }; + + for (const guard of FILE_READ_GUARDS) { + const result = await guard(context); + if (result) { + return result; + } } return { allowed: true }; @@ -143,12 +215,7 @@ export function formatNumberedLines( lines: string[], startLine1: number ): string { - return lines - .map((line, i) => { - const lineNum = startLine1 + i; - return ` ${String(lineNum).padStart(4)} | ${line}`; - }) - .join("\n"); + return formatHashlineNumberedLines(lines, startLine1); } export function formatBlock(title: string, body: string): string { @@ -176,6 +243,7 @@ export interface ReadFileResultEnhanced { bytes: number; content: string; endLine1: number; + fileHash: string; numberedContent: string; startLine1: number; totalLines: number; @@ -204,10 +272,14 @@ export async function safeReadFileEnhanced( let endLine1: number; if (options?.around_line !== undefined) { - const before = options.before ?? 5; - const after = options.after ?? 10; + const before = Math.max(options.before ?? 5, 0); + const after = Math.max(options.after ?? 10, 0); + const clampedAroundLine = Math.min( + Math.max(options.around_line, 1), + totalLines + ); const window = computeLineWindow({ - aroundLine1: options.around_line, + aroundLine1: clampedAroundLine, before, after, totalLines, @@ -215,8 +287,11 @@ export async function safeReadFileEnhanced( startLine1 = window.startLine1; endLine1 = window.endLine1; } else { - const offset = options?.offset ?? 0; - const limit = options?.limit ?? MAX_LINES; + const offset = Math.max(options?.offset ?? 0, 0); + const limit = Math.max( + options?.limit ?? FILE_READ_POLICY.maxLinesPerRead, + 1 + ); startLine1 = Math.min(offset + 1, totalLines); endLine1 = Math.min(offset + limit, totalLines); } @@ -232,5 +307,6 @@ export async function safeReadFileEnhanced( endLine1, truncated, bytes, + fileHash: computeFileHash(rawContent), }; } diff --git a/src/tools/explore/system-context.txt b/src/tools/explore/system-context.txt deleted file mode 100644 index 2fac457..0000000 --- a/src/tools/explore/system-context.txt +++ /dev/null @@ -1,37 +0,0 @@ -## Explore Tools - -Use these tools to understand the codebase before making changes. - -### read_file -**Purpose**: Read file contents with line numbers - -**When to use**: -- ALWAYS read a file before editing it -- Inspecting data files (CSV, JSON, etc.) to understand structure -- Understanding existing code behavior - -**Example**: `read_file("src/config.ts")` - -### glob_files -**Purpose**: Find files matching patterns - -**When to use**: -- Finding files by name or extension: `**/*.ts`, `src/**/*.json` -- Locating configuration files: `**/tsconfig.json` -- Discovering project structure - -**DO NOT use shell commands**: Use this instead of `find`, `ls`, or `tree` - -**Example**: `glob_files("**/*.test.ts")` finds all test files - -### grep_files -**Purpose**: Search file contents for patterns (supports regex) - -**When to use**: -- Finding where a function/variable is used -- Searching for specific patterns across files -- Locating TODO comments or error messages - -**DO NOT use shell commands**: Use this instead of `grep`, `rg`, or `ag` - -**Example**: `grep_files("function.*calculate", "src/**/*.ts")` finds all calculate functions diff --git a/src/tools/modify/AGENTS.md b/src/tools/modify/AGENTS.md new file mode 100644 index 0000000..6723507 --- /dev/null +++ b/src/tools/modify/AGENTS.md @@ -0,0 +1,38 @@ +# MODIFY TOOLS KNOWLEDGE BASE + +**Generated:** 2026-02-23 14:40 KST +**Scope:** `src/tools/modify/**` + +## OVERVIEW +`modify/` owns all write operations: deterministic edits, file creation/overwrite, and deletion. + +## STRUCTURE +```text +src/tools/modify/ +|- edit-file.ts # Hashline-native edit engine +|- edit-file.txt # edit_file description snippet +|- write-file.ts # Create or overwrite full files +|- write-file.txt # write_file description snippet +|- delete-file.ts # Safe file/directory deletion wrapper +`- delete-file.txt # delete_file description snippet +``` + +## WHERE TO LOOK +| Task | Location | Notes | +|------|----------|-------| +| Surgical line-anchored edits | `src/tools/modify/edit-file.ts` | Supports hashline ops | +| Hashline integrity logic | `src/tools/utils/hashline/hashline.ts` | Anchors, hashing, and operation ordering | +| Full-file replacement behavior | `src/tools/modify/write-file.ts` | Creates parent directories and returns metadata | +| Deletion semantics and safeguards | `src/tools/modify/delete-file.ts` | Recursive behavior and ignore-missing handling | + +## CONVENTIONS +- Prefer hashline edits with `expected_file_hash` for stale-safe deterministic updates. +- Preserve line-ending normalization behavior during write/edit operations. +- Maintain rich failure messages for near-match and context diagnostics. +- Keep all mutation behavior covered by colocated tests before changing edit semantics. + +## ANTI-PATTERNS +- Bypassing expected hash checks when deterministic edits are available. +- Replacing anchored edits with non-deterministic text matching. +- Introducing shell-based file mutation flows for behavior already handled here. +- Silencing partial-match or stale-anchor failures instead of surfacing actionable errors. diff --git a/src/tools/modify/delete-file.ts b/src/tools/modify/delete-file.ts index 0a317f8..c788f1e 100644 --- a/src/tools/modify/delete-file.ts +++ b/src/tools/modify/delete-file.ts @@ -2,6 +2,7 @@ import { rm, stat } from "node:fs/promises"; import { basename } from "node:path"; import { tool } from "ai"; import { z } from "zod"; +import DELETE_FILE_DESCRIPTION from "./delete-file.txt"; const inputSchema = z.object({ path: z.string().describe("Path to delete"), @@ -70,11 +71,7 @@ export async function executeDeleteFile({ } export const deleteFileTool = tool({ - description: - "DANGER: Permanently delete file or directory (NO RECOVERY). " + - "Verify path with read_file or glob_files before deleting. " + - "Use recursive: true for non-empty directories. " + - "Use ignore_missing: true to skip if file doesn't exist.", + description: DELETE_FILE_DESCRIPTION, inputSchema, execute: executeDeleteFile, }); diff --git a/src/tools/modify/delete-file.txt b/src/tools/modify/delete-file.txt new file mode 100644 index 0000000..c12b51a --- /dev/null +++ b/src/tools/modify/delete-file.txt @@ -0,0 +1,9 @@ +### delete_file +**Purpose**: Permanently delete a file or directory + +**When to use**: +- Removing obsolete files +- Cleaning temporary artifacts +- Deleting directories with explicit recursive intent + +**DO NOT use shell**: Prefer this over `rm` / `rm -rf` diff --git a/src/tools/modify/edit-file.test.ts b/src/tools/modify/edit-file.test.ts index 13051e6..10cb424 100644 --- a/src/tools/modify/edit-file.test.ts +++ b/src/tools/modify/edit-file.test.ts @@ -8,12 +8,30 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { executeReadFile } from "../explore/read-file"; import { executeEditFile } from "./edit-file"; -const EDITED_LINE_PATTERN = /^>/; -const CONTEXT_LINE_PATTERN = /^ /; - -describe("editFileTool", () => { +const FILE_HASH_REGEX = /^file_hash:\s+([0-9a-f]{8})$/m; +const LINE_REF_REGEX_TEMPLATE = (lineNumber: number): RegExp => + new RegExp(`\\s${lineNumber}#([ZPMQVRWSNKTXJBYH]{2})\\s\\|`); + +function extractFileHash(readOutput: string): string { + const matched = readOutput.match(FILE_HASH_REGEX); + if (!matched?.[1]) { + throw new Error("Failed to extract file_hash from read output"); + } + return matched[1]; +} + +function extractLineRef(readOutput: string, lineNumber: number): string { + const matched = readOutput.match(LINE_REF_REGEX_TEMPLATE(lineNumber)); + if (!matched?.[1]) { + throw new Error(`Failed to extract line reference for line ${lineNumber}`); + } + return `${lineNumber}#${matched[1]}`; +} + +describe("edit_file (hashline-only)", () => { let tempDir: string; beforeAll(() => { @@ -26,400 +44,154 @@ describe("editFileTool", () => { } }); - describe("basic replacement", () => { - it("replaces single occurrence and returns context", async () => { - const testFile = join(tempDir, "basic.txt"); - writeFileSync(testFile, "line1\nline2\nline3\nline4\nline5"); - - const result = await executeEditFile({ - path: testFile, - old_str: "line3", - new_str: "MODIFIED", - replace_all: false, - }); - - expect(result).toContain("OK"); - expect(result).toContain("======== basic.txt L3-L3 ========"); - expect(result).toContain("MODIFIED"); - expect(result).toContain("======== end ========"); - - const content = readFileSync(testFile, "utf-8"); - expect(content).toBe("line1\nline2\nMODIFIED\nline4\nline5"); - }); - - it("shows context lines around edit", async () => { - const testFile = join(tempDir, "context.txt"); - writeFileSync(testFile, "a\nb\nc\nd\ne\nf\ng"); - - const result = await executeEditFile({ - path: testFile, - old_str: "d", - new_str: "CHANGED", - replace_all: false, - }); - - expect(result).toContain("b"); - expect(result).toContain("c"); - expect(result).toContain("CHANGED"); - expect(result).toContain("e"); - expect(result).toContain("f"); - }); - - it("marks edited lines with > prefix", async () => { - const testFile = join(tempDir, "prefix.txt"); - writeFileSync(testFile, "keep1\nkeep2\nchange\nkeep3\nkeep4"); - - const result = await executeEditFile({ - path: testFile, - old_str: "change", - new_str: "EDITED", - replace_all: false, - }); - - const lines = result.split("\n"); - const editedLine = lines.find((l: string) => l.includes("EDITED")); - const contextLine = lines.find((l: string) => l.includes("keep2")); - - expect(editedLine).toMatch(EDITED_LINE_PATTERN); - expect(contextLine).toMatch(CONTEXT_LINE_PATTERN); - }); + it("replaces target line using LINE#HASH anchor", async () => { + const testFile = join(tempDir, "hashline-replace.txt"); + writeFileSync(testFile, "alpha\nbravo\ncharlie\n"); + + const readOutput = await executeReadFile({ path: testFile }); + const lineRef = extractLineRef(readOutput, 2); + const fileHash = extractFileHash(readOutput); + + const result = await executeEditFile({ + path: testFile, + expected_file_hash: fileHash, + edits: [ + { + op: "replace", + pos: lineRef, + lines: ["BRAVO"], + }, + ], + }); + + expect(result).toContain("OK - hashline edit"); + expect(result).toContain("file_hash:"); + expect(readFileSync(testFile, "utf-8")).toBe("alpha\nBRAVO\ncharlie\n"); }); - describe("multiline replacement", () => { - it("handles multiline old_str and new_str", async () => { - const testFile = join(tempDir, "multiline.txt"); - writeFileSync(testFile, "header\nold1\nold2\nold3\nfooter"); - - const result = await executeEditFile({ - path: testFile, - old_str: "old1\nold2\nold3", - new_str: "new1\nnew2", - replace_all: false, - }); + it("fails with helpful mismatch when anchor is stale", async () => { + const testFile = join(tempDir, "hashline-stale.txt"); + writeFileSync(testFile, "one\ntwo\nthree\n"); - expect(result).toContain("L2-L3"); - expect(result).toContain("new1"); - expect(result).toContain("new2"); - - const content = readFileSync(testFile, "utf-8"); - expect(content).toBe("header\nnew1\nnew2\nfooter"); - }); + const readOutput = await executeReadFile({ path: testFile }); + const lineRef = extractLineRef(readOutput, 2); - it("shows correct line range for multiline edits", async () => { - const testFile = join(tempDir, "range.txt"); - writeFileSync(testFile, "1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); + writeFileSync(testFile, "one\nTWO-CHANGED\nthree\n"); - const result = await executeEditFile({ + await expect( + executeEditFile({ path: testFile, - old_str: "4\n5\n6", - new_str: "A\nB\nC\nD", - replace_all: false, - }); - - expect(result).toContain("L4-L7"); - - const content = readFileSync(testFile, "utf-8"); - expect(content).toBe("1\n2\n3\nA\nB\nC\nD\n7\n8\n9\n10"); - }); + edits: [ + { + op: "replace", + pos: lineRef, + lines: ["two-updated"], + }, + ], + }) + ).rejects.toThrow("changed since last read"); }); - describe("replace_all mode", () => { - it("replaces all occurrences and shows each edit", async () => { - const testFile = join(tempDir, "replaceall.txt"); - writeFileSync(testFile, "foo\nbar\nfoo\nbaz\nfoo"); - - const result = await executeEditFile({ - path: testFile, - old_str: "foo", - new_str: "XXX", - replace_all: true, - }); - - expect(result).toContain("OK - replaced 3 occurrence(s)"); - - const editBlocks = result.split("======== end ========").length - 1; - expect(editBlocks).toBe(3); + it("rejects stale expected_file_hash", async () => { + const testFile = join(tempDir, "hashline-filehash.txt"); + writeFileSync(testFile, "x\ny\n"); - const content = readFileSync(testFile, "utf-8"); - expect(content).toBe("XXX\nbar\nXXX\nbaz\nXXX"); - }); + const readOutput = await executeReadFile({ path: testFile }); + const lineRef = extractLineRef(readOutput, 2); + const fileHash = extractFileHash(readOutput); - it("shows correct line numbers for each replacement", async () => { - const testFile = join(tempDir, "positions.txt"); - writeFileSync(testFile, "target\nother\ntarget\nother\ntarget"); + writeFileSync(testFile, "x\nY-NEW\n"); - const result = await executeEditFile({ + await expect( + executeEditFile({ path: testFile, - old_str: "target", - new_str: "REPLACED", - replace_all: true, - }); - - expect(result).toContain("L1-L1"); - expect(result).toContain("L3-L3"); - expect(result).toContain("L5-L5"); - }); + expected_file_hash: fileHash, + edits: [ + { + op: "replace", + pos: lineRef, + lines: ["y-updated"], + }, + ], + }) + ).rejects.toThrow("File changed since read_file output"); }); - describe("file creation", () => { - it("creates new file when old_str is empty", async () => { - const newFile = join(tempDir, "newfile.txt"); - - const result = await executeEditFile({ - path: newFile, - old_str: "", - new_str: "brand new content", - replace_all: false, - }); - - expect(result).toContain("Successfully created file"); - expect(existsSync(newFile)).toBe(true); - - const content = readFileSync(newFile, "utf-8"); - expect(content).toBe("brand new content"); - }); - - it("creates parent directories when needed", async () => { - const nestedFile = join(tempDir, "deep", "nested", "file.txt"); - - const result = await executeEditFile({ - path: nestedFile, - old_str: "", - new_str: "nested content", - replace_all: false, - }); - - expect(result).toContain("Successfully created file"); - expect(existsSync(nestedFile)).toBe(true); - }); + it("supports prepend and append in one call", async () => { + const testFile = join(tempDir, "hashline-insert.txt"); + writeFileSync(testFile, "a\nb\nc\n"); + + const readOutput = await executeReadFile({ path: testFile }); + const middleRef = extractLineRef(readOutput, 2); + + await executeEditFile({ + path: testFile, + edits: [ + { + op: "prepend", + pos: middleRef, + lines: ["before-b"], + }, + { + op: "append", + pos: middleRef, + lines: ["after-b"], + }, + ], + }); + + expect(readFileSync(testFile, "utf-8")).toBe( + "a\nbefore-b\nb\nafter-b\nc\n" + ); }); - describe("error handling", () => { - it("throws error when old_str not found", async () => { - const testFile = join(tempDir, "notfound.txt"); - writeFileSync(testFile, "some content"); + it("creates missing file with anchorless append", async () => { + const testFile = join(tempDir, "hashline-create.txt"); - await expect( - executeEditFile({ - path: testFile, - old_str: "nonexistent", - new_str: "replacement", - replace_all: false, - }) - ).rejects.toThrow("old_str not found in file"); + const result = await executeEditFile({ + path: testFile, + edits: [ + { + op: "append", + lines: ["created-via-hashline"], + }, + ], }); - it("throws error when multiple matches without replace_all", async () => { - const testFile = join(tempDir, "multiple.txt"); - writeFileSync(testFile, "dup\ndup\ndup"); - - await expect( - executeEditFile({ - path: testFile, - old_str: "dup", - new_str: "single", - replace_all: false, - }) - ).rejects.toThrow("found 3 times"); - }); - - it("throws error when old_str equals new_str", async () => { - const testFile = join(tempDir, "same.txt"); - writeFileSync(testFile, "content"); - - await expect( - executeEditFile({ - path: testFile, - old_str: "content", - new_str: "content", - replace_all: false, - }) - ).rejects.toThrow("old_str and new_str are identical"); - }); - - it("throws error for non-existent file (unless creating)", async () => { - await expect( - executeEditFile({ - path: join(tempDir, "nonexistent.txt"), - old_str: "something", - new_str: "else", - replace_all: false, - }) - ).rejects.toThrow(); - }); + expect(result).toContain("OK - hashline edit"); + expect(readFileSync(testFile, "utf-8")).toBe("created-via-hashline"); }); - describe("edge cases", () => { - it("handles edit at start of file", async () => { - const testFile = join(tempDir, "start.txt"); - writeFileSync(testFile, "first\nsecond\nthird"); - - const result = await executeEditFile({ - path: testFile, - old_str: "first", - new_str: "FIRST", - replace_all: false, - }); - - expect(result).toContain("L1-L1"); - expect(result).toContain("FIRST"); - }); - - it("handles edit at end of file", async () => { - const testFile = join(tempDir, "end.txt"); - writeFileSync(testFile, "first\nsecond\nthird"); - - const result = await executeEditFile({ - path: testFile, - old_str: "third", - new_str: "THIRD", - replace_all: false, - }); - - expect(result).toContain("L3-L3"); - expect(result).toContain("THIRD"); - }); - - it("handles single line file", async () => { - const testFile = join(tempDir, "single.txt"); - writeFileSync(testFile, "only line"); - - const result = await executeEditFile({ - path: testFile, - old_str: "only", - new_str: "ONLY", - replace_all: false, - }); - - expect(result).toContain("L1-L1"); - expect(result).toContain("ONLY line"); - }); - - it("handles empty new_str (deletion)", async () => { - const testFile = join(tempDir, "delete.txt"); - writeFileSync(testFile, "keep\nremove\nkeep"); + it("rejects empty edits", async () => { + const testFile = join(tempDir, "hashline-empty-edits.txt"); + writeFileSync(testFile, "a\nb\n"); - await executeEditFile({ + await expect( + executeEditFile({ path: testFile, - old_str: "remove\n", - new_str: "", - replace_all: false, - }); - - const content = readFileSync(testFile, "utf-8"); - expect(content).toBe("keep\nkeep"); - }); - - it("handles special characters in replacement", async () => { - const testFile = join(tempDir, "special.txt"); - writeFileSync(testFile, "placeholder"); - - await executeEditFile({ - path: testFile, - old_str: "placeholder", - new_str: 'const x = { a: 1, b: "test" };', - replace_all: false, - }); - - const content = readFileSync(testFile, "utf-8"); - expect(content).toBe('const x = { a: 1, b: "test" };'); - }); + edits: [], + }) + ).rejects.toThrow(); }); - describe("enhanced error messages with Unicode", () => { - it("provides helpful suggestions when Unicode characters cause mismatch", async () => { - const testFile = join(tempDir, "unicode-corrupt.txt"); - writeFileSync(testFile, "model = load(model\u0D4D\n"); - - try { - await executeEditFile({ - path: testFile, - old_str: "modelname", - new_str: "model_name", - replace_all: false, - }); - } catch (error) { - const errorMsg = (error as Error).message; - expect(errorMsg).toContain("SEARCH TARGET"); - expect(errorMsg).toContain("escaped"); - } - }); - - it("suggests similar strings when exact match fails", async () => { - const testFile = join(tempDir, "unicode-suggest.txt"); - writeFileSync( - testFile, - "if probs[1] >= probs[\u6E38\u620F] else negative\n" - ); + it("rejects no-op edits", async () => { + const testFile = join(tempDir, "hashline-noop.txt"); + writeFileSync(testFile, "a\nb\n"); - try { - await executeEditFile({ - path: testFile, - old_str: "probs[1] >= probs[0]", - new_str: "probs[1] > probs[0]", - replace_all: false, - }); - } catch (error) { - const errorMsg = (error as Error).message; - expect(errorMsg).toContain("SIMILAR STRINGS FOUND"); - expect(errorMsg).toContain("Escaped:"); - expect(errorMsg).toContain("SUGGESTION"); - } - }); - - it("detects non-ASCII characters in file diagnostics", async () => { - const testFile = join(tempDir, "unicode-diagnostic.txt"); - writeFileSync(testFile, "hello\u{1F600}world\n"); - - try { - await executeEditFile({ - path: testFile, - old_str: "nonexistent", - new_str: "replacement", - replace_all: false, - }); - } catch (error) { - const errorMsg = (error as Error).message; - expect(errorMsg).toContain("FILE DIAGNOSTICS"); - expect(errorMsg).toContain("Non-ASCII characters"); - } - }); - - it("provides recovery strategies in error message", async () => { - const testFile = join(tempDir, "unicode-recovery.txt"); - writeFileSync(testFile, "some content"); + const readOutput = await executeReadFile({ path: testFile }); + const lineRef = extractLineRef(readOutput, 2); - try { - await executeEditFile({ - path: testFile, - old_str: "missing", - new_str: "replacement", - replace_all: false, - }); - } catch (error) { - const errorMsg = (error as Error).message; - expect(errorMsg).toContain("RECOVERY STRATEGIES"); - expect(errorMsg).toContain("Re-run read_file"); - expect(errorMsg).toContain("write_file"); - } - }); - - it("escapes Unicode in error messages for copy-paste", async () => { - const testFile = join(tempDir, "unicode-escape.txt"); - const content = "test\u0D4Dstring\nother line"; - writeFileSync(testFile, content); - - try { - await executeEditFile({ - path: testFile, - old_str: "teststring", - new_str: "right", - replace_all: false, - }); - } catch (error) { - const errorMsg = (error as Error).message; - expect(errorMsg).toContain("\\u0D4D"); - } - }); + await expect( + executeEditFile({ + path: testFile, + edits: [ + { + op: "replace", + pos: lineRef, + lines: ["b"], + }, + ], + }) + ).rejects.toThrow("No changes made"); }); }); diff --git a/src/tools/modify/edit-file.ts b/src/tools/modify/edit-file.ts index 1028e7e..2acadcf 100644 --- a/src/tools/modify/edit-file.ts +++ b/src/tools/modify/edit-file.ts @@ -2,6 +2,14 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { basename, dirname } from "node:path"; import { tool } from "ai"; import { z } from "zod"; +import { + applyHashlineEdits, + computeFileHash, + type HashlineEdit, + parseHashlineText, + parseLineTag, +} from "../utils/hashline/hashline"; +import EDIT_FILE_DESCRIPTION from "./edit-file.txt"; interface EditResult { context: string; @@ -9,283 +17,141 @@ interface EditResult { startLine: number; } -interface SimilarStringCandidate { - context: string; - lineNumber: number; - similarity: number; - text: string; -} - -interface FileIssues { - hasCRLF: boolean; - hasNonAscii: boolean; - hasReplacementChar: boolean; - nonAsciiCount: number; -} - -function escapeUnicode(str: string): string { - return str - .split("") - .map((char) => { - const code = char.charCodeAt(0); - if (code > 127) { - return `\\u${code.toString(16).toUpperCase().padStart(4, "0")}`; - } - return char; - }) - .join(""); -} - -function levenshteinDistance(str1: string, str2: string): number { - const len1 = str1.length; - const len2 = str2.length; - - if (Math.abs(len1 - len2) > Math.max(len1, len2) * 0.7) { - return Math.max(len1, len2); - } - - const matrix: number[][] = new Array(len1 + 1) - .fill(null) - .map(() => new Array(len2 + 1).fill(0)); - - for (let i = 0; i <= len1; i++) { - matrix[i][0] = i; - } - for (let j = 0; j <= len2; j++) { - matrix[0][j] = j; - } - - for (let i = 1; i <= len1; i++) { - for (let j = 1; j <= len2; j++) { - const cost = str1[i - 1] === str2[j - 1] ? 0 : 1; - matrix[i][j] = Math.min( - matrix[i - 1][j] + 1, - matrix[i][j - 1] + 1, - matrix[i - 1][j - 1] + cost - ); - } - } +type HashlineToolOp = "append" | "prepend" | "replace"; +type LineEnding = "\n" | "\r\n"; - return matrix[len1][len2]; +interface HashlineToolEdit { + end?: string; + lines?: string[] | string | null; + op: HashlineToolOp; + pos?: string; } -function calculateSimilarity(str1: string, str2: string): number { - if (str1 === str2) { - return 100; +const hashlineToolEditSchema = z + .object({ + op: z + .enum(["replace", "append", "prepend"]) + .describe("replace/append/prepend use hashline anchors."), + pos: z + .string() + .optional() + .describe("Line anchor from read_file (format: LINE#HASH)."), + end: z + .string() + .optional() + .describe("Range end anchor for replace operations (format: LINE#HASH)."), + lines: z + .union([z.array(z.string()), z.string(), z.null()]) + .optional() + .describe("Replacement/inserted lines. null or [] deletes for replace."), + }) + .strict(); + +const inputSchema = z + .object({ + path: z.string().describe("The path to the file"), + edits: z + .array(hashlineToolEditSchema) + .min(1) + .describe("Hashline-native edit operations."), + expected_file_hash: z + .string() + .optional() + .describe("Optional stale-check file hash from read_file output."), + }) + .strict(); + +export type EditFileInput = z.input; + +function parseReplaceEdit(edit: HashlineToolEdit): HashlineEdit { + const primaryRef = edit.pos ?? edit.end; + if (!primaryRef) { + throw new Error("replace requires pos or end anchor."); } - if (str1.length === 0 || str2.length === 0) { - return 0; - } - - const distance = levenshteinDistance(str1, str2); - const maxLen = Math.max(str1.length, str2.length); - const similarity = ((maxLen - distance) / maxLen) * 100; - return Math.max(0, Math.min(100, similarity)); -} - -function extractLineContext( - lines: string[], - lineIdx: number, - contextLines: number -): string { - const start = Math.max(0, lineIdx - contextLines); - const end = Math.min(lines.length - 1, lineIdx + contextLines); - - return lines - .slice(start, end + 1) - .map((line, idx) => { - const actualLineNum = start + idx + 1; - const marker = actualLineNum === lineIdx + 1 ? ">" : " "; - return `${marker} ${actualLineNum.toString().padStart(4)} | ${line}`; - }) - .join("\n"); + return { + op: "replace", + pos: parseLineTag(primaryRef), + end: edit.end ? parseLineTag(edit.end) : undefined, + lines: parseHashlineText(edit.lines), + }; } -function findSimilarStrings( - content: string, - searchStr: string, - options: { - threshold?: number; - maxResults?: number; - contextLines?: number; - } = {} -): SimilarStringCandidate[] { - const { threshold = 50, maxResults = 3, contextLines = 2 } = options; - - const lines = content.split("\n"); - const candidates: SimilarStringCandidate[] = []; - - for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { - const line = lines[lineIdx]; - - const lineSimilarity = calculateSimilarity(line, searchStr); - if (lineSimilarity >= threshold) { - candidates.push({ - text: line, - lineNumber: lineIdx + 1, - similarity: Math.round(lineSimilarity), - context: extractLineContext(lines, lineIdx, contextLines), - }); - } - - const minLen = Math.max(1, searchStr.length - 5); - const maxLen = searchStr.length + 5; - - for (let start = 0; start < line.length; start++) { - for ( - let len = minLen; - len <= maxLen && start + len <= line.length; - len++ - ) { - const substring = line.slice(start, start + len); - const similarity = calculateSimilarity(substring, searchStr); - - if (similarity >= threshold) { - candidates.push({ - text: substring, - lineNumber: lineIdx + 1, - similarity: Math.round(similarity), - context: extractLineContext(lines, lineIdx, contextLines), - }); - } - } - } - } - - return candidates - .sort((a, b) => b.similarity - a.similarity) - .slice(0, maxResults) - .filter( - (candidate, index, self) => - index === self.findIndex((c) => c.text === candidate.text) - ); +function parseAppendPrependEdit( + op: "append" | "prepend", + edit: HashlineToolEdit +): HashlineEdit { + const ref = op === "append" ? (edit.pos ?? edit.end) : (edit.end ?? edit.pos); + return { + op, + pos: ref ? parseLineTag(ref) : undefined, + lines: parseHashlineText(edit.lines), + }; } -function hasNonAsciiChars(text: string): boolean { - for (let i = 0; i < text.length; i++) { - const code = text.charCodeAt(i); - if (code > 127) { - return true; +function parseHashlineToolEdits(edits: HashlineToolEdit[]): HashlineEdit[] { + return edits.map((edit) => { + switch (edit.op) { + case "replace": + return parseReplaceEdit(edit); + case "append": + return parseAppendPrependEdit("append", edit); + case "prepend": + return parseAppendPrependEdit("prepend", edit); + default: + throw new Error("Unsupported edit operation."); } - } - return false; + }); } -function countNonAsciiChars(text: string): number { - let count = 0; - for (let i = 0; i < text.length; i++) { - const code = text.charCodeAt(i); - if (code > 127) { - count++; - } - } - return count; +function canCreateMissingFileWithHashlineEdits(edits: HashlineEdit[]): boolean { + return edits.every((edit) => edit.op !== "replace" && edit.pos === undefined); } -function detectFileIssues(content: string): FileIssues { - const hasNonAscii = hasNonAsciiChars(content); - const hasCRLF = content.includes("\r\n"); - const hasReplacementChar = content.includes("\uFFFD"); - const nonAsciiCount = countNonAsciiChars(content); - - return { - hasNonAscii, - hasCRLF, - hasReplacementChar, - nonAsciiCount, - }; +function resolvePreferredLineEnding(content: string): LineEnding { + return content.includes("\r\n") ? "\r\n" : "\n"; } -function buildEnhancedErrorMessage(oldStr: string, content: string): string { - const issues = detectFileIssues(content); - const candidates = findSimilarStrings(content, oldStr, { - threshold: 40, - maxResults: 3, - contextLines: 2, - }); - - let errorMsg = "old_str not found in file\n\n"; - errorMsg += "SEARCH TARGET (escaped):\n"; - errorMsg += ` old_str = "${escapeUnicode(oldStr)}"\n`; - errorMsg += ` Length: ${oldStr.length} characters\n`; - - if (oldStr.length <= 20) { - const bytes = Array.from(oldStr) - .map((c) => c.charCodeAt(0).toString(16).padStart(2, "0")) - .join(" "); - errorMsg += ` Bytes (hex): ${bytes}\n`; +function applyPreferredLineEnding( + content: string, + lineEnding: LineEnding +): string { + if (lineEnding === "\n") { + return content.replace(/\r\n/g, "\n"); } + return content.replace(/\r\n/g, "\n").replace(/\n/g, "\r\n"); +} - errorMsg += "\n❌ This exact string was not found in the file.\n"; - - if (candidates.length > 0) { - errorMsg += - "\n🔍 SIMILAR STRINGS FOUND (you might be looking for one of these):\n"; - - candidates.forEach((candidate, idx) => { - errorMsg += `\n${idx + 1}. Line ${candidate.lineNumber} (${candidate.similarity}% similar):\n`; - errorMsg += ` Visual: "${candidate.text}"\n`; - errorMsg += ` Escaped: "${escapeUnicode(candidate.text)}"\n`; - errorMsg += "\n Context:\n"; - errorMsg += candidate.context - .split("\n") - .map((line) => ` ${line}`) - .join("\n"); - errorMsg += "\n"; - }); - - errorMsg += "\n💡 SUGGESTION:\n"; - errorMsg += " Use one of the escaped strings above as your old_str.\n"; - errorMsg += " Example:\n"; - errorMsg += ` old_str = "${escapeUnicode(candidates[0].text)}"\n`; +function assertExpectedFileHash( + expectedHash: string | undefined, + currentContent: string +): void { + if (!expectedHash) { + return; } - if (issues.hasNonAscii || issues.hasCRLF || issues.hasReplacementChar) { - errorMsg += "\n⚠️ FILE DIAGNOSTICS:\n"; - if (issues.hasNonAscii) { - errorMsg += ` • Non-ASCII characters: ${issues.nonAsciiCount} found\n`; - } - if (issues.hasReplacementChar) { - errorMsg += - " • Replacement characters (�): YES (possible encoding corruption)\n"; - } - if (issues.hasCRLF) { - errorMsg += " • Line endings: CRLF (Windows-style)\n"; - } else { - errorMsg += " • Line endings: LF (Unix-style)\n"; - } + const normalizedExpected = expectedHash.toLowerCase(); + const currentHash = computeFileHash(currentContent).toLowerCase(); + if (normalizedExpected !== currentHash) { + throw new Error( + `File changed since read_file output. expected=${normalizedExpected}, current=${currentHash}` + ); } - - errorMsg += "\n📋 RECOVERY STRATEGIES:\n"; - errorMsg += " 1. Re-run read_file to see the exact file content\n"; - errorMsg += ` 2. Copy the EXACT text from read_file output (don't guess)\n`; - errorMsg += - " 3. If edit_file keeps failing, use write_file to rewrite the entire file\n"; - - return errorMsg; } -function extractEditContext( - content: string, - editStartIndex: number, - newStr: string, +function createContextByLineRange( + lines: string[], + startLine: number, + endLine: number, contextLines = 2 ): EditResult { - const lines = content.split("\n"); - const beforeEdit = content.slice(0, editStartIndex); - const startLine = beforeEdit.split("\n").length; - - const newStrLines = newStr.split("\n"); - const endLine = startLine + newStrLines.length - 1; - const contextStart = Math.max(1, startLine - contextLines); const contextEnd = Math.min(lines.length, endLine + contextLines); - const contextSnippet = lines + const context = lines .slice(contextStart - 1, contextEnd) - .map((line, i) => { - const lineNum = contextStart + i; + .map((line, index) => { + const lineNum = contextStart + index; const isEdited = lineNum >= startLine && lineNum <= endLine; const prefix = isEdited ? ">" : " "; return `${prefix} ${String(lineNum).padStart(4)} | ${line}`; @@ -295,7 +161,7 @@ function extractEditContext( return { startLine, endLine, - context: contextSnippet, + context, }; } @@ -314,163 +180,125 @@ function formatEditResult(filePath: string, results: EditResult[]): string { return output.join("\n"); } -const inputSchema = z.object({ - path: z.string().describe("The path to the file"), - old_str: z - .string() - .describe( - "Text to search for - must match exactly. " + - "By default, must have exactly one match unless replace_all is true." - ), - new_str: z.string().describe("Text to replace old_str with"), - replace_all: z - .boolean() - .optional() - .default(false) - .describe( - "If true, replace all occurrences of old_str. " + - "If false (default), old_str must match exactly once." - ), -}); - -export type EditFileInput = z.infer; - -async function handleFileCreation( - filePath: string, - content: string -): Promise { - const dir = dirname(filePath); - if (dir !== ".") { - await mkdir(dir, { recursive: true }); +function collectHashlineEditResults( + content: string, + firstChangedLine: number | undefined +): EditResult[] { + if (firstChangedLine === undefined) { + return []; } - await writeFile(filePath, content, "utf-8"); - return `Successfully created file ${filePath}`; -} -interface ReplaceResult { - editResults: EditResult[]; - newContent: string; - replacementCount: number; + const lines = content.split("\n"); + const safeStart = Math.max(1, Math.min(firstChangedLine, lines.length || 1)); + return [createContextByLineRange(lines, safeStart, safeStart, 4)]; } -function performReplaceAll( - content: string, - oldStr: string, - newStr: string -): ReplaceResult { - const matchPositions: number[] = []; - let pos = content.indexOf(oldStr); - while (pos !== -1) { - matchPositions.push(pos); - pos = content.indexOf(oldStr, pos + 1); +function formatHashlineModeResult(params: { + path: string; + newContent: string; + resultBlocks: EditResult[]; + warningLines?: string[]; +}): string { + const output: string[] = []; + output.push("OK - hashline edit"); + output.push(`path: ${params.path}`); + output.push(`file_hash: ${computeFileHash(params.newContent)}`); + if (params.warningLines && params.warningLines.length > 0) { + output.push(`warnings: ${params.warningLines.length}`); } - let newContent = content; - let offset = 0; - const editResults: EditResult[] = []; - - for (const originalPos of matchPositions) { - const adjustedPos = originalPos + offset; - newContent = - newContent.slice(0, adjustedPos) + - newStr + - newContent.slice(adjustedPos + oldStr.length); - - editResults.push(extractEditContext(newContent, adjustedPos, newStr)); - offset += newStr.length - oldStr.length; + if (params.resultBlocks.length > 0) { + output.push(""); + output.push(formatEditResult(params.path, params.resultBlocks)); } - return { - newContent, - replacementCount: matchPositions.length, - editResults, - }; -} - -function performSingleReplace( - content: string, - oldStr: string, - newStr: string -): ReplaceResult { - const matchCount = content.split(oldStr).length - 1; - if (matchCount > 1) { - throw new Error( - `old_str found ${matchCount} times in file. ` + - "Use replace_all: true to replace all occurrences, " + - "or provide more context to match exactly once." - ); + if (params.warningLines && params.warningLines.length > 0) { + output.push(""); + output.push("Warnings:"); + output.push(...params.warningLines.map((line) => `- ${line}`)); } - const editStartIndex = content.indexOf(oldStr); - const newContent = content.replace(oldStr, newStr); - const editResults = [extractEditContext(newContent, editStartIndex, newStr)]; - - return { - newContent, - replacementCount: 1, - editResults, - }; + return output.join("\n"); } -export async function executeEditFile({ - path, - old_str, - new_str, - replace_all = false, -}: EditFileInput): Promise { - if (!path) { - throw new Error("Missing required parameter: path"); - } - if (old_str === new_str) { - throw new Error( - "old_str and new_str are identical - no changes to make. " + - "If you intended to make a change, verify the content differs." - ); +async function ensureParentDir(path: string): Promise { + const directory = dirname(path); + if (directory !== ".") { + await mkdir(directory, { recursive: true }); } +} - let content: string; - +async function readExistingContent(path: string): Promise<{ + content: string; + exists: boolean; +}> { try { - content = await readFile(path, "utf-8"); + return { + content: await readFile(path, "utf-8"), + exists: true, + }; } catch (error) { if ( error instanceof Error && "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" && - old_str === "" + (error as NodeJS.ErrnoException).code === "ENOENT" ) { - return await handleFileCreation(path, new_str); + return { + content: "", + exists: false, + }; } throw error; } +} - if (old_str !== "" && !content.includes(old_str)) { - throw new Error(buildEnhancedErrorMessage(old_str, content)); - } +export async function executeEditFile(input: EditFileInput): Promise { + const parsed = inputSchema.parse(input); + const hashlineEdits = parseHashlineToolEdits(parsed.edits); - const { newContent, replacementCount, editResults } = replace_all - ? performReplaceAll(content, old_str, new_str) - : performSingleReplace(content, old_str, new_str); + const { content: loadedContent, exists } = await readExistingContent( + parsed.path + ); + let content = loadedContent; - if (content === newContent && old_str !== "") { - throw new Error(buildEnhancedErrorMessage(old_str, content)); + if (!exists) { + if (!canCreateMissingFileWithHashlineEdits(hashlineEdits)) { + throw new Error(`File not found: ${parsed.path}`); + } + content = ""; } - await writeFile(path, newContent, "utf-8"); + assertExpectedFileHash(parsed.expected_file_hash, content); + + const preferredLineEnding = resolvePreferredLineEnding(content); + const hashlineResult = applyHashlineEdits(content, hashlineEdits); + const normalizedContent = applyPreferredLineEnding( + hashlineResult.lines, + preferredLineEnding + ); - const summary = replace_all - ? `OK - replaced ${replacementCount} occurrence(s)` - : "OK"; + if (normalizedContent === content) { + throw new Error( + "No changes made. The provided hashline edits resolved to identical content." + ); + } - return `${summary}\n\n${formatEditResult(path, editResults)}`; + await ensureParentDir(parsed.path); + await writeFile(parsed.path, normalizedContent, "utf-8"); + + return formatHashlineModeResult({ + path: parsed.path, + newContent: normalizedContent, + resultBlocks: collectHashlineEditResults( + normalizedContent, + hashlineResult.firstChangedLine + ), + warningLines: hashlineResult.warnings, + }); } export const editFileTool = tool({ - description: - "Replace text in file (surgical edits). " + - "old_str must match exactly (including whitespace/indentation) - always copy-paste lines from read_file output. " + - "replace_all: false (default) replaces FIRST match only; use replace_all: true for renaming across file. " + - "For new files, prefer write_file instead.", + description: EDIT_FILE_DESCRIPTION, inputSchema, execute: executeEditFile, }); diff --git a/src/tools/modify/edit-file.txt b/src/tools/modify/edit-file.txt new file mode 100644 index 0000000..77b53b8 --- /dev/null +++ b/src/tools/modify/edit-file.txt @@ -0,0 +1,36 @@ +### edit_file +**Purpose**: Hashline-native surgical edits with deterministic anchors + +**When to use**: +- Modifying existing files (changing functions, fixing bugs) +- Applying anchored edits using `LINE#HASH` refs from `read_file` +- Inserting/replacing/deleting exact ranges without ambiguous matching + +**Key features**: +- Hashline ops via `edits[]`: `replace`, `append`, `prepend` +- `expected_file_hash` stale check prevents edits on changed files + +**Input contract**: +- `path` (required): target file path +- `edits` (required, min 1): list of hashline operations +- `expected_file_hash` (optional): file hash from latest `read_file` + +**Operation rules**: +- `replace`: requires `pos` or `end` anchor (`LINE#HASH`) +- `append` / `prepend`: optional anchor; without anchor, insert at file boundary +- `lines: []` or `lines: null` on `replace` can be used for line deletion + +**Failure handling**: +- Stale anchor/hash errors mean file changed; run `read_file` again and retry with fresh refs +- No-op edits are rejected to prevent silent failures + +**DO NOT use shell commands**: Use this instead of `sed`, `awk`, or `perl -i` + +**Example**: +``` +edit_file({ + path: "config.ts", + edits: [{ op: "replace", pos: "12#AB", lines: ["port: 8080"] }], + expected_file_hash: "0a1b2c3d" +}) +``` diff --git a/src/tools/modify/system-context.txt b/src/tools/modify/system-context.txt deleted file mode 100644 index 4d77084..0000000 --- a/src/tools/modify/system-context.txt +++ /dev/null @@ -1,47 +0,0 @@ -## Modify Tools - -Use these tools to make changes to files. ALWAYS read files before modifying them. - -### edit_file -**Purpose**: Replace specific text in a file (surgical edits) - -**When to use**: -- Modifying existing files (changing functions, fixing bugs) -- Replacing specific patterns across a file -- Making targeted changes without rewriting entire file - -**Key features**: -- `replace_all: true` replaces all occurrences (useful for renaming) -- `replace_all: false` (default) replaces first occurrence only - -**DO NOT use shell commands**: Use this instead of `sed`, `awk`, or `perl -i` - -**Example**: -``` -edit_file("config.ts", "port: 3000", "port: 8080") -edit_file("app.ts", "oldName", "newName", replace_all=true) -``` - -### write_file -**Purpose**: Create new file or completely overwrite existing file - -**When to use**: -- Creating new files from scratch -- Completely replacing file contents (when most of the file changes) -- Writing generated code or configuration - -**DO NOT use shell commands**: Use this instead of `echo >`, `cat <` - -**Example**: `write_file("output.json", '{"status": "ok"}')` - -### delete_file -**Purpose**: Permanently delete a file - -**When to use**: -- Removing obsolete files -- Cleaning up temporary files -- Deleting files as part of refactoring - -**DO NOT use shell commands**: Use this instead of `rm` - -**Example**: `delete_file("old-config.js")` diff --git a/src/tools/modify/write-file.ts b/src/tools/modify/write-file.ts index 46442de..9bcd1a4 100644 --- a/src/tools/modify/write-file.ts +++ b/src/tools/modify/write-file.ts @@ -2,6 +2,7 @@ import { mkdir, stat, writeFile } from "node:fs/promises"; import { basename, dirname } from "node:path"; import { tool } from "ai"; import { z } from "zod"; +import WRITE_FILE_DESCRIPTION from "./write-file.txt"; const PREVIEW_LINES = 3; @@ -76,10 +77,7 @@ export async function executeWriteFile({ } export const writeFileTool = tool({ - description: - "Create new file or completely overwrite existing file. " + - "Creates parent directories automatically. " + - "Use edit_file for surgical changes to existing files.", + description: WRITE_FILE_DESCRIPTION, inputSchema, execute: executeWriteFile, }); diff --git a/src/tools/modify/write-file.txt b/src/tools/modify/write-file.txt new file mode 100644 index 0000000..e918e4e --- /dev/null +++ b/src/tools/modify/write-file.txt @@ -0,0 +1,9 @@ +### write_file +**Purpose**: Create new file or completely overwrite existing file + +**When to use**: +- Creating new files from scratch +- Replacing most or all contents of a file +- Writing generated code/configuration + +**DO NOT use shell**: Prefer this over `echo >`, `cat <` diff --git a/src/tools/planning/load-skill.ts b/src/tools/planning/load-skill.ts index 091d129..155258a 100644 --- a/src/tools/planning/load-skill.ts +++ b/src/tools/planning/load-skill.ts @@ -3,6 +3,7 @@ import { isAbsolute, join, normalize, relative } from "node:path"; import { tool } from "ai"; import { z } from "zod"; import { loadSkillById, type SkillInfo } from "../../context/skills"; +import LOAD_SKILL_DESCRIPTION from "./load-skill.txt"; const inputSchema = z.object({ skillName: z @@ -129,8 +130,7 @@ export async function executeLoadSkill({ } export const loadSkillTool = tool({ - description: - "Load detailed skill documentation or files from a skill directory. Skills provide comprehensive workflows and references. For v2 skills, you can optionally load subdirectory files (scripts/, references/, etc.) by providing a relativePath.", + description: LOAD_SKILL_DESCRIPTION, inputSchema, execute: executeLoadSkill, }); diff --git a/src/tools/planning/load-skill.txt b/src/tools/planning/load-skill.txt new file mode 100644 index 0000000..3a8b09f --- /dev/null +++ b/src/tools/planning/load-skill.txt @@ -0,0 +1,6 @@ +### load_skill +**Purpose**: Load detailed skill documentation and optional skill subfiles + +**When to use**: +- Fetching workflow instructions from a named skill +- Loading referenced files from v2 skill directories diff --git a/src/tools/planning/system-context.txt b/src/tools/planning/system-context.txt deleted file mode 100644 index 6deece6..0000000 --- a/src/tools/planning/system-context.txt +++ /dev/null @@ -1,56 +0,0 @@ -# Planning Tools - -## todo_write - -Create and manage structured task lists for complex coding sessions. - -**When to use:** -- Tasks with 3+ distinct steps -- User provides multiple requirements -- Need to demonstrate systematic approach -- Complex refactoring or feature implementation - -**Parameters:** -- todos: Array of todo items with: - - id: Unique identifier (string) - - content: Task description (string) - - status: "pending" | "in_progress" | "completed" | "cancelled" - - priority: "high" | "medium" | "low" - - description: Optional detailed description - -**Example:** -``` -todo_write({ - todos: [ - { id: "1", content: "Setup project structure", status: "pending", priority: "high" }, - { id: "2", content: "Implement core feature", status: "pending", priority: "high" }, - { id: "3", content: "Write tests", status: "pending", priority: "medium" } - ] -}) -``` - -**Task Status Workflow:** -1. Create todos with status="pending" -2. Mark as "in_progress" before starting work -3. **EXECUTE THE TASK IMMEDIATELY** - use shell_execute, write_file, etc. -4. Mark as "completed" when done -5. **AUTOMATICALLY continue to next task** - do NOT wait for user - -**CRITICAL EXECUTION RULES:** -- DO NOT just tell the user what commands to run -- ACTUALLY EXECUTE the commands using shell_execute tool -- DO NOT stop after each task - continue automatically to the next -- If a task requires shell commands, USE shell_execute immediately -- The user will approve tools when needed, but YOU must call them - -**Example Flow:** -``` -1. todo_write (mark task 1 as in_progress) -2. shell_execute (run the actual command) -3. todo_write (mark task 1 as completed, task 2 as in_progress) -4. shell_execute (run next command) -... continue until all tasks completed -``` - -**Auto-continuation:** -If conversation ends with incomplete todos, they will automatically continue in the next message. You don't need to manually check the todo list - the system will automatically remind you of incomplete tasks. diff --git a/src/tools/planning/todo-write.ts b/src/tools/planning/todo-write.ts index 2d7f7f1..2c9a754 100644 --- a/src/tools/planning/todo-write.ts +++ b/src/tools/planning/todo-write.ts @@ -4,6 +4,7 @@ import { tool } from "ai"; import { z } from "zod"; import { TODO_DIR } from "../../context/paths"; import { getSessionId } from "../../context/session"; +import TODO_WRITE_DESCRIPTION from "./todo-write.txt"; const todoItemSchema = z.object({ id: z.string().describe("Unique identifier for the todo item"), @@ -134,11 +135,7 @@ export async function executeTodoWrite({ } export const todoWriteTool = tool({ - description: - "Create and manage a structured task list for your current coding session. " + - "This helps track progress, organize complex tasks, and demonstrate thoroughness to the user. " + - "Use this tool when a task has 3+ steps or when the user provides multiple requirements. " + - "IMPORTANT: If todos are incomplete when the conversation ends, they will automatically continue in the next message.", + description: TODO_WRITE_DESCRIPTION, inputSchema, execute: executeTodoWrite, }); diff --git a/src/tools/planning/todo-write.txt b/src/tools/planning/todo-write.txt new file mode 100644 index 0000000..af54774 --- /dev/null +++ b/src/tools/planning/todo-write.txt @@ -0,0 +1,7 @@ +### todo_write +**Purpose**: Create and update structured task lists + +**When to use**: +- Tasks with 3+ distinct steps +- Multi-requirement work needing progress tracking +- Complex refactor/feature sessions diff --git a/src/tools/utils/hashline/hashline.test.ts b/src/tools/utils/hashline/hashline.test.ts new file mode 100644 index 0000000..fb56347 --- /dev/null +++ b/src/tools/utils/hashline/hashline.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from "bun:test"; +import { + applyHashlineEdits, + computeFileHash, + computeLineHash, + HashlineMismatchError, + parseHashlineText, + parseLineTag, +} from "./hashline"; + +const HASH_PATTERN = /^[ZPMQVRWSNKTXJBYH]{2}$/; + +describe("hashline", () => { + it("computes 2-char line hashes", () => { + const hash = computeLineHash(1, "hello"); + expect(hash).toMatch(HASH_PATTERN); + }); + + it("computes deterministic file hash", () => { + const a = computeFileHash("a\nb\n"); + const b = computeFileHash("a\nb\n"); + const c = computeFileHash("a\nB\n"); + + expect(a).toBe(b); + expect(a).not.toBe(c); + }); + + it("parses line tags with noisy prefixes", () => { + expect(parseLineTag(">>> 12#ZP:line")).toEqual({ + line: 12, + hash: "ZP", + }); + }); + + it("replaces anchored line", () => { + const content = "alpha\nbravo\ncharlie\n"; + const bravoHash = computeLineHash(2, "bravo"); + const result = applyHashlineEdits(content, [ + { + op: "replace", + pos: { line: 2, hash: bravoHash }, + lines: ["BRAVO"], + }, + ]); + + expect(result.lines).toBe("alpha\nBRAVO\ncharlie\n"); + expect(result.firstChangedLine).toBe(2); + }); + + it("inserts with append and prepend", () => { + const content = "a\nb\n"; + const bHash = computeLineHash(2, "b"); + + const result = applyHashlineEdits(content, [ + { + op: "prepend", + pos: { line: 2, hash: bHash }, + lines: ["before-b"], + }, + { + op: "append", + pos: { line: 2, hash: bHash }, + lines: ["after-b"], + }, + ]); + + expect(result.lines).toBe("a\nbefore-b\nb\nafter-b\n"); + }); + + it("appends at EOF before trailing newline sentinel", () => { + const content = "a\nb\n"; + const result = applyHashlineEdits(content, [ + { + op: "append", + lines: ["c"], + }, + ]); + + expect(result.lines).toBe("a\nb\nc\n"); + expect(result.firstChangedLine).toBe(3); + }); + + it("preserves append order for same anchor", () => { + const content = "a\nb\n"; + const bHash = computeLineHash(2, "b"); + + const result = applyHashlineEdits(content, [ + { + op: "append", + pos: { line: 2, hash: bHash }, + lines: ["first"], + }, + { + op: "append", + pos: { line: 2, hash: bHash }, + lines: ["second"], + }, + ]); + + expect(result.lines).toBe("a\nb\nfirst\nsecond\n"); + }); + + it("preserves prepend order for same anchor", () => { + const content = "a\nb\n"; + const bHash = computeLineHash(2, "b"); + + const result = applyHashlineEdits(content, [ + { + op: "prepend", + pos: { line: 2, hash: bHash }, + lines: ["first"], + }, + { + op: "prepend", + pos: { line: 2, hash: bHash }, + lines: ["second"], + }, + ]); + + expect(result.lines).toBe("a\nfirst\nsecond\nb\n"); + }); + + it("throws mismatch error for stale anchors", () => { + const content = "one\ntwo\nthree\n"; + const staleHash = computeLineHash(2, "TWO"); + + expect(() => + applyHashlineEdits(content, [ + { + op: "replace", + pos: { line: 2, hash: staleHash }, + lines: ["two-updated"], + }, + ]) + ).toThrow(HashlineMismatchError); + }); + + it("strips copied hashline prefixes from string inputs", () => { + const parsed = parseHashlineText("1#ZP:foo\n2#MQ:bar\n"); + expect(parsed).toEqual(["foo", "bar"]); + }); +}); diff --git a/src/tools/utils/hashline/hashline.ts b/src/tools/utils/hashline/hashline.ts new file mode 100644 index 0000000..bfbf734 --- /dev/null +++ b/src/tools/utils/hashline/hashline.ts @@ -0,0 +1,519 @@ +export interface LineTag { + hash: string; + line: number; +} + +export interface HashMismatch { + actual: string; + expected: string; + line: number; +} + +export type HashlineEdit = + | { + op: "append"; + lines: string[]; + pos?: LineTag; + } + | { + op: "prepend"; + lines: string[]; + pos?: LineTag; + } + | { + op: "replace"; + end?: LineTag; + lines: string[]; + pos: LineTag; + }; + +export interface HashlineNoopEdit { + current: string; + editIndex: number; + loc: string; +} + +export interface ApplyHashlineEditsResult { + firstChangedLine: number | undefined; + lines: string; + noopEdits?: HashlineNoopEdit[]; + warnings?: string[]; +} + +const NIBBLE_STR = "ZPMQVRWSNKTXJBYH"; +const HASH_DICT = Array.from({ length: 256 }, (_, i) => { + const high = Math.floor(i / 16); + const low = i % 16; + return `${NIBBLE_STR[high]}${NIBBLE_STR[low]}`; +}); + +const SIGNIFICANT_CHAR_REGEX = /[\p{L}\p{N}]/u; +const TAG_REGEX = /^\s*[>+-]*\s*(\d+)\s*#\s*([ZPMQVRWSNKTXJBYH]{2})/i; +const HASHLINE_PREFIX_REGEX = + /^\s*(?:>>>|>>)?\s*\d+\s*#\s*[ZPMQVRWSNKTXJBYH]{2}\s*(?:[:|])\s*/i; +const DIFF_PLUS_REGEX = /^[+](?![+])/; + +export function computeLineHash(lineNumber: number, lineText: string): string { + let normalized = lineText; + if (normalized.endsWith("\r")) { + normalized = normalized.slice(0, -1); + } + normalized = normalized.replace(/\s+/g, ""); + + let seed = 0; + if (!SIGNIFICANT_CHAR_REGEX.test(normalized)) { + seed = lineNumber; + } + + const rawHash = Bun.hash.xxHash32(normalized, seed); + const index = ((rawHash % 256) + 256) % 256; + return HASH_DICT[index]; +} + +export function computeFileHash(content: string): string { + const rawHash = Bun.hash.xxHash32(content, 0); + const normalized = + ((rawHash % 0x1_00_00_00_00) + 0x1_00_00_00_00) % 0x1_00_00_00_00; + return normalized.toString(16).padStart(8, "0"); +} + +export function formatLineTag(lineNumber: number, lineText: string): string { + return `${lineNumber}#${computeLineHash(lineNumber, lineText)}`; +} + +export function parseLineTag(tag: string): LineTag { + const matched = tag.match(TAG_REGEX); + if (!matched) { + throw new Error( + `Invalid line reference "${tag}". Expected format "LINE#ID" (example: "5#AB").` + ); + } + + const lineNumber = Number.parseInt(matched[1], 10); + if (lineNumber < 1) { + throw new Error(`Line number must be >= 1, got ${lineNumber} in "${tag}".`); + } + + return { + line: lineNumber, + hash: matched[2].toUpperCase(), + }; +} + +export function stripNewLinePrefixes(lines: string[]): string[] { + let hashPrefixCount = 0; + let diffPlusCount = 0; + let nonEmptyLines = 0; + + for (const line of lines) { + if (line.length === 0) { + continue; + } + nonEmptyLines += 1; + if (HASHLINE_PREFIX_REGEX.test(line)) { + hashPrefixCount += 1; + } + if (DIFF_PLUS_REGEX.test(line)) { + diffPlusCount += 1; + } + } + + if (nonEmptyLines === 0) { + return lines; + } + + const shouldStripHashlinePrefixes = + hashPrefixCount > 0 && hashPrefixCount >= nonEmptyLines * 0.5; + const shouldStripDiffPrefix = + !shouldStripHashlinePrefixes && + diffPlusCount > 0 && + diffPlusCount >= nonEmptyLines * 0.5; + + if (!(shouldStripHashlinePrefixes || shouldStripDiffPrefix)) { + return lines; + } + + return lines.map((line) => { + if (shouldStripHashlinePrefixes) { + return line.replace(HASHLINE_PREFIX_REGEX, ""); + } + if (shouldStripDiffPrefix) { + return line.replace(DIFF_PLUS_REGEX, ""); + } + return line; + }); +} + +export function parseHashlineText( + input: string[] | string | null | undefined +): string[] { + if (input === null || input === undefined) { + return []; + } + if (Array.isArray(input)) { + return input; + } + + const lines = stripNewLinePrefixes(input.split("\n")); + if (lines.length === 0) { + return []; + } + if (lines.at(-1)?.trim() === "") { + return lines.slice(0, -1); + } + return lines; +} + +function formatMismatchMessage( + mismatches: HashMismatch[], + fileLines: string[] +): string { + const mismatchByLine = new Map(); + for (const mismatch of mismatches) { + mismatchByLine.set(mismatch.line, mismatch); + } + + const CONTEXT = 2; + const displayLines = new Set(); + for (const mismatch of mismatches) { + const min = Math.max(1, mismatch.line - CONTEXT); + const max = Math.min(fileLines.length, mismatch.line + CONTEXT); + for (let line = min; line <= max; line += 1) { + displayLines.add(line); + } + } + + const sorted = [...displayLines].sort((a, b) => a - b); + const output: string[] = []; + output.push( + `${mismatches.length} line${mismatches.length === 1 ? "" : "s"} changed since last read. Use updated LINE#ID references (>>> marks changed lines).` + ); + output.push(""); + + let previousLine = -1; + for (const lineNumber of sorted) { + if (previousLine !== -1 && lineNumber > previousLine + 1) { + output.push(" ..."); + } + previousLine = lineNumber; + + const text = fileLines[lineNumber - 1] ?? ""; + const prefix = `${lineNumber}#${computeLineHash(lineNumber, text)}`; + const marker = mismatchByLine.has(lineNumber) ? ">>>" : " "; + output.push(`${marker} ${prefix}:${text}`); + } + + return output.join("\n"); +} + +export class HashlineMismatchError extends Error { + readonly mismatches: readonly HashMismatch[]; + + constructor(mismatches: HashMismatch[], fileLines: string[]) { + super(formatMismatchMessage(mismatches, fileLines)); + this.name = "HashlineMismatchError"; + this.mismatches = mismatches; + } +} + +function isSameText(linesA: string[], linesB: string[]): boolean { + if (linesA.length !== linesB.length) { + return false; + } + for (let index = 0; index < linesA.length; index += 1) { + if (linesA[index] !== linesB[index]) { + return false; + } + } + return true; +} + +function ensureLineTagValid( + tag: LineTag, + lines: string[], + mismatches: HashMismatch[] +): boolean { + if (tag.line < 1 || tag.line > lines.length) { + throw new Error( + `Line ${tag.line} does not exist (file has ${lines.length} lines).` + ); + } + + const actualHash = computeLineHash(tag.line, lines[tag.line - 1] ?? ""); + if (actualHash === tag.hash.toUpperCase()) { + return true; + } + + mismatches.push({ + line: tag.line, + expected: tag.hash.toUpperCase(), + actual: actualHash, + }); + return false; +} + +function dedupeMismatches(mismatches: HashMismatch[]): HashMismatch[] { + const seenByLine = new Map(); + for (const mismatch of mismatches) { + if (!seenByLine.has(mismatch.line)) { + seenByLine.set(mismatch.line, mismatch); + } + } + return [...seenByLine.values()].sort((a, b) => a.line - b.line); +} + +function validateEditsAgainstFile( + edits: HashlineEdit[], + fileLines: string[] +): void { + const mismatches: HashMismatch[] = []; + + for (const edit of edits) { + switch (edit.op) { + case "replace": { + const startValid = ensureLineTagValid(edit.pos, fileLines, mismatches); + const endValid = edit.end + ? ensureLineTagValid(edit.end, fileLines, mismatches) + : true; + + if ( + startValid && + endValid && + edit.end && + edit.pos.line > edit.end.line + ) { + throw new Error( + `Range start line ${edit.pos.line} must be <= end line ${edit.end.line}.` + ); + } + break; + } + case "append": { + if (edit.pos) { + ensureLineTagValid(edit.pos, fileLines, mismatches); + } + break; + } + case "prepend": { + if (edit.pos) { + ensureLineTagValid(edit.pos, fileLines, mismatches); + } + break; + } + default: { + throw new Error("Unsupported hashline operation."); + } + } + } + + if (mismatches.length > 0) { + throw new HashlineMismatchError(dedupeMismatches(mismatches), fileLines); + } +} + +interface AnnotatedHashlineEdit { + edit: HashlineEdit; + editIndex: number; + precedence: number; + sortLine: number; +} + +function buildAnnotatedEdits( + edits: HashlineEdit[], + fileLines: string[] +): AnnotatedHashlineEdit[] { + const seenEditKeys = new Set(); + const deduped: HashlineEdit[] = []; + + for (const edit of edits) { + const key = JSON.stringify(edit); + if (seenEditKeys.has(key)) { + continue; + } + seenEditKeys.add(key); + deduped.push(edit); + } + + const annotated = deduped.map((edit, editIndex) => { + if (edit.op === "replace") { + return { + edit, + editIndex, + precedence: 0, + sortLine: edit.end?.line ?? edit.pos.line, + }; + } + + if (edit.op === "append") { + return { + edit, + editIndex, + precedence: 1, + sortLine: edit.pos?.line ?? fileLines.length + 1, + }; + } + + return { + edit, + editIndex, + precedence: 2, + sortLine: edit.pos?.line ?? 0, + }; + }); + + annotated.sort((a, b) => { + if (a.sortLine !== b.sortLine) { + return b.sortLine - a.sortLine; + } + if (a.precedence !== b.precedence) { + return a.precedence - b.precedence; + } + if (a.precedence === 1 || a.precedence === 2) { + return b.editIndex - a.editIndex; + } + return a.editIndex - b.editIndex; + }); + + return annotated; +} + +function applyAnnotatedEdit(params: { + annotated: AnnotatedHashlineEdit; + fileLines: string[]; + originalLines: string[]; + noopEdits: HashlineNoopEdit[]; + trackFirstChangedLine: (lineNumber: number) => void; +}): void { + const { + annotated, + fileLines, + originalLines, + noopEdits, + trackFirstChangedLine, + } = params; + const { edit, editIndex } = annotated; + + if (edit.op === "replace") { + const startIndex = edit.pos.line - 1; + const replaceCount = edit.end ? edit.end.line - edit.pos.line + 1 : 1; + const currentSlice = originalLines.slice( + startIndex, + startIndex + replaceCount + ); + + if (isSameText(currentSlice, edit.lines)) { + noopEdits.push({ + editIndex, + loc: `${edit.pos.line}#${edit.pos.hash}`, + current: currentSlice.join("\n"), + }); + return; + } + + fileLines.splice(startIndex, replaceCount, ...edit.lines); + trackFirstChangedLine(edit.pos.line); + return; + } + + const insertedLines = edit.lines.length === 0 ? [""] : edit.lines; + if (edit.op === "append") { + if (edit.pos) { + fileLines.splice(edit.pos.line, 0, ...insertedLines); + trackFirstChangedLine(edit.pos.line + 1); + return; + } + + if (fileLines.length === 1 && fileLines[0] === "") { + fileLines.splice(0, 1, ...insertedLines); + trackFirstChangedLine(1); + return; + } + + const hasTrailingNewlineSentinel = fileLines.at(-1) === ""; + const insertionIndex = hasTrailingNewlineSentinel + ? Math.max(fileLines.length - 1, 0) + : fileLines.length; + const startLine = insertionIndex + 1; + fileLines.splice(insertionIndex, 0, ...insertedLines); + trackFirstChangedLine(startLine); + return; + } + + if (edit.pos) { + fileLines.splice(edit.pos.line - 1, 0, ...insertedLines); + trackFirstChangedLine(edit.pos.line); + return; + } + + if (fileLines.length === 1 && fileLines[0] === "") { + fileLines.splice(0, 1, ...insertedLines); + trackFirstChangedLine(1); + return; + } + + fileLines.splice(0, 0, ...insertedLines); + trackFirstChangedLine(1); +} + +export function applyHashlineEdits( + content: string, + edits: HashlineEdit[] +): ApplyHashlineEditsResult { + if (edits.length === 0) { + return { + lines: content, + firstChangedLine: undefined, + }; + } + + const fileLines = content.split("\n"); + const originalLines = [...fileLines]; + validateEditsAgainstFile(edits, fileLines); + const annotated = buildAnnotatedEdits(edits, fileLines); + + let firstChangedLine: number | undefined; + const noopEdits: HashlineNoopEdit[] = []; + + const trackFirstChangedLine = (lineNumber: number): void => { + if (firstChangedLine === undefined || lineNumber < firstChangedLine) { + firstChangedLine = lineNumber; + } + }; + + for (const annotatedEdit of annotated) { + applyAnnotatedEdit({ + annotated: annotatedEdit, + fileLines, + originalLines, + noopEdits, + trackFirstChangedLine, + }); + } + + const warnings = + noopEdits.length > 0 + ? [ + `${noopEdits.length} edit(s) were no-ops because replacement text matched existing content.`, + ] + : undefined; + + return { + lines: fileLines.join("\n"), + firstChangedLine, + ...(noopEdits.length > 0 ? { noopEdits } : {}), + ...(warnings ? { warnings } : {}), + }; +} + +export function formatHashlineNumberedLines( + lines: string[], + startLine: number +): string { + return lines + .map((line, index) => { + const lineNumber = startLine + index; + const tag = formatLineTag(lineNumber, line); + return ` ${tag} | ${line}`; + }) + .join("\n"); +} From 76f7c91498c95d262b515ec4cdac71816ff2946b Mon Sep 17 00:00:00 2001 From: minpeter Date: Mon, 23 Feb 2026 15:48:07 +0900 Subject: [PATCH 03/12] feat(core): harden execution and tooling reliability Improve process output handling, read safety metadata flow, and loop continuation semantics while adding tool-registry injection and translation toggles. This reduces hidden failures and makes runtime behavior more deterministic across CLI/headless paths. --- AGENTS.md | 206 +++++++++------------- benchmark/harbor_agent.py | 1 - benchmark/install-agent.sh.j2 | 3 +- src/AGENTS.md | 52 ++++++ src/agent-reasoning-default.test.ts | 48 +++++ src/agent.test.ts | 99 +++++++++++ src/agent.ts | 60 ++++++- src/commands/translate.test.ts | 70 ++++++++ src/commands/translate.ts | 14 ++ src/context/environment-context.ts | 2 +- src/context/skills.ts | 78 +++++++- src/entrypoints/AGENTS.md | 33 ++++ src/entrypoints/cli.ts | 14 +- src/entrypoints/headless.ts | 180 +++++++++++++++---- src/interaction/pi-tui-stream-renderer.ts | 2 +- src/interaction/tool-loop-control.test.ts | 17 +- src/interaction/tool-loop-control.ts | 14 +- src/tools/execute/output-handler.test.ts | 15 +- src/tools/execute/output-handler.ts | 32 +++- src/tools/execute/process-manager.test.ts | 3 +- src/tools/execute/process-manager.ts | 83 +++++++-- src/tools/execute/shell-interact.test.ts | 8 +- src/tools/explore/read-file.ts | 20 +-- src/tools/explore/safety-utils.ts | 23 ++- src/tools/index.ts | 30 ++-- src/utils/tools-manager.ts | 4 +- 26 files changed, 854 insertions(+), 257 deletions(-) create mode 100644 src/AGENTS.md create mode 100644 src/agent-reasoning-default.test.ts create mode 100644 src/agent.test.ts create mode 100644 src/commands/translate.test.ts create mode 100644 src/commands/translate.ts create mode 100644 src/entrypoints/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 28d2de8..e9bda57 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,123 +1,83 @@ -# Ultracite Code Standards - -This project uses **Ultracite**, a zero-config preset that enforces strict code quality standards through automated formatting and linting. - -## Quick Reference - -- **Format code**: `npm exec -- ultracite fix` -- **Check for issues**: `npm exec -- ultracite check` -- **Diagnose setup**: `npm exec -- ultracite doctor` - -Biome (the underlying engine) provides robust linting and formatting. Most issues are automatically fixable. - ---- - -## Core Principles - -Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity. - -### Type Safety & Explicitness - -- Use explicit types for function parameters and return values when they enhance clarity -- Prefer `unknown` over `any` when the type is genuinely unknown -- Use const assertions (`as const`) for immutable values and literal types -- Leverage TypeScript's type narrowing instead of type assertions -- Use meaningful variable names instead of magic numbers - extract constants with descriptive names - -### Modern JavaScript/TypeScript - -- Use arrow functions for callbacks and short functions -- Prefer `for...of` loops over `.forEach()` and indexed `for` loops -- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access -- Prefer template literals over string concatenation -- Use destructuring for object and array assignments -- Use `const` by default, `let` only when reassignment is needed, never `var` - -### Async & Promises - -- Always `await` promises in async functions - don't forget to use the return value -- Use `async/await` syntax instead of promise chains for better readability -- Handle errors appropriately in async code with try-catch blocks -- Don't use async functions as Promise executors - -### React & JSX - -- Use function components over class components -- Call hooks at the top level only, never conditionally -- Specify all dependencies in hook dependency arrays correctly -- Use the `key` prop for elements in iterables (prefer unique IDs over array indices) -- Nest children between opening and closing tags instead of passing as props -- Don't define components inside other components -- Use semantic HTML and ARIA attributes for accessibility: - - Provide meaningful alt text for images - - Use proper heading hierarchy - - Add labels for form inputs - - Include keyboard event handlers alongside mouse events - - Use semantic elements (`