Skip to content

Adapters

Adapters transform framework-specific traces into a canonical AgentTrace. Usage and custom schema mapping: Adapters guide.

uaef.adapters.base

Base adapter interface for framework-specific trace transformation.

AdapterTransformationError

Bases: Exception

Exception raised when adapter transformation fails.

This exception should be raised when: - Required fields are missing from the raw data - Data format is invalid or unexpected - Transformation logic encounters an error

Source code in uaef/adapters/base.py
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class AdapterTransformationError(Exception):
    """
    Exception raised when adapter transformation fails.

    This exception should be raised when:
    - Required fields are missing from the raw data
    - Data format is invalid or unexpected
    - Transformation logic encounters an error
    """

    def __init__(self, message: str, adapter_name: str = None, original_error: Exception = None):
        """
        Initialize the exception.

        Args:
            message: Human-readable error message
            adapter_name: Name of the adapter that raised the error
            original_error: Original exception that caused the transformation failure
        """
        self.adapter_name = adapter_name
        self.original_error = original_error

        full_message = message
        if adapter_name:
            full_message = f"[{adapter_name}] {message}"
        if original_error:
            full_message = f"{full_message} (caused by: {str(original_error)})"

        super().__init__(full_message)

BaseAdapter

Bases: ABC

Abstract base class for framework-specific trace adapters.

All adapters must implement this interface to transform framework-specific traces into the canonical UAEF AgentTrace format. This ensures consistent evaluation across different agent frameworks.

The transformation process should: 1. Extract all messages from the framework-specific trace 2. Extract all tool calls with their arguments and results 3. Extract metadata (tokens, latency, cost) 4. Construct a canonical AgentTrace object 5. Preserve all information from the original trace (no data loss)

Source code in uaef/adapters/base.py
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
class BaseAdapter(ABC):
    """
    Abstract base class for framework-specific trace adapters.

    All adapters must implement this interface to transform framework-specific
    traces into the canonical UAEF AgentTrace format. This ensures consistent
    evaluation across different agent frameworks.

    The transformation process should:
    1. Extract all messages from the framework-specific trace
    2. Extract all tool calls with their arguments and results
    3. Extract metadata (tokens, latency, cost)
    4. Construct a canonical AgentTrace object
    5. Preserve all information from the original trace (no data loss)
    """

    @property
    @abstractmethod
    def name(self) -> str:
        """
        Return the name of this adapter.

        Returns:
            Adapter name (e.g., "langgraph", "bedrock", "langchain")
        """
        pass

    @abstractmethod
    def transform_to_canonical(self, raw_data: Dict[str, Any]) -> Union[AgentTrace, MultiAgentTrace]:
        """
        Transform framework-specific trace data to canonical format.

        Returns AgentTrace for single-agent traces, or MultiAgentTrace when
        multiple agent nodes are detected.

        Args:
            raw_data: Framework-specific trace data as a dictionary

        Returns:
            AgentTrace or MultiAgentTrace

        Raises:
            AdapterTransformationError: If transformation fails
        """
        pass

    def supports_session_transform(self) -> bool:
        """
        Whether ``transform_to_canonical`` accepts a *list* of per-turn payloads
        and returns one session dict per conversation.

        Multi-turn evaluation needs a whole conversation assembled into
        ``{"session_id", "per_turn_traces", "full_trace"}``. Only adapters that
        implement that list form can produce it; the rest take a single payload
        and know nothing about turns.

        Concrete (not abstract) and defaulting to ``False`` so existing adapters
        keep working untouched and simply opt out. Callers must check this before
        handing a list to :meth:`transform_to_canonical` — passing one to an
        adapter that expects a dict fails in whatever way that adapter happens to
        fail, which is not a useful error.

        Returns:
            True if list/session input is supported, False otherwise.
        """
        return False

    @abstractmethod
    def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
        """
        Extract messages from framework-specific trace data.

        Messages should be extracted in chronological order and include:
        - User messages (inputs)
        - Assistant messages (agent responses)
        - System messages (if present)
        - Tool messages (if present)

        Args:
            raw_data: Framework-specific trace data

        Returns:
            List of Message objects in chronological order

        Raises:
            AdapterTransformationError: If message extraction fails
        """
        pass

    @abstractmethod
    def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
        """
        Extract tool calls from framework-specific trace data.

        Tool calls should include:
        - Tool name
        - Arguments passed to the tool
        - Result returned by the tool (if available)
        - Timestamp of the call
        - Execution time (if available)
        - Error information (if the call failed)

        Args:
            raw_data: Framework-specific trace data

        Returns:
            List of ToolCall objects in chronological order

        Raises:
            AdapterTransformationError: If tool call extraction fails
        """
        pass

    @abstractmethod
    def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Extract metadata from framework-specific trace data.

        Metadata should include (when available):
        - input_tokens: Number of input tokens consumed
        - output_tokens: Number of output tokens generated
        - latency: Total execution time in seconds
        - cost: Estimated cost in USD
        - model: Model name/identifier
        - framework_version: Version of the framework
        - Any other framework-specific metadata

        Args:
            raw_data: Framework-specific trace data

        Returns:
            Dictionary of metadata key-value pairs

        Raises:
            AdapterTransformationError: If metadata extraction fails
        """
        pass

    def validate_raw_data(self, raw_data: Dict[str, Any], required_keys: List[str]) -> None:
        """
        Validate that raw data contains required keys.

        This is a helper method that adapters can use to validate input data.

        Args:
            raw_data: Framework-specific trace data
            required_keys: List of required keys that must be present

        Raises:
            AdapterTransformationError: If any required key is missing
        """
        missing_keys = [key for key in required_keys if key not in raw_data]
        if missing_keys:
            raise AdapterTransformationError(
                f"Missing required keys: {', '.join(missing_keys)}",
                adapter_name=self.name
            )

name abstractmethod property

Return the name of this adapter.

Returns:

Type Description
str

Adapter name (e.g., "langgraph", "bedrock", "langchain")

extract_messages(raw_data) abstractmethod

Extract messages from framework-specific trace data.

Messages should be extracted in chronological order and include: - User messages (inputs) - Assistant messages (agent responses) - System messages (if present) - Tool messages (if present)

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Framework-specific trace data

required

Returns:

Type Description
List[Message]

List of Message objects in chronological order

Raises:

Type Description
AdapterTransformationError

If message extraction fails

Source code in uaef/adapters/base.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
@abstractmethod
def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
    """
    Extract messages from framework-specific trace data.

    Messages should be extracted in chronological order and include:
    - User messages (inputs)
    - Assistant messages (agent responses)
    - System messages (if present)
    - Tool messages (if present)

    Args:
        raw_data: Framework-specific trace data

    Returns:
        List of Message objects in chronological order

    Raises:
        AdapterTransformationError: If message extraction fails
    """
    pass

extract_metadata(raw_data) abstractmethod

Extract metadata from framework-specific trace data.

Metadata should include (when available): - input_tokens: Number of input tokens consumed - output_tokens: Number of output tokens generated - latency: Total execution time in seconds - cost: Estimated cost in USD - model: Model name/identifier - framework_version: Version of the framework - Any other framework-specific metadata

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Framework-specific trace data

required

Returns:

Type Description
Dict[str, Any]

Dictionary of metadata key-value pairs

Raises:

Type Description
AdapterTransformationError

If metadata extraction fails

Source code in uaef/adapters/base.py
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
@abstractmethod
def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
    """
    Extract metadata from framework-specific trace data.

    Metadata should include (when available):
    - input_tokens: Number of input tokens consumed
    - output_tokens: Number of output tokens generated
    - latency: Total execution time in seconds
    - cost: Estimated cost in USD
    - model: Model name/identifier
    - framework_version: Version of the framework
    - Any other framework-specific metadata

    Args:
        raw_data: Framework-specific trace data

    Returns:
        Dictionary of metadata key-value pairs

    Raises:
        AdapterTransformationError: If metadata extraction fails
    """
    pass

extract_tool_calls(raw_data) abstractmethod

Extract tool calls from framework-specific trace data.

Tool calls should include: - Tool name - Arguments passed to the tool - Result returned by the tool (if available) - Timestamp of the call - Execution time (if available) - Error information (if the call failed)

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Framework-specific trace data

required

Returns:

Type Description
List[ToolCall]

List of ToolCall objects in chronological order

Raises:

Type Description
AdapterTransformationError

If tool call extraction fails

Source code in uaef/adapters/base.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
@abstractmethod
def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
    """
    Extract tool calls from framework-specific trace data.

    Tool calls should include:
    - Tool name
    - Arguments passed to the tool
    - Result returned by the tool (if available)
    - Timestamp of the call
    - Execution time (if available)
    - Error information (if the call failed)

    Args:
        raw_data: Framework-specific trace data

    Returns:
        List of ToolCall objects in chronological order

    Raises:
        AdapterTransformationError: If tool call extraction fails
    """
    pass

supports_session_transform()

Whether transform_to_canonical accepts a list of per-turn payloads and returns one session dict per conversation.

Multi-turn evaluation needs a whole conversation assembled into {"session_id", "per_turn_traces", "full_trace"}. Only adapters that implement that list form can produce it; the rest take a single payload and know nothing about turns.

Concrete (not abstract) and defaulting to False so existing adapters keep working untouched and simply opt out. Callers must check this before handing a list to :meth:transform_to_canonical — passing one to an adapter that expects a dict fails in whatever way that adapter happens to fail, which is not a useful error.

Returns:

Type Description
bool

True if list/session input is supported, False otherwise.

Source code in uaef/adapters/base.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
def supports_session_transform(self) -> bool:
    """
    Whether ``transform_to_canonical`` accepts a *list* of per-turn payloads
    and returns one session dict per conversation.

    Multi-turn evaluation needs a whole conversation assembled into
    ``{"session_id", "per_turn_traces", "full_trace"}``. Only adapters that
    implement that list form can produce it; the rest take a single payload
    and know nothing about turns.

    Concrete (not abstract) and defaulting to ``False`` so existing adapters
    keep working untouched and simply opt out. Callers must check this before
    handing a list to :meth:`transform_to_canonical` — passing one to an
    adapter that expects a dict fails in whatever way that adapter happens to
    fail, which is not a useful error.

    Returns:
        True if list/session input is supported, False otherwise.
    """
    return False

transform_to_canonical(raw_data) abstractmethod

Transform framework-specific trace data to canonical format.

Returns AgentTrace for single-agent traces, or MultiAgentTrace when multiple agent nodes are detected.

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Framework-specific trace data as a dictionary

required

Returns:

Type Description
Union[AgentTrace, MultiAgentTrace]

AgentTrace or MultiAgentTrace

Raises:

Type Description
AdapterTransformationError

If transformation fails

Source code in uaef/adapters/base.py
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
@abstractmethod
def transform_to_canonical(self, raw_data: Dict[str, Any]) -> Union[AgentTrace, MultiAgentTrace]:
    """
    Transform framework-specific trace data to canonical format.

    Returns AgentTrace for single-agent traces, or MultiAgentTrace when
    multiple agent nodes are detected.

    Args:
        raw_data: Framework-specific trace data as a dictionary

    Returns:
        AgentTrace or MultiAgentTrace

    Raises:
        AdapterTransformationError: If transformation fails
    """
    pass

validate_raw_data(raw_data, required_keys)

Validate that raw data contains required keys.

This is a helper method that adapters can use to validate input data.

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Framework-specific trace data

required
required_keys List[str]

List of required keys that must be present

required

Raises:

Type Description
AdapterTransformationError

If any required key is missing

Source code in uaef/adapters/base.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
def validate_raw_data(self, raw_data: Dict[str, Any], required_keys: List[str]) -> None:
    """
    Validate that raw data contains required keys.

    This is a helper method that adapters can use to validate input data.

    Args:
        raw_data: Framework-specific trace data
        required_keys: List of required keys that must be present

    Raises:
        AdapterTransformationError: If any required key is missing
    """
    missing_keys = [key for key in required_keys if key not in raw_data]
    if missing_keys:
        raise AdapterTransformationError(
            f"Missing required keys: {', '.join(missing_keys)}",
            adapter_name=self.name
        )

uaef.adapters.registry

Adapter registry and factory for managing framework adapters.

AdapterRegistry

Registry for managing framework-specific adapters.

This registry allows: - Registration of built-in adapters - Registration of custom adapters - Retrieval of adapters by framework name - Validation of adapter implementations

Source code in uaef/adapters/registry.py
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
class AdapterRegistry:
    """
    Registry for managing framework-specific adapters.

    This registry allows:
    - Registration of built-in adapters
    - Registration of custom adapters
    - Retrieval of adapters by framework name
    - Validation of adapter implementations
    """

    def __init__(self):
        """Initialize the adapter registry."""
        self._adapters: Dict[str, Type[BaseAdapter]] = {}
        self._adapter_instances: Dict[str, BaseAdapter] = {}
        self._register_builtin_adapters()

    def _register_builtin_adapters(self) -> None:
        """Register built-in adapters."""
        try:
            from uaef.adapters.langgraph import LangGraphAdapter
            self.register("langgraph", LangGraphAdapter)
        except ImportError:
            pass

        try:
            from uaef.adapters.langchain import LangChainAdapter
            self.register("langchain", LangChainAdapter)
        except ImportError:
            pass

        try:
            from uaef.adapters.bedrock import BedrockAgentAdapter
            self.register("bedrock", BedrockAgentAdapter)
        except ImportError:
            pass

        try:
            from uaef.adapters.agentcore import AgentCoreAdapter
            self.register("agentcore", AgentCoreAdapter)
        except ImportError:
            pass

        try:
            from uaef.adapters.langfuse import LangfuseAdapter
            self.register("langfuse", LangfuseAdapter)
        except ImportError:
            pass

        try:
            from uaef.adapters.strands import StrandsAdapter
            self.register("strands", StrandsAdapter)
        except ImportError:
            pass

        try:
            from uaef.adapters.generic import GenericJSONAdapter
            self.register("generic", GenericJSONAdapter)
        except ImportError:
            pass

    def register(
        self,
        name: str,
        adapter_class: Type[BaseAdapter],
        override: bool = False
    ) -> None:
        """
        Register an adapter class.

        Args:
            name: Framework name (e.g., "langgraph", "bedrock")
            adapter_class: Adapter class that implements BaseAdapter
            override: If True, allow overriding existing adapters

        Raises:
            ValueError: If adapter is already registered and override is False
            TypeError: If adapter_class does not implement BaseAdapter
        """
        # Validate adapter class
        if not issubclass(adapter_class, BaseAdapter):
            raise TypeError(
                f"Adapter class {adapter_class.__name__} must inherit from BaseAdapter"
            )

        # Check for existing registration
        if name in self._adapters and not override:
            raise ValueError(
                f"Adapter '{name}' is already registered. "
                f"Use override=True to replace it."
            )

        # Register the adapter
        self._adapters[name] = adapter_class

        # Clear cached instance if it exists
        if name in self._adapter_instances:
            del self._adapter_instances[name]

    def get_adapter(
        self,
        name: str,
        adapter_config: Optional[Dict[str, Any]] = None
    ) -> BaseAdapter:
        """
        Get an adapter instance by framework name.

        Args:
            name: Framework name (e.g., "langgraph", "bedrock")
            adapter_config: Optional configuration for adapter initialization

        Returns:
            Adapter instance

        Raises:
            ValueError: If adapter is not registered
        """
        if name not in self._adapters:
            raise ValueError(
                f"Adapter '{name}' is not registered. "
                f"Available adapters: {', '.join(self.list_adapters())}"
            )

        # Check if we need to create a new instance (for adapters with config)
        if adapter_config:
            adapter_class = self._adapters[name]
            return adapter_class(**adapter_config)

        # Return cached instance or create new one
        if name not in self._adapter_instances:
            adapter_class = self._adapters[name]
            self._adapter_instances[name] = adapter_class()

        return self._adapter_instances[name]

    def list_adapters(self) -> list[str]:
        """
        List all registered adapter names.

        Returns:
            List of adapter names
        """
        return list(self._adapters.keys())

    def is_registered(self, name: str) -> bool:
        """
        Check if an adapter is registered.

        Args:
            name: Framework name

        Returns:
            True if adapter is registered, False otherwise
        """
        return name in self._adapters

    def unregister(self, name: str) -> None:
        """
        Unregister an adapter.

        Args:
            name: Framework name

        Raises:
            ValueError: If adapter is not registered
        """
        if name not in self._adapters:
            raise ValueError(f"Adapter '{name}' is not registered")

        del self._adapters[name]

        if name in self._adapter_instances:
            del self._adapter_instances[name]

get_adapter(name, adapter_config=None)

Get an adapter instance by framework name.

Parameters:

Name Type Description Default
name str

Framework name (e.g., "langgraph", "bedrock")

required
adapter_config Optional[Dict[str, Any]]

Optional configuration for adapter initialization

None

Returns:

Type Description
BaseAdapter

Adapter instance

Raises:

Type Description
ValueError

If adapter is not registered

Source code in uaef/adapters/registry.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
def get_adapter(
    self,
    name: str,
    adapter_config: Optional[Dict[str, Any]] = None
) -> BaseAdapter:
    """
    Get an adapter instance by framework name.

    Args:
        name: Framework name (e.g., "langgraph", "bedrock")
        adapter_config: Optional configuration for adapter initialization

    Returns:
        Adapter instance

    Raises:
        ValueError: If adapter is not registered
    """
    if name not in self._adapters:
        raise ValueError(
            f"Adapter '{name}' is not registered. "
            f"Available adapters: {', '.join(self.list_adapters())}"
        )

    # Check if we need to create a new instance (for adapters with config)
    if adapter_config:
        adapter_class = self._adapters[name]
        return adapter_class(**adapter_config)

    # Return cached instance or create new one
    if name not in self._adapter_instances:
        adapter_class = self._adapters[name]
        self._adapter_instances[name] = adapter_class()

    return self._adapter_instances[name]

is_registered(name)

Check if an adapter is registered.

Parameters:

Name Type Description Default
name str

Framework name

required

Returns:

Type Description
bool

True if adapter is registered, False otherwise

Source code in uaef/adapters/registry.py
155
156
157
158
159
160
161
162
163
164
165
def is_registered(self, name: str) -> bool:
    """
    Check if an adapter is registered.

    Args:
        name: Framework name

    Returns:
        True if adapter is registered, False otherwise
    """
    return name in self._adapters

list_adapters()

List all registered adapter names.

Returns:

Type Description
list[str]

List of adapter names

Source code in uaef/adapters/registry.py
146
147
148
149
150
151
152
153
def list_adapters(self) -> list[str]:
    """
    List all registered adapter names.

    Returns:
        List of adapter names
    """
    return list(self._adapters.keys())

register(name, adapter_class, override=False)

Register an adapter class.

Parameters:

Name Type Description Default
name str

Framework name (e.g., "langgraph", "bedrock")

required
adapter_class Type[BaseAdapter]

Adapter class that implements BaseAdapter

required
override bool

If True, allow overriding existing adapters

False

Raises:

Type Description
ValueError

If adapter is already registered and override is False

TypeError

If adapter_class does not implement BaseAdapter

Source code in uaef/adapters/registry.py
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def register(
    self,
    name: str,
    adapter_class: Type[BaseAdapter],
    override: bool = False
) -> None:
    """
    Register an adapter class.

    Args:
        name: Framework name (e.g., "langgraph", "bedrock")
        adapter_class: Adapter class that implements BaseAdapter
        override: If True, allow overriding existing adapters

    Raises:
        ValueError: If adapter is already registered and override is False
        TypeError: If adapter_class does not implement BaseAdapter
    """
    # Validate adapter class
    if not issubclass(adapter_class, BaseAdapter):
        raise TypeError(
            f"Adapter class {adapter_class.__name__} must inherit from BaseAdapter"
        )

    # Check for existing registration
    if name in self._adapters and not override:
        raise ValueError(
            f"Adapter '{name}' is already registered. "
            f"Use override=True to replace it."
        )

    # Register the adapter
    self._adapters[name] = adapter_class

    # Clear cached instance if it exists
    if name in self._adapter_instances:
        del self._adapter_instances[name]

unregister(name)

Unregister an adapter.

Parameters:

Name Type Description Default
name str

Framework name

required

Raises:

Type Description
ValueError

If adapter is not registered

Source code in uaef/adapters/registry.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def unregister(self, name: str) -> None:
    """
    Unregister an adapter.

    Args:
        name: Framework name

    Raises:
        ValueError: If adapter is not registered
    """
    if name not in self._adapters:
        raise ValueError(f"Adapter '{name}' is not registered")

    del self._adapters[name]

    if name in self._adapter_instances:
        del self._adapter_instances[name]

get_adapter(name, adapter_config=None)

Get an adapter instance from the global registry.

Parameters:

Name Type Description Default
name str

Framework name (e.g., "langgraph", "bedrock")

required
adapter_config Optional[Dict[str, Any]]

Optional configuration for adapter initialization

None

Returns:

Type Description
BaseAdapter

Adapter instance

Raises:

Type Description
ValueError

If adapter is not registered

Source code in uaef/adapters/registry.py
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def get_adapter(
    name: str,
    adapter_config: Optional[Dict[str, Any]] = None
) -> BaseAdapter:
    """
    Get an adapter instance from the global registry.

    Args:
        name: Framework name (e.g., "langgraph", "bedrock")
        adapter_config: Optional configuration for adapter initialization

    Returns:
        Adapter instance

    Raises:
        ValueError: If adapter is not registered
    """
    return _global_registry.get_adapter(name, adapter_config)

get_registry()

Get the global adapter registry instance.

Returns:

Type Description
AdapterRegistry

Global AdapterRegistry instance

Source code in uaef/adapters/registry.py
253
254
255
256
257
258
259
260
def get_registry() -> AdapterRegistry:
    """
    Get the global adapter registry instance.

    Returns:
        Global AdapterRegistry instance
    """
    return _global_registry

is_adapter_registered(name)

Check if an adapter is registered in the global registry.

Parameters:

Name Type Description Default
name str

Framework name

required

Returns:

Type Description
bool

True if adapter is registered, False otherwise

Source code in uaef/adapters/registry.py
240
241
242
243
244
245
246
247
248
249
250
def is_adapter_registered(name: str) -> bool:
    """
    Check if an adapter is registered in the global registry.

    Args:
        name: Framework name

    Returns:
        True if adapter is registered, False otherwise
    """
    return _global_registry.is_registered(name)

list_adapters()

List all registered adapter names in the global registry.

Returns:

Type Description
list[str]

List of adapter names

Source code in uaef/adapters/registry.py
230
231
232
233
234
235
236
237
def list_adapters() -> list[str]:
    """
    List all registered adapter names in the global registry.

    Returns:
        List of adapter names
    """
    return _global_registry.list_adapters()

register_adapter(name, adapter_class, override=False)

Register an adapter in the global registry.

Parameters:

Name Type Description Default
name str

Framework name (e.g., "langgraph", "bedrock")

required
adapter_class Type[BaseAdapter]

Adapter class that implements BaseAdapter

required
override bool

If True, allow overriding existing adapters

False

Raises:

Type Description
ValueError

If adapter is already registered and override is False

TypeError

If adapter_class does not implement BaseAdapter

Source code in uaef/adapters/registry.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
def register_adapter(
    name: str,
    adapter_class: Type[BaseAdapter],
    override: bool = False
) -> None:
    """
    Register an adapter in the global registry.

    Args:
        name: Framework name (e.g., "langgraph", "bedrock")
        adapter_class: Adapter class that implements BaseAdapter
        override: If True, allow overriding existing adapters

    Raises:
        ValueError: If adapter is already registered and override is False
        TypeError: If adapter_class does not implement BaseAdapter
    """
    _global_registry.register(name, adapter_class, override)

uaef.adapters.langgraph

LangGraph adapter for transforming LangGraph traces to canonical format.

LangGraphAdapter

Bases: BaseAdapter

Adapter for transforming LangGraph stream events to canonical AgentTrace format.

This adapter processes LangGraph execution traces that contain: - Stream events from graph.stream() calls - Messages with tool calls and responses - Token usage metadata - Agent and tool node outputs

The adapter reuses logic from the get_answers_detail_langgraph() function in agenticevaluationframework-main/frontend/evaluation.py.

Source code in uaef/adapters/langgraph.py
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
class LangGraphAdapter(BaseAdapter):
    """
    Adapter for transforming LangGraph stream events to canonical AgentTrace format.

    This adapter processes LangGraph execution traces that contain:
    - Stream events from graph.stream() calls
    - Messages with tool calls and responses
    - Token usage metadata
    - Agent and tool node outputs

    The adapter reuses logic from the get_answers_detail_langgraph() function
    in agenticevaluationframework-main/frontend/evaluation.py.
    """

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        """Initialize the adapter, requiring the optional 'langgraph' extra.

        The adapter depends on ``langchain-core`` message types, which ship
        behind the optional ``langgraph`` extra. Instantiating it on a
        core-only install raises an actionable ImportError naming the extra
        and the exact ``pip install`` command.
        """
        if not _LANGCHAIN_CORE_AVAILABLE:
            raise ImportError(
                "The LangGraph adapter requires the 'langgraph' extra. "
                "Install with: pip install 'uaef[langgraph]'"
            )
        super().__init__(*args, **kwargs)

    @property
    def name(self) -> str:
        """Return the adapter name."""
        return "langgraph"

    def supports_session_transform(self) -> bool:
        """This adapter accepts a list of per-turn payloads (see
        :meth:`_transform_sessions`) and returns one session dict per
        conversation, so multi-turn evaluation can use it."""
        return True


    def _infer_agent_and_tool_nodes(self, stream_events: List[Any]) -> tuple:
        """
        Classify graph nodes by inspecting message types in stream events.

        A node is an agent node if it emits any AIMessage (type == "ai").
        A node is a tool node if it emits any ToolMessage (type == "tool").
        Preserves first-seen order for agent nodes.

        Returns:
            (agent_node_names, tool_node_names) — both as lists
        """
        agent_nodes: List[str] = []
        tool_nodes: List[str] = []

        for event in stream_events:
            if not isinstance(event, dict):
                continue
            for key, value in event.items():
                if not isinstance(value, dict):
                    continue
                messages = value.get("messages", [])
                if not isinstance(messages, list):
                    messages = [messages]
                for raw_msg in messages:
                    msg = self._coerce_message(raw_msg)
                    if isinstance(msg, AIMessage) and key not in agent_nodes:
                        agent_nodes.append(key)
                    elif isinstance(msg, ToolMessage) and key not in tool_nodes:
                        tool_nodes.append(key)

        return agent_nodes, tool_nodes

    def transform_to_canonical(self, raw_data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Union[AgentTrace, MultiAgentTrace, Dict[str, Any]]:
        """
        Transform LangGraph trace data to canonical format.

        Supports two input shapes:

        **Single-turn (dict)** — returns AgentTrace or MultiAgentTrace as before.
        Expected for single-agent:
        {
            "stream_events": [...],
            "agent_node_name": "agent",   # optional; inferred if omitted
            "tool_node_name": "tools",
        }

        Expected for multi-agent:
        {
            "stream_events": [...],
            "agent_node_names": ["researcher", "writer", "reviewer"],  # optional; inferred if omitted
            "tool_node_name": "tools",
            "orchestrator_node_name": "orchestrator",  # optional
        }

        **Multi-turn / multi-session (list)** — returns a list of session dicts.

        Input is a flat list of per-turn payloads. Turns are grouped by
        `session_id` (preserving first-seen order) and sorted within each
        session by `turn_id`. Each unique session_id produces one session
        dict in the output.

        Per-turn payload schema:
        [
            {
                "session_id": "...",          # required, used to group turns
                "turn_id": 1,                  # required, used to order turns within a session
                "stream_events": [...],        # required, raw LangGraph events for this turn
                "latency": 1.23,               # optional, per-turn latency in seconds
                "input_tokens": 100,           # optional, per-turn input tokens
                "output_tokens": 200,          # optional, per-turn output tokens
                "cost_usd": 0.0015,            # optional, per-turn cost in USD
            },
            ...
        ]

        Returns a list with one entry per unique session_id:
        [
            {
                "session_id": "session_a",
                "per_turn_traces": [AgentTrace, AgentTrace, ...],  # one per turn in this session
                "full_trace": AgentTrace,                           # concatenated across all turns
            },
            {
                "session_id": "session_b",
                "per_turn_traces": [AgentTrace],                    # single-turn session is a list of 1
                "full_trace": AgentTrace,
            },
            ...
        ]

        Mixed-cardinality batches (some sessions with one turn, others with
        many) are handled uniformly — single-turn sessions just produce a
        per_turn_traces list of length 1.

        Multi-agent runs follow the same shape: if a session's events involve
        more than one agent node, both `per_turn_traces` entries and
        `full_trace` come back as `MultiAgentTrace` instead of `AgentTrace`,
        which lets multi-agent metrics score on top of multi-turn metrics.
        Mixing single-agent and multi-agent turns within the same session
        raises an error.

        Args:
            raw_data: LangGraph trace data (dict for single-turn,
                      list for multi-turn / multi-session)

        Returns:
            AgentTrace or MultiAgentTrace for dict input, or list of session
            dicts for list input.
        """
        # Multi-turn / multi-session input: list of per-turn payloads
        if isinstance(raw_data, list):
            return self._transform_sessions(raw_data)

        try:
            self.validate_raw_data(raw_data, ["stream_events"])

            stream_events = raw_data.get("stream_events", [])

            # Resolve agent/tool node names: explicit values > infer from message types
            agent_node_names = raw_data.get("agent_node_names")
            if not isinstance(agent_node_names, list):
                agent_node_names, inferred_tool_nodes = self._infer_agent_and_tool_nodes(stream_events)
                tool_node_name = (
                    raw_data.get("tool_node_name")
                    or (inferred_tool_nodes[0] if inferred_tool_nodes else "tools")
                )
                raw_data = {**raw_data, "agent_node_names": agent_node_names, "tool_node_name": tool_node_name}
            else:
                tool_node_name = raw_data.get("tool_node_name", "tools")

            if len(agent_node_names) > 1:
                return self._transform_multi_agent(raw_data)

            # Single-agent: fall back using the (possibly inferred) sole agent name
            if agent_node_names and "agent_node_name" not in raw_data:
                raw_data = {**raw_data, "agent_node_name": agent_node_names[0]}

            return self._transform_single_agent(raw_data)

        except AdapterTransformationError:
            raise
        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to transform LangGraph trace: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def _transform_sessions(self, turns: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """
        Transform a flat list of per-turn payloads into a list of session dicts.

        Turns are grouped by session_id (preserving first-seen order), sorted by
        turn_id within each session, and each session is materialized into a
        {"session_id", "per_turn_traces", "full_trace"} dict.

        Args:
            turns: Flat list of per-turn payloads. Each must include "session_id"
                   and "stream_events"; "turn_id", "latency", "input_tokens",
                   "output_tokens", "cost_usd" are optional.

        Returns:
            List of session dicts, one per unique session_id:
            [
                {
                    "session_id": "...",
                    "per_turn_traces": [AgentTrace, ...],
                    "full_trace": AgentTrace,
                },
                ...
            ]

        Raises:
            AdapterTransformationError: If transformation fails
        """
        try:
            if not turns:
                raise AdapterTransformationError(
                    "Multi-turn input list is empty",
                    adapter_name=self.name,
                )

            # Group turns by session_id, preserving first-seen order.
            session_order: List[str] = []
            grouped: Dict[str, List[Dict[str, Any]]] = {}
            for turn in turns:
                if "stream_events" not in turn:
                    raise AdapterTransformationError(
                        f"Turn payload missing 'stream_events': turn_id={turn.get('turn_id')}",
                        adapter_name=self.name,
                    )
                sid = turn.get("session_id")
                if sid is None:
                    raise AdapterTransformationError(
                        "Turn payload missing 'session_id'",
                        adapter_name=self.name,
                    )
                if turn.get("turn_id") is None:
                    raise AdapterTransformationError(
                        f"Turn payload missing 'turn_id' (session_id={sid})",
                        adapter_name=self.name,
                    )
                if sid not in grouped:
                    grouped[sid] = []
                    session_order.append(sid)
                grouped[sid].append(turn)

            sessions: List[Dict[str, Any]] = []
            for sid in session_order:
                session_turns = grouped[sid]
                # Sort within-session by turn_id (always present after validation above)
                session_turns = sorted(session_turns, key=lambda t: t["turn_id"])
                sessions.append(self._build_session(sid, session_turns))

            return sessions

        except AdapterTransformationError:
            raise
        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to transform multi-turn LangGraph trace: {str(e)}",
                adapter_name=self.name,
                original_error=e,
            )

    def _build_session(self, session_id: str, turns: List[Dict[str, Any]]) -> Dict[str, Any]:
        """
        Build a single session dict from its ordered list of turn payloads.

        Each per-turn trace is built by reusing the same per-turn-vs-multi-turn
        dispatch as the single-call path: if the turn's events involve more
        than one agent node, we produce a MultiAgentTrace; otherwise an
        AgentTrace. The full session trace is built the same way from the
        concatenated events. Latency / tokens / cost are aggregated by
        summation across turns.

        Args:
            session_id: Session identifier shared by all turns
            turns: Ordered list of per-turn payloads for this session

        Returns:
            {"session_id", "per_turn_traces", "full_trace"}
        """
        per_turn_traces: List[Union[AgentTrace, MultiAgentTrace]] = []
        all_events: List[Any] = []

        agg_latency = 0.0
        agg_input_tokens = 0
        agg_output_tokens = 0
        agg_cost_usd = 0.0
        has_latency = False
        has_tokens = False
        has_cost = False

        def _build_trace_for_events(stream_events):
            """Build a per-turn or full trace from raw events.

            Picks single-agent vs. multi-agent based on inferred agent nodes,
            mirroring the dispatch in transform_to_canonical().
            """
            agent_nodes, tool_nodes = self._infer_agent_and_tool_nodes(stream_events)
            payload: Dict[str, Any] = {"stream_events": stream_events}
            if tool_nodes:
                payload["tool_node_name"] = tool_nodes[0]
            if len(agent_nodes) > 1:
                payload["agent_node_names"] = agent_nodes
                return self._transform_multi_agent(payload)
            if agent_nodes:
                payload["agent_node_name"] = agent_nodes[0]
            return self._transform_single_agent(payload)

        def _set_trace_field(trace, field, value):
            """Set a field on either an AgentTrace or a MultiAgentTrace.

            MultiAgentTrace stores latency on `total_latency`, and
            input/output tokens are computed properties — so we cache the
            per-turn values inside metadata to keep them visible.
            """
            if isinstance(trace, MultiAgentTrace):
                if field == "latency":
                    trace.total_latency = value
                elif field in ("input_tokens", "output_tokens"):
                    trace.metadata[field] = value
            else:
                setattr(trace, field, value)

        for turn in turns:
            turn_trace = _build_trace_for_events(turn["stream_events"])

            if turn.get("latency") is not None:
                _set_trace_field(turn_trace, "latency", turn["latency"])
                agg_latency += float(turn["latency"])
                has_latency = True
            if turn.get("input_tokens") is not None:
                _set_trace_field(turn_trace, "input_tokens", turn["input_tokens"])
                agg_input_tokens += int(turn["input_tokens"])
                has_tokens = True
            if turn.get("output_tokens") is not None:
                _set_trace_field(turn_trace, "output_tokens", turn["output_tokens"])
                agg_output_tokens += int(turn["output_tokens"])
                has_tokens = True
            if turn.get("cost_usd") is not None:
                turn_trace.metadata["cost_usd"] = turn["cost_usd"]
                agg_cost_usd += float(turn["cost_usd"])
                has_cost = True
            if turn.get("turn_id") is not None:
                turn_trace.metadata["turn_id"] = turn["turn_id"]
            turn_trace.session_id = session_id

            per_turn_traces.append(turn_trace)
            all_events.extend(turn["stream_events"])

        full_trace = _build_trace_for_events(all_events)
        full_trace.session_id = session_id
        if has_latency:
            _set_trace_field(full_trace, "latency", agg_latency)
        if has_tokens:
            _set_trace_field(full_trace, "input_tokens", agg_input_tokens)
            _set_trace_field(full_trace, "output_tokens", agg_output_tokens)
        if has_cost:
            full_trace.metadata["cost_usd"] = agg_cost_usd
        full_trace.metadata["num_turns"] = len(per_turn_traces)

        # Mixing single-agent and multi-agent traces within one session is rejected
        # by evaluate_multi_turn; surface the error here with a clearer message.
        per_turn_types = {type(t) for t in per_turn_traces}
        if len(per_turn_types) > 1 or type(full_trace) not in per_turn_types:
            raise AdapterTransformationError(
                f"Session '{session_id}' mixes single-agent and multi-agent turns; "
                "every turn in a session must use the same graph topology.",
                adapter_name=self.name,
            )

        return {
            "session_id": session_id,
            "per_turn_traces": per_turn_traces,
            "full_trace": full_trace,
        }

    def _transform_single_agent(self, raw_data: Dict[str, Any]) -> AgentTrace:
        """Transform to single AgentTrace (original behavior)."""
        messages = self.extract_messages(raw_data)
        tool_calls = self.extract_tool_calls(raw_data)
        metadata = self.extract_metadata(raw_data)

        return AgentTrace(
            trace_id=raw_data.get("trace_id", uuid4()),
            session_id=raw_data.get("session_id"),
            messages=messages,
            tool_calls=tool_calls,
            input_tokens=metadata.get("input_tokens"),
            output_tokens=metadata.get("output_tokens"),
            latency=metadata.get("latency"),
            cost=metadata.get("cost"),
            metadata=metadata,
            framework="langgraph",
            timestamp=datetime.now(timezone.utc)
        )

    def _transform_multi_agent(self, raw_data: Dict[str, Any]) -> MultiAgentTrace:
        """
        Transform to MultiAgentTrace by splitting stream events per agent node.

        Each agent node name becomes a separate AgentTrace. Coordination events
        are inferred from the sequential ordering of agent activations.
        """
        stream_events = raw_data.get("stream_events", [])
        agent_node_names = raw_data.get("agent_node_names", [])
        tool_node_name = raw_data.get("tool_node_name", "tools")
        orchestrator_name = raw_data.get("orchestrator_node_name")

        # Build per-agent data by scanning events
        agent_messages: Dict[str, List[Message]] = {name: [] for name in agent_node_names}
        agent_tool_calls: Dict[str, List[ToolCall]] = {name: [] for name in agent_node_names}
        coordination_events: List[CoordinationEvent] = []
        activation_order: List[str] = []
        # HumanMessages are prepended to the first agent that activates
        human_messages: List[Message] = []
        first_agent_populated = False

        for event in stream_events:
            if not isinstance(event, dict):
                continue

            for key, value in event.items():
                if not isinstance(value, dict):
                    continue
                value_messages = value.get("messages")
                if not value_messages:
                    continue
                if not isinstance(value_messages, list):
                    value_messages = [value_messages]

                # Collect HumanMessages before any agent fires
                for msg in value_messages:
                    if isinstance(msg, HumanMessage) and not first_agent_populated:
                        human_messages.append(Message(
                            role=MessageRole.USER,
                            content=str(getattr(msg, "content", "") or ""),
                            timestamp=datetime.now(timezone.utc)
                        ))

                if key in agent_node_names:
                    activation_order.append(key)
                    msg = value_messages[-1]

                    if not first_agent_populated and human_messages:
                        agent_messages[key].extend(human_messages)
                        first_agent_populated = True

                    content = getattr(msg, "content", "") or ""
                    tc_data = getattr(msg, "tool_calls", []) or []

                    message_tool_calls = []
                    for tc in tc_data:
                        if isinstance(tc, dict) and tc.get("name"):
                            tool_call = ToolCall(
                                name=tc.get("name", ""),
                                arguments=tc.get("args", {}),
                                timestamp=datetime.now(timezone.utc)
                            )
                            message_tool_calls.append(tool_call)
                            agent_tool_calls[key].append(tool_call)

                    agent_messages[key].append(Message(
                        role=MessageRole.ASSISTANT,
                        content=str(content),
                        tool_calls=message_tool_calls,
                        timestamp=datetime.now(timezone.utc)
                    ))

        # Infer coordination events from sequential agent activations
        for i in range(1, len(activation_order)):
            from_agent = activation_order[i - 1]
            to_agent = activation_order[i]
            if from_agent != to_agent:
                coordination_events.append(CoordinationEvent(
                    from_agent=from_agent,
                    to_agent=to_agent,
                    event_type="HANDOFF",
                    message=f"Control passed from {from_agent} to {to_agent}",
                    timestamp=datetime.now(timezone.utc)
                ))

        # Build per-agent AgentTraces
        agent_traces = {}
        for name in agent_node_names:
            agent_traces[name] = AgentTrace(
                trace_id=uuid4(),
                messages=agent_messages[name],
                tool_calls=agent_tool_calls[name],
                framework="langgraph",
                timestamp=datetime.now(timezone.utc)
            )

        metadata = self.extract_metadata(raw_data)
        return MultiAgentTrace(
            trace_id=raw_data.get("trace_id", uuid4()),
            session_id=raw_data.get("session_id"),
            agent_traces=agent_traces,
            coordination_events=coordination_events,
            workflow_status=WorkflowStatus.COMPLETED,
            total_latency=metadata.get("latency"),
            metadata=metadata,
            timestamp=datetime.now(timezone.utc)
        )

    @staticmethod
    def _coerce_message(msg: Any) -> Any:
        """Reconstruct a live LangChain message from a serialized dict.

        In-process (notebook) runs hand the adapter real ``AIMessage`` /
        ``HumanMessage`` objects. When a LangGraph agent is served over HTTP
        (see ``notebooks/serve_langgraph_agent.py``), those objects are
        serialized to JSON and arrive here as **dicts** — so the downstream
        ``isinstance`` checks would match nothing and the trace would come back
        empty. This helper turns the dict back into the corresponding LangChain
        message object so the rest of the extraction logic is unchanged.

        Handles three shapes:
          * live message objects — returned as-is,
          * LangChain-native serialized form ``{"type": "ai", "data": {...}}``
            (``messages_to_dict`` output) — restored via ``messages_from_dict``,
          * the class-name / role form used by the reference HTTP server
            ``{"type": "AIMessage"|..., "content": ..., "tool_calls": [...],
            "tool_call_id": "..."}`` (or ``{"role": "assistant", ...}``).

        Returns the message object, or ``None`` when the dict can't be mapped
        (callers skip ``None``).
        """
        # Already a live LangChain message.
        if BaseMessage is not None and isinstance(msg, BaseMessage):
            return msg
        if not isinstance(msg, dict):
            return msg

        # LangChain-native serialized form: {"type": "...", "data": {...}}.
        if messages_from_dict is not None and isinstance(msg.get("data"), dict):
            try:
                restored = messages_from_dict([msg])
                if restored:
                    return restored[0]
            except Exception:  # noqa: BLE001 — fall through to the role/class form
                pass

        type_hint = str(msg.get("type") or msg.get("role") or "").lower()
        content = msg.get("content", "")
        try:
            if type_hint in ("aimessage", "aimessagechunk", "ai", "assistant"):
                tool_calls = msg.get("tool_calls") or []
                try:
                    return AIMessage(content=content, tool_calls=tool_calls)
                except Exception:  # noqa: BLE001 — malformed tool_calls; keep the text
                    return AIMessage(content=content)
            if type_hint in ("humanmessage", "human", "user"):
                return HumanMessage(content=content)
            if type_hint in ("toolmessage", "tool"):
                return ToolMessage(
                    content=content, tool_call_id=msg.get("tool_call_id", "") or ""
                )
            if type_hint in ("systemmessage", "system"):
                return SystemMessage(content=content)
        except Exception:  # noqa: BLE001 — unmappable dict; skip it
            return None
        return None

    def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
        """
        Extract messages from LangGraph stream events.

        Messages are extracted from agent node outputs in the stream events.
        Each agent node output contains a message with content and optional tool calls.
        Serialized (dict) messages from an HTTP-served agent are reconstructed
        into LangChain message objects via :meth:`_coerce_message` first.

        Args:
            raw_data: LangGraph trace data

        Returns:
            List of Message objects

        Raises:
            AdapterTransformationError: If message extraction fails
        """
        try:
            messages = []
            stream_events = raw_data.get("stream_events", [])
            agent_node_name = raw_data.get("agent_node_name", "agent")

            for event in stream_events:
                if not isinstance(event, dict):
                    continue

                for key, value in event.items():
                    if not isinstance(value, dict):
                        continue
                    value_messages = value.get("messages")
                    if not value_messages:
                        continue
                    if not isinstance(value_messages, list):
                        value_messages = [value_messages]

                    for raw_msg in value_messages:
                        msg = self._coerce_message(raw_msg)
                        if msg is None:
                            continue
                        if isinstance(msg, HumanMessage):
                            messages.append(Message(
                                role=MessageRole.USER,
                                content=str(getattr(msg, "content", "") or ""),
                                timestamp=datetime.now(timezone.utc)
                            ))
                        elif key == agent_node_name and isinstance(msg, AIMessage):
                            tool_calls_data = getattr(msg, "tool_calls", []) or []
                            message_tool_calls = [
                                ToolCall(
                                    name=tc.get("name", ""),
                                    arguments=tc.get("args", {}),
                                    timestamp=datetime.now(timezone.utc)
                                )
                                for tc in tool_calls_data if isinstance(tc, dict)
                            ]
                            messages.append(Message(
                                role=MessageRole.ASSISTANT,
                                content=str(getattr(msg, "content", "") or ""),
                                tool_calls=message_tool_calls,
                                timestamp=datetime.now(timezone.utc)
                            ))

            return messages

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract messages: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
        """
        Extract tool calls from LangGraph stream events.

        Tool calls are extracted from:
        1. Agent node outputs (tool_calls field)
        2. Tool node outputs (tool results)

        Args:
            raw_data: LangGraph trace data

        Returns:
            List of ToolCall objects

        Raises:
            AdapterTransformationError: If tool call extraction fails
        """
        try:
            tool_calls = []
            stream_events = raw_data.get("stream_events", [])
            agent_node_name = raw_data.get("agent_node_name", "agent")
            tool_node_name = raw_data.get("tool_node_name", "tools")

            # Track tool calls and their results
            tool_call_map = {}  # Maps tool call index to ToolCall object
            tool_call_index = 0

            for event in stream_events:
                if not isinstance(event, dict):
                    continue

                for key, value in event.items():
                    # Extract tool calls from agent node
                    if key == agent_node_name and isinstance(value, dict):
                        value_messages = value.get("messages")
                        if not value_messages:
                            continue

                        # Handle both list and single message
                        if isinstance(value_messages, list):
                            msg = value_messages[-1] if value_messages else None
                        else:
                            msg = value_messages

                        # Reconstruct a live message from a serialized dict
                        # (HTTP-served agents) so getattr(tool_calls) works.
                        msg = self._coerce_message(msg) if msg is not None else None

                        if msg:
                            tool_calls_data = getattr(msg, "tool_calls", []) or []
                            for tc in tool_calls_data:
                                if isinstance(tc, dict) and tc.get("name"):
                                    tool_call = ToolCall(
                                        name=tc.get("name", ""),
                                        arguments=tc.get("args", {}),
                                        timestamp=datetime.now(timezone.utc)
                                    )
                                    tool_call_map[tool_call_index] = tool_call
                                    tool_calls.append(tool_call)
                                    tool_call_index += 1

                    # Extract tool results from tool node
                    elif key == tool_node_name and value:
                        # Tool node output contains results
                        # Try to match with previous tool calls
                        # Note: This is a simplified approach; actual matching may need more logic
                        if isinstance(value, dict):
                            # Store as result for the most recent tool call
                            if tool_calls:
                                tool_calls[-1].result = value

            return tool_calls

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract tool calls: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Extract metadata from LangGraph stream events.

        Metadata includes:
        - input_tokens: Total input tokens from usage_metadata
        - output_tokens: Total output tokens from usage_metadata
        - latency: Execution time (if provided)
        - model: Model name (if available)

        Args:
            raw_data: LangGraph trace data

        Returns:
            Dictionary of metadata

        Raises:
            AdapterTransformationError: If metadata extraction fails
        """
        try:
            metadata = {}
            stream_events = raw_data.get("stream_events", [])
            agent_node_name = raw_data.get("agent_node_name", "agent")

            input_tokens = 0
            output_tokens = 0

            # Accumulate token usage from all agent node outputs
            for event in stream_events:
                if not isinstance(event, dict):
                    continue

                for key, value in event.items():
                    if key == agent_node_name and isinstance(value, dict):
                        value_messages = value.get("messages")
                        if not value_messages:
                            continue

                        # Handle both list and single message
                        if isinstance(value_messages, list):
                            msg = value_messages[-1] if value_messages else None
                        else:
                            msg = value_messages

                        if msg:
                            # Extract usage metadata
                            usage_metadata = getattr(msg, "usage_metadata", {}) or {}
                            if isinstance(usage_metadata, dict):
                                input_tokens += usage_metadata.get("input_tokens", 0)
                                output_tokens += usage_metadata.get("output_tokens", 0)

            # Add token counts to metadata
            if input_tokens > 0:
                metadata["input_tokens"] = input_tokens
            if output_tokens > 0:
                metadata["output_tokens"] = output_tokens

            # Add any additional metadata from raw_data
            if "latency" in raw_data:
                metadata["latency"] = raw_data["latency"]
            if "model" in raw_data:
                metadata["model"] = raw_data["model"]
            if "config" in raw_data:
                metadata["config"] = raw_data["config"]

            return metadata

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract metadata: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

