Skip to content

Commit 812299a

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(typesafe): validate answers against projected criteria
1 parent 6e80902 commit 812299a

7 files changed

Lines changed: 90 additions & 21 deletions

File tree

‎apps/sim/tools/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3085,7 +3085,10 @@ async function executeToolRequest(
30853085
blob: () => response.blob(),
30863086
} as Response
30873087

3088-
const data = await tool.transformResponse(mockResponse, params, { signal })
3088+
const data = await tool.transformResponse(mockResponse, params, {
3089+
signal,
3090+
requestBody: requestParams.body,
3091+
})
30893092
if (tool.request.responseType === 'binary' && data.success) {
30903093
if (!context) throw new Error('Binary file output requires trusted execution context')
30913094
const file = data.output?.file

‎apps/sim/tools/types.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ export type ParameterVisibility =
9797

9898
export interface ToolResponseContext {
9999
signal?: AbortSignal
100+
/** Serialized external request body after model-input projection and request normalization. */
101+
requestBody?: string
100102
}
101103

102104
export interface ToolResponse {

‎apps/sim/tools/typesafe/choice.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,12 @@ export const typesafeChoiceTool: ToolConfig<TypeSafeChoiceParams, TypeSafeChoice
6363
modelInput: { mode: 'project', select: selectTypeSafeModelInput },
6464
retry: { enabled: true, maxRetries: 2, initialDelayMs: 500, maxDelayMs: 5000 },
6565
},
66-
transformResponse: async (response) => {
67-
const data = await readTypeSafeResponse(response, { result: { type: 'choice' } })
66+
transformResponse: async (response, params, context) => {
67+
const questions =
68+
context?.requestBody === undefined && params
69+
? { result: buildTypeSafeQuestion('choice', params.instructions, params.criteria) }
70+
: undefined
71+
const data = await readTypeSafeResponse(response, questions, context)
6872
const answer = data.answers.result
6973
if (answer.type !== 'choice') throw new Error('TypeSafe returned an unexpected answer type')
7074
return {

‎apps/sim/tools/typesafe/evaluate.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,13 @@ export const typesafeEvaluateTool: ToolConfig<TypeSafeEvaluateParams, TypeSafeEv
6262
},
6363
retry: { enabled: true, maxRetries: 2, initialDelayMs: 500, maxDelayMs: 5000 },
6464
},
65-
transformResponse: async (response, params) => {
65+
transformResponse: async (response, params, context) => {
6666
const output = await readTypeSafeResponse(
6767
response,
68-
params ? normalizeTypeSafeQuestions(params.questions) : undefined
68+
context?.requestBody === undefined && params
69+
? normalizeTypeSafeQuestions(params.questions)
70+
: undefined,
71+
context
6972
)
7073
return { success: true, output }
7174
},

‎apps/sim/tools/typesafe/noul.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,8 @@ export const typesafeNoulTool: ToolConfig<TypeSafeNoulParams, TypeSafeNoulRespon
6464
modelInput: { mode: 'project', select: selectTypeSafeModelInput },
6565
retry: { enabled: true, maxRetries: 2, initialDelayMs: 500, maxDelayMs: 5000 },
6666
},
67-
transformResponse: async (response) => {
68-
const data = await readTypeSafeResponse(response, { result: { type: 'noul' } })
67+
transformResponse: async (response, _params, context) => {
68+
const data = await readTypeSafeResponse(response, { result: { type: 'noul' } }, context)
6969
const answer = data.answers.result
7070
if (answer.type !== 'noul') throw new Error('TypeSafe returned an unexpected answer type')
7171
return { success: true, output: { noul: answer.noul, model: data.model, usage: data.usage } }

‎apps/sim/tools/typesafe/score.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,12 @@ export const typesafeScoreTool: ToolConfig<TypeSafeScoreParams, TypeSafeScoreRes
6969
modelInput: { mode: 'project', select: selectTypeSafeModelInput },
7070
retry: { enabled: true, maxRetries: 2, initialDelayMs: 500, maxDelayMs: 5000 },
7171
},
72-
transformResponse: async (response) => {
73-
const data = await readTypeSafeResponse(response, { result: { type: 'score' } })
72+
transformResponse: async (response, params, context) => {
73+
const questions =
74+
context?.requestBody === undefined && params
75+
? { result: buildTypeSafeQuestion('score', params.instructions, params.criteria) }
76+
: undefined
77+
const data = await readTypeSafeResponse(response, questions, context)
7478
const answer = data.answers.result
7579
if (answer.type !== 'score') throw new Error('TypeSafe returned an unexpected answer type')
7680
return {

‎apps/sim/tools/typesafe/utils.ts‎

Lines changed: 65 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
import { isDeepStrictEqual } from 'node:util'
12
import { z } from 'zod'
3+
import type { ToolResponseContext } from '@/tools/types'
24
import type {
5+
TypeSafeAnswer,
36
TypeSafeBaseParams,
47
TypeSafeEntry,
58
TypeSafeQuestion,
@@ -51,6 +54,7 @@ const questionsSchema = z
5154
.refine((value) => Object.keys(value).length > 0, {
5255
message: 'Questions must contain at least one named question',
5356
})
57+
const requestQuestionsSchema = z.object({ questions: questionsSchema })
5458
const probabilitySchema = z.number().min(0).max(1)
5559
const probabilitiesSchema = z
5660
.record(z.string(), probabilitySchema)
@@ -156,23 +160,72 @@ export function buildTypeSafeRequest(
156160
return { state: normalizeTypeSafeEntry(params.state, 'state'), model, questions }
157161
}
158162

159-
/** Reads typed answers and verifies that every requested question has an answer of the same type. */
163+
function hasExactKeys(value: Record<string, unknown>, keys: string[]): boolean {
164+
return Object.keys(value).length === keys.length && keys.every((key) => Object.hasOwn(value, key))
165+
}
166+
167+
function validateAnswerCriteria(
168+
name: string,
169+
question: TypeSafeQuestion,
170+
answer: TypeSafeAnswer
171+
): void {
172+
if (question.type === 'choice' && answer.type === 'choice') {
173+
if (!Object.hasOwn(question.criteria, answer.choice)) {
174+
throw new Error(`TypeSafe response choice is not a requested option for question "${name}"`)
175+
}
176+
if (!hasExactKeys(answer.probabilities, Object.keys(question.criteria))) {
177+
throw new Error(
178+
`TypeSafe response probabilities do not match the options for question "${name}"`
179+
)
180+
}
181+
}
182+
if (question.type === 'score' && answer.type === 'score') {
183+
const levels = question.criteria.map((_, index) => String(index))
184+
if (answer.score > question.criteria.length - 1) {
185+
throw new Error(`TypeSafe response score exceeds the rubric for question "${name}"`)
186+
}
187+
if (!hasExactKeys(answer.probabilities, levels)) {
188+
throw new Error(
189+
`TypeSafe response probabilities do not match the rubric for question "${name}"`
190+
)
191+
}
192+
if (
193+
!hasExactKeys(answer.legend, levels) ||
194+
levels.some(
195+
(level, index) => !isDeepStrictEqual(answer.legend[level], question.criteria[index])
196+
)
197+
) {
198+
throw new Error(`TypeSafe response legend does not match the rubric for question "${name}"`)
199+
}
200+
}
201+
}
202+
203+
/** Validates typed answers against the actual projected request, including its criteria. */
160204
export async function readTypeSafeResponse(
161205
response: Response,
162-
expectedQuestions?: Record<string, { type: TypeSafeQuestion['type'] }>
206+
expectedQuestions?: TypeSafeQuestions,
207+
context?: ToolResponseContext
163208
): Promise<TypeSafeResult> {
164209
const data = validate(resultSchema, await response.json(), 'response')
165-
if (expectedQuestions) {
166-
for (const [name, question] of Object.entries(expectedQuestions)) {
167-
if (!Object.hasOwn(data.answers, name) || data.answers[name].type !== question.type) {
168-
throw new Error(
169-
`TypeSafe response is missing the ${question.type} answer for question "${name}"`
170-
)
171-
}
172-
}
173-
if (Object.keys(data.answers).length !== Object.keys(expectedQuestions).length) {
174-
throw new Error('TypeSafe response question IDs do not match the request')
210+
const questions =
211+
context?.requestBody === undefined
212+
? expectedQuestions
213+
: validate(
214+
requestQuestionsSchema,
215+
parseTypeSafeJson(context.requestBody, 'request body'),
216+
'request body'
217+
).questions
218+
if (!questions) throw new Error('TypeSafe response validation requires the request questions')
219+
for (const [name, question] of Object.entries(questions)) {
220+
if (!Object.hasOwn(data.answers, name) || data.answers[name].type !== question.type) {
221+
throw new Error(
222+
`TypeSafe response is missing the ${question.type} answer for question "${name}"`
223+
)
175224
}
225+
validateAnswerCriteria(name, question, data.answers[name])
226+
}
227+
if (Object.keys(data.answers).length !== Object.keys(questions).length) {
228+
throw new Error('TypeSafe response question IDs do not match the request')
176229
}
177230
return data
178231
}

0 commit comments

Comments
 (0)