Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.54.0"
version = "0.55.0"
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