Skip to content

Commit f01c83e

Browse files
anandgupta42claude
andcommitted
fix: pre-execution SQL validation, apply_patch retry, agent behavior rules, and error UX
- Wire datafusion validation into sql_execute — catches column/table errors locally before hitting the warehouse (uses schema cache with 24h TTL) - Add sql_pre_validation telemetry event to measure catch rate and latency - Add apply_patch retry-with-re-read on verification failure — re-reads the file and retries once before giving up, with actionable error messages - Add file-not-found cache in read tool — prevents retry loops on missing paths (capped at 500 entries) - Add agent behavior rules to system prompt: act first/ask later, enforce read-before-edit, limit retries to 2 per input - Add actionable connection error guidance in warehouse_test — maps common auth failures (wrong password, missing key, SSO timeout) to fix instructions - Auto-pull schema cache in altimate_core_validate when no schema provided Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8505605 commit f01c83e

7 files changed

Lines changed: 298 additions & 5 deletions

File tree

packages/opencode/src/altimate/telemetry/index.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,20 @@ export namespace Telemetry {
446446
duration_ms: number
447447
}
448448
// altimate_change end
449+
// altimate_change start — pre-execution SQL validation telemetry
450+
| {
451+
type: "sql_pre_validation"
452+
timestamp: number
453+
session_id: string
454+
/** skipped = no cache or stale, passed = valid SQL, blocked = invalid SQL caught, error = validation itself failed */
455+
outcome: "skipped" | "passed" | "blocked" | "error"
456+
/** why: no_cache, stale_cache, empty_cache, valid, non_structural, structural_error, validation_exception */
457+
reason: string
458+
schema_columns: number
459+
duration_ms: number
460+
error_message?: string
461+
}
462+
// altimate_change end
449463

450464
// altimate_change start — expanded error classification patterns for better triage
451465
// Order matters: earlier patterns take priority. Use specific phrases, not

packages/opencode/src/altimate/tools/altimate-core-validate.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import z from "zod"
22
import { Tool } from "../../tool/tool"
33
import { Dispatcher } from "../native"
44
import type { Telemetry } from "../telemetry"
5+
// altimate_change start — auto-pull schema from cache when not provided
6+
import { getCache } from "../native/schema/cache"
7+
// altimate_change end
58

