Python: Expose forwardedProps to agents and tools via session metadata (#5264)

* Expose forwarded_props to agents and tools via session metadata (#5239)

Include forwarded_props from AG-UI request input_data in session.metadata
(agent runner) and function_invocation_kwargs (workflow runner) so that
agents, tools, and workflow executors can access request-level metadata
such as invocation source flags from CopilotKit.

- Add forwarded_props to base_metadata in _agent_run.py when present
- Add 'forwarded_props' to AG_UI_INTERNAL_METADATA_KEYS to filter it
  from LLM-bound client metadata
- Extract forwarded_props in _workflow_run.py and pass via
  function_invocation_kwargs to workflow.run()
- Accept both snake_case and camelCase keys (forwarded_props/forwardedProps)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(ag-ui): pass stream=True as literal to satisfy pyright overload resolution (#5239)

The previous fix passed stream=True via **kwargs dict, which prevented
pyright from resolving the Workflow.run() overload to the streaming
variant. Pass stream=True as an explicit keyword argument so pyright
can correctly infer the ResponseStream return type.

Also remove unused pytest import in test file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: address PR review feedback for forwarded_props (#5239)

- Use key-presence checks instead of truthiness for forwarded_props so
  empty dict {} is forwarded correctly
- Gate function_invocation_kwargs on workflow.run() signature inspection
  to avoid TypeError for workflows without **kwargs
- Change _build_safe_metadata to drop (with warning) keys whose
  serialized values exceed 512 chars instead of truncating into invalid
  JSON
- Rewrite metadata tests to exercise _build_safe_metadata directly with
  JSON-decodability and truncation assertions
- Add workflow tests for empty dict forwarded_props, stream=True
  assertion, and signature-gated kwarg dropping

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: add stream=True assertions to CapturingWorkflow tests (#5239)

Guard against accidental removal of the explicit stream=True kwarg
in all forwarded_props CapturingWorkflow test cases.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review feedback for #5239: Python: Expose forwardedProps to agents and tools via session metadata

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Evan Mattson
2026-04-21 13:25:45 +09:00
committed by GitHub
Unverified
parent 04aaf0c1fe
commit 07f4c8a8d6
5 changed files with 339 additions and 13 deletions
@@ -69,19 +69,23 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
# Keys that are internal to AG-UI orchestration and should not be passed to chat clients
AG_UI_INTERNAL_METADATA_KEYS = {"ag_ui_thread_id", "ag_ui_run_id", "current_state"}
AG_UI_INTERNAL_METADATA_KEYS = {"ag_ui_thread_id", "ag_ui_run_id", "current_state", "forwarded_props"}
def _build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, Any]:
"""Build metadata dict with truncated string values for Azure compatibility.
"""Build metadata dict with string values for Azure compatibility.
Azure has a 512 character limit per metadata value.
Azure has a 512 character limit per metadata value. String values that
already fit are kept as-is. Non-string values are JSON-serialized. If the
resulting string exceeds 512 characters the key is **dropped** (with a
warning) instead of truncated, because truncation can produce invalid JSON
that downstream consumers cannot decode.
Args:
thread_metadata: Raw metadata dict
Returns:
Metadata with string values truncated to 512 chars
Metadata with safe string values (each <= 512 chars)
"""
if not thread_metadata:
return {}
@@ -89,7 +93,12 @@ def _build_safe_metadata(thread_metadata: dict[str, Any] | None) -> dict[str, An
for key, value in thread_metadata.items():
value_str = value if isinstance(value, str) else json.dumps(value)
if len(value_str) > 512:
value_str = value_str[:512]
logger.warning(
"Dropping metadata key %r: serialized value is %d chars (limit 512)",
key,
len(value_str),
)
continue
safe_metadata[key] = value_str
return safe_metadata
@@ -790,6 +799,10 @@ async def run_agent_stream(
"ag_ui_thread_id": thread_id,
"ag_ui_run_id": run_id,
}
if "forwarded_props" in input_data:
base_metadata["forwarded_props"] = input_data["forwarded_props"]
elif "forwardedProps" in input_data:
base_metadata["forwarded_props"] = input_data["forwardedProps"]
if flow.current_state:
base_metadata["current_state"] = flow.current_state
session.metadata = _build_safe_metadata(base_metadata) # type: ignore[attr-defined]
@@ -4,6 +4,7 @@
from __future__ import annotations
import inspect
import json
import logging
import uuid
@@ -581,11 +582,33 @@ async def run_workflow_stream(
flow.accumulated_text = ""
return [TextMessageEndEvent(message_id=current_message_id)]
fwd_kwargs: dict[str, Any] = {}
if "forwarded_props" in input_data:
forwarded_props = input_data["forwarded_props"]
fwd_kwargs["function_invocation_kwargs"] = {"forwarded_props": forwarded_props}
elif "forwardedProps" in input_data:
forwarded_props = input_data["forwardedProps"]
fwd_kwargs["function_invocation_kwargs"] = {"forwarded_props": forwarded_props}
# Only pass function_invocation_kwargs if the workflow.run signature accepts it
if fwd_kwargs:
try:
sig = inspect.signature(workflow.run)
params = sig.parameters
accepts_fwd = "function_invocation_kwargs" in params or any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
)
except (ValueError, TypeError):
accepts_fwd = False
if not accepts_fwd:
logger.debug("workflow.run() does not accept function_invocation_kwargs; dropping forwarded_props")
fwd_kwargs = {}
try:
if responses:
event_stream = workflow.run(responses=responses, stream=True)
event_stream = workflow.run(responses=responses, stream=True, **fwd_kwargs)
else:
event_stream = workflow.run(message=messages, stream=True)
event_stream = workflow.run(message=messages, stream=True, **fwd_kwargs)
async for event in event_stream:
event_type = getattr(event, "type", None)