name property

Return the adapter name.

extract_messages(raw_data)

Extract messages from LangGraph stream events.

Messages are extracted from agent node outputs in the stream events. Each agent node output contains a message with content and optional tool calls. Serialized (dict) messages from an HTTP-served agent are reconstructed into LangChain message objects via :meth:_coerce_message first.

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

LangGraph trace data

required

Returns:

Type Description
List[Message]

List of Message objects

Raises:

Type Description
AdapterTransformationError

If message extraction fails

Source code in uaef/adapters/langgraph.py
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
    """
    Extract messages from LangGraph stream events.

    Messages are extracted from agent node outputs in the stream events.
    Each agent node output contains a message with content and optional tool calls.
    Serialized (dict) messages from an HTTP-served agent are reconstructed
    into LangChain message objects via :meth:`_coerce_message` first.

    Args:
        raw_data: LangGraph trace data

    Returns:
        List of Message objects

    Raises:
        AdapterTransformationError: If message extraction fails
    """
    try:
        messages = []
        stream_events = raw_data.get("stream_events", [])
        agent_node_name = raw_data.get("agent_node_name", "agent")

        for event in stream_events:
            if not isinstance(event, dict):
                continue

            for key, value in event.items():
                if not isinstance(value, dict):
                    continue
                value_messages = value.get("messages")
                if not value_messages:
                    continue
                if not isinstance(value_messages, list):
                    value_messages = [value_messages]

                for raw_msg in value_messages:
                    msg = self._coerce_message(raw_msg)
                    if msg is None:
                        continue
                    if isinstance(msg, HumanMessage):
                        messages.append(Message(
                            role=MessageRole.USER,
                            content=str(getattr(msg, "content", "") or ""),
                            timestamp=datetime.now(timezone.utc)
                        ))
                    elif key == agent_node_name and isinstance(msg, AIMessage):
                        tool_calls_data = getattr(msg, "tool_calls", []) or []
                        message_tool_calls = [
                            ToolCall(
                                name=tc.get("name", ""),
                                arguments=tc.get("args", {}),
                                timestamp=datetime.now(timezone.utc)
                            )
                            for tc in tool_calls_data if isinstance(tc, dict)
                        ]
                        messages.append(Message(
                            role=MessageRole.ASSISTANT,
                            content=str(getattr(msg, "content", "") or ""),
                            tool_calls=message_tool_calls,
                            timestamp=datetime.now(timezone.utc)
                        ))

        return messages

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract messages: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

extract_metadata(raw_data)

Extract metadata from LangGraph stream events.

Metadata includes: - input_tokens: Total input tokens from usage_metadata - output_tokens: Total output tokens from usage_metadata - latency: Execution time (if provided) - model: Model name (if available)

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

LangGraph trace data

required

Returns:

Type Description
Dict[str, Any]

Dictionary of metadata

Raises:

Type Description
AdapterTransformationError

If metadata extraction fails

Source code in uaef/adapters/langgraph.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
    """
    Extract metadata from LangGraph stream events.

    Metadata includes:
    - input_tokens: Total input tokens from usage_metadata
    - output_tokens: Total output tokens from usage_metadata
    - latency: Execution time (if provided)
    - model: Model name (if available)

    Args:
        raw_data: LangGraph trace data

    Returns:
        Dictionary of metadata

    Raises:
        AdapterTransformationError: If metadata extraction fails
    """
    try:
        metadata = {}
        stream_events = raw_data.get("stream_events", [])
        agent_node_name = raw_data.get("agent_node_name", "agent")

        input_tokens = 0
        output_tokens = 0

        # Accumulate token usage from all agent node outputs
        for event in stream_events:
            if not isinstance(event, dict):
                continue

            for key, value in event.items():
                if key == agent_node_name and isinstance(value, dict):
                    value_messages = value.get("messages")
                    if not value_messages:
                        continue

                    # Handle both list and single message
                    if isinstance(value_messages, list):
                        msg = value_messages[-1] if value_messages else None
                    else:
                        msg = value_messages

                    if msg:
                        # Extract usage metadata
                        usage_metadata = getattr(msg, "usage_metadata", {}) or {}
                        if isinstance(usage_metadata, dict):
                            input_tokens += usage_metadata.get("input_tokens", 0)
                            output_tokens += usage_metadata.get("output_tokens", 0)

        # Add token counts to metadata
        if input_tokens > 0:
            metadata["input_tokens"] = input_tokens
        if output_tokens > 0:
            metadata["output_tokens"] = output_tokens

        # Add any additional metadata from raw_data
        if "latency" in raw_data:
            metadata["latency"] = raw_data["latency"]
        if "model" in raw_data:
            metadata["model"] = raw_data["model"]
        if "config" in raw_data:
            metadata["config"] = raw_data["config"]

        return metadata

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract metadata: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

extract_tool_calls(raw_data)

Extract tool calls from LangGraph stream events.

Tool calls are extracted from: 1. Agent node outputs (tool_calls field) 2. Tool node outputs (tool results)

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

LangGraph trace data

required

Returns:

Type Description
List[ToolCall]

List of ToolCall objects

Raises:

Type Description
AdapterTransformationError

If tool call extraction fails

Source code in uaef/adapters/langgraph.py
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
    """
    Extract tool calls from LangGraph stream events.

    Tool calls are extracted from:
    1. Agent node outputs (tool_calls field)
    2. Tool node outputs (tool results)

    Args:
        raw_data: LangGraph trace data

    Returns:
        List of ToolCall objects

    Raises:
        AdapterTransformationError: If tool call extraction fails
    """
    try:
        tool_calls = []
        stream_events = raw_data.get("stream_events", [])
        agent_node_name = raw_data.get("agent_node_name", "agent")
        tool_node_name = raw_data.get("tool_node_name", "tools")

        # Track tool calls and their results
        tool_call_map = {}  # Maps tool call index to ToolCall object
        tool_call_index = 0

        for event in stream_events:
            if not isinstance(event, dict):
                continue

            for key, value in event.items():
                # Extract tool calls from agent node
                if key == agent_node_name and isinstance(value, dict):
                    value_messages = value.get("messages")
                    if not value_messages:
                        continue

                    # Handle both list and single message
                    if isinstance(value_messages, list):
                        msg = value_messages[-1] if value_messages else None
                    else:
                        msg = value_messages

                    # Reconstruct a live message from a serialized dict
                    # (HTTP-served agents) so getattr(tool_calls) works.
                    msg = self._coerce_message(msg) if msg is not None else None

                    if msg:
                        tool_calls_data = getattr(msg, "tool_calls", []) or []
                        for tc in tool_calls_data:
                            if isinstance(tc, dict) and tc.get("name"):
                                tool_call = ToolCall(
                                    name=tc.get("name", ""),
                                    arguments=tc.get("args", {}),
                                    timestamp=datetime.now(timezone.utc)
                                )
                                tool_call_map[tool_call_index] = tool_call
                                tool_calls.append(tool_call)
                                tool_call_index += 1

                # Extract tool results from tool node
                elif key == tool_node_name and value:
                    # Tool node output contains results
                    # Try to match with previous tool calls
                    # Note: This is a simplified approach; actual matching may need more logic
                    if isinstance(value, dict):
                        # Store as result for the most recent tool call
                        if tool_calls:
                            tool_calls[-1].result = value

        return tool_calls

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract tool calls: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

supports_session_transform()

This adapter accepts a list of per-turn payloads (see :meth:_transform_sessions) and returns one session dict per conversation, so multi-turn evaluation can use it.

Source code in uaef/adapters/langgraph.py
71
72
73
74
75
def supports_session_transform(self) -> bool:
    """This adapter accepts a list of per-turn payloads (see
    :meth:`_transform_sessions`) and returns one session dict per
    conversation, so multi-turn evaluation can use it."""
    return True

transform_to_canonical(raw_data)

Transform LangGraph trace data to canonical format.

Supports two input shapes:

