Skip to content

Commit c73a370

Browse files
committed
improvement(agent): allow variable references in reasoning effort and verbosity
Reasoning Effort and Verbosity were select-only dropdowns, so a workflow could not sweep them from a variable or an upstream block the way it already can with the model. Both become editable comboboxes, matching the model field directly above them and the managed-agent selectors. - switch both subblocks to `combobox`, keeping their fetched per-model option lists intact - keep them visible when `model` itself holds a reference, since the concrete model id is only known at execution time and cannot be matched against the static capability list - normalize the resolved level in the provider chokepoint so a reference that resolves to `"High"` or to nothing behaves sanely instead of hitting a provider 400
1 parent f210a6e commit c73a370

8 files changed

Lines changed: 233 additions & 12 deletions

File tree

apps/sim/blocks/blocks.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
22

33
vi.unmock('@/blocks/registry')
44

5+
import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility'
56
import { generateRouterPrompt } from '@/blocks/blocks/router'
67
import {
78
getAllBlocks,
@@ -842,6 +843,33 @@ describe.concurrent('Blocks Module', () => {
842843
expect(modelSubBlock?.commandSearchable).toBe(true)
843844
})
844845

846+
it('should let the agent reasoning and verbosity fields take a typed reference', () => {
847+
const agentBlock = getBlock('agent')
848+
849+
for (const id of ['reasoningEffort', 'verbosity']) {
850+
const subBlock = agentBlock?.subBlocks.find((sb) => sb.id === id)
851+
// A combobox is editable, so a `<block.output>` / `{{ENV_VAR}}` reference can be
852+
// typed into it; the option list still offers every level the model accepts.
853+
expect(subBlock?.type).toBe('combobox')
854+
expect(typeof subBlock?.condition).toBe('function')
855+
}
856+
})
857+
858+
it('should keep the agent reasoning and verbosity fields visible when the model is a reference', () => {
859+
const agentBlock = getBlock('agent')
860+
861+
for (const id of ['reasoningEffort', 'verbosity']) {
862+
const subBlock = agentBlock?.subBlocks.find((sb) => sb.id === id)
863+
const condition = subBlock?.condition
864+
if (typeof condition !== 'function') throw new Error(`${id} condition is not a function`)
865+
866+
expect(evaluateSubBlockCondition(condition, { model: '<start.model>' })).toBe(true)
867+
expect(evaluateSubBlockCondition(condition, { model: '{{MODEL_ID}}' })).toBe(true)
868+
expect(evaluateSubBlockCondition(condition, { model: 'gpt-5.1' })).toBe(true)
869+
expect(evaluateSubBlockCondition(condition, { model: 'claude-sonnet-5' })).toBe(false)
870+
}
871+
})
872+
845873
it('should hide generator API keys on hosted only for Fal.ai providers', () => {
846874
for (const blockType of ['image_generator_v2', 'video_generator_v3']) {
847875
const block = getBlock(blockType)

apps/sim/blocks/blocks/agent.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { AgentIcon } from '@/components/icons'
33
import type { BlockConfig } from '@/blocks/types'
44
import { AuthMode, IntegrationType } from '@/blocks/types'
55
import {
6+
getModelCapabilityCondition,
67
getModelOptions,
78
getProviderCredentialSubBlocks,
89
normalizeFileInput,
@@ -159,8 +160,8 @@ Return ONLY the JSON array.`,
159160
{
160161
id: 'reasoningEffort',
161162
title: 'Reasoning Effort',
162-
type: 'dropdown',
163-
placeholder: 'Select reasoning effort...',
163+
type: 'combobox',
164+
placeholder: 'Type or select reasoning effort...',
164165
options: [
165166
{ label: 'auto', id: 'auto' },
166167
{ label: 'low', id: 'low' },
@@ -207,16 +208,13 @@ Return ONLY the JSON array.`,
207208
return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
208209
},
209210
mode: 'advanced',
210-
condition: {
211-
field: 'model',
212-
value: MODELS_WITH_REASONING_EFFORT,
213-
},
211+
condition: getModelCapabilityCondition(MODELS_WITH_REASONING_EFFORT),
214212
},
215213
{
216214
id: 'verbosity',
217215
title: 'Verbosity',
218-
type: 'dropdown',
219-
placeholder: 'Select verbosity...',
216+
type: 'combobox',
217+
placeholder: 'Type or select verbosity...',
220218
options: [
221219
{ label: 'auto', id: 'auto' },
222220
{ label: 'low', id: 'low' },
@@ -263,10 +261,7 @@ Return ONLY the JSON array.`,
263261
return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
264262
},
265263
mode: 'advanced',
266-
condition: {
267-
field: 'model',
268-
value: MODELS_WITH_VERBOSITY,
269-
},
264+
condition: getModelCapabilityCondition(MODELS_WITH_VERBOSITY),
270265
},
271266
{
272267
id: 'thinkingLevel',

apps/sim/blocks/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
isOllamaConfigured,
88
} from '@/lib/core/config/env-flags'
99
import { getScopesForService } from '@/lib/oauth/utils'
10+
import { containsReference } from '@/lib/workflows/sanitization/references'
1011
import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility'
1112
import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types'
1213
import {
@@ -235,6 +236,23 @@ function shouldRequireApiKeyForModel(model: string): boolean {
235236
return true
236237
}
237238

239+
/**
240+
* Visibility condition for a model-tuning field that only some models accept, such as
241+
* reasoning effort or verbosity. Gates on the capability list, but keeps the field visible
242+
* when `model` itself holds a variable or block reference — the concrete model id is only
243+
* known at execution time then, so matching a reference against a static list would hide
244+
* the field for every workflow that binds its model dynamically.
245+
*/
246+
export function getModelCapabilityCondition(capableModels: string[]) {
247+
return (values?: Record<string, unknown>) => {
248+
const model = typeof values?.model === 'string' ? values.model : ''
249+
if (containsReference(model)) {
250+
return buildModelVisibilityCondition(model, true)
251+
}
252+
return { field: 'model', value: capableModels }
253+
}
254+
}
255+
238256
/**
239257
* Get the API key condition for provider credential subblocks.
240258
* Handles hosted vs self-hosted environments and excludes providers that don't need API key.

apps/sim/executor/variables/resolver.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1341,3 +1341,50 @@ describe('VariableResolver function context overflow offload', () => {
13411341
expect(result.resolvedInputs.code).toBe('return globals()["__blockRef_0"]')
13421342
})
13431343
})
1344+
1345+
/**
1346+
* The agent block's Reasoning Effort and Verbosity fields are editable comboboxes, so a
1347+
* workflow can bind them to a reference instead of picking a level. These lock in that the
1348+
* generic input resolution actually reaches those two fields.
1349+
*/
1350+
describe('VariableResolver agent model levels', () => {
1351+
it('resolves block, workflow-variable, and env references in reasoning effort and verbosity', async () => {
1352+
const producer = createBlock('producer', 'Producer', BlockType.API)
1353+
const agent = createBlock('agent', 'Agent', BlockType.AGENT, {
1354+
model: 'gpt-5',
1355+
reasoningEffort: '<Producer.result>',
1356+
verbosity: '<variable.Detail>',
1357+
thinkingLevel: '{{THINKING}}',
1358+
})
1359+
const workflow: SerializedWorkflow = {
1360+
version: '1',
1361+
blocks: [producer, agent],
1362+
connections: [],
1363+
loops: {},
1364+
parallels: {},
1365+
}
1366+
1367+
const state = new ExecutionState()
1368+
state.setBlockOutput('producer', { result: 'high' })
1369+
const ctx = {
1370+
blockStates: state.getBlockStates(),
1371+
blockLogs: [],
1372+
environmentVariables: { THINKING: 'medium' },
1373+
workflowVariables: { 'var-1': { id: 'var-1', name: 'Detail', type: 'string', value: 'low' } },
1374+
decisions: { router: new Map(), condition: new Map() },
1375+
loopExecutions: new Map(),
1376+
executedBlocks: new Set(),
1377+
activeExecutionPath: new Set(),
1378+
completedLoops: new Set(),
1379+
metadata: {},
1380+
} as unknown as ExecutionContext
1381+
1382+
const resolver = new VariableResolver(workflow, { THINKING: 'medium' }, state)
1383+
const result = await resolver.resolveInputs(ctx, 'agent', agent.config.params, agent)
1384+
1385+
expect(result.reasoningEffort).toBe('high')
1386+
expect(result.verbosity).toBe('low')
1387+
expect(result.thinkingLevel).toBe('medium')
1388+
expect(result.model).toBe('gpt-5')
1389+
})
1390+
})

apps/sim/lib/workflows/sanitization/references.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest'
22
import {
3+
containsReference,
34
isLikelyReferenceSegment,
45
splitReferenceSegment,
56
} from '@/lib/workflows/sanitization/references'
@@ -53,3 +54,37 @@ describe('isLikelyReferenceSegment', () => {
5354
expect(isLikelyReferenceSegment('<123>')).toBe(false)
5455
})
5556
})
57+
58+
describe('containsReference', () => {
59+
it('detects block and variable references', () => {
60+
expect(containsReference('<start.input>')).toBe(true)
61+
expect(containsReference('<variable.model>')).toBe(true)
62+
expect(containsReference('<loop.index>')).toBe(true)
63+
})
64+
65+
it('detects environment variable placeholders', () => {
66+
expect(containsReference('{{MODEL_ID}}')).toBe(true)
67+
})
68+
69+
it('detects a reference embedded in surrounding text', () => {
70+
expect(containsReference('gpt-<start.suffix>')).toBe(true)
71+
})
72+
73+
it('returns false for literal model ids', () => {
74+
expect(containsReference('gpt-5.1')).toBe(false)
75+
expect(containsReference('claude-sonnet-5')).toBe(false)
76+
expect(containsReference('azure/gpt-5.1-codex')).toBe(false)
77+
})
78+
79+
it('returns false for empty and non-string values', () => {
80+
expect(containsReference('')).toBe(false)
81+
expect(containsReference(undefined)).toBe(false)
82+
expect(containsReference(null)).toBe(false)
83+
expect(containsReference(42)).toBe(false)
84+
})
85+
86+
it('returns false for stray brackets that are not references', () => {
87+
expect(containsReference('a < b')).toBe(false)
88+
expect(containsReference('<123>')).toBe(false)
89+
})
90+
})

apps/sim/lib/workflows/sanitization/references.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@ export function isLikelyReferenceSegment(segment: string): boolean {
7777
return true
7878
}
7979

80+
const ENV_VAR_PATTERN = new RegExp(`\\${REFERENCE.ENV_VAR_START}[^}]+\\${REFERENCE.ENV_VAR_END}`)
81+
82+
/**
83+
* Whether a subblock value carries a `<block.path>` / `<variable.name>` reference or a
84+
* `{{ENV_VAR}}` placeholder instead of a literal value — i.e. its real value is only known
85+
* once the workflow runs. Conditions that gate one field on a sibling's literal value use
86+
* this to stay visible while the sibling is bound dynamically.
87+
*/
88+
export function containsReference(value: unknown): boolean {
89+
if (typeof value !== 'string' || !value) {
90+
return false
91+
}
92+
return extractReferencePrefixes(value).length > 0 || ENV_VAR_PATTERN.test(value)
93+
}
94+
8095
export function extractReferencePrefixes(value: string): Array<{ raw: string; prefix: string }> {
8196
if (!value || typeof value !== 'string') {
8297
return []

apps/sim/providers/index.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -412,3 +412,70 @@ describe('executeProviderRequest — streaming cost policy', () => {
412412
})
413413
})
414414
})
415+
416+
/**
417+
* `reasoningEffort` and `verbosity` can be bound to a variable or block reference in the
418+
* agent block, so by the time they reach the provider they hold whatever that reference
419+
* resolved to rather than a value picked from a list.
420+
*/
421+
describe('executeProviderRequest — model level normalization', () => {
422+
beforeEach(() => {
423+
vi.clearAllMocks()
424+
mockGetApiKeyWithBYOK.mockResolvedValue({ apiKey: 'sk-rotating', isBYOK: false })
425+
mockExecuteRequest.mockResolvedValue({
426+
content: 'hi',
427+
model: 'gpt-5',
428+
tokens: { input: 1, output: 1, total: 2 },
429+
} as ProviderResponse)
430+
})
431+
432+
const sentRequest = () => mockExecuteRequest.mock.calls[0][0] as Record<string, unknown>
433+
434+
it('trims and lower-cases levels a reference resolved to', async () => {
435+
await executeProviderRequest('openai', {
436+
model: 'gpt-5',
437+
workspaceId: 'ws-1',
438+
reasoningEffort: ' High ',
439+
verbosity: 'LOW',
440+
})
441+
442+
expect(sentRequest().reasoningEffort).toBe('high')
443+
expect(sentRequest().verbosity).toBe('low')
444+
})
445+
446+
it('treats a level that resolved to nothing as unset rather than an empty string', async () => {
447+
await executeProviderRequest('openai', {
448+
model: 'gpt-5',
449+
workspaceId: 'ws-1',
450+
reasoningEffort: '',
451+
verbosity: ' ',
452+
})
453+
454+
expect(sentRequest().reasoningEffort).toBeUndefined()
455+
expect(sentRequest().verbosity).toBeUndefined()
456+
})
457+
458+
it('leaves an already-valid level untouched', async () => {
459+
await executeProviderRequest('openai', {
460+
model: 'gpt-5',
461+
workspaceId: 'ws-1',
462+
reasoningEffort: 'medium',
463+
verbosity: 'high',
464+
})
465+
466+
expect(sentRequest().reasoningEffort).toBe('medium')
467+
expect(sentRequest().verbosity).toBe('high')
468+
})
469+
470+
it('still drops levels the resolved model does not support', async () => {
471+
await executeProviderRequest('anthropic', {
472+
model: 'claude-opus-4-6',
473+
workspaceId: 'ws-1',
474+
reasoningEffort: 'high',
475+
verbosity: 'high',
476+
})
477+
478+
expect(sentRequest().reasoningEffort).toBeUndefined()
479+
expect(sentRequest().verbosity).toBeUndefined()
480+
})
481+
})

apps/sim/providers/index.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,26 @@ const logger = createLogger('Providers')
3939
*/
4040
export const MAX_TOOL_ITERATIONS = 20
4141

42+
/**
43+
* Normalizes a model-tuning level that may have arrived from a variable or block reference
44+
* rather than a picker. Every level a model declares is lower-case, so trimming and
45+
* lower-casing lets a reference resolve to `"High"` or `" high "` and still apply. A level
46+
* that resolves to nothing becomes `undefined` so the field reads as untouched instead of
47+
* sending an empty string the provider rejects.
48+
*/
49+
function normalizeModelLevel(value: string | undefined): string | undefined {
50+
if (typeof value !== 'string') return undefined
51+
const normalized = value.trim().toLowerCase()
52+
return normalized || undefined
53+
}
54+
4255
function sanitizeRequest(request: ProviderRequest): ProviderRequest {
4356
const sanitizedRequest = { ...request }
4457
const model = sanitizedRequest.model
4558

59+
sanitizedRequest.reasoningEffort = normalizeModelLevel(sanitizedRequest.reasoningEffort)
60+
sanitizedRequest.verbosity = normalizeModelLevel(sanitizedRequest.verbosity)
61+
4662
if (model && !supportsTemperature(model)) {
4763
sanitizedRequest.temperature = undefined
4864
}

0 commit comments

Comments
 (0)