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.
- ftai_langchain-0.2.1/.gitignore +16 -0
- ftai_langchain-0.2.1/PKG-INFO +7 -0
- ftai_langchain-0.2.1/README.md +162 -0
- ftai_langchain-0.2.1/examples/1_simple_model/main.py +55 -0
- ftai_langchain-0.2.1/examples/2_langchain_agent/main.py +73 -0
- ftai_langchain-0.2.1/examples/3_langgraph_react/main.py +100 -0
- ftai_langchain-0.2.1/pyproject.toml +28 -0
- ftai_langchain-0.2.1/src/ftai_langchain/__init__.py +16 -0
- ftai_langchain-0.2.1/src/ftai_langchain/_convert.py +134 -0
- ftai_langchain-0.2.1/src/ftai_langchain/agent_hub.py +125 -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))
|