Single-turn (dict) — returns AgentTrace or MultiAgentTrace as before. Expected for single-agent: { "stream_events": [...], "agent_node_name": "agent", # optional; inferred if omitted "tool_node_name": "tools", }

Expected for multi-agent: { "stream_events": [...], "agent_node_names": ["researcher", "writer", "reviewer"], # optional; inferred if omitted "tool_node_name": "tools", "orchestrator_node_name": "orchestrator", # optional }

Multi-turn / multi-session (list) — returns a list of session dicts.

Input is a flat list of per-turn payloads. Turns are grouped by session_id (preserving first-seen order) and sorted within each session by turn_id. Each unique session_id produces one session dict in the output.

Per-turn payload schema: [ { "session_id": "...", # required, used to group turns "turn_id": 1, # required, used to order turns within a session "stream_events": [...], # required, raw LangGraph events for this turn "latency": 1.23, # optional, per-turn latency in seconds "input_tokens": 100, # optional, per-turn input tokens "output_tokens": 200, # optional, per-turn output tokens "cost_usd": 0.0015, # optional, per-turn cost in USD }, ... ]

Returns a list with one entry per unique session_id: [ { "session_id": "session_a", "per_turn_traces": [AgentTrace, AgentTrace, ...], # one per turn in this session "full_trace": AgentTrace, # concatenated across all turns }, { "session_id": "session_b", "per_turn_traces": [AgentTrace], # single-turn session is a list of 1 "full_trace": AgentTrace, }, ... ]

Mixed-cardinality batches (some sessions with one turn, others with many) are handled uniformly — single-turn sessions just produce a per_turn_traces list of length 1.

Multi-agent runs follow the same shape: if a session's events involve more than one agent node, both per_turn_traces entries and full_trace come back as MultiAgentTrace instead of AgentTrace, which lets multi-agent metrics score on top of multi-turn metrics. Mixing single-agent and multi-agent turns within the same session raises an error.

Parameters:

Name Type Description Default
raw_data Union[Dict[str, Any], List[Dict[str, Any]]]

LangGraph trace data (dict for single-turn, list for multi-turn / multi-session)

required

Returns:

Type Description
Union[AgentTrace, MultiAgentTrace, Dict[str, Any]]

AgentTrace or MultiAgentTrace for dict input, or list of session

Union[AgentTrace, MultiAgentTrace, Dict[str, Any]]

dicts for list input.

Source code in uaef/adapters/langgraph.py
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
def transform_to_canonical(self, raw_data: Union[Dict[str, Any], List[Dict[str, Any]]]) -> Union[AgentTrace, MultiAgentTrace, Dict[str, Any]]:
    """
    Transform LangGraph trace data to canonical format.

    Supports two input shapes:

    **Single-turn (dict)** — returns AgentTrace or MultiAgentTrace as before.
    Expected for single-agent:
    {
        "stream_events": [...],
        "agent_node_name": "agent",   # optional; inferred if omitted
        "tool_node_name": "tools",
    }

    Expected for multi-agent:
    {
        "stream_events": [...],
        "agent_node_names": ["researcher", "writer", "reviewer"],  # optional; inferred if omitted
        "tool_node_name": "tools",
        "orchestrator_node_name": "orchestrator",  # optional
    }

    **Multi-turn / multi-session (list)** — returns a list of session dicts.

    Input is a flat list of per-turn payloads. Turns are grouped by
    `session_id` (preserving first-seen order) and sorted within each
    session by `turn_id`. Each unique session_id produces one session
    dict in the output.

    Per-turn payload schema:
    [
        {
            "session_id": "...",          # required, used to group turns
            "turn_id": 1,                  # required, used to order turns within a session
            "stream_events": [...],        # required, raw LangGraph events for this turn
            "latency": 1.23,               # optional, per-turn latency in seconds
            "input_tokens": 100,           # optional, per-turn input tokens
            "output_tokens": 200,          # optional, per-turn output tokens
            "cost_usd": 0.0015,            # optional, per-turn cost in USD
        },
        ...
    ]

    Returns a list with one entry per unique session_id:
    [
        {
            "session_id": "session_a",
            "per_turn_traces": [AgentTrace, AgentTrace, ...],  # one per turn in this session
            "full_trace": AgentTrace,                           # concatenated across all turns
        },
        {
            "session_id": "session_b",
            "per_turn_traces": [AgentTrace],                    # single-turn session is a list of 1
            "full_trace": AgentTrace,
        },
        ...
    ]

    Mixed-cardinality batches (some sessions with one turn, others with
    many) are handled uniformly — single-turn sessions just produce a
    per_turn_traces list of length 1.

    Multi-agent runs follow the same shape: if a session's events involve
    more than one agent node, both `per_turn_traces` entries and
    `full_trace` come back as `MultiAgentTrace` instead of `AgentTrace`,
    which lets multi-agent metrics score on top of multi-turn metrics.
    Mixing single-agent and multi-agent turns within the same session
    raises an error.

    Args:
        raw_data: LangGraph trace data (dict for single-turn,
                  list for multi-turn / multi-session)

    Returns:
        AgentTrace or MultiAgentTrace for dict input, or list of session
        dicts for list input.
    """
    # Multi-turn / multi-session input: list of per-turn payloads
    if isinstance(raw_data, list):
        return self._transform_sessions(raw_data)

    try:
        self.validate_raw_data(raw_data, ["stream_events"])

        stream_events = raw_data.get("stream_events", [])

        # Resolve agent/tool node names: explicit values > infer from message types
        agent_node_names = raw_data.get("agent_node_names")
        if not isinstance(agent_node_names, list):
            agent_node_names, inferred_tool_nodes = self._infer_agent_and_tool_nodes(stream_events)
            tool_node_name = (
                raw_data.get("tool_node_name")
                or (inferred_tool_nodes[0] if inferred_tool_nodes else "tools")
            )
            raw_data = {**raw_data, "agent_node_names": agent_node_names, "tool_node_name": tool_node_name}
        else:
            tool_node_name = raw_data.get("tool_node_name", "tools")

        if len(agent_node_names) > 1:
            return self._transform_multi_agent(raw_data)

        # Single-agent: fall back using the (possibly inferred) sole agent name
        if agent_node_names and "agent_node_name" not in raw_data:
            raw_data = {**raw_data, "agent_node_name": agent_node_names[0]}

        return self._transform_single_agent(raw_data)

    except AdapterTransformationError:
        raise
    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to transform LangGraph trace: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

uaef.adapters.langchain

LangChain adapter for transforming LangChain traces to canonical format.

LangChainAdapter

Bases: BaseAdapter

Adapter for transforming LangChain callback data to canonical AgentTrace format.

This adapter processes LangChain execution traces that contain: - Callback handler data from LangChain runs - Messages from chat models and agents - Tool invocations and results - Token usage metadata - Run information and metadata

LangChain provides callback handlers that capture execution details. This adapter transforms that data into the canonical UAEF format.

Source code in uaef/adapters/langchain.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
class LangChainAdapter(BaseAdapter):
    """
    Adapter for transforming LangChain callback data to canonical AgentTrace format.

    This adapter processes LangChain execution traces that contain:
    - Callback handler data from LangChain runs
    - Messages from chat models and agents
    - Tool invocations and results
    - Token usage metadata
    - Run information and metadata

    LangChain provides callback handlers that capture execution details.
    This adapter transforms that data into the canonical UAEF format.
    """

    @property
    def name(self) -> str:
        """Return the adapter name."""
        return "langchain"

    def transform_to_canonical(self, raw_data: Dict[str, Any]) -> AgentTrace:
        """
        Transform LangChain trace data to canonical AgentTrace format.

        Expected raw_data structure:
        {
            "runs": [...],  # List of run data from callback handlers
            "messages": [...],  # Optional: Direct message list
            "session_id": "...",  # Optional session identifier
            "trace_id": "...",  # Optional trace identifier (UUID string)
            "metadata": {...},  # Optional additional metadata
        }

        Alternative structure (single run):
        {
            "run_id": "...",
            "inputs": {...},
            "outputs": {...},
            "actions": [...],  # Tool calls/actions
            "llm_output": {...},  # LLM response with token usage
            "messages": [...],  # Chat messages
            "metadata": {...},
        }

        Args:
            raw_data: LangChain trace data

        Returns:
            Canonical AgentTrace object

        Raises:
            AdapterTransformationError: If transformation fails
        """
        try:
            # Validate that we have some data to work with
            if not raw_data:
                raise AdapterTransformationError(
                    "Empty raw_data provided",
                    adapter_name=self.name
                )

            # Extract components
            messages = self.extract_messages(raw_data)
            tool_calls = self.extract_tool_calls(raw_data)
            metadata = self.extract_metadata(raw_data)

            # Create AgentTrace
            trace = AgentTrace(
                trace_id=raw_data.get("trace_id", uuid4()),
                session_id=raw_data.get("session_id"),
                messages=messages,
                tool_calls=tool_calls,
                input_tokens=metadata.get("input_tokens"),
                output_tokens=metadata.get("output_tokens"),
                latency=metadata.get("latency"),
                cost=metadata.get("cost"),
                metadata=metadata,
                framework="langchain",
                timestamp=datetime.utcnow()
            )

            return trace

        except AdapterTransformationError:
            raise
        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to transform LangChain trace: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
        """
        Extract messages from LangChain callback data.

        Messages can come from:
        1. Direct "messages" field (list of message objects)
        2. "runs" field containing run data with inputs/outputs
        3. "inputs" and "outputs" fields in a single run

        LangChain messages may have different formats:
        - HumanMessage, AIMessage, SystemMessage, ToolMessage
        - Dict with "role" and "content" keys
        - String content with role inferred from context

        Args:
            raw_data: LangChain trace data

        Returns:
            List of Message objects

        Raises:
            AdapterTransformationError: If message extraction fails
        """
        try:
            messages = []

            # Case 1: Direct messages field
            if "messages" in raw_data and raw_data["messages"]:
                messages.extend(self._parse_message_list(raw_data["messages"]))

            # Case 2: Runs field with multiple runs
            elif "runs" in raw_data and raw_data["runs"]:
                for run in raw_data["runs"]:
                    if not isinstance(run, dict):
                        continue

                    # Extract messages from run inputs
                    if "inputs" in run:
                        input_messages = self._extract_messages_from_inputs(run["inputs"])
                        messages.extend(input_messages)

                    # Extract messages from run outputs
                    if "outputs" in run:
                        output_messages = self._extract_messages_from_outputs(run["outputs"])
                        messages.extend(output_messages)

            # Case 3: Single run with inputs/outputs
            elif "inputs" in raw_data or "outputs" in raw_data:
                if "inputs" in raw_data:
                    input_messages = self._extract_messages_from_inputs(raw_data["inputs"])
                    messages.extend(input_messages)

                if "outputs" in raw_data:
                    output_messages = self._extract_messages_from_outputs(raw_data["outputs"])
                    messages.extend(output_messages)

            return messages

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract messages: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def _parse_message_list(self, messages_data: List[Any]) -> List[Message]:
        """
        Parse a list of LangChain message objects.

        Args:
            messages_data: List of message objects or dicts

        Returns:
            List of Message objects
        """
        messages = []

        for msg in messages_data:
            if not msg:
                continue

            # Handle dict format
            if isinstance(msg, dict):
                role = self._parse_role(msg.get("role", msg.get("type", "assistant")))
                content = msg.get("content", "")
                tool_calls_data = msg.get("tool_calls", [])

                # Parse tool calls if present
                message_tool_calls = []
                if tool_calls_data:
                    for tc in tool_calls_data:
                        if isinstance(tc, dict):
                            message_tool_calls.append(
                                ToolCall(
                                    name=tc.get("name", tc.get("function", {}).get("name", "")),
                                    arguments=tc.get("args", tc.get("function", {}).get("arguments", {})),
                                    timestamp=datetime.utcnow()
                                )
                            )

                messages.append(
                    Message(
                        role=role,
                        content=str(content),
                        tool_calls=message_tool_calls,
                        timestamp=datetime.utcnow()
                    )
                )

            # Handle LangChain message objects (with type attribute)
            elif hasattr(msg, "type"):
                role = self._parse_role(msg.type)
                content = getattr(msg, "content", "")

                # Extract tool calls if present
                message_tool_calls = []
                if hasattr(msg, "tool_calls"):
                    tool_calls_data = getattr(msg, "tool_calls", [])
                    for tc in tool_calls_data:
                        if isinstance(tc, dict):
                            message_tool_calls.append(
                                ToolCall(
                                    name=tc.get("name", ""),
                                    arguments=tc.get("args", {}),
                                    timestamp=datetime.utcnow()
                                )
                            )

                messages.append(
                    Message(
                        role=role,
                        content=str(content),
                        tool_calls=message_tool_calls,
                        timestamp=datetime.utcnow()
                    )
                )

        return messages

    def _extract_messages_from_inputs(self, inputs: Dict[str, Any]) -> List[Message]:
        """
        Extract messages from run inputs.

        Args:
            inputs: Input dictionary from a run

        Returns:
            List of Message objects
        """
        messages = []

        # Check for direct message content
        if "input" in inputs:
            input_content = inputs["input"]
            if isinstance(input_content, str):
                messages.append(
                    Message(
                        role=MessageRole.USER,
                        content=input_content,
                        timestamp=datetime.utcnow()
                    )
                )
            elif isinstance(input_content, list):
                messages.extend(self._parse_message_list(input_content))

        # Check for messages field in inputs
        if "messages" in inputs:
            messages.extend(self._parse_message_list(inputs["messages"]))

        # Check for question field (common in QA chains)
        if "question" in inputs and isinstance(inputs["question"], str):
            messages.append(
                Message(
                    role=MessageRole.USER,
                    content=inputs["question"],
                    timestamp=datetime.utcnow()
                )
            )

        return messages

    def _extract_messages_from_outputs(self, outputs: Dict[str, Any]) -> List[Message]:
        """
        Extract messages from run outputs.

        Args:
            outputs: Output dictionary from a run

        Returns:
            List of Message objects
        """
        messages = []

        # Check for direct output content
        if "output" in outputs:
            output_content = outputs["output"]
            if isinstance(output_content, str):
                messages.append(
                    Message(
                        role=MessageRole.ASSISTANT,
                        content=output_content,
                        timestamp=datetime.utcnow()
                    )
                )
            elif isinstance(output_content, list):
                messages.extend(self._parse_message_list(output_content))

        # Check for messages field in outputs
        if "messages" in outputs:
            messages.extend(self._parse_message_list(outputs["messages"]))

        # Check for answer field (common in QA chains)
        if "answer" in outputs and isinstance(outputs["answer"], str):
            messages.append(
                Message(
                    role=MessageRole.ASSISTANT,
                    content=outputs["answer"],
                    timestamp=datetime.utcnow()
                )
            )

        # Check for text field
        if "text" in outputs and isinstance(outputs["text"], str):
            messages.append(
                Message(
                    role=MessageRole.ASSISTANT,
                    content=outputs["text"],
                    timestamp=datetime.utcnow()
                )
            )

        return messages

    def _parse_role(self, role_str: str) -> MessageRole:
        """
        Parse role string to MessageRole enum.

        Args:
            role_str: Role string from LangChain

        Returns:
            MessageRole enum value
        """
        role_lower = str(role_str).lower()

        # Map LangChain role types to MessageRole
        if role_lower in ["human", "user", "humanmessage"]:
            return MessageRole.USER
        elif role_lower in ["ai", "assistant", "aimessage"]:
            return MessageRole.ASSISTANT
        elif role_lower in ["system", "systemmessage"]:
            return MessageRole.SYSTEM
        elif role_lower in ["tool", "function", "toolmessage", "functionmessage"]:
            return MessageRole.TOOL
        else:
            # Default to assistant for unknown types
            return MessageRole.ASSISTANT

    def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
        """
        Extract tool calls from LangChain callback data.

        Tool calls can come from:
        1. "actions" field in agent execution
        2. "tool_calls" in messages
        3. "intermediate_steps" in agent runs

        Args:
            raw_data: LangChain trace data

        Returns:
            List of ToolCall objects

        Raises:
            AdapterTransformationError: If tool call extraction fails
        """
        try:
            tool_calls = []

            # Case 1: Direct actions field
            if "actions" in raw_data and raw_data["actions"]:
                for action in raw_data["actions"]:
                    if not isinstance(action, dict):
                        continue

                    tool_call = self._parse_action_to_tool_call(action)
                    if tool_call:
                        tool_calls.append(tool_call)

            # Case 2: Intermediate steps (agent execution)
            if "intermediate_steps" in raw_data and raw_data["intermediate_steps"]:
                for step in raw_data["intermediate_steps"]:
                    if not isinstance(step, (list, tuple)) or len(step) < 2:
                        continue

                    # Step format: (action, observation)
                    action = step[0]
                    observation = step[1] if len(step) > 1 else None

                    tool_call = self._parse_action_to_tool_call(action, observation)
                    if tool_call:
                        tool_calls.append(tool_call)

            # Case 3: Runs with actions
            if "runs" in raw_data and raw_data["runs"]:
                for run in raw_data["runs"]:
                    if not isinstance(run, dict):
                        continue

                    # Check for actions in run
                    if "actions" in run:
                        for action in run["actions"]:
                            tool_call = self._parse_action_to_tool_call(action)
                            if tool_call:
                                tool_calls.append(tool_call)

                    # Check for intermediate_steps in run
                    if "intermediate_steps" in run:
                        for step in run["intermediate_steps"]:
                            if isinstance(step, (list, tuple)) and len(step) >= 2:
                                action = step[0]
                                observation = step[1]
                                tool_call = self._parse_action_to_tool_call(action, observation)
                                if tool_call:
                                    tool_calls.append(tool_call)

            # Case 4: Extract from messages with tool_calls
            messages = raw_data.get("messages", [])
            if messages:
                for msg in messages:
                    if isinstance(msg, dict) and "tool_calls" in msg:
                        for tc in msg["tool_calls"]:
                            if isinstance(tc, dict):
                                tool_calls.append(
                                    ToolCall(
                                        name=tc.get("name", tc.get("function", {}).get("name", "")),
                                        arguments=tc.get("args", tc.get("function", {}).get("arguments", {})),
                                        result=tc.get("result"),
                                        timestamp=datetime.utcnow()
                                    )
                                )

            return tool_calls

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract tool calls: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def _parse_action_to_tool_call(
        self,
        action: Any,
        observation: Any = None
    ) -> ToolCall:
        """
        Parse a LangChain action to a ToolCall object.

        Args:
            action: Action object or dict from LangChain
            observation: Optional observation/result from tool execution

        Returns:
            ToolCall object or None if parsing fails
        """
        try:
            # Handle dict format
            if isinstance(action, dict):
                tool_name = action.get("tool", action.get("name", ""))
                tool_input = action.get("tool_input", action.get("args", {}))

                # Ensure tool_input is a dict
                if not isinstance(tool_input, dict):
                    tool_input = {"input": tool_input}

                return ToolCall(
                    name=tool_name,
                    arguments=tool_input,
                    result=observation,
                    timestamp=datetime.utcnow()
                )

            # Handle object format (AgentAction)
            elif hasattr(action, "tool"):
                tool_name = getattr(action, "tool", "")
                tool_input = getattr(action, "tool_input", {})

                # Ensure tool_input is a dict
                if not isinstance(tool_input, dict):
                    tool_input = {"input": tool_input}

                return ToolCall(
                    name=tool_name,
                    arguments=tool_input,
                    result=observation,
                    timestamp=datetime.utcnow()
                )

            return None

        except Exception:
            return None

    def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Extract metadata from LangChain callback data.

        Metadata includes:
        - input_tokens: Total input tokens from llm_output
        - output_tokens: Total output tokens from llm_output
        - latency: Execution time (if provided)
        - model: Model name (if available)
        - run_id: LangChain run identifier

        Args:
            raw_data: LangChain trace data

        Returns:
            Dictionary of metadata

        Raises:
            AdapterTransformationError: If metadata extraction fails
        """
        try:
            metadata = {}

            input_tokens = 0
            output_tokens = 0

            # Extract from llm_output field
            if "llm_output" in raw_data and isinstance(raw_data["llm_output"], dict):
                llm_output = raw_data["llm_output"]

                # Check for token_usage
                if "token_usage" in llm_output:
                    token_usage = llm_output["token_usage"]
                    if isinstance(token_usage, dict):
                        input_tokens += token_usage.get("prompt_tokens", 0)
                        output_tokens += token_usage.get("completion_tokens", 0)

                # Check for model_name
                if "model_name" in llm_output:
                    metadata["model"] = llm_output["model_name"]

            # Extract from runs
            if "runs" in raw_data and raw_data["runs"]:
                for run in raw_data["runs"]:
                    if not isinstance(run, dict):
                        continue

                    # Extract token usage from run
                    if "llm_output" in run and isinstance(run["llm_output"], dict):
                        llm_output = run["llm_output"]
                        if "token_usage" in llm_output:
                            token_usage = llm_output["token_usage"]
                            if isinstance(token_usage, dict):
                                input_tokens += token_usage.get("prompt_tokens", 0)
                                output_tokens += token_usage.get("completion_tokens", 0)

            # Add token counts to metadata
            if input_tokens > 0:
                metadata["input_tokens"] = input_tokens
            if output_tokens > 0:
                metadata["output_tokens"] = output_tokens

            # Add run_id if present
            if "run_id" in raw_data:
                metadata["run_id"] = str(raw_data["run_id"])

            # Add latency if provided
            if "latency" in raw_data:
                metadata["latency"] = raw_data["latency"]

            # Add model if provided directly
            if "model" in raw_data:
                metadata["model"] = raw_data["model"]

            # Add any additional metadata from raw_data
            if "metadata" in raw_data and isinstance(raw_data["metadata"], dict):
                metadata.update(raw_data["metadata"])

            return metadata

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract metadata: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

name property

Return the adapter name.

extract_messages(raw_data)

Extract messages from LangChain callback data.

Messages can come from: 1. Direct "messages" field (list of message objects) 2. "runs" field containing run data with inputs/outputs 3. "inputs" and "outputs" fields in a single run

LangChain messages may have different formats: - HumanMessage, AIMessage, SystemMessage, ToolMessage - Dict with "role" and "content" keys - String content with role inferred from context

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

LangChain trace data

required

Returns:

Type Description
List[Message]

List of Message objects

Raises:

Type Description
AdapterTransformationError

If message extraction fails

Source code in uaef/adapters/langchain.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
    """
    Extract messages from LangChain callback data.

    Messages can come from:
    1. Direct "messages" field (list of message objects)
    2. "runs" field containing run data with inputs/outputs
    3. "inputs" and "outputs" fields in a single run

    LangChain messages may have different formats:
    - HumanMessage, AIMessage, SystemMessage, ToolMessage
    - Dict with "role" and "content" keys
    - String content with role inferred from context

    Args:
        raw_data: LangChain trace data

    Returns:
        List of Message objects

    Raises:
        AdapterTransformationError: If message extraction fails
    """
    try:
        messages = []

        # Case 1: Direct messages field
        if "messages" in raw_data and raw_data["messages"]:
            messages.extend(self._parse_message_list(raw_data["messages"]))

        # Case 2: Runs field with multiple runs
        elif "runs" in raw_data and raw_data["runs"]:
            for run in raw_data["runs"]:
                if not isinstance(run, dict):
                    continue

                # Extract messages from run inputs
                if "inputs" in run:
                    input_messages = self._extract_messages_from_inputs(run["inputs"])
                    messages.extend(input_messages)

                # Extract messages from run outputs
                if "outputs" in run:
                    output_messages = self._extract_messages_from_outputs(run["outputs"])
                    messages.extend(output_messages)

        # Case 3: Single run with inputs/outputs
        elif "inputs" in raw_data or "outputs" in raw_data:
            if "inputs" in raw_data:
                input_messages = self._extract_messages_from_inputs(raw_data["inputs"])
                messages.extend(input_messages)

            if "outputs" in raw_data:
                output_messages = self._extract_messages_from_outputs(raw_data["outputs"])
                messages.extend(output_messages)

        return messages

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract messages: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

extract_metadata(raw_data)

Extract metadata from LangChain callback data.

Metadata includes: - input_tokens: Total input tokens from llm_output - output_tokens: Total output tokens from llm_output - latency: Execution time (if provided) - model: Model name (if available) - run_id: LangChain run identifier

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

LangChain trace data

required

Returns:

Type Description
Dict[str, Any]

Dictionary of metadata

Raises:

Type Description
AdapterTransformationError

If metadata extraction fails

Source code in uaef/adapters/langchain.py
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
    """
    Extract metadata from LangChain callback data.

    Metadata includes:
    - input_tokens: Total input tokens from llm_output
    - output_tokens: Total output tokens from llm_output
    - latency: Execution time (if provided)
    - model: Model name (if available)
    - run_id: LangChain run identifier

    Args:
        raw_data: LangChain trace data

    Returns:
        Dictionary of metadata

    Raises:
        AdapterTransformationError: If metadata extraction fails
    """
    try:
        metadata = {}

        input_tokens = 0
        output_tokens = 0

        # Extract from llm_output field
        if "llm_output" in raw_data and isinstance(raw_data["llm_output"], dict):
            llm_output = raw_data["llm_output"]

            # Check for token_usage
            if "token_usage" in llm_output:
                token_usage = llm_output["token_usage"]
                if isinstance(token_usage, dict):
                    input_tokens += token_usage.get("prompt_tokens", 0)
                    output_tokens += token_usage.get("completion_tokens", 0)

            # Check for model_name
            if "model_name" in llm_output:
                metadata["model"] = llm_output["model_name"]

        # Extract from runs
        if "runs" in raw_data and raw_data["runs"]:
            for run in raw_data["runs"]:
                if not isinstance(run, dict):
                    continue

                # Extract token usage from run
                if "llm_output" in run and isinstance(run["llm_output"], dict):
                    llm_output = run["llm_output"]
                    if "token_usage" in llm_output:
                        token_usage = llm_output["token_usage"]
                        if isinstance(token_usage, dict):
                            input_tokens += token_usage.get("prompt_tokens", 0)
                            output_tokens += token_usage.get("completion_tokens", 0)

        # Add token counts to metadata
        if input_tokens > 0:
            metadata["input_tokens"] = input_tokens
        if output_tokens > 0:
            metadata["output_tokens"] = output_tokens

        # Add run_id if present
        if "run_id" in raw_data:
            metadata["run_id"] = str(raw_data["run_id"])

        # Add latency if provided
        if "latency" in raw_data:
            metadata["latency"] = raw_data["latency"]

        # Add model if provided directly
        if "model" in raw_data:
            metadata["model"] = raw_data["model"]

        # Add any additional metadata from raw_data
        if "metadata" in raw_data and isinstance(raw_data["metadata"], dict):
            metadata.update(raw_data["metadata"])

        return metadata

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract metadata: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

extract_tool_calls(raw_data)

Extract tool calls from LangChain callback data.

Tool calls can come from: 1. "actions" field in agent execution 2. "tool_calls" in messages 3. "intermediate_steps" in agent runs

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

LangChain trace data

required

Returns:

Type Description
List[ToolCall]

List of ToolCall objects

Raises:

Type Description
AdapterTransformationError

If tool call extraction fails

Source code in uaef/adapters/langchain.py
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
    """
    Extract tool calls from LangChain callback data.

    Tool calls can come from:
    1. "actions" field in agent execution
    2. "tool_calls" in messages
    3. "intermediate_steps" in agent runs

    Args:
        raw_data: LangChain trace data

    Returns:
        List of ToolCall objects

    Raises:
        AdapterTransformationError: If tool call extraction fails
    """
    try:
        tool_calls = []

        # Case 1: Direct actions field
        if "actions" in raw_data and raw_data["actions"]:
            for action in raw_data["actions"]:
                if not isinstance(action, dict):
                    continue

                tool_call = self._parse_action_to_tool_call(action)
                if tool_call:
                    tool_calls.append(tool_call)

        # Case 2: Intermediate steps (agent execution)
        if "intermediate_steps" in raw_data and raw_data["intermediate_steps"]:
            for step in raw_data["intermediate_steps"]:
                if not isinstance(step, (list, tuple)) or len(step) < 2:
                    continue

                # Step format: (action, observation)
                action = step[0]
                observation = step[1] if len(step) > 1 else None

                tool_call = self._parse_action_to_tool_call(action, observation)
                if tool_call:
                    tool_calls.append(tool_call)

        # Case 3: Runs with actions
        if "runs" in raw_data and raw_data["runs"]:
            for run in raw_data["runs"]:
                if not isinstance(run, dict):
                    continue

                # Check for actions in run
                if "actions" in run:
                    for action in run["actions"]:
                        tool_call = self._parse_action_to_tool_call(action)
                        if tool_call:
                            tool_calls.append(tool_call)

                # Check for intermediate_steps in run
                if "intermediate_steps" in run:
                    for step in run["intermediate_steps"]:
                        if isinstance(step, (list, tuple)) and len(step) >= 2:
                            action = step[0]
                            observation = step[1]
                            tool_call = self._parse_action_to_tool_call(action, observation)
                            if tool_call:
                                tool_calls.append(tool_call)

        # Case 4: Extract from messages with tool_calls
        messages = raw_data.get("messages", [])
        if messages:
            for msg in messages:
                if isinstance(msg, dict) and "tool_calls" in msg:
                    for tc in msg["tool_calls"]:
                        if isinstance(tc, dict):
                            tool_calls.append(
                                ToolCall(
                                    name=tc.get("name", tc.get("function", {}).get("name", "")),
                                    arguments=tc.get("args", tc.get("function", {}).get("arguments", {})),
                                    result=tc.get("result"),
                                    timestamp=datetime.utcnow()
                                )
                            )

        return tool_calls

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract tool calls: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

transform_to_canonical(raw_data)

Transform LangChain trace data to canonical AgentTrace format.

Expected raw_data structure: { "runs": [...], # List of run data from callback handlers "messages": [...], # Optional: Direct message list "session_id": "...", # Optional session identifier "trace_id": "...", # Optional trace identifier (UUID string) "metadata": {...}, # Optional additional metadata }

Alternative structure (single run): { "run_id": "...", "inputs": {...}, "outputs": {...}, "actions": [...], # Tool calls/actions "llm_output": {...}, # LLM response with token usage "messages": [...], # Chat messages "metadata": {...}, }

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

LangChain trace data

required

Returns:

Type Description
AgentTrace

Canonical AgentTrace object

Raises:

Type Description
AdapterTransformationError

If transformation fails

Source code in uaef/adapters/langchain.py
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
def transform_to_canonical(self, raw_data: Dict[str, Any]) -> AgentTrace:
    """
    Transform LangChain trace data to canonical AgentTrace format.

    Expected raw_data structure:
    {
        "runs": [...],  # List of run data from callback handlers
        "messages": [...],  # Optional: Direct message list
        "session_id": "...",  # Optional session identifier
        "trace_id": "...",  # Optional trace identifier (UUID string)
        "metadata": {...},  # Optional additional metadata
    }

    Alternative structure (single run):
    {
        "run_id": "...",
        "inputs": {...},
        "outputs": {...},
        "actions": [...],  # Tool calls/actions
        "llm_output": {...},  # LLM response with token usage
        "messages": [...],  # Chat messages
        "metadata": {...},
    }

    Args:
        raw_data: LangChain trace data

    Returns:
        Canonical AgentTrace object

    Raises:
        AdapterTransformationError: If transformation fails
    """
    try:
        # Validate that we have some data to work with
        if not raw_data:
            raise AdapterTransformationError(
                "Empty raw_data provided",
                adapter_name=self.name
            )

        # Extract components
        messages = self.extract_messages(raw_data)
        tool_calls = self.extract_tool_calls(raw_data)
        metadata = self.extract_metadata(raw_data)

        # Create AgentTrace
        trace = AgentTrace(
            trace_id=raw_data.get("trace_id", uuid4()),
            session_id=raw_data.get("session_id"),
            messages=messages,
            tool_calls=tool_calls,
            input_tokens=metadata.get("input_tokens"),
            output_tokens=metadata.get("output_tokens"),
            latency=metadata.get("latency"),
            cost=metadata.get("cost"),
            metadata=metadata,
            framework="langchain",
            timestamp=datetime.utcnow()
        )

        return trace

    except AdapterTransformationError:
        raise
    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to transform LangChain trace: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

uaef.adapters.bedrock

Bedrock Agent adapter for transforming Bedrock Agent traces to canonical format.

BedrockAgentAdapter

Bases: BaseAdapter

Adapter for transforming Bedrock Agent invoke_agent responses to canonical AgentTrace format.

This adapter processes Bedrock Agent execution traces that contain: - Event stream from invoke_agent response - Chunk events with response text - Trace events with orchestration information - Tool invocation details - Token usage metadata

The adapter reuses logic from the get_answers_detail_bedrock() function in agenticevaluationframework-main/frontend/evaluation.py.

Source code in uaef/adapters/bedrock.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
class BedrockAgentAdapter(BaseAdapter):
    """
    Adapter for transforming Bedrock Agent invoke_agent responses to canonical AgentTrace format.

    This adapter processes Bedrock Agent execution traces that contain:
    - Event stream from invoke_agent response
    - Chunk events with response text
    - Trace events with orchestration information
    - Tool invocation details
    - Token usage metadata

    The adapter reuses logic from the get_answers_detail_bedrock() function
    in agenticevaluationframework-main/frontend/evaluation.py.
    """

    @property
    def name(self) -> str:
        """Return the adapter name."""
        return "bedrock"

    def transform_to_canonical(self, raw_data: Dict[str, Any]) -> AgentTrace:
        """
        Transform Bedrock Agent response data to canonical AgentTrace format.

        Expected raw_data structure:
        {
            "event_stream": [...],  # List of events from invoke_agent response
            "user_input": "...",  # Original user input
            "session_id": "...",  # Session identifier
            "trace_id": "...",  # Optional trace identifier (UUID string)
            "agent_id": "...",  # Bedrock agent ID
            "alias_id": "...",  # Agent alias ID
        }

        Args:
            raw_data: Bedrock Agent response data

        Returns:
            Canonical AgentTrace object

        Raises:
            AdapterTransformationError: If transformation fails
        """
        try:
            # Validate required fields
            self.validate_raw_data(raw_data, ["event_stream"])

            # Extract components
            messages = self.extract_messages(raw_data)
            tool_calls = self.extract_tool_calls(raw_data)
            metadata = self.extract_metadata(raw_data)

            # Create AgentTrace
            trace = AgentTrace(
                trace_id=raw_data.get("trace_id", uuid4()),
                session_id=raw_data.get("session_id"),
                messages=messages,
                tool_calls=tool_calls,
                input_tokens=metadata.get("input_tokens"),
                output_tokens=metadata.get("output_tokens"),
                latency=metadata.get("latency"),
                cost=metadata.get("cost"),
                metadata=metadata,
                framework="bedrock",
                timestamp=datetime.utcnow()
            )

            return trace

        except AdapterTransformationError:
            raise
        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to transform Bedrock Agent trace: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
        """
        Extract messages from Bedrock Agent event stream.

        Messages are extracted from:
        1. User input (if provided)
        2. Chunk events containing response text

        Args:
            raw_data: Bedrock Agent response data

        Returns:
            List of Message objects

        Raises:
            AdapterTransformationError: If message extraction fails
        """
        try:
            messages = []

            # Add user input message if provided
            user_input = raw_data.get("user_input")
            if user_input:
                messages.append(
                    Message(
                        role=MessageRole.USER,
                        content=user_input,
                        timestamp=datetime.utcnow()
                    )
                )

            # Extract response chunks from event stream
            event_stream = raw_data.get("event_stream", [])
            response_chunks = []

            for event in event_stream:
                if not isinstance(event, dict):
                    continue

                # Check for chunk events containing response text
                if "chunk" in event:
                    chunk_data = event["chunk"]
                    if isinstance(chunk_data, dict) and "bytes" in chunk_data:
                        # Decode bytes to string
                        try:
                            text = chunk_data["bytes"].decode("utf-8")
                            response_chunks.append(text)
                        except (AttributeError, UnicodeDecodeError):
                            # If bytes is already a string or decoding fails
                            response_chunks.append(str(chunk_data["bytes"]))

            # Combine response chunks into a single assistant message
            if response_chunks:
                messages.append(
                    Message(
                        role=MessageRole.ASSISTANT,
                        content="".join(response_chunks),
                        timestamp=datetime.utcnow()
                    )
                )

            return messages

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract messages: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
        """
        Extract tool calls from Bedrock Agent trace events.

        Tool calls are extracted from orchestrationTrace events that contain
        invocationInput with actionGroupInvocationInput information.

        Args:
            raw_data: Bedrock Agent response data

        Returns:
            List of ToolCall objects

        Raises:
            AdapterTransformationError: If tool call extraction fails
        """
        try:
            tool_calls = []
            event_stream = raw_data.get("event_stream", [])

            for event in event_stream:
                if not isinstance(event, dict):
                    continue

                # Check for trace events
                if "trace" in event:
                    trace_data = event["trace"]
                    if not isinstance(trace_data, dict):
                        continue

                    # Navigate to orchestrationTrace
                    trace_info = trace_data.get("trace", {})
                    if not isinstance(trace_info, dict):
                        continue

                    orchestration_trace = trace_info.get("orchestrationTrace", {})
                    if not isinstance(orchestration_trace, dict):
                        continue

                    # Extract tool invocation information
                    if "invocationInput" in orchestration_trace:
                        invocation_input = orchestration_trace["invocationInput"]
                        if not isinstance(invocation_input, dict):
                            continue

                        action_group_input = invocation_input.get("actionGroupInvocationInput")
                        if isinstance(action_group_input, dict):
                            # Extract tool name
                            tool_name = action_group_input.get("function", "")

                            # Extract tool parameters
                            parameters = action_group_input.get("parameters", [])
                            tool_args = {}

                            if isinstance(parameters, list):
                                for param in parameters:
                                    if isinstance(param, dict):
                                        param_name = param.get("name")
                                        param_value = param.get("value")
                                        if param_name:
                                            tool_args[param_name] = param_value

                            # Create ToolCall object
                            if tool_name:
                                tool_call = ToolCall(
                                    name=tool_name,
                                    arguments=tool_args,
                                    timestamp=datetime.utcnow()
                                )
                                tool_calls.append(tool_call)

            return tool_calls

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract tool calls: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Extract metadata from Bedrock Agent trace events.

        Metadata includes:
        - input_tokens: Total input tokens from modelInvocationOutput
        - output_tokens: Total output tokens from modelInvocationOutput
        - latency: Execution time (if provided)
        - agent_id: Bedrock agent ID
        - alias_id: Agent alias ID

        Args:
            raw_data: Bedrock Agent response data

        Returns:
            Dictionary of metadata

        Raises:
            AdapterTransformationError: If metadata extraction fails
        """
        try:
            metadata = {}
            event_stream = raw_data.get("event_stream", [])

            input_tokens = 0
            output_tokens = 0

            # Accumulate token usage from trace events
            for event in event_stream:
                if not isinstance(event, dict):
                    continue

                # Check for trace events with token usage
                if "trace" in event:
                    trace_data = event["trace"]
                    if not isinstance(trace_data, dict):
                        continue

                    # Navigate to orchestrationTrace
                    trace_info = trace_data.get("trace", {})
                    if not isinstance(trace_info, dict):
                        continue

                    orchestration_trace = trace_info.get("orchestrationTrace", {})
                    if not isinstance(orchestration_trace, dict):
                        continue

                    # Extract token usage from modelInvocationOutput
                    if "modelInvocationOutput" in orchestration_trace:
                        model_output = orchestration_trace["modelInvocationOutput"]
                        if isinstance(model_output, dict):
                            metadata_info = model_output.get("metadata", {})
                            if isinstance(metadata_info, dict):
                                usage = metadata_info.get("usage", {})
                                if isinstance(usage, dict):
                                    input_tokens += usage.get("inputTokens", 0)
                                    output_tokens += usage.get("outputTokens", 0)

            # Add token counts to metadata
            if input_tokens > 0:
                metadata["input_tokens"] = input_tokens
            if output_tokens > 0:
                metadata["output_tokens"] = output_tokens

            # Add agent identifiers
            if "agent_id" in raw_data:
                metadata["agent_id"] = raw_data["agent_id"]
            if "alias_id" in raw_data:
                metadata["alias_id"] = raw_data["alias_id"]

            # Add latency if provided
            if "latency" in raw_data:
                metadata["latency"] = raw_data["latency"]

            return metadata

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract metadata: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

name property

Return the adapter name.

extract_messages(raw_data)

Extract messages from Bedrock Agent event stream.

Messages are extracted from: 1. User input (if provided) 2. Chunk events containing response text

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Bedrock Agent response data

required

Returns:

Type Description
List[Message]

List of Message objects

Raises:

Type Description
AdapterTransformationError

If message extraction fails

Source code in uaef/adapters/bedrock.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
    """
    Extract messages from Bedrock Agent event stream.

    Messages are extracted from:
    1. User input (if provided)
    2. Chunk events containing response text

    Args:
        raw_data: Bedrock Agent response data

    Returns:
        List of Message objects

    Raises:
        AdapterTransformationError: If message extraction fails
    """
    try:
        messages = []

        # Add user input message if provided
        user_input = raw_data.get("user_input")
        if user_input:
            messages.append(
                Message(
                    role=MessageRole.USER,
                    content=user_input,
                    timestamp=datetime.utcnow()
                )
            )

        # Extract response chunks from event stream
        event_stream = raw_data.get("event_stream", [])
        response_chunks = []

        for event in event_stream:
            if not isinstance(event, dict):
                continue

            # Check for chunk events containing response text
            if "chunk" in event:
                chunk_data = event["chunk"]
                if isinstance(chunk_data, dict) and "bytes" in chunk_data:
                    # Decode bytes to string
                    try:
                        text = chunk_data["bytes"].decode("utf-8")
                        response_chunks.append(text)
                    except (AttributeError, UnicodeDecodeError):
                        # If bytes is already a string or decoding fails
                        response_chunks.append(str(chunk_data["bytes"]))

        # Combine response chunks into a single assistant message
        if response_chunks:
            messages.append(
                Message(
                    role=MessageRole.ASSISTANT,
                    content="".join(response_chunks),
                    timestamp=datetime.utcnow()
                )
            )

        return messages

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract messages: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

extract_metadata(raw_data)

Extract metadata from Bedrock Agent trace events.

Metadata includes: - input_tokens: Total input tokens from modelInvocationOutput - output_tokens: Total output tokens from modelInvocationOutput - latency: Execution time (if provided) - agent_id: Bedrock agent ID - alias_id: Agent alias ID

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Bedrock Agent response data

required

Returns:

Type Description
Dict[str, Any]

Dictionary of metadata

Raises:

Type Description
AdapterTransformationError

If metadata extraction fails

Source code in uaef/adapters/bedrock.py
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
    """
    Extract metadata from Bedrock Agent trace events.

    Metadata includes:
    - input_tokens: Total input tokens from modelInvocationOutput
    - output_tokens: Total output tokens from modelInvocationOutput
    - latency: Execution time (if provided)
    - agent_id: Bedrock agent ID
    - alias_id: Agent alias ID

    Args:
        raw_data: Bedrock Agent response data

    Returns:
        Dictionary of metadata

    Raises:
        AdapterTransformationError: If metadata extraction fails
    """
    try:
        metadata = {}
        event_stream = raw_data.get("event_stream", [])

        input_tokens = 0
        output_tokens = 0

        # Accumulate token usage from trace events
        for event in event_stream:
            if not isinstance(event, dict):
                continue

            # Check for trace events with token usage
            if "trace" in event:
                trace_data = event["trace"]
                if not isinstance(trace_data, dict):
                    continue

                # Navigate to orchestrationTrace
                trace_info = trace_data.get("trace", {})
                if not isinstance(trace_info, dict):
                    continue

                orchestration_trace = trace_info.get("orchestrationTrace", {})
                if not isinstance(orchestration_trace, dict):
                    continue

                # Extract token usage from modelInvocationOutput
                if "modelInvocationOutput" in orchestration_trace:
                    model_output = orchestration_trace["modelInvocationOutput"]
                    if isinstance(model_output, dict):
                        metadata_info = model_output.get("metadata", {})
                        if isinstance(metadata_info, dict):
                            usage = metadata_info.get("usage", {})
                            if isinstance(usage, dict):
                                input_tokens += usage.get("inputTokens", 0)
                                output_tokens += usage.get("outputTokens", 0)

        # Add token counts to metadata
        if input_tokens > 0:
            metadata["input_tokens"] = input_tokens
        if output_tokens > 0:
            metadata["output_tokens"] = output_tokens

        # Add agent identifiers
        if "agent_id" in raw_data:
            metadata["agent_id"] = raw_data["agent_id"]
        if "alias_id" in raw_data:
            metadata["alias_id"] = raw_data["alias_id"]

        # Add latency if provided
        if "latency" in raw_data:
            metadata["latency"] = raw_data["latency"]

        return metadata

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract metadata: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

extract_tool_calls(raw_data)

Extract tool calls from Bedrock Agent trace events.

Tool calls are extracted from orchestrationTrace events that contain invocationInput with actionGroupInvocationInput information.

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Bedrock Agent response data

required

Returns:

Type Description
List[ToolCall]

List of ToolCall objects

Raises:

Type Description
AdapterTransformationError

If tool call extraction fails

Source code in uaef/adapters/bedrock.py
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
    """
    Extract tool calls from Bedrock Agent trace events.

    Tool calls are extracted from orchestrationTrace events that contain
    invocationInput with actionGroupInvocationInput information.

    Args:
        raw_data: Bedrock Agent response data

    Returns:
        List of ToolCall objects

    Raises:
        AdapterTransformationError: If tool call extraction fails
    """
    try:
        tool_calls = []
        event_stream = raw_data.get("event_stream", [])

        for event in event_stream:
            if not isinstance(event, dict):
                continue

            # Check for trace events
            if "trace" in event:
                trace_data = event["trace"]
                if not isinstance(trace_data, dict):
                    continue

                # Navigate to orchestrationTrace
                trace_info = trace_data.get("trace", {})
                if not isinstance(trace_info, dict):
                    continue

                orchestration_trace = trace_info.get("orchestrationTrace", {})
                if not isinstance(orchestration_trace, dict):
                    continue

                # Extract tool invocation information
                if "invocationInput" in orchestration_trace:
                    invocation_input = orchestration_trace["invocationInput"]
                    if not isinstance(invocation_input, dict):
                        continue

                    action_group_input = invocation_input.get("actionGroupInvocationInput")
                    if isinstance(action_group_input, dict):
                        # Extract tool name
                        tool_name = action_group_input.get("function", "")

                        # Extract tool parameters
                        parameters = action_group_input.get("parameters", [])
                        tool_args = {}

                        if isinstance(parameters, list):
                            for param in parameters:
                                if isinstance(param, dict):
                                    param_name = param.get("name")
                                    param_value = param.get("value")
                                    if param_name:
                                        tool_args[param_name] = param_value

                        # Create ToolCall object
                        if tool_name:
                            tool_call = ToolCall(
                                name=tool_name,
                                arguments=tool_args,
                                timestamp=datetime.utcnow()
                            )
                            tool_calls.append(tool_call)

        return tool_calls

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract tool calls: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

transform_to_canonical(raw_data)

Transform Bedrock Agent response data to canonical AgentTrace format.

Expected raw_data structure: { "event_stream": [...], # List of events from invoke_agent response "user_input": "...", # Original user input "session_id": "...", # Session identifier "trace_id": "...", # Optional trace identifier (UUID string) "agent_id": "...", # Bedrock agent ID "alias_id": "...", # Agent alias ID }

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Bedrock Agent response data

required

Returns:

Type Description
AgentTrace

Canonical AgentTrace object

Raises:

Type Description
AdapterTransformationError

If transformation fails

Source code in uaef/adapters/bedrock.py
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def transform_to_canonical(self, raw_data: Dict[str, Any]) -> AgentTrace:
    """
    Transform Bedrock Agent response data to canonical AgentTrace format.

    Expected raw_data structure:
    {
        "event_stream": [...],  # List of events from invoke_agent response
        "user_input": "...",  # Original user input
        "session_id": "...",  # Session identifier
        "trace_id": "...",  # Optional trace identifier (UUID string)
        "agent_id": "...",  # Bedrock agent ID
        "alias_id": "...",  # Agent alias ID
    }

    Args:
        raw_data: Bedrock Agent response data

    Returns:
        Canonical AgentTrace object

    Raises:
        AdapterTransformationError: If transformation fails
    """
    try:
        # Validate required fields
        self.validate_raw_data(raw_data, ["event_stream"])

        # Extract components
        messages = self.extract_messages(raw_data)
        tool_calls = self.extract_tool_calls(raw_data)
        metadata = self.extract_metadata(raw_data)

        # Create AgentTrace
        trace = AgentTrace(
            trace_id=raw_data.get("trace_id", uuid4()),
            session_id=raw_data.get("session_id"),
            messages=messages,
            tool_calls=tool_calls,
            input_tokens=metadata.get("input_tokens"),
            output_tokens=metadata.get("output_tokens"),
            latency=metadata.get("latency"),
            cost=metadata.get("cost"),
            metadata=metadata,
            framework="bedrock",
            timestamp=datetime.utcnow()
        )

        return trace

    except AdapterTransformationError:
        raise
    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to transform Bedrock Agent trace: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

uaef.adapters.langfuse

Langfuse adapter for transforming Langfuse traces to canonical format.

LangfuseAdapter

Bases: BaseAdapter

Adapter for transforming Langfuse observation data to canonical AgentTrace format.

This adapter processes Langfuse traces that contain: - Hierarchical observation trees (spans, generations, events) - Messages from LLM generations - Tool calls from function/tool observations - Token usage and cost metadata from generations - Latency information from span durations

Langfuse uses a hierarchical structure where: - Trace: Top-level container for a single execution - Observations: Individual steps within a trace (spans, generations, events) - Spans: Units of work with duration - Generations: LLM calls with token counts and I/O - Events: Point-in-time occurrences

The adapter transforms this hierarchical structure into the flat canonical format.

Source code in uaef/adapters/langfuse.py
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
class LangfuseAdapter(BaseAdapter):
    """
    Adapter for transforming Langfuse observation data to canonical AgentTrace format.

    This adapter processes Langfuse traces that contain:
    - Hierarchical observation trees (spans, generations, events)
    - Messages from LLM generations
    - Tool calls from function/tool observations
    - Token usage and cost metadata from generations
    - Latency information from span durations

    Langfuse uses a hierarchical structure where:
    - Trace: Top-level container for a single execution
    - Observations: Individual steps within a trace (spans, generations, events)
    - Spans: Units of work with duration
    - Generations: LLM calls with token counts and I/O
    - Events: Point-in-time occurrences

    The adapter transforms this hierarchical structure into the flat canonical format.
    """

    @property
    def name(self) -> str:
        """Return the adapter name."""
        return "langfuse"

    def transform_to_canonical(self, raw_data: Dict[str, Any]) -> AgentTrace:
        """
        Transform Langfuse trace data to canonical AgentTrace format.

        Expected raw_data structure:
        {
            "trace_id": "...",  # Langfuse trace ID
            "session_id": "...",  # Optional session identifier
            "observations": [...],  # List of observation objects (spans, generations, events)
            "input": {...},  # Optional trace-level input
            "output": {...},  # Optional trace-level output
            "metadata": {...},  # Optional trace metadata
            "timestamp": "...",  # ISO 8601 timestamp
        }

        Alternative structure (from Observations API):
        {
            "data": [
                {
                    "id": "...",
                    "trace_id": "...",
                    "type": "generation|span|event",
                    "name": "...",
                    "input": {...},
                    "output": {...},
                    "metadata": {...},
                    "usage": {"input": 100, "output": 50, "total": 150},
                    "cost": 0.0025,
                    "start_time": "...",
                    "end_time": "...",
                    "parent_observation_id": "...",
                },
                ...
            ]
        }

        Args:
            raw_data: Langfuse trace data

        Returns:
            Canonical AgentTrace object

        Raises:
            AdapterTransformationError: If transformation fails
        """
        try:
            # Validate that we have some data to work with
            if not raw_data:
                raise AdapterTransformationError(
                    "Empty raw_data provided",
                    adapter_name=self.name
                )

            # Extract components
            messages = self.extract_messages(raw_data)
            tool_calls = self.extract_tool_calls(raw_data)
            metadata = self.extract_metadata(raw_data)

            # Determine trace_id
            trace_id = raw_data.get("trace_id")
            if trace_id:
                # Keep as string if it's not a UUID format
                try:
                    from uuid import UUID
                    if isinstance(trace_id, str):
                        trace_id = UUID(trace_id)
                except (ValueError, AttributeError):
                    # If not a valid UUID, generate a new one and store original in metadata
                    metadata["langfuse_trace_id"] = trace_id
                    trace_id = uuid4()
            else:
                trace_id = uuid4()

            # Create AgentTrace
            trace = AgentTrace(
                trace_id=trace_id,
                session_id=raw_data.get("session_id"),
                messages=messages,
                tool_calls=tool_calls,
                input_tokens=metadata.get("input_tokens"),
                output_tokens=metadata.get("output_tokens"),
                latency=metadata.get("latency"),
                cost=metadata.get("cost"),
                metadata=metadata,
                framework="langfuse",
                timestamp=self._parse_timestamp(raw_data.get("timestamp"))
            )

            return trace

        except AdapterTransformationError:
            raise
        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to transform Langfuse trace: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
        """
        Extract messages from Langfuse observations.

        Messages are extracted from:
        1. Generation observations (LLM calls with input/output)
        2. Trace-level input/output
        3. Span observations with message content

        Langfuse generations typically contain:
        - input: The prompt or messages sent to the LLM
        - output: The LLM's response

        Args:
            raw_data: Langfuse trace data

        Returns:
            List of Message objects in chronological order

        Raises:
            AdapterTransformationError: If message extraction fails
        """
        try:
            messages = []

            # Get observations list (handle both direct and nested formats)
            observations = raw_data.get("observations", [])
            if not observations and "data" in raw_data:
                observations = raw_data["data"]

            # Sort observations by timestamp to maintain chronological order
            sorted_observations = self._sort_observations_by_time(observations)

            # Extract messages from each observation
            for obs in sorted_observations:
                if not isinstance(obs, dict):
                    continue

                obs_type = obs.get("type", "").lower()

                # Extract from generation observations (LLM calls)
                if obs_type == "generation":
                    obs_messages = self._extract_messages_from_generation(obs)
                    messages.extend(obs_messages)

                # Extract from span observations if they contain message data
                elif obs_type == "span":
                    obs_messages = self._extract_messages_from_span(obs)
                    messages.extend(obs_messages)

            # If no messages found in observations, try trace-level input/output
            if not messages:
                if "input" in raw_data:
                    input_msg = self._parse_input_to_message(raw_data["input"])
                    if input_msg:
                        messages.append(input_msg)

                if "output" in raw_data:
                    output_msg = self._parse_output_to_message(raw_data["output"])
                    if output_msg:
                        messages.append(output_msg)

            return messages

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract messages: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def _extract_messages_from_generation(self, generation: Dict[str, Any]) -> List[Message]:
        """
        Extract messages from a Langfuse generation observation.

        Args:
            generation: Generation observation dict

        Returns:
            List of Message objects
        """
        messages = []
        timestamp = self._parse_timestamp(generation.get("start_time"))

        # Extract input (user message or prompt)
        if "input" in generation:
            input_msg = self._parse_input_to_message(generation["input"], timestamp)
            if input_msg:
                messages.append(input_msg)

        # Extract output (assistant response)
        if "output" in generation:
            output_msg = self._parse_output_to_message(generation["output"], timestamp)
            if output_msg:
                messages.append(output_msg)

        return messages

    def _extract_messages_from_span(self, span: Dict[str, Any]) -> List[Message]:
        """
        Extract messages from a Langfuse span observation.

        Spans may contain input/output that represents messages.

        Args:
            span: Span observation dict

        Returns:
            List of Message objects
        """
        messages = []
        timestamp = self._parse_timestamp(span.get("start_time"))

        # Check if span has message-like input/output
        if "input" in span:
            input_msg = self._parse_input_to_message(span["input"], timestamp)
            if input_msg:
                messages.append(input_msg)

        if "output" in span:
            output_msg = self._parse_output_to_message(span["output"], timestamp)
            if output_msg:
                messages.append(output_msg)

        return messages

    def _parse_input_to_message(
        self,
        input_data: Any,
        timestamp: Optional[datetime] = None
    ) -> Optional[Message]:
        """
        Parse input data to a Message object.

        Input can be:
        - String: Direct message content
        - Dict with "content" or "message" key
        - List of message objects

        Args:
            input_data: Input data from observation
            timestamp: Optional timestamp for the message

        Returns:
            Message object or None
        """
        if not input_data:
            return None

        if timestamp is None:
            timestamp = datetime.utcnow()

        # Handle string input
        if isinstance(input_data, str):
            return Message(
                role=MessageRole.USER,
                content=input_data,
                timestamp=timestamp
            )

        # Handle dict input
        if isinstance(input_data, dict):
            # Check for direct content
            content = input_data.get("content") or input_data.get("message") or input_data.get("text")
            if content:
                role = self._parse_role(input_data.get("role", "user"))
                return Message(
                    role=role,
                    content=str(content),
                    timestamp=timestamp
                )

            # Check for messages array
            if "messages" in input_data and isinstance(input_data["messages"], list):
                # Return first message (others will be handled separately)
                for msg in input_data["messages"]:
                    if isinstance(msg, dict) and msg.get("content"):
                        role = self._parse_role(msg.get("role", "user"))
                        return Message(
                            role=role,
                            content=str(msg["content"]),
                            timestamp=timestamp
                        )

        # Handle list input (take first message)
        if isinstance(input_data, list) and input_data:
            first_item = input_data[0]
            if isinstance(first_item, dict) and first_item.get("content"):
                role = self._parse_role(first_item.get("role", "user"))
                return Message(
                    role=role,
                    content=str(first_item["content"]),
                    timestamp=timestamp
                )

        return None

    def _parse_output_to_message(
        self,
        output_data: Any,
        timestamp: Optional[datetime] = None
    ) -> Optional[Message]:
        """
        Parse output data to a Message object.

        Output can be:
        - String: Direct message content
        - Dict with "content", "message", or "text" key
        - Dict with "choices" array (OpenAI format)

        Args:
            output_data: Output data from observation
            timestamp: Optional timestamp for the message

        Returns:
            Message object or None
        """
        if not output_data:
            return None

        if timestamp is None:
            timestamp = datetime.utcnow()

        # Handle string output
        if isinstance(output_data, str):
            return Message(
                role=MessageRole.ASSISTANT,
                content=output_data,
                timestamp=timestamp
            )

        # Handle dict output
        if isinstance(output_data, dict):
            # Check for direct content
            content = output_data.get("content") or output_data.get("message") or output_data.get("text")
            if content:
                return Message(
                    role=MessageRole.ASSISTANT,
                    content=str(content),
                    timestamp=timestamp
                )

            # Check for OpenAI-style choices
            if "choices" in output_data and isinstance(output_data["choices"], list):
                if output_data["choices"]:
                    first_choice = output_data["choices"][0]
                    if isinstance(first_choice, dict):
                        message_data = first_choice.get("message", {})
                        if isinstance(message_data, dict):
                            content = message_data.get("content")
                            if content:
                                return Message(
                                    role=MessageRole.ASSISTANT,
                                    content=str(content),
                                    timestamp=timestamp
                                )

        return None

    def _parse_role(self, role_str: str) -> MessageRole:
        """
        Parse role string to MessageRole enum.

        Args:
            role_str: Role string from Langfuse

        Returns:
            MessageRole enum value
        """
        role_lower = str(role_str).lower()

        if role_lower in ["user", "human"]:
            return MessageRole.USER
        elif role_lower in ["assistant", "ai", "bot"]:
            return MessageRole.ASSISTANT
        elif role_lower in ["system"]:
            return MessageRole.SYSTEM
        elif role_lower in ["tool", "function"]:
            return MessageRole.TOOL
        else:
            # Default to assistant for unknown types
            return MessageRole.ASSISTANT

    def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
        """
        Extract tool calls from Langfuse observations.

        Tool calls are extracted from:
        1. Span observations with type "tool" or name containing "tool"
        2. Event observations representing function calls
        3. Generation observations with function_call metadata

        Args:
            raw_data: Langfuse trace data

        Returns:
            List of ToolCall objects in chronological order

        Raises:
            AdapterTransformationError: If tool call extraction fails
        """
        try:
            tool_calls = []

            # Get observations list (handle both direct and nested formats)
            observations = raw_data.get("observations", [])
            if not observations and "data" in raw_data:
                observations = raw_data["data"]

            # Sort observations by timestamp
            sorted_observations = self._sort_observations_by_time(observations)

            # Extract tool calls from each observation
            for obs in sorted_observations:
                if not isinstance(obs, dict):
                    continue

                obs_type = obs.get("type", "").lower()
                obs_name = obs.get("name", "").lower()

                # Check if this is a tool-related observation
                is_tool_span = obs_type == "span" and ("tool" in obs_name or "function" in obs_name)
                is_tool_event = obs_type == "event" and ("tool" in obs_name or "function" in obs_name)

                if is_tool_span or is_tool_event:
                    tool_call = self._parse_observation_to_tool_call(obs)
                    if tool_call:
                        tool_calls.append(tool_call)

                # Check for function calls in generation metadata
                elif obs_type == "generation":
                    metadata = obs.get("metadata", {})
                    if isinstance(metadata, dict) and "function_call" in metadata:
                        tool_call = self._parse_function_call_to_tool_call(
                            metadata["function_call"],
                            obs
                        )
                        if tool_call:
                            tool_calls.append(tool_call)

            return tool_calls

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract tool calls: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def _parse_observation_to_tool_call(self, observation: Dict[str, Any]) -> Optional[ToolCall]:
        """
        Parse a Langfuse observation to a ToolCall object.

        Args:
            observation: Observation dict from Langfuse

        Returns:
            ToolCall object or None
        """
        try:
            # Extract tool name from observation name
            tool_name = observation.get("name", "")

            # Clean up tool name (remove prefixes like "tool_", "function_")
            for prefix in ["tool_", "function_", "call_"]:
                if tool_name.lower().startswith(prefix):
                    tool_name = tool_name[len(prefix):]

            if not tool_name:
                return None

            # Extract arguments from input
            arguments = {}
            if "input" in observation:
                input_data = observation["input"]
                if isinstance(input_data, dict):
                    arguments = input_data
                elif isinstance(input_data, str):
                    # Try to parse as JSON
                    try:
                        import json
                        arguments = json.loads(input_data)
                    except (json.JSONDecodeError, ValueError):
                        arguments = {"input": input_data}

            # Extract result from output
            result = None
            if "output" in observation:
                result = observation["output"]

            # Calculate execution time if available
            execution_time = None
            if "start_time" in observation and "end_time" in observation:
                start = self._parse_timestamp(observation["start_time"])
                end = self._parse_timestamp(observation["end_time"])
                if start and end:
                    execution_time = (end - start).total_seconds()

            # Get timestamp
            timestamp = self._parse_timestamp(observation.get("start_time"))

            return ToolCall(
                name=tool_name,
                arguments=arguments,
                result=result,
                execution_time=execution_time,
                timestamp=timestamp
            )

        except Exception:
            return None

    def _parse_function_call_to_tool_call(
        self,
        function_call: Dict[str, Any],
        observation: Dict[str, Any]
    ) -> Optional[ToolCall]:
        """
        Parse a function call from generation metadata to a ToolCall object.

        Args:
            function_call: Function call dict from metadata
            observation: Parent observation for context

        Returns:
            ToolCall object or None
        """
        try:
            tool_name = function_call.get("name", "")
            if not tool_name:
                return None

            # Parse arguments
            arguments = function_call.get("arguments", {})
            if isinstance(arguments, str):
                # Try to parse as JSON
                try:
                    import json
                    arguments = json.loads(arguments)
                except (json.JSONDecodeError, ValueError):
                    arguments = {"arguments": arguments}

            # Get timestamp from observation
            timestamp = self._parse_timestamp(observation.get("start_time"))

            return ToolCall(
                name=tool_name,
                arguments=arguments,
                timestamp=timestamp
            )

        except Exception:
            return None

    def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Extract metadata from Langfuse observations.

        Metadata includes:
        - input_tokens: Total input tokens from generation usage
        - output_tokens: Total output tokens from generation usage
        - latency: Total execution time from span durations
        - cost: Total cost from generation cost data
        - model: Model name from generations

        Args:
            raw_data: Langfuse trace data

        Returns:
            Dictionary of metadata

        Raises:
            AdapterTransformationError: If metadata extraction fails
        """
        try:
            metadata = {}

            # Get observations list (handle both direct and nested formats)
            observations = raw_data.get("observations", [])
            if not observations and "data" in raw_data:
                observations = raw_data["data"]

            # Accumulate token usage and cost
            input_tokens = 0
            output_tokens = 0
            total_cost = 0.0
            total_latency = 0.0
            model_names = set()

            for obs in observations:
                if not isinstance(obs, dict):
                    continue

                # Extract token usage from generations
                if obs.get("type") == "generation":
                    usage = obs.get("usage", {})
                    if isinstance(usage, dict):
                        input_tokens += usage.get("input", 0) or usage.get("promptTokens", 0)
                        output_tokens += usage.get("output", 0) or usage.get("completionTokens", 0)

                    # Extract cost
                    if "cost" in obs and obs["cost"] is not None:
                        total_cost += float(obs["cost"])

                    # Extract model name
                    if "model" in obs and obs["model"]:
                        model_names.add(obs["model"])

                # Calculate latency from spans
                if obs.get("type") == "span":
                    if "start_time" in obs and "end_time" in obs:
                        start = self._parse_timestamp(obs["start_time"])
                        end = self._parse_timestamp(obs["end_time"])
                        if start and end:
                            duration = (end - start).total_seconds()
                            total_latency += duration

            # Add aggregated values to metadata
            if input_tokens > 0:
                metadata["input_tokens"] = input_tokens
            if output_tokens > 0:
                metadata["output_tokens"] = output_tokens
            if total_cost > 0:
                metadata["cost"] = total_cost
            if total_latency > 0:
                metadata["latency"] = total_latency
            if model_names:
                metadata["model"] = ", ".join(sorted(model_names))

            # Add trace-level metadata
            if "metadata" in raw_data and isinstance(raw_data["metadata"], dict):
                # Merge trace metadata, but don't overwrite calculated values
                for key, value in raw_data["metadata"].items():
                    if key not in metadata:
                        metadata[key] = value

            # Add trace name if present
            if "name" in raw_data:
                metadata["trace_name"] = raw_data["name"]

            # Add user information if present
            if "user_id" in raw_data:
                metadata["user_id"] = raw_data["user_id"]

            # Add tags if present
            if "tags" in raw_data:
                metadata["tags"] = raw_data["tags"]

            return metadata

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract metadata: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def _sort_observations_by_time(self, observations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
        """
        Sort observations by start time in chronological order.

        Args:
            observations: List of observation dicts

        Returns:
            Sorted list of observations
        """
        def get_sort_key(obs: Dict[str, Any]) -> datetime:
            """Get sort key (start time) for an observation."""
            timestamp = self._parse_timestamp(obs.get("start_time"))
            return timestamp if timestamp else datetime.min

        try:
            return sorted(observations, key=get_sort_key)
        except Exception:
            # If sorting fails, return original list
            return observations

    def _parse_timestamp(self, timestamp_str: Optional[str]) -> datetime:
        """
        Parse ISO 8601 timestamp string to datetime object.

        Args:
            timestamp_str: ISO 8601 timestamp string

        Returns:
            datetime object or current time if parsing fails
        """
        if not timestamp_str:
            return datetime.utcnow()

        try:
            # Handle ISO 8601 format with timezone
            if isinstance(timestamp_str, str):
                # Remove timezone suffix for parsing
                if timestamp_str.endswith('Z'):
                    timestamp_str = timestamp_str[:-1]

                # Try parsing with microseconds
                try:
                    return datetime.fromisoformat(timestamp_str)
                except ValueError:
                    # Try without microseconds
                    return datetime.strptime(timestamp_str, "%Y-%m-%dT%H:%M:%S")

            return datetime.utcnow()

        except Exception:
            return datetime.utcnow()

name property

Return the adapter name.

extract_messages(raw_data)

Extract messages from Langfuse observations.

Messages are extracted from: 1. Generation observations (LLM calls with input/output) 2. Trace-level input/output 3. Span observations with message content

Langfuse generations typically contain: - input: The prompt or messages sent to the LLM - output: The LLM's response

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Langfuse trace data

required

Returns:

Type Description
List[Message]

List of Message objects in chronological order

Raises:

Type Description
AdapterTransformationError

If message extraction fails

Source code in uaef/adapters/langfuse.py
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
    """
    Extract messages from Langfuse observations.

    Messages are extracted from:
    1. Generation observations (LLM calls with input/output)
    2. Trace-level input/output
    3. Span observations with message content

    Langfuse generations typically contain:
    - input: The prompt or messages sent to the LLM
    - output: The LLM's response

    Args:
        raw_data: Langfuse trace data

    Returns:
        List of Message objects in chronological order

    Raises:
        AdapterTransformationError: If message extraction fails
    """
    try:
        messages = []

        # Get observations list (handle both direct and nested formats)
        observations = raw_data.get("observations", [])
        if not observations and "data" in raw_data:
            observations = raw_data["data"]

        # Sort observations by timestamp to maintain chronological order
        sorted_observations = self._sort_observations_by_time(observations)

        # Extract messages from each observation
        for obs in sorted_observations:
            if not isinstance(obs, dict):
                continue

            obs_type = obs.get("type", "").lower()

            # Extract from generation observations (LLM calls)
            if obs_type == "generation":
                obs_messages = self._extract_messages_from_generation(obs)
                messages.extend(obs_messages)

            # Extract from span observations if they contain message data
            elif obs_type == "span":
                obs_messages = self._extract_messages_from_span(obs)
                messages.extend(obs_messages)

        # If no messages found in observations, try trace-level input/output
        if not messages:
            if "input" in raw_data:
                input_msg = self._parse_input_to_message(raw_data["input"])
                if input_msg:
                    messages.append(input_msg)

            if "output" in raw_data:
                output_msg = self._parse_output_to_message(raw_data["output"])
                if output_msg:
                    messages.append(output_msg)

        return messages

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract messages: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

extract_metadata(raw_data)

Extract metadata from Langfuse observations.

Metadata includes: - input_tokens: Total input tokens from generation usage - output_tokens: Total output tokens from generation usage - latency: Total execution time from span durations - cost: Total cost from generation cost data - model: Model name from generations

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Langfuse trace data

required

Returns:

Type Description
Dict[str, Any]

Dictionary of metadata

Raises:

Type Description
AdapterTransformationError

If metadata extraction fails

Source code in uaef/adapters/langfuse.py
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
    """
    Extract metadata from Langfuse observations.

    Metadata includes:
    - input_tokens: Total input tokens from generation usage
    - output_tokens: Total output tokens from generation usage
    - latency: Total execution time from span durations
    - cost: Total cost from generation cost data
    - model: Model name from generations

    Args:
        raw_data: Langfuse trace data

    Returns:
        Dictionary of metadata

    Raises:
        AdapterTransformationError: If metadata extraction fails
    """
    try:
        metadata = {}

        # Get observations list (handle both direct and nested formats)
        observations = raw_data.get("observations", [])
        if not observations and "data" in raw_data:
            observations = raw_data["data"]

        # Accumulate token usage and cost
        input_tokens = 0
        output_tokens = 0
        total_cost = 0.0
        total_latency = 0.0
        model_names = set()

        for obs in observations:
            if not isinstance(obs, dict):
                continue

            # Extract token usage from generations
            if obs.get("type") == "generation":
                usage = obs.get("usage", {})
                if isinstance(usage, dict):
                    input_tokens += usage.get("input", 0) or usage.get("promptTokens", 0)
                    output_tokens += usage.get("output", 0) or usage.get("completionTokens", 0)

                # Extract cost
                if "cost" in obs and obs["cost"] is not None:
                    total_cost += float(obs["cost"])

                # Extract model name
                if "model" in obs and obs["model"]:
                    model_names.add(obs["model"])

            # Calculate latency from spans
            if obs.get("type") == "span":
                if "start_time" in obs and "end_time" in obs:
                    start = self._parse_timestamp(obs["start_time"])
                    end = self._parse_timestamp(obs["end_time"])
                    if start and end:
                        duration = (end - start).total_seconds()
                        total_latency += duration

        # Add aggregated values to metadata
        if input_tokens > 0:
            metadata["input_tokens"] = input_tokens
        if output_tokens > 0:
            metadata["output_tokens"] = output_tokens
        if total_cost > 0:
            metadata["cost"] = total_cost
        if total_latency > 0:
            metadata["latency"] = total_latency
        if model_names:
            metadata["model"] = ", ".join(sorted(model_names))

        # Add trace-level metadata
        if "metadata" in raw_data and isinstance(raw_data["metadata"], dict):
            # Merge trace metadata, but don't overwrite calculated values
            for key, value in raw_data["metadata"].items():
                if key not in metadata:
                    metadata[key] = value

        # Add trace name if present
        if "name" in raw_data:
            metadata["trace_name"] = raw_data["name"]

        # Add user information if present
        if "user_id" in raw_data:
            metadata["user_id"] = raw_data["user_id"]

        # Add tags if present
        if "tags" in raw_data:
            metadata["tags"] = raw_data["tags"]

        return metadata

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract metadata: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

extract_tool_calls(raw_data)

Extract tool calls from Langfuse observations.

Tool calls are extracted from: 1. Span observations with type "tool" or name containing "tool" 2. Event observations representing function calls 3. Generation observations with function_call metadata

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Langfuse trace data

required

Returns:

Type Description
List[ToolCall]

List of ToolCall objects in chronological order

Raises:

Type Description
AdapterTransformationError

If tool call extraction fails

Source code in uaef/adapters/langfuse.py
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
    """
    Extract tool calls from Langfuse observations.

    Tool calls are extracted from:
    1. Span observations with type "tool" or name containing "tool"
    2. Event observations representing function calls
    3. Generation observations with function_call metadata

    Args:
        raw_data: Langfuse trace data

    Returns:
        List of ToolCall objects in chronological order

    Raises:
        AdapterTransformationError: If tool call extraction fails
    """
    try:
        tool_calls = []

        # Get observations list (handle both direct and nested formats)
        observations = raw_data.get("observations", [])
        if not observations and "data" in raw_data:
            observations = raw_data["data"]

        # Sort observations by timestamp
        sorted_observations = self._sort_observations_by_time(observations)

        # Extract tool calls from each observation
        for obs in sorted_observations:
            if not isinstance(obs, dict):
                continue

            obs_type = obs.get("type", "").lower()
            obs_name = obs.get("name", "").lower()

            # Check if this is a tool-related observation
            is_tool_span = obs_type == "span" and ("tool" in obs_name or "function" in obs_name)
            is_tool_event = obs_type == "event" and ("tool" in obs_name or "function" in obs_name)

            if is_tool_span or is_tool_event:
                tool_call = self._parse_observation_to_tool_call(obs)
                if tool_call:
                    tool_calls.append(tool_call)

            # Check for function calls in generation metadata
            elif obs_type == "generation":
                metadata = obs.get("metadata", {})
                if isinstance(metadata, dict) and "function_call" in metadata:
                    tool_call = self._parse_function_call_to_tool_call(
                        metadata["function_call"],
                        obs
                    )
                    if tool_call:
                        tool_calls.append(tool_call)

        return tool_calls

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract tool calls: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

transform_to_canonical(raw_data)

Transform Langfuse trace data to canonical AgentTrace format.

Expected raw_data structure: { "trace_id": "...", # Langfuse trace ID "session_id": "...", # Optional session identifier "observations": [...], # List of observation objects (spans, generations, events) "input": {...}, # Optional trace-level input "output": {...}, # Optional trace-level output "metadata": {...}, # Optional trace metadata "timestamp": "...", # ISO 8601 timestamp }

Alternative structure (from Observations API): { "data": [ { "id": "...", "trace_id": "...", "type": "generation|span|event", "name": "...", "input": {...}, "output": {...}, "metadata": {...}, "usage": {"input": 100, "output": 50, "total": 150}, "cost": 0.0025, "start_time": "...", "end_time": "...", "parent_observation_id": "...", }, ... ] }

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Langfuse trace data

required

Returns:

Type Description
AgentTrace

Canonical AgentTrace object

Raises:

Type Description
AdapterTransformationError

If transformation fails

Source code in uaef/adapters/langfuse.py
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
def transform_to_canonical(self, raw_data: Dict[str, Any]) -> AgentTrace:
    """
    Transform Langfuse trace data to canonical AgentTrace format.

    Expected raw_data structure:
    {
        "trace_id": "...",  # Langfuse trace ID
        "session_id": "...",  # Optional session identifier
        "observations": [...],  # List of observation objects (spans, generations, events)
        "input": {...},  # Optional trace-level input
        "output": {...},  # Optional trace-level output
        "metadata": {...},  # Optional trace metadata
        "timestamp": "...",  # ISO 8601 timestamp
    }

    Alternative structure (from Observations API):
    {
        "data": [
            {
                "id": "...",
                "trace_id": "...",
                "type": "generation|span|event",
                "name": "...",
                "input": {...},
                "output": {...},
                "metadata": {...},
                "usage": {"input": 100, "output": 50, "total": 150},
                "cost": 0.0025,
                "start_time": "...",
                "end_time": "...",
                "parent_observation_id": "...",
            },
            ...
        ]
    }

    Args:
        raw_data: Langfuse trace data

    Returns:
        Canonical AgentTrace object

    Raises:
        AdapterTransformationError: If transformation fails
    """
    try:
        # Validate that we have some data to work with
        if not raw_data:
            raise AdapterTransformationError(
                "Empty raw_data provided",
                adapter_name=self.name
            )

        # Extract components
        messages = self.extract_messages(raw_data)
        tool_calls = self.extract_tool_calls(raw_data)
        metadata = self.extract_metadata(raw_data)

        # Determine trace_id
        trace_id = raw_data.get("trace_id")
        if trace_id:
            # Keep as string if it's not a UUID format
            try:
                from uuid import UUID
                if isinstance(trace_id, str):
                    trace_id = UUID(trace_id)
            except (ValueError, AttributeError):
                # If not a valid UUID, generate a new one and store original in metadata
                metadata["langfuse_trace_id"] = trace_id
                trace_id = uuid4()
        else:
            trace_id = uuid4()

        # Create AgentTrace
        trace = AgentTrace(
            trace_id=trace_id,
            session_id=raw_data.get("session_id"),
            messages=messages,
            tool_calls=tool_calls,
            input_tokens=metadata.get("input_tokens"),
            output_tokens=metadata.get("output_tokens"),
            latency=metadata.get("latency"),
            cost=metadata.get("cost"),
            metadata=metadata,
            framework="langfuse",
            timestamp=self._parse_timestamp(raw_data.get("timestamp"))
        )

        return trace

    except AdapterTransformationError:
        raise
    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to transform Langfuse trace: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

uaef.adapters.strands

Strands Agents adapter for transforming Strands traces to canonical format.

Uses native Strands telemetry (EventLoopMetrics, AgentResult) rather than requiring manual cycle construction. Supports both single-agent and multi-agent traces.

StrandsAdapter

Bases: BaseAdapter

Adapter for transforming Strands Agents SDK traces to canonical AgentTrace format.

This adapter consumes the native telemetry objects produced by the Strands SDK: - AgentResult.messages — the full conversation history - AgentResult.metrics — an EventLoopMetrics instance with traces, accumulated token usage, tool metrics, and per-cycle breakdowns

Expected raw_data for single-agent::

{
    "messages": [...],          # AgentResult.messages (list of message dicts)
    "metrics_summary": {...},   # EventLoopMetrics.get_summary()
    "stop_reason": "end_turn",  # AgentResult.stop_reason (optional)
    "session_id": "...",        # Optional session identifier
    "trace_id": "...",          # Optional trace identifier (UUID string)
    "model": "...",             # Optional model identifier
    "cost": 0.005,             # Optional estimated cost in USD
}

Expected raw_data for multi-agent::

{
    "agents": {
        "research_agent": {
            "messages": [...],
            "metrics_summary": {...},
            "stop_reason": "end_turn",
        },
        "responder_agent": {
            "messages": [...],
            "metrics_summary": {...},
            "stop_reason": "end_turn",
        },
    },
    "coordination_events": [...],  # Optional; inferred from agent ordering
    "session_id": "...",
    "trace_id": "...",
}
Source code in uaef/adapters/strands.py
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
class StrandsAdapter(BaseAdapter):
    """
    Adapter for transforming Strands Agents SDK traces to canonical AgentTrace format.

    This adapter consumes the native telemetry objects produced by the Strands SDK:
    - ``AgentResult.messages`` — the full conversation history
    - ``AgentResult.metrics`` — an ``EventLoopMetrics`` instance with traces,
      accumulated token usage, tool metrics, and per-cycle breakdowns

    Expected raw_data for single-agent::

        {
            "messages": [...],          # AgentResult.messages (list of message dicts)
            "metrics_summary": {...},   # EventLoopMetrics.get_summary()
            "stop_reason": "end_turn",  # AgentResult.stop_reason (optional)
            "session_id": "...",        # Optional session identifier
            "trace_id": "...",          # Optional trace identifier (UUID string)
            "model": "...",             # Optional model identifier
            "cost": 0.005,             # Optional estimated cost in USD
        }

    Expected raw_data for multi-agent::

        {
            "agents": {
                "research_agent": {
                    "messages": [...],
                    "metrics_summary": {...},
                    "stop_reason": "end_turn",
                },
                "responder_agent": {
                    "messages": [...],
                    "metrics_summary": {...},
                    "stop_reason": "end_turn",
                },
            },
            "coordination_events": [...],  # Optional; inferred from agent ordering
            "session_id": "...",
            "trace_id": "...",
        }
    """

    @property
    def name(self) -> str:
        """Return the adapter name."""
        return "strands"

    def transform_to_canonical(self, raw_data: Dict[str, Any]) -> Union[AgentTrace, MultiAgentTrace]:
        """Transform Strands trace data to canonical format.

        Returns ``MultiAgentTrace`` when ``raw_data`` contains an ``agents``
        dict with more than one entry.  Otherwise returns a single
        ``AgentTrace``.

        Args:
            raw_data: Strands trace data.

        Returns:
            AgentTrace for single-agent, MultiAgentTrace for multi-agent.

        Raises:
            AdapterTransformationError: If transformation fails.
        """
        try:
            agents = raw_data.get("agents")
            if isinstance(agents, dict):
                if not agents:
                    raise AdapterTransformationError(
                        "'agents' dict is empty",
                        adapter_name=self.name,
                    )
                if len(agents) > 1:
                    return self._transform_multi_agent(raw_data)
                only_data = next(iter(agents.values()))
                merged = {**only_data, "session_id": raw_data.get("session_id")}
                if raw_data.get("trace_id") is not None:
                    merged["trace_id"] = raw_data["trace_id"]
                raw_data = merged
            return self._transform_single_agent(raw_data)
        except AdapterTransformationError:
            raise
        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to transform Strands trace: {str(e)}",
                adapter_name=self.name,
                original_error=e,
            )

    # ------------------------------------------------------------------
    # Single-agent transformation
    # ------------------------------------------------------------------

    def _transform_single_agent(self, raw_data: Dict[str, Any]) -> AgentTrace:
        """Transform to a single AgentTrace (original behaviour)."""
        self.validate_raw_data(raw_data, ["messages", "metrics_summary"])

        messages = self.extract_messages(raw_data)
        tool_calls = self.extract_tool_calls(raw_data)
        metadata = self.extract_metadata(raw_data)

        return AgentTrace(
            trace_id=raw_data.get("trace_id", uuid4()),
            session_id=raw_data.get("session_id"),
            messages=messages,
            tool_calls=tool_calls,
            input_tokens=metadata.get("input_tokens"),
            output_tokens=metadata.get("output_tokens"),
            latency=metadata.get("latency"),
            cost=metadata.get("cost"),
            metadata=metadata,
            framework="strands",
            timestamp=datetime.now(timezone.utc),
        )

    # ------------------------------------------------------------------
    # Multi-agent transformation
    # ------------------------------------------------------------------

    def _transform_multi_agent(self, raw_data: Dict[str, Any]) -> MultiAgentTrace:
        """Transform to MultiAgentTrace by building per-agent traces.

        Each key in ``raw_data["agents"]`` becomes a separate AgentTrace.
        Coordination events are taken from ``raw_data["coordination_events"]``
        if provided, otherwise inferred from agent ordering.
        """
        agents_data: Dict[str, Dict[str, Any]] = raw_data.get("agents", {})
        agent_names = list(agents_data.keys())
        session_id = raw_data.get("session_id")

        # Build per-agent AgentTraces by reusing existing extraction methods
        agent_traces: Dict[str, AgentTrace] = {}
        total_latency = 0.0
        total_cost = 0.0
        for name, agent_raw in agents_data.items():
            trace = self._transform_single_agent({
                **agent_raw,
                "session_id": session_id,
            })
            agent_traces[name] = trace
            if trace.latency:
                total_latency += trace.latency
            if trace.cost:
                total_cost += trace.cost

        # Coordination events: use explicit list or infer from ordering
        coordination_events = self._build_coordination_events(
            raw_data.get("coordination_events"), agent_names
        )

        # Determine workflow status from individual agent stop reasons
        statuses = [a.get("stop_reason") for a in agents_data.values()]
        if all(s in ("end_turn", None) for s in statuses):
            workflow_status = WorkflowStatus.COMPLETED
        elif any(s == "error" for s in statuses):
            workflow_status = WorkflowStatus.FAILED
        else:
            workflow_status = WorkflowStatus.PARTIAL

        return MultiAgentTrace(
            trace_id=raw_data.get("trace_id", uuid4()),
            session_id=session_id,
            agent_traces=agent_traces,
            coordination_events=coordination_events,
            workflow_status=workflow_status,
            total_latency=total_latency or None,
            total_cost=total_cost or None,
            metadata={"framework": "strands"},
            timestamp=datetime.now(timezone.utc),
        )

    @staticmethod
    def _build_coordination_events(
        explicit_events: Any, agent_names: List[str]
    ) -> List[CoordinationEvent]:
        """Return explicit CoordinationEvents or infer from agent ordering."""
        if isinstance(explicit_events, list) and explicit_events:
            return [
                e if isinstance(e, CoordinationEvent)
                else CoordinationEvent(**e)
                for e in explicit_events
            ]
        events: List[CoordinationEvent] = []
        for i in range(1, len(agent_names)):
            events.append(CoordinationEvent(
                from_agent=agent_names[i - 1],
                to_agent=agent_names[i],
                event_type="HANDOFF",
                message=f"Control passed from {agent_names[i - 1]} to {agent_names[i]}",
                timestamp=datetime.now(timezone.utc),
            ))
        return events

    # ------------------------------------------------------------------
    # Message extraction
    # ------------------------------------------------------------------

    def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
        """Extract messages from the Strands conversation history.

        Walks ``raw_data["messages"]`` (the native list stored on
        ``AgentResult.messages``).  Each dict has ``role`` and ``content``
        (a list of content blocks).

        Args:
            raw_data: Strands trace data.

        Returns:
            List of canonical Message objects.
        """
        try:
            messages: List[Message] = []
            raw_messages = raw_data.get("messages", [])

            for msg_data in raw_messages:
                if not isinstance(msg_data, dict):
                    continue

                role_str = msg_data.get("role", "assistant")
                content_blocks = msg_data.get("content", [])

                # Content can be a plain string (system prompt) or list of blocks
                if isinstance(content_blocks, str):
                    messages.append(
                        Message(
                            role=self._map_role(role_str),
                            content=content_blocks,
                            timestamp=datetime.now(timezone.utc),
                        )
                    )
                    continue

                text_parts: List[str] = []
                message_tool_calls: List[ToolCall] = []

                for block in content_blocks:
                    if not isinstance(block, dict):
                        continue

                    # Text content
                    if "text" in block:
                        text_parts.append(str(block["text"]))

                    # Tool-use content block
                    tool_use = block.get("toolUse")
                    if isinstance(tool_use, dict) and tool_use.get("name"):
                        message_tool_calls.append(
                            ToolCall(
                                name=tool_use["name"],
                                arguments=tool_use.get("input", {}),
                                timestamp=datetime.now(timezone.utc),
                            )
                        )

                    # Tool-result content block (inside user messages)
                    tool_result = block.get("toolResult")
                    if isinstance(tool_result, dict):
                        result_text = self._extract_tool_result_text(tool_result)
                        text_parts.append(result_text)

                role = self._map_role(role_str)
                messages.append(
                    Message(
                        role=role,
                        content="\n".join(text_parts) if text_parts else "",
                        tool_calls=message_tool_calls,
                        timestamp=datetime.now(timezone.utc),
                    )
                )

            return messages

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract messages: {str(e)}",
                adapter_name=self.name,
                original_error=e,
            )

    # ------------------------------------------------------------------
    # Tool-call extraction
    # ------------------------------------------------------------------

    def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
        """Extract tool calls from the Strands conversation and metrics.

        Tool invocations are found in two places:
        1. ``toolUse`` content blocks inside assistant messages
        2. ``tool_usage`` in ``metrics_summary`` for execution stats

        The method walks the messages for call details and enriches them
        with duration/success info from the metrics summary.

        Args:
            raw_data: Strands trace data.

        Returns:
            List of ToolCall objects.
        """
        try:
            tool_calls: List[ToolCall] = []
            raw_messages = raw_data.get("messages", [])
            summary = raw_data.get("metrics_summary", {})

            # Build execution stats lookup from metrics_summary.tool_usage
            tool_stats: Dict[str, Dict[str, Any]] = {}
            for tool_name, info in summary.get("tool_usage", {}).items():
                exec_stats = info.get("execution_stats", {})
                tool_stats[tool_name] = exec_stats

            # Build a map of toolUseId → tool result from user messages
            result_map: Dict[str, Dict[str, Any]] = {}
            for msg_data in raw_messages:
                if not isinstance(msg_data, dict) or msg_data.get("role") != "user":
                    continue
                for block in msg_data.get("content", []):
                    if isinstance(block, dict) and "toolResult" in block:
                        tr = block["toolResult"]
                        if isinstance(tr, dict) and "toolUseId" in tr:
                            result_map[tr["toolUseId"]] = tr

            # Walk assistant messages for toolUse blocks
            for msg_data in raw_messages:
                if not isinstance(msg_data, dict) or msg_data.get("role") != "assistant":
                    continue

                for block in msg_data.get("content", []):
                    if not isinstance(block, dict):
                        continue

                    tool_use = block.get("toolUse")
                    if not isinstance(tool_use, dict) or not tool_use.get("name"):
                        continue

                    tool_name = tool_use["name"]
                    tool_use_id = tool_use.get("toolUseId", "")

                    # Match result
                    result = result_map.get(tool_use_id)
                    result_value = self._extract_tool_result_text(result) if result else None
                    status = result.get("status") if result else None
                    error = None
                    if status and status != "success":
                        error = result_value

                    # Enrich with execution time from metrics
                    stats = tool_stats.get(tool_name, {})
                    call_count = stats.get("call_count", 0)
                    avg_time = stats.get("average_time") if call_count else None

                    tool_calls.append(
                        ToolCall(
                            name=tool_name,
                            arguments=tool_use.get("input", {}),
                            result=result_value,
                            timestamp=datetime.now(timezone.utc),
                            execution_time=avg_time,
                            error=error,
                        )
                    )

            return tool_calls

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract tool calls: {str(e)}",
                adapter_name=self.name,
                original_error=e,
            )

    # ------------------------------------------------------------------
    # Metadata extraction
    # ------------------------------------------------------------------

    def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
        """Extract metadata from the Strands metrics summary.

        Reads ``accumulated_usage`` and ``accumulated_metrics`` from the
        ``metrics_summary`` dict produced by ``EventLoopMetrics.get_summary()``.

        Args:
            raw_data: Strands trace data.

        Returns:
            Dictionary of metadata.
        """
        try:
            metadata: Dict[str, Any] = {}
            summary = raw_data.get("metrics_summary", {})

            # Token usage from accumulated_usage
            usage = summary.get("accumulated_usage", {})
            input_tokens = usage.get("inputTokens", 0)
            output_tokens = usage.get("outputTokens", 0)
            if input_tokens > 0:
                metadata["input_tokens"] = input_tokens
            if output_tokens > 0:
                metadata["output_tokens"] = output_tokens

            # Latency from accumulated_metrics (ms → seconds)
            acc_metrics = summary.get("accumulated_metrics", {})
            latency_ms = acc_metrics.get("latencyMs", 0)
            if latency_ms > 0:
                metadata["latency"] = latency_ms / 1000.0

            # Cycle stats
            total_cycles = summary.get("total_cycles", 0)
            if total_cycles > 0:
                metadata["total_cycles"] = total_cycles
                metadata["total_duration"] = summary.get("total_duration", 0)
                metadata["average_cycle_time"] = summary.get("average_cycle_time", 0)

            # Tool usage summary
            if summary.get("tool_usage"):
                metadata["tool_usage"] = summary["tool_usage"]

            # Per-invocation breakdown
            if summary.get("agent_invocations"):
                metadata["agent_invocations"] = summary["agent_invocations"]

            # Pass-through optional top-level fields
            if "model" in raw_data:
                metadata["model"] = raw_data["model"]
            if "cost" in raw_data:
                metadata["cost"] = raw_data["cost"]
            if "stop_reason" in raw_data:
                metadata["stop_reason"] = raw_data["stop_reason"]

            return metadata

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract metadata: {str(e)}",
                adapter_name=self.name,
                original_error=e,
            )

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _map_role(role_str: str) -> MessageRole:
        """Map a Strands role string to a canonical MessageRole."""
        mapping = {
            "user": MessageRole.USER,
            "assistant": MessageRole.ASSISTANT,
            "system": MessageRole.SYSTEM,
            "tool": MessageRole.TOOL,
        }
        return mapping.get(role_str, MessageRole.ASSISTANT)

    @staticmethod
    def _extract_tool_result_text(tool_result: Dict[str, Any]) -> str:
        """Extract a text representation from a tool-result dict."""
        content = tool_result.get("content", [])
        if isinstance(content, list):
            parts = [
                str(c.get("text", ""))
                for c in content
                if isinstance(c, dict) and "text" in c
            ]
            return "\n".join(parts)
        return str(content)

name property

Return the adapter name.

extract_messages(raw_data)

Extract messages from the Strands conversation history.

Walks raw_data["messages"] (the native list stored on AgentResult.messages). Each dict has role and content (a list of content blocks).

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Strands trace data.

required

Returns:

Type Description
List[Message]

List of canonical Message objects.

Source code in uaef/adapters/strands.py
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
    """Extract messages from the Strands conversation history.

    Walks ``raw_data["messages"]`` (the native list stored on
    ``AgentResult.messages``).  Each dict has ``role`` and ``content``
    (a list of content blocks).

    Args:
        raw_data: Strands trace data.

    Returns:
        List of canonical Message objects.
    """
    try:
        messages: List[Message] = []
        raw_messages = raw_data.get("messages", [])

        for msg_data in raw_messages:
            if not isinstance(msg_data, dict):
                continue

            role_str = msg_data.get("role", "assistant")
            content_blocks = msg_data.get("content", [])

            # Content can be a plain string (system prompt) or list of blocks
            if isinstance(content_blocks, str):
                messages.append(
                    Message(
                        role=self._map_role(role_str),
                        content=content_blocks,
                        timestamp=datetime.now(timezone.utc),
                    )
                )
                continue

            text_parts: List[str] = []
            message_tool_calls: List[ToolCall] = []

            for block in content_blocks:
                if not isinstance(block, dict):
                    continue

                # Text content
                if "text" in block:
                    text_parts.append(str(block["text"]))

                # Tool-use content block
                tool_use = block.get("toolUse")
                if isinstance(tool_use, dict) and tool_use.get("name"):
                    message_tool_calls.append(
                        ToolCall(
                            name=tool_use["name"],
                            arguments=tool_use.get("input", {}),
                            timestamp=datetime.now(timezone.utc),
                        )
                    )

                # Tool-result content block (inside user messages)
                tool_result = block.get("toolResult")
                if isinstance(tool_result, dict):
                    result_text = self._extract_tool_result_text(tool_result)
                    text_parts.append(result_text)

            role = self._map_role(role_str)
            messages.append(
                Message(
                    role=role,
                    content="\n".join(text_parts) if text_parts else "",
                    tool_calls=message_tool_calls,
                    timestamp=datetime.now(timezone.utc),
                )
            )

        return messages

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract messages: {str(e)}",
            adapter_name=self.name,
            original_error=e,
        )

extract_metadata(raw_data)

Extract metadata from the Strands metrics summary.

Reads accumulated_usage and accumulated_metrics from the metrics_summary dict produced by EventLoopMetrics.get_summary().

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Strands trace data.

required

Returns:

Type Description
Dict[str, Any]

Dictionary of metadata.

Source code in uaef/adapters/strands.py
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
    """Extract metadata from the Strands metrics summary.

    Reads ``accumulated_usage`` and ``accumulated_metrics`` from the
    ``metrics_summary`` dict produced by ``EventLoopMetrics.get_summary()``.

    Args:
        raw_data: Strands trace data.

    Returns:
        Dictionary of metadata.
    """
    try:
        metadata: Dict[str, Any] = {}
        summary = raw_data.get("metrics_summary", {})

        # Token usage from accumulated_usage
        usage = summary.get("accumulated_usage", {})
        input_tokens = usage.get("inputTokens", 0)
        output_tokens = usage.get("outputTokens", 0)
        if input_tokens > 0:
            metadata["input_tokens"] = input_tokens
        if output_tokens > 0:
            metadata["output_tokens"] = output_tokens

        # Latency from accumulated_metrics (ms → seconds)
        acc_metrics = summary.get("accumulated_metrics", {})
        latency_ms = acc_metrics.get("latencyMs", 0)
        if latency_ms > 0:
            metadata["latency"] = latency_ms / 1000.0

        # Cycle stats
        total_cycles = summary.get("total_cycles", 0)
        if total_cycles > 0:
            metadata["total_cycles"] = total_cycles
            metadata["total_duration"] = summary.get("total_duration", 0)
            metadata["average_cycle_time"] = summary.get("average_cycle_time", 0)

        # Tool usage summary
        if summary.get("tool_usage"):
            metadata["tool_usage"] = summary["tool_usage"]

        # Per-invocation breakdown
        if summary.get("agent_invocations"):
            metadata["agent_invocations"] = summary["agent_invocations"]

        # Pass-through optional top-level fields
        if "model" in raw_data:
            metadata["model"] = raw_data["model"]
        if "cost" in raw_data:
            metadata["cost"] = raw_data["cost"]
        if "stop_reason" in raw_data:
            metadata["stop_reason"] = raw_data["stop_reason"]

        return metadata

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract metadata: {str(e)}",
            adapter_name=self.name,
            original_error=e,
        )

extract_tool_calls(raw_data)

Extract tool calls from the Strands conversation and metrics.

Tool invocations are found in two places: 1. toolUse content blocks inside assistant messages 2. tool_usage in metrics_summary for execution stats

The method walks the messages for call details and enriches them with duration/success info from the metrics summary.

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Strands trace data.

required

Returns:

Type Description
List[ToolCall]

List of ToolCall objects.

Source code in uaef/adapters/strands.py
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
    """Extract tool calls from the Strands conversation and metrics.

    Tool invocations are found in two places:
    1. ``toolUse`` content blocks inside assistant messages
    2. ``tool_usage`` in ``metrics_summary`` for execution stats

    The method walks the messages for call details and enriches them
    with duration/success info from the metrics summary.

    Args:
        raw_data: Strands trace data.

    Returns:
        List of ToolCall objects.
    """
    try:
        tool_calls: List[ToolCall] = []
        raw_messages = raw_data.get("messages", [])
        summary = raw_data.get("metrics_summary", {})

        # Build execution stats lookup from metrics_summary.tool_usage
        tool_stats: Dict[str, Dict[str, Any]] = {}
        for tool_name, info in summary.get("tool_usage", {}).items():
            exec_stats = info.get("execution_stats", {})
            tool_stats[tool_name] = exec_stats

        # Build a map of toolUseId → tool result from user messages
        result_map: Dict[str, Dict[str, Any]] = {}
        for msg_data in raw_messages:
            if not isinstance(msg_data, dict) or msg_data.get("role") != "user":
                continue
            for block in msg_data.get("content", []):
                if isinstance(block, dict) and "toolResult" in block:
                    tr = block["toolResult"]
                    if isinstance(tr, dict) and "toolUseId" in tr:
                        result_map[tr["toolUseId"]] = tr

        # Walk assistant messages for toolUse blocks
        for msg_data in raw_messages:
            if not isinstance(msg_data, dict) or msg_data.get("role") != "assistant":
                continue

            for block in msg_data.get("content", []):
                if not isinstance(block, dict):
                    continue

                tool_use = block.get("toolUse")
                if not isinstance(tool_use, dict) or not tool_use.get("name"):
                    continue

                tool_name = tool_use["name"]
                tool_use_id = tool_use.get("toolUseId", "")

                # Match result
                result = result_map.get(tool_use_id)
                result_value = self._extract_tool_result_text(result) if result else None
                status = result.get("status") if result else None
                error = None
                if status and status != "success":
                    error = result_value

                # Enrich with execution time from metrics
                stats = tool_stats.get(tool_name, {})
                call_count = stats.get("call_count", 0)
                avg_time = stats.get("average_time") if call_count else None

                tool_calls.append(
                    ToolCall(
                        name=tool_name,
                        arguments=tool_use.get("input", {}),
                        result=result_value,
                        timestamp=datetime.now(timezone.utc),
                        execution_time=avg_time,
                        error=error,
                    )
                )

        return tool_calls

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract tool calls: {str(e)}",
            adapter_name=self.name,
            original_error=e,
        )

transform_to_canonical(raw_data)

Transform Strands trace data to canonical format.

Returns MultiAgentTrace when raw_data contains an agents dict with more than one entry. Otherwise returns a single AgentTrace.

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Strands trace data.

required

Returns:

Type Description
Union[AgentTrace, MultiAgentTrace]

AgentTrace for single-agent, MultiAgentTrace for multi-agent.

Raises:

Type Description
AdapterTransformationError

If transformation fails.

Source code in uaef/adapters/strands.py
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def transform_to_canonical(self, raw_data: Dict[str, Any]) -> Union[AgentTrace, MultiAgentTrace]:
    """Transform Strands trace data to canonical format.

    Returns ``MultiAgentTrace`` when ``raw_data`` contains an ``agents``
    dict with more than one entry.  Otherwise returns a single
    ``AgentTrace``.

    Args:
        raw_data: Strands trace data.

    Returns:
        AgentTrace for single-agent, MultiAgentTrace for multi-agent.

    Raises:
        AdapterTransformationError: If transformation fails.
    """
    try:
        agents = raw_data.get("agents")
        if isinstance(agents, dict):
            if not agents:
                raise AdapterTransformationError(
                    "'agents' dict is empty",
                    adapter_name=self.name,
                )
            if len(agents) > 1:
                return self._transform_multi_agent(raw_data)
            only_data = next(iter(agents.values()))
            merged = {**only_data, "session_id": raw_data.get("session_id")}
            if raw_data.get("trace_id") is not None:
                merged["trace_id"] = raw_data["trace_id"]
            raw_data = merged
        return self._transform_single_agent(raw_data)
    except AdapterTransformationError:
        raise
    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to transform Strands trace: {str(e)}",
            adapter_name=self.name,
            original_error=e,
        )

uaef.adapters.agentcore

AgentCore adapter for transforming AgentCore traces to canonical format.

Supports two modes: 1. Pre-collected traces — pass raw_data with trace, final_response, etc. 2. Live invocation — use the invoke() helper to call an AgentCore-deployed agent via bedrock-agentcore:invoke_agent_runtime and automatically collect the streaming response.

Multi-agent auto-detection

When raw_data contains an "agents" key mapping agent names to their individual response dicts, transform_to_canonical() automatically returns a MultiAgentTrace with per-agent traces and inferred coordination events. This mirrors the LangGraph adapter's auto-detect pattern.

AgentCoreAdapter

Bases: BaseAdapter

Adapter for transforming AgentCore invoke_agent_runtime responses to canonical AgentTrace format.

AgentCore agents are identified by their runtime ARN (or runtime ID + account ID). The adapter calls the bedrock-agentcore service client, not the legacy bedrock-agent-runtime client.

Expected raw_data structure::

{
    "user_input": "...",
    "final_response": "..." | {...},
    "latency": 1.234,
    "trace": [...],               # CloudWatch log entries (JSON strings, optional)
    "response_chunks": [...],      # Raw streaming chunks (optional)
    "trace_id": "...",
    "session_id": "...",
    "agent_runtime_id": "...",     # Runtime ARN or ID
}
Source code in uaef/adapters/agentcore.py
  33
  34
  35
  36
  37
  38
  39
  40
  41
  42
  43
  44
  45
  46
  47
  48
  49
  50
  51
  52
  53
  54
  55
  56
  57
  58
  59
  60
  61
  62
  63
  64
  65
  66
  67
  68
  69
  70
  71
  72
  73
  74
  75
  76
  77
  78
  79
  80
  81
  82
  83
  84
  85
  86
  87
  88
  89
  90
  91
  92
  93
  94
  95
  96
  97
  98
  99
 100
 101
 102
 103
 104
 105
 106
 107
 108
 109
 110
 111
 112
 113
 114
 115
 116
 117
 118
 119
 120
 121
 122
 123
 124
 125
 126
 127
 128
 129
 130
 131
 132
 133
 134
 135
 136
 137
 138
 139
 140
 141
 142
 143
 144
 145
 146
 147
 148
 149
 150
 151
 152
 153
 154
 155
 156
 157
 158
 159
 160
 161
 162
 163
 164
 165
 166
 167
 168
 169
 170
 171
 172
 173
 174
 175
 176
 177
 178
 179
 180
 181
 182
 183
 184
 185
 186
 187
 188
 189
 190
 191
 192
 193
 194
 195
 196
 197
 198
 199
 200
 201
 202
 203
 204
 205
 206
 207
 208
 209
 210
 211
 212
 213
 214
 215
 216
 217
 218
 219
 220
 221
 222
 223
 224
 225
 226
 227
 228
 229
 230
 231
 232
 233
 234
 235
 236
 237
 238
 239
 240
 241
 242
 243
 244
 245
 246
 247
 248
 249
 250
 251
 252
 253
 254
 255
 256
 257
 258
 259
 260
 261
 262
 263
 264
 265
 266
 267
 268
 269
 270
 271
 272
 273
 274
 275
 276
 277
 278
 279
 280
 281
 282
 283
 284
 285
 286
 287
 288
 289
 290
 291
 292
 293
 294
 295
 296
 297
 298
 299
 300
 301
 302
 303
 304
 305
 306
 307
 308
 309
 310
 311
 312
 313
 314
 315
 316
 317
 318
 319
 320
 321
 322
 323
 324
 325
 326
 327
 328
 329
 330
 331
 332
 333
 334
 335
 336
 337
 338
 339
 340
 341
 342
 343
 344
 345
 346
 347
 348
 349
 350
 351
 352
 353
 354
 355
 356
 357
 358
 359
 360
 361
 362
 363
 364
 365
 366
 367
 368
 369
 370
 371
 372
 373
 374
 375
 376
 377
 378
 379
 380
 381
 382
 383
 384
 385
 386
 387
 388
 389
 390
 391
 392
 393
 394
 395
 396
 397
 398
 399
 400
 401
 402
 403
 404
 405
 406
 407
 408
 409
 410
 411
 412
 413
 414
 415
 416
 417
 418
 419
 420
 421
 422
 423
 424
 425
 426
 427
 428
 429
 430
 431
 432
 433
 434
 435
 436
 437
 438
 439
 440
 441
 442
 443
 444
 445
 446
 447
 448
 449
 450
 451
 452
 453
 454
 455
 456
 457
 458
 459
 460
 461
 462
 463
 464
 465
 466
 467
 468
 469
 470
 471
 472
 473
 474
 475
 476
 477
 478
 479
 480
 481
 482
 483
 484
 485
 486
 487
 488
 489
 490
 491
 492
 493
 494
 495
 496
 497
 498
 499
 500
 501
 502
 503
 504
 505
 506
 507
 508
 509
 510
 511
 512
 513
 514
 515
 516
 517
 518
 519
 520
 521
 522
 523
 524
 525
 526
 527
 528
 529
 530
 531
 532
 533
 534
 535
 536
 537
 538
 539
 540
 541
 542
 543
 544
 545
 546
 547
 548
 549
 550
 551
 552
 553
 554
 555
 556
 557
 558
 559
 560
 561
 562
 563
 564
 565
 566
 567
 568
 569
 570
 571
 572
 573
 574
 575
 576
 577
 578
 579
 580
 581
 582
 583
 584
 585
 586
 587
 588
 589
 590
 591
 592
 593
 594
 595
 596
 597
 598
 599
 600
 601
 602
 603
 604
 605
 606
 607
 608
 609
 610
 611
 612
 613
 614
 615
 616
 617
 618
 619
 620
 621
 622
 623
 624
 625
 626
 627
 628
 629
 630
 631
 632
 633
 634
 635
 636
 637
 638
 639
 640
 641
 642
 643
 644
 645
 646
 647
 648
 649
 650
 651
 652
 653
 654
 655
 656
 657
 658
 659
 660
 661
 662
 663
 664
 665
 666
 667
 668
 669
 670
 671
 672
 673
 674
 675
 676
 677
 678
 679
 680
 681
 682
 683
 684
 685
 686
 687
 688
 689
 690
 691
 692
 693
 694
 695
 696
 697
 698
 699
 700
 701
 702
 703
 704
 705
 706
 707
 708
 709
 710
 711
 712
 713
 714
 715
 716
 717
 718
 719
 720
 721
 722
 723
 724
 725
 726
 727
 728
 729
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
class AgentCoreAdapter(BaseAdapter):
    """
    Adapter for transforming AgentCore ``invoke_agent_runtime`` responses to
    canonical AgentTrace format.

    AgentCore agents are identified by their **runtime ARN** (or runtime ID +
    account ID).  The adapter calls the ``bedrock-agentcore`` service client,
    *not* the legacy ``bedrock-agent-runtime`` client.

    Expected raw_data structure::

        {
            "user_input": "...",
            "final_response": "..." | {...},
            "latency": 1.234,
            "trace": [...],               # CloudWatch log entries (JSON strings, optional)
            "response_chunks": [...],      # Raw streaming chunks (optional)
            "trace_id": "...",
            "session_id": "...",
            "agent_runtime_id": "...",     # Runtime ARN or ID
        }
    """

    @property
    def name(self) -> str:
        """Return the adapter name."""
        return "agentcore"

    # ------------------------------------------------------------------
    # Live invocation helpers
    # ------------------------------------------------------------------

    @staticmethod
    def invoke(
        agent_runtime_arn: str,
        user_input: str,
        agent_version: str = None,
        *,
        region: Optional[str] = None,
        session_id: Optional[str] = None,
        qualifier: Optional[str] = None,
        bearer_token: Optional[str] = None,
        auto_detect_auth: bool = True,
        payload_override: Optional[Dict[str, Any]] = None,
        boto_session=None,
        log_stream_name: Optional[str] = None,
        fetch_logs: bool = True,
    ) -> Dict[str, Any]:
        """Invoke an AgentCore-deployed agent and collect its streaming response.

        Supports both authentication modes:

        * **SigV4 (default)** — uses the ``bedrock-agentcore`` boto3 client.
          Works when the agent runtime is configured for IAM auth.
        * **OAuth / JWT bearer token** — when *bearer_token* is provided the
          adapter makes a raw HTTPS ``POST`` to the AgentCore data-plane
          endpoint instead, because the AWS SDK always signs with SigV4 which
          is rejected by OAuth-configured runtimes.

        When *auto_detect_auth* is True (default) and no *bearer_token* is
        provided, the adapter will first try SigV4.  If that fails with an
        authorization mismatch, it queries the runtime's authorizer config
        and attempts to auto-fetch a Cognito token via client_credentials
        grant before retrying with OAuth.

        Args:
            agent_runtime_arn: The AgentCore Runtime ARN.
            user_input: The prompt to send.
            region: AWS region (reads from UAEF config if not provided).
            session_id: Optional runtime session ID (generated if omitted).
            qualifier: Optional qualifier / endpoint name.
            bearer_token: If the agent runtime uses OAuth/JWT inbound auth,
                pass the access token here.  When set, the invocation is made
                via HTTPS with an ``Authorization: Bearer`` header instead of
                the boto3 SDK.
            auto_detect_auth: If True and SigV4 fails with auth mismatch,
                automatically detect OAuth config and retry. Default True.
            payload_override: Optional dict to use as the JSON payload instead
                of the default ``{"prompt": ..., "runtimeSessionId": ...}``.
                Use this when your agent container expects a custom payload
                format.
            boto_session: Optional ``boto3.Session`` (SigV4 mode only).

        Returns:
            A dict suitable for ``transform_to_canonical()``.
        """
        from uaef.config import get_config

        if region is None:
            region = get_config().aws.region

        session_id = session_id or str(uuid4())

        # Build payload — use override if provided, otherwise default format
        if payload_override is not None:
            payload_dict = payload_override
        else:
            payload_dict = {
                "prompt": user_input,
                "runtimeSessionId": session_id,
            }

        if bearer_token:
            return AgentCoreAdapter._invoke_oauth(
                agent_runtime_arn=agent_runtime_arn,
                user_input=user_input,
                bearer_token=bearer_token,
                payload_dict=payload_dict,
                region=region,
                session_id=session_id,
                qualifier=qualifier,
                agent_version=agent_version,
                log_stream_name=log_stream_name,
            )

        # Try SigV4 first
        try:
            return AgentCoreAdapter._invoke_sigv4(
                agent_runtime_arn=agent_runtime_arn,
                user_input=user_input,
                agent_version=agent_version,
                payload_dict=payload_dict,
                region=region,
                session_id=session_id,
                qualifier=qualifier,
                boto_session=boto_session,
                log_stream_name=log_stream_name,
                fetch_logs=fetch_logs,
            )
        except Exception as e:
            if not auto_detect_auth:
                raise
            error_msg = str(e)
            if "Authorization method mismatch" not in error_msg and "explicit deny" not in error_msg:
                raise

        # SigV4 failed with auth mismatch — attempt OAuth auto-detection
        token = AgentCoreAdapter._auto_detect_oauth_token(
            agent_runtime_arn=agent_runtime_arn,
            region=region,
            boto_session=boto_session,
        )
        if not token:
            raise RuntimeError(
                f"SigV4 auth failed and OAuth auto-detection could not fetch a token. "
                f"Provide a bearer_token manually."
            )
        return AgentCoreAdapter._invoke_oauth(
            agent_runtime_arn=agent_runtime_arn,
            user_input=user_input,
            bearer_token=token,
            payload_dict=payload_dict,
            region=region,
            session_id=session_id,
            qualifier=qualifier,
            agent_version=agent_version,
            log_stream_name=log_stream_name,
            fetch_logs=fetch_logs,
        )

    # ---- OAuth auto-detection ----------------------------------------

    @staticmethod
    def _auto_detect_oauth_token(
        agent_runtime_arn: str,
        region: str,
        boto_session=None,
    ) -> str:
        """Attempt to auto-detect OAuth config and fetch a Cognito token.

        Queries the runtime's authorizer configuration and tries the
        client_credentials grant on the discovered token endpoint.

        Returns:
            Access token string, or empty string if detection fails.
        """
        import base64
        import urllib.parse
        import urllib.request

        try:
            import boto3
        except ImportError:
            return ""

        try:
            session = boto_session or boto3.Session(region_name=region)
            runtime_id = agent_runtime_arn.split("/")[-1]
            control = session.client("bedrock-agentcore-control", region_name=region)
            info = control.get_agent_runtime(agentRuntimeId=runtime_id)
            auth_config = info.get("authorizerConfiguration", {})

            if not auth_config:
                return ""

            issuer = auth_config.get("issuerUrl", auth_config.get("issuer", ""))
            audience = auth_config.get("allowedAudiences", auth_config.get("audience", []))
            client_id = auth_config.get("clientId", "")
            client_secret = auth_config.get("clientSecret", "")

            if not issuer or not client_id or not client_secret:
                return ""

            token_url = issuer.rstrip("/") + "/oauth2/token"
            if urllib.parse.urlsplit(token_url).scheme != "https":
                # Never send the client secret over a non-https token endpoint.
                raise ValueError(f"Refusing non-https token URL: {token_url!r}")
            auth_header = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
            data = urllib.parse.urlencode({
                "grant_type": "client_credentials",
                "scope": " ".join(audience) if audience else "",
            }).encode()

            req = urllib.request.Request(token_url, data=data, headers={
                "Content-Type": "application/x-www-form-urlencoded",
                "Authorization": f"Basic {auth_header}",
            })

            # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- False positive: token_url scheme is validated to https above; endpoint is the first-party Cognito/AgentCore token URL, not user-controlled input.
            with urllib.request.urlopen(req) as resp:  # nosec B310 - token_url scheme validated to https above
                body = json.loads(resp.read().decode())
                return body.get("access_token", "")

        except Exception:
            return ""

    # ---- SigV4 path (default) ----------------------------------------

    @classmethod
    def _invoke_sigv4(
        cls,
        agent_runtime_arn: str,
        user_input: str,
        agent_version: str,
        payload_dict: Dict[str, Any],
        *,
        region: str,
        session_id: str,
        qualifier: Optional[str] = None,
        boto_session=None,
        log_stream_name: Optional[str] = None,
        fetch_logs: bool = True,
    ) -> Dict[str, Any]:
        try:
            import boto3
        except ImportError:
            raise ImportError("boto3 is required for SigV4 invocation — pip install boto3")

        session = boto_session or boto3.Session(region_name=region)
        client = session.client("bedrock-agentcore", region_name=region)

        payload = json.dumps(payload_dict).encode()

        # Generate a trace ID client-side so we can query logs by it later
        trace_id = cls.generate_xray_trace_id()

        invoke_kwargs: Dict[str, Any] = {
            "agentRuntimeArn": agent_runtime_arn,
            "runtimeSessionId": session_id,
            "traceId": trace_id,
            "payload": payload,
        }
        if qualifier:
            invoke_kwargs["qualifier"] = qualifier

        start = time.time()
        response = client.invoke_agent_runtime(**invoke_kwargs)
        latency = time.time() - start

        content = []
        for chunk in response.get("response", []):
            content.append(chunk.decode('utf-8'))

        joined_content = ''.join(content)
        if joined_content:
            try:
                final_response = json.loads(joined_content)
            except json.JSONDecodeError:
                # Response may not be JSON — treat as plain text
                final_response = joined_content
        else:
            final_response = ""

        # Optionally enrich with CloudWatch otel logs (tool/multi-agent info).
        # This polls CloudWatch Logs Insights for up to several minutes per
        # invocation, so it is skipped unless explicitly requested AND an
        # ``agent_version`` is supplied. Without ``agent_version`` the log-group
        # name would be ``...-None`` (a group that never exists), making every
        # StartQuery fail and the poll loop burn its full timeout for nothing —
        # which, across a multi-row batch, can exceed a caller's time budget
        # (e.g. a 15-minute Lambda). Callers that only need the response text
        # (most response-quality evaluation) should pass ``fetch_logs=False``.
        trace: List[str] = []
        tools_info: Dict[str, Any] = {}
        if fetch_logs and agent_version:
            # Extract agent_id from the ARN
            # ARN: arn:aws:bedrock-agentcore:<region>:<account>:runtime/<name>
            arn_parts = agent_runtime_arn.split("/")
            agent_id = arn_parts[1] if len(arn_parts) > 1 else "unknown"

            your_log_groups = [f"/aws/bedrock-agentcore/runtimes/{agent_id}-{agent_version}"]

            # Query logs by the trace ID we passed to the invocation.
            # The trace ID appears in the otel logs, stripped of the X-Ray prefix.
            your_trace_id = trace_id.replace("Root=1-", "").split(";")[0].replace("-", "")

            # Poll for logs using the trace ID (matching helper.py approach)
            timeout_minutes = 3
            check_interval_seconds = 5
            query_start_time = datetime.now()
            timeout_time = query_start_time + timedelta(minutes=timeout_minutes)

            while datetime.now() < timeout_time:
                trace = cls.get_logs_by_trace_id(
                    your_trace_id, your_log_groups,
                    region_name=region,
                    log_stream_name=log_stream_name,
                )
                if trace and len(trace) > 0:
                    break
                time.sleep(check_interval_seconds)

            # Final fetch after additional wait for all logs to flush
            time.sleep(10)
            trace = cls.get_logs_by_trace_id(
                your_trace_id, your_log_groups,
                region_name=region,
                log_stream_name=log_stream_name,
            )

            # Auto-extract tool info from the logs
            tools_info = cls.extract_tools_info(trace)

        return {
            "user_input": user_input,
            "final_response": final_response,
            "latency": latency,
            "trace": trace,
            "tools_info": tools_info,
            "trace_id": response.get("ResponseMetadata", {}).get("RequestId", ""),
            "session_id": response.get("runtimeSessionId", session_id),
            "agent_runtime_id": agent_runtime_arn,
        }

    # ---- OAuth / JWT bearer-token path --------------------------------

    @staticmethod
    def _invoke_oauth(
        agent_runtime_arn: str,
        user_input: str,
        bearer_token: str,
        payload_dict: Dict[str, Any],
        *,
        region: str,
        session_id: str,
        qualifier: Optional[str] = None,
        agent_version: Optional[str] = None,
        log_stream_name: Optional[str] = None,
        fetch_logs: bool = True,
    ) -> Dict[str, Any]:
        """HTTPS invocation for OAuth-configured agent runtimes.

        The AWS SDK cannot be used when the runtime expects a JWT bearer
        token because the SDK always signs with SigV4.  Instead we POST
        directly to the AgentCore data-plane endpoint.

        Handles SSE (Server-Sent Events) streaming responses where each line
        is formatted as ``data: {...}``.  Extracts the final text from the
        ``{"data": "..."}`` chunks emitted by Strands-based agents.

        When *fetch_logs* is True and *agent_version* is provided, polls
        CloudWatch for otel logs after invocation to enable multi-agent
        detection from log patterns.
        """
        try:
            import urllib.request
            import urllib.parse
            import urllib.error
        except ImportError:
            raise  # stdlib — should never fail

        # Build the URL:
        # POST /runtimes/{agentRuntimeArn}/invocations
        escaped_arn = urllib.parse.quote(agent_runtime_arn, safe="")
        url = f"https://bedrock-agentcore.{region}.amazonaws.com/runtimes/{escaped_arn}/invocations"
        if qualifier:
            url += f"?qualifier={urllib.parse.quote(qualifier, safe='')}"
        if urllib.parse.urlsplit(url).scheme != "https":
            raise ValueError(f"Refusing to open non-https URL: {url!r}")

        payload = json.dumps(payload_dict).encode()

        headers = {
            "Content-Type": "application/json",
            "Accept": "text/event-stream",
            "Authorization": f"Bearer {bearer_token}",
            "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id": session_id,
        }

        req = urllib.request.Request(url, data=payload, headers=headers, method="POST")

        start = time.time()
        try:
            # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected -- False positive: url scheme is validated to https above; endpoint is the first-party AgentCore runtime URL, not user-controlled input.
            with urllib.request.urlopen(req) as resp:  # nosec B310 - url scheme validated to https above
                latency = time.time() - start
                body = resp.read().decode("utf-8")
                resp_session_id = resp.headers.get(
                    "X-Amzn-Bedrock-AgentCore-Runtime-Session-Id", session_id
                )
                request_id = resp.headers.get("x-amzn-RequestId", "")
        except urllib.error.HTTPError as e:
            body_bytes = e.read() if hasattr(e, "read") else b""
            raise AdapterTransformationError(
                f"AgentCore OAuth invocation failed ({e.code}): {body_bytes.decode('utf-8', errors='replace')}",
                adapter_name="agentcore",
            )

        # Parse SSE response: lines formatted as "data: {...}"
        response_chunks: List[str] = []
        text_parts: List[str] = []

        for line in body.split("\n"):
            line = line.strip()
            if not line or not line.startswith("data: "):
                continue
            json_str = line[6:]  # strip "data: " prefix
            try:
                chunk = json.loads(json_str)
                response_chunks.append(json_str)

                # Strands format: {"data": "text content"}
                if isinstance(chunk, dict) and "data" in chunk and isinstance(chunk["data"], str):
                    text_parts.append(chunk["data"])
            except json.JSONDecodeError:
                # Not JSON — treat as raw text
                response_chunks.append(json_str)
                text_parts.append(json_str)

        # If no SSE lines found, fall back to parsing body as plain JSON/text
        if not response_chunks:
            try:
                parsed = json.loads(body)
                if isinstance(parsed, dict):
                    text_parts.append(parsed.get("response", parsed.get("output", parsed.get("data", body))))
                else:
                    text_parts.append(body)
                response_chunks.append(body)
            except json.JSONDecodeError:
                text_parts.append(body)
                response_chunks.append(body)

        final_text = "".join(text_parts)

        # Optionally fetch CloudWatch logs for multi-agent detection
        trace: List[str] = []
        tools_info: Dict[str, Any] = {}
        if fetch_logs and agent_version:
            arn_parts = agent_runtime_arn.split("/")
            agent_id = arn_parts[1] if len(arn_parts) > 1 else "unknown"
            log_groups = [f"/aws/bedrock-agentcore/runtimes/{agent_id}-{agent_version}"]

            # Wait for logs to flush to CloudWatch (typically 15-30s after invocation)
            time.sleep(15)

            # Step 1: Query by session_id to find the otel traceId
            # (strands.telemetry.tracer entries have session.id in attributes)
            timeout_minutes = 3
            check_interval_seconds = 10
            query_start_time = datetime.now()
            timeout_time = query_start_time + timedelta(minutes=timeout_minutes)
            otel_trace_id = None

            while datetime.now() < timeout_time:
                partial = AgentCoreAdapter.get_logs_by_session_id(
                    session_id, log_groups,
                    region_name=region,
                    log_stream_name=log_stream_name,
                    invocation_time=start,
                )
                if partial:
                    # Extract the otel traceId from any entry
                    otel_trace_id = AgentCoreAdapter._extract_otel_trace_id(partial)
                    if otel_trace_id:
                        break
                time.sleep(check_interval_seconds)

            # Step 2: If we found a traceId, query ALL logs for that trace
            # (this picks up __main__ scope entries like [Step N] that don't
            # have session.id but DO share the same traceId)
            if otel_trace_id:
                trace = AgentCoreAdapter.get_logs_by_trace_id(
                    otel_trace_id, log_groups,
                    region_name=region,
                    log_stream_name=log_stream_name,
                )
            else:
                # Fallback: use whatever we found by session_id
                trace = partial if partial else []

            # Extract tool info from logs
            if trace:
                tools_info = AgentCoreAdapter.extract_tools_info(trace)

        return {
            "user_input": user_input,
            "final_response": final_text,
            "latency": latency,
            "trace": trace,
            "tools_info": tools_info,
            "response_chunks": response_chunks,
            "trace_id": request_id,
            "session_id": resp_session_id,
            "agent_runtime_id": agent_runtime_arn,
        }

    # ---- Shared response reader --------------------------------------

    @staticmethod
    def _read_response_body(response: Dict[str, Any]) -> List[str]:
        """Read chunks from a boto3 streaming response."""
        content_type = response.get("contentType", "")
        response_body = response.get("response", b"")
        chunks: List[str] = []

        if "text/event-stream" in content_type:
            for line in response_body.iter_lines(chunk_size=10):
                if line:
                    decoded = line.decode("utf-8")
                    if decoded.startswith("data: "):
                        decoded = decoded[6:]
                    chunks.append(decoded)
        elif content_type == "application/json":
            parts = []
            for chunk in response_body:
                parts.append(chunk.decode("utf-8"))
            chunks.append("".join(parts))
        else:
            if hasattr(response_body, "read"):
                chunks.append(response_body.read().decode("utf-8"))
            elif isinstance(response_body, bytes):
                chunks.append(response_body.decode("utf-8"))

        return chunks

    # ------------------------------------------------------------------
    # Multi-agent invocation helper
    # ------------------------------------------------------------------

    # @staticmethod
    # def invoke_multi(
    #     agents: Dict[str, Dict[str, Any]],
    #     user_input: str,
    #     *,
    #     region: Optional[str] = None,
    #     session_id: Optional[str] = None,
    #     chain: bool = True,
    #     boto_session=None,
    # ) -> Dict[str, Any]:
    #     """Invoke multiple AgentCore-deployed agents and collect responses.

    #     This produces a raw_data dict with an ``"agents"`` key that
    #     ``transform_to_canonical()`` auto-detects as multi-agent, returning
    #     a ``MultiAgentTrace``.

    #     Args:
    #         agents: Mapping of agent name → config dict. Each config must have
    #             ``"arn"`` and may include ``"bearer_token"``, ``"qualifier"``,
    #             ``"payload_builder"``, ``"payload_override"``.
    #         user_input: The user prompt to send.
    #         region: AWS region (reads from UAEF config if not provided).
    #         session_id: Optional session ID (generated if omitted).
    #         chain: If True (default), each agent receives the previous agent's
    #             response as additional context. If False, all agents receive
    #             the original user_input.
    #         boto_session: Optional ``boto3.Session`` (SigV4 mode only).

    #     Returns:
    #         A dict suitable for ``transform_to_canonical()`` with auto-detected
    #         multi-agent structure::

    #             {
    #                 "user_input": "...",
    #                 "agents": {
    #                     "agent_name": { <single-agent raw_data> },
    #                     ...
    #                 },
    #                 "agent_order": ["agent1", "agent2", ...],
    #                 "session_id": "...",
    #                 "latency": <total>,
    #             }
    #     """
    #     from uaef.config import get_config

    #     if region is None:
    #         region = get_config().aws.region

    #     session_id = session_id or str(uuid4())
    #     agent_results: Dict[str, Dict[str, Any]] = {}
    #     agent_order: List[str] = []
    #     total_latency = 0.0
    #     current_input = user_input

    #     for agent_name, cfg in agents.items():
    #         agent_order.append(agent_name)

    #         # Build payload override if a builder is provided
    #         payload_override = cfg.get("payload_override")
    #         if "payload_builder" in cfg:
    #             payload_override = cfg["payload_builder"](current_input, session_id)

    #         raw = AgentCoreAdapter.invoke(
    #             agent_runtime_arn=cfg["arn"],
    #             user_input=current_input,
    #             region=region,
    #             session_id=session_id,
    #             bearer_token=cfg.get("bearer_token"),
    #             qualifier=cfg.get("qualifier"),
    #             payload_override=payload_override,
    #             boto_session=boto_session,
    #         )

    #         agent_results[agent_name] = raw
    #         total_latency += raw.get("latency", 0.0)

    #         # Chain: pass this agent's response as context to the next
    #         if chain and raw.get("final_response"):
    #             response_text = AgentCoreAdapter._extract_text_from_response(
    #                 raw["final_response"]
    #             )
    #             current_input = (
    #                 f"Previous agent ({agent_name}) responded:\n"
    #                 f"{response_text}\n\n"
    #                 f"Original question: {user_input}"
    #             )

    #     return {
    #         "user_input": user_input,
    #         "agents": agent_results,
    #         "agent_order": agent_order,
    #         "session_id": session_id,
    #         "latency": total_latency,
    #     }

    # ------------------------------------------------------------------
    # Canonical transformation
    # ------------------------------------------------------------------

    def transform_to_canonical(self, raw_data: Dict[str, Any]) -> Union[AgentTrace, MultiAgentTrace]:
        """Transform AgentCore response data to canonical format.

        Auto-detects multi-agent traces when ``raw_data`` contains an
        ``"agents"`` key mapping agent names to their individual response
        dicts. Otherwise returns a single ``AgentTrace``.

        This mirrors the LangGraph adapter's auto-detect pattern: the caller
        does not need to specify agent node names — the adapter infers the
        multi-agent structure from the data shape.

        Args:
            raw_data: AgentCore response data. For multi-agent, include::

                {
                    "user_input": "...",
                    "agents": {
                        "agent_name": { <single-agent raw_data> },
                        ...
                    },
                    "agent_order": ["agent1", "agent2", ...],  # optional
                    "session_id": "...",
                }

        Returns:
            AgentTrace for single-agent, MultiAgentTrace for multi-agent.
        """
        try:
            # Auto-detect: if "agents" key is present with multiple entries,
            agents_data = raw_data.get("agents")
            if isinstance(agents_data, dict) and len(agents_data) > 0:
                return self._transform_multi_agent(raw_data)

            # Auto-detect from otel logs: if trace contains multiple agent
            trace_logs = raw_data.get("trace", [])
            if trace_logs:
                detected_agents = self._detect_agents_from_otel_logs(trace_logs)
                if len(detected_agents) > 1:
                    # Restructure raw_data into multi-agent format
                    multi_raw = self._build_multi_agent_raw_data(raw_data, detected_agents)
                    return self._transform_multi_agent(multi_raw)

            return self._transform_single_agent(raw_data)

        except AdapterTransformationError:
            raise
        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to transform AgentCore trace: {str(e)}",
                adapter_name=self.name,
                original_error=e,
            )

    def _transform_single_agent(self, raw_data: Dict[str, Any]) -> AgentTrace:
        """Transform single-agent response to AgentTrace (original behavior)."""
        messages = self.extract_messages(raw_data)
        tool_calls = self.extract_tool_calls(raw_data)
        metadata = self.extract_metadata(raw_data)

        # If tools_info is present (from invoke with otel log extraction),
        # use it to populate tool_calls — it's more complete than the legacy path.
        tools_info = raw_data.get("tools_info")
        if tools_info and tools_info.get("tool_calls") and not tool_calls:
            for tc_info in tools_info["tool_calls"]:
                result_str = None
                if tc_info.get("response") is not None:
                    resp = tc_info["response"]
                    if isinstance(resp, list):
                        # Extract text from content list: [{"text": "..."}]
                        texts = []
                        for item in resp:
                            if isinstance(item, dict) and "text" in item:
                                texts.append(item["text"])
                            elif isinstance(item, str):
                                texts.append(item)
                        result_str = "\n".join(texts) if texts else str(resp)
                    elif isinstance(resp, str):
                        result_str = resp
                    else:
                        result_str = str(resp)

                tool_calls.append(
                    ToolCall(
                        name=tc_info.get("name", "unknown"),
                        arguments=tc_info.get("args", {}),
                        result=result_str,
                        timestamp=datetime.now(timezone.utc),
                    )
                )

            # Also update token usage from tools_info if available
            token_usage = tools_info.get("token_usage", {})
            if token_usage.get("input_tokens") and not metadata.get("input_tokens"):
                metadata["input_tokens"] = token_usage["input_tokens"]
            if token_usage.get("output_tokens") and not metadata.get("output_tokens"):
                metadata["output_tokens"] = token_usage["output_tokens"]

        trace_id_str = raw_data.get("trace_id")
        if trace_id_str:
            trace_id_str = str(trace_id_str).replace("Root=1-", "").split(";")[0].replace("-", "")

        trace = AgentTrace(
            trace_id=uuid4(),
            session_id=raw_data.get("session_id"),
            messages=messages,
            tool_calls=tool_calls,
            input_tokens=metadata.get("input_tokens"),
            output_tokens=metadata.get("output_tokens"),
            latency=metadata.get("latency"),
            cost=metadata.get("cost"),
            metadata=metadata,
            framework="agentcore",
            timestamp=datetime.now(timezone.utc),
        )

        if trace_id_str:
            trace.metadata["xray_trace_id"] = trace_id_str

        return trace

    # ------------------------------------------------------------------
    # Auto-detect multi-agent from otel logs
    # ------------------------------------------------------------------

    def _detect_agents_from_otel_logs(self, trace_logs: List[str]) -> List[Dict[str, Any]]:
        """Detect individual agent steps from otel log entries.

        Looks for patterns like "[Step N] Agent Name analyzing..." in the log
        body text, and also detects agent boundaries from span transitions
        in the strands.telemetry.tracer scope.

        Returns:
            List of dicts with agent info, ordered by appearance:
            [{"name": "trend_agent", "display_name": "Trend Agent", "start_idx": 0, "end_idx": 10}, ...]
        """
        import re

        agents: List[Dict[str, Any]] = []
        step_pattern = re.compile(r'\[Step\s+(\d+)\]\s+(.+?)(?:\s+analyzing|\s+consolidating|\s+processing|\.\.\.)', re.IGNORECASE)

        for idx, entry in enumerate(trace_logs):
            try:
                entry_data = json.loads(entry)
            except (json.JSONDecodeError, TypeError):
                continue

            body = entry_data.get("body", "")
            if not isinstance(body, str):
                continue

            match = step_pattern.search(body)
            if match:
                step_num = int(match.group(1))
                agent_display_name = match.group(2).strip()
                # Normalize to a key name
                agent_key = agent_display_name.lower().replace(" ", "_")

                # Close the previous agent's range
                if agents:
                    agents[-1]["end_idx"] = idx

                agents.append({
                    "name": agent_key,
                    "display_name": agent_display_name,
                    "step": step_num,
                    "start_idx": idx,
                    "end_idx": len(trace_logs),  # will be updated by next agent
                })

        return agents

    def _build_multi_agent_raw_data(self, raw_data: Dict[str, Any], detected_agents: List[Dict[str, Any]]) -> Dict[str, Any]:
        """Build multi-agent raw_data structure from detected agent segments.

        Splits the otel trace logs into per-agent segments and constructs
        the expected multi-agent raw_data format with an "agents" key.

        Args:
            raw_data: Original single-invocation raw_data with "trace" logs.
            detected_agents: List from _detect_agents_from_otel_logs.

        Returns:
            Dict in multi-agent format suitable for _transform_multi_agent().
        """
        trace_logs = raw_data.get("trace", [])
        agents_dict: Dict[str, Dict[str, Any]] = {}
        agent_order: List[str] = []

        for agent_info in detected_agents:
            agent_name = agent_info["name"]
            start_idx = agent_info["start_idx"]
            end_idx = agent_info["end_idx"]
            agent_order.append(agent_name)

            # Slice the trace logs for this agent's segment
            agent_logs = trace_logs[start_idx:end_idx]

            # Extract tools_info for this agent's segment
            tools_info = self.extract_tools_info(agent_logs)

            # Build per-agent raw_data
            agents_dict[agent_name] = {
                "user_input": raw_data.get("user_input", ""),
                "final_response": "",  # intermediate agents don't have a final response
                "latency": 0,
                "trace": agent_logs,
                "tools_info": tools_info,
                "session_id": raw_data.get("session_id"),
            }

        # The last agent gets the final_response
        if agent_order and agents_dict:
            last_agent = agent_order[-1]
            agents_dict[last_agent]["final_response"] = raw_data.get("final_response", "")

        # Calculate approximate per-agent latency from timestamps
        total_latency = raw_data.get("latency", 0)
        if agent_order and total_latency:
            per_agent_latency = total_latency / len(agent_order)
            for name in agent_order:
                agents_dict[name]["latency"] = per_agent_latency

        return {
            "user_input": raw_data.get("user_input", ""),
            "agents": agents_dict,
            "agent_order": agent_order,
            "session_id": raw_data.get("session_id"),
            "latency": total_latency,
        }

    def _transform_multi_agent(self, raw_data: Dict[str, Any]) -> MultiAgentTrace:
        """Transform multi-agent response data to MultiAgentTrace.

        Infers agent ordering and coordination events from the ``agents`` dict
        and optional ``agent_order`` list. Each agent's raw data is transformed
        into a separate AgentTrace.
        """
        agents_data = raw_data["agents"]
        agent_order = raw_data.get("agent_order", list(agents_data.keys()))
        session_id = raw_data.get("session_id")
        user_input = raw_data.get("user_input", "")

        # Build per-agent AgentTraces
        agent_traces: Dict[str, AgentTrace] = {}
        for agent_name in agent_order:
            agent_raw = agents_data.get(agent_name)
            if agent_raw is None:
                continue
            agent_traces[agent_name] = self._transform_single_agent(agent_raw)

        # Infer coordination events from sequential agent ordering
        coordination_events: List[CoordinationEvent] = []
        for i in range(1, len(agent_order)):
            from_agent = agent_order[i - 1]
            to_agent = agent_order[i]
            coordination_events.append(CoordinationEvent(
                from_agent=from_agent,
                to_agent=to_agent,
                event_type="HANDOFF",
                message=f"Control passed from {from_agent} to {to_agent}",
                timestamp=datetime.now(timezone.utc),
            ))

        # Final handoff to user
        if agent_order:
            coordination_events.append(CoordinationEvent(
                from_agent=agent_order[-1],
                to_agent="user",
                event_type="RESPONSE",
                message="Final answer delivered to user",
                timestamp=datetime.now(timezone.utc),
            ))

        # Aggregate metadata
        total_latency = raw_data.get("latency")
        if total_latency is None:
            latencies = [
                agents_data[name].get("latency", 0.0)
                for name in agent_order
                if name in agents_data
            ]
            total_latency = sum(latencies) if latencies else None

        return MultiAgentTrace(
            trace_id=uuid4(),
            session_id=session_id,
            agent_traces=agent_traces,
            coordination_events=coordination_events,
            workflow_status=WorkflowStatus.COMPLETED,
            total_latency=total_latency,
            metadata={
                "agents_invoked": agent_order,
                "query": user_input,
                "framework": "agentcore",
            },
            timestamp=datetime.now(timezone.utc),
        )

    # ------------------------------------------------------------------
    # Message extraction
    # ------------------------------------------------------------------

    @staticmethod
    def _extract_text_from_response(response) -> str:
        """
        Extract plain text from various AgentCore/Strands response formats.

        Handles:
        - Plain string: "hello"
        - Dict with text: {"response": "hello"} or {"result": "hello"}
        - Bedrock Converse format: {"response": {"role": "assistant", "content": [{"text": "hello"}]}}
        - Nested JSON string: '{"response": {"content": [...]}}'
        """
        if isinstance(response, str):
            # Try parsing as JSON first
            try:
                import json
                parsed = json.loads(response)
                return AgentCoreAdapter._extract_text_from_response(parsed)
            except (json.JSONDecodeError, ValueError):
                return response

        if isinstance(response, dict):
            # Direct text fields
            for key in ("result", "text", "output", "message"):
                if key in response and isinstance(response[key], str):
                    return response[key]

            # Nested response dict (Bedrock Converse format)
            inner = response.get("response")
            if isinstance(inner, str):
                return inner
            if isinstance(inner, dict):
                # {"role": "assistant", "content": [{"text": "..."}]}
                content = inner.get("content")
                if isinstance(content, list):
                    texts = []
                    for item in content:
                        if isinstance(item, dict) and "text" in item:
                            texts.append(item["text"])
                    if texts:
                        return "\n".join(texts)
                # {"content": "plain string"}
                if isinstance(content, str):
                    return content
                # Fallback for inner dict
                return AgentCoreAdapter._extract_text_from_response(inner)

            # Direct content field
            content = response.get("content")
            if isinstance(content, str):
                return content
            if isinstance(content, list):
                texts = []
                for item in content:
                    if isinstance(item, dict) and "text" in item:
                        texts.append(item["text"])
                    elif isinstance(item, str):
                        texts.append(item)
                if texts:
                    return "\n".join(texts)

            # Last resort: stringify
            return str(response)

        return str(response)

    def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
        """Extract messages from AgentCore response and logs."""
        try:
            messages: List[Message] = []

            user_input = raw_data.get("user_input")
            if user_input:
                messages.append(
                    Message(
                        role=MessageRole.USER,
                        content=user_input,
                        timestamp=datetime.now(timezone.utc),
                    )
                )

            # Try to extract intermediate reasoning from trace_events
            for te in raw_data.get("trace_events", []):
                trace_detail = te.get("trace", {})
                orch = trace_detail.get("orchestrationTrace", {})
                rationale = orch.get("rationale", {})
                if rationale and rationale.get("text"):
                    messages.append(
                        Message(
                            role=MessageRole.ASSISTANT,
                            content=rationale["text"],
                            timestamp=datetime.now(timezone.utc),
                        )
                    )

            final_response = raw_data.get("final_response")
            if final_response:
                content = self._extract_text_from_response(final_response)
                if content:
                    messages.append(
                        Message(
                            role=MessageRole.ASSISTANT,
                            content=content,
                            timestamp=datetime.now(timezone.utc),
                        )
                    )

            return messages

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract messages: {str(e)}",
                adapter_name=self.name,
                original_error=e,
            )

    # ------------------------------------------------------------------
    # Tool-call extraction
    # ------------------------------------------------------------------

    def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
        """Extract tool calls from AgentCore trace events and CloudWatch logs."""
        try:
            tool_calls: List[ToolCall] = []

            # --- Source 1: trace_events from invoke_agent response stream ---
            for te in raw_data.get("trace_events", []):
                trace_detail = te.get("trace", {})
                orch = trace_detail.get("orchestrationTrace", {})

                invocation = orch.get("invocationInput", {})
                action_group = invocation.get("actionGroupInvocationInput", {})
                if action_group:
                    tool_name = action_group.get("function", action_group.get("apiPath", "unknown"))
                    params = {}
                    for p in action_group.get("parameters", []):
                        params[p.get("name", "")] = p.get("value", "")
                    tool_calls.append(
                        ToolCall(
                            name=tool_name,
                            arguments=params,
                            timestamp=datetime.now(timezone.utc),
                        )
                    )

                observation = orch.get("observation", {})
                action_group_obs = observation.get("actionGroupInvocationOutput", {})
                if action_group_obs and tool_calls:
                    tool_calls[-1].result = action_group_obs.get("text", "")

            # --- Source 2: CloudWatch log entries (legacy format) ---
            log_entries = raw_data.get("trace", [])
            if log_entries and not tool_calls:
                tool_call_map: Dict[str, ToolCall] = {}

                for entry in log_entries:
                    if not isinstance(entry, str):
                        continue
                    try:
                        entry_data = json.loads(entry)
                    except json.JSONDecodeError:
                        continue

                    body = entry_data.get("body")
                    if not isinstance(body, dict):
                        continue

                    if "content" in body:
                        content = body.get("content", "")
                        if isinstance(content, list):
                            for item in content:
                                if isinstance(item, dict) and item.get("type") == "tool_use":
                                    tool_name = item.get("name")
                                    tool_input = item.get("input", {})
                                    tool_id = item.get("id")
                                    if tool_name:
                                        tc = ToolCall(
                                            name=tool_name,
                                            arguments=tool_input if isinstance(tool_input, dict) else {},
                                            timestamp=datetime.now(timezone.utc),
                                        )
                                        if tool_id:
                                            tool_call_map[tool_id] = tc
                                        tool_calls.append(tc)

                    if "id" in body:
                        tool_id = body.get("id")
                        content = body.get("content")
                        attributes = entry_data.get("attributes", {})
                        if isinstance(attributes, dict):
                            event_name = attributes.get("event.name")
                            if event_name == "gen_ai.tool.message" and tool_id in tool_call_map:
                                tool_call_map[tool_id].result = content

            return tool_calls

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract tool calls: {str(e)}",
                adapter_name=self.name,
                original_error=e,
            )

    # ------------------------------------------------------------------
    # Metadata extraction
    # ------------------------------------------------------------------

    def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
        """Extract metadata from AgentCore response."""
        try:
            metadata: Dict[str, Any] = {}

            if "latency" in raw_data:
                metadata["latency"] = raw_data["latency"]
            if "trace_id" in raw_data:
                metadata["trace_id"] = raw_data["trace_id"]
            if "agent_runtime_id" in raw_data:
                metadata["agent_runtime_id"] = raw_data["agent_runtime_id"]
            # Legacy keys for backward compatibility
            if "agent_id" in raw_data:
                metadata["agent_id"] = raw_data["agent_id"]
            if "agent_version" in raw_data:
                metadata["agent_version"] = raw_data["agent_version"]
            if "agent_arn" in raw_data:
                metadata["agent_arn"] = raw_data["agent_arn"]

            # Token usage from trace_events
            input_tokens = 0
            output_tokens = 0
            for te in raw_data.get("trace_events", []):
                trace_detail = te.get("trace", {})
                usage = trace_detail.get("orchestrationTrace", {}).get("modelInvocationOutput", {}).get("metadata", {}).get("usage", {})
                if usage:
                    input_tokens += usage.get("inputTokens", 0)
                    output_tokens += usage.get("outputTokens", 0)

            # Fallback: token usage from CloudWatch log entries
            if input_tokens == 0 and output_tokens == 0:
                for entry in raw_data.get("trace", []):
                    if not isinstance(entry, str):
                        continue
                    try:
                        entry_data = json.loads(entry)
                        if "usage" in entry_data:
                            usage = entry_data["usage"]
                            if isinstance(usage, dict):
                                input_tokens += usage.get("input_tokens", 0) or usage.get("inputTokens", 0)
                                output_tokens += usage.get("output_tokens", 0) or usage.get("outputTokens", 0)
                    except json.JSONDecodeError:
                        continue

            if input_tokens > 0:
                metadata["input_tokens"] = input_tokens
            if output_tokens > 0:
                metadata["output_tokens"] = output_tokens

            return metadata

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract metadata: {str(e)}",
                adapter_name=self.name,
                original_error=e,
            )


    @classmethod
    def generate_xray_trace_id(cls) -> str:
        """Generate a synthetic X-Ray trace ID."""
        # Generate timestamp part (8 hex digits)
        timestamp = hex(int(time.time()))[2:].zfill(8)

        # Generate random part (24 hex digits)
        random_hex = uuid.uuid4().hex[:24]

        # Generate parent ID (16 hex digits)
        parent_id = uuid.uuid4().hex[:16]

        # Construct the trace ID (always sampled)
        trace_id = f"Root=1-{timestamp}-{random_hex};Parent={parent_id};Sampled=1"

        return trace_id

    @classmethod
    def _extract_otel_trace_id(cls, log_entries: List[str]) -> Optional[str]:
        """Extract the otel traceId from a list of log entries.

        Looks for the top-level 'traceId' field in the otel log format.
        Returns the first non-empty traceId found, or None.
        """
        for entry in log_entries:
            try:
                entry_data = json.loads(entry)
                trace_id = entry_data.get("traceId")
                if trace_id:
                    return trace_id
            except (json.JSONDecodeError, TypeError):
                continue
        return None

    @classmethod
    def get_logs_by_trace_id(cls, trace_id: str, log_group_names: List[str], region_name: str = 'us-east-1', log_stream_name: Optional[str] = None) -> List[str]:
        """
        Run a CloudWatch Logs Insights query to find all logs for a given trace ID.

        Args:
            trace_id: The X-Ray trace ID to search for.
            log_group_names: A list of CloudWatch log group names to search.
            region_name: The AWS region where the log groups are located.
            log_stream_name: Optional log stream name to scope the query to
                (e.g. "otel-rt-logs"). If None, searches all streams.

        Returns:
            List of log message strings matching the trace ID.
        """
        try:
            import boto3
        except ImportError:
            raise ImportError("boto3 is required for log retrieval — pip install boto3")

        client = boto3.client('logs', region_name=region_name)

        # Build query with optional log stream filter
        filters = [f'filter @message like /"{trace_id}"/']
        if log_stream_name:
            filters.insert(0, f'filter @logStream = "{log_stream_name}"')

        query_string = f'fields @timestamp, @message | {" | ".join(filters)} | sort @timestamp asc'
        out: List[str] = []
        try:
            # Start the query
            response = client.start_query(
                logGroupNames=log_group_names,
                startTime=int((time.time() - 3600) * 1000),  # Search logs from the last hour
                endTime=int(time.time() * 1000),
                queryString=query_string,
                limit=10000,
            )
            query_id = response['queryId']

            # Wait for the query to complete
            status = None
            while status not in ('Complete', 'Failed', 'Cancelled'):
                time.sleep(5)
                query_status_response = client.get_query_results(queryId=query_id)
                status = query_status_response['status']

            # Retrieve the results
            if status == 'Complete':
                results = query_status_response['results']
                if not results:
                    print(f"No logs found for trace ID: {trace_id} - waiting 5 more seconds ...")
                else:
                    for log_event in results:
                        message = next((field['value'] for field in log_event if field['field'] == '@message'), None)
                        if message:
                            out.append(message)
            else:
                print(f"Query failed or was cancelled. Status: {status}")

        except Exception as e:
            print(f"An error occurred: {e}")
        return out

    @classmethod
    def get_logs_by_session_id(
        cls,
        session_id: str,
        log_group_names: List[str],
        region_name: str = 'us-east-1',
        log_stream_name: Optional[str] = None,
        invocation_time: Optional[float] = None,
    ) -> List[str]:
        """
        Run a CloudWatch Logs Insights query to find all logs for a given session ID.

        Uses the session ID (runtimeSessionId) which is passed to the agent
        during invocation and appears in the otel logs, making it a reliable
        correlation key.

        Args:
            session_id: The runtime session ID used in the invocation.
            log_group_names: A list of CloudWatch log group names to search.
            region_name: The AWS region where the log groups are located.
            log_stream_name: Optional log stream name to scope the query to
                (e.g. "otel-rt-logs"). If None, searches all streams.
            invocation_time: Unix timestamp of when the invocation started.
                If provided, searches from 1 minute before this time.
                If None, searches the last hour.

        Returns:
            List of log message strings matching the session ID.
        """
        try:
            import boto3
        except ImportError:
            raise ImportError("boto3 is required for log retrieval — pip install boto3")

        client = boto3.client('logs', region_name=region_name)

        # Build query with optional log stream filter
        filters = [f'filter @message like /"{session_id}"/']
        if log_stream_name:
            filters.insert(0, f'filter @logStream = "{log_stream_name}"')

        query_string = f'fields @timestamp, @message | {" | ".join(filters)} | sort @timestamp asc'
        out: List[str] = []

        # Time window: from 1 min before invocation (or last hour) to now
        if invocation_time:
            start_ms = int((invocation_time - 60) * 1000)
        else:
            start_ms = int((time.time() - 3600) * 1000)
        end_ms = int(time.time() * 1000)

        try:
            response = client.start_query(
                logGroupNames=log_group_names,
                startTime=start_ms,
                endTime=end_ms,
                queryString=query_string,
                limit=10000,
            )
            query_id = response['queryId']

            # Wait for the query to complete
            status = None
            while status not in ('Complete', 'Failed', 'Cancelled'):
                time.sleep(5)
                query_status_response = client.get_query_results(queryId=query_id)
                status = query_status_response['status']

            # Retrieve the results
            if status == 'Complete':
                results = query_status_response['results']
                if not results:
                    print(f"No logs found for session ID: {session_id}")
                else:
                    for log_event in results:
                        message = next((field['value'] for field in log_event if field['field'] == '@message'), None)
                        if message:
                            out.append(message)
            else:
                print(f"Query failed or was cancelled. Status: {status}")

        except Exception as e:
            print(f"An error occurred: {e}")
        return out

    @classmethod
    def get_logs_by_time_window(
        cls,
        log_group_names: List[str],
        invocation_time: float,
        region_name: str = 'us-east-1',
        log_stream_name: Optional[str] = None,
    ) -> List[str]:
        """
        Fetch all otel logs from a narrow time window around an invocation.

        Since the otel traceId is generated server-side and can't be predicted
        client-side, this method fetches all logs from the invocation time
        window and returns them. The caller can then filter/group by traceId
        if needed.

        Args:
            log_group_names: CloudWatch log group names to search.
            invocation_time: Unix timestamp of when the invocation started.
            region_name: AWS region.
            log_stream_name: Optional log stream name filter (e.g. "otel-rt-logs").

        Returns:
            List of log message strings from the time window.
        """
        try:
            import boto3
        except ImportError:
            raise ImportError("boto3 is required for log retrieval — pip install boto3")

        client = boto3.client('logs', region_name=region_name)

        # Build query with optional log stream filter
        filters = []
        if log_stream_name:
            filters.append(f'filter @logStream = "{log_stream_name}"')

        filter_clause = f' | {" | ".join(filters)}' if filters else ''
        query_string = f'fields @timestamp, @message{filter_clause} | sort @timestamp asc'

        # Narrow time window: from invocation start to now
        start_ms = int(invocation_time * 1000)
        end_ms = int(time.time() * 1000)

        out: List[str] = []
        try:
            response = client.start_query(
                logGroupNames=log_group_names,
                startTime=start_ms,
                endTime=end_ms,
                queryString=query_string,
                limit=10000,
            )
            query_id = response['queryId']

            status = None
            while status not in ('Complete', 'Failed', 'Cancelled'):
                time.sleep(5)
                query_status_response = client.get_query_results(queryId=query_id)
                status = query_status_response['status']

            if status == 'Complete':
                results = query_status_response['results']
                if not results:
                    print(f"No logs found in time window starting at {invocation_time}")
                else:
                    for log_event in results:
                        message = next((field['value'] for field in log_event if field['field'] == '@message'), None)
                        if message:
                            out.append(message)
            else:
                print(f"Query failed or was cancelled. Status: {status}")

        except Exception as e:
            print(f"An error occurred: {e}")
        return out

    @classmethod
    def extract_tools_info(cls, log_entries: List[str]) -> Dict[str, Any]:
        """Extract tool calls, responses, and token metrics from otel-rt-logs.

        Parses OpenTelemetry-formatted log entries (Strands/AgentCore format)
        to extract tool calls, their responses, and token usage.

        Handles multiple formats found in otel-rt-logs:
        - gen_ai.assistant.message: contains toolUse items in body.content
          and/or body.tool_calls with function format
        - gen_ai.choice: contains body.message.tool_calls
        - gen_ai.tool.message: contains tool responses with body.id and body.content

        Args:
            log_entries: List of JSON-encoded log message strings from CloudWatch.

        Returns:
            Dict with tool_calls list and token_usage metrics.
        """
        tool_calls: List[Dict[str, Any]] = []
        tool_call_map: Dict[str, Dict[str, Any]] = {}  # id -> tool_call dict
        input_tokens = 0
        output_tokens = 0

        for entry in log_entries:
            try:
                entry_data = json.loads(entry)
            except (json.JSONDecodeError, TypeError):
                continue

            body = entry_data.get("body", {})
            attributes = entry_data.get("attributes", {})
            if not isinstance(attributes, dict):
                attributes = {}
            event_name = attributes.get("event.name", "") or entry_data.get("eventName", "")

            # If body is a JSON string, try to parse it
            if isinstance(body, str):
                try:
                    body = json.loads(body)
                except (json.JSONDecodeError, ValueError):
                    continue

            if not isinstance(body, dict):
                continue

            # --- Format 1: body.content with toolUse items (Bedrock Converse format) ---
            if "content" in body:
                content = body.get("content", "")
                if isinstance(content, list):
                    for item in content:
                        if not isinstance(item, dict):
                            continue
                        # toolUse format: {"toolUse": {"name": ..., "input": ..., "toolUseId": ...}}
                        if "toolUse" in item:
                            tu = item["toolUse"]
                            tool_name = tu.get("name")
                            tool_input = tu.get("input", {})
                            tool_id = tu.get("toolUseId")
                            if tool_name and tool_id not in tool_call_map:
                                tc = {
                                    "name": tool_name,
                                    "args": tool_input if isinstance(tool_input, dict) else {},
                                    "id": tool_id,
                                    "response": None,
                                }
                                tool_calls.append(tc)
                                if tool_id:
                                    tool_call_map[tool_id] = tc

                        # type: tool_use format (legacy)
                        if item.get("type") == "tool_use":
                            tool_name = item.get("name")
                            tool_input = item.get("input", {})
                            tool_id = item.get("id")
                            if tool_name and tool_id not in tool_call_map:
                                tc = {
                                    "name": tool_name,
                                    "args": tool_input if isinstance(tool_input, dict) else {},
                                    "id": tool_id,
                                    "response": None,
                                }
                                tool_calls.append(tc)
                                if tool_id:
                                    tool_call_map[tool_id] = tc

            # --- Format 2: body.tool_calls with function format ---
            if "tool_calls" in body:
                for tc_entry in body["tool_calls"]:
                    if isinstance(tc_entry, dict) and tc_entry.get("type") == "function":
                        func = tc_entry.get("function", {})
                        tool_name = func.get("name")
                        tool_args = func.get("arguments", {})
                        tool_id = tc_entry.get("id")
                        if tool_name and tool_id not in tool_call_map:
                            tc = {
                                "name": tool_name,
                                "args": tool_args if isinstance(tool_args, dict) else {},
                                "id": tool_id,
                                "response": None,
                            }
                            tool_calls.append(tc)
                            if tool_id:
                                tool_call_map[tool_id] = tc

            # --- Format 3: body.message.tool_calls (gen_ai.choice) ---
            if "message" in body and isinstance(body["message"], dict):
                msg = body["message"]
                if "tool_calls" in msg:
                    for tc_entry in msg["tool_calls"]:
                        if isinstance(tc_entry, dict) and tc_entry.get("type") == "function":
                            func = tc_entry.get("function", {})
                            tool_name = func.get("name")
                            tool_args = func.get("arguments", {})
                            tool_id = tc_entry.get("id")
                            if tool_name and tool_id not in tool_call_map:
                                tc = {
                                    "name": tool_name,
                                    "args": tool_args if isinstance(tool_args, dict) else {},
                                    "id": tool_id,
                                    "response": None,
                                }
                                tool_calls.append(tc)
                                if tool_id:
                                    tool_call_map[tool_id] = tc

            # --- Format 4: strands.telemetry.tracer format ---
            # Strands agents emit logs with body.output.messages[].content.message
            # containing a JSON-encoded string with toolUse items, and
            # body.output.messages[].content["tool.result"] with toolResult items.
            if "output" in body and isinstance(body.get("output"), dict):
                output = body["output"]
                output_messages = output.get("messages", [])
                for msg in output_messages:
                    if not isinstance(msg, dict):
                        continue
                    msg_content = msg.get("content", {})

                    # Handle content as a dict with "message" and "tool.result" keys
                    if isinstance(msg_content, dict):
                        message_str = msg_content.get("message", "")
                        tool_result_str = msg_content.get("tool.result", "")

                        # Parse tool calls from the "message" field
                        if message_str and isinstance(message_str, str):
                            try:
                                message_items = json.loads(message_str)
                                if isinstance(message_items, list):
                                    for item in message_items:
                                        if not isinstance(item, dict):
                                            continue
                                        if "toolUse" in item:
                                            tu = item["toolUse"]
                                            tool_name = tu.get("name")
                                            tool_input = tu.get("input", {})
                                            tool_id = tu.get("toolUseId")
                                            if tool_name and tool_id not in tool_call_map:
                                                tc = {
                                                    "name": tool_name,
                                                    "args": tool_input if isinstance(tool_input, dict) else {},
                                                    "id": tool_id,
                                                    "response": None,
                                                }
                                                tool_calls.append(tc)
                                                if tool_id:
                                                    tool_call_map[tool_id] = tc
                            except (json.JSONDecodeError, TypeError):
                                pass

                        # Parse tool results from the "tool.result" field
                        if tool_result_str and isinstance(tool_result_str, str):
                            try:
                                result_items = json.loads(tool_result_str)
                                if isinstance(result_items, list):
                                    for item in result_items:
                                        if not isinstance(item, dict):
                                            continue
                                        if "toolResult" in item:
                                            tr = item["toolResult"]
                                            tool_id = tr.get("toolUseId")
                                            if tool_id and tool_id in tool_call_map:
                                                # Extract text from content list
                                                tr_content = tr.get("content", [])
                                                texts = []
                                                for c in tr_content:
                                                    if isinstance(c, dict) and "text" in c:
                                                        texts.append(c["text"])
                                                tool_call_map[tool_id]["response"] = texts if len(texts) != 1 else texts[0]
                            except (json.JSONDecodeError, TypeError):
                                pass

                    # Handle content as a JSON string (tool results in a separate message)
                    elif isinstance(msg_content, str):
                        try:
                            content_items = json.loads(msg_content)
                            if isinstance(content_items, list):
                                for item in content_items:
                                    if not isinstance(item, dict):
                                        continue
                                    if "toolResult" in item:
                                        tr = item["toolResult"]
                                        tool_id = tr.get("toolUseId")
                                        if tool_id and tool_id in tool_call_map:
                                            tr_content = tr.get("content", [])
                                            texts = []
                                            for c in tr_content:
                                                if isinstance(c, dict) and "text" in c:
                                                    texts.append(c["text"])
                                            tool_call_map[tool_id]["response"] = texts if len(texts) != 1 else texts[0]
                        except (json.JSONDecodeError, TypeError):
                            pass

            # --- Extract tool responses (gen_ai.tool.message) ---
            if event_name == "gen_ai.tool.message" and "id" in body:
                tool_id = body.get("id")
                content = body.get("content")
                if tool_id and tool_id in tool_call_map:
                    tool_call_map[tool_id]["response"] = content

            # --- Extract token usage ---
            if "usage" in body and isinstance(body.get("usage"), dict):
                usage = body["usage"]
                input_tokens += usage.get("input_tokens", 0) or usage.get("inputTokens", 0)
                output_tokens += usage.get("output_tokens", 0) or usage.get("outputTokens", 0)

            if "gen_ai.usage.input_tokens" in attributes:
                input_tokens += int(attributes["gen_ai.usage.input_tokens"])
            if "gen_ai.usage.output_tokens" in attributes:
                output_tokens += int(attributes["gen_ai.usage.output_tokens"])

        return {
            "tool_calls": tool_calls,
            "token_usage": {
                "input_tokens": input_tokens,
                "output_tokens": output_tokens,
                "total_tokens": input_tokens + output_tokens,
            },
        }

name property

Return the adapter name.

extract_messages(raw_data)

Extract messages from AgentCore response and logs.

Source code in uaef/adapters/agentcore.py
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
    """Extract messages from AgentCore response and logs."""
    try:
        messages: List[Message] = []

        user_input = raw_data.get("user_input")
        if user_input:
            messages.append(
                Message(
                    role=MessageRole.USER,
                    content=user_input,
                    timestamp=datetime.now(timezone.utc),
                )
            )

        # Try to extract intermediate reasoning from trace_events
        for te in raw_data.get("trace_events", []):
            trace_detail = te.get("trace", {})
            orch = trace_detail.get("orchestrationTrace", {})
            rationale = orch.get("rationale", {})
            if rationale and rationale.get("text"):
                messages.append(
                    Message(
                        role=MessageRole.ASSISTANT,
                        content=rationale["text"],
                        timestamp=datetime.now(timezone.utc),
                    )
                )

        final_response = raw_data.get("final_response")
        if final_response:
            content = self._extract_text_from_response(final_response)
            if content:
                messages.append(
                    Message(
                        role=MessageRole.ASSISTANT,
                        content=content,
                        timestamp=datetime.now(timezone.utc),
                    )
                )

        return messages

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract messages: {str(e)}",
            adapter_name=self.name,
            original_error=e,
        )

extract_metadata(raw_data)

Extract metadata from AgentCore response.

Source code in uaef/adapters/agentcore.py
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
    """Extract metadata from AgentCore response."""
    try:
        metadata: Dict[str, Any] = {}

        if "latency" in raw_data:
            metadata["latency"] = raw_data["latency"]
        if "trace_id" in raw_data:
            metadata["trace_id"] = raw_data["trace_id"]
        if "agent_runtime_id" in raw_data:
            metadata["agent_runtime_id"] = raw_data["agent_runtime_id"]
        # Legacy keys for backward compatibility
        if "agent_id" in raw_data:
            metadata["agent_id"] = raw_data["agent_id"]
        if "agent_version" in raw_data:
            metadata["agent_version"] = raw_data["agent_version"]
        if "agent_arn" in raw_data:
            metadata["agent_arn"] = raw_data["agent_arn"]

        # Token usage from trace_events
        input_tokens = 0
        output_tokens = 0
        for te in raw_data.get("trace_events", []):
            trace_detail = te.get("trace", {})
            usage = trace_detail.get("orchestrationTrace", {}).get("modelInvocationOutput", {}).get("metadata", {}).get("usage", {})
            if usage:
                input_tokens += usage.get("inputTokens", 0)
                output_tokens += usage.get("outputTokens", 0)

        # Fallback: token usage from CloudWatch log entries
        if input_tokens == 0 and output_tokens == 0:
            for entry in raw_data.get("trace", []):
                if not isinstance(entry, str):
                    continue
                try:
                    entry_data = json.loads(entry)
                    if "usage" in entry_data:
                        usage = entry_data["usage"]
                        if isinstance(usage, dict):
                            input_tokens += usage.get("input_tokens", 0) or usage.get("inputTokens", 0)
                            output_tokens += usage.get("output_tokens", 0) or usage.get("outputTokens", 0)
                except json.JSONDecodeError:
                    continue

        if input_tokens > 0:
            metadata["input_tokens"] = input_tokens
        if output_tokens > 0:
            metadata["output_tokens"] = output_tokens

        return metadata

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract metadata: {str(e)}",
            adapter_name=self.name,
            original_error=e,
        )

extract_tool_calls(raw_data)

Extract tool calls from AgentCore trace events and CloudWatch logs.

Source code in uaef/adapters/agentcore.py
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
    """Extract tool calls from AgentCore trace events and CloudWatch logs."""
    try:
        tool_calls: List[ToolCall] = []

        # --- Source 1: trace_events from invoke_agent response stream ---
        for te in raw_data.get("trace_events", []):
            trace_detail = te.get("trace", {})
            orch = trace_detail.get("orchestrationTrace", {})

            invocation = orch.get("invocationInput", {})
            action_group = invocation.get("actionGroupInvocationInput", {})
            if action_group:
                tool_name = action_group.get("function", action_group.get("apiPath", "unknown"))
                params = {}
                for p in action_group.get("parameters", []):
                    params[p.get("name", "")] = p.get("value", "")
                tool_calls.append(
                    ToolCall(
                        name=tool_name,
                        arguments=params,
                        timestamp=datetime.now(timezone.utc),
                    )
                )

            observation = orch.get("observation", {})
            action_group_obs = observation.get("actionGroupInvocationOutput", {})
            if action_group_obs and tool_calls:
                tool_calls[-1].result = action_group_obs.get("text", "")

        # --- Source 2: CloudWatch log entries (legacy format) ---
        log_entries = raw_data.get("trace", [])
        if log_entries and not tool_calls:
            tool_call_map: Dict[str, ToolCall] = {}

            for entry in log_entries:
                if not isinstance(entry, str):
                    continue
                try:
                    entry_data = json.loads(entry)
                except json.JSONDecodeError:
                    continue

                body = entry_data.get("body")
                if not isinstance(body, dict):
                    continue

                if "content" in body:
                    content = body.get("content", "")
                    if isinstance(content, list):
                        for item in content:
                            if isinstance(item, dict) and item.get("type") == "tool_use":
                                tool_name = item.get("name")
                                tool_input = item.get("input", {})
                                tool_id = item.get("id")
                                if tool_name:
                                    tc = ToolCall(
                                        name=tool_name,
                                        arguments=tool_input if isinstance(tool_input, dict) else {},
                                        timestamp=datetime.now(timezone.utc),
                                    )
                                    if tool_id:
                                        tool_call_map[tool_id] = tc
                                    tool_calls.append(tc)

                if "id" in body:
                    tool_id = body.get("id")
                    content = body.get("content")
                    attributes = entry_data.get("attributes", {})
                    if isinstance(attributes, dict):
                        event_name = attributes.get("event.name")
                        if event_name == "gen_ai.tool.message" and tool_id in tool_call_map:
                            tool_call_map[tool_id].result = content

        return tool_calls

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract tool calls: {str(e)}",
            adapter_name=self.name,
            original_error=e,
        )

