Skip to content

Commit c1a0d2b

Browse files
anandgupta42claude
andauthored
fix: tool reliability improvements for sql-classify, edit, and webfetch (#581) (#582)
**sql-classify.ts:** - Fix `computeSqlFingerprint` referencing undefined `core` variable after safe-import refactor — extract `extractMetadata` as module-level guard - Invert fallback classifier to whitelist reads (`READ_PATTERN`) instead of blacklisting writes — treats unknown statements as "write" for safety - Handle multi-statement SQL in fallback by splitting on semicolons - Strip `--` line comments in fallback (block comments already stripped) - Fix `HARD_DENY_PATTERN` trailing `\s` → `\b` to match `TRUNCATE;` **edit.ts:** - Add `buildNotFoundMessage` with Levenshtein nearest-match snippets for LLM self-correction when `oldString` not found - Fix substring matching to prefer exact equality over short-line matches **webfetch.ts:** - Add session-level URL failure cache (404/410/451) with 5-min TTL - Add `buildFetchError` with actionable status-specific error messages - Add `sanitizeUrl` to strip query strings from error messages - Add URL validation via `new URL()` constructor - Add `MAX_CACHED_URLS = 500` size cap with oldest-entry eviction **Tests:** 12 new tests for `buildNotFoundMessage`, `replace` error messages, `computeSqlFingerprint`, and updated webfetch assertions. Closes #581 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 382b5db commit c1a0d2b

File tree

7 files changed

+343
-47
lines changed

7 files changed

+343
-47
lines changed

.github/meta/commit.txt

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,29 @@
1-
feat: add task intent classification telemetry event
1+
fix: tool reliability improvements for sql-classify, edit, and webfetch (#581)
22

3-
Add `task_classified` event emitted at session start with keyword/regex
4-
classification of the first user message. Categories: debug_dbt, write_sql,
5-
optimize_query, build_model, analyze_lineage, explore_schema, migrate_sql,
6-
manage_warehouse, finops, general.
3+
**sql-classify.ts:**
4+
- Fix `computeSqlFingerprint` referencing undefined `core` variable after
5+
safe-import refactor — extract `extractMetadata` as module-level guard
6+
- Invert fallback classifier to whitelist reads (`READ_PATTERN`) instead of
7+
blacklisting writes — treats unknown statements as "write" for safety
8+
- Handle multi-statement SQL in fallback by splitting on semicolons
9+
- Strip `--` line comments in fallback (block comments already stripped)
10+
- Fix `HARD_DENY_PATTERN` trailing `\s` → `\b` to match `TRUNCATE;`
711

8-
- `classifyTaskIntent()` — pure regex matcher, zero LLM cost, <1ms
9-
- Includes warehouse type from fingerprint cache
10-
- Strong/weak confidence levels (1.0 vs 0.5)
11-
- 15 unit tests covering all intent categories + edge cases
12+
**edit.ts:**
13+
- Add `buildNotFoundMessage` with Levenshtein nearest-match snippets for
14+
LLM self-correction when `oldString` not found
15+
- Fix substring matching to prefer exact equality over short-line matches
1216

13-
Closes AI-6029
17+
**webfetch.ts:**
18+
- Add session-level URL failure cache (404/410/451) with 5-min TTL
19+
- Add `buildFetchError` with actionable status-specific error messages
20+
- Add `sanitizeUrl` to strip query strings from error messages
21+
- Add URL validation via `new URL()` constructor
22+
- Add `MAX_CACHED_URLS = 500` size cap with oldest-entry eviction
23+
24+
**Tests:** 12 new tests for `buildNotFoundMessage`, `replace` error
25+
messages, `computeSqlFingerprint`, and updated webfetch assertions.
26+
27+
Closes #581
1428

1529
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

packages/opencode/src/altimate/tools/sql-classify.ts

Lines changed: 68 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,69 @@
22
//
33
// Uses altimate-core's AST-based getStatementTypes() for accurate classification.
44
// Handles CTEs, string literals, procedural blocks, all dialects correctly.
5+
// Falls back to regex-based heuristics if the napi binary fails to load.
56

6-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7-
const core: any = require("@altimateai/altimate-core")
7+
// Safe import: napi binary may not be available on all platforms
8+
let getStatementTypes: ((sql: string, dialect?: string | null) => any) | null = null
9+
let extractMetadata: ((sql: string) => any) | null = null
10+
try {
11+
// eslint-disable-next-line @typescript-eslint/no-require-imports
12+
const core = require("@altimateai/altimate-core")
13+
if (typeof core?.getStatementTypes === "function") {
14+
getStatementTypes = core.getStatementTypes
15+
}
16+
if (typeof core?.extractMetadata === "function") {
17+
extractMetadata = core.extractMetadata
18+
}
19+
} catch {
20+
// napi binary failed to load — will use regex fallback
21+
}
822

9-
// Categories from altimate-core that indicate write operations
10-
const WRITE_CATEGORIES = new Set(["dml", "ddl", "dcl", "tcl"])
1123
// Only SELECT queries are known safe. "other" (SHOW, SET, USE, etc.) is ambiguous — prompt for permission.
1224
const READ_CATEGORIES = new Set(["query"])
1325

1426
// Hard-deny patterns — blocked regardless of permissions
1527
const HARD_DENY_TYPES = new Set(["DROP DATABASE", "DROP SCHEMA", "TRUNCATE", "TRUNCATE TABLE"])
1628

29+
// Regex fallback: conservative — only known-safe reads are whitelisted, everything else is "write"
30+
const READ_PATTERN = /^\s*(SELECT|WITH|SHOW|EXPLAIN|DESCRIBE|DESC)\b/i
31+
const HARD_DENY_PATTERN =
32+
/^\s*(DROP\s+(DATABASE|SCHEMA)\b|TRUNCATE(\s+TABLE)?\b)/i
33+
34+
/**
35+
* Regex-based fallback classifier for when altimate-core is unavailable.
36+
* Conservative: treats anything not clearly a SELECT/WITH/SHOW/EXPLAIN as "write".
37+
* Handles multi-statement SQL by splitting on semicolons and checking each statement.
38+
*/
39+
function classifyFallback(sql: string): { queryType: "read" | "write"; blocked: boolean } {
40+
const cleaned = sql
41+
.replace(/\/\*[\s\S]*?\*\//g, "") // block comments
42+
.replace(/--[^\n]*/g, "") // line comments
43+
const statements = cleaned.split(";").map(s => s.trim()).filter(Boolean)
44+
if (statements.length === 0) return { queryType: "read", blocked: false }
45+
let queryType: "read" | "write" = "read"
46+
let blocked = false
47+
for (const stmt of statements) {
48+
if (HARD_DENY_PATTERN.test(stmt)) blocked = true
49+
if (!READ_PATTERN.test(stmt)) queryType = "write"
50+
}
51+
return { queryType, blocked }
52+
}
53+
1754
/**
1855
* Classify a SQL string as "read" or "write" using AST parsing.
1956
* If ANY statement is a write, returns "write".
2057
*/
2158
export function classify(sql: string): "read" | "write" {
22-
const result = core.getStatementTypes(sql)
23-
if (!result?.categories?.length) return "read"
24-
// Treat unknown categories (not in WRITE or READ sets) as write to fail safe
25-
return result.categories.some((c: string) => !READ_CATEGORIES.has(c)) ? "write" : "read"
59+
if (!sql || typeof sql !== "string") return "read"
60+
if (!getStatementTypes) return classifyFallback(sql).queryType
61+
try {
62+
const result = getStatementTypes(sql)
63+
if (!result?.categories?.length) return "read"
64+
return result.categories.some((c: string) => !READ_CATEGORIES.has(c)) ? "write" : "read"
65+
} catch {
66+
return classifyFallback(sql).queryType
67+
}
2668
}
2769

2870
/**
@@ -38,17 +80,23 @@ export function classifyMulti(sql: string): "read" | "write" {
3880
* Returns both the overall query type and whether a hard-deny pattern was found.
3981
*/
4082
export function classifyAndCheck(sql: string): { queryType: "read" | "write"; blocked: boolean } {
41-
const result = core.getStatementTypes(sql)
42-
if (!result?.statements?.length) return { queryType: "read", blocked: false }
83+
if (!sql || typeof sql !== "string") return { queryType: "read", blocked: false }
84+
if (!getStatementTypes) return classifyFallback(sql)
85+
try {
86+
const result = getStatementTypes(sql)
87+
if (!result?.statements?.length) return { queryType: "read", blocked: false }
4388

44-
const blocked = result.statements.some((s: { statement_type: string }) =>
45-
s.statement_type && HARD_DENY_TYPES.has(s.statement_type.toUpperCase()),
46-
)
89+
const blocked = result.statements.some(
90+
(s: { statement_type: string }) =>
91+
s.statement_type && HARD_DENY_TYPES.has(s.statement_type.toUpperCase()),
92+
)
4793

48-
const categories = result.categories ?? []
49-
// Unknown categories (not in WRITE or READ sets) are treated as write to fail safe
50-
const queryType = categories.some((c: string) => !READ_CATEGORIES.has(c)) ? "write" : "read"
51-
return { queryType: queryType as "read" | "write", blocked }
94+
const categories = result.categories ?? []
95+
const queryType = categories.some((c: string) => !READ_CATEGORIES.has(c)) ? "write" : "read"
96+
return { queryType: queryType as "read" | "write", blocked }
97+
} catch {
98+
return classifyFallback(sql)
99+
}
52100
}
53101

54102
// altimate_change start — SQL structure fingerprint for telemetry (no content, only shape)
@@ -66,9 +114,10 @@ export interface SqlFingerprint {
66114
/** Compute a PII-safe structural fingerprint of a SQL query.
67115
* Uses altimate-core AST parsing — local, no API calls, ~1-5ms. */
68116
export function computeSqlFingerprint(sql: string): SqlFingerprint | null {
117+
if (!getStatementTypes || !extractMetadata) return null
69118
try {
70-
const stmtResult = core.getStatementTypes(sql)
71-
const meta = core.extractMetadata(sql)
119+
const stmtResult = getStatementTypes(sql)
120+
const meta = extractMetadata(sql)
72121
return {
73122
statement_types: stmtResult?.types ?? [],
74123
categories: stmtResult?.categories ?? [],

packages/opencode/src/tool/edit.ts

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,76 @@ export function trimDiff(diff: string): string {
629629
return trimmedLines.join("\n")
630630
}
631631

632+
/**
633+
* Build a helpful error message when oldString isn't found.
634+
* Includes a snippet of the closest-matching region so the model can self-correct.
635+
*/
636+
export function buildNotFoundMessage(content: string, oldString: string): string {
637+
const base = "Could not find oldString in the file."
638+
639+
// Find the first line of oldString and search for it in the file
640+
const firstLine = oldString.split("\n")[0].trim()
641+
if (!firstLine) return base + " The oldString appears to be empty or whitespace-only."
642+
643+
const contentLines = content.split("\n")
644+
let bestLine = -1
645+
let bestScore = 0
646+
647+
// Search for the line with highest similarity to the first line of oldString
648+
for (let i = 0; i < contentLines.length; i++) {
649+
const trimmed = contentLines[i].trim()
650+
if (!trimmed) continue
651+
652+
// Skip very short lines — they produce false similarity matches
653+
const minLen = Math.min(trimmed.length, firstLine.length)
654+
if (minLen < 4) continue
655+
656+
// Exact equality is best — break immediately
657+
if (trimmed === firstLine) {
658+
bestLine = i
659+
bestScore = 1
660+
break
661+
}
662+
663+
// Substring match — strong score but keep searching for exact match
664+
if (trimmed.includes(firstLine) || firstLine.includes(trimmed)) {
665+
if (bestScore < 0.99) {
666+
bestLine = i
667+
bestScore = 0.99
668+
}
669+
continue
670+
}
671+
672+
// Skip if lengths are too different (>3x ratio) — not a meaningful comparison
673+
const maxLen = Math.max(trimmed.length, firstLine.length)
674+
if (minLen * 3 < maxLen) continue
675+
676+
// Levenshtein similarity for close matches
677+
const score = 1 - levenshtein(trimmed, firstLine) / maxLen
678+
if (score > bestScore && score > 0.6) {
679+
bestScore = score
680+
bestLine = i
681+
}
682+
}
683+
684+
if (bestLine === -1) {
685+
return base + ` The first line of your oldString ("${firstLine.slice(0, 80)}") was not found anywhere in the file. Re-read the file before editing.`
686+
}
687+
688+
// Show a small window around the best match
689+
const start = Math.max(0, bestLine - 1)
690+
const end = Math.min(contentLines.length, bestLine + 4)
691+
const snippet = contentLines
692+
.slice(start, end)
693+
.map((l, i) => ` ${start + i + 1} | ${l}`)
694+
.join("\n")
695+
696+
return (
697+
base +
698+
` A similar line was found at line ${bestLine + 1}. The file may have changed since you last read it.\n\nNearest match:\n${snippet}\n\nRe-read the file and use the exact current content for oldString.`
699+
)
700+
}
701+
632702
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
633703
if (oldString === newString) {
634704
throw new Error("No changes to apply: oldString and newString are identical.")
@@ -661,9 +731,7 @@ export function replace(content: string, oldString: string, newString: string, r
661731
}
662732

663733
if (notFound) {
664-
throw new Error(
665-
"Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",
666-
)
734+
throw new Error(buildNotFoundMessage(content, oldString))
667735
}
668736
throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.")
669737
}

packages/opencode/src/tool/webfetch.ts

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,67 @@ const BROWSER_UA =
1515
// Status codes that warrant a retry with a different User-Agent
1616
const RETRYABLE_STATUSES = new Set([403, 406])
1717

18+
// altimate_change start — session-level URL failure cache (#471)
19+
// Prevents repeated fetches to URLs that already returned 404/410 in this session.
20+
const failedUrls = new Map<string, { status: number; timestamp: number }>()
21+
const FAILURE_CACHE_TTL = 5 * 60 * 1000 // 5 minutes
22+
23+
function isUrlCachedFailure(url: string): { status: number } | null {
24+
const entry = failedUrls.get(url)
25+
if (!entry) return null
26+
if (Date.now() - entry.timestamp > FAILURE_CACHE_TTL) {
27+
failedUrls.delete(url)
28+
return null
29+
}
30+
return { status: entry.status }
31+
}
32+
33+
const MAX_CACHED_URLS = 500
34+
35+
function cacheUrlFailure(url: string, status: number): void {
36+
if (status === 404 || status === 410 || status === 451) {
37+
if (failedUrls.size >= MAX_CACHED_URLS) {
38+
// Evict oldest entry (Map preserves insertion order)
39+
const oldest = failedUrls.keys().next().value
40+
if (oldest) failedUrls.delete(oldest)
41+
}
42+
failedUrls.set(url, { status, timestamp: Date.now() })
43+
}
44+
}
45+
46+
/** Strip query string from URL to avoid leaking auth tokens in error messages. */
47+
function sanitizeUrl(url: string): string {
48+
try {
49+
const parsed = new URL(url)
50+
return parsed.origin + parsed.pathname
51+
} catch {
52+
return url.split("?")[0]
53+
}
54+
}
55+
56+
/** Build an actionable error message so the model knows whether to retry. */
57+
function buildFetchError(url: string, status: number, headers?: Headers): string {
58+
const safe = sanitizeUrl(url)
59+
switch (status) {
60+
case 404:
61+
return `HTTP 404: ${safe} does not exist. Do NOT retry this URL — it will fail again. Try a different URL or search for the correct page.`
62+
case 410:
63+
return `HTTP 410: ${safe} has been permanently removed. Do NOT retry. Find an alternative resource.`
64+
case 403:
65+
return `HTTP 403: Access to ${safe} is forbidden. The server rejected both bot and browser User-Agents. Try a different source.`
66+
case 429: {
67+
const retryAfter = headers?.get("retry-after")
68+
const wait = retryAfter ? ` (retry after ${retryAfter})` : ""
69+
return `HTTP 429: Rate limited by ${new URL(url).hostname}${wait}. Wait before fetching from this domain again, or use a different source.`
70+
}
71+
case 451:
72+
return `HTTP 451: ${safe} is unavailable for legal reasons. Do NOT retry.`
73+
default:
74+
return `HTTP ${status}: Request to ${safe} failed. This may be transient — retry once if needed.`
75+
}
76+
}
77+
// altimate_change end
78+
1879
export const WebFetchTool = Tool.define("webfetch", {
1980
description: DESCRIPTION,
2081
parameters: z.object({
@@ -26,10 +87,22 @@ export const WebFetchTool = Tool.define("webfetch", {
2687
timeout: z.number().describe("Optional timeout in seconds (max 120)").optional(),
2788
}),
2889
async execute(params, ctx) {
29-
// Validate URL
90+
// altimate_change start — URL validation and failure cache (#471)
3091
if (!params.url.startsWith("http://") && !params.url.startsWith("https://")) {
3192
throw new Error("URL must start with http:// or https://")
3293
}
94+
try {
95+
new URL(params.url)
96+
} catch {
97+
throw new Error(`Invalid URL: "${params.url.slice(0, 200)}" is not a valid URL. Check the format and try again.`)
98+
}
99+
100+
// Check failure cache — avoid re-fetching URLs that already returned 404/410
101+
const cached = isUrlCachedFailure(params.url)
102+
if (cached) {
103+
throw new Error(buildFetchError(params.url, cached.status))
104+
}
105+
// altimate_change end
33106

34107
await ctx.ask({
35108
permission: "webfetch",
@@ -83,19 +156,12 @@ export const WebFetchTool = Tool.define("webfetch", {
83156
response = await fetch(params.url, { signal, headers: browserHeaders })
84157
}
85158

159+
// altimate_change start — actionable error messages and failure caching (#471)
86160
if (!response.ok) {
87-
// altimate_change start — include URL domain in error for easier triage
88-
let domain: string
89-
try {
90-
domain = new URL(params.url).hostname
91-
} catch {
92-
domain = params.url.slice(0, 60)
93-
}
94-
throw new Error(
95-
`Request failed with status code: ${response.status} (${domain})`,
96-
)
97-
// altimate_change end
161+
cacheUrlFailure(params.url, response.status)
162+
throw new Error(buildFetchError(params.url, response.status, response.headers))
98163
}
164+
// altimate_change end
99165

100166
// Check content length
101167
const contentLength = response.headers.get("content-length")

0 commit comments

Comments
 (0)