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.
- trytilde_crewai-3.0.1/.gitignore +51 -0
- trytilde_crewai-3.0.1/PKG-INFO +191 -0
- trytilde_crewai-3.0.1/README.md +180 -0
- trytilde_crewai-3.0.1/pyproject.toml +22 -0
- trytilde_crewai-3.0.1/src/tilde_crewai/__init__.py +35 -0
- trytilde_crewai-3.0.1/src/tilde_crewai/attachments.py +79 -0
- trytilde_crewai-3.0.1/src/tilde_crewai/discover.py +124 -0
- trytilde_crewai-3.0.1/src/tilde_crewai/inference.py +39 -0
- trytilde_crewai-3.0.1/src/tilde_crewai/messages.py +190 -0
- trytilde_crewai-3.0.1/src/tilde_crewai/steering.py +23 -0
- trytilde_crewai-3.0.1/src/tilde_crewai/tools.py +201 -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,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
|