extract_tools_info(log_entries) classmethod

Extract tool calls, responses, and token metrics from otel-rt-logs.

Parses OpenTelemetry-formatted log entries (Strands/AgentCore format) to extract tool calls, their responses, and token usage.

Handles multiple formats found in otel-rt-logs: - gen_ai.assistant.message: contains toolUse items in body.content and/or body.tool_calls with function format - gen_ai.choice: contains body.message.tool_calls - gen_ai.tool.message: contains tool responses with body.id and body.content

Parameters:

Name Type Description Default
log_entries List[str]

List of JSON-encoded log message strings from CloudWatch.

required

Returns:

Type Description
Dict[str, Any]

Dict with tool_calls list and token_usage metrics.

Source code in uaef/adapters/agentcore.py
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
@classmethod
def extract_tools_info(cls, log_entries: List[str]) -> Dict[str, Any]:
    """Extract tool calls, responses, and token metrics from otel-rt-logs.

    Parses OpenTelemetry-formatted log entries (Strands/AgentCore format)
    to extract tool calls, their responses, and token usage.

    Handles multiple formats found in otel-rt-logs:
    - gen_ai.assistant.message: contains toolUse items in body.content
      and/or body.tool_calls with function format
    - gen_ai.choice: contains body.message.tool_calls
    - gen_ai.tool.message: contains tool responses with body.id and body.content

    Args:
        log_entries: List of JSON-encoded log message strings from CloudWatch.

    Returns:
        Dict with tool_calls list and token_usage metrics.
    """
    tool_calls: List[Dict[str, Any]] = []
    tool_call_map: Dict[str, Dict[str, Any]] = {}  # id -> tool_call dict
    input_tokens = 0
    output_tokens = 0

    for entry in log_entries:
        try:
            entry_data = json.loads(entry)
        except (json.JSONDecodeError, TypeError):
            continue

        body = entry_data.get("body", {})
        attributes = entry_data.get("attributes", {})
        if not isinstance(attributes, dict):
            attributes = {}
        event_name = attributes.get("event.name", "") or entry_data.get("eventName", "")

        # If body is a JSON string, try to parse it
        if isinstance(body, str):
            try:
                body = json.loads(body)
            except (json.JSONDecodeError, ValueError):
                continue

        if not isinstance(body, dict):
            continue

        # --- Format 1: body.content with toolUse items (Bedrock Converse format) ---
        if "content" in body:
            content = body.get("content", "")
            if isinstance(content, list):
                for item in content:
                    if not isinstance(item, dict):
                        continue
                    # toolUse format: {"toolUse": {"name": ..., "input": ..., "toolUseId": ...}}
                    if "toolUse" in item:
                        tu = item["toolUse"]
                        tool_name = tu.get("name")
                        tool_input = tu.get("input", {})
                        tool_id = tu.get("toolUseId")
                        if tool_name and tool_id not in tool_call_map:
                            tc = {
                                "name": tool_name,
                                "args": tool_input if isinstance(tool_input, dict) else {},
                                "id": tool_id,
                                "response": None,
                            }
                            tool_calls.append(tc)
                            if tool_id:
                                tool_call_map[tool_id] = tc

                    # type: tool_use format (legacy)
                    if item.get("type") == "tool_use":
                        tool_name = item.get("name")
                        tool_input = item.get("input", {})
                        tool_id = item.get("id")
                        if tool_name and tool_id not in tool_call_map:
                            tc = {
                                "name": tool_name,
                                "args": tool_input if isinstance(tool_input, dict) else {},
                                "id": tool_id,
                                "response": None,
                            }
                            tool_calls.append(tc)
                            if tool_id:
                                tool_call_map[tool_id] = tc

        # --- Format 2: body.tool_calls with function format ---
        if "tool_calls" in body:
            for tc_entry in body["tool_calls"]:
                if isinstance(tc_entry, dict) and tc_entry.get("type") == "function":
                    func = tc_entry.get("function", {})
                    tool_name = func.get("name")
                    tool_args = func.get("arguments", {})
                    tool_id = tc_entry.get("id")
                    if tool_name and tool_id not in tool_call_map:
                        tc = {
                            "name": tool_name,
                            "args": tool_args if isinstance(tool_args, dict) else {},
                            "id": tool_id,
                            "response": None,
                        }
                        tool_calls.append(tc)
                        if tool_id:
                            tool_call_map[tool_id] = tc

        # --- Format 3: body.message.tool_calls (gen_ai.choice) ---
        if "message" in body and isinstance(body["message"], dict):
            msg = body["message"]
            if "tool_calls" in msg:
                for tc_entry in msg["tool_calls"]:
                    if isinstance(tc_entry, dict) and tc_entry.get("type") == "function":
                        func = tc_entry.get("function", {})
                        tool_name = func.get("name")
                        tool_args = func.get("arguments", {})
                        tool_id = tc_entry.get("id")
                        if tool_name and tool_id not in tool_call_map:
                            tc = {
                                "name": tool_name,
                                "args": tool_args if isinstance(tool_args, dict) else {},
                                "id": tool_id,
                                "response": None,
                            }
                            tool_calls.append(tc)
                            if tool_id:
                                tool_call_map[tool_id] = tc

        # --- Format 4: strands.telemetry.tracer format ---
        # Strands agents emit logs with body.output.messages[].content.message
        # containing a JSON-encoded string with toolUse items, and
        # body.output.messages[].content["tool.result"] with toolResult items.
        if "output" in body and isinstance(body.get("output"), dict):
            output = body["output"]
            output_messages = output.get("messages", [])
            for msg in output_messages:
                if not isinstance(msg, dict):
                    continue
                msg_content = msg.get("content", {})

                # Handle content as a dict with "message" and "tool.result" keys
                if isinstance(msg_content, dict):
                    message_str = msg_content.get("message", "")
                    tool_result_str = msg_content.get("tool.result", "")

                    # Parse tool calls from the "message" field
                    if message_str and isinstance(message_str, str):
                        try:
                            message_items = json.loads(message_str)
                            if isinstance(message_items, list):
                                for item in message_items:
                                    if not isinstance(item, dict):
                                        continue
                                    if "toolUse" in item:
                                        tu = item["toolUse"]
                                        tool_name = tu.get("name")
                                        tool_input = tu.get("input", {})
                                        tool_id = tu.get("toolUseId")
                                        if tool_name and tool_id not in tool_call_map:
                                            tc = {
                                                "name": tool_name,
                                                "args": tool_input if isinstance(tool_input, dict) else {},
                                                "id": tool_id,
                                                "response": None,
                                            }
                                            tool_calls.append(tc)
                                            if tool_id:
                                                tool_call_map[tool_id] = tc
                        except (json.JSONDecodeError, TypeError):
                            pass

                    # Parse tool results from the "tool.result" field
                    if tool_result_str and isinstance(tool_result_str, str):
                        try:
                            result_items = json.loads(tool_result_str)
                            if isinstance(result_items, list):
                                for item in result_items:
                                    if not isinstance(item, dict):
                                        continue
                                    if "toolResult" in item:
                                        tr = item["toolResult"]
                                        tool_id = tr.get("toolUseId")
                                        if tool_id and tool_id in tool_call_map:
                                            # Extract text from content list
                                            tr_content = tr.get("content", [])
                                            texts = []
                                            for c in tr_content:
                                                if isinstance(c, dict) and "text" in c:
                                                    texts.append(c["text"])
                                            tool_call_map[tool_id]["response"] = texts if len(texts) != 1 else texts[0]
                        except (json.JSONDecodeError, TypeError):
                            pass

                # Handle content as a JSON string (tool results in a separate message)
                elif isinstance(msg_content, str):
                    try:
                        content_items = json.loads(msg_content)
                        if isinstance(content_items, list):
                            for item in content_items:
                                if not isinstance(item, dict):
                                    continue
                                if "toolResult" in item:
                                    tr = item["toolResult"]
                                    tool_id = tr.get("toolUseId")
                                    if tool_id and tool_id in tool_call_map:
                                        tr_content = tr.get("content", [])
                                        texts = []
                                        for c in tr_content:
                                            if isinstance(c, dict) and "text" in c:
                                                texts.append(c["text"])
                                        tool_call_map[tool_id]["response"] = texts if len(texts) != 1 else texts[0]
                    except (json.JSONDecodeError, TypeError):
                        pass

        # --- Extract tool responses (gen_ai.tool.message) ---
        if event_name == "gen_ai.tool.message" and "id" in body:
            tool_id = body.get("id")
            content = body.get("content")
            if tool_id and tool_id in tool_call_map:
                tool_call_map[tool_id]["response"] = content

        # --- Extract token usage ---
        if "usage" in body and isinstance(body.get("usage"), dict):
            usage = body["usage"]
            input_tokens += usage.get("input_tokens", 0) or usage.get("inputTokens", 0)
            output_tokens += usage.get("output_tokens", 0) or usage.get("outputTokens", 0)

        if "gen_ai.usage.input_tokens" in attributes:
            input_tokens += int(attributes["gen_ai.usage.input_tokens"])
        if "gen_ai.usage.output_tokens" in attributes:
            output_tokens += int(attributes["gen_ai.usage.output_tokens"])

    return {
        "tool_calls": tool_calls,
        "token_usage": {
            "input_tokens": input_tokens,
            "output_tokens": output_tokens,
            "total_tokens": input_tokens + output_tokens,
        },
    }

