From 89656eedcfb7751a0b1ec66168837cb0d4a7c0a4 Mon Sep 17 00:00:00 2001 From: Sirui Wang Date: Mon, 26 Oct 2026 14:57:55 -0700 Subject: [PATCH] fix(protocol): keep forwarded request headers out of logged ACP params The ACP params models rendered `request` (the forwarded x-* headers, which can carry end-user credentials) in str() and repr(), and the default, temporal and temporal-openai-agents templates plus the Temporal tutorials logged the whole params object at INFO. - Mark `request` repr=False on CreateTaskParams, SendMessageParams, SendEventParams, CancelTaskParams and InterruptTaskParams. Attribute access and model_dump() are unchanged, so handlers, the ADK and the Temporal signal payload still receive the headers. - Log task and event ids instead of the whole params in the templates, the Temporal tutorials and the procurement demo. Co-Authored-By: Claude Opus 5.5 --- .../procurement_agent/project/workflow.py | 2 +- .../000_hello_acp/project/workflow.py | 4 +- .../010_agent_chat/project/workflow.py | 4 +- .../030_custom_activities/project/workflow.py | 2 +- .../project/workflow.py | 2 +- .../project/workflow.py | 4 +- .../project/workflow.py | 4 +- .../project/workflow.py | 4 +- .../100_gemini_litellm/project/workflow.py | 4 +- .../cli/templates/default/project/acp.py.j2 | 16 ++--- .../project/workflow.py.j2 | 4 +- .../templates/temporal/project/workflow.py.j2 | 4 +- src/agentex/protocol/acp.py | 5 ++ tests/test_acp_params_repr.py | 65 +++++++++++++++++++ 14 files changed, 96 insertions(+), 28 deletions(-) create mode 100644 tests/test_acp_params_repr.py diff --git a/examples/demos/procurement_agent/project/workflow.py b/examples/demos/procurement_agent/project/workflow.py index 115c2d2f6..d2cf04939 100644 --- a/examples/demos/procurement_agent/project/workflow.py +++ b/examples/demos/procurement_agent/project/workflow.py @@ -148,7 +148,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: @workflow.run @override async def on_task_create(self, params: CreateTaskParams) -> str: - logger.info(f"Received task create params: {params}") + logger.info(f"Received task create for task {params.task.id}") self._state = StateModel(input_list=[], turn_number=0) diff --git a/examples/tutorials/10_async/10_temporal/000_hello_acp/project/workflow.py b/examples/tutorials/10_async/10_temporal/000_hello_acp/project/workflow.py index e45ecd897..1d8d37e20 100644 --- a/examples/tutorials/10_async/10_temporal/000_hello_acp/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/000_hello_acp/project/workflow.py @@ -33,7 +33,7 @@ def __init__(self): @workflow.signal(name=SignalName.RECEIVE_EVENT) @override async def on_task_event_send(self, params: SendEventParams) -> None: - logger.info(f"Received task message instruction: {params}") + logger.info(f"Received task message instruction for task {params.task.id}, event {params.event.id}") # 2. Echo back the client's message to show it in the UI. This is not done by default so the agent developer has full control over what is shown to the user. await adk.messages.create(task_id=params.task.id, content=params.event.content) @@ -51,7 +51,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: @workflow.run @override async def on_task_create(self, params: CreateTaskParams) -> None: - logger.info(f"Received task create params: {params}") + logger.info(f"Received task create for task {params.task.id}") # 1. Acknowledge that the task has been created. Gate this one-time prologue # on is_continued_run(): run_until_complete below recycles the workflow via diff --git a/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py b/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py index 7db940733..fd32cdb54 100644 --- a/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/010_agent_chat/project/workflow.py @@ -166,7 +166,7 @@ def __init__(self): @workflow.signal(name=SignalName.RECEIVE_EVENT) @override async def on_task_event_send(self, params: SendEventParams) -> None: - logger.info(f"Received task message instruction: {params}") + logger.info(f"Received task message instruction for task {params.task.id}, event {params.event.id}") if not params.event.content: return @@ -259,7 +259,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: @workflow.run @override async def on_task_create(self, params: CreateTaskParams) -> None: - logger.info(f"Received task create params: {params}") + logger.info(f"Received task create for task {params.task.id}") # 1. Initialize the state. You can either do this here or in the __init__ method. # This function is triggered whenever a client creates a task for this agent. diff --git a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow.py b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow.py index 0fa85bbb9..088ff18de 100644 --- a/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/030_custom_activities/project/workflow.py @@ -94,7 +94,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: @workflow.run @override async def on_task_create(self, params: CreateTaskParams) -> None: - logger.info(f"Received task create params: {params}") + logger.info(f"Received task create for task {params.task.id}") self._state = StateModel() await adk.messages.create( diff --git a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py index b54c8fade..9bcbe846c 100644 --- a/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/050_agent_chat_guardrails/project/workflow.py @@ -453,7 +453,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: @workflow.run @override async def on_task_create(self, params: CreateTaskParams) -> None: - logger.info(f"Received task create params: {params}") + logger.info(f"Received task create for task {params.task.id}") # 1. Initialize the state. You can either do this here or in the # __init__ method. This function is triggered whenever a client diff --git a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/workflow.py b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/workflow.py index e01f40ce6..49b79fdd8 100644 --- a/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/060_open_ai_agents_sdk_hello_world/project/workflow.py @@ -118,7 +118,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: - If the LLM call fails, Temporal automatically retries it - The entire conversation is durable and survives process restarts """ - logger.info(f"Received task message instruction: {params}") + logger.info(f"Received task message instruction for task {params.task.id}, event {params.event.id}") if self._state is None: raise ValueError("State is not initialized") @@ -253,7 +253,7 @@ async def on_task_create(self, params: CreateTaskParams) -> str: - Can resume from exact point of failure without losing context - Scales to handle millions of concurrent agent conversations """ - logger.info(f"Received task create params: {params}") + logger.info(f"Received task create for task {params.task.id}") # ============================================================================ # WORKFLOW INITIALIZATION: Initialize State diff --git a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/workflow.py b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/workflow.py index 2204d3a05..c81dc4962 100644 --- a/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/070_open_ai_agents_sdk_tools/project/workflow.py @@ -127,7 +127,7 @@ def __init__(self): @workflow.signal(name=SignalName.RECEIVE_EVENT) async def on_task_event_send(self, params: SendEventParams) -> None: - logger.info(f"Received task message instruction: {params}") + logger.info(f"Received task message instruction for task {params.task.id}, event {params.event.id}") if self._state is None: raise ValueError("State is not initialized") @@ -329,7 +329,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: @workflow.run async def on_task_create(self, params: CreateTaskParams) -> str: - logger.info(f"Received task create params: {params}") + logger.info(f"Received task create for task {params.task.id}") # Initialize the conversation state with an empty history self._state = StateModel( diff --git a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py index 4f11ac4c0..409044b57 100644 --- a/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/080_open_ai_agents_sdk_human_in_the_loop/project/workflow.py @@ -114,7 +114,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: When the agent needs human approval, it calls wait_for_confirmation which spawns a child workflow that waits for external signals before continuing. """ - logger.info(f"Received task message instruction: {params}") + logger.info(f"Received task message instruction for task {params.task.id}, event {params.event.id}") if self._state is None: raise ValueError("State is not initialized") @@ -214,7 +214,7 @@ async def on_task_create(self, params: CreateTaskParams) -> str: Handles both automated decisions and human approval workflows durably. To approve waiting actions: temporal workflow signal --workflow-id="child-workflow-id" --name="fulfill_order_signal" --input=true """ - logger.info(f"Received task create params: {params}") + logger.info(f"Received task create for task {params.task.id}") # Initialize the conversation state with an empty history self._state = StateModel( diff --git a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/workflow.py b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/workflow.py index 249bdaa50..20cb31487 100644 --- a/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/workflow.py +++ b/examples/tutorials/10_async/10_temporal/100_gemini_litellm/project/workflow.py @@ -112,7 +112,7 @@ async def on_task_event_send(self, params: SendEventParams) -> None: - The agent interface remains identical to OpenAI examples - Temporal durability works the same way regardless of model provider """ - logger.info(f"Received task message instruction: {params}") + logger.info(f"Received task message instruction for task {params.task.id}, event {params.event.id}") if self._state is None: raise ValueError("State is not initialized") @@ -198,7 +198,7 @@ async def on_task_create(self, params: CreateTaskParams) -> str: This method runs when the workflow starts and keeps the agent conversation alive. The pattern is identical to other tutorials - only the model provider changes. """ - logger.info(f"Received task create params: {params}") + logger.info(f"Received task create for task {params.task.id}") # Initialize the conversation state self._state = StateModel( diff --git a/src/agentex/lib/cli/templates/default/project/acp.py.j2 b/src/agentex/lib/cli/templates/default/project/acp.py.j2 index b0da14a5c..23e5025a2 100644 --- a/src/agentex/lib/cli/templates/default/project/acp.py.j2 +++ b/src/agentex/lib/cli/templates/default/project/acp.py.j2 @@ -24,9 +24,9 @@ acp = FastACP.create( # It's a good place to initialize any state or resources needed for the task. @acp.on_task_event_send async def handle_task_event_send(params: SendEventParams): - # For this tutorial, we log the parameters sent to the handler - # so you can see where and how messages within a long running task are handled - logger.info(f"Received task event send rpc: {params}") + # For this tutorial, we log the task and event ids so you can see where and how + # messages within a long running task are handled + logger.info(f"Received task event send rpc for task {params.task.id}, event {params.event.id}") # 1. Echo back the client's message to show it in the UI. This is not done by default so the agent developer has full control over what is shown to the user. await adk.messages.create(task_id=params.task.id, content=params.event.content) @@ -43,14 +43,12 @@ async def handle_task_event_send(params: SendEventParams): @acp.on_task_cancel async def handle_task_canceled(params: CancelTaskParams): - # For this tutorial, we print the parameters sent to the handler - # so you can see where and how task cancellation is handled - logger.info(f"Received task cancel rpc: {params}") + # For this tutorial, we log the task id so you can see where and how task cancellation is handled + logger.info(f"Received task cancel rpc for task {params.task.id}") @acp.on_task_create async def handle_task_create(params: CreateTaskParams): - # For this tutorial, we log the parameters sent to the handler - # so you can see where and how task creation is handled + # For this tutorial, we log the task id so you can see where and how task creation is handled # Here is where you can initialize any state or resources needed for the task. - logger.info(f"Received task create rpc: {params}") + logger.info(f"Received task create rpc for task {params.task.id}") diff --git a/src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 b/src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 index 6897cd5a8..106d5b79c 100644 --- a/src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-openai-agents/project/workflow.py.j2 @@ -105,7 +105,7 @@ class {{ workflow_class }}(BaseWorkflow): @workflow.signal(name=SignalName.RECEIVE_EVENT) async def on_task_event_send(self, params: SendEventParams) -> None: - logger.info(f"Received task message instruction: {params}") + logger.info(f"Received task message instruction for task {params.task.id}, event {params.event.id}") content = params.event.content if not isinstance(content, TextContent): @@ -170,7 +170,7 @@ class {{ workflow_class }}(BaseWorkflow): @workflow.run async def on_task_create(self, params: CreateTaskParams) -> str: - logger.info(f"Received task create params: {params}") + logger.info(f"Received task create for task {params.task.id}") # Acknowledge that the task has been created await adk.messages.create( diff --git a/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 b/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 index 8c23ecfc1..f8c813f3f 100644 --- a/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 +++ b/src/agentex/lib/cli/templates/temporal/project/workflow.py.j2 @@ -31,7 +31,7 @@ class {{ workflow_class }}(BaseWorkflow): @workflow.signal(name=SignalName.RECEIVE_EVENT) async def on_task_event_send(self, params: SendEventParams) -> None: - logger.info(f"Received task message instruction: {params}") + logger.info(f"Received task message instruction for task {params.task.id}, event {params.event.id}") # 2. Echo back the client's message to show it in the UI. This is not done by default so the agent developer has full control over what is shown to the user. await adk.messages.create(task_id=params.task.id, content=params.event.content) @@ -48,7 +48,7 @@ class {{ workflow_class }}(BaseWorkflow): @workflow.run async def on_task_create(self, params: CreateTaskParams) -> str: - logger.info(f"Received task create params: {params}") + logger.info(f"Received task create for task {params.task.id}") # 1. Acknowledge that the task has been created. await adk.messages.create( diff --git a/src/agentex/protocol/acp.py b/src/agentex/protocol/acp.py index 917deec48..c722987b6 100644 --- a/src/agentex/protocol/acp.py +++ b/src/agentex/protocol/acp.py @@ -40,6 +40,7 @@ class CreateTaskParams(BaseModel): request: dict[str, Any] | None = Field( default=None, description="Additional request context including headers forwarded to this agent", + repr=False, # forwarded headers can carry end-user credentials; keep them out of str() and logs ) @@ -66,6 +67,7 @@ class SendMessageParams(BaseModel): request: dict[str, Any] | None = Field( default=None, description="Additional request context including headers forwarded to this agent", + repr=False, # forwarded headers can carry end-user credentials; keep them out of str() and logs ) @@ -85,6 +87,7 @@ class SendEventParams(BaseModel): request: dict[str, Any] | None = Field( default=None, description="Additional request context including headers forwarded to this agent", + repr=False, # forwarded headers can carry end-user credentials; keep them out of str() and logs ) @@ -102,6 +105,7 @@ class CancelTaskParams(BaseModel): request: dict[str, Any] | None = Field( default=None, description="Additional request context including headers forwarded to this agent", + repr=False, # forwarded headers can carry end-user credentials; keep them out of str() and logs ) @@ -124,6 +128,7 @@ class InterruptTaskParams(BaseModel): request: dict[str, Any] | None = Field( default=None, description="Additional request context including headers forwarded to this agent", + repr=False, # forwarded headers can carry end-user credentials; keep them out of str() and logs ) diff --git a/tests/test_acp_params_repr.py b/tests/test_acp_params_repr.py new file mode 100644 index 000000000..05d62727d --- /dev/null +++ b/tests/test_acp_params_repr.py @@ -0,0 +1,65 @@ +"""Forwarded request headers must stay out of the string form of ACP params. + +``BaseACPServer`` copies inbound ``x-*`` headers (which can carry end-user +credentials) into ``params.request``. Agent code commonly logs ``f"{params}"``, +so ``request`` is excluded from ``str()``/``repr()``, while handlers and the +Temporal signal payload (``model_dump()``) still receive it. +""" + +from __future__ import annotations + +import pytest + +from agentex.types.task import Task +from agentex.types.agent import Agent +from agentex.types.event import Event +from agentex.protocol.acp import ( + SendEventParams, + CancelTaskParams, + CreateTaskParams, + SendMessageParams, + InterruptTaskParams, +) +from agentex.types.text_content import TextContent + +CANARY = "canary-forwarded-header-value" +REQUEST = {"headers": {"x-user-oauth-credentials": CANARY}} + +_agent = Agent( + id="agent-1", + name="agent", + description="d", + acp_type="async", + created_at="2023-01-01T00:00:00Z", + updated_at="2023-01-01T00:00:00Z", +) +_task = Task(id="task-1") +_content = TextContent(author="user", content="hi") +_event = Event(id="event-1", agent_id="agent-1", task_id="task-1", sequence_id=1, content=_content) + +AnyParams = CreateTaskParams | SendMessageParams | SendEventParams | CancelTaskParams | InterruptTaskParams + +PARAMS: list[AnyParams] = [ + CreateTaskParams(agent=_agent, task=_task, params={"k": "v"}, request=REQUEST), + SendMessageParams(agent=_agent, task=_task, content=_content, stream=False, request=REQUEST), + SendEventParams(agent=_agent, task=_task, event=_event, request=REQUEST), + CancelTaskParams(agent=_agent, task=_task, request=REQUEST), + InterruptTaskParams(agent=_agent, task=_task, request=REQUEST), +] + + +@pytest.mark.parametrize("params", PARAMS, ids=lambda p: type(p).__name__) +def test_header_values_absent_from_str_and_repr(params: AnyParams) -> None: + assert CANARY not in str(params) + assert CANARY not in repr(params) + assert "task-1" in str(params) + + +@pytest.mark.parametrize("params", PARAMS, ids=lambda p: type(p).__name__) +def test_headers_still_reach_handlers_and_payloads(params: AnyParams) -> None: + assert params.request == REQUEST + dumped = params.model_dump() + assert dumped["request"] == REQUEST + assert CANARY in params.model_dump_json() + # TemporalTaskService signals model_dump() and the workflow re-validates it. + assert type(params).model_validate(dumped).request == REQUEST