Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/docs/data-engineering/agent-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ Refinements are capped at **5 revisions per session** to avoid endless loops. Af

### Example conversation

```
```text
You: Plan a migration of our raw_events table from a view to an incremental model

Plan: Here's my proposed approach:
Expand Down
46 changes: 30 additions & 16 deletions packages/drivers/src/mongodb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,10 +187,7 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
if (val instanceof Date) {
return val.toISOString()
}
// Arrays and plain objects — JSON-serialize for tabular display
if (Array.isArray(val) || typeof (val as any).toJSON !== "function") {
return JSON.stringify(val)
}
// Arrays, plain objects, and remaining BSON types — JSON-serialize for tabular display
return JSON.stringify(val)
}

Expand Down Expand Up @@ -333,20 +330,37 @@ export async function connect(config: ConnectionConfig): Promise<Connector> {
if (!parsed.pipeline || !Array.isArray(parsed.pipeline)) {
throw new Error("aggregate requires a 'pipeline' array")
}
// Cap or append $limit to prevent OOM. Skip for $out/$merge write pipelines.
// Block dangerous stages/operators:
// - $out/$merge: write operations (top-level stage keys)
// - $function/$accumulator: arbitrary JS execution (can be nested in expressions)
const pipeline = [...parsed.pipeline]
const hasWrite = pipeline.some((stage) => "$out" in stage || "$merge" in stage)
if (!hasWrite) {
const limitIdx = pipeline.findIndex((stage) => "$limit" in stage)
if (limitIdx >= 0) {
// Cap user-specified $limit against effectiveLimit
const userLimit = (pipeline[limitIdx] as any).$limit
if (typeof userLimit === "number" && userLimit > effectiveLimit) {
pipeline[limitIdx] = { $limit: effectiveLimit + 1 }
}
} else {
pipeline.push({ $limit: effectiveLimit + 1 })
const blockedWriteStages = ["$out", "$merge"]
const hasBlockedWrite = pipeline.some((stage) =>
blockedWriteStages.some((s) => s in stage),
)
if (hasBlockedWrite) {
throw new Error(
`Pipeline contains a blocked write stage (${blockedWriteStages.join(", ")}). Write operations are not allowed.`,
)
}
// $function/$accumulator can appear nested inside $project, $addFields, $group, etc.
// Stringify and scan to catch them at any depth.
const pipelineStr = JSON.stringify(pipeline)
if (pipelineStr.includes('"$function"') || pipelineStr.includes('"$accumulator"')) {
throw new Error(
"Pipeline contains a blocked operator ($function, $accumulator). Executing arbitrary JavaScript is not allowed.",
)
}
// Cap or append $limit to prevent OOM (write stages already blocked above).
const limitIdx = pipeline.findIndex((stage) => "$limit" in stage)
if (limitIdx >= 0) {
// Cap user-specified $limit against effectiveLimit
const userLimit = (pipeline[limitIdx] as any).$limit
if (typeof userLimit === "number" && userLimit > effectiveLimit) {
pipeline[limitIdx] = { $limit: effectiveLimit + 1 }
}
} else {
pipeline.push({ $limit: effectiveLimit + 1 })
}

const docs = await coll.aggregate(pipeline).toArray()
Expand Down
9 changes: 7 additions & 2 deletions packages/opencode/src/altimate/tools/mcp-discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@ async function getPersistedMcpNames(): Promise<Set<string>> {
/** Redact server details for safe display — show type and name only, not commands/URLs */
function safeDetail(server: { type: string } & Record<string, any>): string {
if (server.type === "remote") return "(remote)"
if (server.type === "local" && Array.isArray(server.command) && server.command.length > 0) {
if (server.type === "local") {
// Show only the executable name, not args (which may contain credentials)
return `(local: ${server.command[0]})`
if (Array.isArray(server.command) && server.command.length > 0) {
return `(local: ${server.command[0]})`
}
if (typeof server.command === "string" && server.command.trim()) {
return `(local: ${server.command.trim().split(/\s+/)[0]})`
}
}
return `(${server.type})`
}
Expand Down
21 changes: 13 additions & 8 deletions packages/opencode/src/altimate/tools/post-connect-suggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,19 @@ export namespace PostConnectSuggestions {
)
}

suggestions.push(
"Run SQL queries against your " +
ctx.warehouseType +
" warehouse using sql_execute",
)
suggestions.push(
"Analyze SQL quality and find potential issues with sql_analyze",
)
// MongoDB uses MQL, not SQL — skip SQL-specific suggestions
const nonSqlWarehouses = ["mongodb", "mongo"]
const isMongo = nonSqlWarehouses.includes(ctx.warehouseType.toLowerCase())
if (!isMongo) {
suggestions.push(
"Run SQL queries against your " +
ctx.warehouseType +
" warehouse using sql_execute",
)
suggestions.push(
"Analyze SQL quality and find potential issues with sql_analyze",
)
}

if (ctx.dbtDetected) {
suggestions.push(
Expand Down
20 changes: 14 additions & 6 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export namespace SessionPrompt {
// altimate_change start — plan refinement tracking
let planRevisionCount = 0
let planHasWritten = false
let planLastUserMsgId: string | undefined
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This still counts non-feedback turns as plan revisions.

planLastUserMsgId starts unset, so once the plan file is first created the next internal loop will treat the same user prompt as a new revision. It also pulls currentUserMsgId from the last user message of any kind, so the synthetic user turns created on Lines 571-589 can increment the counter again. That makes revision_number telemetry drift and can hit the 5-revision cap early. Seed the tracker when the plan file first appears, and derive it from the last user message with a non-synthetic text part.

Also applies to: 641-646

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/src/session/prompt.ts` at line 326, The tracker variable
planLastUserMsgId is currently unset and later updated from the last user
message of any kind, causing synthetic/internal user turns to be counted as new
plan revisions; modify the logic that initializes/updates planLastUserMsgId (the
variable declared as planLastUserMsgId) so that when the plan file is first
detected you seed planLastUserMsgId from the most recent user message that
contains a non-synthetic text part (i.e., skip synthetic user turns created in
the synthetic-user-turn creation block around Lines 571-589), and apply the same
fix to the analogous update logic around Lines 641-646 so only genuine user text
messages move currentUserMsgId/planLastUserMsgId and increment revision_number.

// altimate_change end
let emergencySessionEndFired = false
// altimate_change start — quality signal, tool chain, error fingerprint tracking
Expand Down Expand Up @@ -637,10 +638,12 @@ export namespace SessionPrompt {
const planPath = Session.plan(session)
planHasWritten = await Filesystem.exists(planPath)
}
// If plan was already written and user sent a new message, this is a refinement
if (planHasWritten && step > 1) {
// Detect approval phrases in the last user message text
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
// If plan was already written and user sent a new message, this is a refinement.
// Only count once per user message (not on internal loop iterations).
const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
const currentUserMsgId = lastUserMsg?.info.id
if (planHasWritten && step > 1 && currentUserMsgId && currentUserMsgId !== planLastUserMsgId) {
planLastUserMsgId = currentUserMsgId
const userText = lastUserMsg?.parts
.filter((p): p is MessageV2.TextPart => p.type === "text" && !("synthetic" in p && p.synthetic))
.map((p) => p.text.toLowerCase())
Expand Down Expand Up @@ -678,7 +681,7 @@ export namespace SessionPrompt {
const refinementQualifiers = [" but ", " however ", " except ", " change ", " modify ", " update ", " instead ", " although ", " with the following", " with these"]
const hasRefinementQualifier = refinementQualifiers.some((q) => userText.includes(q))

const rejectionPhrases = ["don't", "stop", "reject", "not good", "undo", "abort", "start over", "wrong"]
const rejectionPhrases = ["don't", "stop", "reject", "not good", "not approve", "not approved", "disapprove", "undo", "abort", "start over", "wrong"]
// "no" as a standalone word to avoid matching "know", "notion", etc.
const rejectionWords = ["no"]
const approvalPhrases = ["looks good", "proceed", "approved", "approve", "lgtm", "go ahead", "ship it", "yes", "perfect"]
Expand All @@ -689,7 +692,12 @@ export namespace SessionPrompt {
return regex.test(userText)
})
const isRejection = isRejectionPhrase || isRejectionWord
const isApproval = !isRejection && !hasRefinementQualifier && approvalPhrases.some((phrase) => userText.includes(phrase))
// Use word-boundary matching for approval phrases to avoid false positives
// e.g. "this doesn't look good" should NOT match "looks good"
const isApproval = !isRejection && !hasRefinementQualifier && approvalPhrases.some((phrase) => {
const regex = new RegExp(`\\b${phrase.replace(/\s+/g, "\\s+")}\\b`, "i")
return regex.test(userText)
})
const action = isRejection ? "reject" : isApproval ? "approve" : "refine"
Telemetry.track({
type: "plan_revision",
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/tool/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const PlanExitTool = Tool.define("plan_exit", {
})

const answer = answers[0]?.[0]
if (answer === "No") throw new Question.RejectedError()
if (answer !== "Yes") throw new Question.RejectedError()

const model = await getLastModel(ctx.sessionID)

Expand Down Expand Up @@ -97,7 +97,7 @@ export const PlanEnterTool = Tool.define("plan_enter", {

const answer = answers[0]?.[0]

if (answer === "No") throw new Question.RejectedError()
if (answer !== "Yes") throw new Question.RejectedError()

const model = await getLastModel(ctx.sessionID)

Expand Down
10 changes: 4 additions & 6 deletions packages/opencode/test/altimate/plan-refinement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,10 @@ describe("plan_revision telemetry", () => {
const promptTsPath = path.join(__dirname, "../../src/session/prompt.ts")
const content = await fs.readFile(promptTsPath, "utf-8")

// Extract region around plan_revision telemetry — generous window
const startIdx = content.indexOf('type: "plan_revision"')
expect(startIdx).toBeGreaterThan(-1)
const regionStart = Math.max(0, startIdx - 200)
const regionEnd = Math.min(content.length, startIdx + 400)
const trackBlock = content.slice(regionStart, regionEnd)
// Find the Telemetry.track({ ... }) block containing plan_revision
const trackMatch = content.match(/Telemetry\.track\(\{[^}]*type:\s*"plan_revision"[^}]*\}\)/s)
expect(trackMatch).not.toBeNull()
const trackBlock = trackMatch![0]
expect(trackBlock).toContain("timestamp:")
expect(trackBlock).toContain("session_id:")
expect(trackBlock).toContain("revision_number:")
Expand Down
Loading