ftai-langchain 0.2.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,16 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ # Environment variables
13
+ .env
14
+
15
+ # VSC Extension Files
16
+ .history
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: ftai-langchain
3
+ Version: 0.2.1
4
+ Summary: FtAi Agent Hub adapter for LangChain / LangGraph streaming
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: ftai-agent-core
7
+ Requires-Dist: langchain-core>=0.3.0
@@ -0,0 +1,162 @@
1
+ # ftai-langchain
2
+
3
+ 将任意 [LangChain](https://python.langchain.com/) / [LangGraph](https://langchain-ai.github.io/langgraph/) 模型或图接入 [FtAi Agent Hub](https://ftai.chat) 的通用适配器。
4
+
5
+ 只需提供一个 **async generator**(输入 OpenAI 格式消息,yield `AIMessageChunk`),即可获得流式输出、工具调用上报、人机交互、自动重连等全部能力。
6
+
7
+ ## 安装
8
+
9
+ ```bash
10
+ uv add ftai-langchain
11
+ ```
12
+
13
+ ## 快速开始
14
+
15
+ ### 接入单独的 Chat Model
16
+
17
+ ```python
18
+ import asyncio
19
+ import os
20
+
21
+ from langchain_anthropic import ChatAnthropic
22
+ from ftai_langchain import LangChainAgentHubClient, openai_to_langchain
23
+
24
+ model = ChatAnthropic(model="claude-sonnet-4-6")
25
+
26
+
27
+ async def handler(messages):
28
+ """接收 OpenAI 格式消息,yield LangChain AIMessageChunk。"""
29
+ lc_messages = openai_to_langchain(messages)
30
+ async for chunk in model.astream(lc_messages):
31
+ yield chunk
32
+
33
+
34
+ async def main():
35
+ client = LangChainAgentHubClient(secret=os.environ["AGENT_SECRET"])
36
+ await client.run(handler)
37
+
38
+
39
+ asyncio.run(main())
40
+ ```
41
+
42
+ ### 接入 LangGraph 图
43
+
44
+ ```python
45
+ import asyncio
46
+ import os
47
+
48
+ from langchain.agents import create_agent
49
+ from ftai_langchain import LangChainAgentHubClient, openai_to_langchain
50
+
51
+
52
+ def get_weather(city: str) -> str:
53
+ """获取城市天气。"""
54
+ return f"{city}:晴,25°C"
55
+
56
+
57
+ agent = create_agent(
58
+ model="anthropic:claude-sonnet-4-6",
59
+ tools=[get_weather],
60
+ )
61
+
62
+
63
+ async def handler(messages):
64
+ lc_messages = openai_to_langchain(messages)
65
+ async for msg, _metadata in agent.astream(
66
+ {"messages": lc_messages},
67
+ stream_mode="messages",
68
+ ):
69
+ yield msg # AIMessageChunk 或 ToolMessage,均可处理
70
+
71
+
72
+ async def main():
73
+ client = LangChainAgentHubClient(secret=os.environ["AGENT_SECRET"])
74
+ await client.run(handler)
75
+
76
+
77
+ asyncio.run(main())
78
+ ```
79
+
80
+ ## 注意事项:LangGraph 子图流式输出
81
+
82
+ 如果你将 `create_agent()` 返回的图作为节点嵌入更大的 `StateGraph`,外层图的 `astream(stream_mode="messages")` **默认不会传播子图内部的流式 chunk**,只会返回子图的最终结果。需要加 `subgraphs=True`:
83
+
84
+ ```python
85
+ # ❗ 输出格式从 (msg, metadata) 变为 (namespace, (msg, metadata))
86
+ async def handler(messages):
87
+ lc_messages = openai_to_langchain(messages)
88
+ async for _namespace, (msg, _metadata) in app.astream(
89
+ {"messages": lc_messages},
90
+ stream_mode="messages",
91
+ subgraphs=True, # ← 穿透子图,获取完整的流式 chunk
92
+ ):
93
+ yield msg
94
+ ```
95
+
96
+ 完整示例见 [`examples/3_langgraph_react/`](examples/3_langgraph_react/main.py)。
97
+
98
+ ## 核心概念
99
+
100
+ ### ChatHandler
101
+
102
+ ```python
103
+ async def handler(messages: list[dict]) -> AsyncIterator[BaseMessage]:
104
+ ...
105
+ ```
106
+
107
+ | 参数 | 类型 | 说明 |
108
+ |------|------|------|
109
+ | `messages` | `list[dict[str, Any]]` | OpenAI 格式的完整对话历史 |
110
+ | **yield** | `AIMessageChunk` | 文本 / 思考 / 工具调用片段 → 自动转为协议消息 |
111
+ | **yield** | `ToolMessage` | (可选)工具执行完毕 → 触发 `tool_call` 上报 |
112
+
113
+ ### 自动处理的事件映射
114
+
115
+ | yield 的内容 | Agent Hub 协议消息 |
116
+ |---|---|
117
+ | `AIMessageChunk.content`(文本) | `stream_text` |
118
+ | `AIMessageChunk.additional_kwargs["reasoning_content"]` | `stream_thinking` |
119
+ | `AIMessageChunk.tool_call_chunks` | 内部累积,由 `ToolMessage` 触发上报 |
120
+ | `ToolMessage` | `tool_call`(上报工具名 + 参数) |
121
+ | handler 正常结束 | `message_end`(自动) |
122
+ | handler 被取消 | `message_end(cancel)`(自动) |
123
+ | handler 抛异常 | `error(internal_error)`(自动) |
124
+
125
+ ## API 参考
126
+
127
+ ### `LangChainAgentHubClient`
128
+
129
+ ```python
130
+ from ftai_langchain import LangChainAgentHubClient
131
+
132
+ client = LangChainAgentHubClient(
133
+ secret="sk-ftai-ag-...", # Agent 密钥(必填)
134
+ # agent_hub_url="wss://...", # 可选,默认读取 AGENT_HUB_URL 环境变量或内置默认地址
135
+ reconnect_initial=2.0, # 重连初始间隔(秒)
136
+ reconnect_max=60.0, # 重连最大间隔(秒)
137
+ )
138
+ ```
139
+
140
+ | 属性 / 方法 | 说明 |
141
+ |---|---|
142
+ | `client.agent_id` | 认证成功后的 Agent ID(只读) |
143
+ <!-- | `client.human_in_loop_tool` | 人机交互工具函数,可加入 LangChain tools | -->
144
+ | `await client.run(handler)` | 连接网关并处理请求(阻塞,自动重连) |
145
+ | `await client.stop()` | 优雅关闭连接 |
146
+
147
+ ### 工具函数
148
+
149
+ ```python
150
+ from ftai_langchain import openai_to_langchain, ToolCallAccumulator
151
+ ```
152
+
153
+ | 函数 / 类 | 说明 |
154
+ |---|---|
155
+ | `openai_to_langchain(messages)` | OpenAI 格式 → LangChain `BaseMessage` 列表 |
156
+ | `ToolCallAccumulator` | 将流式 `tool_call_chunks` 重组为完整的工具调用记录 |
157
+
158
+ ## 与 ftai-deep-agent 的关系
159
+
160
+ [`ftai-deep-agent`](../deep-agent/) 构建在本包之上,是一个面向 [DeepAgent](https://github.com/langchain-ai/deepagents) 的瘦包装——它将 `agent.astream(stream_mode="messages")` 封装为 `ChatHandler` 后委托给 `LangChainAgentHubClient`。
161
+
162
+ 如果你使用 DeepAgent,直接用 `ftai-deep-agent` 即可;如果你使用其他 LangChain / LangGraph 用法,直接用本包。
@@ -0,0 +1,55 @@
1
+ """纯模型 IP 知识助手 — 最简单的 ChatHandler 模式。
2
+
3
+ 无工具调用,模型仅依靠自身知识回答 IP 相关问题。
4
+
5
+ 用法:
6
+ uv run python packages/langchain/examples/1_simple_model/main.py
7
+
8
+ 环境变量(写在 .env 或 shell 中):
9
+ AGENT_SECRET — Agent 密钥,如 sk-ftai-ag-xxxxx
10
+ AGENT_HUB_URL — (可选)Agent Hub WebSocket 地址
11
+ """
12
+
13
+ import asyncio
14
+ import logging
15
+ import os
16
+
17
+ from dotenv import load_dotenv
18
+ from langchain.chat_models import init_chat_model
19
+ from langchain_core.messages import SystemMessage
20
+
21
+ from ftai_langchain import LangChainAgentHubClient, openai_to_langchain
22
+
23
+ load_dotenv()
24
+
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format="%(asctime)s %(name)-32s %(levelname)-5s %(message)s",
28
+ )
29
+
30
+ SYSTEM_PROMPT = (
31
+ "你是一个 IP 地址知识助手。你可以解释 IP 地址的格式、分类(A/B/C/D/E 类)、"
32
+ "公网与私网地址的区别、子网掩码、CIDR 表示法等网络概念。\n"
33
+ "当用户提供具体 IP 地址时,根据已知规则判断其类型(公网/私网/回环/保留地址等)。"
34
+ )
35
+
36
+ model = init_chat_model("openrouter:qwen/qwen3.5-flash-02-23")
37
+
38
+
39
+ async def handler(messages):
40
+ """接收 OpenAI 格式消息,yield LangChain AIMessageChunk。"""
41
+ lc_messages = [SystemMessage(content=SYSTEM_PROMPT)] + openai_to_langchain(messages)
42
+ async for chunk in model.astream(lc_messages):
43
+ yield chunk
44
+
45
+
46
+ def main() -> None:
47
+ client = LangChainAgentHubClient(
48
+ secret=os.environ["AGENT_SECRET"],
49
+ agent_hub_url=os.environ.get("AGENT_HUB_URL"),
50
+ )
51
+ asyncio.run(client.run(handler))
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
@@ -0,0 +1,73 @@
1
+ """LangChain Agent IP 助手 — 使用 create_agent() 快速创建带工具的 Agent。
2
+
3
+ 用法:
4
+ uv run python packages/langchain/examples/2_langchain_agent/main.py
5
+
6
+ 环境变量(写在 .env 或 shell 中):
7
+ AGENT_SECRET — Agent 密钥,如 sk-ftai-ag-xxxxx
8
+ AGENT_HUB_URL — (可选)Agent Hub WebSocket 地址
9
+ """
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ import os
15
+
16
+ import httpx
17
+ from dotenv import load_dotenv
18
+ from langchain.agents import create_agent
19
+
20
+ from ftai_langchain import LangChainAgentHubClient, openai_to_langchain
21
+
22
+ load_dotenv()
23
+
24
+ logging.basicConfig(
25
+ level=logging.INFO,
26
+ format="%(asctime)s %(name)-32s %(levelname)-5s %(message)s",
27
+ )
28
+
29
+ SYSTEM_PROMPT = (
30
+ "你是一个网络工具助手,可以查询 IP 地址的地理位置和网络信息。\n"
31
+ "当用户提供 IP 地址时,使用 get_ip_info 工具查询详细信息,"
32
+ "然后用自然语言向用户汇报结果。"
33
+ )
34
+
35
+
36
+ async def get_ip_info(ip: str) -> str:
37
+ """查询 IP 地址的地理位置和网络信息。
38
+
39
+ Args:
40
+ ip: 要查询的 IP 地址,如 "8.8.8.8"。
41
+ """
42
+ async with httpx.AsyncClient() as client:
43
+ resp = await client.get(f"https://ipinfo.io/{ip}/json")
44
+ return json.dumps(resp.json(), ensure_ascii=False)
45
+
46
+
47
+ agent = create_agent(
48
+ model="openrouter:qwen/qwen3.5-flash-02-23",
49
+ tools=[get_ip_info],
50
+ system_prompt=SYSTEM_PROMPT,
51
+ )
52
+
53
+
54
+ async def handler(messages):
55
+ """将 create_agent() 的图流式输出转为 ChatHandler。"""
56
+ lc_messages = openai_to_langchain(messages)
57
+ async for msg, _metadata in agent.astream(
58
+ {"messages": lc_messages},
59
+ stream_mode="messages",
60
+ ):
61
+ yield msg
62
+
63
+
64
+ def main() -> None:
65
+ client = LangChainAgentHubClient(
66
+ secret=os.environ["AGENT_SECRET"],
67
+ agent_hub_url=os.environ.get("AGENT_HUB_URL"),
68
+ )
69
+ asyncio.run(client.run(handler))
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
@@ -0,0 +1,100 @@
1
+ """LangGraph 自定义图 IP 助手 — 将 ReAct Agent 作为节点放入 StateGraph。
2
+
3
+ 演示如何用 LangGraph 构建自定义图:
4
+ 1. 用 create_agent() 创建 ReAct Agent 节点
5
+ 2. 放入 StateGraph 并编译
6
+ 3. 通过 ChatHandler 接入 Agent Hub
7
+
8
+ 用法:
9
+ uv run python packages/langchain/examples/3_langgraph_react/main.py
10
+
11
+ 环境变量(写在 .env 或 shell 中):
12
+ AGENT_SECRET — Agent 密钥,如 sk-ftai-ag-xxxxx
13
+ AGENT_HUB_URL — (可选)Agent Hub WebSocket 地址
14
+ """
15
+
16
+ import asyncio
17
+ import json
18
+ import logging
19
+ import os
20
+
21
+ import httpx
22
+ from dotenv import load_dotenv
23
+ from langchain.agents import create_agent
24
+ from langgraph.graph import END, START, MessagesState, StateGraph
25
+
26
+ from ftai_langchain import LangChainAgentHubClient, openai_to_langchain
27
+
28
+ load_dotenv()
29
+
30
+ logging.basicConfig(
31
+ level=logging.INFO,
32
+ format="%(asctime)s %(name)-32s %(levelname)-5s %(message)s",
33
+ )
34
+
35
+ SYSTEM_PROMPT = (
36
+ "你是一个网络工具助手,可以查询 IP 地址的地理位置和网络信息。\n"
37
+ "当用户提供 IP 地址时,使用 get_ip_info 工具查询详细信息,"
38
+ "然后用自然语言向用户汇报结果。"
39
+ )
40
+
41
+
42
+ async def get_ip_info(ip: str) -> str:
43
+ """查询 IP 地址的地理位置和网络信息。
44
+
45
+ Args:
46
+ ip: 要查询的 IP 地址,如 "8.8.8.8"。
47
+ """
48
+ async with httpx.AsyncClient() as client:
49
+ resp = await client.get(f"https://ipinfo.io/{ip}/json")
50
+ return json.dumps(resp.json(), ensure_ascii=False)
51
+
52
+
53
+ # ── 构建 LangGraph 图 ───────────────────────────────────────────
54
+
55
+ # 1. 用 create_agent() 创建 ReAct Agent(内部已包含 model → tool 循环)
56
+ react_agent = create_agent(
57
+ model="openrouter:qwen/qwen3.5-flash-02-23",
58
+ tools=[get_ip_info],
59
+ system_prompt=SYSTEM_PROMPT,
60
+ )
61
+
62
+ # 2. 放入自定义 StateGraph
63
+ graph = StateGraph(MessagesState)
64
+ graph.add_node("agent", react_agent)
65
+ graph.add_edge(START, "agent")
66
+ graph.add_edge("agent", END)
67
+
68
+ app = graph.compile()
69
+
70
+
71
+ # ── ChatHandler ──────────────────────────────────────────────────
72
+
73
+ async def handler(messages):
74
+ """将 LangGraph 图的流式输出转为 ChatHandler。
75
+
76
+ 子图(create_agent 节点)的流式 chunk 默认不会传播到外层图,
77
+ 需要 ``subgraphs=True``。此时输出格式从 ``(msg, metadata)``
78
+ 变为 ``(namespace, (msg, metadata))``。
79
+ """
80
+ lc_messages = openai_to_langchain(messages)
81
+ async for _namespace, (msg, _metadata) in app.astream(
82
+ {"messages": lc_messages},
83
+ stream_mode="messages",
84
+ subgraphs=True,
85
+ ):
86
+ yield msg
87
+
88
+
89
+ # ── 入口 ─────────────────────────────────────────────────────────
90
+
91
+ def main() -> None:
92
+ client = LangChainAgentHubClient(
93
+ secret=os.environ["AGENT_SECRET"],
94
+ agent_hub_url=os.environ.get("AGENT_HUB_URL"),
95
+ )
96
+ asyncio.run(client.run(handler))
97
+
98
+
99
+ if __name__ == "__main__":
100
+ main()
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "ftai-langchain"
3
+ version = "0.2.1"
4
+ description = "FtAi Agent Hub adapter for LangChain / LangGraph streaming"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "ftai-agent-core",
8
+ "langchain-core>=0.3.0",
9
+ ]
10
+
11
+ [dependency-groups]
12
+ examples = [
13
+ "httpx[socks]>=0.28.1",
14
+ "langchain>=1.2.0",
15
+ "langchain-anthropic>=1.4.0",
16
+ "langchain-openrouter>=0.1.1",
17
+ "python-dotenv>=1.2.2",
18
+ ]
19
+
20
+ [build-system]
21
+ requires = ["hatchling"]
22
+ build-backend = "hatchling.build"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/ftai_langchain"]
26
+
27
+ [tool.uv.sources]
28
+ ftai-agent-core = { workspace = true }
@@ -0,0 +1,16 @@
1
+ """ftai-langchain: FtAi Agent Hub adapter for LangChain / LangGraph."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from ftai_agent_core.errors import AuthError
6
+ from ftai_langchain._convert import ToolCallAccumulator, openai_to_langchain
7
+ from ftai_langchain.agent_hub import LangChainAgentHubClient
8
+
9
+ __version__ = version("ftai-langchain")
10
+
11
+ __all__ = [
12
+ "LangChainAgentHubClient",
13
+ "ToolCallAccumulator",
14
+ "openai_to_langchain",
15
+ "AuthError",
16
+ ]
@@ -0,0 +1,134 @@
1
+ """Convert between OpenAI message format and LangChain message format.
2
+
3
+ The FtAi Agent Hub server sends conversation history in OpenAI-compatible
4
+ format (``{"role": "user", "content": "..."}``). LangChain components
5
+ expect message objects (``HumanMessage``, ``AIMessage``, ...).
6
+
7
+ This module also provides :class:`ToolCallAccumulator` which reassembles
8
+ streaming tool-call fragments so we can report a complete ``tool_call``
9
+ message to the agent hub once the tool finishes executing.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from typing import Any
16
+
17
+ from langchain_core.messages import (
18
+ AIMessage,
19
+ AIMessageChunk,
20
+ BaseMessage,
21
+ HumanMessage,
22
+ SystemMessage,
23
+ )
24
+
25
+
26
+ def openai_to_langchain(messages: list[dict[str, Any]]) -> list[BaseMessage]:
27
+ """Convert an OpenAI-compatible message list to LangChain messages.
28
+
29
+ Supports roles: ``user``, ``assistant``, ``system``.
30
+ Unknown roles are treated as ``user``.
31
+ """
32
+ result: list[BaseMessage] = []
33
+ for msg in messages:
34
+ role = msg.get("role", "")
35
+ content = msg.get("content", "")
36
+ match role:
37
+ case "user":
38
+ result.append(HumanMessage(content=content))
39
+ case "assistant":
40
+ result.append(AIMessage(content=content))
41
+ case "system":
42
+ result.append(SystemMessage(content=content))
43
+ case _:
44
+ result.append(HumanMessage(content=content))
45
+ return result
46
+
47
+
48
+ class ToolCallAccumulator:
49
+ """Accumulate streaming ``tool_call_chunks`` from :class:`AIMessageChunk`
50
+ into complete tool-call records that can be reported to the agent hub.
51
+
52
+ Tool-call data arrives in fragments across multiple chunks. This class
53
+ reassembles them by *index* (positional slot in the tool-calls array) so
54
+ that when a :class:`ToolMessage` arrives we can look up the full call by
55
+ its ``tool_call_id``.
56
+ """
57
+
58
+ def __init__(self) -> None:
59
+ self._by_index: dict[int, dict[str, str]] = {}
60
+
61
+ # ── Feed ────────────────────────────────────────────────────────
62
+
63
+ def feed(self, chunk: AIMessageChunk) -> bool:
64
+ """Ingest tool-call data from a single streaming chunk.
65
+
66
+ Returns ``True`` if the chunk contained tool-call data, ``False``
67
+ otherwise. This lets callers decide whether to treat the chunk as
68
+ text without duplicating the detection logic.
69
+
70
+ Two code-paths handle different provider behaviours:
71
+
72
+ 1. **Streaming fragments** (``tool_call_chunks``) — most providers
73
+ send partial JSON across many chunks. We concatenate ``name``,
74
+ ``args`` (raw JSON string), and capture ``id`` from whichever
75
+ chunk provides it first. Fragments are keyed by ``index`` (the
76
+ positional slot in the tool-calls array).
77
+
78
+ 2. **Complete tool-calls in one shot** — some providers emit a
79
+ single chunk with fully-parsed ``tool_calls``. In this case
80
+ ``tool_call_chunks`` is empty, so we fall back to serialising
81
+ the already-parsed ``args`` dict for later retrieval.
82
+ """
83
+ # Path 1: raw streaming fragments
84
+ fragments = getattr(chunk, "tool_call_chunks", None) or []
85
+ if fragments:
86
+ for tc in fragments:
87
+ idx: int = tc.get("index", 0)
88
+ if idx not in self._by_index:
89
+ self._by_index[idx] = {
90
+ "name": tc.get("name") or "",
91
+ "args": tc.get("args") or "",
92
+ "id": tc.get("id") or "",
93
+ }
94
+ else:
95
+ entry = self._by_index[idx]
96
+ entry["name"] += tc.get("name") or ""
97
+ entry["args"] += tc.get("args") or ""
98
+ if tc.get("id"):
99
+ entry["id"] = tc["id"]
100
+ return True
101
+
102
+ # Path 2: complete tool_calls already parsed by the provider
103
+ tool_calls = chunk.tool_calls or []
104
+ if tool_calls:
105
+ for i, tc in enumerate(tool_calls):
106
+ self._by_index[i] = {
107
+ "name": tc.get("name", ""),
108
+ "args": json.dumps(tc.get("args", {}), ensure_ascii=False),
109
+ "id": tc.get("id", ""),
110
+ }
111
+ return True
112
+
113
+ return False
114
+
115
+ # ── Retrieve ────────────────────────────────────────────────────
116
+
117
+ def pop_by_id(self, tool_call_id: str) -> dict[str, Any] | None:
118
+ """Pop and return a completed tool-call record matched by *id*.
119
+
120
+ Returns a dict with ``name`` (str) and ``arguments`` (dict), or
121
+ ``None`` if no match is found.
122
+ """
123
+ for idx, entry in list(self._by_index.items()):
124
+ if entry["id"] == tool_call_id:
125
+ del self._by_index[idx]
126
+ try:
127
+ args = json.loads(entry["args"]) if entry["args"] else {}
128
+ except json.JSONDecodeError:
129
+ args = {}
130
+ return {"name": entry["name"], "arguments": args}
131
+ return None
132
+
133
+ def clear(self) -> None:
134
+ self._by_index.clear()
@@ -0,0 +1,125 @@
1
+ """FtAi Agent Hub client for LangChain / LangGraph streaming.
2
+
3
+ Provides a concrete :class:`~ftai_agent_core.client.BaseAgentHubClient`
4
+ subclass that accepts a user-provided async generator function
5
+ (``ChatHandler``) to produce LangChain message objects.
6
+
7
+ The ``ChatHandler`` receives OpenAI-format messages and yields
8
+ :class:`~langchain_core.messages.AIMessageChunk` (or
9
+ :class:`~langchain_core.messages.ToolMessage`, etc.) objects. This client
10
+ handles converting those chunks into agent hub protocol messages
11
+ (``stream_text``, ``stream_thinking``, ``tool_call``).
12
+
13
+ Usage::
14
+
15
+ from ftai_langchain import LangChainAgentHubClient, openai_to_langchain
16
+
17
+ async def my_handler(messages):
18
+ model = ChatAnthropic(model="claude-sonnet-4-6")
19
+ async for chunk in model.astream(openai_to_langchain(messages)):
20
+ yield chunk
21
+
22
+ client = LangChainAgentHubClient(secret="sk-ftai-ag-xxxxx")
23
+ await client.run(my_handler)
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import logging
29
+ from typing import Any, AsyncIterator, Callable
30
+
31
+ from langchain_core.messages import AIMessageChunk, BaseMessage, ToolMessage
32
+
33
+ from ftai_agent_core import protocol
34
+ from ftai_agent_core.client import BaseAgentHubClient
35
+ from ftai_langchain._convert import ToolCallAccumulator
36
+
37
+ logger = logging.getLogger("ftai_langchain.agent_hub")
38
+
39
+ # Type alias for the user-provided chat handler function.
40
+ # Input: OpenAI-format message list [{"role": "user", "content": "..."}]
41
+ # Output: async iterator of LangChain message objects (typically AIMessageChunk)
42
+ ChatHandler = Callable[[list[dict[str, Any]]], AsyncIterator[BaseMessage]]
43
+
44
+
45
+ class LangChainAgentHubClient(BaseAgentHubClient):
46
+ """WebSocket client that bridges a LangChain ChatHandler to FtAi Agent Hub.
47
+
48
+ The handler function is passed to :meth:`run` and called for each
49
+ incoming ``chat_request``.
50
+
51
+ Usage::
52
+
53
+ client = LangChainAgentHubClient(secret="sk-ftai-ag-xxxxx")
54
+ await client.run(my_handler)
55
+ """
56
+
57
+ async def run(self, handler: ChatHandler) -> None:
58
+ """Connect to the agent hub and start processing requests.
59
+
60
+ Parameters
61
+ ----------
62
+ handler:
63
+ An async generator function that receives OpenAI-format messages
64
+ and yields LangChain message objects (primarily
65
+ :class:`AIMessageChunk`).
66
+ """
67
+ await super().run(handler)
68
+
69
+ async def _process_chat(
70
+ self,
71
+ request_id: str,
72
+ messages: list[dict[str, Any]],
73
+ ) -> None:
74
+ """Stream the handler's response for a single chat request.
75
+
76
+ 1. Call the handler with the OpenAI-format *messages*.
77
+ 2. For every :class:`AIMessageChunk`, forward text / thinking /
78
+ tool-call fragments to the agent hub.
79
+ 3. For every :class:`ToolMessage`, look up the accumulated tool-call
80
+ by its ``tool_call_id`` and report a ``tool_call`` frame.
81
+ 4. The base class sends ``message_end`` after this method returns.
82
+ """
83
+ accumulator = ToolCallAccumulator()
84
+
85
+ async for msg in self._agent(messages):
86
+ if isinstance(msg, AIMessageChunk):
87
+ await self._handle_ai_chunk(request_id, msg, accumulator)
88
+
89
+ elif isinstance(msg, ToolMessage):
90
+ # When a ToolMessage arrives the tool has finished
91
+ # executing. We report the *call* (name + arguments)
92
+ # that was accumulated from earlier AIMessageChunk
93
+ # fragments — the result itself is omitted to save
94
+ # bandwidth.
95
+ tc_id = getattr(msg, "tool_call_id", None)
96
+ if not tc_id:
97
+ continue
98
+ tc_info = accumulator.pop_by_id(tc_id)
99
+ if not tc_info:
100
+ continue
101
+ await self._send(
102
+ protocol.tool_call(
103
+ request_id, tc_info["name"], tc_info["arguments"],
104
+ ),
105
+ )
106
+
107
+ async def _handle_ai_chunk(
108
+ self,
109
+ request_id: str,
110
+ chunk: AIMessageChunk,
111
+ accumulator: ToolCallAccumulator,
112
+ ) -> None:
113
+ """Process a single AIMessageChunk from the handler stream."""
114
+ # 1) Thinking / reasoning tokens
115
+ reasoning = chunk.additional_kwargs.get("reasoning_content", "")
116
+ if reasoning:
117
+ await self._send(protocol.stream_thinking(request_id, reasoning))
118
+
119
+ # 2) Accumulate tool-call fragments (feed returns True if consumed)
120
+ has_tool_data = accumulator.feed(chunk)
121
+
122
+ # 3) Regular text — skip chunks that only carry tool-call data
123
+ text = chunk.content if isinstance(chunk.content, str) else ""
124
+ if text and not has_tool_data:
125
+ await self._send(protocol.stream_text(request_id, text))