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