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
36 changes: 36 additions & 0 deletions apps/sim/blocks/blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'

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

import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility'
import { generateRouterPrompt } from '@/blocks/blocks/router'
import {
getAllBlocks,
Expand Down Expand Up @@ -842,6 +843,41 @@ describe.concurrent('Blocks Module', () => {
expect(modelSubBlock?.commandSearchable).toBe(true)
})

/** Each model-tuning field with a model that accepts it and one that does not. */
const AGENT_MODEL_LEVEL_FIELDS = [
{ id: 'reasoningEffort', capable: 'gpt-5.1', incapable: 'claude-sonnet-5' },
{ id: 'verbosity', capable: 'gpt-5.1', incapable: 'claude-sonnet-5' },
{ id: 'thinkingLevel', capable: 'claude-sonnet-5', incapable: 'gpt-5.1' },
] as const

it('should let the agent model-tuning fields take a typed reference', () => {
const agentBlock = getBlock('agent')

for (const { id } of AGENT_MODEL_LEVEL_FIELDS) {
const subBlock = agentBlock?.subBlocks.find((sb) => sb.id === id)
// A combobox is editable, so a `<block.output>` / `{{ENV_VAR}}` reference can be
// typed into it; the option list still offers every level the model accepts.
expect(subBlock?.type).toBe('combobox')
expect(typeof subBlock?.condition).toBe('function')
}
})

it('should keep the agent model-tuning fields visible when the model is a reference', () => {
const agentBlock = getBlock('agent')

for (const { id, capable, incapable } of AGENT_MODEL_LEVEL_FIELDS) {
const subBlock = agentBlock?.subBlocks.find((sb) => sb.id === id)
const condition = subBlock?.condition
if (typeof condition !== 'function') throw new Error(`${id} condition is not a function`)

expect(evaluateSubBlockCondition(condition, { model: '<start.model>' })).toBe(true)
expect(evaluateSubBlockCondition(condition, { model: '{{MODEL_ID}}' })).toBe(true)
// Gating on the capability list is unchanged for a literal model.
expect(evaluateSubBlockCondition(condition, { model: capable })).toBe(true)
expect(evaluateSubBlockCondition(condition, { model: incapable })).toBe(false)
}
})

it('should hide generator API keys on hosted only for Fal.ai providers', () => {
for (const blockType of ['image_generator_v2', 'video_generator_v3']) {
const block = getBlock(blockType)
Expand Down
28 changes: 10 additions & 18 deletions apps/sim/blocks/blocks/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AgentIcon } from '@/components/icons'
import type { BlockConfig } from '@/blocks/types'
import { AuthMode, IntegrationType } from '@/blocks/types'
import {
getModelCapabilityCondition,
getModelOptions,
getProviderCredentialSubBlocks,
normalizeFileInput,
Expand Down Expand Up @@ -159,8 +160,8 @@ Return ONLY the JSON array.`,
{
id: 'reasoningEffort',
title: 'Reasoning Effort',
type: 'dropdown',
placeholder: 'Select reasoning effort...',
type: 'combobox',
placeholder: 'Type or select reasoning effort...',
options: [
{ label: 'auto', id: 'auto' },
{ label: 'low', id: 'low' },
Expand Down Expand Up @@ -207,16 +208,13 @@ Return ONLY the JSON array.`,
return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
},
mode: 'advanced',
condition: {
field: 'model',
value: MODELS_WITH_REASONING_EFFORT,
},
condition: getModelCapabilityCondition(MODELS_WITH_REASONING_EFFORT),
},
{
id: 'verbosity',
title: 'Verbosity',
type: 'dropdown',
placeholder: 'Select verbosity...',
type: 'combobox',
placeholder: 'Type or select verbosity...',
options: [
{ label: 'auto', id: 'auto' },
{ label: 'low', id: 'low' },
Expand Down Expand Up @@ -263,16 +261,13 @@ Return ONLY the JSON array.`,
return [autoOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
},
mode: 'advanced',
condition: {
field: 'model',
value: MODELS_WITH_VERBOSITY,
},
condition: getModelCapabilityCondition(MODELS_WITH_VERBOSITY),
},
{
id: 'thinkingLevel',
title: 'Thinking Level',
type: 'dropdown',
placeholder: 'Select thinking level...',
type: 'combobox',
placeholder: 'Type or select thinking level...',
options: [
{ label: 'none', id: 'none' },
{ label: 'minimal', id: 'minimal' },
Expand Down Expand Up @@ -306,10 +301,7 @@ Return ONLY the JSON array.`,
return [noneOption, ...validOptions.map((opt) => ({ label: opt, id: opt }))]
},
mode: 'advanced',
condition: {
field: 'model',
value: MODELS_WITH_THINKING,
},
condition: getModelCapabilityCondition(MODELS_WITH_THINKING),
Comment thread
cursor[bot] marked this conversation as resolved.
},
{
id: 'promptCaching',
Expand Down
18 changes: 18 additions & 0 deletions apps/sim/blocks/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
isOllamaConfigured,
} from '@/lib/core/config/env-flags'
import { getScopesForService } from '@/lib/oauth/utils'
import { containsReference } from '@/lib/workflows/sanitization/references'
import { buildCanonicalIndex } from '@/lib/workflows/subblocks/visibility'
import type { BlockOutput, OutputFieldDefinition, SubBlockConfig } from '@/blocks/types'
import {
Expand Down Expand Up @@ -235,6 +236,23 @@ function shouldRequireApiKeyForModel(model: string): boolean {
return true
}

/**
* Visibility condition for a model-tuning field that only some models accept, such as
* reasoning effort or verbosity. Gates on the capability list, but keeps the field visible
* when `model` itself holds a variable or block reference — the concrete model id is only
* known at execution time then, so matching a reference against a static list would hide
* the field for every workflow that binds its model dynamically.
*/
export function getModelCapabilityCondition(capableModels: string[]) {
return (values?: Record<string, unknown>) => {
const model = typeof values?.model === 'string' ? values.model : ''
if (containsReference(model)) {
return buildModelVisibilityCondition(model, true)
}
return { field: 'model', value: capableModels }
}
}

/**
* Get the API key condition for provider credential subblocks.
* Handles hosted vs self-hosted environments and excludes providers that don't need API key.
Expand Down
47 changes: 47 additions & 0 deletions apps/sim/executor/variables/resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1341,3 +1341,50 @@ describe('VariableResolver function context overflow offload', () => {
expect(result.resolvedInputs.code).toBe('return globals()["__blockRef_0"]')
})
})

/**
* The agent block's Reasoning Effort and Verbosity fields are editable comboboxes, so a
* workflow can bind them to a reference instead of picking a level. These lock in that the
* generic input resolution actually reaches those two fields.
*/
describe('VariableResolver agent model levels', () => {
it('resolves block, workflow-variable, and env references in reasoning effort and verbosity', async () => {
const producer = createBlock('producer', 'Producer', BlockType.API)
const agent = createBlock('agent', 'Agent', BlockType.AGENT, {
model: 'gpt-5',
reasoningEffort: '<Producer.result>',
verbosity: '<variable.Detail>',
thinkingLevel: '{{THINKING}}',
})
const workflow: SerializedWorkflow = {
version: '1',
blocks: [producer, agent],
connections: [],
loops: {},
parallels: {},
}

const state = new ExecutionState()
state.setBlockOutput('producer', { result: 'high' })
const ctx = {
blockStates: state.getBlockStates(),
blockLogs: [],
environmentVariables: { THINKING: 'medium' },
workflowVariables: { 'var-1': { id: 'var-1', name: 'Detail', type: 'string', value: 'low' } },
decisions: { router: new Map(), condition: new Map() },
loopExecutions: new Map(),
executedBlocks: new Set(),
activeExecutionPath: new Set(),
completedLoops: new Set(),
metadata: {},
} as unknown as ExecutionContext

const resolver = new VariableResolver(workflow, { THINKING: 'medium' }, state)
const result = await resolver.resolveInputs(ctx, 'agent', agent.config.params, agent)

expect(result.reasoningEffort).toBe('high')
expect(result.verbosity).toBe('low')
expect(result.thinkingLevel).toBe('medium')
expect(result.model).toBe('gpt-5')
})
})
35 changes: 35 additions & 0 deletions apps/sim/lib/workflows/sanitization/references.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import {
containsReference,
isLikelyReferenceSegment,
splitReferenceSegment,
} from '@/lib/workflows/sanitization/references'
Expand Down Expand Up @@ -53,3 +54,37 @@ describe('isLikelyReferenceSegment', () => {
expect(isLikelyReferenceSegment('<123>')).toBe(false)
})
})

describe('containsReference', () => {
it('detects block and variable references', () => {
expect(containsReference('<start.input>')).toBe(true)
expect(containsReference('<variable.model>')).toBe(true)
expect(containsReference('<loop.index>')).toBe(true)
})

it('detects environment variable placeholders', () => {
expect(containsReference('{{MODEL_ID}}')).toBe(true)
})

it('detects a reference embedded in surrounding text', () => {
expect(containsReference('gpt-<start.suffix>')).toBe(true)
})

it('returns false for literal model ids', () => {
expect(containsReference('gpt-5.1')).toBe(false)
expect(containsReference('claude-sonnet-5')).toBe(false)
expect(containsReference('azure/gpt-5.1-codex')).toBe(false)
})

it('returns false for empty and non-string values', () => {
expect(containsReference('')).toBe(false)
expect(containsReference(undefined)).toBe(false)
expect(containsReference(null)).toBe(false)
expect(containsReference(42)).toBe(false)
})

it('returns false for stray brackets that are not references', () => {
expect(containsReference('a < b')).toBe(false)
expect(containsReference('<123>')).toBe(false)
})
})
15 changes: 15 additions & 0 deletions apps/sim/lib/workflows/sanitization/references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,21 @@ export function isLikelyReferenceSegment(segment: string): boolean {
return true
}

const ENV_VAR_PATTERN = new RegExp(`\\${REFERENCE.ENV_VAR_START}[^}]+\\${REFERENCE.ENV_VAR_END}`)

/**
* Whether a subblock value carries a `<block.path>` / `<variable.name>` reference or a
* `{{ENV_VAR}}` placeholder instead of a literal value — i.e. its real value is only known
* once the workflow runs. Conditions that gate one field on a sibling's literal value use
* this to stay visible while the sibling is bound dynamically.
*/
export function containsReference(value: unknown): boolean {
if (typeof value !== 'string' || !value) {
return false
}
return extractReferencePrefixes(value).length > 0 || ENV_VAR_PATTERN.test(value)
}

export function extractReferencePrefixes(value: string): Array<{ raw: string; prefix: string }> {
if (!value || typeof value !== 'string') {
return []
Expand Down
19 changes: 19 additions & 0 deletions apps/sim/providers/anthropic/core.thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
*/
import { describe, expect, it } from 'vitest'
import { buildThinkingConfig } from '@/providers/anthropic/core'
import { describeModelLevel } from '@/providers/utils'

describe('buildThinkingConfig', () => {
it('requests summarized display for omitted-display models on agent-events runs', () => {
Expand Down Expand Up @@ -55,3 +56,21 @@ describe('buildThinkingConfig', () => {
expect(buildThinkingConfig('gpt-4o', 'high', true)).toBeNull()
})
})

/**
* A thinking level that is not one the model declares reaches this adapter, by design — Sim's
* per-model lists can lag a provider. The adapter logs that it is ignoring it, and since the
* field is reference-bound, the value it logs can be whatever a mistyped `{{ENV_VAR}}` or block
* reference resolved to.
*/
describe('unsupported thinking level logging', () => {
it('returns null for a level the model does not declare', () => {
expect(buildThinkingConfig('claude-sonnet-5', 'sk-proj-abcdef0123456789', false)).toBeNull()
})

it('redacts the level in the ignore warning instead of echoing it', () => {
const secret = 'sk-proj-abcdef0123456789'
expect(describeModelLevel(secret)).toBe(`[redacted ${secret.length} chars]`)
expect(describeModelLevel('high')).toBe('high')
})
})
8 changes: 6 additions & 2 deletions apps/sim/providers/anthropic/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ import { adaptAnthropicToolSchema } from '@/providers/tool-schema-adapter'
import { enrichLastModelSegment } from '@/providers/trace-enrichment'
import type { ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types'
import { ProviderError } from '@/providers/types'
import { prepareToolExecution, prepareToolsWithUsageControl } from '@/providers/utils'
import {
describeModelLevel,
prepareToolExecution,
prepareToolsWithUsageControl,
} from '@/providers/utils'

/**
* Configuration for creating an Anthropic provider instance.
Expand Down Expand Up @@ -396,7 +400,7 @@ export async function executeAnthropicProviderRequest(
)
} else {
logger.warn(
`Thinking level "${request.thinkingLevel}" not supported for model: ${modelId}, ignoring`
`Thinking level "${describeModelLevel(request.thinkingLevel)}" not supported for model: ${modelId}, ignoring`
)
}
}
Expand Down
Loading
Loading