Skip to content

Commit d87d4f0

Browse files
EBrownclaude
andcommitted
feat: cherry-pick 2 upstream features (PRs anomalyco#9126, anomalyco#14085)
- anomalyco#9126: Add experimental Ruff language server support for Python projects. Enabled via OPENCODE_EXPERIMENTAL_LSP_RUFF flag. Detects virtual environments and supports all Python file extensions. - anomalyco#14085: Configurable tool alias map for repairing miscalled tools. LLMs frequently call wrong tool names (todo_write→todowrite, search→grep). Adds experimental.tool_aliases config to map common mistakes to correct tool names. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 53abc6c commit d87d4f0

5 files changed

Lines changed: 113 additions & 18 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,6 +1190,10 @@ export namespace Config {
11901190
.positive()
11911191
.optional()
11921192
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
1193+
tool_aliases: z
1194+
.record(z.string(), z.string())
1195+
.optional()
1196+
.describe("Map of alias → canonical tool name for repairing miscalled tools"),
11931197
})
11941198
.optional(),
11951199
})

packages/opencode/src/flag/flag.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ export namespace Flag {
5454
export const OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX")
5555
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
5656
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
57+
export const OPENCODE_EXPERIMENTAL_LSP_RUFF = truthy("OPENCODE_EXPERIMENTAL_LSP_RUFF")
5758
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
5859
export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
5960
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")

packages/opencode/src/lsp/index.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,26 @@ export namespace LSP {
6262
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
6363

6464
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => {
65+
const disable = (id: string, reason?: string) => {
66+
if (!servers[id]) return
67+
if (reason) log.info(reason)
68+
delete servers[id]
69+
}
70+
6571
if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
66-
// If experimental flag is enabled, disable pyright
67-
if (servers["pyright"]) {
68-
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
69-
delete servers["pyright"]
70-
}
71-
} else {
72-
// If experimental flag is disabled, disable ty
73-
if (servers["ty"]) {
74-
delete servers["ty"]
75-
}
72+
disable("pyright", "LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
73+
}
74+
75+
if (!Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
76+
disable("ty")
77+
}
78+
79+
if (Flag.OPENCODE_EXPERIMENTAL_LSP_RUFF) {
80+
disable("pyright", "LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_RUFF is enabled")
81+
}
82+
83+
if (!Flag.OPENCODE_EXPERIMENTAL_LSP_RUFF) {
84+
disable("ruff")
7685
}
7786
}
7887

packages/opencode/src/lsp/server.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,57 @@ export namespace LSPServer {
502502
},
503503
}
504504

505+
export const Ruff: Info = {
506+
id: "ruff",
507+
extensions: [".py", ".pyi"],
508+
root: NearestRoot([
509+
"pyproject.toml",
510+
"ruff.toml",
511+
".ruff.toml",
512+
"setup.py",
513+
"setup.cfg",
514+
"requirements.txt",
515+
"Pipfile",
516+
]),
517+
async spawn(root) {
518+
if (!Flag.OPENCODE_EXPERIMENTAL_LSP_RUFF) {
519+
return undefined
520+
}
521+
522+
const venvs = [process.env["VIRTUAL_ENV"], path.join(root, ".venv"), path.join(root, "venv")].filter(
523+
(p): p is string => p !== undefined,
524+
)
525+
526+
const binary = await (async () => {
527+
const direct = Bun.which("ruff")
528+
if (direct) return direct
529+
530+
for (const venv of venvs) {
531+
const windows = process.platform === "win32"
532+
const candidate = windows ? path.join(venv, "Scripts", "ruff.exe") : path.join(venv, "bin", "ruff")
533+
if (await Bun.file(candidate).exists()) {
534+
return candidate
535+
}
536+
}
537+
538+
return undefined
539+
})()
540+
541+
if (!binary) {
542+
log.error("ruff not found, please install ruff first")
543+
return
544+
}
545+
546+
const proc = spawn(binary, ["server"], {
547+
cwd: root,
548+
})
549+
550+
return {
551+
process: proc,
552+
}
553+
},
554+
}
555+
505556
export const Pyright: Info = {
506557
id: "pyright",
507558
extensions: [".py", ".pyi"],

packages/opencode/src/session/llm.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -176,21 +176,51 @@ export namespace LLM {
176176
})
177177
},
178178
async experimental_repairToolCall(failed) {
179-
const lower = failed.toolCall.toolName.toLowerCase()
180-
if (lower !== failed.toolCall.toolName && tools[lower]) {
179+
const name = failed.toolCall.toolName
180+
// try lowercase first
181+
if (name !== name.toLowerCase() && tools[name.toLowerCase()]) {
181182
l.info("repairing tool call", {
182-
tool: failed.toolCall.toolName,
183-
repaired: lower,
183+
tool: name,
184+
repaired: name.toLowerCase(),
184185
})
185-
return {
186-
...failed.toolCall,
187-
toolName: lower,
186+
return { ...failed.toolCall, toolName: name.toLowerCase() }
187+
}
188+
// try stripping underscores/hyphens and case-insensitive match
189+
// handles todo_write -> todowrite, Web_Fetch -> webfetch, etc.
190+
const normalized = name.replace(/[-_]/g, "").toLowerCase()
191+
for (const toolName of Object.keys(tools)) {
192+
if (toolName.toLowerCase() === normalized) {
193+
l.info("repairing tool call", {
194+
tool: name,
195+
repaired: toolName,
196+
})
197+
return { ...failed.toolCall, toolName }
188198
}
189199
}
200+
// try alias lookup (config overrides builtins)
201+
const builtinAliases: Record<string, string> = {
202+
search: "grep",
203+
find: "glob",
204+
cat: "read",
205+
run: "bash",
206+
shell: "bash",
207+
todo: "todowrite",
208+
fetch: "webfetch",
209+
}
210+
const userAliases = cfg.experimental?.tool_aliases ?? {}
211+
const aliases = { ...builtinAliases, ...userAliases }
212+
const aliasTarget = aliases[name] ?? aliases[name.toLowerCase()] ?? aliases[normalized]
213+
if (aliasTarget && tools[aliasTarget]) {
214+
l.info("repairing tool call via alias", {
215+
tool: name,
216+
alias: aliasTarget,
217+
})
218+
return { ...failed.toolCall, toolName: aliasTarget }
219+
}
190220
return {
191221
...failed.toolCall,
192222
input: JSON.stringify({
193-
tool: failed.toolCall.toolName,
223+
tool: name,
194224
error: failed.error.message,
195225
}),
196226
toolName: "invalid",

0 commit comments

Comments
 (0)