trytilde-langchain 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_langchain-3.0.1/.gitignore +51 -0
- trytilde_langchain-3.0.1/PKG-INFO +175 -0
- trytilde_langchain-3.0.1/README.md +163 -0
- trytilde_langchain-3.0.1/pyproject.toml +22 -0
- trytilde_langchain-3.0.1/src/tilde_langchain/__init__.py +33 -0
- trytilde_langchain-3.0.1/src/tilde_langchain/attachments.py +83 -0
- trytilde_langchain-3.0.1/src/tilde_langchain/discover.py +253 -0
- trytilde_langchain-3.0.1/src/tilde_langchain/messages.py +201 -0
- trytilde_langchain-3.0.1/src/tilde_langchain/middleware.py +128 -0
- trytilde_langchain-3.0.1/src/tilde_langchain/tools.py +181 -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,175 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: trytilde-langchain
|
|
3
|
+
Version: 3.0.1
|
|
4
|
+
Summary: Tilde adapter for LangChain and LangGraph: typed context to LangChain messages and channel tools
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: langchain-core<2,>=1
|
|
8
|
+
Requires-Dist: langchain<2,>=1
|
|
9
|
+
Requires-Dist: langgraph<2,>=1
|
|
10
|
+
Requires-Dist: trytilde
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# LangChain / LangGraph adapter
|
|
14
|
+
|
|
15
|
+
Core history and delivery live in `trytilde`. This package converts typed
|
|
16
|
+
context to LangChain messages and channel tools to LangChain tools, brings each
|
|
17
|
+
invocation to a module-level `create_agent` graph through `tilde_middleware()`,
|
|
18
|
+
and lets `tilde deploy` discover the agent's prompts.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import tilde
|
|
22
|
+
from langchain.agents import create_agent
|
|
23
|
+
from langchain_openai import ChatOpenAI
|
|
24
|
+
from tilde_langchain import convert_to_langchain_messages, tilde_middleware
|
|
25
|
+
|
|
26
|
+
INFERENCE = tilde.inference("default")
|
|
27
|
+
agent = create_agent(
|
|
28
|
+
ChatOpenAI(
|
|
29
|
+
model="gpt-4o-mini",
|
|
30
|
+
base_url=INFERENCE.base_url,
|
|
31
|
+
api_key=INFERENCE.api_key,
|
|
32
|
+
http_async_client=INFERENCE.async_client(),
|
|
33
|
+
),
|
|
34
|
+
system_prompt="Respond using the current channel's tools. Returned model text is private.",
|
|
35
|
+
middleware=[tilde_middleware()],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def run(ctx):
|
|
40
|
+
history = await ctx.message.history()
|
|
41
|
+
messages = await convert_to_langchain_messages(history.items, context=ctx)
|
|
42
|
+
await agent.ainvoke({"messages": messages}, {"recursion_limit": 16})
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A compiled graph takes no per-call tools or messages (`ainvoke`'s config carries
|
|
46
|
+
callbacks and `context`, and callbacks cannot change state), so the invocation's
|
|
47
|
+
pieces come from middleware the graph is created with once. `tilde_middleware()`
|
|
48
|
+
reads the running invocation from Tilde's context variable and does nothing
|
|
49
|
+
outside one:
|
|
50
|
+
|
|
51
|
+
- before each model call it checks cancellation and adds steering input as user
|
|
52
|
+
messages to the graph state, so later calls keep them;
|
|
53
|
+
- around each model call it adds the current channel's tools and, when skills are
|
|
54
|
+
assigned (plain `create_agent` has no native skills), the `list_skills` /
|
|
55
|
+
`read_skill` tools plus `ctx.skills.summary()` as a second system message;
|
|
56
|
+
it also stamps the invocation's inference calls with every `@dynamic_prompt`
|
|
57
|
+
listed after it, so put it first;
|
|
58
|
+
- around tool calls it executes those tools. They are not registered with the
|
|
59
|
+
graph's `ToolNode`; `create_agent` allows that when middleware executes them
|
|
60
|
+
in `wrap_tool_call`.
|
|
61
|
+
|
|
62
|
+
The hooks are async only; use `ainvoke` / `astream`.
|
|
63
|
+
|
|
64
|
+
## Deploy discovery
|
|
65
|
+
|
|
66
|
+
`tilde deploy` (entry point `tilde.discover`) registers:
|
|
67
|
+
|
|
68
|
+
- a module-level `create_agent` graph's static `system_prompt` as the plain prompt
|
|
69
|
+
`<name>/system_prompt` (`create_agent(name=...)`, else the variable name), read
|
|
70
|
+
from the model node's closure (langchain 1.4; a warning when unreadable), and every
|
|
71
|
+
`@dynamic_prompt` middleware in the graph;
|
|
72
|
+
- a `@dynamic_prompt` middleware as the dynamic prompt `<function>/system_prompt`
|
|
73
|
+
with the function's source;
|
|
74
|
+
- module-level `PromptTemplate`s as `<variable>` and `ChatPromptTemplate` messages as
|
|
75
|
+
`<variable>/<index>.<role>`: f-string templates in braces format, mustache in
|
|
76
|
+
mustache format. Jinja2 templates and LangSmith hub prompts are reported, not
|
|
77
|
+
registered.
|
|
78
|
+
|
|
79
|
+
History pages are chronological; pass `before_message_id=history.next_page_token`
|
|
80
|
+
for older messages. The latest page includes the current objective unless it
|
|
81
|
+
already matches the latest received message. `include_objective=False` omits it.
|
|
82
|
+
`include_work=True` also reads current goals/tasks and requires `work.read`.
|
|
83
|
+
Only the acting agent's messages become `AIMessage`; everything else is a
|
|
84
|
+
`HumanMessage`. Every converted message carries the Tilde message id.
|
|
85
|
+
|
|
86
|
+
Images and PDFs are downloaded through `ctx.attachments.download` and embedded as
|
|
87
|
+
standard `image` / `file` content blocks (base64 with `mime_type`). Text files
|
|
88
|
+
include their real content. Unsupported binary formats get an explicit
|
|
89
|
+
attachment description; use `on_attachment` to parse them yourself. No private
|
|
90
|
+
URL or credential needs to be exposed to the model.
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
from langchain_core.messages import HumanMessage
|
|
94
|
+
from tilde_langchain import MessageHandlers
|
|
95
|
+
|
|
96
|
+
history = await ctx.message.history(include_work=True)
|
|
97
|
+
messages = await convert_to_langchain_messages(
|
|
98
|
+
history.items,
|
|
99
|
+
context=ctx,
|
|
100
|
+
on_message=MessageHandlers(
|
|
101
|
+
goal=lambda item: HumanMessage(id=item.id, content=f"Our goal: {item.goal.objective}"),
|
|
102
|
+
task=lambda item: None, # Omit this type, or provide a different rendering.
|
|
103
|
+
),
|
|
104
|
+
on_attachment=decode_attachment, # async def decode_attachment(conversion) -> block | None
|
|
105
|
+
)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`MessageHandlers` supports `message`, `objective`, `goal` and `task`; each may
|
|
109
|
+
be sync or async. Supplied handlers take precedence over cached/default
|
|
110
|
+
rendering; returning `None` omits an item. Without an override the converter
|
|
111
|
+
renders all supported types and skips conversation messages whose status is
|
|
112
|
+
not `complete`. Completed, attachment-free conversions use the existing
|
|
113
|
+
per-agent cache in bounded batches (100 items or 1 MiB) as plain
|
|
114
|
+
`{"id", "role", "content"}` JSON. Files are hydrated afresh and are never
|
|
115
|
+
stored in the cache; objectives/goals/tasks remain live projections rather
|
|
116
|
+
than cached chat records. A cached representation is only reused when its id
|
|
117
|
+
and role match and it contains no `image`/`file` block.
|
|
118
|
+
|
|
119
|
+
## Channel tools
|
|
120
|
+
|
|
121
|
+
`tilde_middleware()` converts the channel's tools itself; convert them yourself for
|
|
122
|
+
graphs built per invocation or `create_react_agent`. Tools are async only and run on
|
|
123
|
+
the agent's event loop, so cancelling the invocation cancels in-flight channel calls.
|
|
124
|
+
|
|
125
|
+
`convert_to_langchain_tools(ctx.channel.current)` preserves the provider's
|
|
126
|
+
descriptions and JSON schemas (passed to LangChain as a JSON-schema
|
|
127
|
+
`args_schema`) and forwards the model's tool-call id to Tilde's audited tool
|
|
128
|
+
execution. It does not publish the model's final text. The agent chooses the
|
|
129
|
+
provider tool and arguments, including routing fields required by that provider.
|
|
130
|
+
|
|
131
|
+
The tool-call id comes from the `ToolCall` dict LangGraph's `ToolNode` (and so
|
|
132
|
+
`create_agent`) passes to `tool.ainvoke`. Invoking a tool directly with plain
|
|
133
|
+
arguments has no call id; a random UUID is generated so the call is still
|
|
134
|
+
audited, but repeated executions are not deduplicated in that case.
|
|
135
|
+
|
|
136
|
+
Override tool instructions with `instructions={"sendMessage": "..."}`, or change
|
|
137
|
+
the channel tool's `description` before conversion. When combining namespaces,
|
|
138
|
+
use `prefix="slack_"` (or another prefix) to keep model tool names distinct.
|
|
139
|
+
Names are sanitized to `[a-zA-Z0-9_-]`, and names over 64 characters are truncated with a
|
|
140
|
+
stable hash suffix; collisions raise.
|
|
141
|
+
|
|
142
|
+
The core SDK exposes typed callable tools on `ctx.channel.slack`, `github`,
|
|
143
|
+
`agentmail`, `linq`, `whatsapp`, `telnyx_whatsapp` and `native`, plus
|
|
144
|
+
`ctx.channel.connections()` / `ctx.channel.for_connection(id)` when several
|
|
145
|
+
connections use one provider. `ctx.channel.current` never falls back to a
|
|
146
|
+
different connection when the inbound connection has no available tools.
|
|
147
|
+
|
|
148
|
+
## Bundled tools
|
|
149
|
+
|
|
150
|
+
The agent's own LangChain tools join Tilde's with one call:
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
from langchain_core.tools import tool
|
|
154
|
+
from tilde import BundledOptions
|
|
155
|
+
from tilde_langchain import with_tilde_tools
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@tool
|
|
159
|
+
def roll_dice(count: int = 1) -> list[int]:
|
|
160
|
+
"""Roll six-sided dice."""
|
|
161
|
+
return [random.randint(1, 6) for _ in range(count)]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
roll_dice.metadata = {"tilde": BundledOptions(summary="Rolled dice")}
|
|
165
|
+
agent = create_agent(model, tools=await with_tilde_tools(ctx, [roll_dice]))
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`with_tilde_tools` returns the current channel's tools, `ctx.agent_tools` and copies of the
|
|
169
|
+
native tools, and publishes the native tools to Tilde with the argument schema the model sees,
|
|
170
|
+
so `tools.search` finds them and a `tools.execute` naming one runs it here. Each copy keeps its
|
|
171
|
+
type and gets an audit callback handler, so `ToolNode` injection keeps working and every call
|
|
172
|
+
is audited once with the model's call id. `options={"roll_dice": BundledOptions(...)}` overrides
|
|
173
|
+
`metadata["tilde"]`. LangChain has no output schema and audits record the tool message content;
|
|
174
|
+
a `ToolException` handled for the model is audited as failed. Tools that need `ToolRuntime` or
|
|
175
|
+
`InjectedState` only get them from `ToolNode`, so they cannot run through `tools.execute`.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# LangChain / LangGraph adapter
|
|
2
|
+
|
|
3
|
+
Core history and delivery live in `trytilde`. This package converts typed
|
|
4
|
+
context to LangChain messages and channel tools to LangChain tools, brings each
|
|
5
|
+
invocation to a module-level `create_agent` graph through `tilde_middleware()`,
|
|
6
|
+
and lets `tilde deploy` discover the agent's prompts.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
import tilde
|
|
10
|
+
from langchain.agents import create_agent
|
|
11
|
+
from langchain_openai import ChatOpenAI
|
|
12
|
+
from tilde_langchain import convert_to_langchain_messages, tilde_middleware
|
|
13
|
+
|
|
14
|
+
INFERENCE = tilde.inference("default")
|
|
15
|
+
agent = create_agent(
|
|
16
|
+
ChatOpenAI(
|
|
17
|
+
model="gpt-4o-mini",
|
|
18
|
+
base_url=INFERENCE.base_url,
|
|
19
|
+
api_key=INFERENCE.api_key,
|
|
20
|
+
http_async_client=INFERENCE.async_client(),
|
|
21
|
+
),
|
|
22
|
+
system_prompt="Respond using the current channel's tools. Returned model text is private.",
|
|
23
|
+
middleware=[tilde_middleware()],
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def run(ctx):
|
|
28
|
+
history = await ctx.message.history()
|
|
29
|
+
messages = await convert_to_langchain_messages(history.items, context=ctx)
|
|
30
|
+
await agent.ainvoke({"messages": messages}, {"recursion_limit": 16})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A compiled graph takes no per-call tools or messages (`ainvoke`'s config carries
|
|
34
|
+
callbacks and `context`, and callbacks cannot change state), so the invocation's
|
|
35
|
+
pieces come from middleware the graph is created with once. `tilde_middleware()`
|
|
36
|
+
reads the running invocation from Tilde's context variable and does nothing
|
|
37
|
+
outside one:
|
|
38
|
+
|
|
39
|
+
- before each model call it checks cancellation and adds steering input as user
|
|
40
|
+
messages to the graph state, so later calls keep them;
|
|
41
|
+
- around each model call it adds the current channel's tools and, when skills are
|
|
42
|
+
assigned (plain `create_agent` has no native skills), the `list_skills` /
|
|
43
|
+
`read_skill` tools plus `ctx.skills.summary()` as a second system message;
|
|
44
|
+
it also stamps the invocation's inference calls with every `@dynamic_prompt`
|
|
45
|
+
listed after it, so put it first;
|
|
46
|
+
- around tool calls it executes those tools. They are not registered with the
|
|
47
|
+
graph's `ToolNode`; `create_agent` allows that when middleware executes them
|
|
48
|
+
in `wrap_tool_call`.
|
|
49
|
+
|
|
50
|
+
The hooks are async only; use `ainvoke` / `astream`.
|
|
51
|
+
|
|
52
|
+
## Deploy discovery
|
|
53
|
+
|
|
54
|
+
`tilde deploy` (entry point `tilde.discover`) registers:
|
|
55
|
+
|
|
56
|
+
- a module-level `create_agent` graph's static `system_prompt` as the plain prompt
|
|
57
|
+
`<name>/system_prompt` (`create_agent(name=...)`, else the variable name), read
|
|
58
|
+
from the model node's closure (langchain 1.4; a warning when unreadable), and every
|
|
59
|
+
`@dynamic_prompt` middleware in the graph;
|
|
60
|
+
- a `@dynamic_prompt` middleware as the dynamic prompt `<function>/system_prompt`
|
|
61
|
+
with the function's source;
|
|
62
|
+
- module-level `PromptTemplate`s as `<variable>` and `ChatPromptTemplate` messages as
|
|
63
|
+
`<variable>/<index>.<role>`: f-string templates in braces format, mustache in
|
|
64
|
+
mustache format. Jinja2 templates and LangSmith hub prompts are reported, not
|
|
65
|
+
registered.
|
|
66
|
+
|
|
67
|
+
History pages are chronological; pass `before_message_id=history.next_page_token`
|
|
68
|
+
for older messages. The latest page includes the current objective unless it
|
|
69
|
+
already matches the latest received message. `include_objective=False` omits it.
|
|
70
|
+
`include_work=True` also reads current goals/tasks and requires `work.read`.
|
|
71
|
+
Only the acting agent's messages become `AIMessage`; everything else is a
|
|
72
|
+
`HumanMessage`. Every converted message carries the Tilde message id.
|
|
73
|
+
|
|
74
|
+
Images and PDFs are downloaded through `ctx.attachments.download` and embedded as
|
|
75
|
+
standard `image` / `file` content blocks (base64 with `mime_type`). Text files
|
|
76
|
+
include their real content. Unsupported binary formats get an explicit
|
|
77
|
+
attachment description; use `on_attachment` to parse them yourself. No private
|
|
78
|
+
URL or credential needs to be exposed to the model.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from langchain_core.messages import HumanMessage
|
|
82
|
+
from tilde_langchain import MessageHandlers
|
|
83
|
+
|
|
84
|
+
history = await ctx.message.history(include_work=True)
|
|
85
|
+
messages = await convert_to_langchain_messages(
|
|
86
|
+
history.items,
|
|
87
|
+
context=ctx,
|
|
88
|
+
on_message=MessageHandlers(
|
|
89
|
+
goal=lambda item: HumanMessage(id=item.id, content=f"Our goal: {item.goal.objective}"),
|
|
90
|
+
task=lambda item: None, # Omit this type, or provide a different rendering.
|
|
91
|
+
),
|
|
92
|
+
on_attachment=decode_attachment, # async def decode_attachment(conversion) -> block | None
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`MessageHandlers` supports `message`, `objective`, `goal` and `task`; each may
|
|
97
|
+
be sync or async. Supplied handlers take precedence over cached/default
|
|
98
|
+
rendering; returning `None` omits an item. Without an override the converter
|
|
99
|
+
renders all supported types and skips conversation messages whose status is
|
|
100
|
+
not `complete`. Completed, attachment-free conversions use the existing
|
|
101
|
+
per-agent cache in bounded batches (100 items or 1 MiB) as plain
|
|
102
|
+
`{"id", "role", "content"}` JSON. Files are hydrated afresh and are never
|
|
103
|
+
stored in the cache; objectives/goals/tasks remain live projections rather
|
|
104
|
+
than cached chat records. A cached representation is only reused when its id
|
|
105
|
+
and role match and it contains no `image`/`file` block.
|
|
106
|
+
|
|
107
|
+
## Channel tools
|
|
108
|
+
|
|
109
|
+
`tilde_middleware()` converts the channel's tools itself; convert them yourself for
|
|
110
|
+
graphs built per invocation or `create_react_agent`. Tools are async only and run on
|
|
111
|
+
the agent's event loop, so cancelling the invocation cancels in-flight channel calls.
|
|
112
|
+
|
|
113
|
+
`convert_to_langchain_tools(ctx.channel.current)` preserves the provider's
|
|
114
|
+
descriptions and JSON schemas (passed to LangChain as a JSON-schema
|
|
115
|
+
`args_schema`) and forwards the model's tool-call id to Tilde's audited tool
|
|
116
|
+
execution. It does not publish the model's final text. The agent chooses the
|
|
117
|
+
provider tool and arguments, including routing fields required by that provider.
|
|
118
|
+
|
|
119
|
+
The tool-call id comes from the `ToolCall` dict LangGraph's `ToolNode` (and so
|
|
120
|
+
`create_agent`) passes to `tool.ainvoke`. Invoking a tool directly with plain
|
|
121
|
+
arguments has no call id; a random UUID is generated so the call is still
|
|
122
|
+
audited, but repeated executions are not deduplicated in that case.
|
|
123
|
+
|
|
124
|
+
Override tool instructions with `instructions={"sendMessage": "..."}`, or change
|
|
125
|
+
the channel tool's `description` before conversion. When combining namespaces,
|
|
126
|
+
use `prefix="slack_"` (or another prefix) to keep model tool names distinct.
|
|
127
|
+
Names are sanitized to `[a-zA-Z0-9_-]`, and names over 64 characters are truncated with a
|
|
128
|
+
stable hash suffix; collisions raise.
|
|
129
|
+
|
|
130
|
+
The core SDK exposes typed callable tools on `ctx.channel.slack`, `github`,
|
|
131
|
+
`agentmail`, `linq`, `whatsapp`, `telnyx_whatsapp` and `native`, plus
|
|
132
|
+
`ctx.channel.connections()` / `ctx.channel.for_connection(id)` when several
|
|
133
|
+
connections use one provider. `ctx.channel.current` never falls back to a
|
|
134
|
+
different connection when the inbound connection has no available tools.
|
|
135
|
+
|
|
136
|
+
## Bundled tools
|
|
137
|
+
|
|
138
|
+
The agent's own LangChain tools join Tilde's with one call:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
from langchain_core.tools import tool
|
|
142
|
+
from tilde import BundledOptions
|
|
143
|
+
from tilde_langchain import with_tilde_tools
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@tool
|
|
147
|
+
def roll_dice(count: int = 1) -> list[int]:
|
|
148
|
+
"""Roll six-sided dice."""
|
|
149
|
+
return [random.randint(1, 6) for _ in range(count)]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
roll_dice.metadata = {"tilde": BundledOptions(summary="Rolled dice")}
|
|
153
|
+
agent = create_agent(model, tools=await with_tilde_tools(ctx, [roll_dice]))
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`with_tilde_tools` returns the current channel's tools, `ctx.agent_tools` and copies of the
|
|
157
|
+
native tools, and publishes the native tools to Tilde with the argument schema the model sees,
|
|
158
|
+
so `tools.search` finds them and a `tools.execute` naming one runs it here. Each copy keeps its
|
|
159
|
+
type and gets an audit callback handler, so `ToolNode` injection keeps working and every call
|
|
160
|
+
is audited once with the model's call id. `options={"roll_dice": BundledOptions(...)}` overrides
|
|
161
|
+
`metadata["tilde"]`. LangChain has no output schema and audits record the tool message content;
|
|
162
|
+
a `ToolException` handled for the model is audited as failed. Tools that need `ToolRuntime` or
|
|
163
|
+
`InjectedState` only get them from `ToolNode`, so they cannot run through `tools.execute`.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "trytilde-langchain"
|
|
3
|
+
version = "3.0.1"
|
|
4
|
+
description = "Tilde adapter for LangChain and LangGraph: typed context to LangChain messages and channel tools"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "Apache-2.0"
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
dependencies = ["trytilde", "langchain-core>=1,<2", "langchain>=1,<2", "langgraph>=1,<2"]
|
|
9
|
+
|
|
10
|
+
# `tilde deploy` reads create_agent graphs, dynamic prompts and prompt templates through this.
|
|
11
|
+
[project.entry-points."tilde.discover"]
|
|
12
|
+
langchain = "tilde_langchain.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_langchain"]
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.sdist]
|
|
22
|
+
include = ["src/tilde_langchain", "README.md"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Tilde adapter for LangChain / LangGraph.
|
|
2
|
+
|
|
3
|
+
Core history and delivery live in ``tilde``; this package converts typed context to LangChain
|
|
4
|
+
messages, exposes channel tools as LangChain ``BaseTool`` instances, brings the invocation to a
|
|
5
|
+
module-level ``create_agent`` graph through ``tilde_middleware()`` and lets ``tilde deploy``
|
|
6
|
+
discover its prompts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from tilde_langchain.attachments import (
|
|
10
|
+
AttachmentConversion,
|
|
11
|
+
AttachmentHandler,
|
|
12
|
+
ContentBlock,
|
|
13
|
+
convert_attachment,
|
|
14
|
+
)
|
|
15
|
+
from tilde_langchain.discover import discover
|
|
16
|
+
from tilde_langchain.messages import MessageHandlers, convert_to_langchain_messages
|
|
17
|
+
from tilde_langchain.middleware import TildeMiddleware, tilde_middleware
|
|
18
|
+
from tilde_langchain.tools import ChannelToolAdapter, convert_to_langchain_tools, with_tilde_tools
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AttachmentConversion",
|
|
22
|
+
"AttachmentHandler",
|
|
23
|
+
"ChannelToolAdapter",
|
|
24
|
+
"ContentBlock",
|
|
25
|
+
"MessageHandlers",
|
|
26
|
+
"TildeMiddleware",
|
|
27
|
+
"convert_attachment",
|
|
28
|
+
"convert_to_langchain_messages",
|
|
29
|
+
"convert_to_langchain_tools",
|
|
30
|
+
"discover",
|
|
31
|
+
"tilde_middleware",
|
|
32
|
+
"with_tilde_tools",
|
|
33
|
+
]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Attachment hydration into LangChain standard content blocks (langchain-core 1.x)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from tilde import ConversationMessage, DownloadedAttachment
|
|
11
|
+
from tilde.types.v1.chat_pb2 import Attachment
|
|
12
|
+
|
|
13
|
+
ContentBlock = dict[str, Any]
|
|
14
|
+
"""A ``langchain_core.messages.content`` block: ``{"type": "text", ...}``, ``image``, ``file``."""
|
|
15
|
+
|
|
16
|
+
_TEXT_TYPES = {"application/json", "application/xml"}
|
|
17
|
+
_FILE_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf"}
|
|
18
|
+
_EXTENSIONS = {
|
|
19
|
+
"jpg": "image/jpeg",
|
|
20
|
+
"jpeg": "image/jpeg",
|
|
21
|
+
"png": "image/png",
|
|
22
|
+
"gif": "image/gif",
|
|
23
|
+
"webp": "image/webp",
|
|
24
|
+
"pdf": "application/pdf",
|
|
25
|
+
"txt": "text/plain",
|
|
26
|
+
"md": "text/plain",
|
|
27
|
+
"csv": "text/plain",
|
|
28
|
+
"log": "text/plain",
|
|
29
|
+
"json": "application/json",
|
|
30
|
+
"xml": "application/xml",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(slots=True)
|
|
35
|
+
class AttachmentConversion:
|
|
36
|
+
message: ConversationMessage
|
|
37
|
+
attachment: Attachment
|
|
38
|
+
download: Callable[[], Awaitable[DownloadedAttachment]]
|
|
39
|
+
"""Uses the current invocation's authenticated, thread-scoped attachment API."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
AttachmentResult = ContentBlock | list[ContentBlock] | None
|
|
43
|
+
AttachmentHandler = Callable[[AttachmentConversion], AttachmentResult | Awaitable[AttachmentResult]]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def convert_attachment(input: AttachmentConversion) -> AttachmentResult:
|
|
47
|
+
"""Hydrate images/PDFs as base64 blocks and textual files as actual text, not filenames."""
|
|
48
|
+
attachment = input.attachment
|
|
49
|
+
media_type = normalize_media_type(attachment.media_type, attachment.filename)
|
|
50
|
+
text = media_type.startswith("text/") or media_type in _TEXT_TYPES
|
|
51
|
+
file = media_type in _FILE_TYPES
|
|
52
|
+
if not text and not file:
|
|
53
|
+
return {
|
|
54
|
+
"type": "text",
|
|
55
|
+
"text": (
|
|
56
|
+
f"Attached file: {attachment.filename} ({media_type}). "
|
|
57
|
+
"A custom attachment handler is needed to read this format."
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
result = await input.download()
|
|
61
|
+
if result.attachment.id != attachment.id:
|
|
62
|
+
raise RuntimeError("Attachment download returned a different file")
|
|
63
|
+
if text:
|
|
64
|
+
content = result.content.decode("utf-8", errors="replace")
|
|
65
|
+
return {"type": "text", "text": f"Attached file: {attachment.filename}\n{content}"}
|
|
66
|
+
encoded = base64.b64encode(result.content).decode("ascii")
|
|
67
|
+
if media_type.startswith("image/"):
|
|
68
|
+
return {"type": "image", "mime_type": media_type, "base64": encoded}
|
|
69
|
+
# ``filename`` is not part of FileContentBlock but the OpenAI translator forwards it.
|
|
70
|
+
return {
|
|
71
|
+
"type": "file",
|
|
72
|
+
"mime_type": media_type,
|
|
73
|
+
"base64": encoded,
|
|
74
|
+
"filename": attachment.filename,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def normalize_media_type(value: str, filename: str) -> str:
|
|
79
|
+
media_type = value.split(";")[0].strip().lower()
|
|
80
|
+
if media_type and media_type != "application/octet-stream":
|
|
81
|
+
return media_type
|
|
82
|
+
extension = filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
|
|
83
|
+
return _EXTENSIONS.get(extension, media_type or "application/octet-stream")
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""``tilde deploy`` discovery for LangChain (entry point ``tilde.discover``).
|
|
2
|
+
|
|
3
|
+
- A module-level ``create_agent`` graph: its static ``system_prompt`` becomes the plain prompt
|
|
4
|
+
``<agent>/system_prompt`` (``<agent>`` is the ``name=`` given to ``create_agent``, else the
|
|
5
|
+
variable name), and every ``@dynamic_prompt`` middleware in it a dynamic prompt.
|
|
6
|
+
- A module-level ``@dynamic_prompt`` middleware: ``<function name>/system_prompt``, dynamic,
|
|
7
|
+
with the function's source.
|
|
8
|
+
- A module-level ``PromptTemplate`` / ``ChatPromptTemplate``: ``<variable>`` (chat templates:
|
|
9
|
+
``<variable>/<index>.<role>`` per message), f-string templates in braces format, mustache in
|
|
10
|
+
mustache format. Jinja2 templates and LangSmith hub prompts are reported, not registered.
|
|
11
|
+
|
|
12
|
+
Tools are declared as ``with_tilde_tools`` publishes them: those of a ``create_agent`` graph's
|
|
13
|
+
``ToolNode`` (``tools=`` and middleware tools) and a ``define_tools`` value of LangChain tools.
|
|
14
|
+
|
|
15
|
+
``create_agent`` keeps the system prompt and ``wrap_model_call`` middleware only in the model
|
|
16
|
+
node's closures (langchain 1.4); they are read from there and reported when unreadable.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import functools
|
|
22
|
+
import inspect
|
|
23
|
+
import sys
|
|
24
|
+
import types
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from langchain.agents.middleware import AgentMiddleware
|
|
30
|
+
from langchain_core.messages import BaseMessage
|
|
31
|
+
from langchain_core.prompts import (
|
|
32
|
+
ChatPromptTemplate,
|
|
33
|
+
MessagesPlaceholder,
|
|
34
|
+
PromptTemplate,
|
|
35
|
+
)
|
|
36
|
+
from langchain_core.tools import BaseTool
|
|
37
|
+
from langgraph.graph.state import CompiledStateGraph
|
|
38
|
+
from langgraph.prebuilt import ToolNode
|
|
39
|
+
|
|
40
|
+
from tilde import BundledTools
|
|
41
|
+
from tilde.discovery import (
|
|
42
|
+
PROMPT_FORMAT_BRACES,
|
|
43
|
+
PROMPT_FORMAT_DYNAMIC,
|
|
44
|
+
PROMPT_FORMAT_MUSTACHE,
|
|
45
|
+
PROMPT_FORMAT_PLAIN,
|
|
46
|
+
Discovered,
|
|
47
|
+
DiscoveryContext,
|
|
48
|
+
declared_prompt,
|
|
49
|
+
declared_tool,
|
|
50
|
+
)
|
|
51
|
+
from tilde.prompts import canonical_json, prompt_hash, valid_prompt_name
|
|
52
|
+
from tilde_langchain.tools import describe_tool
|
|
53
|
+
|
|
54
|
+
_FORMATS = {"f-string": PROMPT_FORMAT_BRACES, "mustache": PROMPT_FORMAT_MUSTACHE}
|
|
55
|
+
_DEFAULT_GRAPH_NAME = "LangGraph"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_create_agent(value: Any) -> bool:
|
|
59
|
+
return isinstance(value, CompiledStateGraph) and (
|
|
60
|
+
(value.config or {}).get("metadata", {}).get("ls_integration") == "langchain_create_agent"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def reachable_middleware(root: Any) -> list[AgentMiddleware[Any, Any]]:
|
|
65
|
+
"""Middleware instances reachable from ``root`` through bound methods, wrappers, partials
|
|
66
|
+
and closures: from a ``create_agent`` model node, or from a ``wrap_model_call`` handler."""
|
|
67
|
+
found: list[AgentMiddleware[Any, Any]] = []
|
|
68
|
+
seen: set[int] = set()
|
|
69
|
+
pending = [root]
|
|
70
|
+
while pending:
|
|
71
|
+
value = pending.pop()
|
|
72
|
+
if id(value) in seen:
|
|
73
|
+
continue
|
|
74
|
+
seen.add(id(value))
|
|
75
|
+
if isinstance(value, AgentMiddleware):
|
|
76
|
+
found.append(value)
|
|
77
|
+
elif inspect.ismethod(value):
|
|
78
|
+
pending += [value.__self__, value.__func__]
|
|
79
|
+
elif isinstance(value, functools.partial):
|
|
80
|
+
pending += [value.func, *value.args]
|
|
81
|
+
elif isinstance(value, types.FunctionType):
|
|
82
|
+
for cell in value.__closure__ or ():
|
|
83
|
+
try:
|
|
84
|
+
pending.append(cell.cell_contents)
|
|
85
|
+
except ValueError: # an empty cell
|
|
86
|
+
continue
|
|
87
|
+
elif isinstance(value, list | tuple):
|
|
88
|
+
pending += list(value)
|
|
89
|
+
wrapped = getattr(value, "__wrapped__", None)
|
|
90
|
+
if wrapped is not None:
|
|
91
|
+
pending.append(wrapped)
|
|
92
|
+
return found
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def dynamic_prompt_function(middleware: AgentMiddleware[Any, Any]) -> Callable[..., Any] | None:
|
|
96
|
+
"""The function behind a ``@dynamic_prompt`` middleware (the class ``dynamic_prompt``
|
|
97
|
+
builds keeps it in its hook's closure)."""
|
|
98
|
+
for hook in ("wrap_model_call", "awrap_model_call"):
|
|
99
|
+
method = type(middleware).__dict__.get(hook)
|
|
100
|
+
if not isinstance(method, types.FunctionType):
|
|
101
|
+
continue
|
|
102
|
+
if not method.__qualname__.startswith("dynamic_prompt."):
|
|
103
|
+
return None
|
|
104
|
+
for name, cell in zip(method.__code__.co_freevars, method.__closure__ or (), strict=True):
|
|
105
|
+
if name == "func":
|
|
106
|
+
return cell.cell_contents
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def dynamic_prompt(middleware: AgentMiddleware[Any, Any]) -> tuple[str, str] | None:
|
|
111
|
+
"""``(name, source)`` of a ``@dynamic_prompt`` middleware, or None for other middleware."""
|
|
112
|
+
func = dynamic_prompt_function(middleware)
|
|
113
|
+
if func is None:
|
|
114
|
+
return None
|
|
115
|
+
try:
|
|
116
|
+
return f"{middleware.name}/system_prompt", inspect.getsource(func)
|
|
117
|
+
except (OSError, TypeError):
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def dynamic_stamp(middleware: AgentMiddleware[Any, Any]) -> tuple[str, str] | None:
|
|
122
|
+
prompt = dynamic_prompt(middleware)
|
|
123
|
+
if prompt is None:
|
|
124
|
+
return None
|
|
125
|
+
return prompt[0], prompt_hash(prompt[1], {}, canonical_json({}))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def discover(value: Any, context: DiscoveryContext) -> Discovered | None:
|
|
129
|
+
module = sys.modules.get(context.module)
|
|
130
|
+
file = getattr(module, "__file__", None)
|
|
131
|
+
origin = f"{context.relative(Path(file)) if file else context.module}#{context.name}"
|
|
132
|
+
if isinstance(value, BundledTools):
|
|
133
|
+
if not value.tools or not all(isinstance(tool, BaseTool) for tool in value.tools):
|
|
134
|
+
return None
|
|
135
|
+
return Discovered(
|
|
136
|
+
tools=[
|
|
137
|
+
declared_tool(describe_tool(tool, value.options), f"{origin}.tools.{tool.name}")
|
|
138
|
+
for tool in value.tools
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
if is_create_agent(value):
|
|
142
|
+
return _agent(value, origin, context)
|
|
143
|
+
if isinstance(value, AgentMiddleware):
|
|
144
|
+
found = Discovered()
|
|
145
|
+
_dynamic(value, origin, found, context)
|
|
146
|
+
return found
|
|
147
|
+
if isinstance(value, PromptTemplate | ChatPromptTemplate):
|
|
148
|
+
if (value.metadata or {}).get("lc_hub_repo"):
|
|
149
|
+
context.warn(f"{origin}: LangSmith hub prompt is versioned by the hub, not by Tilde")
|
|
150
|
+
return None
|
|
151
|
+
found = Discovered()
|
|
152
|
+
if isinstance(value, PromptTemplate):
|
|
153
|
+
_template(context.name, value, origin, found, context)
|
|
154
|
+
elif isinstance(value, ChatPromptTemplate):
|
|
155
|
+
_chat(value, origin, found, context)
|
|
156
|
+
return found
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _agent(graph: CompiledStateGraph, origin: str, context: DiscoveryContext) -> Discovered:
|
|
161
|
+
found = Discovered()
|
|
162
|
+
name = graph.name if graph.name != _DEFAULT_GRAPH_NAME else context.name
|
|
163
|
+
model = graph.nodes.get("model")
|
|
164
|
+
bound = getattr(model, "bound", None)
|
|
165
|
+
func = getattr(bound, "func", None)
|
|
166
|
+
try:
|
|
167
|
+
nonlocals = inspect.getclosurevars(func).nonlocals
|
|
168
|
+
system = nonlocals["system_message"]
|
|
169
|
+
except (KeyError, TypeError):
|
|
170
|
+
context.warn(f"{origin}: create_agent's system prompt could not be read")
|
|
171
|
+
system = None
|
|
172
|
+
if isinstance(system, BaseMessage):
|
|
173
|
+
if isinstance(system.content, str) and valid_prompt_name(f"{name}/system_prompt"):
|
|
174
|
+
found.prompts.append(
|
|
175
|
+
declared_prompt(
|
|
176
|
+
f"{name}/system_prompt",
|
|
177
|
+
system.content,
|
|
178
|
+
PROMPT_FORMAT_PLAIN,
|
|
179
|
+
f"{origin}.system_prompt",
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
else:
|
|
183
|
+
context.warn(f"{origin}: system prompt {name!r} is not registered")
|
|
184
|
+
# Hook nodes (`<middleware>.before_model`, ...) and the model node's composed wrappers.
|
|
185
|
+
roots = [
|
|
186
|
+
getattr(getattr(node, "bound", None), attribute, None)
|
|
187
|
+
for node in graph.nodes.values()
|
|
188
|
+
for attribute in ("func", "afunc")
|
|
189
|
+
]
|
|
190
|
+
for middleware in reachable_middleware(roots):
|
|
191
|
+
_dynamic(middleware, f"{origin}.middleware.{middleware.name}", found, context)
|
|
192
|
+
node = getattr(graph.nodes.get("tools"), "bound", None)
|
|
193
|
+
if isinstance(node, ToolNode):
|
|
194
|
+
found.tools += [
|
|
195
|
+
declared_tool(describe_tool(tool), f"{origin}.tools.{name}")
|
|
196
|
+
for name, tool in node.tools_by_name.items()
|
|
197
|
+
]
|
|
198
|
+
return found
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _dynamic(
|
|
202
|
+
middleware: AgentMiddleware[Any, Any], origin: str, found: Discovered, context: DiscoveryContext
|
|
203
|
+
) -> None:
|
|
204
|
+
if dynamic_prompt_function(middleware) is None:
|
|
205
|
+
return
|
|
206
|
+
prompt = dynamic_prompt(middleware)
|
|
207
|
+
if prompt is None or not valid_prompt_name(prompt[0]):
|
|
208
|
+
context.warn(f"{origin}: dynamic prompt {middleware.name!r} could not be read")
|
|
209
|
+
return
|
|
210
|
+
found.prompts.append(declared_prompt(prompt[0], prompt[1], PROMPT_FORMAT_DYNAMIC, origin))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _template(
|
|
214
|
+
name: str, template: PromptTemplate, origin: str, found: Discovered, context: DiscoveryContext
|
|
215
|
+
) -> None:
|
|
216
|
+
format = _FORMATS.get(template.template_format)
|
|
217
|
+
if format is None:
|
|
218
|
+
context.warn(f"{origin}: {template.template_format} templates are not registered")
|
|
219
|
+
return
|
|
220
|
+
if "{" not in template.template:
|
|
221
|
+
format = PROMPT_FORMAT_PLAIN
|
|
222
|
+
if not valid_prompt_name(name):
|
|
223
|
+
context.warn(f"{origin}: {name!r} cannot name a prompt")
|
|
224
|
+
return
|
|
225
|
+
found.prompts.append(declared_prompt(name, template.template, format, origin))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _chat(
|
|
229
|
+
chat: ChatPromptTemplate, origin: str, found: Discovered, context: DiscoveryContext
|
|
230
|
+
) -> None:
|
|
231
|
+
for index, message in enumerate(chat.messages):
|
|
232
|
+
if isinstance(message, MessagesPlaceholder):
|
|
233
|
+
continue
|
|
234
|
+
if isinstance(message, BaseMessage):
|
|
235
|
+
if isinstance(message.content, str):
|
|
236
|
+
name = f"{context.name}/{index}.{message.type}"
|
|
237
|
+
found.prompts.append(
|
|
238
|
+
declared_prompt(
|
|
239
|
+
name, message.content, PROMPT_FORMAT_PLAIN, f"{origin}.messages[{index}]"
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
continue
|
|
243
|
+
prompt = getattr(message, "prompt", None)
|
|
244
|
+
if isinstance(prompt, PromptTemplate):
|
|
245
|
+
# ChatMessagePromptTemplate has a role; the others are named for theirs.
|
|
246
|
+
role = (
|
|
247
|
+
getattr(message, "role", None)
|
|
248
|
+
or type(message).__name__.removesuffix("MessagePromptTemplate").lower()
|
|
249
|
+
)
|
|
250
|
+
name = f"{context.name}/{index}.{role}"
|
|
251
|
+
_template(name, prompt, f"{origin}.messages[{index}]", found, context)
|
|
252
|
+
else:
|
|
253
|
+
context.warn(f"{origin}.messages[{index}]: only text message templates are registered")
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Convert typed Tilde context into LangChain messages."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Awaitable, Callable, Iterable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage
|
|
12
|
+
|
|
13
|
+
from tilde import (
|
|
14
|
+
ContextMessage,
|
|
15
|
+
ConversationMessage,
|
|
16
|
+
DownloadedAttachment,
|
|
17
|
+
GoalMessage,
|
|
18
|
+
ObjectiveMessage,
|
|
19
|
+
TaskMessage,
|
|
20
|
+
)
|
|
21
|
+
from tilde_langchain.attachments import AttachmentConversion, AttachmentHandler, convert_attachment
|
|
22
|
+
|
|
23
|
+
Converted = BaseMessage | None
|
|
24
|
+
_Handler = Callable[[Any], Converted | Awaitable[Converted]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class MessageHandlers:
|
|
29
|
+
"""Explicit handlers take precedence over cached and default rendering; None omits an item."""
|
|
30
|
+
|
|
31
|
+
message: Callable[[ConversationMessage], Converted | Awaitable[Converted]] | None = None
|
|
32
|
+
objective: Callable[[ObjectiveMessage], Converted | Awaitable[Converted]] | None = None
|
|
33
|
+
goal: Callable[[GoalMessage], Converted | Awaitable[Converted]] | None = None
|
|
34
|
+
task: Callable[[TaskMessage], Converted | Awaitable[Converted]] | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Attachments(Protocol):
|
|
38
|
+
async def download(self, attachment_id: str) -> DownloadedAttachment: ...
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConversionContext(Protocol):
|
|
42
|
+
"""The parts of ``AgentContext`` the converter uses; a test double only needs these."""
|
|
43
|
+
|
|
44
|
+
attachments: _Attachments
|
|
45
|
+
|
|
46
|
+
async def cache_converted_messages(self, messages: list[tuple[str, Any]]) -> None: ...
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_BATCH_ITEMS = 100
|
|
50
|
+
_BATCH_BYTES = 1024 * 1024
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
async def convert_to_langchain_messages(
|
|
54
|
+
messages: Iterable[ContextMessage],
|
|
55
|
+
*,
|
|
56
|
+
context: ConversionContext | None = None,
|
|
57
|
+
on_message: MessageHandlers | None = None,
|
|
58
|
+
on_attachment: AttachmentHandler | None = None,
|
|
59
|
+
) -> list[BaseMessage]:
|
|
60
|
+
"""Convert typed SDK context into ``HumanMessage``/``AIMessage`` items, ready for an agent.
|
|
61
|
+
|
|
62
|
+
Each result carries the Tilde message id. Incomplete conversation messages are skipped
|
|
63
|
+
unless a custom ``message`` handler is supplied. Completed, attachment-free conversions are
|
|
64
|
+
cached through ``context.cache_converted_messages`` in bounded batches; attachments are
|
|
65
|
+
hydrated afresh on every run and never stored in the cache.
|
|
66
|
+
"""
|
|
67
|
+
output: list[BaseMessage] = []
|
|
68
|
+
batch: list[tuple[str, Any]] = []
|
|
69
|
+
batch_bytes = 0
|
|
70
|
+
|
|
71
|
+
async def flush() -> None:
|
|
72
|
+
nonlocal batch_bytes
|
|
73
|
+
if batch and context is not None:
|
|
74
|
+
await context.cache_converted_messages(batch[:])
|
|
75
|
+
batch.clear()
|
|
76
|
+
batch_bytes = 0
|
|
77
|
+
|
|
78
|
+
for item in messages:
|
|
79
|
+
handler: _Handler | None
|
|
80
|
+
converted: Converted
|
|
81
|
+
cacheable = False
|
|
82
|
+
if isinstance(item, ObjectiveMessage):
|
|
83
|
+
handler = on_message.objective if on_message else None
|
|
84
|
+
converted = (
|
|
85
|
+
await _call(handler, item)
|
|
86
|
+
if handler
|
|
87
|
+
else HumanMessage(id=item.id, content=item.objective)
|
|
88
|
+
)
|
|
89
|
+
elif isinstance(item, GoalMessage):
|
|
90
|
+
handler = on_message.goal if on_message else None
|
|
91
|
+
converted = (
|
|
92
|
+
await _call(handler, item)
|
|
93
|
+
if handler
|
|
94
|
+
else HumanMessage(
|
|
95
|
+
id=item.id, content=f"Goal ({item.goal.status}): {item.goal.objective}"
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
elif isinstance(item, TaskMessage):
|
|
99
|
+
handler = on_message.task if on_message else None
|
|
100
|
+
if handler:
|
|
101
|
+
converted = await _call(handler, item)
|
|
102
|
+
else:
|
|
103
|
+
text = f"Task ({item.task.status}): {item.task.title}"
|
|
104
|
+
if item.task.blocked_reason:
|
|
105
|
+
text += f"\nBlocked: {item.task.blocked_reason}"
|
|
106
|
+
converted = HumanMessage(id=item.id, content=text)
|
|
107
|
+
else:
|
|
108
|
+
handler = on_message.message if on_message else None
|
|
109
|
+
if handler:
|
|
110
|
+
converted = await _call(handler, item)
|
|
111
|
+
elif item.message.status != "complete":
|
|
112
|
+
continue
|
|
113
|
+
else:
|
|
114
|
+
# File bytes are hydrated afresh through the scoped SDK, never from the cache.
|
|
115
|
+
hydrated = _hydrate(item) if not item.message.attachments else None
|
|
116
|
+
converted = hydrated or await _render(item, context, on_attachment)
|
|
117
|
+
# Only canonical, attachment-free chat records are cached by the runtime; work
|
|
118
|
+
# state stays a live projection and file bytes never duplicate into the cache.
|
|
119
|
+
cacheable = hydrated is None and not item.message.attachments
|
|
120
|
+
if converted is None:
|
|
121
|
+
continue
|
|
122
|
+
output.append(converted)
|
|
123
|
+
if cacheable and context is not None:
|
|
124
|
+
entry = {"id": converted.id, "role": _role(converted), "content": converted.content}
|
|
125
|
+
serialized = json.dumps(entry)
|
|
126
|
+
size = len(serialized.encode())
|
|
127
|
+
if size > _BATCH_BYTES:
|
|
128
|
+
continue
|
|
129
|
+
if len(batch) == _BATCH_ITEMS or batch_bytes + size > _BATCH_BYTES:
|
|
130
|
+
await flush()
|
|
131
|
+
batch.append((item.id, json.loads(serialized)))
|
|
132
|
+
batch_bytes += size
|
|
133
|
+
await flush()
|
|
134
|
+
return output
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _call(handler: _Handler, item: ContextMessage) -> Converted:
|
|
138
|
+
result = handler(item)
|
|
139
|
+
if inspect.isawaitable(result):
|
|
140
|
+
result = await result
|
|
141
|
+
return result
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _render(
|
|
145
|
+
item: ConversationMessage,
|
|
146
|
+
context: ConversionContext | None,
|
|
147
|
+
on_attachment: AttachmentHandler | None,
|
|
148
|
+
) -> Converted:
|
|
149
|
+
blocks: list[dict[str, Any]] = []
|
|
150
|
+
if item.message.subject:
|
|
151
|
+
blocks.append({"type": "text", "text": f"Subject: {item.message.subject}"})
|
|
152
|
+
if item.message.text:
|
|
153
|
+
blocks.append({"type": "text", "text": item.message.text})
|
|
154
|
+
for attachment in item.message.attachments:
|
|
155
|
+
|
|
156
|
+
def download(attachment_id: str = attachment.id) -> Awaitable[DownloadedAttachment]:
|
|
157
|
+
if context is None:
|
|
158
|
+
raise RuntimeError(
|
|
159
|
+
"Attachment conversion requires context or a custom on_attachment handler"
|
|
160
|
+
)
|
|
161
|
+
return context.attachments.download(attachment_id)
|
|
162
|
+
|
|
163
|
+
result = (on_attachment or convert_attachment)(
|
|
164
|
+
AttachmentConversion(message=item, attachment=attachment, download=download)
|
|
165
|
+
)
|
|
166
|
+
if inspect.isawaitable(result):
|
|
167
|
+
result = await result
|
|
168
|
+
if result is None:
|
|
169
|
+
continue
|
|
170
|
+
blocks.extend(result if isinstance(result, list) else [result])
|
|
171
|
+
if not blocks:
|
|
172
|
+
return None
|
|
173
|
+
return _message(item.role, item.id, blocks)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _hydrate(item: ConversationMessage) -> Converted:
|
|
177
|
+
cached = item.cached_agent_representation
|
|
178
|
+
if (
|
|
179
|
+
not isinstance(cached, dict)
|
|
180
|
+
or cached.get("id") != item.id
|
|
181
|
+
or cached.get("role") != item.role
|
|
182
|
+
):
|
|
183
|
+
return None
|
|
184
|
+
content = cached.get("content")
|
|
185
|
+
if isinstance(content, str):
|
|
186
|
+
return _message(item.role, item.id, content)
|
|
187
|
+
if not isinstance(content, list) or not all(isinstance(block, dict) for block in content):
|
|
188
|
+
return None
|
|
189
|
+
if any(block.get("type") in ("image", "file") for block in content):
|
|
190
|
+
return None
|
|
191
|
+
return _message(item.role, item.id, content)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _message(role: str, id: str, content: str | list[dict[str, Any]]) -> BaseMessage:
|
|
195
|
+
if role == "assistant":
|
|
196
|
+
return AIMessage(id=id, content=content)
|
|
197
|
+
return HumanMessage(id=id, content=content)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _role(message: BaseMessage) -> str:
|
|
201
|
+
return "assistant" if isinstance(message, AIMessage) else "user"
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Tilde's per-invocation pieces for a module-level ``create_agent`` graph.
|
|
2
|
+
|
|
3
|
+
A compiled graph takes no per-call tools or messages: ``ainvoke``'s config carries callbacks
|
|
4
|
+
and ``context`` only, and callbacks cannot change state. So the pieces come from middleware
|
|
5
|
+
the agent is created with once, ``create_agent(..., middleware=[tilde_middleware(), ...])``,
|
|
6
|
+
which reads the invocation running the graph from Tilde's context variable. Outside an
|
|
7
|
+
invocation it does nothing.
|
|
8
|
+
|
|
9
|
+
Tools added to a model request in ``wrap_model_call`` are not registered with the graph's
|
|
10
|
+
``ToolNode``; ``create_agent`` allows that only when some middleware executes them in
|
|
11
|
+
``wrap_tool_call`` (its "dynamic tools" contract), which this middleware does.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import weakref
|
|
17
|
+
from collections.abc import Awaitable, Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from langchain.agents.middleware import (
|
|
22
|
+
AgentMiddleware,
|
|
23
|
+
AgentState,
|
|
24
|
+
ModelRequest,
|
|
25
|
+
ModelResponse,
|
|
26
|
+
)
|
|
27
|
+
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
|
28
|
+
from langchain_core.tools import BaseTool
|
|
29
|
+
from langgraph.prebuilt.tool_node import ToolCallRequest
|
|
30
|
+
from langgraph.runtime import Runtime
|
|
31
|
+
from langgraph.types import Command
|
|
32
|
+
|
|
33
|
+
from tilde import AgentContext, BundledTools, current_invocation
|
|
34
|
+
from tilde_langchain.discover import dynamic_stamp, reachable_middleware
|
|
35
|
+
from tilde_langchain.tools import convert_to_langchain_tools, with_tilde_tools
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(slots=True)
|
|
39
|
+
class _Invocation:
|
|
40
|
+
tools: dict[str, BaseTool]
|
|
41
|
+
skills: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# One entry per running invocation; it goes when the invocation's context does.
|
|
45
|
+
_invocations: weakref.WeakKeyDictionary[AgentContext, _Invocation] = weakref.WeakKeyDictionary()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def _invocation(ctx: AgentContext, bundled: BundledTools | None) -> _Invocation:
|
|
49
|
+
found = _invocations.get(ctx)
|
|
50
|
+
if found is None:
|
|
51
|
+
# Plain create_agent has no native skills: tools to read them plus a summary.
|
|
52
|
+
summary = await ctx.skills.summary()
|
|
53
|
+
tools = await with_tilde_tools(
|
|
54
|
+
ctx,
|
|
55
|
+
bundled.tools if bundled else [],
|
|
56
|
+
options=bundled.options if bundled else None,
|
|
57
|
+
)
|
|
58
|
+
if summary:
|
|
59
|
+
tools += convert_to_langchain_tools(ctx.skills.tools())
|
|
60
|
+
found = _invocations[ctx] = _Invocation({tool.name: tool for tool in tools}, summary)
|
|
61
|
+
return found
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TildeMiddleware(AgentMiddleware[AgentState[Any], Any]):
|
|
65
|
+
"""Before each model call: check cancellation and add steering input as user messages
|
|
66
|
+
(kept in the graph state). Around it: stamp the ``@dynamic_prompt`` middleware listed
|
|
67
|
+
after this one, add the channel's tools, the agent's other Tilde tools, its bundled tools
|
|
68
|
+
(audited and published as ``with_tilde_tools`` does) and, when skills are assigned, the
|
|
69
|
+
skill tools and their summary. Around tool calls: execute those tools."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, bundled: BundledTools | None = None) -> None:
|
|
72
|
+
super().__init__()
|
|
73
|
+
self.bundled = bundled
|
|
74
|
+
|
|
75
|
+
async def abefore_model(
|
|
76
|
+
self, state: AgentState[Any], runtime: Runtime[Any]
|
|
77
|
+
) -> dict[str, Any] | None:
|
|
78
|
+
ctx = current_invocation()
|
|
79
|
+
if ctx is None:
|
|
80
|
+
return None
|
|
81
|
+
ctx.check()
|
|
82
|
+
inputs = ctx.take_inputs()
|
|
83
|
+
if not inputs:
|
|
84
|
+
return None
|
|
85
|
+
return {"messages": [HumanMessage(content=input.text) for input in inputs]}
|
|
86
|
+
|
|
87
|
+
async def awrap_model_call(
|
|
88
|
+
self,
|
|
89
|
+
request: ModelRequest[Any],
|
|
90
|
+
handler: Callable[[ModelRequest[Any]], Awaitable[ModelResponse[Any]]],
|
|
91
|
+
) -> ModelResponse[Any]:
|
|
92
|
+
ctx = current_invocation()
|
|
93
|
+
if ctx is None:
|
|
94
|
+
return await handler(request)
|
|
95
|
+
# `handler` runs the middleware after this one; a dynamic prompt there sets the system
|
|
96
|
+
# prompt this call sends.
|
|
97
|
+
for middleware in reachable_middleware(handler):
|
|
98
|
+
stamp = dynamic_stamp(middleware)
|
|
99
|
+
if stamp is not None:
|
|
100
|
+
ctx.activate_prompt(*stamp)
|
|
101
|
+
invocation = await _invocation(ctx, self.bundled)
|
|
102
|
+
messages = request.messages
|
|
103
|
+
if invocation.skills:
|
|
104
|
+
# A second system message right after the system prompt, which a dynamic prompt
|
|
105
|
+
# after this middleware replaces; this call only, not the graph state.
|
|
106
|
+
messages = [SystemMessage(content=invocation.skills), *messages]
|
|
107
|
+
return await handler(
|
|
108
|
+
request.override(tools=[*request.tools, *invocation.tools.values()], messages=messages)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def awrap_tool_call(
|
|
112
|
+
self,
|
|
113
|
+
request: ToolCallRequest,
|
|
114
|
+
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
|
|
115
|
+
) -> ToolMessage | Command[Any]:
|
|
116
|
+
ctx = current_invocation()
|
|
117
|
+
invocation = _invocations.get(ctx) if ctx is not None else None
|
|
118
|
+
if request.tool is None and invocation is not None:
|
|
119
|
+
tool = invocation.tools.get(request.tool_call["name"])
|
|
120
|
+
if tool is not None:
|
|
121
|
+
return await handler(request.override(tool=tool))
|
|
122
|
+
return await handler(request)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def tilde_middleware(bundled: BundledTools | None = None) -> TildeMiddleware:
|
|
126
|
+
"""Put it first in ``create_agent(middleware=[...])`` so it sees the dynamic prompts.
|
|
127
|
+
``bundled`` is the agent's ``define_tools(...)``."""
|
|
128
|
+
return TildeMiddleware(bundled)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Expose Tilde channel tools as LangChain tools with the model's tool-call id forwarded, and
|
|
2
|
+
publish the agent's own LangChain tools to Tilde as bundled tools."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
from uuid import UUID
|
|
9
|
+
|
|
10
|
+
from langchain_core.callbacks import AsyncCallbackHandler, BaseCallbackManager
|
|
11
|
+
from langchain_core.messages import ToolMessage
|
|
12
|
+
from langchain_core.tools import BaseTool
|
|
13
|
+
from langchain_core.utils.function_calling import convert_to_openai_tool
|
|
14
|
+
|
|
15
|
+
from tilde import AgentContext, BundledOptions, ChannelTool, Tool
|
|
16
|
+
from tilde._tools import ToolSpec, bundled_tool, model_tool_name, tool_spec
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ChannelToolAdapter(BaseTool):
|
|
20
|
+
"""Async-only LangChain tool over one Tilde channel tool.
|
|
21
|
+
|
|
22
|
+
LangChain passes the model's tool-call id into ``BaseTool.arun`` whenever the tool is
|
|
23
|
+
invoked with a ``ToolCall`` dict (which is what LangGraph's ``ToolNode`` and therefore
|
|
24
|
+
``create_agent`` do). With a JSON-schema ``args_schema`` nothing injects that id into the
|
|
25
|
+
coroutine arguments, so ``_to_args_and_kwargs`` is overridden to carry it into ``_arun``.
|
|
26
|
+
When invoked directly with plain arguments there is no call id; ``ChannelTool.execute``
|
|
27
|
+
then generates a random UUID, so the call is audited but not deduplicated.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
channel: ChannelTool | Tool
|
|
31
|
+
|
|
32
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
33
|
+
|
|
34
|
+
def _to_args_and_kwargs(
|
|
35
|
+
self, tool_input: str | dict[str, Any], tool_call_id: str | None
|
|
36
|
+
) -> tuple[tuple[str, ...], dict[str, Any]]:
|
|
37
|
+
return (), {"input": tool_input, "tool_call_id": tool_call_id}
|
|
38
|
+
|
|
39
|
+
def _run(self, *args: Any, **kwargs: Any) -> Any:
|
|
40
|
+
raise NotImplementedError("Channel tools run on the agent's event loop; use ainvoke")
|
|
41
|
+
|
|
42
|
+
async def _arun(self, input: Any, tool_call_id: str | None = None) -> Any:
|
|
43
|
+
return await self.channel.execute(input, tool_call_id=tool_call_id)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def convert_to_langchain_tools(
|
|
47
|
+
channels: Mapping[str, ChannelTool | Tool],
|
|
48
|
+
*,
|
|
49
|
+
instructions: Mapping[str, str] | None = None,
|
|
50
|
+
prefix: str = "",
|
|
51
|
+
) -> list[BaseTool]:
|
|
52
|
+
"""Preserve provider schemas and descriptions; ``instructions`` overrides descriptions
|
|
53
|
+
without touching schemas or authorization, ``prefix`` namespaces model tool names.
|
|
54
|
+
``ctx.skills.tools()`` converts the same way."""
|
|
55
|
+
result: dict[str, BaseTool] = {}
|
|
56
|
+
for name, channel in channels.items():
|
|
57
|
+
key = model_tool_name(f"{prefix}{name}")
|
|
58
|
+
if not key or key in result:
|
|
59
|
+
raise ValueError("Conflicting or invalid model tool names")
|
|
60
|
+
description = (
|
|
61
|
+
instructions[name]
|
|
62
|
+
if instructions is not None and name in instructions
|
|
63
|
+
else channel.description
|
|
64
|
+
)
|
|
65
|
+
result[key] = ChannelToolAdapter(
|
|
66
|
+
name=key,
|
|
67
|
+
description=description,
|
|
68
|
+
args_schema=channel.input_schema,
|
|
69
|
+
channel=channel,
|
|
70
|
+
)
|
|
71
|
+
return list(result.values())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def with_tilde_tools(
|
|
75
|
+
ctx: AgentContext,
|
|
76
|
+
native_tools: Sequence[BaseTool],
|
|
77
|
+
*,
|
|
78
|
+
options: Mapping[str, BundledOptions] | None = None,
|
|
79
|
+
) -> list[BaseTool]:
|
|
80
|
+
"""The current channel's tools, the agent's other Tilde tools and its own tools, for
|
|
81
|
+
``create_agent(tools=...)`` or a ``ToolNode``.
|
|
82
|
+
|
|
83
|
+
Each native tool (``@tool``, ``StructuredTool`` or any ``BaseTool``) is published to Tilde
|
|
84
|
+
with its name, description and the argument schema the model sees, so ``tools.search``
|
|
85
|
+
finds it and a ``tools.execute`` naming it runs it here. It is returned as a copy of the
|
|
86
|
+
same type with an audit callback handler attached, so ``ToolNode`` injection keeps working
|
|
87
|
+
and every call is audited once with the model's call id. Tilde metadata comes from
|
|
88
|
+
``metadata={"tilde": BundledOptions(...)}`` or ``options[name]``, which wins. Call it once per
|
|
89
|
+
invocation.
|
|
90
|
+
|
|
91
|
+
Caveats: LangChain has no output schema, and audits record the tool message content. A
|
|
92
|
+
tool that needs ``ToolRuntime`` or ``InjectedState`` only gets them from ``ToolNode``, so it
|
|
93
|
+
cannot run through ``tools.execute``. Invoked without a ``ToolCall`` (no model call id) the
|
|
94
|
+
call is audited under LangChain's run id.
|
|
95
|
+
"""
|
|
96
|
+
tools = convert_to_langchain_tools({**ctx.channel.current, **ctx.agent_tools})
|
|
97
|
+
names = {tool.name for tool in tools}
|
|
98
|
+
definitions = {}
|
|
99
|
+
for native in native_tools:
|
|
100
|
+
if native.name in names:
|
|
101
|
+
raise ValueError(f"Tool {native.name} conflicts with another tool")
|
|
102
|
+
names.add(native.name)
|
|
103
|
+
tool = _audited(ctx, native)
|
|
104
|
+
definitions[tool.name] = bundled_tool(describe_tool(native, options), _route(tool))
|
|
105
|
+
tools.append(tool)
|
|
106
|
+
# Published even when empty, so tools removed since the last call stop running here.
|
|
107
|
+
await ctx._set_bundled_tools(definitions)
|
|
108
|
+
return tools
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def describe_tool(tool: BaseTool, options: Mapping[str, BundledOptions] | None = None) -> ToolSpec:
|
|
112
|
+
"""What ``with_tilde_tools`` publishes and ``tilde deploy`` declares for one tool."""
|
|
113
|
+
return tool_spec(
|
|
114
|
+
tool.name,
|
|
115
|
+
tool.description,
|
|
116
|
+
convert_to_openai_tool(tool)["function"]["parameters"],
|
|
117
|
+
options=(options or {}).get(tool.name, (tool.metadata or {}).get("tilde")),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class _Audit(AsyncCallbackHandler):
|
|
122
|
+
"""Reports one tool's calls. Attached to that tool only, it is not inherited by child runs;
|
|
123
|
+
``raise_error`` surfaces failed reports instead of LangChain logging them."""
|
|
124
|
+
|
|
125
|
+
raise_error = True
|
|
126
|
+
|
|
127
|
+
def __init__(self, ctx: AgentContext, name: str) -> None:
|
|
128
|
+
self._ctx = ctx
|
|
129
|
+
self._name = name
|
|
130
|
+
self._calls: dict[UUID, str] = {}
|
|
131
|
+
|
|
132
|
+
async def on_tool_start(
|
|
133
|
+
self,
|
|
134
|
+
serialized: dict[str, Any],
|
|
135
|
+
input_str: str,
|
|
136
|
+
*,
|
|
137
|
+
run_id: UUID,
|
|
138
|
+
inputs: dict[str, Any] | None = None,
|
|
139
|
+
tool_call_id: str | None = None,
|
|
140
|
+
**kwargs: Any,
|
|
141
|
+
) -> None:
|
|
142
|
+
call = self._calls[run_id] = tool_call_id or str(run_id)
|
|
143
|
+
input = inputs if inputs is not None else input_str
|
|
144
|
+
await self._ctx._report_bundled(self._name, call, "running", input=input)
|
|
145
|
+
|
|
146
|
+
async def on_tool_end(self, output: Any, *, run_id: UUID, **kwargs: Any) -> None:
|
|
147
|
+
call = self._calls.pop(run_id, str(run_id))
|
|
148
|
+
# A ToolMessage with error status is a failure LangChain handled for the model.
|
|
149
|
+
if isinstance(output, ToolMessage) and output.status == "error":
|
|
150
|
+
await self._ctx._report_bundled(self._name, call, "failed", error=str(output.content))
|
|
151
|
+
return
|
|
152
|
+
content = output.content if isinstance(output, ToolMessage) else output
|
|
153
|
+
await self._ctx._report_bundled(self._name, call, "completed", output=content)
|
|
154
|
+
|
|
155
|
+
async def on_tool_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any) -> None:
|
|
156
|
+
call = self._calls.pop(run_id, str(run_id))
|
|
157
|
+
await self._ctx._report_bundled(self._name, call, "failed", error=str(error))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _audited(ctx: AgentContext, tool: BaseTool) -> BaseTool:
|
|
161
|
+
# Not a proxy: a copy of the tool itself keeps its type, schema and injected arguments.
|
|
162
|
+
handler = _Audit(ctx, tool.name)
|
|
163
|
+
callbacks = tool.callbacks
|
|
164
|
+
if isinstance(callbacks, BaseCallbackManager):
|
|
165
|
+
callbacks = callbacks.copy()
|
|
166
|
+
callbacks.add_handler(handler, inherit=False)
|
|
167
|
+
else:
|
|
168
|
+
callbacks = [*(callbacks or []), handler]
|
|
169
|
+
return tool.model_copy(update={"callbacks": callbacks})
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _route(tool: BaseTool):
|
|
173
|
+
# A tools.execute naming this tool invokes it with a ToolCall carrying the tools.execute
|
|
174
|
+
# call id; it runs inside that call's LangChain config, so traces nest.
|
|
175
|
+
async def execute(input: Any, call: str) -> Any:
|
|
176
|
+
result = await tool.ainvoke(
|
|
177
|
+
{"type": "tool_call", "id": call, "name": tool.name, "args": input}
|
|
178
|
+
)
|
|
179
|
+
return result.content if isinstance(result, ToolMessage) else result
|
|
180
|
+
|
|
181
|
+
return execute
|