trytilde-agno 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_agno-3.0.1/.gitignore +51 -0
- trytilde_agno-3.0.1/PKG-INFO +148 -0
- trytilde_agno-3.0.1/README.md +138 -0
- trytilde_agno-3.0.1/pyproject.toml +22 -0
- trytilde_agno-3.0.1/src/tilde_agno/__init__.py +32 -0
- trytilde_agno-3.0.1/src/tilde_agno/attachments.py +75 -0
- trytilde_agno-3.0.1/src/tilde_agno/discover.py +133 -0
- trytilde_agno-3.0.1/src/tilde_agno/invocation.py +124 -0
- trytilde_agno-3.0.1/src/tilde_agno/messages.py +191 -0
- trytilde_agno-3.0.1/src/tilde_agno/tools.py +221 -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,148 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: trytilde-agno
|
|
3
|
+
Version: 3.0.1
|
|
4
|
+
Summary: Tilde adapter for Agno: convert invocation history to Agno messages and channel tools to Agno functions
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: agno<4,>=3
|
|
8
|
+
Requires-Dist: trytilde
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# Agno adapter
|
|
12
|
+
|
|
13
|
+
Core history and delivery live in `trytilde`. This package converts typed context to
|
|
14
|
+
Agno messages and channel tools to Agno functions, bundles an invocation's run options in
|
|
15
|
+
`tilde_agno(ctx, agent)`, serves registry skills through `TildeSkills` and lets `tilde deploy`
|
|
16
|
+
register module-level agents and teams.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
import tilde
|
|
20
|
+
from agno.agent import Agent
|
|
21
|
+
from agno.models.openai import OpenAIChat
|
|
22
|
+
from agno.skills import LocalSkills
|
|
23
|
+
from tilde_agno import TildeSkills, convert_to_agno_messages, tilde_agno
|
|
24
|
+
|
|
25
|
+
INFERENCE = tilde.inference("default")
|
|
26
|
+
responder = Agent(
|
|
27
|
+
model=OpenAIChat(
|
|
28
|
+
id="gpt-4o-mini",
|
|
29
|
+
base_url=INFERENCE.base_url,
|
|
30
|
+
api_key=INFERENCE.api_key,
|
|
31
|
+
http_client=INFERENCE.async_client(),
|
|
32
|
+
),
|
|
33
|
+
instructions="Respond using the current channel's tools. Returned model text is private.",
|
|
34
|
+
skills=TildeSkills([LocalSkills("skills")]),
|
|
35
|
+
tool_call_limit=8,
|
|
36
|
+
telemetry=False,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
async def run(ctx):
|
|
41
|
+
history = await ctx.message.history()
|
|
42
|
+
messages = await convert_to_agno_messages(history.items, context=ctx)
|
|
43
|
+
await responder.arun(input=messages, **await tilde_agno(ctx, responder))
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`tilde_agno` returns `run_context` (with the current channel's tools as Agno's per-run
|
|
47
|
+
`client_tools`), a `run_id` that a stop or suspension cancels, and the thread as `session_id`.
|
|
48
|
+
Agno has no per-step message hook, so input steered while the agent works is appended to the
|
|
49
|
+
next Tilde tool result by a tool hook (an agent-level `tool_hooks` replaces it). Dynamic
|
|
50
|
+
`instructions`, `description` or `system_message` stamp the invocation's model calls.
|
|
51
|
+
`TildeSkills` is Agno's `Skills` plus the skills assigned to the agent in Tilde, loaded for each
|
|
52
|
+
invocation from `ctx.skills.directory()`; with plain `Skills` (or none) the registry skills come
|
|
53
|
+
as `list_skills`/`read_skill` tools with their summary in the system message.
|
|
54
|
+
|
|
55
|
+
`tilde deploy` (entry point `tilde.discover`) registers each module-level `Agent`/`Team`:
|
|
56
|
+
`<name or variable>/instructions` (`/<n>` from 1 for a list), `/description` and
|
|
57
|
+
`/system_message`, plain or braces when `{var}` is resolved in context, dynamic (the function's
|
|
58
|
+
source) for callables; `LocalSkills` folders ship with the deployment.
|
|
59
|
+
|
|
60
|
+
The converted list is passed as the run input. Agno appends a `list[Message]` input verbatim
|
|
61
|
+
after its own system message, so roles and media survive and Tilde remains the source of
|
|
62
|
+
history; leave `add_history_to_context` off. `tool_call_limit` caps tool-call rounds: once
|
|
63
|
+
reached, further calls receive an error result instead of executing.
|
|
64
|
+
|
|
65
|
+
History pages are chronological; pass `before_message_id=history.next_page_token` for older
|
|
66
|
+
messages. The latest page includes the current objective unless it already matches the latest
|
|
67
|
+
received message. `include_objective=False` omits it. `include_work=True` also reads current
|
|
68
|
+
goals/tasks and requires `work.read`. Only the acting agent's messages receive the assistant
|
|
69
|
+
role.
|
|
70
|
+
|
|
71
|
+
Images and PDFs are downloaded through `ctx.attachments.download` and attached as Agno
|
|
72
|
+
`Image`/`File` media. Text files include their real content. Unsupported binary formats get an
|
|
73
|
+
explicit attachment description; use `on_attachment` to parse them yourself. No private URL or
|
|
74
|
+
credential needs to be exposed to the model.
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from agno.models.message import Message
|
|
78
|
+
from tilde_agno import MessageHandlers
|
|
79
|
+
|
|
80
|
+
history = await ctx.message.history(include_work=True)
|
|
81
|
+
messages = await convert_to_agno_messages(
|
|
82
|
+
history.items,
|
|
83
|
+
context=ctx,
|
|
84
|
+
on_message=MessageHandlers(
|
|
85
|
+
goal=lambda item: Message(
|
|
86
|
+
id=item.id, role="user", content=f"Our goal: {item.goal.objective}"
|
|
87
|
+
),
|
|
88
|
+
task=lambda item: None, # Omit this type, or provide a different rendering.
|
|
89
|
+
),
|
|
90
|
+
on_attachment=decode_your_format, # (conversion) -> str | Image | File | list | None
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`MessageHandlers` supports `message`, `objective`, `goal`, and `task`; handlers may be sync or
|
|
95
|
+
async. Supplied handlers take precedence over cached/default rendering; returning None omits
|
|
96
|
+
an item. Without an override the converter renders all supported types. Completed
|
|
97
|
+
conversation conversions use the existing per-agent cache, in bounded batches. Files are
|
|
98
|
+
hydrated afresh and are never stored in the cache; objectives/goals/tasks remain live
|
|
99
|
+
projections rather than cached chat records.
|
|
100
|
+
|
|
101
|
+
## Channel tools
|
|
102
|
+
|
|
103
|
+
`convert_to_agno_tools(ctx.channel.current)` preserves the provider's descriptions and JSON
|
|
104
|
+
schemas (`skip_entrypoint_processing` keeps Agno from introspecting a replacement schema) and
|
|
105
|
+
forwards the model's tool-call id to Tilde's audited tool execution. Agno injects the running
|
|
106
|
+
`FunctionCall` into the entrypoint's `fc` parameter; its `call_id` is the id the model chose.
|
|
107
|
+
The adapter does not publish the model's final text. The agent chooses the provider tool and
|
|
108
|
+
arguments, including routing fields required by that provider.
|
|
109
|
+
|
|
110
|
+
Override tool instructions with `instructions={"sendMessage": "..."}`, or change the channel
|
|
111
|
+
tool's `description` before conversion. When combining namespaces, use `prefix="slack_"` (or
|
|
112
|
+
another prefix) to keep model tool names distinct; names are sanitized to `[a-zA-Z0-9_-]` and
|
|
113
|
+
conflicts raise.
|
|
114
|
+
|
|
115
|
+
The core SDK exposes typed callable tools on `ctx.channel.slack`, `github`, `agentmail`,
|
|
116
|
+
`linq`, `whatsapp`, `telnyx_whatsapp`, and `native`. Use `ctx.channel.connections()` and
|
|
117
|
+
`ctx.channel.for_connection(id)` when multiple connections use a provider.
|
|
118
|
+
|
|
119
|
+
## Bundled tools
|
|
120
|
+
|
|
121
|
+
The agent's own Agno tools join Tilde's with one call:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from tilde import BundledOptions
|
|
125
|
+
from tilde_agno import with_tilde_tools
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def roll_dice(count: int = 1) -> list[int]:
|
|
129
|
+
"""Roll six-sided dice."""
|
|
130
|
+
return [random.randint(1, 6) for _ in range(count)]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
tools = await with_tilde_tools(
|
|
134
|
+
ctx, [roll_dice], options={"roll_dice": BundledOptions(summary="Rolled dice")}
|
|
135
|
+
)
|
|
136
|
+
agent = Agent(model=model, tools=tools)
|
|
137
|
+
await agent.arun(input=messages)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
`with_tilde_tools` takes plain functions, `@tool` functions and toolkits and returns the
|
|
141
|
+
current channel's tools, `ctx.agent_tools` and copies of the native functions. It publishes the
|
|
142
|
+
native tools to Tilde with their parameter schema and MCP `annotations` hints, so
|
|
143
|
+
`tools.search` finds them and a `tools.execute` naming one runs it here as the same agent. Every
|
|
144
|
+
call is audited once with the model's call id by `pre_hook`/`post_hook`, chained around the
|
|
145
|
+
tool's own hooks; the entrypoint is untouched, so Agno's argument injection keeps working. Agno
|
|
146
|
+
has no free metadata or output schema, so summaries come from `options`. Run with `arun`: the
|
|
147
|
+
audit hooks are async. Agno swallows hook errors, so a failed audit never fails the call, and
|
|
148
|
+
toolkit-level instructions are not carried over.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Agno adapter
|
|
2
|
+
|
|
3
|
+
Core history and delivery live in `trytilde`. This package converts typed context to
|
|
4
|
+
Agno messages and channel tools to Agno functions, bundles an invocation's run options in
|
|
5
|
+
`tilde_agno(ctx, agent)`, serves registry skills through `TildeSkills` and lets `tilde deploy`
|
|
6
|
+
register module-level agents and teams.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
import tilde
|
|
10
|
+
from agno.agent import Agent
|
|
11
|
+
from agno.models.openai import OpenAIChat
|
|
12
|
+
from agno.skills import LocalSkills
|
|
13
|
+
from tilde_agno import TildeSkills, convert_to_agno_messages, tilde_agno
|
|
14
|
+
|
|
15
|
+
INFERENCE = tilde.inference("default")
|
|
16
|
+
responder = Agent(
|
|
17
|
+
model=OpenAIChat(
|
|
18
|
+
id="gpt-4o-mini",
|
|
19
|
+
base_url=INFERENCE.base_url,
|
|
20
|
+
api_key=INFERENCE.api_key,
|
|
21
|
+
http_client=INFERENCE.async_client(),
|
|
22
|
+
),
|
|
23
|
+
instructions="Respond using the current channel's tools. Returned model text is private.",
|
|
24
|
+
skills=TildeSkills([LocalSkills("skills")]),
|
|
25
|
+
tool_call_limit=8,
|
|
26
|
+
telemetry=False,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def run(ctx):
|
|
31
|
+
history = await ctx.message.history()
|
|
32
|
+
messages = await convert_to_agno_messages(history.items, context=ctx)
|
|
33
|
+
await responder.arun(input=messages, **await tilde_agno(ctx, responder))
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`tilde_agno` returns `run_context` (with the current channel's tools as Agno's per-run
|
|
37
|
+
`client_tools`), a `run_id` that a stop or suspension cancels, and the thread as `session_id`.
|
|
38
|
+
Agno has no per-step message hook, so input steered while the agent works is appended to the
|
|
39
|
+
next Tilde tool result by a tool hook (an agent-level `tool_hooks` replaces it). Dynamic
|
|
40
|
+
`instructions`, `description` or `system_message` stamp the invocation's model calls.
|
|
41
|
+
`TildeSkills` is Agno's `Skills` plus the skills assigned to the agent in Tilde, loaded for each
|
|
42
|
+
invocation from `ctx.skills.directory()`; with plain `Skills` (or none) the registry skills come
|
|
43
|
+
as `list_skills`/`read_skill` tools with their summary in the system message.
|
|
44
|
+
|
|
45
|
+
`tilde deploy` (entry point `tilde.discover`) registers each module-level `Agent`/`Team`:
|
|
46
|
+
`<name or variable>/instructions` (`/<n>` from 1 for a list), `/description` and
|
|
47
|
+
`/system_message`, plain or braces when `{var}` is resolved in context, dynamic (the function's
|
|
48
|
+
source) for callables; `LocalSkills` folders ship with the deployment.
|
|
49
|
+
|
|
50
|
+
The converted list is passed as the run input. Agno appends a `list[Message]` input verbatim
|
|
51
|
+
after its own system message, so roles and media survive and Tilde remains the source of
|
|
52
|
+
history; leave `add_history_to_context` off. `tool_call_limit` caps tool-call rounds: once
|
|
53
|
+
reached, further calls receive an error result instead of executing.
|
|
54
|
+
|
|
55
|
+
History pages are chronological; pass `before_message_id=history.next_page_token` for older
|
|
56
|
+
messages. The latest page includes the current objective unless it already matches the latest
|
|
57
|
+
received message. `include_objective=False` omits it. `include_work=True` also reads current
|
|
58
|
+
goals/tasks and requires `work.read`. Only the acting agent's messages receive the assistant
|
|
59
|
+
role.
|
|
60
|
+
|
|
61
|
+
Images and PDFs are downloaded through `ctx.attachments.download` and attached as Agno
|
|
62
|
+
`Image`/`File` media. Text files include their real content. Unsupported binary formats get an
|
|
63
|
+
explicit attachment description; use `on_attachment` to parse them yourself. No private URL or
|
|
64
|
+
credential needs to be exposed to the model.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from agno.models.message import Message
|
|
68
|
+
from tilde_agno import MessageHandlers
|
|
69
|
+
|
|
70
|
+
history = await ctx.message.history(include_work=True)
|
|
71
|
+
messages = await convert_to_agno_messages(
|
|
72
|
+
history.items,
|
|
73
|
+
context=ctx,
|
|
74
|
+
on_message=MessageHandlers(
|
|
75
|
+
goal=lambda item: Message(
|
|
76
|
+
id=item.id, role="user", content=f"Our goal: {item.goal.objective}"
|
|
77
|
+
),
|
|
78
|
+
task=lambda item: None, # Omit this type, or provide a different rendering.
|
|
79
|
+
),
|
|
80
|
+
on_attachment=decode_your_format, # (conversion) -> str | Image | File | list | None
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`MessageHandlers` supports `message`, `objective`, `goal`, and `task`; handlers may be sync or
|
|
85
|
+
async. Supplied handlers take precedence over cached/default rendering; returning None omits
|
|
86
|
+
an item. Without an override the converter renders all supported types. Completed
|
|
87
|
+
conversation conversions use the existing per-agent cache, in bounded batches. Files are
|
|
88
|
+
hydrated afresh and are never stored in the cache; objectives/goals/tasks remain live
|
|
89
|
+
projections rather than cached chat records.
|
|
90
|
+
|
|
91
|
+
## Channel tools
|
|
92
|
+
|
|
93
|
+
`convert_to_agno_tools(ctx.channel.current)` preserves the provider's descriptions and JSON
|
|
94
|
+
schemas (`skip_entrypoint_processing` keeps Agno from introspecting a replacement schema) and
|
|
95
|
+
forwards the model's tool-call id to Tilde's audited tool execution. Agno injects the running
|
|
96
|
+
`FunctionCall` into the entrypoint's `fc` parameter; its `call_id` is the id the model chose.
|
|
97
|
+
The adapter does not publish the model's final text. The agent chooses the provider tool and
|
|
98
|
+
arguments, including routing fields required by that provider.
|
|
99
|
+
|
|
100
|
+
Override tool instructions with `instructions={"sendMessage": "..."}`, or change the channel
|
|
101
|
+
tool's `description` before conversion. When combining namespaces, use `prefix="slack_"` (or
|
|
102
|
+
another prefix) to keep model tool names distinct; names are sanitized to `[a-zA-Z0-9_-]` and
|
|
103
|
+
conflicts raise.
|
|
104
|
+
|
|
105
|
+
The core SDK exposes typed callable tools on `ctx.channel.slack`, `github`, `agentmail`,
|
|
106
|
+
`linq`, `whatsapp`, `telnyx_whatsapp`, and `native`. Use `ctx.channel.connections()` and
|
|
107
|
+
`ctx.channel.for_connection(id)` when multiple connections use a provider.
|
|
108
|
+
|
|
109
|
+
## Bundled tools
|
|
110
|
+
|
|
111
|
+
The agent's own Agno tools join Tilde's with one call:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from tilde import BundledOptions
|
|
115
|
+
from tilde_agno import with_tilde_tools
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def roll_dice(count: int = 1) -> list[int]:
|
|
119
|
+
"""Roll six-sided dice."""
|
|
120
|
+
return [random.randint(1, 6) for _ in range(count)]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
tools = await with_tilde_tools(
|
|
124
|
+
ctx, [roll_dice], options={"roll_dice": BundledOptions(summary="Rolled dice")}
|
|
125
|
+
)
|
|
126
|
+
agent = Agent(model=model, tools=tools)
|
|
127
|
+
await agent.arun(input=messages)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`with_tilde_tools` takes plain functions, `@tool` functions and toolkits and returns the
|
|
131
|
+
current channel's tools, `ctx.agent_tools` and copies of the native functions. It publishes the
|
|
132
|
+
native tools to Tilde with their parameter schema and MCP `annotations` hints, so
|
|
133
|
+
`tools.search` finds them and a `tools.execute` naming one runs it here as the same agent. Every
|
|
134
|
+
call is audited once with the model's call id by `pre_hook`/`post_hook`, chained around the
|
|
135
|
+
tool's own hooks; the entrypoint is untouched, so Agno's argument injection keeps working. Agno
|
|
136
|
+
has no free metadata or output schema, so summaries come from `options`. Run with `arun`: the
|
|
137
|
+
audit hooks are async. Agno swallows hook errors, so a failed audit never fails the call, and
|
|
138
|
+
toolkit-level instructions are not carried over.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "trytilde-agno"
|
|
3
|
+
version = "3.0.1"
|
|
4
|
+
description = "Tilde adapter for Agno: convert invocation history to Agno messages and channel tools to Agno functions"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
dependencies = ["trytilde", "agno>=3,<4"]
|
|
9
|
+
|
|
10
|
+
# `tilde deploy` reads module-level agents' prompts (and skill folders) through this discoverer.
|
|
11
|
+
[project.entry-points."tilde.discover"]
|
|
12
|
+
agno = "tilde_agno.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_agno"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.sdist]
|
|
22
|
+
include = ["src/tilde_agno", "README.md"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Tilde adapter for Agno: typed context to Agno messages, channel tools to Agno functions, the
|
|
2
|
+
per-run ``tilde_agno`` options, registry skills through ``TildeSkills`` and ``tilde deploy``
|
|
3
|
+
discovery of module-level agents and teams."""
|
|
4
|
+
|
|
5
|
+
from tilde_agno.attachments import (
|
|
6
|
+
AttachmentConversion,
|
|
7
|
+
AttachmentHandler,
|
|
8
|
+
AttachmentPart,
|
|
9
|
+
AttachmentResult,
|
|
10
|
+
convert_attachment,
|
|
11
|
+
normalize_media_type,
|
|
12
|
+
)
|
|
13
|
+
from tilde_agno.discover import discover
|
|
14
|
+
from tilde_agno.invocation import TildeSkills, tilde_agno
|
|
15
|
+
from tilde_agno.messages import MessageHandlers, convert_to_agno_messages
|
|
16
|
+
from tilde_agno.tools import convert_to_agno_tools, with_tilde_tools
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"AttachmentConversion",
|
|
20
|
+
"AttachmentHandler",
|
|
21
|
+
"AttachmentPart",
|
|
22
|
+
"AttachmentResult",
|
|
23
|
+
"MessageHandlers",
|
|
24
|
+
"TildeSkills",
|
|
25
|
+
"convert_attachment",
|
|
26
|
+
"convert_to_agno_messages",
|
|
27
|
+
"convert_to_agno_tools",
|
|
28
|
+
"discover",
|
|
29
|
+
"normalize_media_type",
|
|
30
|
+
"tilde_agno",
|
|
31
|
+
"with_tilde_tools",
|
|
32
|
+
]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Default attachment hydration: images and PDFs become Agno media, text files become text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from agno.media import File, Image
|
|
9
|
+
|
|
10
|
+
from tilde import ConversationMessage, DownloadedAttachment
|
|
11
|
+
from tilde.types.v1.chat_pb2 import Attachment
|
|
12
|
+
|
|
13
|
+
AttachmentPart = str | Image | File
|
|
14
|
+
AttachmentResult = AttachmentPart | list[AttachmentPart] | None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class AttachmentConversion:
|
|
19
|
+
message: ConversationMessage
|
|
20
|
+
attachment: Attachment
|
|
21
|
+
# Uses the current invocation's authenticated, thread-scoped attachment API.
|
|
22
|
+
download: Callable[[], Awaitable[DownloadedAttachment]]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
AttachmentHandler = Callable[[AttachmentConversion], AttachmentResult | Awaitable[AttachmentResult]]
|
|
26
|
+
|
|
27
|
+
_TEXT_TYPES = {"application/json", "application/xml"}
|
|
28
|
+
_IMAGE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
|
29
|
+
_EXTENSIONS = {
|
|
30
|
+
"jpg": "image/jpeg",
|
|
31
|
+
"jpeg": "image/jpeg",
|
|
32
|
+
"png": "image/png",
|
|
33
|
+
"gif": "image/gif",
|
|
34
|
+
"webp": "image/webp",
|
|
35
|
+
"pdf": "application/pdf",
|
|
36
|
+
"txt": "text/plain",
|
|
37
|
+
"md": "text/plain",
|
|
38
|
+
"csv": "text/plain",
|
|
39
|
+
"log": "text/plain",
|
|
40
|
+
"json": "application/json",
|
|
41
|
+
"xml": "application/xml",
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
async def convert_attachment(input: AttachmentConversion) -> AttachmentResult:
|
|
46
|
+
"""Hydrate images/PDFs as Agno media and textual files as their actual text, not filenames."""
|
|
47
|
+
attachment = input.attachment
|
|
48
|
+
media_type = normalize_media_type(attachment.media_type, attachment.filename)
|
|
49
|
+
text = media_type.startswith("text/") or media_type in _TEXT_TYPES
|
|
50
|
+
image = media_type in _IMAGE_TYPES
|
|
51
|
+
if not text and not image and media_type != "application/pdf":
|
|
52
|
+
return (
|
|
53
|
+
f"Attached file: {attachment.filename} ({media_type}). "
|
|
54
|
+
"A custom attachment handler is needed to read this format."
|
|
55
|
+
)
|
|
56
|
+
result = await input.download()
|
|
57
|
+
if result.attachment.id != attachment.id:
|
|
58
|
+
raise RuntimeError("Attachment download returned a different file")
|
|
59
|
+
if text:
|
|
60
|
+
return f"Attached file: {attachment.filename}\n{result.content.decode('utf-8', 'replace')}"
|
|
61
|
+
if image:
|
|
62
|
+
return Image(
|
|
63
|
+
content=result.content, mime_type=media_type, format=media_type.removeprefix("image/")
|
|
64
|
+
)
|
|
65
|
+
return File(
|
|
66
|
+
content=result.content, mime_type=media_type, filename=attachment.filename, format="pdf"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normalize_media_type(value: str, filename: str) -> str:
|
|
71
|
+
media_type = value.split(";")[0].strip().lower()
|
|
72
|
+
if media_type and media_type != "application/octet-stream":
|
|
73
|
+
return media_type
|
|
74
|
+
extension = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
|
75
|
+
return _EXTENSIONS.get(extension, media_type or "application/octet-stream")
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""``tilde deploy`` discovery for Agno (entry point ``tilde.discover``).
|
|
2
|
+
|
|
3
|
+
A module-level ``Agent`` or ``Team`` registers, named after ``name`` (else its variable):
|
|
4
|
+
``<name>/instructions`` (``/<n>`` from 1 for a list), ``<name>/description`` and
|
|
5
|
+
``<name>/system_message``. Text is plain, or braces when it holds a ``{var}`` Agno resolves
|
|
6
|
+
from session state and dependencies (``resolve_in_context``, on by default); a function is
|
|
7
|
+
dynamic with its source as the template, stamped at runtime by ``tilde_agno``. Folders of
|
|
8
|
+
``LocalSkills`` loaders in ``skills`` ship with the deployment. Functions and toolkits in
|
|
9
|
+
``tools`` are declared as ``with_tilde_tools`` publishes them, as is a ``define_tools`` value of
|
|
10
|
+
Agno tools, or of plain functions when the project imports Agno. Nothing is run.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import inspect
|
|
16
|
+
import re
|
|
17
|
+
import sys
|
|
18
|
+
from collections.abc import Callable, Mapping
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from agno.agent import Agent
|
|
23
|
+
from agno.models.message import Message
|
|
24
|
+
from agno.skills import LocalSkills, Skills
|
|
25
|
+
from agno.team import Team
|
|
26
|
+
from agno.tools.function import Function
|
|
27
|
+
from agno.tools.toolkit import Toolkit
|
|
28
|
+
|
|
29
|
+
from tilde import BundledOptions, BundledTools
|
|
30
|
+
from tilde.discovery import (
|
|
31
|
+
PROMPT_FORMAT_BRACES,
|
|
32
|
+
PROMPT_FORMAT_DYNAMIC,
|
|
33
|
+
PROMPT_FORMAT_PLAIN,
|
|
34
|
+
Discovered,
|
|
35
|
+
DiscoveryContext,
|
|
36
|
+
declared_prompt,
|
|
37
|
+
declared_tool,
|
|
38
|
+
)
|
|
39
|
+
from tilde.management.v1.deployments_pb2 import DeclaredPrompt, DeclaredTool
|
|
40
|
+
from tilde.prompts import valid_prompt_name
|
|
41
|
+
from tilde.skills import SkillDefinition, read_skill, read_skills
|
|
42
|
+
from tilde_agno.tools import describe_tool, functions_of, processed
|
|
43
|
+
|
|
44
|
+
_VARIABLE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_]*\}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def discover(value: Any, context: DiscoveryContext) -> Discovered | None:
|
|
48
|
+
if isinstance(value, BundledTools):
|
|
49
|
+
if not value.tools or not all(_readable(tool) for tool in value.tools):
|
|
50
|
+
return None
|
|
51
|
+
# Plain functions alone could be another framework's.
|
|
52
|
+
ours = {"agno", "tilde_agno"} & context.imported or any(
|
|
53
|
+
not inspect.isroutine(tool) for tool in value.tools
|
|
54
|
+
)
|
|
55
|
+
if not ours:
|
|
56
|
+
return None
|
|
57
|
+
return Discovered(tools=_tools(value.tools, value.options, f"{context.origin()}.tools"))
|
|
58
|
+
if not isinstance(value, Agent | Team):
|
|
59
|
+
return None
|
|
60
|
+
file = Path(sys.modules[context.module].__file__ or "")
|
|
61
|
+
origin = f"{context.relative(file)}#{context.name}"
|
|
62
|
+
# `tools` may also be a factory called per run; dict tool definitions are skipped too.
|
|
63
|
+
tools = list(value.tools) if isinstance(value.tools, list | tuple) else []
|
|
64
|
+
return Discovered(
|
|
65
|
+
prompts=agent_prompts(value, value.name or context.name, origin, context.warn),
|
|
66
|
+
skills=_skills(value.skills, f"{origin}.skills", context),
|
|
67
|
+
tools=_tools([tool for tool in tools if _readable(tool)], None, f"{origin}.tools"),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _readable(tool: Any) -> bool:
|
|
72
|
+
return isinstance(tool, Function | Toolkit) or inspect.isroutine(tool)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _tools(
|
|
76
|
+
tools: list[Any], options: Mapping[str, BundledOptions] | None, origin: str
|
|
77
|
+
) -> list[DeclaredTool]:
|
|
78
|
+
declared = []
|
|
79
|
+
for function in functions_of(tools):
|
|
80
|
+
spec = describe_tool(processed(function), options)
|
|
81
|
+
declared.append(declared_tool(spec, f"{origin}.{spec.name}"))
|
|
82
|
+
return declared
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def agent_prompts(
|
|
86
|
+
agent: Agent | Team, name: str, origin: str, warn: Callable[[str], None]
|
|
87
|
+
) -> list[DeclaredPrompt]:
|
|
88
|
+
"""The agent's prompts; ``tilde_agno`` calls this again at runtime for the stamps."""
|
|
89
|
+
if not valid_prompt_name(name):
|
|
90
|
+
warn(f"Agno agent {name!r} cannot name a prompt; set name= ({origin})")
|
|
91
|
+
return []
|
|
92
|
+
# (prompt name suffix, attribute for the origin, value)
|
|
93
|
+
fields: list[tuple[str, str, Any]] = []
|
|
94
|
+
if isinstance(agent.instructions, list) and len(agent.instructions) > 1:
|
|
95
|
+
for index, text in enumerate(agent.instructions):
|
|
96
|
+
fields.append((f"instructions/{index + 1}", f"instructions[{index}]", text))
|
|
97
|
+
elif isinstance(agent.instructions, list):
|
|
98
|
+
fields.append(("instructions", "instructions", next(iter(agent.instructions), None)))
|
|
99
|
+
else:
|
|
100
|
+
fields.append(("instructions", "instructions", agent.instructions))
|
|
101
|
+
fields.append(("description", "description", agent.description))
|
|
102
|
+
system = agent.system_message
|
|
103
|
+
system = system.content if isinstance(system, Message) else system
|
|
104
|
+
fields.append(("system_message", "system_message", system))
|
|
105
|
+
prompts = []
|
|
106
|
+
for field, attribute, value in fields:
|
|
107
|
+
where = f"{origin}.{attribute}"
|
|
108
|
+
if isinstance(value, str) and value:
|
|
109
|
+
braces = agent.resolve_in_context and _VARIABLE.search(value)
|
|
110
|
+
format = PROMPT_FORMAT_BRACES if braces else PROMPT_FORMAT_PLAIN
|
|
111
|
+
prompts.append(declared_prompt(f"{name}/{field}", value, format, where))
|
|
112
|
+
elif callable(value):
|
|
113
|
+
try:
|
|
114
|
+
source = inspect.getsource(value)
|
|
115
|
+
except (OSError, TypeError):
|
|
116
|
+
warn(f"{where}: the source of {name}/{field} could not be read; not registered")
|
|
117
|
+
continue
|
|
118
|
+
prompts.append(declared_prompt(f"{name}/{field}", source, PROMPT_FORMAT_DYNAMIC, where))
|
|
119
|
+
elif value is not None and value != "":
|
|
120
|
+
warn(f"{where}: unsupported {type(value).__name__}; not registered")
|
|
121
|
+
return prompts
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _skills(skills: Skills | None, origin: str, context: DiscoveryContext) -> list[SkillDefinition]:
|
|
125
|
+
found: list[SkillDefinition] = []
|
|
126
|
+
for loader in skills.loaders if skills is not None else []:
|
|
127
|
+
if not isinstance(loader, LocalSkills):
|
|
128
|
+
context.warn(f"{origin}: {type(loader).__name__} skills are not shipped")
|
|
129
|
+
continue
|
|
130
|
+
# LocalSkills takes a skill folder or a directory of them, resolved at construction.
|
|
131
|
+
path = loader.path
|
|
132
|
+
found.extend([read_skill(path)] if (path / "SKILL.md").is_file() else read_skills(path))
|
|
133
|
+
return found
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The per-invocation pieces of an Agno run, as ``agent.arun`` keyword arguments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from agno.agent import Agent
|
|
14
|
+
from agno.run import RunContext
|
|
15
|
+
from agno.run.cancel import cancel_run
|
|
16
|
+
from agno.skills import LocalSkills, Skill, Skills
|
|
17
|
+
from agno.team import Team
|
|
18
|
+
|
|
19
|
+
from tilde import AgentContext, BundledTools
|
|
20
|
+
from tilde.discovery import PROMPT_FORMAT_DYNAMIC
|
|
21
|
+
from tilde_agno.discover import agent_prompts
|
|
22
|
+
from tilde_agno.tools import convert_to_agno_tools, with_tilde_tools
|
|
23
|
+
|
|
24
|
+
_registry: ContextVar[dict[str, Skill] | None] = ContextVar("tilde_agno_skills", default=None)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TildeSkills(Skills):
|
|
28
|
+
"""Agno ``Skills`` that also serve the skills assigned to the agent in Tilde.
|
|
29
|
+
|
|
30
|
+
Use it in place of ``Skills``: ``Agent(skills=TildeSkills([LocalSkills("skills")]))``.
|
|
31
|
+
``tilde_agno`` loads the invocation's registry skills (assigned in Tilde, not shipped with
|
|
32
|
+
the deployment) before each run, so a skill assigned in the UI reaches the next invocation;
|
|
33
|
+
the agent's own skills win a name clash. Agno reads loaded skills from ``Skills._skills``
|
|
34
|
+
(3.0.10), which this class resolves per invocation.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def _skills(self) -> dict[str, Skill]:
|
|
39
|
+
own = self.__dict__["_own"]
|
|
40
|
+
registry = _registry.get()
|
|
41
|
+
return {**registry, **own} if registry else own
|
|
42
|
+
|
|
43
|
+
@_skills.setter
|
|
44
|
+
def _skills(self, value: dict[str, Skill]) -> None:
|
|
45
|
+
self.__dict__["_own"] = value
|
|
46
|
+
|
|
47
|
+
def reload(self) -> None:
|
|
48
|
+
# Reload the agent's own skills, never an invocation's merged view.
|
|
49
|
+
token = _registry.set(None)
|
|
50
|
+
try:
|
|
51
|
+
super().reload()
|
|
52
|
+
finally:
|
|
53
|
+
_registry.reset(token)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def tilde_agno(
|
|
57
|
+
ctx: AgentContext, agent: Agent | Team, *, bundled: BundledTools | None = None
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
"""``await agent.arun(input, **await tilde_agno(ctx, agent, bundled=TOOLS))``.
|
|
60
|
+
|
|
61
|
+
- ``run_context.client_tools``: the current channel's tools, the agent's other Tilde tools
|
|
62
|
+
and its bundled tools (``bundled``, from ``define_tools``, audited and published as
|
|
63
|
+
``with_tilde_tools`` does), added to the agent's own for this run (Agno's per-run tools,
|
|
64
|
+
as AG-UI frontend tools use them).
|
|
65
|
+
- Steering: a tool hook on those tools appends input steered while the agent works to the
|
|
66
|
+
tool result the model reads next. Agno has no per-step message hook, so steering waits for
|
|
67
|
+
a Tilde tool call; an agent-level ``tool_hooks`` replaces this hook.
|
|
68
|
+
- ``run_id``: registered so a stop or suspension cancels the run (``agno.run.cancel``).
|
|
69
|
+
- Dynamic instructions, description or system message stamp this invocation's model calls.
|
|
70
|
+
- Registry skills: through ``TildeSkills`` when the agent uses it, else as ``list_skills``
|
|
71
|
+
and ``read_skill`` tools with their summary in the system message.
|
|
72
|
+
"""
|
|
73
|
+
tools = await with_tilde_tools(
|
|
74
|
+
ctx,
|
|
75
|
+
bundled.tools if bundled else [],
|
|
76
|
+
options=bundled.options if bundled else None,
|
|
77
|
+
)
|
|
78
|
+
if isinstance(agent.skills, TildeSkills):
|
|
79
|
+
directory = LocalSkills(await ctx.skills.directory(), validate=False)
|
|
80
|
+
_registry.set({skill.name: skill for skill in await asyncio.to_thread(directory.load)})
|
|
81
|
+
else:
|
|
82
|
+
summary = await ctx.skills.summary()
|
|
83
|
+
if summary:
|
|
84
|
+
skill_tools = convert_to_agno_tools(ctx.skills.tools())
|
|
85
|
+
# Agno adds a function's instructions to the system message for the run.
|
|
86
|
+
skill_tools[0].instructions = summary # list_skills
|
|
87
|
+
skill_tools[0].add_instructions = True
|
|
88
|
+
tools += skill_tools
|
|
89
|
+
steering = _steering(ctx)
|
|
90
|
+
for tool in tools:
|
|
91
|
+
tool.tool_hooks = [steering]
|
|
92
|
+
|
|
93
|
+
fields = (agent.instructions, agent.description, agent.system_message)
|
|
94
|
+
name = agent.name or (_variable(agent) if any(callable(f) for f in fields) else None)
|
|
95
|
+
for prompt in agent_prompts(agent, name, "", lambda _: None) if name else []:
|
|
96
|
+
if prompt.format == PROMPT_FORMAT_DYNAMIC:
|
|
97
|
+
ctx.activate_prompt(prompt.name, prompt.hash)
|
|
98
|
+
|
|
99
|
+
run_id = str(uuid.uuid4())
|
|
100
|
+
ctx.cancellation.on_abort(lambda: cancel_run(run_id))
|
|
101
|
+
run_context = RunContext(run_id=run_id, session_id=ctx.thread_id, client_tools=tools)
|
|
102
|
+
return {"run_id": run_id, "session_id": ctx.thread_id, "run_context": run_context}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _steering(ctx: AgentContext) -> Callable[..., Awaitable[Any]]:
|
|
106
|
+
async def steer(function_call: Callable[..., Awaitable[Any]], arguments: dict[str, Any]) -> Any:
|
|
107
|
+
result = await function_call(**arguments)
|
|
108
|
+
inputs = ctx.take_inputs()
|
|
109
|
+
if not inputs:
|
|
110
|
+
return result
|
|
111
|
+
text = result if isinstance(result, str) else json.dumps(result, default=str)
|
|
112
|
+
steered = "\n".join(f"- {steered.text}" for steered in inputs)
|
|
113
|
+
return f"{text}\n\nNew messages arrived while you were working:\n{steered}"
|
|
114
|
+
|
|
115
|
+
return steer
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _variable(agent: Agent | Team) -> str | None:
|
|
119
|
+
# Discovery names an unnamed agent after its module-level variable; find the same one.
|
|
120
|
+
for module in list(sys.modules.values()):
|
|
121
|
+
for key, value in list(getattr(module, "__dict__", {}).items()):
|
|
122
|
+
if value is agent and not key.startswith("_"):
|
|
123
|
+
return key
|
|
124
|
+
return None
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Convert typed Tilde context into Agno messages.
|
|
2
|
+
|
|
3
|
+
Pass the result as ``await agent.arun(input=messages)``: Agno appends a ``list[Message]`` input
|
|
4
|
+
verbatim after its system message, so roles and media survive and no separate "latest text"
|
|
5
|
+
needs to be extracted. Keep ``add_history_to_context`` off; Tilde owns the history.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import inspect
|
|
11
|
+
import json
|
|
12
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from agno.media import File, Image
|
|
17
|
+
from agno.models.message import Message
|
|
18
|
+
|
|
19
|
+
from tilde import (
|
|
20
|
+
AgentContext,
|
|
21
|
+
ContextMessage,
|
|
22
|
+
ConversationMessage,
|
|
23
|
+
GoalMessage,
|
|
24
|
+
ObjectiveMessage,
|
|
25
|
+
TaskMessage,
|
|
26
|
+
)
|
|
27
|
+
from tilde_agno.attachments import AttachmentConversion, AttachmentHandler, convert_attachment
|
|
28
|
+
|
|
29
|
+
Converted = Message | None | Awaitable[Message | None]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(slots=True)
|
|
33
|
+
class MessageHandlers:
|
|
34
|
+
"""Explicit handlers take precedence over cached and default rendering; None omits an item."""
|
|
35
|
+
|
|
36
|
+
message: Callable[[ConversationMessage], Converted] | None = None
|
|
37
|
+
objective: Callable[[ObjectiveMessage], Converted] | None = None
|
|
38
|
+
goal: Callable[[GoalMessage], Converted] | None = None
|
|
39
|
+
task: Callable[[TaskMessage], Converted] | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_BATCH_ITEMS = 100
|
|
43
|
+
_BATCH_BYTES = 1024 * 1024
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def convert_to_agno_messages(
|
|
47
|
+
messages: Iterable[ContextMessage],
|
|
48
|
+
*,
|
|
49
|
+
context: AgentContext | None = None,
|
|
50
|
+
on_message: MessageHandlers | None = None,
|
|
51
|
+
on_attachment: AttachmentHandler | None = None,
|
|
52
|
+
) -> list[Message]:
|
|
53
|
+
"""Convert typed SDK context into ``agno.models.message.Message`` values."""
|
|
54
|
+
output: list[Message] = []
|
|
55
|
+
handlers = on_message or MessageHandlers()
|
|
56
|
+
cache: list[tuple[str, Any]] = []
|
|
57
|
+
cache_bytes = 0
|
|
58
|
+
|
|
59
|
+
async def flush() -> None:
|
|
60
|
+
nonlocal cache_bytes
|
|
61
|
+
if cache and context is not None:
|
|
62
|
+
await context.cache_converted_messages(cache[:])
|
|
63
|
+
cache.clear()
|
|
64
|
+
cache_bytes = 0
|
|
65
|
+
|
|
66
|
+
for item in messages:
|
|
67
|
+
hydrated = False
|
|
68
|
+
if isinstance(item, ObjectiveMessage):
|
|
69
|
+
converted = (
|
|
70
|
+
await _call(handlers.objective, item)
|
|
71
|
+
if handlers.objective
|
|
72
|
+
else Message(id=item.id, role="user", content=item.objective)
|
|
73
|
+
)
|
|
74
|
+
elif isinstance(item, GoalMessage):
|
|
75
|
+
converted = (
|
|
76
|
+
await _call(handlers.goal, item)
|
|
77
|
+
if handlers.goal
|
|
78
|
+
else Message(
|
|
79
|
+
id=item.id,
|
|
80
|
+
role="user",
|
|
81
|
+
content=f"Goal ({item.goal.status}): {item.goal.objective}",
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
elif isinstance(item, TaskMessage):
|
|
85
|
+
if handlers.task:
|
|
86
|
+
converted = await _call(handlers.task, item)
|
|
87
|
+
else:
|
|
88
|
+
text = f"Task ({item.task.status}): {item.task.title}"
|
|
89
|
+
if item.task.blocked_reason:
|
|
90
|
+
text += f"\nBlocked: {item.task.blocked_reason}"
|
|
91
|
+
converted = Message(id=item.id, role="user", content=text)
|
|
92
|
+
else:
|
|
93
|
+
if item.message.status != "complete" and handlers.message is None:
|
|
94
|
+
continue
|
|
95
|
+
if handlers.message:
|
|
96
|
+
converted = await _call(handlers.message, item)
|
|
97
|
+
else:
|
|
98
|
+
# File bytes are hydrated afresh through the scoped SDK, never from the cache.
|
|
99
|
+
converted = _hydrate(item) if not item.message.attachments else None
|
|
100
|
+
hydrated = converted is not None
|
|
101
|
+
if converted is None:
|
|
102
|
+
converted = await _render(item, context, on_attachment)
|
|
103
|
+
if converted is None:
|
|
104
|
+
continue
|
|
105
|
+
output.append(converted)
|
|
106
|
+
# Only canonical, text-only conversation messages are cached. Work state is always
|
|
107
|
+
# current, and hydrated files must not duplicate attachment bytes in the cache.
|
|
108
|
+
if (
|
|
109
|
+
context is not None
|
|
110
|
+
and isinstance(item, ConversationMessage)
|
|
111
|
+
and item.message.status == "complete"
|
|
112
|
+
and not hydrated
|
|
113
|
+
and not item.message.attachments
|
|
114
|
+
and isinstance(converted.content, str)
|
|
115
|
+
and not (converted.images or converted.files or converted.audio or converted.videos)
|
|
116
|
+
):
|
|
117
|
+
entry = {"id": converted.id, "role": converted.role, "content": converted.content}
|
|
118
|
+
size = len(json.dumps(entry).encode())
|
|
119
|
+
if size > _BATCH_BYTES:
|
|
120
|
+
continue
|
|
121
|
+
if len(cache) == _BATCH_ITEMS or cache_bytes + size > _BATCH_BYTES:
|
|
122
|
+
await flush()
|
|
123
|
+
cache.append((item.id, entry))
|
|
124
|
+
cache_bytes += size
|
|
125
|
+
await flush()
|
|
126
|
+
return output
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _call(handler: Callable[[Any], Any], value: Any) -> Any:
|
|
130
|
+
result = handler(value)
|
|
131
|
+
return await result if inspect.isawaitable(result) else result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _hydrate(item: ConversationMessage) -> Message | None:
|
|
135
|
+
cached = item.cached_agent_representation
|
|
136
|
+
if not isinstance(cached, dict):
|
|
137
|
+
return None
|
|
138
|
+
content = cached.get("content")
|
|
139
|
+
if cached.get("id") != item.id or cached.get("role") != item.role:
|
|
140
|
+
return None
|
|
141
|
+
if not isinstance(content, str):
|
|
142
|
+
return None
|
|
143
|
+
return Message(id=item.id, role=item.role, content=content)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _downloader(context: AgentContext | None, attachment_id: str):
|
|
147
|
+
def download():
|
|
148
|
+
if context is None:
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
"Attachment conversion requires context or a custom on_attachment handler"
|
|
151
|
+
)
|
|
152
|
+
return context.attachments.download(attachment_id)
|
|
153
|
+
|
|
154
|
+
return download
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _render(
|
|
158
|
+
item: ConversationMessage, context: AgentContext | None, on_attachment: AttachmentHandler | None
|
|
159
|
+
) -> Message | None:
|
|
160
|
+
texts: list[str] = []
|
|
161
|
+
images: list[Image] = []
|
|
162
|
+
files: list[File] = []
|
|
163
|
+
if item.message.subject:
|
|
164
|
+
texts.append(f"Subject: {item.message.subject}")
|
|
165
|
+
if item.message.text:
|
|
166
|
+
texts.append(item.message.text)
|
|
167
|
+
handler = on_attachment or convert_attachment
|
|
168
|
+
for attachment in item.message.attachments:
|
|
169
|
+
conversion = AttachmentConversion(
|
|
170
|
+
message=item, attachment=attachment, download=_downloader(context, attachment.id)
|
|
171
|
+
)
|
|
172
|
+
result = await _call(handler, conversion)
|
|
173
|
+
for part in result if isinstance(result, list) else [result]:
|
|
174
|
+
if isinstance(part, str):
|
|
175
|
+
texts.append(part)
|
|
176
|
+
elif isinstance(part, Image):
|
|
177
|
+
images.append(part)
|
|
178
|
+
elif isinstance(part, File):
|
|
179
|
+
files.append(part)
|
|
180
|
+
if not texts and not images and not files:
|
|
181
|
+
return None
|
|
182
|
+
if not texts:
|
|
183
|
+
# Providers reject empty text beside media; name the files instead of sending "".
|
|
184
|
+
texts.append("Attached: " + ", ".join(a.filename for a in item.message.attachments))
|
|
185
|
+
return Message(
|
|
186
|
+
id=item.id,
|
|
187
|
+
role=item.role,
|
|
188
|
+
content="\n\n".join(texts),
|
|
189
|
+
images=images or None,
|
|
190
|
+
files=files or None,
|
|
191
|
+
)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Expose Tilde channel tools to Agno with provider schemas and audited tool-call ids intact, and
|
|
2
|
+
publish the agent's own Agno tools to Tilde as bundled tools."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import copy
|
|
8
|
+
import inspect
|
|
9
|
+
import uuid
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from contextvars import ContextVar
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from agno.tools.function import Function, FunctionCall
|
|
15
|
+
from agno.tools.toolkit import Toolkit
|
|
16
|
+
|
|
17
|
+
from tilde import AgentContext, BundledOptions, ChannelTool, Tool, ToolAnnotations
|
|
18
|
+
from tilde._tools import ToolSpec, bundled_tool, model_tool_name, tool_spec
|
|
19
|
+
|
|
20
|
+
# The FunctionCall of the Tilde tool being executed; a routed tools.execute runs within it.
|
|
21
|
+
_FUNCTION_CALL: ContextVar[FunctionCall] = ContextVar("tilde_agno_function_call")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def convert_to_agno_tools(
|
|
25
|
+
channels: Mapping[str, ChannelTool | Tool],
|
|
26
|
+
*,
|
|
27
|
+
instructions: Mapping[str, str] | None = None,
|
|
28
|
+
prefix: str = "",
|
|
29
|
+
) -> list[Function]:
|
|
30
|
+
"""Build Agno ``Function`` tools that keep provider schemas and execution ids.
|
|
31
|
+
|
|
32
|
+
``skip_entrypoint_processing`` stops Agno from replacing the provider JSON schema with one
|
|
33
|
+
introspected from the entrypoint. Agno injects the running ``FunctionCall`` into an
|
|
34
|
+
entrypoint parameter named ``fc``; its ``call_id`` is the model's tool-call id and is
|
|
35
|
+
forwarded to Tilde's audited execution. A provider schema with a property named ``fc`` is
|
|
36
|
+
rejected here because Agno would drop that argument before it reached the channel.
|
|
37
|
+
``instructions`` overrides descriptions by channel name; ``prefix`` namespaces model tool
|
|
38
|
+
names when combining collections.
|
|
39
|
+
"""
|
|
40
|
+
result: list[Function] = []
|
|
41
|
+
names: set[str] = set()
|
|
42
|
+
for name, channel in channels.items():
|
|
43
|
+
key = model_tool_name(f"{prefix}{name}")
|
|
44
|
+
if not key or key in names:
|
|
45
|
+
raise ValueError("Conflicting or invalid model tool names")
|
|
46
|
+
names.add(key)
|
|
47
|
+
if "fc" in (channel.input_schema.get("properties") or {}):
|
|
48
|
+
raise ValueError(
|
|
49
|
+
f"{name}: Agno reserves the `fc` argument for its injected FunctionCall"
|
|
50
|
+
)
|
|
51
|
+
description = (
|
|
52
|
+
instructions[name]
|
|
53
|
+
if instructions is not None and name in instructions
|
|
54
|
+
else channel.description
|
|
55
|
+
)
|
|
56
|
+
result.append(
|
|
57
|
+
Function(
|
|
58
|
+
name=key,
|
|
59
|
+
description=description,
|
|
60
|
+
parameters=copy.deepcopy(channel.input_schema),
|
|
61
|
+
entrypoint=_entrypoint(channel),
|
|
62
|
+
skip_entrypoint_processing=True,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _entrypoint(channel: ChannelTool | Tool):
|
|
69
|
+
async def execute(fc: FunctionCall, **arguments: Any) -> Any:
|
|
70
|
+
token = _FUNCTION_CALL.set(fc)
|
|
71
|
+
try:
|
|
72
|
+
return await channel.execute(arguments, tool_call_id=fc.call_id or None)
|
|
73
|
+
finally:
|
|
74
|
+
_FUNCTION_CALL.reset(token)
|
|
75
|
+
|
|
76
|
+
return execute
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def with_tilde_tools(
|
|
80
|
+
ctx: AgentContext,
|
|
81
|
+
native_tools: Sequence[Callable[..., Any] | Function | Toolkit],
|
|
82
|
+
*,
|
|
83
|
+
options: Mapping[str, BundledOptions] | None = None,
|
|
84
|
+
) -> list[Function]:
|
|
85
|
+
"""The current channel's tools, the agent's other Tilde tools and its own tools, as Agno
|
|
86
|
+
``Function``s for ``Agent(tools=...)``.
|
|
87
|
+
|
|
88
|
+
``native_tools`` are plain functions, ``@tool`` functions or toolkits (expanded to their
|
|
89
|
+
functions). Each is published to Tilde with its name, description, parameter schema and MCP
|
|
90
|
+
``annotations`` hints, so ``tools.search`` finds it and a ``tools.execute`` naming it runs it
|
|
91
|
+
here, and is returned as a copy whose calls are audited once with the model's call id by
|
|
92
|
+
``pre_hook``/``post_hook`` chained around the tool's own hooks. Agno has no free metadata,
|
|
93
|
+
so the summary and display come from ``options[name]``. Call it once per invocation.
|
|
94
|
+
|
|
95
|
+
Caveats: run the agent with ``arun``; the audit hooks are async, and Agno's sync path does
|
|
96
|
+
not await them. Agno logs and swallows hook exceptions, so a failed audit never fails the
|
|
97
|
+
call. Toolkit-level instructions are not carried over. Agno has no output schema.
|
|
98
|
+
"""
|
|
99
|
+
functions = convert_to_agno_tools({**ctx.channel.current, **ctx.agent_tools})
|
|
100
|
+
names = {function.name for function in functions}
|
|
101
|
+
definitions = {}
|
|
102
|
+
for native in functions_of(native_tools):
|
|
103
|
+
if native.name in names:
|
|
104
|
+
raise ValueError(f"Tool {native.name} conflicts with another tool")
|
|
105
|
+
names.add(native.name)
|
|
106
|
+
function = _audited(ctx, native)
|
|
107
|
+
definitions[function.name] = bundled_tool(
|
|
108
|
+
describe_tool(function, options), _route(function)
|
|
109
|
+
)
|
|
110
|
+
functions.append(function)
|
|
111
|
+
# Published even when empty, so tools removed since the last call stop running here.
|
|
112
|
+
await ctx._set_bundled_tools(definitions)
|
|
113
|
+
return functions
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def describe_tool(
|
|
117
|
+
function: Function, options: Mapping[str, BundledOptions] | None = None
|
|
118
|
+
) -> ToolSpec:
|
|
119
|
+
"""What ``with_tilde_tools`` publishes and ``tilde deploy`` declares for one tool, from a
|
|
120
|
+
``Function`` whose entrypoint has been processed (see ``processed``)."""
|
|
121
|
+
hints = function.annotations or {}
|
|
122
|
+
annotations = (
|
|
123
|
+
ToolAnnotations(
|
|
124
|
+
read_only=bool(hints.get("readOnlyHint")),
|
|
125
|
+
destructive=bool(hints.get("destructiveHint")),
|
|
126
|
+
idempotent=bool(hints.get("idempotentHint")),
|
|
127
|
+
open_world=bool(hints.get("openWorldHint")),
|
|
128
|
+
)
|
|
129
|
+
if hints
|
|
130
|
+
else None
|
|
131
|
+
)
|
|
132
|
+
return tool_spec(
|
|
133
|
+
function.name,
|
|
134
|
+
function.description or "",
|
|
135
|
+
function.parameters,
|
|
136
|
+
annotations=annotations,
|
|
137
|
+
options=(options or {}).get(function.name),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def processed(native: Function) -> Function:
|
|
142
|
+
"""A copy with the parameters and description Agno introspects from the entrypoint."""
|
|
143
|
+
function = native.model_copy(deep=True)
|
|
144
|
+
function.process_entrypoint()
|
|
145
|
+
return function
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def functions_of(tools: Sequence[Callable[..., Any] | Function | Toolkit]) -> list[Function]:
|
|
149
|
+
"""Plain functions, ``@tool`` functions and toolkits (expanded) as unprocessed ``Function``s."""
|
|
150
|
+
result: list[Function] = []
|
|
151
|
+
for tool in tools:
|
|
152
|
+
if isinstance(tool, Toolkit):
|
|
153
|
+
result.extend(tool.get_async_functions().values())
|
|
154
|
+
elif isinstance(tool, Function):
|
|
155
|
+
result.append(tool)
|
|
156
|
+
else:
|
|
157
|
+
result.append(Function.from_callable(tool))
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _audited(ctx: AgentContext, native: Function) -> Function:
|
|
162
|
+
# The entrypoint stays untouched so Agno still injects agent, run_context and media.
|
|
163
|
+
function = processed(native)
|
|
164
|
+
pre, post = native.pre_hook, native.post_hook
|
|
165
|
+
|
|
166
|
+
async def pre_hook(fc: FunctionCall) -> None:
|
|
167
|
+
fc.call_id = fc.call_id or str(uuid.uuid4())
|
|
168
|
+
await ctx._report_bundled(function.name, fc.call_id, "running", input=fc.arguments)
|
|
169
|
+
if pre is not None:
|
|
170
|
+
await _call_hook(pre, fc)
|
|
171
|
+
|
|
172
|
+
async def post_hook(fc: FunctionCall) -> None:
|
|
173
|
+
# Agno runs the post hook in a finally block: an error means the call failed, and a
|
|
174
|
+
# cancelled call is left unreported like any other cancellation.
|
|
175
|
+
call = fc.call_id or ""
|
|
176
|
+
task = asyncio.current_task()
|
|
177
|
+
if task is not None and task.cancelling():
|
|
178
|
+
pass
|
|
179
|
+
elif fc.error is not None:
|
|
180
|
+
await ctx._report_bundled(function.name, call, "failed", error=fc.error)
|
|
181
|
+
else:
|
|
182
|
+
await ctx._report_bundled(function.name, call, "completed", output=fc.result)
|
|
183
|
+
if post is not None:
|
|
184
|
+
await _call_hook(post, fc)
|
|
185
|
+
|
|
186
|
+
function.pre_hook = pre_hook
|
|
187
|
+
function.post_hook = post_hook
|
|
188
|
+
return function
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def _call_hook(hook: Callable[..., Any], fc: FunctionCall) -> None:
|
|
192
|
+
# The developer's own hook, with the arguments Agno would have injected by name.
|
|
193
|
+
available = {
|
|
194
|
+
"agent": fc.function._agent,
|
|
195
|
+
"team": fc.function._team,
|
|
196
|
+
"run_context": fc.function._run_context,
|
|
197
|
+
"fc": fc,
|
|
198
|
+
}
|
|
199
|
+
parameters = inspect.signature(hook).parameters
|
|
200
|
+
result = hook(**{name: value for name, value in available.items() if name in parameters})
|
|
201
|
+
if inspect.isawaitable(result):
|
|
202
|
+
await result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _route(function: Function):
|
|
206
|
+
# A tools.execute naming this tool runs it as Agno would, with the agent and run context of
|
|
207
|
+
# the tools.execute call, audited once by its hooks.
|
|
208
|
+
async def execute(input: Any, call: str) -> Any:
|
|
209
|
+
outer = _FUNCTION_CALL.get(None)
|
|
210
|
+
inner = function.model_copy(deep=True)
|
|
211
|
+
if outer is not None:
|
|
212
|
+
inner._agent = outer.function._agent
|
|
213
|
+
inner._team = outer.function._team
|
|
214
|
+
inner._run_context = outer.function._run_context
|
|
215
|
+
fc = FunctionCall(function=inner, arguments=input, call_id=call)
|
|
216
|
+
result = await fc.aexecute()
|
|
217
|
+
if result.status == "failure":
|
|
218
|
+
raise RuntimeError(result.error or f"{function.name} failed")
|
|
219
|
+
return fc.result
|
|
220
|
+
|
|
221
|
+
return execute
|