Skip to content

Commit 53f4dde

Browse files
committed
improvement(tools): stop the secret guard from faking a difference between copies
Two copies pinned to identical values, where only one of them resolved an environment variable, rendered different text — one withheld its literal — and both were then told "other copies are pinned to different values". That is the exact assertion the comparison exists to prevent. The duplicate check now compares the un-withheld render, so disclosure differences no longer read as configuration differences. Also drops a redundant copy of the resolved-name cache, returns undefined rather than an empty-string sentinel for an unresolved resource, unexports two internal-only interfaces, and corrects six comments: five overstated or referenced the module this branch renamed, and one described the wrong failure mode for a credential entered in advanced mode. Adds the uncovered branches the review named: canonical-id grouping (the shape production actually sees once wire ids are aliased), a sibling that states nothing, negative-cache reuse, both resource kinds in one pass, an omitted tool param map, empty and oversized titles, and a non-finite number.
1 parent fc74117 commit 53f4dde

6 files changed

Lines changed: 171 additions & 42 deletions

File tree

apps/sim/executor/handlers/agent/agent-handler.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,8 +1385,6 @@ export class AgentBlockHandler implements BlockHandler {
13851385
usageControl: config.usageControl || 'auto',
13861386
}
13871387

1388-
// An MCP tool has no block subblocks to describe it, so its pinned params are read straight
1389-
// from the configured values, keyed by the remote schema's own names.
13901388
const { formatParameterLabel, isPasswordParameter } = await import('@/tools/params')
13911389
registerToolPinnedFields(
13921390
mcpTool,

apps/sim/executor/utils/tool-pinned-params.test.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,79 @@ describe('annotateToolPinnedParams', () => {
190190
expect(appended(second[0])).toContain('Gmail Account "Support Inbox"')
191191
})
192192

193+
it('does not claim copies differ when only their secret disclosure does', async () => {
194+
const open = providerTool('gmail_read_email', [account('cred-a'), label('INBOX')])
195+
const secret = providerTool('gmail_read_email', [account('cred-a'), label('INBOX')])
196+
197+
// Identical pins; only `secret` resolved an env variable, so its literal is withheld.
198+
await annotateToolPinnedParams(ctx(), [open, secret], (tool) => tool === secret)
199+
200+
expect(appended(open)).toContain('Label "INBOX".')
201+
expect(appended(secret)).not.toContain('INBOX')
202+
expect(appended(open)).not.toContain('Other copies')
203+
expect(appended(secret)).not.toContain('Other copies')
204+
})
205+
206+
it('groups copies by canonical id once the wire ids have been aliased', async () => {
207+
const first = providerTool('gmail_read_email', [label('INBOX')])
208+
const second = providerTool('gmail_read_email__sim_2', [label('SENT')])
209+
second.canonicalId = 'gmail_read_email'
210+
211+
await annotateToolPinnedParams(ctx(), [first, second])
212+
213+
expect(appended(first)).toContain('Other copies')
214+
expect(appended(second)).toContain('Other copies')
215+
})
216+
217+
it('does not group tools that only share a wire id shape', async () => {
218+
const first = providerTool('gmail_read_email', [label('INBOX')])
219+
const second = providerTool('slack_send_message', [label('SENT')])
220+
221+
await annotateToolPinnedParams(ctx(), [first, second])
222+
223+
expect(appended(first)).not.toContain('Other copies')
224+
expect(appended(second)).not.toContain('Other copies')
225+
})
226+
227+
it('gives no hint when a sibling states nothing at all', async () => {
228+
const stated = providerTool('gmail_read_email', [label('INBOX')])
229+
const silent = providerTool('gmail_read_email', [account('cred-deleted')])
230+
231+
await annotateToolPinnedParams(ctx(), [stated, silent])
232+
233+
expect(appended(stated)).toContain('Label "INBOX".')
234+
expect(silent.description).toBe(BASE)
235+
expect(appended(stated)).not.toContain('Other copies')
236+
})
237+
238+
it('does not re-query an id that already failed to resolve', async () => {
239+
const cache = new Map<string, string | null>()
240+
241+
await annotateToolPinnedParams(ctx(cache), [
242+
providerTool('gmail_read_email', [account('cred-deleted'), label('A')]),
243+
providerTool('gmail_read_email', [account('cred-deleted'), label('B')]),
244+
])
245+
expect(mockFindWorkspaceCredentialLookup).toHaveBeenCalledTimes(1)
246+
247+
await annotateToolPinnedParams(ctx(cache), [
248+
providerTool('gmail_read_email', [account('cred-deleted'), label('C')]),
249+
])
250+
expect(mockFindWorkspaceCredentialLookup).toHaveBeenCalledTimes(1)
251+
})
252+
253+
it('resolves both resource kinds in one pass', async () => {
254+
mockGetKnowledgeBaseNames.mockResolvedValue(new Map([['kb-a', 'Support Docs']]))
255+
const gmail = providerTool('gmail_read_email', [account('cred-a')])
256+
const kb = providerTool('knowledge_search', [
257+
{ title: 'Knowledge Base', resource: { kind: 'knowledgeBase', id: 'kb-a' } },
258+
])
259+
260+
await annotateToolPinnedParams(ctx(), [gmail, kb])
261+
262+
expect(appended(gmail)).toContain('Gmail Account "Support Inbox"')
263+
expect(appended(kb)).toContain('Knowledge Base "Support Docs"')
264+
})
265+
193266
it('does nothing without a workspace', async () => {
194267
const tool = providerTool('gmail_read_email', [label('INBOX')])
195268

apps/sim/executor/utils/tool-pinned-params.ts

Lines changed: 42 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -47,29 +47,49 @@ const RESOLVERS: Record<BoundResourceKind, ResourceNameResolver> = {
4747
knowledgeBase: (ids, workspaceId) => getKnowledgeBaseNames(ids, workspaceId),
4848
}
4949

50-
function renderField(field: ToolPinnedField, resolved: ReadonlyMap<string, string>): string {
50+
function renderField(
51+
field: ToolPinnedField,
52+
resolved: ReadonlyMap<string, string | null>
53+
): string | undefined {
5154
if ('resource' in field) {
5255
const name = sanitizeStatedText(
5356
resolved.get(`${field.resource.kind}:${field.resource.id}`) ?? ''
5457
)
55-
return name ? `${field.title} "${name}"` : ''
58+
return name ? `${field.title} "${name}"` : undefined
5659
}
5760
return typeof field.value === 'string'
5861
? `${field.title} "${field.value}"`
5962
: `${field.title} ${field.value}`
6063
}
6164

65+
/** Joins what one tool states, or undefined when it has nothing to say. */
66+
function buildStatement(
67+
fields: readonly ToolPinnedField[],
68+
resolved: ReadonlyMap<string, string | null>,
69+
withholdLiterals: boolean
70+
): string | undefined {
71+
const rendered: string[] = []
72+
for (const field of fields) {
73+
if (rendered.length === MAX_STATED_FIELDS) break
74+
if (withholdLiterals && !('resource' in field)) continue
75+
const text = renderField(field, resolved)
76+
if (text !== undefined) rendered.push(text)
77+
}
78+
return rendered.length > 0 ? rendered.join(', ') : undefined
79+
}
80+
6281
/**
6382
* Tells the model which values a workflow pinned on a tool, and — when the agent holds several
6483
* copies of that tool that differ — that it must pick the right one.
6584
*
66-
* Every pinned param is stripped from the schema the model sees (`createLLMToolSchema` drops any
67-
* param the user filled), so without this the model cannot tell that a Gmail tool reads only
68-
* `INBOX`, cannot distinguish it from a sibling reading `SENT`, and may promise a caller it will
69-
* search a folder it can never reach.
85+
* A filled param is stripped from the schema the model sees `createLLMToolSchema` skips it for
86+
* block tools, `filterSchemaForLLM` for MCP ones — so without this the model cannot tell that a
87+
* Gmail tool reads only `INBOX`, cannot distinguish it from a sibling reading `SENT`, and may
88+
* promise a caller it will search a folder it can never reach.
7089
*
7190
* Mutates `description` on the exact objects passed in. Provenance elsewhere is keyed on tool
72-
* identity, so no tool is ever replaced. Never throws: an unresolvable value is simply omitted.
91+
* identity, so no tool is ever replaced. A failed name lookup never fails the block; it just
92+
* leaves that field unstated. `withholdLiteralValues` is called uncaught and must not throw.
7393
*
7494
* `withholdLiteralValues` marks a tool whose configured params resolved an environment secret.
7595
* Its literal values are suppressed; resolved resource names still state, since a looked-up name
@@ -121,37 +141,28 @@ export async function annotateToolPinnedParams(
121141
})
122142
)
123143

124-
const resolvedNames = new Map<string, string>()
125-
for (const [key, name] of cache) if (name) resolvedNames.set(key, name)
126-
144+
// Only claim the copies differ when their pinned values actually do. The comparison uses the
145+
// un-withheld render on purpose: two copies pinned identically, where only one of them resolved
146+
// an env secret, differ solely in what is disclosed — telling the model they are "pinned to
147+
// different values" would assert a distinction it cannot act on. Comparing only the first
148+
// MAX_STATED_FIELDS can still miss a difference beyond the cap, which under-warns rather than
149+
// mis-warns.
127150
const statements = new Map<ProviderToolConfig, string>()
151+
const comparableByCanonicalId = new Map<string, Set<string>>()
128152
for (const { tool, fields } of annotatable) {
129-
const withhold = withholdLiteralValues?.(tool) ?? false
130-
const rendered: string[] = []
131-
for (const field of fields) {
132-
if (rendered.length === MAX_STATED_FIELDS) break
133-
if (withhold && !('resource' in field)) continue
134-
const text = renderField(field, resolvedNames)
135-
if (text) rendered.push(text)
136-
}
137-
if (rendered.length > 0) statements.set(tool, rendered.join(', '))
138-
}
139-
140-
// Only claim the copies differ when their stated values actually do. Two tools bound to the same
141-
// account and folder render identically, and telling the model to "call the copy the request
142-
// refers to" would assert a distinction it cannot act on.
143-
const statementsByCanonicalId = new Map<string, Set<string>>()
144-
for (const tool of tools) {
145-
const statement = statements.get(tool)
153+
const statement = buildStatement(fields, cache, withholdLiteralValues?.(tool) ?? false)
146154
if (statement === undefined) continue
155+
statements.set(tool, statement)
156+
157+
const comparable = buildStatement(fields, cache, false) ?? statement
147158
const key = tool.canonicalId ?? tool.id
148-
const seen = statementsByCanonicalId.get(key)
149-
if (seen) seen.add(statement)
150-
else statementsByCanonicalId.set(key, new Set([statement]))
159+
const seen = comparableByCanonicalId.get(key)
160+
if (seen) seen.add(comparable)
161+
else comparableByCanonicalId.set(key, new Set([comparable]))
151162
}
152163

153164
for (const [tool, statement] of statements) {
154-
const distinct = statementsByCanonicalId.get(tool.canonicalId ?? tool.id)?.size ?? 1
165+
const distinct = comparableByCanonicalId.get(tool.canonicalId ?? tool.id)?.size ?? 1
155166
const duplicateHint =
156167
distinct > 1
157168
? ' Other copies of this tool on this agent are pinned to different values — call the copy the request refers to.'

apps/sim/providers/tool-binding.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,46 @@ describe('collectToolPinnedFields', () => {
225225
expect(collect({ subBlocks: credentialPair, resolvedResourceParams: params })).toEqual([])
226226
}
227227
})
228+
229+
it('states nothing when the caller omits the tool param map', () => {
230+
expect(
231+
collect({
232+
subBlocks: [sub({ id: 'folder', title: 'Label', type: 'folder-selector' })],
233+
userProvidedParams: { folder: 'INBOX' },
234+
})
235+
).toEqual([])
236+
})
237+
238+
it('drops a field whose title sanitizes to nothing', () => {
239+
expect(
240+
collect({
241+
subBlocks: [sub({ id: 'folder', title: '""', type: 'folder-selector' })],
242+
userProvidedParams: { folder: 'INBOX' },
243+
toolParams: toolParams('folder'),
244+
formatParamLabel: () => '""',
245+
})
246+
).toEqual([])
247+
})
248+
249+
it('truncates an oversized title', () => {
250+
const fields = collect({
251+
subBlocks: [sub({ id: 'folder', title: 'T'.repeat(80), type: 'folder-selector' })],
252+
userProvidedParams: { folder: 'INBOX' },
253+
toolParams: toolParams('folder'),
254+
})
255+
256+
expect(fields[0].title).toBe(`${'T'.repeat(40)}…`)
257+
})
258+
259+
it('drops a non-finite number', () => {
260+
expect(
261+
collect({
262+
subBlocks: [sub({ id: 'ratio', title: 'Ratio', type: 'short-input' })],
263+
userProvidedParams: { ratio: Number.NaN },
264+
toolParams: toolParams('ratio'),
265+
})
266+
).toEqual([])
267+
})
228268
})
229269

230270
describe('collectPinnedFieldsFromParams', () => {
@@ -256,6 +296,12 @@ describe('sanitizeStatedText', () => {
256296
})
257297

258298
describe('pinned field registration', () => {
299+
it('stores nothing for an empty list, so callers see undefined', () => {
300+
const tool = { id: 'gmail_read_email' }
301+
registerToolPinnedFields(tool, [])
302+
expect(getToolPinnedFields(tool)).toBeUndefined()
303+
})
304+
259305
it('reads back the fields registered for that exact tool object', () => {
260306
const tool = { id: 'gmail_read_email' }
261307
const field = { title: 'Label', value: 'INBOX' } as const

apps/sim/providers/tool-binding.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { SubBlockType } from '@sim/workflow-types/blocks'
33
import type { SubBlockConfig } from '@/blocks/types'
44
import { isNonEmpty } from '@/tools/merge-params'
55

6-
/** Resource kinds whose configured value is an opaque id the labeller must resolve to a name. */
6+
/** Resource kinds whose configured value is an opaque id that must be resolved to a name. */
77
export type BoundResourceKind = 'credential' | 'knowledgeBase'
88

99
/**
@@ -16,7 +16,7 @@ export type ToolPinnedField =
1616

1717
/**
1818
* A workflow id is resolvable too, but its name is already fetched during tool transformation, so
19-
* it never reaches the labeller as an unresolved resource.
19+
* it is stated as a literal and never leaves this module as an unresolved resource.
2020
*/
2121
type ResolvableKind = BoundResourceKind | 'workflow'
2222

@@ -114,7 +114,7 @@ function statedValue(value: unknown): string | number | boolean | undefined {
114114
return sanitizeStatedText(value) || undefined
115115
}
116116

117-
export interface PinnedFieldSourceOptions {
117+
interface PinnedFieldSourceOptions {
118118
formatParamLabel: (paramId: string) => string
119119
/** `isPasswordParameter` from `@/tools/params`, injected to avoid a static registry-side edge. */
120120
isPasswordParam: (paramId: string) => boolean
@@ -125,8 +125,9 @@ function isSecretParamId(paramId: string, options: PinnedFieldSourceOptions): bo
125125
}
126126

127127
/**
128-
* Pinned fields for a tool that has no block subblocks to describe it — an MCP or custom tool,
129-
* whose configured params are plain values keyed by the remote schema's own names.
128+
* Pinned fields for a tool that has no block subblocks to describe it. Used by the MCP path, whose
129+
* configured params are plain values keyed by the remote schema's own names. Custom tools take the
130+
* same shape but are not wired to this yet.
130131
*/
131132
export function collectPinnedFieldsFromParams(
132133
params: Record<string, unknown>,
@@ -182,8 +183,9 @@ export function collectToolPinnedFields(input: CollectToolPinnedFieldsInput): To
182183
if (!subBlocks?.length) return []
183184

184185
// A canonical pair's advanced half is a plain `short-input`, so the kind has to come from the
185-
// whole group rather than from whichever subblock is being scanned. Without this, a credential
186-
// entered in advanced mode falls through to the literal path and is stated verbatim.
186+
// whole group rather than from whichever subblock is being scanned. Without this a resource
187+
// entered in advanced mode takes the literal path: a knowledge base id would be stated verbatim,
188+
// and a credential would be dropped entirely by the secret-name check below.
187189
const kindByParamId = new Map<string, ResolvableKind>()
188190
for (const subBlock of subBlocks) {
189191
const kind = RESOURCE_SUBBLOCK_KINDS[subBlock.type]
@@ -194,7 +196,6 @@ export function collectToolPinnedFields(input: CollectToolPinnedFieldsInput): To
194196
const seenParamIds = new Set<string>()
195197

196198
for (const subBlock of subBlocks) {
197-
// A canonical pair contributes two subblocks (basic + advanced) for one logical field.
198199
const paramId = subBlock.canonicalParamId ?? subBlock.id
199200
if (seenParamIds.has(paramId)) continue
200201
if (selfDescribedParamId && paramId === selfDescribedParamId) continue

apps/sim/providers/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -896,7 +896,7 @@ export async function transformBlockTool(
896896
}
897897

898898
// A tool that rewrote its own description from a bound param already names that resource, so the
899-
// duplicate labeller must not state it twice. Keyed off the declaration rather than the rendered
899+
// pinned-param annotation must not state it twice. Keyed off the declaration rather than the rendered
900900
// text; the inequality catches an enricher that returned the description unchanged.
901901
const selfDescribedParamId =
902902
enrichedDescription && enrichedDescription !== toolConfig.description

0 commit comments

Comments
 (0)