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.
@@ -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