Skip to content

Commit 034aec7

Browse files
committed
improvement(agent): allow variable references in thinking level
Extends the same treatment to Thinking Level so all three model-tuning fields behave consistently, and logs a level a model does not declare. - switch `thinkingLevel` to `combobox` with the reference-aware condition - normalize it alongside the other two; an empty resolve now takes the deliberate "send nothing" path rather than the incoherent half-state it hit before, and stays distinct from an explicit `none` - warn when a level is not one the model declares, still forwarding it: Sim's per-model lists drive the pickers and can lag a provider, and a sweep needs the provider's own error rather than a silent fallback to the default
1 parent c73a370 commit 034aec7

4 files changed

Lines changed: 128 additions & 15 deletions

File tree

apps/sim/blocks/blocks.test.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -843,10 +843,17 @@ describe.concurrent('Blocks Module', () => {
843843
expect(modelSubBlock?.commandSearchable).toBe(true)
844844
})
845845

846-
it('should let the agent reasoning and verbosity fields take a typed reference', () => {
846+
/** Each model-tuning field with a model that accepts it and one that does not. */
847+
const AGENT_MODEL_LEVEL_FIELDS = [
848+
{ id: 'reasoningEffort', capable: 'gpt-5.1', incapable: 'claude-sonnet-5' },
849+
{ id: 'verbosity', capable: 'gpt-5.1', incapable: 'claude-sonnet-5' },
850+
{ id: 'thinkingLevel', capable: 'claude-sonnet-5', incapable: 'gpt-5.1' },
851+
] as const
852+
853+
it('should let the agent model-tuning fields take a typed reference', () => {
847854
const agentBlock = getBlock('agent')
848855

849-
for (const id of ['reasoningEffort', 'verbosity']) {
856+
for (const { id } of AGENT_MODEL_LEVEL_FIELDS) {
850857
const subBlock = agentBlock?.subBlocks.find((sb) => sb.id === id)
851858
// A combobox is editable, so a `<block.output>` / `{{ENV_VAR}}` reference can be
852859
// typed into it; the option list still offers every level the model accepts.
@@ -855,18 +862,19 @@ describe.concurrent('Blocks Module', () => {
855862
}
856863
})
857864

858-
it('should keep the agent reasoning and verbosity fields visible when the model is a reference', () => {
865+
it('should keep the agent model-tuning fields visible when the model is a reference', () => {
859866
const agentBlock = getBlock('agent')
860867

861-
for (const id of ['reasoningEffort', 'verbosity']) {
868+
for (const { id, capable, incapable } of AGENT_MODEL_LEVEL_FIELDS) {
862869
const subBlock = agentBlock?.subBlocks.find((sb) => sb.id === id)
863870
const condition = subBlock?.condition
864871
if (typeof condition !== 'function') throw new Error(`${id} condition is not a function`)
865872

866873
expect(evaluateSubBlockCondition(condition, { model: '<start.model>' })).toBe(true)
867874
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)
875+
// Gating on the capability list is unchanged for a literal model.
876+
expect(evaluateSubBlockCondition(condition, { model: capable })).toBe(true)
877+
expect(evaluateSubBlockCondition(condition, { model: incapable })).toBe(false)
870878
}
871879
})
872880

apps/sim/blocks/blocks/agent.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,8 @@ Return ONLY the JSON array.`,
266266
{
267267
id: 'thinkingLevel',
268268
title: 'Thinking Level',
269-
type: 'dropdown',
270-
placeholder: 'Select thinking level...',
269+
type: 'combobox',
270+
placeholder: 'Type or select thinking level...',
271271
options: [
272272
{ label: 'none', id: 'none' },
273273
{ label: 'minimal', id: 'minimal' },
@@ -301,10 +301,7 @@ Return ONLY the JSON array.`,
301301
return [noneOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
302302
},
303303
mode: 'advanced',
304-
condition: {
305-
field: 'model',
306-
value: MODELS_WITH_THINKING,
307-
},
304+
condition: getModelCapabilityCondition(MODELS_WITH_THINKING),
308305
},
309306
{
310307
id: 'promptCaching',

apps/sim/providers/index.test.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -414,9 +414,9 @@ describe('executeProviderRequest — streaming cost policy', () => {
414414
})
415415

416416
/**
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.
417+
* `reasoningEffort`, `verbosity`, and `thinkingLevel` can be bound to a variable or block
418+
* reference in the agent block, so by the time they reach the provider they hold whatever
419+
* that reference resolved to rather than a value picked from a list.
420420
*/
421421
describe('executeProviderRequest — model level normalization', () => {
422422
beforeEach(() => {
@@ -443,6 +443,16 @@ describe('executeProviderRequest — model level normalization', () => {
443443
expect(sentRequest().verbosity).toBe('low')
444444
})
445445

446+
it('trims and lower-cases a thinking level a reference resolved to', async () => {
447+
await executeProviderRequest('anthropic', {
448+
model: 'claude-sonnet-5',
449+
workspaceId: 'ws-1',
450+
thinkingLevel: ' High ',
451+
})
452+
453+
expect(sentRequest().thinkingLevel).toBe('high')
454+
})
455+
446456
it('treats a level that resolved to nothing as unset rather than an empty string', async () => {
447457
await executeProviderRequest('openai', {
448458
model: 'gpt-5',
@@ -455,6 +465,30 @@ describe('executeProviderRequest — model level normalization', () => {
455465
expect(sentRequest().verbosity).toBeUndefined()
456466
})
457467

468+
/**
469+
* Providers treat an explicit `'none'` as "thinking off" and an absent value as "send
470+
* nothing", so a reference that resolved to nothing must land on the latter.
471+
*/
472+
it('treats a thinking level that resolved to nothing as unset, not as none', async () => {
473+
await executeProviderRequest('anthropic', {
474+
model: 'claude-sonnet-5',
475+
workspaceId: 'ws-1',
476+
thinkingLevel: ' ',
477+
})
478+
479+
expect(sentRequest().thinkingLevel).toBeUndefined()
480+
})
481+
482+
it('preserves an explicit none thinking level', async () => {
483+
await executeProviderRequest('anthropic', {
484+
model: 'claude-sonnet-5',
485+
workspaceId: 'ws-1',
486+
thinkingLevel: 'none',
487+
})
488+
489+
expect(sentRequest().thinkingLevel).toBe('none')
490+
})
491+
458492
it('leaves an already-valid level untouched', async () => {
459493
await executeProviderRequest('openai', {
460494
model: 'gpt-5',
@@ -467,6 +501,22 @@ describe('executeProviderRequest — model level normalization', () => {
467501
expect(sentRequest().verbosity).toBe('high')
468502
})
469503

504+
/**
505+
* Sim's per-model level lists drive the pickers and can lag a provider that has started
506+
* accepting a new level, so an unrecognized level is forwarded rather than dropped: the
507+
* provider answers with an error naming the values it accepts, instead of Sim silently
508+
* substituting the model default and quietly corrupting a sweep.
509+
*/
510+
it('forwards a level the model does not declare so the provider reports it', async () => {
511+
await executeProviderRequest('openai', {
512+
model: 'gpt-5',
513+
workspaceId: 'ws-1',
514+
reasoningEffort: 'xhigh',
515+
})
516+
517+
expect(sentRequest().reasoningEffort).toBe('xhigh')
518+
})
519+
470520
it('still drops levels the resolved model does not support', async () => {
471521
await executeProviderRequest('anthropic', {
472522
model: 'claude-opus-4-6',

apps/sim/providers/index.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ import {
1515
attachLargeFileRemoteUrls,
1616
uploadLargeFilesToProvider,
1717
} from '@/providers/file-attachments.server'
18+
import {
19+
getReasoningEffortValuesForModel,
20+
getThinkingLevelsForModel,
21+
getVerbosityValuesForModel,
22+
} from '@/providers/models'
1823
import { getProviderExecutor } from '@/providers/registry'
1924
import {
2025
type ProviderRuntimeContext,
@@ -52,12 +57,46 @@ function normalizeModelLevel(value: string | undefined): string | undefined {
5257
return normalized || undefined
5358
}
5459

60+
/**
61+
* Levels the pickers offer on top of what a model declares. `auto` means "say nothing" and
62+
* `none` means "explicitly off"; every provider adapter special-cases them, so neither is
63+
* an unrecognized level.
64+
*/
65+
const MODEL_LEVEL_SENTINELS = new Set(['auto', 'none'])
66+
67+
/**
68+
* Logs a level that is not one the model declares.
69+
*
70+
* Deliberately does not drop the value. Sim's per-model level lists exist to populate the
71+
* pickers and can lag a provider that has started accepting a new level, so rejecting on them
72+
* would refuse values the API would have taken. Forwarding instead surfaces the provider's own
73+
* error, which names the field and the values it accepts — the loud failure an eval sweeping
74+
* levels needs, where silently substituting the model default would corrupt the results.
75+
*/
76+
function warnOnUnrecognizedLevel(
77+
field: 'reasoningEffort' | 'verbosity' | 'thinkingLevel',
78+
model: string | undefined,
79+
value: string | undefined,
80+
declaredValues: string[] | null
81+
): void {
82+
if (!model || !value || MODEL_LEVEL_SENTINELS.has(value)) return
83+
if (!declaredValues || declaredValues.includes(value)) return
84+
85+
logger.warn('Model level is not one this model declares; forwarding to the provider', {
86+
field,
87+
model,
88+
value,
89+
declaredValues,
90+
})
91+
}
92+
5593
function sanitizeRequest(request: ProviderRequest): ProviderRequest {
5694
const sanitizedRequest = { ...request }
5795
const model = sanitizedRequest.model
5896

5997
sanitizedRequest.reasoningEffort = normalizeModelLevel(sanitizedRequest.reasoningEffort)
6098
sanitizedRequest.verbosity = normalizeModelLevel(sanitizedRequest.verbosity)
99+
sanitizedRequest.thinkingLevel = normalizeModelLevel(sanitizedRequest.thinkingLevel)
61100

62101
if (model && !supportsTemperature(model)) {
63102
sanitizedRequest.temperature = undefined
@@ -79,6 +118,25 @@ function sanitizeRequest(request: ProviderRequest): ProviderRequest {
79118
sanitizedRequest.promptCaching = undefined
80119
}
81120

121+
warnOnUnrecognizedLevel(
122+
'reasoningEffort',
123+
model,
124+
sanitizedRequest.reasoningEffort,
125+
model ? getReasoningEffortValuesForModel(model) : null
126+
)
127+
warnOnUnrecognizedLevel(
128+
'verbosity',
129+
model,
130+
sanitizedRequest.verbosity,
131+
model ? getVerbosityValuesForModel(model) : null
132+
)
133+
warnOnUnrecognizedLevel(
134+
'thinkingLevel',
135+
model,
136+
sanitizedRequest.thinkingLevel,
137+
model ? getThinkingLevelsForModel(model) : null
138+
)
139+
82140
return sanitizedRequest
83141
}
84142

0 commit comments

Comments
 (0)