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.
- trytilde_pydantic_ai-3.0.1/.gitignore +51 -0
- trytilde_pydantic_ai-3.0.1/PKG-INFO +139 -0
- trytilde_pydantic_ai-3.0.1/README.md +129 -0
- trytilde_pydantic_ai-3.0.1/pyproject.toml +22 -0
- trytilde_pydantic_ai-3.0.1/src/tilde_pydantic_ai/__init__.py +34 -0
- trytilde_pydantic_ai-3.0.1/src/tilde_pydantic_ai/attachments.py +66 -0
- trytilde_pydantic_ai-3.0.1/src/tilde_pydantic_ai/discover.py +124 -0
- trytilde_pydantic_ai-3.0.1/src/tilde_pydantic_ai/invocation.py +79 -0
- trytilde_pydantic_ai-3.0.1/src/tilde_pydantic_ai/messages.py +215 -0
- trytilde_pydantic_ai-3.0.1/src/tilde_pydantic_ai/tools.py +156 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/target/
|
|
2
|
+
/dist/
|
|
3
|
+
node_modules/
|
|
4
|
+
# Build output of an older example-agent layout; the examples live under sdk/ts/examples.
|
|
5
|
+
/dev/example-agent-1/
|
|
6
|
+
/web/dist/
|
|
7
|
+
/.tools/
|
|
8
|
+
/.env.*
|
|
9
|
+
!/.env.example
|
|
10
|
+
!/.env.test
|
|
11
|
+
/.run/
|
|
12
|
+
.run/
|
|
13
|
+
|
|
14
|
+
/sdk/ts/**/dist/
|
|
15
|
+
# Generated TypeScript contracts; @trytilde/contracts builds them from proto/.
|
|
16
|
+
/sdk/ts/packages/contracts/gen/
|
|
17
|
+
|
|
18
|
+
/web/provider-dist/
|
|
19
|
+
|
|
20
|
+
__pycache__/
|
|
21
|
+
|
|
22
|
+
# Local runtime credentials must not enter the initial repository commit.
|
|
23
|
+
/.env
|
|
24
|
+
log
|
|
25
|
+
|
|
26
|
+
/.local/
|
|
27
|
+
|
|
28
|
+
# Bifrost benchmark container state
|
|
29
|
+
/dev/inference-bench/bifrost/*
|
|
30
|
+
!/dev/inference-bench/bifrost/config.json
|
|
31
|
+
|
|
32
|
+
# Local Claude Code worktrees
|
|
33
|
+
/.claude/worktrees/
|
|
34
|
+
|
|
35
|
+
# Python SDK: uv environments and generated contracts (sdk/py/scripts/generate.py).
|
|
36
|
+
/sdk/py/.venv/
|
|
37
|
+
/sdk/py/.venv-crewai/
|
|
38
|
+
/sdk/py/**/.venv/
|
|
39
|
+
/sdk/py/packages/tilde/src/tilde/agent_event_ingress/
|
|
40
|
+
/sdk/py/packages/tilde/src/tilde/agent_host/
|
|
41
|
+
/sdk/py/packages/tilde/src/tilde/ingress/
|
|
42
|
+
/sdk/py/packages/tilde/src/tilde/management/
|
|
43
|
+
/sdk/py/packages/tilde/src/tilde/provider/
|
|
44
|
+
/sdk/py/packages/tilde/src/tilde/run/
|
|
45
|
+
/sdk/py/packages/tilde/src/tilde/runtime/
|
|
46
|
+
/sdk/py/packages/tilde/src/tilde/setup/
|
|
47
|
+
/sdk/py/packages/tilde/src/tilde/tool_host/
|
|
48
|
+
/sdk/py/packages/tilde/src/tilde/types/
|
|
49
|
+
/sdk/py/**/*.egg-info/
|
|
50
|
+
.pytest_cache/
|
|
51
|
+
.ruff_cache/
|
|
@@ -0,0 +1,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
|