diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6f8dc192..a599d60c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,16 +62,24 @@ jobs: grep -E '^(pytest|pytest-asyncio|pytest-xdist|respx|dirty-equals|time-machine)==' requirements-dev.lock > test-tools.txt # Resolve as of a day ago: a release still uploading its wheels forces a # source build that can fail (SQLAlchemy 2.1.0 did). - # google-adk<2: 2.x wraps callback exceptions and fails test_user_exception_propagates_without_chaining. uv pip install --exclude-newer "$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ)" \ -e '.[aiohttp]' -r test-tools.txt \ - openai boto3 claude-agent-sdk 'google-adk<2' google-genai langchain-core wrapt \ + openai boto3 claude-agent-sdk google-adk google-genai langchain-core wrapt \ azure-cognitiveservices-speech # Generated API tests need the mock server and already run in `test`. - name: Run integration tests run: .venv/bin/pytest --ignore=tests/api_resources + # google-adk 2.x runs agents as workflow nodes and moved the patch targets, + # so the ADK tracer also has to keep working on 1.x. Runs even when the + # step above fails, and resolves as of a day ago for the same reason. + - name: Run Google ADK tests on google-adk 1.x + if: ${{ !cancelled() }} + run: | + uv pip install --exclude-newer "$(date -u -d '1 day ago' +%Y-%m-%dT%H:%M:%SZ)" 'google-adk<2' + .venv/bin/pytest tests/test_google_adk_handoffs.py tests/test_google_adk_integration.py tests/test_google_genai_adk_dedup.py + # The one check to require in rulesets, so jobs can be added or renamed freely. # The integrations job stays advisory until its optional dependencies are pinned. # always(), not !cancelled(): a skipped required check would count as passing. diff --git a/examples/tracing/google-adk/google_adk_tracing.ipynb b/examples/tracing/google-adk/google_adk_tracing.ipynb index b6070b3d..2726deb5 100644 --- a/examples/tracing/google-adk/google_adk_tracing.ipynb +++ b/examples/tracing/google-adk/google_adk_tracing.ipynb @@ -498,11 +498,15 @@ "\n", "After running the callback agent, you'll see in your Openlayer dashboard:\n", "\n", - "1. **Agent Step** (`CallbackDemoAgent`):\n", + "1. **Agent turn step** (`Agent turn: `): one per `runner.run_async` call, holding\n", + " the user's message, the final answer and the routing columns (`starting_agent`,\n", + " `final_agent`, `handoff_count`, `handoff_path`)\n", + "\n", + "2. **Agent Step** (`CallbackDemoAgent`), inside the turn:\n", " - Shows which callbacks are registered\n", " - Contains all nested steps in chronological order\n", "\n", - "2. **All Callbacks as Siblings** (direct children of Agent):\n", + "3. **All Callbacks as Siblings** (direct children of Agent):\n", " - `Callback: before_agent` - First, before any processing\n", " - `Callback: before_model` - Before each LLM call\n", " - `LLM Call: gemini-2.0-flash-exp` - The actual LLM invocation\n", @@ -512,9 +516,9 @@ " - `Callback: after_tool` - After tool completion\n", " - `Callback: after_agent` - Last, after all processing complete\n", "\n", - "3. **Token Usage** (captured on LLM Call steps):\n", + "4. **Token Usage** (captured on LLM Call steps):\n", " - Prompt tokens\n", - " - Completion tokens \n", + " - Completion tokens (including thinking tokens)\n", " - Total tokens\n" ] }, @@ -538,21 +542,24 @@ "\n", "The traces will show the hierarchy of operations in chronological order:\n", "```\n", - "Agent: CallbackDemoAgent\n", - "├── Callback: before_agent (CallbackDemoAgent)\n", - "├── Callback: before_model (CallbackDemoAgent)\n", - "├── LLM Call: gemini-2.0-flash-exp\n", - "├── Callback: after_model (CallbackDemoAgent)\n", - "├── Callback: before_tool (CallbackDemoAgent)\n", - "├── Tool: get_current_time\n", - "├── Callback: after_tool (CallbackDemoAgent)\n", - "├── Callback: before_model (CallbackDemoAgent)\n", - "├── LLM Call: gemini-2.0-flash-exp\n", - "├── Callback: after_model (CallbackDemoAgent)\n", - "└── Callback: after_agent (CallbackDemoAgent)\n", + "Agent turn: \n", + "└── Agent: CallbackDemoAgent\n", + " ├── Callback: before_agent (CallbackDemoAgent)\n", + " ├── Callback: before_model (CallbackDemoAgent)\n", + " ├── LLM Call: gemini-2.0-flash-exp\n", + " ├── Callback: after_model (CallbackDemoAgent)\n", + " ├── Callback: before_tool (CallbackDemoAgent)\n", + " ├── Tool: get_current_time\n", + " ├── Callback: after_tool (CallbackDemoAgent)\n", + " ├── Callback: before_model (CallbackDemoAgent)\n", + " ├── LLM Call: gemini-2.0-flash-exp\n", + " ├── Callback: after_model (CallbackDemoAgent)\n", + " └── Callback: after_agent (CallbackDemoAgent)\n", "```\n", "\n", - "**Note:** All callbacks are direct children of the Agent step, appearing in chronological order alongside LLM calls and tool executions. This provides a clear timeline view of the agent execution flow.\n" + "**Note:** All callbacks are direct children of the Agent step, appearing in chronological order alongside LLM calls and tool executions. This provides a clear timeline view of the agent execution flow.\n", + "\n", + "With sub-agents, each transfer ADK makes is a `Handoff: → ` step inside the transferring agent, and the agent that takes over is the next step of the turn. Agents used as tools (`AgentTool`) stay nested under their Tool step and aren't counted as handoffs.\n" ] }, { diff --git a/scripts/mock b/scripts/mock index c99c89ab..a8004281 100755 --- a/scripts/mock +++ b/scripts/mock @@ -16,7 +16,7 @@ elif [ -n "$STAINLESS_OPENAPI_SPEC_URL" ]; then URL="$STAINLESS_OPENAPI_SPEC_URL" else # Embedded OpenAPI spec (base64-encoded, gzipped) - EMBEDDED_SPEC="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" + EMBEDDED_SPEC="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" SPEC_TMPFILE="$(mktemp)" trap 'rm -f "$SPEC_TMPFILE"' EXIT echo "$EMBEDDED_SPEC" | base64 --decode | gunzip > "$SPEC_TMPFILE" diff --git a/src/openlayer/lib/integrations/google_adk_tracer.py b/src/openlayer/lib/integrations/google_adk_tracer.py index 5c92ec27..ca547080 100644 --- a/src/openlayer/lib/integrations/google_adk_tracer.py +++ b/src/openlayer/lib/integrations/google_adk_tracer.py @@ -4,6 +4,13 @@ capturing agent execution, LLM calls, tool calls, callbacks, and other ADK-specific events. +Each ``Runner.run_async`` call (one user turn) becomes a single trace rooted at an +``Agent turn`` step. Every agent that runs during the turn is nested under it, +including agents reached through a transfer, which ADK 2.x runs in separate +asyncio tasks. Each transfer ADK makes is recorded as a Handoff step, and the +turn records ``starting_agent``, ``final_agent``, ``handoff_count`` and +``handoff_path`` as trace metadata. + The following callbacks are traced as Function Call steps: - before_agent_callback: Called before the agent starts processing a request - after_agent_callback: Called after the agent finishes processing a request @@ -18,11 +25,15 @@ import asyncio import contextvars +import importlib import json import logging +import math import sys import time -from typing import Any, Callable, Dict, Optional, TYPE_CHECKING +import weakref +from contextlib import asynccontextmanager +from typing import Any, AsyncGenerator, AsyncIterator, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING try: import wrapt @@ -45,9 +56,14 @@ except ImportError: HAVE_GOOGLE_ADK = False -from ..tracing import tracer, steps, enums +from ..tracing import tracer, steps, enums, traces +from ..tracing.tracer import _rag_context as _tracer_rag_context from ..tracing.tracer import _current_step as _tracer_current_step +from ..tracing.tracer import _current_trace as _tracer_current_trace from ..tracing.tracer import _safe_reset_contextvar +from .google_genai_tracer import PROVIDER as GEMINI_PROVIDER +from .google_genai_tracer import _extract_usage as _extract_gemini_usage +from .google_genai_tracer import _normalize_model_name logger = logging.getLogger(__name__) @@ -68,11 +84,85 @@ "google_adk_user_query", default=None ) -# Context variable to track the parent step for agent transfers -# When set, sub-agent steps will be created as children of this step instead of the current step -_agent_transfer_parent_step: contextvars.ContextVar[Optional[Any]] = contextvars.ContextVar( - "google_adk_transfer_parent", default=None -) +# ADK's built-in agent-transfer tool. Handoffs are read from what ADK actually did +# (``event.actions.transfer_to_agent``), which also covers custom tools and +# callbacks that set that action. A call to this tool that did transfer is shown +# as the Handoff step only, not also as a Tool step. +ADK_TRANSFER_TOOL_NAME = "transfer_to_agent" + +# Where ADK defines the function that runs a single tool call. It moved twice in +# google-adk 2.x; the first one that exists is patched. +_TOOL_CALL_TARGETS: List[Tuple[str, str]] = [ + ("google.adk.flows.llm_flows.tools._caller", "_call_tool_async"), # google-adk >= 2.10 + ("google.adk.flows.llm_flows._tool_caller", "_call_tool_async"), # google-adk 2.9 + ("google.adk.flows.llm_flows.functions", "__call_tool_async"), # google-adk 1.x to 2.8 +] + + +class _AdkTurn: + """State for one ``Runner.run_async`` call (one user turn). + + ADK 2.x runs every agent node in its own asyncio task, so a ContextVar set + inside one agent is invisible to the next. The ContextVar only holds a + reference to this object, set before ADK creates any task, and every task + mutates the same instance. + + A Runner started from inside a turn (AgentTool runs its agent that way) gets + a nested scope: its handoffs are kept apart from the outer turn's, and it + writes nothing to the turn step or the trace. + """ + + def __init__( + self, + step: Any, + trace: Optional[traces.Trace], + user_query: Optional[str] = None, + nested: bool = False, + ) -> None: + self.step = step + self.trace = trace + self.user_query = user_query + self.nested = nested + # First agent that emitted content. On a resume, ADK 1.x re-enters the + # root agent, which silently hands over to the paused sub-agent, so the + # first agent entered (the fallback) isn't necessarily who started. + self.starting_agent: Optional[str] = None + self.first_entered_agent: Optional[str] = None + self.final_agent: Optional[str] = None + self.handoffs: List[Tuple[str, str]] = [] + # (agent, parent step) for agents a transfer handed control to that + # haven't started yet. The parent is the transferring agent's parent, so + # a transfer inside a workflow agent or an AgentTool stays nested there. + self.pending_transfers: List[Tuple[str, Any]] = [] + self.root_attributes_set = False + # This turn's position in _trace_turn_summaries[trace]. + self.trace_slot: Optional[int] = None + + def path(self) -> List[str]: + starting_agent = self.starting_agent or self.first_entered_agent + path = [starting_agent] if starting_agent else [] + path.extend(to_agent for _, to_agent in self.handoffs) + return path + + def summary(self) -> Dict[str, Any]: + path = self.path() + return { + "starting_agent": path[0] if path else None, + # With no content event at all (e.g. an empty model reply), whoever + # last received control is the best guess. + "final_agent": self.final_agent or (path[-1] if path else None), + "handoff_count": len(self.handoffs), + "handoff_path": path, + } + + +_current_turn: contextvars.ContextVar[Optional[_AdkTurn]] = contextvars.ContextVar("google_adk_turn", default=None) + +# Turn summaries per trace, in order. A trace can hold several turns when the +# Runner is called more than once inside one @trace function; its columns then +# describe all of them. Values hold no reference to the trace, so entries go +# away with it. +_trace_turn_summaries: "weakref.WeakKeyDictionary[traces.Trace, List[Dict[str, Any]]]" = weakref.WeakKeyDictionary() # Context variable to store the current LLM step for updating with response data _current_llm_step: contextvars.ContextVar[Optional[Any]] = contextvars.ContextVar("google_adk_llm_step", default=None) @@ -88,6 +178,30 @@ "google_adk_agent_step", default=None ) +# Everything a turn sets in the context. The Runner wrapper swaps these in for +# each step of ADK's generator and back out before handing an event to the +# caller, so the caller's context never holds turn state while the turn is +# suspended (see _runner_run_async_wrapper). +_TURN_CONTEXT_VARS: Tuple["contextvars.ContextVar[Any]", ...] = ( + _tracer_current_step, + _tracer_current_trace, + _tracer_rag_context, + _current_turn, + _current_agent_step, + _current_llm_step, + _current_llm_request, + _current_user_query, +) + + +def _snapshot_context() -> List[Any]: + return [var.get(None) for var in _TURN_CONTEXT_VARS] + + +def _apply_context(values: List[Any]) -> None: + for var, value in zip(_TURN_CONTEXT_VARS, values): + var.set(value) + # Configuration for whether to disable ADK's built-in OpenTelemetry tracing # When False (default), ADK's OTel tracing works alongside Openlayer tracing @@ -196,9 +310,17 @@ def trace_google_adk(disable_adk_otel: bool = False) -> None: Set to True only if you want Openlayer as your sole observability tool. Note: - Agent transfers (handoffs via ``transfer_to_agent``) do not create - separate tool steps to avoid excessive nesting. Sub-agent executions - are nested directly under the LLM call that initiates the transfer. + Each ``Runner.run_async`` call is one trace, rooted at an + ``Agent turn: `` step. Every transfer ADK makes (through + ``transfer_to_agent``, or a tool or callback that sets + ``actions.transfer_to_agent``) becomes a ``Handoff: → `` + step, and the agent that receives control follows the transferring + agent at the same level. ``starting_agent``, ``final_agent``, + ``handoff_count`` and ``handoff_path`` are added to the trace metadata, + so they can be used as columns in tests; when one ``@trace`` function + runs several turns, they cover all of them. Agents used as tools + (``AgentTool``) and workflow agents (``SequentialAgent``, + ``ParallelAgent``, ``LoopAgent``) are nested agent steps, not handoffs. Requirements: Make sure to install Google ADK with: ``pip install google-adk`` @@ -290,6 +412,93 @@ def _sort_steps_by_time(step: Any, recursive: bool = True) -> None: _sort_steps_by_time(child_step, recursive=True) +@asynccontextmanager +async def _aclosing(agen: AsyncGenerator[Any, None]) -> AsyncIterator[AsyncGenerator[Any, None]]: + """Close a wrapped ADK generator when our wrapper closes. + + Re-yielding without this leaves ADK's generator to the event loop's + finalizer, which closes it from another task. ADK's OpenTelemetry spans then + fail to detach their context and log "Failed to detach context". + ``contextlib.aclosing`` needs Python 3.10. + """ + try: + yield agen + finally: + await agen.aclose() + + +def _content_text(content: Any) -> Optional[str]: + """Join the text parts of a ``types.Content``; None when it has no text. + + Thought parts (the model's reasoning) are left out, as ADK does for + ``output_key`` and AgentTool results. Gemini can answer STOP with a Content + whose ``parts`` is None. + """ + parts = getattr(content, "parts", None) or [] + text = "\n".join( + part.text for part in parts if getattr(part, "text", None) and not getattr(part, "thought", None) + ).strip() + return text or None + + +def _reply_text(content: Any) -> Optional[str]: + """The answer a final response carries, for agent and turn outputs. + + A final response can carry only a tool result: on google-adk 1.x an AgentTool + with ``skip_summarization`` ends the turn on its function response. + """ + text = _content_text(content) + if text: + return text + for part in getattr(content, "parts", None) or []: + function_response = getattr(part, "function_response", None) + response = getattr(function_response, "response", None) + if response is None: + continue + if isinstance(response, dict) and list(response) == ["result"]: + response = response["result"] + return response if isinstance(response, str) else json.dumps(response, default=str) + return None + + +def _event_transfer_target(event: Any) -> Optional[str]: + """The agent ADK hands control to after this event, if any.""" + target = getattr(getattr(event, "actions", None), "transfer_to_agent", None) + return str(target) if target else None + + +def _response_json(response: Any) -> str: + """Serialize an LLM response for ``raw_output``. + + ``inline_data`` bytes (generated images or audio) are left out, as on the + request side. mode="json" base64-encodes the remaining bytes, such as the + thought signatures on function calls. + """ + content = getattr(response, "content", None) + parts = getattr(content, "parts", None) + if content is not None and parts and any(getattr(getattr(p, "inline_data", None), "data", None) for p in parts): + parts = [ + part.model_copy(update={"inline_data": part.inline_data.model_copy(update={"data": None})}) + if getattr(getattr(part, "inline_data", None), "data", None) + else part + for part in parts + ] + response = response.model_copy(update={"content": content.model_copy(update={"parts": parts})}) + return json.dumps(response.model_dump(mode="json", exclude_none=True)) + + +def _llm_provider(model_name: Optional[str]) -> str: + """Return the cost-lookup provider slug for a model name. + + ``BaseLlmFlow._call_llm_async`` is model-agnostic (LiteLlm and + Claude-on-Vertex agents reach it too), so only Gemini models get the + "gemini" slug the backend prices with. Other models keep the old label. + """ + if _normalize_model_name(model_name).startswith("gemini"): + return GEMINI_PROVIDER + return "Google" + + def _record_step_error(step: Any, error: BaseException) -> None: """Record exception info on a step's metadata without overwriting output.""" if step is None: @@ -370,12 +579,11 @@ def _extract_messages_from_contents(contents: list) -> Dict[str, Any]: return {"messages": messages, "prompt": messages} -def _extract_llm_attributes(llm_request_dict: Dict[str, Any], llm_response: Optional[Any] = None) -> Dict[str, Any]: - """Extract LLM attributes from request and response. +def _extract_llm_attributes(llm_request_dict: Dict[str, Any]) -> Dict[str, Any]: + """Extract LLM attributes from a request. Args: llm_request_dict: Dictionary containing the LLM request data. - llm_response: Optional LLM response object. Returns: Dictionary containing extracted attributes for the step. @@ -426,42 +634,6 @@ def _extract_llm_attributes(llm_request_dict: Dict[str, Any], llm_response: Opti # No system instruction, use messages as-is attributes["inputs"] = messages_data - # Extract response data - if llm_response: - try: - response_dict = json.loads(llm_response) if isinstance(llm_response, str) else llm_response - - # Extract tokens from usage_metadata - if "usage_metadata" in response_dict: - usage = response_dict["usage_metadata"] - attributes["prompt_tokens"] = usage.get("prompt_token_count", 0) - attributes["completion_tokens"] = usage.get("candidates_token_count", 0) - attributes["total_tokens"] = usage.get("total_token_count", 0) - - # Extract response content - if "content" in response_dict and "parts" in response_dict["content"]: - parts = response_dict["content"]["parts"] - text_parts = [] - - for part in parts: - if "text" in part and part.get("text") is not None: - text_parts.append(str(part["text"])) - - if text_parts: - attributes["output"] = "\n".join(text_parts) - - # Store raw response - if isinstance(llm_response, str): - attributes["raw_output"] = llm_response - else: - try: - attributes["raw_output"] = json.dumps(response_dict) - except (TypeError, ValueError): - pass - - except Exception as e: - logger.debug(f"Failed to extract response attributes: {e}") - return attributes @@ -569,6 +741,251 @@ def extract_agent_attributes(instance: Any) -> Dict[str, Any]: # ----------------------------- Wrapper Functions ---------------------------- # +def _runner_run_async_wrapper() -> Any: + """Wrapper for Runner.run_async to make one trace per user turn. + + The turn step is set as the current step before ADK creates any asyncio + task, so the tasks ADK 2.x spawns for each agent inherit it. It also holds + the turn-level output and the handoff summary. + + The turn's context (current step and trace, the turn itself, ...) is only in + place while ADK's generator runs. Before each event goes to the caller, the + caller's own values are put back, and the turn's are put back in before + asking ADK for the next event. Otherwise a caller that stops iterating early + (``break`` on the final response) would keep the turn as its current step: + asyncio closes an abandoned generator from another task, so the turn could + never be unset, and every later turn or trace in that task would be nested + into it. Swapping on every step also makes the turn independent of which + task resumes it, e.g. one ``ensure_future(agen.__anext__())`` per event. + + Returns: + Decorator function that wraps the original method. + """ + + def actual_decorator(wrapped: Any, instance: Any, args: tuple, kwargs: dict) -> Any: + async def new_function(): + # A Runner started inside a turn (AgentTool runs its agent through + # one) is part of a tool call in the current turn, not a new turn. + if _current_turn.get() is not None: + async for event in _run_nested_runner(wrapped, args, kwargs): + yield event + return + + app_name = getattr(instance, "app_name", None) or "google_adk" + metadata: Dict[str, Any] = {"agent_type": "google_adk", "app_name": app_name} + if kwargs.get("session_id"): + metadata["session_id"] = kwargs["session_id"] + if kwargs.get("user_id"): + metadata["user_id"] = kwargs["user_id"] + user_query = _content_text(kwargs.get("new_message")) + + caller = _snapshot_context() + try: + with tracer.create_step( + name=f"Agent turn: {app_name}", + step_type=enums.StepType.AGENT, + inputs={"user_query": user_query or "No query provided"}, + metadata=metadata, + ) as step: + turn = _AdkTurn(step, tracer.get_current_trace(), user_query=user_query) + _current_turn.set(turn) + inner = _snapshot_context() + _apply_context(caller) + + async_gen = wrapped(*args, **kwargs) + error: Optional[BaseException] = None + try: + while True: + caller = _snapshot_context() + _apply_context(inner) + try: + event = await async_gen.__anext__() + except StopAsyncIteration: + break + finally: + inner = _snapshot_context() + _apply_context(caller) + _record_turn_event(turn, event) + yield event + except BaseException as e: + # GeneratorExit when the caller stops early; anything + # else is a failure ADK or the caller raised. + error = e + raise + finally: + caller = _snapshot_context() + _apply_context(inner) + try: + await async_gen.aclose() + finally: + _finish_turn(turn, error) + # Leaving create_step with the turn's context in place + # completes the trace (and publishes it when the turn is the + # root step), whichever task or context this runs in. + finally: + _apply_context(caller) + + return new_function() + + return actual_decorator + + +async def _run_nested_runner(wrapped: Any, args: tuple, kwargs: dict) -> AsyncGenerator[Any, None]: + """Run a Runner started inside a turn, with its own handoff scope. + + Its agents nest under the current step (the AgentTool's Tool step), and + transfers between them aren't the outer turn's handoffs. + """ + scope = _AdkTurn( + tracer.get_current_step(), + None, + user_query=_content_text(kwargs.get("new_message")), + nested=True, + ) + token = _current_turn.set(scope) + try: + async with _aclosing(wrapped(*args, **kwargs)) as async_gen: + async for event in async_gen: + yield event + finally: + _safe_reset_contextvar(_current_turn, token) + + +def _record_turn_event(turn: _AdkTurn, event: Any) -> None: + """Update the turn step from an event the Runner yields. + + The summary and end time are refreshed on every event: a caller that returns + on the final response (e.g. a @trace handler) can publish its trace before + the turn's generator is closed. + """ + try: + invocation_id = getattr(event, "invocation_id", None) + if invocation_id and "invocation_id" not in turn.step.metadata: + turn.step.metadata["invocation_id"] = invocation_id + + author = getattr(event, "author", None) + if author and author != "user": + content = getattr(event, "content", None) + target = _event_transfer_target(event) + if target: + if turn.starting_agent is None: + turn.starting_agent = author + # Control moved on, even if the target never says anything. + turn.final_agent = target + elif getattr(content, "parts", None): + # Events with no parts are skipped: ADK emits empty closing events + # from every agent on the way out (e.g. on resume), which don't + # say who answered. + if turn.starting_agent is None: + turn.starting_agent = author + turn.final_agent = author + is_final = getattr(event, "is_final_response", None) + if callable(is_final) and is_final(): + text = _reply_text(content) + if text: + turn.step.output = text + _update_turn_step(turn) + except Exception: # pragma: no cover - defensive: never break the user's loop + logger.debug("Failed to record a Google ADK turn event", exc_info=True) + + +def _update_turn_step(turn: _AdkTurn) -> None: + """Write the handoff summary to the turn step and the trace's columns.""" + summary = turn.summary() + step_summary = {**summary, "handoff_path": " > ".join(summary["handoff_path"])} + turn.step.metadata.update(step_summary) + now = time.time() + turn.step.end_time = now + turn.step.latency = (now - turn.step.start_time) * 1000 + + # The turn step's metadata only becomes columns when it is the root step; + # the trace metadata also covers turns run inside a user's @trace function. + trace = turn.trace + if trace is None: + return + summaries = _trace_turn_summaries.setdefault(trace, []) + if turn.trace_slot is None: + turn.trace_slot = len(summaries) + summaries.append(summary) + else: + summaries[turn.trace_slot] = summary + # Assigned directly: Trace.update_metadata skips None values, which would + # keep a previous turn's value next to this one's. + if trace.metadata is None: + trace.metadata = {} + trace.metadata.update(_combine_turn_summaries(summaries)) + + +def _combine_turn_summaries(summaries: List[Dict[str, Any]]) -> Dict[str, Any]: + """Columns for a trace that holds one or more turns, in order.""" + path: List[str] = [] + for summary in summaries: + for agent in summary["handoff_path"]: + if not path or path[-1] != agent: + path.append(agent) + starting_agents = [s["starting_agent"] for s in summaries if s["starting_agent"]] + final_agents = [s["final_agent"] for s in summaries if s["final_agent"]] + return { + "starting_agent": starting_agents[0] if starting_agents else None, + "final_agent": final_agents[-1] if final_agents else None, + "handoff_count": sum(s["handoff_count"] for s in summaries), + "handoff_path": " > ".join(path), + } + + +def _finish_turn(turn: _AdkTurn, error: Optional[BaseException]) -> None: + """Close the turn step: output, error and the final summary.""" + try: + if error is None or isinstance(error, GeneratorExit): + if not turn.step.output: + turn.step.output = "Agent execution completed" + else: + # Failed or cancelled: record why, and leave the output unset so the + # row doesn't look like a completed turn. + _record_step_error(turn.step, error) + _update_turn_step(turn) + _sort_steps_by_time(turn.step, recursive=True) + except Exception: # pragma: no cover - defensive: never break unwinding + logger.debug("Failed to finish a Google ADK turn", exc_info=True) + + +def _record_handoff( + turn: Optional[_AdkTurn], + agent_step: Any, + parent_step: Any, + from_agent: str, + to_agent: str, + event: Any, +) -> None: + """Record a transfer ADK made: a Handoff step under the transferring agent. + + Called for the event the transferring agent authors with + ``actions.transfer_to_agent`` set, which is what ADK acts on. It covers the + built-in ``transfer_to_agent`` tool, custom tools and callbacks that set the + action, and several transfer calls in one reply (ADK keeps the last one). + """ + metadata: Dict[str, Any] = {"tool_system": "google_adk"} + invocation_id = getattr(event, "invocation_id", None) + if invocation_id: + metadata["invocation_id"] = invocation_id + handoff = steps.step_factory( + step_type=enums.StepType.HANDOFF, + name=f"Handoff: {from_agent} → {to_agent}", + inputs={"agent_name": to_agent}, + output={"transferred_to": to_agent}, + metadata=metadata, + ) + handoff.start_time = handoff.end_time = time.time() + handoff.latency = 0 + handoff.from_component = from_agent + handoff.to_component = to_agent + agent_step.add_nested_step(handoff) + + if turn is not None: + turn.handoffs.append((from_agent, to_agent)) + turn.pending_transfers.append((to_agent, parent_step)) + + def _base_agent_run_async_wrapper() -> Any: """Wrapper for BaseAgent.run_async to create agent execution steps. @@ -588,8 +1005,20 @@ async def new_function(): # Wrap agent callbacks for tracing (if not already wrapped) _wrap_agent_callbacks(instance) - # Check if this is a sub-agent being called via transfer - transfer_parent = _agent_transfer_parent_step.get() + # An agent that a transfer handed control to is nested under the + # transferring agent's parent, not under whatever step is current + # (on ADK 1.x that's still inside the transferring agent's step, on + # 2.x it's wherever ADK's scheduler task was created). + turn = _current_turn.get() + transfer_parent = None + if turn is not None: + if turn.first_entered_agent is None: + turn.first_entered_agent = agent_name + for index, (pending_agent, pending_parent) in enumerate(turn.pending_transfers): + if pending_agent == agent_name: + del turn.pending_transfers[index] + transfer_parent = pending_parent + break # Reset the context variable for this agent execution (only for root agents) if transfer_parent is None: @@ -629,135 +1058,153 @@ async def new_function(): # Extract agent attributes agent_attrs = extract_agent_attributes(instance) - # Placeholder for user query - will be updated by LLM wrapper - inputs = {**agent_attrs, "user_query": "Processing..."} + # The first agent of a turn describes it, as the root agent step did + # before turns had their own step: its attributes are the turn's + # input columns. + if turn is not None and not turn.nested and not turn.root_attributes_set: + turn.root_attributes_set = True + turn.step.inputs = {**agent_attrs, "user_query": turn.step.inputs.get("user_query")} + if has_callbacks: + turn.step.metadata["callbacks"] = has_callbacks + + # Every agent in a turn answers the message the Runner was given. The + # LLM wrapper's last user message is only a fallback: on ADK 2.x a + # transferred agent's is ADK's "For context: ..." transcript. + turn_query = turn.user_query if turn is not None else None + inputs = {**agent_attrs, "user_query": turn_query or "Processing..."} + + # The parent this agent's step is created under, which is also where + # an agent it transfers to goes. + parent_step = transfer_parent if transfer_parent is not None else tracer.get_current_step() - # If we're in a transfer, create the step as a child of the transfer parent - # Otherwise, use normal context (child of current step) transfer_token = None if transfer_parent is not None: - logger.debug(f"Creating sub-agent step as sibling: {agent_name}") - # Temporarily set current step to transfer parent so new step becomes its child + logger.debug(f"Creating transferred agent step under its transferring agent's parent: {agent_name}") transfer_token = _tracer_current_step.set(transfer_parent) - # Clear the transfer parent so nested steps work normally - _agent_transfer_parent_step.set(None) - step_cm = tracer.create_step( - name=f"Agent: {agent_name}", step_type=enums.StepType.AGENT, inputs=inputs, metadata=metadata - ) - - # Use the step as a context manager and capture the actual step object - # Note: The step is created when entering the with block with the correct parent - with step_cm as step: - # Store the agent step so callbacks can use it as parent - # This ensures callbacks are siblings of LLM calls, not children - _current_agent_step.set(step) - - try: - # Execute the agent - async_gen = wrapped(*args, **kwargs) - final_response = None - user_query_updated = False - - async for event in async_gen: - # Update user_query as soon as it's available from LLM wrapper - # This ensures it's captured even if generator is abandoned early + try: + with tracer.create_step( + name=f"Agent: {agent_name}", step_type=enums.StepType.AGENT, inputs=inputs, metadata=metadata + ) as step: + # Store the agent step so callbacks and tool calls can use it as + # parent. This keeps them siblings of LLM calls, not children. + agent_step_token = _current_agent_step.set(step) + + user_query_updated = turn_query is not None + transferred = False + failed = False + try: + async with _aclosing(wrapped(*args, **kwargs)) as async_gen: + async for event in async_gen: + # Update user_query as soon as it's available from LLM wrapper + # This ensures it's captured even if generator is abandoned early + if not user_query_updated: + captured_query = _current_user_query.get() + if captured_query: + step.inputs["user_query"] = captured_query + user_query_updated = True + + author = getattr(event, "author", None) + target = _event_transfer_target(event) + if target and author == agent_name: + _record_handoff(turn, step, parent_step, agent_name, target, event) + transferred = True + elif hasattr(event, "is_final_response") and event.is_final_response(): + # On ADK 1.x the agents this one transferred to + # run inside it and their replies pass through + # here; they aren't this agent's output. Replies + # from its own sub-agents before any transfer + # are (workflow and custom agents). + if turn is None or not transferred or author == agent_name: + final_response = _reply_text(getattr(event, "content", None)) + if final_response: + step.output = final_response + + yield event + + except BaseException as e: + # GeneratorExit is ADK (or the caller) closing the + # generator early, which ADK 2.x does after a transfer. + if not isinstance(e, GeneratorExit): + failed = True + _record_step_error(step, e) + logger.debug("Agent execution raised; propagating: %s", e) + raise + finally: + # Fallbacks live here because ADK 2.x closes an agent's + # generator early (e.g. after a transfer), which skips any + # code after the loop. if not user_query_updated: captured_query = _current_user_query.get() - if captured_query: - step.inputs["user_query"] = captured_query - user_query_updated = True - - # Extract final response from events - if hasattr(event, "is_final_response") and event.is_final_response(): - if hasattr(event, "content") and event.content: - try: - final_response = event.content.parts[0].text.strip() - # Update step output IMMEDIATELY when captured - # This ensures it's set even if generator is abandoned - if final_response: - step.output = final_response - except (AttributeError, IndexError): - final_response = str(event.content) - if final_response: - step.output = final_response - - yield event - - # Fallback: Update user_query if not already set - if not user_query_updated: - captured_query = _current_user_query.get() - if captured_query: - step.inputs["user_query"] = captured_query - else: - step.inputs["user_query"] = "No query provided" - - # Fallback: Set default output if none was captured - if not step.output: - step.output = "Agent execution completed" - - except Exception as e: - _record_step_error(step, e) - logger.debug("Agent execution raised; propagating: %s", e) - raise - finally: - # Sort all nested steps recursively by start_time to ensure chronological order - # This fixes the issue where callbacks appear after LLM calls/tools - # even though they executed before/after them - _sort_steps_by_time(step, recursive=True) - logger.debug(f"Sorted nested steps by start_time (recursive)") - - # Clear the agent step context - _current_agent_step.set(None) - - # Restore the current step context if we changed it for transfer - # This must be done AFTER the with block exits - _safe_reset_contextvar(_tracer_current_step, transfer_token) + step.inputs["user_query"] = captured_query or "No query provided" + if not step.output and not failed: + step.output = "Agent execution completed" + + # Sort all nested steps recursively by start_time to ensure chronological order + # This fixes the issue where callbacks appear after LLM calls/tools + # even though they executed before/after them + _sort_steps_by_time(step, recursive=True) + + # Restore the enclosing agent step (None for a root agent) + _safe_reset_contextvar(_current_agent_step, agent_step_token) + finally: + # Restore the current step context if we changed it for transfer. + # This must be done AFTER the with block exits. + _safe_reset_contextvar(_tracer_current_step, transfer_token) return new_function() return actual_decorator -def _extract_usage_from_response(response: Any) -> Dict[str, int]: +def _extract_usage_from_response(response: Any) -> Dict[str, Any]: """Extract token usage from an LLM response object. Args: response: The LLM response object (can be various types). + Thinking tokens (``thoughts_token_count``) are counted as completion tokens, + because Gemini bills them as output. Same accounting as the Gen AI tracer. + Adapters for other APIs (LiteLlm, ApigeeLlm's chat-completions mode) already + include reasoning tokens in ``candidates_token_count``; the totals show it, + and then they aren't added again. + Returns: - Dictionary with prompt_tokens, completion_tokens, total_tokens. + Dictionary with prompt_tokens, completion_tokens, total_tokens, and + "breakdown" (the raw token split, for step metadata). """ - usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0} + usage: Dict[str, Any] = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0, "breakdown": {}} try: - # Check if response has usage_metadata attribute directly if hasattr(response, "usage_metadata"): usage_metadata = response.usage_metadata - if usage_metadata: - usage["prompt_tokens"] = getattr(usage_metadata, "prompt_token_count", 0) or 0 - usage["completion_tokens"] = getattr(usage_metadata, "candidates_token_count", 0) or 0 - usage["total_tokens"] = getattr(usage_metadata, "total_token_count", 0) or 0 - - # Check for dict-based response elif isinstance(response, dict): - if "usage_metadata" in response: - um = response["usage_metadata"] - usage["prompt_tokens"] = um.get("prompt_token_count", 0) or 0 - usage["completion_tokens"] = um.get("candidates_token_count", 0) or 0 - usage["total_tokens"] = um.get("total_token_count", 0) or 0 - - # Try to get from model_dump if available (Pydantic model) + usage_metadata = response.get("usage_metadata") elif hasattr(response, "model_dump"): - try: - resp_dict = response.model_dump() - if "usage_metadata" in resp_dict: - um = resp_dict["usage_metadata"] - usage["prompt_tokens"] = um.get("prompt_token_count", 0) or 0 - usage["completion_tokens"] = um.get("candidates_token_count", 0) or 0 - usage["total_tokens"] = um.get("total_token_count", 0) or 0 - except Exception: - pass + usage_metadata = response.model_dump().get("usage_metadata") + else: + usage_metadata = None + + if isinstance(usage_metadata, dict): + from google.genai import types + + usage_metadata = types.GenerateContentResponseUsageMetadata.model_validate(usage_metadata) + + if usage_metadata: + prompt_tokens, completion_tokens, total_tokens, breakdown = _extract_gemini_usage(usage_metadata) + candidates_tokens = getattr(usage_metadata, "candidates_token_count", None) or 0 + tool_use_tokens = getattr(usage_metadata, "tool_use_prompt_token_count", None) or 0 + reported_total = getattr(usage_metadata, "total_token_count", None) + if ( + completion_tokens > candidates_tokens + and isinstance(reported_total, int) + and prompt_tokens + candidates_tokens + tool_use_tokens >= reported_total + ): + completion_tokens = candidates_tokens + usage["prompt_tokens"] = prompt_tokens + usage["completion_tokens"] = completion_tokens + usage["total_tokens"] = total_tokens + usage["breakdown"] = breakdown except Exception as e: logger.debug(f"Failed to extract usage metadata: {e}") @@ -870,7 +1317,7 @@ async def new_function(): inputs = {} model_parameters = {} if llm_request_dict: - attrs = _extract_llm_attributes(llm_request_dict, None) + attrs = _extract_llm_attributes(llm_request_dict) if "inputs" in attrs: inputs = attrs["inputs"] if "model_parameters" in attrs: @@ -896,16 +1343,11 @@ async def new_function(): inputs=inputs, metadata=metadata, ) as step: - # Set ChatCompletionStep attributes - step.model = model_name - # NOTE: deliberately NOT the "gemini" cost slug used by the Gemini - # tracers. This wrapper is model-agnostic — BaseLlmFlow._call_llm_async - # resolves the LLM via __get_llm() and calls generate_content_async on - # whatever it gets, so LiteLlm and Claude-on-Vertex agents reach here - # too. Hardcoding "gemini" would pair it with e.g. a claude-* model and - # miss the cost lookup entirely. Fixing this properly means deriving the - # slug per-model; tracked separately. - step.provider = "Google" + # Set ChatCompletionStep attributes. The backend prices by an exact + # (provider, model) match, so Gemini models get the "gemini" slug + # and a bare model name (no Vertex resource path). + step.provider = _llm_provider(model_name) + step.model = _normalize_model_name(model_name) if step.provider == GEMINI_PROVIDER else model_name step.model_parameters = model_parameters # Store step in context for later updates (e.g., by callbacks) @@ -913,14 +1355,12 @@ async def new_function(): try: # Execute LLM call - async_gen = wrapped(*args, **kwargs) - collected_responses = [] last_response = None - async for item in async_gen: - collected_responses.append(item) - last_response = item - yield item + async with _aclosing(wrapped(*args, **kwargs)) as async_gen: + async for item in async_gen: + last_response = item + yield item # Extract usage metadata from the last response if last_response is not None: @@ -929,6 +1369,7 @@ async def new_function(): step.prompt_tokens = usage["prompt_tokens"] step.completion_tokens = usage["completion_tokens"] step.tokens = usage["total_tokens"] + step.metadata.update(usage["breakdown"]) logger.debug( f"Captured token usage: prompt={usage['prompt_tokens']}, " f"completion={usage['completion_tokens']}, " @@ -943,7 +1384,7 @@ async def new_function(): # Store raw response for debugging try: if hasattr(last_response, "model_dump"): - step.raw_output = json.dumps(last_response.model_dump(exclude_none=True)) + step.raw_output = _response_json(last_response) elif isinstance(last_response, dict): step.raw_output = json.dumps(last_response) except Exception: @@ -967,7 +1408,11 @@ async def new_function(): def _call_tool_async_wrapper() -> Any: - """Wrapper for __call_tool_async to create tool execution steps. + """Wrapper for ADK's tool-call function to create tool steps. + + Tool steps are nested under the calling agent's step, as siblings of its LLM + calls. Handoffs are recorded from ADK's events instead (see + ``_record_handoff``). Returns: Decorator function that wraps the original method. @@ -983,35 +1428,6 @@ async def new_function(): tool_name = getattr(tool, "name", "unknown_tool") tool_description = getattr(tool, "description", None) - # Check if this is an agent transfer (handoff) - is_agent_transfer = tool_name == "transfer_to_agent" or ( - tool_description and "transfer" in tool_description.lower() - ) - - # For agent transfers, don't create a step - let the agent step handle it - # But set the transfer parent so the sub-agent becomes a sibling of the LLM call - if is_agent_transfer: - logger.debug(f"Handling agent transfer: {tool_name}") - - # Get the current step's parent (should be the LLM call) - # We want the sub-agent to be a sibling of the LLM call, not a child - # So we need to get the grandparent (the main agent step) - current_step = tracer.get_current_step() - if current_step and hasattr(current_step, "parent_step") and current_step.parent_step: - # Set the LLM call's parent as the transfer parent - _agent_transfer_parent_step.set(current_step.parent_step) - logger.debug( - f"Set transfer parent to: {current_step.parent_step.name if hasattr(current_step.parent_step, 'name') else 'unknown'}" - ) - - try: - # Execute tool without creating a step - result = await wrapped(*args, **kwargs) - return result - finally: - # Clear the transfer parent after execution - _agent_transfer_parent_step.set(None) - # Build metadata with session info from tool_context metadata = {"tool_system": "google_adk"} if tool_description: @@ -1031,33 +1447,53 @@ async def new_function(): if hasattr(inv_ctx.session, "user_id"): metadata["user_id"] = inv_ctx.session.user_id - # Use tracer.create_step context manager - with tracer.create_step( - name=f"Tool: {tool_name}", step_type=enums.StepType.TOOL, inputs=tool_args, metadata=metadata - ) as step: - # Set ToolStep attributes - step.function_name = tool_name - step.arguments = tool_args - - try: - # Execute tool - result = await wrapped(*args, **kwargs) - - # Set output - if isinstance(result, dict): - step.output = result - else: - step.output = str(result) + # Nest under the agent step rather than the LLM call that requested + # the tool. The platform doesn't store usage for an LLM step that has + # child steps. + parent_token = None + agent_step = _current_agent_step.get() + if agent_step is not None: + parent_token = _tracer_current_step.set(agent_step) - return result - - except Exception as e: - _record_step_error(step, e) - logger.debug("Tool execution raised; propagating: %s", e) - raise - finally: - # Sort nested steps by start_time for correct chronological order - _sort_steps_by_time(step, recursive=True) + try: + # Use tracer.create_step context manager + with tracer.create_step( + name=f"Tool: {tool_name}", step_type=enums.StepType.TOOL, inputs=tool_args, metadata=metadata + ) as step: + # Set ToolStep attributes + step.function_name = tool_name + step.arguments = tool_args + + try: + # Execute tool + result = await wrapped(*args, **kwargs) + + # Set output + if isinstance(result, dict): + step.output = result + else: + step.output = str(result) + + except Exception as e: + _record_step_error(step, e) + logger.debug("Tool execution raised; propagating: %s", e) + raise + finally: + # Sort nested steps by start_time for correct chronological order + _sort_steps_by_time(step, recursive=True) + finally: + _safe_reset_contextvar(_tracer_current_step, parent_token) + + # A transfer_to_agent call that did transfer is shown by the Handoff + # step the transferring agent records from ADK's event, so its Tool + # step is dropped. A call that failed (e.g. no agent_name) stays. + if tool_name == ADK_TRANSFER_TOOL_NAME and getattr( + getattr(tool_context, "actions", None), "transfer_to_agent", None + ): + parent = agent_step if agent_step is not None else tracer.get_current_step() + if parent is not None and step in parent.steps: + parent.steps.remove(step) + return result return new_function() @@ -1090,6 +1526,7 @@ def actual_decorator(wrapped: Any, instance: Any, args: tuple, kwargs: dict) -> current_step.prompt_tokens = usage["prompt_tokens"] current_step.completion_tokens = usage["completion_tokens"] current_step.tokens = usage["total_tokens"] + current_step.metadata.update(usage["breakdown"]) # Extract and update output if not already set if not current_step.output: @@ -1166,7 +1603,7 @@ def _extract_callback_inputs(callback_type: str, args: tuple, kwargs: dict) -> D # Extract usage from response usage = _extract_usage_from_response(llm_response) if usage["total_tokens"] > 0: - inputs["usage"] = usage + inputs["usage"] = {key: value for key, value in usage.items() if key != "breakdown"} # Extract output text output_text = _extract_output_from_response(llm_response) if output_text: @@ -1223,12 +1660,11 @@ def _create_callback_wrapper(callback_name: str, callback_type: str) -> Callable This creates a wrapper that traces callback execution as a Function Call step. Callback hierarchy and timing: - - Model callbacks (before_model, after_model) are placed at the Agent level - as siblings of LLM calls - - Tool callbacks (before_tool, after_tool) are placed at the LLM level - as siblings of Tool steps - - "before_*" callbacks have their start_time adjusted to appear before - their associated operation when sorted + - All callbacks except the agent ones are placed at the Agent level, as + siblings of LLM calls and Tool steps + - before_model has its start_time moved just before the LLM call, because + ADK invokes it after the LLM step has started. Tool callbacks already + run before and after the tool step. Supported callback types: - before_agent: Called before the agent starts processing @@ -1248,15 +1684,12 @@ def _create_callback_wrapper(callback_name: str, callback_type: str) -> Callable Returns: A wrapper function that traces the callback. """ - # Determine the parent step for this callback: - # - Model callbacks (before_model, after_model) → Agent step (siblings of LLM calls) - # - Tool callbacks (before_tool, after_tool) → LLM step (siblings of Tool steps) - use_agent_parent = callback_type in ("before_model", "after_model") - use_llm_parent = callback_type in ("before_tool", "after_tool") + # Model and tool callbacks → Agent step (siblings of LLM calls and Tool steps) + use_agent_parent = callback_type in ("before_model", "after_model", "before_tool", "after_tool") - # "before_*" callbacks need their start_time adjusted to appear before - # their associated operation (since they're actually called after the operation starts) - is_before_callback = callback_type.startswith("before_") + # before_model runs after the LLM step has started, so its start_time is + # moved just before the LLM call's. + is_before_callback = callback_type == "before_model" def wrapper(original_callback: Callable) -> Callable: """Wrap the original callback with tracing.""" @@ -1275,18 +1708,11 @@ async def async_traced_callback(*args, **kwargs): reference_step = None if use_agent_parent: - # Model callbacks → Agent step (siblings of LLM calls) agent_step = _current_agent_step.get() if agent_step is not None: saved_token = _tracer_current_step.set(agent_step) # Reference for timing is the current LLM step reference_step = _current_llm_step.get() - elif use_llm_parent: - # Tool callbacks → LLM step (siblings of Tool steps) - llm_step = _current_llm_step.get() - if llm_step is not None: - saved_token = _tracer_current_step.set(llm_step) - reference_step = llm_step try: # Create a step for the callback @@ -1296,13 +1722,14 @@ async def async_traced_callback(*args, **kwargs): inputs=inputs, metadata={"callback_type": callback_type, "is_callback": True}, ) as step: - # Adjust start_time for "before_*" callbacks to appear before - # their associated operation when sorted by time + # Move before_model just ahead of its LLM call when sorted + # by time. The smallest possible step keeps it after + # everything that really started earlier (e.g. the + # previous tool's after_tool callback). if is_before_callback and reference_step is not None: ref_start = getattr(reference_step, "start_time", None) if ref_start is not None: - # Set start_time to be 1ms before the reference operation - step.start_time = ref_start - 0.001 + step.start_time = math.nextafter(ref_start, -math.inf) try: result = await original_callback(*args, **kwargs) @@ -1344,18 +1771,11 @@ def sync_traced_callback(*args, **kwargs): reference_step = None if use_agent_parent: - # Model callbacks → Agent step (siblings of LLM calls) agent_step = _current_agent_step.get() if agent_step is not None: saved_token = _tracer_current_step.set(agent_step) # Reference for timing is the current LLM step reference_step = _current_llm_step.get() - elif use_llm_parent: - # Tool callbacks → LLM step (siblings of Tool steps) - llm_step = _current_llm_step.get() - if llm_step is not None: - saved_token = _tracer_current_step.set(llm_step) - reference_step = llm_step try: # Create a step for the callback @@ -1365,13 +1785,14 @@ def sync_traced_callback(*args, **kwargs): inputs=inputs, metadata={"callback_type": callback_type, "is_callback": True}, ) as step: - # Adjust start_time for "before_*" callbacks to appear before - # their associated operation when sorted by time + # Move before_model just ahead of its LLM call when sorted + # by time. The smallest possible step keeps it after + # everything that really started earlier (e.g. the + # previous tool's after_tool callback). if is_before_callback and reference_step is not None: ref_start = getattr(reference_step, "start_time", None) if ref_start is not None: - # Set start_time to be 1ms before the reference operation - step.start_time = ref_start - 0.001 + step.start_time = math.nextafter(ref_start, -math.inf) try: result = original_callback(*args, **kwargs) @@ -1527,11 +1948,43 @@ def _patch_module_function(module_name: str, function_name: str, wrapper_functio logger.warning(f"Could not wrap {module_name}.{function_name}: {e}") +def _patch_tool_execution() -> None: + """Patch the function ADK runs each tool call through. + + It moved in google-adk 2.9 and 2.10 (see ``_TOOL_CALL_TARGETS``). + ``functions.py`` re-exports the moved function, but ADK calls it from the + module that defines it, so only that module can be patched. If it moves + again, the re-export's ``__module__`` still names the defining module. + """ + for module_name, function_name in _TOOL_CALL_TARGETS: + try: + module = importlib.import_module(module_name) + except ImportError: + continue + if hasattr(module, function_name): + _patch_module_function(module_name, function_name, _call_tool_async_wrapper) + return + try: + functions = importlib.import_module("google.adk.flows.llm_flows.functions") + except ImportError: + functions = None + reexported = getattr(functions, "_call_tool_async", None) + defining_module = getattr(reexported, "__module__", None) + if defining_module and hasattr(sys.modules.get(defining_module), "_call_tool_async"): + _patch_module_function(defining_module, "_call_tool_async", _call_tool_async_wrapper) + return + logger.warning( + "Could not find Google ADK's tool-call function (tried %s); tool calls and handoffs won't be traced.", + ", ".join(f"{module}.{function}" for module, function in _TOOL_CALL_TARGETS), + ) + + def _patch_google_adk() -> None: """Apply all patches to Google ADK modules. This function: - Optionally disables ADK's built-in OpenTelemetry tracing (if configured) + - Patches the runner (Runner.run_async), one trace per user turn - Patches agent execution (run_async) - Patches LLM calls (_call_llm_async) - Patches LLM response finalization @@ -1590,6 +2043,9 @@ def _patch_google_adk() -> None: "Telemetry will be sent to both Google Cloud (if configured) and Openlayer." ) + # Patch the runner (one trace per user turn) + _patch("google.adk.runners", "Runner", "run_async", _runner_run_async_wrapper) + # Patch agent execution _patch("google.adk.agents.base_agent", "BaseAgent", "run_async", _base_agent_run_async_wrapper) @@ -1609,8 +2065,8 @@ def _patch_google_adk() -> None: _finalize_model_response_event_wrapper, ) - # Patch tool execution - _patch_module_function("google.adk.flows.llm_flows.functions", "__call_tool_async", _call_tool_async_wrapper) + # Patch tool execution (including transfer_to_agent handoffs) + _patch_tool_execution() _google_adk_patched = True diff --git a/src/openlayer/resources/background_tasks.py b/src/openlayer/resources/background_tasks.py index b3ae6088..107f9648 100644 --- a/src/openlayer/resources/background_tasks.py +++ b/src/openlayer/resources/background_tasks.py @@ -52,11 +52,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BackgroundTaskRetrieveResponse: """ - Retrieve a background task's status, progress and results. - - Endpoints that cannot answer within one request queue a task and hand back its - id -- for example `POST /frameworks/{frameworkId}/export`. Poll this endpoint - until `complete` is `true`, then read what the task produced from `outputs`. + Retrieve a background task's status and outputs. Args: extra_headers: Send extra headers @@ -110,11 +106,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> BackgroundTaskRetrieveResponse: """ - Retrieve a background task's status, progress and results. - - Endpoints that cannot answer within one request queue a task and hand back its - id -- for example `POST /frameworks/{frameworkId}/export`. Poll this endpoint - until `complete` is `true`, then read what the task produced from `outputs`. + Retrieve a background task's status and outputs. Args: extra_headers: Send extra headers diff --git a/src/openlayer/resources/commits/commits.py b/src/openlayer/resources/commits/commits.py index 5b9ac98d..0aa75349 100644 --- a/src/openlayer/resources/commits/commits.py +++ b/src/openlayer/resources/commits/commits.py @@ -64,7 +64,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> CommitRetrieveResponse: """ - Retrieve a project version (commit) by its id. + Retrieve a project commit. Args: extra_headers: Send extra headers @@ -122,7 +122,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> CommitRetrieveResponse: """ - Retrieve a project version (commit) by its id. + Retrieve a project commit. Args: extra_headers: Send extra headers diff --git a/src/openlayer/resources/governance/frameworks/documents.py b/src/openlayer/resources/governance/frameworks/documents.py index b863d104..fad19066 100644 --- a/src/openlayer/resources/governance/frameworks/documents.py +++ b/src/openlayer/resources/governance/frameworks/documents.py @@ -55,11 +55,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> DocumentRetrieveResponse: """ - Retrieve a framework document, including its sections, subsections, and the - rules mapped to each. - - Each section and subsection carries a `ruleCount`, so you can tell which - requirements have rules mapped to them before drilling in. + Retrieve a framework document with its sections and rules. Args: extra_headers: Send extra headers @@ -100,10 +96,6 @@ def list( """ List the documents attached to a framework. - A document holds the text of the standard the framework is based on, split into - sections and subsections. Retrieve a single document to get that structure, - along with the rules mapped to each part of it. - Args: page: The page to return in a paginated query. @@ -171,11 +163,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> DocumentRetrieveResponse: """ - Retrieve a framework document, including its sections, subsections, and the - rules mapped to each. - - Each section and subsection carries a `ruleCount`, so you can tell which - requirements have rules mapped to them before drilling in. + Retrieve a framework document with its sections and rules. Args: extra_headers: Send extra headers @@ -216,10 +204,6 @@ async def list( """ List the documents attached to a framework. - A document holds the text of the standard the framework is based on, split into - sections and subsections. Retrieve a single document to get that structure, - along with the rules mapped to each part of it. - Args: page: The page to return in a paginated query. diff --git a/src/openlayer/resources/governance/frameworks/frameworks.py b/src/openlayer/resources/governance/frameworks/frameworks.py index 834eccbe..028918c8 100644 --- a/src/openlayer/resources/governance/frameworks/frameworks.py +++ b/src/openlayer/resources/governance/frameworks/frameworks.py @@ -112,14 +112,7 @@ def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkCreateResponse: """ - Create a custom governance framework in a workspace. - - Use this to track compliance against an internal policy, or against a standard - Openlayer does not ship as a built-in framework. A new framework starts with no - rules -- add them from the Openlayer app, or map an existing rule to it. - - A framework is created disabled unless you pass `enabled: true`. While it is - disabled its rules are not evaluated and do not count towards compliance. + Create a custom framework in a workspace. Args: name: The framework name. @@ -174,7 +167,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkRetrieveResponse: """ - Retrieve a governance framework by its id. + Retrieve a framework. Args: extra_headers: Send extra headers @@ -215,17 +208,7 @@ def update( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkUpdateResponse: """ - Update a governance framework. - - The most common use is activating or deactivating a framework for the workspace - by setting `enabled`. Rules of a disabled framework are not evaluated and do not - count towards compliance. - - Frameworks that ship with Openlayer report `immutable: true`. For those, only - `enabled`, `tags`, and `projectSelector` can be changed -- their name and - definition are managed by Openlayer. - - Only the fields you send are changed. + Update a framework. Args: avatar: The icon shown for the framework. @@ -303,12 +286,7 @@ def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkListResponse: """ - List the governance frameworks in a workspace. - - A framework is a set of rules -- drawn from a regulation, a standard, or your - own internal policy -- that Openlayer tracks compliance against. Use this - endpoint to find the framework you want to report on, then read its rules and - rule results. + List the frameworks in a workspace. Args: asc: Whether to sort in ascending order. @@ -386,32 +364,12 @@ def export( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkExportResponse: """ - Export a framework's evidence and progress as an audit-ready zip archive. - - The archive holds every evidence file uploaded against the framework's - evidence-based rules, a markdown report of the framework's progress and the - status of all its rules (broken down by documentation section when the framework - has documents), and CSV manifests of rules and evidence with SHA-256 checksums. - - Send `projectId` to export one project's compliance with the framework. Omit it - for the workspace-wide view across every project in the framework, including - workspace-scoped rules. - - The export runs as a background task, so this returns `202` immediately. To - collect the archive: - - 1. Poll `GET /background-tasks/{taskId}` with the returned `taskResultId` until - `complete` is `true`. - 2. Read `outputs.storageUri` off that task. - 3. Exchange it for a download link at - `GET /storage/presigned-url?storageUri=`. - - Rate limited to 2 requests per minute per framework. Asking for an export while - an identical one is still queued returns that task rather than starting a second - one. + Export a framework as an audit-ready zip archive. Args: - project_id: Scope the export to this project. It must belong to the framework. + project_id: Scope the export to this project. It must belong to the framework. Omit it for + the workspace-wide view across every project in the framework, including + workspace-scoped rules. extra_headers: Send extra headers @@ -460,11 +418,7 @@ def list_project_rule_stats( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkListProjectRuleStatsResponse: """ - Get a compliance roll-up for a framework, one row per project it applies to. - - Each row counts the project's rule results by status, so you can report on where - a framework is complete and where it is not without fetching every individual - rule result. + List a framework's compliance stats per project. Args: asc: Whether to sort in ascending order. @@ -521,9 +475,6 @@ def list_projects( """ List the projects a framework applies to. - Which projects a framework covers is determined by its `projectSelector`. A - framework with an empty selector applies to every project in the workspace. - Args: page: The page to return in a paginated query. @@ -571,11 +522,7 @@ def list_rules( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkListRulesResponse: """ - List the rules that belong to a framework. - - To read the compliance status of these rules, use - [List rule results](/api-reference/rest/governance/list-rule-results) with the - `frameworkId` filter, or fetch the results of an individual rule. + List the rules in a framework. Args: page: The page to return in a paginated query. @@ -660,14 +607,7 @@ async def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkCreateResponse: """ - Create a custom governance framework in a workspace. - - Use this to track compliance against an internal policy, or against a standard - Openlayer does not ship as a built-in framework. A new framework starts with no - rules -- add them from the Openlayer app, or map an existing rule to it. - - A framework is created disabled unless you pass `enabled: true`. While it is - disabled its rules are not evaluated and do not count towards compliance. + Create a custom framework in a workspace. Args: name: The framework name. @@ -722,7 +662,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkRetrieveResponse: """ - Retrieve a governance framework by its id. + Retrieve a framework. Args: extra_headers: Send extra headers @@ -763,17 +703,7 @@ async def update( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkUpdateResponse: """ - Update a governance framework. - - The most common use is activating or deactivating a framework for the workspace - by setting `enabled`. Rules of a disabled framework are not evaluated and do not - count towards compliance. - - Frameworks that ship with Openlayer report `immutable: true`. For those, only - `enabled`, `tags`, and `projectSelector` can be changed -- their name and - definition are managed by Openlayer. - - Only the fields you send are changed. + Update a framework. Args: avatar: The icon shown for the framework. @@ -851,12 +781,7 @@ async def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkListResponse: """ - List the governance frameworks in a workspace. - - A framework is a set of rules -- drawn from a regulation, a standard, or your - own internal policy -- that Openlayer tracks compliance against. Use this - endpoint to find the framework you want to report on, then read its rules and - rule results. + List the frameworks in a workspace. Args: asc: Whether to sort in ascending order. @@ -934,32 +859,12 @@ async def export( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkExportResponse: """ - Export a framework's evidence and progress as an audit-ready zip archive. - - The archive holds every evidence file uploaded against the framework's - evidence-based rules, a markdown report of the framework's progress and the - status of all its rules (broken down by documentation section when the framework - has documents), and CSV manifests of rules and evidence with SHA-256 checksums. - - Send `projectId` to export one project's compliance with the framework. Omit it - for the workspace-wide view across every project in the framework, including - workspace-scoped rules. - - The export runs as a background task, so this returns `202` immediately. To - collect the archive: - - 1. Poll `GET /background-tasks/{taskId}` with the returned `taskResultId` until - `complete` is `true`. - 2. Read `outputs.storageUri` off that task. - 3. Exchange it for a download link at - `GET /storage/presigned-url?storageUri=`. - - Rate limited to 2 requests per minute per framework. Asking for an export while - an identical one is still queued returns that task rather than starting a second - one. + Export a framework as an audit-ready zip archive. Args: - project_id: Scope the export to this project. It must belong to the framework. + project_id: Scope the export to this project. It must belong to the framework. Omit it for + the workspace-wide view across every project in the framework, including + workspace-scoped rules. extra_headers: Send extra headers @@ -1008,11 +913,7 @@ async def list_project_rule_stats( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkListProjectRuleStatsResponse: """ - Get a compliance roll-up for a framework, one row per project it applies to. - - Each row counts the project's rule results by status, so you can report on where - a framework is complete and where it is not without fetching every individual - rule result. + List a framework's compliance stats per project. Args: asc: Whether to sort in ascending order. @@ -1069,9 +970,6 @@ async def list_projects( """ List the projects a framework applies to. - Which projects a framework covers is determined by its `projectSelector`. A - framework with an empty selector applies to every project in the workspace. - Args: page: The page to return in a paginated query. @@ -1119,11 +1017,7 @@ async def list_rules( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> FrameworkListRulesResponse: """ - List the rules that belong to a framework. - - To read the compliance status of these rules, use - [List rule results](/api-reference/rest/governance/list-rule-results) with the - `frameworkId` filter, or fetch the results of an individual rule. + List the rules in a framework. Args: page: The page to return in a paginated query. diff --git a/src/openlayer/resources/governance/frameworks/sections.py b/src/openlayer/resources/governance/frameworks/sections.py index 7cfcb382..4236b0f8 100644 --- a/src/openlayer/resources/governance/frameworks/sections.py +++ b/src/openlayer/resources/governance/frameworks/sections.py @@ -62,11 +62,7 @@ def list_rules( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SectionListRulesResponse: """ - List the rules mapped to a section of a framework document. - - Pass `includeSubsectionRules=true` to also return the rules mapped to the - section's subsections, which is how you get every rule covering a requirement - and everything under it. + List the rules mapped to a document section. Args: include_results: Whether to include each rule's results inline, in a `results` array. @@ -159,11 +155,7 @@ async def list_rules( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SectionListRulesResponse: """ - List the rules mapped to a section of a framework document. - - Pass `includeSubsectionRules=true` to also return the rules mapped to the - section's subsections, which is how you get every rule covering a requirement - and everything under it. + List the rules mapped to a document section. Args: include_results: Whether to include each rule's results inline, in a `results` array. diff --git a/src/openlayer/resources/governance/frameworks/subsections.py b/src/openlayer/resources/governance/frameworks/subsections.py index 564325ce..c809ced7 100644 --- a/src/openlayer/resources/governance/frameworks/subsections.py +++ b/src/openlayer/resources/governance/frameworks/subsections.py @@ -61,11 +61,7 @@ def list_rules( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SubsectionListRulesResponse: """ - List the rules mapped to a subsection of a framework document. - - A subsection is usually the level at which a standard states an individual - requirement, so this is the endpoint to use when you want to show which rules - cover a specific clause. + List the rules mapped to a document subsection. Args: include_results: Whether to include each rule's results inline, in a `results` array. @@ -154,11 +150,7 @@ async def list_rules( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> SubsectionListRulesResponse: """ - List the rules mapped to a subsection of a framework document. - - A subsection is usually the level at which a standard states an individual - requirement, so this is the endpoint to use when you want to show which rules - cover a specific clause. + List the rules mapped to a document subsection. Args: include_results: Whether to include each rule's results inline, in a `results` array. diff --git a/src/openlayer/resources/governance/rule_results.py b/src/openlayer/resources/governance/rule_results.py index 563457aa..d48c639f 100644 --- a/src/openlayer/resources/governance/rule_results.py +++ b/src/openlayer/resources/governance/rule_results.py @@ -65,11 +65,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleResultRetrieveResponse: """ - Retrieve a rule result by its id. - - Alongside the status, the response carries the evaluation and renewal dates that - explain it: `dateLastEvaluated` and `dateOfNextEvaluation` for platform rules, - `dateOfLatestEvidence` and `dateOfRenewal` for evidence rules. + Retrieve a rule result. Args: extra_headers: Send extra headers @@ -106,15 +102,8 @@ def update( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleResultUpdateResponse: - """Update a rule result. - - Only the fields you send are changed. - - Use this to assign an owner, or to exclude a single result from compliance - without deactivating the rule everywhere. `deactivatedReason` is required when - setting `deactivated` to `true`. - - A result's `status` is computed by Openlayer and cannot be set directly. + """ + Update a rule result. Args: assignee_id: The user responsible for this result. @@ -123,9 +112,10 @@ def update( blocking: Rule results that this one blocks. - deactivated: Whether this result is excluded from compliance calculations. + deactivated: Whether this result is excluded from compliance calculations. Excludes just this + result, without deactivating the rule everywhere. - deactivated_reason: Why the result was excluded. + deactivated_reason: Why the result was excluded. Required when setting `deactivated` to `true`. extra_headers: Send extra headers @@ -178,12 +168,7 @@ def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleResultListResponse: """ - List rule results across a workspace. - - A rule result is the compliance status of one rule for one entity: a project for - project-scoped rules, or the workspace itself for workspace-scoped rules. This - is the endpoint to poll or export when you want your current compliance state, - filtered to a framework, a project, or a status. + List the rule results in a workspace. Args: enabled_framework_only: Only include items belonging to at least one enabled framework. @@ -262,16 +247,7 @@ def create_evidence( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleResultCreateEvidenceResponse: """ - Attach evidence to a rule result, satisfying an evidence rule. - - Send the field that matches the rule's `evidenceType`: `storageUri` for an - uploaded document, `text` for a written statement, or `url` for a link. - - For a document, upload the file first with `POST /storage/presigned-url` and - send the resulting storage URI as `storageUri`. - - Attaching evidence re-evaluates the rule result. If the rule sets - `renewalCadenceDays`, the renewal window restarts from this evidence. + Attach evidence to a rule result. Args: description: A description of what the evidence shows. @@ -279,7 +255,8 @@ def create_evidence( name: The evidence name. storage_uri: Where the uploaded file is stored. Set when the rule's `evidenceType` is - `document`. + `document`. Upload the file first with `POST /storage/presigned-url` and send + the storage URI it returns. text: The evidence text. Set when the rule's `evidenceType` is `text`. @@ -329,10 +306,6 @@ def list_evidence( """ List the evidence attached to a rule result. - Which field carries the evidence depends on the rule's `evidenceType`: - `storageUri` for uploaded documents, `text` for written statements, and `url` - for links. - Args: page: The page to return in a paginated query. @@ -399,11 +372,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleResultRetrieveResponse: """ - Retrieve a rule result by its id. - - Alongside the status, the response carries the evaluation and renewal dates that - explain it: `dateLastEvaluated` and `dateOfNextEvaluation` for platform rules, - `dateOfLatestEvidence` and `dateOfRenewal` for evidence rules. + Retrieve a rule result. Args: extra_headers: Send extra headers @@ -440,15 +409,8 @@ async def update( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleResultUpdateResponse: - """Update a rule result. - - Only the fields you send are changed. - - Use this to assign an owner, or to exclude a single result from compliance - without deactivating the rule everywhere. `deactivatedReason` is required when - setting `deactivated` to `true`. - - A result's `status` is computed by Openlayer and cannot be set directly. + """ + Update a rule result. Args: assignee_id: The user responsible for this result. @@ -457,9 +419,10 @@ async def update( blocking: Rule results that this one blocks. - deactivated: Whether this result is excluded from compliance calculations. + deactivated: Whether this result is excluded from compliance calculations. Excludes just this + result, without deactivating the rule everywhere. - deactivated_reason: Why the result was excluded. + deactivated_reason: Why the result was excluded. Required when setting `deactivated` to `true`. extra_headers: Send extra headers @@ -512,12 +475,7 @@ async def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleResultListResponse: """ - List rule results across a workspace. - - A rule result is the compliance status of one rule for one entity: a project for - project-scoped rules, or the workspace itself for workspace-scoped rules. This - is the endpoint to poll or export when you want your current compliance state, - filtered to a framework, a project, or a status. + List the rule results in a workspace. Args: enabled_framework_only: Only include items belonging to at least one enabled framework. @@ -596,16 +554,7 @@ async def create_evidence( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleResultCreateEvidenceResponse: """ - Attach evidence to a rule result, satisfying an evidence rule. - - Send the field that matches the rule's `evidenceType`: `storageUri` for an - uploaded document, `text` for a written statement, or `url` for a link. - - For a document, upload the file first with `POST /storage/presigned-url` and - send the resulting storage URI as `storageUri`. - - Attaching evidence re-evaluates the rule result. If the rule sets - `renewalCadenceDays`, the renewal window restarts from this evidence. + Attach evidence to a rule result. Args: description: A description of what the evidence shows. @@ -613,7 +562,8 @@ async def create_evidence( name: The evidence name. storage_uri: Where the uploaded file is stored. Set when the rule's `evidenceType` is - `document`. + `document`. Upload the file first with `POST /storage/presigned-url` and send + the storage URI it returns. text: The evidence text. Set when the rule's `evidenceType` is `text`. @@ -663,10 +613,6 @@ async def list_evidence( """ List the evidence attached to a rule result. - Which field carries the evidence depends on the rule's `evidenceType`: - `storageUri` for uploaded documents, `text` for written statements, and `url` - for links. - Args: page: The page to return in a paginated query. diff --git a/src/openlayer/resources/governance/rule_stats.py b/src/openlayer/resources/governance/rule_stats.py index 52edc7d5..98562417 100644 --- a/src/openlayer/resources/governance/rule_stats.py +++ b/src/openlayer/resources/governance/rule_stats.py @@ -55,11 +55,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleStatRetrieveResponse: """ - Get a compliance roll-up for a workspace: how many rules exist, and how many of - their results are passing, failing, pending, or due for renewal. - - Counts respect the filters you pass, so `frameworkId` gives you a single - framework's overall compliance and `projectId` gives you a single project's. + Get compliance statistics for a workspace. Args: framework_id: Only include items belonging to this framework. @@ -129,11 +125,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleStatRetrieveResponse: """ - Get a compliance roll-up for a workspace: how many rules exist, and how many of - their results are passing, failing, pending, or due for renewal. - - Counts respect the filters you pass, so `frameworkId` gives you a single - framework's overall compliance and `projectId` gives you a single project's. + Get compliance statistics for a workspace. Args: framework_id: Only include items belonging to this framework. diff --git a/src/openlayer/resources/governance/rule_tags.py b/src/openlayer/resources/governance/rule_tags.py index aea39688..3322e831 100644 --- a/src/openlayer/resources/governance/rule_tags.py +++ b/src/openlayer/resources/governance/rule_tags.py @@ -57,10 +57,6 @@ def list( """ List the rule tags in a workspace. - Tags group rules across frameworks, for example by team or by control family. - Use the ids returned here with the `tags` filter on - [List rules](/api-reference/rest/governance/list-rules). - Args: page: The page to return in a paginated query. @@ -131,10 +127,6 @@ async def list( """ List the rule tags in a workspace. - Tags group rules across frameworks, for example by team or by control family. - Use the ids returned here with the `tags` filter on - [List rules](/api-reference/rest/governance/list-rules). - Args: page: The page to return in a paginated query. diff --git a/src/openlayer/resources/governance/rules.py b/src/openlayer/resources/governance/rules.py index 04251098..2d89765e 100644 --- a/src/openlayer/resources/governance/rules.py +++ b/src/openlayer/resources/governance/rules.py @@ -69,47 +69,40 @@ def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleCreateResponse: - """Create a governance rule in a workspace. - - A rule is one requirement. - - Its `type` decides how it is satisfied, and the two - types accept different fields: - - - `platform` rules are evaluated automatically from the state of your workspace. - Set `automationType` to the signal to check. Their `scope` must be `project`, - and `evidenceType` and `renewalCadenceDays` must be omitted or `null`. - - `evidence` rules are satisfied by attaching evidence. Set `evidenceType` to - the kind of evidence that satisfies them. `automationType` and - `automationParams` must be omitted or `null`. - - A new rule belongs to no framework. Map it to one from the Openlayer app. + """ + Create a rule in a workspace. Args: name: The rule name. scope: Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. type: `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. assignee_id: The user responsible for satisfying the rule. - automation_params: Configuration for the platform check, when the automation takes parameters. + automation_params: Configuration for the platform check, when the automation takes parameters. Omit + or `null` for evidence rules. Fixed once the rule is created. automation_type: Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. deactivated: Whether the rule is excluded from compliance calculations. description: What the rule requires. - evidence_type: The kind of evidence that satisfies the rule. `null` for platform rules. + evidence_type: The kind of evidence that satisfies the rule. Set it for evidence rules; omit or + `null` for platform rules. Fixed once the rule is created. renewal_cadence_days: How often evidence must be renewed, in days. Once evidence is older than this, - the rule result becomes `due_soon` and then `failing`. + the rule result becomes `due_soon` and then `failing`. The window restarts + whenever evidence is attached. Omit or `null` for platform rules. tag_ids: The ids of the rule tags to associate with the rule. Replaces the rule's tags. Read them back from `tags`, and list the tags available in the workspace with @@ -161,8 +154,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleRetrieveResponse: """ - Retrieve a governance rule by its id, including the frameworks it belongs to and - its tags. + Retrieve a rule with its frameworks and tags. Args: extra_headers: Send extra headers @@ -200,14 +192,8 @@ def update( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleUpdateResponse: - """Update a governance rule. - - Only the fields you send are changed. - - Rules that ship with Openlayer report `immutable: true` and cannot be edited. - - A rule's `scope`, `type`, `evidenceType`, and automation are fixed once it - exists -- create a new rule instead of converting one. + """ + Update a rule. Args: assignee_id: The user responsible for satisfying the rule. @@ -219,7 +205,8 @@ def update( name: The rule name. renewal_cadence_days: How often evidence must be renewed, in days. Once evidence is older than this, - the rule result becomes `due_soon` and then `failing`. + the rule result becomes `due_soon` and then `failing`. The window restarts + whenever evidence is attached. Omit or `null` for platform rules. tag_ids: The ids of the rule tags to associate with the rule. Replaces the rule's tags. Read them back from `tags`, and list the tags available in the workspace with @@ -283,16 +270,7 @@ def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleListResponse: """ - List the governance rules in a workspace. - - A rule is a single requirement Openlayer tracks. `platform` rules are evaluated - automatically from the state of your workspace; `evidence` rules are satisfied - by attaching evidence. A rule can belong to several frameworks at once, and - rules that belong to none are returned too unless you pass - `includeUnframed=false`. - - Pass `includeResults=true` to get each rule's compliance results inline instead - of fetching them separately. + List the rules in a workspace. Args: asc: Whether to sort in ascending order. @@ -387,11 +365,7 @@ def delete( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: """ - Delete a governance rule and its rule results. - - Only rules you created can be deleted. Rules that ship with Openlayer report - `immutable: true` and cannot be deleted -- exclude one from compliance by - setting `deactivated` with `PUT /rules/{ruleId}` instead. + Delete a rule and its results. Args: extra_headers: Send extra headers @@ -456,47 +430,40 @@ async def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleCreateResponse: - """Create a governance rule in a workspace. - - A rule is one requirement. - - Its `type` decides how it is satisfied, and the two - types accept different fields: - - - `platform` rules are evaluated automatically from the state of your workspace. - Set `automationType` to the signal to check. Their `scope` must be `project`, - and `evidenceType` and `renewalCadenceDays` must be omitted or `null`. - - `evidence` rules are satisfied by attaching evidence. Set `evidenceType` to - the kind of evidence that satisfies them. `automationType` and - `automationParams` must be omitted or `null`. - - A new rule belongs to no framework. Map it to one from the Openlayer app. + """ + Create a rule in a workspace. Args: name: The rule name. scope: Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. type: `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. assignee_id: The user responsible for satisfying the rule. - automation_params: Configuration for the platform check, when the automation takes parameters. + automation_params: Configuration for the platform check, when the automation takes parameters. Omit + or `null` for evidence rules. Fixed once the rule is created. automation_type: Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. deactivated: Whether the rule is excluded from compliance calculations. description: What the rule requires. - evidence_type: The kind of evidence that satisfies the rule. `null` for platform rules. + evidence_type: The kind of evidence that satisfies the rule. Set it for evidence rules; omit or + `null` for platform rules. Fixed once the rule is created. renewal_cadence_days: How often evidence must be renewed, in days. Once evidence is older than this, - the rule result becomes `due_soon` and then `failing`. + the rule result becomes `due_soon` and then `failing`. The window restarts + whenever evidence is attached. Omit or `null` for platform rules. tag_ids: The ids of the rule tags to associate with the rule. Replaces the rule's tags. Read them back from `tags`, and list the tags available in the workspace with @@ -548,8 +515,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleRetrieveResponse: """ - Retrieve a governance rule by its id, including the frameworks it belongs to and - its tags. + Retrieve a rule with its frameworks and tags. Args: extra_headers: Send extra headers @@ -587,14 +553,8 @@ async def update( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleUpdateResponse: - """Update a governance rule. - - Only the fields you send are changed. - - Rules that ship with Openlayer report `immutable: true` and cannot be edited. - - A rule's `scope`, `type`, `evidenceType`, and automation are fixed once it - exists -- create a new rule instead of converting one. + """ + Update a rule. Args: assignee_id: The user responsible for satisfying the rule. @@ -606,7 +566,8 @@ async def update( name: The rule name. renewal_cadence_days: How often evidence must be renewed, in days. Once evidence is older than this, - the rule result becomes `due_soon` and then `failing`. + the rule result becomes `due_soon` and then `failing`. The window restarts + whenever evidence is attached. Omit or `null` for platform rules. tag_ids: The ids of the rule tags to associate with the rule. Replaces the rule's tags. Read them back from `tags`, and list the tags available in the workspace with @@ -670,16 +631,7 @@ async def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RuleListResponse: """ - List the governance rules in a workspace. - - A rule is a single requirement Openlayer tracks. `platform` rules are evaluated - automatically from the state of your workspace; `evidence` rules are satisfied - by attaching evidence. A rule can belong to several frameworks at once, and - rules that belong to none are returned too unless you pass - `includeUnframed=false`. - - Pass `includeResults=true` to get each rule's compliance results inline instead - of fetching them separately. + List the rules in a workspace. Args: asc: Whether to sort in ascending order. @@ -774,11 +726,7 @@ async def delete( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: """ - Delete a governance rule and its rule results. - - Only rules you created can be deleted. Rules that ship with Openlayer report - `immutable: true` and cannot be deleted -- exclude one from compliance by - setting `deactivated` with `PUT /rules/{ruleId}` instead. + Delete a rule and its results. Args: extra_headers: Send extra headers diff --git a/src/openlayer/resources/inference_pipelines/inference_pipelines.py b/src/openlayer/resources/inference_pipelines/inference_pipelines.py index 4336c552..edd4ef8b 100644 --- a/src/openlayer/resources/inference_pipelines/inference_pipelines.py +++ b/src/openlayer/resources/inference_pipelines/inference_pipelines.py @@ -101,7 +101,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InferencePipelineRetrieveResponse: """ - Retrieve inference pipeline. + Retrieve an inference pipeline. Args: expand: Expand specific nested objects. @@ -147,7 +147,7 @@ def update( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InferencePipelineUpdateResponse: """ - Update inference pipeline. + Update an inference pipeline. Args: description: The inference pipeline description. @@ -197,7 +197,7 @@ def delete( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: """ - Delete inference pipeline. + Delete an inference pipeline. Args: extra_headers: Send extra headers @@ -244,12 +244,7 @@ def retrieve_sessions( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InferencePipelineRetrieveSessionsResponse: """ - Get aggregated session data for an inference pipeline with pagination and - metadata. - - Returns a list of sessions for the inference pipeline, including activity - statistics such as record counts, token usage, cost, latency, and the first and - last records. + List the sessions in an inference pipeline, with their stats. Args: asc: Whether or not to sort on the sortColumn in ascending order. @@ -329,11 +324,7 @@ def retrieve_users( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InferencePipelineRetrieveUsersResponse: """ - Get aggregated user data for an inference pipeline with pagination and metadata. - - Returns a list of users who have interacted with the inference pipeline, - including their activity statistics such as session counts, record counts, token - usage, and costs. + List the users of an inference pipeline, with their stats. Args: asc: Whether or not to sort on the sortColumn in ascending order. @@ -436,7 +427,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InferencePipelineRetrieveResponse: """ - Retrieve inference pipeline. + Retrieve an inference pipeline. Args: expand: Expand specific nested objects. @@ -482,7 +473,7 @@ async def update( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InferencePipelineUpdateResponse: """ - Update inference pipeline. + Update an inference pipeline. Args: description: The inference pipeline description. @@ -532,7 +523,7 @@ async def delete( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: """ - Delete inference pipeline. + Delete an inference pipeline. Args: extra_headers: Send extra headers @@ -579,12 +570,7 @@ async def retrieve_sessions( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InferencePipelineRetrieveSessionsResponse: """ - Get aggregated session data for an inference pipeline with pagination and - metadata. - - Returns a list of sessions for the inference pipeline, including activity - statistics such as record counts, token usage, cost, latency, and the first and - last records. + List the sessions in an inference pipeline, with their stats. Args: asc: Whether or not to sort on the sortColumn in ascending order. @@ -664,11 +650,7 @@ async def retrieve_users( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InferencePipelineRetrieveUsersResponse: """ - Get aggregated user data for an inference pipeline with pagination and metadata. - - Returns a list of users who have interacted with the inference pipeline, - including their activity statistics such as session counts, record counts, token - usage, and costs. + List the users of an inference pipeline, with their stats. Args: asc: Whether or not to sort on the sortColumn in ascending order. diff --git a/src/openlayer/resources/inference_pipelines/rows.py b/src/openlayer/resources/inference_pipelines/rows.py index 5f2b18cc..34b8dae7 100644 --- a/src/openlayer/resources/inference_pipelines/rows.py +++ b/src/openlayer/resources/inference_pipelines/rows.py @@ -58,7 +58,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RowRetrieveResponse: """ - Fetch a single inference pipeline row by inference ID, including OTel steps. + Retrieve a row by inference ID, including OTel steps. Args: extra_headers: Send extra headers @@ -102,7 +102,7 @@ def update( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RowUpdateResponse: """ - Update an inference data point in an inference pipeline. + Update a row in an inference pipeline. Args: inference_id: Specify the inference id as a query param. @@ -163,7 +163,7 @@ def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RowListResponse: """ - A list of rows for an inference pipeline. + List the rows in an inference pipeline. Args: asc: Whether or not to sort on the sortColumn in ascending order. @@ -232,10 +232,8 @@ def delete( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: - """Delete a single inference pipeline row by inference ID. - - Only project admins can - perform this action. + """ + Delete a row by inference ID. Args: extra_headers: Send extra headers @@ -299,7 +297,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RowRetrieveResponse: """ - Fetch a single inference pipeline row by inference ID, including OTel steps. + Retrieve a row by inference ID, including OTel steps. Args: extra_headers: Send extra headers @@ -343,7 +341,7 @@ async def update( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RowUpdateResponse: """ - Update an inference data point in an inference pipeline. + Update a row in an inference pipeline. Args: inference_id: Specify the inference id as a query param. @@ -404,7 +402,7 @@ async def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> RowListResponse: """ - A list of rows for an inference pipeline. + List the rows in an inference pipeline. Args: asc: Whether or not to sort on the sortColumn in ascending order. @@ -473,10 +471,8 @@ async def delete( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: - """Delete a single inference pipeline row by inference ID. - - Only project admins can - perform this action. + """ + Delete a row by inference ID. Args: extra_headers: Send extra headers diff --git a/src/openlayer/resources/projects/tests.py b/src/openlayer/resources/projects/tests.py index ad54607d..1b6185c8 100644 --- a/src/openlayer/resources/projects/tests.py +++ b/src/openlayer/resources/projects/tests.py @@ -119,7 +119,7 @@ def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TestCreateResponse: """ - Create a test. + Create a test in a project. Args: description: The test description. @@ -212,7 +212,7 @@ def update( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TestUpdateResponse: """ - Update tests. + Update tests in a project. Args: extra_headers: Send extra headers @@ -253,7 +253,7 @@ def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TestListResponse: """ - List tests under a project. + List the tests in a project. Args: include_archived: Filter for archived tests. @@ -396,7 +396,7 @@ async def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TestCreateResponse: """ - Create a test. + Create a test in a project. Args: description: The test description. @@ -489,7 +489,7 @@ async def update( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TestUpdateResponse: """ - Update tests. + Update tests in a project. Args: extra_headers: Send extra headers @@ -530,7 +530,7 @@ async def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TestListResponse: """ - List tests under a project. + List the tests in a project. Args: include_archived: Filter for archived tests. diff --git a/src/openlayer/resources/storage/presigned_url.py b/src/openlayer/resources/storage/presigned_url.py index 42a7a1de..e76c70b8 100644 --- a/src/openlayer/resources/storage/presigned_url.py +++ b/src/openlayer/resources/storage/presigned_url.py @@ -54,7 +54,7 @@ def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> PresignedURLCreateResponse: """ - Retrieve a presigned url to post storage artifacts. + Get a presigned url to upload a file. Args: object_name: The name of the object. @@ -93,19 +93,11 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> PresignedURLRetrieveResponse: """ - Exchange a `storageUri` for a short-lived presigned url you can download the - object from. - - Use it to collect anything the platform stored on your behalf -- for example the - archive a framework export leaves behind, whose `storageUri` comes back in the - background task's `outputs`. - - The workspace is taken from the API key, so there is nothing else to send. The - url is only issued for objects your workspace owns, and `404` covers both "no - such object" and "not yours". + Get a short-lived download url for a stored object. Args: - storage_uri: The object's storage uri. + storage_uri: The object's storage uri, for example `outputs.storageUri` from a framework + export's background task. extra_headers: Send extra headers @@ -162,7 +154,7 @@ async def create( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> PresignedURLCreateResponse: """ - Retrieve a presigned url to post storage artifacts. + Get a presigned url to upload a file. Args: object_name: The name of the object. @@ -201,19 +193,11 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> PresignedURLRetrieveResponse: """ - Exchange a `storageUri` for a short-lived presigned url you can download the - object from. - - Use it to collect anything the platform stored on your behalf -- for example the - archive a framework export leaves behind, whose `storageUri` comes back in the - background task's `outputs`. - - The workspace is taken from the API key, so there is nothing else to send. The - url is only issued for objects your workspace owns, and `404` covers both "no - such object" and "not yours". + Get a short-lived download url for a stored object. Args: - storage_uri: The object's storage uri. + storage_uri: The object's storage uri, for example `outputs.storageUri` from a framework + export's background task. extra_headers: Send extra headers diff --git a/src/openlayer/resources/tests.py b/src/openlayer/resources/tests.py index 5f71a7c1..70b075ad 100644 --- a/src/openlayer/resources/tests.py +++ b/src/openlayer/resources/tests.py @@ -62,10 +62,7 @@ def evaluate( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TestEvaluateResponse: """ - Triggers one-off evaluation of a specific monitoring test for a custom timestamp - range. This allows evaluating tests for historical data or custom time periods - outside the regular evaluation window schedule. It also allows overwriting the - existing test results. + Evaluate a test over a custom time range. Args: end_timestamp: End timestamp in seconds (Unix epoch) @@ -214,10 +211,7 @@ async def evaluate( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> TestEvaluateResponse: """ - Triggers one-off evaluation of a specific monitoring test for a custom timestamp - range. This allows evaluating tests for historical data or custom time periods - outside the regular evaluation window schedule. It also allows overwriting the - existing test results. + Evaluate a test over a custom time range. Args: end_timestamp: End timestamp in seconds (Unix epoch) diff --git a/src/openlayer/resources/workspaces/api_keys.py b/src/openlayer/resources/workspaces/api_keys.py index 919c67bd..6688f14a 100644 --- a/src/openlayer/resources/workspaces/api_keys.py +++ b/src/openlayer/resources/workspaces/api_keys.py @@ -61,18 +61,17 @@ def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> APIKeyCreateResponse: - """Create a new API key in a workspace. - - The full secret is returned in `secret`, - only in this response. Optionally set `expiresAt`. When you authenticate with an - API key that expires, the new key can't outlive it: omit `expiresAt` to inherit - that expiry, and a later expiry (or `null`) is rejected with 400. + """Create a new API key. Args: - expires_at: When the key stops authenticating. `null` means the key never expires. Set when + expires_at: When the key stops authenticating. + + `null` means the key never expires. Set when the key is created or rotated, and must be in the future. When the request is authenticated with an API key that expires, the result can't be later than that - key's expiry. + key's expiry. On create, omit it to inherit that expiry. On rotate, omit it to + keep the current one. It can't be changed with an update; rotate the key + instead. name: The API key name. @@ -113,10 +112,8 @@ def retrieve( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> APIKeyRetrieveResponse: - """Retrieve one of your API keys, with its lifecycle status. - - The secret is never - returned; `secureKey` is an obfuscated hint. + """ + Retrieve an API key. Args: extra_headers: Send extra headers @@ -154,11 +151,8 @@ def update( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> APIKeyUpdateResponse: - """Rename one of your API keys. - - A key's expiry can't be updated; rotate the key - with a new `expiresAt` instead, so extending a key's life always issues a new - secret. + """ + Rename an API key. Args: name: The API key name. @@ -197,10 +191,8 @@ def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> APIKeyListResponse: - """List the API keys you own in a workspace, with their lifecycle status. - - Secrets - are never returned; `secureKey` is an obfuscated hint. + """ + List your API keys in a workspace. Args: extra_headers: Send extra headers @@ -233,10 +225,8 @@ def delete( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: - """Delete one of your API keys. - - Every secret for the key stops working immediately, - including a previous secret still in its rotation grace period. + """ + Delete an API key. Args: extra_headers: Send extra headers @@ -276,23 +266,20 @@ def rotate( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> APIKeyRotateResponse: - """Replace an API key's secret now. - - The new secret is returned in `secret`, only in - this response. Send `expiresAt` to change the key's expiry (`null` for never); - omit it to keep the current one. The previous secret keeps authenticating for - `gracePeriodHours` (default 0, so it stops working immediately), and never past - `expiresAt`. The key keeps its id and name. Expired keys cannot be rotated. Only - one previous secret is kept, so rotating again during a grace period retires the - older one immediately. + """ + Replace an API key's secret. Args: expires_at: When the key stops authenticating. `null` means the key never expires. Set when the key is created or rotated, and must be in the future. When the request is authenticated with an API key that expires, the result can't be later than that - key's expiry. + key's expiry. On create, omit it to inherit that expiry. On rotate, omit it to + keep the current one. It can't be changed with an update; rotate the key + instead. - grace_period_hours: Hours the previous secret keeps authenticating. + grace_period_hours: Hours the previous secret keeps authenticating. The default of 0 retires it + immediately. It never outlives `expiresAt`. Only one previous secret is kept, so + rotating again during a grace period retires the older one immediately. extra_headers: Send extra headers @@ -359,18 +346,17 @@ async def create( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> APIKeyCreateResponse: - """Create a new API key in a workspace. - - The full secret is returned in `secret`, - only in this response. Optionally set `expiresAt`. When you authenticate with an - API key that expires, the new key can't outlive it: omit `expiresAt` to inherit - that expiry, and a later expiry (or `null`) is rejected with 400. + """Create a new API key. Args: - expires_at: When the key stops authenticating. `null` means the key never expires. Set when + expires_at: When the key stops authenticating. + + `null` means the key never expires. Set when the key is created or rotated, and must be in the future. When the request is authenticated with an API key that expires, the result can't be later than that - key's expiry. + key's expiry. On create, omit it to inherit that expiry. On rotate, omit it to + keep the current one. It can't be changed with an update; rotate the key + instead. name: The API key name. @@ -411,10 +397,8 @@ async def retrieve( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> APIKeyRetrieveResponse: - """Retrieve one of your API keys, with its lifecycle status. - - The secret is never - returned; `secureKey` is an obfuscated hint. + """ + Retrieve an API key. Args: extra_headers: Send extra headers @@ -452,11 +436,8 @@ async def update( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> APIKeyUpdateResponse: - """Rename one of your API keys. - - A key's expiry can't be updated; rotate the key - with a new `expiresAt` instead, so extending a key's life always issues a new - secret. + """ + Rename an API key. Args: name: The API key name. @@ -495,10 +476,8 @@ async def list( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> APIKeyListResponse: - """List the API keys you own in a workspace, with their lifecycle status. - - Secrets - are never returned; `secureKey` is an obfuscated hint. + """ + List your API keys in a workspace. Args: extra_headers: Send extra headers @@ -531,10 +510,8 @@ async def delete( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> None: - """Delete one of your API keys. - - Every secret for the key stops working immediately, - including a previous secret still in its rotation grace period. + """ + Delete an API key. Args: extra_headers: Send extra headers @@ -574,23 +551,20 @@ async def rotate( extra_body: Body | None = None, timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> APIKeyRotateResponse: - """Replace an API key's secret now. - - The new secret is returned in `secret`, only in - this response. Send `expiresAt` to change the key's expiry (`null` for never); - omit it to keep the current one. The previous secret keeps authenticating for - `gracePeriodHours` (default 0, so it stops working immediately), and never past - `expiresAt`. The key keeps its id and name. Expired keys cannot be rotated. Only - one previous secret is kept, so rotating again during a grace period retires the - older one immediately. + """ + Replace an API key's secret. Args: expires_at: When the key stops authenticating. `null` means the key never expires. Set when the key is created or rotated, and must be in the future. When the request is authenticated with an API key that expires, the result can't be later than that - key's expiry. + key's expiry. On create, omit it to inherit that expiry. On rotate, omit it to + keep the current one. It can't be changed with an update; rotate the key + instead. - grace_period_hours: Hours the previous secret keeps authenticating. + grace_period_hours: Hours the previous secret keeps authenticating. The default of 0 retires it + immediately. It never outlives `expiresAt`. Only one previous secret is kept, so + rotating again during a grace period retires the older one immediately. extra_headers: Send extra headers diff --git a/src/openlayer/resources/workspaces/invites.py b/src/openlayer/resources/workspaces/invites.py index c7033989..ca18db62 100644 --- a/src/openlayer/resources/workspaces/invites.py +++ b/src/openlayer/resources/workspaces/invites.py @@ -102,7 +102,7 @@ def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InviteListResponse: """ - Retrieve a list of invites in a workspace. + List the invites in a workspace. Args: page: The page to return in a paginated query. @@ -216,7 +216,7 @@ async def list( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> InviteListResponse: """ - Retrieve a list of invites in a workspace. + List the invites in a workspace. Args: page: The page to return in a paginated query. diff --git a/src/openlayer/resources/workspaces/workspaces.py b/src/openlayer/resources/workspaces/workspaces.py index 2f5f8665..4f2168a2 100644 --- a/src/openlayer/resources/workspaces/workspaces.py +++ b/src/openlayer/resources/workspaces/workspaces.py @@ -78,7 +78,7 @@ def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> WorkspaceRetrieveResponse: """ - Retrieve a workspace by its ID. + Retrieve a workspace. Args: extra_headers: Send extra headers @@ -190,7 +190,7 @@ async def retrieve( timeout: float | httpx.Timeout | None | NotGiven = not_given, ) -> WorkspaceRetrieveResponse: """ - Retrieve a workspace by its ID. + Retrieve a workspace. Args: extra_headers: Send extra headers diff --git a/src/openlayer/types/background_task_retrieve_response.py b/src/openlayer/types/background_task_retrieve_response.py index fb2ce9ac..131f5266 100644 --- a/src/openlayer/types/background_task_retrieve_response.py +++ b/src/openlayer/types/background_task_retrieve_response.py @@ -11,6 +11,10 @@ class BackgroundTaskRetrieveResponse(BaseModel): + """ + A job queued by an endpoint that can't answer within one request, such as a framework export. Poll it until `complete` is `true`, then read what it produced from `outputs`. + """ + id: str """The background task id.""" diff --git a/src/openlayer/types/governance/framework_create_response.py b/src/openlayer/types/governance/framework_create_response.py index 4ff9942b..876d1661 100644 --- a/src/openlayer/types/governance/framework_create_response.py +++ b/src/openlayer/types/governance/framework_create_response.py @@ -130,6 +130,10 @@ class RuleStats(BaseModel): class FrameworkCreateResponse(BaseModel): + """ + A set of rules, drawn from a regulation, a standard, or your own internal policy, that Openlayer tracks compliance against. Openlayer ships built-in frameworks, and you can create your own. + """ + id: str """The framework id.""" @@ -178,7 +182,11 @@ class FrameworkCreateResponse(BaseModel): """A link to the external standard or regulation the framework is based on.""" immutable: Optional[bool] = None - """Whether the framework definition is managed by Openlayer and cannot be edited.""" + """Whether the framework definition is managed by Openlayer. + + For these frameworks only `enabled`, `tags`, and `projectSelector` can be + changed. + """ project_selector: Optional[ProjectSelector] = FieldInfo(alias="projectSelector", default=None) """Determines which projects the framework applies to. diff --git a/src/openlayer/types/governance/framework_export_params.py b/src/openlayer/types/governance/framework_export_params.py index 43b661ae..c8022abe 100644 --- a/src/openlayer/types/governance/framework_export_params.py +++ b/src/openlayer/types/governance/framework_export_params.py @@ -12,4 +12,8 @@ class FrameworkExportParams(TypedDict, total=False): project_id: Annotated[Optional[str], PropertyInfo(alias="projectId")] - """Scope the export to this project. It must belong to the framework.""" + """Scope the export to this project. + + It must belong to the framework. Omit it for the workspace-wide view across + every project in the framework, including workspace-scoped rules. + """ diff --git a/src/openlayer/types/governance/framework_list_project_rule_stats_response.py b/src/openlayer/types/governance/framework_list_project_rule_stats_response.py index 7a4e7749..092f8d0e 100644 --- a/src/openlayer/types/governance/framework_list_project_rule_stats_response.py +++ b/src/openlayer/types/governance/framework_list_project_rule_stats_response.py @@ -76,6 +76,8 @@ class ItemByRuleType(BaseModel): class Item(BaseModel): + """One project's rule result counts by status, for a single framework.""" + project_id: str = FieldInfo(alias="projectId") """The project id.""" diff --git a/src/openlayer/types/governance/framework_list_response.py b/src/openlayer/types/governance/framework_list_response.py index f917e020..5c64dd7e 100644 --- a/src/openlayer/types/governance/framework_list_response.py +++ b/src/openlayer/types/governance/framework_list_response.py @@ -131,6 +131,10 @@ class ItemRuleStats(BaseModel): class Item(BaseModel): + """ + A set of rules, drawn from a regulation, a standard, or your own internal policy, that Openlayer tracks compliance against. Openlayer ships built-in frameworks, and you can create your own. + """ + id: str """The framework id.""" @@ -179,7 +183,11 @@ class Item(BaseModel): """A link to the external standard or regulation the framework is based on.""" immutable: Optional[bool] = None - """Whether the framework definition is managed by Openlayer and cannot be edited.""" + """Whether the framework definition is managed by Openlayer. + + For these frameworks only `enabled`, `tags`, and `projectSelector` can be + changed. + """ project_selector: Optional[ItemProjectSelector] = FieldInfo(alias="projectSelector", default=None) """Determines which projects the framework applies to. diff --git a/src/openlayer/types/governance/framework_list_rules_response.py b/src/openlayer/types/governance/framework_list_rules_response.py index 53698c24..8842ee0b 100644 --- a/src/openlayer/types/governance/framework_list_rules_response.py +++ b/src/openlayer/types/governance/framework_list_rules_response.py @@ -58,17 +58,27 @@ class ItemResultBlockedBy(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ItemResultBlocking(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ItemResult(BaseModel): + """ + The compliance status of one rule for one entity: a project for project-scoped rules, or the workspace for workspace-scoped rules. + """ + id: str """The rule result id.""" @@ -79,13 +89,19 @@ class ItemResult(BaseModel): """The last update date.""" deactivated: bool - """Whether this result is excluded from compliance calculations.""" + """Whether this result is excluded from compliance calculations. + + Excludes just this result, without deactivating the rule everywhere. + """ rule_id: str = FieldInfo(alias="ruleId") """The rule this result belongs to.""" status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ workspace_id: str = FieldInfo(alias="workspaceId") """The id of the workspace the rule result belongs to.""" @@ -112,7 +128,7 @@ class ItemResult(BaseModel): """When the evidence must be renewed. Evidence rules with a renewal cadence only.""" deactivated_reason: Optional[str] = FieldInfo(alias="deactivatedReason", default=None) - """Why the result was excluded.""" + """Why the result was excluded. Required when setting `deactivated` to `true`.""" project_id: Optional[str] = FieldInfo(alias="projectId", default=None) """The project this result was evaluated for. `null` for workspace-scoped rules.""" @@ -132,6 +148,10 @@ class ItemResultsSummary(BaseModel): class ItemTag(BaseModel): + """ + A label that groups rules across frameworks, for example by team or control family. + """ + id: str """The rule tag id.""" @@ -145,7 +165,7 @@ class ItemTag(BaseModel): """The last update date.""" immutable: bool - """Whether the tag is managed by Openlayer and cannot be edited or deleted.""" + """Whether the tag is managed by Openlayer. These tags can't be deleted.""" name: str """The tag name.""" @@ -158,6 +178,11 @@ class ItemTag(BaseModel): class Item(BaseModel): + """A single requirement Openlayer tracks. + + `platform` rules are evaluated automatically from the state of your workspace, and `evidence` rules are satisfied by attaching evidence. A rule can belong to several frameworks, or to none. + """ + id: str """The rule id.""" @@ -173,13 +198,15 @@ class Item(BaseModel): scope: Literal["project", "workspace"] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Literal["platform", "evidence"] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ workspace_id: str = FieldInfo(alias="workspaceId") @@ -189,13 +216,17 @@ class Item(BaseModel): """The user responsible for satisfying the rule.""" automation_params: Optional[Dict[str, object]] = FieldInfo(alias="automationParams", default=None) - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Optional[str] = FieldInfo(alias="automationType", default=None) """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ deactivated: Optional[bool] = None @@ -207,19 +238,28 @@ class Item(BaseModel): evidence_type: Optional[Literal["document", "text", "url", "categoryValue"]] = FieldInfo( alias="evidenceType", default=None ) - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ frameworks: Optional[List[ItemFramework]] = None """The frameworks that include this rule.""" immutable: Optional[bool] = None - """Whether the rule is managed by Openlayer and cannot be edited.""" + """Whether the rule is managed by Openlayer. + + These rules can't be renamed or deleted; set `deactivated` to exclude one from + compliance instead. + """ renewal_cadence_days: Optional[int] = FieldInfo(alias="renewalCadenceDays", default=None) """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ results: Optional[List[ItemResult]] = None diff --git a/src/openlayer/types/governance/framework_retrieve_response.py b/src/openlayer/types/governance/framework_retrieve_response.py index 6c3f37da..7ad3d287 100644 --- a/src/openlayer/types/governance/framework_retrieve_response.py +++ b/src/openlayer/types/governance/framework_retrieve_response.py @@ -130,6 +130,10 @@ class RuleStats(BaseModel): class FrameworkRetrieveResponse(BaseModel): + """ + A set of rules, drawn from a regulation, a standard, or your own internal policy, that Openlayer tracks compliance against. Openlayer ships built-in frameworks, and you can create your own. + """ + id: str """The framework id.""" @@ -178,7 +182,11 @@ class FrameworkRetrieveResponse(BaseModel): """A link to the external standard or regulation the framework is based on.""" immutable: Optional[bool] = None - """Whether the framework definition is managed by Openlayer and cannot be edited.""" + """Whether the framework definition is managed by Openlayer. + + For these frameworks only `enabled`, `tags`, and `projectSelector` can be + changed. + """ project_selector: Optional[ProjectSelector] = FieldInfo(alias="projectSelector", default=None) """Determines which projects the framework applies to. diff --git a/src/openlayer/types/governance/framework_update_response.py b/src/openlayer/types/governance/framework_update_response.py index ff9de4cc..fbe57f53 100644 --- a/src/openlayer/types/governance/framework_update_response.py +++ b/src/openlayer/types/governance/framework_update_response.py @@ -130,6 +130,10 @@ class RuleStats(BaseModel): class FrameworkUpdateResponse(BaseModel): + """ + A set of rules, drawn from a regulation, a standard, or your own internal policy, that Openlayer tracks compliance against. Openlayer ships built-in frameworks, and you can create your own. + """ + id: str """The framework id.""" @@ -178,7 +182,11 @@ class FrameworkUpdateResponse(BaseModel): """A link to the external standard or regulation the framework is based on.""" immutable: Optional[bool] = None - """Whether the framework definition is managed by Openlayer and cannot be edited.""" + """Whether the framework definition is managed by Openlayer. + + For these frameworks only `enabled`, `tags`, and `projectSelector` can be + changed. + """ project_selector: Optional[ProjectSelector] = FieldInfo(alias="projectSelector", default=None) """Determines which projects the framework applies to. diff --git a/src/openlayer/types/governance/frameworks/document_list_response.py b/src/openlayer/types/governance/frameworks/document_list_response.py index f88a8a49..feb574a9 100644 --- a/src/openlayer/types/governance/frameworks/document_list_response.py +++ b/src/openlayer/types/governance/frameworks/document_list_response.py @@ -28,23 +28,29 @@ class ItemSectionRule(BaseModel): scope: Literal["project", "workspace"] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Literal["platform", "evidence"] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ automation_params: Optional[Dict[str, object]] = FieldInfo(alias="automationParams", default=None) - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Optional[str] = FieldInfo(alias="automationType", default=None) """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ date_created: Optional[datetime] = FieldInfo(alias="dateCreated", default=None) @@ -62,16 +68,25 @@ class ItemSectionRule(BaseModel): evidence_type: Optional[Literal["document", "text", "url", "categoryValue"]] = FieldInfo( alias="evidenceType", default=None ) - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ immutable: Optional[bool] = None - """Whether the rule is managed by Openlayer and cannot be edited.""" + """Whether the rule is managed by Openlayer. + + These rules can't be renamed or deleted; set `deactivated` to exclude one from + compliance instead. + """ renewal_cadence_days: Optional[int] = FieldInfo(alias="renewalCadenceDays", default=None) """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ @@ -85,23 +100,29 @@ class ItemSectionSubsectionRule(BaseModel): scope: Literal["project", "workspace"] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Literal["platform", "evidence"] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ automation_params: Optional[Dict[str, object]] = FieldInfo(alias="automationParams", default=None) - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Optional[str] = FieldInfo(alias="automationType", default=None) """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ date_created: Optional[datetime] = FieldInfo(alias="dateCreated", default=None) @@ -119,20 +140,33 @@ class ItemSectionSubsectionRule(BaseModel): evidence_type: Optional[Literal["document", "text", "url", "categoryValue"]] = FieldInfo( alias="evidenceType", default=None ) - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ immutable: Optional[bool] = None - """Whether the rule is managed by Openlayer and cannot be edited.""" + """Whether the rule is managed by Openlayer. + + These rules can't be renamed or deleted; set `deactivated` to exclude one from + compliance instead. + """ renewal_cadence_days: Optional[int] = FieldInfo(alias="renewalCadenceDays", default=None) """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ class ItemSectionSubsection(BaseModel): + """ + A subsection usually states one individual requirement of the standard, so it is where you see which rules cover a specific clause. + """ + id: str """The subsection id.""" @@ -191,6 +225,10 @@ class ItemSection(BaseModel): class Item(BaseModel): + """ + The text of the standard a framework is based on, split into sections and subsections, with the rules mapped to each part. + """ + id: str """The document id.""" diff --git a/src/openlayer/types/governance/frameworks/document_retrieve_response.py b/src/openlayer/types/governance/frameworks/document_retrieve_response.py index cf22e299..7f848720 100644 --- a/src/openlayer/types/governance/frameworks/document_retrieve_response.py +++ b/src/openlayer/types/governance/frameworks/document_retrieve_response.py @@ -21,23 +21,29 @@ class SectionRule(BaseModel): scope: Literal["project", "workspace"] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Literal["platform", "evidence"] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ automation_params: Optional[Dict[str, object]] = FieldInfo(alias="automationParams", default=None) - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Optional[str] = FieldInfo(alias="automationType", default=None) """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ date_created: Optional[datetime] = FieldInfo(alias="dateCreated", default=None) @@ -55,16 +61,25 @@ class SectionRule(BaseModel): evidence_type: Optional[Literal["document", "text", "url", "categoryValue"]] = FieldInfo( alias="evidenceType", default=None ) - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ immutable: Optional[bool] = None - """Whether the rule is managed by Openlayer and cannot be edited.""" + """Whether the rule is managed by Openlayer. + + These rules can't be renamed or deleted; set `deactivated` to exclude one from + compliance instead. + """ renewal_cadence_days: Optional[int] = FieldInfo(alias="renewalCadenceDays", default=None) """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ @@ -78,23 +93,29 @@ class SectionSubsectionRule(BaseModel): scope: Literal["project", "workspace"] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Literal["platform", "evidence"] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ automation_params: Optional[Dict[str, object]] = FieldInfo(alias="automationParams", default=None) - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Optional[str] = FieldInfo(alias="automationType", default=None) """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ date_created: Optional[datetime] = FieldInfo(alias="dateCreated", default=None) @@ -112,20 +133,33 @@ class SectionSubsectionRule(BaseModel): evidence_type: Optional[Literal["document", "text", "url", "categoryValue"]] = FieldInfo( alias="evidenceType", default=None ) - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ immutable: Optional[bool] = None - """Whether the rule is managed by Openlayer and cannot be edited.""" + """Whether the rule is managed by Openlayer. + + These rules can't be renamed or deleted; set `deactivated` to exclude one from + compliance instead. + """ renewal_cadence_days: Optional[int] = FieldInfo(alias="renewalCadenceDays", default=None) """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ class SectionSubsection(BaseModel): + """ + A subsection usually states one individual requirement of the standard, so it is where you see which rules cover a specific clause. + """ + id: str """The subsection id.""" @@ -184,6 +218,10 @@ class Section(BaseModel): class DocumentRetrieveResponse(BaseModel): + """ + The text of the standard a framework is based on, split into sections and subsections, with the rules mapped to each part. + """ + id: str """The document id.""" diff --git a/src/openlayer/types/governance/frameworks/section_list_rules_response.py b/src/openlayer/types/governance/frameworks/section_list_rules_response.py index 75b829b5..66ee0ad5 100644 --- a/src/openlayer/types/governance/frameworks/section_list_rules_response.py +++ b/src/openlayer/types/governance/frameworks/section_list_rules_response.py @@ -58,17 +58,27 @@ class ItemResultBlockedBy(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ItemResultBlocking(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ItemResult(BaseModel): + """ + The compliance status of one rule for one entity: a project for project-scoped rules, or the workspace for workspace-scoped rules. + """ + id: str """The rule result id.""" @@ -79,13 +89,19 @@ class ItemResult(BaseModel): """The last update date.""" deactivated: bool - """Whether this result is excluded from compliance calculations.""" + """Whether this result is excluded from compliance calculations. + + Excludes just this result, without deactivating the rule everywhere. + """ rule_id: str = FieldInfo(alias="ruleId") """The rule this result belongs to.""" status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ workspace_id: str = FieldInfo(alias="workspaceId") """The id of the workspace the rule result belongs to.""" @@ -112,7 +128,7 @@ class ItemResult(BaseModel): """When the evidence must be renewed. Evidence rules with a renewal cadence only.""" deactivated_reason: Optional[str] = FieldInfo(alias="deactivatedReason", default=None) - """Why the result was excluded.""" + """Why the result was excluded. Required when setting `deactivated` to `true`.""" project_id: Optional[str] = FieldInfo(alias="projectId", default=None) """The project this result was evaluated for. `null` for workspace-scoped rules.""" @@ -132,6 +148,10 @@ class ItemResultsSummary(BaseModel): class ItemTag(BaseModel): + """ + A label that groups rules across frameworks, for example by team or control family. + """ + id: str """The rule tag id.""" @@ -145,7 +165,7 @@ class ItemTag(BaseModel): """The last update date.""" immutable: bool - """Whether the tag is managed by Openlayer and cannot be edited or deleted.""" + """Whether the tag is managed by Openlayer. These tags can't be deleted.""" name: str """The tag name.""" @@ -158,6 +178,11 @@ class ItemTag(BaseModel): class Item(BaseModel): + """A single requirement Openlayer tracks. + + `platform` rules are evaluated automatically from the state of your workspace, and `evidence` rules are satisfied by attaching evidence. A rule can belong to several frameworks, or to none. + """ + id: str """The rule id.""" @@ -173,13 +198,15 @@ class Item(BaseModel): scope: Literal["project", "workspace"] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Literal["platform", "evidence"] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ workspace_id: str = FieldInfo(alias="workspaceId") @@ -189,13 +216,17 @@ class Item(BaseModel): """The user responsible for satisfying the rule.""" automation_params: Optional[Dict[str, object]] = FieldInfo(alias="automationParams", default=None) - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Optional[str] = FieldInfo(alias="automationType", default=None) """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ deactivated: Optional[bool] = None @@ -207,19 +238,28 @@ class Item(BaseModel): evidence_type: Optional[Literal["document", "text", "url", "categoryValue"]] = FieldInfo( alias="evidenceType", default=None ) - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ frameworks: Optional[List[ItemFramework]] = None """The frameworks that include this rule.""" immutable: Optional[bool] = None - """Whether the rule is managed by Openlayer and cannot be edited.""" + """Whether the rule is managed by Openlayer. + + These rules can't be renamed or deleted; set `deactivated` to exclude one from + compliance instead. + """ renewal_cadence_days: Optional[int] = FieldInfo(alias="renewalCadenceDays", default=None) """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ results: Optional[List[ItemResult]] = None diff --git a/src/openlayer/types/governance/frameworks/subsection_list_rules_response.py b/src/openlayer/types/governance/frameworks/subsection_list_rules_response.py index 1b934a4b..5dffc317 100644 --- a/src/openlayer/types/governance/frameworks/subsection_list_rules_response.py +++ b/src/openlayer/types/governance/frameworks/subsection_list_rules_response.py @@ -58,17 +58,27 @@ class ItemResultBlockedBy(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ItemResultBlocking(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ItemResult(BaseModel): + """ + The compliance status of one rule for one entity: a project for project-scoped rules, or the workspace for workspace-scoped rules. + """ + id: str """The rule result id.""" @@ -79,13 +89,19 @@ class ItemResult(BaseModel): """The last update date.""" deactivated: bool - """Whether this result is excluded from compliance calculations.""" + """Whether this result is excluded from compliance calculations. + + Excludes just this result, without deactivating the rule everywhere. + """ rule_id: str = FieldInfo(alias="ruleId") """The rule this result belongs to.""" status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ workspace_id: str = FieldInfo(alias="workspaceId") """The id of the workspace the rule result belongs to.""" @@ -112,7 +128,7 @@ class ItemResult(BaseModel): """When the evidence must be renewed. Evidence rules with a renewal cadence only.""" deactivated_reason: Optional[str] = FieldInfo(alias="deactivatedReason", default=None) - """Why the result was excluded.""" + """Why the result was excluded. Required when setting `deactivated` to `true`.""" project_id: Optional[str] = FieldInfo(alias="projectId", default=None) """The project this result was evaluated for. `null` for workspace-scoped rules.""" @@ -132,6 +148,10 @@ class ItemResultsSummary(BaseModel): class ItemTag(BaseModel): + """ + A label that groups rules across frameworks, for example by team or control family. + """ + id: str """The rule tag id.""" @@ -145,7 +165,7 @@ class ItemTag(BaseModel): """The last update date.""" immutable: bool - """Whether the tag is managed by Openlayer and cannot be edited or deleted.""" + """Whether the tag is managed by Openlayer. These tags can't be deleted.""" name: str """The tag name.""" @@ -158,6 +178,11 @@ class ItemTag(BaseModel): class Item(BaseModel): + """A single requirement Openlayer tracks. + + `platform` rules are evaluated automatically from the state of your workspace, and `evidence` rules are satisfied by attaching evidence. A rule can belong to several frameworks, or to none. + """ + id: str """The rule id.""" @@ -173,13 +198,15 @@ class Item(BaseModel): scope: Literal["project", "workspace"] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Literal["platform", "evidence"] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ workspace_id: str = FieldInfo(alias="workspaceId") @@ -189,13 +216,17 @@ class Item(BaseModel): """The user responsible for satisfying the rule.""" automation_params: Optional[Dict[str, object]] = FieldInfo(alias="automationParams", default=None) - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Optional[str] = FieldInfo(alias="automationType", default=None) """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ deactivated: Optional[bool] = None @@ -207,19 +238,28 @@ class Item(BaseModel): evidence_type: Optional[Literal["document", "text", "url", "categoryValue"]] = FieldInfo( alias="evidenceType", default=None ) - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ frameworks: Optional[List[ItemFramework]] = None """The frameworks that include this rule.""" immutable: Optional[bool] = None - """Whether the rule is managed by Openlayer and cannot be edited.""" + """Whether the rule is managed by Openlayer. + + These rules can't be renamed or deleted; set `deactivated` to exclude one from + compliance instead. + """ renewal_cadence_days: Optional[int] = FieldInfo(alias="renewalCadenceDays", default=None) """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ results: Optional[List[ItemResult]] = None diff --git a/src/openlayer/types/governance/rule_create_params.py b/src/openlayer/types/governance/rule_create_params.py index bb660fe7..44be1d51 100644 --- a/src/openlayer/types/governance/rule_create_params.py +++ b/src/openlayer/types/governance/rule_create_params.py @@ -18,26 +18,32 @@ class RuleCreateParams(TypedDict, total=False): scope: Required[Literal["project", "workspace"]] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Required[Literal["platform", "evidence"]] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ assignee_id: Annotated[Optional[str], PropertyInfo(alias="assigneeId")] """The user responsible for satisfying the rule.""" automation_params: Annotated[Optional[Dict[str, object]], PropertyInfo(alias="automationParams")] - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Annotated[Optional[str], PropertyInfo(alias="automationType")] """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ deactivated: bool @@ -49,13 +55,18 @@ class RuleCreateParams(TypedDict, total=False): evidence_type: Annotated[ Optional[Literal["document", "text", "url", "categoryValue"]], PropertyInfo(alias="evidenceType") ] - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ renewal_cadence_days: Annotated[Optional[int], PropertyInfo(alias="renewalCadenceDays")] """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ tag_ids: Annotated[Optional[SequenceNotStr[str]], PropertyInfo(alias="tagIds")] diff --git a/src/openlayer/types/governance/rule_create_response.py b/src/openlayer/types/governance/rule_create_response.py index 3f2475dd..8587f366 100644 --- a/src/openlayer/types/governance/rule_create_response.py +++ b/src/openlayer/types/governance/rule_create_response.py @@ -57,17 +57,27 @@ class ResultBlockedBy(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ResultBlocking(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class Result(BaseModel): + """ + The compliance status of one rule for one entity: a project for project-scoped rules, or the workspace for workspace-scoped rules. + """ + id: str """The rule result id.""" @@ -78,13 +88,19 @@ class Result(BaseModel): """The last update date.""" deactivated: bool - """Whether this result is excluded from compliance calculations.""" + """Whether this result is excluded from compliance calculations. + + Excludes just this result, without deactivating the rule everywhere. + """ rule_id: str = FieldInfo(alias="ruleId") """The rule this result belongs to.""" status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ workspace_id: str = FieldInfo(alias="workspaceId") """The id of the workspace the rule result belongs to.""" @@ -111,7 +127,7 @@ class Result(BaseModel): """When the evidence must be renewed. Evidence rules with a renewal cadence only.""" deactivated_reason: Optional[str] = FieldInfo(alias="deactivatedReason", default=None) - """Why the result was excluded.""" + """Why the result was excluded. Required when setting `deactivated` to `true`.""" project_id: Optional[str] = FieldInfo(alias="projectId", default=None) """The project this result was evaluated for. `null` for workspace-scoped rules.""" @@ -131,6 +147,10 @@ class ResultsSummary(BaseModel): class Tag(BaseModel): + """ + A label that groups rules across frameworks, for example by team or control family. + """ + id: str """The rule tag id.""" @@ -144,7 +164,7 @@ class Tag(BaseModel): """The last update date.""" immutable: bool - """Whether the tag is managed by Openlayer and cannot be edited or deleted.""" + """Whether the tag is managed by Openlayer. These tags can't be deleted.""" name: str """The tag name.""" @@ -157,6 +177,11 @@ class Tag(BaseModel): class RuleCreateResponse(BaseModel): + """A single requirement Openlayer tracks. + + `platform` rules are evaluated automatically from the state of your workspace, and `evidence` rules are satisfied by attaching evidence. A rule can belong to several frameworks, or to none. + """ + id: str """The rule id.""" @@ -172,13 +197,15 @@ class RuleCreateResponse(BaseModel): scope: Literal["project", "workspace"] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Literal["platform", "evidence"] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ workspace_id: str = FieldInfo(alias="workspaceId") @@ -188,13 +215,17 @@ class RuleCreateResponse(BaseModel): """The user responsible for satisfying the rule.""" automation_params: Optional[Dict[str, object]] = FieldInfo(alias="automationParams", default=None) - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Optional[str] = FieldInfo(alias="automationType", default=None) """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ deactivated: Optional[bool] = None @@ -206,19 +237,28 @@ class RuleCreateResponse(BaseModel): evidence_type: Optional[Literal["document", "text", "url", "categoryValue"]] = FieldInfo( alias="evidenceType", default=None ) - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ frameworks: Optional[List[Framework]] = None """The frameworks that include this rule.""" immutable: Optional[bool] = None - """Whether the rule is managed by Openlayer and cannot be edited.""" + """Whether the rule is managed by Openlayer. + + These rules can't be renamed or deleted; set `deactivated` to exclude one from + compliance instead. + """ renewal_cadence_days: Optional[int] = FieldInfo(alias="renewalCadenceDays", default=None) """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ results: Optional[List[Result]] = None diff --git a/src/openlayer/types/governance/rule_list_response.py b/src/openlayer/types/governance/rule_list_response.py index 582fb42a..7b345cde 100644 --- a/src/openlayer/types/governance/rule_list_response.py +++ b/src/openlayer/types/governance/rule_list_response.py @@ -58,17 +58,27 @@ class ItemResultBlockedBy(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ItemResultBlocking(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ItemResult(BaseModel): + """ + The compliance status of one rule for one entity: a project for project-scoped rules, or the workspace for workspace-scoped rules. + """ + id: str """The rule result id.""" @@ -79,13 +89,19 @@ class ItemResult(BaseModel): """The last update date.""" deactivated: bool - """Whether this result is excluded from compliance calculations.""" + """Whether this result is excluded from compliance calculations. + + Excludes just this result, without deactivating the rule everywhere. + """ rule_id: str = FieldInfo(alias="ruleId") """The rule this result belongs to.""" status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ workspace_id: str = FieldInfo(alias="workspaceId") """The id of the workspace the rule result belongs to.""" @@ -112,7 +128,7 @@ class ItemResult(BaseModel): """When the evidence must be renewed. Evidence rules with a renewal cadence only.""" deactivated_reason: Optional[str] = FieldInfo(alias="deactivatedReason", default=None) - """Why the result was excluded.""" + """Why the result was excluded. Required when setting `deactivated` to `true`.""" project_id: Optional[str] = FieldInfo(alias="projectId", default=None) """The project this result was evaluated for. `null` for workspace-scoped rules.""" @@ -132,6 +148,10 @@ class ItemResultsSummary(BaseModel): class ItemTag(BaseModel): + """ + A label that groups rules across frameworks, for example by team or control family. + """ + id: str """The rule tag id.""" @@ -145,7 +165,7 @@ class ItemTag(BaseModel): """The last update date.""" immutable: bool - """Whether the tag is managed by Openlayer and cannot be edited or deleted.""" + """Whether the tag is managed by Openlayer. These tags can't be deleted.""" name: str """The tag name.""" @@ -158,6 +178,11 @@ class ItemTag(BaseModel): class Item(BaseModel): + """A single requirement Openlayer tracks. + + `platform` rules are evaluated automatically from the state of your workspace, and `evidence` rules are satisfied by attaching evidence. A rule can belong to several frameworks, or to none. + """ + id: str """The rule id.""" @@ -173,13 +198,15 @@ class Item(BaseModel): scope: Literal["project", "workspace"] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Literal["platform", "evidence"] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ workspace_id: str = FieldInfo(alias="workspaceId") @@ -189,13 +216,17 @@ class Item(BaseModel): """The user responsible for satisfying the rule.""" automation_params: Optional[Dict[str, object]] = FieldInfo(alias="automationParams", default=None) - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Optional[str] = FieldInfo(alias="automationType", default=None) """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ deactivated: Optional[bool] = None @@ -207,19 +238,28 @@ class Item(BaseModel): evidence_type: Optional[Literal["document", "text", "url", "categoryValue"]] = FieldInfo( alias="evidenceType", default=None ) - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ frameworks: Optional[List[ItemFramework]] = None """The frameworks that include this rule.""" immutable: Optional[bool] = None - """Whether the rule is managed by Openlayer and cannot be edited.""" + """Whether the rule is managed by Openlayer. + + These rules can't be renamed or deleted; set `deactivated` to exclude one from + compliance instead. + """ renewal_cadence_days: Optional[int] = FieldInfo(alias="renewalCadenceDays", default=None) """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ results: Optional[List[ItemResult]] = None diff --git a/src/openlayer/types/governance/rule_result_create_evidence_params.py b/src/openlayer/types/governance/rule_result_create_evidence_params.py index a049e054..fd671cd0 100644 --- a/src/openlayer/types/governance/rule_result_create_evidence_params.py +++ b/src/openlayer/types/governance/rule_result_create_evidence_params.py @@ -20,7 +20,8 @@ class RuleResultCreateEvidenceParams(TypedDict, total=False): storage_uri: Annotated[Optional[str], PropertyInfo(alias="storageUri")] """Where the uploaded file is stored. - Set when the rule's `evidenceType` is `document`. + Set when the rule's `evidenceType` is `document`. Upload the file first with + `POST /storage/presigned-url` and send the storage URI it returns. """ text: Optional[str] diff --git a/src/openlayer/types/governance/rule_result_create_evidence_response.py b/src/openlayer/types/governance/rule_result_create_evidence_response.py index 92e21736..c1e2e5d8 100644 --- a/src/openlayer/types/governance/rule_result_create_evidence_response.py +++ b/src/openlayer/types/governance/rule_result_create_evidence_response.py @@ -11,6 +11,11 @@ class RuleResultCreateEvidenceResponse(BaseModel): + """Evidence attached to a rule result to satisfy an evidence rule. + + Which field holds it depends on the rule's `evidenceType`: `storageUri` for a document, `text` for a written statement, or `url` for a link. + """ + id: str """The evidence id.""" @@ -32,7 +37,8 @@ class RuleResultCreateEvidenceResponse(BaseModel): storage_uri: Optional[str] = FieldInfo(alias="storageUri", default=None) """Where the uploaded file is stored. - Set when the rule's `evidenceType` is `document`. + Set when the rule's `evidenceType` is `document`. Upload the file first with + `POST /storage/presigned-url` and send the storage URI it returns. """ text: Optional[str] = None diff --git a/src/openlayer/types/governance/rule_result_list_evidence_response.py b/src/openlayer/types/governance/rule_result_list_evidence_response.py index 6e45b19e..9bccc2a6 100644 --- a/src/openlayer/types/governance/rule_result_list_evidence_response.py +++ b/src/openlayer/types/governance/rule_result_list_evidence_response.py @@ -11,6 +11,11 @@ class Item(BaseModel): + """Evidence attached to a rule result to satisfy an evidence rule. + + Which field holds it depends on the rule's `evidenceType`: `storageUri` for a document, `text` for a written statement, or `url` for a link. + """ + id: str """The evidence id.""" @@ -32,7 +37,8 @@ class Item(BaseModel): storage_uri: Optional[str] = FieldInfo(alias="storageUri", default=None) """Where the uploaded file is stored. - Set when the rule's `evidenceType` is `document`. + Set when the rule's `evidenceType` is `document`. Upload the file first with + `POST /storage/presigned-url` and send the storage URI it returns. """ text: Optional[str] = None diff --git a/src/openlayer/types/governance/rule_result_list_response.py b/src/openlayer/types/governance/rule_result_list_response.py index 639e7b66..f6d89cba 100644 --- a/src/openlayer/types/governance/rule_result_list_response.py +++ b/src/openlayer/types/governance/rule_result_list_response.py @@ -15,17 +15,27 @@ class ItemBlockedBy(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ItemBlocking(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class Item(BaseModel): + """ + The compliance status of one rule for one entity: a project for project-scoped rules, or the workspace for workspace-scoped rules. + """ + id: str """The rule result id.""" @@ -36,13 +46,19 @@ class Item(BaseModel): """The last update date.""" deactivated: bool - """Whether this result is excluded from compliance calculations.""" + """Whether this result is excluded from compliance calculations. + + Excludes just this result, without deactivating the rule everywhere. + """ rule_id: str = FieldInfo(alias="ruleId") """The rule this result belongs to.""" status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ workspace_id: str = FieldInfo(alias="workspaceId") """The id of the workspace the rule result belongs to.""" @@ -69,7 +85,7 @@ class Item(BaseModel): """When the evidence must be renewed. Evidence rules with a renewal cadence only.""" deactivated_reason: Optional[str] = FieldInfo(alias="deactivatedReason", default=None) - """Why the result was excluded.""" + """Why the result was excluded. Required when setting `deactivated` to `true`.""" project_id: Optional[str] = FieldInfo(alias="projectId", default=None) """The project this result was evaluated for. `null` for workspace-scoped rules.""" diff --git a/src/openlayer/types/governance/rule_result_retrieve_response.py b/src/openlayer/types/governance/rule_result_retrieve_response.py index b7f4db18..6b6db90c 100644 --- a/src/openlayer/types/governance/rule_result_retrieve_response.py +++ b/src/openlayer/types/governance/rule_result_retrieve_response.py @@ -15,17 +15,27 @@ class BlockedBy(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class Blocking(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class RuleResultRetrieveResponse(BaseModel): + """ + The compliance status of one rule for one entity: a project for project-scoped rules, or the workspace for workspace-scoped rules. + """ + id: str """The rule result id.""" @@ -36,13 +46,19 @@ class RuleResultRetrieveResponse(BaseModel): """The last update date.""" deactivated: bool - """Whether this result is excluded from compliance calculations.""" + """Whether this result is excluded from compliance calculations. + + Excludes just this result, without deactivating the rule everywhere. + """ rule_id: str = FieldInfo(alias="ruleId") """The rule this result belongs to.""" status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ workspace_id: str = FieldInfo(alias="workspaceId") """The id of the workspace the rule result belongs to.""" @@ -69,7 +85,7 @@ class RuleResultRetrieveResponse(BaseModel): """When the evidence must be renewed. Evidence rules with a renewal cadence only.""" deactivated_reason: Optional[str] = FieldInfo(alias="deactivatedReason", default=None) - """Why the result was excluded.""" + """Why the result was excluded. Required when setting `deactivated` to `true`.""" project_id: Optional[str] = FieldInfo(alias="projectId", default=None) """The project this result was evaluated for. `null` for workspace-scoped rules.""" diff --git a/src/openlayer/types/governance/rule_result_update_params.py b/src/openlayer/types/governance/rule_result_update_params.py index 6c6ed1a7..432b06bc 100644 --- a/src/openlayer/types/governance/rule_result_update_params.py +++ b/src/openlayer/types/governance/rule_result_update_params.py @@ -21,21 +21,30 @@ class RuleResultUpdateParams(TypedDict, total=False): """Rule results that this one blocks.""" deactivated: bool - """Whether this result is excluded from compliance calculations.""" + """Whether this result is excluded from compliance calculations. + + Excludes just this result, without deactivating the rule everywhere. + """ deactivated_reason: Annotated[Optional[str], PropertyInfo(alias="deactivatedReason")] - """Why the result was excluded.""" + """Why the result was excluded. Required when setting `deactivated` to `true`.""" class BlockedBy(TypedDict, total=False): id: str status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class Blocking(TypedDict, total=False): id: str status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ diff --git a/src/openlayer/types/governance/rule_result_update_response.py b/src/openlayer/types/governance/rule_result_update_response.py index 299af399..d18322ee 100644 --- a/src/openlayer/types/governance/rule_result_update_response.py +++ b/src/openlayer/types/governance/rule_result_update_response.py @@ -15,17 +15,27 @@ class BlockedBy(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class Blocking(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class RuleResultUpdateResponse(BaseModel): + """ + The compliance status of one rule for one entity: a project for project-scoped rules, or the workspace for workspace-scoped rules. + """ + id: str """The rule result id.""" @@ -36,13 +46,19 @@ class RuleResultUpdateResponse(BaseModel): """The last update date.""" deactivated: bool - """Whether this result is excluded from compliance calculations.""" + """Whether this result is excluded from compliance calculations. + + Excludes just this result, without deactivating the rule everywhere. + """ rule_id: str = FieldInfo(alias="ruleId") """The rule this result belongs to.""" status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ workspace_id: str = FieldInfo(alias="workspaceId") """The id of the workspace the rule result belongs to.""" @@ -69,7 +85,7 @@ class RuleResultUpdateResponse(BaseModel): """When the evidence must be renewed. Evidence rules with a renewal cadence only.""" deactivated_reason: Optional[str] = FieldInfo(alias="deactivatedReason", default=None) - """Why the result was excluded.""" + """Why the result was excluded. Required when setting `deactivated` to `true`.""" project_id: Optional[str] = FieldInfo(alias="projectId", default=None) """The project this result was evaluated for. `null` for workspace-scoped rules.""" diff --git a/src/openlayer/types/governance/rule_retrieve_response.py b/src/openlayer/types/governance/rule_retrieve_response.py index 055abe65..19d7dd3c 100644 --- a/src/openlayer/types/governance/rule_retrieve_response.py +++ b/src/openlayer/types/governance/rule_retrieve_response.py @@ -57,17 +57,27 @@ class ResultBlockedBy(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ResultBlocking(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class Result(BaseModel): + """ + The compliance status of one rule for one entity: a project for project-scoped rules, or the workspace for workspace-scoped rules. + """ + id: str """The rule result id.""" @@ -78,13 +88,19 @@ class Result(BaseModel): """The last update date.""" deactivated: bool - """Whether this result is excluded from compliance calculations.""" + """Whether this result is excluded from compliance calculations. + + Excludes just this result, without deactivating the rule everywhere. + """ rule_id: str = FieldInfo(alias="ruleId") """The rule this result belongs to.""" status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ workspace_id: str = FieldInfo(alias="workspaceId") """The id of the workspace the rule result belongs to.""" @@ -111,7 +127,7 @@ class Result(BaseModel): """When the evidence must be renewed. Evidence rules with a renewal cadence only.""" deactivated_reason: Optional[str] = FieldInfo(alias="deactivatedReason", default=None) - """Why the result was excluded.""" + """Why the result was excluded. Required when setting `deactivated` to `true`.""" project_id: Optional[str] = FieldInfo(alias="projectId", default=None) """The project this result was evaluated for. `null` for workspace-scoped rules.""" @@ -131,6 +147,10 @@ class ResultsSummary(BaseModel): class Tag(BaseModel): + """ + A label that groups rules across frameworks, for example by team or control family. + """ + id: str """The rule tag id.""" @@ -144,7 +164,7 @@ class Tag(BaseModel): """The last update date.""" immutable: bool - """Whether the tag is managed by Openlayer and cannot be edited or deleted.""" + """Whether the tag is managed by Openlayer. These tags can't be deleted.""" name: str """The tag name.""" @@ -157,6 +177,11 @@ class Tag(BaseModel): class RuleRetrieveResponse(BaseModel): + """A single requirement Openlayer tracks. + + `platform` rules are evaluated automatically from the state of your workspace, and `evidence` rules are satisfied by attaching evidence. A rule can belong to several frameworks, or to none. + """ + id: str """The rule id.""" @@ -172,13 +197,15 @@ class RuleRetrieveResponse(BaseModel): scope: Literal["project", "workspace"] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Literal["platform", "evidence"] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ workspace_id: str = FieldInfo(alias="workspaceId") @@ -188,13 +215,17 @@ class RuleRetrieveResponse(BaseModel): """The user responsible for satisfying the rule.""" automation_params: Optional[Dict[str, object]] = FieldInfo(alias="automationParams", default=None) - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Optional[str] = FieldInfo(alias="automationType", default=None) """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ deactivated: Optional[bool] = None @@ -206,19 +237,28 @@ class RuleRetrieveResponse(BaseModel): evidence_type: Optional[Literal["document", "text", "url", "categoryValue"]] = FieldInfo( alias="evidenceType", default=None ) - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ frameworks: Optional[List[Framework]] = None """The frameworks that include this rule.""" immutable: Optional[bool] = None - """Whether the rule is managed by Openlayer and cannot be edited.""" + """Whether the rule is managed by Openlayer. + + These rules can't be renamed or deleted; set `deactivated` to exclude one from + compliance instead. + """ renewal_cadence_days: Optional[int] = FieldInfo(alias="renewalCadenceDays", default=None) """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ results: Optional[List[Result]] = None diff --git a/src/openlayer/types/governance/rule_stat_retrieve_response.py b/src/openlayer/types/governance/rule_stat_retrieve_response.py index 84ab85d6..a8c21fb5 100644 --- a/src/openlayer/types/governance/rule_stat_retrieve_response.py +++ b/src/openlayer/types/governance/rule_stat_retrieve_response.py @@ -201,6 +201,11 @@ class Rules(BaseModel): class RuleStatRetrieveResponse(BaseModel): + """Counts of rules and their results by status. + + Narrowed by the request's filters, so `frameworkId` gives one framework's compliance and `projectId` gives one project's. + """ + rule_results: RuleResults = FieldInfo(alias="ruleResults") """ Counts of rule results, after any filters in the request, with breakdowns by the diff --git a/src/openlayer/types/governance/rule_tag_list_response.py b/src/openlayer/types/governance/rule_tag_list_response.py index 12b53d39..f94171ef 100644 --- a/src/openlayer/types/governance/rule_tag_list_response.py +++ b/src/openlayer/types/governance/rule_tag_list_response.py @@ -11,6 +11,10 @@ class Item(BaseModel): + """ + A label that groups rules across frameworks, for example by team or control family. + """ + id: str """The rule tag id.""" @@ -24,7 +28,7 @@ class Item(BaseModel): """The last update date.""" immutable: bool - """Whether the tag is managed by Openlayer and cannot be edited or deleted.""" + """Whether the tag is managed by Openlayer. These tags can't be deleted.""" name: str """The tag name.""" diff --git a/src/openlayer/types/governance/rule_update_params.py b/src/openlayer/types/governance/rule_update_params.py index f8cf2ed3..690ad593 100644 --- a/src/openlayer/types/governance/rule_update_params.py +++ b/src/openlayer/types/governance/rule_update_params.py @@ -28,7 +28,8 @@ class RuleUpdateParams(TypedDict, total=False): """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ tag_ids: Annotated[Optional[SequenceNotStr[str]], PropertyInfo(alias="tagIds")] diff --git a/src/openlayer/types/governance/rule_update_response.py b/src/openlayer/types/governance/rule_update_response.py index 275ba842..28df2d8d 100644 --- a/src/openlayer/types/governance/rule_update_response.py +++ b/src/openlayer/types/governance/rule_update_response.py @@ -57,17 +57,27 @@ class ResultBlockedBy(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class ResultBlocking(BaseModel): id: Optional[str] = None status: Optional[Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"]] = None - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ class Result(BaseModel): + """ + The compliance status of one rule for one entity: a project for project-scoped rules, or the workspace for workspace-scoped rules. + """ + id: str """The rule result id.""" @@ -78,13 +88,19 @@ class Result(BaseModel): """The last update date.""" deactivated: bool - """Whether this result is excluded from compliance calculations.""" + """Whether this result is excluded from compliance calculations. + + Excludes just this result, without deactivating the rule everywhere. + """ rule_id: str = FieldInfo(alias="ruleId") """The rule this result belongs to.""" status: Literal["running", "passing", "failing", "skipped", "error", "pending", "due_soon"] - """The compliance status of the rule for this entity.""" + """The compliance status of the rule for this entity. + + Computed by Openlayer and can't be set directly. + """ workspace_id: str = FieldInfo(alias="workspaceId") """The id of the workspace the rule result belongs to.""" @@ -111,7 +127,7 @@ class Result(BaseModel): """When the evidence must be renewed. Evidence rules with a renewal cadence only.""" deactivated_reason: Optional[str] = FieldInfo(alias="deactivatedReason", default=None) - """Why the result was excluded.""" + """Why the result was excluded. Required when setting `deactivated` to `true`.""" project_id: Optional[str] = FieldInfo(alias="projectId", default=None) """The project this result was evaluated for. `null` for workspace-scoped rules.""" @@ -131,6 +147,10 @@ class ResultsSummary(BaseModel): class Tag(BaseModel): + """ + A label that groups rules across frameworks, for example by team or control family. + """ + id: str """The rule tag id.""" @@ -144,7 +164,7 @@ class Tag(BaseModel): """The last update date.""" immutable: bool - """Whether the tag is managed by Openlayer and cannot be edited or deleted.""" + """Whether the tag is managed by Openlayer. These tags can't be deleted.""" name: str """The tag name.""" @@ -157,6 +177,11 @@ class Tag(BaseModel): class RuleUpdateResponse(BaseModel): + """A single requirement Openlayer tracks. + + `platform` rules are evaluated automatically from the state of your workspace, and `evidence` rules are satisfied by attaching evidence. A rule can belong to several frameworks, or to none. + """ + id: str """The rule id.""" @@ -172,13 +197,15 @@ class RuleUpdateResponse(BaseModel): scope: Literal["project", "workspace"] """ Whether the rule is evaluated once for the whole workspace, or once per project - the rule's frameworks apply to. + the rule's frameworks apply to. Must be `project` for platform rules. Fixed once + the rule is created. """ type: Literal["platform", "evidence"] """ `platform` rules are evaluated automatically from the state of your Openlayer - workspace. `evidence` rules are satisfied by attaching evidence. + workspace. `evidence` rules are satisfied by attaching evidence. Fixed once the + rule is created. """ workspace_id: str = FieldInfo(alias="workspaceId") @@ -188,13 +215,17 @@ class RuleUpdateResponse(BaseModel): """The user responsible for satisfying the rule.""" automation_params: Optional[Dict[str, object]] = FieldInfo(alias="automationParams", default=None) - """Configuration for the platform check, when the automation takes parameters.""" + """Configuration for the platform check, when the automation takes parameters. + + Omit or `null` for evidence rules. Fixed once the rule is created. + """ automation_type: Optional[str] = FieldInfo(alias="automationType", default=None) """ Which workspace signal a platform rule checks, for example - `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. `null` for - evidence rules. + `monitoring_mode_enabled`, `test_setup`, or `project_owner_set`. Set it for + platform rules; omit or `null` for evidence rules. Fixed once the rule is + created. """ deactivated: Optional[bool] = None @@ -206,19 +237,28 @@ class RuleUpdateResponse(BaseModel): evidence_type: Optional[Literal["document", "text", "url", "categoryValue"]] = FieldInfo( alias="evidenceType", default=None ) - """The kind of evidence that satisfies the rule. `null` for platform rules.""" + """The kind of evidence that satisfies the rule. + + Set it for evidence rules; omit or `null` for platform rules. Fixed once the + rule is created. + """ frameworks: Optional[List[Framework]] = None """The frameworks that include this rule.""" immutable: Optional[bool] = None - """Whether the rule is managed by Openlayer and cannot be edited.""" + """Whether the rule is managed by Openlayer. + + These rules can't be renamed or deleted; set `deactivated` to exclude one from + compliance instead. + """ renewal_cadence_days: Optional[int] = FieldInfo(alias="renewalCadenceDays", default=None) """How often evidence must be renewed, in days. Once evidence is older than this, the rule result becomes `due_soon` and then - `failing`. + `failing`. The window restarts whenever evidence is attached. Omit or `null` for + platform rules. """ results: Optional[List[Result]] = None diff --git a/src/openlayer/types/inference_pipeline_retrieve_sessions_response.py b/src/openlayer/types/inference_pipeline_retrieve_sessions_response.py index 7c07cd73..47555585 100644 --- a/src/openlayer/types/inference_pipeline_retrieve_sessions_response.py +++ b/src/openlayer/types/inference_pipeline_retrieve_sessions_response.py @@ -11,6 +11,10 @@ class Item(BaseModel): + """ + A session in an inference pipeline, with its activity stats: record counts, token usage, cost, latency, and its first and last records. + """ + id: str """The unique session identifier""" diff --git a/src/openlayer/types/inference_pipeline_retrieve_users_response.py b/src/openlayer/types/inference_pipeline_retrieve_users_response.py index 57d1b9e7..032c8fdb 100644 --- a/src/openlayer/types/inference_pipeline_retrieve_users_response.py +++ b/src/openlayer/types/inference_pipeline_retrieve_users_response.py @@ -11,6 +11,10 @@ class Item(BaseModel): + """ + A user who has interacted with an inference pipeline, with their activity stats: session and record counts, token usage, and cost. + """ + id: str """The unique user identifier""" diff --git a/src/openlayer/types/storage/presigned_url_retrieve_params.py b/src/openlayer/types/storage/presigned_url_retrieve_params.py index 6868b697..3f5194e2 100644 --- a/src/openlayer/types/storage/presigned_url_retrieve_params.py +++ b/src/openlayer/types/storage/presigned_url_retrieve_params.py @@ -11,4 +11,7 @@ class PresignedURLRetrieveParams(TypedDict, total=False): storage_uri: Required[Annotated[str, PropertyInfo(alias="storageUri")]] - """The object's storage uri.""" + """ + The object's storage uri, for example `outputs.storageUri` from a framework + export's background task. + """ diff --git a/src/openlayer/types/workspaces/api_key_create_params.py b/src/openlayer/types/workspaces/api_key_create_params.py index bee81988..3a2a68e1 100644 --- a/src/openlayer/types/workspaces/api_key_create_params.py +++ b/src/openlayer/types/workspaces/api_key_create_params.py @@ -17,7 +17,9 @@ class APIKeyCreateParams(TypedDict, total=False): `null` means the key never expires. Set when the key is created or rotated, and must be in the future. When the request is authenticated with an API key that - expires, the result can't be later than that key's expiry. + expires, the result can't be later than that key's expiry. On create, omit it to + inherit that expiry. On rotate, omit it to keep the current one. It can't be + changed with an update; rotate the key instead. """ name: Optional[str] diff --git a/src/openlayer/types/workspaces/api_key_create_response.py b/src/openlayer/types/workspaces/api_key_create_response.py index 2c2cff8c..07369d63 100644 --- a/src/openlayer/types/workspaces/api_key_create_response.py +++ b/src/openlayer/types/workspaces/api_key_create_response.py @@ -36,7 +36,7 @@ class APIKeyCreateResponse(BaseModel): `active`: the current secret authenticates. `rotating`: the key was rotated and the previous secret still authenticates until `previousKeyExpiresAt`. `expired`: - `expiresAt` has passed and no secret authenticates. + `expiresAt` has passed, no secret authenticates, and the key can't be rotated. """ expires_at: Optional[datetime] = FieldInfo(alias="expiresAt", default=None) @@ -44,7 +44,9 @@ class APIKeyCreateResponse(BaseModel): `null` means the key never expires. Set when the key is created or rotated, and must be in the future. When the request is authenticated with an API key that - expires, the result can't be later than that key's expiry. + expires, the result can't be later than that key's expiry. On create, omit it to + inherit that expiry. On rotate, omit it to keep the current one. It can't be + changed with an update; rotate the key instead. """ last_rotated_at: Optional[datetime] = FieldInfo(alias="lastRotatedAt", default=None) diff --git a/src/openlayer/types/workspaces/api_key_list_response.py b/src/openlayer/types/workspaces/api_key_list_response.py index 8baab4a8..190a0d52 100644 --- a/src/openlayer/types/workspaces/api_key_list_response.py +++ b/src/openlayer/types/workspaces/api_key_list_response.py @@ -36,7 +36,7 @@ class APIKeyListResponseItem(BaseModel): `active`: the current secret authenticates. `rotating`: the key was rotated and the previous secret still authenticates until `previousKeyExpiresAt`. `expired`: - `expiresAt` has passed and no secret authenticates. + `expiresAt` has passed, no secret authenticates, and the key can't be rotated. """ expires_at: Optional[datetime] = FieldInfo(alias="expiresAt", default=None) @@ -44,7 +44,9 @@ class APIKeyListResponseItem(BaseModel): `null` means the key never expires. Set when the key is created or rotated, and must be in the future. When the request is authenticated with an API key that - expires, the result can't be later than that key's expiry. + expires, the result can't be later than that key's expiry. On create, omit it to + inherit that expiry. On rotate, omit it to keep the current one. It can't be + changed with an update; rotate the key instead. """ last_rotated_at: Optional[datetime] = FieldInfo(alias="lastRotatedAt", default=None) diff --git a/src/openlayer/types/workspaces/api_key_retrieve_response.py b/src/openlayer/types/workspaces/api_key_retrieve_response.py index 27dbcc3c..1e1f78bc 100644 --- a/src/openlayer/types/workspaces/api_key_retrieve_response.py +++ b/src/openlayer/types/workspaces/api_key_retrieve_response.py @@ -36,7 +36,7 @@ class APIKeyRetrieveResponse(BaseModel): `active`: the current secret authenticates. `rotating`: the key was rotated and the previous secret still authenticates until `previousKeyExpiresAt`. `expired`: - `expiresAt` has passed and no secret authenticates. + `expiresAt` has passed, no secret authenticates, and the key can't be rotated. """ expires_at: Optional[datetime] = FieldInfo(alias="expiresAt", default=None) @@ -44,7 +44,9 @@ class APIKeyRetrieveResponse(BaseModel): `null` means the key never expires. Set when the key is created or rotated, and must be in the future. When the request is authenticated with an API key that - expires, the result can't be later than that key's expiry. + expires, the result can't be later than that key's expiry. On create, omit it to + inherit that expiry. On rotate, omit it to keep the current one. It can't be + changed with an update; rotate the key instead. """ last_rotated_at: Optional[datetime] = FieldInfo(alias="lastRotatedAt", default=None) diff --git a/src/openlayer/types/workspaces/api_key_rotate_params.py b/src/openlayer/types/workspaces/api_key_rotate_params.py index 124a581c..d0a57bdc 100644 --- a/src/openlayer/types/workspaces/api_key_rotate_params.py +++ b/src/openlayer/types/workspaces/api_key_rotate_params.py @@ -19,8 +19,15 @@ class APIKeyRotateParams(TypedDict, total=False): `null` means the key never expires. Set when the key is created or rotated, and must be in the future. When the request is authenticated with an API key that - expires, the result can't be later than that key's expiry. + expires, the result can't be later than that key's expiry. On create, omit it to + inherit that expiry. On rotate, omit it to keep the current one. It can't be + changed with an update; rotate the key instead. """ grace_period_hours: Annotated[int, PropertyInfo(alias="gracePeriodHours")] - """Hours the previous secret keeps authenticating.""" + """Hours the previous secret keeps authenticating. + + The default of 0 retires it immediately. It never outlives `expiresAt`. Only one + previous secret is kept, so rotating again during a grace period retires the + older one immediately. + """ diff --git a/src/openlayer/types/workspaces/api_key_rotate_response.py b/src/openlayer/types/workspaces/api_key_rotate_response.py index 7af4c649..ced4b638 100644 --- a/src/openlayer/types/workspaces/api_key_rotate_response.py +++ b/src/openlayer/types/workspaces/api_key_rotate_response.py @@ -36,7 +36,7 @@ class APIKeyRotateResponse(BaseModel): `active`: the current secret authenticates. `rotating`: the key was rotated and the previous secret still authenticates until `previousKeyExpiresAt`. `expired`: - `expiresAt` has passed and no secret authenticates. + `expiresAt` has passed, no secret authenticates, and the key can't be rotated. """ expires_at: Optional[datetime] = FieldInfo(alias="expiresAt", default=None) @@ -44,7 +44,9 @@ class APIKeyRotateResponse(BaseModel): `null` means the key never expires. Set when the key is created or rotated, and must be in the future. When the request is authenticated with an API key that - expires, the result can't be later than that key's expiry. + expires, the result can't be later than that key's expiry. On create, omit it to + inherit that expiry. On rotate, omit it to keep the current one. It can't be + changed with an update; rotate the key instead. """ last_rotated_at: Optional[datetime] = FieldInfo(alias="lastRotatedAt", default=None) diff --git a/src/openlayer/types/workspaces/api_key_update_response.py b/src/openlayer/types/workspaces/api_key_update_response.py index 2a95937a..127fabe9 100644 --- a/src/openlayer/types/workspaces/api_key_update_response.py +++ b/src/openlayer/types/workspaces/api_key_update_response.py @@ -36,7 +36,7 @@ class APIKeyUpdateResponse(BaseModel): `active`: the current secret authenticates. `rotating`: the key was rotated and the previous secret still authenticates until `previousKeyExpiresAt`. `expired`: - `expiresAt` has passed and no secret authenticates. + `expiresAt` has passed, no secret authenticates, and the key can't be rotated. """ expires_at: Optional[datetime] = FieldInfo(alias="expiresAt", default=None) @@ -44,7 +44,9 @@ class APIKeyUpdateResponse(BaseModel): `null` means the key never expires. Set when the key is created or rotated, and must be in the future. When the request is authenticated with an API key that - expires, the result can't be later than that key's expiry. + expires, the result can't be later than that key's expiry. On create, omit it to + inherit that expiry. On rotate, omit it to keep the current one. It can't be + changed with an update; rotate the key instead. """ last_rotated_at: Optional[datetime] = FieldInfo(alias="lastRotatedAt", default=None) diff --git a/tests/test_google_adk_handoffs.py b/tests/test_google_adk_handoffs.py new file mode 100644 index 00000000..53126194 --- /dev/null +++ b/tests/test_google_adk_handoffs.py @@ -0,0 +1,812 @@ +"""Trace shape of Google ADK multi-agent turns (OPEN-12864, OPEN-12518). + +Drives a real ``Runner`` with a scripted ``BaseLlm``, so ADK's own flow runs +end to end with no network. The same assertions hold on google-adk 1.x and +2.x: 2.x runs each agent in its own asyncio task, which used to split a turn +into one trace per agent and drop tool/handoff steps. +""" + +# google-adk and google-genai aren't installed in the lint env, and pytest's +# fixture decorator hides the autouse functions from static analysis. +# pyright: reportMissingImports=false, reportUnknownVariableType=false, reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownParameterType=false, reportMissingParameterType=false, reportUnusedFunction=false +# pyright: reportMissingTypeStubs=false, reportAttributeAccessIssue=false, reportCallIssue=false, reportImplicitOverride=false, reportOptionalMemberAccess=false, reportUntypedBaseClass=false + +import asyncio +import logging +import importlib +from typing import Any, Dict, List, Optional, AsyncGenerator +from unittest.mock import patch + +import pytest + +pytest.importorskip("google.adk") +pytest.importorskip("wrapt") + +from google.genai import types +from google.adk.models.base_llm import BaseLlm +from google.adk.models.llm_response import LlmResponse + +from openlayer.lib.tracing import enums, tracer as _tracer +from openlayer.lib.integrations import google_adk_tracer + +APP_NAME = "bank_app" +USER_ID = "u" +SESSION_ID = "s" + +# Each fake model pops its next reply from the list under its ``script`` key: a +# Content, a full LlmResponse, an exception to raise, or a number of seconds to +# hang for (to be cancelled). +SCRIPTS: Dict[str, List[Any]] = {} + + +class ScriptedLlm(BaseLlm): + model: str = "gemini-2.5-flash" + script: str = "" + + async def generate_content_async(self, llm_request: Any, stream: bool = False) -> AsyncGenerator[LlmResponse, None]: # noqa: ARG002 + item = SCRIPTS[self.script].pop(0) + if isinstance(item, BaseException): + raise item + if isinstance(item, (int, float)): + await asyncio.sleep(item) + item = _text("too late") + if isinstance(item, LlmResponse): + yield item + return + yield LlmResponse( + content=item, + usage_metadata=types.GenerateContentResponseUsageMetadata( + prompt_token_count=10, + candidates_token_count=5, + thoughts_token_count=3, + total_token_count=18, + ), + ) + + +def _call(name: str, **args: Any) -> types.Content: + return types.Content(role="model", parts=[types.Part(function_call=types.FunctionCall(name=name, args=args))]) + + +def _text(value: str) -> types.Content: + return types.Content(role="model", parts=[types.Part(text=value)]) + + +def executar_transferencia_pix(valor: float) -> Dict[str, Any]: + """Executa uma transferência Pix para outra conta.""" + return {"ok": True, "valor": valor} + + +@pytest.fixture(autouse=True) +def _disable_publish(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("OPENLAYER_DISABLE_PUBLISH", "true") + monkeypatch.setenv("OPENLAYER_API_KEY", "fake") + monkeypatch.setattr(_tracer, "_publish", False, raising=False) + + +@pytest.fixture(autouse=True) +def _trace_adk(): + SCRIPTS.clear() + from openlayer.lib.integrations import trace_google_adk + + trace_google_adk() + yield + google_adk_tracer._unpatch_google_adk() + + +@pytest.fixture +def traces(): + """Collect the trace of every completed root step.""" + captured: List[Any] = [] + original = _tracer._handle_trace_completion + + def _capture(*args: Any, **kwargs: Any) -> Any: + if kwargs.get("is_root_step"): + captured.append(_tracer.get_current_trace()) + return original(*args, **kwargs) + + with patch.object(_tracer, "_handle_trace_completion", _capture): + yield captured + + +def _runner(agent: Any) -> Any: + from google.adk.runners import Runner + from google.adk.sessions import InMemorySessionService + + return Runner(agent=agent, app_name=APP_NAME, session_service=InMemorySessionService()) + + +async def _new_session(runner: Any) -> None: + await runner.session_service.create_session(app_name=APP_NAME, user_id=USER_ID, session_id=SESSION_ID) + + +def _turn(runner: Any, message: str) -> Any: + """The Runner's event generator for one user message.""" + content = types.Content(role="user", parts=[types.Part(text=message)]) + return runner.run_async(user_id=USER_ID, session_id=SESSION_ID, new_message=content) + + +async def _until(condition: Any, timeout: float) -> None: + """Yield to the event loop until ``condition()`` holds or ``timeout`` passes.""" + deadline = asyncio.get_running_loop().time() + timeout + while not condition() and asyncio.get_running_loop().time() < deadline: + await asyncio.sleep(0.01) + + +def _run_turns(agent: Any, *messages: str) -> None: + runner = _runner(agent) + + async def _drive() -> None: + await _new_session(runner) + for message in messages: + async for _event in _turn(runner, message): + pass + + asyncio.run(_drive()) + + +def _bank_agents() -> Any: + from google.adk.agents import LlmAgent + + specialist = LlmAgent( + name="pix_specialist", + model=ScriptedLlm(script="pix_specialist"), + description="Handles Pix transfers.", + tools=[executar_transferencia_pix], + ) + return LlmAgent(name="assistant", model=ScriptedLlm(script="assistant"), sub_agents=[specialist]) + + +def _walk(step: Any) -> List[Any]: + found = [step] + for child in step.steps: + found.extend(_walk(child)) + return found + + +def _all_steps(trace: Any) -> List[Any]: + return [step for root in trace.steps for step in _walk(root)] + + +def _find(trace: Any, name: str) -> Optional[Any]: + return next((step for step in _all_steps(trace) if step.name == name), None) + + +def _parent_of(trace: Any, target: Any) -> Optional[Any]: + return next((step for step in _all_steps(trace) if target in step.steps), None) + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning", "ignore::UserWarning") +class TestAdkHandoffs: + def test_turn_is_one_trace_under_a_turn_root(self, traces: List[Any]) -> None: + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [_call("executar_transferencia_pix", valor=10.0), _text("feito")] + + _run_turns(_bank_agents(), "quero fazer um pix") + + assert len(traces) == 1, "every agent of the turn must land in a single trace" + (root,) = traces[0].steps + assert root.name == f"Agent turn: {APP_NAME}" + assert root.inputs["user_query"] == "quero fazer um pix" + assert root.output == "feito" + assert [step.name for step in root.steps] == ["Agent: assistant", "Agent: pix_specialist"] + errors = [step.name for step in _all_steps(traces[0]) if "error" in (step.metadata or {})] + assert not errors, f"a clean turn recorded errors on {errors}" + + def test_transfer_becomes_a_handoff_step(self, traces: List[Any]) -> None: + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [_text("oi")] + + _run_turns(_bank_agents(), "quero fazer um pix") + + handoff = _find(traces[0], "Handoff: assistant → pix_specialist") + assert handoff is not None + assert handoff.step_type == enums.StepType.HANDOFF + assert handoff.from_component == "assistant" + assert handoff.to_component == "pix_specialist" + assert _find(traces[0], "Tool: transfer_to_agent") is None + assert _parent_of(traces[0], handoff).name == "Agent: assistant" + + def test_tool_with_transfer_in_its_description_is_a_tool(self, traces: List[Any]) -> None: + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [_call("executar_transferencia_pix", valor=10.0), _text("feito")] + + _run_turns(_bank_agents(), "quero fazer um pix") + + tool = _find(traces[0], "Tool: executar_transferencia_pix") + assert tool is not None, "a tool whose description mentions 'transfer' must still be traced" + assert tool.output == {"ok": True, "valor": 10.0} + # Tool steps are siblings of the LLM calls, not their children. + assert _parent_of(traces[0], tool).name == "Agent: pix_specialist" + + def test_turn_summary_metadata(self, traces: List[Any]) -> None: + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [_text("feito"), _text("de nada")] + + _run_turns(_bank_agents(), "quero fazer um pix", "obrigado") + + assert len(traces) == 2 + first, second = (trace.metadata for trace in traces) + assert first == { + "starting_agent": "assistant", + "final_agent": "pix_specialist", + "handoff_count": 1, + "handoff_path": "assistant > pix_specialist", + } + # ADK keeps the specialist active, so the next turn starts there. + assert second == { + "starting_agent": "pix_specialist", + "final_agent": "pix_specialist", + "handoff_count": 0, + "handoff_path": "pix_specialist", + } + (root,) = traces[0].steps + assert root.metadata["handoff_path"] == "assistant > pix_specialist" + + def test_agent_tool_is_nested_not_a_handoff(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent + from google.adk.tools.agent_tool import AgentTool + + calculator = LlmAgent(name="calculator", model=ScriptedLlm(script="calculator"), description="Does math.") + boss = LlmAgent(name="boss", model=ScriptedLlm(script="boss"), tools=[AgentTool(agent=calculator)]) + SCRIPTS["boss"] = [_call("calculator", request="2+2"), _text("4!")] + SCRIPTS["calculator"] = [_text("4")] + + _run_turns(boss, "quanto é 2+2?") + + assert len(traces) == 1, "AgentTool's nested Runner must not start its own turn" + nested = _find(traces[0], "Agent: calculator") + assert nested is not None + assert _parent_of(traces[0], nested).name == "Tool: calculator" + assert not [step for step in _all_steps(traces[0]) if step.step_type == enums.StepType.HANDOFF] + assert traces[0].metadata["handoff_count"] == 0 + assert traces[0].metadata["final_agent"] == "boss" + + def test_workflow_agents_are_not_handoffs(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent, SequentialAgent + + first = LlmAgent(name="first", model=ScriptedLlm(script="first")) + second = LlmAgent(name="second", model=ScriptedLlm(script="second")) + SCRIPTS["first"] = [_text("one")] + SCRIPTS["second"] = [_text("two")] + + _run_turns(SequentialAgent(name="pipeline", sub_agents=[first, second]), "go") + + assert len(traces) == 1 + assert traces[0].metadata["handoff_count"] == 0 + assert traces[0].metadata["final_agent"] == "second" + + def test_empty_model_reply_does_not_break_the_turn(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent + + agent = LlmAgent(name="quiet", model=ScriptedLlm(script="quiet")) + SCRIPTS["quiet"] = [types.Content(role="model", parts=None)] + + _run_turns(agent, "hello?") + + assert len(traces) == 1 + assert traces[0].metadata["final_agent"] == "quiet" + assert _find(traces[0], "Agent: quiet").output == "Agent execution completed" + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning", "ignore::UserWarning") +class TestAdkHandoffSources: + """Handoffs come from the transfers ADK actually made.""" + + def test_transfer_set_by_a_custom_tool_is_a_handoff(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent + from google.adk.tools.tool_context import ToolContext + + def escalate(query: str, tool_context: ToolContext) -> str: # noqa: ARG001 + """Escalates the request to the Pix desk.""" + tool_context.actions.transfer_to_agent = "pix_specialist" + return "escalated" + + specialist = LlmAgent(name="pix_specialist", model=ScriptedLlm(script="pix_specialist"), description="Pix.") + assistant = LlmAgent( + name="assistant", model=ScriptedLlm(script="assistant"), sub_agents=[specialist], tools=[escalate] + ) + SCRIPTS["assistant"] = [_call("escalate", query="pix")] + SCRIPTS["pix_specialist"] = [_text("feito")] + + _run_turns(assistant, "quero fazer um pix") + + assert _find(traces[0], "Tool: escalate") is not None + assert _find(traces[0], "Handoff: assistant → pix_specialist") is not None + assert traces[0].metadata["handoff_count"] == 1 + assert traces[0].metadata["handoff_path"] == "assistant > pix_specialist" + + def test_transfer_set_by_a_callback_is_a_handoff(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent + + def lookup_account(query: str) -> Dict[str, str]: # noqa: ARG001 + """Looks up the customer's account.""" + return {"status": "blocked"} + + def escalate_blocked(tool: Any, args: Any, tool_context: Any, tool_response: Any) -> None: # noqa: ARG001 + tool_context.actions.transfer_to_agent = "pix_specialist" + + specialist = LlmAgent(name="pix_specialist", model=ScriptedLlm(script="pix_specialist"), description="Pix.") + assistant = LlmAgent( + name="assistant", + model=ScriptedLlm(script="assistant"), + sub_agents=[specialist], + tools=[lookup_account], + after_tool_callback=escalate_blocked, + ) + SCRIPTS["assistant"] = [_call("lookup_account", query="pix")] + SCRIPTS["pix_specialist"] = [_text("desbloqueado")] + + _run_turns(assistant, "minha conta?") + + assert _find(traces[0], "Handoff: assistant → pix_specialist") is not None + assert traces[0].metadata["handoff_path"] == "assistant > pix_specialist" + + def test_two_transfer_calls_in_one_reply_are_one_handoff(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent + + billing = LlmAgent(name="billing", model=ScriptedLlm(script="billing"), description="Billing.") + support = LlmAgent(name="support", model=ScriptedLlm(script="support"), description="Support.") + triage = LlmAgent(name="triage", model=ScriptedLlm(script="triage"), sub_agents=[billing, support]) + both = [ + types.Part(function_call=types.FunctionCall(name="transfer_to_agent", args={"agent_name": name})) + for name in ("billing", "support") + ] + SCRIPTS["triage"] = [types.Content(role="model", parts=both)] + SCRIPTS["support"] = [_text("suporte aqui")] + + _run_turns(triage, "help") + + # ADK keeps the last transfer of the reply. + assert traces[0].metadata["handoff_count"] == 1 + assert traces[0].metadata["handoff_path"] == "triage > support" + assert _find(traces[0], "Tool: transfer_to_agent") is None + + def test_failed_transfer_call_is_not_a_handoff(self, traces: List[Any]) -> None: + SCRIPTS["assistant"] = [_call("transfer_to_agent"), _text("posso ajudar?")] + + _run_turns(_bank_agents(), "oi") + + assert not [step for step in _all_steps(traces[0]) if step.step_type == enums.StepType.HANDOFF] + # The failed attempt is still visible as a tool call. + assert _find(traces[0], "Tool: transfer_to_agent") is not None + assert traces[0].metadata["handoff_count"] == 0 + assert traces[0].metadata["final_agent"] == "assistant" + + def test_transfer_inside_an_agent_tool_stays_inside_it(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent + from google.adk.tools.agent_tool import AgentTool + + writer = LlmAgent(name="writer", model=ScriptedLlm(script="writer"), description="Writes drafts.") + researcher = LlmAgent( + name="researcher", model=ScriptedLlm(script="researcher"), description="Researches.", sub_agents=[writer] + ) + boss = LlmAgent(name="boss", model=ScriptedLlm(script="boss"), tools=[AgentTool(agent=researcher)]) + SCRIPTS["boss"] = [_call("researcher", request="write about pix"), _text("aqui está")] + SCRIPTS["researcher"] = [_call("transfer_to_agent", agent_name="writer")] + SCRIPTS["writer"] = [_text("rascunho")] + + _run_turns(boss, "go") + + assert len(traces) == 1 + assert traces[0].metadata["handoff_count"] == 0, "a transfer inside an AgentTool isn't the turn's handoff" + assert traces[0].metadata["handoff_path"] == "boss" + writer_step = _find(traces[0], "Agent: writer") + assert writer_step is not None + assert _parent_of(traces[0], writer_step).name == "Tool: researcher" + handoff = _find(traces[0], "Handoff: researcher → writer") + assert handoff is not None + assert _parent_of(traces[0], handoff).name == "Agent: researcher" + + def test_transfer_inside_a_workflow_agent_stays_inside_it(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent, SequentialAgent + + helper = LlmAgent(name="helper", model=ScriptedLlm(script="helper"), description="Helps.") + collector = LlmAgent(name="collector", model=ScriptedLlm(script="collector"), sub_agents=[helper]) + finisher = LlmAgent(name="finisher", model=ScriptedLlm(script="finisher")) + SCRIPTS["collector"] = [_call("transfer_to_agent", agent_name="helper")] + SCRIPTS["helper"] = [_text("ajudei")] + SCRIPTS["finisher"] = [_text("fim")] + + _run_turns(SequentialAgent(name="pipeline", sub_agents=[collector, finisher]), "go") + + pipeline = _find(traces[0], "Agent: pipeline") + assert [step.name for step in pipeline.steps] == ["Agent: collector", "Agent: helper", "Agent: finisher"] + assert traces[0].metadata["handoff_path"] == "collector > helper" + + def test_final_agent_is_the_target_even_when_it_says_nothing(self, traces: List[Any]) -> None: + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [types.Content(role="model", parts=None)] + + _run_turns(_bank_agents(), "quero fazer um pix") + + assert traces[0].metadata["final_agent"] == "pix_specialist" + assert traces[0].metadata["handoff_path"] == "assistant > pix_specialist" + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning", "ignore::UserWarning") +class TestAdkTurnLifecycle: + """However the caller consumes the Runner, each turn is its own trace.""" + + def test_breaking_out_on_the_final_response_keeps_turns_apart(self, traces: List[Any]) -> None: + import gc + + from google.adk.agents import LlmAgent + + runner = _runner(LlmAgent(name="solo", model=ScriptedLlm(script="solo"))) + SCRIPTS["solo"] = [_text("um"), _text("dois"), _text("três")] + + @_tracer.trace() + def unrelated() -> int: + return 1 + + leftovers: Dict[str, Any] = {} + + async def _drive() -> None: + await _new_session(runner) + expected = 1 + for message in ("a", "b", "c"): + # ADK's tutorial pattern. The abandoned generator is closed later, + # from another task. + async for event in _turn(runner, message): + if event.is_final_response(): + break + gc.collect() + await _until(lambda count=expected: len(traces) >= count, timeout=2.0) + expected += 1 + leftovers["step"] = _tracer.get_current_step() + leftovers["turn"] = google_adk_tracer._current_turn.get() + unrelated() + + asyncio.run(_drive()) + + turns = [trace for trace in traces if trace.steps[0].name.startswith("Agent turn")] + assert [trace.steps[0].output for trace in turns] == ["um", "dois", "três"] + assert all(trace.metadata["final_agent"] == "solo" for trace in turns) + assert leftovers == {"step": None, "turn": None}, "the caller's context kept the turn" + assert [trace.steps[0].name for trace in traces if trace not in turns] == ["unrelated"] + + def test_turn_resumed_from_a_new_task_per_event(self, traces: List[Any]) -> None: + runner = _runner(_bank_agents()) + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [_text("feito")] + + async def _drive() -> None: + await _new_session(runner) + events = _turn(runner, "quero fazer um pix") + + async def _next() -> Any: + return await events.__anext__() + + while True: + try: + # A task per event, as an inactivity timeout or an SSE + # heartbeat loop does. + await asyncio.ensure_future(_next()) + except StopAsyncIteration: + break + + asyncio.run(_drive()) + + assert len(traces) == 1 + assert traces[0].metadata["handoff_path"] == "assistant > pix_specialist" + assert traces[0].steps[0].output == "feito" + + def test_turns_inside_one_traced_function_share_the_columns(self, traces: List[Any]) -> None: + runner = _runner(_bank_agents()) + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [_text("feito"), _text("de nada")] + + @_tracer.trace_async() + async def conversation() -> None: + for message in ("quero fazer um pix", "obrigado"): + async for _event in _turn(runner, message): + pass + + async def _drive() -> None: + await _new_session(runner) + await conversation() + + asyncio.run(_drive()) + + assert len(traces) == 1 + assert traces[0].metadata == { + "starting_agent": "assistant", + "final_agent": "pix_specialist", + "handoff_count": 1, + "handoff_path": "assistant > pix_specialist", + } + + def test_handler_that_returns_on_the_final_response(self, traces: List[Any]) -> None: + import gc + + runner = _runner(_bank_agents()) + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [_text("feito"), _text("de nada")] + + @_tracer.trace_async() + async def handler(message: str) -> Optional[str]: + async for event in _turn(runner, message): + if event.is_final_response(): + return event.content.parts[0].text + return None + + async def _drive() -> None: + await _new_session(runner) + assert await handler("quero fazer um pix") == "feito" + gc.collect() + assert await handler("obrigado") == "de nada" + gc.collect() + await _until(lambda: False, timeout=0.1) + + asyncio.run(_drive()) + + handlers = [trace for trace in traces if trace.steps[0].name == "handler"] + assert len(handlers) == 2 + # Each handler's row has its own turn's columns, written before the + # turn's generator was closed. + assert handlers[0].metadata["handoff_path"] == "assistant > pix_specialist" + assert handlers[1].metadata["handoff_path"] == "pix_specialist" + assert [step.name for step in handlers[1].steps[0].steps] == ["Agent turn: bank_app"] + assert [step.name for step in handlers[1].steps[0].steps[0].steps] == ["Agent: pix_specialist"] + + def test_turn_that_reaches_no_agent_keeps_the_columns(self, traces: List[Any]) -> None: + runner = _runner(_bank_agents()) + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [_text("feito")] + + @_tracer.trace_async() + async def conversation() -> None: + async for _event in _turn(runner, "quero fazer um pix"): + pass + content = types.Content(role="user", parts=[types.Part(text="oi")]) + with pytest.raises(Exception): # noqa: B017 - the session doesn't exist + async for _event in runner.run_async(user_id=USER_ID, session_id="missing", new_message=content): + pass + + async def _drive() -> None: + await _new_session(runner) + await conversation() + + asyncio.run(_drive()) + + assert traces[0].metadata == { + "starting_agent": "assistant", + "final_agent": "pix_specialist", + "handoff_count": 1, + "handoff_path": "assistant > pix_specialist", + } + + def test_failed_turn_has_no_completion_output(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent + + def lookup(account: str) -> str: # noqa: ARG001 + """Looks up an account.""" + raise ValueError("backend down") + + agent = LlmAgent(name="solo", model=ScriptedLlm(script="solo"), tools=[lookup]) + SCRIPTS["solo"] = [_call("lookup", account="1")] + + with pytest.raises(ValueError): + _run_turns(agent, "saldo?") + + (root,) = traces[0].steps + assert root.output is None + assert root.metadata["error"]["type"] == "ValueError" + + def test_cancelled_turn_records_the_cancellation(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent + + runner = _runner(LlmAgent(name="solo", model=ScriptedLlm(script="solo"))) + SCRIPTS["solo"] = [5] + + async def _consume() -> None: + async for _event in _turn(runner, "oi"): + pass + + async def _drive() -> None: + await _new_session(runner) + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(_consume(), timeout=0.2) + + asyncio.run(_drive()) + + (root,) = traces[0].steps + assert root.output is None + assert root.metadata["error"]["type"] == "CancelledError" + + def test_resumed_turn_starts_at_the_paused_agent(self, traces: List[Any]) -> None: + from google.adk.apps import App, ResumabilityConfig + from google.adk.agents import LlmAgent + from google.adk.runners import Runner + from google.adk.sessions import InMemorySessionService + from google.adk.tools.long_running_tool import LongRunningFunctionTool + + def aprovar_pix(valor: float) -> None: # noqa: ARG001 + """Asks a human to approve a Pix.""" + return None + + specialist = LlmAgent( + name="pix_specialist", + model=ScriptedLlm(script="pix_specialist"), + description="Pix.", + tools=[LongRunningFunctionTool(aprovar_pix)], + ) + assistant = LlmAgent(name="assistant", model=ScriptedLlm(script="assistant"), sub_agents=[specialist]) + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [_call("aprovar_pix", valor=10.0), _text("pix aprovado"), _text("extra")] + app = App(name=APP_NAME, root_agent=assistant, resumability_config=ResumabilityConfig(is_resumable=True)) + runner = Runner(app=app, session_service=InMemorySessionService()) + + async def _drive() -> None: + await _new_session(runner) + call_id = invocation_id = None + async for event in _turn(runner, "quero fazer um pix"): + invocation_id = event.invocation_id + for function_call in event.get_function_calls() or []: + if function_call.name == "aprovar_pix": + call_id = function_call.id + approval = types.Content( + role="user", + parts=[ + types.Part( + function_response=types.FunctionResponse( + id=call_id, name="aprovar_pix", response={"status": "approved"} + ) + ) + ], + ) + async for _event in runner.run_async( + user_id=USER_ID, session_id=SESSION_ID, invocation_id=invocation_id, new_message=approval + ): + pass + + asyncio.run(_drive()) + + assert len(traces) == 2 + assert traces[0].metadata["handoff_path"] == "assistant > pix_specialist" + assert traces[1].metadata["handoff_count"] == 0 + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning", "ignore::UserWarning") +class TestAdkOutputs: + def test_workflow_agent_output_is_its_last_childs_answer(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent, SequentialAgent + + first = LlmAgent(name="first", model=ScriptedLlm(script="first")) + second = LlmAgent(name="second", model=ScriptedLlm(script="second")) + SCRIPTS["first"] = [_text("one")] + SCRIPTS["second"] = [_text("two")] + + _run_turns(SequentialAgent(name="pipeline", sub_agents=[first, second]), "go") + + assert _find(traces[0], "Agent: pipeline").output == "two" + + def test_agent_tool_result_without_summary_is_the_answer(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent + from google.adk.tools.agent_tool import AgentTool + + calculator = LlmAgent(name="calculator", model=ScriptedLlm(script="calculator"), description="Does math.") + boss = LlmAgent( + name="boss", + model=ScriptedLlm(script="boss"), + tools=[AgentTool(agent=calculator, skip_summarization=True)], + ) + SCRIPTS["boss"] = [_call("calculator", request="2+2")] + SCRIPTS["calculator"] = [_text("The answer is 4")] + + _run_turns(boss, "quanto é 2+2?") + + assert traces[0].steps[0].output == "The answer is 4" + + def test_thoughts_are_not_part_of_the_answer(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent + + SCRIPTS["solo"] = [ + types.Content( + role="model", + parts=[types.Part(text="**Considering the request**", thought=True), types.Part(text="Pix enviado.")], + ) + ] + + _run_turns(LlmAgent(name="solo", model=ScriptedLlm(script="solo")), "manda o pix") + + assert traces[0].steps[0].output == "Pix enviado." + assert _find(traces[0], "Agent: solo").output == "Pix enviado." + + def test_turn_root_carries_the_first_agents_columns(self, traces: List[Any]) -> None: + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [_text("feito")] + + _run_turns(_bank_agents(), "quero fazer um pix") + + row, input_names = _tracer.post_process_trace(traces[0]) + assert row["agent_name"] == "assistant" + assert {"agent_name", "model", "instruction", "sub_agents", "user_query"} <= set(input_names) + assert input_names[-1] == "user_query" + # Every agent of the turn answers the user's message. + assert _find(traces[0], "Agent: pix_specialist").inputs["user_query"] == "quero fazer um pix" + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning", "ignore::UserWarning") +class TestAdkLlmStep: + def test_llm_step_uses_gemini_provider_and_counts_thinking_tokens(self, traces: List[Any]) -> None: + from google.adk.agents import LlmAgent + + agent = LlmAgent(name="solo", model=ScriptedLlm(script="solo")) + SCRIPTS["solo"] = [_text("hi")] + + _run_turns(agent, "hi") + + (llm,) = [step for step in _all_steps(traces[0]) if step.step_type == enums.StepType.CHAT_COMPLETION] + assert llm.provider == "gemini" + assert llm.model == "gemini-2.5-flash" + assert llm.prompt_tokens == 10 + # Thinking tokens are billed as output tokens. + assert llm.completion_tokens == 8 + assert llm.tokens == 18 + + def test_no_context_detach_errors(self, traces: List[Any], caplog: pytest.LogCaptureFixture) -> None: + SCRIPTS["assistant"] = [_call("transfer_to_agent", agent_name="pix_specialist")] + SCRIPTS["pix_specialist"] = [_call("executar_transferencia_pix", valor=10.0), _text("feito")] + + with caplog.at_level(logging.ERROR): + _run_turns(_bank_agents(), "quero fazer um pix") + + assert traces + assert "Failed to detach context" not in caplog.text + + +class TestAdkPatchTargets: + def test_tool_call_function_is_patched(self) -> None: + """The first target that exists in the installed ADK must be wrapped.""" + for module_name, attr in google_adk_tracer._TOOL_CALL_TARGETS: + try: + module = importlib.import_module(module_name) + except ImportError: + continue + if hasattr(module, attr): + assert hasattr(getattr(module, attr), "__wrapped__"), f"{module_name}.{attr} is not patched" + return + pytest.fail("none of _TOOL_CALL_TARGETS exists in the installed google-adk") + + +class TestAdkUsageAndRawOutput: + @staticmethod + def _usage(**counts: int) -> Any: + return LlmResponse(usage_metadata=types.GenerateContentResponseUsageMetadata(**counts)) + + def test_native_gemini_thoughts_are_added_to_completion(self) -> None: + usage = google_adk_tracer._extract_usage_from_response( + self._usage(prompt_token_count=8, candidates_token_count=6, thoughts_token_count=743, total_token_count=757) + ) + assert (usage["prompt_tokens"], usage["completion_tokens"], usage["total_tokens"]) == (8, 749, 757) + assert usage["breakdown"]["thoughtsTokens"] == 743 + + def test_reasoning_already_in_candidates_is_not_added_twice(self) -> None: + # ADK's LiteLlm maps LiteLLM's completion_tokens (reasoning included) to + # candidates and the reasoning count to thoughts. + usage = google_adk_tracer._extract_usage_from_response( + self._usage( + prompt_token_count=100, candidates_token_count=500, thoughts_token_count=400, total_token_count=600 + ) + ) + assert (usage["completion_tokens"], usage["total_tokens"]) == (500, 600) + + def test_raw_output_leaves_out_inline_data(self) -> None: + blob = b"\x89PNG" + b"0" * (1 << 20) + response = LlmResponse( + content=types.Content( + role="model", + parts=[ + types.Part(text="here"), + types.Part(inline_data=types.Blob(mime_type="image/png", data=blob)), + ], + ) + ) + + raw = google_adk_tracer._response_json(response) + + assert len(raw) < 1000 + assert "image/png" in raw and "here" in raw + assert response.content.parts[1].inline_data.data == blob, "the caller's response must not change" diff --git a/tests/test_google_adk_integration.py b/tests/test_google_adk_integration.py index 28a6b0b7..12cc6b83 100644 --- a/tests/test_google_adk_integration.py +++ b/tests/test_google_adk_integration.py @@ -70,14 +70,15 @@ class TestGoogleADKCallbackExceptions: USER_ID = "u" SESSION_ID = "s" - def _build_runner_and_agent(self) -> Tuple[Any, Any]: + def _build_runner_and_agent(self, traced: bool = True) -> Tuple[Any, Any]: from google.adk.agents import LlmAgent from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService from openlayer.lib.integrations import trace_google_adk - trace_google_adk() + if traced: + trace_google_adk() async def before_model(callback_context: Any, llm_request: Any, **_: Any) -> None: # noqa: ARG001 raise ErrorHandling("input blocked by guardrail (pi_and_jailbreak)") @@ -106,7 +107,14 @@ async def _drive_runner(self, runner: Any, session_service: Any) -> None: @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_user_exception_propagates_without_chaining(self) -> None: - """The user's ``ErrorHandling`` must be the only exception surfaced.""" + """The tracer must not add anything to the exception chain ADK raises.""" + # ADK 2.x itself chains its DynamicNodeFailError onto the user's + # exception, so compare against the same run without the tracer. + runner, session_service = self._build_runner_and_agent(traced=False) + with pytest.raises(ErrorHandling) as baseline_info: + asyncio.run(self._drive_runner(runner, session_service)) + baseline = [type(item) for item in _collect_exception_chain(baseline_info.value)] + runner, session_service = self._build_runner_and_agent() with pytest.raises(ErrorHandling) as exc_info: @@ -121,13 +129,11 @@ def test_user_exception_propagates_without_chaining(self) -> None: for item in chain ), f"chained ValueError appeared in exception chain: {chain}" - # Anything in the chain that isn't the user's exception would itself - # be a tracer-introduced failure — fail loudly. - for item in chain: - assert isinstance(item, ErrorHandling), ( - "unexpected non-ErrorHandling exception in chain: " - f"{type(item).__name__}: {item}" - ) + # Anything beyond what ADK raises untraced would be a tracer-introduced + # failure — fail loudly. + assert [type(item) for item in chain] == baseline, ( + f"tracer changed the exception chain: {chain} (untraced: {baseline})" + ) @pytest.mark.filterwarnings("ignore::DeprecationWarning") def test_callback_error_recorded_on_step_metadata(self) -> None: