diff --git a/README.md b/README.md index b79bdc2..e35f7b8 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ The Python SDK offers a clean, type-safe API following Python best practices whi ### Key Features +- **Agent Decorators** - **AI Core Integration** - **Audit Log Service** - **Destination Service** @@ -58,6 +59,7 @@ The SDK automatically resolves configuration from multiple sources with the foll Each module has comprehensive usage guides: +- [Agent Decorators](src/sap_cloud_sdk/agent_decorators/user-guide.md) - [AuditLog](src/sap_cloud_sdk/core/auditlog/user-guide.md) - [Destination](src/sap_cloud_sdk/destination/user-guide.md) - [DMS](src/sap_cloud_sdk/dms/user-guide.md) diff --git a/src/sap_cloud_sdk/agent_decorators/__init__.py b/src/sap_cloud_sdk/agent_decorators/__init__.py new file mode 100644 index 0000000..156f30a --- /dev/null +++ b/src/sap_cloud_sdk/agent_decorators/__init__.py @@ -0,0 +1,39 @@ +"""SAP Cloud SDK for Python - Agent Decorators module. + +Decorator-based configuration-as-code for SAP AI agents. Developers +annotate functions with decorators to expose configuration fields +(prompts, models, settings) to a low-code UI. + +Usage: + from sap_cloud_sdk.agent_decorators import ( + prompt_section, + agent_config, + agent_model, + ) + + @prompt_section( + key="prompts.system", + label="System Prompt", + description="Main system prompt for the agent", + ) + def system_prompt() -> str: + return "You are a helpful assistant." +""" + +from sap_cloud_sdk.agent_decorators.decorators import ( + agent_config, + agent_model, + prompt_section, +) +from sap_cloud_sdk.agent_decorators.exceptions import ( + AgentDecoratorError, +) + +__all__ = [ + # Decorators + "prompt_section", + "agent_config", + "agent_model", + # Exceptions + "AgentDecoratorError", +] diff --git a/src/sap_cloud_sdk/agent_decorators/decorators.py b/src/sap_cloud_sdk/agent_decorators/decorators.py new file mode 100644 index 0000000..746783d --- /dev/null +++ b/src/sap_cloud_sdk/agent_decorators/decorators.py @@ -0,0 +1,138 @@ +"""Decorator functions for exposing agent configuration fields. + +Each decorator is a marker — it annotates a zero-argument function +whose return value is the coded default for a configuration field. +External tooling discovers these markers in source text and extracts +their arguments and return values. + +At decoration time only the *key* is validated; the function is +returned unchanged. +""" + +from typing import Any, Callable, Optional + +from .exceptions import AgentDecoratorError + + +def _validate_key(key: str) -> None: + """Validate a decorator key. + + Raises: + AgentDecoratorError: If the key is empty or whitespace-only. + """ + if not key or not key.strip(): + raise AgentDecoratorError( + f"Decorator key must be a non-empty string, got {key!r}" + ) + + +def prompt_section( + key: str, + label: str, + description: str, + validation: Optional[dict[str, Any]] = None, +) -> Callable: + """Expose a prompt section for editing. + + Args: + key: Unique identifier for this prompt (e.g. ``"prompts.system"``). + label: Human-readable label shown in the UI. + description: Help text explaining what this prompt does. + validation: Optional validation rules as a dict + (e.g. ``{"format": "text", "max_length": 500}``). + + Returns: + A decorator that validates the key and returns the function unchanged. + + Raises: + AgentDecoratorError: If the key is empty or whitespace-only. + + Example:: + + @prompt_section( + key="prompts.identity", + label="Agent Identity", + description="Core identity and role definition", + validation={"format": "text", "max_length": 500}, + ) + def get_identity_prompt() -> str: + return "You are an expert assistant..." + """ + _validate_key(key) + + def decorator(fn: Callable[[], str]) -> Callable[[], str]: + return fn + + return decorator + + +def agent_config( + key: str, + label: str, + description: str, +) -> Callable: + """Expose an agent configuration value for editing. + + Args: + key: Unique identifier (e.g. ``"config.temperature"``). + label: Human-readable label. + description: Help text. + + Returns: + A decorator that validates the key and returns the function unchanged. + + Raises: + AgentDecoratorError: If the key is empty or whitespace-only. + + Example:: + + @agent_config( + key="config.temperature", + label="Temperature", + description="The temperature setting for the language model", + ) + def get_temperature() -> float: + return 0.7 + """ + _validate_key(key) + + def decorator(fn: Callable) -> Callable: + return fn + + return decorator + + +def agent_model( + key: str, + label: str, + description: str = "", +) -> Callable: + """Expose an agent model selection for editing. + + Args: + key: Unique identifier (e.g. ``"config.model"``). + label: Human-readable label. + description: Help text (optional). + + Returns: + A decorator that validates the key and returns the function unchanged. + + Raises: + AgentDecoratorError: If the key is empty or whitespace-only. + + Example:: + + @agent_model( + key="config.model", + label="LLM Model", + description="The language model powering this agent", + ) + def get_model_name() -> str: + return "sap/gpt-4o" + """ + _validate_key(key) + + def decorator(fn: Callable) -> Callable: + return fn + + return decorator diff --git a/src/sap_cloud_sdk/agent_decorators/exceptions.py b/src/sap_cloud_sdk/agent_decorators/exceptions.py new file mode 100644 index 0000000..2208411 --- /dev/null +++ b/src/sap_cloud_sdk/agent_decorators/exceptions.py @@ -0,0 +1,7 @@ +"""Custom exceptions for SAP Agent Decorators.""" + + +class AgentDecoratorError(Exception): + """Base exception for agent decorator operations.""" + + pass diff --git a/src/sap_cloud_sdk/agent_decorators/py.typed b/src/sap_cloud_sdk/agent_decorators/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/src/sap_cloud_sdk/agent_decorators/user-guide.md b/src/sap_cloud_sdk/agent_decorators/user-guide.md new file mode 100644 index 0000000..1b7000f --- /dev/null +++ b/src/sap_cloud_sdk/agent_decorators/user-guide.md @@ -0,0 +1,102 @@ +# Agent Decorators User Guide + +This module provides a decorator-based configuration-as-code system for SAP AI agents. Developers annotate functions with decorators to expose configuration fields — prompts, model selections, and agent settings — to a low-code UI. External tooling discovers these markers in source text and extracts their arguments and return values. + +## Installation + +The agent decorators module is part of the Cloud SDK for Python and is automatically available when the SDK is installed. + +## Import + +```python +from sap_cloud_sdk.agent_decorators import ( + prompt_section, + agent_config, + agent_model, +) +``` + +## Quick Start + +```python +from sap_cloud_sdk.agent_decorators import prompt_section, agent_model + +# Define a prompt with a coded default +@prompt_section( + key="prompts.system", + label="System Prompt", + description="Main system prompt for the agent", +) +def system_prompt() -> str: + return "You are a helpful assistant." + +# Define the model selection +@agent_model(key="config.model", label="LLM Model") +def model_name() -> str: + return "gpt-4" +``` + +## Decorators + +### @prompt_section + +Expose a prompt section for editing. + +```python +from sap_cloud_sdk.agent_decorators import prompt_section + +@prompt_section( + key="prompts.identity", + label="Agent Identity", + description="Core identity and role definition", + validation={"format": "text", "max_length": 500}, +) +def identity_prompt() -> str: + return "You are an expert assistant specializing in SAP systems." +``` + +### @agent_config + +Expose a configuration value for editing. + +```python +from sap_cloud_sdk.agent_decorators import agent_config + +@agent_config( + key="config.temperature", + label="Temperature", + description="Controls randomness (0.0 = deterministic, 1.0 = creative)", +) +def temperature() -> float: + return 0.7 +``` + +### @agent_model + +Expose a model selection. The `description` parameter is optional. + +```python +from sap_cloud_sdk.agent_decorators import agent_model + +@agent_model( + key="config.model", + label="Default Model", + description="The LLM model to use", +) +def default_model() -> str: + return "gpt-4" +``` + +## Error Handling + +```python +from sap_cloud_sdk.agent_decorators.exceptions import AgentDecoratorError + +try: + @prompt_section(key="", label="L", description="D") + def bad(): + return "" +except AgentDecoratorError as e: + # Raised when decorator arguments are invalid (e.g. empty key) + print(f"Decorator error: {e}") +``` diff --git a/tests/agent_decorators/__init__.py b/tests/agent_decorators/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/agent_decorators/unit/__init__.py b/tests/agent_decorators/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/agent_decorators/unit/test_decorators.py b/tests/agent_decorators/unit/test_decorators.py new file mode 100644 index 0000000..54416aa --- /dev/null +++ b/tests/agent_decorators/unit/test_decorators.py @@ -0,0 +1,112 @@ +"""Tests for decorator functionality.""" + +import pytest + +from sap_cloud_sdk.agent_decorators.decorators import ( + agent_config, + agent_model, + prompt_section, +) +from sap_cloud_sdk.agent_decorators.exceptions import AgentDecoratorError + + +class TestPromptSection: + """Tests for @prompt_section decorator.""" + + def test_function_still_callable(self): + @prompt_section( + key="prompts.system", + label="System Prompt", + description="Main system prompt", + ) + def system_prompt(): + return "You are a helpful assistant." + + assert system_prompt() == "You are a helpful assistant." + + def test_with_validation_dict(self): + @prompt_section( + key="prompts.validated", + label="Validated", + description="A validated prompt", + validation={"format": "text", "max_length": 500}, + ) + def validated(): + return "Hello" + + assert validated() == "Hello" + + +class TestAgentConfig: + """Tests for @agent_config decorator.""" + + def test_function_still_callable(self): + @agent_config( + key="config.temperature", + label="Temperature", + description="Model temperature", + ) + def temperature(): + return 0.7 + + assert temperature() == 0.7 + + +class TestAgentModel: + """Tests for @agent_model decorator.""" + + def test_function_still_callable(self): + @agent_model(key="config.model", label="Model") + def model(): + return "gpt-4" + + assert model() == "gpt-4" + + def test_no_description_required(self): + @agent_model(key="config.model2", label="Model") + def model(): + return "claude-3" + + assert model() == "claude-3" + +class TestKeyValidation: + """Tests for decorator key validation.""" + + def test_empty_key_raises_error(self): + with pytest.raises(AgentDecoratorError, match="non-empty string"): + + @prompt_section(key="", label="L", description="D") + def bad(): + return "" + + def test_whitespace_key_raises_error(self): + with pytest.raises(AgentDecoratorError, match="non-empty string"): + + @agent_config(key=" ", label="L", description="D") + def bad(): + return {} + + def test_none_key_raises_error(self): + with pytest.raises(AgentDecoratorError, match="non-empty string"): + + @agent_model(key=None, label="L") # ty: ignore[invalid-argument-type] + def bad(): + return {} + + def test_valid_key_does_not_raise(self): + @agent_model(key="valid_key", label="L") + def good(): + return "model" + + assert good() == "model" + + def test_all_decorators_validate_key(self): + decorators = [ + lambda fn: prompt_section(key="", label="L", description="D")(fn), + lambda fn: agent_config(key="", label="L", description="D")(fn), + lambda fn: agent_model(key="", label="L")(fn), + ] + + for dec in decorators: + with pytest.raises(AgentDecoratorError): + dec(lambda: None) diff --git a/tests/agent_decorators/unit/test_exceptions.py b/tests/agent_decorators/unit/test_exceptions.py new file mode 100644 index 0000000..678ca39 --- /dev/null +++ b/tests/agent_decorators/unit/test_exceptions.py @@ -0,0 +1,22 @@ +"""Tests for agent decorator exception classes.""" + +from sap_cloud_sdk.agent_decorators.exceptions import ( + AgentDecoratorError, +) + + +class TestExceptionHierarchy: + """Tests for exception inheritance.""" + + def test_base_error_is_exception(self): + """Test that AgentDecoratorError inherits from Exception.""" + assert issubclass(AgentDecoratorError, Exception) + + +class TestExceptionInstantiation: + """Tests for exception creation and message handling.""" + + def test_base_error_with_message(self): + """Test creating AgentDecoratorError with a message.""" + error = AgentDecoratorError("something went wrong") + assert str(error) == "something went wrong"