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.
- trytilde_openai_agents-3.0.1/.gitignore +51 -0
- trytilde_openai_agents-3.0.1/PKG-INFO +158 -0
- trytilde_openai_agents-3.0.1/README.md +148 -0
- trytilde_openai_agents-3.0.1/pyproject.toml +22 -0
- trytilde_openai_agents-3.0.1/src/tilde_openai_agents/__init__.py +29 -0
- trytilde_openai_agents-3.0.1/src/tilde_openai_agents/attachments.py +86 -0
- trytilde_openai_agents-3.0.1/src/tilde_openai_agents/discover.py +205 -0
- trytilde_openai_agents-3.0.1/src/tilde_openai_agents/invocation.py +144 -0
- trytilde_openai_agents-3.0.1/src/tilde_openai_agents/messages.py +189 -0
- trytilde_openai_agents-3.0.1/src/tilde_openai_agents/tools.py +150 -0
|
@@ -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
|