Skip to content
Merged
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "sap-cloud-sdk"
version = "0.55.1"
version = "0.55.2"
description = "SAP Cloud SDK for Python"
readme = "README.md"
license = "Apache-2.0"
Expand Down
4 changes: 4 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
ATTR_SUMMARY_HOOK_CALL_COUNT,
ATTR_SUMMARY_HAS_INSTRUCTION,
ATTR_SUMMARY_JOULE_STUDIO_GSID,
ATTR_SUMMARY_IS_EXTENSION,
ATTR_SUMMARY_SOLUTION_ID,
resolve_source_info,
build_extension_span_attributes,
reset_tool_call_metrics,
Expand Down Expand Up @@ -98,6 +100,8 @@
"ATTR_SUMMARY_HOOK_CALL_COUNT",
"ATTR_SUMMARY_HAS_INSTRUCTION",
"ATTR_SUMMARY_JOULE_STUDIO_GSID",
"ATTR_SUMMARY_IS_EXTENSION",
"ATTR_SUMMARY_SOLUTION_ID",
"resolve_source_info",
"build_extension_span_attributes",
"reset_tool_call_metrics",
Expand Down
8 changes: 8 additions & 0 deletions src/sap_cloud_sdk/core/telemetry/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ def get_extension_context() -> dict[str, Any] | None:
ATTR_SUMMARY_HOOK_CALL_COUNT = "sap.extension.summary.hookCallCount"
ATTR_SUMMARY_HAS_INSTRUCTION = "sap.extension.summary.hasInstruction"
ATTR_SUMMARY_JOULE_STUDIO_GSID = "sap.extension.joule_studio_gsid"
ATTR_SUMMARY_IS_EXTENSION = "sap.extension.isExtension"
ATTR_SUMMARY_SOLUTION_ID = "sap.extension.solutionId"

