Skip to content

Commit 532cbb6

Browse files
committed
fix(wand): stop markdown code fences landing in generated code
Strip fences from wand output for raw-value generation types, reset the conversation history when a Function block switches language, and give the Python prompt the worked example the JavaScript one already had.
1 parent 53c3bea commit 532cbb6

5 files changed

Lines changed: 241 additions & 7 deletions

File tree

apps/sim/app/api/wand/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,13 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
328328
'\n\nIMPORTANT: Return ONLY the raw cron expression (e.g., "0 9 * * 1-5"). Do NOT wrap it in markdown code blocks, backticks, or quotes. Do NOT include any explanation or text before or after the expression.'
329329
}
330330

331+
// Both the JavaScript and Python function-body prompts share this type, so
332+
// the reinforcement stays language-neutral.
333+
if (generationType === 'javascript-function-body') {
334+
finalSystemPrompt +=
335+
'\n\nIMPORTANT: Return ONLY the raw function body. Do NOT wrap it in markdown code blocks (no ```javascript, no ```python, no ```). Do NOT include any explanation before or after the code.'
336+
}
337+
331338
if (generationType === 'json-object') {
332339
finalSystemPrompt +=
333340
'\n\nIMPORTANT: Return ONLY the raw JSON object. Do NOT wrap it in markdown code blocks (no ```json or ```). Do NOT include any explanation or text before or after the JSON. The response must start with { and end with }.'

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/code/code.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,32 @@ IMPORTANT FORMATTING RULES:
6767
1. Reference Environment Variables: Use the exact syntax {{VARIABLE_NAME}}. Do NOT wrap it in quotes.
6868
2. Reference Input Parameters/Workflow Variables: Use the exact syntax <variable_name>. Do NOT wrap it in quotes.
6969
3. Function Body ONLY: Do NOT include the function signature (e.g., 'def my_func(...)') or surrounding braces. Return the final value with 'return'.
70-
4. Imports: You may add imports as needed (standard library or pip-installed packages) without comments.
70+
4. Imports: The Python standard library is always available. Third-party packages are available ONLY when the block has a sandbox selected — the sandbox's package list is appended below when one is. Never import a package that is not on that list.
7171
5. No Markdown: Do NOT include backticks, code fences, or any markdown.
72-
6. Clarity: Write clean, readable Python code.`
72+
6. Clarity: Write clean, readable Python code.
73+
7. No Explanations: Output the raw Python code only — no prose before or after it.
74+
75+
Example Scenario:
76+
User Prompt: "Fetch user data from an API. Use the User ID passed in as 'userId' and an API Key stored as the 'SERVICE_API_KEY' environment variable."
77+
78+
Generated Code:
79+
import json
80+
import urllib.error
81+
import urllib.request
82+
83+
user_id = <userId> # Correct: accessing an input parameter without quotes
84+
api_key = {{SERVICE_API_KEY}} # Correct: accessing an environment variable without quotes
85+
url = f"https://api.example.com/users/{user_id}"
86+
87+
request = urllib.request.Request(url, headers={"Authorization": f"Bearer {api_key}"})
88+
89+
try:
90+
with urllib.request.urlopen(request) as response:
91+
# Return the fetched data, which becomes the block's output
92+
return json.loads(response.read().decode())
93+
except urllib.error.HTTPError as error:
94+
# Raising marks the block execution as failed
95+
raise Exception(f"API request failed with status {error.code}: {error.read().decode()}")`
7396

7497
/**
7598
* Line height constant for consistent rendering.
@@ -330,6 +353,9 @@ export const Code = memo(function Code({
330353
tableId: typeof tableIdValue === 'string' ? tableIdValue : null,
331354
sandboxId: typeof sandboxIdValue === 'string' ? sandboxIdValue : null,
332355
},
356+
// Keyed off the same value that swaps the prompt below, so history from the
357+
// previous language cannot steer the next generation back to it.
358+
historyResetKey: typeof languageValue === 'string' ? languageValue : undefined,
333359
onStreamStart: () => handleStreamStartRef.current?.(),
334360
onStreamChunk: (chunk: string) => handleStreamChunkRef.current?.(chunk),
335361
onGeneratedContent: (content: string) => handleGeneratedContentRef.current?.(content),

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-wand.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { requestRaw } from '@/lib/api/client'
88
import { isApiClientError } from '@/lib/api/client/errors'
99
import { wandGenerateStreamContract } from '@/lib/api/contracts'
1010
import { readSSEStream } from '@/lib/core/utils/sse'
11+
import { shouldStripCodeFences, stripCodeFences } from '@/lib/wand/strip-code-fences'
1112
import type { GenerationType } from '@/blocks/types'
1213
import { subscriptionKeys } from '@/hooks/queries/subscription'
1314
import { useSettingsNavigation } from '@/hooks/use-settings-navigation'
@@ -100,6 +101,13 @@ interface UseWandProps {
100101
wandConfig?: WandConfig
101102
currentValue?: string
102103
contextParams?: WandContextParams
104+
/**
105+
* Clears the conversation history whenever this value changes. Pass anything
106+
* that invalidates prior turns — a Function block switching language rewrites
107+
* `wandConfig.prompt`, but replayed history would keep steering the model back
108+
* to the previous language.
109+
*/
110+
historyResetKey?: string
103111
onGeneratedContent: (content: string) => void
104112
onStreamChunk?: (chunk: string) => void
105113
onStreamStart?: () => void
@@ -110,6 +118,7 @@ export function useWand({
110118
wandConfig,
111119
currentValue,
112120
contextParams,
121+
historyResetKey,
113122
onGeneratedContent,
114123
onStreamChunk,
115124
onStreamStart,
@@ -127,6 +136,18 @@ export function useWand({
127136

128137
const [conversationHistory, setConversationHistory] = useState<ChatMessage[]>([])
129138

139+
/**
140+
* Adjusted during render rather than in an effect so a generation started in
141+
* the same commit as the change can never send the stale history. History is
142+
* already empty on mount, so seeding the tracker with the current key
143+
* correctly makes the first render a no-op.
144+
*/
145+
const [prevHistoryResetKey, setPrevHistoryResetKey] = useState(historyResetKey)
146+
if (prevHistoryResetKey !== historyResetKey) {
147+
setPrevHistoryResetKey(historyResetKey)
148+
setConversationHistory([])
149+
}
150+
130151
const abortControllerRef = useRef<AbortController | null>(null)
131152

132153
const showPromptInline = useCallback(() => {
@@ -224,25 +245,38 @@ export function useWand({
224245
signal: abortControllerRef.current?.signal,
225246
})
226247

227-
if (accumulatedContent) {
228-
onGeneratedContent(accumulatedContent)
248+
/**
249+
* Sanitized once the full response is known, then written back over the
250+
* streamed text. Doing it per-chunk would mean guessing whether a
251+
* trailing backtick run opens a fence or is part of the code, so the
252+
* editor may briefly show a fence that the final value does not.
253+
*/
254+
const generatedContent = shouldStripCodeFences(wandConfig?.generationType)
255+
? stripCodeFences(accumulatedContent)
256+
: accumulatedContent
257+
258+
if (generatedContent) {
259+
onGeneratedContent(generatedContent)
229260

230261
if (wandConfig?.maintainHistory) {
262+
// The sanitized form goes into history so a single fenced reply
263+
// cannot become the in-context example for every later turn.
231264
setConversationHistory((prev) => [
232265
...prev,
233266
{ role: 'user', content: currentPrompt },
234-
{ role: 'assistant', content: accumulatedContent },
267+
{ role: 'assistant', content: generatedContent },
235268
])
236269
}
237270

238271
if (onGenerationComplete) {
239-
onGenerationComplete(currentPrompt, accumulatedContent)
272+
onGenerationComplete(currentPrompt, generatedContent)
240273
}
241274
}
242275

243276
logger.debug('Wand generation completed', {
244277
prompt,
245-
contentLength: accumulatedContent.length,
278+
contentLength: generatedContent.length,
279+
strippedFences: generatedContent !== accumulatedContent,
246280
})
247281

248282
setTimeout(() => {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { shouldStripCodeFences, stripCodeFences } from '@/lib/wand/strip-code-fences'
6+
7+
describe('stripCodeFences', () => {
8+
it('leaves unfenced code untouched', () => {
9+
const code = 'const total = <a> + <b>;\nreturn total;'
10+
expect(stripCodeFences(code)).toBe(code)
11+
})
12+
13+
it('unwraps a fully wrapped response', () => {
14+
expect(stripCodeFences('```python\nresult = <num1> + <num2>\nreturn result\n```')).toBe(
15+
'result = <num1> + <num2>\nreturn result'
16+
)
17+
})
18+
19+
it('unwraps a response with no closing fence', () => {
20+
expect(stripCodeFences('```javascript\nconst x = 1;\nreturn x;')).toBe(
21+
'const x = 1;\nreturn x;'
22+
)
23+
})
24+
25+
it('unwraps an untagged fence', () => {
26+
expect(stripCodeFences('```\nreturn 1;\n```')).toBe('return 1;')
27+
})
28+
29+
it('tolerates leading whitespace before the opening fence', () => {
30+
expect(stripCodeFences('\n ```python\nreturn 1\n```')).toBe('return 1')
31+
})
32+
33+
it('preserves indentation inside the fence', () => {
34+
const fenced = '```python\nif <flag>:\n return "yes"\nreturn "no"\n```'
35+
expect(stripCodeFences(fenced)).toBe('if <flag>:\n return "yes"\nreturn "no"')
36+
})
37+
38+
it('keeps every fenced region and drops prose between them', () => {
39+
const fenced = '```js\nconst a = 1;\n```\nThen send it:\n```js\nreturn a;\n```'
40+
expect(stripCodeFences(fenced)).toBe('const a = 1;\nreturn a;')
41+
})
42+
43+
it('does not touch code that merely contains a fence later', () => {
44+
const code = 'const doc = `\n```json\n{"a":1}\n```\n`;\nreturn doc;'
45+
expect(stripCodeFences(code)).toBe(code)
46+
})
47+
48+
it('returns the original when stripping would leave nothing', () => {
49+
const empty = '```python\n```'
50+
expect(stripCodeFences(empty)).toBe(empty)
51+
})
52+
53+
it('is idempotent', () => {
54+
const once = stripCodeFences('```python\nreturn <x>\n```')
55+
expect(stripCodeFences(once)).toBe(once)
56+
})
57+
58+
it('handles an empty string', () => {
59+
expect(stripCodeFences('')).toBe('')
60+
})
61+
})
62+
63+
describe('shouldStripCodeFences', () => {
64+
it('strips for code and structured value types', () => {
65+
expect(shouldStripCodeFences('javascript-function-body')).toBe(true)
66+
expect(shouldStripCodeFences('custom-tool-schema')).toBe(true)
67+
expect(shouldStripCodeFences('json-object')).toBe(true)
68+
expect(shouldStripCodeFences('cron-expression')).toBe(true)
69+
})
70+
71+
it('does not strip free-form prose', () => {
72+
expect(shouldStripCodeFences('system-prompt')).toBe(false)
73+
})
74+
75+
it('does not strip when no generation type is declared', () => {
76+
expect(shouldStripCodeFences(undefined)).toBe(false)
77+
expect(shouldStripCodeFences('')).toBe(false)
78+
})
79+
80+
it('does not strip an unrecognized type', () => {
81+
expect(shouldStripCodeFences('something-new')).toBe(false)
82+
})
83+
})
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { GenerationType } from '@/blocks/types'
2+
3+
/** A markdown fence delimiter at the start of a line, ignoring indentation. */
4+
const FENCE_LINE = /^\s*```/
5+
6+
/**
7+
* Whether a wand generation's output is a raw machine value, where a leading
8+
* markdown fence is always wrong and must be removed.
9+
*
10+
* Declared as a total `Record` so adding a `GenerationType` fails the build
11+
* until the new type opts in or out deliberately — a silent default would let a
12+
* prose type start stripping fences (or a code type stop) without review.
13+
*
14+
* `system-prompt` is the sole exclusion: it is free-form prose for a model, so a
15+
* fenced example inside it is legitimate authored content, not a formatting slip.
16+
*/
17+
const STRIPS_CODE_FENCES: Record<GenerationType, boolean> = {
18+
'javascript-function-body': true,
19+
'typescript-function-body': true,
20+
'json-schema': true,
21+
'json-object': true,
22+
'table-schema': true,
23+
'system-prompt': false,
24+
'custom-tool-schema': true,
25+
'sql-query': true,
26+
postgrest: true,
27+
'mongodb-filter': true,
28+
'mongodb-pipeline': true,
29+
'mongodb-sort': true,
30+
'mongodb-documents': true,
31+
'mongodb-update': true,
32+
'neo4j-cypher': true,
33+
'neo4j-parameters': true,
34+
timestamp: true,
35+
timezone: true,
36+
'cron-expression': true,
37+
'odata-expression': true,
38+
}
39+
40+
/**
41+
* Whether generated content for this type should have markdown fences stripped.
42+
*
43+
* An absent type means the field's `wandConfig` never declared one, which is the
44+
* case for free-form prose fields — those are left untouched.
45+
*/
46+
export function shouldStripCodeFences(generationType?: string): boolean {
47+
if (!generationType) return false
48+
return STRIPS_CODE_FENCES[generationType as GenerationType] === true
49+
}
50+
51+
/**
52+
* Removes the markdown code fences a model wrapped around a raw value.
53+
*
54+
* Applies only when the response *opens* with a fence. Content that merely
55+
* contains a fence later is left untouched, because a backtick run inside a
56+
* template literal or a docstring is valid code that must survive verbatim —
57+
* a false positive here would corrupt working code, which is far worse than
58+
* leaving a rare unwrapped response for the user to fix.
59+
*
60+
* Within a fenced response every fenced region is kept and everything between
61+
* them is dropped: text outside a fence is prose, which is never valid code.
62+
* Falls back to the original text if stripping would leave nothing.
63+
*/
64+
export function stripCodeFences(text: string): string {
65+
if (!text.trimStart().startsWith('```')) return text
66+
67+
const collected: string[] = []
68+
let insideFence = false
69+
70+
for (const line of text.split('\n')) {
71+
if (FENCE_LINE.test(line)) {
72+
insideFence = !insideFence
73+
continue
74+
}
75+
if (insideFence) collected.push(line)
76+
}
77+
78+
// Trim blank lines only — leading whitespace on a kept line is indentation,
79+
// which is load-bearing in Python.
80+
while (collected.length > 0 && collected[0].trim() === '') collected.shift()
81+
while (collected.length > 0 && collected[collected.length - 1].trim() === '') collected.pop()
82+
83+
return collected.length > 0 ? collected.join('\n') : text
84+
}

0 commit comments

Comments
 (0)