trytilde-pydantic-ai 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,139 @@
1
+ Metadata-Version: 2.5
2
+ Name: trytilde-pydantic-ai
3
+ Version: 3.0.1
4
+ Summary: Tilde adapter for Pydantic AI: typed context to model messages, channel tools to Pydantic AI tools
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: pydantic-ai-slim<3,>=2
8
+ Requires-Dist: trytilde
9
+ Description-Content-Type: text/markdown
10
+
11
+ # Pydantic AI adapter
12
+
13
+ Core history and delivery live in `trytilde`. This package converts typed context to
14
+ Pydantic AI `ModelMessage` history and channel tools to Pydantic AI tools, bundles an
15
+ invocation's run options in `tilde_pydantic_ai(ctx)` and lets `tilde deploy` register a
16
+ module-level agent's prompts.
17
+
18
+ ```python
19
+ import tilde
20
+ from pydantic_ai import Agent
21
+ from pydantic_ai.usage import UsageLimits
22
+ from tilde_pydantic_ai import convert_to_pydantic_ai_messages, tilde_pydantic_ai
23
+
24
+ INFERENCE = tilde.inference("default")
25
+ responder = Agent(model_using(INFERENCE), instructions="Respond using the channel's tools.")
26
+
27
+
28
+ async def run(ctx):
29
+ history = await ctx.message.history()
30
+ messages = await convert_to_pydantic_ai_messages(history.items, context=ctx)
31
+ await responder.run(
32
+ message_history=messages,
33
+ usage_limits=UsageLimits(request_limit=8),
34
+ **await tilde_pydantic_ai(ctx),
35
+ )
36
+ ```
37
+
38
+ `tilde_pydantic_ai(ctx)` returns `capabilities=[TildeInvocation]` and a `cancellation_token`
39
+ that a stop or suspension cancels. The per-run capability contributes the current channel's
40
+ tools, enqueues input steered while the agent works after every graph node (`RunContext.enqueue`,
41
+ delivered with the next model request), stamps the agent's dynamic instructions on its model
42
+ calls and, since Pydantic AI has no native skills, adds `list_skills`/`read_skill` with the
43
+ skills summary in the instructions (`skills=False` leaves them out).
44
+
45
+ `tilde deploy` (entry point `tilde.discover`) registers each module-level `Agent`'s instructions
46
+ and system prompts as `<name or variable>/instructions` and `<name>/system_prompt` (`/<n>` from 1
47
+ when there are several): text is plain, `TemplateStr` is mustache and a function is dynamic with
48
+ its source as the template. They are read from Pydantic AI's private fields (2.43); an
49
+ unreadable agent is reported in the inventory.
50
+
51
+ History pages are chronological; pass `before_message_id=history.next_page_token` for older
52
+ messages. The latest page includes the current objective unless it already matches the latest
53
+ received message. `include_objective=False` omits it. `include_work=True` also reads current
54
+ goals/tasks and requires `work.read`. Only the acting agent's messages become `ModelResponse`
55
+ items; everything else is a `ModelRequest` with a `UserPromptPart`. Messages whose status is
56
+ not `complete` are skipped unless a custom `message` handler is given.
57
+
58
+ Pydantic AI parts have no id field. Each converted message keeps its Tilde id in the
59
+ application-level `metadata` (`{"tilde_message_id": ...}`, read it with `message_id(message)`);
60
+ that metadata is never sent to the model.
61
+
62
+ Images and PDFs are downloaded through `ctx.attachments.download` and embedded as
63
+ `BinaryContent`. Text files include their real content. Unsupported binary formats get an
64
+ explicit attachment description; use `on_attachment` to parse them yourself. No private URL or
65
+ credential needs to be exposed to the model. Converting a message with attachments without a
66
+ `context` or custom handler raises instead of silently dropping the file.
67
+
68
+ ```python
69
+ from tilde_pydantic_ai import MessageHandlers, text_message
70
+
71
+ history = await ctx.message.history(include_work=True)
72
+ messages = await convert_to_pydantic_ai_messages(
73
+ history.items,
74
+ context=ctx,
75
+ on_message=MessageHandlers(
76
+ goal=lambda item: text_message(item.id, "user", f"Our goal: {item.goal.objective}"),
77
+ task=lambda item: None, # Omit this type, or provide a different rendering.
78
+ ),
79
+ on_attachment=decode_attachment, # async def decode_attachment(input) -> str | BinaryContent
80
+ )
81
+ ```
82
+
83
+ `MessageHandlers` supports `message`, `objective`, `goal` and `task`; handlers may be sync or
84
+ async. Supplied handlers take precedence over cached/default rendering; returning `None` omits
85
+ an item. Without an override the converter renders all supported types. Completed, attachment-free
86
+ conversation messages are cached through the existing per-agent cache in bounded batches (100
87
+ items or 1 MiB) as a small `{"id", "role", "text"}` JSON value; a cached entry is reused only
88
+ when its id and role match. Files are hydrated afresh and never stored in the cache;
89
+ objectives/goals/tasks remain live projections rather than cached chat records.
90
+
91
+ ## Channel tools
92
+
93
+ `convert_to_pydantic_ai_tools(ctx.channel.current)` preserves the provider's descriptions and
94
+ JSON schemas (via `Tool.from_schema`) and forwards Pydantic AI's `RunContext.tool_call_id` to
95
+ Tilde's audited tool execution. It does not publish the model's final text. The agent chooses
96
+ the provider tool and arguments, including routing fields required by that provider.
97
+ Cancellation is enforced by the core: a stopped invocation refuses further tool RPCs.
98
+
99
+ Override tool instructions with `instructions={"sendMessage": "..."}`, or change the channel
100
+ tool's `description` before conversion. When combining namespaces, use `prefix="slack_"` (or
101
+ another prefix) to keep model tool names distinct. Names are sanitized to `[a-zA-Z0-9_-]`;
102
+ names over 64 characters are truncated with a stable hash suffix; conflicts raise.
103
+
104
+ The core SDK exposes typed callable tools on `ctx.channel.slack`, `github`, `agentmail`, `linq`,
105
+ `whatsapp`, `telnyx_whatsapp` and `native`. Use `ctx.channel.connections()` and
106
+ `ctx.channel.for_connection(id)` when multiple connections use a provider. `ctx.channel.current`
107
+ never falls back to a different connection when the inbound connection has no available tools.
108
+
109
+ Cap a run with `usage_limits=UsageLimits(request_limit=8)` (or `tool_calls_limit`), the
110
+ Pydantic AI equivalent of `stepCountIs`.
111
+
112
+ ## Bundled tools
113
+
114
+ The agent's own Pydantic AI tools join Tilde's in one toolset:
115
+
116
+ ```python
117
+ from pydantic_ai import Agent, Tool
118
+ from tilde import BundledOptions
119
+ from tilde_pydantic_ai import with_tilde_tools
120
+
121
+
122
+ def roll_dice(count: int = 1) -> list[int]:
123
+ """Roll six-sided dice."""
124
+ return [random.randint(1, 6) for _ in range(count)]
125
+
126
+
127
+ toolset = await with_tilde_tools(
128
+ ctx, [Tool(roll_dice, metadata={"tilde": BundledOptions(summary="Rolled dice")})]
129
+ )
130
+ agent = Agent(model, toolsets=[toolset])
131
+ ```
132
+
133
+ `with_tilde_tools` takes `Tool`s or plain functions and returns one toolset with the current
134
+ channel's tools, `ctx.agent_tools` and the native tools. It publishes the native tools to Tilde
135
+ with their parameter and return schemas, so `tools.search` finds them and a `tools.execute`
136
+ naming one validates and runs it here within the run's `RunContext`; every call is audited once
137
+ with the model's call id. `options={"roll_dice": BundledOptions(...)}` overrides
138
+ `metadata["tilde"]`. A `ModelRetry` is audited as a failed call. Tools registered with
139
+ `@agent.tool` are outside the toolset and are neither published nor audited.
@@ -0,0 +1,129 @@
1
+ # Pydantic AI adapter
2
+
3
+ Core history and delivery live in `trytilde`. This package converts typed context to
4
+ Pydantic AI `ModelMessage` history and channel tools to Pydantic AI tools, bundles an
5
+ invocation's run options in `tilde_pydantic_ai(ctx)` and lets `tilde deploy` register a
6
+ module-level agent's prompts.
7
+
8
+ ```python
9
+ import tilde
10
+ from pydantic_ai import Agent
11
+ from pydantic_ai.usage import UsageLimits
12
+ from tilde_pydantic_ai import convert_to_pydantic_ai_messages, tilde_pydantic_ai
13
+
14
+ INFERENCE = tilde.inference("default")
15
+ responder = Agent(model_using(INFERENCE), instructions="Respond using the channel's tools.")
16
+
17
+
18
+ async def run(ctx):
19
+ history = await ctx.message.history()
20
+ messages = await convert_to_pydantic_ai_messages(history.items, context=ctx)
21
+ await responder.run(
22
+ message_history=messages,
23
+ usage_limits=UsageLimits(request_limit=8),
24
+ **await tilde_pydantic_ai(ctx),
25
+ )
26
+ ```
27
+
28
+ `tilde_pydantic_ai(ctx)` returns `capabilities=[TildeInvocation]` and a `cancellation_token`
29
+ that a stop or suspension cancels. The per-run capability contributes the current channel's
30
+ tools, enqueues input steered while the agent works after every graph node (`RunContext.enqueue`,
31
+ delivered with the next model request), stamps the agent's dynamic instructions on its model
32
+ calls and, since Pydantic AI has no native skills, adds `list_skills`/`read_skill` with the
33
+ skills summary in the instructions (`skills=False` leaves them out).
34
+
35
+ `tilde deploy` (entry point `tilde.discover`) registers each module-level `Agent`'s instructions
36
+ and system prompts as `<name or variable>/instructions` and `<name>/system_prompt` (`/<n>` from 1
37
+ when there are several): text is plain, `TemplateStr` is mustache and a function is dynamic with
38
+ its source as the template. They are read from Pydantic AI's private fields (2.43); an
39
+ unreadable agent is reported in the inventory.
40
+
41
+ History pages are chronological; pass `before_message_id=history.next_page_token` for older
42
+ messages. The latest page includes the current objective unless it already matches the latest
43
+ received message. `include_objective=False` omits it. `include_work=True` also reads current
44
+ goals/tasks and requires `work.read`. Only the acting agent's messages become `ModelResponse`
45
+ items; everything else is a `ModelRequest` with a `UserPromptPart`. Messages whose status is
46
+ not `complete` are skipped unless a custom `message` handler is given.
47
+
48
+ Pydantic AI parts have no id field. Each converted message keeps its Tilde id in the
49
+ application-level `metadata` (`{"tilde_message_id": ...}`, read it with `message_id(message)`);
50
+ that metadata is never sent to the model.
51
+
52
+ Images and PDFs are downloaded through `ctx.attachments.download` and embedded as
53
+ `BinaryContent`. Text files include their real content. Unsupported binary formats get an
54
+ explicit attachment description; use `on_attachment` to parse them yourself. No private URL or
55
+ credential needs to be exposed to the model. Converting a message with attachments without a
56
+ `context` or custom handler raises instead of silently dropping the file.
57
+
58
+ ```python
59
+ from tilde_pydantic_ai import MessageHandlers, text_message
60
+
61
+ history = await ctx.message.history(include_work=True)
62
+ messages = await convert_to_pydantic_ai_messages(
63
+ history.items,
64
+ context=ctx,
65
+ on_message=MessageHandlers(
66
+ goal=lambda item: text_message(item.id, "user", f"Our goal: {item.goal.objective}"),
67
+ task=lambda item: None, # Omit this type, or provide a different rendering.
68
+ ),
69
+ on_attachment=decode_attachment, # async def decode_attachment(input) -> str | BinaryContent
70
+ )
71
+ ```
72
+
73
+ `MessageHandlers` supports `message`, `objective`, `goal` and `task`; handlers may be sync or
74
+ async. Supplied handlers take precedence over cached/default rendering; returning `None` omits
75
+ an item. Without an override the converter renders all supported types. Completed, attachment-free
76
+ conversation messages are cached through the existing per-agent cache in bounded batches (100
77
+ items or 1 MiB) as a small `{"id", "role", "text"}` JSON value; a cached entry is reused only
78
+ when its id and role match. Files are hydrated afresh and never stored in the cache;
79
+ objectives/goals/tasks remain live projections rather than cached chat records.
80
+
81
+ ## Channel tools
82
+
83
+ `convert_to_pydantic_ai_tools(ctx.channel.current)` preserves the provider's descriptions and
84
+ JSON schemas (via `Tool.from_schema`) and forwards Pydantic AI's `RunContext.tool_call_id` to
85
+ Tilde's audited tool execution. It does not publish the model's final text. The agent chooses
86
+ the provider tool and arguments, including routing fields required by that provider.
87
+ Cancellation is enforced by the core: a stopped invocation refuses further tool RPCs.
88
+
89
+ Override tool instructions with `instructions={"sendMessage": "..."}`, or change the channel
90
+ tool's `description` before conversion. When combining namespaces, use `prefix="slack_"` (or
91
+ another prefix) to keep model tool names distinct. Names are sanitized to `[a-zA-Z0-9_-]`;
92
+ names over 64 characters are truncated with a stable hash suffix; conflicts raise.
93
+
94
+ The core SDK exposes typed callable tools on `ctx.channel.slack`, `github`, `agentmail`, `linq`,
95
+ `whatsapp`, `telnyx_whatsapp` and `native`. Use `ctx.channel.connections()` and
96
+ `ctx.channel.for_connection(id)` when multiple connections use a provider. `ctx.channel.current`
97
+ never falls back to a different connection when the inbound connection has no available tools.
98
+
99
+ Cap a run with `usage_limits=UsageLimits(request_limit=8)` (or `tool_calls_limit`), the
100
+ Pydantic AI equivalent of `stepCountIs`.
101
+
102
+ ## Bundled tools
103
+
104
+ The agent's own Pydantic AI tools join Tilde's in one toolset:
105
+
106
+ ```python
107
+ from pydantic_ai import Agent, Tool
108
+ from tilde import BundledOptions
109
+ from tilde_pydantic_ai import with_tilde_tools
110
+
111
+
112
+ def roll_dice(count: int = 1) -> list[int]:
113
+ """Roll six-sided dice."""
114
+ return [random.randint(1, 6) for _ in range(count)]
115
+
116
+
117
+ toolset = await with_tilde_tools(
118
+ ctx, [Tool(roll_dice, metadata={"tilde": BundledOptions(summary="Rolled dice")})]
119
+ )
120
+ agent = Agent(model, toolsets=[toolset])
121
+ ```
122
+
123
+ `with_tilde_tools` takes `Tool`s or plain functions and returns one toolset with the current
124
+ channel's tools, `ctx.agent_tools` and the native tools. It publishes the native tools to Tilde
125
+ with their parameter and return schemas, so `tools.search` finds them and a `tools.execute`
126
+ naming one validates and runs it here within the run's `RunContext`; every call is audited once
127
+ with the model's call id. `options={"roll_dice": BundledOptions(...)}` overrides
128
+ `metadata["tilde"]`. A `ModelRetry` is audited as a failed call. Tools registered with
129
+ `@agent.tool` are outside the toolset and are neither published nor audited.
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "trytilde-pydantic-ai"
3
+ version = "3.0.1"
4
+ description = "Tilde adapter for Pydantic AI: typed context to model messages, channel tools to Pydantic AI tools"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ requires-python = ">=3.11"
8
+ dependencies = ["trytilde", "pydantic-ai-slim>=2,<3"]
9
+
10
+ # `tilde deploy` reads module-level agents' prompts (and skill folders) through this discoverer.
11
+ [project.entry-points."tilde.discover"]
12
+ pydantic-ai = "tilde_pydantic_ai.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_pydantic_ai"]
20
+
21
+ [tool.hatch.build.targets.sdist]
22
+ include = ["src/tilde_pydantic_ai", "README.md"]
@@ -0,0 +1,34 @@
1
+ """Tilde adapter for Pydantic AI: typed context to model messages, channel tools to tools, the
2
+ per-run ``tilde_pydantic_ai`` capability and ``tilde deploy`` discovery of module-level agents."""
3
+
4
+ from tilde_pydantic_ai.attachments import (
5
+ AttachmentConversion,
6
+ AttachmentHandler,
7
+ convert_attachment,
8
+ )
9
+ from tilde_pydantic_ai.discover import discover
10
+ from tilde_pydantic_ai.invocation import TildeInvocation, tilde_pydantic_ai
11
+ from tilde_pydantic_ai.messages import (
12
+ MESSAGE_ID_KEY,
13
+ MessageHandlers,
14
+ convert_to_pydantic_ai_messages,
15
+ message_id,
16
+ text_message,
17
+ )
18
+ from tilde_pydantic_ai.tools import convert_to_pydantic_ai_tools, with_tilde_tools
19
+
20
+ __all__ = [
21
+ "MESSAGE_ID_KEY",
22
+ "AttachmentConversion",
23
+ "AttachmentHandler",
24
+ "MessageHandlers",
25
+ "TildeInvocation",
26
+ "convert_attachment",
27
+ "discover",
28
+ "convert_to_pydantic_ai_messages",
29
+ "convert_to_pydantic_ai_tools",
30
+ "message_id",
31
+ "text_message",
32
+ "tilde_pydantic_ai",
33
+ "with_tilde_tools",
34
+ ]
@@ -0,0 +1,66 @@
1
+ """Attachment hydration: real file bytes for the model, never private URLs or credentials."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from dataclasses import dataclass
7
+
8
+ from pydantic_ai.messages import BinaryContent, UserContent
9
+
10
+ from tilde import ConversationMessage, DownloadedAttachment
11
+ from tilde.types.v1.chat_pb2 import Attachment
12
+
13
+
14
+ @dataclass(slots=True)
15
+ class AttachmentConversion:
16
+ message: ConversationMessage
17
+ attachment: Attachment
18
+ download: Callable[[], Awaitable[DownloadedAttachment]]
19
+ """Uses the current invocation's authenticated, thread-scoped attachment API."""
20
+
21
+
22
+ AttachmentResult = UserContent | list[UserContent] | None
23
+ AttachmentHandler = Callable[[AttachmentConversion], AttachmentResult | Awaitable[AttachmentResult]]
24
+
25
+ _TEXT_TYPES = {"application/json", "application/xml"}
26
+ _FILE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf"}
27
+ _EXTENSIONS = {
28
+ "jpg": "image/jpeg",
29
+ "jpeg": "image/jpeg",
30
+ "png": "image/png",
31
+ "gif": "image/gif",
32
+ "webp": "image/webp",
33
+ "pdf": "application/pdf",
34
+ "txt": "text/plain",
35
+ "md": "text/plain",
36
+ "csv": "text/plain",
37
+ "log": "text/plain",
38
+ "json": "application/json",
39
+ "xml": "application/xml",
40
+ }
41
+
42
+
43
+ async def convert_attachment(input: AttachmentConversion) -> AttachmentResult:
44
+ """Hydrate images/PDFs as binary content and textual files as actual text, not filenames."""
45
+ attachment = input.attachment
46
+ media_type = normalize_media_type(attachment.media_type, attachment.filename)
47
+ text = media_type.startswith("text/") or media_type in _TEXT_TYPES
48
+ if not text and media_type not in _FILE_TYPES:
49
+ return (
50
+ f"Attached file: {attachment.filename} ({media_type}). "
51
+ "A custom attachment handler is needed to read this format."
52
+ )
53
+ result = await input.download()
54
+ if result.attachment.id != attachment.id:
55
+ raise RuntimeError("Attachment download returned a different file")
56
+ if text:
57
+ return f"Attached file: {attachment.filename}\n{result.content.decode('utf-8', 'replace')}"
58
+ return BinaryContent(result.content, media_type=media_type, identifier=attachment.filename)
59
+
60
+
61
+ def normalize_media_type(value: str, filename: str) -> str:
62
+ kind = value.split(";")[0].strip().lower()
63
+ if kind and kind != "application/octet-stream":
64
+ return kind
65
+ extension = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
66
+ return _EXTENSIONS.get(extension, kind or "application/octet-stream")
@@ -0,0 +1,124 @@
1
+ """``tilde deploy`` discovery for Pydantic AI (entry point ``tilde.discover``).
2
+
3
+ A module-level ``Agent`` registers its instructions and system prompts, named
4
+ ``<agent name or variable>/instructions`` and ``<agent>/system_prompt`` (``/<n>`` from 1 when
5
+ there are several): literal text is plain, ``TemplateStr`` (Handlebars) is mustache and a
6
+ function is dynamic with its source as the template; ``tilde_pydantic_ai`` stamps the dynamic ones
7
+ on the run's model calls. Pydantic AI keeps them in private fields (``_instructions``,
8
+ ``_system_prompts``, ``_system_prompt_functions``, as of 2.43); an agent whose fields cannot be
9
+ read is reported in the inventory.
10
+
11
+ Tools are declared as ``with_tilde_tools`` publishes them: the agent's function tools
12
+ (``tools=``, ``@agent.tool``; the private ``_function_toolset``) and a ``define_tools`` value of
13
+ Pydantic AI ``Tool``s, or of plain functions when the project imports Pydantic AI.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import inspect
19
+ import sys
20
+ from collections.abc import Callable, Mapping
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ from pydantic_ai import Agent, Tool
25
+ from pydantic_ai.messages import InstructionPart
26
+ from pydantic_ai.template import TemplateStr
27
+
28
+ from tilde import BundledOptions, BundledTools
29
+ from tilde.discovery import (
30
+ PROMPT_FORMAT_DYNAMIC,
31
+ PROMPT_FORMAT_MUSTACHE,
32
+ PROMPT_FORMAT_PLAIN,
33
+ Discovered,
34
+ DiscoveryContext,
35
+ declared_prompt,
36
+ declared_tool,
37
+ )
38
+ from tilde.management.v1.deployments_pb2 import DeclaredPrompt, DeclaredTool
39
+ from tilde.prompts import valid_prompt_name
40
+ from tilde_pydantic_ai.tools import describe_tool
41
+
42
+
43
+ def discover(value: Any, context: DiscoveryContext) -> Discovered | None:
44
+ if isinstance(value, BundledTools):
45
+ return _bundled(value, context)
46
+ if not isinstance(value, Agent):
47
+ return None
48
+ file = Path(sys.modules[context.module].__file__ or "")
49
+ origin = f"{context.relative(file)}#{context.name}"
50
+ name = value.name or context.name
51
+ try:
52
+ tools = list(value._function_toolset.tools.values())
53
+ except AttributeError:
54
+ context.warn(f"Pydantic AI agent {name}: tools could not be read ({origin})")
55
+ tools = []
56
+ return Discovered(
57
+ prompts=agent_prompts(value, name, origin, context.warn),
58
+ tools=_declared(tools, None, f"{origin}.tools"),
59
+ )
60
+
61
+
62
+ def _bundled(value: BundledTools, context: DiscoveryContext) -> Discovered | None:
63
+ if not all(isinstance(tool, Tool) or inspect.isroutine(tool) for tool in value.tools):
64
+ return None
65
+ # Plain functions alone could be another framework's.
66
+ ours = {"pydantic_ai", "tilde_pydantic_ai"} & context.imported or any(
67
+ isinstance(tool, Tool) for tool in value.tools
68
+ )
69
+ if not value.tools or not ours:
70
+ return None
71
+ tools = [tool if isinstance(tool, Tool) else Tool(tool) for tool in value.tools]
72
+ return Discovered(tools=_declared(tools, value.options, f"{context.origin()}.tools"))
73
+
74
+
75
+ def _declared(
76
+ tools: list[Tool[Any]], options: Mapping[str, BundledOptions] | None, origin: str
77
+ ) -> list[DeclaredTool]:
78
+ return [declared_tool(describe_tool(tool, options), f"{origin}.{tool.name}") for tool in tools]
79
+
80
+
81
+ def agent_prompts(
82
+ agent: Agent[Any, Any], name: str, origin: str, warn: Callable[[str], None]
83
+ ) -> list[DeclaredPrompt]:
84
+ """The agent's prompts; ``tilde_pydantic_ai`` calls this again at runtime for the stamps."""
85
+ try:
86
+ instructions = [sourced.instruction for sourced in agent._instructions]
87
+ system = [*agent._system_prompts]
88
+ system += [runner.function for runner in agent._system_prompt_functions]
89
+ except AttributeError:
90
+ warn(f"Pydantic AI agent {name}: instructions could not be read ({origin})")
91
+ return []
92
+ if not valid_prompt_name(name):
93
+ warn(f"Pydantic AI agent {name!r} cannot name a prompt; set Agent(name=...) ({origin})")
94
+ return []
95
+ prompts = []
96
+ for kind, values in (("instructions", instructions), ("system_prompt", system)):
97
+ for index, value in enumerate(values, 1):
98
+ key = f"{name}/{kind}" if len(values) == 1 else f"{name}/{kind}/{index}"
99
+ where = f"{origin}.{kind}" if len(values) == 1 else f"{origin}.{kind}[{index - 1}]"
100
+ prompt = _prompt(key, value, where, warn)
101
+ if prompt is not None:
102
+ prompts.append(prompt)
103
+ return prompts
104
+
105
+
106
+ def _prompt(
107
+ name: str, value: Any, origin: str, warn: Callable[[str], None]
108
+ ) -> DeclaredPrompt | None:
109
+ if isinstance(value, str):
110
+ return declared_prompt(name, value, PROMPT_FORMAT_PLAIN, origin) if value else None
111
+ if isinstance(value, InstructionPart):
112
+ return declared_prompt(name, value.content, PROMPT_FORMAT_PLAIN, origin)
113
+ # TemplateStr is callable too; str() is its Handlebars source.
114
+ if isinstance(value, TemplateStr):
115
+ return declared_prompt(name, str(value), PROMPT_FORMAT_MUSTACHE, origin)
116
+ if callable(value):
117
+ try:
118
+ source = inspect.getsource(value)
119
+ except (OSError, TypeError):
120
+ warn(f"{origin}: the source of {name} could not be read; not registered")
121
+ return None
122
+ return declared_prompt(name, source, PROMPT_FORMAT_DYNAMIC, origin)
123
+ warn(f"{origin}: unsupported instruction {type(value).__name__}; not registered")
124
+ return None
@@ -0,0 +1,79 @@
1
+ """The per-invocation pieces of a Pydantic AI run, as ``agent.run`` keyword arguments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from pydantic_ai import CancellationToken, RunContext
9
+ from pydantic_ai.capabilities import AbstractCapability
10
+ from pydantic_ai.toolsets import AbstractToolset, CombinedToolset, FunctionToolset
11
+
12
+ from tilde import AgentContext, BundledTools
13
+ from tilde.discovery import PROMPT_FORMAT_DYNAMIC
14
+ from tilde_pydantic_ai.discover import agent_prompts
15
+ from tilde_pydantic_ai.tools import convert_to_pydantic_ai_tools, with_tilde_tools
16
+
17
+
18
+ @dataclass
19
+ class TildeInvocation(AbstractCapability[Any]):
20
+ """Per-run capability: Tilde tools, the skills summary, steering and dynamic prompt stamps.
21
+
22
+ Steering: after every graph node, newly steered input is enqueued (``RunContext.enqueue``)
23
+ and Pydantic AI delivers it as a user message with the next model request, or redirects a
24
+ run that would otherwise end into one more request.
25
+ """
26
+
27
+ context: AgentContext
28
+ toolset: AbstractToolset[Any]
29
+ skills: str | None = None
30
+
31
+ @classmethod
32
+ def get_serialization_name(cls) -> str | None:
33
+ return None # holds the live invocation
34
+
35
+ def get_toolset(self) -> AbstractToolset[Any]:
36
+ return self.toolset
37
+
38
+ def get_instructions(self) -> str | None:
39
+ return self.skills
40
+
41
+ async def before_run(self, ctx: RunContext[Any]) -> None:
42
+ # `agent.run` has named an unnamed agent after its variable by now, as discovery did.
43
+ agent = ctx.agent
44
+ if agent is None or agent.name is None:
45
+ return
46
+ for prompt in agent_prompts(agent, agent.name, "", lambda _: None):
47
+ if prompt.format == PROMPT_FORMAT_DYNAMIC:
48
+ self.context.activate_prompt(prompt.name, prompt.hash)
49
+
50
+ async def after_node_run(self, ctx: RunContext[Any], *, node: Any, result: Any) -> Any:
51
+ for steered in self.context.take_inputs():
52
+ ctx.enqueue(steered.text)
53
+ return result
54
+
55
+
56
+ async def tilde_pydantic_ai(
57
+ ctx: AgentContext, *, bundled: BundledTools | None = None, skills: bool = True
58
+ ) -> dict[str, Any]:
59
+ """``agent.run(..., **await tilde_pydantic_ai(ctx, bundled=TOOLS))``: the current channel's
60
+ tools, the agent's other Tilde tools, its bundled tools (``bundled``, from ``define_tools``,
61
+ audited and published as ``with_tilde_tools`` does), the invocation's cancellation,
62
+ steering, dynamic prompt stamps and, with ``skills``, the agent's skills (deployed and
63
+ registry-assigned) as ``list_skills``/``read_skill`` tools with a summary in the
64
+ instructions, since Pydantic AI has no native skills.
65
+ """
66
+ toolset = await with_tilde_tools(
67
+ ctx,
68
+ bundled.tools if bundled else [],
69
+ options=bundled.options if bundled else None,
70
+ )
71
+ summary = await ctx.skills.summary() if skills else ""
72
+ if summary:
73
+ toolset = CombinedToolset(
74
+ [toolset, FunctionToolset(convert_to_pydantic_ai_tools(ctx.skills.tools()))]
75
+ )
76
+ token = CancellationToken()
77
+ ctx.cancellation.on_abort(token.cancel)
78
+ capability = TildeInvocation(ctx, toolset, summary or None)
79
+ return {"capabilities": [capability], "cancellation_token": token}
@@ -0,0 +1,215 @@
1
+ """Convert typed Tilde context into Pydantic AI model messages for ``message_history``.
2
+
3
+ User-role items become ``ModelRequest(parts=[UserPromptPart(...)])`` and the acting agent's own
4
+ messages become ``ModelResponse(parts=[TextPart(...)])``. Pydantic AI parts carry no id, so the
5
+ Tilde message id is stored in each message's application-level ``metadata`` (never sent to the
6
+ model) as ``{"tilde_message_id": id}`` and in the cached ``{"id", "role", "text"}`` representation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import inspect
12
+ import json
13
+ from collections.abc import Awaitable, Callable, Iterable
14
+ from dataclasses import dataclass
15
+ from typing import Any, Literal
16
+
17
+ from pydantic_ai.messages import (
18
+ BinaryContent,
19
+ FilePart,
20
+ ModelMessage,
21
+ ModelRequest,
22
+ ModelResponse,
23
+ TextPart,
24
+ UserContent,
25
+ UserPromptPart,
26
+ )
27
+
28
+ from tilde import (
29
+ AgentContext,
30
+ ContextMessage,
31
+ ConversationMessage,
32
+ GoalMessage,
33
+ ObjectiveMessage,
34
+ TaskMessage,
35
+ )
36
+ from tilde_pydantic_ai.attachments import (
37
+ AttachmentConversion,
38
+ AttachmentHandler,
39
+ convert_attachment,
40
+ )
41
+
42
+ MESSAGE_ID_KEY = "tilde_message_id"
43
+ _MAX_BYTES = 1024 * 1024
44
+ _MAX_ITEMS = 100
45
+
46
+ Converted = ModelMessage | None
47
+ Handler = Callable[[Any], Converted | Awaitable[Converted]]
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class MessageHandlers:
52
+ """Explicit handlers take precedence over cached and default rendering; None omits an item."""
53
+
54
+ message: Callable[[ConversationMessage], Converted | Awaitable[Converted]] | None = None
55
+ objective: Callable[[ObjectiveMessage], Converted | Awaitable[Converted]] | None = None
56
+ goal: Callable[[GoalMessage], Converted | Awaitable[Converted]] | None = None
57
+ task: Callable[[TaskMessage], Converted | Awaitable[Converted]] | None = None
58
+
59
+
60
+ def text_message(id: str, role: Literal["user", "assistant"], text: str) -> ModelMessage:
61
+ """A single-text message tagged with its Tilde id, the shape the converter caches."""
62
+ return _build(id, role, [text])
63
+
64
+
65
+ def message_id(message: ModelMessage) -> str | None:
66
+ """The Tilde message id a converted message came from, if it carries one."""
67
+ return (message.metadata or {}).get(MESSAGE_ID_KEY)
68
+
69
+
70
+ async def convert_to_pydantic_ai_messages(
71
+ messages: Iterable[ContextMessage],
72
+ *,
73
+ context: AgentContext | None = None,
74
+ on_message: MessageHandlers | None = None,
75
+ on_attachment: AttachmentHandler | None = None,
76
+ ) -> list[ModelMessage]:
77
+ """Convert typed SDK context into ``ModelMessage`` history for ``Agent.run``."""
78
+ output: list[ModelMessage] = []
79
+ batch: list[tuple[str, Any]] = []
80
+ batch_bytes = 0
81
+
82
+ async def flush() -> None:
83
+ nonlocal batch_bytes
84
+ if batch and context is not None:
85
+ await context.cache_converted_messages(batch[:])
86
+ batch.clear()
87
+ batch_bytes = 0
88
+
89
+ handlers = on_message or MessageHandlers()
90
+ for item in messages:
91
+ if (
92
+ item.type == "message"
93
+ and item.message.status != "complete"
94
+ and handlers.message is None
95
+ ):
96
+ continue
97
+ converted: ModelMessage | None
98
+ cacheable = False
99
+ if item.type == "objective":
100
+ converted = (
101
+ await _call(handlers.objective, item)
102
+ if handlers.objective
103
+ else text_message(item.id, "user", item.objective)
104
+ )
105
+ elif item.type == "goal":
106
+ converted = (
107
+ await _call(handlers.goal, item)
108
+ if handlers.goal
109
+ else text_message(
110
+ item.id, "user", f"Goal ({item.goal.status}): {item.goal.objective}"
111
+ )
112
+ )
113
+ elif item.type == "task":
114
+ if handlers.task:
115
+ converted = await _call(handlers.task, item)
116
+ else:
117
+ text = f"Task ({item.task.status}): {item.task.title}"
118
+ if item.task.blocked_reason:
119
+ text += f"\nBlocked: {item.task.blocked_reason}"
120
+ converted = text_message(item.id, "user", text)
121
+ elif handlers.message:
122
+ converted = await _call(handlers.message, item)
123
+ else:
124
+ # File bytes are hydrated afresh through the scoped SDK, never replayed from the cache.
125
+ converted = _hydrate(item) if not item.message.attachments else None
126
+ if converted is None:
127
+ converted = await _render(item, context, on_attachment)
128
+ cacheable = item.message.status == "complete" and not item.message.attachments
129
+ if converted is None:
130
+ continue
131
+ output.append(converted)
132
+ # Only canonical messages can be cached by the runtime. Work state is always current,
133
+ # and hydrated files must not duplicate large/sensitive attachment bytes in the cache.
134
+ if context is None or not cacheable:
135
+ continue
136
+ entry = {"id": item.id, "role": item.role, "text": _text_of(converted)}
137
+ size = len(json.dumps(entry).encode())
138
+ if size > _MAX_BYTES:
139
+ continue
140
+ if len(batch) == _MAX_ITEMS or batch_bytes + size > _MAX_BYTES:
141
+ await flush()
142
+ batch.append((item.id, entry))
143
+ batch_bytes += size
144
+ await flush()
145
+ return output
146
+
147
+
148
+ async def _call(handler: Handler, item: ContextMessage) -> Converted:
149
+ result = handler(item)
150
+ return await result if inspect.isawaitable(result) else result
151
+
152
+
153
+ async def _render(
154
+ item: ConversationMessage,
155
+ context: AgentContext | None,
156
+ on_attachment: AttachmentHandler | None,
157
+ ) -> ModelMessage | None:
158
+ content: list[UserContent] = []
159
+ if item.message.HasField("subject") and item.message.subject:
160
+ content.append(f"Subject: {item.message.subject}")
161
+ if item.message.text:
162
+ content.append(item.message.text)
163
+ for attachment in item.message.attachments:
164
+
165
+ async def download(attachment=attachment):
166
+ if context is None:
167
+ raise RuntimeError(
168
+ "Attachment conversion requires context or a custom on_attachment handler"
169
+ )
170
+ return await context.attachments.download(attachment.id)
171
+
172
+ result = (on_attachment or convert_attachment)(
173
+ AttachmentConversion(message=item, attachment=attachment, download=download)
174
+ )
175
+ if inspect.isawaitable(result):
176
+ result = await result
177
+ if result is None:
178
+ continue
179
+ content.extend(result if isinstance(result, list) else [result])
180
+ return _build(item.id, item.role, content) if content else None
181
+
182
+
183
+ def _build(id: str, role: Literal["user", "assistant"], content: list[UserContent]) -> ModelMessage:
184
+ metadata = {MESSAGE_ID_KEY: id}
185
+ if role == "assistant":
186
+ parts = [
187
+ FilePart(content=part) if isinstance(part, BinaryContent) else TextPart(str(part))
188
+ for part in content
189
+ ]
190
+ return ModelResponse(parts=parts, metadata=metadata)
191
+ prompt = content[0] if len(content) == 1 and isinstance(content[0], str) else content
192
+ return ModelRequest(parts=[UserPromptPart(prompt)], metadata=metadata)
193
+
194
+
195
+ def _hydrate(item: ConversationMessage) -> ModelMessage | None:
196
+ cached = item.cached_agent_representation
197
+ if (
198
+ not isinstance(cached, dict)
199
+ or cached.get("id") != item.id
200
+ or cached.get("role") != item.role
201
+ or not isinstance(cached.get("text"), str)
202
+ ):
203
+ return None
204
+ return text_message(item.id, item.role, cached["text"])
205
+
206
+
207
+ def _text_of(message: ModelMessage) -> str:
208
+ texts: list[str] = []
209
+ for part in message.parts:
210
+ if isinstance(part, TextPart):
211
+ texts.append(part.content)
212
+ elif isinstance(part, UserPromptPart):
213
+ content = [part.content] if isinstance(part.content, str) else part.content
214
+ texts.extend(piece for piece in content if isinstance(piece, str))
215
+ return "\n".join(texts)
@@ -0,0 +1,156 @@
1
+ """Expose Tilde channel tools to Pydantic AI without altering provider schemas or authorization,
2
+ and publish the agent's own Pydantic AI tools to Tilde as bundled tools."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import inspect
7
+ import uuid
8
+ from collections.abc import Callable, Mapping, Sequence
9
+ from contextvars import ContextVar
10
+ from dataclasses import dataclass, replace
11
+ from typing import Any
12
+
13
+ from pydantic_ai import RunContext
14
+ from pydantic_ai.tools import Tool
15
+ from pydantic_ai.toolsets import AbstractToolset, FunctionToolset, WrapperToolset
16
+ from pydantic_ai.toolsets.abstract import ToolsetTool
17
+
18
+ from tilde import AgentContext, BundledOptions, ChannelTool
19
+ from tilde import Tool as TildeTool
20
+ from tilde._tools import ToolSpec, bundled_tool, model_tool_name, tool_spec
21
+
22
+ # The RunContext of the Tilde tool being executed; a routed tools.execute runs within it.
23
+ _RUN_CONTEXT: ContextVar[RunContext[Any]] = ContextVar("tilde_pydantic_ai_run_context")
24
+
25
+
26
+ def convert_to_pydantic_ai_tools(
27
+ channels: Mapping[str, ChannelTool | TildeTool],
28
+ *,
29
+ instructions: Mapping[str, str] | None = None,
30
+ prefix: str = "",
31
+ ) -> list[Tool[Any]]:
32
+ """Preserve provider schemas and forward the model's tool-call id to Tilde's audited execution.
33
+
34
+ ``instructions`` overrides provider descriptions by channel tool name; ``prefix`` namespaces
35
+ model tool names when combining several channel collections. Pass the result as ``tools=``
36
+ to ``Agent``.
37
+ """
38
+ result: list[Tool[Any]] = []
39
+ seen: set[str] = set()
40
+ for name, channel in channels.items():
41
+ key = model_tool_name(f"{prefix}{name}")
42
+ if not key or key in seen:
43
+ raise ValueError("Conflicting or invalid model tool names")
44
+ seen.add(key)
45
+ description = (
46
+ instructions[name] if instructions and name in instructions else channel.description
47
+ )
48
+ result.append(
49
+ Tool.from_schema(
50
+ _execute(channel),
51
+ name=key,
52
+ description=description,
53
+ json_schema=channel.input_schema,
54
+ takes_ctx=True,
55
+ )
56
+ )
57
+ return result
58
+
59
+
60
+ def _execute(channel: ChannelTool | TildeTool):
61
+ # Pydantic AI sets RunContext.tool_call_id to the model's call id before invoking a tool
62
+ # function that takes the context; the schema's fields arrive as keyword arguments.
63
+ async def execute(ctx: RunContext[Any], /, **arguments: Any) -> Any:
64
+ token = _RUN_CONTEXT.set(ctx)
65
+ try:
66
+ return await channel.execute(arguments, tool_call_id=ctx.tool_call_id)
67
+ finally:
68
+ _RUN_CONTEXT.reset(token)
69
+
70
+ return execute
71
+
72
+
73
+ async def with_tilde_tools(
74
+ ctx: AgentContext,
75
+ native_tools: Sequence[Tool[Any] | Callable[..., Any]],
76
+ *,
77
+ options: Mapping[str, BundledOptions] | None = None,
78
+ ) -> AbstractToolset[Any]:
79
+ """One toolset with the current channel's tools, the agent's other Tilde tools and its own
80
+ tools, for ``Agent(toolsets=[...])``.
81
+
82
+ ``native_tools`` are ``Tool`` instances or plain functions (``takes_ctx`` inferred). Each is
83
+ published to Tilde with its name, description, parameter and return schemas, so
84
+ ``tools.search`` finds it and a ``tools.execute`` naming it runs it here; every call is
85
+ audited once with the model's call id. Tilde metadata comes from
86
+ ``Tool(..., metadata={"tilde": BundledOptions(...)})`` or ``options[name]``, which wins.
87
+ Call it once per invocation.
88
+
89
+ Caveats: tools registered with ``@agent.tool`` are outside this toolset and are neither
90
+ published nor audited; a ``ModelRetry`` is audited as a failed call; a tool's ``prepare``
91
+ function does not change what is published.
92
+ """
93
+ tools = [tool if isinstance(tool, Tool) else Tool(tool) for tool in native_tools]
94
+ channel_tools = convert_to_pydantic_ai_tools({**ctx.channel.current, **ctx.agent_tools})
95
+ toolset = _AuditedToolset(
96
+ FunctionToolset([*channel_tools, *tools]),
97
+ tilde=ctx,
98
+ bundled=frozenset(tool.name for tool in tools),
99
+ )
100
+ definitions = {
101
+ tool.name: bundled_tool(describe_tool(tool, options), _route(toolset, tool.name))
102
+ for tool in tools
103
+ }
104
+ # Published even when empty, so tools removed since the last call stop running here.
105
+ await ctx._set_bundled_tools(definitions)
106
+ return toolset
107
+
108
+
109
+ def describe_tool(tool: Tool[Any], options: Mapping[str, BundledOptions] | None = None) -> ToolSpec:
110
+ """What ``with_tilde_tools`` publishes and ``tilde deploy`` declares for one tool."""
111
+ definition = tool.tool_def
112
+ declared = (definition.metadata or {}).get("tilde")
113
+ return tool_spec(
114
+ tool.name,
115
+ definition.description or "",
116
+ definition.parameters_json_schema,
117
+ output_schema=definition.return_schema,
118
+ options=(options or {}).get(tool.name, declared),
119
+ )
120
+
121
+
122
+ @dataclass
123
+ class _AuditedToolset(WrapperToolset[Any]):
124
+ tilde: AgentContext
125
+ bundled: frozenset[str]
126
+
127
+ async def call_tool(
128
+ self, name: str, tool_args: dict[str, Any], ctx: RunContext[Any], tool: ToolsetTool[Any]
129
+ ) -> Any:
130
+ if name not in self.bundled:
131
+ return await super().call_tool(name, tool_args, ctx, tool)
132
+ return await self.tilde._run_audited(
133
+ name,
134
+ ctx.tool_call_id or str(uuid.uuid4()),
135
+ tool_args,
136
+ lambda: super(_AuditedToolset, self).call_tool(name, tool_args, ctx, tool),
137
+ )
138
+
139
+
140
+ def _route(toolset: _AuditedToolset, name: str):
141
+ # A tools.execute naming this tool: validate and call it as Pydantic AI would, within the
142
+ # tools.execute call's RunContext, so the audited call_tool above reports it once.
143
+ async def execute(input: Any, call: str) -> Any:
144
+ run = _RUN_CONTEXT.get(None)
145
+ if run is None:
146
+ raise RuntimeError(f"{name} runs through tools.execute only inside a Pydantic AI run")
147
+ step = replace(run, tool_name=name, tool_call_id=call)
148
+ tool = (await toolset.get_tools(step))[name]
149
+ args = tool.args_validator.validate_python(input)
150
+ if tool.args_validator_func is not None:
151
+ checked = tool.args_validator_func(step, **args)
152
+ if inspect.isawaitable(checked):
153
+ await checked
154
+ return await toolset.call_tool(name, args, step, tool)
155
+
156
+ return execute