@@ -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.'
0 commit comments