Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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