generate_xray_trace_id() classmethod

Generate a synthetic X-Ray trace ID.

Source code in uaef/adapters/agentcore.py
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
@classmethod
def generate_xray_trace_id(cls) -> str:
    """Generate a synthetic X-Ray trace ID."""
    # Generate timestamp part (8 hex digits)
    timestamp = hex(int(time.time()))[2:].zfill(8)

    # Generate random part (24 hex digits)
    random_hex = uuid.uuid4().hex[:24]

    # Generate parent ID (16 hex digits)
    parent_id = uuid.uuid4().hex[:16]

    # Construct the trace ID (always sampled)
    trace_id = f"Root=1-{timestamp}-{random_hex};Parent={parent_id};Sampled=1"

    return trace_id

get_logs_by_session_id(session_id, log_group_names, region_name='us-east-1', log_stream_name=None, invocation_time=None) classmethod

Run a CloudWatch Logs Insights query to find all logs for a given session ID.

Uses the session ID (runtimeSessionId) which is passed to the agent during invocation and appears in the otel logs, making it a reliable correlation key.

Parameters:

Name Type Description Default
session_id str

The runtime session ID used in the invocation.

required
log_group_names List[str]

A list of CloudWatch log group names to search.

required
region_name str

The AWS region where the log groups are located.

