trytilde-crewai 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,191 @@
1
+ Metadata-Version: 2.5
2
+ Name: trytilde-crewai
3
+ Version: 3.0.1
4
+ Summary: Tilde adapter for CrewAI: convert invocation history to CrewAI messages and channel tools to CrewAI tools
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: crewai<2,>=1.15
8
+ Requires-Dist: pyyaml>=6
9
+ Requires-Dist: trytilde
10
+ Description-Content-Type: text/markdown
11
+
12
+ # CrewAI adapter
13
+
14
+ Core history and delivery live in `trytilde`. This package converts typed context to
15
+ CrewAI messages and channel tools to CrewAI tools, following Tilde's core/framework
16
+ separation.
17
+
18
+ ```python
19
+ import tilde
20
+ from crewai import LLM, Agent
21
+ from crewai.project import CrewBase, agent
22
+ from tilde_crewai import convert_to_crewai_messages, convert_to_crewai_tools, inference_interceptor
23
+
24
+ INFERENCE = tilde.inference("default")
25
+ llm = LLM(
26
+ model="openai/gpt-4o-mini",
27
+ base_url=INFERENCE.base_url,
28
+ api_key=INFERENCE.api_key,
29
+ interceptor=inference_interceptor(INFERENCE),
30
+ )
31
+
32
+
33
+ @CrewBase
34
+ class SupportCrew:
35
+ agents_config = "config/agents.yaml" # role/goal/backstory, registered by `tilde deploy`
36
+
37
+ def __init__(self, tools):
38
+ self.tools = tools
39
+
40
+ @agent
41
+ def support(self) -> Agent:
42
+ return Agent(config=self.agents_config["support"], llm=llm, tools=self.tools, max_iter=8)
43
+
44
+
45
+ async def run(ctx):
46
+ history = await ctx.message.history()
47
+ messages = await convert_to_crewai_messages(history.items, context=ctx)
48
+ await (
49
+ SupportCrew(convert_to_crewai_tools(ctx.channel.current)).support().kickoff_async(messages)
50
+ )
51
+ ```
52
+
53
+ ## Deploy discovery, inference and steering
54
+
55
+ This package registers a `tilde.discover` entry point, so `python -m tilde deploy` recognises
56
+ `@CrewBase` classes (or instances) and reads their YAML raw, before CrewAI interpolates inputs:
57
+ agent `role`/`goal`/`backstory` become prompts `agents/<key>/<field>` and task
58
+ `description`/`expected_output` become `tasks/<key>/<field>`, in braces format when the text
59
+ holds a `{variable}`, with origin `<yaml path>#<key>.<field>`. Skill search paths listed under
60
+ an agent's `skills:` in the YAML are shipped too (resolved from the working directory, as CrewAI
61
+ does); skills passed in code are not visible without instantiating, so declare them with
62
+ `tilde.define_skills(...)` and pass `.path`. Agents built in code at module scope are reported
63
+ as warnings.
64
+
65
+ The LLM is built once at module scope: `inference_interceptor` stamps each request with the
66
+ running invocation's token and prompt stamps and sends it to that invocation's gateway. Importing
67
+ `tilde_crewai` registers a global `before_llm_call` hook that appends the invocation's newly
68
+ steered input (`ctx.take_inputs()`) as user messages before each model call; outside an
69
+ invocation it does nothing.
70
+
71
+ Skills assigned to the agent in Tilde (not shipped with the deployment) reach running
72
+ deployments through CrewAI's own skill discovery: give the per-invocation agent
73
+ `skills=[SKILLS.path, Path(await ctx.skills.directory())]`. The directory holds one folder per
74
+ registry skill, cached by version, so a skill assigned in the UI is used on the next invocation.
75
+
76
+ Set `CREWAI_DISABLE_TELEMETRY=true` in the environment before `crewai` is imported to turn
77
+ off CrewAI's anonymous telemetry. `OTEL_SDK_DISABLED=true` has the same effect but also
78
+ disables your own OpenTelemetry SDK, so prefer the CrewAI flag. `max_iter` caps the agent's
79
+ model/tool iterations. Leave agent `memory` off; Tilde owns the history.
80
+
81
+ The converted list is passed to `kickoff_async`. CrewAI messages are OpenAI-style
82
+ `{"role", "content"}` dicts (`crewai.utilities.types.LLMMessage`) and carry no id. Every message
83
+ keeps its role as conversation history, except the last `user` message: CrewAI collapses it to
84
+ text and promotes it into its task prompt (`Current Task: ...`). Content parts on that message
85
+ would be dropped, so when it carries media the converter appends a short text request
86
+ (`tilde_crewai.messages.MEDIA_REQUEST`) to be promoted in its place.
87
+
88
+ History pages are chronological; pass `before_message_id=history.next_page_token` for older
89
+ messages. The latest page includes the current objective unless it already matches the latest
90
+ received message. `include_objective=False` omits it. `include_work=True` also reads current
91
+ goals/tasks and requires `work.read`. Only the acting agent's messages receive the assistant
92
+ role.
93
+
94
+ Images and PDFs are downloaded through `ctx.attachments.download` and attached as content
95
+ parts with base64 data URLs. Text files include their real content. Unsupported binary formats
96
+ get an explicit attachment description; use `on_attachment` to parse them yourself. No private
97
+ URL or credential needs to be exposed to the model. CrewAI has no media type for history and
98
+ passes content parts to the provider unchanged, so the default parts are the OpenAI Chat
99
+ Completions shapes (`image_url`, `file`), CrewAI's default OpenAI API. For the Responses API
100
+ or another provider, return that provider's part from `on_attachment`. CrewAI's own `files`
101
+ message field is not used: it needs the optional `crewai-files` extra.
102
+
103
+ ```python
104
+ from tilde_crewai import MessageHandlers
105
+
106
+ history = await ctx.message.history(include_work=True)
107
+ messages = await convert_to_crewai_messages(
108
+ history.items,
109
+ context=ctx,
110
+ on_message=MessageHandlers(
111
+ goal=lambda item: {"role": "user", "content": f"Our goal: {item.goal.objective}"},
112
+ task=lambda item: None, # Omit this type, or provide a different rendering.
113
+ ),
114
+ on_attachment=decode_your_format, # (conversion) -> str | content part dict | list | None
115
+ )
116
+ ```
117
+
118
+ `MessageHandlers` supports `message`, `objective`, `goal`, and `task`; handlers may be sync or
119
+ async. Supplied handlers take precedence over cached/default rendering; returning None omits
120
+ an item. Without an override the converter renders all supported types. Completed
121
+ conversation conversions use the existing per-agent cache, in bounded batches. Files are
122
+ hydrated afresh and are never stored in the cache; objectives/goals/tasks remain live
123
+ projections rather than cached chat records.
124
+
125
+ ## Channel tools
126
+
127
+ `convert_to_crewai_tools(ctx.channel.current)` returns `crewai.tools.BaseTool` instances that
128
+ keep the provider's descriptions and JSON schemas. CrewAI reads tool parameters from a pydantic
129
+ `args_schema`; the adapter supplies a field-less model whose `model_json_schema()` returns the
130
+ provider schema, so nothing is introspected and arguments reach the channel exactly as the
131
+ model sent them. The adapter does not publish the model's final text. The agent chooses the
132
+ provider tool and arguments, including routing fields required by that provider.
133
+
134
+ CrewAI itself changes three things that an adapter cannot turn off:
135
+
136
+ - Every tool schema goes through its OpenAI strict-mode pass before it reaches the model:
137
+ all properties of every object become `required`, objects get
138
+ `additionalProperties: false`, `oneOf` becomes `anyOf`, `$ref`s are inlined and unsupported
139
+ `format`s are removed. Types, nesting, enums and descriptions are preserved. The model must
140
+ therefore supply a value for optional provider fields.
141
+ - Model-facing names are lowercased snake_case (`sendMessage` becomes `send_message`).
142
+ Conflicts are checked on that name.
143
+ - The model's tool-call id is not passed to tools, hooks or events, so it cannot be forwarded.
144
+ Tilde's core generates a UUID per execution; audited tool-call ids do not match the
145
+ provider's ids.
146
+
147
+ CrewAI executes tools synchronously on worker threads. Channel execution belongs to the
148
+ invocation's event loop, so call `convert_to_crewai_tools` on that loop (inside your `run`
149
+ handler): the tools capture it and submit each execution back to it, blocking only the worker
150
+ thread. `await tool.arun(...)` runs directly on the loop. Results are returned to the model as
151
+ JSON. CrewAI's tool-result cache is opt-in; leave it off so repeated sends are executed.
152
+
153
+ Override tool instructions with `instructions={"sendMessage": "..."}`, or change the channel
154
+ tool's `description` before conversion. When combining namespaces, use `prefix="slack_"` (or
155
+ another prefix) to keep model tool names distinct; names are sanitized to `[a-zA-Z0-9_-]` and
156
+ conflicts raise.
157
+
158
+ The core SDK exposes typed callable tools on `ctx.channel.slack`, `github`, `agentmail`,
159
+ `linq`, `whatsapp`, `telnyx_whatsapp`, and `native`. Use `ctx.channel.connections()` and
160
+ `ctx.channel.for_connection(id)` when multiple connections use a provider.
161
+
162
+ ## Bundled tools
163
+
164
+ The agent's own CrewAI tools join Tilde's with one call, on the invocation's event loop:
165
+
166
+ ```python
167
+ from crewai.tools import tool
168
+ from tilde import BundledOptions
169
+ from tilde_crewai import with_tilde_tools
170
+
171
+
172
+ @tool("roll_dice")
173
+ def roll_dice(count: int = 1) -> list[int]:
174
+ """Roll six-sided dice."""
175
+ return [random.randint(1, 6) for _ in range(count)]
176
+
177
+
178
+ tools = await with_tilde_tools(
179
+ ctx, [roll_dice], options={"roll_dice": BundledOptions(summary="Rolled dice")}
180
+ )
181
+ agent = Agent(role="Assistant", goal="Help", backstory="...", llm=llm, tools=tools)
182
+ ```
183
+
184
+ `with_tilde_tools` returns the current channel's tools, `ctx.agent_tools` and a delegating tool
185
+ per native tool that keeps its schemas, `result_as_answer`, usage limit, cache function and
186
+ failure policy. The native tools are published to Tilde under the name CrewAI shows the model
187
+ (`sanitize_tool_name`), so `tools.search` finds them and a `tools.execute` naming one runs it
188
+ here. Every call is audited once on the event loop. CrewAI has no free metadata, so summaries
189
+ come from `options`, keyed by the tool's own name. CrewAI never passes the model's tool-call id
190
+ to tools, so direct calls are audited under a generated id; `tools.execute` calls are not counted
191
+ towards the usage limit.
@@ -0,0 +1,180 @@
1
+ # CrewAI adapter
2
+
3
+ Core history and delivery live in `trytilde`. This package converts typed context to
4
+ CrewAI messages and channel tools to CrewAI tools, following Tilde's core/framework
5
+ separation.
6
+
7
+ ```python
8
+ import tilde
9
+ from crewai import LLM, Agent
10
+ from crewai.project import CrewBase, agent
11
+ from tilde_crewai import convert_to_crewai_messages, convert_to_crewai_tools, inference_interceptor
12
+
13
+ INFERENCE = tilde.inference("default")
14
+ llm = LLM(
15
+ model="openai/gpt-4o-mini",
16
+ base_url=INFERENCE.base_url,
17
+ api_key=INFERENCE.api_key,
18
+ interceptor=inference_interceptor(INFERENCE),
19
+ )
20
+
21
+
22
+ @CrewBase
23
+ class SupportCrew:
24
+ agents_config = "config/agents.yaml" # role/goal/backstory, registered by `tilde deploy`
25
+
26
+ def __init__(self, tools):
27
+ self.tools = tools
28
+
29
+ @agent
30
+ def support(self) -> Agent:
31
+ return Agent(config=self.agents_config["support"], llm=llm, tools=self.tools, max_iter=8)
32
+
33
+
34
+ async def run(ctx):
35
+ history = await ctx.message.history()
36
+ messages = await convert_to_crewai_messages(history.items, context=ctx)
37
+ await (
38
+ SupportCrew(convert_to_crewai_tools(ctx.channel.current)).support().kickoff_async(messages)
39
+ )
40
+ ```
41
+
42
+ ## Deploy discovery, inference and steering
43
+
44
+ This package registers a `tilde.discover` entry point, so `python -m tilde deploy` recognises
45
+ `@CrewBase` classes (or instances) and reads their YAML raw, before CrewAI interpolates inputs:
46
+ agent `role`/`goal`/`backstory` become prompts `agents/<key>/<field>` and task
47
+ `description`/`expected_output` become `tasks/<key>/<field>`, in braces format when the text
48
+ holds a `{variable}`, with origin `<yaml path>#<key>.<field>`. Skill search paths listed under
49
+ an agent's `skills:` in the YAML are shipped too (resolved from the working directory, as CrewAI
50
+ does); skills passed in code are not visible without instantiating, so declare them with
51
+ `tilde.define_skills(...)` and pass `.path`. Agents built in code at module scope are reported
52
+ as warnings.
53
+
54
+ The LLM is built once at module scope: `inference_interceptor` stamps each request with the
55
+ running invocation's token and prompt stamps and sends it to that invocation's gateway. Importing
56
+ `tilde_crewai` registers a global `before_llm_call` hook that appends the invocation's newly
57
+ steered input (`ctx.take_inputs()`) as user messages before each model call; outside an
58
+ invocation it does nothing.
59
+
60
+ Skills assigned to the agent in Tilde (not shipped with the deployment) reach running
61
+ deployments through CrewAI's own skill discovery: give the per-invocation agent
62
+ `skills=[SKILLS.path, Path(await ctx.skills.directory())]`. The directory holds one folder per
63
+ registry skill, cached by version, so a skill assigned in the UI is used on the next invocation.
64
+
65
+ Set `CREWAI_DISABLE_TELEMETRY=true` in the environment before `crewai` is imported to turn
66
+ off CrewAI's anonymous telemetry. `OTEL_SDK_DISABLED=true` has the same effect but also
67
+ disables your own OpenTelemetry SDK, so prefer the CrewAI flag. `max_iter` caps the agent's
68
+ model/tool iterations. Leave agent `memory` off; Tilde owns the history.
69
+
70
+ The converted list is passed to `kickoff_async`. CrewAI messages are OpenAI-style
71
+ `{"role", "content"}` dicts (`crewai.utilities.types.LLMMessage`) and carry no id. Every message
72
+ keeps its role as conversation history, except the last `user` message: CrewAI collapses it to
73
+ text and promotes it into its task prompt (`Current Task: ...`). Content parts on that message
74
+ would be dropped, so when it carries media the converter appends a short text request
75
+ (`tilde_crewai.messages.MEDIA_REQUEST`) to be promoted in its place.
76
+
77
+ History pages are chronological; pass `before_message_id=history.next_page_token` for older
78
+ messages. The latest page includes the current objective unless it already matches the latest
79
+ received message. `include_objective=False` omits it. `include_work=True` also reads current
80
+ goals/tasks and requires `work.read`. Only the acting agent's messages receive the assistant
81
+ role.
82
+
83
+ Images and PDFs are downloaded through `ctx.attachments.download` and attached as content
84
+ parts with base64 data URLs. Text files include their real content. Unsupported binary formats
85
+ get an explicit attachment description; use `on_attachment` to parse them yourself. No private
86
+ URL or credential needs to be exposed to the model. CrewAI has no media type for history and
87
+ passes content parts to the provider unchanged, so the default parts are the OpenAI Chat
88
+ Completions shapes (`image_url`, `file`), CrewAI's default OpenAI API. For the Responses API
89
+ or another provider, return that provider's part from `on_attachment`. CrewAI's own `files`
90
+ message field is not used: it needs the optional `crewai-files` extra.
91
+
92
+ ```python
93
+ from tilde_crewai import MessageHandlers
94
+
95
+ history = await ctx.message.history(include_work=True)
96
+ messages = await convert_to_crewai_messages(
97
+ history.items,
98
+ context=ctx,
99
+ on_message=MessageHandlers(
100
+ goal=lambda item: {"role": "user", "content": f"Our goal: {item.goal.objective}"},
101
+ task=lambda item: None, # Omit this type, or provide a different rendering.
102
+ ),
103
+ on_attachment=decode_your_format, # (conversion) -> str | content part dict | list | None
104
+ )
105
+ ```
106
+
107
+ `MessageHandlers` supports `message`, `objective`, `goal`, and `task`; handlers may be sync or
108
+ async. Supplied handlers take precedence over cached/default rendering; returning None omits
109
+ an item. Without an override the converter renders all supported types. Completed
110
+ conversation conversions use the existing per-agent cache, in bounded batches. Files are
111
+ hydrated afresh and are never stored in the cache; objectives/goals/tasks remain live
112
+ projections rather than cached chat records.
113
+
114
+ ## Channel tools
115
+
116
+ `convert_to_crewai_tools(ctx.channel.current)` returns `crewai.tools.BaseTool` instances that
117
+ keep the provider's descriptions and JSON schemas. CrewAI reads tool parameters from a pydantic
118
+ `args_schema`; the adapter supplies a field-less model whose `model_json_schema()` returns the
119
+ provider schema, so nothing is introspected and arguments reach the channel exactly as the
120
+ model sent them. The adapter does not publish the model's final text. The agent chooses the
121
+ provider tool and arguments, including routing fields required by that provider.
122
+
123
+ CrewAI itself changes three things that an adapter cannot turn off:
124
+
125
+ - Every tool schema goes through its OpenAI strict-mode pass before it reaches the model:
126
+ all properties of every object become `required`, objects get
127
+ `additionalProperties: false`, `oneOf` becomes `anyOf`, `$ref`s are inlined and unsupported
128
+ `format`s are removed. Types, nesting, enums and descriptions are preserved. The model must
129
+ therefore supply a value for optional provider fields.
130
+ - Model-facing names are lowercased snake_case (`sendMessage` becomes `send_message`).
131
+ Conflicts are checked on that name.
132
+ - The model's tool-call id is not passed to tools, hooks or events, so it cannot be forwarded.
133
+ Tilde's core generates a UUID per execution; audited tool-call ids do not match the
134
+ provider's ids.
135
+
136
+ CrewAI executes tools synchronously on worker threads. Channel execution belongs to the
137
+ invocation's event loop, so call `convert_to_crewai_tools` on that loop (inside your `run`
138
+ handler): the tools capture it and submit each execution back to it, blocking only the worker
139
+ thread. `await tool.arun(...)` runs directly on the loop. Results are returned to the model as
140
+ JSON. CrewAI's tool-result cache is opt-in; leave it off so repeated sends are executed.
141
+
142
+ Override tool instructions with `instructions={"sendMessage": "..."}`, or change the channel
143
+ tool's `description` before conversion. When combining namespaces, use `prefix="slack_"` (or
144
+ another prefix) to keep model tool names distinct; names are sanitized to `[a-zA-Z0-9_-]` and
145
+ conflicts raise.
146
+
147
+ The core SDK exposes typed callable tools on `ctx.channel.slack`, `github`, `agentmail`,
148
+ `linq`, `whatsapp`, `telnyx_whatsapp`, and `native`. Use `ctx.channel.connections()` and
149
+ `ctx.channel.for_connection(id)` when multiple connections use a provider.
150
+
151
+ ## Bundled tools
152
+
153
+ The agent's own CrewAI tools join Tilde's with one call, on the invocation's event loop:
154
+
155
+ ```python
156
+ from crewai.tools import tool
157
+ from tilde import BundledOptions
158
+ from tilde_crewai import with_tilde_tools
159
+
160
+
161
+ @tool("roll_dice")
162
+ def roll_dice(count: int = 1) -> list[int]:
163
+ """Roll six-sided dice."""
164
+ return [random.randint(1, 6) for _ in range(count)]
165
+
166
+
167
+ tools = await with_tilde_tools(
168
+ ctx, [roll_dice], options={"roll_dice": BundledOptions(summary="Rolled dice")}
169
+ )
170
+ agent = Agent(role="Assistant", goal="Help", backstory="...", llm=llm, tools=tools)
171
+ ```
172
+
173
+ `with_tilde_tools` returns the current channel's tools, `ctx.agent_tools` and a delegating tool
174
+ per native tool that keeps its schemas, `result_as_answer`, usage limit, cache function and
175
+ failure policy. The native tools are published to Tilde under the name CrewAI shows the model
176
+ (`sanitize_tool_name`), so `tools.search` finds them and a `tools.execute` naming one runs it
177
+ here. Every call is audited once on the event loop. CrewAI has no free metadata, so summaries
178
+ come from `options`, keyed by the tool's own name. CrewAI never passes the model's tool-call id
179
+ to tools, so direct calls are audited under a generated id; `tools.execute` calls are not counted
180
+ towards the usage limit.
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "trytilde-crewai"
3
+ version = "3.0.1"
4
+ description = "Tilde adapter for CrewAI: convert invocation history to CrewAI messages and channel tools to CrewAI tools"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.11"
8
+ dependencies = ["trytilde", "crewai>=1.15,<2", "pyyaml>=6"]
9
+
10
+ # `tilde deploy` reads @CrewBase YAML prompts and skills through this discoverer.
11
+ [project.entry-points."tilde.discover"]
12
+ crewai = "tilde_crewai.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_crewai"]
20
+
21
+ [tool.hatch.build.targets.sdist]
22
+ include = ["src/tilde_crewai", "README.md"]
@@ -0,0 +1,35 @@
1
+ """Tilde adapter for CrewAI: typed context to CrewAI messages, channel tools to CrewAI tools,
2
+ the inference gateway through a transport interceptor, steering through a global
3
+ ``before_llm_call`` hook (registered on import) and ``tilde deploy`` discovery of
4
+ ``@CrewBase`` YAML prompts."""
5
+
6
+ from tilde_crewai.attachments import (
7
+ AttachmentConversion,
8
+ AttachmentHandler,
9
+ AttachmentPart,
10
+ AttachmentResult,
11
+ convert_attachment,
12
+ normalize_media_type,
13
+ )
14
+ from tilde_crewai.discover import discover
15
+ from tilde_crewai.inference import InferenceInterceptor, inference_interceptor
16
+ from tilde_crewai.messages import MessageHandlers, convert_to_crewai_messages
17
+ from tilde_crewai.steering import inject_steering
18
+ from tilde_crewai.tools import convert_to_crewai_tools, with_tilde_tools
19
+
20
+ __all__ = [
21
+ "AttachmentConversion",
22
+ "AttachmentHandler",
23
+ "AttachmentPart",
24
+ "AttachmentResult",
25
+ "InferenceInterceptor",
26
+ "MessageHandlers",
27
+ "convert_attachment",
28
+ "convert_to_crewai_messages",
29
+ "convert_to_crewai_tools",
30
+ "discover",
31
+ "inference_interceptor",
32
+ "inject_steering",
33
+ "normalize_media_type",
34
+ "with_tilde_tools",
35
+ ]
@@ -0,0 +1,79 @@
1
+ """Default attachment hydration: images and PDFs become content parts, text files become text.
2
+
3
+ CrewAI has no media type of its own for conversation history; its LLM layer passes a message's
4
+ content-part list to the provider unchanged. The parts built here are the OpenAI Chat
5
+ Completions shapes (``image_url`` and ``file`` with base64 data URLs), CrewAI's default OpenAI
6
+ API. For another provider, return that provider's part from a custom ``on_attachment``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ from collections.abc import Awaitable, Callable
13
+ from dataclasses import dataclass
14
+ from typing import Any
15
+
16
+ from tilde import ConversationMessage, DownloadedAttachment
17
+ from tilde.types.v1.chat_pb2 import Attachment
18
+
19
+ # A string is rendered as text; a dict is passed through as a provider content part.
20
+ AttachmentPart = str | dict[str, Any]
21
+ AttachmentResult = AttachmentPart | list[AttachmentPart] | None
22
+
23
+
24
+ @dataclass(slots=True)
25
+ class AttachmentConversion:
26
+ message: ConversationMessage
27
+ attachment: Attachment
28
+ # Uses the current invocation's authenticated, thread-scoped attachment API.
29
+ download: Callable[[], Awaitable[DownloadedAttachment]]
30
+
31
+
32
+ AttachmentHandler = Callable[[AttachmentConversion], AttachmentResult | Awaitable[AttachmentResult]]
33
+
34
+ _TEXT_TYPES = {"application/json", "application/xml"}
35
+ _IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
36
+ _EXTENSIONS = {
37
+ "jpg": "image/jpeg",
38
+ "jpeg": "image/jpeg",
39
+ "png": "image/png",
40
+ "gif": "image/gif",
41
+ "webp": "image/webp",
42
+ "pdf": "application/pdf",
43
+ "txt": "text/plain",
44
+ "md": "text/plain",
45
+ "csv": "text/plain",
46
+ "log": "text/plain",
47
+ "json": "application/json",
48
+ "xml": "application/xml",
49
+ }
50
+
51
+
52
+ async def convert_attachment(input: AttachmentConversion) -> AttachmentResult:
53
+ """Hydrate images/PDFs as content parts and textual files as their actual text."""
54
+ attachment = input.attachment
55
+ media_type = normalize_media_type(attachment.media_type, attachment.filename)
56
+ text = media_type.startswith("text/") or media_type in _TEXT_TYPES
57
+ image = media_type in _IMAGE_TYPES
58
+ if not text and not image and media_type != "application/pdf":
59
+ return (
60
+ f"Attached file: {attachment.filename} ({media_type}). "
61
+ "A custom attachment handler is needed to read this format."
62
+ )
63
+ result = await input.download()
64
+ if result.attachment.id != attachment.id:
65
+ raise RuntimeError("Attachment download returned a different file")
66
+ if text:
67
+ return f"Attached file: {attachment.filename}\n{result.content.decode('utf-8', 'replace')}"
68
+ data_url = f"data:{media_type};base64,{base64.b64encode(result.content).decode('ascii')}"
69
+ if image:
70
+ return {"type": "image_url", "image_url": {"url": data_url}}
71
+ return {"type": "file", "file": {"filename": attachment.filename, "file_data": data_url}}
72
+
73
+
74
+ def normalize_media_type(value: str, filename: str) -> str:
75
+ media_type = value.split(";")[0].strip().lower()
76
+ if media_type and media_type != "application/octet-stream":
77
+ return media_type
78
+ extension = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
79
+ return _EXTENSIONS.get(extension, media_type or "application/octet-stream")
@@ -0,0 +1,124 @@
1
+ """``tilde deploy`` discovery for CrewAI (entry point ``tilde.discover``).
2
+
3
+ ``@CrewBase`` classes keep their YAML config paths relative to the class file
4
+ (``base_directory`` / ``original_agents_config_path``). The YAML is read raw, before CrewAI
5
+ interpolates inputs: each agent ``role``/``goal``/``backstory`` becomes the prompt
6
+ ``agents/<key>/<field>`` and each task ``description``/``expected_output`` becomes
7
+ ``tasks/<key>/<field>``, in braces format when it holds a ``{variable}``. Skill search paths
8
+ listed under an agent's ``skills:`` resolve from the working directory, as CrewAI resolves them.
9
+ Nothing is instantiated, so tools given to agents in ``@agent`` methods are not seen; a
10
+ ``define_tools`` value of CrewAI tools, and the tools of a module-level agent, are declared as
11
+ ``with_tilde_tools`` publishes them.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from collections.abc import Mapping
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+ from crewai.agents.agent_builder.base_agent import BaseAgent
23
+ from crewai.tools import BaseTool
24
+
25
+ from tilde import BundledOptions, BundledTools, SkillDefinition, SkillFile
26
+ from tilde.discovery import (
27
+ PROMPT_FORMAT_BRACES,
28
+ PROMPT_FORMAT_PLAIN,
29
+ Discovered,
30
+ DiscoveryContext,
31
+ declared_prompt,
32
+ declared_tool,
33
+ )
34
+ from tilde.management.v1.deployments_pb2 import DeclaredTool
35
+ from tilde.prompts import valid_prompt_name
36
+ from tilde.skills import read_skills
37
+ from tilde_crewai.tools import describe_tool
38
+
39
+ # CrewAI's own interpolation pattern (crewai.utilities.string_utils).
40
+ _VARIABLE = re.compile(r"\{([A-Za-z_][A-Za-z0-9_\-]*)}")
41
+ _FIELDS = {"agents": ("role", "goal", "backstory"), "tasks": ("description", "expected_output")}
42
+
43
+
44
+ def discover(value: Any, context: DiscoveryContext) -> Discovered | None:
45
+ if isinstance(value, BundledTools):
46
+ if not value.tools or not all(isinstance(tool, BaseTool) for tool in value.tools):
47
+ return None
48
+ return Discovered(tools=_tools(value.tools, value.options, f"{context.origin()}.tools"))
49
+ if isinstance(value, BaseAgent):
50
+ context.warn(
51
+ f"CrewAI agent {context.module}.{context.name} is built in code; declare it in a "
52
+ "@CrewBase agents.yaml to register its prompts"
53
+ )
54
+ tools = [tool for tool in value.tools or [] if isinstance(tool, BaseTool)]
55
+ return Discovered(tools=_tools(tools, None, f"{context.origin()}.tools"))
56
+ cls = value if isinstance(value, type) else type(value)
57
+ if not getattr(cls, "is_crew_class", False) or not hasattr(cls, "base_directory"):
58
+ return None
59
+ found = Discovered()
60
+ for kind, attribute in (
61
+ ("agents", "original_agents_config_path"),
62
+ ("tasks", "original_tasks_config_path"),
63
+ ):
64
+ path = getattr(cls, attribute, None)
65
+ explicit = getattr(cls, f"{kind}_config", None) is not None
66
+ if not isinstance(path, str):
67
+ if explicit:
68
+ context.warn(f"{cls.__name__}.{kind}_config is not a YAML path; not registered")
69
+ continue
70
+ file = Path(cls.base_directory) / path
71
+ if not file.is_file():
72
+ if explicit:
73
+ context.warn(f"{cls.__name__}: {kind} config {file} does not exist")
74
+ continue
75
+ config = yaml.safe_load(file.read_text(encoding="utf-8")) or {}
76
+ if not isinstance(config, dict):
77
+ context.warn(f"{context.relative(file)} is not a mapping of {kind}")
78
+ continue
79
+ _read(kind, config, context.relative(file), found, context)
80
+ return found
81
+
82
+
83
+ def _tools(
84
+ tools: list[BaseTool], options: Mapping[str, BundledOptions] | None, origin: str
85
+ ) -> list[DeclaredTool]:
86
+ specs = [describe_tool(tool, options) for tool in tools]
87
+ return [declared_tool(spec, f"{origin}.{spec.name}") for spec in specs]
88
+
89
+
90
+ def _read(
91
+ kind: str, config: dict[str, Any], origin: str, found: Discovered, context: DiscoveryContext
92
+ ) -> None:
93
+ for key, entry in config.items():
94
+ if not isinstance(entry, dict):
95
+ continue
96
+ for field in _FIELDS[kind]:
97
+ text = entry.get(field)
98
+ if not isinstance(text, str) or not text:
99
+ continue
100
+ name = f"{kind}/{key}/{field}"
101
+ if not valid_prompt_name(name):
102
+ context.warn(f"{origin}#{key}: {key!r} cannot name a prompt")
103
+ break
104
+ format = PROMPT_FORMAT_BRACES if _VARIABLE.search(text) else PROMPT_FORMAT_PLAIN
105
+ found.prompts.append(declared_prompt(name, text, format, f"{origin}#{key}.{field}"))
106
+ if kind == "agents":
107
+ for skill in entry.get("skills") or []:
108
+ found.skills.extend(_skills(skill, f"{origin}#{key}.skills", context))
109
+
110
+
111
+ def _skills(skill: Any, origin: str, context: DiscoveryContext) -> list[SkillDefinition]:
112
+ if not isinstance(skill, str):
113
+ context.warn(f"{origin}: only skill paths and inline SKILL.md are registered")
114
+ return []
115
+ if skill.startswith("@"):
116
+ context.warn(f"{origin}: registry skill {skill} is not shipped with the deployment")
117
+ return []
118
+ if skill.lstrip().startswith("---"):
119
+ name = re.search(r"^name:\s*['\"]?([^'\"\n]+)", skill, re.MULTILINE)
120
+ if name is None:
121
+ context.warn(f"{origin}: inline SKILL.md has no name")
122
+ return []
123
+ return [SkillDefinition(name[1].strip(), [SkillFile("SKILL.md", skill.encode())])]
124
+ return read_skills(context.root / skill)
@@ -0,0 +1,39 @@
1
+ """Route CrewAI's native OpenAI client through Tilde's inference gateway.
2
+
3
+ CrewAI builds its own OpenAI clients, so the invocation token is stamped by a transport
4
+ interceptor rather than a supplied ``httpx`` client. With ``tilde.inference(alias)`` the LLM is
5
+ built once at module scope and every request resolves the invocation running it (token,
6
+ callback URL and prompt stamps)::
7
+
8
+ INFERENCE = tilde.inference("default")
9
+ llm = LLM(model="openai/gpt-4o-mini", base_url=INFERENCE.base_url,
10
+ api_key=INFERENCE.api_key, interceptor=inference_interceptor(INFERENCE))
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import httpx
16
+ from crewai.llms.hooks.base import BaseInterceptor
17
+
18
+ from tilde import Inference
19
+
20
+
21
+ class InferenceInterceptor(BaseInterceptor[httpx.Request, httpx.Response]):
22
+ def __init__(self, inference: Inference) -> None:
23
+ self._inference = inference
24
+
25
+ def on_outbound(self, message: httpx.Request) -> httpx.Request:
26
+ return self._inference.prepare(message)
27
+
28
+ def on_inbound(self, message: httpx.Response) -> httpx.Response:
29
+ return message
30
+
31
+ async def aon_outbound(self, message: httpx.Request) -> httpx.Request:
32
+ return self._inference.prepare(message)
33
+
34
+ async def aon_inbound(self, message: httpx.Response) -> httpx.Response:
35
+ return message
36
+
37
+
38
+ def inference_interceptor(inference: Inference) -> InferenceInterceptor:
39
+ return InferenceInterceptor(inference)
@@ -0,0 +1,190 @@
1
+ """Convert typed Tilde context into CrewAI messages.
2
+
3
+ Pass the result as ``await agent.kickoff_async(messages)``. CrewAI takes OpenAI-style
4
+ ``{"role", "content"}`` dicts (``crewai.utilities.types.LLMMessage``): every message keeps its
5
+ role as conversation history, except the last ``user`` message, which CrewAI collapses to text
6
+ and promotes into its task prompt. Content parts on that one message would be lost, so when it
7
+ carries media a short text request is appended to take its place as the promoted turn.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ import json
14
+ from collections.abc import Awaitable, Callable, Iterable
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ from crewai.utilities.types import LLMMessage
19
+
20
+ from tilde import (
21
+ AgentContext,
22
+ ContextMessage,
23
+ ConversationMessage,
24
+ GoalMessage,
25
+ ObjectiveMessage,
26
+ TaskMessage,
27
+ )
28
+ from tilde_crewai.attachments import AttachmentConversion, AttachmentHandler, convert_attachment
29
+
30
+ Converted = LLMMessage | None | Awaitable[LLMMessage | None]
31
+
32
+ MEDIA_REQUEST = "Respond to the latest message above, using its attachments."
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class MessageHandlers:
37
+ """Explicit handlers take precedence over cached and default rendering; None omits an item."""
38
+
39
+ message: Callable[[ConversationMessage], Converted] | None = None
40
+ objective: Callable[[ObjectiveMessage], Converted] | None = None
41
+ goal: Callable[[GoalMessage], Converted] | None = None
42
+ task: Callable[[TaskMessage], Converted] | None = None
43
+
44
+
45
+ _BATCH_ITEMS = 100
46
+ _BATCH_BYTES = 1024 * 1024
47
+
48
+
49
+ async def convert_to_crewai_messages(
50
+ messages: Iterable[ContextMessage],
51
+ *,
52
+ context: AgentContext | None = None,
53
+ on_message: MessageHandlers | None = None,
54
+ on_attachment: AttachmentHandler | None = None,
55
+ ) -> list[LLMMessage]:
56
+ """Convert typed SDK context into the message dicts ``Agent.kickoff_async`` accepts."""
57
+ output: list[LLMMessage] = []
58
+ handlers = on_message or MessageHandlers()
59
+ cache: list[tuple[str, Any]] = []
60
+ cache_bytes = 0
61
+
62
+ async def flush() -> None:
63
+ nonlocal cache_bytes
64
+ if cache and context is not None:
65
+ await context.cache_converted_messages(cache[:])
66
+ cache.clear()
67
+ cache_bytes = 0
68
+
69
+ for item in messages:
70
+ hydrated = False
71
+ if isinstance(item, ObjectiveMessage):
72
+ converted = (
73
+ await _call(handlers.objective, item)
74
+ if handlers.objective
75
+ else _user(item.objective)
76
+ )
77
+ elif isinstance(item, GoalMessage):
78
+ converted = (
79
+ await _call(handlers.goal, item)
80
+ if handlers.goal
81
+ else _user(f"Goal ({item.goal.status}): {item.goal.objective}")
82
+ )
83
+ elif isinstance(item, TaskMessage):
84
+ if handlers.task:
85
+ converted = await _call(handlers.task, item)
86
+ else:
87
+ text = f"Task ({item.task.status}): {item.task.title}"
88
+ if item.task.blocked_reason:
89
+ text += f"\nBlocked: {item.task.blocked_reason}"
90
+ converted = _user(text)
91
+ else:
92
+ if item.message.status != "complete" and handlers.message is None:
93
+ continue
94
+ if handlers.message:
95
+ converted = await _call(handlers.message, item)
96
+ else:
97
+ # File bytes are hydrated afresh through the scoped SDK, never from the cache.
98
+ converted = _hydrate(item) if not item.message.attachments else None
99
+ hydrated = converted is not None
100
+ if converted is None:
101
+ converted = await _render(item, context, on_attachment)
102
+ if converted is None:
103
+ continue
104
+ output.append(converted)
105
+ # Only canonical, text-only conversation messages are cached. Work state is always
106
+ # current, and hydrated files must not duplicate attachment bytes in the cache.
107
+ if (
108
+ context is not None
109
+ and isinstance(item, ConversationMessage)
110
+ and item.message.status == "complete"
111
+ and not hydrated
112
+ and not item.message.attachments
113
+ and isinstance(converted.get("content"), str)
114
+ ):
115
+ entry = {"id": item.id, "role": converted["role"], "content": converted["content"]}
116
+ size = len(json.dumps(entry).encode())
117
+ if size > _BATCH_BYTES:
118
+ continue
119
+ if len(cache) == _BATCH_ITEMS or cache_bytes + size > _BATCH_BYTES:
120
+ await flush()
121
+ cache.append((item.id, entry))
122
+ cache_bytes += size
123
+ await flush()
124
+ # CrewAI flattens the last user message to text; keep its media in history instead.
125
+ last_user = next((m for m in reversed(output) if m["role"] == "user"), None)
126
+ if last_user is not None and isinstance(last_user["content"], list):
127
+ output.append(_user(MEDIA_REQUEST))
128
+ return output
129
+
130
+
131
+ def _user(text: str) -> LLMMessage:
132
+ return {"role": "user", "content": text}
133
+
134
+
135
+ async def _call(handler: Callable[[Any], Any], value: Any) -> Any:
136
+ result = handler(value)
137
+ return await result if inspect.isawaitable(result) else result
138
+
139
+
140
+ def _hydrate(item: ConversationMessage) -> LLMMessage | None:
141
+ cached = item.cached_agent_representation
142
+ if not isinstance(cached, dict):
143
+ return None
144
+ content = cached.get("content")
145
+ if cached.get("id") != item.id or cached.get("role") != item.role:
146
+ return None
147
+ if not isinstance(content, str):
148
+ return None
149
+ return {"role": item.role, "content": content}
150
+
151
+
152
+ def _downloader(context: AgentContext | None, attachment_id: str):
153
+ def download():
154
+ if context is None:
155
+ raise RuntimeError(
156
+ "Attachment conversion requires context or a custom on_attachment handler"
157
+ )
158
+ return context.attachments.download(attachment_id)
159
+
160
+ return download
161
+
162
+
163
+ async def _render(
164
+ item: ConversationMessage, context: AgentContext | None, on_attachment: AttachmentHandler | None
165
+ ) -> LLMMessage | None:
166
+ texts: list[str] = []
167
+ parts: list[dict[str, Any]] = []
168
+ if item.message.subject:
169
+ texts.append(f"Subject: {item.message.subject}")
170
+ if item.message.text:
171
+ texts.append(item.message.text)
172
+ handler = on_attachment or convert_attachment
173
+ for attachment in item.message.attachments:
174
+ conversion = AttachmentConversion(
175
+ message=item, attachment=attachment, download=_downloader(context, attachment.id)
176
+ )
177
+ result = await _call(handler, conversion)
178
+ for part in result if isinstance(result, list) else [result]:
179
+ if isinstance(part, str):
180
+ texts.append(part)
181
+ elif isinstance(part, dict):
182
+ parts.append(part)
183
+ if not texts and not parts:
184
+ return None
185
+ if not parts:
186
+ return {"role": item.role, "content": "\n\n".join(texts)}
187
+ if not texts:
188
+ # Providers reject empty text beside media; name the files instead of sending "".
189
+ texts.append("Attached: " + ", ".join(a.filename for a in item.message.attachments))
190
+ return {"role": item.role, "content": [{"type": "text", "text": "\n\n".join(texts)}, *parts]}
@@ -0,0 +1,23 @@
1
+ """Steering for CrewAI: a global ``before_llm_call`` hook, registered when ``tilde_crewai`` is
2
+ imported, appends the running invocation's newly steered inputs as user messages before each
3
+ model call. Outside an invocation it does nothing, so crews run unchanged elsewhere.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from crewai.hooks import LLMCallHookContext, register_before_llm_call_hook
9
+
10
+ from tilde import current_invocation
11
+
12
+
13
+ def inject_steering(context: LLMCallHookContext) -> None:
14
+ ctx = current_invocation()
15
+ if ctx is None:
16
+ return None
17
+ # Mutate in place: the executor keeps its own reference to this list.
18
+ for steered in ctx.take_inputs():
19
+ context.messages.append({"role": "user", "content": steered.text})
20
+ return None
21
+
22
+
23
+ register_before_llm_call_hook(inject_steering)
@@ -0,0 +1,201 @@
1
+ """Expose Tilde channel tools to CrewAI with provider descriptions and schemas intact, and
2
+ publish the agent's own CrewAI tools to Tilde as bundled tools."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import asyncio
7
+ import copy
8
+ import json
9
+ import uuid
10
+ from collections.abc import Mapping, Sequence
11
+ from typing import Any
12
+
13
+ from crewai.tools import BaseTool
14
+ from crewai.utilities.string_utils import sanitize_tool_name
15
+ from pydantic import BaseModel, PrivateAttr
16
+
17
+ from tilde import AgentContext, BundledOptions, ChannelTool, Tool
18
+ from tilde._tools import ToolSpec, bundled_tool, model_tool_name, tool_spec
19
+
20
+
21
+ class ChannelCrewTool(BaseTool):
22
+ """A channel tool executed on the agent's event loop from wherever CrewAI calls it."""
23
+
24
+ _channel: ChannelTool | Tool = PrivateAttr()
25
+ _loop: asyncio.AbstractEventLoop = PrivateAttr()
26
+
27
+ def _run(self, **arguments: Any) -> str:
28
+ # CrewAI's executor calls tools synchronously on a worker thread, while channel
29
+ # execution belongs to the invocation's event loop.
30
+ try:
31
+ running = asyncio.get_running_loop()
32
+ except RuntimeError:
33
+ running = None
34
+ if running is self._loop:
35
+ raise RuntimeError("Channel tools block when run on the event loop; await arun()")
36
+ future = asyncio.run_coroutine_threadsafe(self._arun(**arguments), self._loop)
37
+ return future.result()
38
+
39
+ async def _arun(self, **arguments: Any) -> str:
40
+ # CrewAI does not pass the model's tool-call id to tools; the core generates one.
41
+ result = await self._channel.execute(arguments)
42
+ # CrewAI renders results with str(); JSON reads better to the model than a Python repr.
43
+ return result if isinstance(result, str) else json.dumps(result, default=str)
44
+
45
+
46
+ def convert_to_crewai_tools(
47
+ channels: Mapping[str, ChannelTool | Tool],
48
+ *,
49
+ instructions: Mapping[str, str] | None = None,
50
+ prefix: str = "",
51
+ ) -> list[BaseTool]:
52
+ """Build CrewAI tools that keep provider descriptions and JSON schemas.
53
+
54
+ Call this on the invocation's event loop: the loop is captured so that CrewAI's worker
55
+ threads can execute channel tools on it. CrewAI reads a tool's parameters from
56
+ ``args_schema.model_json_schema()``, so the schema model returns the provider schema rather
57
+ than one introspected from fields; having no fields, it also passes arguments through
58
+ unvalidated. CrewAI then applies its own strict-mode pass and name normalization (see the
59
+ README). ``instructions`` overrides descriptions by channel name; ``prefix`` namespaces
60
+ model tool names when combining collections.
61
+ """
62
+ loop = asyncio.get_running_loop()
63
+ result: list[BaseTool] = []
64
+ names: set[str] = set()
65
+ for name, channel in channels.items():
66
+ key = model_tool_name(f"{prefix}{name}")
67
+ # CrewAI lowercases and snake_cases names for the model and would silently suffix a
68
+ # duplicate, so conflicts are checked on the name the model sees.
69
+ model_name = sanitize_tool_name(key)
70
+ if not key or not model_name or model_name in names:
71
+ raise ValueError("Conflicting or invalid model tool names")
72
+ names.add(model_name)
73
+ description = (
74
+ instructions[name]
75
+ if instructions is not None and name in instructions
76
+ else channel.description
77
+ )
78
+ tool = ChannelCrewTool(
79
+ name=key, description=description, args_schema=_schema_model(channel.input_schema)
80
+ )
81
+ tool._channel = channel
82
+ tool._loop = loop
83
+ result.append(tool)
84
+ return result
85
+
86
+
87
+ def _schema_model(schema: dict[str, Any]) -> type[BaseModel]:
88
+ class ChannelToolInput(BaseModel):
89
+ @classmethod
90
+ def model_json_schema(cls, *args: Any, **kwargs: Any) -> dict[str, Any]:
91
+ return copy.deepcopy(schema)
92
+
93
+ return ChannelToolInput
94
+
95
+
96
+ class _AuditedCrewTool(BaseTool):
97
+ """One of the agent's own CrewAI tools, audited on the invocation's event loop."""
98
+
99
+ _tool: BaseTool = PrivateAttr()
100
+ _ctx: AgentContext = PrivateAttr()
101
+ _loop: asyncio.AbstractEventLoop = PrivateAttr()
102
+ _published: str = PrivateAttr()
103
+
104
+ def _run(self, **arguments: Any) -> Any:
105
+ # CrewAI calls tools synchronously on a worker thread; audits belong to the loop.
106
+ try:
107
+ running = asyncio.get_running_loop()
108
+ except RuntimeError:
109
+ running = None
110
+ if running is self._loop:
111
+ raise RuntimeError("Audited tools block when run on the event loop; await arun()")
112
+ return asyncio.run_coroutine_threadsafe(self._arun(**arguments), self._loop).result()
113
+
114
+ async def _arun(self, **arguments: Any) -> Any:
115
+ # CrewAI never passes the model's tool-call id to tools; audit under a fresh one.
116
+ return await self._call(arguments, str(uuid.uuid4()))
117
+
118
+ async def _call(self, arguments: dict[str, Any], call: str) -> Any:
119
+ return await self._ctx._run_audited(
120
+ self._published, call, arguments, lambda: asyncio.to_thread(self._invoke, arguments)
121
+ )
122
+
123
+ def _invoke(self, arguments: dict[str, Any]) -> Any:
124
+ # Arguments were validated by this tool's run(); run the wrapped tool's own body.
125
+ result = self._tool._run(**arguments)
126
+ return asyncio.run(result) if asyncio.iscoroutine(result) else result
127
+
128
+
129
+ async def with_tilde_tools(
130
+ ctx: AgentContext,
131
+ native_tools: Sequence[BaseTool],
132
+ *,
133
+ options: Mapping[str, BundledOptions] | None = None,
134
+ ) -> list[BaseTool]:
135
+ """The current channel's tools, the agent's other Tilde tools and its own tools, for
136
+ ``Agent(tools=...)``. Call it on the invocation's event loop.
137
+
138
+ Each native tool (``@tool`` or a ``BaseTool`` subclass) is published to Tilde under the name
139
+ CrewAI shows the model, with its description and argument and result schemas, so
140
+ ``tools.search`` finds it and a ``tools.execute`` naming it runs it here. It is returned as
141
+ a ``BaseTool`` that delegates to it, keeping its schemas, ``result_as_answer``, usage limit,
142
+ cache function and failure policy, and audits every call once. CrewAI has no
143
+ free metadata, so the summary and display come from ``options[name]`` (the tool's own name).
144
+
145
+ Caveats: CrewAI never passes the model's tool-call id to tools, so direct calls are audited
146
+ under a generated id that does not match the model's; ``tools.execute`` calls use their own
147
+ call id. A ``tools.execute`` call does not count towards the tool's usage limit.
148
+ """
149
+ loop = asyncio.get_running_loop()
150
+ tools = convert_to_crewai_tools({**ctx.channel.current, **ctx.agent_tools})
151
+ names = {sanitize_tool_name(tool.name) for tool in tools}
152
+ definitions = {}
153
+ for native in native_tools:
154
+ published = sanitize_tool_name(native.name)
155
+ if not published or published in names:
156
+ raise ValueError(f"Tool {native.name} conflicts with another tool")
157
+ names.add(published)
158
+ tool = _AuditedCrewTool(
159
+ name=native.name,
160
+ description=native.description,
161
+ env_vars=native.env_vars,
162
+ args_schema=native.args_schema,
163
+ result_schema=native.result_schema,
164
+ cache_function=native.cache_function,
165
+ result_as_answer=native.result_as_answer,
166
+ max_usage_count=native.max_usage_count,
167
+ tool_failure_policy=native.tool_failure_policy,
168
+ )
169
+ tool._tool = native
170
+ tool._ctx = ctx
171
+ tool._loop = loop
172
+ tool._published = published
173
+ definitions[published] = bundled_tool(describe_tool(native, options), _route(tool))
174
+ tools.append(tool)
175
+ # Published even when empty, so tools removed since the last call stop running here.
176
+ await ctx._set_bundled_tools(definitions)
177
+ return tools
178
+
179
+
180
+ def describe_tool(
181
+ native: BaseTool, options: Mapping[str, BundledOptions] | None = None
182
+ ) -> ToolSpec:
183
+ """What ``with_tilde_tools`` publishes and ``tilde deploy`` declares for one tool: named as
184
+ CrewAI shows it to the model, with options looked up by the tool's own name."""
185
+ schema = native.args_schema.model_json_schema()
186
+ schema.pop("title", None)
187
+ return tool_spec(
188
+ sanitize_tool_name(native.name),
189
+ native.description,
190
+ schema,
191
+ output_schema=native.result_schema.model_json_schema() if native.result_schema else None,
192
+ options=(options or {}).get(native.name),
193
+ )
194
+
195
+
196
+ def _route(tool: _AuditedCrewTool):
197
+ # A tools.execute naming this tool: validate as CrewAI would, audit under that call's id.
198
+ async def execute(input: Any, call: str) -> Any:
199
+ return await tool._call(tool._validate_kwargs(input), call)
200
+
201
+ return execute