Skip to content

Commit 5d8a181

Browse files
committed
fix(jev): expose typed answer fields in reference pickers
1 parent a8b48e1 commit 5d8a181

3 files changed

Lines changed: 172 additions & 13 deletions

File tree

‎apps/sim/blocks/agent-evaluation.test.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
getEffectiveBlockOutputs,
66
getEffectiveBlockOutputType,
77
} from '@/lib/workflows/blocks/block-outputs'
8+
import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags'
89
import { evaluateSubBlockCondition } from '@/lib/workflows/subblocks/visibility'
910
import { AgentBlock } from '@/blocks/blocks/agent'
1011
import { getAgentModelOptions, getModelOptions } from '@/blocks/utils'
@@ -75,6 +76,110 @@ describe('Agent evaluation configuration', () => {
7576
expect(getModelOptions().map((option) => option.id)).not.toContain('jev-1.13.0')
7677
})
7778

79+
describe('evaluation answer references', () => {
80+
const questions = {
81+
category: { type: 'choice', instructions: 'Choose a category', criteria: { a: 'A', b: 'B' } },
82+
rating: { type: 'score', instructions: 'Rate the result', criteria: ['Low', 'High'] },
83+
passed: { type: 'noul', instructions: 'Did it pass?' },
84+
}
85+
86+
it.each(['jev-1.13.0', 'jev-latest', 'jev-preview', '<start.model>', '{{MODEL_ID}}'])(
87+
'exposes typed question fields for %s without an execution result',
88+
(model) => {
89+
const values = {
90+
model: { value: model },
91+
evaluationQuestions: { value: JSON.stringify(questions) },
92+
responseFormat: {
93+
value: { schema: { type: 'object', properties: { title: { type: 'string' } } } },
94+
},
95+
}
96+
const tags = getBlockReferenceTags({
97+
block: { id: 'agent-test', type: 'agent', name: 'Evaluate', subBlocks: values },
98+
})
99+
const fields = {
100+
'answers.category.choice': 'string',
101+
'answers.category.confidence': 'number',
102+
'answers.category.probabilities': 'json',
103+
'answers.category.type': 'string',
104+
'answers.rating.score': 'number',
105+
'answers.rating.confidence': 'number',
106+
'answers.rating.legend': 'json',
107+
'answers.passed.noul': 'number',
108+
}
109+
for (const [path, type] of Object.entries(fields)) {
110+
expect(tags).toContain(`evaluate.${path}`)
111+
expect(getEffectiveBlockOutputType('agent', path, values)).toBe(type)
112+
}
113+
expect(getEffectiveBlockOutputType('agent', 'answers', values)).toBe('json')
114+
expect(getEffectiveBlockOutputType('agent', 'answers.category', values)).toBe('json')
115+
expect(tags).not.toContain('evaluate.answers.passed.confidence')
116+
expect(tags.includes('evaluate.title')).toBe(!model.startsWith('jev-'))
117+
}
118+
)
119+
120+
it.each([undefined, '', '{', '<start.questions>', '{{QUESTIONS}}', [], null, { unknown: {} }])(
121+
'keeps the answers object selectable when questions cannot be inferred: %j',
122+
(value) => {
123+
const values = { model: { value: 'jev-latest' }, evaluationQuestions: { value } }
124+
expect(getEffectiveBlockOutputPaths('agent', values)).toContain('answers')
125+
expect(getEffectiveBlockOutputType('agent', 'answers', values)).toBe('json')
126+
}
127+
)
128+
129+
it('uses structured questions and follows edits without leaking fields into chat models', () => {
130+
const values = {
131+
model: { value: 'jev-latest' },
132+
evaluationQuestions: { value: { result: questions.category } },
133+
}
134+
expect(getEffectiveBlockOutputPaths('agent', values)).toContain('answers.result.choice')
135+
expect(
136+
getEffectiveBlockOutputPaths('agent', {
137+
...values,
138+
evaluationQuestions: { value: { result: questions.passed } },
139+
})
140+
).not.toContain('answers.result.choice')
141+
expect(
142+
getEffectiveBlockOutputPaths('agent', {
143+
...values,
144+
model: { value: 'gpt-4o' },
145+
}).some((path) => path.startsWith('answers'))
146+
).toBe(false)
147+
})
148+
149+
it('does not offer ambiguous reference paths for special question IDs', () => {
150+
const values = {
151+
model: { value: 'jev-latest' },
152+
evaluationQuestions: {
153+
value: {
154+
'with.dot': questions.passed,
155+
'with space': questions.passed,
156+
'with[0]': questions.passed,
157+
'<start.question>': questions.passed,
158+
'valid-id_1': questions.passed,
159+
},
160+
},
161+
}
162+
const paths = getEffectiveBlockOutputPaths('agent', values)
163+
expect(paths.filter((path) => path.startsWith('answers.'))).toEqual([
164+
'answers.valid-id_1.noul',
165+
'answers.valid-id_1.type',
166+
])
167+
expect(getEffectiveBlockOutputType('agent', 'answers', values)).toBe('json')
168+
})
169+
170+
it.each(['type', 'properties', 'description', '__proto__'])(
171+
'resolves the question named %s through schema properties',
172+
(id) => {
173+
const values = {
174+
model: { value: 'jev-latest' },
175+
evaluationQuestions: { value: { [id]: questions.passed } },
176+
}
177+
expect(getEffectiveBlockOutputPaths('agent', values)).toContain(`answers.${id}.noul`)
178+
expect(getEffectiveBlockOutputType('agent', `answers.${id}.noul`, values)).toBe('number')
179+
}
180+
)
181+
})
182+
78183
it.each([false, true])(
79184
'serializes native fields without requiring messages, advanced=%s',
80185
(advancedMode) => {

‎apps/sim/lib/workflows/blocks/block-outputs.ts‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
extractFieldsFromSchema,
44
parseResponseFormatSafely,
55
} from '@/lib/core/utils/response-format'
6+
import { getJevAnswerOutput } from '@/lib/workflows/blocks/jev-outputs'
67
import { normalizeInputFormatValue } from '@/lib/workflows/input-format'
78
import { containsReference } from '@/lib/workflows/sanitization/references'
89
import {
@@ -435,18 +436,19 @@ export function getEffectiveBlockOutputs(
435436

436437
if (blockType === 'agent') {
437438
const model = subBlocks?.model?.value
438-
if (typeof model !== 'string' || !isEvaluationModel(model)) {
439-
const responseFormatOutputs = getResponseFormatOutputs(subBlocks, 'agent')
440-
if (responseFormatOutputs) {
441-
/** A referenced model may select either evaluation or chat at execution time. */
442-
return typeof model === 'string' && containsReference(model)
443-
? {
444-
...getBlockOutputs('agent', subBlocks, false, { includeHidden }),
445-
...responseFormatOutputs,
446-
}
447-
: responseFormatOutputs
439+
const mayEvaluate =
440+
typeof model === 'string' && (isEvaluationModel(model) || containsReference(model))
441+
if (mayEvaluate) {
442+
const outputs = getBlockOutputs('agent', subBlocks, false, { includeHidden })
443+
const answers = getJevAnswerOutput(subBlocks?.evaluationQuestions?.value)
444+
return {
445+
...outputs,
446+
...(containsReference(model) ? getResponseFormatOutputs(subBlocks, 'agent') : undefined),
447+
...(answers ? { answers } : undefined),
448448
}
449449
}
450+
const responseFormatOutputs = getResponseFormatOutputs(subBlocks, 'agent')
451+
if (responseFormatOutputs) return responseFormatOutputs
450452
}
451453

452454
let baseOutputs: OutputDefinition
@@ -559,9 +561,7 @@ function traverseOutputPath(outputs: OutputDefinition, pathParts: string[]): unk
559561

560562
const currentObj = current as Record<string, unknown>
561563

562-
if (part in currentObj) {
563-
current = currentObj[part]
564-
} else if (
564+
if (
565565
'type' in currentObj &&
566566
(currentObj.type === 'object' || currentObj.type === 'json') &&
567567
'properties' in currentObj &&
@@ -592,6 +592,8 @@ function traverseOutputPath(outputs: OutputDefinition, pathParts: string[]): unk
592592
} else {
593593
return null
594594
}
595+
} else if (part in currentObj) {
596+
current = currentObj[part]
595597
} else {
596598
return null
597599
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { isRecordLike } from '@sim/utils/object'
2+
import type { JevAnswer } from '@/providers/typesafe/types'
3+
import type { OutputProperty } from '@/tools/types'
4+
5+
const ANSWER_FIELDS = {
6+
choice: {
7+
choice: { type: 'string' },
8+
confidence: { type: 'number' },
9+
probabilities: { type: 'json' },
10+
type: { type: 'string' },
11+
},
12+
score: {
13+
score: { type: 'number' },
14+
confidence: { type: 'number' },
15+
probabilities: { type: 'json' },
16+
legend: { type: 'json' },
17+
type: { type: 'string' },
18+
},
19+
noul: {
20+
noul: { type: 'number' },
21+
type: { type: 'string' },
22+
},
23+
} satisfies {
24+
[Type in JevAnswer['type']]: Record<keyof Extract<JevAnswer, { type: Type }>, OutputProperty>
25+
}
26+
27+
/**
28+
* Describes answers known from the editor's questions without requiring resolved inputs.
29+
* Keys containing reference syntax remain accessible through the whole answers object.
30+
*/
31+
export function getJevAnswerOutput(
32+
questions: unknown
33+
): (OutputProperty & { type: 'json' }) | undefined {
34+
if (typeof questions === 'string') {
35+
try {
36+
questions = JSON.parse(questions)
37+
} catch {
38+
return undefined
39+
}
40+
}
41+
if (!isRecordLike(questions)) return undefined
42+
43+
const entries: Array<[string, OutputProperty]> = []
44+
for (const [id, question] of Object.entries(questions)) {
45+
if (!/^[\w-]+$/.test(id) || !isRecordLike(question)) continue
46+
const { type } = question
47+
if (type !== 'choice' && type !== 'score' && type !== 'noul') continue
48+
entries.push([id, { type: 'json', properties: ANSWER_FIELDS[type] }])
49+
}
50+
51+
return entries.length > 0 ? { type: 'json', properties: Object.fromEntries(entries) } : undefined
52+
}

0 commit comments

Comments
 (0)