'us-east-1'
log_stream_name Optional[str]

Optional log stream name to scope the query to (e.g. "otel-rt-logs"). If None, searches all streams.

None
invocation_time Optional[float]

Unix timestamp of when the invocation started. If provided, searches from 1 minute before this time. If None, searches the last hour.

None

Returns:

Type Description
List[str]

List of log message strings matching the session ID.

Source code in uaef/adapters/agentcore.py
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
@classmethod
def get_logs_by_session_id(
    cls,
    session_id: str,
    log_group_names: List[str],
    region_name: str = 'us-east-1',
    log_stream_name: Optional[str] = None,
    invocation_time: Optional[float] = None,
) -> List[str]:
    """
    Run a CloudWatch Logs Insights query to find all logs for a given session ID.

    Uses the session ID (runtimeSessionId) which is passed to the agent
    during invocation and appears in the otel logs, making it a reliable
    correlation key.

    Args:
        session_id: The runtime session ID used in the invocation.
        log_group_names: A list of CloudWatch log group names to search.
        region_name: The AWS region where the log groups are located.
        log_stream_name: Optional log stream name to scope the query to
            (e.g. "otel-rt-logs"). If None, searches all streams.
        invocation_time: Unix timestamp of when the invocation started.
            If provided, searches from 1 minute before this time.
            If None, searches the last hour.

    Returns:
        List of log message strings matching the session ID.
    """
    try:
        import boto3
    except ImportError:
        raise ImportError("boto3 is required for log retrieval — pip install boto3")

    client = boto3.client('logs', region_name=region_name)

    # Build query with optional log stream filter
    filters = [f'filter @message like /"{session_id}"/']
    if log_stream_name:
        filters.insert(0, f'filter @logStream = "{log_stream_name}"')

    query_string = f'fields @timestamp, @message | {" | ".join(filters)} | sort @timestamp asc'
    out: List[str] = []

    # Time window: from 1 min before invocation (or last hour) to now
    if invocation_time:
        start_ms = int((invocation_time - 60) * 1000)
    else:
        start_ms = int((time.time() - 3600) * 1000)
    end_ms = int(time.time() * 1000)

    try:
        response = client.start_query(
            logGroupNames=log_group_names,
            startTime=start_ms,
            endTime=end_ms,
            queryString=query_string,
            limit=10000,
        )
        query_id = response['queryId']

        # Wait for the query to complete
        status = None
        while status not in ('Complete', 'Failed', 'Cancelled'):
            time.sleep(5)
            query_status_response = client.get_query_results(queryId=query_id)
            status = query_status_response['status']

        # Retrieve the results
        if status == 'Complete':
            results = query_status_response['results']
            if not results:
                print(f"No logs found for session ID: {session_id}")
            else:
                for log_event in results:
                    message = next((field['value'] for field in log_event if field['field'] == '@message'), None)
                    if message:
                        out.append(message)
        else:
            print(f"Query failed or was cancelled. Status: {status}")

    except Exception as e:
        print(f"An error occurred: {e}")
    return out