69
export const AltimateCoreValidateTool = Tool.define("altimate_core_validate", {
710
description:
@@ -12,11 +15,20 @@ export const AltimateCoreValidateTool = Tool.define("altimate_core_validate", {
1215
schema_context: z.record(z.string(), z.any()).optional().describe("Inline schema definition"),
1316
}),
1417
async execute(args, ctx) {
15-
const hasSchema = !!(args.schema_path || (args.schema_context && Object.keys(args.schema_context).length > 0))
18+
let hasSchema = !!(args.schema_path || (args.schema_context && Object.keys(args.schema_context).length > 0))
19+
// altimate_change start — auto-pull schema from cache when not provided
20+
if (!hasSchema) {
21+
const cachedSchema = await tryGetSchemaFromCache()
22+
if (cachedSchema) {
23+
args = { ...args, schema_context: cachedSchema }
24+
hasSchema = true
25+
}
26+
}
27+
// altimate_change end
1628
const noSchema = !hasSchema
1729
if (noSchema) {
1830
const error =
19-
"No schema provided. Provide schema_context or schema_path so table/column references can be resolved."
31+
"No schema provided. Provide schema_context or schema_path so table/column references can be resolved. Tip: run schema_index first to cache your warehouse schema."
2032
return {
2133
title: "Validate: NO SCHEMA",
2234
metadata: { success: false, valid: false, has_schema: false, error },
@@ -77,6 +89,41 @@ function classifyValidationError(message: string): string {
7789
return "validation_error"
7890
}
7991

92+
// altimate_change start — auto-pull schema from cache when not provided
93+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000
94+
95+
async function tryGetSchemaFromCache(): Promise<Record<string, any> | null> {
96+
try {
97+
const cache = await getCache()
98+
const status = cache.cacheStatus()
99+
const warehouse = status.warehouses[0]
100+
if (!warehouse?.last_indexed) return null
101+
102+
const cacheAge = Date.now() - new Date(warehouse.last_indexed).getTime()
103+
if (cacheAge > CACHE_TTL_MS) return null
104+
105+
const columns = cache.listColumns(warehouse.name, 10_000)
106+
if (columns.length === 0) return null
107+
108+
const schemaContext: Record<string, any> = {}
109+
for (const col of columns) {
110+
const tableName = col.schema_name ? `${col.schema_name}.${col.table}` : col.table
111+
if (!schemaContext[tableName]) {
112+
schemaContext[tableName] = []
113+
}
114+
schemaContext[tableName].push({
115+
name: col.name,
116+
type: col.data_type || "VARCHAR",
117+
nullable: col.nullable,
118+
})
119+
}
120+
return schemaContext
121+
} catch {
122+
return null
123+
}
124+
}
125+
// altimate_change end
126+
80127
function formatValidate(data: Record<string, any>): string {
81128
if (data.error) return `Error: ${data.error}`
82129
if (data.valid) return "SQL is valid."

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

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import { classifyAndCheck } from "./sql-classify"
88
// altimate_change start — progressive disclosure suggestions
99
import { PostConnectSuggestions } from "./post-connect-suggestions"
1010
// altimate_change end
11+
// altimate_change start — pre-execution SQL validation via cached schema
12+
import { getCache } from "../native/schema/cache"
13+
import { Telemetry } from "../../telemetry"
14+
// altimate_change end
1115

1216
export const SqlExecuteTool = Tool.define("sql_execute", {
1317
description: "Execute SQL against a connected data warehouse. Returns results as a formatted table.",
@@ -33,6 +37,17 @@ export const SqlExecuteTool = Tool.define("sql_execute", {
3337
}
3438
// altimate_change end
3539

40+
// altimate_change start — pre-execution SQL validation via cached schema
41+
const preValidation = await preValidateSql(args.query, args.warehouse)
42+
if (preValidation.blocked) {
43+
return {
44+
title: "SQL: VALIDATION ERROR",
45+
metadata: { rowCount: 0, truncated: false, error: preValidation.error },
46+
output: preValidation.error!,
47+
}
48+
}
49+
// altimate_change end
50+
3651
try {
3752
const result = await Dispatcher.call("sql.execute", {
3853
sql: args.query,
@@ -68,6 +83,135 @@ export const SqlExecuteTool = Tool.define("sql_execute", {
6883
},
6984
})
7085

86+
// altimate_change start — pre-execution SQL validation via cached schema
87+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
88+
89+
interface PreValidationResult {
90+
blocked: boolean
91+
error?: string
92+
}
93+
94+
async function preValidateSql(sql: string, warehouse?: string): Promise<PreValidationResult> {
95+
const startTime = Date.now()
96+
try {
97+
const cache = await getCache()
98+
const status = cache.cacheStatus()
99+
100+
// Find the target warehouse in cache
101+
const warehouseName = warehouse || status.warehouses[0]?.name
102+
if (!warehouseName) {
103+
trackPreValidation("skipped", "no_cache", 0, Date.now() - startTime)
104+
return { blocked: false }
105+
}
106+
107+
const warehouseStatus = status.warehouses.find((w) => w.name === warehouseName)
108+
if (!warehouseStatus?.last_indexed) {
109+
trackPreValidation("skipped", "no_cache", 0, Date.now() - startTime)
110+
return { blocked: false }
111+
}
112+
113+
// Check cache freshness
114+
const cacheAge = Date.now() - new Date(warehouseStatus.last_indexed).getTime()
115+
if (cacheAge > CACHE_TTL_MS) {
116+
trackPreValidation("skipped", "stale_cache", 0, Date.now() - startTime)
117+
return { blocked: false }
118+
}
119+
120+
// Build schema context from cached columns
121+
const columns = cache.listColumns(warehouseName, 10_000)
122+
if (columns.length === 0) {
123+
trackPreValidation("skipped", "empty_cache", 0, Date.now() - startTime)
124+
return { blocked: false }
125+
}
126+
127+
const schemaContext: Record<string, any> = {}
128+
for (const col of columns) {
129+
const tableName = col.schema_name ? `${col.schema_name}.${col.table}` : col.table
130+
if (!schemaContext[tableName]) {
131+
schemaContext[tableName] = []
132+
}
133+
schemaContext[tableName].push({
134+
name: col.name,
135+
type: col.data_type || "VARCHAR",
136+
nullable: col.nullable,
137+
})
138+
}
139+
140+
// Validate SQL against cached schema
141+
const validationResult = await Dispatcher.call("altimate_core.validate", {
142+
sql,
143+
schema_path: "",
144+
schema_context: schemaContext,
145+
})
146+
147+
const data = (validationResult.data ?? {}) as Record<string, any>
148+
const errors = Array.isArray(data.errors) ? data.errors : []
149+
const isValid = data.valid !== false && errors.length === 0
150+
151+
if (isValid) {
152+
trackPreValidation("passed", "valid", columns.length, Date.now() - startTime)
153+
return { blocked: false }
154+
}
155+
156+
// Only block on high-confidence structural errors
157+
const structuralErrors = errors.filter((e: any) => {
158+
const msg = (e.message ?? "").toLowerCase()
159+
return msg.includes("column") || msg.includes("table") || msg.includes("not found") || msg.includes("does not exist")
160+
})
161+
162+
if (structuralErrors.length === 0) {
163+
// Non-structural errors (ambiguous cases) — let them through
164+
trackPreValidation("passed", "non_structural", columns.length, Date.now() - startTime)
165+
return { blocked: false }
166+
}
167+
168+
// Build helpful error with available columns
169+
const errorMsgs = structuralErrors.map((e: any) => e.message).join("\n")
170+
const referencedTables = Object.keys(schemaContext).slice(0, 10)
171+
const availableColumns = referencedTables
172+
.map((t) => `${t}: ${schemaContext[t].map((c: any) => c.name).join(", ")}`)
173+
.join("\n")
174+
175+
const errorOutput = [
176+
`Pre-execution validation failed (validated against cached schema):`,
177+
``,
178+
errorMsgs,
179+
``,
180+
`Available tables and columns:`,
181+
availableColumns,
182+
``,
183+
`Fix the query and retry. If the schema cache is outdated, run schema_index to refresh it.`,
184+
].join("\n")
185+
186+
trackPreValidation("blocked", "structural_error", columns.length, Date.now() - startTime, errorMsgs)
187+
return { blocked: true, error: errorOutput }
188+
} catch {
189+
// Validation failure should never block execution
190+
trackPreValidation("error", "validation_exception", 0, Date.now() - startTime)
191+
return { blocked: false }
192+
}
193+
}
194+
195+
function trackPreValidation(
196+
outcome: "skipped" | "passed" | "blocked" | "error",
197+
reason: string,
198+
schema_columns: number,
199+
duration_ms: number,
200+
error_message?: string,
201+
) {
202+
Telemetry.track({
203+
type: "sql_pre_validation",
204+
timestamp: Date.now(),
205+
session_id: Telemetry.getContext().sessionId,
206+
outcome,
207+
reason,
208+
schema_columns,
209+
duration_ms,
210+
...(error_message && { error_message: error_message.slice(0, 500) }),
211+
})
212+
}
213+
// altimate_change end
214+
71215
function formatResult(result: SqlExecuteResult): string {
72216
if (result.row_count === 0) return "(0 rows)"
73217

packages/opencode/src/altimate/tools/warehouse-test.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ export const WarehouseTestTool = Tool.define("warehouse_test", {
2020
}
2121
}
2222

23+
// altimate_change start — actionable error guidance for common auth failures
24+
const errorDetail = result.error ?? "Unknown error"
25+
const guidance = getConnectionGuidance(errorDetail)
26+
// altimate_change end
2327
return {
2428
title: `Connection '${args.name}': FAILED`,
2529
metadata: { connected: false },
26-
output: `Failed to connect to warehouse '${args.name}'.\nError: ${result.error ?? "Unknown error"}`,
30+
output: `Failed to connect to warehouse '${args.name}'.\nError: ${errorDetail}${guidance}`,
2731
}
2832
} catch (e) {
2933
const msg = e instanceof Error ? e.message : String(e)
@@ -35,3 +39,36 @@ export const WarehouseTestTool = Tool.define("warehouse_test", {
3539
}
3640
},
3741
})
42+
43+
// altimate_change start — actionable error guidance for common auth failures
44+
function getConnectionGuidance(error: string): string {
45+
const lower = error.toLowerCase()
46+
47+
if (lower.includes("password") && (lower.includes("incorrect") || lower.includes("authentication failed"))) {
48+
return "\n\nHow to fix: Check the password in your connection config. Verify the username has access from your current IP address. Use `warehouse_remove` then `warehouse_add` to re-enter credentials."
49+
}
50+
if (lower.includes("password must be a string") || lower.includes("scram")) {
51+
return "\n\nHow to fix: The password field is missing or not a string. Check your connection config — the password may be empty or set to a non-string value. Use `warehouse_remove` then `warehouse_add` to re-configure."
52+
}
53+
if (lower.includes("private key") || lower.includes("decrypt")) {
54+
return "\n\nHow to fix: Key pair authentication failed. Verify: (1) the key file is PEM/PKCS#8 format, (2) the passphrase is correct, (3) the key has not expired, (4) the public key is registered in your warehouse."
55+
}
56+
if (lower.includes("missing") && lower.includes("password")) {
57+
return "\n\nHow to fix: No password was provided. Use `warehouse_remove` then `warehouse_add` to configure credentials. For Snowflake, you can also use key pair or SSO authentication."
58+
}
59+
if (lower.includes("browser") && lower.includes("timed out")) {
60+
return "\n\nHow to fix: SSO browser authentication timed out. Ensure your default browser opened the auth page. If running in a headless environment, switch to password or key pair authentication instead."
61+
}
62+
if (lower.includes("not installed") || lower.includes("cannot find module")) {
63+
return "\n\nHow to fix: The database driver is not installed. Run `npm install` with the appropriate driver package for your database type."
64+
}
65+
if (lower.includes("econnrefused") || lower.includes("enotfound")) {
66+
return "\n\nHow to fix: Cannot reach the database server. Check: (1) the hostname and port are correct, (2) the server is running, (3) any firewalls or VPNs are configured to allow the connection."
67+
}
68+
if (lower.includes("schema") && (lower.includes("does not exist") || lower.includes("not authorized"))) {
69+
return "\n\nHow to fix: The specified schema does not exist or your user lacks access. Check: (1) the schema name is spelled correctly, (2) your user/role has USAGE privilege on the schema."
70+
}
71+
72+
return ""
73+
}
74+
// altimate_change end

packages/opencode/src/session/prompt/anthropic.txt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,27 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre
6969

7070
# Doing tasks
7171
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
72-
-
72+
-
7373
- Use the TodoWrite tool to plan the task if required
7474

75+
<!-- altimate_change start — bias toward action, reduce over-questioning, enforce read-before-edit -->
76+
# Agent behavior rules
77+
78+
## Act first, ask later
79+
- Start working immediately. Do NOT ask clarifying questions unless the request is genuinely ambiguous with multiple incompatible interpretations.
80+
- If you can make a reasonable assumption, make it and state what you assumed. The user can correct you.
81+
- Prefer taking a small action (scan the project, read a file, check the schema) over asking the user what to do.
82+
83+
## Always read before editing
84+
- NEVER call edit, write, or apply_patch on a file you have not read in the current session.
85+
- If apply_patch fails with "Failed to find expected lines", re-read the file immediately and generate a new patch from the fresh content. Do NOT retry the same patch.
86+
- After a file_not_found error on a specific path, do NOT attempt to read that same path again. Move on or ask the user.
87+
88+
## Limit retries
89+
- If a tool call fails twice on the same input, do NOT retry a third time. Instead, explain what went wrong and try an alternative approach.
90+
- If apply_patch or edit fails on the same file twice, re-read the file, then try once more with fresh content. If that also fails, stop and report the issue.
91+
<!-- altimate_change end -->
92+
7593
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
7694

7795

packages/opencode/src/tool/apply_patch.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,30 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
100100
const oldContent = await fs.readFile(filePath, "utf-8")
101101
let newContent = oldContent
102102

103+
// altimate_change start — retry patch application with re-read on mismatch
103104
// Apply the update chunks to get new content
104105
try {
105106
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
106107
newContent = fileUpdate.content
107108
} catch (error) {
108-
throw new Error(`apply_patch verification failed: ${error}`)
109+
// Re-read the file and retry once — the file may have changed since the LLM last saw it
110+
const freshContent = await fs.readFile(filePath, "utf-8")
111+
if (freshContent !== oldContent) {
112+
try {
113+
const retryUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
114+
newContent = retryUpdate.content
115+
} catch (retryError) {
116+
throw new Error(
117+
`apply_patch verification failed: ${retryError}\n\nThe file has been modified since you last read it. Please re-read the file and generate a new patch.`,
118+
)
119+
}
120+
} else {
121+
throw new Error(
122+
`apply_patch verification failed: ${error}\n\nThe expected lines were not found in the file. Please re-read the file and generate a new patch based on its current contents.`,
123+
)
124+
}
109125
}
126+
// altimate_change end
110127

111128
const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
112129

0 commit comments

Comments
 (0)