8282from ..auth .auth_schemes import AuthScheme
8383from ..auth .auth_tool import AuthConfig
8484from ..events .event import Event
85+ from ..flows .llm_flows ._fencing import quote_untrusted
8586from ..flows .llm_flows .contents import _is_other_agent_reply
8687from ..flows .llm_flows .contents import _present_other_agent_message
8788from ..flows .llm_flows .functions import find_matching_function_call
150151# producer in flows.llm_flows.functions emits the camelCase form.
151152_AUTH_CONFIG_ARG_KEYS = ("authConfig" , "auth_config" )
152153
154+ # A card description fetched over the network is peer-controlled text that a
155+ # parent agent puts in its own instruction, so it is capped before being fenced.
156+ _MAX_CARD_DESCRIPTION_CHARS = 1024
157+ # Marks a capped description as incomplete, so neither the model nor a reader
158+ # takes the cut-off text for the whole description.
159+ _CARD_DESCRIPTION_TRUNCATION_SUFFIX = "... [truncated]"
160+
153161
154162def _payload_is_auth_config (payload : Any ) -> bool :
155163 """Whether a payload looks like a serialized AuthConfig (fail closed)."""
@@ -193,41 +201,74 @@ def _is_credential_function_call(
193201 )
194202
195203
196- def _without_credential_function_calls (event : Event ) -> Event :
197- """Returns ``event`` with any credential-bearing function_call removed.
204+ def _is_credential_part (part : genai_types .Part ) -> bool :
205+ """Whether a part carries credential material (fail closed).
206+
207+ Both directions count: the request call carries the serialized AuthConfig in
208+ its args, and the matching response carries the exchanged credential.
209+ """
210+ if part .function_response is not None :
211+ return _is_credential_function_response (part .function_response )
212+ if part .function_call is not None :
213+ return _is_credential_function_call (part .function_call )
214+ return False
215+
216+
217+ def _without_credential_parts (event : Event ) -> Event :
218+ """Returns ``event`` with every credential-bearing part removed.
198219
199220 An `adk_request_credential` call carries a serialized `AuthConfig` in its
200221 arguments, including `raw_auth_credential` (an OAuth2 client secret or a
201- service account key). A flow appends that event to the session when it asks
202- the client for a credential, so it is in the history that the next request is
203- rebuilt from and would otherwise be sent to the remote peer.
222+ service account key), and the matching response carries the credential that
223+ was exchanged for it. A flow appends both to the session, so they are in the
224+ history that the next request is rebuilt from.
225+
226+ Filtering the event rather than the parts that come out of it matters because
227+ another agent's turn is flattened into quoted text before it is forwarded: by
228+ then the credential is a string like any other, and dropping it afterwards is
229+ no longer possible.
204230
205231 Args:
206232 event: The session event to scrub.
207233
208234 Returns:
209- The event unchanged when it holds no credential call , or a copy without
235+ The event unchanged when it holds no credential part , or a copy without
210236 those parts.
211237 """
212- if not event .content or not event .content .parts :
238+ if event .content is None or not event .content .parts :
213239 return event
214- if not any (
215- part .function_call is not None
216- and _is_credential_function_call (part .function_call )
217- for part in event .content .parts
218- ):
240+ if not any (_is_credential_part (part ) for part in event .content .parts ):
219241 return event
220-
221- scrubbed = event .model_copy (deep = True )
222- content = scrubbed .content
223- assert content is not None
224- content .parts = [
225- part
226- for part in content .parts or []
227- if part .function_call is None
228- or not _is_credential_function_call (part .function_call )
242+ new_event = event .model_copy (deep = True )
243+ # ``event.content`` is non-None (checked above) and ``model_copy`` preserves
244+ # it; bind a local so the checker keeps it narrowed after ``.parts`` is set.
245+ new_content = new_event .content
246+ assert new_content is not None
247+ new_content .parts = [
248+ part for part in new_content .parts or [] if not _is_credential_part (part )
229249 ]
230- return scrubbed
250+ return new_event
251+
252+
253+ def _adopted_card_description (
254+ description : str , agent_card_source : Optional [str ]
255+ ) -> str :
256+ """Returns the description to adopt from a resolved agent card.
257+
258+ A parent agent interpolates a transfer target's description straight into its
259+ own instruction, so a description that arrived over the network is capped and
260+ fenced as quoted peer content -- the same treatment the peer's message text
261+ already gets. A card read from a local file is the caller's own text and is
262+ adopted unchanged.
263+ """
264+ if not agent_card_source or not agent_card_source .startswith (
265+ ("http://" , "https://" )
266+ ):
267+ return description
268+ capped = description [:_MAX_CARD_DESCRIPTION_CHARS ]
269+ if len (capped ) < len (description ):
270+ capped += _CARD_DESCRIPTION_TRUNCATION_SUFFIX
271+ return quote_untrusted (capped )
231272
232273
233274def _render_user_function_response (
@@ -1002,7 +1043,9 @@ async def _ensure_resolved(
10021043
10031044 # Update description if empty
10041045 if not self .description and agent_card .description :
1005- self .description = agent_card .description
1046+ self .description = _adopted_card_description (
1047+ agent_card .description , self ._agent_card_source
1048+ )
10061049
10071050 # Initialize A2A client
10081051 if not self ._a2a_client :
@@ -1191,9 +1234,10 @@ def _construct_message_parts_from_session(
11911234
11921235 for event in reversed (events_to_process ):
11931236 # Drop credential material before anything else looks at the event.
1194- # `_present_other_agent_message` renders a function_call as text with its
1195- # arguments inlined, so scrubbing after it would be too late.
1196- scrubbed_event = _without_credential_function_calls (event )
1237+ # `_present_other_agent_message` renders a function_call and a
1238+ # function_response as text with their payloads inlined, so scrubbing
1239+ # after it would be too late.
1240+ scrubbed_event = _without_credential_parts (event )
11971241 processed_event : Optional [Event ] = scrubbed_event
11981242 if _is_other_agent_reply (self .name , scrubbed_event ):
11991243 processed_event = _present_other_agent_message (scrubbed_event )
@@ -1206,17 +1250,6 @@ def _construct_message_parts_from_session(
12061250 continue
12071251
12081252 for part in processed_event .content .parts :
1209- if (
1210- part .function_response is not None
1211- and _is_credential_function_response (part .function_response )
1212- ):
1213- # Never forward credential material (an AuthConfig envelope with
1214- # access tokens / client secrets) to the remote peer, even when
1215- # reconstructing the request from raw session history. This closes the
1216- # path where a dropped credential resume falls back to here and the
1217- # untouched function_response would otherwise be re-serialized.
1218- continue
1219-
12201253 if (
12211254 self .mode == "task"
12221255 and task_scope
0 commit comments