get_logs_by_time_window(log_group_names, invocation_time, region_name='us-east-1', log_stream_name=None) classmethod

Fetch all otel logs from a narrow time window around an invocation.

Since the otel traceId is generated server-side and can't be predicted client-side, this method fetches all logs from the invocation time window and returns them. The caller can then filter/group by traceId if needed.

Parameters:

Name Type Description Default
log_group_names List[str]

CloudWatch log group names to search.

required
invocation_time float

Unix timestamp of when the invocation started.

required
region_name str

AWS region.

'us-east-1'
log_stream_name Optional[str]

Optional log stream name filter (e.g. "otel-rt-logs").

None

Returns:

Type Description
List[str]

List of log message strings from the time window.

Source code in uaef/adapters/agentcore.py
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
@classmethod
def get_logs_by_time_window(
    cls,
    log_group_names: List[str],
    invocation_time: float,
    region_name: str = 'us-east-1',
    log_stream_name: Optional[str] = None,
) -> List[str]:
    """
    Fetch all otel logs from a narrow time window around an invocation.

    Since the otel traceId is generated server-side and can't be predicted
    client-side, this method fetches all logs from the invocation time
    window and returns them. The caller can then filter/group by traceId
    if needed.

    Args:
        log_group_names: CloudWatch log group names to search.
        invocation_time: Unix timestamp of when the invocation started.
        region_name: AWS region.
        log_stream_name: Optional log stream name filter (e.g. "otel-rt-logs").

    Returns:
        List of log message strings from the time window.
    """
    try:
        import boto3
    except ImportError:
        raise ImportError("boto3 is required for log retrieval — pip install boto3")

    client = boto3.client('logs', region_name=region_name)

    # Build query with optional log stream filter
    filters = []
    if log_stream_name:
        filters.append(f'filter @logStream = "{log_stream_name}"')

    filter_clause = f' | {" | ".join(filters)}' if filters else ''
    query_string = f'fields @timestamp, @message{filter_clause} | sort @timestamp asc'

    # Narrow time window: from invocation start to now
    start_ms = int(invocation_time * 1000)
    end_ms = int(time.time() * 1000)

    out: List[str] = []
    try:
        response = client.start_query(
            logGroupNames=log_group_names,
            startTime=start_ms,
            endTime=end_ms,
            queryString=query_string,
            limit=10000,
        )
        query_id = response['queryId']

        status = None
        while status not in ('Complete', 'Failed', 'Cancelled'):
            time.sleep(5)
            query_status_response = client.get_query_results(queryId=query_id)
            status = query_status_response['status']

        if status == 'Complete':
            results = query_status_response['results']
            if not results:
                print(f"No logs found in time window starting at {invocation_time}")
            else:
                for log_event in results:
                    message = next((field['value'] for field in log_event if field['field'] == '@message'), None)
                    if message:
                        out.append(message)
        else:
            print(f"Query failed or was cancelled. Status: {status}")

    except Exception as e:
        print(f"An error occurred: {e}")
    return out

get_logs_by_trace_id(trace_id, log_group_names, region_name='us-east-1', log_stream_name=None) classmethod

Run a CloudWatch Logs Insights query to find all logs for a given trace ID.

Parameters:

Name Type Description Default
trace_id str

The X-Ray trace ID to search for.

required
log_group_names List[str]

A list of CloudWatch log group names to search.

required
region_name str

The AWS region where the log groups are located.

'us-east-1'
log_stream_name Optional[str]

Optional log stream name to scope the query to (e.g. "otel-rt-logs"). If None, searches all streams.

None

Returns:

Type Description
List[str]

List of log message strings matching the trace ID.

Source code in uaef/adapters/agentcore.py
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
@classmethod
def get_logs_by_trace_id(cls, trace_id: str, log_group_names: List[str], region_name: str = 'us-east-1', log_stream_name: Optional[str] = None) -> List[str]:
    """
    Run a CloudWatch Logs Insights query to find all logs for a given trace ID.

    Args:
        trace_id: The X-Ray trace ID to search for.
        log_group_names: A list of CloudWatch log group names to search.
        region_name: The AWS region where the log groups are located.
        log_stream_name: Optional log stream name to scope the query to
            (e.g. "otel-rt-logs"). If None, searches all streams.

    Returns:
        List of log message strings matching the trace ID.
    """
    try:
        import boto3
    except ImportError:
        raise ImportError("boto3 is required for log retrieval — pip install boto3")

    client = boto3.client('logs', region_name=region_name)

    # Build query with optional log stream filter
    filters = [f'filter @message like /"{trace_id}"/']
    if log_stream_name:
        filters.insert(0, f'filter @logStream = "{log_stream_name}"')

    query_string = f'fields @timestamp, @message | {" | ".join(filters)} | sort @timestamp asc'
    out: List[str] = []
    try:
        # Start the query
        response = client.start_query(
            logGroupNames=log_group_names,
            startTime=int((time.time() - 3600) * 1000),  # Search logs from the last hour
            endTime=int(time.time() * 1000),
            queryString=query_string,
            limit=10000,
        )
        query_id = response['queryId']

        # Wait for the query to complete
        status = None
        while status not in ('Complete', 'Failed', 'Cancelled'):
            time.sleep(5)
            query_status_response = client.get_query_results(queryId=query_id)
            status = query_status_response['status']

        # Retrieve the results
        if status == 'Complete':
            results = query_status_response['results']
            if not results:
                print(f"No logs found for trace ID: {trace_id} - waiting 5 more seconds ...")
            else:
                for log_event in results:
                    message = next((field['value'] for field in log_event if field['field'] == '@message'), None)
                    if message:
                        out.append(message)
        else:
            print(f"Query failed or was cancelled. Status: {status}")

    except Exception as e:
        print(f"An error occurred: {e}")
    return out

invoke(agent_runtime_arn, user_input, agent_version=None, *, region=None, session_id=None, qualifier=None, bearer_token=None, auto_detect_auth=True, payload_override=None, boto_session=None, log_stream_name=None, fetch_logs=True) staticmethod

Invoke an AgentCore-deployed agent and collect its streaming response.

Supports both authentication modes:

  • SigV4 (default) — uses the bedrock-agentcore boto3 client. Works when the agent runtime is configured for IAM auth.
  • OAuth / JWT bearer token — when bearer_token is provided the adapter makes a raw HTTPS POST to the AgentCore data-plane endpoint instead, because the AWS SDK always signs with SigV4 which is rejected by OAuth-configured runtimes.

When auto_detect_auth is True (default) and no bearer_token is provided, the adapter will first try SigV4. If that fails with an authorization mismatch, it queries the runtime's authorizer config and attempts to auto-fetch a Cognito token via client_credentials grant before retrying with OAuth.

Parameters:

Name Type Description Default
agent_runtime_arn str

The AgentCore Runtime ARN.

required
user_input str

The prompt to send.

required
region Optional[str]

AWS region (reads from UAEF config if not provided).

None
session_id Optional[str]

Optional runtime session ID (generated if omitted).

None
qualifier Optional[str]

Optional qualifier / endpoint name.

None
bearer_token Optional[str]

If the agent runtime uses OAuth/JWT inbound auth, pass the access token here. When set, the invocation is made via HTTPS with an Authorization: Bearer header instead of the boto3 SDK.

None
auto_detect_auth bool

If True and SigV4 fails with auth mismatch, automatically detect OAuth config and retry. Default True.

True
payload_override Optional[Dict[str, Any]]

Optional dict to use as the JSON payload instead of the default {"prompt": ..., "runtimeSessionId": ...}. Use this when your agent container expects a custom payload format.

None
boto_session

Optional boto3.Session (SigV4 mode only).

None

Returns:

Type Description
Dict[str, Any]

A dict suitable for transform_to_canonical().

Source code in uaef/adapters/agentcore.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
@staticmethod
def invoke(
    agent_runtime_arn: str,
    user_input: str,
    agent_version: str = None,
    *,
    region: Optional[str] = None,
    session_id: Optional[str] = None,
    qualifier: Optional[str] = None,
    bearer_token: Optional[str] = None,
    auto_detect_auth: bool = True,
    payload_override: Optional[Dict[str, Any]] = None,
    boto_session=None,
    log_stream_name: Optional[str] = None,
    fetch_logs: bool = True,
) -> Dict[str, Any]:
    """Invoke an AgentCore-deployed agent and collect its streaming response.

    Supports both authentication modes:

    * **SigV4 (default)** — uses the ``bedrock-agentcore`` boto3 client.
      Works when the agent runtime is configured for IAM auth.
    * **OAuth / JWT bearer token** — when *bearer_token* is provided the
      adapter makes a raw HTTPS ``POST`` to the AgentCore data-plane
      endpoint instead, because the AWS SDK always signs with SigV4 which
      is rejected by OAuth-configured runtimes.

    When *auto_detect_auth* is True (default) and no *bearer_token* is
    provided, the adapter will first try SigV4.  If that fails with an
    authorization mismatch, it queries the runtime's authorizer config
    and attempts to auto-fetch a Cognito token via client_credentials
    grant before retrying with OAuth.

    Args:
        agent_runtime_arn: The AgentCore Runtime ARN.
        user_input: The prompt to send.
        region: AWS region (reads from UAEF config if not provided).
        session_id: Optional runtime session ID (generated if omitted).
        qualifier: Optional qualifier / endpoint name.
        bearer_token: If the agent runtime uses OAuth/JWT inbound auth,
            pass the access token here.  When set, the invocation is made
            via HTTPS with an ``Authorization: Bearer`` header instead of
            the boto3 SDK.
        auto_detect_auth: If True and SigV4 fails with auth mismatch,
            automatically detect OAuth config and retry. Default True.
        payload_override: Optional dict to use as the JSON payload instead
            of the default ``{"prompt": ..., "runtimeSessionId": ...}``.
            Use this when your agent container expects a custom payload
            format.
        boto_session: Optional ``boto3.Session`` (SigV4 mode only).

    Returns:
        A dict suitable for ``transform_to_canonical()``.
    """
    from uaef.config import get_config

    if region is None:
        region = get_config().aws.region

    session_id = session_id or str(uuid4())

    # Build payload — use override if provided, otherwise default format
    if payload_override is not None:
        payload_dict = payload_override
    else:
        payload_dict = {
            "prompt": user_input,
            "runtimeSessionId": session_id,
        }

    if bearer_token:
        return AgentCoreAdapter._invoke_oauth(
            agent_runtime_arn=agent_runtime_arn,
            user_input=user_input,
            bearer_token=bearer_token,
            payload_dict=payload_dict,
            region=region,
            session_id=session_id,
            qualifier=qualifier,
            agent_version=agent_version,
            log_stream_name=log_stream_name,
        )

    # Try SigV4 first
    try:
        return AgentCoreAdapter._invoke_sigv4(
            agent_runtime_arn=agent_runtime_arn,
            user_input=user_input,
            agent_version=agent_version,
            payload_dict=payload_dict,
            region=region,
            session_id=session_id,
            qualifier=qualifier,
            boto_session=boto_session,
            log_stream_name=log_stream_name,
            fetch_logs=fetch_logs,
        )
    except Exception as e:
        if not auto_detect_auth:
            raise
        error_msg = str(e)
        if "Authorization method mismatch" not in error_msg and "explicit deny" not in error_msg:
            raise

    # SigV4 failed with auth mismatch — attempt OAuth auto-detection
    token = AgentCoreAdapter._auto_detect_oauth_token(
        agent_runtime_arn=agent_runtime_arn,
        region=region,
        boto_session=boto_session,
    )
    if not token:
        raise RuntimeError(
            f"SigV4 auth failed and OAuth auto-detection could not fetch a token. "
            f"Provide a bearer_token manually."
        )
    return AgentCoreAdapter._invoke_oauth(
        agent_runtime_arn=agent_runtime_arn,
        user_input=user_input,
        bearer_token=token,
        payload_dict=payload_dict,
        region=region,
        session_id=session_id,
        qualifier=qualifier,
        agent_version=agent_version,
        log_stream_name=log_stream_name,
        fetch_logs=fetch_logs,
    )

transform_to_canonical(raw_data)

Transform AgentCore response data to canonical format.

Auto-detects multi-agent traces when raw_data contains an "agents" key mapping agent names to their individual response dicts. Otherwise returns a single AgentTrace.

This mirrors the LangGraph adapter's auto-detect pattern: the caller does not need to specify agent node names — the adapter infers the multi-agent structure from the data shape.

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

AgentCore response data. For multi-agent, include::

{ "user_input": "...", "agents": { "agent_name": { }, ... }, "agent_order": ["agent1", "agent2", ...], # optional "session_id": "...", }

required

Returns:

Type Description
Union[AgentTrace, MultiAgentTrace]

AgentTrace for single-agent, MultiAgentTrace for multi-agent.

Source code in uaef/adapters/agentcore.py
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
def transform_to_canonical(self, raw_data: Dict[str, Any]) -> Union[AgentTrace, MultiAgentTrace]:
    """Transform AgentCore response data to canonical format.

    Auto-detects multi-agent traces when ``raw_data`` contains an
    ``"agents"`` key mapping agent names to their individual response
    dicts. Otherwise returns a single ``AgentTrace``.

    This mirrors the LangGraph adapter's auto-detect pattern: the caller
    does not need to specify agent node names — the adapter infers the
    multi-agent structure from the data shape.

    Args:
        raw_data: AgentCore response data. For multi-agent, include::

            {
                "user_input": "...",
                "agents": {
                    "agent_name": { <single-agent raw_data> },
                    ...
                },
                "agent_order": ["agent1", "agent2", ...],  # optional
                "session_id": "...",
            }

    Returns:
        AgentTrace for single-agent, MultiAgentTrace for multi-agent.
    """
    try:
        # Auto-detect: if "agents" key is present with multiple entries,
        agents_data = raw_data.get("agents")
        if isinstance(agents_data, dict) and len(agents_data) > 0:
            return self._transform_multi_agent(raw_data)

        # Auto-detect from otel logs: if trace contains multiple agent
        trace_logs = raw_data.get("trace", [])
        if trace_logs:
            detected_agents = self._detect_agents_from_otel_logs(trace_logs)
            if len(detected_agents) > 1:
                # Restructure raw_data into multi-agent format
                multi_raw = self._build_multi_agent_raw_data(raw_data, detected_agents)
                return self._transform_multi_agent(multi_raw)

        return self._transform_single_agent(raw_data)

    except AdapterTransformationError:
        raise
    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to transform AgentCore trace: {str(e)}",
            adapter_name=self.name,
            original_error=e,
        )

uaef.adapters.generic

Generic JSON adapter for transforming arbitrary JSON traces to canonical format.

GenericJSONAdapter

Bases: BaseAdapter

Adapter for transforming arbitrary JSON traces to canonical AgentTrace format.

This adapter uses schema mapping configuration to extract fields from JSON data with flexible structure. It supports JSONPath expressions for nested field extraction.

Schema mapping format: { "messages": { "path": "$.conversation.messages[]", "role_field": "sender", "content_field": "text", "timestamp_field": "created_at" }, "tool_calls": { "path": "$.execution.tools[]", "name_field": "tool_name", "arguments_field": "params", "result_field": "output", "timestamp_field": "executed_at" }, "metadata": { "input_tokens": "$.usage.input", "output_tokens": "$.usage.output", "latency": "$.performance.duration", "model": "$.config.model_name" }, "trace_id": "$.trace.id", "session_id": "$.session.id" }

Source code in uaef/adapters/generic.py
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
class GenericJSONAdapter(BaseAdapter):
    """
    Adapter for transforming arbitrary JSON traces to canonical AgentTrace format.

    This adapter uses schema mapping configuration to extract fields from
    JSON data with flexible structure. It supports JSONPath expressions for
    nested field extraction.

    Schema mapping format:
    {
        "messages": {
            "path": "$.conversation.messages[*]",
            "role_field": "sender",
            "content_field": "text",
            "timestamp_field": "created_at"
        },
        "tool_calls": {
            "path": "$.execution.tools[*]",
            "name_field": "tool_name",
            "arguments_field": "params",
            "result_field": "output",
            "timestamp_field": "executed_at"
        },
        "metadata": {
            "input_tokens": "$.usage.input",
            "output_tokens": "$.usage.output",
            "latency": "$.performance.duration",
            "model": "$.config.model_name"
        },
        "trace_id": "$.trace.id",
        "session_id": "$.session.id"
    }
    """

    DEFAULT_SCHEMA_MAPPING: Dict[str, Any] = {
        "messages": {
            "path": "$.messages[*]",
            "role_field": "role",
            "content_field": "content",
            "timestamp_field": "timestamp",
        },
        "tool_calls": {
            "path": "$.tool_calls[*]",
            "name_field": "name",
            "arguments_field": "arguments",
            "result_field": "result",
        },
        "metadata": {
            "input_tokens": "$.usage.input_tokens",
            "output_tokens": "$.usage.output_tokens",
            "latency": "$.latency",
            "model": "$.model",
        },
        "trace_id": "$.trace_id",
        "session_id": "$.session_id",
    }

    def __init__(self, schema_mapping: Optional[Dict[str, Any]] = None):
        """
        Initialize the adapter with schema mapping configuration.

        Args:
            schema_mapping: Dictionary defining how to extract fields from JSON.
                If None, a default mapping is used that expects common field names
                (messages, tool_calls, usage, etc.).
        """
        self.schema_mapping = schema_mapping or self.DEFAULT_SCHEMA_MAPPING

    @property
    def name(self) -> str:
        """Return the adapter name."""
        return "generic_json"

    def transform_to_canonical(self, raw_data: Dict[str, Any]) -> AgentTrace:
        """
        Transform generic JSON data to canonical AgentTrace format.

        Args:
            raw_data: Generic JSON trace data

        Returns:
            Canonical AgentTrace object

        Raises:
            AdapterTransformationError: If transformation fails
        """
        try:
            # Extract components using schema mapping
            messages = self.extract_messages(raw_data)
            tool_calls = self.extract_tool_calls(raw_data)
            metadata = self.extract_metadata(raw_data)

            # Extract trace_id and session_id
            trace_id = self._extract_field(raw_data, self.schema_mapping.get("trace_id"))
            if trace_id and isinstance(trace_id, str):
                try:
                    from uuid import UUID
                    trace_id = UUID(trace_id)
                except (ValueError, AttributeError):
                    trace_id = uuid4()
            else:
                trace_id = uuid4()

            session_id = self._extract_field(raw_data, self.schema_mapping.get("session_id"))
            if session_id:
                session_id = str(session_id)

            # Create AgentTrace
            trace = AgentTrace(
                trace_id=trace_id,
                session_id=session_id,
                messages=messages,
                tool_calls=tool_calls,
                input_tokens=metadata.get("input_tokens"),
                output_tokens=metadata.get("output_tokens"),
                latency=metadata.get("latency"),
                cost=metadata.get("cost"),
                metadata=metadata,
                framework=metadata.get("framework", "generic"),
                timestamp=datetime.utcnow()
            )

            return trace

        except AdapterTransformationError:
            raise
        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to transform generic JSON trace: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
        """
        Extract messages from generic JSON using schema mapping.

        Args:
            raw_data: Generic JSON trace data

        Returns:
            List of Message objects

        Raises:
            AdapterTransformationError: If message extraction fails
        """
        try:
            messages = []
            messages_config = self.schema_mapping.get("messages", {})

            if not messages_config:
                return messages

            # Extract message objects using JSONPath
            messages_path = messages_config.get("path")
            if not messages_path:
                return messages

            message_objects = self._extract_field(raw_data, messages_path, multiple=True)
            if not message_objects:
                return messages

            # Extract fields from each message object
            role_field = messages_config.get("role_field", "role")
            content_field = messages_config.get("content_field", "content")
            timestamp_field = messages_config.get("timestamp_field")

            for msg_obj in message_objects:
                if not isinstance(msg_obj, dict):
                    continue

                # Extract role
                role_str = msg_obj.get(role_field, "assistant")
                role = self._parse_role(role_str)

                # Extract content
                content = msg_obj.get(content_field, "")

                # Extract timestamp
                timestamp = datetime.utcnow()
                if timestamp_field and timestamp_field in msg_obj:
                    timestamp = self._parse_timestamp(msg_obj[timestamp_field])

                # Create message
                message = Message(
                    role=role,
                    content=str(content),
                    timestamp=timestamp
                )
                messages.append(message)

            return messages

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract messages: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
        """
        Extract tool calls from generic JSON using schema mapping.

        Args:
            raw_data: Generic JSON trace data

        Returns:
            List of ToolCall objects

        Raises:
            AdapterTransformationError: If tool call extraction fails
        """
        try:
            tool_calls = []
            tool_calls_config = self.schema_mapping.get("tool_calls", {})

            if not tool_calls_config:
                return tool_calls

            # Extract tool call objects using JSONPath
            tool_calls_path = tool_calls_config.get("path")
            if not tool_calls_path:
                return tool_calls

            tool_call_objects = self._extract_field(raw_data, tool_calls_path, multiple=True)
            if not tool_call_objects:
                return tool_calls

            # Extract fields from each tool call object
            name_field = tool_calls_config.get("name_field", "name")
            arguments_field = tool_calls_config.get("arguments_field", "arguments")
            result_field = tool_calls_config.get("result_field", "result")
            timestamp_field = tool_calls_config.get("timestamp_field")
            execution_time_field = tool_calls_config.get("execution_time_field")
            error_field = tool_calls_config.get("error_field")

            for tc_obj in tool_call_objects:
                if not isinstance(tc_obj, dict):
                    continue

                # Extract tool name
                name = tc_obj.get(name_field, "")
                if not name:
                    continue

                # Extract arguments
                arguments = tc_obj.get(arguments_field, {})
                if not isinstance(arguments, dict):
                    arguments = {}

                # Extract result
                result = tc_obj.get(result_field) if result_field else None

                # Extract timestamp
                timestamp = datetime.utcnow()
                if timestamp_field and timestamp_field in tc_obj:
                    timestamp = self._parse_timestamp(tc_obj[timestamp_field])

                # Extract execution time
                execution_time = None
                if execution_time_field and execution_time_field in tc_obj:
                    try:
                        execution_time = float(tc_obj[execution_time_field])
                    except (ValueError, TypeError):
                        pass

                # Extract error
                error = tc_obj.get(error_field) if error_field else None
                if error:
                    error = str(error)

                # Create ToolCall object
                tool_call = ToolCall(
                    name=name,
                    arguments=arguments,
                    result=result,
                    timestamp=timestamp,
                    execution_time=execution_time,
                    error=error
                )
                tool_calls.append(tool_call)

            return tool_calls

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract tool calls: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
        """
        Extract metadata from generic JSON using schema mapping.

        Args:
            raw_data: Generic JSON trace data

        Returns:
            Dictionary of metadata

        Raises:
            AdapterTransformationError: If metadata extraction fails
        """
        try:
            metadata = {}
            metadata_config = self.schema_mapping.get("metadata", {})

            if not metadata_config:
                return metadata

            # Extract each metadata field
            for key, path in metadata_config.items():
                if not path:
                    continue

                value = self._extract_field(raw_data, path)
                if value is not None:
                    # Convert numeric fields
                    if key in ["input_tokens", "output_tokens"]:
                        try:
                            metadata[key] = int(value)
                        except (ValueError, TypeError):
                            pass
                    elif key in ["latency", "cost"]:
                        try:
                            metadata[key] = float(value)
                        except (ValueError, TypeError):
                            pass
                    else:
                        metadata[key] = value

            return metadata

        except Exception as e:
            raise AdapterTransformationError(
                f"Failed to extract metadata: {str(e)}",
                adapter_name=self.name,
                original_error=e
            )

    def _extract_field(
        self,
        data: Dict[str, Any],
        path: Optional[str],
        multiple: bool = False
    ) -> Any:
        """
        Extract a field from JSON data using JSONPath.

        Args:
            data: JSON data
            path: JSONPath expression
            multiple: If True, return all matches; if False, return first match

        Returns:
            Extracted value(s) or None if not found
        """
        if not path:
            return None

        try:
            # Parse JSONPath expression
            jsonpath_expr = parse(path)
            matches = jsonpath_expr.find(data)

            if not matches:
                return None

            if multiple:
                return [match.value for match in matches]
            else:
                return matches[0].value

        except Exception:
            # If JSONPath fails, try simple dict access
            if "." not in path and "$" not in path:
                return data.get(path)
            return None

    def _parse_role(self, role_str: Any) -> MessageRole:
        """
        Parse role string to MessageRole enum.

        Args:
            role_str: Role string or value

        Returns:
            MessageRole enum value
        """
        if isinstance(role_str, MessageRole):
            return role_str

        role_str = str(role_str).lower()

        if role_str in ["user", "human"]:
            return MessageRole.USER
        elif role_str in ["assistant", "ai", "agent", "bot"]:
            return MessageRole.ASSISTANT
        elif role_str in ["system"]:
            return MessageRole.SYSTEM
        elif role_str in ["tool", "function"]:
            return MessageRole.TOOL
        else:
            return MessageRole.ASSISTANT

    def _parse_timestamp(self, timestamp_value: Any) -> datetime:
        """
        Parse timestamp value to datetime object.

        Args:
            timestamp_value: Timestamp string or value

        Returns:
            datetime object
        """
        if isinstance(timestamp_value, datetime):
            return timestamp_value

        if isinstance(timestamp_value, (int, float)):
            # Assume Unix timestamp
            return datetime.fromtimestamp(timestamp_value)

        if isinstance(timestamp_value, str):
            # Try ISO format
            try:
                return datetime.fromisoformat(timestamp_value.replace("Z", "+00:00"))
            except (ValueError, AttributeError):
                pass

        # Default to current time
        return datetime.utcnow()

name property

Return the adapter name.

extract_messages(raw_data)

Extract messages from generic JSON using schema mapping.

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Generic JSON trace data

required

Returns:

Type Description
List[Message]

List of Message objects

Raises:

Type Description
AdapterTransformationError

If message extraction fails

Source code in uaef/adapters/generic.py
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def extract_messages(self, raw_data: Dict[str, Any]) -> List[Message]:
    """
    Extract messages from generic JSON using schema mapping.

    Args:
        raw_data: Generic JSON trace data

    Returns:
        List of Message objects

    Raises:
        AdapterTransformationError: If message extraction fails
    """
    try:
        messages = []
        messages_config = self.schema_mapping.get("messages", {})

        if not messages_config:
            return messages

        # Extract message objects using JSONPath
        messages_path = messages_config.get("path")
        if not messages_path:
            return messages

        message_objects = self._extract_field(raw_data, messages_path, multiple=True)
        if not message_objects:
            return messages

        # Extract fields from each message object
        role_field = messages_config.get("role_field", "role")
        content_field = messages_config.get("content_field", "content")
        timestamp_field = messages_config.get("timestamp_field")

        for msg_obj in message_objects:
            if not isinstance(msg_obj, dict):
                continue

            # Extract role
            role_str = msg_obj.get(role_field, "assistant")
            role = self._parse_role(role_str)

            # Extract content
            content = msg_obj.get(content_field, "")

            # Extract timestamp
            timestamp = datetime.utcnow()
            if timestamp_field and timestamp_field in msg_obj:
                timestamp = self._parse_timestamp(msg_obj[timestamp_field])

            # Create message
            message = Message(
                role=role,
                content=str(content),
                timestamp=timestamp
            )
            messages.append(message)

        return messages

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract messages: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

extract_metadata(raw_data)

Extract metadata from generic JSON using schema mapping.

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Generic JSON trace data

required

Returns:

Type Description
Dict[str, Any]

Dictionary of metadata

Raises:

Type Description
AdapterTransformationError

If metadata extraction fails

Source code in uaef/adapters/generic.py
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
def extract_metadata(self, raw_data: Dict[str, Any]) -> Dict[str, Any]:
    """
    Extract metadata from generic JSON using schema mapping.

    Args:
        raw_data: Generic JSON trace data

    Returns:
        Dictionary of metadata

    Raises:
        AdapterTransformationError: If metadata extraction fails
    """
    try:
        metadata = {}
        metadata_config = self.schema_mapping.get("metadata", {})

        if not metadata_config:
            return metadata

        # Extract each metadata field
        for key, path in metadata_config.items():
            if not path:
                continue

            value = self._extract_field(raw_data, path)
            if value is not None:
                # Convert numeric fields
                if key in ["input_tokens", "output_tokens"]:
                    try:
                        metadata[key] = int(value)
                    except (ValueError, TypeError):
                        pass
                elif key in ["latency", "cost"]:
                    try:
                        metadata[key] = float(value)
                    except (ValueError, TypeError):
                        pass
                else:
                    metadata[key] = value

        return metadata

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract metadata: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

extract_tool_calls(raw_data)

Extract tool calls from generic JSON using schema mapping.

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Generic JSON trace data

required

Returns:

Type Description
List[ToolCall]

List of ToolCall objects

Raises:

Type Description
AdapterTransformationError

If tool call extraction fails

Source code in uaef/adapters/generic.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
def extract_tool_calls(self, raw_data: Dict[str, Any]) -> List[ToolCall]:
    """
    Extract tool calls from generic JSON using schema mapping.

    Args:
        raw_data: Generic JSON trace data

    Returns:
        List of ToolCall objects

    Raises:
        AdapterTransformationError: If tool call extraction fails
    """
    try:
        tool_calls = []
        tool_calls_config = self.schema_mapping.get("tool_calls", {})

        if not tool_calls_config:
            return tool_calls

        # Extract tool call objects using JSONPath
        tool_calls_path = tool_calls_config.get("path")
        if not tool_calls_path:
            return tool_calls

        tool_call_objects = self._extract_field(raw_data, tool_calls_path, multiple=True)
        if not tool_call_objects:
            return tool_calls

        # Extract fields from each tool call object
        name_field = tool_calls_config.get("name_field", "name")
        arguments_field = tool_calls_config.get("arguments_field", "arguments")
        result_field = tool_calls_config.get("result_field", "result")
        timestamp_field = tool_calls_config.get("timestamp_field")
        execution_time_field = tool_calls_config.get("execution_time_field")
        error_field = tool_calls_config.get("error_field")

        for tc_obj in tool_call_objects:
            if not isinstance(tc_obj, dict):
                continue

            # Extract tool name
            name = tc_obj.get(name_field, "")
            if not name:
                continue

            # Extract arguments
            arguments = tc_obj.get(arguments_field, {})
            if not isinstance(arguments, dict):
                arguments = {}

            # Extract result
            result = tc_obj.get(result_field) if result_field else None

            # Extract timestamp
            timestamp = datetime.utcnow()
            if timestamp_field and timestamp_field in tc_obj:
                timestamp = self._parse_timestamp(tc_obj[timestamp_field])

            # Extract execution time
            execution_time = None
            if execution_time_field and execution_time_field in tc_obj:
                try:
                    execution_time = float(tc_obj[execution_time_field])
                except (ValueError, TypeError):
                    pass

            # Extract error
            error = tc_obj.get(error_field) if error_field else None
            if error:
                error = str(error)

            # Create ToolCall object
            tool_call = ToolCall(
                name=name,
                arguments=arguments,
                result=result,
                timestamp=timestamp,
                execution_time=execution_time,
                error=error
            )
            tool_calls.append(tool_call)

        return tool_calls

    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to extract tool calls: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

transform_to_canonical(raw_data)

Transform generic JSON data to canonical AgentTrace format.

Parameters:

Name Type Description Default
raw_data Dict[str, Any]

Generic JSON trace data

required

Returns:

Type Description
AgentTrace

Canonical AgentTrace object

Raises:

Type Description
AdapterTransformationError

If transformation fails

Source code in uaef/adapters/generic.py
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
def transform_to_canonical(self, raw_data: Dict[str, Any]) -> AgentTrace:
    """
    Transform generic JSON data to canonical AgentTrace format.

    Args:
        raw_data: Generic JSON trace data

    Returns:
        Canonical AgentTrace object

    Raises:
        AdapterTransformationError: If transformation fails
    """
    try:
        # Extract components using schema mapping
        messages = self.extract_messages(raw_data)
        tool_calls = self.extract_tool_calls(raw_data)
        metadata = self.extract_metadata(raw_data)

        # Extract trace_id and session_id
        trace_id = self._extract_field(raw_data, self.schema_mapping.get("trace_id"))
        if trace_id and isinstance(trace_id, str):
            try:
                from uuid import UUID
                trace_id = UUID(trace_id)
            except (ValueError, AttributeError):
                trace_id = uuid4()
        else:
            trace_id = uuid4()

        session_id = self._extract_field(raw_data, self.schema_mapping.get("session_id"))
        if session_id:
            session_id = str(session_id)

        # Create AgentTrace
        trace = AgentTrace(
            trace_id=trace_id,
            session_id=session_id,
            messages=messages,
            tool_calls=tool_calls,
            input_tokens=metadata.get("input_tokens"),
            output_tokens=metadata.get("output_tokens"),
            latency=metadata.get("latency"),
            cost=metadata.get("cost"),
            metadata=metadata,
            framework=metadata.get("framework", "generic"),
            timestamp=datetime.utcnow()
        )

        return trace

    except AdapterTransformationError:
        raise
    except Exception as e:
        raise AdapterTransformationError(
            f"Failed to transform generic JSON trace: {str(e)}",
            adapter_name=self.name,
            original_error=e
        )

uaef.adapters.invocation

Live agent-invocation helpers.

These helpers call live agents (HTTP endpoints, Bedrock agents, Langfuse traces) and package their raw responses for the matching framework adapter. They live in the library — alongside the adapters — so that every consumer (the demo backend, the service, notebooks) invokes live agents through the same code path instead of re-implementing this glue.

httpx is not part of the lean core dependency set, so the HTTP helpers import it lazily and raise an actionable :class:ImportError when it is missing. boto3 is a core dependency and is imported lazily here only to keep this module importable in environments where AWS credentials/clients are not configured.

invoke_agentcore_runtime(agent_runtime_arn, query, region='us-east-1', qualifier=None, session_id=None, payload_key='prompt')

Invoke a Bedrock AgentCore runtime and return packaged data for the adapter.

Calls the AgentCore data-plane InvokeAgentRuntime API with a JSON payload {payload_key: query} (payload_key defaults to "prompt"; override it if your runtime expects a different input key). The streaming response is read fully and JSON-decoded when possible, then returned in the shape the AgentCore adapter (get_adapter("agentcore")) expects to transform into a canonical trace.

boto3 is imported lazily so this module stays importable where AWS is not configured. Requires the caller's role to allow bedrock-agentcore:InvokeAgentRuntime on the target runtime.

Source code in uaef/adapters/invocation.py
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
def invoke_agentcore_runtime(
    agent_runtime_arn: str,
    query: str,
    region: str = "us-east-1",
    qualifier: Optional[str] = None,
    session_id: Optional[str] = None,
    payload_key: str = "prompt",
) -> Dict[str, Any]:
    """Invoke a Bedrock AgentCore runtime and return packaged data for the adapter.

    Calls the AgentCore data-plane ``InvokeAgentRuntime`` API with a JSON payload
    ``{payload_key: query}`` (``payload_key`` defaults to ``"prompt"``; override
    it if your runtime expects a different input key). The streaming response is
    read fully and JSON-decoded when possible, then returned in the shape the
    AgentCore adapter (``get_adapter("agentcore")``) expects to transform into a
    canonical trace.

    ``boto3`` is imported lazily so this module stays importable where AWS is not
    configured. Requires the caller's role to allow
    ``bedrock-agentcore:InvokeAgentRuntime`` on the target runtime.
    """
    import boto3

    client = boto3.client("bedrock-agentcore", region_name=region)

    # AgentCore requires a runtime session id of at least 33 characters.
    session_id = session_id or (uuid4().hex + uuid4().hex)

    kwargs: Dict[str, Any] = {
        "agentRuntimeArn": agent_runtime_arn,
        "runtimeSessionId": session_id,
        "payload": json.dumps({payload_key: query}).encode("utf-8"),
    }
    if qualifier:
        kwargs["qualifier"] = qualifier

    response = client.invoke_agent_runtime(**kwargs)

    body = response.get("response")
    raw = body.read() if hasattr(body, "read") else body
    if isinstance(raw, (bytes, bytearray)):
        raw = raw.decode("utf-8")
    try:
        parsed = json.loads(raw)
    except (ValueError, TypeError):
        parsed = {"output": raw}

    return {
        "session_id": session_id,
        "agent_runtime_arn": agent_runtime_arn,
        "user_input": query,
        "response": parsed,
    }

invoke_bedrock_agent(agent_id, alias_id, query, region='us-east-1')

Invoke a Bedrock agent and return packaged data for the adapter.

Source code in uaef/adapters/invocation.py
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
def invoke_bedrock_agent(
    agent_id: str, alias_id: str, query: str, region: str = "us-east-1"
) -> Dict[str, Any]:
    """Invoke a Bedrock agent and return packaged data for the adapter."""
    import boto3

    bedrock_runtime = boto3.client("bedrock-agent-runtime", region_name=region)
    session_id = str(uuid4())

    response = bedrock_runtime.invoke_agent(
        agentId=agent_id,
        agentAliasId=alias_id,
        sessionId=session_id,
        inputText=query,
    )
    event_stream = list(response["completion"])

    return {
        "event_stream": event_stream,
        "user_input": query,
        "session_id": session_id,
        "agent_id": agent_id,
        "alias_id": alias_id,
    }

invoke_http_agent(endpoint, query, *, session_id=None, turn_id=None, timeout=120.0, field_map=None)

Call an HTTP agent endpoint with POST {"query": "..."} and return JSON.

Conversation continuity (both optional, omitted from the body when unset, so a single-turn call posts exactly {"query": ...}):

session_id Identifies the conversation. The endpoint is required to keep the conversation state itself and key on this. Multi-turn evaluation is only meaningful against such an endpoint: a dependent turn like "Book the cheapest hotel." has no referent unless the agent remembers the turn that listed them. turn_id 1-based position of this turn within the session. Lets an agent distinguish "start of conversation" from "continuation" — useful when beginning a turn has side effects, such as resetting a scratch database.

field_map renames any request field (see :data:DEFAULT_REQUEST_FIELDS) for endpoints that use their own names.

Source code in uaef/adapters/invocation.py
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
def invoke_http_agent(
    endpoint: str,
    query: str,
    *,
    session_id: Optional[str] = None,
    turn_id: Optional[int] = None,
    timeout: float = 120.0,
    field_map: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
    """Call an HTTP agent endpoint with ``POST {"query": "..."}`` and return JSON.

    Conversation continuity (both optional, omitted from the body when unset, so a
    single-turn call posts exactly ``{"query": ...}``):

    ``session_id``
        Identifies the conversation. **The endpoint is required to keep the
        conversation state itself and key on this.** Multi-turn evaluation is only
        meaningful against such an endpoint: a dependent turn like "Book the
        cheapest hotel." has no referent unless the agent remembers the turn that
        listed them.
    ``turn_id``
        1-based position of this turn within the session. Lets an agent
        distinguish "start of conversation" from "continuation" — useful when
        beginning a turn has side effects, such as resetting a scratch database.

    ``field_map`` renames any request field (see :data:`DEFAULT_REQUEST_FIELDS`) for
    endpoints that use their own names.
    """
    try:
        import httpx
    except ImportError as exc:  # pragma: no cover
        raise ImportError(
            "HTTP agent invocation requires 'httpx'. "
            "Install with: pip install httpx"
        ) from exc

    # SSRF guard: the endpoint is caller-supplied and fetched with the service's
    # credentials, so validate it before any request leaves the process.
    _validate_agent_endpoint(endpoint)

    fields = {**DEFAULT_REQUEST_FIELDS, **(field_map or {})}

    payload: Dict[str, Any] = {fields["query"]: query}
    if session_id is not None:
        payload[fields["session_id"]] = session_id
    if turn_id is not None:
        payload[fields["turn_id"]] = turn_id

    with httpx.Client(timeout=timeout) as client:
        resp = client.post(endpoint, json=payload)
        resp.raise_for_status()
        return resp.json()

invoke_http_agent_for_framework(endpoint, query, framework, *, session_id=None, turn_id=None, field_map=None, payload_session_id=None)

Call an HTTP agent and package the response for the given framework adapter.

session_id is what the agent is told. payload_session_id, when given, is what gets stamped on the returned payload instead — because those are legitimately different values. Against a stateful agent the id sent must be unique per run, or re-running a dataset continues the previous run's conversation; but grouping and reporting want the dataset's own stable id. Defaults to session_id when omitted, which is the single-identifier case.

session_id / turn_id are forwarded to the agent (see :func:invoke_http_agent) and, when supplied, stamped onto the returned payload. That stamping matters: the multi-turn path of :meth:~uaef.adapters.langgraph.LangGraphAdapter.transform_to_canonical groups a list of per-turn payloads by session_id and orders them by turn_id, and raises if either is missing. A caller-supplied value always wins over whatever the agent echoes back, so grouping reflects the dataset rather than the agent's own bookkeeping.

Source code in uaef/adapters/invocation.py
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
def invoke_http_agent_for_framework(
    endpoint: str,
    query: str,
    framework: str,
    *,
    session_id: Optional[str] = None,
    turn_id: Optional[int] = None,
    field_map: Optional[Dict[str, str]] = None,
    payload_session_id: Optional[str] = None,
) -> Dict[str, Any]:
    """Call an HTTP agent and package the response for the given framework adapter.

    ``session_id`` is what the *agent* is told. ``payload_session_id``, when given,
    is what gets stamped on the returned payload instead — because those are
    legitimately different values. Against a stateful agent the id sent must be
    unique per run, or re-running a dataset continues the previous run's
    conversation; but grouping and reporting want the dataset's own stable id.
    Defaults to ``session_id`` when omitted, which is the single-identifier case.

    ``session_id`` / ``turn_id`` are forwarded to the agent (see
    :func:`invoke_http_agent`) and, when supplied, stamped onto the returned
    payload. That stamping matters: the multi-turn path of
    :meth:`~uaef.adapters.langgraph.LangGraphAdapter.transform_to_canonical`
    groups a list of per-turn payloads by ``session_id`` and orders them by
    ``turn_id``, and raises if either is missing. A caller-supplied value always
    wins over whatever the agent echoes back, so grouping reflects the dataset
    rather than the agent's own bookkeeping.
    """
    raw = invoke_http_agent(
        endpoint, query,
        session_id=session_id, turn_id=turn_id,
        field_map=field_map,
    )

    #: The id used for grouping — the dataset's, when it differs from the agent's.
    group_session_id = payload_session_id if payload_session_id is not None else session_id

    def _with_turn_keys(payload: Dict[str, Any]) -> Dict[str, Any]:
        """Stamp caller-supplied session/turn identity onto the payload."""
        if group_session_id is not None:
            payload["session_id"] = group_session_id
        if turn_id is not None:
            payload["turn_id"] = turn_id
        return payload

    if framework == "langgraph":
        if "stream_events" in raw:
            return _with_turn_keys(raw)
        return _with_turn_keys({
            "stream_events": raw.get("events", [raw]),
            "session_id": raw.get("session_id", str(uuid4())),
            "agent_node_name": raw.get("agent_node_name", "agent"),
            "tool_node_name": raw.get("tool_node_name", "tools"),
        })
    elif framework == "langchain":
        if "inputs" in raw and "outputs" in raw:
            return raw
        return {
            "inputs": {"input": query},
            "outputs": {"output": raw.get("output", raw.get("response", str(raw)))},
            "intermediate_steps": raw.get("intermediate_steps", []),
            "session_id": raw.get("session_id", str(uuid4())),
        }
    else:
        # strands, agentcore (http), generic
        return raw

invoke_langfuse_trace(query, expected, public_key, secret_key, host)

Fetch a real Langfuse trace matching the query, or simulate one.

Strategy: 1. Try to fetch traces from Langfuse that match the query text. 2. If a matching trace is found, return its observations in the format expected by LangfuseAdapter. 3. If no match or Langfuse SDK unavailable, fall back to a simulated trace using the ground truth expected answer.

Source code in uaef/adapters/invocation.py
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
def invoke_langfuse_trace(
    query: str, expected: str,
    public_key: str, secret_key: str, host: str,
) -> Dict[str, Any]:
    """
    Fetch a real Langfuse trace matching the query, or simulate one.

    Strategy:
    1. Try to fetch traces from Langfuse that match the query text.
    2. If a matching trace is found, return its observations in the format
       expected by LangfuseAdapter.
    3. If no match or Langfuse SDK unavailable, fall back to a simulated
       trace using the ground truth expected answer.
    """
    try:
        from langfuse import Langfuse

        client = Langfuse(
            public_key=public_key,
            secret_key=secret_key,
            host=host,
        )

        # Search for traces whose input matches the query
        traces = client.fetch_traces(limit=5)
        for trace_data in traces.data:
            trace_input = trace_data.input
            if isinstance(trace_input, dict):
                trace_input = trace_input.get("content", trace_input.get("message", ""))
            if isinstance(trace_input, str) and query.lower() in trace_input.lower():
                observations = client.fetch_observations(trace_id=trace_data.id)
                return {
                    "trace_id": trace_data.id,
                    "session_id": trace_data.session_id or str(uuid4()),
                    "name": trace_data.name or "langfuse-trace",
                    "timestamp": trace_data.timestamp.isoformat() if trace_data.timestamp else datetime.now(timezone.utc).isoformat(),
                    "metadata": trace_data.metadata or {},
                    "observations": [
                        {
                            "id": obs.id,
                            "type": obs.type,
                            "name": obs.name,
                            "input": obs.input,
                            "output": obs.output,
                            "model": obs.model,
                            "usage": {"input": (obs.usage.input if obs.usage else 0), "output": (obs.usage.output if obs.usage else 0), "total": (obs.usage.total if obs.usage else 0)} if obs.usage else {},
                            "cost": obs.calculated_total_cost,
                            "start_time": obs.start_time.isoformat() if obs.start_time else None,
                            "end_time": obs.end_time.isoformat() if obs.end_time else None,
                            "parent_observation_id": obs.parent_observation_id,
                        }
                        for obs in observations.data
                    ],
                }
    except ImportError:
        pass
    except Exception as e:
        print(f"[WARN] Langfuse fetch failed, using simulated trace: {e}")

    # Fallback: simulate a trace using ground truth data
    return {
        "trace_id": str(uuid4()),
        "session_id": str(uuid4()),
        "name": "langfuse-eval-simulated",
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "observations": [
            {
                "id": str(uuid4()),
                "type": "generation",
                "name": "llm-call",
                "input": {"role": "user", "content": query},
                "output": {"content": expected},
                "model": "unknown",
                "usage": {"input": 0, "output": 0, "total": 0},
                "start_time": datetime.now(timezone.utc).isoformat(),
                "end_time": datetime.now(timezone.utc).isoformat(),
            }
        ],
    }