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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions temporalio/contrib/openai_agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,36 @@ A stateless factory that declares no parameters — like the `lambda: MCPServerS

For network-accessible MCP servers, you can also use `HostedMCPTool` from the OpenAI Agents SDK, which uses an MCP client hosted by OpenAI.

## Secrets for Hosted Tools

⚠️ **Experimental** - This functionality is subject to change prior to General Availability.

Use `secret_reference()` for a hosted tool credential that should come from the worker's environment rather than being written into your workflow. Pass it the *name of an environment variable*, in place of the credential itself:

```python
from agents import HostedMCPTool
from temporalio.contrib.openai_agents import secret_reference

tool = HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "my_server",
"server_url": "https://example.com/mcp",
"authorization": secret_reference("MY_MCP_TOKEN"),
}
)
```

Set `MY_MCP_TOKEN` on every worker that runs model activities — if it is missing or empty there, the model call fails with a non-retryable error naming it.

The variable's value is substituted in these fields and no others:

- `authorization`, and the value of each entry in `headers`, in a `HostedMCPTool`'s `tool_config`
- `value` in each entry of `network_policy.domain_secrets` under a hosted `ShellTool`'s `environment`
- `value` in each entry of `network_policy.domain_secrets` under a `CodeInterpreterTool`'s `container`

Anywhere else — a header *name*, or an MCP server `factory_argument` (see [Factory Arguments](#factory-arguments)) — the placeholder is passed on as literal text, with no error from this SDK.

## Sandbox Support

⚠️ **Pre-release** - This functionality is subject to change prior to General Availability.
Expand Down
2 changes: 2 additions & 0 deletions temporalio/contrib/openai_agents/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
StatelessMCPServerProvider,
)
from temporalio.contrib.openai_agents._model_parameters import ModelActivityParameters
from temporalio.contrib.openai_agents._secret_reference import secret_reference
from temporalio.contrib.openai_agents._temporal_openai_agents import (
OpenAIAgentsPlugin,
OpenAIPayloadConverter,
Expand All @@ -28,6 +29,7 @@
"SandboxClientProvider",
"StatelessMCPServerProvider",
"StatefulMCPServerProvider",
"secret_reference",
"testing",
"workflow",
]
24 changes: 20 additions & 4 deletions temporalio/contrib/openai_agents/_invoke_model_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@

from temporalio import activity
from temporalio.contrib.openai_agents._heartbeat_decorator import auto_heartbeater
from temporalio.contrib.openai_agents._secret_reference import (
resolve_code_interpreter_tool_config,
resolve_mcp_tool_config,
resolve_shell_tool_environment,
)
from temporalio.contrib.workflow_streams import WorkflowStreamClient
from temporalio.exceptions import ApplicationError

Expand Down Expand Up @@ -220,6 +225,7 @@ async def _empty_on_invoke_handoff(_ctx: RunContextWrapper[Any], _input: str) ->


async def _noop_shell_executor(*_a: Any, **_kw: Any) -> str:
"""Satisfies the ShellExecutor type for tool reconstruction during model calls."""
return ""


Expand All @@ -231,22 +237,32 @@ def _build_tool(tool: ToolInput) -> Tool:
FileSearchTool,
WebSearchTool,
ImageGenerationTool,
CodeInterpreterTool,
LocalShellTool,
ToolSearchTool,
),
):
return tool
elif isinstance(tool, CodeInterpreterTool):
return CodeInterpreterTool(
tool_config=resolve_code_interpreter_tool_config(tool.tool_config)
)
elif isinstance(tool, ShellToolInput):
environment = resolve_shell_tool_environment(tool.environment)
# Only a local environment takes an executor, and an absent type means
# local, matching how ShellTool normalizes its environment.
return ShellTool(
name=tool.name,
environment=tool.environment,
executor=_noop_shell_executor,
environment=environment,
executor=(
_noop_shell_executor
if environment.get("type", "local") == "local"
else None
),
)
elif isinstance(tool, ApplyPatchToolInput):
return ApplyPatchTool(name=tool.name, editor=_NoopApplyPatchEditor())
elif isinstance(tool, HostedMCPToolInput):
return HostedMCPTool(tool_config=tool.tool_config)
return HostedMCPTool(tool_config=resolve_mcp_tool_config(tool.tool_config))
elif isinstance(tool, CustomToolInput):
return CustomTool(
name=tool.tool_config["name"],
Expand Down
228 changes: 228 additions & 0 deletions temporalio/contrib/openai_agents/_secret_reference.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
"""References to secrets held in the worker process environment.

A resolved secret is never written back into the activity's own input: the
resolvers copy at every level they write to, so the input keeps the marker.
"""

from __future__ import annotations

import os
from collections.abc import Iterator, Mapping, MutableMapping
from typing import Any, cast

from agents.tool import ShellToolContainerAutoEnvironment, ShellToolEnvironment
from openai.types.responses.tool_param import CodeInterpreter, Mcp
from pydantic import ValidationError

from temporalio.contrib.openai_agents.workflow import AgentsWorkflowError
from temporalio.exceptions import ApplicationError

_MARKER_PREFIX = "temporal.secret_reference:"

_ERROR_TYPE = "SecretReferenceFailure"


def secret_reference(key: str) -> str:
"""Refer to a secret held in the worker process environment.

.. warning::
This function is experimental and may change in future versions.
Use with caution in production environments.

Use it for a hosted tool credential that should come from the worker's
environment rather than being written into your workflow. Put the
placeholder returned where the credential would have gone. Only the
variable's name is recorded in workflow history.

::

from agents import HostedMCPTool

from temporalio.contrib.openai_agents import secret_reference

tool = HostedMCPTool(
tool_config={
"type": "mcp",
"server_label": "my_server",
"server_url": "https://example.com/mcp",
"authorization": secret_reference("MY_MCP_TOKEN"),
}
)

Set the variable on every worker that runs model activities — if it is
missing or empty there, the model call fails with a non-retryable
``ApplicationError`` of type ``SecretReferenceFailure`` naming it.

Args:
key: Name of the environment variable to read on the worker.

Returns:
A placeholder string to use in place of the secret.

Raises:
AgentsWorkflowError: If ``key`` is empty.
"""
if not key:
raise AgentsWorkflowError(
"secret_reference() requires the name of an environment variable to read "
"on the worker, but the name given was empty."
)
return _MARKER_PREFIX + key


def _resolve_secret_reference(value: str) -> str:
"""Return ``value`` with a secret reference marker replaced by its secret.

Raises:
ApplicationError: If the marker names no environment variable, or the
variable it names is unset or empty in the worker process
environment. Non-retryable, of type ``SecretReferenceFailure``.
"""
if not value.startswith(_MARKER_PREFIX):
return value
key = value[len(_MARKER_PREFIX) :]
if not key:
raise ApplicationError(
f"Malformed secret reference {value!r}: the text after "
f"{_MARKER_PREFIX!r} must be the name of an environment variable to read "
"on the worker. Build the placeholder with secret_reference().",
type=_ERROR_TYPE,
non_retryable=True,
)
secret = os.environ.get(key)
if not secret:
raise ApplicationError(
f"Secret reference environment variable {key!r} is not set, or is empty, "
"in the worker process environment.",
type=_ERROR_TYPE,
non_retryable=True,
)
return secret


def _shallow_copy(mapping: Any) -> Any:
return dict(cast(Mapping[str, Any], mapping))


def _malformed_domain_secret_error(e: ValidationError) -> ApplicationError:
"""The rejection to raise for a domain secret that does not validate.

pydantic rejects the whole entry for some malformed shapes and a single
field for others, so the type named is not claimed to be the entry's.
"""
error = e.errors()[0]
return ApplicationError(
f"Domain secret {error['loc'][0]} in a container network policy is "
f"malformed. Only its position and the type of the value that was "
f"rejected ({type(error['input']).__name__}) are reported: a malformed "
"entry could itself hold the secret.",
type=_ERROR_TYPE,
non_retryable=True,
)


class _UnreadDomainSecrets:
"""Raises when iterated, so secrets a failed read consumed never read as absent."""

def __init__(self, error: ApplicationError) -> None:
self._error = error

def __iter__(self) -> Iterator[Any]:
raise self._error


def _resolve_network_policy(network_policy: Any) -> Any:
"""Copy a container network policy, resolving each domain secret value.

On the code interpreter path pydantic deserializes ``domain_secrets`` into a
single-pass iterator, so the entries read here go back onto the input.

Raises:
ApplicationError: If a marker cannot be resolved, or a domain secret is
malformed. Non-retryable.
"""
policy = cast(MutableMapping[str, Any], network_policy)
domain_secrets = policy.get("domain_secrets")
if domain_secrets is None:
return dict(policy)
try:
unresolved = list(domain_secrets)
except ValidationError as e:
error = _malformed_domain_secret_error(e)
policy["domain_secrets"] = _UnreadDomainSecrets(error)
# Not chained: the validation error carries the entry it rejected.
raise error from None
policy["domain_secrets"] = unresolved
resolved = dict(policy)
resolved["domain_secrets"] = [
_resolve_domain_secret(secret) for secret in unresolved
]
return resolved


def _resolve_domain_secret(secret: Mapping[str, Any]) -> dict[str, Any]:
return {**secret, "value": _resolve_secret_reference(secret["value"])}


def resolve_mcp_tool_config(tool_config: Mcp) -> Mcp:
"""Copy a hosted MCP tool config, resolving its authorization and headers.

Raises:
ApplicationError: If a marker cannot be resolved. Non-retryable.
"""
resolved = _shallow_copy(tool_config)
if "authorization" in tool_config:
resolved["authorization"] = _resolve_secret_reference(
tool_config["authorization"]
)
headers = tool_config.get("headers")
if headers is not None:
resolved["headers"] = {
name: _resolve_secret_reference(value) for name, value in headers.items()
}
return resolved


def resolve_shell_tool_environment(
environment: ShellToolEnvironment | None,
) -> ShellToolEnvironment:
"""Copy a shell tool environment, resolving its domain secret values.

An absent environment comes back as the local one ``ShellTool`` normalizes it to.

Raises:
ApplicationError: If a marker cannot be resolved, or a domain secret is
malformed. Non-retryable.
"""
if environment is None:
return {"type": "local"}
if environment.get("type") != "container_auto":
return _shallow_copy(environment)
auto = cast(ShellToolContainerAutoEnvironment, environment)
network_policy = auto.get("network_policy")
resolved = _shallow_copy(auto)
if network_policy is not None:
resolved["network_policy"] = _resolve_network_policy(network_policy)
return resolved


def resolve_code_interpreter_tool_config(
tool_config: CodeInterpreter,
) -> CodeInterpreter:
"""Copy a code interpreter tool config, resolving its domain secret values.

Raises:
ApplicationError: If a marker cannot be resolved, or a domain secret is
malformed. Non-retryable.
"""
resolved = _shallow_copy(tool_config)
container = tool_config.get("container")
if not isinstance(container, Mapping):
return resolved
network_policy = container.get("network_policy")
if network_policy is None:
return resolved
resolved_container = _shallow_copy(container)
resolved_container["network_policy"] = _resolve_network_policy(network_policy)
resolved["container"] = resolved_container
return resolved
Loading
Loading