# ---------------------------------------------------------------------------
# Private state
Expand Down Expand Up @@ -614,6 +616,7 @@ def emit_extensions_summary_span(
has_instruction: bool,
total_duration_ms: float,
joule_studio_gsid: str = "",
solution_id: str = "",
) -> None:
"""Emit a sibling summary span with aggregate extension metrics.

Expand All @@ -637,6 +640,8 @@ def emit_extensions_summary_span(
operations.
joule_studio_gsid: Global solution ID of Joule Studio (empty string
if not available).
solution_id: Solution ID of the contributing extension (empty string
if not available).
"""
total = tool_call_count + hook_call_count + (1 if has_instruction else 0)
attrs = {
Expand All @@ -645,9 +650,12 @@ def emit_extensions_summary_span(
ATTR_SUMMARY_TOOL_CALL_COUNT: tool_call_count,
ATTR_SUMMARY_HOOK_CALL_COUNT: hook_call_count,
ATTR_SUMMARY_HAS_INSTRUCTION: has_instruction,
ATTR_SUMMARY_IS_EXTENSION: True,
}
if joule_studio_gsid:
attrs[ATTR_SUMMARY_JOULE_STUDIO_GSID] = joule_studio_gsid
if solution_id:
attrs[ATTR_SUMMARY_SOLUTION_ID] = solution_id
span = _tracer.start_span("agent_extensions_summary", attributes=attrs)
span.end()

Expand Down
36 changes: 36 additions & 0 deletions src/sap_cloud_sdk/extensibility/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,16 +500,19 @@ class ExtensionSourceMapping:
(e.g., ``"create_ticket"``).
Hook keys are hook IDs (UUIDs) (e.g.,
``"3f5c8c8a-7b4d-4f9c-a4c0-7d5cb1a39f7e"``).
Instruction keys are extension instance IDs (e.g., ``"ext-instance-1"``).
Values are :class:`ExtensionSourceInfo` objects containing the extension's
name, version, and unique identifier.

Attributes:
tools: Mapping of tool name to extension source info.
hooks: Mapping of hook ID to extension source info.
instructions: Mapping of extension instance ID to extension source info.
"""

tools: Dict[str, ExtensionSourceInfo] = field(default_factory=dict)
hooks: Dict[str, ExtensionSourceInfo] = field(default_factory=dict)
instructions: Dict[str, ExtensionSourceInfo] = field(default_factory=dict)

@classmethod
def from_dict(cls, obj: Dict[str, Any]) -> ExtensionSourceMapping:
Expand All @@ -531,6 +534,13 @@ def from_dict(cls, obj: Dict[str, Any]) -> ExtensionSourceMapping:
"extensionVersion": "1",
"extensionId": "a1b2c3d4-..."
}
},
"instructions": {
"ext-instance-1": {
"extensionName": "ServiceNow Extension",
"extensionVersion": "1.0.0",
"extensionId": "ext-instance-1"
}
}
}

Expand All @@ -545,9 +555,14 @@ def from_dict(cls, obj: Dict[str, Any]) -> ExtensionSourceMapping:
"""
raw_tools = obj.get("tools", {})
raw_hooks = obj.get("hooks", {})
raw_instructions = obj.get("instructions", {})
return cls(
tools={k: ExtensionSourceInfo.from_value(v) for k, v in raw_tools.items()},
hooks={k: ExtensionSourceInfo.from_value(v) for k, v in raw_hooks.items()},
instructions={
k: ExtensionSourceInfo.from_value(v)
for k, v in raw_instructions.items()
},
)


Expand Down Expand Up @@ -812,3 +827,24 @@ def get_source_info_for_hook(self, hook_id: str) -> Optional[ExtensionSourceInfo
if self.source and hook_id in self.source.hooks:
return self.source.hooks[hook_id]
return None

def get_source_info_for_instruction(
self, extension_id: str
) -> Optional[ExtensionSourceInfo]:
"""Look up the full source info for a specific instruction contributor.

Returns the :class:`ExtensionSourceInfo` containing extension name,
version, and ID for the extension that contributed an instruction
fragment. Returns ``None`` when source mapping is not available or
the extension ID is not found.

Args:
extension_id: The extension instance ID used as the key in
``source.instructions`` (e.g., ``"ext-instance-1"``).

Returns:
:class:`ExtensionSourceInfo` for the instruction contributor, or ``None``.
"""
if self.source and extension_id in self.source.instructions:
return self.source.instructions[extension_id]
return None
21 changes: 17 additions & 4 deletions src/sap_cloud_sdk/extensibility/_ums_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,14 +312,15 @@ def _build_source_mapping(
mcp_servers: List[McpServer],
hooks: List[Hook],
) -> ExtensionSourceMapping:
"""Build a source mapping from per-node title to contributed tools/hooks.
"""Build a source mapping from per-node title to contributed tools/hooks/instructions.

Each node has a ``title`` (the extension name) and a list of
``capabilityImplementations`` whose tools and hooks were contributed
by that extension.
``capabilityImplementations`` whose tools, hooks, and instructions were
contributed by that extension.
"""
tool_map: Dict[str, ExtensionSourceInfo] = {}
hook_map: Dict[str, ExtensionSourceInfo] = {}
instruction_map: Dict[str, ExtensionSourceInfo] = {}

for node in nodes:
title = node.get("title", "")
Expand All @@ -346,7 +347,19 @@ def _build_source_mapping(
if hook_id:
hook_map[hook_id] = source_info

return ExtensionSourceMapping(tools=tool_map, hooks=hook_map)
# Map instructions (use the extension instance id as the mapping key)
raw_instruction = cap_impl.get("instruction")
if raw_instruction and isinstance(raw_instruction, dict):
if raw_instruction.get("text"):
instruction_key = node.get("id", "") or title
if instruction_key:
instruction_map[instruction_key] = source_info

return ExtensionSourceMapping(
tools=tool_map,
hooks=hook_map,
instructions=instruction_map,
)


def _transform_ums_response(
Expand Down
115 changes: 115 additions & 0 deletions tests/extensibility/unit/test_ums_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ def test_empty_nodes(self):
mapping = _build_source_mapping([], [], [])
assert mapping.tools == {}
assert mapping.hooks == {}
assert mapping.instructions == {}

def test_null_hooks_in_capability(self):
"""hooks: null should not crash _build_source_mapping."""
Expand Down Expand Up @@ -243,6 +244,105 @@ def test_missing_extension_version_defaults_to_empty(self):
mapping = _build_source_mapping(nodes, [], [])
assert mapping.tools["tool_x"].extension_version == ""

def test_maps_instruction_by_instance_id(self):
nodes = [
{
"id": "ext-instance-1",
"title": "ServiceNow Extension",
"extensionVersion": "2.1.0",
"solutionId": "sol-abc",
"jouleStudioGsid": "gsid-xyz",
"capabilityImplementations": [
{
"capabilityId": "default",
"instruction": {"text": "Use ServiceNow tools."},
"tools": {"additions": []},
"hooks": [],
}
],
}
]
mapping = _build_source_mapping(nodes, [], [])
assert "ext-instance-1" in mapping.instructions
info = mapping.instructions["ext-instance-1"]
assert info.extension_name == "ServiceNow Extension"
assert info.extension_id == "ext-instance-1"
assert info.extension_version == "2.1.0"
assert info.solution_id == "sol-abc"
assert info.joule_studio_gsid == "gsid-xyz"

def test_instruction_falls_back_to_title_when_id_missing(self):
"""Node without an id keys the instruction map by title."""
nodes = [
{
"title": "Titled Extension",
"capabilityImplementations": [
{
"capabilityId": "default",
"instruction": {"text": "Some instruction."},
"tools": {"additions": []},
"hooks": [],
}
],
}
]
mapping = _build_source_mapping(nodes, [], [])
assert "Titled Extension" in mapping.instructions

def test_instruction_without_text_not_mapped(self):
nodes = [
{
"id": "ext-1",
"title": "Empty Instruction",
"capabilityImplementations": [
{
"capabilityId": "default",
"instruction": {"text": ""},
"tools": {"additions": []},
"hooks": [],
}
],
}
]
mapping = _build_source_mapping(nodes, [], [])
assert mapping.instructions == {}

def test_missing_instruction_not_mapped(self):
nodes = [
{
"id": "ext-1",
"title": "No Instruction",
"capabilityImplementations": [
{
"capabilityId": "default",
"tools": {"additions": []},
"hooks": [],
}
],
}
]
mapping = _build_source_mapping(nodes, [], [])
assert mapping.instructions == {}

def test_instruction_non_dict_not_mapped(self):
"""A legacy plain-string instruction should not crash or map."""
nodes = [
{
"id": "ext-1",
"title": "String Instruction",
"capabilityImplementations": [
{
"capabilityId": "default",
"instruction": "plain string",
"tools": {"additions": []},
"hooks": [],
}
],
}
]
mapping = _build_source_mapping(nodes, [], [])
assert mapping.instructions == {}

# ---------------------------------------------------------------------------
# Tests: _transform_ums_response
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -330,6 +430,21 @@ def test_source_mapping_populated(self):
assert result.source.tools["create_ticket"].extension_id == "ext-instance-1"
assert result.source.tools["create_ticket"].extension_version == "2.1.0"
assert "9f6e5f66-7e4f-4ef0-a9f6-e6e1c1220c11" in result.source.hooks
# Instruction attribution is mapped by extension instance id
assert "ext-instance-1" in result.source.instructions
assert (
result.source.instructions["ext-instance-1"].extension_name
== "ServiceNow Extension"
)
assert (
result.source.instructions["ext-instance-1"].extension_id
== "ext-instance-1"
)

def test_source_instructions_empty_when_no_instruction(self):
result = _transform_ums_response(UMS_RESPONSE_NO_INSTRUCTION["data"], "default")
assert result.source is not None
assert result.source.instructions == {}

def test_hooks_with_unknown_type_skipped(self):
data = {
Expand Down
Loading
Loading