trytilde-openai-agents 3.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ /target/
2
+ /dist/
3
+ node_modules/
4
+ # Build output of an older example-agent layout; the examples live under sdk/ts/examples.
5
+ /dev/example-agent-1/
6
+ /web/dist/
7
+ /.tools/
8
+ /.env.*
9
+ !/.env.example
10
+ !/.env.test
11
+ /.run/
12
+ .run/
13
+
14
+ /sdk/ts/**/dist/
15
+ # Generated TypeScript contracts; @trytilde/contracts builds them from proto/.
16
+ /sdk/ts/packages/contracts/gen/
17
+
18
+ /web/provider-dist/
19
+
20
+ __pycache__/
21
+
22
+ # Local runtime credentials must not enter the initial repository commit.
23
+ /.env
24
+ log
25
+
26
+ /.local/
27
+
28
+ # Bifrost benchmark container state
29
+ /dev/inference-bench/bifrost/*
30
+ !/dev/inference-bench/bifrost/config.json
31
+
32
+ # Local Claude Code worktrees
33
+ /.claude/worktrees/
34
+
35
+ # Python SDK: uv environments and generated contracts (sdk/py/scripts/generate.py).
36
+ /sdk/py/.venv/
37
+ /sdk/py/.venv-crewai/
38
+ /sdk/py/**/.venv/
39
+ /sdk/py/packages/tilde/src/tilde/agent_event_ingress/
40
+ /sdk/py/packages/tilde/src/tilde/agent_host/
41
+ /sdk/py/packages/tilde/src/tilde/ingress/
42
+ /sdk/py/packages/tilde/src/tilde/management/
43
+ /sdk/py/packages/tilde/src/tilde/provider/
44
+ /sdk/py/packages/tilde/src/tilde/run/
45
+ /sdk/py/packages/tilde/src/tilde/runtime/
46
+ /sdk/py/packages/tilde/src/tilde/setup/
47
+ /sdk/py/packages/tilde/src/tilde/tool_host/
48
+ /sdk/py/packages/tilde/src/tilde/types/
49
+ /sdk/py/**/*.egg-info/
50
+ .pytest_cache/
51
+ .ruff_cache/
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.5
2
+ Name: trytilde-openai-agents
3
+ Version: 3.0.1
4
+ Summary: Tilde adapter for the OpenAI Agents SDK: typed history to Responses input items and channel tools to function tools
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: openai-agents<1,>=0.22
8
+ Requires-Dist: trytilde
9
+ Description-Content-Type: text/markdown
10
+
11
+ # OpenAI Agents SDK adapter
12
+
13
+ Core history and delivery live in `trytilde`. This package converts typed context to
14
+ OpenAI Agents SDK input items and channel tools to `FunctionTool`s, bundles each invocation's
15
+ run options (`tilde_openai_agents`) and lets `tilde deploy` discover module-level agents.
16
+
17
+ ```python
18
+ import tilde
19
+ from agents import Agent, OpenAIResponsesModel, Runner, set_tracing_disabled
20
+ from openai import AsyncOpenAI
21
+ from tilde_openai_agents import convert_to_openai_agents_messages, tilde_openai_agents
22
+
23
+ set_tracing_disabled(True) # or OPENAI_AGENTS_DISABLE_TRACING=1; keeps traces out of OpenAI
24
+ INFERENCE = tilde.inference("default")
25
+ client = AsyncOpenAI(
26
+ base_url=INFERENCE.base_url, api_key=INFERENCE.api_key, http_client=INFERENCE.async_client()
27
+ )
28
+ agent = Agent(
29
+ name="assistant",
30
+ instructions="Respond using the current channel's tools. Returned model text is private.",
31
+ model=OpenAIResponsesModel(model="gpt-4o-mini", openai_client=client),
32
+ )
33
+
34
+
35
+ async def run(ctx):
36
+ history = await ctx.message.history()
37
+ items = await convert_to_openai_agents_messages(history.items, context=ctx)
38
+ run_agent, run_config = await tilde_openai_agents(ctx, agent)
39
+ await Runner.run(run_agent, items, run_config=run_config, max_turns=8)
40
+ ```
41
+
42
+ `tilde_openai_agents(ctx, agent, run_config=None)` returns a clone of `agent` and a
43
+ `RunConfig` (a copy of yours, when given):
44
+
45
+ - the clone has the current channel's tools;
46
+ - skills assigned in the registry: with a local `ShellTool` they join its environment
47
+ `skills` (written to disk by `ctx.skills.directory()`); otherwise the clone gets the
48
+ `list_skills` / `read_skill` tools and `ctx.skills.summary()` after its instructions;
49
+ - the config's `call_model_input_filter` (chained after yours) checks cancellation before
50
+ every model call, stamps the invocation's inference calls with the calling agent's dynamic
51
+ instructions and adds steering input as user messages. The SDK sends filtered input for
52
+ one call only, so steered messages are re-inserted where they arrived on later calls; with
53
+ server-managed conversations (`previous_response_id`, `conversation_id`) that duplicates
54
+ them.
55
+
56
+ Handoff targets and agent tools run as defined; only the returned agent has channel tools.
57
+
58
+ ## Deploy discovery
59
+
60
+ `tilde deploy` (entry point `tilde.discover`) registers every module-level `Agent` and the
61
+ agents reachable through its handoffs and `as_tool` tools (cycles are fine):
62
+ `<name>/instructions` (a string is plain, a function dynamic with its source) and
63
+ `<name>/handoff_description`. Skill folders the SDK loads from disk are shipped: local
64
+ `ShellTool` environment skills and a `SandboxAgent`'s `Skills(from_=LocalDir(...))` or
65
+ `lazy_from=LocalDirLazySkillSource(...)`, resolved from the working directory. Hosted
66
+ prompts (`prompt={"id": ...}`) are versioned by OpenAI and reported as warnings. Handoff
67
+ targets and agent-tool agents are read from private fields (openai-agents 0.22); an
68
+ unreadable one is reported.
69
+
70
+ History pages are chronological; pass `before_message_id=history.next_page_token` for
71
+ older messages. The latest page includes the current objective unless it already matches
72
+ the latest received message. `include_objective=False` omits it. `include_work=True` also
73
+ reads current goals/tasks and requires `work.read`. Only the acting agent's messages receive
74
+ the assistant role (`output_text` parts); everything else is a user item (`input_text`).
75
+
76
+ Returned items never carry an `id`: the Responses API rejects ids it did not issue. The
77
+ Tilde message id is stored only inside the cached representation, where it validates that
78
+ a cached rendering belongs to the message being hydrated.
79
+
80
+ Images and PDFs are downloaded through `ctx.attachments.download` and embedded as
81
+ `input_image` / `input_file` data URLs. Text files include their real content. Unsupported
82
+ binary formats get an explicit attachment description; use `on_attachment` to parse them
83
+ yourself. No private URL or credential needs to be exposed to the model. Without a context
84
+ or custom handler, a message with attachments raises instead of being silently truncated.
85
+
86
+ ```python
87
+ history = await ctx.message.history(include_work=True)
88
+ items = await convert_to_openai_agents_messages(
89
+ history.items,
90
+ context=ctx,
91
+ on_message=MessageHandlers(
92
+ goal=lambda item: {
93
+ "role": "user",
94
+ "content": [{"type": "input_text", "text": f"Our goal: {item.goal.objective}"}],
95
+ },
96
+ task=lambda item: None, # Omit this type, or provide a different rendering.
97
+ ),
98
+ on_attachment=decode_your_format, # async def (AttachmentConversion) -> part | [parts] | None
99
+ )
100
+ ```
101
+
102
+ `MessageHandlers` supports `message`, `objective`, `goal`, and `task`; handlers may be sync
103
+ or async. Supplied handlers take precedence over cached/default rendering; returning None
104
+ omits an item. Without an override the converter renders all supported types (incomplete
105
+ conversation messages are skipped unless a `message` handler is given). Completed
106
+ conversation conversions use the existing per-agent cache, in bounded batches (100 items or
107
+ 1 MiB). Files are hydrated afresh and are never stored as data URLs in the cache;
108
+ objectives/goals/tasks remain live projections rather than cached chat records.
109
+
110
+ ## Channel tools
111
+
112
+ `convert_to_openai_agents_tools(ctx.channel.current)` preserves the provider's descriptions
113
+ and JSON schemas (`strict_json_schema=False`, so provider schemas are used unchanged) and
114
+ forwards the Agents SDK tool-call id (`ToolContext.tool_call_id`) to Tilde's audited tool
115
+ execution. Provider results are returned to the model as JSON strings. The adapter does
116
+ not publish the model's final text. The agent chooses the provider tool and arguments,
117
+ including routing fields required by that provider.
118
+
119
+ Override tool instructions with `instructions={"sendMessage": "..."}`, or change the
120
+ channel tool's `description` before conversion. When combining collections, use
121
+ `prefix="slack_"` (or another prefix) to keep model tool names distinct; names are sanitized
122
+ to `[a-zA-Z0-9_-]`, truncated to 64 characters with a stable hash suffix, and conflicts raise.
123
+
124
+ The core SDK exposes callable tools on `ctx.channel.slack`, `github`, `agentmail`, `linq`,
125
+ `whatsapp`, `telnyx_whatsapp`, and `native`. Use `ctx.channel.connections()` and
126
+ `ctx.channel.for_connection(id)` when multiple connections use a provider.
127
+ `ctx.channel.current` never falls back to a different connection.
128
+
129
+ ## Bundled tools
130
+
131
+ The agent's own function tools join Tilde's with one call:
132
+
133
+ ```python
134
+ from agents import Agent, function_tool
135
+ from tilde import BundledOptions
136
+ from tilde_openai_agents import with_tilde_tools
137
+
138
+
139
+ @function_tool
140
+ def roll_dice(count: int = 1) -> list[int]:
141
+ """Roll six-sided dice."""
142
+ return [random.randint(1, 6) for _ in range(count)]
143
+
144
+
145
+ tools = await with_tilde_tools(
146
+ ctx, [roll_dice], options={"roll_dice": BundledOptions(summary="Rolled dice")}
147
+ )
148
+ agent = Agent(name="assistant", tools=tools)
149
+ ```
150
+
151
+ `with_tilde_tools` returns the current channel's tools, `ctx.agent_tools` and copies of the
152
+ native `FunctionTool`s with an audited `on_invoke_tool`, and publishes the native tools to
153
+ Tilde with their parameter and output schemas, so `tools.search` finds them and a
154
+ `tools.execute` naming one runs it here with the run's context. Every call is audited once with
155
+ the model's call id. `FunctionTool` has no free metadata, so summaries come from `options`.
156
+ `@function_tool` turns an exception into an error string for the model by default, so such a
157
+ failure is audited as completed with that text; `failure_error_function=None` audits it as
158
+ failed (and fails the run).
@@ -0,0 +1,148 @@
1
+ # OpenAI Agents SDK adapter
2
+
3
+ Core history and delivery live in `trytilde`. This package converts typed context to
4
+ OpenAI Agents SDK input items and channel tools to `FunctionTool`s, bundles each invocation's
5
+ run options (`tilde_openai_agents`) and lets `tilde deploy` discover module-level agents.
6
+
7
+ ```python
8
+ import tilde
9
+ from agents import Agent, OpenAIResponsesModel, Runner, set_tracing_disabled
10
+ from openai import AsyncOpenAI
11
+ from tilde_openai_agents import convert_to_openai_agents_messages, tilde_openai_agents
12
+
13
+ set_tracing_disabled(True) # or OPENAI_AGENTS_DISABLE_TRACING=1; keeps traces out of OpenAI
14
+ INFERENCE = tilde.inference("default")
15
+ client = AsyncOpenAI(
16
+ base_url=INFERENCE.base_url, api_key=INFERENCE.api_key, http_client=INFERENCE.async_client()
17
+ )
18
+ agent = Agent(
19
+ name="assistant",
20
+ instructions="Respond using the current channel's tools. Returned model text is private.",
21
+ model=OpenAIResponsesModel(model="gpt-4o-mini", openai_client=client),
22
+ )
23
+
24
+
25
+ async def run(ctx):
26
+ history = await ctx.message.history()
27
+ items = await convert_to_openai_agents_messages(history.items, context=ctx)
28
+ run_agent, run_config = await tilde_openai_agents(ctx, agent)
29
+ await Runner.run(run_agent, items, run_config=run_config, max_turns=8)
30
+ ```
31
+
32
+ `tilde_openai_agents(ctx, agent, run_config=None)` returns a clone of `agent` and a
33
+ `RunConfig` (a copy of yours, when given):
34
+
35
+ - the clone has the current channel's tools;
36
+ - skills assigned in the registry: with a local `ShellTool` they join its environment
37
+ `skills` (written to disk by `ctx.skills.directory()`); otherwise the clone gets the
38
+ `list_skills` / `read_skill` tools and `ctx.skills.summary()` after its instructions;
39
+ - the config's `call_model_input_filter` (chained after yours) checks cancellation before
40
+ every model call, stamps the invocation's inference calls with the calling agent's dynamic
41
+ instructions and adds steering input as user messages. The SDK sends filtered input for
42
+ one call only, so steered messages are re-inserted where they arrived on later calls; with
43
+ server-managed conversations (`previous_response_id`, `conversation_id`) that duplicates
44
+ them.
45
+
46
+ Handoff targets and agent tools run as defined; only the returned agent has channel tools.
47
+
48
+ ## Deploy discovery
49
+
50
+ `tilde deploy` (entry point `tilde.discover`) registers every module-level `Agent` and the
51
+ agents reachable through its handoffs and `as_tool` tools (cycles are fine):
52
+ `<name>/instructions` (a string is plain, a function dynamic with its source) and
53
+ `<name>/handoff_description`. Skill folders the SDK loads from disk are shipped: local
54
+ `ShellTool` environment skills and a `SandboxAgent`'s `Skills(from_=LocalDir(...))` or
55
+ `lazy_from=LocalDirLazySkillSource(...)`, resolved from the working directory. Hosted
56
+ prompts (`prompt={"id": ...}`) are versioned by OpenAI and reported as warnings. Handoff
57
+ targets and agent-tool agents are read from private fields (openai-agents 0.22); an
58
+ unreadable one is reported.
59
+
60
+ History pages are chronological; pass `before_message_id=history.next_page_token` for
61
+ older messages. The latest page includes the current objective unless it already matches
62
+ the latest received message. `include_objective=False` omits it. `include_work=True` also
63
+ reads current goals/tasks and requires `work.read`. Only the acting agent's messages receive
64
+ the assistant role (`output_text` parts); everything else is a user item (`input_text`).
65
+
66
+ Returned items never carry an `id`: the Responses API rejects ids it did not issue. The
67
+ Tilde message id is stored only inside the cached representation, where it validates that
68
+ a cached rendering belongs to the message being hydrated.
69
+
70
+ Images and PDFs are downloaded through `ctx.attachments.download` and embedded as
71
+ `input_image` / `input_file` data URLs. Text files include their real content. Unsupported
72
+ binary formats get an explicit attachment description; use `on_attachment` to parse them
73
+ yourself. No private URL or credential needs to be exposed to the model. Without a context
74
+ or custom handler, a message with attachments raises instead of being silently truncated.
75
+
76
+ ```python
77
+ history = await ctx.message.history(include_work=True)
78
+ items = await convert_to_openai_agents_messages(
79
+ history.items,
80
+ context=ctx,
81
+ on_message=MessageHandlers(
82
+ goal=lambda item: {
83
+ "role": "user",
84
+ "content": [{"type": "input_text", "text": f"Our goal: {item.goal.objective}"}],
85
+ },
86
+ task=lambda item: None, # Omit this type, or provide a different rendering.
87
+ ),
88
+ on_attachment=decode_your_format, # async def (AttachmentConversion) -> part | [parts] | None
89
+ )
90
+ ```
91
+
92
+ `MessageHandlers` supports `message`, `objective`, `goal`, and `task`; handlers may be sync
93
+ or async. Supplied handlers take precedence over cached/default rendering; returning None
94
+ omits an item. Without an override the converter renders all supported types (incomplete
95
+ conversation messages are skipped unless a `message` handler is given). Completed
96
+ conversation conversions use the existing per-agent cache, in bounded batches (100 items or
97
+ 1 MiB). Files are hydrated afresh and are never stored as data URLs in the cache;
98
+ objectives/goals/tasks remain live projections rather than cached chat records.
99
+
100
+ ## Channel tools
101
+
102
+ `convert_to_openai_agents_tools(ctx.channel.current)` preserves the provider's descriptions
103
+ and JSON schemas (`strict_json_schema=False`, so provider schemas are used unchanged) and
104
+ forwards the Agents SDK tool-call id (`ToolContext.tool_call_id`) to Tilde's audited tool
105
+ execution. Provider results are returned to the model as JSON strings. The adapter does
106
+ not publish the model's final text. The agent chooses the provider tool and arguments,
107
+ including routing fields required by that provider.
108
+
109
+ Override tool instructions with `instructions={"sendMessage": "..."}`, or change the
110
+ channel tool's `description` before conversion. When combining collections, use
111
+ `prefix="slack_"` (or another prefix) to keep model tool names distinct; names are sanitized
112
+ to `[a-zA-Z0-9_-]`, truncated to 64 characters with a stable hash suffix, and conflicts raise.
113
+
114
+ The core SDK exposes callable tools on `ctx.channel.slack`, `github`, `agentmail`, `linq`,
115
+ `whatsapp`, `telnyx_whatsapp`, and `native`. Use `ctx.channel.connections()` and
116
+ `ctx.channel.for_connection(id)` when multiple connections use a provider.
117
+ `ctx.channel.current` never falls back to a different connection.
118
+
119
+ ## Bundled tools
120
+
121
+ The agent's own function tools join Tilde's with one call:
122
+
123
+ ```python
124
+ from agents import Agent, function_tool
125
+ from tilde import BundledOptions
126
+ from tilde_openai_agents import with_tilde_tools
127
+
128
+
129
+ @function_tool
130
+ def roll_dice(count: int = 1) -> list[int]:
131
+ """Roll six-sided dice."""
132
+ return [random.randint(1, 6) for _ in range(count)]
133
+
134
+
135
+ tools = await with_tilde_tools(
136
+ ctx, [roll_dice], options={"roll_dice": BundledOptions(summary="Rolled dice")}
137
+ )
138
+ agent = Agent(name="assistant", tools=tools)
139
+ ```
140
+
141
+ `with_tilde_tools` returns the current channel's tools, `ctx.agent_tools` and copies of the
142
+ native `FunctionTool`s with an audited `on_invoke_tool`, and publishes the native tools to
143
+ Tilde with their parameter and output schemas, so `tools.search` finds them and a
144
+ `tools.execute` naming one runs it here with the run's context. Every call is audited once with
145
+ the model's call id. `FunctionTool` has no free metadata, so summaries come from `options`.
146
+ `@function_tool` turns an exception into an error string for the model by default, so such a
147
+ failure is audited as completed with that text; `failure_error_function=None` audits it as
148
+ failed (and fails the run).
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "trytilde-openai-agents"
3
+ version = "3.0.1"
4
+ description = "Tilde adapter for the OpenAI Agents SDK: typed history to Responses input items and channel tools to function tools"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.11"
8
+ dependencies = ["trytilde", "openai-agents>=0.22,<1"]
9
+
10
+ # `tilde deploy` reads module-level agents' instructions and skill folders through this.
11
+ [project.entry-points."tilde.discover"]
12
+ openai-agents = "tilde_openai_agents.discover:discover"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/tilde_openai_agents"]
20
+
21
+ [tool.hatch.build.targets.sdist]
22
+ include = ["src/tilde_openai_agents", "README.md"]
@@ -0,0 +1,29 @@
1
+ """Tilde adapter for the OpenAI Agents SDK (``openai-agents``).
2
+
3
+ Core history and delivery live in ``trytilde``. This package converts typed context into
4
+ Responses API input items, exposes channel tools as ``agents.FunctionTool`` instances, bundles
5
+ the per-invocation run options (``tilde_openai_agents``) and lets ``tilde deploy`` discover
6
+ module-level agents' prompts and skills.
7
+ """
8
+
9
+ from tilde_openai_agents.attachments import (
10
+ AttachmentConversion,
11
+ AttachmentHandler,
12
+ convert_attachment,
13
+ )
14
+ from tilde_openai_agents.discover import discover
15
+ from tilde_openai_agents.invocation import tilde_openai_agents
16
+ from tilde_openai_agents.messages import MessageHandlers, convert_to_openai_agents_messages
17
+ from tilde_openai_agents.tools import convert_to_openai_agents_tools, with_tilde_tools
18
+
19
+ __all__ = [
20
+ "AttachmentConversion",
21
+ "AttachmentHandler",
22
+ "MessageHandlers",
23
+ "convert_attachment",
24
+ "convert_to_openai_agents_messages",
25
+ "convert_to_openai_agents_tools",
26
+ "discover",
27
+ "tilde_openai_agents",
28
+ "with_tilde_tools",
29
+ ]
@@ -0,0 +1,86 @@
1
+ """Attachment hydration into Responses API content parts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from collections.abc import Awaitable, Callable
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ from tilde import ConversationMessage, DownloadedAttachment
11
+ from tilde.types.v1.chat_pb2 import Attachment
12
+
13
+ # Responses API content parts: input_text, input_image, input_file (openai TypedDicts).
14
+ ContentPart = dict[str, Any]
15
+ Conversion = ContentPart | list[ContentPart] | None
16
+ AttachmentHandler = Callable[["AttachmentConversion"], Conversion | Awaitable[Conversion]]
17
+
18
+ _TEXT_TYPES = {"application/json", "application/xml"}
19
+ _IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
20
+ _FILE_TYPES = {"application/pdf"}
21
+
22
+
23
+ @dataclass(slots=True)
24
+ class AttachmentConversion:
25
+ message: ConversationMessage
26
+ attachment: Attachment
27
+ download: Callable[[], Awaitable[DownloadedAttachment]]
28
+ """Uses the current invocation's authenticated, thread-scoped attachment API."""
29
+
30
+
31
+ async def convert_attachment(input: AttachmentConversion) -> ContentPart:
32
+ """Hydrate images as ``input_image``, PDFs as ``input_file`` and textual files as real text.
33
+
34
+ Assistant messages cannot carry image or file parts in the Responses API, so the agent's
35
+ own attachments are described instead of downloaded.
36
+ """
37
+ attachment = input.attachment
38
+ media_type = normalize_media_type(attachment.media_type, attachment.filename)
39
+ text = media_type.startswith("text/") or media_type in _TEXT_TYPES
40
+ image = media_type in _IMAGE_TYPES
41
+ file = media_type in _FILE_TYPES
42
+ if input.message.role == "assistant" and not text:
43
+ return _text(f"Attached file: {attachment.filename} ({media_type}).")
44
+ if not (text or image or file):
45
+ return _text(
46
+ f"Attached file: {attachment.filename} ({media_type}). "
47
+ "A custom attachment handler is needed to read this format."
48
+ )
49
+ result = await input.download()
50
+ if result.attachment.id != attachment.id:
51
+ raise RuntimeError("Attachment download returned a different file")
52
+ if text:
53
+ content = result.content.decode("utf-8", errors="replace")
54
+ return _text(f"Attached file: {attachment.filename}\n{content}")
55
+ data_url = f"data:{media_type};base64,{base64.b64encode(result.content).decode('ascii')}"
56
+ if image:
57
+ return {"type": "input_image", "detail": "auto", "image_url": data_url}
58
+ return {"type": "input_file", "filename": attachment.filename, "file_data": data_url}
59
+
60
+
61
+ def _text(text: str) -> ContentPart:
62
+ return {"type": "input_text", "text": text}
63
+
64
+
65
+ _EXTENSIONS = {
66
+ "jpg": "image/jpeg",
67
+ "jpeg": "image/jpeg",
68
+ "png": "image/png",
69
+ "gif": "image/gif",
70
+ "webp": "image/webp",
71
+ "pdf": "application/pdf",
72
+ "txt": "text/plain",
73
+ "md": "text/plain",
74
+ "csv": "text/plain",
75
+ "log": "text/plain",
76
+ "json": "application/json",
77
+ "xml": "application/xml",
78
+ }
79
+
80
+
81
+ def normalize_media_type(value: str, filename: str) -> str:
82
+ media_type = value.split(";")[0].strip().lower()
83
+ if media_type and media_type != "application/octet-stream":
84
+ return media_type
85
+ extension = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
86
+ return _EXTENSIONS.get(extension, media_type or "application/octet-stream")
@@ -0,0 +1,205 @@
1
+ """``tilde deploy`` discovery for the OpenAI Agents SDK (entry point ``tilde.discover``).
2
+
3
+ A module-level ``Agent`` contributes itself and every agent reachable through its handoffs and
4
+ agent tools (``Agent.as_tool``), each once:
5
+
6
+ - ``<name>/instructions``: a string is plain text; a function is dynamic with its source.
7
+ - ``<name>/handoff_description``: plain text.
8
+ - A hosted ``prompt`` (``{"id", "version"}``) lives in the OpenAI dashboard and is not versioned
9
+ by Tilde; it is reported as a warning.
10
+ - Skill folders the SDK loads from disk: local ``ShellTool`` environment skills and a
11
+ ``SandboxAgent``'s ``Skills(from_=LocalDir(...))`` / ``lazy_from=LocalDirLazySkillSource``.
12
+ Relative paths resolve from the working directory, as the SDK resolves them.
13
+ - ``FunctionTool``s in ``tools`` (agent tools included), declared as ``with_tilde_tools``
14
+ publishes them, as is a ``define_tools`` value of ``FunctionTool``s. Hosted and shell tools
15
+ are not.
16
+
17
+ Handoff targets and agent-tool agents are only reachable through private fields
18
+ (``Handoff._agent_ref``, ``FunctionTool._agent_instance``, openai-agents 0.22); an unreadable
19
+ one is reported as a warning.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import inspect
25
+ import re
26
+ import sys
27
+ from collections.abc import Iterator
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from agents import Agent, FunctionTool, Handoff, ShellTool
32
+ from agents.sandbox import SandboxAgent
33
+ from agents.sandbox.capabilities import LocalDirLazySkillSource, Skills
34
+ from agents.sandbox.entries import LocalDir
35
+
36
+ from tilde import BundledTools
37
+ from tilde.discovery import (
38
+ PROMPT_FORMAT_DYNAMIC,
39
+ PROMPT_FORMAT_PLAIN,
40
+ Discovered,
41
+ DiscoveryContext,
42
+ declared_prompt,
43
+ declared_tool,
44
+ )
45
+ from tilde.prompts import canonical_json, prompt_hash
46
+ from tilde.skills import read_skill, read_skills
47
+ from tilde_openai_agents.tools import describe_tool
48
+
49
+ _UNSAFE = re.compile(r"[^A-Za-z0-9._-]")
50
+
51
+
52
+ def prompt_name(agent: Agent[Any], field: str) -> str:
53
+ """Prompt names allow ``[A-Za-z0-9._/-]``; agent names are free text."""
54
+ return f"{_UNSAFE.sub('-', agent.name)[:96] or 'agent'}/{field}"
55
+
56
+
57
+ def reachable_agents(root: Agent[Any], warn: Any = None) -> Iterator[tuple[str, Agent[Any]]]:
58
+ """``root`` and every agent reachable through handoffs and agent tools, each once, with
59
+ the attribute path from ``root`` (``handoffs.billing``, ``tools.lookup``)."""
60
+ seen: set[int] = set()
61
+ pending: list[tuple[str, Agent[Any]]] = [("", root)]
62
+ while pending:
63
+ path, agent = pending.pop(0)
64
+ if id(agent) in seen:
65
+ continue
66
+ seen.add(id(agent))
67
+ yield path, agent
68
+ prefix = f"{path}." if path else ""
69
+ for item in agent.handoffs:
70
+ target = item
71
+ if isinstance(item, Handoff):
72
+ ref = getattr(item, "_agent_ref", None)
73
+ target = ref() if callable(ref) else None
74
+ if isinstance(target, Agent):
75
+ pending.append((f"{prefix}handoffs.{target.name}", target))
76
+ elif warn is not None:
77
+ warn(f"handoff {getattr(item, 'agent_name', '?')} of agent {agent.name}")
78
+ for tool in agent.tools:
79
+ if not getattr(tool, "_is_agent_tool", False):
80
+ continue
81
+ target = getattr(tool, "_agent_instance", None)
82
+ if isinstance(target, Agent):
83
+ pending.append((f"{prefix}tools.{tool.name}", target))
84
+ elif warn is not None:
85
+ warn(f"agent tool {tool.name} of agent {agent.name}")
86
+
87
+
88
+ def dynamic_source(instructions: Any) -> str | None:
89
+ """The source of an instructions function (a callable object's class), or None when Python
90
+ cannot read it."""
91
+ for candidate in (instructions, type(instructions)):
92
+ try:
93
+ return inspect.getsource(candidate)
94
+ except (OSError, TypeError):
95
+ continue
96
+ return None
97
+
98
+
99
+ def instructions_stamp(agent: Agent[Any]) -> tuple[str, str] | None:
100
+ """``(name, hash)`` of an agent's dynamic instructions, as ``tilde deploy`` registers them."""
101
+ if not callable(agent.instructions):
102
+ return None
103
+ source = dynamic_source(agent.instructions)
104
+ if source is None:
105
+ return None
106
+ return prompt_name(agent, "instructions"), prompt_hash(source, {}, canonical_json({}))
107
+
108
+
109
+ def discover(value: Any, context: DiscoveryContext) -> Discovered | None:
110
+ if isinstance(value, BundledTools):
111
+ if not value.tools or not all(isinstance(tool, FunctionTool) for tool in value.tools):
112
+ return None
113
+ origin = f"{context.origin()}.tools"
114
+ return Discovered(
115
+ tools=[
116
+ declared_tool(describe_tool(tool, value.options), f"{origin}.{tool.name}")
117
+ for tool in value.tools
118
+ ]
119
+ )
120
+ if not isinstance(value, Agent):
121
+ return None
122
+ module = sys.modules.get(context.module)
123
+ file = getattr(module, "__file__", None)
124
+ origin = f"{context.relative(Path(file)) if file else context.module}#{context.name}"
125
+ found = Discovered()
126
+
127
+ def unreadable(what: str) -> None:
128
+ context.warn(f"{origin}: {what} could not be read; its prompts are not registered")
129
+
130
+ for path, agent in reachable_agents(value, unreadable):
131
+ at = f"{origin}.{path}" if path else origin
132
+ _prompts(agent, at, found, context)
133
+ _skills(agent, at, found, context)
134
+ found.tools += [
135
+ declared_tool(describe_tool(tool), f"{at}.tools.{tool.name}")
136
+ for tool in agent.tools
137
+ if isinstance(tool, FunctionTool)
138
+ ]
139
+ return found
140
+
141
+
142
+ def _prompts(agent: Agent[Any], at: str, found: Discovered, context: DiscoveryContext) -> None:
143
+ instructions = agent.instructions
144
+ if isinstance(instructions, str) and instructions:
145
+ found.prompts.append(
146
+ declared_prompt(
147
+ prompt_name(agent, "instructions"),
148
+ instructions,
149
+ PROMPT_FORMAT_PLAIN,
150
+ f"{at}.instructions",
151
+ )
152
+ )
153
+ elif callable(instructions):
154
+ source = dynamic_source(instructions)
155
+ if source is None:
156
+ context.warn(f"{at}.instructions: the function's source cannot be read")
157
+ else:
158
+ found.prompts.append(
159
+ declared_prompt(
160
+ prompt_name(agent, "instructions"),
161
+ source,
162
+ PROMPT_FORMAT_DYNAMIC,
163
+ f"{at}.instructions",
164
+ )
165
+ )
166
+ if agent.handoff_description:
167
+ found.prompts.append(
168
+ declared_prompt(
169
+ prompt_name(agent, "handoff_description"),
170
+ agent.handoff_description,
171
+ PROMPT_FORMAT_PLAIN,
172
+ f"{at}.handoff_description",
173
+ )
174
+ )
175
+ if agent.prompt is not None:
176
+ hosted = agent.prompt.get("id") if isinstance(agent.prompt, dict) else "(dynamic)"
177
+ context.warn(
178
+ f"{at}.prompt: OpenAI-hosted prompt {hosted} is versioned by OpenAI, not by Tilde"
179
+ )
180
+
181
+
182
+ def _skills(agent: Agent[Any], at: str, found: Discovered, context: DiscoveryContext) -> None:
183
+ for tool in agent.tools:
184
+ if not isinstance(tool, ShellTool) or not isinstance(tool.environment, dict):
185
+ continue
186
+ if tool.environment.get("type") != "local":
187
+ continue
188
+ for skill in tool.environment.get("skills") or []:
189
+ directory = context.root / skill["path"]
190
+ if (directory / "SKILL.md").is_file():
191
+ found.skills.append(read_skill(directory))
192
+ else:
193
+ context.warn(f"{at}.tools.{tool.name}: skill {skill['name']} has no SKILL.md")
194
+ if not isinstance(agent, SandboxAgent):
195
+ return
196
+ for capability in agent.capabilities:
197
+ if not isinstance(capability, Skills):
198
+ continue
199
+ source = capability.from_
200
+ if isinstance(capability.lazy_from, LocalDirLazySkillSource):
201
+ source = capability.lazy_from.source
202
+ if isinstance(source, LocalDir) and source.src is not None:
203
+ found.skills.extend(read_skills(context.root / source.src))
204
+ else:
205
+ context.warn(f"{at}.capabilities: only LocalDir skills are registered")
@@ -0,0 +1,144 @@
1
+ """The per-invocation pieces of an OpenAI Agents SDK run.
2
+
3
+ ``Runner.run`` takes the agent positionally and everything else as options, so the helper
4
+ returns both: a clone of the module-level agent carrying this invocation's tools and skills,
5
+ and a ``RunConfig`` whose ``call_model_input_filter`` runs before every model call.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ import inspect
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from agents import Agent, RunConfig, RunContextWrapper, ShellTool, TResponseInputItem
16
+ from agents.run_config import CallModelData, CallModelInputFilter, ModelInputData
17
+
18
+ from tilde import AgentContext, BundledTools
19
+ from tilde_openai_agents.discover import instructions_stamp, reachable_agents
20
+ from tilde_openai_agents.tools import convert_to_openai_agents_tools, with_tilde_tools
21
+
22
+
23
+ async def tilde_openai_agents(
24
+ ctx: AgentContext,
25
+ agent: Agent[Any],
26
+ *,
27
+ bundled: BundledTools | None = None,
28
+ run_config: RunConfig | None = None,
29
+ ) -> tuple[Agent[Any], RunConfig]:
30
+ """``agent, config = await tilde_openai_agents(ctx, agent, bundled=TOOLS)`` then
31
+ ``await Runner.run(agent, input, run_config=config, max_turns=8)``.
32
+
33
+ - Tools: the current channel's tools, the agent's other Tilde tools and its bundled tools
34
+ (``bundled``, from ``define_tools``, audited and published as ``with_tilde_tools`` does)
35
+ are added to the returned clone of ``agent``.
36
+ - Skills assigned in the registry: with a local ``ShellTool`` they are added to its
37
+ environment ``skills`` (written to disk by ``ctx.skills.directory()``); otherwise the
38
+ ``list_skills``/``read_skill`` tools are added and ``ctx.skills.summary()`` is appended
39
+ to the instructions.
40
+ - Before every model call: cancellation is checked, the calling agent's dynamic
41
+ instructions stamp this invocation's inference calls, and steering input sent while the
42
+ agent works becomes user messages. ``run_config``'s own filter, if any, runs first.
43
+
44
+ Handoff targets and agent tools run as defined; only ``agent`` gets Tilde's tools.
45
+ """
46
+ stamps = {
47
+ candidate.name: stamp
48
+ for _, candidate in reachable_agents(agent)
49
+ if (stamp := instructions_stamp(candidate)) is not None
50
+ }
51
+ own = await with_tilde_tools(
52
+ ctx,
53
+ bundled.tools if bundled else [],
54
+ options=bundled.options if bundled else None,
55
+ )
56
+ tools = [*agent.tools, *own]
57
+ instructions = agent.instructions
58
+ shell = next(
59
+ (
60
+ tool
61
+ for tool in tools
62
+ if isinstance(tool, ShellTool) and (tool.environment or {}).get("type") == "local"
63
+ ),
64
+ None,
65
+ )
66
+ if shell is not None:
67
+ environment: dict[str, Any] = dict(shell.environment or {})
68
+ environment["skills"] = [*environment.get("skills", []), *await _local_skills(ctx)]
69
+ tools[tools.index(shell)] = dataclasses.replace(shell, environment=environment)
70
+ else:
71
+ summary = await ctx.skills.summary()
72
+ if summary:
73
+ tools.extend(convert_to_openai_agents_tools(ctx.skills.tools()))
74
+ instructions = _with_summary(instructions, summary)
75
+ config = dataclasses.replace(run_config) if run_config is not None else RunConfig()
76
+ config.call_model_input_filter = _input_filter(ctx, stamps, config.call_model_input_filter)
77
+ return agent.clone(tools=tools, instructions=instructions), config
78
+
79
+
80
+ async def _local_skills(ctx: AgentContext) -> list[dict[str, str]]:
81
+ registry = [skill for skill in await ctx.skills.list() if not skill.deployed]
82
+ if not registry:
83
+ return []
84
+ root = Path(await ctx.skills.directory())
85
+ names = [skill.name for skill in registry]
86
+ skills = []
87
+ for skill in registry:
88
+ # `directory()` names a folder `<source>-<name>` only when two sources share a name.
89
+ folder = f"{skill.source}-{skill.name}" if names.count(skill.name) > 1 else skill.name
90
+ skills.append(
91
+ {"name": skill.name, "description": skill.description, "path": str(root / folder)}
92
+ )
93
+ return skills
94
+
95
+
96
+ def _with_summary(instructions: Any, summary: str) -> Any:
97
+ if instructions is None:
98
+ return summary
99
+ if isinstance(instructions, str):
100
+ return f"{instructions}\n\n{summary}"
101
+
102
+ # The SDK requires exactly (context, agent).
103
+ async def with_summary(context: RunContextWrapper[Any], agent: Agent[Any]) -> str:
104
+ text = instructions(context, agent)
105
+ if inspect.isawaitable(text):
106
+ text = await text
107
+ return f"{text}\n\n{summary}"
108
+
109
+ return with_summary
110
+
111
+
112
+ def _input_filter(
113
+ ctx: AgentContext,
114
+ stamps: dict[str, tuple[str, str]],
115
+ previous: CallModelInputFilter | None,
116
+ ) -> CallModelInputFilter:
117
+ # The SDK sends the filtered input for one call only and keeps its own history, so steered
118
+ # messages are remembered with their position and re-inserted on every later call. That
119
+ # assumes the SDK resends the history; with server-managed conversations
120
+ # (`previous_response_id`, `conversation_id`) it sends only new items.
121
+ steered: list[tuple[int, list[TResponseInputItem]]] = []
122
+
123
+ async def filter(data: CallModelData[Any]) -> ModelInputData:
124
+ if previous is not None:
125
+ updated = previous(data)
126
+ data.model_data = await updated if inspect.isawaitable(updated) else updated
127
+ ctx.check()
128
+ stamp = stamps.get(data.agent.name)
129
+ if stamp is not None:
130
+ ctx.activate_prompt(*stamp)
131
+ items = list(data.model_data.input)
132
+ inputs = ctx.take_inputs()
133
+ if inputs:
134
+ steered.append(
135
+ (len(items), [{"role": "user", "content": input.text} for input in inputs])
136
+ )
137
+ offset = 0
138
+ for position, added in steered:
139
+ at = min(position + offset, len(items))
140
+ items[at:at] = added
141
+ offset += len(added)
142
+ return ModelInputData(input=items, instructions=data.model_data.instructions)
143
+
144
+ return filter
@@ -0,0 +1,189 @@
1
+ """Typed Tilde context to Responses API input items for ``agents.Runner.run``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import json
7
+ from collections.abc import Awaitable, Callable, Iterable
8
+ from dataclasses import dataclass
9
+ from typing import Any
10
+
11
+ from agents import TResponseInputItem
12
+
13
+ from tilde import (
14
+ AgentContext,
15
+ ContextMessage,
16
+ ConversationMessage,
17
+ GoalMessage,
18
+ ObjectiveMessage,
19
+ TaskMessage,
20
+ )
21
+ from tilde_openai_agents.attachments import (
22
+ AttachmentConversion,
23
+ AttachmentHandler,
24
+ convert_attachment,
25
+ )
26
+
27
+ Converted = TResponseInputItem | None
28
+ Handler = Callable[[Any], Converted | Awaitable[Converted]]
29
+
30
+ _MAX_ENTRY_BYTES = 1024 * 1024
31
+ _MAX_BATCH_ITEMS = 100
32
+ _CACHED_PART_TYPES = {"input_text", "output_text"}
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class MessageHandlers:
37
+ """Explicit handlers take precedence over cached and default conversion; None omits an item."""
38
+
39
+ message: Callable[[ConversationMessage], Converted | Awaitable[Converted]] | None = None
40
+ objective: Callable[[ObjectiveMessage], Converted | Awaitable[Converted]] | None = None
41
+ goal: Callable[[GoalMessage], Converted | Awaitable[Converted]] | None = None
42
+ task: Callable[[TaskMessage], Converted | Awaitable[Converted]] | None = None
43
+
44
+
45
+ async def convert_to_openai_agents_messages(
46
+ messages: Iterable[ContextMessage],
47
+ *,
48
+ context: AgentContext | None = None,
49
+ on_message: MessageHandlers | None = None,
50
+ on_attachment: AttachmentHandler | None = None,
51
+ ) -> list[TResponseInputItem]:
52
+ """Convert typed SDK context into Responses input items.
53
+
54
+ Returned items carry no ``id``: the Responses API rejects ids it did not issue. The Tilde
55
+ message id lives only in the cached representation, where it validates a hydration.
56
+ """
57
+ handlers = on_message or MessageHandlers()
58
+ output: list[TResponseInputItem] = []
59
+ batch: list[tuple[str, Any]] = []
60
+ batch_bytes = 0
61
+
62
+ async def flush() -> None:
63
+ nonlocal batch_bytes
64
+ if batch and context is not None:
65
+ await context.cache_converted_messages(batch[:])
66
+ batch.clear()
67
+ batch_bytes = 0
68
+
69
+ for item in messages:
70
+ hydrated = False
71
+ if isinstance(item, ObjectiveMessage):
72
+ converted = await _convert(handlers.objective, item, "user", item.objective)
73
+ elif isinstance(item, GoalMessage):
74
+ converted = await _convert(
75
+ handlers.goal, item, "user", f"Goal ({item.goal.status}): {item.goal.objective}"
76
+ )
77
+ elif isinstance(item, TaskMessage):
78
+ text = f"Task ({item.task.status}): {item.task.title}"
79
+ if item.task.blocked_reason:
80
+ text += f"\nBlocked: {item.task.blocked_reason}"
81
+ converted = await _convert(handlers.task, item, "user", text)
82
+ elif handlers.message is not None:
83
+ converted = await _call(handlers.message, item)
84
+ elif item.message.status != "complete":
85
+ continue
86
+ else:
87
+ # File bytes are hydrated afresh through the scoped SDK, never replayed from the cache.
88
+ converted = _hydrate(item) if not item.message.attachments else None
89
+ hydrated = converted is not None
90
+ if converted is None:
91
+ converted = await _render(item, context, on_attachment)
92
+ if converted is None:
93
+ continue
94
+ output.append(converted)
95
+ # Only canonical, complete, attachment-free messages are cached; work state stays live.
96
+ if (
97
+ context is None
98
+ or hydrated
99
+ or not isinstance(item, ConversationMessage)
100
+ or item.message.status != "complete"
101
+ or item.message.attachments
102
+ ):
103
+ continue
104
+ entry = {"id": item.id, **converted}
105
+ size = len(json.dumps(entry).encode())
106
+ if size > _MAX_ENTRY_BYTES:
107
+ continue
108
+ if len(batch) == _MAX_BATCH_ITEMS or batch_bytes + size > _MAX_ENTRY_BYTES:
109
+ await flush()
110
+ batch.append((item.id, json.loads(json.dumps(entry))))
111
+ batch_bytes += size
112
+ await flush()
113
+ return output
114
+
115
+
116
+ async def _convert(
117
+ handler: Handler | None, item: ContextMessage, role: str, text: str
118
+ ) -> Converted:
119
+ """A supplied handler decides (None omits); otherwise render the default text item."""
120
+ return await _call(handler, item) if handler is not None else _text_item(role, text)
121
+
122
+
123
+ async def _call(handler: Handler, item: ContextMessage) -> Converted:
124
+ result = handler(item)
125
+ if inspect.isawaitable(result):
126
+ result = await result
127
+ return result
128
+
129
+
130
+ def _text_item(role: str, text: str) -> TResponseInputItem:
131
+ part_type = "output_text" if role == "assistant" else "input_text"
132
+ return {"role": role, "content": [{"type": part_type, "text": text}]} # type: ignore[return-value]
133
+
134
+
135
+ async def _render(
136
+ item: ConversationMessage, context: AgentContext | None, on_attachment: AttachmentHandler | None
137
+ ) -> Converted:
138
+ parts: list[dict[str, Any]] = []
139
+ if item.message.HasField("subject") and item.message.subject:
140
+ parts.append({"type": "input_text", "text": f"Subject: {item.message.subject}"})
141
+ if item.message.text:
142
+ parts.append({"type": "input_text", "text": item.message.text})
143
+ for attachment in item.message.attachments:
144
+
145
+ async def download(attachment_id: str = attachment.id):
146
+ if context is None:
147
+ raise RuntimeError(
148
+ "Attachment conversion requires context or a custom on_attachment handler"
149
+ )
150
+ return await context.attachments.download(attachment_id)
151
+
152
+ converted = (on_attachment or convert_attachment)(
153
+ AttachmentConversion(message=item, attachment=attachment, download=download)
154
+ )
155
+ if inspect.isawaitable(converted):
156
+ converted = await converted
157
+ if converted is None:
158
+ continue
159
+ parts.extend(converted if isinstance(converted, list) else [converted])
160
+ if not parts:
161
+ return None
162
+ if item.role == "assistant":
163
+ parts = [
164
+ {**part, "type": "output_text"} if part.get("type") == "input_text" else part
165
+ for part in parts
166
+ ]
167
+ return {"role": item.role, "content": parts} # type: ignore[return-value]
168
+
169
+
170
+ def _hydrate(item: ConversationMessage) -> Converted:
171
+ """Reuse a cached rendering only when its id, role and text-only content check out."""
172
+ cached = item.cached_agent_representation
173
+ if (
174
+ not isinstance(cached, dict)
175
+ or cached.get("id") != item.id
176
+ or cached.get("role") != item.role
177
+ ):
178
+ return None
179
+ content = cached.get("content")
180
+ if not isinstance(content, list) or not content:
181
+ return None
182
+ for part in content:
183
+ if (
184
+ not isinstance(part, dict)
185
+ or part.get("type") not in _CACHED_PART_TYPES
186
+ or not isinstance(part.get("text"), str)
187
+ ):
188
+ return None
189
+ return {"role": item.role, "content": content} # type: ignore[return-value]
@@ -0,0 +1,150 @@
1
+ """Channel tools as ``agents.FunctionTool`` instances with audited, id-preserving execution, and
2
+ the agent's own function tools published to Tilde as bundled tools."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
8
+ from contextvars import ContextVar
9
+ from dataclasses import replace
10
+ from typing import Any
11
+
12
+ from agents import FunctionTool
13
+ from agents.tool_context import ToolContext
14
+
15
+ from tilde import AgentContext, BundledOptions, ChannelTool, Tool
16
+ from tilde._tools import ToolSpec, bundled_tool, model_tool_name, tool_spec
17
+
18
+ # The ToolContext of the Tilde tool being executed; a routed tools.execute runs within it.
19
+ _TOOL_CONTEXT: ContextVar[ToolContext[Any]] = ContextVar("tilde_openai_agents_tool_context")
20
+
21
+
22
+ def convert_to_openai_agents_tools(
23
+ channels: Mapping[str, ChannelTool | Tool],
24
+ *,
25
+ instructions: Mapping[str, str] | None = None,
26
+ prefix: str = "",
27
+ ) -> list[FunctionTool]:
28
+ """Preserve provider descriptions and schemas; forward the model's call id to Tilde.
29
+
30
+ ``instructions`` overrides descriptions by channel tool name without touching schemas or
31
+ authorization. ``prefix`` namespaces model tool names when combining collections.
32
+ ``ctx.skills.tools()`` converts the same way.
33
+ """
34
+ tools: list[FunctionTool] = []
35
+ names: set[str] = set()
36
+ for name, channel in channels.items():
37
+ key = model_tool_name(f"{prefix}{name}")
38
+ if not key or key in names:
39
+ raise ValueError("Conflicting or invalid model tool names")
40
+ names.add(key)
41
+ description = (
42
+ instructions[name] if instructions and name in instructions else channel.description
43
+ )
44
+ tools.append(
45
+ FunctionTool(
46
+ name=key,
47
+ description=description,
48
+ params_json_schema=channel.input_schema,
49
+ on_invoke_tool=_invoker(channel),
50
+ strict_json_schema=False,
51
+ )
52
+ )
53
+ return tools
54
+
55
+
56
+ def _invoker(channel: ChannelTool | Tool):
57
+ async def on_invoke_tool(ctx: ToolContext, input_json: str) -> str:
58
+ arguments = json.loads(input_json) if input_json.strip() else {}
59
+ token = _TOOL_CONTEXT.set(ctx)
60
+ try:
61
+ result = await channel.execute(arguments, tool_call_id=ctx.tool_call_id)
62
+ finally:
63
+ _TOOL_CONTEXT.reset(token)
64
+ return json.dumps(result, default=str)
65
+
66
+ return on_invoke_tool
67
+
68
+
69
+ async def with_tilde_tools(
70
+ ctx: AgentContext,
71
+ native_tools: Sequence[FunctionTool],
72
+ *,
73
+ options: Mapping[str, BundledOptions] | None = None,
74
+ ) -> list[FunctionTool]:
75
+ """The current channel's tools, the agent's other Tilde tools and its own function tools,
76
+ for ``Agent(tools=...)``.
77
+
78
+ Each native tool (``@function_tool`` or ``FunctionTool``) is published to Tilde with its
79
+ name, description, parameter and output schemas, so ``tools.search`` finds it and a
80
+ ``tools.execute`` naming it runs it here, and is returned as a copy whose calls are audited
81
+ once with the model's call id. ``FunctionTool`` has no free metadata, so the summary and
82
+ display come from ``options[name]``. Call it once per invocation.
83
+
84
+ Caveat: ``@function_tool`` turns a raised exception into an error string for the model by
85
+ default (``failure_error_function``), so such a failure is audited as completed with that
86
+ text; pass ``failure_error_function=None`` to have it audited as failed (it then fails the
87
+ run).
88
+ """
89
+ tools = convert_to_openai_agents_tools({**ctx.channel.current, **ctx.agent_tools})
90
+ names = {tool.name for tool in tools}
91
+ definitions = {}
92
+ for tool in native_tools:
93
+ if tool.name in names:
94
+ raise ValueError(f"Tool {tool.name} conflicts with another tool")
95
+ names.add(tool.name)
96
+ audited = replace(tool, on_invoke_tool=_audited(ctx, tool.name, tool.on_invoke_tool))
97
+ definitions[tool.name] = bundled_tool(describe_tool(tool, options), _route(audited))
98
+ tools.append(audited)
99
+ # Published even when empty, so tools removed since the last call stop running here.
100
+ await ctx._set_bundled_tools(definitions)
101
+ return tools
102
+
103
+
104
+ def describe_tool(
105
+ tool: FunctionTool, options: Mapping[str, BundledOptions] | None = None
106
+ ) -> ToolSpec:
107
+ """What ``with_tilde_tools`` publishes and ``tilde deploy`` declares for one tool."""
108
+ return tool_spec(
109
+ tool.name,
110
+ tool.description,
111
+ tool.params_json_schema,
112
+ output_schema=tool.output_json_schema,
113
+ options=(options or {}).get(tool.name),
114
+ )
115
+
116
+
117
+ def _audited(
118
+ ctx: AgentContext, name: str, invoke: Callable[[ToolContext[Any], str], Awaitable[Any]]
119
+ ):
120
+ async def on_invoke_tool(tool_ctx: ToolContext[Any], input_json: str) -> Any:
121
+ arguments = json.loads(input_json) if input_json.strip() else {}
122
+ return await ctx._run_audited(
123
+ name, tool_ctx.tool_call_id, arguments, lambda: invoke(tool_ctx, input_json)
124
+ )
125
+
126
+ return on_invoke_tool
127
+
128
+
129
+ def _route(tool: FunctionTool):
130
+ # A tools.execute naming this tool runs it as the Agents SDK would, with the run context,
131
+ # usage and agent of the tools.execute call, audited once by its on_invoke_tool.
132
+ async def execute(input: Any, call: str) -> Any:
133
+ outer = _TOOL_CONTEXT.get(None)
134
+ arguments = json.dumps(input)
135
+ tool_ctx = (
136
+ ToolContext(
137
+ outer.context,
138
+ outer.usage,
139
+ tool_name=tool.name,
140
+ tool_call_id=call,
141
+ tool_arguments=arguments,
142
+ agent=outer.agent,
143
+ run_config=outer.run_config,
144
+ )
145
+ if outer is not None
146
+ else ToolContext(None, tool_name=tool.name, tool_call_id=call, tool_arguments=arguments)
147
+ )
148
+ return await tool.on_invoke_tool(tool_ctx, arguments)
149
+
150
+ return execute